Compare commits

...
Author SHA1 Message Date
Dominik Schmidt ab9a224091 Remove changelog fragment 2026-08-18 18:33:41 +02:00
Dominik Schmidt 1de86ef559 changelog: deterministic audio cover art thumbnails 2026-08-18 18:33:41 +02:00
Dominik Schmidt 0fb6877878 fix(thumbnails): pick audio cover art deterministically
Switch to the dschmidt/tag fork and select the front cover (picture type
0x03) when tagged, else the first available picture, instead of the lib's
single non-deterministic Picture().
2026-08-18 18:33:41 +02:00
Dominik Schmidt 35231b1418 feat(search): populate audio and gps-altitude facets from tika, drop the audio-only gate 2026-08-18 18:27:12 +02:00
Dominik Schmidt 15149109e2 test(search): pin the acceptance tika image to the latest 4.0.0 snapshot 2026-08-18 18:27:12 +02:00
Dominik Schmidt 2edb893ee6 refactor(search): split the tika extractor and its tests into per-facet files 2026-08-18 18:27:12 +02:00
Dominik Schmidt c7340ac004 chore(search): tighten the schema-reconcile comments 2026-08-18 18:11:42 +02:00
Dominik Schmidt 6022e8fcfd refactor(search): single-source the new-index log message 2026-08-18 18:11:41 +02:00
Dominik Schmidt b14dc31545 feat(search): log a reindex hint when a fresh search index is created 2026-08-18 18:11:41 +02:00
Dominik Schmidt dc90d2dda9 refactor(search): warn on a persisted additive change even when the reopen fails 2026-08-18 18:11:41 +02:00
Dominik Schmidt fe10ad0ead refactor(search): route schema verdict handling through a shared mapping.Reconcile 2026-08-18 18:11:41 +02:00
Dominik Schmidt 5d08f16114 refactor(search): extract opensearch.NewClient out of server startup 2026-08-18 18:11:41 +02:00
Dominik Schmidt c6f9cc72dc refactor(search): harden the index-diff helpers against unset input 2026-08-18 18:11:41 +02:00
Dominik Schmidt a74b1c58f7 refactor(search): only enforce analysis settings, tolerate operational drift 2026-08-18 18:11:41 +02:00
Dominik Schmidt edff1e93d5 refactor(search): make the breaking-schema error developer-facing 2026-08-18 18:11:41 +02:00
Dominik Schmidt c40c46aa88 fix(search): list the schema mismatch reasons on separate lines 2026-08-18 18:11:41 +02:00
Dominik Schmidt 41af1d8bb8 fix(search): name the service to stop in the schema mismatch error 2026-08-18 18:11:41 +02:00
Dominik Schmidt 7e59839a76 fix(search): address max-review findings
- the additive warnings advertise --all-spaces --force-rescan; a plain
  walk skips unchanged documents and never backfills the new fields
- Apply checks index existence first again, so a pre-provisioned index
  needs no create privilege and odd create-error shapes (string error
  bodies, cluster blocks) cannot fail a healthy startup; Create on 404
  keeps the typed already-exists swallow as the creation-race backstop
- number_of_replicas drift is not breaking, it is runtime-tunable and
  needs no rebuild
- bleve returns the classification alongside post-persist errors and
  the server warns before the error check, so the one-time additive
  warning is not lost when close or reopen fails
- a golden fixture pins the marshaled bleve mapping so a dependency
  bump that changes marshaling fails in CI instead of refusing every
  installation in the field
2026-08-18 18:11:41 +02:00
Dominik Schmidt 6d799d1439 chore(search): tighten doc comments 2026-08-18 18:07:50 +02:00
Dominik Schmidt c595a53818 chore(search): warn on additive opensearch changes and name the exact delete step
Addresses the two Copilot review comments on the PR: the additive
opensearch log now matches the bleve warning (level and re-index hint),
and the refuse message spells out how to delete the index per engine
(DELETE /<name> vs removing the bleve directory).
2026-08-18 18:07:50 +02:00
Dominik Schmidt aca8f9d2b8 chore(search): mention the impact of disabling search in the refuse message 2026-08-18 18:07:50 +02:00
Dominik Schmidt 8bfacb0823 chore(search): drop the changelog entry 2026-08-18 18:07:50 +02:00
Dominik Schmidt dfd5a47026 feat(search): check the index schema on startup and refuse breaking changes
Both engines now diff the stored/live index schema against the schema
generated from code when the service starts. A shared recursive
classifier in the mapping package is the single oracle:

- equal: start normally.
- additive (new fields without any indexed data): applied in place.
  OpenSearch gets a PUT _mapping with the full code properties, bleve
  persists the code mapping into the index (SetInternal + reopen) so
  the new fields are properly typed immediately and later startups
  classify equal. A startup warning lists the new fields because
  documents indexed before the upgrade lack them until re-indexed.
- breaking (changed definitions or analyzers, removed or renamed
  fields, or new fields that already contain data of unknown form):
  refuse to start with an error describing the rebuild procedure
  (delete the index, start, run "opencloud search index --all-spaces")
  and the OC_EXCLUDE_RUN_SERVICES=search escape hatch.

PUT _mapping is deliberately only the apply mechanism, never the
judge: its merge semantics cannot see removals or renames and it
accepts in-place updatable param changes with an ack. bleve
additionally checks idx.Fields() so previously dynamically indexed
data (which leaves no schema trace in bleve) is caught, matching by
exact name and by path prefix.

While at it: the OpenSearch startup check runs with a real,
minute-bounded context instead of context.TODO(), bleve indexes are
opened with a 5s bolt_timeout so a second process fails fast instead
of hanging on the file lock, and the reversed errors.Is arguments in
bleve.NewIndex were fixed.

https://github.com/opencloud-eu/opencloud/issues/3092
2026-08-18 18:07:50 +02:00
Dominik Schmidt a7ed42f434 test(search): re-apply opensearch backend coverage under ginkgo 2026-08-18 17:24:21 +02:00
Dominik Schmidt e5d16157bc refactor(search): make paths case-sensitive and scope searches at query 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.
2026-08-18 17:12:32 +02:00
Dominik Schmidt 51bda55501 fix(search): bind a leading NOT to the term right after it, on both backends
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.
2026-08-18 17:12:32 +02:00
Dominik Schmidt f40544de04 test(search): port fieldindex, mimetype and normalize tests to ginkgo 2026-08-18 17:12:32 +02:00
Dominik Schmidt 65712716a8 test(search): make the OpenSearch testcontainer start reliably
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.
2026-08-18 17:12:32 +02:00
Dominik Schmidt 3affd156d6 fix(search): keep OpenSearch path queries as unanalyzed term queries
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.
2026-08-18 17:12:32 +02:00
Dominik Schmidt 4500036f33 refactor(search): anchor the OpenSearch move prefix replace to the path 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.
2026-08-18 17:12:32 +02:00
Dominik Schmidt ab6d0b56b6 fix(search): validate CaseInsensitive against the effective field type
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.
2026-08-18 17:12:32 +02:00
Dominik Schmidt 3fbc04d06b fix(search): keep mediatype:file atomic so it composes with other terms
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.
2026-08-18 17:12:32 +02:00
Dominik Schmidt c2031654fb fix(search): nest json-tagged embedded structs instead of flattening 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.
2026-08-18 17:12:32 +02:00
Dominik Schmidt 5278fd92f3 fix(search): make mediatype categories and MIME types case-insensitive
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.
2026-08-18 17:12:32 +02:00
Dominik Schmidt 8f53b31178 test(search): cover mediatype and direct MimeType search on both backends
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 *.
2026-08-18 17:12:32 +02:00
Dominik Schmidt b09834f98d fix(search): reject CaseInsensitive on non-keyword/path fields
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.
2026-08-18 17:12:32 +02:00
Dominik Schmidt ac3c2b4579 fix(search): write a consistent empty _lowercase sibling for empty arrays
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.
2026-08-18 17:12:32 +02:00
Dominik Schmidt d352ee53c5 fix(search): review fixes for path AND-term and content wildcard
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.
2026-08-18 17:12:32 +02:00
Dominik Schmidt f7087c973b fix(search): analyze OpenSearch full-text queries, stem Content like 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.
2026-08-18 17:12:32 +02:00
Dominik Schmidt 6ce3d206c3 feat(search): per-field case-insensitive search via _lowercase siblings
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.
2026-08-18 17:12:32 +02:00
Dominik Schmidt 6fae0be65d refactor(search): run the OpenSearch backend on the shared lowering pass
KQLToOpenSearchBoolQuery runs query.Normalize, then only value lowercasing stays
backend-specific; remapKey and unfoldValue are gone.
2026-08-18 17:12:32 +02:00
Dominik Schmidt 6fa56469d1 refactor(search): run the bleve backend on the shared lowering pass
The bleve Creator runs query.Normalize before compiling; the compiler consumes a
canonical AST with no field resolution or media-type special-casing.
2026-08-18 17:12:32 +02:00
Dominik Schmidt 162f6e26b8 feat(search): add shared KQL lowering pass
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.
2026-08-18 17:12:32 +02:00
Dominik Schmidt 2d3f44be43 feat(search): derive a case-insensitive field-name index from the resource struct
mapping.FieldNameIndex walks the struct and maps a lowercased field path to the
real field name, including nested facet sub-fields. Backend-neutral.
2026-08-18 17:12:32 +02:00
Dominik Schmidt 25450554b4 refactor(kql): move parse/validation errors into pkg/kql
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.
2026-08-18 17:12:32 +02:00
Dominik Schmidt ee500379f0 fix(search): preserve query value case for case-sensitive fields on OpenSearch
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).
2026-08-18 17:12:32 +02:00
Dominik Schmidt 9dd78193c8 feat(search): version the search index by schema version
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.
2026-08-18 17:12:32 +02:00
Dominik Schmidt 102f2274a3 fix(search): type mtime as a date 2026-08-18 17:12:32 +02:00
Dominik Schmidt 14bcd45d4d test(search): set Mtime on opensearch folder and root fixtures
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.
2026-08-18 16:57:08 +02:00
Dominik Schmidt c763d96207 test(search): convert mapping package tests to ginkgo
New package, so use the repo's standard test framework.
2026-08-18 16:57:08 +02:00
Dominik Schmidt e2a2915712 test(search): convert bleve geo/mtime tests to ginkgo
The package's engine suite is ginkgo; these new tests were plain.
2026-08-18 16:57:07 +02:00
Dominik Schmidt 561a963e85 test(search): use RFC3339 Mtime in opensearch fixture
Mtime is now a date field; the fixture's Go-format string fails
OpenSearch date parsing.
2026-08-18 16:57:07 +02:00
Dominik Schmidt 19b3a96d26 feat(search): index Location as a geopoint on both backends
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.
2026-08-18 16:57:07 +02:00
Dominik Schmidt fb69bd525a refactor: reflection-based search mapping
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.
2026-08-18 16:57:07 +02:00
102 changed files with 5978 additions and 2019 deletions

No files matched your search

+2
View File
@@ -408,3 +408,5 @@ replace go-micro.dev/v4 => github.com/butonic/go-micro/v4 v4.11.1-0.202411151126
exclude github.com/mattn/go-sqlite3 v2.0.3+incompatible
replace github.com/go-micro/plugins/v4/store/nats-js-kv => github.com/opencloud-eu/go-micro-plugins/v4/store/nats-js-kv v0.0.0-20250512152754-23325793059a
replace github.com/dhowden/tag => github.com/dschmidt/tag v0.0.0-20260730135516-8eb6fc4a6973
+2 -2
View File
@@ -293,8 +293,6 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cu
github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no=
github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7cNTs5R6Hk4V2lcmLz2NsG2VnInyNo=
github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA=
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 h1:OtSeLS5y0Uy01jaKK4mA/WVIYtpzVm63vLVAPzJXigg=
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8/go.mod h1:apkPC/CR3s48O2D7Y++n1XWEpgPNNCjXYga3PPbJe2E=
github.com/dimchansky/utfbom v1.1.0/go.mod h1:rO41eb7gLfo8SF1jd9F8HplJm1Fewwi4mQvIirEdv+8=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
@@ -306,6 +304,8 @@ github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf
github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/dschmidt/tag v0.0.0-20260730135516-8eb6fc4a6973 h1:XnAwTTbblPJI6OVkRwqVA2MGjre29B/+wsi16lQfJ+E=
github.com/dschmidt/tag v0.0.0-20260730135516-8eb6fc4a6973/go.mod h1:apkPC/CR3s48O2D7Y++n1XWEpgPNNCjXYga3PPbJe2E=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/dutchcoders/go-clamd v0.0.0-20170520113014-b970184f4d9e h1:rcHHSQqzCgvlwP0I/fQ8rQMn/MpHE5gWSLdtpxtP6KQ=
+3
View File
@@ -43,6 +43,9 @@ type StringNode struct {
*Base
Key string
Value string
// CaseInsensitive marks a case-insensitive restriction; set by the search
// lowering pass, a backend routes it to the field's lowercased form.
CaseInsensitive bool
}
// BooleanNode represents a bool value
+2 -3
View File
@@ -6,7 +6,6 @@ import (
"github.com/jinzhu/now"
"github.com/opencloud-eu/opencloud/pkg/ast"
"github.com/opencloud-eu/opencloud/services/search/pkg/query"
)
func toNode[T ast.Node](in any) (T, error) {
@@ -84,7 +83,7 @@ func toTimeRange(in any) (*time.Time, *time.Time, error) {
value, err := toString(in)
if err != nil {
return &from, &to, &query.UnsupportedTimeRangeError{}
return &from, &to, &UnsupportedTimeRangeError{}
}
c := &now.Config{
@@ -131,7 +130,7 @@ func toTimeRange(in any) (*time.Time, *time.Time, error) {
}
if from.IsZero() || to.IsZero() {
return nil, nil, &query.UnsupportedTimeRangeError{}
return nil, nil, &UnsupportedTimeRangeError{}
}
return &from, &to, nil
+10 -11
View File
@@ -9,7 +9,6 @@ import (
"github.com/opencloud-eu/opencloud/pkg/ast"
"github.com/opencloud-eu/opencloud/pkg/ast/test"
"github.com/opencloud-eu/opencloud/pkg/kql"
"github.com/opencloud-eu/opencloud/services/search/pkg/query"
tAssert "github.com/stretchr/testify/assert"
)
@@ -34,13 +33,13 @@ func TestParse_Spec(t *testing.T) {
},
{
name: `AND`,
error: query.StartsWithBinaryOperatorError{
error: kql.StartsWithBinaryOperatorError{
Node: &ast.OperatorNode{Value: kql.BoolAND},
},
},
{
name: `AND cat AND dog`,
error: query.StartsWithBinaryOperatorError{
error: kql.StartsWithBinaryOperatorError{
Node: &ast.OperatorNode{Value: kql.BoolAND},
},
},
@@ -80,13 +79,13 @@ func TestParse_Spec(t *testing.T) {
},
{
name: `OR`,
error: query.StartsWithBinaryOperatorError{
error: kql.StartsWithBinaryOperatorError{
Node: &ast.OperatorNode{Value: kql.BoolOR},
},
},
{
name: `OR cat AND dog`,
error: query.StartsWithBinaryOperatorError{
error: kql.StartsWithBinaryOperatorError{
Node: &ast.OperatorNode{Value: kql.BoolOR},
},
},
@@ -860,37 +859,37 @@ func TestParse_Errors(t *testing.T) {
tests := []testCase{
{
query: "animal:(mammal:cat mammal:dog reptile:turtle)",
error: query.NamedGroupInvalidNodesError{
error: kql.NamedGroupInvalidNodesError{
Node: &ast.StringNode{Key: "mammal", Value: "cat"},
},
},
{
query: "animal:(cat mammal:dog turtle)",
error: query.NamedGroupInvalidNodesError{
error: kql.NamedGroupInvalidNodesError{
Node: &ast.StringNode{Key: "mammal", Value: "dog"},
},
},
{
query: "animal:(AND cat)",
error: query.StartsWithBinaryOperatorError{
error: kql.StartsWithBinaryOperatorError{
Node: &ast.OperatorNode{Value: kql.BoolAND},
},
},
{
query: "animal:(OR cat)",
error: query.StartsWithBinaryOperatorError{
error: kql.StartsWithBinaryOperatorError{
Node: &ast.OperatorNode{Value: kql.BoolOR},
},
},
{
query: "(AND cat)",
error: query.StartsWithBinaryOperatorError{
error: kql.StartsWithBinaryOperatorError{
Node: &ast.OperatorNode{Value: kql.BoolAND},
},
},
{
query: "(OR cat)",
error: query.StartsWithBinaryOperatorError{
error: kql.StartsWithBinaryOperatorError{
Node: &ast.OperatorNode{Value: kql.BoolOR},
},
},
@@ -1,4 +1,4 @@
package query
package kql
import (
"fmt"
@@ -38,6 +38,8 @@ func (e UnsupportedTimeRangeError) Error() string {
return fmt.Sprintf("unable to convert '%v' to a time range", e.Value)
}
// IsValidationError reports whether err is one of the KQL parse/validation
// errors produced by this package.
func IsValidationError(err error) bool {
switch err.(type) {
case *StartsWithBinaryOperatorError, *NamedGroupInvalidNodesError, *UnsupportedTimeRangeError:
+1 -2
View File
@@ -5,7 +5,6 @@ import (
"github.com/opencloud-eu/opencloud/pkg/ast"
"github.com/opencloud-eu/opencloud/pkg/kql"
"github.com/opencloud-eu/opencloud/services/search/pkg/query"
tAssert "github.com/stretchr/testify/assert"
)
@@ -22,7 +21,7 @@ func TestNewAST(t *testing.T) {
{
name: "error",
givenQuery: kql.BoolAND,
expectedError: query.StartsWithBinaryOperatorError{
expectedError: kql.StartsWithBinaryOperatorError{
Node: &ast.OperatorNode{Value: kql.BoolAND},
},
},
+3 -4
View File
@@ -2,7 +2,6 @@ package kql
import (
"github.com/opencloud-eu/opencloud/pkg/ast"
"github.com/opencloud-eu/opencloud/services/search/pkg/query"
)
func validateAst(a *ast.Ast) error {
@@ -10,7 +9,7 @@ func validateAst(a *ast.Ast) error {
case *ast.OperatorNode:
switch node.Value {
case BoolAND, BoolOR:
return &query.StartsWithBinaryOperatorError{Node: node}
return &StartsWithBinaryOperatorError{Node: node}
}
}
return nil
@@ -21,14 +20,14 @@ func validateGroupNode(n *ast.GroupNode) error {
case *ast.OperatorNode:
switch node.Value {
case BoolAND, BoolOR:
return &query.StartsWithBinaryOperatorError{Node: node}
return &StartsWithBinaryOperatorError{Node: node}
}
}
if n.Key != "" {
for _, node := range n.Nodes {
if ast.NodeKey(node) != "" {
return &query.NamedGroupInvalidNodesError{Node: node}
return &NamedGroupInvalidNodesError{Node: node}
}
}
}
+10 -121
View File
@@ -9,9 +9,7 @@ import (
"net/http"
"net/url"
"path"
"reflect"
"strconv"
"strings"
"time"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
@@ -28,6 +26,7 @@ import (
"github.com/opencloud-eu/opencloud/pkg/log"
"github.com/opencloud-eu/opencloud/services/graph/pkg/errorcode"
"github.com/opencloud-eu/opencloud/services/search/pkg/mapping"
)
// CreateUploadSession create an upload session to allow your app to upload files up to the maximum file size.
@@ -452,130 +451,20 @@ func cs3ResourceToDriveItem(logger *log.Logger, publicBaseURL *url.URL, res *sto
}
}
if res.GetArbitraryMetadata() != nil {
driveItem.Audio = cs3ResourceToDriveItemAudioFacet(logger, res)
driveItem.Image = cs3ResourceToDriveItemImageFacet(logger, res)
driveItem.Location = cs3ResourceToDriveItemLocationFacet(logger, res)
driveItem.Photo = cs3ResourceToDriveItemPhotoFacet(logger, res)
if metadata := res.GetArbitraryMetadata().GetMetadata(); metadata != nil {
driveItem.Audio = metadataToFacet[libregraph.Audio](metadata, "audio")
driveItem.Image = metadataToFacet[libregraph.Image](metadata, "image")
driveItem.Location = metadataToFacet[libregraph.GeoCoordinates](metadata, "location")
driveItem.Photo = metadataToFacet[libregraph.Photo](metadata, "photo")
}
return driveItem, nil
}
func cs3ResourceToDriveItemAudioFacet(logger *log.Logger, res *storageprovider.ResourceInfo) *libregraph.Audio {
if !strings.HasPrefix(res.GetMimeType(), "audio/") {
return nil
}
k := res.GetArbitraryMetadata().GetMetadata()
if k == nil {
return nil
}
var audio = &libregraph.Audio{}
if ok := unmarshalStringMap(logger, audio, k, "libre.graph.audio."); ok {
return audio
}
return nil
}
func cs3ResourceToDriveItemImageFacet(logger *log.Logger, res *storageprovider.ResourceInfo) *libregraph.Image {
k := res.GetArbitraryMetadata().GetMetadata()
if k == nil {
return nil
}
var image = &libregraph.Image{}
if ok := unmarshalStringMap(logger, image, k, "libre.graph.image."); ok {
return image
}
return nil
}
func cs3ResourceToDriveItemLocationFacet(logger *log.Logger, res *storageprovider.ResourceInfo) *libregraph.GeoCoordinates {
k := res.GetArbitraryMetadata().GetMetadata()
if k == nil {
return nil
}
var location = &libregraph.GeoCoordinates{}
if ok := unmarshalStringMap(logger, location, k, "libre.graph.location."); ok {
return location
}
return nil
}
func cs3ResourceToDriveItemPhotoFacet(logger *log.Logger, res *storageprovider.ResourceInfo) *libregraph.Photo {
k := res.GetArbitraryMetadata().GetMetadata()
if k == nil {
return nil
}
var photo = &libregraph.Photo{}
if ok := unmarshalStringMap(logger, photo, k, "libre.graph.photo."); ok {
return photo
}
return nil
}
func getFieldName(structField reflect.StructField) string {
tag := structField.Tag.Get("json")
if tag == "" {
return structField.Name
}
return strings.Split(tag, ",")[0]
}
func unmarshalStringMap(logger *log.Logger, out any, flatMap map[string]string, prefix string) bool {
nonEmpty := false
obj := reflect.ValueOf(out).Elem()
timeKind := reflect.TypeOf(&time.Time{}).Elem().Kind()
for i := 0; i < obj.NumField(); i++ {
field := obj.Field(i)
structField := obj.Type().Field(i)
mapKey := prefix + getFieldName(structField)
if value, ok := flatMap[mapKey]; ok {
if field.Kind() == reflect.Ptr {
newValue := reflect.New(field.Type().Elem())
var tmp any
var err error
switch t := newValue.Type().Elem().Kind(); t {
case reflect.String:
tmp = value
case reflect.Int32:
tmp, err = strconv.ParseInt(value, 10, 32)
case reflect.Int64:
tmp, err = strconv.ParseInt(value, 10, 64)
case reflect.Float32:
tmp, err = strconv.ParseFloat(value, 32)
case reflect.Float64:
tmp, err = strconv.ParseFloat(value, 64)
case reflect.Bool:
tmp, err = strconv.ParseBool(value)
case timeKind:
tmp, err = time.Parse(time.RFC3339, value)
default:
err = errors.New("unsupported type")
logger.Error().Err(err).Str("type", t.String()).Str("mapKey", mapKey).Msg("target field type for value of mapKey is not supported")
}
if err != nil {
logger.Error().Err(err).Str("mapKey", mapKey).Msg("unmarshalling failed")
continue
}
newValue.Elem().Set(reflect.ValueOf(tmp).Convert(field.Type().Elem()))
field.Set(newValue)
nonEmpty = true
}
}
}
return nonEmpty
// metadataToFacet builds a DriveItem facet *T from CS3 arbitrary metadata under
// the "libre.graph.<facet>." key prefix. Nil when no such keys are present.
func metadataToFacet[T any](metadata map[string]string, facet string) *T {
return mapping.DeserializeStringsAt[T](metadata, "libre.graph."+facet+".")
}
func cs3ResourceToRemoteItem(res *storageprovider.ResourceInfo) (*libregraph.RemoteItem, error) {
+1 -1
View File
@@ -39,7 +39,7 @@ To enable OpenSearch as a backend, the following settings must be set:
Additionally, the following optional settings can be set:
* `SEARCH_ENGINE_OPEN_SEARCH_RESOURCE_INDEX_NAME=val` (default: `opencloud-resource`): Name of the OpenSearch index
* `SEARCH_ENGINE_OPEN_SEARCH_RESOURCE_INDEX_NAME=val` (default: `opencloud-resource`): Base name of the OpenSearch index. The running index is suffixed with the schema version (e.g. `opencloud-resource-v3`); a breaking schema change targets a fresh index and leaves the old one in place.
* `SEARCH_ENGINE_OPEN_SEARCH_CLIENT_USERNAME=val`: Username for HTTP Basic Authentication.
* `SEARCH_ENGINE_OPEN_SEARCH_CLIENT_PASSWORD=val`: Password for HTTP Basic Authentication.
* `SEARCH_ENGINE_OPEN_SEARCH_CLIENT_HEADER=val`: HTTP headers to include in requests.
+16 -17
View File
@@ -3,7 +3,6 @@ package bleve
import (
"context"
"math"
"strings"
"time"
"github.com/blevesearch/bleve/v2"
@@ -14,6 +13,7 @@ import (
"github.com/opencloud-eu/reva/v2/pkg/utils"
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/opencloud-eu/opencloud/pkg/kql"
"github.com/opencloud-eu/opencloud/pkg/log"
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
@@ -45,7 +45,7 @@ func NewBackend(index bleve.Index, queryCreator searchQuery.Creator[query.Query]
func (b *Backend) Search(_ context.Context, sir *searchService.SearchIndexRequest) (*searchService.SearchIndexResponse, error) {
createdQuery, err := b.queryCreator.Create(sir.Query)
if err != nil {
if searchQuery.IsValidationError(err) {
if kql.IsValidationError(err) {
return nil, errtypes.BadRequest(err.Error())
}
return nil, err
@@ -74,6 +74,16 @@ func (b *Backend) Search(_ context.Context, sir *searchService.SearchIndexReques
),
},
)
// Scope below the space root: restrict at query level so totals and
// paging respect the path too. Path is a case-preserving keyword
// (paths act as references, /Foo and /foo are distinct), so the exact
// folder or the folder prefix matches all of, and only, the scope.
if requestedPath := utils.MakeRelativePath(sir.Ref.Path); requestedPath != "." {
q.Conjuncts = append(q.Conjuncts, query.NewDisjunctionQuery([]query.Query{
&query.TermQuery{FieldVal: "Path", Term: requestedPath},
&query.PrefixQuery{FieldVal: "Path", Prefix: requestedPath + "/"},
}))
}
}
bleveReq := bleve.NewSearchRequest(q)
@@ -97,17 +107,6 @@ func (b *Backend) Search(_ context.Context, sir *searchService.SearchIndexReques
matches := make([]*searchMessage.Match, 0, len(res.Hits))
totalMatches := res.Total
for _, hit := range res.Hits {
if sir.Ref != nil {
hitPath := strings.TrimSuffix(getFieldValue[string](hit.Fields, "Path"), "/")
requestedPath := utils.MakeRelativePath(sir.Ref.Path)
isRoot := hitPath == requestedPath
if !isRoot && requestedPath != "." && !strings.HasPrefix(hitPath, requestedPath+"/") {
totalMatches--
continue
}
}
rootID, err := storagespace.ParseID(getFieldValue[string](hit.Fields, "RootID"))
if err != nil {
return nil, err
@@ -136,10 +135,10 @@ func (b *Backend) Search(_ context.Context, sir *searchService.SearchIndexReques
Tags: getFieldSliceValue[string](hit.Fields, "Tags"),
Favorites: getFieldSliceValue[string](hit.Fields, "Favorites"),
Highlights: getFragmentValue(hit.Fragments, "Content", 0),
Audio: getAudioValue[searchMessage.Audio](hit.Fields),
Image: getImageValue[searchMessage.Image](hit.Fields),
Location: getLocationValue[searchMessage.GeoCoordinates](hit.Fields),
Photo: getPhotoValue[searchMessage.Photo](hit.Fields),
Audio: hitToFacet[searchMessage.Audio](hit.Fields, "audio"),
Image: hitToFacet[searchMessage.Image](hit.Fields, "image"),
Location: hitToFacet[searchMessage.GeoCoordinates](hit.Fields, "location"),
Photo: hitToFacet[searchMessage.Photo](hit.Fields, "photo"),
},
}
+168
View File
@@ -125,6 +125,29 @@ var _ = Describe("Bleve", func() {
assertDocCount(rootResource.ID, "Tags:baz", 0)
})
It("finds files by tags case-insensitively", func() {
// exercises the []string/[]any sibling-lowercasing branch end-to-end.
parentResource.Document.Tags = []string{"Work", "Urgent"}
Expect(eng.Upsert(parentResource.ID, parentResource)).To(Succeed())
assertDocCount(rootResource.ID, "tag:work", 1) // stored "Work", queried lower
assertDocCount(rootResource.ID, "tag:WORK", 1) // queried upper
assertDocCount(rootResource.ID, "Tags:Urgent", 1)
assertDocCount(rootResource.ID, "tag:missing", 0)
})
It("binds a leading NOT to the term right after it, combined with AND", func() {
// regression: a leading NOT next to AND dropped the AND'd term, so
// `NOT tag:x AND name:y` matched nothing (a self-contradicting clause).
parentResource.Document.Tags = []string{"physik"}
childResource.Document.Tags = []string{"mathe"}
Expect(eng.Upsert(parentResource.ID, parentResource)).To(Succeed())
Expect(eng.Upsert(childResource.ID, childResource)).To(Succeed())
assertDocCount(rootResource.ID, "NOT tag:physik AND name:child.pdf", 1) // the mathe child
assertDocCount(rootResource.ID, "NOT tag:mathe AND name:parent*", 1) // the physik parent
})
It("finds files by size", func() {
parentResource.Document.Size = 12345
err := eng.Upsert(parentResource.ID, parentResource)
@@ -268,6 +291,81 @@ var _ = Describe("Bleve", func() {
})
})
Context("by path", func() {
BeforeEach(func() {
for _, r := range []search.Resource{parentResource, childResource, childResource2} {
Expect(eng.Upsert(r.ID, r)).To(Succeed())
}
})
It("matches a folder and its descendants", func() {
assertDocCount(rootResource.ID, `path:"./parent d!r"`, 3)
})
It("matches a descendant path only itself", func() {
assertDocCount(rootResource.ID, `path:"./parent d!r/child.pdf"`, 1)
})
It("matches case-sensitively", func() {
// paths act as references: /Foo and /foo are distinct siblings,
// so a wrong-cased path must not match
assertDocCount(rootResource.ID, `path:"./PARENT D!R"`, 0)
})
It("applies an AND filter to the folder itself, not only descendants", func() {
// regression: the folder-itself clause used to match unconditionally
// under an AND, so the parent leaked in despite the name filter.
matches := assertDocCount(rootResource.ID, `path:"./parent d!r" AND name:child.pdf`, 1)
Expect(matches[0].Entity.Name).To(Equal("child.pdf"))
})
})
Context("by content", func() {
It("matches full-text case-insensitively and stemmed", func() {
parentResource.Document.Content = "Running Foxes"
Expect(eng.Upsert(parentResource.ID, parentResource)).To(Succeed())
assertDocCount(rootResource.ID, "content:running", 1)
assertDocCount(rootResource.ID, "content:RUNNING", 1) // case-insensitive
assertDocCount(rootResource.ID, "content:run", 1) // porter stemming
assertDocCount(rootResource.ID, "content:run*", 1) // wildcard over the stemmed term
assertDocCount(rootResource.ID, "content:cat", 0)
})
})
Context("by mediatype", func() {
It("matches categories and literal MIME types (incl. + and /)", func() {
childResource.Document.MimeType = "image/svg+xml"
childResource2.Document.MimeType = "image/png"
for _, r := range []search.Resource{childResource, childResource2} {
Expect(eng.Upsert(r.ID, r)).To(Succeed())
}
assertDocCount(rootResource.ID, "mediatype:image", 2) // image/* wildcard -> both
assertDocCount(rootResource.ID, "mediatype:IMAGE", 2) // categories are case-insensitive
assertDocCount(rootResource.ID, "mediatype:pdf", 0)
// literal MIME with + and /, must hit only the svg doc, not the png
assertDocCount(rootResource.ID, "mediatype:image/svg+xml", 1)
assertDocCount(rootResource.ID, "mediatype:image/png", 1)
// the same literal via the raw field name (no mediatype alias)
assertDocCount(rootResource.ID, "MimeType:image/svg+xml", 1)
assertDocCount(rootResource.ID, "MimeType:image/png", 1)
})
It("combines mediatype:file with another term", func() {
// regression: mediatype:file (a NOT) next to an operator dropped the
// other operand, so mediatype:file AND name:x matched nothing.
parentResource.Document.MimeType = "httpd/unix-directory" // a folder
childResource.Document.MimeType = "image/png" // a file
for _, r := range []search.Resource{parentResource, childResource} {
Expect(eng.Upsert(r.ID, r)).To(Succeed())
}
assertDocCount(rootResource.ID, "mediatype:file", 1) // only the file
assertDocCount(rootResource.ID, "mediatype:file AND name:child.pdf", 1) // file AND its name
assertDocCount(rootResource.ID, "mediatype:file AND name:nope", 0)
})
})
Context("Highlights", func() {
It("highlights only for content searches", func() {
@@ -379,6 +477,61 @@ var _ = Describe("Bleve", func() {
})
Describe("path scoped searches", func() {
BeforeEach(func() {
Expect(eng.Upsert(parentResource.ID, parentResource)).To(Succeed())
Expect(eng.Upsert(childResource.ID, childResource)).To(Succeed())
Expect(eng.Upsert(childResource2.ID, childResource2)).To(Succeed())
outside := search.Resource{
ID: "1$2!6",
ParentID: rootResource.ID,
RootID: rootResource.ID,
Path: "./other/child3.pdf",
Type: uint64(sprovider.ResourceType_RESOURCE_TYPE_FILE),
Document: content.Document{Name: "child3.pdf"},
}
Expect(eng.Upsert(outside.ID, outside)).To(Succeed())
})
It("restricts hits and totals to the scope at query level", func() {
// without the scope all three children match
res, err := doSearch(rootResource.ID, "name:child*", "")
Expect(err).ToNot(HaveOccurred())
Expect(res.TotalMatches).To(Equal(int32(3)))
res, err = doSearch(rootResource.ID, "name:child*", "./parent d!r")
Expect(err).ToNot(HaveOccurred())
Expect(res.TotalMatches).To(Equal(int32(2)))
Expect(len(res.Matches)).To(Equal(2))
})
It("keeps totals right on a small page", func() {
// the scope is part of the query, so totals cover the full scope
// even when the page holds a single hit
rID, err := storagespace.ParseID(rootResource.ID)
Expect(err).ToNot(HaveOccurred())
res, err := eng.Search(context.Background(), &searchsvc.SearchIndexRequest{
Query: "name:child*",
PageSize: 1,
Ref: &searchmsg.Reference{
ResourceId: &searchmsg.ResourceID{
StorageId: rID.StorageId, SpaceId: rID.SpaceId, OpaqueId: rID.OpaqueId,
},
Path: "./parent d!r",
},
})
Expect(err).ToNot(HaveOccurred())
Expect(res.TotalMatches).To(Equal(int32(2)))
Expect(len(res.Matches)).To(Equal(1))
})
It("matches the scope case-sensitively", func() {
res, err := doSearch(rootResource.ID, "name:child*", "./PARENT D!R")
Expect(err).ToNot(HaveOccurred())
Expect(res.TotalMatches).To(Equal(int32(0)))
})
})
Describe("Upsert", func() {
It("adds a resourceInfo to the index", func() {
err := eng.Upsert(childResource.ID, childResource)
@@ -555,6 +708,21 @@ var _ = Describe("Bleve", func() {
Expect(matches[0].Entity.Ref.Path).To(Equal("./somewhere/else/newname"))
})
It("keeps case-insensitive search working after a move", func() {
Expect(eng.Upsert(parentResource.ID, parentResource)).To(Succeed())
Expect(eng.Upsert(childResource.ID, childResource)).To(Succeed())
Expect(eng.Move(parentResource.ID, parentResource.ParentID, "./my/NewName")).To(Succeed())
// the lowercased name sibling is rebuilt, so a case-insensitive
// name query still works; the path is case-sensitive by design, so
// only the exact new path matches (and the old one no longer does).
assertDocCount(rootResource.ID, "name:NEWNAME", 1)
assertDocCount(rootResource.ID, `path:"./my/NewName"`, 2)
assertDocCount(rootResource.ID, `path:"./MY/NEWNAME"`, 0)
assertDocCount(rootResource.ID, `path:"./parent d!r"`, 0)
})
})
Describe("StartBatch", func() {
+16 -4
View File
@@ -10,6 +10,7 @@ import (
"github.com/opencloud-eu/reva/v2/pkg/utils"
"github.com/opencloud-eu/opencloud/pkg/log"
"github.com/opencloud-eu/opencloud/services/search/pkg/mapping"
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
)
@@ -36,10 +37,21 @@ func NewBatch(index bleve.Index, size int) (*Batch, error) {
func (b *Batch) Upsert(id string, r search.Resource) error {
return b.withSizeLimit(func() error {
return b.batch.Index(id, r)
return b.indexResource(id, r)
})
}
// indexResource prepares r for bleve (resolving json tags and splicing in
// type-specific adaptations via the mapping package) and appends it to the
// batch under id.
func (b *Batch) indexResource(id string, r search.Resource) error {
doc, err := mapping.PrepareForIndex(r, r.SearchFieldOverrides())
if err != nil {
return err
}
return b.batch.Index(id, doc)
}
func (b *Batch) Move(id, parentID, location string) error {
return b.withSizeLimit(func() error {
rootResource, err := searchResourceByID(id, b.index)
@@ -68,7 +80,7 @@ func (b *Batch) Move(id, parentID, location string) error {
}
for _, resource := range resources {
if err := b.batch.Index(resource.ID, resource); err != nil {
if err := b.indexResource(resource.ID, *resource); err != nil {
return err
}
if b.batch.Size() >= b.size {
@@ -90,7 +102,7 @@ func (b *Batch) Delete(id string) error {
}
for _, resource := range affectedResources {
if err := b.batch.Index(resource.ID, resource); err != nil {
if err := b.indexResource(resource.ID, *resource); err != nil {
return err
}
if b.batch.Size() >= b.size {
@@ -112,7 +124,7 @@ func (b *Batch) Restore(id string) error {
}
for _, resource := range affectedResources {
if err := b.batch.Index(resource.ID, resource); err != nil {
if err := b.indexResource(resource.ID, *resource); err != nil {
return err
}
if b.batch.Size() >= b.size {
+11 -128
View File
@@ -1,18 +1,13 @@
package bleve
import (
"reflect"
"regexp"
"strings"
"time"
bleveSearch "github.com/blevesearch/bleve/v2/search"
storageProvider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
"google.golang.org/protobuf/types/known/timestamppb"
searchMessage "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/messages/search/v0"
"github.com/opencloud-eu/opencloud/services/search/pkg/content"
"github.com/opencloud-eu/opencloud/services/search/pkg/mapping"
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
)
@@ -75,131 +70,19 @@ func getFragmentValue(m bleveSearch.FieldFragmentMap, key string, idx int) strin
return val[idx]
}
func getAudioValue[T any](fields map[string]any) *T {
if !strings.HasPrefix(getFieldValue[string](fields, "MimeType"), "audio/") {
return nil
}
var audio = newPointerOfType[T]()
if ok := unmarshalInterfaceMap(audio, fields, "audio."); ok {
return audio
}
return nil
}
func getImageValue[T any](fields map[string]any) *T {
var image = newPointerOfType[T]()
if ok := unmarshalInterfaceMap(image, fields, "image."); ok {
return image
}
return nil
}
func getLocationValue[T any](fields map[string]any) *T {
var location = newPointerOfType[T]()
if ok := unmarshalInterfaceMap(location, fields, "location."); ok {
return location
}
return nil
}
func getPhotoValue[T any](fields map[string]any) *T {
var photo = newPointerOfType[T]()
if ok := unmarshalInterfaceMap(photo, fields, "photo."); ok {
return photo
}
return nil
}
func newPointerOfType[T any]() *T {
t := reflect.TypeOf((*T)(nil)).Elem()
ptr := reflect.New(t).Interface()
return ptr.(*T)
}
func unmarshalInterfaceMap(out any, flatMap map[string]any, prefix string) bool {
nonEmpty := false
obj := reflect.ValueOf(out).Elem()
for i := 0; i < obj.NumField(); i++ {
field := obj.Field(i)
structField := obj.Type().Field(i)
mapKey := prefix + getFieldName(structField)
if value, ok := flatMap[mapKey]; ok {
if field.Kind() == reflect.Ptr {
alloc := reflect.New(field.Type().Elem())
elemType := field.Type().Elem()
// convert time strings from index for search requests
if elemType == reflect.TypeOf(timestamppb.Timestamp{}) {
if strValue, ok := value.(string); ok {
if parsedTime, err := time.Parse(time.RFC3339, strValue); err == nil {
alloc.Elem().Set(reflect.ValueOf(*timestamppb.New(parsedTime)))
field.Set(alloc)
nonEmpty = true
}
}
continue
}
// convert time strings from index for libregraph structs when updating resources
if elemType == reflect.TypeOf(time.Time{}) {
if strValue, ok := value.(string); ok {
if parsedTime, err := time.Parse(time.RFC3339, strValue); err == nil {
alloc.Elem().Set(reflect.ValueOf(parsedTime))
field.Set(alloc)
nonEmpty = true
}
}
continue
}
alloc.Elem().Set(reflect.ValueOf(value).Convert(elemType))
field.Set(alloc)
nonEmpty = true
}
}
}
return nonEmpty
}
func getFieldName(structField reflect.StructField) string {
tag := structField.Tag.Get("json")
if tag == "" {
return structField.Name
}
return strings.Split(tag, ",")[0]
// hitToFacet builds a search Entity facet *T from a bleve hit's fields under the
// given key prefix. Nil when the hit has no such fields.
func hitToFacet[T any](fields map[string]any, prefix string) *T {
return mapping.DeserializeAt[T](fields, prefix)
}
// matchToResource reconstructs a search.Resource from a bleve hit. Used by
// the Move / Delete / Restore / Purge paths that round-trip a record through
// the index. Always returns a non-nil *Resource: Deserialize is fail-soft
// for per-field parse errors, so corrupted hit values surface as zero
// values on individual fields instead of dropping the whole record.
func matchToResource(match *bleveSearch.DocumentMatch) *search.Resource {
return &search.Resource{
ID: getFieldValue[string](match.Fields, "ID"),
RootID: getFieldValue[string](match.Fields, "RootID"),
Path: getFieldValue[string](match.Fields, "Path"),
ParentID: getFieldValue[string](match.Fields, "ParentID"),
Type: uint64(getFieldValue[float64](match.Fields, "Type")),
Deleted: getFieldValue[bool](match.Fields, "Deleted"),
Document: content.Document{
Name: getFieldValue[string](match.Fields, "Name"),
Title: getFieldValue[string](match.Fields, "Title"),
Size: uint64(getFieldValue[float64](match.Fields, "Size")),
Mtime: getFieldValue[string](match.Fields, "Mtime"),
MimeType: getFieldValue[string](match.Fields, "MimeType"),
Content: getFieldValue[string](match.Fields, "Content"),
Tags: getFieldSliceValue[string](match.Fields, "Tags"),
Favorites: getFieldSliceValue[string](match.Fields, "Favorites"),
Audio: getAudioValue[libregraph.Audio](match.Fields),
Image: getImageValue[libregraph.Image](match.Fields),
Location: getLocationValue[libregraph.GeoCoordinates](match.Fields),
Photo: getPhotoValue[libregraph.Photo](match.Fields),
},
}
return mapping.Deserialize[search.Resource](match.Fields)
}
func escapeQuery(s string) string {
@@ -0,0 +1,127 @@
package bleve_test
import (
bleveSearch "github.com/blevesearch/bleve/v2"
"github.com/blevesearch/bleve/v2/search/query"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
"github.com/opencloud-eu/opencloud/services/search/pkg/bleve"
"github.com/opencloud-eu/opencloud/services/search/pkg/content"
"github.com/opencloud-eu/opencloud/services/search/pkg/mapping"
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
)
// geoFixture builds an in-memory bleve index with a single resource carrying
// the given lon/lat/alt, indexed through the full bleve pipeline.
func geoFixture(lon, lat, alt float64) bleveSearch.Index {
idxMapping, err := bleve.NewMapping()
Expect(err).ToNot(HaveOccurred())
idx, err := bleveSearch.NewMemOnly(idxMapping)
Expect(err).ToNot(HaveOccurred())
r := search.Resource{
ID: "x",
Document: content.Document{
Name: "team.jpg",
Location: &libregraph.GeoCoordinates{
Longitude: &lon,
Latitude: &lat,
Altitude: &alt,
},
},
}
doc, err := mapping.PrepareForIndex(r, r.SearchFieldOverrides())
Expect(err).ToNot(HaveOccurred())
Expect(idx.Index(r.ID, doc)).To(Succeed())
return idx
}
var _ = Describe("Location geo queries", func() {
// Every Location subfield (including altitude) must end up in hit.Fields
// when a Resource is indexed through the full bleve pipeline. This is the
// invariant the Move / Delete / Restore round-trip depends on.
It("round-trips every Location subfield into hit.Fields", func() {
idx := geoFixture(11.103870357204285, 49.48675890884328, 1047.7)
req := bleveSearch.NewSearchRequest(bleveSearch.NewMatchAllQuery())
req.Fields = []string{"*"}
res, err := idx.Search(req)
Expect(err).ToNot(HaveOccurred())
Expect(res.Hits).ToNot(BeEmpty())
for _, k := range []string{"location.longitude", "location.latitude", "location.altitude"} {
Expect(res.Hits[0].Fields).To(HaveKey(k))
}
})
It("matches a latitude numeric range and misses outside it", func() {
idx := geoFixture(11.1, 49.48, 1000)
min, max := 49.0, 50.0
incl := true
q := query.NewNumericRangeInclusiveQuery(&min, &max, &incl, &incl)
q.SetField("location.latitude")
res, err := idx.Search(bleveSearch.NewSearchRequest(q))
Expect(err).ToNot(HaveOccurred())
Expect(res.Hits).To(HaveLen(1))
lowMin, lowMax := 0.0, 10.0
q2 := query.NewNumericRangeInclusiveQuery(&lowMin, &lowMax, &incl, &incl)
q2.SetField("location.latitude")
res, err = idx.Search(bleveSearch.NewSearchRequest(q2))
Expect(err).ToNot(HaveOccurred())
Expect(res.Hits).To(BeEmpty())
})
It("matches a longitude numeric range", func() {
idx := geoFixture(11.1, 49.48, 1000)
min, max := 11.0, 12.0
incl := true
q := query.NewNumericRangeInclusiveQuery(&min, &max, &incl, &incl)
q.SetField("location.longitude")
res, err := idx.Search(bleveSearch.NewSearchRequest(q))
Expect(err).ToNot(HaveOccurred())
Expect(res.Hits).To(HaveLen(1))
})
It("matches an altitude lower-bound range and misses above it", func() {
idx := geoFixture(11.1, 49.48, 1047.7)
min := 1000.0
incl := true
q := query.NewNumericRangeInclusiveQuery(&min, nil, &incl, nil)
q.SetField("location.altitude")
res, err := idx.Search(bleveSearch.NewSearchRequest(q))
Expect(err).ToNot(HaveOccurred())
Expect(res.Hits).To(HaveLen(1))
highMin := 2000.0
q2 := query.NewNumericRangeInclusiveQuery(&highMin, nil, &incl, nil)
q2.SetField("location.altitude")
res, err = idx.Search(bleveSearch.NewSearchRequest(q2))
Expect(err).ToNot(HaveOccurred())
Expect(res.Hits).To(BeEmpty())
})
It("matches a geo-distance query near the point and misses far away", func() {
// Nuremberg-ish coordinates.
idx := geoFixture(11.103870357204285, 49.48675890884328, 1047.7)
// 10 km radius around the indexed point should match.
near := query.NewGeoDistanceQuery(11.103870357204285, 49.48675890884328, "10km")
near.SetField("location" + mapping.GeopointSuffix)
res, err := idx.Search(bleveSearch.NewSearchRequest(near))
Expect(err).ToNot(HaveOccurred())
Expect(res.Hits).To(HaveLen(1))
// Far away (Berlin, ~400 km) with a 10 km radius should miss.
far := query.NewGeoDistanceQuery(13.404954, 52.520008, "10km")
far.SetField("location" + mapping.GeopointSuffix)
res, err = idx.Search(bleveSearch.NewSearchRequest(far))
Expect(err).ToNot(HaveOccurred())
Expect(res.Hits).To(BeEmpty())
})
})
+167 -38
View File
@@ -1,76 +1,205 @@
package bleve
import (
"encoding/json"
"errors"
"fmt"
"maps"
"math"
"path/filepath"
"reflect"
"slices"
"strings"
"github.com/blevesearch/bleve/v2"
"github.com/blevesearch/bleve/v2/analysis/analyzer/custom"
"github.com/blevesearch/bleve/v2/analysis/analyzer/keyword"
"github.com/blevesearch/bleve/v2/analysis/token/lowercase"
"github.com/blevesearch/bleve/v2/analysis/token/porter"
"github.com/blevesearch/bleve/v2/analysis/tokenizer/single"
"github.com/blevesearch/bleve/v2/analysis/tokenizer/unicode"
"github.com/blevesearch/bleve/v2/mapping"
storageProvider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
"github.com/opencloud-eu/opencloud/pkg/log"
searchmapping "github.com/opencloud-eu/opencloud/services/search/pkg/mapping"
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
)
func NewIndex(root string) (bleve.Index, error) {
destination := filepath.Join(root, "bleve")
index, err := bleve.Open(destination)
if errors.Is(bleve.ErrorIndexPathDoesNotExist, err) {
// bolt_timeout makes a second process on the same datapath fail after 5s
// instead of blocking forever on the file lock.
var openRuntimeConfig = map[string]interface{}{"bolt_timeout": "5s"}
// NewIndex opens (or creates) the bleve index at root and reconciles its schema
// against NewMapping() via searchmapping.Reconcile.
func NewIndex(root string, logger log.Logger) (bleve.Index, searchmapping.Classification, error) {
destination := filepath.Join(root, fmt.Sprintf("bleve-v%d", search.SchemaVersion))
index, err := bleve.OpenUsing(destination, openRuntimeConfig)
if errors.Is(err, bleve.ErrorIndexPathDoesNotExist) {
indexMapping, err := NewMapping()
if err != nil {
return nil, err
return nil, searchmapping.Classification{}, err
}
index, err = bleve.New(destination, indexMapping)
if err != nil {
return nil, err
return nil, searchmapping.Classification{}, err
}
return index, nil
searchmapping.LogNewIndexCreated(logger, destination)
return index, searchmapping.Classification{Verdict: searchmapping.VerdictEqual}, nil
}
if err != nil {
return nil, searchmapping.Classification{}, err
}
return index, err
r := &bleveReconciler{index: index, destination: destination}
classification, err := searchmapping.Reconcile(destination, r, logger)
if err != nil {
if r.index != nil {
_ = r.index.Close()
}
return nil, classification, err
}
return r.index, classification, nil
}
// bleveReconciler adapts a bleve index to searchmapping.SchemaReconciler.
type bleveReconciler struct {
index bleve.Index
destination string
codeB []byte // marshaled code mapping, produced by Classify, used by ApplyAdditive
}
func (r *bleveReconciler) Classify() (searchmapping.Classification, error) {
classification, codeB, err := classifyStoredMapping(r.index)
r.codeB = codeB
return classification, err
}
// ApplyAdditive persists the code mapping and reopens so the live mapping picks
// it up. persisted=true once SetInternal succeeds, even if the reopen then
// fails; on error it closes the index and clears the handle.
func (r *bleveReconciler) ApplyAdditive() (bool, error) {
if err := r.index.SetInternal([]byte("_mapping"), r.codeB); err != nil {
_ = r.index.Close()
r.index = nil
return false, fmt.Errorf("failed to store the updated index mapping: %w", err)
}
if err := r.index.Close(); err != nil {
r.index = nil
return true, err
}
index, err := bleve.OpenUsing(r.destination, openRuntimeConfig)
if err != nil {
r.index = nil
return true, err
}
r.index = index
return true, nil
}
// classifyStoredMapping diffs the stored mapping against NewMapping() and
// returns the marshaled code mapping. New-in-code fields that already hold
// data (previously indexed dynamically) are breaking. The compare is only
// stable within one bleve version: a changed marshaling default fails towards
// breaking, normalize the affected key here if that ever fires.
func classifyStoredMapping(index bleve.Index) (searchmapping.Classification, []byte, error) {
storedB, err := index.GetInternal([]byte("_mapping"))
if err != nil {
return searchmapping.Classification{}, nil, fmt.Errorf("failed to read the stored index mapping: %w", err)
}
codeMapping, err := NewMapping()
if err != nil {
return searchmapping.Classification{}, nil, err
}
codeB, err := json.Marshal(codeMapping)
if err != nil {
return searchmapping.Classification{}, nil, err
}
var stored, code map[string]any
if err := json.Unmarshal(storedB, &stored); err != nil {
return searchmapping.Classification{}, nil, fmt.Errorf("failed to parse the stored index mapping: %w", err)
}
if err := json.Unmarshal(codeB, &code); err != nil {
return searchmapping.Classification{}, nil, err
}
fields, err := index.Fields()
if err != nil {
return searchmapping.Classification{}, nil, fmt.Errorf("failed to list the indexed fields: %w", err)
}
indexedFields := make(map[string]struct{}, len(fields))
for _, f := range fields {
if !strings.HasPrefix(f, "_") { // skip bleve-internal fields like _all
indexedFields[f] = struct{}{}
}
}
storedDM, _ := stored["default_mapping"].(map[string]any)
codeDM, _ := code["default_mapping"].(map[string]any)
storedProps, _ := storedDM["properties"].(map[string]any)
codeProps, _ := codeDM["properties"].(map[string]any)
classification := searchmapping.Classify(storedProps, codeProps, func(path string) bool {
if _, ok := indexedFields[path]; ok {
return true
}
nested := path + "."
for f := range indexedFields {
if strings.HasPrefix(f, nested) {
return true
}
}
return false
})
// everything outside default_mapping.properties (analyzer definitions,
// default analyzer, dynamic flags, ...) must match exactly
var reasons []string
compareKeysExcept(stored, code, "default_mapping", "", &reasons)
compareKeysExcept(storedDM, codeDM, "properties", "default_mapping.", &reasons)
if len(reasons) > 0 {
classification.Verdict = searchmapping.VerdictBreaking
classification.Reasons = append(reasons, classification.Reasons...)
}
return classification, codeB, nil
}
// compareKeysExcept deep-compares all keys present on either side except skip.
func compareKeysExcept(stored, code map[string]any, skip, prefix string, reasons *[]string) {
keys := slices.Collect(maps.Keys(stored))
for k := range code {
if _, ok := stored[k]; !ok {
keys = append(keys, k)
}
}
slices.Sort(keys)
for _, k := range keys {
if k == skip {
continue
}
if !reflect.DeepEqual(stored[k], code[k]) {
*reasons = append(*reasons, fmt.Sprintf("%s%s changed", prefix, k))
}
}
}
func NewMapping() (mapping.IndexMapping, error) {
nameMapping := bleve.NewTextFieldMapping()
nameMapping.Analyzer = "lowercaseKeyword"
lowercaseMapping := bleve.NewTextFieldMapping()
lowercaseMapping.IncludeInAll = false
lowercaseMapping.Analyzer = "lowercaseKeyword"
fulltextFieldMapping := bleve.NewTextFieldMapping()
fulltextFieldMapping.Analyzer = "fulltext"
fulltextFieldMapping.IncludeInAll = false
docMapping := bleve.NewDocumentMapping()
docMapping.AddFieldMappingsAt("Name", nameMapping)
docMapping.AddFieldMappingsAt("Tags", lowercaseMapping)
docMapping.AddFieldMappingsAt("Favorites", lowercaseMapping)
docMapping.AddFieldMappingsAt("Content", fulltextFieldMapping)
indexMapping := bleve.NewIndexMapping()
indexMapping.DefaultAnalyzer = keyword.Name
indexMapping.DefaultMapping = docMapping
err := indexMapping.AddCustomAnalyzer("lowercaseKeyword",
map[string]any{
"type": custom.Name,
"tokenizer": single.Name,
"token_filters": []string{
lowercase.Name,
},
},
)
resourceType := reflect.TypeFor[search.Resource]()
overrides := search.Resource{}.SearchFieldOverrides()
if err := searchmapping.Validate(resourceType, overrides); err != nil {
return nil, err
}
docMapping, err := searchmapping.BleveBuildMapping(resourceType, overrides)
if err != nil {
return nil, err
}
indexMapping := bleve.NewIndexMapping()
indexMapping.DefaultAnalyzer = keyword.Name
indexMapping.DefaultMapping = docMapping
err = indexMapping.AddCustomAnalyzer("fulltext",
map[string]any{
"type": custom.Name,
+202
View File
@@ -0,0 +1,202 @@
package bleve_test
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
bleveSearch "github.com/blevesearch/bleve/v2"
"github.com/blevesearch/bleve/v2/analysis/analyzer/custom"
"github.com/blevesearch/bleve/v2/analysis/token/lowercase"
"github.com/blevesearch/bleve/v2/analysis/tokenizer/unicode"
bleveMapping "github.com/blevesearch/bleve/v2/mapping"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/opencloud-eu/opencloud/pkg/log"
"github.com/opencloud-eu/opencloud/services/search/pkg/bleve"
searchmapping "github.com/opencloud-eu/opencloud/services/search/pkg/mapping"
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
)
var _ = Describe("NewIndex", func() {
var root string
BeforeEach(func() {
root = GinkgoT().TempDir()
})
codeMapping := func() *bleveMapping.IndexMappingImpl {
m, err := bleve.NewMapping()
Expect(err).ToNot(HaveOccurred())
impl, ok := m.(*bleveMapping.IndexMappingImpl)
Expect(ok).To(BeTrue())
return impl
}
// buildIndex simulates an index left behind by an older release
buildIndex := func(m bleveMapping.IndexMapping, docs map[string]map[string]any) {
idx, err := bleveSearch.New(filepath.Join(root, fmt.Sprintf("bleve-v%d", search.SchemaVersion)), m)
Expect(err).ToNot(HaveOccurred())
for id, doc := range docs {
Expect(idx.Index(id, doc)).To(Succeed())
}
Expect(idx.Close()).To(Succeed())
}
It("creates a fresh index", func() {
idx, classification, err := bleve.NewIndex(root, log.NopLogger())
Expect(err).ToNot(HaveOccurred())
Expect(classification.Verdict).To(Equal(searchmapping.VerdictEqual))
Expect(idx.Close()).To(Succeed())
})
It("opens an index with an identical schema", func() {
buildIndex(codeMapping(), nil)
idx, classification, err := bleve.NewIndex(root, log.NopLogger())
Expect(err).ToNot(HaveOccurred())
Expect(classification.Verdict).To(Equal(searchmapping.VerdictEqual))
Expect(classification.NewFields).To(BeEmpty())
Expect(idx.Close()).To(Succeed())
})
It("treats a genuinely new field as additive", func() {
old := codeMapping()
Expect(old.DefaultMapping.Properties).To(HaveKey("Title"))
delete(old.DefaultMapping.Properties, "Title")
buildIndex(old, nil)
idx, classification, err := bleve.NewIndex(root, log.NopLogger())
Expect(err).ToNot(HaveOccurred())
Expect(classification.Verdict).To(Equal(searchmapping.VerdictAdditive))
Expect(classification.NewFields).To(ConsistOf("Title"))
Expect(idx.Index("1", map[string]any{"Title": "hello"})).To(Succeed())
Expect(idx.Close()).To(Succeed())
})
It("treats a new nested field as additive", func() {
old := codeMapping()
photo := old.DefaultMapping.Properties["photo"]
Expect(photo).ToNot(BeNil())
Expect(photo.Properties).To(HaveKey("cameraMake"))
delete(photo.Properties, "cameraMake")
buildIndex(old, nil)
idx, classification, err := bleve.NewIndex(root, log.NopLogger())
Expect(err).ToNot(HaveOccurred())
Expect(classification.Verdict).To(Equal(searchmapping.VerdictAdditive))
Expect(classification.NewFields).To(ConsistOf("photo.cameraMake"))
Expect(idx.Close()).To(Succeed())
})
It("persists an additive schema change so later startups classify it as equal", func() {
old := codeMapping()
delete(old.DefaultMapping.Properties, "Title")
buildIndex(old, nil)
idx, classification, err := bleve.NewIndex(root, log.NopLogger())
Expect(err).ToNot(HaveOccurred())
Expect(classification.Verdict).To(Equal(searchmapping.VerdictAdditive))
Expect(idx.Index("1", map[string]any{"Title": "hello"})).To(Succeed())
Expect(idx.Close()).To(Succeed())
idx, classification, err = bleve.NewIndex(root, log.NopLogger())
Expect(err).ToNot(HaveOccurred())
Expect(classification.Verdict).To(Equal(searchmapping.VerdictEqual))
Expect(idx.Close()).To(Succeed())
})
It("refuses when a new field already has data in the index", func() {
old := codeMapping()
Expect(old.DefaultMapping.Properties).To(HaveKey("Mtime"))
delete(old.DefaultMapping.Properties, "Mtime")
buildIndex(old, map[string]map[string]any{"1": {"Mtime": "2026-01-02T03:04:05Z"}})
idx, _, err := bleve.NewIndex(root, log.NopLogger())
Expect(err).To(MatchError(searchmapping.ErrManualActionRequired))
Expect(idx).To(BeNil())
})
It("refuses when a new object field already has nested data in the index", func() {
old := codeMapping()
Expect(old.DefaultMapping.Properties).To(HaveKey("photo"))
delete(old.DefaultMapping.Properties, "photo")
buildIndex(old, map[string]map[string]any{"1": {"photo": map[string]any{"cameraMake": "ACME"}}})
_, _, err := bleve.NewIndex(root, log.NopLogger())
Expect(err).To(MatchError(searchmapping.ErrManualActionRequired))
})
It("refuses on a changed field definition", func() {
old := codeMapping()
name := old.DefaultMapping.Properties["Name"]
Expect(name).ToNot(BeNil())
Expect(name.Fields).ToNot(BeEmpty())
name.Fields[0].Analyzer = "fulltext"
buildIndex(old, nil)
_, _, err := bleve.NewIndex(root, log.NopLogger())
Expect(err).To(MatchError(searchmapping.ErrManualActionRequired))
})
It("refuses when a stored field was removed from the code schema", func() {
old := codeMapping()
old.DefaultMapping.AddFieldMappingsAt("Legacy", bleveSearch.NewTextFieldMapping())
buildIndex(old, nil)
_, _, err := bleve.NewIndex(root, log.NopLogger())
Expect(err).To(MatchError(searchmapping.ErrManualActionRequired))
})
It("refuses when a default_mapping attribute changed", func() {
old := codeMapping()
old.DefaultMapping.Dynamic = false
buildIndex(old, nil)
_, _, err := bleve.NewIndex(root, log.NopLogger())
Expect(err).To(MatchError(searchmapping.ErrManualActionRequired))
})
It("refuses on a changed analyzer definition", func() {
old := codeMapping()
Expect(old.CustomAnalysis.Analyzers).To(HaveKey("fulltext"))
old.CustomAnalysis.Analyzers["fulltext"] = map[string]any{
"type": custom.Name,
"tokenizer": unicode.Name,
"token_filters": []string{lowercase.Name},
}
buildIndex(old, nil)
_, _, err := bleve.NewIndex(root, log.NopLogger())
Expect(err).To(MatchError(searchmapping.ErrManualActionRequired))
})
})
var _ = Describe("NewMapping", func() {
It("only references registered analyzers", func() {
m, err := bleve.NewMapping()
Expect(err).ToNot(HaveOccurred())
impl, ok := m.(*bleveMapping.IndexMappingImpl)
Expect(ok).To(BeTrue())
Expect(impl.Validate()).To(Succeed())
})
// A diff here means existing indexes will classify as breaking (schema or
// bleve marshaling changed); update the golden file only deliberately.
It("matches the committed golden mapping", func() {
m, err := bleve.NewMapping()
Expect(err).ToNot(HaveOccurred())
b, err := json.Marshal(m)
Expect(err).ToNot(HaveOccurred())
var got, golden map[string]any
Expect(json.Unmarshal(b, &got)).To(Succeed())
goldenB, err := os.ReadFile("testdata/mapping.golden.json")
Expect(err).ToNot(HaveOccurred())
Expect(json.Unmarshal(goldenB, &golden)).To(Succeed())
Expect(got).To(Equal(golden))
})
})
+41
View File
@@ -0,0 +1,41 @@
package bleve_test
import (
"time"
"github.com/opencloud-eu/opencloud/pkg/conversions"
bleveSearch "github.com/blevesearch/bleve/v2"
bquery "github.com/blevesearch/bleve/v2/search/query"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/opencloud-eu/opencloud/services/search/pkg/bleve"
"github.com/opencloud-eu/opencloud/services/search/pkg/content"
"github.com/opencloud-eu/opencloud/services/search/pkg/mapping"
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
)
// Mtime is typed as a date, so range queries are chronological, not a
// lexicographic keyword compare.
var _ = Describe("Mtime date range", func() {
It("compares chronologically, not lexicographically", func() {
m, err := bleve.NewMapping()
Expect(err).ToNot(HaveOccurred())
idx, err := bleveSearch.NewMemOnly(m)
Expect(err).ToNot(HaveOccurred())
r := search.Resource{ID: "x", Document: content.Document{Name: "f", Mtime: conversions.ToPointer(time.Date(2026, 3, 15, 12, 0, 0, 123456789, time.UTC))}}
doc, err := mapping.PrepareForIndex(r, r.SearchFieldOverrides())
Expect(err).ToNot(HaveOccurred())
Expect(idx.Index(r.ID, doc)).To(Succeed())
hits := func(qs string) uint64 {
res, err := idx.Search(bleveSearch.NewSearchRequest(bquery.NewQueryStringQuery(qs)))
Expect(err).ToNot(HaveOccurred(), qs)
return res.Total
}
Expect(hits(`Mtime:>"2026-01-01T00:00:00Z"`)).To(Equal(uint64(1)), "in-range")
Expect(hits(`Mtime:>"2026-06-01T00:00:00Z"`)).To(Equal(uint64(0)), "out-of-range")
})
})
+720
View File
@@ -0,0 +1,720 @@
{
"default_mapping": {
"enabled": true,
"dynamic": true,
"properties": {
"Content": {
"enabled": true,
"dynamic": true,
"fields": [
{
"type": "text",
"analyzer": "fulltext",
"store": true,
"index": true,
"include_term_vectors": true,
"docvalues": true
}
]
},
"Deleted": {
"enabled": true,
"dynamic": true,
"fields": [
{
"type": "boolean",
"store": true,
"index": true,
"include_in_all": true,
"docvalues": true
}
]
},
"Favorites": {
"enabled": true,
"dynamic": true,
"fields": [
{
"type": "text",
"analyzer": "keyword",
"store": true,
"index": true,
"include_term_vectors": true,
"docvalues": true
}
]
},
"Favorites_lowercase": {
"enabled": true,
"dynamic": true,
"fields": [
{
"type": "text",
"analyzer": "keyword",
"index": true,
"include_term_vectors": true
}
]
},
"Hidden": {
"enabled": true,
"dynamic": true,
"fields": [
{
"type": "boolean",
"store": true,
"index": true,
"include_in_all": true,
"docvalues": true
}
]
},
"ID": {
"enabled": true,
"dynamic": true,
"fields": [
{
"type": "text",
"analyzer": "keyword",
"store": true,
"index": true,
"include_term_vectors": true,
"include_in_all": true,
"docvalues": true
}
]
},
"MimeType": {
"enabled": true,
"dynamic": true,
"fields": [
{
"type": "text",
"analyzer": "keyword",
"store": true,
"index": true,
"include_term_vectors": true,
"include_in_all": true,
"docvalues": true
}
]
},
"Mtime": {
"enabled": true,
"dynamic": true,
"fields": [
{
"type": "datetime",
"store": true,
"index": true,
"include_in_all": true,
"docvalues": true
}
]
},
"Name": {
"enabled": true,
"dynamic": true,
"fields": [
{
"type": "text",
"analyzer": "keyword",
"store": true,
"index": true,
"include_term_vectors": true,
"include_in_all": true,
"docvalues": true
}
]
},
"Name_lowercase": {
"enabled": true,
"dynamic": true,
"fields": [
{
"type": "text",
"analyzer": "keyword",
"index": true,
"include_term_vectors": true
}
]
},
"ParentID": {
"enabled": true,
"dynamic": true,
"fields": [
{
"type": "text",
"analyzer": "keyword",
"store": true,
"index": true,
"include_term_vectors": true,
"include_in_all": true,
"docvalues": true
}
]
},
"Path": {
"enabled": true,
"dynamic": true,
"fields": [
{
"type": "text",
"analyzer": "keyword",
"store": true,
"index": true,
"include_term_vectors": true,
"docvalues": true
}
]
},
"RootID": {
"enabled": true,
"dynamic": true,
"fields": [
{
"type": "text",
"analyzer": "keyword",
"store": true,
"index": true,
"include_term_vectors": true,
"include_in_all": true,
"docvalues": true
}
]
},
"Size": {
"enabled": true,
"dynamic": true,
"fields": [
{
"type": "number",
"store": true,
"index": true,
"include_in_all": true,
"docvalues": true
}
]
},
"Tags": {
"enabled": true,
"dynamic": true,
"fields": [
{
"type": "text",
"analyzer": "keyword",
"store": true,
"index": true,
"include_term_vectors": true,
"docvalues": true
}
]
},
"Tags_lowercase": {
"enabled": true,
"dynamic": true,
"fields": [
{
"type": "text",
"analyzer": "keyword",
"index": true,
"include_term_vectors": true
}
]
},
"Title": {
"enabled": true,
"dynamic": true,
"fields": [
{
"type": "text",
"analyzer": "keyword",
"store": true,
"index": true,
"include_term_vectors": true,
"include_in_all": true,
"docvalues": true
}
]
},
"Type": {
"enabled": true,
"dynamic": true,
"fields": [
{
"type": "number",
"store": true,
"index": true,
"include_in_all": true,
"docvalues": true
}
]
},
"audio": {
"enabled": true,
"dynamic": true,
"properties": {
"album": {
"enabled": true,
"dynamic": true,
"fields": [
{
"type": "text",
"analyzer": "keyword",
"store": true,
"index": true,
"include_term_vectors": true,
"include_in_all": true,
"docvalues": true
}
]
},
"albumArtist": {
"enabled": true,
"dynamic": true,
"fields": [
{
"type": "text",
"analyzer": "keyword",
"store": true,
"index": true,
"include_term_vectors": true,
"include_in_all": true,
"docvalues": true
}
]
},
"artist": {
"enabled": true,
"dynamic": true,
"fields": [
{
"type": "text",
"analyzer": "keyword",
"store": true,
"index": true,
"include_term_vectors": true,
"include_in_all": true,
"docvalues": true
}
]
},
"bitrate": {
"enabled": true,
"dynamic": true,
"fields": [
{
"type": "number",
"store": true,
"index": true,
"include_in_all": true,
"docvalues": true
}
]
},
"composers": {
"enabled": true,
"dynamic": true,
"fields": [
{
"type": "text",
"analyzer": "keyword",
"store": true,
"index": true,
"include_term_vectors": true,
"include_in_all": true,
"docvalues": true
}
]
},
"copyright": {
"enabled": true,
"dynamic": true,
"fields": [
{
"type": "text",
"analyzer": "keyword",
"store": true,
"index": true,
"include_term_vectors": true,
"include_in_all": true,
"docvalues": true
}
]
},
"disc": {
"enabled": true,
"dynamic": true,
"fields": [
{
"type": "number",
"store": true,
"index": true,
"include_in_all": true,
"docvalues": true
}
]
},
"discCount": {
"enabled": true,
"dynamic": true,
"fields": [
{
"type": "number",
"store": true,
"index": true,
"include_in_all": true,
"docvalues": true
}
]
},
"duration": {
"enabled": true,
"dynamic": true,
"fields": [
{
"type": "number",
"store": true,
"index": true,
"include_in_all": true,
"docvalues": true
}
]
},
"genre": {
"enabled": true,
"dynamic": true,
"fields": [
{
"type": "text",
"analyzer": "keyword",
"store": true,
"index": true,
"include_term_vectors": true,
"include_in_all": true,
"docvalues": true
}
]
},
"hasDrm": {
"enabled": true,
"dynamic": true,
"fields": [
{
"type": "boolean",
"store": true,
"index": true,
"include_in_all": true,
"docvalues": true
}
]
},
"isVariableBitrate": {
"enabled": true,
"dynamic": true,
"fields": [
{
"type": "boolean",
"store": true,
"index": true,
"include_in_all": true,
"docvalues": true
}
]
},
"title": {
"enabled": true,
"dynamic": true,
"fields": [
{
"type": "text",
"analyzer": "keyword",
"store": true,
"index": true,
"include_term_vectors": true,
"include_in_all": true,
"docvalues": true
}
]
},
"track": {
"enabled": true,
"dynamic": true,
"fields": [
{
"type": "number",
"store": true,
"index": true,
"include_in_all": true,
"docvalues": true
}
]
},
"trackCount": {
"enabled": true,
"dynamic": true,
"fields": [
{
"type": "number",
"store": true,
"index": true,
"include_in_all": true,
"docvalues": true
}
]
},
"year": {
"enabled": true,
"dynamic": true,
"fields": [
{
"type": "number",
"store": true,
"index": true,
"include_in_all": true,
"docvalues": true
}
]
}
}
},
"image": {
"enabled": true,
"dynamic": true,
"properties": {
"height": {
"enabled": true,
"dynamic": true,
"fields": [
{
"type": "number",
"store": true,
"index": true,
"include_in_all": true,
"docvalues": true
}
]
},
"width": {
"enabled": true,
"dynamic": true,
"fields": [
{
"type": "number",
"store": true,
"index": true,
"include_in_all": true,
"docvalues": true
}
]
}
}
},
"location": {
"enabled": true,
"dynamic": true,
"properties": {
"altitude": {
"enabled": true,
"dynamic": true,
"fields": [
{
"type": "number",
"store": true,
"index": true,
"include_in_all": true,
"docvalues": true
}
]
},
"latitude": {
"enabled": true,
"dynamic": true,
"fields": [
{
"type": "number",
"store": true,
"index": true,
"include_in_all": true,
"docvalues": true
}
]
},
"longitude": {
"enabled": true,
"dynamic": true,
"fields": [
{
"type": "number",
"store": true,
"index": true,
"include_in_all": true,
"docvalues": true
}
]
}
}
},
"location_geopoint": {
"enabled": true,
"dynamic": true,
"fields": [
{
"type": "geopoint",
"store": true,
"index": true,
"include_in_all": true,
"docvalues": true
}
]
},
"photo": {
"enabled": true,
"dynamic": true,
"properties": {
"cameraMake": {
"enabled": true,
"dynamic": true,
"fields": [
{
"type": "text",
"analyzer": "keyword",
"store": true,
"index": true,
"include_term_vectors": true,
"include_in_all": true,
"docvalues": true
}
]
},
"cameraModel": {
"enabled": true,
"dynamic": true,
"fields": [
{
"type": "text",
"analyzer": "keyword",
"store": true,
"index": true,
"include_term_vectors": true,
"include_in_all": true,
"docvalues": true
}
]
},
"exposureDenominator": {
"enabled": true,
"dynamic": true,
"fields": [
{
"type": "number",
"store": true,
"index": true,
"include_in_all": true,
"docvalues": true
}
]
},
"exposureNumerator": {
"enabled": true,
"dynamic": true,
"fields": [
{
"type": "number",
"store": true,
"index": true,
"include_in_all": true,
"docvalues": true
}
]
},
"fNumber": {
"enabled": true,
"dynamic": true,
"fields": [
{
"type": "number",
"store": true,
"index": true,
"include_in_all": true,
"docvalues": true
}
]
},
"focalLength": {
"enabled": true,
"dynamic": true,
"fields": [
{
"type": "number",
"store": true,
"index": true,
"include_in_all": true,
"docvalues": true
}
]
},
"iso": {
"enabled": true,
"dynamic": true,
"fields": [
{
"type": "number",
"store": true,
"index": true,
"include_in_all": true,
"docvalues": true
}
]
},
"orientation": {
"enabled": true,
"dynamic": true,
"fields": [
{
"type": "number",
"store": true,
"index": true,
"include_in_all": true,
"docvalues": true
}
]
},
"takenDateTime": {
"enabled": true,
"dynamic": true,
"fields": [
{
"type": "datetime",
"store": true,
"index": true,
"include_in_all": true,
"docvalues": true
}
]
}
}
}
}
},
"type_field": "_type",
"default_type": "_default",
"default_analyzer": "keyword",
"default_datetime_parser": "dateTimeOptional",
"default_field": "_all",
"store_dynamic": true,
"index_dynamic": true,
"docvalues_dynamic": true,
"analysis": {
"analyzers": {
"fulltext": {
"token_filters": [
"to_lower",
"stemmer_porter"
],
"tokenizer": "unicode",
"type": "custom"
}
}
}
}
+9 -39
View File
@@ -2,11 +2,9 @@ package command
import (
"context"
"crypto/tls"
"fmt"
"net/http"
"os"
"os/signal"
"time"
"github.com/opencloud-eu/opencloud/pkg/config/configlog"
"github.com/opencloud-eu/opencloud/pkg/generators"
@@ -30,8 +28,6 @@ import (
"github.com/opencloud-eu/reva/v2/pkg/events/raw"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
opensearchgo "github.com/opensearch-project/opensearch-go/v4"
opensearchgoAPI "github.com/opensearch-project/opensearch-go/v4/opensearchapi"
"github.com/spf13/cobra"
)
@@ -71,7 +67,7 @@ func Server(cfg *config.Config) *cobra.Command {
var eng search.Engine
switch cfg.Engine.Type {
case "bleve":
idx, err := bleve.NewIndex(cfg.Engine.Bleve.Datapath)
idx, _, err := bleve.NewIndex(cfg.Engine.Bleve.Datapath, logger)
if err != nil {
return err
}
@@ -84,42 +80,16 @@ func Server(cfg *config.Config) *cobra.Command {
eng = bleve.NewBackend(idx, bleveQuery.DefaultCreator, logger)
case "open-search":
clientConfig := opensearchgo.Config{
Addresses: cfg.Engine.OpenSearch.Client.Addresses,
Username: cfg.Engine.OpenSearch.Client.Username,
Password: cfg.Engine.OpenSearch.Client.Password,
Header: cfg.Engine.OpenSearch.Client.Header,
RetryOnStatus: cfg.Engine.OpenSearch.Client.RetryOnStatus,
DisableRetry: cfg.Engine.OpenSearch.Client.DisableRetry,
EnableRetryOnTimeout: cfg.Engine.OpenSearch.Client.EnableRetryOnTimeout,
MaxRetries: cfg.Engine.OpenSearch.Client.MaxRetries,
CompressRequestBody: cfg.Engine.OpenSearch.Client.CompressRequestBody,
DiscoverNodesOnStart: cfg.Engine.OpenSearch.Client.DiscoverNodesOnStart,
DiscoverNodesInterval: cfg.Engine.OpenSearch.Client.DiscoverNodesInterval,
EnableMetrics: cfg.Engine.OpenSearch.Client.EnableMetrics,
EnableDebugLogger: cfg.Engine.OpenSearch.Client.EnableDebugLogger,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
MinVersion: tls.VersionTLS12,
InsecureSkipVerify: cfg.Engine.OpenSearch.Client.Insecure,
},
},
}
if cfg.Engine.OpenSearch.Client.CACert != "" {
certBytes, err := os.ReadFile(cfg.Engine.OpenSearch.Client.CACert)
if err != nil {
return fmt.Errorf("failed to read CA cert: %w", err)
}
clientConfig.CACert = certBytes
}
client, err := opensearchgoAPI.NewClient(opensearchgoAPI.Config{Client: clientConfig})
client, err := opensearch.NewClient(cfg.Engine.OpenSearch.Client)
if err != nil {
return fmt.Errorf("failed to create OpenSearch client: %w", err)
return err
}
openSearchBackend, err := opensearch.NewBackend(cfg.Engine.OpenSearch.ResourceIndex.Name, client)
// a hung cluster must fail the start, not block it forever
startupCtx, cancelStartup := context.WithTimeout(ctx, time.Minute)
indexName := opensearch.VersionedIndexName(cfg.Engine.OpenSearch.ResourceIndex.Name)
openSearchBackend, err := opensearch.NewBackend(startupCtx, indexName, client, logger)
cancelStartup()
if err != nil {
return fmt.Errorf("failed to create OpenSearch backend: %w", err)
}
+1 -1
View File
@@ -25,7 +25,7 @@ type EngineOpenSearch struct {
// EngineOpenSearchResourceIndex defines the OpenSearch index for resources
type EngineOpenSearchResourceIndex struct {
Name string `yaml:"name" env:"SEARCH_ENGINE_OPEN_SEARCH_RESOURCE_INDEX_NAME" desc:"The name of the OpenSearch index for resources." introductionVersion:"4.0.0"`
Name string `yaml:"name" env:"SEARCH_ENGINE_OPEN_SEARCH_RESOURCE_INDEX_NAME" desc:"The base name of the OpenSearch index for resources. The running index is suffixed with the schema version, e.g. opencloud-resource-v3." introductionVersion:"4.0.0"`
}
// EngineOpenSearchClient configures the OpenSearch client
+2 -2
View File
@@ -3,9 +3,9 @@ package content
import (
"context"
"encoding/json"
"time"
storageProvider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
"github.com/opencloud-eu/opencloud/pkg/conversions"
"github.com/opencloud-eu/opencloud/pkg/log"
"github.com/opencloud-eu/reva/v2/pkg/tags"
"github.com/opencloud-eu/reva/v2/pkg/utils"
@@ -54,7 +54,7 @@ func (b Basic) Extract(_ context.Context, ri *storageProvider.ResourceInfo) (Doc
}
if ri.Mtime != nil {
doc.Mtime = utils.TSToTime(ri.Mtime).UTC().Format(time.RFC3339Nano)
doc.Mtime = conversions.ToPointer(utils.TSToTime(ri.Mtime).UTC())
}
return doc, nil
+8 -4
View File
@@ -1,6 +1,10 @@
package content_test
import (
"time"
"github.com/opencloud-eu/opencloud/pkg/conversions"
"context"
"encoding/json"
@@ -69,11 +73,11 @@ var _ = Describe("Basic", func() {
It("RFC3339 mtime", func() {
for _, data := range []struct {
second uint64
expect string
expect *time.Time
}{
{second: 4000, expect: "1970-01-01T01:06:40Z"},
{second: 3000, expect: "1970-01-01T00:50:00Z"},
{expect: ""},
{second: 4000, expect: conversions.ToPointer(time.Unix(4000, 0).UTC())},
{second: 3000, expect: conversions.ToPointer(time.Unix(3000, 0).UTC())},
{},
} {
ri := &storageProvider.ResourceInfo{}
+9 -8
View File
@@ -2,6 +2,7 @@ package content
import (
"strings"
"time"
"github.com/bbalet/stopwords"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
@@ -14,14 +15,14 @@ func init() {
// Document wraps all resource meta fields,
// it is used as a content extraction result.
type Document struct {
Title string
Name string
Content string
Size uint64
Mtime string
MimeType string
Tags []string
Favorites []string
Title string `json:"Title"`
Name string `json:"Name"`
Content string `json:"Content"`
Size uint64 `json:"Size"`
Mtime *time.Time `json:"Mtime"`
MimeType string `json:"MimeType"`
Tags []string `json:"Tags"`
Favorites []string `json:"Favorites"`
Audio *libregraph.Audio `json:"audio,omitempty"`
Image *libregraph.Image `json:"image,omitempty"`
Location *libregraph.GeoCoordinates `json:"location,omitempty"`
+1 -204
View File
@@ -3,16 +3,12 @@ package content
import (
"context"
"fmt"
"math"
"strconv"
"strings"
"time"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
"github.com/google/go-tika/tika"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
"github.com/opencloud-eu/opencloud/pkg/log"
"github.com/opencloud-eu/opencloud/services/search/pkg/config"
@@ -94,10 +90,7 @@ func (t Tika) Extract(ctx context.Context, ri *provider.ResourceInfo) (Document,
doc.Location = t.getLocation(meta)
doc.Image = t.getImage(meta)
doc.Photo = t.getPhoto(meta)
if contentType, err := getFirstValue(meta, "Content-Type"); err == nil && strings.HasPrefix(contentType, "audio/") {
doc.Audio = t.getAudio(meta)
}
doc.Audio = t.getAudio(meta)
}
if langCode, _ := t.tika.LanguageString(ctx, doc.Content); langCode != "" && t.CleanStopWords {
@@ -106,199 +99,3 @@ func (t Tika) Extract(ctx context.Context, ri *provider.ResourceInfo) (Document,
return doc, nil
}
func (t Tika) getImage(meta map[string][]string) *libregraph.Image {
var image *libregraph.Image
initImage := func() {
if image == nil {
image = libregraph.NewImage()
}
}
if v, err := getFirstValue(meta, "tiff:ImageWidth"); err == nil {
if i, err := strconv.ParseInt(v, 0, 32); err == nil {
initImage()
image.SetWidth(int32(i))
}
}
if v, err := getFirstValue(meta, "tiff:ImageLength"); err == nil {
if i, err := strconv.ParseInt(v, 0, 32); err == nil {
initImage()
image.SetHeight(int32(i))
}
}
return image
}
func (t Tika) getLocation(meta map[string][]string) *libregraph.GeoCoordinates {
var location *libregraph.GeoCoordinates
initLocation := func() {
if location == nil {
location = libregraph.NewGeoCoordinates()
}
}
// TODO: location.Altitute: transform the following data to … feet above sea level.
// "GPS:GPS Altitude": []string{"227.4 metres"},
// "GPS:GPS Altitude Ref": []string{"Sea level"},
if v, err := getFirstValue(meta, "geo:lat"); err == nil {
if i, err := strconv.ParseFloat(v, 64); err == nil {
initLocation()
location.SetLatitude(i)
}
}
if v, err := getFirstValue(meta, "geo:long"); err == nil {
if i, err := strconv.ParseFloat(v, 64); err == nil {
initLocation()
location.SetLongitude(i)
}
}
return location
}
func (t Tika) getPhoto(meta map[string][]string) *libregraph.Photo {
var photo *libregraph.Photo
initPhoto := func() {
if photo == nil {
photo = libregraph.NewPhoto()
}
}
if v, err := getFirstValue(meta, "tiff:Make"); err == nil {
initPhoto()
photo.SetCameraMake(v)
}
if v, err := getFirstValue(meta, "tiff:Model"); err == nil {
initPhoto()
photo.SetCameraModel(v)
}
if v, err := getFirstValue(meta, "exif:FNumber"); err == nil {
if i, err := strconv.ParseFloat(v, 64); err == nil {
initPhoto()
photo.SetFNumber(i)
}
}
if v, err := getFirstValue(meta, "exif:FocalLength"); err == nil {
if i, err := strconv.ParseFloat(v, 64); err == nil {
initPhoto()
photo.SetFocalLength(i)
}
}
if v, err := getFirstValue(meta, "Base ISO"); err == nil {
if i, err := strconv.ParseInt(v, 0, 32); err == nil {
initPhoto()
photo.SetIso(int32(i))
}
}
if v, err := getFirstValue(meta, "tiff:Orientation"); err == nil {
if i, err := strconv.ParseInt(v, 0, 32); err == nil {
initPhoto()
photo.SetOrientation(int32(i))
}
}
if v, err := getFirstValue(meta, "exif:DateTimeOriginal"); err == nil {
layout := "2006-01-02T15:04:05"
if t, err := time.Parse(layout, v); err == nil {
initPhoto()
photo.SetTakenDateTime(t)
}
}
if v, err := getFirstValue(meta, "exif:ExposureTime"); err == nil {
if i, err := strconv.ParseFloat(v, 64); err == nil {
initPhoto()
photo.SetExposureNumerator(1)
photo.SetExposureDenominator(math.Round(1 / i))
}
}
return photo
}
func (t Tika) getAudio(meta map[string][]string) *libregraph.Audio {
var audio *libregraph.Audio
initAudio := func() {
if audio == nil {
audio = libregraph.NewAudio()
}
}
if v, err := getFirstValue(meta, "xmpDM:album"); err == nil {
initAudio()
audio.SetAlbum(v)
}
if v, err := getFirstValue(meta, "xmpDM:albumArtist"); err == nil {
initAudio()
audio.SetAlbumArtist(v)
}
if v, err := getFirstValue(meta, "xmpDM:artist"); err == nil {
initAudio()
audio.SetArtist(v)
}
// TODO: audio.Bitrate: not provided by tika
// TODO: audio.Composers: not provided by tika
// TODO: audio.Copyright: not provided by tika for audio files?
if v, err := getFirstValue(meta, "xmpDM:discNumber"); err == nil {
if i, err := strconv.ParseInt(v, 10, 32); err == nil {
initAudio()
audio.SetDisc(int32(i))
}
}
// TODO: audio.DiscCount: not provided by tika
if v, err := getFirstValue(meta, "xmpDM:duration"); err == nil {
// Tika emits fractional seconds.
if f, err := strconv.ParseFloat(v, 64); err == nil {
initAudio()
audio.SetDuration(int64(math.Round(f * 1000)))
}
}
if v, err := getFirstValue(meta, "xmpDM:genre"); err == nil {
initAudio()
audio.SetGenre(v)
}
// TODO: audio.HasDrm: not provided by tika
// TODO: audio.IsVariableBitrate: not provided by tika
if v, err := getFirstValue(meta, "dc:title"); err == nil {
initAudio()
audio.SetTitle(v)
}
if v, err := getFirstValue(meta, "xmpDM:trackNumber"); err == nil {
if i, err := strconv.ParseInt(v, 10, 32); err == nil {
initAudio()
audio.SetTrack(int32(i))
}
}
// TODO: audio.TrackCount: not provided by tika
if v, err := getFirstValue(meta, "xmpDM:releaseDate"); err == nil {
if i, err := strconv.ParseInt(v, 10, 32); err == nil {
initAudio()
audio.SetYear(int32(i))
}
}
return audio
}
+120
View File
@@ -0,0 +1,120 @@
package content
import (
"math"
"strconv"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
)
func (t Tika) getAudio(meta map[string][]string) *libregraph.Audio {
var audio *libregraph.Audio
initAudio := func() {
if audio == nil {
audio = libregraph.NewAudio()
}
}
if v, err := getFirstValue(meta, "xmpDM:album"); err == nil {
initAudio()
audio.SetAlbum(v)
}
if v, err := getFirstValue(meta, "xmpDM:albumArtist"); err == nil {
initAudio()
audio.SetAlbumArtist(v)
}
if v, err := getFirstValue(meta, "xmpDM:artist"); err == nil {
initAudio()
audio.SetArtist(v)
}
if v, err := getFirstValue(meta, "audio:bitrate"); err == nil {
// tika emits bits per second, graph wants kbps
if bps, err := strconv.ParseInt(v, 10, 64); err == nil {
initAudio()
audio.SetBitrate(bps / 1000)
}
}
if v, err := getFirstValue(meta, "xmpDM:composer"); err == nil {
initAudio()
audio.SetComposers(v)
}
if v, err := getFirstValue(meta, "xmpDM:copyright"); err == nil {
initAudio()
audio.SetCopyright(v)
}
if v, err := getFirstValue(meta, "xmpDM:discNumber"); err == nil {
if i, err := strconv.ParseInt(v, 10, 32); err == nil {
initAudio()
audio.SetDisc(int32(i))
}
}
if v, err := getFirstValue(meta, "audio:disc-count"); err == nil {
if i, err := strconv.ParseInt(v, 10, 32); err == nil {
initAudio()
audio.SetDiscCount(int32(i))
}
}
if v, err := getFirstValue(meta, "xmpDM:duration"); err == nil {
// Tika emits fractional seconds.
if f, err := strconv.ParseFloat(v, 64); err == nil {
initAudio()
audio.SetDuration(int64(math.Round(f * 1000)))
}
}
if v, err := getFirstValue(meta, "xmpDM:genre"); err == nil {
initAudio()
audio.SetGenre(v)
}
if v, err := getFirstValue(meta, "audio:has-drm"); err == nil {
if b, err := strconv.ParseBool(v); err == nil {
initAudio()
audio.SetHasDrm(b)
}
}
if v, err := getFirstValue(meta, "audio:is-variable-bitrate"); err == nil {
if b, err := strconv.ParseBool(v); err == nil {
initAudio()
audio.SetIsVariableBitrate(b)
}
}
if v, err := getFirstValue(meta, "dc:title"); err == nil {
initAudio()
audio.SetTitle(v)
}
if v, err := getFirstValue(meta, "xmpDM:trackNumber"); err == nil {
if i, err := strconv.ParseInt(v, 10, 32); err == nil {
initAudio()
audio.SetTrack(int32(i))
}
}
if v, err := getFirstValue(meta, "audio:track-count"); err == nil {
if i, err := strconv.ParseInt(v, 10, 32); err == nil {
initAudio()
audio.SetTrackCount(int32(i))
}
}
if v, err := getFirstValue(meta, "xmpDM:releaseDate"); err == nil {
if i, err := strconv.ParseInt(v, 10, 32); err == nil {
initAudio()
audio.SetYear(int32(i))
}
}
return audio
}
@@ -0,0 +1,54 @@
package content
import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
)
var _ = Describe("getAudio", func() {
It("maps the audio metadata to the audio facet", func() {
meta := map[string][]string{
"xmpDM:genre": {"Some Genre"},
"xmpDM:album": {"Some Album"},
"xmpDM:trackNumber": {"7"},
"xmpDM:discNumber": {"4"},
"xmpDM:releaseDate": {"2004"},
"xmpDM:artist": {"Some Artist"},
"xmpDM:albumArtist": {"Some AlbumArtist"},
"dc:title": {"Some Title"},
"xmpDM:duration": {"225.5"},
"xmpDM:composer": {"Some Composers"},
"xmpDM:copyright": {"Some Copyright"},
"audio:bitrate": {"192000"},
"audio:is-variable-bitrate": {"true"},
"audio:has-drm": {"false"},
"audio:track-count": {"9"},
"audio:disc-count": {"5"},
}
audio := Tika{}.getAudio(meta)
Expect(audio).ToNot(BeNil())
Expect(audio.Album).To(Equal(libregraph.PtrString("Some Album")))
Expect(audio.AlbumArtist).To(Equal(libregraph.PtrString("Some AlbumArtist")))
Expect(audio.Artist).To(Equal(libregraph.PtrString("Some Artist")))
Expect(audio.Bitrate).To(Equal(libregraph.PtrInt64(192)))
Expect(audio.Composers).To(Equal(libregraph.PtrString("Some Composers")))
Expect(audio.Copyright).To(Equal(libregraph.PtrString("Some Copyright")))
Expect(audio.Disc).To(Equal(libregraph.PtrInt32(4)))
Expect(audio.DiscCount).To(Equal(libregraph.PtrInt32(5)))
Expect(audio.Duration).To(Equal(libregraph.PtrInt64(225500)))
Expect(audio.Genre).To(Equal(libregraph.PtrString("Some Genre")))
Expect(audio.HasDrm).To(Equal(libregraph.PtrBool(false)))
Expect(audio.IsVariableBitrate).To(Equal(libregraph.PtrBool(true)))
Expect(audio.Title).To(Equal(libregraph.PtrString("Some Title")))
Expect(audio.Track).To(Equal(libregraph.PtrInt32(7)))
Expect(audio.TrackCount).To(Equal(libregraph.PtrInt32(9)))
Expect(audio.Year).To(Equal(libregraph.PtrInt32(2004)))
})
It("returns nil when no audio metadata is present", func() {
Expect(Tika{}.getAudio(map[string][]string{})).To(BeNil())
})
})
+32
View File
@@ -0,0 +1,32 @@
package content
import (
"strconv"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
)
func (t Tika) getImage(meta map[string][]string) *libregraph.Image {
var image *libregraph.Image
initImage := func() {
if image == nil {
image = libregraph.NewImage()
}
}
if v, err := getFirstValue(meta, "tiff:ImageWidth"); err == nil {
if i, err := strconv.ParseInt(v, 0, 32); err == nil {
initImage()
image.SetWidth(int32(i))
}
}
if v, err := getFirstValue(meta, "tiff:ImageLength"); err == nil {
if i, err := strconv.ParseInt(v, 0, 32); err == nil {
initImage()
image.SetHeight(int32(i))
}
}
return image
}
@@ -0,0 +1,23 @@
package content
import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
)
var _ = Describe("getImage", func() {
It("maps the image dimensions to the image facet", func() {
image := Tika{}.getImage(map[string][]string{
"tiff:ImageWidth": {"100"},
"tiff:ImageLength": {"200"},
})
Expect(image).ToNot(BeNil())
Expect(image.Width).To(Equal(libregraph.PtrInt32(100)))
Expect(image.Height).To(Equal(libregraph.PtrInt32(200)))
})
It("returns nil when no image metadata is present", func() {
Expect(Tika{}.getImage(map[string][]string{})).To(BeNil())
})
})
@@ -0,0 +1,43 @@
package content
import (
"strconv"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
)
// graph geoCoordinates.altitude is in feet, exif GPS altitude (geo:alt) in metres.
const metresToFeet = 3.280839895
func (t Tika) getLocation(meta map[string][]string) *libregraph.GeoCoordinates {
var location *libregraph.GeoCoordinates
initLocation := func() {
if location == nil {
location = libregraph.NewGeoCoordinates()
}
}
if v, err := getFirstValue(meta, "geo:lat"); err == nil {
if i, err := strconv.ParseFloat(v, 64); err == nil {
initLocation()
location.SetLatitude(i)
}
}
if v, err := getFirstValue(meta, "geo:long"); err == nil {
if i, err := strconv.ParseFloat(v, 64); err == nil {
initLocation()
location.SetLongitude(i)
}
}
// tika emits metres (negative below sea level), graph wants feet
if v, err := getFirstValue(meta, "geo:alt"); err == nil {
if metres, err := strconv.ParseFloat(v, 64); err == nil {
initLocation()
location.SetAltitude(metres * metresToFeet)
}
}
return location
}
@@ -0,0 +1,33 @@
package content
import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
)
var _ = Describe("getLocation", func() {
It("maps lat/long and converts altitude from metres to feet", func() {
metres := 227.4
location := Tika{}.getLocation(map[string][]string{
"geo:lat": {"49.48675890884328"},
"geo:long": {"11.103870357204285"},
"geo:alt": {"227.4"},
})
Expect(location).ToNot(BeNil())
Expect(location.Latitude).To(Equal(libregraph.PtrFloat64(49.48675890884328)))
Expect(location.Longitude).To(Equal(libregraph.PtrFloat64(11.103870357204285)))
Expect(location.Altitude).To(Equal(libregraph.PtrFloat64(metres * metresToFeet)))
})
It("keeps below-sea-level altitude negative", func() {
metres := -227.4
location := Tika{}.getLocation(map[string][]string{"geo:alt": {"-227.4"}})
Expect(location).ToNot(BeNil())
Expect(location.Altitude).To(Equal(libregraph.PtrFloat64(metres * metresToFeet)))
})
It("returns nil when no location metadata is present", func() {
Expect(Tika{}.getLocation(map[string][]string{})).To(BeNil())
})
})
+74
View File
@@ -0,0 +1,74 @@
package content
import (
"math"
"strconv"
"time"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
)
func (t Tika) getPhoto(meta map[string][]string) *libregraph.Photo {
var photo *libregraph.Photo
initPhoto := func() {
if photo == nil {
photo = libregraph.NewPhoto()
}
}
if v, err := getFirstValue(meta, "tiff:Make"); err == nil {
initPhoto()
photo.SetCameraMake(v)
}
if v, err := getFirstValue(meta, "tiff:Model"); err == nil {
initPhoto()
photo.SetCameraModel(v)
}
if v, err := getFirstValue(meta, "exif:FNumber"); err == nil {
if i, err := strconv.ParseFloat(v, 64); err == nil {
initPhoto()
photo.SetFNumber(i)
}
}
if v, err := getFirstValue(meta, "exif:FocalLength"); err == nil {
if i, err := strconv.ParseFloat(v, 64); err == nil {
initPhoto()
photo.SetFocalLength(i)
}
}
if v, err := getFirstValue(meta, "Base ISO"); err == nil {
if i, err := strconv.ParseInt(v, 0, 32); err == nil {
initPhoto()
photo.SetIso(int32(i))
}
}
if v, err := getFirstValue(meta, "tiff:Orientation"); err == nil {
if i, err := strconv.ParseInt(v, 0, 32); err == nil {
initPhoto()
photo.SetOrientation(int32(i))
}
}
if v, err := getFirstValue(meta, "exif:DateTimeOriginal"); err == nil {
layout := "2006-01-02T15:04:05"
if t, err := time.Parse(layout, v); err == nil {
initPhoto()
photo.SetTakenDateTime(t)
}
}
if v, err := getFirstValue(meta, "exif:ExposureTime"); err == nil {
if i, err := strconv.ParseFloat(v, 64); err == nil {
initPhoto()
photo.SetExposureNumerator(1)
photo.SetExposureDenominator(math.Round(1 / i))
}
}
return photo
}
@@ -0,0 +1,38 @@
package content
import (
"time"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
)
var _ = Describe("getPhoto", func() {
It("maps the exif metadata to the photo facet", func() {
photo := Tika{}.getPhoto(map[string][]string{
"tiff:Make": {"Canon"},
"tiff:Model": {"Canon EOS 5D"},
"exif:ExposureTime": {"0.001"},
"exif:FNumber": {"1.8"},
"exif:FocalLength": {"50"},
"Base ISO": {"100"},
"tiff:Orientation": {"1"},
"exif:DateTimeOriginal": {"2018-01-01T12:34:56"},
})
Expect(photo).ToNot(BeNil())
Expect(photo.CameraMake).To(Equal(libregraph.PtrString("Canon")))
Expect(photo.CameraModel).To(Equal(libregraph.PtrString("Canon EOS 5D")))
Expect(photo.ExposureNumerator).To(Equal(libregraph.PtrFloat64(1)))
Expect(photo.ExposureDenominator).To(Equal(libregraph.PtrFloat64(1000)))
Expect(photo.FNumber).To(Equal(libregraph.PtrFloat64(1.8)))
Expect(photo.FocalLength).To(Equal(libregraph.PtrFloat64(50)))
Expect(photo.Iso).To(Equal(libregraph.PtrInt32(100)))
Expect(photo.Orientation).To(Equal(libregraph.PtrInt32(1)))
Expect(photo.TakenDateTime).To(Equal(libregraph.PtrTime(time.Date(2018, 1, 1, 12, 34, 56, 0, time.UTC))))
})
It("returns nil when no photo metadata is present", func() {
Expect(Tika{}.getPhoto(map[string][]string{})).To(BeNil())
})
})
-127
View File
@@ -7,12 +7,10 @@ import (
"net/http"
"net/http/httptest"
"strings"
"time"
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
"github.com/stretchr/testify/mock"
"github.com/opencloud-eu/opencloud/pkg/log"
@@ -91,131 +89,6 @@ var _ = Describe("Tika", func() {
Expect(doc.Content).To(Equal(body))
})
It("adds audio content", func() {
fullResponse = `[
{
"xmpDM:genre": "Some Genre",
"xmpDM:album": "Some Album",
"xmpDM:trackNumber": "7",
"xmpDM:discNumber": "4",
"xmpDM:releaseDate": "2004",
"xmpDM:artist": "Some Artist",
"xmpDM:albumArtist": "Some AlbumArtist",
"xmpDM:audioCompressor": "MP3",
"xmpDM:audioChannelType": "Stereo",
"version": "MPEG 3 Layer III Version 1",
"xmpDM:logComment": "some comment",
"xmpDM:audioSampleRate": "44100",
"channels": "2",
"dc:title": "Some Title",
"xmpDM:duration": "225.5",
"Content-Type": "audio/mpeg",
"samplerate": "44100"
}
]`
doc, err := tika.Extract(context.TODO(), &provider.ResourceInfo{
Type: provider.ResourceType_RESOURCE_TYPE_FILE,
Size: 1,
})
Expect(err).ToNot(HaveOccurred())
audio := doc.Audio
Expect(audio).ToNot(BeNil())
Expect(audio.Album).To(Equal(libregraph.PtrString("Some Album")))
Expect(audio.AlbumArtist).To(Equal(libregraph.PtrString("Some AlbumArtist")))
Expect(audio.Artist).To(Equal(libregraph.PtrString("Some Artist")))
// Expect(audio.Bitrate).To(Equal(libregraph.PtrInt64(192)))
// Expect(audio.Composers).To(Equal(libregraph.PtrString("Some Composers")))
// Expect(audio.Copyright).To(Equal(libregraph.PtrString("Some Copyright")))
Expect(audio.Disc).To(Equal(libregraph.PtrInt32(4)))
// Expect(audio.DiscCount).To(Equal(libregraph.PtrInt32(5)))
Expect(audio.Duration).To(Equal(libregraph.PtrInt64(225500)))
Expect(audio.Genre).To(Equal(libregraph.PtrString("Some Genre")))
// Expect(audio.HasDrm).To(Equal(libregraph.PtrBool(false)))
// Expect(audio.IsVariableBitrate).To(Equal(libregraph.PtrBool(true)))
Expect(audio.Title).To(Equal(libregraph.PtrString("Some Title")))
Expect(audio.Track).To(Equal(libregraph.PtrInt32(7)))
// Expect(audio.TrackCount).To(Equal(libregraph.PtrInt32(9)))
Expect(audio.Year).To(Equal(libregraph.PtrInt32(2004)))
})
It("adds location content", func() {
fullResponse = `[
{
"geo:lat": "49.48675890884328",
"geo:long": "11.103870357204285"
}
]`
doc, err := tika.Extract(context.TODO(), &provider.ResourceInfo{
Type: provider.ResourceType_RESOURCE_TYPE_FILE,
Size: 1,
})
Expect(err).ToNot(HaveOccurred())
location := doc.Location
Expect(location).ToNot(BeNil())
// TODO: Altitude is not supported right now
Expect(location.Altitude).To(BeNil())
Expect(location.Latitude).To(Equal(libregraph.PtrFloat64(49.48675890884328)))
Expect(location.Longitude).To(Equal(libregraph.PtrFloat64(11.103870357204285)))
})
It("adds image content", func() {
fullResponse = `[
{
"tiff:ImageWidth": "100",
"tiff:ImageLength": "100"
}
]`
doc, err := tika.Extract(context.TODO(), &provider.ResourceInfo{
Type: provider.ResourceType_RESOURCE_TYPE_FILE,
Size: 1,
})
Expect(err).ToNot(HaveOccurred())
image := doc.Image
Expect(image).ToNot(BeNil())
Expect(image.Width).To(Equal(libregraph.PtrInt32(100)))
Expect(image.Height).To(Equal(libregraph.PtrInt32(100)))
})
It("adds photo content", func() {
fullResponse = `[
{
"tiff:Make": "Canon",
"tiff:Model": "Canon EOS 5D",
"exif:ExposureTime": "0.001",
"exif:FNumber": "1.8",
"exif:FocalLength": "50",
"Base ISO": "100",
"tiff:Orientation": "1",
"exif:DateTimeOriginal": "2018-01-01T12:34:56"
}
]`
doc, err := tika.Extract(context.TODO(), &provider.ResourceInfo{
Type: provider.ResourceType_RESOURCE_TYPE_FILE,
Size: 1,
})
Expect(err).ToNot(HaveOccurred())
photo := doc.Photo
Expect(photo).ToNot(BeNil())
Expect(photo.CameraMake).To(Equal(libregraph.PtrString("Canon")))
Expect(photo.CameraModel).To(Equal(libregraph.PtrString("Canon EOS 5D")))
Expect(photo.ExposureNumerator).To(Equal(libregraph.PtrFloat64(1)))
Expect(photo.ExposureDenominator).To(Equal(libregraph.PtrFloat64(1000)))
Expect(photo.FNumber).To(Equal(libregraph.PtrFloat64(1.8)))
Expect(photo.FocalLength).To(Equal(libregraph.PtrFloat64(50)))
Expect(photo.Iso).To(Equal(libregraph.PtrInt32(100)))
Expect(photo.Orientation).To(Equal(libregraph.PtrInt32(1)))
Expect(photo.TakenDateTime).To(Equal(libregraph.PtrTime(time.Date(2018, 1, 1, 12, 34, 56, 0, time.UTC))))
})
It("removes stop words", func() {
body = "body to test stop words!!! against almost everyone"
language = "en"
+139
View File
@@ -0,0 +1,139 @@
package mapping
import (
"fmt"
"reflect"
"github.com/blevesearch/bleve/v2"
bleveMapping "github.com/blevesearch/bleve/v2/mapping"
)
// BleveBuildMapping builds a bleve DocumentMapping for t by walking the
// struct via reflection. Field names come from json tags; overrides are
// keyed by those names (or dotted paths for nested fields).
//
// The returned mapping references analyzer names (Analyzer field on the
// FieldOpts, plus "fulltext" / "path_hierarchy" for the corresponding Types);
// the caller must register every referenced analyzer on the enclosing
// IndexMapping (IndexMapping.Validate catches missing ones).
func BleveBuildMapping(t reflect.Type, overrides map[string]FieldOpts) (*bleveMapping.DocumentMapping, error) {
return buildBleveDocMapping(t, overrides, "")
}
func buildBleveDocMapping(t reflect.Type, overrides map[string]FieldOpts, prefix string) (*bleveMapping.DocumentMapping, error) {
doc := bleve.NewDocumentMapping()
err := walkFields(t, func(fi fieldInfo) error {
key := fi.Name
if prefix != "" {
key = prefix + "." + fi.Name
}
opts := overrides[key]
fieldType := opts.Type
if fieldType == "" {
fieldType = inferType(fi.GoField.Type)
}
if fieldType == TypeObject {
sub := structType(fi.GoField.Type)
if sub == nil {
return fmt.Errorf("mapping: object type on non-struct field %q", key)
}
subDoc, err := buildBleveDocMapping(sub, overrides, key)
if err != nil {
return err
}
doc.AddSubDocumentMapping(fi.Name, subDoc)
return nil
}
if fieldType == TypeGeopoint {
// Keep the facet object, add a sibling _geopoint field (see GeopointSuffix).
sub := structType(fi.GoField.Type)
if sub == nil {
return fmt.Errorf("mapping: geopoint type on non-struct field %q", key)
}
subDoc, err := buildBleveDocMapping(sub, overrides, key)
if err != nil {
return err
}
doc.AddSubDocumentMapping(fi.Name, subDoc)
doc.AddFieldMappingsAt(fi.Name+GeopointSuffix, bleve.NewGeoPointFieldMapping())
return nil
}
if fieldType == TypeKeyword || fieldType == TypePath {
// bleve has no path tokenizer, so a path is a plain keyword here.
base := bleveKeywordMapping(fieldType, opts)
doc.AddFieldMappingsAt(fi.Name, base)
if opts.caseInsensitive() {
doc.AddFieldMappingsAt(fi.Name+LowercaseSuffix, lowercaseSibling(base))
}
return nil
}
fm, err := bleveFieldMapping(fieldType, opts)
if err != nil {
return fmt.Errorf("mapping: field %q: %w", key, err)
}
doc.AddFieldMappingsAt(fi.Name, fm)
return nil
})
return doc, err
}
// bleveKeywordMapping is a case-preserving keyword field; path fields stay out
// of _all by default.
func bleveKeywordMapping(fieldType string, opts FieldOpts) *bleveMapping.FieldMapping {
fm := bleve.NewKeywordFieldMapping()
switch {
case opts.IncludeInAll != nil:
fm.IncludeInAll = *opts.IncludeInAll
case fieldType == TypePath:
fm.IncludeInAll = false
}
return fm
}
// lowercaseSibling derives the lowercased shadow of a keyword/path field from its
// base mapping: used only for case-insensitive matching, so indexed but never
// stored, kept out of _all, and without doc values, since the case-preserved base
// field is what we return and aggregate on.
func lowercaseSibling(base *bleveMapping.FieldMapping) *bleveMapping.FieldMapping {
fm := *base
fm.Store = false
fm.IncludeInAll = false
fm.DocValues = false
return &fm
}
func bleveFieldMapping(fieldType string, opts FieldOpts) (*bleveMapping.FieldMapping, error) {
switch fieldType {
case TypeWildcard:
// bleve has no wildcard type; fall back to keyword-ish text.
fieldType = TypeKeyword
fallthrough
case TypeKeyword, TypeFulltext:
fm := bleve.NewTextFieldMapping()
if fieldType == TypeFulltext {
fm.Analyzer = "fulltext"
}
switch {
case opts.IncludeInAll != nil:
fm.IncludeInAll = *opts.IncludeInAll
case fieldType == TypeFulltext:
fm.IncludeInAll = false
}
return fm, nil
case TypeNumeric:
return bleve.NewNumericFieldMapping(), nil
case TypeBool:
return bleve.NewBooleanFieldMapping(), nil
case TypeDatetime:
return bleve.NewDateTimeFieldMapping(), nil
case TypeGeopoint:
return bleve.NewGeoPointFieldMapping(), nil
case "":
return nil, fmt.Errorf("no type inferred and no override")
}
return nil, fmt.Errorf("unsupported type %q", fieldType)
}
+125
View File
@@ -0,0 +1,125 @@
package mapping
import (
"reflect"
"time"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
type bleveDoc struct {
Name string `json:"Name"`
Content string `json:"Content"`
Tags []string `json:"Tags"`
Size uint64 `json:"Size"`
Deleted bool `json:"Deleted"`
CreatedAt time.Time `json:"CreatedAt"`
Nested *nested `json:"nested,omitempty"`
}
type nested struct {
Artist string `json:"artist"`
Year int `json:"year"`
}
var _ = Describe("BleveBuildMapping", func() {
It("falls back to text for wildcard fields", func() {
// bleve wildcard falls back to keyword-ish text (bleve has no wildcard type).
type doc struct {
Mime string `json:"mime"`
}
dm, err := BleveBuildMapping(reflect.TypeFor[doc](), map[string]FieldOpts{"mime": {Type: TypeWildcard}})
Expect(err).ToNot(HaveOccurred())
fms := dm.Properties["mime"].Fields
Expect(fms).To(HaveLen(1))
Expect(fms[0].Type).To(Equal("text"), "wildcard should map to text")
})
DescribeTable("infers field types",
func(field, wantType string) {
dm, err := BleveBuildMapping(reflect.TypeFor[bleveDoc](), nil)
Expect(err).ToNot(HaveOccurred())
prop := dm.Properties[field]
Expect(prop).ToNot(BeNil(), "missing property %q", field)
Expect(prop.Fields).ToNot(BeEmpty(), "%q: no field mappings", field)
Expect(prop.Fields[0].Type).To(Equal(wantType), "%q type", field)
},
Entry("Name", "Name", "text"),
Entry("Content", "Content", "text"),
Entry("Tags", "Tags", "text"),
Entry("Size", "Size", "number"),
Entry("Deleted", "Deleted", "boolean"),
Entry("CreatedAt", "CreatedAt", "datetime"),
)
It("maps a nested struct as a sub-document", func() {
dm, err := BleveBuildMapping(reflect.TypeFor[bleveDoc](), nil)
Expect(err).ToNot(HaveOccurred())
sub := dm.Properties["nested"]
Expect(sub).ToNot(BeNil(), "missing nested sub-document")
Expect(sub.Properties["artist"]).ToNot(BeNil())
Expect(sub.Properties["year"]).ToNot(BeNil())
Expect(sub.Properties["artist"].Fields[0].Type).To(Equal("text"), "nested.artist")
Expect(sub.Properties["year"].Fields[0].Type).To(Equal("number"), "nested.year")
})
It("applies field overrides", func() {
True, False := true, false
dm, err := BleveBuildMapping(reflect.TypeFor[bleveDoc](), map[string]FieldOpts{
"Name": {CaseInsensitive: &True},
"Content": {Type: TypeFulltext},
"Tags": {CaseInsensitive: &True, IncludeInAll: &False},
})
Expect(err).ToNot(HaveOccurred())
// Name: case-preserved base keyword + lowercased sibling.
Expect(dm.Properties["Name"]).ToNot(BeNil(), "Name base field")
Expect(dm.Properties["Name"].Fields[0].Analyzer).To(Equal("keyword"), "Name base is a keyword")
Expect(dm.Properties["Name"].Fields[0].Store).To(BeTrue(), "Name base is stored (returned)")
Expect(dm.Properties["Name_lowercase"]).ToNot(BeNil(), "Name_lowercase sibling")
// The sibling is a search-only shadow: indexed but never stored, kept out
// of _all, no doc values (the base is what we return).
sibling := dm.Properties["Name_lowercase"].Fields[0]
Expect(sibling.Index).To(BeTrue(), "Name_lowercase is indexed")
Expect(sibling.Store).To(BeFalse(), "Name_lowercase is not stored")
Expect(sibling.IncludeInAll).To(BeFalse(), "Name_lowercase is out of _all")
Expect(sibling.DocValues).To(BeFalse(), "Name_lowercase has no doc values")
contentField := dm.Properties["Content"].Fields[0]
Expect(contentField.Analyzer).To(Equal("fulltext"), "Content analyzer")
Expect(contentField.IncludeInAll).To(BeFalse(), "Content IncludeInAll should default to false for fulltext type")
// Tags: base + lowercased sibling, both honoring the IncludeInAll override.
Expect(dm.Properties["Tags"].Fields[0].IncludeInAll).To(BeFalse(), "Tags base IncludeInAll honored")
Expect(dm.Properties["Tags_lowercase"].Fields[0].IncludeInAll).To(BeFalse(), "Tags sibling IncludeInAll honored")
})
It("builds an object sub-document plus a geopoint sibling", func() {
type geoDoc struct {
Location *struct {
Lon *float64 `json:"longitude,omitempty"`
Lat *float64 `json:"latitude,omitempty"`
Alt *float64 `json:"altitude,omitempty"`
} `json:"location,omitempty"`
}
dm, err := BleveBuildMapping(reflect.TypeFor[geoDoc](), map[string]FieldOpts{
"location": {Type: TypeGeopoint},
})
Expect(err).ToNot(HaveOccurred())
// Original facet stays as an object sub-document with numeric
// sub-properties - for data retrieval via hit.Fields and ordinary
// numeric queries.
loc := dm.Properties["location"]
Expect(loc).ToNot(BeNil(), "location sub-document missing")
Expect(loc.Fields).To(BeEmpty(), "location should not carry field mappings directly")
for _, sub := range []string{"longitude", "latitude", "altitude"} {
prop, ok := loc.Properties[sub]
Expect(ok).To(BeTrue(), "missing sub-field %q under location", sub)
Expect(prop.Fields).ToNot(BeEmpty(), "location.%s Fields", sub)
Expect(prop.Fields[0].Type).To(Equal("number"), "location.%s type", sub)
}
// Sibling geopoint at "<name>_geopoint" for geo-distance queries.
sibling := dm.Properties["location"+GeopointSuffix]
Expect(sibling).ToNot(BeNil(), "location%s missing", GeopointSuffix)
Expect(sibling.Fields).ToNot(BeEmpty(), "location%s Fields", GeopointSuffix)
Expect(sibling.Fields[0].Type).To(Equal("geopoint"), "location%s type", GeopointSuffix)
})
})
+55
View File
@@ -0,0 +1,55 @@
package mapping
import "strings"
func addLowercaseSiblings(m map[string]any, overrides map[string]FieldOpts) {
for key, opts := range overrides {
if !opts.caseInsensitive() || !isCasedType(opts) {
continue
}
parent, leaf, ok := resolveLeaf(m, key)
if !ok {
continue
}
addLowercaseSibling(parent, leaf)
}
}
func isCasedType(opts FieldOpts) bool {
return opts.Type == "" || opts.Type == TypeKeyword || opts.Type == TypePath
}
func resolveLeaf(m map[string]any, dottedPath string) (map[string]any, string, bool) {
parts := strings.Split(dottedPath, ".")
parent := m
for _, p := range parts[:len(parts)-1] {
next, ok := parent[p].(map[string]any)
if !ok {
return nil, "", false
}
parent = next
}
return parent, parts[len(parts)-1], true
}
// addLowercaseSibling writes a <leaf>_lowercase sibling; no-op for non-strings.
func addLowercaseSibling(parent map[string]any, leaf string) {
switch v := parent[leaf].(type) {
case string:
parent[leaf+LowercaseSuffix] = strings.ToLower(v)
case []any:
out := make([]any, 0, len(v))
for _, item := range v {
if s, ok := item.(string); ok {
out = append(out, strings.ToLower(s))
}
}
parent[leaf+LowercaseSuffix] = out
case []string:
out := make([]string, len(v))
for i, s := range v {
out[i] = strings.ToLower(s)
}
parent[leaf+LowercaseSuffix] = out
}
}
@@ -0,0 +1,54 @@
package mapping
import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("PrepareForIndex casing", func() {
It("adds lowercased siblings for CaseInsensitive keyword and path fields", func() {
True := true
type doc struct {
Name string `json:"Name"`
Path string `json:"Path"`
Tags []string `json:"Tags"`
}
d := doc{Name: "Report FINAL", Path: "/Foo/Bar", Tags: []string{"Work", "Urgent"}}
m, err := PrepareForIndex(d, map[string]FieldOpts{
"Name": {CaseInsensitive: &True},
"Path": {Type: TypePath, CaseInsensitive: &True},
"Tags": {CaseInsensitive: &True},
})
Expect(err).ToNot(HaveOccurred())
// Originals stay for the case-preserved base fields and the cascade.
Expect(m["Name"]).To(Equal("Report FINAL"))
Expect(m["Path"]).To(Equal("/Foo/Bar"))
Expect(m["Name_lowercase"]).To(Equal("report final"))
Expect(m["Path_lowercase"]).To(Equal("/foo/bar"))
Expect(m["Tags_lowercase"]).To(Equal([]any{"work", "urgent"}))
})
It("writes no sibling without CaseInsensitive", func() {
type doc struct {
ID string `json:"ID"`
}
m, err := PrepareForIndex(doc{ID: "ABC"}, nil)
Expect(err).ToNot(HaveOccurred())
Expect(m).ToNot(HaveKey("ID" + LowercaseSuffix))
})
It("writes an empty sibling for an empty array, like a non-empty one", func() {
True := true
type doc struct {
Tags []string `json:"Tags"`
}
m, err := PrepareForIndex(doc{Tags: []string{}}, map[string]FieldOpts{
"Tags": {CaseInsensitive: &True},
})
Expect(err).ToNot(HaveOccurred())
Expect(m).To(HaveKey("Tags" + LowercaseSuffix))
Expect(m["Tags"+LowercaseSuffix]).To(BeEmpty())
})
})
+164
View File
@@ -0,0 +1,164 @@
package mapping
import (
"encoding/json"
"errors"
"fmt"
"maps"
"reflect"
"slices"
"strings"
)
// ErrManualActionRequired marks schema changes that cannot be applied in place.
var ErrManualActionRequired = errors.New("manual action required")
// ManualActionRequiredError reports a breaking schema change. Because the index
// is versioned by search.SchemaVersion, a released instance never hits this: a
// version bump builds a fresh index. It fires in development when the mapping is
// changed in a breaking way without bumping search.SchemaVersion, so the fix is
// to bump it (or revert the change).
func ManualActionRequiredError(index string, reasons []string) error {
return fmt.Errorf(
"%w: the search mapping in code differs from index %s in a breaking way:\n - %s\n"+
"bump search.SchemaVersion to build a fresh index, or revert the mapping change",
ErrManualActionRequired, index, strings.Join(reasons, "\n - "),
)
}
type Verdict string
const (
VerdictEqual Verdict = "equal"
VerdictAdditive Verdict = "additive"
VerdictBreaking Verdict = "breaking"
)
// Classification is the outcome of diffing a stored index schema against the
// schema generated from code.
type Classification struct {
Verdict Verdict
// NewFields are dotted paths of fields that only exist in the code schema.
NewFields []string
// Reasons are human-readable breaking differences.
Reasons []string
}
// Classify recursively compares a stored `properties` tree against the one
// generated from code. Both sides must be generic JSON-decoded values, not
// marshaled Go structs. dataFields reports whether the index holds data at or
// below a dotted field path absent from the stored schema (bleve dynamic
// fields); engines without that blind spot pass nil.
func Classify(stored, code map[string]any, dataFields func(path string) bool) Classification {
c := Classification{Verdict: VerdictEqual}
classifyProperties(stored, code, dataFields, "", &c)
if c.Verdict == VerdictEqual && len(c.NewFields) > 0 {
c.Verdict = VerdictAdditive
}
return c
}
func classifyProperties(stored, code map[string]any, dataFields func(string) bool, prefix string, c *Classification) {
for _, k := range slices.Sorted(maps.Keys(stored)) {
path := joinPath(prefix, k)
codeNode, ok := code[k]
if !ok {
c.breaking(fmt.Sprintf("field %s exists in the index but not in the code schema (removed or renamed)", path))
continue
}
classifyNode(stored[k], codeNode, dataFields, path, c)
}
for _, k := range slices.Sorted(maps.Keys(code)) {
if _, ok := stored[k]; ok {
continue
}
path := joinPath(prefix, k)
if dataFields != nil && dataFields(path) {
c.breaking(fmt.Sprintf("field %s is explicitly mapped now, but the index already holds data that was indexed dynamically for it, of an unknown type", path))
continue
}
c.NewFields = append(c.NewFields, leafPaths(code[k], path)...)
}
}
func classifyNode(stored, code any, dataFields func(string) bool, path string, c *Classification) {
storedMap, sOK := stored.(map[string]any)
codeMap, cOK := code.(map[string]any)
if !sOK || !cOK {
if !reflect.DeepEqual(stored, code) {
c.breaking(fmt.Sprintf("field %s changed: index %s, code %s", path, compactJSON(stored), compactJSON(code)))
}
return
}
for _, k := range sortedUnionKeys(storedMap, codeMap) {
if k == "properties" {
continue
}
sv, sHas := storedMap[k]
cv, cHas := codeMap[k]
if sHas && cHas && reflect.DeepEqual(sv, cv) {
continue
}
c.breaking(fmt.Sprintf("field %s: %s changed: index %s, code %s", path, k, optJSON(sv, sHas), optJSON(cv, cHas)))
}
storedProps, _ := storedMap["properties"].(map[string]any)
codeProps, _ := codeMap["properties"].(map[string]any)
if len(storedProps) > 0 || len(codeProps) > 0 {
classifyProperties(storedProps, codeProps, dataFields, path, c)
}
}
// leafPaths lists the dotted paths of all leaf fields at or below node.
func leafPaths(node any, path string) []string {
if nodeMap, ok := node.(map[string]any); ok {
if props, ok := nodeMap["properties"].(map[string]any); ok && len(props) > 0 {
var leaves []string
for _, k := range slices.Sorted(maps.Keys(props)) {
leaves = append(leaves, leafPaths(props[k], path+"."+k)...)
}
return leaves
}
}
return []string{path}
}
func (c *Classification) breaking(reason string) {
c.Verdict = VerdictBreaking
c.Reasons = append(c.Reasons, reason)
}
func joinPath(prefix, k string) string {
if prefix == "" {
return k
}
return prefix + "." + k
}
func sortedUnionKeys(a, b map[string]any) []string {
keys := slices.Collect(maps.Keys(a))
for k := range b {
if _, ok := a[k]; !ok {
keys = append(keys, k)
}
}
slices.Sort(keys)
return keys
}
func optJSON(v any, present bool) string {
if !present {
return "(unset)"
}
return compactJSON(v)
}
func compactJSON(v any) string {
b, err := json.Marshal(v)
if err != nil {
return fmt.Sprintf("%v", v)
}
return string(b)
}
@@ -0,0 +1,128 @@
package mapping
import (
"encoding/json"
"slices"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Classify", func() {
code := `{
"Name": {"type": "keyword"},
"Size": {"type": "long"},
"photo": {"properties": {"cameraMake": {"type": "keyword"}, "cameraModel": {"type": "keyword"}}}
}`
parse := func(s string) map[string]any {
var m map[string]any
Expect(json.Unmarshal([]byte(s), &m)).To(Succeed())
return m
}
hasData := func(fields ...string) func(string) bool {
return func(path string) bool { return slices.Contains(fields, path) }
}
It("classifies identical schemas as equal", func() {
c := Classify(parse(code), parse(code), nil)
Expect(c.Verdict).To(Equal(VerdictEqual))
Expect(c.NewFields).To(BeEmpty())
Expect(c.Reasons).To(BeEmpty())
})
It("classifies a new top-level field as additive", func() {
stored := parse(code)
delete(stored, "Size")
c := Classify(stored, parse(code), nil)
Expect(c.Verdict).To(Equal(VerdictAdditive))
Expect(c.NewFields).To(ConsistOf("Size"))
Expect(c.Reasons).To(BeEmpty())
})
It("classifies a new nested field as additive", func() {
stored := parse(code)
delete(stored["photo"].(map[string]any)["properties"].(map[string]any), "cameraMake")
c := Classify(stored, parse(code), nil)
Expect(c.Verdict).To(Equal(VerdictAdditive))
Expect(c.NewFields).To(ConsistOf("photo.cameraMake"))
})
It("lists every leaf of a new subtree", func() {
stored := parse(code)
delete(stored, "photo")
c := Classify(stored, parse(code), nil)
Expect(c.Verdict).To(Equal(VerdictAdditive))
Expect(c.NewFields).To(ConsistOf("photo.cameraMake", "photo.cameraModel"))
})
It("breaks when a new field already has data in the index", func() {
stored := parse(code)
delete(stored, "Size")
c := Classify(stored, parse(code), hasData("Size"))
Expect(c.Verdict).To(Equal(VerdictBreaking))
Expect(c.Reasons).To(ConsistOf(ContainSubstring("Size")))
})
It("breaks when a new nested field already has data in the index", func() {
stored := parse(code)
delete(stored["photo"].(map[string]any)["properties"].(map[string]any), "cameraMake")
c := Classify(stored, parse(code), hasData("photo.cameraMake"))
Expect(c.Verdict).To(Equal(VerdictBreaking))
Expect(c.Reasons).To(ConsistOf(ContainSubstring("photo.cameraMake")))
})
It("breaks when a new subtree already has data below it", func() {
stored := parse(code)
delete(stored, "photo")
// the callback is consulted with the subtree root
c := Classify(stored, parse(code), hasData("photo"))
Expect(c.Verdict).To(Equal(VerdictBreaking))
Expect(c.Reasons).To(ConsistOf(ContainSubstring("photo")))
})
It("breaks on a changed field definition", func() {
stored := parse(code)
stored["Size"].(map[string]any)["type"] = "keyword"
c := Classify(stored, parse(code), nil)
Expect(c.Verdict).To(Equal(VerdictBreaking))
Expect(c.Reasons).To(ConsistOf(ContainSubstring("Size")))
})
It("breaks on a field that was removed from the code schema", func() {
reduced := parse(code)
delete(reduced, "Size")
c := Classify(parse(code), reduced, nil)
Expect(c.Verdict).To(Equal(VerdictBreaking))
Expect(c.Reasons).To(ConsistOf(ContainSubstring("removed or renamed")))
})
It("breaks on a changed object attribute", func() {
stored := parse(code)
stored["photo"].(map[string]any)["dynamic"] = true
c := Classify(stored, parse(code), nil)
Expect(c.Verdict).To(Equal(VerdictBreaking))
Expect(c.Reasons).To(ConsistOf(ContainSubstring("dynamic")))
})
It("lets breaking win over additive", func() {
stored := parse(code)
delete(stored, "Size")
stored["Name"].(map[string]any)["type"] = "text"
c := Classify(stored, parse(code), nil)
Expect(c.Verdict).To(Equal(VerdictBreaking))
Expect(c.NewFields).To(ConsistOf("Size"))
Expect(c.Reasons).To(ConsistOf(ContainSubstring("Name")))
})
})
+180
View File
@@ -0,0 +1,180 @@
package mapping
import (
"fmt"
"reflect"
"time"
"google.golang.org/protobuf/types/known/timestamppb"
)
// Deserialize builds a *T from bleve's flat hit.Fields map (json-tag keys,
// "parent.child" for nested pointers). Used to rebuild a search Resource from a
// hit. Fail-soft: an unparseable field stays at its zero value.
func Deserialize[T any](fields map[string]any) *T {
t := reflect.TypeFor[T]()
if t.Kind() != reflect.Struct {
panic(fmt.Sprintf("mapping: Deserialize requires a struct type, got %v", t))
}
out := reflect.New(t)
fillStruct(out.Elem(), fields, "", setValue)
return out.Interface().(*T)
}
// DeserializeAt is Deserialize scoped to a dotted prefix, used to rebuild one
// search-result facet (e.g. "audio"). Returns nil when nothing matched, so the
// caller can leave the enclosing pointer nil.
func DeserializeAt[T any](fields map[string]any, prefix string) *T {
t := reflect.TypeFor[T]()
if t.Kind() != reflect.Struct {
panic(fmt.Sprintf("mapping: DeserializeAt requires a struct type, got %v", t))
}
out := reflect.New(t)
if !fillStruct(out.Elem(), fields, prefix, setValue) {
return nil
}
return out.Interface().(*T)
}
// fillStruct walks v's exported fields, reading values from the flat fields map
// (json-tag keys, "parent.child" for nested pointers). setLeaf converts each raw
// value into a leaf, which is what lets the any-valued and string-valued
// deserializers share one walker. Returns true if any leaf was populated.
func fillStruct[V any](v reflect.Value, fields map[string]V, prefix string, setLeaf func(reflect.Value, V) error) bool {
t := v.Type()
touched := false
for i := 0; i < t.NumField(); i++ {
fi := resolveField(t.Field(i))
if fi.Skip {
continue
}
fv := v.Field(i)
if fi.Embedded {
// Embedded *struct: allocate, recurse, keep the pointer only if set.
if fv.Kind() == reflect.Ptr {
if fv.Type().Elem().Kind() != reflect.Struct || !fv.CanSet() {
continue
}
alloc := reflect.New(fv.Type().Elem())
if fillStruct(alloc.Elem(), fields, prefix, setLeaf) {
fv.Set(alloc)
touched = true
}
continue
}
if fv.Kind() == reflect.Struct && fillStruct(fv, fields, prefix, setLeaf) {
touched = true
}
continue
}
key := fi.Name
if prefix != "" {
key = prefix + "." + fi.Name
}
// Pointer to a nested (non-time) struct: recurse, keep the pointer only
// if a field was populated.
if fv.Kind() == reflect.Ptr {
if elem := fv.Type().Elem(); elem.Kind() == reflect.Struct && elem != timeType && elem != timestampType {
alloc := reflect.New(elem)
if fillStruct(alloc.Elem(), fields, key, setLeaf) {
fv.Set(alloc)
touched = true
}
continue
}
}
// Value nested struct (e.g. a tagged embedded struct): recurse under key.
if fv.Kind() == reflect.Struct && fv.Type() != timeType && fv.Type() != timestampType {
if fillStruct(fv, fields, key, setLeaf) {
touched = true
}
continue
}
if raw, ok := fields[key]; ok && setLeaf(fv, raw) == nil {
touched = true
}
}
return touched
}
// setValue writes raw (an any value from a bleve hit) into v, converting to the
// field's type. Returns an error on nil or a type mismatch.
func setValue(v reflect.Value, raw any) error {
if v.Kind() == reflect.Ptr {
alloc := reflect.New(v.Type().Elem())
if err := setValue(alloc.Elem(), raw); err != nil {
return err
}
v.Set(alloc)
return nil
}
if v.Type() == timeType || v.Type() == timestampType {
t, ok := parseTime(raw)
if !ok {
return fmt.Errorf("not an RFC3339 time: %v", raw)
}
setParsedTime(v, t)
return nil
}
if v.Kind() == reflect.Slice {
return setSlice(v, raw)
}
rv := reflect.ValueOf(raw)
if !rv.IsValid() {
return fmt.Errorf("nil value for %s", v.Type())
}
if !rv.Type().ConvertibleTo(v.Type()) {
return fmt.Errorf("cannot convert %s to %s", rv.Type(), v.Type())
}
v.Set(rv.Convert(v.Type()))
return nil
}
func setSlice(v reflect.Value, raw any) error {
items, ok := raw.([]any)
if !ok {
// bleve unwraps single-element slices; re-wrap here.
items = []any{raw}
}
// Compact in place with a single MakeSlice: unparseable elements are
// dropped, Slice(0, j) trims the tail.
out := reflect.MakeSlice(v.Type(), len(items), len(items))
j := 0
for _, item := range items {
if setValue(out.Index(j), item) == nil {
j++
}
}
if j == 0 {
return fmt.Errorf("no slice elements set from %T", raw)
}
v.Set(out.Slice(0, j))
return nil
}
func parseTime(raw any) (time.Time, bool) {
s, ok := raw.(string)
if !ok {
return time.Time{}, false
}
t, err := time.Parse(time.RFC3339, s)
if err != nil {
return time.Time{}, false
}
return t, true
}
// setParsedTime writes t into v, which must be a time.Time or
// timestamppb.Timestamp field.
func setParsedTime(v reflect.Value, t time.Time) {
if v.Type() == timestampType {
v.Set(reflect.ValueOf(*timestamppb.New(t)))
return
}
v.Set(reflect.ValueOf(t))
}
@@ -0,0 +1,82 @@
package mapping
import (
"fmt"
"reflect"
"strconv"
"strings"
"time"
)
// DeserializeStringsAt is DeserializeAt for a string-valued map (e.g. CS3
// ArbitraryMetadata), parsing each string into the field's Go type via strconv/
// time.Parse. Used to build a graph DriveItem facet. Returns nil when nothing
// under the prefix matched.
func DeserializeStringsAt[T any](fields map[string]string, prefix string) *T {
t := reflect.TypeFor[T]()
if t.Kind() != reflect.Struct {
panic(fmt.Sprintf("mapping: DeserializeStringsAt requires a struct type, got %v", t))
}
out := reflect.New(t)
// Callers pass a flat-key prefix with a trailing dot (e.g.
// "libre.graph.audio."); fillStruct joins segments with ".", so drop it.
if !fillStruct(out.Elem(), fields, strings.TrimSuffix(prefix, "."), setValueFromString) {
return nil
}
return out.Interface().(*T)
}
// setValueFromString parses the string raw into v's Go type via strconv/
// time.Parse, returning a descriptive error on failure.
func setValueFromString(v reflect.Value, raw string) error {
if v.Kind() == reflect.Ptr {
alloc := reflect.New(v.Type().Elem())
if err := setValueFromString(alloc.Elem(), raw); err != nil {
return err
}
v.Set(alloc)
return nil
}
if v.Type() == timeType || v.Type() == timestampType {
t, err := time.Parse(time.RFC3339, raw)
if err != nil {
return fmt.Errorf("parse time %q: %w", raw, err)
}
setParsedTime(v, t)
return nil
}
switch v.Kind() {
case reflect.String:
v.SetString(raw)
return nil
case reflect.Bool:
b, err := strconv.ParseBool(raw)
if err != nil {
return fmt.Errorf("parse bool %q: %w", raw, err)
}
v.SetBool(b)
return nil
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
n, err := strconv.ParseInt(raw, 10, v.Type().Bits())
if err != nil {
return fmt.Errorf("parse int %q: %w", raw, err)
}
v.SetInt(n)
return nil
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
n, err := strconv.ParseUint(raw, 10, v.Type().Bits())
if err != nil {
return fmt.Errorf("parse uint %q: %w", raw, err)
}
v.SetUint(n)
return nil
case reflect.Float32, reflect.Float64:
f, err := strconv.ParseFloat(raw, v.Type().Bits())
if err != nil {
return fmt.Errorf("parse float %q: %w", raw, err)
}
v.SetFloat(f)
return nil
}
return fmt.Errorf("unsupported target kind %s", v.Kind())
}
@@ -0,0 +1,98 @@
package mapping
import (
"reflect"
"time"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"google.golang.org/protobuf/types/known/timestamppb"
)
type stringFacet struct {
Artist *string `json:"artist,omitempty"`
Year *int32 `json:"year,omitempty"`
Duration *int64 `json:"duration,omitempty"`
Rating *float64 `json:"rating,omitempty"`
Explicit *bool `json:"explicit,omitempty"`
Taken *time.Time `json:"takenDateTime,omitempty"`
}
var _ = Describe("DeserializeStringsAt", func() {
It("errors on an unsupported target kind", func() {
v := reflect.New(reflect.TypeFor[[]int]()).Elem() // settable slice
Expect(setValueFromString(v, "x")).To(HaveOccurred(), "expected error for unsupported target kind (slice)")
})
It("parses basic types", func() {
r := DeserializeStringsAt[stringFacet](map[string]string{
"libre.graph.audio.artist": "Queen",
"libre.graph.audio.year": "1975",
"libre.graph.audio.duration": "354000",
"libre.graph.audio.rating": "4.9",
"libre.graph.audio.explicit": "true",
"libre.graph.audio.takenDateTime": "2024-01-02T03:04:05Z",
}, "libre.graph.audio.")
Expect(r).ToNot(BeNil())
Expect(r.Artist).ToNot(BeNil())
Expect(*r.Artist).To(Equal("Queen"))
Expect(r.Year).ToNot(BeNil())
Expect(*r.Year).To(Equal(int32(1975)))
Expect(r.Duration).ToNot(BeNil())
Expect(*r.Duration).To(Equal(int64(354000)))
Expect(r.Rating).ToNot(BeNil())
Expect(*r.Rating).To(Equal(4.9))
Expect(r.Explicit).ToNot(BeNil())
Expect(*r.Explicit).To(BeTrue())
Expect(r.Taken).ToNot(BeNil())
Expect(r.Taken.Equal(time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC))).To(BeTrue(), "Taken: %#v", r.Taken)
})
It("returns nil when nothing matches the prefix", func() {
r := DeserializeStringsAt[stringFacet](map[string]string{
"libre.graph.image.width": "1200",
}, "libre.graph.audio.")
Expect(r).To(BeNil())
})
It("parses into a timestamppb.Timestamp", func() {
type photoFacet struct {
Taken *timestamppb.Timestamp `json:"takenDateTime,omitempty"`
}
r := DeserializeStringsAt[photoFacet](map[string]string{
"libre.graph.photo.takenDateTime": "2024-05-06T07:08:09Z",
}, "libre.graph.photo.")
Expect(r).ToNot(BeNil())
Expect(r.Taken).ToNot(BeNil())
want := time.Date(2024, 5, 6, 7, 8, 9, 0, time.UTC)
Expect(r.Taken.AsTime().Equal(want)).To(BeTrue(), "Taken: got %v, want %v", r.Taken.AsTime(), want)
})
It("is fail-soft on malformed fields", func() {
// A single malformed field (year is unparseable as int) must not drop
// the whole facet. The bad field stays at zero value, the rest of the
// facet still populates. Mirrors the bleve-hit Deserialize behavior.
r := DeserializeStringsAt[stringFacet](map[string]string{
"libre.graph.audio.artist": "Iron Maiden",
"libre.graph.audio.year": "not-a-number",
"libre.graph.audio.duration": "354000",
"libre.graph.audio.explicit": "not-a-bool",
"libre.graph.audio.rating": "4.9",
}, "libre.graph.audio.")
Expect(r).ToNot(BeNil())
Expect(r.Artist).ToNot(BeNil())
Expect(*r.Artist).To(Equal("Iron Maiden"), "Artist should still be populated")
Expect(r.Duration).ToNot(BeNil())
Expect(*r.Duration).To(Equal(int64(354000)), "Duration should still be populated")
Expect(r.Rating).ToNot(BeNil())
Expect(*r.Rating).To(Equal(4.9), "Rating should still be populated")
Expect(r.Year).To(BeNil(), "Year should stay nil for bad int")
Expect(r.Explicit).To(BeNil(), "Explicit should stay nil for bad bool")
})
It("panics for a non-struct T", func() {
Expect(func() {
DeserializeStringsAt[int](nil, "")
}).To(Panic())
})
})
@@ -0,0 +1,139 @@
package mapping
import (
"time"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"google.golang.org/protobuf/types/known/timestamppb"
)
type Leaf struct {
Name string `json:"Name"`
Size uint64 `json:"Size"`
Deleted bool `json:"Deleted"`
Tags []string `json:"Tags"`
Favorites []string `json:"Favorites"`
}
type audio struct {
Artist *string `json:"artist,omitempty"`
Year *int32 `json:"year,omitempty"`
}
type photo struct {
Taken *timestamppb.Timestamp `json:"takenDateTime,omitempty"`
Mtime *time.Time `json:"mtime,omitempty"`
}
type embedded struct {
Leaf
Audio *audio `json:"audio,omitempty"`
Photo *photo `json:"photo,omitempty"`
}
// taggedEmbedded embeds Leaf with a json tag, so encoding/json nests it under
// "leaf" rather than flattening it onto the parent.
type taggedEmbedded struct {
Leaf `json:"leaf"`
Top string `json:"top"`
}
var _ = Describe("Deserialize", func() {
It("panics for a non-struct type at a prefix", func() {
Expect(func() {
_ = DeserializeAt[int](map[string]any{}, "")
}).To(Panic())
})
It("panics for a non-struct T", func() {
Expect(func() {
Deserialize[int](nil)
}).To(Panic())
})
It("deserializes leaf fields", func() {
r := Deserialize[Leaf](map[string]any{
"Name": "n",
"Size": float64(42),
"Deleted": true,
})
Expect(r.Name).To(Equal("n"))
Expect(r.Size).To(Equal(uint64(42)))
Expect(r.Deleted).To(BeTrue())
})
It("coerces a scalar into a slice field", func() {
r := Deserialize[Leaf](map[string]any{
"Tags": "single",
"Favorites": []any{"a", "b"},
})
Expect(r.Tags).To(Equal([]string{"single"}))
Expect(r.Favorites).To(Equal([]string{"a", "b"}))
})
It("parses timestamps", func() {
r := Deserialize[embedded](map[string]any{
"photo.takenDateTime": "2024-01-02T03:04:05Z",
"photo.mtime": "2024-05-06T07:08:09Z",
})
Expect(r.Photo).ToNot(BeNil())
Expect(r.Photo.Taken).ToNot(BeNil())
expected := time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC)
Expect(r.Photo.Taken.AsTime().Equal(expected)).To(BeTrue(), "Taken: got %v, want %v", r.Photo.Taken.AsTime(), expected)
Expect(r.Photo.Mtime).ToNot(BeNil())
Expect(r.Photo.Mtime.Equal(time.Date(2024, 5, 6, 7, 8, 9, 0, time.UTC))).To(BeTrue(), "Mtime: %v", r.Photo.Mtime)
})
It("is fail-soft on malformed values", func() {
// Malformed values (type mismatch, unparseable time) leave the
// affected field at its zero value instead of dropping the whole
// record. Matches the pre-refactor getFieldValue behavior so
// matchToResource never returns nil on a corrupted hit.
r := Deserialize[embedded](map[string]any{
"Name": "n",
"Size": "not-a-number", // wrong type
"Deleted": true,
"photo.takenDateTime": "not-an-rfc3339-time",
"photo.mtime": "2024-05-06T07:08:09Z",
})
Expect(r).ToNot(BeNil())
Expect(r.Name).To(Equal("n"))
Expect(r.Size).To(Equal(uint64(0)), "Size should stay zero on mismatch")
Expect(r.Deleted).To(BeTrue())
Expect(r.Photo).ToNot(BeNil(), "Photo should be populated because Mtime parsed ok")
Expect(r.Photo.Taken).To(BeNil(), "Taken should stay nil for unparseable time")
Expect(r.Photo.Mtime).ToNot(BeNil(), "Mtime should be parsed")
})
It("returns nil when nothing matches the prefix", func() {
r := DeserializeAt[audio](map[string]any{"Name": "n"}, "audio")
Expect(r).To(BeNil())
})
It("returns a value when the prefix matches", func() {
r := DeserializeAt[audio](map[string]any{
"audio.artist": "A",
"audio.year": float64(2024), // setValue: pointer + numeric convert
}, "audio")
Expect(r).ToNot(BeNil())
Expect(r.Artist).ToNot(BeNil())
Expect(*r.Artist).To(Equal("A"))
Expect(r.Year).ToNot(BeNil())
Expect(*r.Year).To(Equal(int32(2024)))
})
It("nests a tagged embedded struct instead of flattening it", func() {
// matches encoding/json: a tagged embedded struct is read under its tag.
r := Deserialize[taggedEmbedded](map[string]any{
"leaf.Name": "n",
"top": "t",
})
Expect(r.Leaf.Name).To(Equal("n"))
Expect(r.Top).To(Equal("t"))
// the flattened top-level key must NOT populate the nested field.
flat := Deserialize[taggedEmbedded](map[string]any{"Name": "flat"})
Expect(flat.Leaf.Name).To(BeEmpty())
})
})
+36
View File
@@ -0,0 +1,36 @@
package mapping
import (
"reflect"
"strings"
)
// FieldNameIndex maps a lowercased field path to the real field name for every
// field of t, recursing into nested facets (photo.cameraMake, ...). Names come
// from json tags, so it is backend-neutral; the query layer resolves KQL keys
// case-insensitively against it.
func FieldNameIndex(t reflect.Type, overrides map[string]FieldOpts) map[string]string {
out := map[string]string{}
var walk func(t reflect.Type, prefix string)
walk = func(t reflect.Type, prefix string) {
_ = walkFields(t, func(fi fieldInfo) error {
name := fi.Name
if prefix != "" {
name = prefix + "." + fi.Name
}
out[strings.ToLower(name)] = name
fieldType := overrides[name].Type
if fieldType == "" {
fieldType = inferType(fi.GoField.Type)
}
// recurse into nested facets; time.Time is a struct too but a leaf.
if sub := structType(fi.GoField.Type); sub != nil && fieldType != TypeDatetime {
walk(sub, name)
}
return nil
})
}
walk(t, "")
return out
}
@@ -0,0 +1,101 @@
package mapping_test
import (
"reflect"
"strings"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/opencloud-eu/opencloud/services/search/pkg/mapping"
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
)
func resourceFieldIndex() map[string]string {
return mapping.FieldNameIndex(
reflect.TypeFor[search.Resource](),
search.Resource{}.SearchFieldOverrides(),
)
}
// resolve looks up the lowercased key in the index, falling back to the key.
func resolve(idx map[string]string, key string) string {
if v, ok := idx[strings.ToLower(key)]; ok {
return v
}
return key
}
// NestInner is embedded with a json tag below, so it must nest, not flatten.
type NestInner struct {
A string `json:"A"`
}
type taggedOuter struct {
NestInner `json:"inner"`
Top string `json:"top"`
}
var _ = Describe("FieldNameIndex", func() {
It("resolves top-level fields case-insensitively", func() {
idx := resourceFieldIndex()
for in, want := range map[string]string{
"rootid": "RootID", "ROOTID": "RootID", "RootID": "RootID",
"name": "Name", "NAME": "Name",
"mimetype": "MimeType", "MimeType": "MimeType",
"tags": "Tags", "favorites": "Favorites",
"mtime": "Mtime", "parentid": "ParentID", "id": "ID",
} {
Expect(resolve(idx, in)).To(Equal(want), "resolve(%q)", in)
}
})
// Facet sub-fields are lowerCamelCase in the index (from libregraph json
// tags); the derived index resolves them case-insensitively.
It("resolves facet sub-fields case-insensitively", func() {
idx := resourceFieldIndex()
for in, want := range map[string]string{
// case-insensitive: same field, different casings
"photo.cameramake": "photo.cameraMake",
"photo.CAMERAMAKE": "photo.cameraMake",
// a representative sub-field across each facet
"photo.takendatetime": "photo.takenDateTime",
"audio.artist": "audio.artist",
"audio.albumartist": "audio.albumArtist",
"image.width": "image.width",
"location.latitude": "location.latitude",
} {
Expect(resolve(idx, in)).To(Equal(want), "resolve(%q)", in)
}
})
It("passes unknown keys through unchanged", func() {
idx := resourceFieldIndex()
Expect(resolve(idx, "nope.field")).To(Equal("nope.field"))
Expect(resolve(idx, "custom")).To(Equal("custom"))
})
// All top-level fields are covered from one derived source, so both
// backends resolve them the same way.
It("covers all top-level fields", func() {
idx := resourceFieldIndex()
for in, want := range map[string]string{
"rootid": "RootID", "path": "Path", "id": "ID", "name": "Name",
"size": "Size", "mtime": "Mtime", "type": "Type",
"content": "Content", "hidden": "Hidden", "tags": "Tags",
"favorites": "Favorites",
} {
Expect(resolve(idx, in)).To(Equal(want), "derived should cover %q", in)
}
})
// A json-tagged embedded struct nests under its tag in the derived index
// too (walkFields must match encoding/json), so its fields are "inner.A",
// not "A".
It("nests json-tagged embedded structs", func() {
idx := mapping.FieldNameIndex(reflect.TypeFor[taggedOuter](), nil)
Expect(resolve(idx, "inner.a")).To(Equal("inner.A")) // nested under the tag
Expect(resolve(idx, "top")).To(Equal("top"))
Expect(resolve(idx, "a")).To(Equal("a")) // not flattened: bare "a" is not a key
})
})
@@ -0,0 +1,106 @@
package mapping
import (
"errors"
"reflect"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// The walker is shared by both deserializers, so its structural behavior
// (flattening embedded structs, recursing into nested pointers, joining the
// prefix, keeping a pointer only when a field was set, fail-soft skipping) is
// tested here once against a trivial string setter instead of twice through
// Deserialize and DeserializeStringsAt.
// FsEmbVal/FsEmbPtr are exported so their embedded field is exported; an
// unexported embedded type would be skipped by resolveField.
type FsEmbVal struct {
EV string `json:"ev"`
}
type FsEmbPtr struct {
EP string `json:"ep"`
}
type fsNested struct {
N string `json:"n"`
}
type fsRoot struct {
FsEmbVal // embedded value struct: fields promoted
*FsEmbPtr // embedded pointer struct: allocated on demand
Leaf string `json:"leaf"`
Nested *fsNested `json:"nested"` // nested pointer: recursed under "nested."
}
var errBadLeaf = errors.New("bad leaf")
// fsSet writes raw into a string field; the "BAD" sentinel simulates a parse
// failure so the fail-soft path can be exercised.
func fsSet(v reflect.Value, raw string) error {
if raw == "BAD" {
return errBadLeaf
}
v.SetString(raw)
return nil
}
var _ = Describe("fillStruct", func() {
fill := func(fields map[string]string, prefix string) (fsRoot, bool) {
var root fsRoot
touched := fillStruct(reflect.ValueOf(&root).Elem(), fields, prefix, fsSet)
return root, touched
}
It("flattens embedded and recurses nested", func() {
root, touched := fill(map[string]string{
"leaf": "L",
"ev": "EV",
"ep": "EP",
"nested.n": "N",
}, "")
Expect(touched).To(BeTrue())
Expect(root.Leaf).To(Equal("L"))
Expect(root.EV).To(Equal("EV"), "embedded value not promoted")
Expect(root.FsEmbPtr).ToNot(BeNil(), "embedded pointer not allocated")
Expect(root.EP).To(Equal("EP"))
Expect(root.Nested).ToNot(BeNil(), "nested pointer not populated")
Expect(root.Nested.N).To(Equal("N"))
})
It("leaves touched false and pointers nil when nothing matches", func() {
root, touched := fill(map[string]string{"other": "x"}, "")
Expect(touched).To(BeFalse())
Expect(root.Nested).To(BeNil(), "Nested should stay nil")
Expect(root.FsEmbPtr).To(BeNil(), "embedded pointer should stay nil")
})
It("joins the prefix arg with the field name", func() {
root, touched := fill(map[string]string{
"pre.leaf": "L",
"pre.nested.n": "N",
}, "pre")
Expect(touched).To(BeTrue())
Expect(root.Leaf).To(Equal("L"))
Expect(root.Nested).ToNot(BeNil())
Expect(root.Nested.N).To(Equal("N"))
})
It("is fail-soft: errored leaf stays zero, walk continues", func() {
root, touched := fill(map[string]string{
"leaf": "BAD",
"ev": "EV",
}, "")
Expect(touched).To(BeTrue(), "expected touched because ev was set")
Expect(root.Leaf).To(BeEmpty(), "errored leaf should stay zero")
Expect(root.EV).To(Equal("EV"), "walk should continue past the error")
})
It("drops the embedded pointer when its only field errors", func() {
root, touched := fill(map[string]string{"ep": "BAD"}, "")
Expect(touched).To(BeFalse())
Expect(root.FsEmbPtr).To(BeNil(), "embedded pointer should stay nil on error")
})
})
+51
View File
@@ -0,0 +1,51 @@
package mapping
import "strings"
// GeopointSuffix is appended to a field's name to produce the sibling key
// that carries the geo_point / bleve-geopoint representation of the
// original facet. For example, a libregraph "location" object with
// longitude / latitude / altitude is preserved as-is under "location" (for
// data retrieval and numeric queries) while "location_geopoint" carries
// the {lat, lon} form the geo indices understand.
const GeopointSuffix = "_geopoint"
// addGeopointSiblings walks the overrides; for each TypeGeopoint entry at
// a dotted path (e.g. "location" or "journey.start") it writes a sibling
// under the suffixed key with the {lat, lon} form both bleve's
// ExtractGeoPoint and OpenSearch's geo_point parser accept. The original
// facet object stays untouched so downstream code still sees the full
// libregraph shape (including altitude).
func addGeopointSiblings(m map[string]any, overrides map[string]FieldOpts) {
for key, opts := range overrides {
if opts.Type == TypeGeopoint {
addGeopointSibling(m, key)
}
}
}
// addGeopointSibling resolves dottedPath within m and, if the target is a
// libregraph-shaped geo object (with numeric "longitude" and "latitude"),
// writes the `{lat, lon}` sibling at the same level under the suffixed key.
func addGeopointSibling(m map[string]any, dottedPath string) {
parts := strings.Split(dottedPath, ".")
parent := m
for _, p := range parts[:len(parts)-1] {
next, ok := parent[p].(map[string]any)
if !ok {
return
}
parent = next
}
leaf := parts[len(parts)-1]
obj, ok := parent[leaf].(map[string]any)
if !ok {
return
}
lon, hasLon := obj["longitude"].(float64)
lat, hasLat := obj["latitude"].(float64)
if !hasLon || !hasLat {
return
}
parent[leaf+GeopointSuffix] = map[string]any{"lat": lat, "lon": lon}
}
+143
View File
@@ -0,0 +1,143 @@
package mapping
import (
"reflect"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("BuildMapping geopoint errors", func() {
// build-mapping error paths: an override that doesn't fit the Go field.
It("errors for geopoint on a non-struct field on both backends", func() {
type doc struct {
Name string `json:"name"`
}
// Geopoint on a non-struct field must error on both backends.
_, err := BleveBuildMapping(reflect.TypeFor[doc](), map[string]FieldOpts{"name": {Type: TypeGeopoint}})
Expect(err).To(HaveOccurred(), "bleve: expected error for geopoint on string field")
_, err = OpenSearchBuildMapping(reflect.TypeFor[doc](), map[string]FieldOpts{"name": {Type: TypeGeopoint}})
Expect(err).To(HaveOccurred(), "opensearch: expected error for geopoint on string field")
})
})
var _ = Describe("addGeopointSibling", func() {
It("bails without panicking when the intermediate is missing", func() {
m := map[string]any{"journey": "not-a-map"}
addGeopointSibling(m, "journey.start") // must bail, not panic
Expect(m).ToNot(HaveKey("journey.start" + GeopointSuffix))
})
})
var _ = Describe("PrepareForIndex geopoint", func() {
It("adds a geopoint sibling", func() {
type geoDoc struct {
Location *struct {
Longitude *float64 `json:"longitude,omitempty"`
Latitude *float64 `json:"latitude,omitempty"`
Altitude *float64 `json:"altitude,omitempty"`
} `json:"location,omitempty"`
}
lon, lat, alt := 11.1, 49.4, 1047.7
doc := geoDoc{Location: &struct {
Longitude *float64 `json:"longitude,omitempty"`
Latitude *float64 `json:"latitude,omitempty"`
Altitude *float64 `json:"altitude,omitempty"`
}{Longitude: &lon, Latitude: &lat, Altitude: &alt}}
m, err := PrepareForIndex(doc, map[string]FieldOpts{
"location": {Type: TypeGeopoint},
})
Expect(err).ToNot(HaveOccurred())
// Original location object stays untouched (full libregraph shape).
orig, ok := m["location"].(map[string]any)
Expect(ok).To(BeTrue(), "expected location object preserved, got %T", m["location"])
Expect(orig["longitude"]).To(Equal(lon))
Expect(orig["latitude"]).To(Equal(lat))
Expect(orig["altitude"]).To(Equal(alt))
// Sibling location_geopoint has {lat, lon} for the geo indices.
gp, ok := m["location"+GeopointSuffix].(map[string]any)
Expect(ok).To(BeTrue(), "expected location_geopoint sibling, got %T", m["location"+GeopointSuffix])
Expect(gp["lat"]).To(Equal(lat))
Expect(gp["lon"]).To(Equal(lon))
})
It("skips incomplete geopoints", func() {
type geoDoc struct {
Location *struct {
Altitude *float64 `json:"altitude,omitempty"`
} `json:"location,omitempty"`
}
alt := 100.0
doc := geoDoc{Location: &struct {
Altitude *float64 `json:"altitude,omitempty"`
}{Altitude: &alt}}
m, err := PrepareForIndex(doc, map[string]FieldOpts{
"location": {Type: TypeGeopoint},
})
Expect(err).ToNot(HaveOccurred())
// Original stays (altitude alone is still useful metadata).
Expect(m).To(HaveKey("location"), "location should still be present when only altitude is set")
// No sibling without both lon and lat.
Expect(m).ToNot(HaveKey("location"+GeopointSuffix), "no sibling expected")
})
It("writes no sibling without the geopoint override", func() {
type geoDoc struct {
Location *struct {
Longitude *float64 `json:"longitude,omitempty"`
Latitude *float64 `json:"latitude,omitempty"`
} `json:"location,omitempty"`
}
lon, lat := 11.1, 49.4
doc := geoDoc{Location: &struct {
Longitude *float64 `json:"longitude,omitempty"`
Latitude *float64 `json:"latitude,omitempty"`
}{Longitude: &lon, Latitude: &lat}}
m, err := PrepareForIndex(doc, nil)
Expect(err).ToNot(HaveOccurred())
Expect(m).ToNot(HaveKey("location"+GeopointSuffix), "no sibling expected without override")
})
It("handles nested geopoints via the dotted-path walker", func() {
// journey.start and journey.end - two geopoints in the same facet,
// demonstrating the dotted-path walker.
type geo struct {
Longitude *float64 `json:"longitude,omitempty"`
Latitude *float64 `json:"latitude,omitempty"`
}
type journey struct {
Start *geo `json:"start,omitempty"`
End *geo `json:"end,omitempty"`
}
type doc struct {
Journey *journey `json:"journey,omitempty"`
}
slon, slat := 11.0, 49.0
elon, elat := 13.4, 52.5
d := doc{Journey: &journey{
Start: &geo{Longitude: &slon, Latitude: &slat},
End: &geo{Longitude: &elon, Latitude: &elat},
}}
m, err := PrepareForIndex(d, map[string]FieldOpts{
"journey.start": {Type: TypeGeopoint},
"journey.end": {Type: TypeGeopoint},
})
Expect(err).ToNot(HaveOccurred())
j, ok := m["journey"].(map[string]any)
Expect(ok).To(BeTrue(), "journey not an object: %T", m["journey"])
startGp, ok := j["start"+GeopointSuffix].(map[string]any)
Expect(ok).To(BeTrue(), "journey.start sibling: %#v", j["start"+GeopointSuffix])
Expect(startGp["lat"]).To(Equal(slat))
Expect(startGp["lon"]).To(Equal(slon))
endGp, ok := j["end"+GeopointSuffix].(map[string]any)
Expect(ok).To(BeTrue(), "journey.end sibling: %#v", j["end"+GeopointSuffix])
Expect(endGp["lat"]).To(Equal(elat))
Expect(endGp["lon"]).To(Equal(elon))
})
})
+121
View File
@@ -0,0 +1,121 @@
package mapping
import (
"reflect"
"strings"
"time"
"google.golang.org/protobuf/types/known/timestamppb"
)
var (
timeType = reflect.TypeFor[time.Time]()
timestampType = reflect.TypeFor[timestamppb.Timestamp]()
)
// deref unwraps pointer and slice types to their element type.
func deref(t reflect.Type) reflect.Type {
for t.Kind() == reflect.Ptr || t.Kind() == reflect.Slice {
t = t.Elem()
}
return t
}
// inferType returns the mapping type for a Go type. Pointers and slices are
// unwrapped to their element type. time.Time and timestamppb.Timestamp become
// datetime; other structs become object.
func inferType(t reflect.Type) string {
t = deref(t)
switch t.Kind() {
case reflect.String:
return TypeKeyword
case reflect.Bool:
return TypeBool
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64,
reflect.Float32, reflect.Float64:
return TypeNumeric
case reflect.Struct:
if t == timeType || t == timestampType {
return TypeDatetime
}
return TypeObject
}
return ""
}
// fieldInfo is the resolved metadata for one struct field.
type fieldInfo struct {
Name string
GoField reflect.StructField
Skip bool
Embedded bool
}
// resolveField resolves a struct field's json-tag name and skip/embed state.
func resolveField(sf reflect.StructField) fieldInfo {
if !sf.IsExported() {
return fieldInfo{Skip: true}
}
name := sf.Name
named := false
tag := sf.Tag.Get("json")
if tag != "" {
first, _, _ := strings.Cut(tag, ",")
if first == "-" {
return fieldInfo{Skip: true}
}
if first != "" {
name = first
named = true
}
}
return fieldInfo{
Name: name,
GoField: sf,
// An anonymous field is embedded (flattened onto the parent) only when it
// has no json tag name, matching encoding/json: a tag name nests it as a
// regular field instead.
Embedded: sf.Anonymous && !named,
}
}
// walkFields visits exported leaf fields of t, flattening embedded structs
// onto the enclosing level. It returns the first error returned by fn.
func walkFields(t reflect.Type, fn func(fi fieldInfo) error) error {
for t.Kind() == reflect.Ptr {
t = t.Elem()
}
if t.Kind() != reflect.Struct {
return nil
}
for i := 0; i < t.NumField(); i++ {
fi := resolveField(t.Field(i))
if fi.Skip {
continue
}
if fi.Embedded {
if err := walkFields(fi.GoField.Type, fn); err != nil {
return err
}
continue
}
if err := fn(fi); err != nil {
return err
}
}
return nil
}
// structType returns the underlying struct type, unwrapping pointers and
// slices. Returns nil when t is not a walkable struct (e.g. time.Time).
func structType(t reflect.Type) reflect.Type {
t = deref(t)
if t.Kind() != reflect.Struct {
return nil
}
if t == timeType || t == timestampType {
return nil
}
return t
}
+100
View File
@@ -0,0 +1,100 @@
package mapping
import (
"reflect"
"time"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"google.golang.org/protobuf/types/known/timestamppb"
)
var _ = Describe("inferType", func() {
It("returns empty for unsupported kinds", func() {
Expect(inferType(reflect.TypeFor[map[string]int]())).To(BeEmpty(), "map")
Expect(inferType(reflect.TypeFor[chan int]())).To(BeEmpty(), "chan")
})
DescribeTable("infers the mapping type",
func(in any, want string) {
Expect(inferType(reflect.TypeOf(in))).To(Equal(want))
},
Entry("string", "", TypeKeyword),
Entry("*string", (*string)(nil), TypeKeyword),
Entry("[]string", []string(nil), TypeKeyword),
Entry("bool", false, TypeBool),
Entry("int", int(0), TypeNumeric),
Entry("int64", int64(0), TypeNumeric),
Entry("uint64", uint64(0), TypeNumeric),
Entry("float64", float64(0), TypeNumeric),
Entry("time.Time", time.Time{}, TypeDatetime),
Entry("*time.Time", (*time.Time)(nil), TypeDatetime),
Entry("*timestamppb.Timestamp", (*timestamppb.Timestamp)(nil), TypeDatetime),
Entry("struct", struct{ X int }{}, TypeObject),
Entry("*struct", (*struct{ X int })(nil), TypeObject),
)
})
var _ = Describe("resolveField", func() {
type S struct {
Exported string `json:"exp"`
Renamed string `json:"renamed,omitempty"`
NoTag string
OmitOnly string `json:",omitempty"`
Skipped string `json:"-"`
unexported string //nolint:unused
}
st := reflect.TypeFor[S]()
DescribeTable("resolves the field name and skip flag",
func(fieldIdx int, wantName string, wantSkip bool) {
fi := resolveField(st.Field(fieldIdx))
Expect(fi.Skip).To(Equal(wantSkip), "field %d skip", fieldIdx)
if !wantSkip {
Expect(fi.Name).To(Equal(wantName), "field %d name", fieldIdx)
}
},
Entry("exported json tag", 0, "exp", false),
Entry("renamed with omitempty", 1, "renamed", false),
Entry("no tag", 2, "NoTag", false),
Entry("omitempty only", 3, "OmitOnly", false),
Entry("json:- skipped", 4, "", true),
Entry("unexported skipped", 5, "", true),
)
})
var _ = Describe("walkFields", func() {
It("flattens embedded structs", func() {
type Inner struct {
A string `json:"a"`
B int `json:"b"`
}
type Outer struct {
Inner
C bool `json:"c"`
}
var names []string
err := walkFields(reflect.TypeFor[Outer](), func(fi fieldInfo) error {
names = append(names, fi.Name)
return nil
})
Expect(err).ToNot(HaveOccurred())
Expect(names).To(Equal([]string{"a", "b", "c"}))
})
})
var _ = Describe("structType", func() {
type S struct{ X int }
DescribeTable("resolves struct-ish types",
func(in reflect.Type, wantNil bool) {
got := structType(in)
Expect(got == nil).To(Equal(wantNil))
},
Entry("struct", reflect.TypeFor[S](), false),
Entry("*struct", reflect.TypeFor[*S](), false),
Entry("[]struct", reflect.TypeFor[[]S](), false),
Entry("time.Time", reflect.TypeFor[time.Time](), true),
Entry("string", reflect.TypeFor[string](), true),
)
})
@@ -0,0 +1,13 @@
package mapping
import (
"testing"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestMapping(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Mapping Suite")
}
+126
View File
@@ -0,0 +1,126 @@
package mapping
import (
"fmt"
"reflect"
)
// OpenSearchBuildMapping builds the OpenSearch "properties" map (the value
// of mappings.properties) for type t by walking the struct via reflection.
// Field names come from json tags; overrides are keyed by those names.
//
// The returned map contains plain JSON-friendly values (strings, bools,
// nested maps) and can be marshalled directly.
func OpenSearchBuildMapping(t reflect.Type, overrides map[string]FieldOpts) (map[string]any, error) {
return buildOpenSearchProperties(t, overrides, "")
}
func buildOpenSearchProperties(t reflect.Type, overrides map[string]FieldOpts, prefix string) (map[string]any, error) {
props := map[string]any{}
err := walkFields(t, func(fi fieldInfo) error {
key := fi.Name
if prefix != "" {
key = prefix + "." + fi.Name
}
opts := overrides[key]
fieldType := opts.Type
if fieldType == "" {
fieldType = inferType(fi.GoField.Type)
}
if fieldType == TypeObject {
sub := structType(fi.GoField.Type)
if sub == nil {
return fmt.Errorf("mapping: object type on non-struct field %q", key)
}
subProps, err := buildOpenSearchProperties(sub, overrides, key)
if err != nil {
return err
}
props[fi.Name] = map[string]any{"properties": subProps}
return nil
}
if fieldType == TypeGeopoint {
// Keep the facet object, add a sibling _geopoint field (see GeopointSuffix).
sub := structType(fi.GoField.Type)
if sub == nil {
return fmt.Errorf("mapping: geopoint type on non-struct field %q", key)
}
subProps, err := buildOpenSearchProperties(sub, overrides, key)
if err != nil {
return err
}
props[fi.Name] = map[string]any{"properties": subProps}
props[fi.Name+GeopointSuffix] = map[string]any{"type": "geo_point"}
return nil
}
if fieldType == TypeKeyword || fieldType == TypePath {
// path_hierarchy is case-preserving here; casing lives in the value.
m := map[string]any{"type": "keyword"}
if fieldType == TypePath {
m = map[string]any{"type": "text", "analyzer": "path_hierarchy"}
}
props[fi.Name] = m
if opts.caseInsensitive() {
props[fi.Name+LowercaseSuffix] = m
}
return nil
}
fm, err := openSearchFieldMapping(fieldType, fi.GoField.Type)
if err != nil {
return fmt.Errorf("mapping: field %q: %w", key, err)
}
props[fi.Name] = fm
return nil
})
return props, err
}
// openSearchFieldMapping handles the non-keyword/path types; keyword and path
// are emitted (with their cased forms) by buildOpenSearchProperties directly.
func openSearchFieldMapping(fieldType string, goType reflect.Type) (map[string]any, error) {
switch fieldType {
case TypeFulltext:
return map[string]any{
"type": "text",
"term_vector": "with_positions_offsets",
"analyzer": "fulltext",
}, nil
case TypeWildcard:
// OpenSearch stores wildcard fields with doc_values=false by
// default, so emit it explicitly to keep local and remote
// mappings in sync for the Apply comparison.
return map[string]any{"type": "wildcard", "doc_values": false}, nil
case TypeNumeric:
return map[string]any{"type": openSearchNumericType(goType)}, nil
case TypeBool:
return map[string]any{"type": "boolean"}, nil
case TypeDatetime:
return map[string]any{"type": "date"}, nil
case TypeGeopoint:
return map[string]any{"type": "geo_point"}, nil
case "":
return nil, fmt.Errorf("no type inferred and no override")
}
return nil, fmt.Errorf("unsupported type %q", fieldType)
}
// openSearchNumericType maps a Go numeric type to an OpenSearch numeric
// field type.
func openSearchNumericType(t reflect.Type) string {
t = deref(t)
switch t.Kind() {
case reflect.Float32:
return "float"
case reflect.Float64:
return "double"
case reflect.Int8, reflect.Uint8, reflect.Int16, reflect.Uint16:
return "short"
case reflect.Int32, reflect.Uint32:
return "integer"
}
return "long"
}
@@ -0,0 +1,136 @@
package mapping
import (
"reflect"
"time"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
type osDoc struct {
ID string `json:"ID"`
Size uint64 `json:"Size"`
Deleted bool `json:"Deleted"`
CreatedAt time.Time `json:"CreatedAt"`
Rating float64 `json:"Rating"`
Nested *struct {
Artist string `json:"artist"`
Year int32 `json:"year"`
} `json:"nested,omitempty"`
}
var _ = Describe("OpenSearchBuildMapping", func() {
DescribeTable("maps numeric Go types to OpenSearch types",
func(field, wantType string) {
type doc struct {
A int8 `json:"a"`
B int16 `json:"b"`
C int32 `json:"c"`
D int64 `json:"d"`
E uint8 `json:"e"`
F uint64 `json:"f"`
G float32 `json:"g"`
H float64 `json:"h"`
}
props, err := OpenSearchBuildMapping(reflect.TypeFor[doc](), nil)
Expect(err).ToNot(HaveOccurred())
Expect(props[field].(map[string]any)["type"]).To(Equal(wantType), "%s type", field)
},
Entry("int8 -> short", "a", "short"),
Entry("int16 -> short", "b", "short"),
Entry("int32 -> integer", "c", "integer"),
Entry("int64 -> long", "d", "long"),
Entry("uint8 -> short", "e", "short"),
Entry("uint64 -> long", "f", "long"),
Entry("float32 -> float", "g", "float"),
Entry("float64 -> double", "h", "double"),
)
DescribeTable("infers field types",
func(field, wantType string) {
props, err := OpenSearchBuildMapping(reflect.TypeFor[osDoc](), nil)
Expect(err).ToNot(HaveOccurred())
m, ok := props[field].(map[string]any)
Expect(ok).To(BeTrue(), "%s: missing or not a map: %#v", field, props[field])
Expect(m["type"]).To(Equal(wantType), "%s type", field)
},
Entry("ID -> keyword", "ID", "keyword"),
Entry("Size -> long", "Size", "long"),
Entry("Deleted -> boolean", "Deleted", "boolean"),
Entry("CreatedAt -> date", "CreatedAt", "date"),
Entry("Rating -> double", "Rating", "double"),
)
It("maps nested structs with their sub-properties", func() {
props, err := OpenSearchBuildMapping(reflect.TypeFor[osDoc](), nil)
Expect(err).ToNot(HaveOccurred())
nested, ok := props["nested"].(map[string]any)
Expect(ok).To(BeTrue(), "nested: not a map: %#v", props["nested"])
sub, ok := nested["properties"].(map[string]any)
Expect(ok).To(BeTrue(), "nested.properties: missing: %#v", nested)
artist, ok := sub["artist"].(map[string]any)
Expect(ok).To(BeTrue(), "nested.artist: %#v", sub)
Expect(artist["type"]).To(Equal("keyword"), "nested.artist.type")
year, ok := sub["year"].(map[string]any)
Expect(ok).To(BeTrue(), "nested.year: %#v", sub)
Expect(year["type"]).To(Equal("integer"), "nested.year.type (int32 -> integer expected)")
})
It("applies field overrides", func() {
True := true
type doc struct {
Name string `json:"Name"`
Content string `json:"Content"`
Path string `json:"Path"`
MimeType string `json:"MimeType"`
}
props, err := OpenSearchBuildMapping(reflect.TypeFor[doc](), map[string]FieldOpts{
"Name": {CaseInsensitive: &True},
"Content": {Type: TypeFulltext},
"Path": {Type: TypePath, CaseInsensitive: &True},
"MimeType": {Type: TypeWildcard},
})
Expect(err).ToNot(HaveOccurred())
// Name: case-preserved keyword base + lowercased keyword sibling.
Expect(props["Name"]).To(Equal(map[string]any{"type": "keyword"}))
Expect(props["Name_lowercase"]).To(Equal(map[string]any{"type": "keyword"}))
content := props["Content"].(map[string]any)
Expect(content["type"]).To(Equal("text"), "Content: %#v", content)
Expect(content["term_vector"]).To(Equal("with_positions_offsets"), "Content: %#v", content)
Expect(content["analyzer"]).To(Equal("fulltext"), "Content uses the stemming fulltext analyzer, like bleve")
// Path: path_hierarchy base + lowercased sibling, both case-preserving.
Expect(props["Path"]).To(Equal(map[string]any{"type": "text", "analyzer": "path_hierarchy"}))
Expect(props["Path_lowercase"]).To(Equal(map[string]any{"type": "text", "analyzer": "path_hierarchy"}))
mime := props["MimeType"].(map[string]any)
Expect(mime["type"]).To(Equal("wildcard"), "MimeType: %#v", mime)
})
It("builds an object plus a geo_point sibling for geopoints", func() {
type doc struct {
Location *struct {
Lon float64 `json:"longitude"`
Lat float64 `json:"latitude"`
Alt float64 `json:"altitude"`
} `json:"location,omitempty"`
}
props, err := OpenSearchBuildMapping(reflect.TypeFor[doc](), map[string]FieldOpts{
"location": {Type: TypeGeopoint},
})
Expect(err).ToNot(HaveOccurred())
// Object for libregraph-shape data retrieval.
loc, ok := props["location"].(map[string]any)
Expect(ok).To(BeTrue(), "location: %#v", props["location"])
sub, ok := loc["properties"].(map[string]any)
Expect(ok).To(BeTrue(), "location should have numeric sub-properties, got %#v", loc)
for _, k := range []string{"longitude", "latitude", "altitude"} {
prop, ok := sub[k].(map[string]any)
Expect(ok).To(BeTrue(), "location.%s: %#v", k, sub[k])
Expect(prop["type"]).To(Equal("double"), "location.%s: %#v", k, sub[k])
}
// Sibling geo_point for spatial queries.
gp, ok := props["location"+GeopointSuffix].(map[string]any)
Expect(ok).To(BeTrue(), "location%s: %#v", GeopointSuffix, props["location"+GeopointSuffix])
Expect(gp["type"]).To(Equal("geo_point"), "location%s.type", GeopointSuffix)
})
})
+41
View File
@@ -0,0 +1,41 @@
// Package mapping builds search index mappings for bleve and OpenSearch from
// a Go struct via reflection. Field names come from json tags; the caller
// provides overrides for fields that need a specific type or analyzer.
package mapping
// Field type constants used in FieldOpts.Type. An empty Type means the type
// is inferred from the Go field via reflection.
const (
TypeKeyword = "keyword"
TypeFulltext = "fulltext"
TypePath = "path"
TypeWildcard = "wildcard"
TypeNumeric = "numeric"
TypeDatetime = "datetime"
TypeBool = "bool"
TypeObject = "object"
TypeGeopoint = "geopoint"
)
// LowercaseSuffix names the lowercased sibling of a keyword/path field.
const LowercaseSuffix = "_lowercase"
// FieldOpts overrides the default type inference for a struct field. Keys in
// the override map are json-tag names (e.g. "Name", "location", "audio.artist"),
// not Go field names.
type FieldOpts struct {
// Type is one of the Type* constants. Empty means "infer from Go type".
Type string
// CaseInsensitive additionally indexes a lowercased <name>_lowercase sibling
// for case-insensitive search; the case-preserved base is always indexed.
// Nil/false means off. Keyword/path only.
CaseInsensitive *bool
// IncludeInAll controls bleve's _all field inclusion. Nil means "use the
// bleve default for this field type". Has no effect on OpenSearch.
IncludeInAll *bool
}
func (o FieldOpts) caseInsensitive() bool { return o.CaseInsensitive != nil && *o.CaseInsensitive }
+49
View File
@@ -0,0 +1,49 @@
package mapping
import (
"github.com/opencloud-eu/opencloud/pkg/log"
)
// SchemaReconciler is the engine-specific half of the startup schema check:
// Classify reads stored vs code schema (extras like analyzer drift folded in),
// ApplyAdditive applies an additive change. Reconcile drives them so the
// verdict-to-action policy lives in one place for every backend.
type SchemaReconciler interface {
Classify() (Classification, error)
// ApplyAdditive applies an additive change and reports whether the schema
// was persisted. persisted=true even if a later step fails (e.g. a bleve
// reopen), so Reconcile still warns: it is on disk, the next start
// classifies equal and stays silent.
ApplyAdditive() (persisted bool, err error)
}
// Reconcile runs the shared schema-verdict flow: an equal schema starts
// silently, a breaking one refuses with ManualActionRequiredError, an additive
// one is applied and warned about. index names the index in the messages.
func Reconcile(index string, r SchemaReconciler, logger log.Logger) (Classification, error) {
classification, err := r.Classify()
if err != nil {
return classification, err
}
switch classification.Verdict {
case VerdictBreaking:
return classification, ManualActionRequiredError(index, classification.Reasons)
case VerdictAdditive:
persisted, err := r.ApplyAdditive()
if persisted {
logger.Warn().Strs("fields", classification.NewFields).Str("index", index).Msg("extended the search index mapping with new fields; documents indexed before the upgrade do not contain them and queries on these fields will miss those documents until they are re-indexed; to re-index everything run: opencloud search index --all-spaces --force-rescan")
}
if err != nil {
return classification, err
}
}
return classification, nil
}
// LogNewIndexCreated logs that a fresh, empty index was created and how to
// backfill it. The create path does not run through Reconcile.
func LogNewIndexCreated(logger log.Logger, index string) {
logger.Info().Str("index", index).Msg("created a new empty search index; if this OpenCloud instance already held files, they are not in it yet, index them by running: opencloud search index --all-spaces --force-rescan")
}
+24
View File
@@ -0,0 +1,24 @@
package mapping
import (
"fmt"
"github.com/opencloud-eu/opencloud/pkg/conversions"
)
// PrepareForIndex converts v to the flat map[string]any the backend index
// clients expect: a json round-trip (conversions.To) plus type-specific
// adaptations (currently geopoint siblings). Pass the same overrides as the
// *BuildMapping calls so the document and the mapping stay in sync.
func PrepareForIndex(v any, overrides map[string]FieldOpts) (map[string]any, error) {
out, err := conversions.To[map[string]any](v)
if err != nil {
return nil, fmt.Errorf("mapping: prepare %T: %w", v, err)
}
if out == nil {
return out, nil
}
addGeopointSiblings(out, overrides)
addLowercaseSiblings(out, overrides)
return out, nil
}
@@ -0,0 +1,67 @@
package mapping
import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("PrepareForIndex serialization", func() {
It("errors for a non-marshallable value", func() {
// a func field can't be json-marshalled -> conversions.To errors
type bad struct {
F func() `json:"f"`
}
_, err := PrepareForIndex(bad{}, nil)
Expect(err).To(HaveOccurred())
})
It("returns nil map for a typed nil pointer", func() {
// a typed nil pointer marshals to null -> nil map, no error, no panic
out, err := PrepareForIndex((*struct{})(nil), nil)
Expect(err).ToNot(HaveOccurred())
Expect(out).To(BeNil())
})
It("flattens embedded structs", func() {
type inner struct {
Name string `json:"Name"`
Size uint64 `json:"Size"`
}
type outer struct {
inner
ID string `json:"ID"`
}
m, err := PrepareForIndex(outer{inner: inner{Name: "a", Size: 7}, ID: "x"}, nil)
Expect(err).ToNot(HaveOccurred())
want := map[string]any{"Name": "a", "Size": float64(7), "ID": "x"}
Expect(m).To(Equal(want))
})
It("omits nil fields tagged omitempty", func() {
type facet struct {
Artist string `json:"artist"`
}
type doc struct {
Name string `json:"Name"`
Audio *facet `json:"audio,omitempty"`
}
m, err := PrepareForIndex(doc{Name: "n"}, nil)
Expect(err).ToNot(HaveOccurred())
Expect(m).ToNot(HaveKey("audio"))
Expect(m["Name"]).To(Equal("n"))
})
It("includes nested facets when set", func() {
type facet struct {
Artist string `json:"artist"`
}
type doc struct {
Audio *facet `json:"audio,omitempty"`
}
m, err := PrepareForIndex(doc{Audio: &facet{Artist: "A"}}, nil)
Expect(err).ToNot(HaveOccurred())
nested, ok := m["audio"].(map[string]any)
Expect(ok).To(BeTrue(), "audio should be a nested map: %#v", m["audio"])
Expect(nested["artist"]).To(Equal("A"))
})
})
+75
View File
@@ -0,0 +1,75 @@
package mapping
import (
"fmt"
"reflect"
"sort"
"strings"
)
// Validate returns an error if any override key does not match a known field
// name in t. Top-level fields are identified by their json-tag name; nested
// named struct fields are reachable as "parent.child". Embedded (anonymous)
// structs are flattened, so their fields sit at the parent level (as with
// encoding/json).
func Validate(t reflect.Type, overrides map[string]FieldOpts) error {
if len(overrides) == 0 {
return nil
}
fields := collectFields(t, "")
var unknown, miscased []string
for k, opts := range overrides {
goType, ok := fields[k]
if !ok {
unknown = append(unknown, k)
continue
}
// CaseInsensitive routes queries to a <field>_lowercase sibling, which is
// only generated for keyword/path fields; on any other type the query
// would target a non-existent field and silently match nothing. Use the
// effective type (override, else the inferred Go type), since an override
// with no explicit Type still infers keyword/numeric/... from the field.
if opts.caseInsensitive() && !effectivelyCased(opts, goType) {
miscased = append(miscased, k)
}
}
if len(unknown) > 0 {
sort.Strings(unknown)
return fmt.Errorf("mapping: unknown override keys: %s", strings.Join(unknown, ", "))
}
if len(miscased) > 0 {
sort.Strings(miscased)
return fmt.Errorf("mapping: CaseInsensitive is only valid on keyword/path fields: %s", strings.Join(miscased, ", "))
}
return nil
}
// effectivelyCased reports whether a field is keyword/path (the only types that
// get a _lowercase sibling), from the override type or the inferred Go type.
func effectivelyCased(opts FieldOpts, goType reflect.Type) bool {
eff := opts.Type
if eff == "" && goType != nil {
eff = inferType(goType)
}
return eff == TypeKeyword || eff == TypePath
}
// collectFields maps every known field name (nested as "parent.child") to its Go
// type. Embedded structs are flattened, matching encoding/json.
func collectFields(t reflect.Type, prefix string) map[string]reflect.Type {
out := map[string]reflect.Type{}
_ = walkFields(t, func(fi fieldInfo) error {
key := fi.Name
if prefix != "" {
key = prefix + "." + fi.Name
}
out[key] = fi.GoField.Type
if sub := structType(fi.GoField.Type); sub != nil {
for k, v := range collectFields(sub, key) {
out[k] = v
}
}
return nil
})
return out
}
@@ -0,0 +1,81 @@
package mapping
import (
"reflect"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
type inner struct {
Artist string `json:"artist"`
}
type sample struct {
Name string `json:"Name"`
Audio *inner `json:"audio,omitempty"`
Location *struct { //nolint:unused
Lon float64 `json:"longitude"`
Lat float64 `json:"latitude"`
} `json:"location,omitempty"`
}
var _ = Describe("Validate", func() {
It("accepts known override keys", func() {
err := Validate(reflect.TypeFor[sample](), map[string]FieldOpts{
"Name": {},
"audio": {Type: TypeObject},
"audio.artist": {},
"location": {Type: TypeGeopoint},
})
Expect(err).ToNot(HaveOccurred())
})
It("rejects unknown override keys", func() {
err := Validate(reflect.TypeFor[sample](), map[string]FieldOpts{
"nope": {},
"audio.zzz": {},
})
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("nope"))
Expect(err.Error()).To(ContainSubstring("audio.zzz"))
})
It("accepts empty overrides", func() {
Expect(Validate(reflect.TypeFor[sample](), nil)).To(Succeed())
})
It("rejects CaseInsensitive on a non-keyword/path field", func() {
True := true
err := Validate(reflect.TypeFor[sample](), map[string]FieldOpts{
"Name": {Type: TypeFulltext, CaseInsensitive: &True},
})
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("Name"))
Expect(err.Error()).To(ContainSubstring("CaseInsensitive"))
})
It("rejects CaseInsensitive on an inferred non-keyword field (empty Type)", func() {
True := true
type doc struct {
Size uint64 `json:"Size"`
}
err := Validate(reflect.TypeFor[doc](), map[string]FieldOpts{
"Size": {CaseInsensitive: &True}, // no explicit Type -> inferred numeric
})
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("Size"))
})
It("accepts CaseInsensitive on an inferred keyword field (empty Type)", func() {
True := true
type doc struct {
Name string `json:"Name"`
Tags []string `json:"Tags"`
}
Expect(Validate(reflect.TypeFor[doc](), map[string]FieldOpts{
"Name": {CaseInsensitive: &True},
"Tags": {CaseInsensitive: &True},
})).To(Succeed())
})
})
+14 -16
View File
@@ -3,7 +3,6 @@ package opensearch
import (
"context"
"fmt"
"strings"
"time"
storageProvider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
@@ -13,6 +12,7 @@ import (
"github.com/opencloud-eu/reva/v2/pkg/utils"
"github.com/opencloud-eu/opencloud/pkg/conversions"
"github.com/opencloud-eu/opencloud/pkg/log"
searchMessage "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/messages/search/v0"
searchService "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/services/search/v0"
"github.com/opencloud-eu/opencloud/services/search/pkg/opensearch/internal/convert"
@@ -31,8 +31,8 @@ type Backend struct {
client *opensearchgoAPI.Client
}
func NewBackend(index string, client *opensearchgoAPI.Client) (*Backend, error) {
pingResp, err := client.Ping(context.TODO(), &opensearchgoAPI.PingReq{})
func NewBackend(ctx context.Context, index string, client *opensearchgoAPI.Client, logger log.Logger) (*Backend, error) {
pingResp, err := client.Ping(ctx, &opensearchgoAPI.PingReq{})
switch {
case err != nil:
return nil, fmt.Errorf("%w, failed to ping opensearch: %w", ErrUnhealthyCluster, err)
@@ -41,13 +41,13 @@ func NewBackend(index string, client *opensearchgoAPI.Client) (*Backend, error)
}
// apply the index template
if err := IndexManagerLatest.Apply(context.TODO(), index, client); err != nil {
if err := IndexManagerLatest.Apply(ctx, index, client, logger); err != nil {
return nil, fmt.Errorf("failed to apply index template: %w", err)
}
// first check if the cluster is healthy
resp, err := client.Cluster.Health(context.TODO(), &opensearchgoAPI.ClusterHealthReq{
resp, err := client.Cluster.Health(ctx, &opensearchgoAPI.ClusterHealthReq{
Indices: []string{index},
Params: opensearchgoAPI.ClusterHealthParams{
Local: opensearchgoAPI.ToPointer(true),
@@ -90,6 +90,15 @@ func (b *Backend) Search(ctx context.Context, sir *searchService.SearchIndexRequ
),
),
)
// Scope below the space root: restrict at query level so totals and
// paging respect the path too. Path uses the case-preserving
// path_hierarchy analyzer, so the folder path is an indexed token of
// the folder itself and every descendant.
if requestedPath := utils.MakeRelativePath(sir.Ref.Path); requestedPath != "." {
boolQuery.Filter(
osu.NewTermQuery[string]("Path").Value(requestedPath),
)
}
}
searchParams := opensearchgoAPI.SearchParams{
@@ -142,17 +151,6 @@ func (b *Backend) Search(ctx context.Context, sir *searchService.SearchIndexRequ
return nil, fmt.Errorf("failed to convert hit to match: %w", err)
}
if sir.Ref != nil {
hitPath := strings.TrimSuffix(match.GetEntity().GetRef().GetPath(), "/")
requestedPath := utils.MakeRelativePath(sir.Ref.Path)
isRoot := hitPath == requestedPath
if !isRoot && requestedPath != "." && !strings.HasPrefix(hitPath, requestedPath+"/") {
totalMatches--
continue
}
}
matches = append(matches, match)
}
+251 -11
View File
@@ -11,6 +11,8 @@ import (
opensearchgo "github.com/opensearch-project/opensearch-go/v4"
opensearchgoAPI "github.com/opensearch-project/opensearch-go/v4/opensearchapi"
"github.com/opencloud-eu/opencloud/pkg/log"
searchMessage "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/messages/search/v0"
searchService "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/services/search/v0"
"github.com/opencloud-eu/opencloud/services/search/pkg/opensearch"
opensearchtest "github.com/opencloud-eu/opencloud/services/search/pkg/opensearch/internal/test"
@@ -61,7 +63,7 @@ func newBackend(indexName string, resources ...search.Resource) (*opensearch.Bac
tc.Require.IndicesReset([]string{indexName})
tc.Require.IndicesCount([]string{indexName}, nil, 0)
backend, err := opensearch.NewBackend(indexName, tc.Client())
backend, err := opensearch.NewBackend(context.Background(), indexName, tc.Client(), log.NopLogger())
Expect(err).ToNot(HaveOccurred())
for _, r := range resources {
@@ -82,7 +84,7 @@ var _ = Describe("Backend", func() {
})
Expect(err).ToNot(HaveOccurred(), "failed to create OpenSearch client")
backend, err := opensearch.NewBackend("test-engine-new-engine", client)
backend, err := opensearch.NewBackend(context.Background(), "test-engine-new-engine", client, log.NopLogger())
Expect(backend).To(BeNil())
Expect(err).To(MatchError(opensearch.ErrUnhealthyCluster))
})
@@ -104,11 +106,14 @@ var _ = Describe("Backend", func() {
deleteIndexOnCleanup(tc, indexName)
var err error
backend, err = opensearch.NewBackend(indexName, tc.Client())
backend, err = opensearch.NewBackend(context.Background(), indexName, tc.Client(), log.NopLogger())
Expect(err).ToNot(HaveOccurred())
document = opensearchtest.Testdata.Resources.File
tc.Require.DocumentCreate(indexName, document.ID, strings.NewReader(opensearchtest.JSONMustMarshal(GinkgoTB(), document)))
// Upsert (not DocumentCreate) so PrepareForIndex writes the _lowercase
// siblings the case-insensitive Name and path scope searches rely on.
Expect(backend.Upsert(document.ID, document)).To(Succeed())
tc.Require.IndicesRefresh([]string{indexName}, nil)
tc.Require.IndicesCount([]string{indexName}, nil, 1)
})
@@ -127,7 +132,8 @@ var _ = Describe("Backend", func() {
deletedDocument.ID = "1$2!4"
deletedDocument.Deleted = true
tc.Require.DocumentCreate(indexName, deletedDocument.ID, strings.NewReader(opensearchtest.JSONMustMarshal(GinkgoTB(), deletedDocument)))
Expect(backend.Upsert(deletedDocument.ID, deletedDocument)).To(Succeed())
tc.Require.IndicesRefresh([]string{indexName}, nil)
tc.Require.IndicesCount([]string{indexName}, nil, 2)
resp, err := backend.Search(context.Background(), &searchService.SearchIndexRequest{
@@ -138,6 +144,202 @@ var _ = Describe("Backend", func() {
Expect(resp.TotalMatches).To(Equal(int32(1)))
Expect(fmt.Sprintf("%s$%s!%s", resp.Matches[0].Entity.Id.StorageId, resp.Matches[0].Entity.Id.SpaceId, resp.Matches[0].Entity.Id.OpaqueId)).To(Equal(document.ID))
})
It("restricts hits and totals to the path scope", func() {
outside := opensearchtest.Testdata.Resources.File
outside.ID = "1$1!5"
outside.Path = "./other folder/else.jpg"
Expect(backend.Upsert(outside.ID, outside)).To(Succeed())
tc.Require.IndicesRefresh([]string{indexName}, nil)
scoped := &searchMessage.Reference{
ResourceId: &searchMessage.ResourceID{StorageId: "1", SpaceId: "1", OpaqueId: "1"},
Path: "./parent d!r",
}
resp, err := backend.Search(context.Background(), &searchService.SearchIndexRequest{
Query: fmt.Sprintf(`"%s"`, document.Name),
Ref: scoped,
})
Expect(err).ToNot(HaveOccurred())
Expect(resp.Matches).To(HaveLen(1))
Expect(resp.TotalMatches).To(Equal(int32(1)))
Expect(resp.Matches[0].Entity.Ref.Path).To(Equal("./parent d!r/child.jpg"))
// the scope is a reference and matches case-sensitively
wrongCase := &searchMessage.Reference{
ResourceId: &searchMessage.ResourceID{StorageId: "1", SpaceId: "1", OpaqueId: "1"},
Path: "./PARENT D!R",
}
respWrongCase, err := backend.Search(context.Background(), &searchService.SearchIndexRequest{
Query: fmt.Sprintf(`"%s"`, document.Name),
Ref: wrongCase,
})
Expect(err).ToNot(HaveOccurred())
Expect(respWrongCase.Matches).To(HaveLen(0))
Expect(respWrongCase.TotalMatches).To(Equal(int32(0)))
})
})
Describe("FullTextSearch", func() {
const indexName = "opencloud-test-engine-fulltext"
var (
tc *opensearchtest.TestClient
backend *opensearch.Backend
)
BeforeEach(func() {
tc = opensearchtest.NewDefaultTestClient(GinkgoTB(), defaultConfig.Engine.OpenSearch.Client)
tc.Require.IndicesReset([]string{indexName})
tc.Require.IndicesCount([]string{indexName}, nil, 0)
deleteIndexOnCleanup(tc, indexName)
var err error
backend, err = opensearch.NewBackend(context.Background(), indexName, tc.Client(), log.NopLogger())
Expect(err).ToNot(HaveOccurred())
document := opensearchtest.Testdata.Resources.File
document.Content = "Running Foxes"
Expect(backend.Upsert(document.ID, document)).To(Succeed())
tc.Require.IndicesRefresh([]string{indexName}, nil)
})
It("searches content case-insensitively and stemmed, like bleve", func() {
// case-folded and porter-stemmed by the fulltext analyzer; the match
// query analyzes the query value the same way. "content:run*" is an
// unanalyzed wildcard over the stemmed term "run", so it must still
// route to a wildcard query (not degrade to a phrase match).
for _, q := range []string{"content:running", "content:RUNNING", "content:run", "content:run*"} {
resp, err := backend.Search(context.Background(), &searchService.SearchIndexRequest{Query: q})
Expect(err).ToNot(HaveOccurred(), q)
Expect(resp.Matches).To(HaveLen(1), q)
}
resp, err := backend.Search(context.Background(), &searchService.SearchIndexRequest{Query: "content:cat"})
Expect(err).ToNot(HaveOccurred())
Expect(resp.Matches).To(HaveLen(0))
})
})
Describe("CaseInsensitiveSearch", func() {
const indexName = "opencloud-test-engine-ci"
var (
tc *opensearchtest.TestClient
backend *opensearch.Backend
)
BeforeEach(func() {
tc = opensearchtest.NewDefaultTestClient(GinkgoTB(), defaultConfig.Engine.OpenSearch.Client)
tc.Require.IndicesReset([]string{indexName})
tc.Require.IndicesCount([]string{indexName}, nil, 0)
deleteIndexOnCleanup(tc, indexName)
var err error
backend, err = opensearch.NewBackend(context.Background(), indexName, tc.Client(), log.NopLogger())
Expect(err).ToNot(HaveOccurred())
folder := opensearchtest.Testdata.Resources.Folder
folder.ID = "1$2!cifolder"
folder.Path = "./My Dir"
folder.Tags = []string{"Work", "Urgent"}
Expect(backend.Upsert(folder.ID, folder)).To(Succeed())
child := opensearchtest.Testdata.Resources.File
child.ID = "1$2!cichild"
child.ParentID = folder.ID
child.Path = "./My Dir/report.pdf"
child.Tags = nil
Expect(backend.Upsert(child.ID, child)).To(Succeed())
// a doc outside the folder, so the path assertions below discriminate:
// a phrase-matched path query would analyze into the "." prefix and
// match this one too
outside := opensearchtest.Testdata.Resources.File
outside.ID = "1$2!cioutside"
outside.Path = "./other.pdf"
outside.Tags = nil
Expect(backend.Upsert(outside.ID, outside)).To(Succeed())
tc.Require.IndicesRefresh([]string{indexName}, nil)
})
It("matches tags case-insensitively (array sibling)", func() {
for _, q := range []string{"tag:work", "tag:WORK", "Tags:Urgent"} {
resp, err := backend.Search(context.Background(), &searchService.SearchIndexRequest{Query: q})
Expect(err).ToNot(HaveOccurred(), q)
Expect(resp.Matches).To(HaveLen(1), q)
}
})
It("matches a spaced path on the folder and its descendants case-sensitively", func() {
resp, err := backend.Search(context.Background(), &searchService.SearchIndexRequest{Query: `path:"./My Dir"`})
Expect(err).ToNot(HaveOccurred())
Expect(resp.Matches).To(HaveLen(2)) // folder itself + the descendant, not the outside doc
// paths act as references, a wrong-cased path must not match
respWrongCase, err := backend.Search(context.Background(), &searchService.SearchIndexRequest{Query: `path:"./MY DIR"`})
Expect(err).ToNot(HaveOccurred())
Expect(respWrongCase.Matches).To(HaveLen(0))
})
It("matches a spaced descendant path only itself", func() {
resp, err := backend.Search(context.Background(), &searchService.SearchIndexRequest{Query: `path:"./My Dir/report.pdf"`})
Expect(err).ToNot(HaveOccurred())
Expect(resp.Matches).To(HaveLen(1))
})
})
Describe("MediaTypeSearch", func() {
const indexName = "opencloud-test-engine-mediatype"
var (
tc *opensearchtest.TestClient
backend *opensearch.Backend
)
BeforeEach(func() {
tc = opensearchtest.NewDefaultTestClient(GinkgoTB(), defaultConfig.Engine.OpenSearch.Client)
tc.Require.IndicesReset([]string{indexName})
tc.Require.IndicesCount([]string{indexName}, nil, 0)
deleteIndexOnCleanup(tc, indexName)
var err error
backend, err = opensearch.NewBackend(context.Background(), indexName, tc.Client(), log.NopLogger())
Expect(err).ToNot(HaveOccurred())
svg := opensearchtest.Testdata.Resources.File
svg.ID = "1$2!svg"
svg.MimeType = "image/svg+xml"
Expect(backend.Upsert(svg.ID, svg)).To(Succeed())
png := opensearchtest.Testdata.Resources.File
png.ID = "1$2!png"
png.MimeType = "image/png"
Expect(backend.Upsert(png.ID, png)).To(Succeed())
folder := opensearchtest.Testdata.Resources.Folder
folder.ID = "1$2!dir"
folder.MimeType = "httpd/unix-directory"
Expect(backend.Upsert(folder.ID, folder)).To(Succeed())
tc.Require.IndicesRefresh([]string{indexName}, nil)
})
DescribeTable("resolves the media type query",
func(query string, want int) {
resp, err := backend.Search(context.Background(), &searchService.SearchIndexRequest{Query: query})
Expect(err).ToNot(HaveOccurred(), query)
Expect(resp.Matches).To(HaveLen(want), query)
},
Entry("image/* wildcard matches both files", "mediatype:image", 2),
Entry("categories are case-insensitive", "mediatype:IMAGE", 2),
Entry("literal MIME (+ and /) via mediatype", "mediatype:image/svg+xml", 1),
Entry("same literal via the raw field name", "MimeType:image/svg+xml", 1),
Entry("literal png MIME", "mediatype:image/png", 1),
Entry("no pdf documents", "mediatype:pdf", 0),
Entry("folder category matches the directory only", "mediatype:folder", 1),
Entry("file category matches both files, not the directory", "mediatype:file", 2),
Entry("file category combined with a term", "mediatype:file AND MimeType:image/png", 1),
)
})
Describe("Upsert", func() {
@@ -155,7 +357,7 @@ var _ = Describe("Backend", func() {
deleteIndexOnCleanup(tc, indexName)
var err error
backend, err = opensearch.NewBackend(indexName, tc.Client())
backend, err = opensearch.NewBackend(context.Background(), indexName, tc.Client(), log.NopLogger())
Expect(err).ToNot(HaveOccurred())
})
@@ -165,6 +367,16 @@ var _ = Describe("Backend", func() {
tc.Require.IndicesCount([]string{indexName}, nil, 1)
})
It("upserts a document without an mtime", func() {
// content.Extract leaves Mtime nil when the resource info carries none
document := opensearchtest.Testdata.Resources.File
document.ID = "1$1!4"
document.Mtime = nil
Expect(backend.Upsert(document.ID, document)).To(Succeed())
tc.Require.IndicesCount([]string{indexName}, nil, 1)
})
})
Describe("Move", func() {
@@ -182,7 +394,7 @@ var _ = Describe("Backend", func() {
deleteIndexOnCleanup(tc, indexName)
var err error
backend, err = opensearch.NewBackend(indexName, tc.Client())
backend, err = opensearch.NewBackend(context.Background(), indexName, tc.Client(), log.NopLogger())
Expect(err).ToNot(HaveOccurred())
})
@@ -210,6 +422,34 @@ var _ = Describe("Backend", func() {
Expect(resources).To(HaveLen(1))
Expect(resources[0].Path).To(Equal(document.Path))
})
It("keeps case-sensitive path search working after a move", func() {
// Spaced paths so the queries only stay exact as term queries; a phrase
// match would analyze into the "." prefix and match regardless.
document := opensearchtest.Testdata.Resources.File
document.ID = "1$2!cimove"
document.Path = "./Foo Dir/Bar"
Expect(backend.Upsert(document.ID, document)).To(Succeed())
tc.Require.IndicesRefresh([]string{indexName}, nil)
document.Path = "./Moved Dir/Bar"
Expect(backend.Move(document.ID, document.ParentID, document.Path)).To(Succeed())
tc.Require.IndicesRefresh([]string{indexName}, nil)
// Path is case-sensitive by design: the exact new path matches, a
// wrong-cased query does not, and the old path no longer matches.
respNew, err := backend.Search(context.Background(), &searchService.SearchIndexRequest{Query: `path:"./Moved Dir/Bar"`})
Expect(err).ToNot(HaveOccurred())
Expect(respNew.Matches).To(HaveLen(1))
respWrongCase, err := backend.Search(context.Background(), &searchService.SearchIndexRequest{Query: `path:"./MOVED DIR/BAR"`})
Expect(err).ToNot(HaveOccurred())
Expect(respWrongCase.Matches).To(HaveLen(0))
respOld, err := backend.Search(context.Background(), &searchService.SearchIndexRequest{Query: `path:"./Foo Dir/Bar"`})
Expect(err).ToNot(HaveOccurred())
Expect(respOld.Matches).To(HaveLen(0))
})
})
Describe("Delete", func() {
@@ -227,7 +467,7 @@ var _ = Describe("Backend", func() {
deleteIndexOnCleanup(tc, indexName)
var err error
backend, err = opensearch.NewBackend(indexName, tc.Client())
backend, err = opensearch.NewBackend(context.Background(), indexName, tc.Client(), log.NopLogger())
Expect(err).ToNot(HaveOccurred())
})
@@ -268,7 +508,7 @@ var _ = Describe("Backend", func() {
deleteIndexOnCleanup(tc, indexName)
var err error
backend, err = opensearch.NewBackend(indexName, tc.Client())
backend, err = opensearch.NewBackend(context.Background(), indexName, tc.Client(), log.NopLogger())
Expect(err).ToNot(HaveOccurred())
})
@@ -310,7 +550,7 @@ var _ = Describe("Backend", func() {
deleteIndexOnCleanup(tc, indexName)
var err error
backend, err = opensearch.NewBackend(indexName, tc.Client())
backend, err = opensearch.NewBackend(context.Background(), indexName, tc.Client(), log.NopLogger())
Expect(err).ToNot(HaveOccurred())
})
@@ -370,7 +610,7 @@ var _ = Describe("Backend", func() {
deleteIndexOnCleanup(tc, indexName)
var err error
backend, err = opensearch.NewBackend(indexName, tc.Client())
backend, err = opensearch.NewBackend(context.Background(), indexName, tc.Client(), log.NopLogger())
Expect(err).ToNot(HaveOccurred())
})
+29 -10
View File
@@ -14,6 +14,7 @@ import (
"github.com/opencloud-eu/opencloud/pkg/conversions"
"github.com/opencloud-eu/opencloud/pkg/log"
"github.com/opencloud-eu/opencloud/services/search/pkg/mapping"
"github.com/opencloud-eu/opencloud/services/search/pkg/opensearch/internal/osu"
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
)
@@ -43,7 +44,7 @@ func NewBatch(client *opensearchgoAPI.Client, index string, size int) (*Batch, e
func (b *Batch) Upsert(id string, r search.Resource) error {
return b.withSizeLimit(func() error {
body, err := conversions.To[map[string]any](r)
body, err := mapping.PrepareForIndex(r, r.SearchFieldOverrides())
if err != nil {
return fmt.Errorf("failed to marshal resource: %w", err)
}
@@ -67,18 +68,36 @@ func (b *Batch) Move(id, parentID, location string) error {
return b.withSizeLimit(func() error {
op := func() error {
return updateSelfAndDescendants(context.Background(), b.client, b.index, id, func(rootResource search.Resource) *osu.BodyParamScript {
newPath := utils.MakeRelativePath(location)
newName := path.Base(newPath)
return &osu.BodyParamScript{
Source: `
if (ctx._source.ID == params.id ) { ctx._source.Name = params.newName; ctx._source.ParentID = params.parentID; }
ctx._source.Path = ctx._source.Path.replace(params.oldPath, params.newPath)
`,
// Keep Name and its lowercased search sibling in sync; Path has
// no sibling (case-sensitive by design). Only the leading
// oldPath is replaced (startsWith + substring, not
// String.replace, which would also rewrite a repeated segment
// deeper in a descendant's path, e.g. /Music/Music.m3u). The
// lowercased new name comes from Go's strings.ToLower via
// params, so the sibling stays byte-identical to what
// PrepareForIndex writes on upsert (painless toLowerCase would
// lowercase differently than Go).
Source: fmt.Sprintf(`
if (ctx._source.ID == params.id) {
ctx._source.Name = params.newName;
ctx._source.ParentID = params.parentID;
if (ctx._source.Name%[1]s != null) { ctx._source.Name%[1]s = params.newNameLower; }
}
if (ctx._source.Path != null && ctx._source.Path.startsWith(params.oldPath)) {
ctx._source.Path = params.newPath + ctx._source.Path.substring(params.oldPath.length());
}
`, mapping.LowercaseSuffix),
Lang: "painless",
Params: map[string]any{
"id": id,
"parentID": parentID,
"oldPath": rootResource.Path,
"newPath": utils.MakeRelativePath(location),
"newName": path.Base(utils.MakeRelativePath(location)),
"id": id,
"parentID": parentID,
"oldPath": rootResource.Path,
"newPath": newPath,
"newName": newName,
"newNameLower": strings.ToLower(newName),
},
}
})
+52
View File
@@ -0,0 +1,52 @@
package opensearch
import (
"crypto/tls"
"fmt"
"net/http"
"os"
opensearchgo "github.com/opensearch-project/opensearch-go/v4"
opensearchgoAPI "github.com/opensearch-project/opensearch-go/v4/opensearchapi"
"github.com/opencloud-eu/opencloud/services/search/pkg/config"
)
// NewClient builds an OpenSearch API client from the engine client config.
func NewClient(cfg config.EngineOpenSearchClient) (*opensearchgoAPI.Client, error) {
clientConfig := opensearchgo.Config{
Addresses: cfg.Addresses,
Username: cfg.Username,
Password: cfg.Password,
Header: cfg.Header,
RetryOnStatus: cfg.RetryOnStatus,
DisableRetry: cfg.DisableRetry,
EnableRetryOnTimeout: cfg.EnableRetryOnTimeout,
MaxRetries: cfg.MaxRetries,
CompressRequestBody: cfg.CompressRequestBody,
DiscoverNodesOnStart: cfg.DiscoverNodesOnStart,
DiscoverNodesInterval: cfg.DiscoverNodesInterval,
EnableMetrics: cfg.EnableMetrics,
EnableDebugLogger: cfg.EnableDebugLogger,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
MinVersion: tls.VersionTLS12,
InsecureSkipVerify: cfg.Insecure,
},
},
}
if cfg.CACert != "" {
certBytes, err := os.ReadFile(cfg.CACert)
if err != nil {
return nil, fmt.Errorf("failed to read CA cert: %w", err)
}
clientConfig.CACert = certBytes
}
client, err := opensearchgoAPI.NewClient(opensearchgoAPI.Config{Client: clientConfig})
if err != nil {
return nil, fmt.Errorf("failed to create OpenSearch client: %w", err)
}
return client, nil
}
+206 -91
View File
@@ -3,29 +3,44 @@ package opensearch
import (
"bytes"
"context"
"embed"
"encoding/json"
"errors"
"fmt"
"path"
"maps"
"reflect"
"strings"
"github.com/go-jose/go-jose/v3/json"
opensearchgo "github.com/opensearch-project/opensearch-go/v4"
opensearchgoAPI "github.com/opensearch-project/opensearch-go/v4/opensearchapi"
"github.com/tidwall/gjson"
"github.com/opencloud-eu/opencloud/pkg/log"
searchmapping "github.com/opencloud-eu/opencloud/services/search/pkg/mapping"
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
)
var (
ErrManualActionRequired = errors.New("manual action required")
IndexManagerLatest = IndexIndexManagerResourceV2
IndexIndexManagerResourceV1 IndexManager = "resource_v1.json"
IndexIndexManagerResourceV2 IndexManager = "resource_v2.json"
// ErrManualActionRequired is the shared sentinel, see the mapping package.
ErrManualActionRequired = searchmapping.ErrManualActionRequired
// IndexManagerLatest identifies the current resource mapping; its version is
// derived from search.SchemaVersion so it never drifts from the index name.
IndexManagerLatest = IndexManager(fmt.Sprintf("resource_v%d", search.SchemaVersion))
)
//go:embed internal/indexes/*.json
var indexes embed.FS
// VersionedIndexName suffixes the base index name with the schema version, e.g.
// "opencloud-resource" -> "opencloud-resource-v3".
func VersionedIndexName(base string) string {
return fmt.Sprintf("%s-v%d", base, search.SchemaVersion)
}
type IndexManager string
// indexGenerators dispatches each IndexManager variant to its builder.
var indexGenerators = map[IndexManager]func() ([]byte, error){
IndexManagerLatest: buildResourceMapping,
}
func (m IndexManager) String() string {
b, err := m.MarshalJSON()
if err != nil {
@@ -36,108 +51,208 @@ func (m IndexManager) String() string {
}
func (m IndexManager) MarshalJSON() ([]byte, error) {
filePath := string(m)
body, err := indexes.ReadFile(path.Join("./internal/indexes", filePath))
switch {
case err != nil:
return nil, fmt.Errorf("failed to read index file %s: %w", filePath, err)
case len(body) <= 0:
return nil, fmt.Errorf("index file %s is empty", filePath)
gen, ok := indexGenerators[m]
if !ok {
return nil, fmt.Errorf("unknown index manager %q", string(m))
}
return body, nil
return gen()
}
func (m IndexManager) Apply(ctx context.Context, name string, client *opensearchgoAPI.Client) error {
// buildResourceMapping renders the OpenSearch index template for a
// search.Resource from the shared SearchFieldOverrides. OpenSearch-specific
// tweaks (wildcard MimeType, path_hierarchy Path) are applied on top.
func buildResourceMapping() ([]byte, error) {
resourceType := reflect.TypeFor[search.Resource]()
overrides := maps.Clone(search.Resource{}.SearchFieldOverrides())
overrides["MimeType"] = searchmapping.FieldOpts{Type: searchmapping.TypeWildcard}
if err := searchmapping.Validate(resourceType, overrides); err != nil {
return nil, err
}
props, err := searchmapping.OpenSearchBuildMapping(resourceType, overrides)
if err != nil {
return nil, err
}
index := map[string]any{
"settings": map[string]any{
"number_of_shards": "1",
"number_of_replicas": "1",
"analysis": map[string]any{
// path_hierarchy is case-preserving; casing lives in the value.
// fulltext mirrors the bleve fulltext analyzer (lowercase + porter
// stemming) so full-text search behaves the same on both backends.
"analyzer": map[string]any{
"path_hierarchy": map[string]any{
"type": "custom",
"tokenizer": "path_hierarchy",
},
"fulltext": map[string]any{
"type": "custom",
"tokenizer": "standard",
"filter": []string{"lowercase", "porter_stem"},
},
},
"tokenizer": map[string]any{
"path_hierarchy": map[string]any{"type": "path_hierarchy"},
},
},
},
"mappings": map[string]any{
"properties": props,
},
}
return json.Marshal(index)
}
// Apply ensures the index exists and matches the code schema: created if
// missing, otherwise reconciled via searchmapping.Reconcile (see osReconciler).
func (m IndexManager) Apply(ctx context.Context, name string, client *opensearchgoAPI.Client, logger log.Logger) error {
localIndexB, err := m.MarshalJSON()
if err != nil {
return fmt.Errorf("failed to marshal index %s: %w", name, err)
}
// Exists first: a pre-provisioned index must not require create privileges
indicesExistsResp, err := client.Indices.Exists(ctx, opensearchgoAPI.IndicesExistsReq{
Indices: []string{name},
})
switch {
case indicesExistsResp != nil && indicesExistsResp.StatusCode == 404:
break
createResp, createErr := client.Indices.Create(ctx, opensearchgoAPI.IndicesCreateReq{
Index: name,
Body: bytes.NewReader(localIndexB),
})
var structErr *opensearchgo.StructError
switch {
case createErr == nil && createResp.Acknowledged:
searchmapping.LogNewIndexCreated(logger, name)
return nil
case createErr == nil:
return fmt.Errorf("failed to create index %s: not acknowledged", name)
case !errors.As(createErr, &structErr) || structErr.Err.Type != "resource_already_exists_exception":
// transport errors, disk-full etc. stay plain fatal, the restart policy retries
return fmt.Errorf("failed to create index %s: %w", name, createErr)
}
// lost the creation race to another instance, compare against its index
case err != nil:
return fmt.Errorf("failed to check if index %s exists: %w", name, err)
case indicesExistsResp == nil:
return fmt.Errorf("indicesExistsResp is nil for index %s", name)
}
if indicesExistsResp.StatusCode == 200 {
resp, err := client.Indices.Get(ctx, opensearchgoAPI.IndicesGetReq{
Indices: []string{name},
})
if err != nil {
return fmt.Errorf("failed to get index %s: %w", name, err)
}
remoteIndex, ok := resp.Indices[name]
if !ok {
return fmt.Errorf("index %s not found in response", name)
}
remoteIndexB, err := json.Marshal(remoteIndex)
if err != nil {
return fmt.Errorf("failed to marshal index %s: %w", name, err)
}
localIndexJson := gjson.ParseBytes(localIndexB)
remoteIndexJson := gjson.ParseBytes(remoteIndexB)
compare := func(lvPath, rvPath string) (any, any, bool) {
lv := localIndexJson.Get(lvPath).Raw
rv := remoteIndexJson.Get(rvPath).Raw
var lvv, rvv any
if err := json.Unmarshal([]byte(lv), &lvv); err != nil {
return nil, nil, false
}
if err := json.Unmarshal([]byte(rv), &rvv); err != nil {
return nil, nil, false
}
return lv, rv, reflect.DeepEqual(lvv, rvv)
}
var errs []error
for k := range localIndexJson.Get("settings").Map() {
if lv, rv, ok := compare("settings."+k, "settings.index."+k); !ok {
errs = append(errs, fmt.Errorf("settings.%s local %s, remote %s", k, lv, rv))
}
}
for k := range localIndexJson.Get("mappings.properties").Map() {
if _, _, ok := compare("mappings.properties."+k, "mappings.properties."+k); !ok {
errs = append(errs, fmt.Errorf("mappings.properties.%s", k))
}
}
if errs != nil {
return fmt.Errorf(
"index %s already exists and is different from the requested version, %w: %w",
name,
ErrManualActionRequired,
errors.Join(errs...),
)
}
return nil // Index is already up to date, no action needed
}
createResp, err := client.Indices.Create(ctx, opensearchgoAPI.IndicesCreateReq{
Index: name,
Body: bytes.NewReader(localIndexB),
// the index exists: reconcile its schema through the shared verdict flow
resp, err := client.Indices.Get(ctx, opensearchgoAPI.IndicesGetReq{
Indices: []string{name},
})
switch {
case err != nil:
return fmt.Errorf("failed to create index %s: %w", name, err)
case !createResp.Acknowledged:
return fmt.Errorf("failed to create index %s: not acknowledged", name)
if err != nil {
return fmt.Errorf("failed to get index %s: %w", name, err)
}
remoteIndex, ok := resp.Indices[name]
if !ok {
return fmt.Errorf("index %s not found in response", name)
}
remoteIndexB, err := json.Marshal(remoteIndex)
if err != nil {
return fmt.Errorf("failed to marshal index %s: %w", name, err)
}
return nil
r := &osReconciler{
ctx: ctx,
name: name,
client: client,
local: gjson.ParseBytes(localIndexB),
remote: gjson.ParseBytes(remoteIndexB),
}
_, err = searchmapping.Reconcile(name, r, logger)
return err
}
// osReconciler adapts an existing OpenSearch index to searchmapping.SchemaReconciler.
type osReconciler struct {
ctx context.Context
name string
client *opensearchgoAPI.Client
local gjson.Result
remote gjson.Result
}
func (r *osReconciler) Classify() (searchmapping.Classification, error) {
// Only the analysis settings affect indexing correctness; shard/replica
// counts and other operational knobs are the operator's to tune (and a
// pre-provisioned index's to own), so they are not compared.
var reasons []string
lv := r.local.Get("settings.analysis").Raw
rv := r.remote.Get("settings.index.analysis").Raw
if !jsonEqual(lv, rv) {
reasons = append(reasons, fmt.Sprintf("settings.analysis changed: index %s, code %s", rawOrUnset(rv), rawOrUnset(lv)))
}
classification := searchmapping.Classify(
propertiesMap(r.remote.Get("mappings.properties").Raw),
propertiesMap(r.local.Get("mappings.properties").Raw),
nil,
)
reasons = append(reasons, classification.Reasons...)
if len(reasons) > 0 {
classification.Verdict = searchmapping.VerdictBreaking
classification.Reasons = reasons
}
return classification, nil
}
// ApplyAdditive puts the full code properties (only additions, per the
// classifier). The PUT is atomic, so persisted is true only on success.
func (r *osReconciler) ApplyAdditive() (bool, error) {
putResp, err := r.client.Indices.Mapping.Put(r.ctx, opensearchgoAPI.MappingPutReq{
Indices: []string{r.name},
Body: strings.NewReader(r.local.Get("mappings").Raw),
})
var putErr *opensearchgo.StructError
switch {
case err != nil && errors.As(err, &putErr) && putErr.Err.Type == "illegal_argument_exception" &&
(strings.Contains(putErr.Err.Reason, "cannot be changed") || strings.Contains(putErr.Err.Reason, "Cannot update parameter")):
// backstop, should be unreachable after the classification above
return false, searchmapping.ManualActionRequiredError(r.name, []string{putErr.Err.Reason})
case err != nil:
return false, fmt.Errorf("failed to update mapping of index %s: %w", r.name, err)
case !putResp.Acknowledged:
return false, fmt.Errorf("failed to update mapping of index %s: not acknowledged", r.name)
}
return true, nil
}
// jsonEqual reports whether two raw JSON values are deeply equal. gjson yields
// an empty string for a path that does not exist; two such unset values are
// equal, an unset value never equals a present one, and a value that fails to
// parse counts as unequal.
func jsonEqual(a, b string) bool {
if a == "" || b == "" {
return a == b
}
var av, bv any
if err := json.Unmarshal([]byte(a), &av); err != nil {
return false
}
if err := json.Unmarshal([]byte(b), &bv); err != nil {
return false
}
return reflect.DeepEqual(av, bv)
}
// propertiesMap parses a raw mappings.properties object into a map. Missing,
// empty, null or malformed input yields an empty (non-nil) map, which
// classifies as purely additive.
func propertiesMap(raw string) map[string]any {
props := map[string]any{}
if err := json.Unmarshal([]byte(raw), &props); err != nil || props == nil {
return map[string]any{}
}
return props
}
func rawOrUnset(raw string) string {
if raw == "" {
return "(unset)"
}
return raw
}
+139 -4
View File
@@ -1,16 +1,35 @@
package opensearch_test
import (
"fmt"
"strings"
"testing"
opensearchgo "github.com/opensearch-project/opensearch-go/v4"
opensearchgoAPI "github.com/opensearch-project/opensearch-go/v4/opensearchapi"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
"github.com/opencloud-eu/opencloud/pkg/log"
"github.com/opencloud-eu/opencloud/services/search/pkg/opensearch"
"github.com/opencloud-eu/opencloud/services/search/pkg/opensearch/internal/test"
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
)
// TestVersionedIndexName guards that the index name and the generator identity
// carry the same schema version.
func TestVersionedIndexName(t *testing.T) {
require.Equal(t,
fmt.Sprintf("opencloud-resource-v%d", search.SchemaVersion),
opensearch.VersionedIndexName("opencloud-resource"),
)
require.Equal(t,
fmt.Sprintf("resource_v%d", search.SchemaVersion),
string(opensearch.IndexManagerLatest),
)
}
func TestIndexManager(t *testing.T) {
t.Run("index plausibility", func(t *testing.T) {
tests := []opensearchtest.TableTest[opensearch.IndexManager, struct{}]{
@@ -31,7 +50,7 @@ func TestIndexManager(t *testing.T) {
require.NotEmpty(t, body)
require.NotEmpty(t, test.Got.String())
require.JSONEq(t, test.Got.String(), string(body))
require.NoError(t, test.Got.Apply(t.Context(), indexName, tc.Client()))
require.NoError(t, test.Got.Apply(t.Context(), indexName, tc.Client(), log.NopLogger()))
})
}
})
@@ -44,10 +63,38 @@ func TestIndexManager(t *testing.T) {
tc.Require.IndicesReset([]string{indexName})
tc.Require.IndicesCreate(indexName, strings.NewReader(indexManager.String()))
require.NoError(t, indexManager.Apply(t.Context(), indexName, tc.Client()))
require.NoError(t, indexManager.Apply(t.Context(), indexName, tc.Client(), log.NopLogger()))
})
t.Run("fails to create index if it already exists but is not up to date", func(t *testing.T) {
t.Run("fails when the analysis settings drift", func(t *testing.T) {
indexManager := opensearch.IndexManagerLatest
indexName := "opencloud-test-resource"
tc := opensearchtest.NewDefaultTestClient(t, defaultConfig.Engine.OpenSearch.Client)
tc.Require.IndicesReset([]string{indexName})
body, err := sjson.Set(indexManager.String(), "settings.analysis.analyzer.lowercaseKeyword.tokenizer", "standard")
require.NoError(t, err)
tc.Require.IndicesCreate(indexName, strings.NewReader(body))
require.ErrorIs(t, indexManager.Apply(t.Context(), indexName, tc.Client(), log.NopLogger()), opensearch.ErrManualActionRequired)
})
t.Run("tolerates replica drift", func(t *testing.T) {
indexManager := opensearch.IndexManagerLatest
indexName := "opencloud-test-resource"
tc := opensearchtest.NewDefaultTestClient(t, defaultConfig.Engine.OpenSearch.Client)
tc.Require.IndicesReset([]string{indexName})
body, err := sjson.Set(indexManager.String(), "settings.number_of_replicas", "2")
require.NoError(t, err)
tc.Require.IndicesCreate(indexName, strings.NewReader(body))
require.NoError(t, indexManager.Apply(t.Context(), indexName, tc.Client(), log.NopLogger()))
})
t.Run("tolerates shard drift", func(t *testing.T) {
indexManager := opensearch.IndexManagerLatest
indexName := "opencloud-test-resource"
@@ -58,6 +105,94 @@ func TestIndexManager(t *testing.T) {
require.NoError(t, err)
tc.Require.IndicesCreate(indexName, strings.NewReader(body))
require.ErrorIs(t, indexManager.Apply(t.Context(), indexName, tc.Client()), opensearch.ErrManualActionRequired)
require.NoError(t, indexManager.Apply(t.Context(), indexName, tc.Client(), log.NopLogger()))
})
t.Run("is idempotent", func(t *testing.T) {
indexManager := opensearch.IndexManagerLatest
indexName := "opencloud-test-resource"
tc := opensearchtest.NewDefaultTestClient(t, defaultConfig.Engine.OpenSearch.Client)
tc.Require.IndicesReset([]string{indexName})
require.NoError(t, indexManager.Apply(t.Context(), indexName, tc.Client(), log.NopLogger()))
require.NoError(t, indexManager.Apply(t.Context(), indexName, tc.Client(), log.NopLogger()))
})
t.Run("adds a new field to an existing index in place", func(t *testing.T) {
indexManager := opensearch.IndexManagerLatest
indexName := "opencloud-test-resource"
tc := opensearchtest.NewDefaultTestClient(t, defaultConfig.Engine.OpenSearch.Client)
tc.Require.IndicesReset([]string{indexName})
body, err := sjson.Delete(indexManager.String(), "mappings.properties.Title")
require.NoError(t, err)
tc.Require.IndicesCreate(indexName, strings.NewReader(body))
require.NoError(t, indexManager.Apply(t.Context(), indexName, tc.Client(), log.NopLogger()))
resp, err := tc.Client().Indices.Mapping.Get(t.Context(), &opensearchgoAPI.MappingGetReq{Indices: []string{indexName}})
require.NoError(t, err)
require.True(t, gjson.GetBytes(resp.Indices[indexName].Mappings, "properties.Title").Exists())
})
t.Run("adds a new nested field to an existing index in place", func(t *testing.T) {
indexManager := opensearch.IndexManagerLatest
indexName := "opencloud-test-resource"
tc := opensearchtest.NewDefaultTestClient(t, defaultConfig.Engine.OpenSearch.Client)
tc.Require.IndicesReset([]string{indexName})
body, err := sjson.Delete(indexManager.String(), "mappings.properties.photo.properties.cameraMake")
require.NoError(t, err)
tc.Require.IndicesCreate(indexName, strings.NewReader(body))
require.NoError(t, indexManager.Apply(t.Context(), indexName, tc.Client(), log.NopLogger()))
resp, err := tc.Client().Indices.Mapping.Get(t.Context(), &opensearchgoAPI.MappingGetReq{Indices: []string{indexName}})
require.NoError(t, err)
require.True(t, gjson.GetBytes(resp.Indices[indexName].Mappings, "properties.photo.properties.cameraMake").Exists())
})
t.Run("fails when an existing field changed its definition", func(t *testing.T) {
indexManager := opensearch.IndexManagerLatest
indexName := "opencloud-test-resource"
tc := opensearchtest.NewDefaultTestClient(t, defaultConfig.Engine.OpenSearch.Client)
tc.Require.IndicesReset([]string{indexName})
body, err := sjson.Set(indexManager.String(), "mappings.properties.Deleted.type", "keyword")
require.NoError(t, err)
tc.Require.IndicesCreate(indexName, strings.NewReader(body))
require.ErrorIs(t, indexManager.Apply(t.Context(), indexName, tc.Client(), log.NopLogger()), opensearch.ErrManualActionRequired)
})
t.Run("fails when the index contains a field the code schema does not know", func(t *testing.T) {
indexManager := opensearch.IndexManagerLatest
indexName := "opencloud-test-resource"
tc := opensearchtest.NewDefaultTestClient(t, defaultConfig.Engine.OpenSearch.Client)
tc.Require.IndicesReset([]string{indexName})
body, err := sjson.Set(indexManager.String(), "mappings.properties.legacyField.type", "keyword")
require.NoError(t, err)
tc.Require.IndicesCreate(indexName, strings.NewReader(body))
require.ErrorIs(t, indexManager.Apply(t.Context(), indexName, tc.Client(), log.NopLogger()), opensearch.ErrManualActionRequired)
})
t.Run("transport errors do not demand manual action", func(t *testing.T) {
client, err := opensearchgoAPI.NewClient(opensearchgoAPI.Config{
Client: opensearchgo.Config{
Addresses: []string{"http://localhost:1025"},
},
})
require.NoError(t, err)
err = opensearch.IndexManagerLatest.Apply(t.Context(), "opencloud-test-resource", client, log.NopLogger())
require.Error(t, err)
require.NotErrorIs(t, err, opensearch.ErrManualActionRequired)
})
}
@@ -1,197 +0,0 @@
package convert
import (
"fmt"
"reflect"
"slices"
"strings"
"github.com/opencloud-eu/opencloud/pkg/ast"
)
func ExpandKQL(nodes []ast.Node) ([]ast.Node, error) {
return kqlExpander{}.expand(nodes, "")
}
type kqlExpander struct{}
func (e kqlExpander) expand(nodes []ast.Node, defaultKey string) ([]ast.Node, error) {
for i, node := range nodes {
rnode := reflect.ValueOf(node)
// we need to ensure that the node is a pointer to an ast.Node in every case
if rnode.Kind() != reflect.Ptr {
ptr := reflect.New(rnode.Type())
ptr.Elem().Set(rnode)
rnode = ptr
cnode, ok := rnode.Interface().(ast.Node)
if !ok {
return nil, fmt.Errorf("expected node to be of type ast.Node, got %T", rnode.Interface())
}
node = cnode // Update the original node to the pointer
nodes[i] = node // Update the original slice with the pointer
}
var unfoldedNodes []ast.Node
switch cnode := node.(type) {
case *ast.GroupNode:
if cnode.Key != "" { // group nodes should not get a default key
cnode.Key = e.remapKey(cnode.Key, defaultKey)
}
groupNodes, err := e.expand(cnode.Nodes, cnode.Key)
if err != nil {
return nil, err
}
cnode.Nodes = groupNodes
case *ast.StringNode:
cnode.Key = e.remapKey(cnode.Key, defaultKey)
cnode.Value = e.lowerValue(cnode.Key, cnode.Value)
unfoldedNodes = e.unfoldValue(cnode.Key, cnode.Value)
case *ast.DateTimeNode:
cnode.Key = e.remapKey(cnode.Key, defaultKey)
case *ast.BooleanNode:
cnode.Key = e.remapKey(cnode.Key, defaultKey)
}
if unfoldedNodes != nil {
// Insert unfolded nodes at the current index
nodes = append(nodes[:i], append(unfoldedNodes, nodes[i+1:]...)...)
// Adjust index to account for new nodes
i += len(unfoldedNodes) - 1
}
}
return nodes, nil
}
func (_ kqlExpander) remapKey(current string, defaultKey string) string {
if defaultKey == "" {
defaultKey = "Name" // Set a default key if none is provided
}
key, ok := map[string]string{
"": defaultKey, // Default case if current is empty
"rootid": "RootID",
"path": "Path",
"id": "ID",
"name": "Name",
"size": "Size",
"mtime": "Mtime",
"mediatype": "MimeType",
"type": "Type",
"tag": "Tags",
"tags": "Tags",
"content": "Content",
"hidden": "Hidden",
}[current]
if !ok {
return current // Return the original key if not found
}
return key
}
func (_ kqlExpander) lowerValue(key, value string) string {
if slices.Contains([]string{"Hidden"}, key) {
return value // ignore certain keys and return the original value
}
return strings.ToLower(value)
}
func (_ kqlExpander) unfoldValue(key, value string) []ast.Node {
result, ok := map[string][]ast.Node{
"MimeType:file": {
&ast.OperatorNode{Value: "NOT"},
&ast.StringNode{Key: key, Value: "httpd/unix-directory"},
},
"MimeType:folder": {
&ast.StringNode{Key: key, Value: "httpd/unix-directory"},
},
"MimeType:document": {
&ast.GroupNode{Nodes: []ast.Node{
&ast.StringNode{Key: key, Value: "application/msword"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: key, Value: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: key, Value: "application/vnd.openxmlformats-officedocument.wordprocessingml.form"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: key, Value: "application/vnd.oasis.opendocument.text"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: key, Value: "text/plain"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: key, Value: "text/markdown"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: key, Value: "application/rtf"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: key, Value: "application/vnd.apple.pages"},
}},
},
"MimeType:spreadsheet": {
&ast.GroupNode{Nodes: []ast.Node{
&ast.StringNode{Key: key, Value: "application/vnd.ms-excel"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: key, Value: "application/vnd.oasis.opendocument.spreadsheet"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: key, Value: "text/csv"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: key, Value: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: key, Value: "application/vnd.oasis.opendocument.spreadshee"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: key, Value: "application/vnd.apple.numbers"},
}},
},
"MimeType:presentation": {
&ast.GroupNode{Nodes: []ast.Node{
&ast.StringNode{Key: key, Value: "application/vnd.openxmlformats-officedocument.presentationml.presentation"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: key, Value: "application/vnd.oasis.opendocument.presentation"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: key, Value: "application/vnd.ms-powerpoint"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: key, Value: "application/vnd.apple.keynote"},
}},
},
"MimeType:pdf": {
&ast.StringNode{Key: key, Value: "application/pdf"},
},
"MimeType:image": {
&ast.StringNode{Key: key, Value: "image/*"},
},
"MimeType:video": {
&ast.StringNode{Key: key, Value: "video/*"},
},
"MimeType:audio": {
&ast.StringNode{Key: key, Value: "audio/*"},
},
"MimeType:archive": {
&ast.GroupNode{Nodes: []ast.Node{
&ast.StringNode{Key: key, Value: "application/zip"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: key, Value: "application/gzip"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: key, Value: "application/x-gzip"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: key, Value: "application/x-7z-compressed"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: key, Value: "application/x-rar-compressed"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: key, Value: "application/x-tar"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: key, Value: "application/x-bzip2"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: key, Value: "application/x-bzip"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: key, Value: "application/x-tgz"},
}},
},
}[fmt.Sprintf("%s:%s", key, value)]
if !ok {
return nil
}
return result
}
@@ -1,607 +0,0 @@
package convert_test
import (
"fmt"
"testing"
"github.com/stretchr/testify/require"
"github.com/opencloud-eu/opencloud/services/search/pkg/opensearch/internal/convert"
"github.com/opencloud-eu/opencloud/pkg/ast"
"github.com/opencloud-eu/opencloud/services/search/pkg/opensearch/internal/test"
)
func TestExpandKQLAST(t *testing.T) {
t.Run("always converts a value node to a pointer node", func(t *testing.T) {
tests := []opensearchtest.TableTest[[]ast.Node, []ast.Node]{
{
Name: "ast.node.V -> ast.node.PTR",
Got: []ast.Node{
&ast.StringNode{Key: "a"},
&ast.OperatorNode{Value: "AND"},
ast.StringNode{Key: "b"},
ast.OperatorNode{Value: "AND"},
&ast.DateTimeNode{Key: "c"},
&ast.OperatorNode{Value: "OR"},
ast.DateTimeNode{Key: "d"},
ast.OperatorNode{Value: "OR"},
&ast.BooleanNode{Key: "f"},
&ast.OperatorNode{Value: "NOT"},
ast.BooleanNode{Key: "g"},
ast.OperatorNode{Value: "NOT"},
&ast.GroupNode{Key: "h", Nodes: []ast.Node{
&ast.StringNode{Key: "a"},
&ast.OperatorNode{Value: "AND"},
ast.StringNode{Key: "b"},
&ast.OperatorNode{Value: "OR"},
&ast.GroupNode{Key: "h", Nodes: []ast.Node{
&ast.StringNode{Key: "a"},
&ast.OperatorNode{Value: "AND"},
ast.StringNode{Key: "b"},
&ast.OperatorNode{Value: "OR"},
&ast.GroupNode{Key: "h", Nodes: []ast.Node{
&ast.StringNode{Key: "a"},
&ast.OperatorNode{Value: "AND"},
ast.StringNode{Key: "b"},
}},
}},
}},
ast.GroupNode{Key: "i", Nodes: []ast.Node{
ast.StringNode{Key: "a"},
ast.OperatorNode{Value: "AND"},
ast.StringNode{Key: "b"},
ast.OperatorNode{Value: "OR"},
ast.GroupNode{Key: "h", Nodes: []ast.Node{
ast.StringNode{Key: "a"},
ast.OperatorNode{Value: "AND"},
ast.StringNode{Key: "b"},
ast.OperatorNode{Value: "OR"},
ast.GroupNode{Key: "h", Nodes: []ast.Node{
ast.StringNode{Key: "a"},
ast.OperatorNode{Value: "AND"},
ast.StringNode{Key: "b"},
}},
}},
}},
},
Want: []ast.Node{
&ast.StringNode{Key: "a"},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "b"},
&ast.OperatorNode{Value: "AND"},
&ast.DateTimeNode{Key: "c"},
&ast.OperatorNode{Value: "OR"},
&ast.DateTimeNode{Key: "d"},
&ast.OperatorNode{Value: "OR"},
&ast.BooleanNode{Key: "f"},
&ast.OperatorNode{Value: "NOT"},
&ast.BooleanNode{Key: "g"},
&ast.OperatorNode{Value: "NOT"},
&ast.GroupNode{Key: "h", Nodes: []ast.Node{
&ast.StringNode{Key: "a"},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "b"},
&ast.OperatorNode{Value: "OR"},
&ast.GroupNode{Key: "h", Nodes: []ast.Node{
&ast.StringNode{Key: "a"},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "b"},
&ast.OperatorNode{Value: "OR"},
&ast.GroupNode{Key: "h", Nodes: []ast.Node{
&ast.StringNode{Key: "a"},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "b"},
}},
}},
}},
&ast.GroupNode{Key: "i", Nodes: []ast.Node{
&ast.StringNode{Key: "a"},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "b"},
&ast.OperatorNode{Value: "OR"},
&ast.GroupNode{Key: "h", Nodes: []ast.Node{
&ast.StringNode{Key: "a"},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "b"},
&ast.OperatorNode{Value: "OR"},
&ast.GroupNode{Key: "h", Nodes: []ast.Node{
&ast.StringNode{Key: "a"},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "b"},
}},
}},
}},
},
},
}
for _, test := range tests {
t.Run(test.Name, func(t *testing.T) {
result, err := convert.ExpandKQL(test.Got)
require.NoError(t, err)
require.Equal(t, test.Want, result)
})
}
})
t.Run("remaps some keys", func(t *testing.T) {
var tests []opensearchtest.TableTest[[]ast.Node, []ast.Node]
for k, v := range map[string]string{
"": "Name", // Default to "Name" if no key is provided
"rootid": "RootID",
"path": "Path",
"id": "ID",
"name": "Name",
"size": "Size",
"mtime": "Mtime",
"mediatype": "MimeType",
"type": "Type",
"tag": "Tags",
"tags": "Tags",
"content": "Content",
"hidden": "Hidden",
"any": "any", // Example of an unknown key that should remain unchanged
} {
tests = append(tests, opensearchtest.TableTest[[]ast.Node, []ast.Node]{
Name: fmt.Sprintf("%s -> %s", k, v),
Got: []ast.Node{
&ast.StringNode{Key: k},
&ast.OperatorNode{Value: "AND"},
ast.StringNode{Key: k},
ast.OperatorNode{Value: "AND"},
&ast.DateTimeNode{Key: k},
&ast.OperatorNode{Value: "OR"},
ast.DateTimeNode{Key: k},
ast.OperatorNode{Value: "OR"},
&ast.BooleanNode{Key: k},
&ast.OperatorNode{Value: "NOT"},
ast.BooleanNode{Key: k},
ast.OperatorNode{Value: "NOT"},
&ast.GroupNode{Key: k, Nodes: []ast.Node{
&ast.StringNode{Key: k},
&ast.OperatorNode{Value: "AND"},
ast.StringNode{Key: k},
&ast.OperatorNode{Value: "OR"},
&ast.GroupNode{Key: k, Nodes: []ast.Node{
&ast.StringNode{Key: k},
&ast.OperatorNode{Value: "AND"},
ast.StringNode{Key: k},
&ast.OperatorNode{Value: "OR"},
&ast.GroupNode{Key: k, Nodes: []ast.Node{
&ast.StringNode{Key: k},
&ast.OperatorNode{Value: "AND"},
ast.StringNode{Key: k},
}},
}},
}},
},
Want: []ast.Node{
&ast.StringNode{Key: v},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: v},
&ast.OperatorNode{Value: "AND"},
&ast.DateTimeNode{Key: v},
&ast.OperatorNode{Value: "OR"},
&ast.DateTimeNode{Key: v},
&ast.OperatorNode{Value: "OR"},
&ast.BooleanNode{Key: v},
&ast.OperatorNode{Value: "NOT"},
&ast.BooleanNode{Key: v},
&ast.OperatorNode{Value: "NOT"},
&ast.GroupNode{Key: func() string {
switch {
case k == "":
return k
default:
return v
}
}(), Nodes: []ast.Node{
&ast.StringNode{Key: v},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: v},
&ast.OperatorNode{Value: "OR"},
&ast.GroupNode{Key: func() string {
switch {
case k == "":
return k
default:
return v
}
}(), Nodes: []ast.Node{
&ast.StringNode{Key: v},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: v},
&ast.OperatorNode{Value: "OR"},
&ast.GroupNode{Key: func() string {
switch {
case k == "":
return k
default:
return v
}
}(), Nodes: []ast.Node{
&ast.StringNode{Key: v},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: v},
}},
}},
}},
},
})
}
for _, test := range tests {
t.Run(test.Name, func(t *testing.T) {
result, err := convert.ExpandKQL(test.Got)
require.NoError(t, err)
require.Equal(t, test.Want, result)
})
}
})
t.Run("lowercases some values", func(t *testing.T) {
tests := []opensearchtest.TableTest[[]ast.Node, []ast.Node]{
{
Name: "!Hidden: StringNode -> stringnode",
Got: []ast.Node{
ast.StringNode{Key: "aBc", Value: "StringNode"},
ast.GroupNode{Key: "GroupNode", Nodes: []ast.Node{
ast.StringNode{Key: "aBc", Value: "StringNode"},
}},
},
Want: []ast.Node{
&ast.StringNode{Key: "aBc", Value: "stringnode"},
&ast.GroupNode{Key: "GroupNode", Nodes: []ast.Node{
&ast.StringNode{Key: "aBc", Value: "stringnode"},
}},
},
},
{
Name: "Hidden: StringNode -> StringNode",
Got: []ast.Node{
ast.StringNode{Key: "Hidden", Value: "StringNode"},
ast.GroupNode{Key: "GroupNode", Nodes: []ast.Node{
ast.StringNode{Key: "Hidden", Value: "StringNode"},
}},
},
Want: []ast.Node{
&ast.StringNode{Key: "Hidden", Value: "StringNode"},
&ast.GroupNode{Key: "GroupNode", Nodes: []ast.Node{
&ast.StringNode{Key: "Hidden", Value: "StringNode"},
}},
},
},
}
for _, test := range tests {
t.Run(test.Name, func(t *testing.T) {
result, err := convert.ExpandKQL(test.Got)
require.NoError(t, err)
require.Equal(t, test.Want, result)
})
}
})
t.Run("unfolds some values", func(t *testing.T) {
tests := []opensearchtest.TableTest[[]ast.Node, []ast.Node]{
{
Name: "MimeType:unknown",
Got: []ast.Node{
&ast.StringNode{Key: "MimeType", Value: "unknown"},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "Name", Value: "some-name"},
},
Want: []ast.Node{
&ast.StringNode{Key: "MimeType", Value: "unknown"},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "Name", Value: `some-name`},
},
},
{
Name: "MimeType:file",
Got: []ast.Node{
&ast.StringNode{Key: "MimeType", Value: "file"},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "Name", Value: "some-name"},
},
Want: []ast.Node{
&ast.OperatorNode{Value: "NOT"},
&ast.StringNode{Key: "MimeType", Value: "httpd/unix-directory"},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "Name", Value: `some-name`},
},
},
{
Name: "MimeType:folder",
Got: []ast.Node{
ast.BooleanNode{Key: "Deleted", Value: false},
ast.OperatorNode{Value: "AND"},
ast.StringNode{Key: "MimeType", Value: "folder"},
ast.OperatorNode{Value: "AND"},
ast.StringNode{Value: "some-name"},
},
Want: []ast.Node{
&ast.BooleanNode{Key: "Deleted", Value: false},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "MimeType", Value: "httpd/unix-directory"},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "Name", Value: `some-name`},
},
},
{
Name: "MimeType:document",
Got: []ast.Node{
ast.BooleanNode{Key: "Deleted", Value: false},
ast.OperatorNode{Value: "AND"},
ast.StringNode{Key: "MimeType", Value: "document"},
ast.OperatorNode{Value: "AND"},
ast.StringNode{Value: "some-name"},
},
Want: []ast.Node{
&ast.BooleanNode{Key: "Deleted", Value: false},
&ast.OperatorNode{Value: "AND"},
&ast.GroupNode{Nodes: []ast.Node{
&ast.StringNode{Key: "MimeType", Value: "application/msword"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "MimeType", Value: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "MimeType", Value: "application/vnd.openxmlformats-officedocument.wordprocessingml.form"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "MimeType", Value: "application/vnd.oasis.opendocument.text"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "MimeType", Value: "text/plain"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "MimeType", Value: "text/markdown"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "MimeType", Value: "application/rtf"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "MimeType", Value: "application/vnd.apple.pages"},
}},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "Name", Value: `some-name`},
},
},
{
Name: "MimeType:spreadsheet",
Got: []ast.Node{
ast.BooleanNode{Key: "Deleted", Value: false},
ast.OperatorNode{Value: "AND"},
ast.StringNode{Key: "MimeType", Value: "spreadsheet"},
ast.OperatorNode{Value: "AND"},
ast.StringNode{Value: "some-name"},
},
Want: []ast.Node{
&ast.BooleanNode{Key: "Deleted", Value: false},
&ast.OperatorNode{Value: "AND"},
&ast.GroupNode{Nodes: []ast.Node{
&ast.StringNode{Key: "MimeType", Value: "application/vnd.ms-excel"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "MimeType", Value: "application/vnd.oasis.opendocument.spreadsheet"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "MimeType", Value: "text/csv"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "MimeType", Value: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "MimeType", Value: "application/vnd.oasis.opendocument.spreadshee"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "MimeType", Value: "application/vnd.apple.numbers"},
}},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "Name", Value: `some-name`},
},
},
{
Name: "MimeType:presentation",
Got: []ast.Node{
ast.BooleanNode{Key: "Deleted", Value: false},
ast.OperatorNode{Value: "AND"},
ast.StringNode{Key: "MimeType", Value: "presentation"},
ast.OperatorNode{Value: "AND"},
ast.StringNode{Value: "some-name"},
},
Want: []ast.Node{
&ast.BooleanNode{Key: "Deleted", Value: false},
&ast.OperatorNode{Value: "AND"},
&ast.GroupNode{Nodes: []ast.Node{
&ast.StringNode{Key: "MimeType", Value: "application/vnd.openxmlformats-officedocument.presentationml.presentation"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "MimeType", Value: "application/vnd.oasis.opendocument.presentation"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "MimeType", Value: "application/vnd.ms-powerpoint"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "MimeType", Value: "application/vnd.apple.keynote"},
}},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "Name", Value: `some-name`},
},
},
{
Name: "MimeType:pdf",
Got: []ast.Node{
ast.BooleanNode{Key: "Deleted", Value: false},
ast.OperatorNode{Value: "AND"},
ast.StringNode{Key: "MimeType", Value: "pdf"},
ast.OperatorNode{Value: "AND"},
ast.StringNode{Value: "some-name"},
},
Want: []ast.Node{
&ast.BooleanNode{Key: "Deleted", Value: false},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "MimeType", Value: "application/pdf"},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "Name", Value: `some-name`},
},
},
{
Name: "MimeType:image",
Got: []ast.Node{
ast.BooleanNode{Key: "Deleted", Value: false},
ast.OperatorNode{Value: "AND"},
ast.StringNode{Key: "MimeType", Value: "image"},
ast.OperatorNode{Value: "AND"},
ast.StringNode{Value: "some-name"},
},
Want: []ast.Node{
&ast.BooleanNode{Key: "Deleted", Value: false},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "MimeType", Value: "image/*"},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "Name", Value: `some-name`},
},
},
{
Name: "MimeType:video",
Got: []ast.Node{
ast.BooleanNode{Key: "Deleted", Value: false},
ast.OperatorNode{Value: "AND"},
ast.StringNode{Key: "MimeType", Value: "video"},
ast.OperatorNode{Value: "AND"},
ast.StringNode{Value: "some-name"},
},
Want: []ast.Node{
&ast.BooleanNode{Key: "Deleted", Value: false},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "MimeType", Value: "video/*"},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "Name", Value: `some-name`},
},
},
{
Name: "MimeType:audio",
Got: []ast.Node{
ast.BooleanNode{Key: "Deleted", Value: false},
ast.OperatorNode{Value: "AND"},
ast.StringNode{Key: "MimeType", Value: "audio"},
ast.OperatorNode{Value: "AND"},
ast.StringNode{Value: "some-name"},
},
Want: []ast.Node{
&ast.BooleanNode{Key: "Deleted", Value: false},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "MimeType", Value: "audio/*"},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "Name", Value: `some-name`},
},
},
{
Name: "MimeType:archive",
Got: []ast.Node{
ast.BooleanNode{Key: "Deleted", Value: false},
ast.OperatorNode{Value: "AND"},
ast.StringNode{Key: "MimeType", Value: "archive"},
ast.OperatorNode{Value: "AND"},
ast.StringNode{Value: "some-name"},
},
Want: []ast.Node{
&ast.BooleanNode{Key: "Deleted", Value: false},
&ast.OperatorNode{Value: "AND"},
&ast.GroupNode{Nodes: []ast.Node{
&ast.StringNode{Key: "MimeType", Value: "application/zip"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "MimeType", Value: "application/gzip"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "MimeType", Value: "application/x-gzip"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "MimeType", Value: "application/x-7z-compressed"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "MimeType", Value: "application/x-rar-compressed"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "MimeType", Value: "application/x-tar"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "MimeType", Value: "application/x-bzip2"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "MimeType", Value: "application/x-bzip"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "MimeType", Value: "application/x-tgz"},
}},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "Name", Value: `some-name`},
},
},
}
for _, test := range tests {
t.Run(test.Name, func(t *testing.T) {
if test.Skip {
t.Skip("Skipping test due to known issue")
}
result, err := convert.ExpandKQL(test.Got)
require.NoError(t, err)
require.EqualValues(t, test.Want, result)
})
}
})
t.Run("different cases", func(t *testing.T) {
tests := []opensearchtest.TableTest[[]ast.Node, []ast.Node]{
{
Name: "use the group node key as default key",
Got: []ast.Node{
&ast.GroupNode{Nodes: []ast.Node{
&ast.StringNode{Value: "b"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "c", Value: "d"},
}},
&ast.OperatorNode{Value: "AND"},
&ast.GroupNode{Key: "a", Nodes: []ast.Node{
&ast.StringNode{Value: "b"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "c", Value: "d"},
}},
&ast.OperatorNode{Value: "AND"},
&ast.GroupNode{Key: "mediatype", Nodes: []ast.Node{
&ast.StringNode{Value: "file"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "c", Value: "d"},
}},
&ast.OperatorNode{Value: "AND"},
&ast.GroupNode{Nodes: []ast.Node{
&ast.StringNode{Key: "mediatype", Value: "file"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "c", Value: "d"},
}},
},
Want: []ast.Node{
&ast.GroupNode{Nodes: []ast.Node{
&ast.StringNode{Key: "Name", Value: "b"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "c", Value: "d"},
}},
&ast.OperatorNode{Value: "AND"},
&ast.GroupNode{Key: "a", Nodes: []ast.Node{
&ast.StringNode{Key: "a", Value: "b"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "c", Value: "d"},
}},
&ast.OperatorNode{Value: "AND"},
&ast.GroupNode{Key: "MimeType", Nodes: []ast.Node{
&ast.OperatorNode{Value: "NOT"},
&ast.StringNode{Key: "MimeType", Value: "httpd/unix-directory"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "c", Value: "d"},
}},
&ast.OperatorNode{Value: "AND"},
&ast.GroupNode{Nodes: []ast.Node{
&ast.OperatorNode{Value: "NOT"},
&ast.StringNode{Key: "MimeType", Value: "httpd/unix-directory"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "c", Value: "d"},
}},
},
},
}
for _, test := range tests {
t.Run(test.Name, func(t *testing.T) {
if test.Skip {
t.Skip("Skipping test due to known issue")
}
result, err := convert.ExpandKQL(test.Got)
require.NoError(t, err)
require.EqualValues(t, test.Want, result)
})
}
})
}
@@ -5,6 +5,7 @@ import (
"github.com/opencloud-eu/opencloud/pkg/kql"
"github.com/opencloud-eu/opencloud/services/search/pkg/opensearch/internal/osu"
"github.com/opencloud-eu/opencloud/services/search/pkg/query"
)
var (
@@ -17,12 +18,10 @@ func KQLToOpenSearchBoolQuery(kqlQuery string) (*osu.BoolQuery, error) {
return nil, fmt.Errorf("failed to build query: %w", err)
}
kqlNodes, err := ExpandKQL(kqlAst.Nodes)
if err != nil {
return nil, fmt.Errorf("failed to expand KQL AST nodes: %w", err)
}
// shared lowering: field resolution, media-type expansion, value lowercasing.
kqlAst = query.Normalize(kqlAst, query.ResolveField)
builder, err := TranspileKQLToOpenSearch(kqlNodes)
builder, err := TranspileKQLToOpenSearch(kqlAst.Nodes)
if err != nil {
return nil, fmt.Errorf("failed to compile query: %w", err)
}
@@ -8,7 +8,9 @@ import (
"github.com/opencloud-eu/opencloud/pkg/ast"
"github.com/opencloud-eu/opencloud/pkg/kql"
"github.com/opencloud-eu/opencloud/services/search/pkg/mapping"
"github.com/opencloud-eu/opencloud/services/search/pkg/opensearch/internal/osu"
"github.com/opencloud-eu/opencloud/services/search/pkg/query"
)
func TranspileKQLToOpenSearch(nodes []ast.Node) (osu.Builder, error) {
@@ -46,13 +48,21 @@ func (t kqlOpensearchTranspiler) transpile(nodes []ast.Node) (osu.Builder, error
nextOp := t.getOperatorValueAt(nodes, i+1)
prevOp := t.getOperatorValueAt(nodes, i-1)
// A preceding NOT negates this node regardless of what follows (NOT x AND y
// is (NOT x) AND y), so it must win over nextOp. The prevOp AND/OR cases
// give the right operand its own bucket instead of inheriting the previous
// one, which matters right after a NOT (its MustNot must not carry over).
switch {
case prevOp == kql.BoolNOT:
boolQueryAdd = boolQuery.MustNot
case nextOp == kql.BoolOR:
boolQueryAdd = boolQuery.Should
case nextOp == kql.BoolAND:
boolQueryAdd = boolQuery.Must
case prevOp == kql.BoolNOT:
boolQueryAdd = boolQuery.MustNot
case prevOp == kql.BoolOR:
boolQueryAdd = boolQuery.Should
case prevOp == kql.BoolAND:
boolQueryAdd = boolQuery.Must
}
builder, err := t.toBuilder(node)
@@ -69,7 +79,7 @@ func (t kqlOpensearchTranspiler) transpile(nodes []ast.Node) (osu.Builder, error
continue
}
if nextOp == kql.BoolOR {
if nextOp == kql.BoolOR || prevOp == kql.BoolOR {
// if there are should clauses, we set the minimum should match to 1
boolQueryParams.MinimumShouldMatch = 1
}
@@ -99,22 +109,39 @@ func (t kqlOpensearchTranspiler) toBuilder(node ast.Node) (osu.Builder, error) {
case *ast.BooleanNode:
return osu.NewTermQuery[bool](node.Key).Value(node.Value), nil
case *ast.StringNode:
isWildcard := strings.Contains(node.Value, "*")
if isWildcard {
return osu.NewWildcardQuery(node.Key).Value(node.Value), nil
field, value := node.Key, node.Value
if node.CaseInsensitive {
field += mapping.LowercaseSuffix
value = strings.ToLower(value)
}
totalTerms := strings.Split(node.Value, " ")
isWildcard := strings.Contains(value, "*")
if isWildcard {
return osu.NewWildcardQuery(field).Value(value), nil
}
if query.FieldIsFulltext(node.Key) {
return osu.NewMatchPhraseQuery(field).Query(value), nil
}
// a path value is a single term in the path_hierarchy token stream; a
// phrase match would analyze the query into its path prefixes and match
// everything under the root, so paths with spaces must stay term queries.
if query.FieldIsPath(node.Key) {
return osu.NewTermQuery[string](field).Value(value), nil
}
totalTerms := strings.Split(value, " ")
isSingleTerm := len(totalTerms) == 1
isMultiTerm := len(totalTerms) >= 1
switch {
case isSingleTerm:
return osu.NewTermQuery[string](node.Key).Value(node.Value), nil
return osu.NewTermQuery[string](field).Value(value), nil
case isMultiTerm:
return osu.NewMatchPhraseQuery(node.Key).Query(node.Value), nil
return osu.NewMatchPhraseQuery(field).Query(value), nil
}
return nil, fmt.Errorf("unsupported string node value: %s", node.Value)
return nil, fmt.Errorf("unsupported string node value: %s", value)
case *ast.DateTimeNode:
if node.Operator == nil {
return builder, fmt.Errorf("date time node without operator: %w", ErrUnsupportedNodeType)
@@ -24,6 +24,33 @@ func TestTranspileKQLToOpenSearch(t *testing.T) {
},
Want: osu.NewTermQuery[string]("Name").Value("openCloud"),
},
{
Name: "case-insensitive term routes to the lowercased sibling",
Got: &ast.Ast{
Nodes: []ast.Node{
&ast.StringNode{Key: "Name", Value: "openCloud", CaseInsensitive: true},
},
},
Want: osu.NewTermQuery[string]("Name_lowercase").Value("opencloud"),
},
{
Name: "case-insensitive wildcard routes to the lowercased sibling",
Got: &ast.Ast{
Nodes: []ast.Node{
&ast.StringNode{Key: "Name", Value: "Open*", CaseInsensitive: true},
},
},
Want: osu.NewWildcardQuery("Name_lowercase").Value("open*"),
},
{
Name: "full-text field uses an analyzed match query, not an unanalyzed term",
Got: &ast.Ast{
Nodes: []ast.Node{
&ast.StringNode{Key: "Content", Value: "Running"},
},
},
Want: osu.NewMatchPhraseQuery("Content").Query("Running"),
},
{
Name: "term query - boolean node - true",
Got: &ast.Ast{
@@ -60,6 +87,26 @@ func TestTranspileKQLToOpenSearch(t *testing.T) {
},
Want: osu.NewWildcardQuery("Name").Value("open*"),
},
{
// a phrase match would analyze the query with path_hierarchy and match
// everything under the root
Name: "path with spaces stays an unanalyzed term query",
Got: &ast.Ast{
Nodes: []ast.Node{
&ast.StringNode{Key: "Path", Value: "./parent d!r/child.pdf"},
},
},
Want: osu.NewTermQuery[string]("Path").Value("./parent d!r/child.pdf"),
},
{
Name: "case-insensitive path with spaces routes to the lowercased sibling as a term query",
Got: &ast.Ast{
Nodes: []ast.Node{
&ast.StringNode{Key: "Path", Value: "./Parent Dir", CaseInsensitive: true},
},
},
Want: osu.NewTermQuery[string]("Path_lowercase").Value("./parent dir"),
},
{
Name: "bool query",
Got: &ast.Ast{
@@ -223,6 +270,26 @@ func TestTranspileKQLToOpenSearch(t *testing.T) {
osu.NewTermQuery[string]("age").Value("32"),
),
},
{
// NOT binds to the node directly after it, not to whatever operator
// follows that node: NOT x AND y is (NOT x) AND y.
Name: "[NOT * AND *]",
Got: &ast.Ast{
Nodes: []ast.Node{
&ast.OperatorNode{Value: "NOT"},
&ast.StringNode{Key: "age", Value: "32"},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "Name", Value: "openCloud"},
},
},
Want: osu.NewBoolQuery().
MustNot(
osu.NewTermQuery[string]("age").Value("32"),
).
Must(
osu.NewTermQuery[string]("Name").Value("openCloud"),
),
},
{
Name: "[* OR * OR *]",
Got: &ast.Ast{
@@ -3,7 +3,6 @@ package convert
import (
"fmt"
"strings"
"time"
opensearchgoAPI "github.com/opensearch-project/opensearch-go/v4/opensearchapi"
"google.golang.org/protobuf/types/known/timestamppb"
@@ -15,6 +14,17 @@ import (
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
)
// copyFacet converts a typed pointer from the indexed shape (libregraph) to
// the protobuf shape via conversions.To. Returns nil when src is nil so the
// enclosing Match.Entity field stays nil.
func copyFacet[Dst, Src any](src *Src) *Dst {
if src == nil {
return nil
}
dst, _ := conversions.To[*Dst](src)
return dst
}
func OpenSearchHitToMatch(hit opensearchgoAPI.SearchHit) (*searchMessage.Match, error) {
resource, err := conversions.To[search.Resource](hit.Source)
if err != nil {
@@ -69,31 +79,15 @@ func OpenSearchHitToMatch(hit opensearchgoAPI.SearchHit) (*searchMessage.Match,
return strings.Join(contentHighlights[:], "; ")
}(),
Audio: func() *searchMessage.Audio {
if !strings.HasPrefix(resource.MimeType, "audio/") {
return nil
}
audio, _ := conversions.To[*searchMessage.Audio](resource.Audio)
return audio
}(),
Image: func() *searchMessage.Image {
image, _ := conversions.To[*searchMessage.Image](resource.Image)
return image
}(),
Location: func() *searchMessage.GeoCoordinates {
geoCoordinates, _ := conversions.To[*searchMessage.GeoCoordinates](resource.Location)
return geoCoordinates
}(),
Photo: func() *searchMessage.Photo {
photo, _ := conversions.To[*searchMessage.Photo](resource.Photo)
return photo
}(),
Audio: copyFacet[searchMessage.Audio](resource.Audio),
Image: copyFacet[searchMessage.Image](resource.Image),
Location: copyFacet[searchMessage.GeoCoordinates](resource.Location),
Photo: copyFacet[searchMessage.Photo](resource.Photo),
},
}
if mtime, err := time.Parse(time.RFC3339, resource.Mtime); err == nil {
match.Entity.LastModifiedTime = &timestamppb.Timestamp{Seconds: mtime.Unix(), Nanos: int32(mtime.Nanosecond())}
if resource.Mtime != nil {
match.Entity.LastModifiedTime = timestamppb.New(*resource.Mtime)
}
return match, nil
@@ -36,7 +36,7 @@ var _ = Describe("OpenSearchHitToMatch", func() {
resource = opensearchtest.Testdata.Resources.File
resource.MimeType = "audio/mpeg"
mtime = time.Date(2025, 7, 24, 15, 15, 1, 0, time.UTC)
resource.Mtime = mtime.Format(time.RFC3339)
resource.Mtime = &mtime
resource.Favorites = []string{"cbf24bce-3e6e-4d9e-a2a2-cbf24bce3e6e"}
hit = opensearchgoAPI.SearchHit{
@@ -1,49 +0,0 @@
{
"settings": {
"number_of_shards": "1",
"number_of_replicas": "1",
"analysis": {
"analyzer": {
"path_hierarchy": {
"filter": [
"lowercase"
],
"tokenizer": "path_hierarchy",
"type": "custom"
}
},
"tokenizer": {
"path_hierarchy": {
"type": "path_hierarchy"
}
}
}
},
"mappings": {
"properties": {
"ID": {
"type": "keyword"
},
"ParentID": {
"type": "keyword"
},
"RootID": {
"type": "keyword"
},
"MimeType": {
"type": "wildcard",
"doc_values": false
},
"Path": {
"type": "text",
"analyzer": "path_hierarchy"
},
"Deleted": {
"type": "boolean"
},
"Hidden": {
"type": "boolean"
}
}
}
}
@@ -1,56 +0,0 @@
{
"settings": {
"number_of_shards": "1",
"number_of_replicas": "1",
"analysis": {
"analyzer": {
"path_hierarchy": {
"filter": [
"lowercase"
],
"tokenizer": "path_hierarchy",
"type": "custom"
}
},
"tokenizer": {
"path_hierarchy": {
"type": "path_hierarchy"
}
}
}
},
"mappings": {
"properties": {
"Content": {
"type": "text",
"term_vector": "with_positions_offsets"
},
"ID": {
"type": "keyword"
},
"ParentID": {
"type": "keyword"
},
"RootID": {
"type": "keyword"
},
"MimeType": {
"type": "wildcard",
"doc_values": false
},
"Path": {
"type": "text",
"analyzer": "path_hierarchy"
},
"Deleted": {
"type": "boolean"
},
"Hidden": {
"type": "boolean"
},
"Favorites": {
"type": "keyword"
}
}
}
}
@@ -72,9 +72,15 @@ func setupOpenSearchTestContainer(ctx context.Context, cfg *config.Config) (func
opensearch.WithPassword(cfg.Engine.OpenSearch.Client.Password),
testcontainers.WithName(containerName),
testcontainers.WithReuseByName(containerName),
// test indexes are tiny; don't let a full host disk trip the flood-stage
// create-index / read-only blocks mid-run
testcontainers.WithEnv(map[string]string{
"cluster.routing.allocation.disk.threshold_enabled": "false",
}),
testcontainers.WithWaitStrategy(
wait.ForLog("ML configuration initialized successfully").
WithStartupTimeout(5*time.Second),
// a cold OpenSearch boot takes well over the previous 5s
WithStartupTimeout(2*time.Minute),
),
)
if err != nil {
@@ -8,7 +8,7 @@
"Name" : "dummy name",
"Content" : "dummy content",
"Size" : 42,
"Mtime" : "2025-07-24 15:15:01.324093 +0200 CEST m=+0.000056251",
"Mtime" : "2025-07-24T15:15:01.324093+02:00",
"MimeType" : "image/jpeg",
"Tags" : [ "dummy" ],
"Deleted" : false,
@@ -3,5 +3,6 @@
"RootID" : "1$1!1",
"ParentID" : "1$1!1",
"Path" : "./parent d!r",
"Type" : 2
"Type" : 2,
"Mtime" : "2025-07-24T15:15:01.324093+02:00"
}
@@ -1,5 +1,6 @@
{
"ID" : "1$1!1",
"RootID" : "1$1!1",
"Path" : "."
"Path" : ".",
"Mtime" : "2025-07-24T15:15:01.324093+02:00"
}
+4
View File
@@ -22,6 +22,10 @@ func (c Creator[T]) Create(qs string) (T, error) {
return t, err
}
// shared KQL lowering pass: resolve field names + expand media-type aliases
// once, so the compiler below sees only canonical field:value nodes.
builderAst = query.Normalize(builderAst, query.ResolveField)
t, err = c.compiler.Compile(builderAst)
if err != nil {
return t, err
+27 -134
View File
@@ -8,35 +8,10 @@ import (
bleveQuery "github.com/blevesearch/bleve/v2/search/query"
"github.com/opencloud-eu/opencloud/pkg/ast"
"github.com/opencloud-eu/opencloud/pkg/kql"
"github.com/opencloud-eu/opencloud/services/search/pkg/mapping"
searchQuery "github.com/opencloud-eu/opencloud/services/search/pkg/query"
)
// lowercaseFields lists the bleve fields whose index mapping uses a
// lowercasing analyzer. Values bound to these fields are pre-lowercased
// so query-side matching stays consistent with the index.
// Keep in sync with services/search/pkg/bleve/index.go NewMapping.
var lowercaseFields = map[string]struct{}{
"Name": {},
"Tags": {},
"Favorites": {},
"Content": {},
}
var _fields = map[string]string{
"rootid": "RootID",
"path": "Path",
"id": "ID",
"name": "Name",
"size": "Size",
"mtime": "Mtime",
"mediatype": "MimeType",
"type": "Type",
"tag": "Tags",
"tags": "Tags",
"content": "Content",
"hidden": "Hidden",
"favorite": "Favorites",
}
// The following quoted string enumerates the characters which may be escaped: "+-=&|><!(){}[]^\"~*?:\\/ "
// based on bleve docs https://blevesearch.com/docs/Query-String-Query/
// Wildcards * and ? are excluded
@@ -96,26 +71,30 @@ func walk(offset int, nodes []ast.Node) (bleveQuery.Query, int, error) {
for i := offset; i < len(nodes); i++ {
switch n := nodes[i].(type) {
case *ast.StringNode:
k := getField(n.Key)
// keys are resolved and media-type expanded by normalize. MimeType
// skips the escaper so the category wildcards (image/*) keep their `*`;
// bleve treats `/` and `+` as literals mid-term, so a literal MIME like
// image/svg+xml still matches exactly.
k := n.Key
v := n.Value
if k != "ID" && k != "Size" {
if k != "ID" && k != "Size" && k != "MimeType" {
v = bleveEscaper.Replace(n.Value)
}
if _, ok := lowercaseFields[k]; ok {
if n.CaseInsensitive {
k += mapping.LowercaseSuffix
v = strings.ToLower(v)
}
var q bleveQuery.Query
var group bool
switch k {
case "MimeType":
q, group = mimeType(k, v)
if prev == nil {
isGroup = group
}
default:
q = bleveQuery.NewQueryStringQuery(k + ":" + v)
var q bleveQuery.Query = bleveQuery.NewQueryStringQuery(k + ":" + v)
if searchQuery.FieldIsPath(n.Key) {
// bleve has no path hierarchy analyzer, unlike OpenSearch: match the
// folder itself and its descendants (`\/*`). A BooleanQuery keeps
// this atomic; a DisjunctionQuery would be redistributed by an
// enclosing AND (mapBinary treats a left disjunction as an OR-chain).
bq := bleve.NewBooleanQuery()
bq.AddShould(q, bleveQuery.NewQueryStringQuery(k+":"+v+`\/*`))
bq.SetMinShould(1)
q = bq
}
if prev == nil {
@@ -129,7 +108,7 @@ func walk(offset int, nodes []ast.Node) (bleveQuery.Query, int, error) {
End: bleveQuery.BleveQueryTime{},
InclusiveStart: nil,
InclusiveEnd: nil,
FieldVal: getField(n.Key),
FieldVal: n.Key,
}
if n.Operator == nil {
@@ -159,16 +138,14 @@ func walk(offset int, nodes []ast.Node) (bleveQuery.Query, int, error) {
next = q
}
case *ast.BooleanNode:
q := bleveQuery.NewQueryStringQuery(getField(n.Key) + fmt.Sprintf(":%v", n.Value))
q := bleveQuery.NewQueryStringQuery(n.Key + fmt.Sprintf(":%v", n.Value))
if prev == nil {
prev = q
} else {
next = q
}
case *ast.GroupNode:
if n.Key != "" {
n = normalizeGroupingProperty(n)
}
// keys resolved and grouping property propagated in normalize
q, _, err := walk(0, n.Nodes)
if err != nil {
return nil, 0, err
@@ -191,8 +168,11 @@ func walk(offset int, nodes []ast.Node) (bleveQuery.Query, int, error) {
q := bleve.NewBooleanQuery()
q.AddMustNot(next)
if prev == nil {
// unary in the beginning
// unary at the beginning: the term was consumed into the
// MustNot via nextNode, so clear next, otherwise a following
// operator would bind the stale term (NOT x AND y drops y).
prev = q
next = nil
} else {
next = q
}
@@ -276,90 +256,3 @@ func mapBinary(operator *ast.OperatorNode, ln, rn bleveQuery.Query, leftIsGroup
rn,
})
}
func getField(name string) string {
if name == "" {
return "Name"
}
if _, ok := _fields[strings.ToLower(name)]; ok {
return _fields[strings.ToLower(name)]
}
return name
}
func normalizeGroupingProperty(group *ast.GroupNode) *ast.GroupNode {
for _, n := range group.Nodes {
if onode, ok := n.(*ast.StringNode); ok {
onode.Key = group.Key
}
}
return group
}
func mimeType(k, v string) (bleveQuery.Query, bool) {
switch v {
case "file":
q := bleve.NewBooleanQuery()
q.AddMustNot(bleveQuery.NewQueryStringQuery(k + ":httpd/unix-directory"))
return q, false
case "folder":
return bleveQuery.NewQueryStringQuery(k + ":httpd/unix-directory"), false
case "document":
return bleveQuery.NewDisjunctionQuery(newQueryStringQueryList(k,
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.openxmlformats-officedocument.wordprocessingml.form",
"application/vnd.oasis.opendocument.text",
"text/plain",
"text/markdown",
"application/rtf",
"application/vnd.apple.pages",
)), true
case "spreadsheet":
return bleveQuery.NewDisjunctionQuery(newQueryStringQueryList(k,
"application/vnd.ms-excel",
"application/vnd.oasis.opendocument.spreadsheet",
"text/csv",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.oasis.opendocument.spreadsheet",
"application/vnd.apple.numbers",
)), true
case "presentation":
return bleveQuery.NewDisjunctionQuery(newQueryStringQueryList(k,
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"application/vnd.oasis.opendocument.presentation",
"application/vnd.ms-powerpoint",
"application/vnd.apple.keynote",
)), true
case "pdf":
return bleveQuery.NewQueryStringQuery(k + ":application/pdf"), false
case "image":
return bleveQuery.NewQueryStringQuery(k + ":image/*"), false
case "video":
return bleveQuery.NewQueryStringQuery(k + ":video/*"), false
case "audio":
return bleveQuery.NewQueryStringQuery(k + ":audio/*"), false
case "archive":
return bleveQuery.NewDisjunctionQuery(newQueryStringQueryList(k,
"application/zip",
"application/gzip",
"application/x-gzip",
"application/x-7z-compressed",
"application/x-rar-compressed",
"application/x-tar",
"application/x-bzip2",
"application/x-bzip",
"application/x-tgz",
)), true
default:
return bleveQuery.NewQueryStringQuery(k + ":" + v), false
}
}
func newQueryStringQueryList(k string, v ...string) []bleveQuery.Query {
list := make([]bleveQuery.Query, len(v))
for i := 0; i < len(v); i++ {
list[i] = bleveQuery.NewQueryStringQuery(k + ":" + v[i])
}
return list
}
@@ -6,6 +6,7 @@ import (
"github.com/blevesearch/bleve/v2/search/query"
"github.com/opencloud-eu/opencloud/pkg/ast"
searchquery "github.com/opencloud-eu/opencloud/services/search/pkg/query"
tAssert "github.com/stretchr/testify/assert"
)
@@ -18,6 +19,11 @@ var timeMustParse = func(t *testing.T, ts string) time.Time {
return tp
}
// TODO(followup): make this a pure compiler test. Field resolution and
// media-type expansion live in query.Normalize, so this test could feed
// canonical ASTs (real field names, media-type already expanded) and call
// compile() directly, dropping the query.Normalize wrapper and the mediatype
// cases.
func Test_compile(t *testing.T) {
tests := []struct {
name string
@@ -33,10 +39,31 @@ func Test_compile(t *testing.T) {
},
},
want: query.NewConjunctionQuery([]query.Query{
query.NewQueryStringQuery(`Name:federated`),
query.NewQueryStringQuery(`Name_lowercase:federated`),
}),
wantErr: false,
},
{
// path fields expand to match the folder itself and its descendants,
// since bleve has no path hierarchy analyzer.
name: `path:/Foo`,
args: &ast.Ast{
Nodes: []ast.Node{
&ast.StringNode{Key: "path", Value: "/Foo"},
},
},
// a BooleanQuery (should: exact OR descendants), not a DisjunctionQuery,
// so an enclosing AND does not redistribute the folder-itself clause.
want: func() query.Query {
bq := query.NewBooleanQuery(nil, []query.Query{
query.NewQueryStringQuery(`Path:\/Foo`),
query.NewQueryStringQuery(`Path:\/Foo\/*`),
}, nil)
bq.SetMinShould(1)
return query.NewConjunctionQuery([]query.Query{bq})
}(),
wantErr: false,
},
{
name: `"John Smith"`,
args: &ast.Ast{
@@ -45,7 +72,7 @@ func Test_compile(t *testing.T) {
},
},
want: query.NewConjunctionQuery([]query.Query{
query.NewQueryStringQuery(`Name:john\ smith`),
query.NewQueryStringQuery(`Name_lowercase:john\ smith`),
}),
wantErr: false,
},
@@ -59,8 +86,8 @@ func Test_compile(t *testing.T) {
},
},
want: query.NewConjunctionQuery([]query.Query{
query.NewQueryStringQuery(`Name:john\ smith`),
query.NewQueryStringQuery(`Name:jane`),
query.NewQueryStringQuery(`Name_lowercase:john\ smith`),
query.NewQueryStringQuery(`Name_lowercase:jane`),
}),
wantErr: false,
},
@@ -74,8 +101,8 @@ func Test_compile(t *testing.T) {
},
},
want: query.NewConjunctionQuery([]query.Query{
query.NewQueryStringQuery(`Tags:bestseller`),
query.NewQueryStringQuery(`Tags:book`),
query.NewQueryStringQuery(`Tags_lowercase:bestseller`),
query.NewQueryStringQuery(`Tags_lowercase:book`),
}),
wantErr: false,
},
@@ -91,10 +118,10 @@ func Test_compile(t *testing.T) {
},
},
want: query.NewDisjunctionQuery([]query.Query{
query.NewQueryStringQuery(`Name:moby\ di*`),
query.NewQueryStringQuery(`Name_lowercase:moby\ di*`),
query.NewConjunctionQuery([]query.Query{
query.NewQueryStringQuery(`Tags:bestseller`),
query.NewQueryStringQuery(`Tags:book`),
query.NewQueryStringQuery(`Tags_lowercase:bestseller`),
query.NewQueryStringQuery(`Tags_lowercase:book`),
}),
}),
wantErr: false,
@@ -112,10 +139,10 @@ func Test_compile(t *testing.T) {
},
want: query.NewDisjunctionQuery([]query.Query{
query.NewConjunctionQuery([]query.Query{
query.NewQueryStringQuery(`Name:a`),
query.NewQueryStringQuery(`Name:b`),
query.NewQueryStringQuery(`Name_lowercase:a`),
query.NewQueryStringQuery(`Name_lowercase:b`),
}),
query.NewQueryStringQuery(`Name:c`),
query.NewQueryStringQuery(`Name_lowercase:c`),
}),
wantErr: false,
},
@@ -131,10 +158,10 @@ func Test_compile(t *testing.T) {
},
},
want: query.NewDisjunctionQuery([]query.Query{
query.NewQueryStringQuery(`Name:a`),
query.NewQueryStringQuery(`Name_lowercase:a`),
query.NewConjunctionQuery([]query.Query{
query.NewQueryStringQuery(`Name:b`),
query.NewQueryStringQuery(`Name:c`),
query.NewQueryStringQuery(`Name_lowercase:b`),
query.NewQueryStringQuery(`Name_lowercase:c`),
}),
}),
wantErr: false,
@@ -156,11 +183,11 @@ func Test_compile(t *testing.T) {
},
want: query.NewConjunctionQuery([]query.Query{
query.NewDisjunctionQuery([]query.Query{
query.NewQueryStringQuery(`Name:a`),
query.NewQueryStringQuery(`Name:b`),
query.NewQueryStringQuery(`Name:c`),
query.NewQueryStringQuery(`Name_lowercase:a`),
query.NewQueryStringQuery(`Name_lowercase:b`),
query.NewQueryStringQuery(`Name_lowercase:c`),
}),
query.NewQueryStringQuery(`Name:d`),
query.NewQueryStringQuery(`Name_lowercase:d`),
}),
wantErr: false,
},
@@ -179,10 +206,10 @@ func Test_compile(t *testing.T) {
},
want: query.NewConjunctionQuery([]query.Query{
query.NewDisjunctionQuery([]query.Query{
query.NewQueryStringQuery(`Name:moby\ di*`),
query.NewQueryStringQuery(`Tags:bestseller`),
query.NewQueryStringQuery(`Name_lowercase:moby\ di*`),
query.NewQueryStringQuery(`Tags_lowercase:bestseller`),
}),
query.NewQueryStringQuery(`Tags:book`),
query.NewQueryStringQuery(`Tags_lowercase:book`),
}),
wantErr: false,
},
@@ -204,11 +231,11 @@ func Test_compile(t *testing.T) {
},
want: query.NewConjunctionQuery([]query.Query{
query.NewDisjunctionQuery([]query.Query{
query.NewQueryStringQuery(`Name:moby\ di*`),
query.NewQueryStringQuery(`Tags:bestseller`),
query.NewQueryStringQuery(`Name_lowercase:moby\ di*`),
query.NewQueryStringQuery(`Tags_lowercase:bestseller`),
}),
query.NewQueryStringQuery(`Tags:book`),
query.NewBooleanQuery(nil, nil, []query.Query{query.NewQueryStringQuery(`Tags:read`)}),
query.NewQueryStringQuery(`Tags_lowercase:book`),
query.NewBooleanQuery(nil, nil, []query.Query{query.NewQueryStringQuery(`Tags_lowercase:read`)}),
}),
wantErr: false,
},
@@ -251,7 +278,7 @@ func Test_compile(t *testing.T) {
want: query.NewConjunctionQuery([]query.Query{
query.NewQueryStringQuery(`author:John\ Smith`),
query.NewQueryStringQuery(`author:Jane`),
query.NewQueryStringQuery(`Tags:bestseller`),
query.NewQueryStringQuery(`Tags_lowercase:bestseller`),
}),
wantErr: false,
},
@@ -293,7 +320,7 @@ func Test_compile(t *testing.T) {
},
},
want: query.NewConjunctionQuery([]query.Query{
query.NewQueryStringQuery(`Name:john\ smith`),
query.NewQueryStringQuery(`Name_lowercase:john\ smith`),
query.NewQueryStringQuery(`Hidden:T`),
query.NewQueryStringQuery(`Hidden:T`),
}),
@@ -308,7 +335,7 @@ func Test_compile(t *testing.T) {
},
},
want: query.NewConjunctionQuery([]query.Query{
query.NewBooleanQuery(nil, nil, []query.Query{query.NewQueryStringQuery(`Tags:physik`)}),
query.NewBooleanQuery(nil, nil, []query.Query{query.NewQueryStringQuery(`Tags_lowercase:physik`)}),
}),
wantErr: false,
},
@@ -424,7 +451,7 @@ func Test_compile(t *testing.T) {
query.NewQueryStringQuery(`MimeType:application/rtf`),
query.NewQueryStringQuery(`MimeType:application/vnd.apple.pages`),
}),
query.NewQueryStringQuery(`Name:*tdd*`),
query.NewQueryStringQuery(`Name_lowercase:*tdd*`),
}),
wantErr: false,
},
@@ -450,7 +477,7 @@ func Test_compile(t *testing.T) {
query.NewQueryStringQuery(`MimeType:application/vnd.apple.pages`),
query.NewConjunctionQuery([]query.Query{
query.NewQueryStringQuery(`MimeType:application/pdf`),
query.NewQueryStringQuery(`Name:*tdd*`),
query.NewQueryStringQuery(`Name_lowercase:*tdd*`),
}),
}),
wantErr: false,
@@ -480,7 +507,7 @@ func Test_compile(t *testing.T) {
query.NewQueryStringQuery(`MimeType:application/vnd.apple.pages`),
query.NewQueryStringQuery(`MimeType:application/pdf`),
}),
query.NewQueryStringQuery(`Name:*tdd*`),
query.NewQueryStringQuery(`Name_lowercase:*tdd*`),
}),
wantErr: false,
},
@@ -509,7 +536,7 @@ func Test_compile(t *testing.T) {
query.NewQueryStringQuery(`MimeType:application/rtf`),
query.NewQueryStringQuery(`MimeType:application/vnd.apple.pages`),
}),
query.NewQueryStringQuery(`Name:*tdd*`),
query.NewQueryStringQuery(`Name_lowercase:*tdd*`),
}),
wantErr: false,
},
@@ -521,7 +548,7 @@ func Test_compile(t *testing.T) {
},
},
want: query.NewConjunctionQuery([]query.Query{
query.NewQueryStringQuery(`Name:john\ smith\ \+\-\=\&\|\>\<\!\(\)\{\}\[\]\^\"\~\:\ `),
query.NewQueryStringQuery(`Name_lowercase:john\ smith\ \+\-\=\&\|\>\<\!\(\)\{\}\[\]\^\"\~\:\ `),
}),
wantErr: false,
},
@@ -531,7 +558,7 @@ func Test_compile(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := compile(tt.args)
got, err := compile(searchquery.Normalize(tt.args, searchquery.ResolveField))
if (err != nil) != tt.wantErr {
t.Errorf("compile() error = %v, wantErr %v", err, tt.wantErr)
@@ -0,0 +1,98 @@
// Package mimetype maps the "mediatype" KQL restriction (field name and value)
// to a concrete MimeType query.
package mimetype
import (
"strings"
"github.com/opencloud-eu/opencloud/pkg/ast"
"github.com/opencloud-eu/opencloud/pkg/kql"
)
// field is the real index field a mediatype restriction targets.
const field = "MimeType"
// Expand turns mediatype:<value> into the MimeType query it stands for: category
// values (file/document/image/...) expand to their MIME set, anything else is a
// literal MimeType:<value>. Returns nil for non-mediatype keys. Categories and
// MIME types are case-insensitive, so the value is lowercased.
func Expand(key, value string) []ast.Node {
if strings.ToLower(key) != "mediatype" {
return nil
}
value = strings.ToLower(value)
switch value {
case "file":
return []ast.Node{
&ast.OperatorNode{Value: kql.BoolNOT},
&ast.StringNode{Key: field, Value: "httpd/unix-directory"},
}
case "folder":
return term("httpd/unix-directory")
case "document":
return group(
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.openxmlformats-officedocument.wordprocessingml.form",
"application/vnd.oasis.opendocument.text",
"text/plain",
"text/markdown",
"application/rtf",
"application/vnd.apple.pages",
)
case "spreadsheet":
return group(
"application/vnd.ms-excel",
"application/vnd.oasis.opendocument.spreadsheet",
"text/csv",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.apple.numbers",
)
case "presentation":
return group(
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"application/vnd.oasis.opendocument.presentation",
"application/vnd.ms-powerpoint",
"application/vnd.apple.keynote",
)
case "pdf":
return term("application/pdf")
case "image":
return term("image/*")
case "video":
return term("video/*")
case "audio":
return term("audio/*")
case "archive":
return group(
"application/zip",
"application/gzip",
"application/x-gzip",
"application/x-7z-compressed",
"application/x-rar-compressed",
"application/x-tar",
"application/x-bzip2",
"application/x-bzip",
"application/x-tgz",
)
}
// not a category: treat the value as a literal MIME type.
return term(value)
}
// term is a single MimeType:value restriction.
func term(value string) []ast.Node {
return []ast.Node{&ast.StringNode{Key: field, Value: value}}
}
// group is a single OR group of MimeType:value restrictions.
func group(values ...string) []ast.Node {
nodes := make([]ast.Node, 0, len(values)*2-1)
for i, v := range values {
if i > 0 {
nodes = append(nodes, &ast.OperatorNode{Value: kql.BoolOR})
}
nodes = append(nodes, &ast.StringNode{Key: field, Value: v})
}
return []ast.Node{&ast.GroupNode{Nodes: nodes}}
}
@@ -0,0 +1,13 @@
package mimetype_test
import (
"testing"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestMimetype(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Mimetype Suite")
}
@@ -0,0 +1,108 @@
package mimetype_test
import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/opencloud-eu/opencloud/pkg/ast"
"github.com/opencloud-eu/opencloud/services/search/pkg/query/mimetype"
)
// This is the single place the mediatype -> MimeType mapping is tested. The
// query pipeline consumes Expand via query.Normalize and must NOT re-test it.
// mimeValues extracts the StringNode values from an OR group, dropping operators.
func mimeValues(group *ast.GroupNode) []string {
var out []string
for _, n := range group.Nodes {
if s, ok := n.(*ast.StringNode); ok {
out = append(out, s.Value)
}
}
return out
}
var _ = Describe("Expand", func() {
It("only triggers on the mediatype key", func() {
Expect(mimetype.Expand("Name", "document")).To(BeNil())
Expect(mimetype.Expand("MimeType", "file")).To(BeNil()) // the real field name is not the trigger
Expect(mimetype.Expand("Tags", "file")).To(BeNil())
})
It("matches the key case-insensitively", func() {
Expect(mimetype.Expand("MediaType", "file")).ToNot(BeNil())
})
It("matches the value case-insensitively", func() {
// a category matches regardless of case
Expect(mimetype.Expand("mediatype", "Folder")).To(Equal([]ast.Node{
&ast.StringNode{Key: "MimeType", Value: "httpd/unix-directory"},
}))
// a literal MIME type is lowercased too (MIME types are case-insensitive)
Expect(mimetype.Expand("mediatype", "Image/SVG+XML")).To(Equal([]ast.Node{
&ast.StringNode{Key: "MimeType", Value: "image/svg+xml"},
}))
})
// A non-category value is a literal MIME type and targets the MimeType field.
It("passes literal values through to MimeType", func() {
Expect(mimetype.Expand("mediatype", "application/pdf")).To(Equal([]ast.Node{
&ast.StringNode{Key: "MimeType", Value: "application/pdf"},
}))
Expect(mimetype.Expand("mediatype", "image/jpeg")).To(Equal([]ast.Node{
&ast.StringNode{Key: "MimeType", Value: "image/jpeg"},
}))
})
It("expands file to not-a-folder", func() {
Expect(mimetype.Expand("mediatype", "file")).To(Equal([]ast.Node{
&ast.OperatorNode{Value: "NOT"},
&ast.StringNode{Key: "MimeType", Value: "httpd/unix-directory"},
}))
})
It("expands folder to a single term", func() {
Expect(mimetype.Expand("mediatype", "folder")).To(Equal([]ast.Node{
&ast.StringNode{Key: "MimeType", Value: "httpd/unix-directory"},
}))
})
It("expands wildcard categories", func() {
for value, mime := range map[string]string{
"image": "image/*", "video": "video/*", "audio": "audio/*", "pdf": "application/pdf",
} {
Expect(mimetype.Expand("mediatype", value)).To(Equal([]ast.Node{
&ast.StringNode{Key: "MimeType", Value: mime},
}), value)
}
})
It("expands the document group", func() {
got := mimetype.Expand("mediatype", "document")
Expect(got).To(HaveLen(1))
group, ok := got[0].(*ast.GroupNode)
Expect(ok).To(BeTrue())
Expect(mimeValues(group)).To(Equal([]string{
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.openxmlformats-officedocument.wordprocessingml.form",
"application/vnd.oasis.opendocument.text",
"text/plain",
"text/markdown",
"application/rtf",
"application/vnd.apple.pages",
}))
})
// spreadsheet asserts the exact MIME set, in order, with no duplicate entry.
It("expands the spreadsheet group", func() {
group := mimetype.Expand("mediatype", "spreadsheet")[0].(*ast.GroupNode)
Expect(mimeValues(group)).To(Equal([]string{
"application/vnd.ms-excel",
"application/vnd.oasis.opendocument.spreadsheet",
"text/csv",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.apple.numbers",
}))
})
})
+74
View File
@@ -0,0 +1,74 @@
package query
import (
"reflect"
"github.com/opencloud-eu/opencloud/pkg/ast"
"github.com/opencloud-eu/opencloud/services/search/pkg/query/mimetype"
)
// Normalize is the shared KQL lowering pass between parse and compile: it
// resolves keys to real field names (via resolve) and expands media-type
// restrictions, so the backends compile a plain field:value AST.
func Normalize(a *ast.Ast, resolve func(string) string) *ast.Ast {
a.Nodes = normalizeNodes(a.Nodes, resolve, "")
return a
}
// normalizeNodes rewrites nodes in place. defaultKey is what a bare restriction
// inherits: its enclosing group's key, or "" at the top level.
func normalizeNodes(nodes []ast.Node, resolve func(string) string, defaultKey string) []ast.Node {
resolveKey := func(key string) string {
if key == "" && defaultKey != "" {
return defaultKey // bare child inherits the group key
}
return resolve(key)
}
out := make([]ast.Node, 0, len(nodes))
for _, n := range nodes {
n = toPointer(n) // ensure a pointer so in-place key rewrites persist
switch node := n.(type) {
case *ast.StringNode:
node.Key = resolveKey(node.Key)
if exp := mimetype.Expand(node.Key, node.Value); exp != nil {
out = append(out, normalizeNodes(exp, resolve, defaultKey)...)
continue
}
node.CaseInsensitive = FieldIsCaseInsensitive(node.Key)
out = append(out, node)
case *ast.DateTimeNode:
node.Key = resolveKey(node.Key)
out = append(out, node)
case *ast.BooleanNode:
node.Key = resolveKey(node.Key)
out = append(out, node)
case *ast.GroupNode:
groupKey := defaultKey
if node.Key != "" {
node.Key = resolve(node.Key)
groupKey = node.Key
}
node.Nodes = normalizeNodes(node.Nodes, resolve, groupKey)
out = append(out, node)
default:
out = append(out, n)
}
}
return out
}
// toPointer returns n as a pointer; the parser emits some nodes by value and the
// in-place key rewrites would be lost on those.
func toPointer(n ast.Node) ast.Node {
rv := reflect.ValueOf(n)
if rv.Kind() == reflect.Ptr {
return n
}
ptr := reflect.New(rv.Type())
ptr.Elem().Set(rv)
if pn, ok := ptr.Interface().(ast.Node); ok {
return pn
}
return n
}
+107
View File
@@ -0,0 +1,107 @@
package query_test
import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/opencloud-eu/opencloud/pkg/ast"
"github.com/opencloud-eu/opencloud/services/search/pkg/query"
)
// This is the single place the shared KQL lowering pass is tested (field
// resolution, media-type expansion, group-key defaulting, pointer conversion).
// The backend query compilers consume its canonical output and must not re-test
// it.
func norm(nodes ...ast.Node) []ast.Node {
return query.Normalize(&ast.Ast{Nodes: nodes}, query.ResolveField).Nodes
}
var _ = Describe("ResolveField", func() {
It("resolves keys to canonical field names", func() {
Expect(query.ResolveField("")).To(Equal("Name")) // empty -> free-text default
Expect(query.ResolveField("NAME")).To(Equal("Name")) // canonical, case-insensitive key match
Expect(query.ResolveField("tag")).To(Equal("Tags")) // singular alias
Expect(query.ResolveField("mimetype")).To(Equal("MimeType")) // real field
Expect(query.ResolveField("photo.CAMERAMAKE")).To(Equal("photo.cameraMake")) // facet, case-insensitive key match
Expect(query.ResolveField("unknown.field")).To(Equal("unknown.field")) // unknown key: unchanged, becomes a dead query
})
})
var _ = Describe("FieldIsCaseInsensitive", func() {
It("reports the CaseInsensitive override fields", func() {
// The three CaseInsensitive override fields (resolved canonical names).
for _, f := range []string{"Name", "Tags", "Favorites"} {
Expect(query.FieldIsCaseInsensitive(f)).To(BeTrue(), f)
}
// Case-preserved / non-keyword fields are not.
for _, f := range []string{"MimeType", "ID", "Content", "Path", "unknown"} {
Expect(query.FieldIsCaseInsensitive(f)).To(BeFalse(), f)
}
})
})
var _ = Describe("Normalize", func() {
It("resolves fields and expands mediatype", func() {
got := norm(
&ast.StringNode{Key: "", Value: "free"},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "TAG", Value: "x"},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "photo.cameramake", Value: "canon"},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "mediatype", Value: "file"},
)
Expect(got).To(Equal([]ast.Node{
&ast.StringNode{Key: "Name", Value: "free", CaseInsensitive: true},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "Tags", Value: "x", CaseInsensitive: true},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "photo.cameraMake", Value: "canon"},
&ast.OperatorNode{Value: "AND"},
&ast.OperatorNode{Value: "NOT"},
&ast.StringNode{Key: "MimeType", Value: "httpd/unix-directory"},
}))
})
// A bare restriction inside a named group inherits the group key; a keyed
// child keeps its own key; a bare restriction in an unnamed group falls
// back to Name.
It("defaults group keys", func() {
got := norm(
&ast.GroupNode{Key: "author", Nodes: []ast.Node{
&ast.StringNode{Value: "b"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "name", Value: "d"},
}},
&ast.OperatorNode{Value: "AND"},
&ast.GroupNode{Nodes: []ast.Node{
&ast.StringNode{Value: "e"},
}},
)
Expect(got).To(Equal([]ast.Node{
&ast.GroupNode{Key: "author", Nodes: []ast.Node{
&ast.StringNode{Key: "author", Value: "b"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "Name", Value: "d", CaseInsensitive: true},
}},
&ast.OperatorNode{Value: "AND"},
&ast.GroupNode{Nodes: []ast.Node{
&ast.StringNode{Key: "Name", Value: "e", CaseInsensitive: true},
}},
}))
})
It("converts value nodes to pointers", func() {
got := norm(
ast.StringNode{Key: "name", Value: "x"},
ast.OperatorNode{Value: "AND"},
ast.DateTimeNode{Key: "mtime"},
)
Expect(got).To(Equal([]ast.Node{
&ast.StringNode{Key: "Name", Value: "x", CaseInsensitive: true},
&ast.OperatorNode{Value: "AND"},
&ast.DateTimeNode{Key: "Mtime"},
}))
})
})
@@ -0,0 +1,13 @@
package query_test
import (
"testing"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestQuery(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Query Suite")
}
+88
View File
@@ -0,0 +1,88 @@
package query
import (
"reflect"
"strings"
"sync"
"github.com/opencloud-eu/opencloud/services/search/pkg/mapping"
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
)
// aliases are KQL spellings the derived index can't produce (fields are plural).
var aliases = map[string]string{
"tag": "Tags",
"favorite": "Favorites",
}
// fieldIndex maps a lowercased KQL key to its canonical field name ("" is the
// bare-search default).
var fieldIndex = sync.OnceValue(func() map[string]string {
idx := mapping.FieldNameIndex(reflect.TypeFor[search.Resource](), search.Resource{}.SearchFieldOverrides())
for k, v := range aliases {
idx[k] = v
}
idx[""] = idx["name"]
return idx
})
// caseInsensitiveFields are the fields searched case-insensitively by default,
// derived from the CaseInsensitive overrides.
var caseInsensitiveFields = sync.OnceValue(func() map[string]struct{} {
out := map[string]struct{}{}
for field, opts := range (search.Resource{}).SearchFieldOverrides() {
if opts.CaseInsensitive != nil && *opts.CaseInsensitive {
out[field] = struct{}{}
}
}
return out
})
// pathFields are hierarchical path fields (TypePath), derived from the overrides.
var pathFields = sync.OnceValue(func() map[string]struct{} {
out := map[string]struct{}{}
for field, opts := range (search.Resource{}).SearchFieldOverrides() {
if opts.Type == mapping.TypePath {
out[field] = struct{}{}
}
}
return out
})
// fulltextFields are analyzed full-text fields (TypeFulltext), derived from the
// overrides.
var fulltextFields = sync.OnceValue(func() map[string]struct{} {
out := map[string]struct{}{}
for field, opts := range (search.Resource{}).SearchFieldOverrides() {
if opts.Type == mapping.TypeFulltext {
out[field] = struct{}{}
}
}
return out
})
// ResolveField maps a KQL key to its canonical field name; unknown keys pass through.
func ResolveField(name string) string {
if v, ok := fieldIndex()[strings.ToLower(name)]; ok {
return v
}
return name
}
// FieldIsCaseInsensitive reports whether a field's default search is case-insensitive.
func FieldIsCaseInsensitive(field string) bool {
_, ok := caseInsensitiveFields()[field]
return ok
}
// FieldIsPath reports whether a field is a hierarchical path field.
func FieldIsPath(field string) bool {
_, ok := pathFields()[field]
return ok
}
// FieldIsFulltext reports whether a field is an analyzed full-text field.
func FieldIsFulltext(field string) bool {
_, ok := fulltextFields()[field]
return ok
}
+36 -7
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"regexp"
"strings"
"sync"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
@@ -19,8 +20,15 @@ import (
searchmsg "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/messages/search/v0"
searchService "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/services/search/v0"
"github.com/opencloud-eu/opencloud/services/search/pkg/content"
"github.com/opencloud-eu/opencloud/services/search/pkg/mapping"
)
// SchemaVersion is the shared schema version for both search backends. Bump it
// on a breaking mapping change: each version gets its own index (OpenSearch name
// suffix, bleve path suffix), so the service builds a fresh index instead of
// colliding with the old one. No migration; reindex to populate.
const SchemaVersion = 3
var scopeRegex = regexp.MustCompile(`scope:\s*([^" "\n\r]*)`)
// Engine is the interface to the search engine
@@ -51,13 +59,34 @@ type BatchOperator interface {
type Resource struct {
content.Document
ID string
RootID string
Path string
ParentID string
Type uint64
Deleted bool
Hidden bool
ID string `json:"ID"`
RootID string `json:"RootID"`
Path string `json:"Path"`
ParentID string `json:"ParentID"`
Type uint64 `json:"Type"`
Deleted bool `json:"Deleted"`
Hidden bool `json:"Hidden"`
}
// resourceFieldOverrides is built once (it never changes) and reused on hot
// paths instead of reallocating per call.
var resourceFieldOverrides = sync.OnceValue(func() map[string]mapping.FieldOpts {
True, False := true, false
return map[string]mapping.FieldOpts{
"Name": {CaseInsensitive: &True},
"Path": {Type: mapping.TypePath},
"Content": {Type: mapping.TypeFulltext},
"Tags": {CaseInsensitive: &True, IncludeInAll: &False},
"Favorites": {CaseInsensitive: &True, IncludeInAll: &False},
"location": {Type: mapping.TypeGeopoint},
}
})
// SearchFieldOverrides returns the field options the mapping package needs to
// build per-backend index mappings for a Resource (keys are json-tag names).
// The map is shared and read-only; clone it before mutating.
func (Resource) SearchFieldOverrides() map[string]mapping.FieldOpts {
return resourceFieldOverrides()
}
// ResolveReference makes sure the path is relative to the space root
+19 -36
View File
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"path/filepath"
"reflect"
"sort"
"strconv"
"strings"
@@ -635,10 +636,10 @@ func (s *Service) doUpsertItem(ref *provider.Reference, batch BatchOperator) {
// determine if metadata needs to be stored in storage as well
metadata := map[string]string{}
addAudioMetadata(metadata, doc.Audio)
addImageMetadata(metadata, doc.Image)
addLocationMetadata(metadata, doc.Location)
addPhotoMetadata(metadata, doc.Photo)
facetToMetadata(metadata, doc.Audio, "libre.graph.audio.")
facetToMetadata(metadata, doc.Image, "libre.graph.image.")
facetToMetadata(metadata, doc.Location, "libre.graph.location.")
facetToMetadata(metadata, doc.Photo, "libre.graph.photo.")
if len(metadata) == 0 {
return
}
@@ -663,43 +664,25 @@ func (s *Service) doUpsertItem(ref *provider.Reference, batch BatchOperator) {
}
}
func addAudioMetadata(metadata map[string]string, audio *libregraph.Audio) {
if audio == nil {
return
// facetToMetadata flattens a libregraph facet (Audio / Image / Location / Photo
// pointer) into the metadata map under the given prefix via the model's ToMap.
// No-op when the facet is nil.
func facetToMetadata[T libregraph.MappedNullable](metadata map[string]string, facet T, prefix string) {
// Only nilable kinds can be nil; IsNil panics on a value type (some
// libregraph models satisfy MappedNullable with a value receiver).
switch v := reflect.ValueOf(facet); v.Kind() {
case reflect.Ptr, reflect.Map, reflect.Slice, reflect.Interface, reflect.Chan, reflect.Func:
if v.IsNil() {
return
}
}
marshalToStringMap(audio, metadata, "libre.graph.audio.")
}
func addImageMetadata(metadata map[string]string, image *libregraph.Image) {
if image == nil {
return
}
marshalToStringMap(image, metadata, "libre.graph.image.")
}
func addLocationMetadata(metadata map[string]string, location *libregraph.GeoCoordinates) {
if location == nil {
return
}
marshalToStringMap(location, metadata, "libre.graph.location.")
}
func addPhotoMetadata(metadata map[string]string, photo *libregraph.Photo) {
if photo == nil {
return
}
marshalToStringMap(photo, metadata, "libre.graph.photo.")
}
func marshalToStringMap[T libregraph.MappedNullable](source T, target map[string]string, prefix string) {
// ToMap never returns a non-nil error ...
m, _ := source.ToMap()
// ToMap never returns a non-nil error.
m, _ := facet.ToMap()
for k, v := range m {
if v == nil {
continue
}
target[prefix+k] = valueToString(v)
metadata[prefix+k] = valueToString(v)
}
}
@@ -89,7 +89,7 @@ func (i AudioDecoder) Convert(r io.Reader) (any, error) {
return nil, err
}
picture := m.Picture()
picture := selectCoverArt(m.Pictures())
if picture == nil {
return nil, thumbnailerErrors.ErrNoImageFromAudioFile
}
@@ -102,6 +102,27 @@ func (i AudioDecoder) Convert(r io.Reader) (any, error) {
return converter.Convert(bytes.NewReader(picture.Data))
}
// frontCoverType is the ID3/APIC picture type byte for a front cover, shared by
// the ID3v2, FLAC and OGG tag formats (see github.com/dhowden/tag pictureTypes).
const frontCoverType byte = 0x03
// selectCoverArt deterministically picks the cover art from an audio file's
// embedded pictures: the explicitly tagged front cover if present, otherwise
// the first available picture. Many taggers store the cover as type "Other"
// (0x00) instead of "Cover (front)" (0x03), so the fallback is the common case.
// It returns nil when there are no pictures.
func selectCoverArt(pictures []tag.Picture) *tag.Picture {
if len(pictures) == 0 {
return nil
}
for i := range pictures {
if pictures[i].RawType == frontCoverType {
return &pictures[i]
}
}
return &pictures[0]
}
// TxtToImageConverter is a converter for the text file
type TxtToImageConverter struct {
fontLoader *FontLoader
@@ -6,6 +6,7 @@ import (
"os"
"testing"
"github.com/dhowden/tag"
"golang.org/x/image/font"
"golang.org/x/image/font/opentype"
@@ -127,6 +128,28 @@ var _ = Describe("ImageDecoder", func() {
})
})
Describe("selectCoverArt", func() {
front := tag.Picture{RawType: frontCoverType, MIMEType: "image/jpeg", Data: []byte("front")}
back := tag.Picture{RawType: 0x04, MIMEType: "image/jpeg", Data: []byte("back")}
other := tag.Picture{RawType: 0x00, MIMEType: "image/png", Data: []byte("other")}
It("returns nil when there are no pictures", func() {
Expect(selectCoverArt(nil)).To(BeNil())
Expect(selectCoverArt([]tag.Picture{})).To(BeNil())
})
It("prefers the front cover regardless of order", func() {
got := selectCoverArt([]tag.Picture{back, other, front})
Expect(got).ToNot(BeNil())
Expect(got.RawType).To(Equal(frontCoverType))
Expect(got.Data).To(Equal([]byte("front")))
})
It("falls back to the first picture when none is a front cover", func() {
got := selectCoverArt([]tag.Picture{other, back})
Expect(got).ToNot(BeNil())
Expect(got.Data).To(Equal([]byte("other")))
})
})
Describe("should decode text", func() {
var decoder TxtToImageConverter
BeforeEach(func() {
+3 -1
View File
@@ -5,4 +5,6 @@ services:
- tika
command: tika:9998
tika:
image: apache/tika:3.2.3.0-full
# latest snapshot, pinned by digest (the SNAPSHOT tag is rebuilt daily);
# needed for the audio/GPS metadata keys added in the tika 4.0.0 dev line.
image: apache/tika:4.0.0-SNAPSHOT-full@sha256:64a022ebae0830e90067d619b6b1b1d5640d5bfd635b2173ae51ed8de967078f
+4
View File
@@ -96,6 +96,10 @@ func (m metadataDSF) Picture() *Picture {
return m.id3.Picture()
}
func (m metadataDSF) Pictures() []Picture {
return m.id3.Pictures()
}
func (m metadataDSF) Lyrics() string {
return m.id3.Lyrics()
}
+1
View File
@@ -140,5 +140,6 @@ func (m metadataID3v1) AlbumArtist() string { return "" }
func (m metadataID3v1) Composer() string { return "" }
func (metadataID3v1) Disc() (int, int) { return 0, 0 }
func (m metadataID3v1) Picture() *Picture { return nil }
func (m metadataID3v1) Pictures() []Picture { return nil }
func (m metadataID3v1) Lyrics() string { return "" }
func (m metadataID3v1) Comment() string { return m["comment"].(string) }
+3
View File
@@ -546,6 +546,7 @@ type Picture struct {
Ext string // Extension of the picture file.
MIMEType string // MIMEType of the picture.
Type string // Type of the picture (see pictureTypes).
RawType byte // Raw picture type byte (see pictureTypes); 0x03 is the front cover.
Description string // Description.
Data []byte // Raw picture data.
}
@@ -596,6 +597,7 @@ func readPICFrame(b []byte) (*Picture, error) {
Ext: ext,
MIMEType: mimeType,
Type: pictureTypes[picType],
RawType: picType,
Description: desc,
Data: descDataSplit[1],
}, nil
@@ -649,6 +651,7 @@ func readAPICFrame(b []byte) (*Picture, error) {
Ext: ext,
MIMEType: mimeType,
Type: pictureTypes[picType],
RawType: picType,
Description: desc,
Data: descDataSplit[1],
}, nil
+25
View File
@@ -150,3 +150,28 @@ func (m metadataID3v2) Picture() *Picture {
}
return v.(*Picture)
}
// Pictures returns all attached pictures. Duplicate picture frames are stored
// under suffixed keys ("APIC", "APIC_0", "APIC_1", ...) by readFrames, so we
// walk that sequence to return them in file order.
func (m metadataID3v2) Pictures() []Picture {
base := frames.Name("picture", m.Format())
if base == "" {
return nil
}
var pics []Picture
for i := -1; ; i++ {
key := base
if i >= 0 {
key = base + "_" + strconv.Itoa(i)
}
v, ok := m.frames[key]
if !ok {
break
}
if p, ok := v.(*Picture); ok {
pics = append(pics, *p)
}
}
return pics
}
+10
View File
@@ -377,3 +377,13 @@ func (m metadataMP4) Picture() *Picture {
p, _ := v.(*Picture)
return p
}
// Pictures returns the attached cover art. The MP4 "covr" atom carries no
// picture type, so at most a single untyped picture is returned.
func (m metadataMP4) Pictures() []Picture {
p := m.Picture()
if p == nil {
return nil
}
return []Picture{*p}
}
+3
View File
@@ -135,6 +135,9 @@ type Metadata interface {
// Picture returns a picture, or nil if not available.
Picture() *Picture
// Pictures returns all attached pictures, or nil if none are available.
Pictures() []Picture
// Lyrics returns the lyrics, or an empty string if unavailable.
Lyrics() string
Loaded 100 of 102 files, more files were not shown because too many files have changed in this diff. Show more