Compare commits

...
Author SHA1 Message Date
Dominik Schmidt ac2f0944ef fix(search): pass order_by through the grpc service and cache key
The grpc layer rebuilt the searcher request field by field and dropped
order_by; the response cache also ignored it, so differently sorted
searches collided on the same cache entry.

(cherry picked from commit 97626bb26bd4fe8cdb74c19b519b2230c492681c)
2026-09-08 00:34:04 +02:00
Dominik Schmidt 79e616da3a feat(search): native order_by sorting in the opensearch backend
Name is analyzed (lowercaseKeyword) and therefore a text field, which
OpenSearch refuses to sort on without fielddata; enable it in the index
template. The keyword tokenizer emits one term per document, so the
fielddata cache stays small. The schema version has not shipped yet, so
no reindex is needed.

(cherry picked from commit f1d0e7b773b170b119dbd59fa176d656237bed96)
2026-09-08 00:26:03 +02:00
Dominik Schmidt 55b89d02d4 feat(search): native order_by sorting in the bleve backend
(cherry picked from commit 66a0755ad69a6a1af07e0b8335b08d63793777ce)
2026-09-08 00:25:48 +02:00
Dominik Schmidt eb6df66d13 feat(search): merge per-space results in order_by order
The fan-out passes order_by through to each engine query; the merge
re-sorts the combined matches with CompareMatches and keeps the score
as tiebreaker (and as the sole criterion when no order_by is given).

(cherry picked from commit 66af40fc34fd9daffc028ab06f83f7ade8a360d1)
2026-09-08 00:25:37 +02:00
Dominik Schmidt 641aa74a1e feat(search): allow sorting by any scalar hit field
Sortable is every field that is indexed as a scalar and carried on the
match entity: name, size, lastModifiedDateTime, mimeType and the scalar
facet fields (photo.*, audio.*, image.*, location.*, ...). Both sets
are derived by reflection, so new facet fields become sortable
automatically. Multivalued fields (tags), bare facets and internal
index fields are rejected with invalidRequest at the graph layer.
CompareMatches provides the merge comparator for the service layer.

(cherry picked from commit bd32795194cf3945e8416ede496a147ce8d6cb5c)
2026-09-08 00:25:28 +02:00
Dominik Schmidt da6809a2cb feat(graph): accept sortProperties on search requests
Validated against the sortable-field whitelist (name, size,
lastModifiedDateTime, photo.takenDateTime) and forwarded to the search
service as order_by. Also fixes the stub search service's IndexSpace
signature (streaming response) so the suite builds again.

(cherry picked from commit 9aa6a3492de3468da7a8480a3b9ef5748cee1f5d)
(cherry picked from commit 35a6cad8ea6a7890bdc0bc98c7ac97bdcbf477cc)
2026-09-08 00:25:02 +02:00
Dominik Schmidt 43f6efe403 feat(search): add sortProperties plumbing (libregraph model, proto order_by)
(cherry picked from commit 1b748fb757e2dde5b3cd1beaa543be4266342590)
2026-09-08 00:18:57 +02:00
Dominik Schmidt dfe5fdbb26 feat(search): metric objects, aggregationFilterToken and report parity
Migrate the graph search query to metricDefinition/searchMetric and the
@libre.graph prefixes. Add the aggregationFilterToken round-trip: encode
terms/range/or tokens on the response, pass aggregationFilters 1:1 to the
search service, decode them and force exact case-sensitive matches in both
backends. Map the WebDAV-report facets onto search hits: tags, video,
motionPhoto, livePhoto, allowedValues (from the space permission set),
me.following (favorite), webUrl (private link, shared helper) and
thumbnails via opt-in $expand.
2026-09-07 23:25:31 +02:00
Dominik Schmidt 5a92245286 chore(search): regenerate libre-graph-api-go and search protos
Regenerate the vendored libre-graph-api-go from the feat/search spec
(metricDefinition/searchMetric, @libre.graph.subAggregations,
aggregationFilterToken) and add aggregation_filters plus the
permissionsActionsAllowedValues entity field to the search protos.
2026-09-07 23:25:31 +02:00
Dominik Schmidt 07c7389104 Merge remote-tracking branch 'origin/main' into feat/graph-search-query 2026-09-07 23:20:22 +02:00
Dominik Schmidt 71ca404695 feat(search): describe hits from shared spaces as remote items 2026-09-03 10:30:38 +02:00
Dominik Schmidt e38a735e20 test(search): pin aggregations in the parity suite 2026-09-03 10:30:38 +02:00
Dominik Schmidt b1393b504c fix(search): date range aggregations on opensearch, validate range bounds 2026-09-03 10:30:38 +02:00
Dominik Schmidt 504bdd5e7f fix(search): merge top-level metric aggregations across spaces
The cross-space merge only carried buckets, dropping metric results
(value/metricKind) from the per-space responses. Reduce metrics with
their kind's reducer, keyed by field and kind.
2026-09-03 10:30:38 +02:00
Dominik Schmidt 16ae504226 feat(search): support top-level metric aggregations on the bleve backend
Metric aggregations (sum/min/max/avg) only worked as sub-aggregations
under a terms bucket. Compute top-level metrics by folding the matched
hits through the existing accumulator and allow them through the graph
layer's numeric field validation.
2026-09-03 10:30:38 +02:00
Dominik Schmidt c16bdff872 feat(search): support date bounds in bleve range aggregations
Range aggregations parsed from/to with ParseFloat only, so date bounds on
datetime fields like photo.takenDateTime silently degraded to unbounded
numeric ranges. Detect date-formatted bounds (RFC3339 or YYYY-MM-DD),
switch the facet to bleve date ranges and read DateRanges from the facet
result. Malformed bounds in date mode are rejected.
2026-09-03 10:30:38 +02:00
Dominik Schmidt 9661f66fe8 feat(search): opensearch aggregations
Implements terms, range, metric and sub-aggregations for the OpenSearch backend
via a dedicated aggs builder, wiring them through the shared search service.
2026-09-03 10:30:38 +02:00
Dominik Schmidt dfb3893118 feat(search): graph search query endpoint with bleve aggregations
Adds the graph /v1beta1/search/query endpoint, the aggregation proto messages,
the service-layer aggregation forwarding/merging, and the recursive bleve
aggregation implementation (terms, range, metric, sub-aggregations).
2026-09-03 10:30:38 +02:00
Dominik Schmidt 0085478b79 chore(vendor): regenerate libre-graph-api-go with search query + aggregations
Rebased dschmidt/libre-graph-api feat/graph-search-full (PR #34) onto
opencloud-eu main and regenerated via the repo's woodpecker build-go recipe
(openapi-generator v7.23.0, --api-name-suffix Api).
2026-09-03 10:30:25 +02:00
59 changed files with 8300 additions and 187 deletions

No files matched your search

@@ -824,6 +824,10 @@ type Entity struct {
MotionPhoto *MotionPhoto `protobuf:"bytes,21,opt,name=motionPhoto,proto3" json:"motionPhoto,omitempty"`
Video *Video `protobuf:"bytes,22,opt,name=video,proto3" json:"video,omitempty"`
LivePhoto *LivePhoto `protobuf:"bytes,23,opt,name=livePhoto,proto3" json:"livePhoto,omitempty"`
// The effective permission actions of the caller, projected from the space
// root permission set at query time (the same source as `permissions`), for
// the driveItem `@libre.graph.permissions.actions.allowedValues` facet.
PermissionsActionsAllowedValues []string `protobuf:"bytes,24,rep,name=permissionsActionsAllowedValues,proto3" json:"permissionsActionsAllowedValues,omitempty"`
}
func (x *Entity) Reset() {
@@ -1019,6 +1023,13 @@ func (x *Entity) GetLivePhoto() *LivePhoto {
return nil
}
func (x *Entity) GetPermissionsActionsAllowedValues() []string {
if x != nil {
return x.PermissionsActionsAllowedValues
}
return nil
}
type Match struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
@@ -1259,7 +1270,7 @@ var file_opencloud_messages_search_v0_search_proto_rawDesc = []byte{
0x5f, 0x61, 0x75, 0x74, 0x6f, 0x42, 0x10, 0x0a, 0x0e, 0x5f, 0x76, 0x69, 0x74, 0x61, 0x6c, 0x69,
0x74, 0x79, 0x53, 0x63, 0x6f, 0x72, 0x65, 0x42, 0x19, 0x0a, 0x17, 0x5f, 0x76, 0x69, 0x74, 0x61,
0x6c, 0x69, 0x74, 0x79, 0x53, 0x63, 0x6f, 0x72, 0x69, 0x6e, 0x67, 0x56, 0x65, 0x72, 0x73, 0x69,
0x6f, 0x6e, 0x22, 0xc9, 0x08, 0x0a, 0x06, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x39, 0x0a,
0x6f, 0x6e, 0x22, 0x93, 0x09, 0x0a, 0x06, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x39, 0x0a,
0x03, 0x72, 0x65, 0x66, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x27, 0x2e, 0x6f, 0x70, 0x65,
0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e,
0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65,
@@ -1327,19 +1338,23 @@ var file_opencloud_messages_search_v0_search_proto_rawDesc = []byte{
0x68, 0x6f, 0x74, 0x6f, 0x18, 0x17, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x27, 0x2e, 0x6f, 0x70, 0x65,
0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e,
0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x4c, 0x69, 0x76, 0x65, 0x50, 0x68,
0x6f, 0x74, 0x6f, 0x52, 0x09, 0x6c, 0x69, 0x76, 0x65, 0x50, 0x68, 0x6f, 0x74, 0x6f, 0x22, 0x5b,
0x0a, 0x05, 0x4d, 0x61, 0x74, 0x63, 0x68, 0x12, 0x3c, 0x0a, 0x06, 0x65, 0x6e, 0x74, 0x69, 0x74,
0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x24, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c,
0x6f, 0x75, 0x64, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61,
0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x06, 0x65,
0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x63, 0x6f, 0x72, 0x65, 0x18, 0x02,
0x20, 0x01, 0x28, 0x02, 0x52, 0x05, 0x73, 0x63, 0x6f, 0x72, 0x65, 0x42, 0x4d, 0x5a, 0x4b, 0x67,
0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c,
0x6f, 0x75, 0x64, 0x2d, 0x65, 0x75, 0x2f, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64,
0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x67, 0x65, 0x6e, 0x2f, 0x67, 0x65, 0x6e, 0x2f, 0x6f, 0x70,
0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73,
0x2f, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2f, 0x76, 0x30, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74,
0x6f, 0x33,
0x6f, 0x74, 0x6f, 0x52, 0x09, 0x6c, 0x69, 0x76, 0x65, 0x50, 0x68, 0x6f, 0x74, 0x6f, 0x12, 0x48,
0x0a, 0x1f, 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x41, 0x63, 0x74,
0x69, 0x6f, 0x6e, 0x73, 0x41, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x56, 0x61, 0x6c, 0x75, 0x65,
0x73, 0x18, 0x18, 0x20, 0x03, 0x28, 0x09, 0x52, 0x1f, 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73,
0x69, 0x6f, 0x6e, 0x73, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x41, 0x6c, 0x6c, 0x6f, 0x77,
0x65, 0x64, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x22, 0x5b, 0x0a, 0x05, 0x4d, 0x61, 0x74, 0x63,
0x68, 0x12, 0x3c, 0x0a, 0x06, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28,
0x0b, 0x32, 0x24, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x6d, 0x65,
0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30,
0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x06, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12,
0x14, 0x0a, 0x05, 0x73, 0x63, 0x6f, 0x72, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x02, 0x52, 0x05,
0x73, 0x63, 0x6f, 0x72, 0x65, 0x42, 0x4d, 0x5a, 0x4b, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e,
0x63, 0x6f, 0x6d, 0x2f, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2d, 0x65, 0x75,
0x2f, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f,
0x67, 0x65, 0x6e, 0x2f, 0x67, 0x65, 0x6e, 0x2f, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75,
0x64, 0x2f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2f, 0x73, 0x65, 0x61, 0x72, 0x63,
0x68, 0x2f, 0x76, 0x30, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
}
var (
File diff suppressed because it is too large. Load diff
@@ -167,6 +167,67 @@
}
}
},
"v0AggregationOption": {
"type": "object",
"properties": {
"field": {
"type": "string",
"description": "Required. The indexed field to aggregate on (for terms/range\naggregations) or to reduce (for metric aggregations)."
},
"size": {
"type": "integer",
"format": "int32",
"description": "Optional. Maximum number of buckets to return for a terms aggregation.\nIgnored for range and metric aggregations."
},
"bucketDefinition": {
"$ref": "#/definitions/v0BucketDefinition",
"description": "Optional. Controls bucket selection, ordering and filtering.\nIgnored for metric aggregations."
},
"subAggregations": {
"type": "array",
"items": {
"$ref": "#/definitions/v0AggregationOption"
},
"description": "Optional. Nested aggregations computed within each bucket of this\naggregation. On bleve, sub-aggregations are emulated by walking the\nmatched result set; on OpenSearch they translate to native composite\naggregations."
},
"metricKind": {
"$ref": "#/definitions/v0MetricKind",
"description": "Optional. When set, this aggregation is a scalar metric over `field`\nrather than a bucket aggregation; the corresponding AggregationResult\ncarries `value` instead of `buckets`."
}
}
},
"v0AggregationResult": {
"type": "object",
"properties": {
"field": {
"type": "string"
},
"buckets": {
"type": "array",
"items": {
"$ref": "#/definitions/v0Bucket"
}
},
"value": {
"type": "number",
"format": "double",
"description": "Scalar value for metric aggregations (metric_kind set on the\ncorresponding AggregationOption). Unset / zero for terms/range\naggregations."
},
"metricKind": {
"$ref": "#/definitions/v0MetricKind",
"description": "Echoes the metric_kind of the corresponding AggregationOption. Lets\nthe cross-space merge layer pick the right reducer."
},
"sum": {
"type": "number",
"format": "double",
"description": "Accumulators used exclusively for AVG during cross-space merges.\nA backend computes (sum, count) per bucket so the service layer can\nmerge them additively and emit `value = sum/count` only at the\noutermost collapse. Other metric kinds leave these unset."
},
"count": {
"type": "string",
"format": "int64"
}
}
},
"v0Audio": {
"type": "object",
"properties": {
@@ -227,6 +288,63 @@
}
}
},
"v0Bucket": {
"type": "object",
"properties": {
"key": {
"type": "string"
},
"count": {
"type": "string",
"format": "int64"
},
"aggregationFilterToken": {
"type": "string"
},
"subAggregations": {
"type": "array",
"items": {
"$ref": "#/definitions/v0AggregationResult"
},
"description": "Nested aggregation results, one entry per sub_aggregation requested\non the parent AggregationOption."
}
}
},
"v0BucketDefinition": {
"type": "object",
"properties": {
"sortBy": {
"type": "string",
"description": "One of \"count\", \"keyAsString\", \"keyAsNumber\"."
},
"isDescending": {
"type": "boolean"
},
"minimumCount": {
"type": "integer",
"format": "int32"
},
"ranges": {
"type": "array",
"items": {
"$ref": "#/definitions/v0BucketRange"
},
"description": "Optional. When non-empty, the aggregation is computed over these numeric\nor date ranges instead of as a terms aggregation."
}
}
},
"v0BucketRange": {
"type": "object",
"properties": {
"from": {
"type": "string",
"description": "At least one of `from` or `to` must be set. Both are string-encoded;\nnumeric bounds are decimal strings, dates use RFC3339."
},
"to": {
"type": "string"
}
}
},
"v0Entity": {
"type": "object",
"properties": {
@@ -307,6 +425,13 @@
},
"livePhoto": {
"$ref": "#/definitions/v0LivePhoto"
},
"permissionsActionsAllowedValues": {
"type": "array",
"items": {
"type": "string"
},
"description": "The effective permission actions of the caller, projected from the space\nroot permission set at query time (the same source as `permissions`), for\nthe driveItem `@libre.graph.permissions.actions.allowedValues` facet."
}
}
},
@@ -422,6 +547,17 @@
}
}
},
"v0MetricKind": {
"type": "string",
"enum": [
"METRIC_KIND_UNSPECIFIED",
"METRIC_KIND_SUM",
"METRIC_KIND_MIN",
"METRIC_KIND_MAX",
"METRIC_KIND_AVG"
],
"default": "METRIC_KIND_UNSPECIFIED"
},
"v0MotionPhoto": {
"type": "object",
"properties": {
@@ -520,6 +656,27 @@
},
"ref": {
"$ref": "#/definitions/v0Reference"
},
"aggregations": {
"type": "array",
"items": {
"$ref": "#/definitions/v0AggregationOption"
},
"description": "Optional. Per-space aggregations (facets) to compute alongside the matches."
},
"aggregationFilters": {
"type": "array",
"items": {
"type": "string"
},
"description": "Optional. Decoded aggregation filters as KQL fragments; the engine parses\neach, forces exact/case-sensitive matching, and ANDs them with `query`."
},
"orderBy": {
"type": "array",
"items": {
"$ref": "#/definitions/v0SortProperty"
},
"description": "Optional. Fields to sort the matches by, in order of precedence. When\nempty, matches are sorted by relevance score. Each backend translates\nthis to its native sort (bleve: SortBy, OpenSearch: sort clause)."
}
}
},
@@ -539,6 +696,13 @@
"totalMatches": {
"type": "integer",
"format": "int32"
},
"aggregations": {
"type": "array",
"items": {
"$ref": "#/definitions/v0AggregationResult"
},
"description": "Per-space aggregation results. The service layer merges these across\nspaces before returning them to the caller."
}
}
},
@@ -559,6 +723,27 @@
},
"ref": {
"$ref": "#/definitions/v0Reference"
},
"aggregations": {
"type": "array",
"items": {
"$ref": "#/definitions/v0AggregationOption"
},
"description": "Optional. Aggregations (facets) to compute alongside the matches."
},
"aggregationFilters": {
"type": "array",
"items": {
"type": "string"
},
"description": "Optional. Decoded aggregation filters, one per selected bucket, as KQL\nfragments (e.g. `audio.artist:\"Pink Floyd\"`). Combined with `query` via AND\nand matched case-sensitively/exactly. Passed through from the graph layer."
},
"orderBy": {
"type": "array",
"items": {
"$ref": "#/definitions/v0SortProperty"
},
"description": "Optional. Fields to sort the matches by, in order of precedence. When\nempty, matches are sorted by relevance score. Only a subset of the\nindexed fields is sortable; the graph service validates this before\nforwarding the request."
}
}
},
@@ -578,6 +763,26 @@
"totalMatches": {
"type": "integer",
"format": "int32"
},
"aggregations": {
"type": "array",
"items": {
"$ref": "#/definitions/v0AggregationResult"
},
"description": "Aggregation results, one entry per requested aggregation."
}
}
},
"v0SortProperty": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Required. The field to sort on, in graph notation (\"name\", \"size\",\n\"lastModifiedDateTime\", \"mimeType\" or a scalar facet field such as\n\"photo.takenDateTime\" or \"audio.artist\"). A field is sortable when it is\nindexed as a scalar in both backends AND carried on the Match entity\n(the service layer needs the sort key to merge per-space result\nstreams); see the search package's IsSortableField."
},
"isDescending": {
"type": "boolean",
"description": "Optional. Sort in descending order. Defaults to ascending."
}
}
},
@@ -110,6 +110,10 @@ message Entity {
MotionPhoto motionPhoto = 21;
Video video = 22;
LivePhoto livePhoto = 23;
// The effective permission actions of the caller, projected from the space
// root permission set at query time (the same source as `permissions`), for
// the driveItem `@libre.graph.permissions.actions.allowedValues` facet.
repeated string permissionsActionsAllowedValues = 24;
}
message Match {
@@ -72,6 +72,19 @@ message SearchRequest {
string query = 3;
opencloud.messages.search.v0.Reference ref = 4 [(google.api.field_behavior) = OPTIONAL];
// Optional. Aggregations (facets) to compute alongside the matches.
repeated AggregationOption aggregations = 5 [(google.api.field_behavior) = OPTIONAL];
// Optional. Decoded aggregation filters, one per selected bucket, as KQL
// fragments (e.g. `audio.artist:"Pink Floyd"`). Combined with `query` via AND
// and matched case-sensitively/exactly. Passed through from the graph layer.
repeated string aggregation_filters = 6 [(google.api.field_behavior) = OPTIONAL];
// Optional. Fields to sort the matches by, in order of precedence. When
// empty, matches are sorted by relevance score. Only a subset of the
// indexed fields is sortable; the graph service validates this before
// forwarding the request.
repeated SortProperty order_by = 7 [(google.api.field_behavior) = OPTIONAL];
}
message SearchResponse {
@@ -81,6 +94,9 @@ message SearchResponse {
// more results in the list
string next_page_token = 2;
int32 total_matches = 3;
// Aggregation results, one entry per requested aggregation.
repeated AggregationResult aggregations = 4;
}
message SearchIndexRequest {
@@ -93,6 +109,17 @@ message SearchIndexRequest {
string query = 3;
opencloud.messages.search.v0.Reference ref = 4 [(google.api.field_behavior) = OPTIONAL];
// Optional. Per-space aggregations (facets) to compute alongside the matches.
repeated AggregationOption aggregations = 5 [(google.api.field_behavior) = OPTIONAL];
// Optional. Decoded aggregation filters as KQL fragments; the engine parses
// each, forces exact/case-sensitive matching, and ANDs them with `query`.
repeated string aggregation_filters = 6 [(google.api.field_behavior) = OPTIONAL];
// Optional. Fields to sort the matches by, in order of precedence. When
// empty, matches are sorted by relevance score. Each backend translates
// this to its native sort (bleve: SortBy, OpenSearch: sort clause).
repeated SortProperty order_by = 7 [(google.api.field_behavior) = OPTIONAL];
}
message SearchIndexResponse {
@@ -102,6 +129,95 @@ message SearchIndexResponse {
// more results in the list
string next_page_token = 2;
int32 total_matches = 3;
// Per-space aggregation results. The service layer merges these across
// spaces before returning them to the caller.
repeated AggregationResult aggregations = 4;
}
message AggregationOption {
// Required. The indexed field to aggregate on (for terms/range
// aggregations) or to reduce (for metric aggregations).
string field = 1;
// Optional. Maximum number of buckets to return for a terms aggregation.
// Ignored for range and metric aggregations.
int32 size = 2 [(google.api.field_behavior) = OPTIONAL];
// Optional. Controls bucket selection, ordering and filtering.
// Ignored for metric aggregations.
BucketDefinition bucket_definition = 3 [(google.api.field_behavior) = OPTIONAL];
// Optional. Nested aggregations computed within each bucket of this
// aggregation. On bleve, sub-aggregations are emulated by walking the
// matched result set; on OpenSearch they translate to native composite
// aggregations.
repeated AggregationOption sub_aggregations = 4 [(google.api.field_behavior) = OPTIONAL];
// Optional. When set, this aggregation is a scalar metric over `field`
// rather than a bucket aggregation; the corresponding AggregationResult
// carries `value` instead of `buckets`.
MetricKind metric_kind = 5 [(google.api.field_behavior) = OPTIONAL];
}
message SortProperty {
// Required. The field to sort on, in graph notation ("name", "size",
// "lastModifiedDateTime", "mimeType" or a scalar facet field such as
// "photo.takenDateTime" or "audio.artist"). A field is sortable when it is
// indexed as a scalar in both backends AND carried on the Match entity
// (the service layer needs the sort key to merge per-space result
// streams); see the search package's IsSortableField.
string name = 1;
// Optional. Sort in descending order. Defaults to ascending.
bool is_descending = 2;
}
enum MetricKind {
METRIC_KIND_UNSPECIFIED = 0;
METRIC_KIND_SUM = 1;
METRIC_KIND_MIN = 2;
METRIC_KIND_MAX = 3;
METRIC_KIND_AVG = 4;
}
message BucketDefinition {
// One of "count", "keyAsString", "keyAsNumber".
string sort_by = 1;
bool is_descending = 2;
int32 minimum_count = 3;
// Optional. When non-empty, the aggregation is computed over these numeric
// or date ranges instead of as a terms aggregation.
repeated BucketRange ranges = 4;
}
message BucketRange {
// At least one of `from` or `to` must be set. Both are string-encoded;
// numeric bounds are decimal strings, dates use RFC3339.
string from = 1;
string to = 2;
}
message AggregationResult {
string field = 1;
repeated Bucket buckets = 2;
// Scalar value for metric aggregations (metric_kind set on the
// corresponding AggregationOption). Unset / zero for terms/range
// aggregations.
double value = 3;
// Echoes the metric_kind of the corresponding AggregationOption. Lets
// the cross-space merge layer pick the right reducer.
MetricKind metric_kind = 4;
// Accumulators used exclusively for AVG during cross-space merges.
// A backend computes (sum, count) per bucket so the service layer can
// merge them additively and emit `value = sum/count` only at the
// outermost collapse. Other metric kinds leave these unset.
double sum = 5;
int64 count = 6;
}
message Bucket {
string key = 1;
int64 count = 2;
string aggregation_filter_token = 3;
// Nested aggregation results, one entry per sub_aggregation requested
// on the parent AggregationOption.
repeated AggregationResult sub_aggregations = 4;
}
message IndexSpaceRequest {
+13 -3
View File
@@ -500,6 +500,18 @@ func cs3TimestampToTime(t *types.Timestamp) time.Time {
return time.Unix(int64(t.GetSeconds()), int64(t.GetNanos()))
}
// webURLForID builds an item's private link, {publicBaseURL}/f/{id}, which the
// web client reads as privateLink. Used by the drive item listing and the
// search hits so the field is identical in both.
func webURLForID(publicBaseURL *url.URL, id string) *string {
if publicBaseURL == nil {
return nil
}
u := *publicBaseURL
u.Path = path.Join(u.Path, "f", id)
return libregraph.PtrString(u.String())
}
func cs3ResourceToDriveItem(logger *log.Logger, publicBaseURL *url.URL, res *storageprovider.ResourceInfo) (*libregraph.DriveItem, error) {
size := new(int64)
*size = int64(res.GetSize()) // TODO lurking overflow: make size of libregraph drive item use uint64
@@ -509,9 +521,7 @@ func cs3ResourceToDriveItem(logger *log.Logger, publicBaseURL *url.URL, res *sto
Size: size,
}
webURL := *publicBaseURL
webURL.Path = path.Join(webURL.Path, "f", storagespace.FormatResourceID(res.GetId()))
driveItem.WebUrl = libregraph.PtrString(webURL.String())
driveItem.WebUrl = webURLForID(publicBaseURL, storagespace.FormatResourceID(res.GetId()))
if name := path.Base(res.GetPath()); name != "" {
driveItem.Name = &name
@@ -0,0 +1,586 @@
package svc
import (
"context"
"fmt"
"net/http"
"path"
"slices"
"time"
storageprovider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
"github.com/go-chi/render"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
revaCtx "github.com/opencloud-eu/reva/v2/pkg/ctx"
"github.com/opencloud-eu/reva/v2/pkg/storagespace"
merrors "go-micro.dev/v4/errors"
"go-micro.dev/v4/metadata"
searchmsg "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/messages/search/v0"
searchsvc "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/services/search/v0"
"github.com/opencloud-eu/opencloud/services/graph/pkg/errorcode"
"github.com/opencloud-eu/opencloud/services/search/pkg/aggregation"
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
)
// SearchQuery runs the search requests and returns results grouped by request
// (MS Graph searchQuery).
func (g Graph) SearchQuery(w http.ResponseWriter, r *http.Request) {
var req libregraph.SearchQueryRequest
if err := StrictJSONUnmarshal(r.Body, &req); err != nil {
g.logger.Debug().Err(err).Msg("could not decode search query request")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "invalid body schema definition")
return
}
if len(req.Requests) == 0 {
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "requests array must not be empty")
return
}
for _, sr := range req.Requests {
if err := validateAggregations(sr.Aggregations); err != nil {
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
return
}
if err := validateSortProperties(sr.SortProperties); err != nil {
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
return
}
}
th := r.Header.Get(revaCtx.TokenHeader)
ctx := revaCtx.ContextSetToken(r.Context(), th)
ctx = metadata.Set(ctx, revaCtx.TokenHeader, th)
expandThumbnails := driveItemRelationExpanded(r, _expandThumbnails)
responses := make([]libregraph.SearchResponse, 0, len(req.Requests))
for _, sr := range req.Requests {
sresp, err := g.runSingleSearch(ctx, sr, expandThumbnails)
if err != nil {
g.renderSearchError(w, r, err)
return
}
responses = append(responses, sresp)
}
render.Status(r, http.StatusOK)
render.JSON(w, r, libregraph.SearchQuery200Response{Value: responses})
}
func (g Graph) runSingleSearch(ctx context.Context, sr libregraph.SearchRequest, expandThumbnails bool) (libregraph.SearchResponse, error) {
from, size := clampPagination(sr.From, sr.Size)
// The gRPC layer has no from field: request from+size matches and slice
// client-side. int64 avoids int32 overflow.
pageSize := int32(int64(from) + int64(size))
if size == 0 {
pageSize = 0
}
rsp, err := g.searchService.Search(ctx, &searchsvc.SearchRequest{
Query: sr.Query.QueryString,
PageSize: pageSize,
Aggregations: libregraphAggregationsToSearch(sr.Aggregations),
AggregationFilters: sr.AggregationFilters,
OrderBy: libregraphSortToSearch(sr.SortProperties),
})
if err != nil {
return libregraph.SearchResponse{}, err
}
// the current user id decides the @libre.graph.me.following (favorite) flag,
// mirroring the WebDAV report's oc:favorite (favorited by the caller).
uid := ""
if u, ok := revaCtx.ContextGetUser(ctx); ok {
uid = u.GetId().GetOpaqueId()
}
hits := make([]libregraph.SearchHit, 0)
if size > 0 {
start := min(int(from), len(rsp.Matches))
end := min(start+int(size), len(rsp.Matches))
for i := start; i < end; i++ {
hit := matchToSearchHit(rsp.Matches[i], int32(i+1), uid)
hit.Resource.WebUrl = webURLForID(g.publicBaseURL, hit.Resource.GetId())
if expandThumbnails {
setDriveItemThumbnailsByID(hit.Resource, hit.Resource.GetId(), g.config.Commons.OpenCloudURL)
}
hits = append(hits, hit)
}
}
total := int64(rsp.TotalMatches)
more := int64(from+size) < total
return libregraph.SearchResponse{
SearchTerms: []string{sr.Query.QueryString},
HitsContainers: []libregraph.SearchHitsContainer{{
Hits: hits,
Total: &total,
MoreResultsAvailable: &more,
Aggregations: searchAggregationsToLibregraph(rsp.Aggregations, sr.Aggregations),
}},
}, nil
}
// maxPageSize mirrors the openapi spec's upper bound on SearchRequest.size.
const maxPageSize = 500
// clampPagination normalises the from/size JSON pointers into safe non-negative
// int32s; openapi-generator does not enforce the spec's [0,500]/[0,inf) bounds.
func clampPagination(fromP, sizeP *int32) (int32, int32) {
from := int32(0)
if fromP != nil && *fromP > 0 {
from = *fromP
}
size := int32(25)
if sizeP != nil {
size = *sizeP
if size < 0 {
size = 0
}
if size > maxPageSize {
size = maxPageSize
}
}
// from+size is sent as a single int32 PageSize; guard against overflow.
const maxInt32 = int32(1<<31 - 1)
if int64(from)+int64(size) > int64(maxInt32) {
if from > maxInt32-size {
from = maxInt32 - size
}
}
return from, size
}
// validateAggregations rejects terms aggregations on numeric/time fields: bleve
// indexes them as prefix-coded binary, so term buckets are meaningless. Ranges
// are the supported alternative. Classification via search.IsNumericField.
func validateAggregations(aggs []libregraph.AggregationOption) error {
for _, a := range aggs {
if !search.IsNumericField(a.Field) {
continue
}
if a.LibreGraphMetricDefinition != nil {
// metrics reduce numeric values, no term buckets involved
continue
}
hasRanges := a.BucketDefinition != nil && len(a.BucketDefinition.Ranges) > 0
if hasRanges {
continue
}
return fmt.Errorf("terms aggregation is not supported on numeric field %q; use bucketDefinition.ranges", a.Field)
}
return nil
}
// validateSortProperties rejects sorting by unknown or multivalued fields.
// Sortable are scalar fields carried on the search hit: name, size,
// lastModifiedDateTime, mimeType and the facet fields (photo.takenDateTime,
// audio.artist, image.width, ...); see search.IsSortableField.
func validateSortProperties(sortProperties []libregraph.SortProperty) error {
for _, sp := range sortProperties {
if !search.IsSortableField(sp.Name) {
return fmt.Errorf("field %q is not sortable; sortable are scalar hit fields such as name, size, lastModifiedDateTime, mimeType or photo.takenDateTime", sp.Name)
}
}
return nil
}
func libregraphSortToSearch(in []libregraph.SortProperty) []*searchsvc.SortProperty {
if len(in) == 0 {
return nil
}
out := make([]*searchsvc.SortProperty, 0, len(in))
for _, sp := range in {
p := &searchsvc.SortProperty{Name: sp.Name}
if sp.IsDescending != nil {
p.IsDescending = *sp.IsDescending
}
out = append(out, p)
}
return out
}
func libregraphAggregationsToSearch(in []libregraph.AggregationOption) []*searchsvc.AggregationOption {
if len(in) == 0 {
return nil
}
out := make([]*searchsvc.AggregationOption, 0, len(in))
for _, a := range in {
agg := &searchsvc.AggregationOption{Field: a.Field}
if a.Size != nil {
agg.Size = *a.Size
}
if a.BucketDefinition != nil {
agg.BucketDefinition = libregraphBucketDefinitionToSearch(*a.BucketDefinition)
}
if len(a.LibreGraphSubAggregations) > 0 {
agg.SubAggregations = libregraphAggregationsToSearch(a.LibreGraphSubAggregations)
}
if a.LibreGraphMetricDefinition != nil {
agg.MetricKind = metricKindFromLibregraph(a.LibreGraphMetricDefinition.Kind)
}
out = append(out, agg)
}
return out
}
// metricKindFromLibregraph maps the OpenAPI string enum to the proto enum;
// unknown values degrade to UNSPECIFIED.
func metricKindFromLibregraph(kind string) searchsvc.MetricKind {
switch kind {
case "sum":
return searchsvc.MetricKind_METRIC_KIND_SUM
case "min":
return searchsvc.MetricKind_METRIC_KIND_MIN
case "max":
return searchsvc.MetricKind_METRIC_KIND_MAX
case "avg":
return searchsvc.MetricKind_METRIC_KIND_AVG
}
return searchsvc.MetricKind_METRIC_KIND_UNSPECIFIED
}
// metricKindToLibregraph is the inverse of metricKindFromLibregraph.
func metricKindToLibregraph(kind searchsvc.MetricKind) *string {
var s string
switch kind {
case searchsvc.MetricKind_METRIC_KIND_SUM:
s = "sum"
case searchsvc.MetricKind_METRIC_KIND_MIN:
s = "min"
case searchsvc.MetricKind_METRIC_KIND_MAX:
s = "max"
case searchsvc.MetricKind_METRIC_KIND_AVG:
s = "avg"
default:
return nil
}
return &s
}
func libregraphBucketDefinitionToSearch(in libregraph.BucketDefinition) *searchsvc.BucketDefinition {
bd := &searchsvc.BucketDefinition{SortBy: in.SortBy}
if in.IsDescending != nil {
bd.IsDescending = *in.IsDescending
}
if in.MinimumCount != nil {
bd.MinimumCount = *in.MinimumCount
}
if len(in.Ranges) > 0 {
bd.Ranges = make([]*searchsvc.BucketRange, 0, len(in.Ranges))
for _, r := range in.Ranges {
br := &searchsvc.BucketRange{}
if r.From != nil {
br.From = *r.From
}
if r.To != nil {
br.To = *r.To
}
bd.Ranges = append(bd.Ranges, br)
}
}
return bd
}
func searchAggregationsToLibregraph(in []*searchsvc.AggregationResult, defs []libregraph.AggregationOption) []libregraph.SearchAggregation {
if len(in) == 0 {
return nil
}
defsByField := make(map[string]libregraph.AggregationOption, len(defs))
for _, d := range defs {
defsByField[d.Field] = d
}
out := make([]libregraph.SearchAggregation, 0, len(in))
for _, a := range in {
field := a.GetField()
def := defsByField[field]
// Metric result: a scalar, no buckets. For AVG the backend
// transported (sum, count); collapse to the average here.
if kind := a.GetMetricKind(); kind != searchsvc.MetricKind_METRIC_KIND_UNSPECIFIED {
value := a.GetValue()
if kind == searchsvc.MetricKind_METRIC_KIND_AVG && a.GetCount() > 0 {
value = a.GetSum() / float64(a.GetCount())
}
out = append(out, libregraph.SearchAggregation{
Field: &field,
LibreGraphMetric: &libregraph.SearchMetric{Kind: metricKindToLibregraph(kind), Value: &value},
})
continue
}
buckets := make([]libregraph.SearchBucket, 0, len(a.GetBuckets()))
for _, b := range a.GetBuckets() {
key := b.GetKey()
count := b.GetCount()
lb := libregraph.SearchBucket{
Key: &key,
Count: &count,
}
if token := aggregationTokenForBucket(key, def); token != "" {
lb.AggregationFilterToken = &token
}
if subs := b.GetSubAggregations(); len(subs) > 0 {
lb.LibreGraphSubAggregations = searchAggregationsToLibregraph(subs, def.LibreGraphSubAggregations)
}
buckets = append(buckets, lb)
}
out = append(out, libregraph.SearchAggregation{
Field: &field,
Buckets: buckets,
})
}
return out
}
// aggregationTokenForBucket returns the aggregationFilterToken for a bucket: a
// range token when the aggregation defines ranges (matched to the range whose
// from-to key produced this bucket), otherwise a terms token for the key.
func aggregationTokenForBucket(key string, def libregraph.AggregationOption) string {
if def.BucketDefinition != nil && len(def.BucketDefinition.Ranges) > 0 {
for _, r := range def.BucketDefinition.Ranges {
from, to := ptrStr(r.From), ptrStr(r.To)
if from+"-"+to == key {
return aggregation.EncodeRangeToken(from, to)
}
}
return ""
}
return aggregation.EncodeTermsToken(key)
}
func ptrStr(s *string) string {
if s == nil {
return ""
}
return *s
}
func (g Graph) renderSearchError(w http.ResponseWriter, r *http.Request, err error) {
e := merrors.Parse(err.Error())
switch e.Code {
case http.StatusBadRequest:
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, e.Detail)
default:
g.logger.Error().Err(err).Msg("search service call failed")
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
}
}
func matchToSearchHit(m *searchmsg.Match, rank int32, uid string) libregraph.SearchHit {
hit := libregraph.SearchHit{
HitId: libregraph.PtrString(searchEntityHitID(m.GetEntity())),
Rank: &rank,
}
if h := m.GetEntity().GetHighlights(); h != "" {
hit.Summary = libregraph.PtrString(h)
}
di := searchEntityToDriveItem(m.GetEntity(), uid)
hit.Resource = di
return hit
}
func searchEntityHitID(e *searchmsg.Entity) string {
return storagespace.FormatResourceID(&storageprovider.ResourceId{
StorageId: e.GetId().GetStorageId(),
SpaceId: e.GetId().GetSpaceId(),
OpaqueId: e.GetId().GetOpaqueId(),
})
}
func searchEntityToDriveItem(e *searchmsg.Entity, uid string) *libregraph.DriveItem {
size := int64(e.GetSize())
di := &libregraph.DriveItem{
Id: libregraph.PtrString(searchEntityHitID(e)),
Name: libregraph.PtrString(e.GetName()),
Size: &size,
}
if etag := e.GetEtag(); etag != "" {
di.ETag = &etag
}
if mt := e.GetLastModifiedTime(); mt != nil {
lm := time.Unix(mt.GetSeconds(), int64(mt.GetNanos())).UTC()
di.LastModifiedDateTime = &lm
}
if e.GetType() == uint64(storageprovider.ResourceType_RESOURCE_TYPE_FILE) && e.GetMimeType() != "" {
mt := e.GetMimeType()
di.File = &libregraph.OpenGraphFile{MimeType: &mt}
}
if e.GetType() == uint64(storageprovider.ResourceType_RESOURCE_TYPE_CONTAINER) {
di.Folder = &libregraph.Folder{}
}
if p := e.GetParentId(); p != nil {
ref := libregraph.NewItemReference()
ref.SetDriveId(storagespace.FormatStorageID(p.GetStorageId(), p.GetSpaceId()))
ref.SetId(storagespace.FormatResourceID(&storageprovider.ResourceId{
StorageId: p.GetStorageId(),
SpaceId: p.GetSpaceId(),
OpaqueId: p.GetOpaqueId(),
}))
if refPath := e.GetRef().GetPath(); refPath != "" {
ref.SetName(path.Base(path.Dir(refPath)))
ref.SetPath(path.Dir(refPath))
}
di.ParentReference = ref
}
di.RemoteItem = searchEntityToRemoteItem(e)
di.Audio = searchAudioToLibregraph(e.GetAudio())
di.Image = searchImageToLibregraph(e.GetImage())
di.Photo = searchPhotoToLibregraph(e.GetPhoto())
di.Location = searchLocationToLibregraph(e.GetLocation())
di.Video = searchVideoToLibregraph(e.GetVideo())
di.LibreGraphMotionPhoto = searchMotionPhotoToLibregraph(e.GetMotionPhoto())
di.LibreGraphLivePhoto = searchLivePhotoToLibregraph(e.GetLivePhoto())
if tags := e.GetTags(); len(tags) > 0 {
di.LibreGraphTags = tags
}
if av := e.GetPermissionsActionsAllowedValues(); len(av) > 0 {
di.LibreGraphPermissionsActionsAllowedValues = av
}
// @libre.graph.me.following mirrors the WebDAV report's oc:favorite: the
// report emits it only when the current user has favorited the item, so set
// it to true only in that case and leave it unset otherwise.
if uid != "" && slices.Contains(e.GetFavorites(), uid) {
di.LibreGraphMeFollowing = libregraph.PtrBool(true)
}
return di
}
// searchEntityToRemoteItem describes a hit that lives in a space shared with the
// caller: the item id in the owner's drive and the mountpoint it is reached
// through. Absent for hits from the caller's own spaces.
func searchEntityToRemoteItem(e *searchmsg.Entity) *libregraph.RemoteItem {
id := e.GetRemoteItemId()
if id == nil {
return nil
}
item := libregraph.NewRemoteItem()
item.SetId(storagespace.FormatResourceID(&storageprovider.ResourceId{
StorageId: id.GetStorageId(),
SpaceId: id.GetSpaceId(),
OpaqueId: id.GetOpaqueId(),
}))
if root := e.GetShareRootName(); root != "" {
item.SetPath(root)
item.SetName(path.Base(root))
}
return item
}
func searchAudioToLibregraph(a *searchmsg.Audio) *libregraph.Audio {
if a == nil {
return nil
}
out := &libregraph.Audio{
Album: a.Album,
AlbumArtist: a.AlbumArtist,
Artist: a.Artist,
Bitrate: a.Bitrate,
Composers: a.Composers,
Copyright: a.Copyright,
Disc: a.Disc,
DiscCount: a.DiscCount,
Duration: a.Duration,
Genre: a.Genre,
HasDrm: a.HasDrm,
IsVariableBitrate: a.IsVariableBitrate,
Title: a.Title,
Track: a.Track,
TrackCount: a.TrackCount,
Year: a.Year,
}
return out
}
func searchImageToLibregraph(i *searchmsg.Image) *libregraph.Image {
if i == nil {
return nil
}
return &libregraph.Image{Width: i.Width, Height: i.Height}
}
func searchPhotoToLibregraph(p *searchmsg.Photo) *libregraph.Photo {
if p == nil {
return nil
}
out := &libregraph.Photo{
CameraMake: p.CameraMake,
CameraModel: p.CameraModel,
ExposureDenominator: f32ToF64(p.ExposureDenominator),
ExposureNumerator: f32ToF64(p.ExposureNumerator),
FNumber: f32ToF64(p.FNumber),
FocalLength: f32ToF64(p.FocalLength),
Iso: p.Iso,
Orientation: p.Orientation,
}
if p.TakenDateTime != nil {
t := time.Unix(p.TakenDateTime.GetSeconds(), int64(p.TakenDateTime.GetNanos())).UTC()
out.TakenDateTime = &t
}
return out
}
func searchLocationToLibregraph(l *searchmsg.GeoCoordinates) *libregraph.GeoCoordinates {
if l == nil {
return nil
}
return &libregraph.GeoCoordinates{
Altitude: l.Altitude,
Latitude: l.Latitude,
Longitude: l.Longitude,
}
}
func searchVideoToLibregraph(v *searchmsg.Video) *libregraph.Video {
if v == nil {
return nil
}
return &libregraph.Video{
AudioBitsPerSample: v.AudioBitsPerSample,
AudioChannels: v.AudioChannels,
AudioFormat: v.AudioFormat,
AudioSamplesPerSecond: v.AudioSamplesPerSecond,
Bitrate: v.Bitrate,
Duration: v.Duration,
FourCC: v.FourCC,
FrameRate: v.FrameRate,
Height: v.Height,
Width: v.Width,
}
}
func searchMotionPhotoToLibregraph(m *searchmsg.MotionPhoto) *libregraph.MotionPhoto {
if m == nil {
return nil
}
return &libregraph.MotionPhoto{
Version: m.Version,
PresentationTimestampUs: m.PresentationTimestampUs,
VideoSize: m.VideoSize,
}
}
func searchLivePhotoToLibregraph(l *searchmsg.LivePhoto) *libregraph.LivePhoto {
if l == nil {
return nil
}
return &libregraph.LivePhoto{
ContentId: l.GetContentId(),
StillImageTimeUs: l.StillImageTimeUs,
Auto: l.Auto,
VitalityScore: l.VitalityScore,
VitalityScoringVersion: l.VitalityScoringVersion,
}
}
func f32ToF64(v *float32) *float64 {
if v == nil {
return nil
}
f := float64(*v)
return &f
}
@@ -0,0 +1,322 @@
package svc
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
// ginkgo qualified: the svc package declares Context (option.go), which
// would collide with a dot-import.
ginkgo "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"go-micro.dev/v4/client"
"github.com/opencloud-eu/opencloud/pkg/log"
searchmsg "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/messages/search/v0"
searchsvc "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/services/search/v0"
)
type stubSearchService struct {
search func(*searchsvc.SearchRequest) (*searchsvc.SearchResponse, error)
}
func (s stubSearchService) Search(_ context.Context, req *searchsvc.SearchRequest, _ ...client.CallOption) (*searchsvc.SearchResponse, error) {
return s.search(req)
}
func (s stubSearchService) IndexSpace(_ context.Context, _ *searchsvc.IndexSpaceRequest, _ ...client.CallOption) (searchsvc.SearchProvider_IndexSpaceService, error) {
return nil, nil
}
func graphWithSearch(stub stubSearchService) Graph {
logger := log.NewLogger()
return Graph{
BaseGraphService: BaseGraphService{logger: &logger},
searchService: stub,
}
}
func postSearchQuery(g Graph, body string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, "/search/query", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
g.SearchQuery(rr, req)
return rr
}
func int32Ptr(v int32) *int32 { return &v }
// searchHitRemoteItem decodes the remoteItem of the first hit, nil when absent.
func searchHitRemoteItem(rr *httptest.ResponseRecorder) *struct {
Id *string `json:"id"`
Name *string `json:"name"`
Path *string `json:"path"`
} {
var decoded struct {
Value []struct {
HitsContainers []struct {
Hits []struct {
Resource struct {
RemoteItem *struct {
Id *string `json:"id"`
Name *string `json:"name"`
Path *string `json:"path"`
} `json:"remoteItem"`
} `json:"resource"`
} `json:"hits"`
} `json:"hitsContainers"`
} `json:"value"`
}
Expect(json.Unmarshal(rr.Body.Bytes(), &decoded)).To(Succeed())
Expect(decoded.Value).To(HaveLen(1))
Expect(decoded.Value[0].HitsContainers).To(HaveLen(1))
Expect(decoded.Value[0].HitsContainers[0].Hits).To(HaveLen(1))
return decoded.Value[0].HitsContainers[0].Hits[0].Resource.RemoteItem
}
var _ = ginkgo.Describe("SearchQuery", func() {
ginkgo.It("forwards aggregations to the search service and groups results by request", func() {
var captured *searchsvc.SearchRequest
g := graphWithSearch(stubSearchService{
search: func(req *searchsvc.SearchRequest) (*searchsvc.SearchResponse, error) {
captured = req
return &searchsvc.SearchResponse{
TotalMatches: 10,
Aggregations: []*searchsvc.AggregationResult{{
Field: "audio.artist",
Buckets: []*searchsvc.Bucket{
{Key: "Pink Floyd", Count: 7},
{Key: "Motörhead", Count: 3},
},
}},
}, nil
},
})
rr := postSearchQuery(g, `{
"requests": [{
"entityTypes": ["driveItem"],
"query": {"queryString": "mediatype:audio"},
"size": 0,
"aggregations": [{
"field": "audio.artist",
"size": 5,
"bucketDefinition": {"sortBy": "count", "isDescending": true}
}]
}]
}`)
Expect(rr.Code).To(Equal(http.StatusOK), rr.Body.String())
Expect(captured).ToNot(BeNil())
Expect(captured.Aggregations).To(HaveLen(1))
Expect(captured.Aggregations[0].Field).To(Equal("audio.artist"))
var decoded struct {
Value []struct {
HitsContainers []struct {
Aggregations []struct {
Field *string `json:"field"`
Buckets []struct {
Key *string `json:"key"`
Count *int64 `json:"count"`
} `json:"buckets"`
} `json:"aggregations"`
} `json:"hitsContainers"`
} `json:"value"`
}
Expect(json.Unmarshal(rr.Body.Bytes(), &decoded)).To(Succeed())
Expect(decoded.Value).To(HaveLen(1))
Expect(decoded.Value[0].HitsContainers).To(HaveLen(1))
aggs := decoded.Value[0].HitsContainers[0].Aggregations
Expect(aggs).To(HaveLen(1))
Expect(aggs[0].Field).To(HaveValue(Equal("audio.artist")))
Expect(aggs[0].Buckets).To(HaveLen(2))
})
ginkgo.It("describes a hit from a shared space as a remote item", func() {
g := graphWithSearch(stubSearchService{
search: func(_ *searchsvc.SearchRequest) (*searchsvc.SearchResponse, error) {
return &searchsvc.SearchResponse{
TotalMatches: 1,
Matches: []*searchmsg.Match{{
Entity: &searchmsg.Entity{
Id: &searchmsg.ResourceID{StorageId: "1", SpaceId: "2", OpaqueId: "3"},
Name: "contract.pdf",
ShareRootName: "/Project X",
RemoteItemId: &searchmsg.ResourceID{StorageId: "4", SpaceId: "5", OpaqueId: "6"},
},
}},
}, nil
},
})
rr := postSearchQuery(g, `{"requests": [{"entityTypes": ["driveItem"], "query": {"queryString": "contract"}}]}`)
Expect(rr.Code).To(Equal(http.StatusOK))
remote := searchHitRemoteItem(rr)
Expect(remote).ToNot(BeNil())
Expect(remote.Id).To(HaveValue(Equal("4$5!6")))
Expect(remote.Path).To(HaveValue(Equal("/Project X")))
Expect(remote.Name).To(HaveValue(Equal("Project X")), "the mountpoint name the caller sees")
})
ginkgo.It("leaves the remote item out for hits from the caller's own spaces", func() {
g := graphWithSearch(stubSearchService{
search: func(_ *searchsvc.SearchRequest) (*searchsvc.SearchResponse, error) {
return &searchsvc.SearchResponse{
TotalMatches: 1,
Matches: []*searchmsg.Match{{
Entity: &searchmsg.Entity{
Id: &searchmsg.ResourceID{StorageId: "1", SpaceId: "2", OpaqueId: "3"},
Name: "notes.txt",
},
}},
}, nil
},
})
rr := postSearchQuery(g, `{"requests": [{"entityTypes": ["driveItem"], "query": {"queryString": "notes"}}]}`)
Expect(rr.Code).To(Equal(http.StatusOK))
Expect(searchHitRemoteItem(rr)).To(BeNil())
})
ginkgo.DescribeTable("clampPagination keeps from/size within valid bounds",
func(from, size *int32, wantFrom, wantSize int32) {
gotFrom, gotSize := clampPagination(from, size)
Expect(gotFrom).To(Equal(wantFrom))
Expect(gotSize).To(Equal(wantSize))
},
ginkgo.Entry("defaults", nil, nil, int32(0), int32(25)),
ginkgo.Entry("zero size", int32Ptr(5), int32Ptr(0), int32(5), int32(0)),
ginkgo.Entry("negative from clamps to zero", int32Ptr(-10), int32Ptr(5), int32(0), int32(5)),
ginkgo.Entry("negative size clamps to zero", int32Ptr(10), int32Ptr(-1), int32(10), int32(0)),
ginkgo.Entry("oversized size clamps to max", int32Ptr(0), int32Ptr(1000), int32(0), int32(500)),
ginkgo.Entry("from+size overflow collapses", int32Ptr(1<<31-1), int32Ptr(500), int32(1<<31-1-500), int32(500)),
)
ginkgo.It("forwards sortProperties to the search service as order_by", func() {
var captured *searchsvc.SearchRequest
g := graphWithSearch(stubSearchService{
search: func(req *searchsvc.SearchRequest) (*searchsvc.SearchResponse, error) {
captured = req
return &searchsvc.SearchResponse{}, nil
},
})
rr := postSearchQuery(g, `{
"requests": [{
"entityTypes": ["driveItem"],
"query": {"queryString": "mediatype:image"},
"sortProperties": [
{"name": "photo.takenDateTime", "isDescending": true},
{"name": "name"}
]
}]
}`)
Expect(rr.Code).To(Equal(http.StatusOK), rr.Body.String())
Expect(captured).ToNot(BeNil())
Expect(captured.OrderBy).To(HaveLen(2))
Expect(captured.OrderBy[0].Name).To(Equal("photo.takenDateTime"))
Expect(captured.OrderBy[0].IsDescending).To(BeTrue())
Expect(captured.OrderBy[1].Name).To(Equal("name"))
Expect(captured.OrderBy[1].IsDescending).To(BeFalse())
})
ginkgo.DescribeTable("accepts sorting by scalar hit fields",
func(field string) {
g := graphWithSearch(stubSearchService{
search: func(*searchsvc.SearchRequest) (*searchsvc.SearchResponse, error) {
return &searchsvc.SearchResponse{}, nil
},
})
rr := postSearchQuery(g, `{
"requests": [{
"entityTypes": ["driveItem"],
"query": {"queryString": "*"},
"sortProperties": [{"name": "`+field+`"}]
}]
}`)
Expect(rr.Code).To(Equal(http.StatusOK), rr.Body.String())
},
ginkgo.Entry("name", "name"),
ginkgo.Entry("size", "size"),
ginkgo.Entry("lastModifiedDateTime", "lastModifiedDateTime"),
ginkgo.Entry("mimeType", "mimeType"),
ginkgo.Entry("photo.takenDateTime", "photo.takenDateTime"),
ginkgo.Entry("photo.iso", "photo.iso"),
ginkgo.Entry("audio.artist", "audio.artist"),
ginkgo.Entry("image.width", "image.width"),
)
ginkgo.DescribeTable("rejects sorting by unsortable fields with 400",
func(field string) {
g := graphWithSearch(stubSearchService{
search: func(*searchsvc.SearchRequest) (*searchsvc.SearchResponse, error) {
ginkgo.Fail("search service must not be called when validation fails")
return nil, nil
},
})
rr := postSearchQuery(g, `{
"requests": [{
"entityTypes": ["driveItem"],
"query": {"queryString": "mediatype:image"},
"sortProperties": [{"name": "`+field+`"}]
}]
}`)
Expect(rr.Code).To(Equal(http.StatusBadRequest), rr.Body.String())
Expect(rr.Body.String()).To(ContainSubstring(field))
},
ginkgo.Entry("unknown field", "definitelyNotAField"),
ginkgo.Entry("multivalued field", "tags"),
ginkgo.Entry("internal index field name", "Mtime"),
ginkgo.Entry("bare audio facet", "audio"),
ginkgo.Entry("bare location facet", "location"),
)
ginkgo.It("rejects a terms aggregation on a numeric field with 400", func() {
g := graphWithSearch(stubSearchService{
search: func(*searchsvc.SearchRequest) (*searchsvc.SearchResponse, error) {
ginkgo.Fail("search service must not be called when validation fails")
return nil, nil
},
})
rr := postSearchQuery(g, `{
"requests": [{
"entityTypes": ["driveItem"],
"query": {"queryString": "mediatype:audio"},
"size": 0,
"aggregations": [{"field": "audio.year"}]
}]
}`)
Expect(rr.Code).To(Equal(http.StatusBadRequest), rr.Body.String())
})
ginkgo.It("allows a range aggregation on a numeric field", func() {
called := false
g := graphWithSearch(stubSearchService{
search: func(*searchsvc.SearchRequest) (*searchsvc.SearchResponse, error) {
called = true
return &searchsvc.SearchResponse{}, nil
},
})
rr := postSearchQuery(g, `{
"requests": [{
"entityTypes": ["driveItem"],
"query": {"queryString": "mediatype:audio"},
"size": 0,
"aggregations": [{
"field": "audio.year",
"bucketDefinition": {
"sortBy": "keyAsString",
"ranges": [{"from": "1970", "to": "1980"}]
}
}]
}]
}`)
Expect(rr.Code).To(Equal(http.StatusOK), rr.Body.String())
Expect(called).To(BeTrue())
})
})
+3
View File
@@ -110,6 +110,8 @@ type Service interface { //nolint:interfacebloat
GetTags(w http.ResponseWriter, r *http.Request)
AssignTags(w http.ResponseWriter, r *http.Request)
UnassignTags(w http.ResponseWriter, r *http.Request)
SearchQuery(w http.ResponseWriter, r *http.Request)
}
// NewService returns a service implementation for Service.
@@ -288,6 +290,7 @@ func NewService(opts ...Option) (Graph, error) { //nolint:maintidx
r.Get("/", svc.GetRoleDefinitions)
r.Get("/{roleID}", svc.GetRoleDefinition)
})
r.Post("/search/query", svc.SearchQuery)
})
r.Route("/v1.0", func(r chi.Router) {
r.Route("/extensions/org.libregraph", func(r chi.Router) {
+1 -1
View File
@@ -42,7 +42,7 @@ func (g Graph) GetSharedByMe(w http.ResponseWriter, r *http.Request) {
expandThumbnails := strings.Contains(expand, "thumbnails")
if expandThumbnails {
for k, item := range driveItems {
setShareThumbnails(&item, item.GetId(), g.config.Commons.OpenCloudURL)
setDriveItemThumbnailsByID(&item, item.GetId(), g.config.Commons.OpenCloudURL)
driveItems[k] = item
}
}
@@ -71,7 +71,7 @@ func (g Graph) listSharedWithMe(ctx context.Context, expandThumbnails bool) ([]l
if expandThumbnails {
for k, item := range driveItems {
setShareThumbnails(&item, item.RemoteItem.GetId(), g.config.Commons.OpenCloudURL)
setDriveItemThumbnailsByID(&item, item.RemoteItem.GetId(), g.config.Commons.OpenCloudURL)
driveItems[k] = item
}
}
+4 -3
View File
@@ -57,9 +57,10 @@ func previewThumbnail(base string, box int32) *libregraph.Thumbnail {
return &libregraph.Thumbnail{Url: &url}
}
// setShareThumbnails works off the driveItem, the share listings have no resource
// info. The id comes separately, a received share carries it on its remote item.
func setShareThumbnails(item *libregraph.DriveItem, itemID, baseURL string) {
// setDriveItemThumbnailsByID works off the driveItem itself (mime type from the
// item, id passed separately), for callers that have no CS3 resource info: the
// share listings and the search results.
func setDriveItemThumbnailsByID(item *libregraph.DriveItem, itemID, baseURL string) {
mimeType := item.GetFile().MimeType
if itemID == "" || mimeType == nil || !thumbnail.IsMimeTypeSupported(*mimeType) {
return
@@ -0,0 +1,13 @@
package aggregation_test
import (
"testing"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestAggregation(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Aggregation Suite")
}
+148
View File
@@ -0,0 +1,148 @@
// Package aggregation encodes and decodes the aggregationFilterToken exchanged
// with clients. A terms token is the bucket key as lowercase hex of its UTF-8
// bytes, prefixed with U+01C2 twice and wrapped in double quotes (the same
// encoding MS Graph uses); a range token is range(from,to) with min/max for open
// bounds. Decoding turns a {field}:{token} filter into a KQL fragment the search
// engines parse and then force to an exact, case-sensitive match.
package aggregation
import (
"encoding/hex"
"fmt"
"strings"
)
// termPrefix marks a hex-encoded terms token (U+01C2 LATIN LETTER ALVEOLAR
// CLICK, twice).
const termPrefix = "ǂǂ"
// EncodeTermsToken encodes a terms bucket key as an aggregationFilterToken: the
// key as lowercase hex of its UTF-8 bytes, prefixed with termPrefix and wrapped
// in double quotes. The quotes are part of the token value.
func EncodeTermsToken(key string) string {
return `"` + termPrefix + hex.EncodeToString([]byte(key)) + `"`
}
// EncodeRangeToken encodes a range bucket as range(from,to). An empty bound is
// open and written as min (lower) or max (upper).
func EncodeRangeToken(from, to string) string {
if from == "" {
from = "min"
}
if to == "" {
to = "max"
}
return "range(" + from + "," + to + ")"
}
// DecodeAggregationFilter turns a {field}:{token} aggregation filter into a KQL
// fragment. Terms and or() tokens become field:"value" restrictions (to be
// matched exactly and case-sensitively by the caller); range() becomes a
// numeric/date range. Tokens that are not server-shaped are rejected.
func DecodeAggregationFilter(filter string) (string, error) {
field, token, ok := strings.Cut(filter, ":")
if !ok || field == "" || token == "" {
return "", fmt.Errorf("invalid aggregation filter %q", filter)
}
switch {
case strings.HasPrefix(token, "or("):
return decodeOr(field, token)
case strings.HasPrefix(token, "range("):
return decodeRange(field, token)
default:
v, err := decodeTerm(token)
if err != nil {
return "", err
}
frag, err := kqlTerm(field, v)
if err != nil {
return "", err
}
return frag, nil
}
}
// decodeTerm strips the quotes and termPrefix and hex-decodes a terms token.
func decodeTerm(token string) (string, error) {
if len(token) < 2 || token[0] != '"' || token[len(token)-1] != '"' {
return "", fmt.Errorf("invalid terms token %q", token)
}
inner := token[1 : len(token)-1]
if !strings.HasPrefix(inner, termPrefix) {
return "", fmt.Errorf("invalid terms token %q", token)
}
b, err := hex.DecodeString(strings.TrimPrefix(inner, termPrefix))
if err != nil {
return "", fmt.Errorf("invalid terms token %q: %w", token, err)
}
return string(b), nil
}
// decodeRange turns range(from,to) into a KQL comparison; open bounds (min/max)
// are dropped.
func decodeRange(field, token string) (string, error) {
inner, ok := trimCall(token, "range")
if !ok {
return "", fmt.Errorf("invalid range token %q", token)
}
from, to, ok := strings.Cut(inner, ",")
if !ok {
return "", fmt.Errorf("invalid range token %q", token)
}
from, to = strings.TrimSpace(from), strings.TrimSpace(to)
var parts []string
if from != "" && from != "min" {
parts = append(parts, field+">="+from)
}
if to != "" && to != "max" {
parts = append(parts, field+"<="+to)
}
if len(parts) == 0 {
return "", fmt.Errorf("range token %q has no bounds", token)
}
return "(" + strings.Join(parts, " AND ") + ")", nil
}
// decodeOr turns or("token","token",...) into an OR group of terms.
func decodeOr(field, token string) (string, error) {
inner, ok := trimCall(token, "or")
if !ok {
return "", fmt.Errorf("invalid or token %q", token)
}
// terms tokens are quote-wrapped lowercase hex, so they never contain a
// comma; a plain split is safe.
parts := make([]string, 0)
for _, t := range strings.Split(inner, ",") {
v, err := decodeTerm(strings.TrimSpace(t))
if err != nil {
return "", err
}
frag, err := kqlTerm(field, v)
if err != nil {
return "", err
}
parts = append(parts, frag)
}
if len(parts) == 0 {
return "", fmt.Errorf("empty or token %q", token)
}
return "(" + strings.Join(parts, " OR ") + ")", nil
}
// trimCall unwraps name(inner); ok is false when token is not name(...).
func trimCall(token, name string) (string, bool) {
if !strings.HasPrefix(token, name+"(") || !strings.HasSuffix(token, ")") {
return "", false
}
return token[len(name)+1 : len(token)-1], true
}
// kqlTerm builds a field:"value" restriction. KQL quoted strings have no escape
// syntax, so a value containing a double quote cannot be expressed and is
// rejected rather than emitted as broken KQL.
func kqlTerm(field, value string) (string, error) {
if strings.Contains(value, `"`) {
return "", fmt.Errorf("aggregation value %q contains an unsupported double quote", value)
}
return field + `:"` + value + `"`, nil
}
@@ -0,0 +1,68 @@
package aggregation_test
import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/opencloud-eu/opencloud/services/search/pkg/aggregation"
)
var _ = Describe("Token", func() {
Describe("EncodeTermsToken", func() {
It("encodes the key as quoted ǂǂ-prefixed lowercase hex", func() {
Expect(aggregation.EncodeTermsToken("And the Bands Played On")).To(Equal(`"ǂǂ416e64207468652042616e647320506c61796564204f6e"`))
})
})
Describe("EncodeRangeToken", func() {
DescribeTable("bounds",
func(from, to, want string) {
Expect(aggregation.EncodeRangeToken(from, to)).To(Equal(want))
},
Entry("closed", "0", "100", "range(0,100)"),
Entry("open lower", "", "100", "range(min,100)"),
Entry("open upper", "0", "", "range(0,max)"),
)
})
Describe("DecodeAggregationFilter", func() {
DescribeTable("valid tokens",
func(filter, want string) {
got, err := aggregation.DecodeAggregationFilter(filter)
Expect(err).ToNot(HaveOccurred())
Expect(got).To(Equal(want))
},
Entry("terms with a space", `audio.artist:"ǂǂ5361786f6e"`, `audio.artist:"Saxon"`),
Entry("closed range", "Size:range(0,100)", "(Size>=0 AND Size<=100)"),
Entry("open lower range", "Size:range(min,100)", "(Size<=100)"),
Entry("open upper range", "Size:range(0,max)", "(Size>=0)"),
Entry("or of two terms",
`audio.artist:or("ǂǂ5361786f6e","ǂǂ49726f6e204d616964656e")`,
`(audio.artist:"Saxon" OR audio.artist:"Iron Maiden")`),
)
DescribeTable("rejected tokens",
func(filter string) {
_, err := aggregation.DecodeAggregationFilter(filter)
Expect(err).To(HaveOccurred())
},
Entry("no colon", `audio.artist"ǂǂ00"`),
Entry("empty field", `:"ǂǂ00"`),
Entry("missing ǂǂ prefix", `audio.artist:"deadbeef"`),
Entry("odd hex", `audio.artist:"ǂǂabc"`),
Entry("range without bounds", "Size:range(min,max)"),
)
It("round-trips a terms key through encode+decode", func() {
got, err := aggregation.DecodeAggregationFilter("audio.artist:" + aggregation.EncodeTermsToken("AC/DC"))
Expect(err).ToNot(HaveOccurred())
Expect(got).To(Equal(`audio.artist:"AC/DC"`))
})
It("rejects a decoded value containing a double quote", func() {
// 22 is a double quote; it cannot be expressed in a KQL string.
_, err := aggregation.DecodeAggregationFilter(`audio.artist:"ǂǂ22"`)
Expect(err).To(HaveOccurred())
})
})
})
+389 -1
View File
@@ -2,10 +2,13 @@ package bleve
import (
"context"
"fmt"
"math"
"strconv"
"time"
"github.com/blevesearch/bleve/v2"
bleveSearch "github.com/blevesearch/bleve/v2/search"
"github.com/blevesearch/bleve/v2/search/query"
storageProvider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
"github.com/opencloud-eu/reva/v2/pkg/errtypes"
@@ -43,7 +46,7 @@ func NewBackend(index bleve.Index, queryCreator searchQuery.Creator[query.Query]
// Search executes a search request operation within the index.
// Returns a SearchIndexResponse object or an error.
func (b *Backend) Search(_ context.Context, sir *searchService.SearchIndexRequest) (*searchService.SearchIndexResponse, error) {
createdQuery, err := b.queryCreator.Create(sir.Query)
createdQuery, err := b.queryCreator.CreateWithFilters(sir.Query, sir.GetAggregationFilters())
if err != nil {
if kql.IsValidationError(err) {
return nil, errtypes.BadRequest(err.Error())
@@ -89,6 +92,24 @@ func (b *Backend) Search(_ context.Context, sir *searchService.SearchIndexReques
bleveReq := bleve.NewSearchRequest(q)
bleveReq.Highlight = bleve.NewHighlight()
// Sort natively in the index; the service layer re-establishes this order
// when merging matches across spaces. Score sorting (bleve's default)
// stays in place when no order_by is given.
if orderBy := sir.GetOrderBy(); len(orderBy) > 0 {
sortOrder := make([]string, 0, len(orderBy)+1)
for _, sp := range orderBy {
field, ok := search.SortIndexField(sp.GetName())
if !ok {
return nil, errtypes.BadRequest(fmt.Sprintf("field %q is not sortable", sp.GetName()))
}
if sp.GetIsDescending() {
field = "-" + field
}
sortOrder = append(sortOrder, field)
}
bleveReq.SortBy(append(sortOrder, "-_score"))
}
switch {
case sir.PageSize == -1:
bleveReq.Size = math.MaxInt
@@ -98,6 +119,26 @@ func (b *Backend) Search(_ context.Context, sir *searchService.SearchIndexReques
bleveReq.Size = int(sir.PageSize)
}
for _, agg := range sir.GetAggregations() {
// Top-level metrics are computed by scanning the matched hits, they
// have no facet representation.
if agg.GetMetricKind() != searchService.MetricKind_METRIC_KIND_UNSPECIFIED {
continue
}
fr, err := newBleveFacetRequest(agg)
if err != nil {
return nil, err
}
bleveReq.AddFacet(agg.GetField(), fr)
}
// Sub-aggregations and top-level metrics need the matched hit set, not just
// count facets: widen the page so the emulator has enough docs. The caller's
// larger PageSize wins.
if needsSubAggScan(sir.GetAggregations()) && bleveReq.Size < subAggScanSize {
bleveReq.Size = subAggScanSize
}
bleveReq.Fields = []string{"*"}
res, err := b.index.Search(bleveReq)
if err != nil {
@@ -155,9 +196,356 @@ func (b *Backend) Search(_ context.Context, sir *searchService.SearchIndexReques
return &searchService.SearchIndexResponse{
Matches: matches,
TotalMatches: int32(totalMatches),
Aggregations: extractBleveAggregations(res, sir.GetAggregations()),
}, nil
}
// subAggScanSize caps how many hits we walk when emulating sub-aggregations;
// math.MaxInt returns everything.
const subAggScanSize = math.MaxInt
func needsSubAggScan(aggs []*searchService.AggregationOption) bool {
for _, agg := range aggs {
if len(agg.GetSubAggregations()) > 0 {
return true
}
if agg.GetMetricKind() != searchService.MetricKind_METRIC_KIND_UNSPECIFIED {
return true
}
}
return false
}
// defaultFacetSize is used when no size is requested; the service layer trims
// after cross-space merge.
const defaultFacetSize = 1000
func newBleveFacetRequest(agg *searchService.AggregationOption) (*bleve.FacetRequest, error) {
size := int(agg.GetSize())
if size <= 0 {
size = defaultFacetSize
}
fr := bleve.NewFacetRequest(agg.GetField(), size)
ranges := aggregationRanges(agg)
if rangesAreDates(ranges) {
// bleve facets cannot mix numeric and date ranges, so one date-looking
// bound switches the whole aggregation to date mode.
for _, r := range ranges {
start, err := parseRangeTime(r.GetFrom())
if err != nil {
return nil, fmt.Errorf("invalid date range bound %q on field %q", r.GetFrom(), agg.GetField())
}
end, err := parseRangeTime(r.GetTo())
if err != nil {
return nil, fmt.Errorf("invalid date range bound %q on field %q", r.GetTo(), agg.GetField())
}
fr.AddDateTimeRange(rangeBucketKey(r), start, end)
}
return fr, nil
}
for _, r := range ranges {
minP := parseFloatPtr(r.GetFrom())
maxP := parseFloatPtr(r.GetTo())
fr.AddNumericRange(rangeBucketKey(r), minP, maxP)
}
return fr, nil
}
// rangeTimeLayouts are the accepted formats for date range bounds, tried in order.
var rangeTimeLayouts = []string{time.RFC3339, "2006-01-02"}
// rangesAreDates reports whether the ranges should be treated as datetime
// ranges: at least one bound parses as a date rather than a number.
func rangesAreDates(ranges []*searchService.BucketRange) bool {
for _, r := range ranges {
for _, s := range []string{r.GetFrom(), r.GetTo()} {
if s == "" {
continue
}
if _, err := strconv.ParseFloat(s, 64); err == nil {
continue
}
if _, err := parseRangeTime(s); err == nil {
return true
}
}
}
return false
}
// parseRangeTime parses a range bound; the zero time marks an open bound.
func parseRangeTime(s string) (time.Time, error) {
if s == "" {
return time.Time{}, nil
}
for _, layout := range rangeTimeLayouts {
if t, err := time.Parse(layout, s); err == nil {
return t, nil
}
}
return time.Time{}, fmt.Errorf("unsupported time format %q", s)
}
func extractBleveAggregations(res *bleve.SearchResult, aggs []*searchService.AggregationOption) []*searchService.AggregationResult {
if len(aggs) == 0 {
return nil
}
out := make([]*searchService.AggregationResult, 0, len(aggs))
for _, agg := range aggs {
// Top-level metric: fold the matched hits through the sub-agg
// accumulator, there is no facet to read from.
if agg.GetMetricKind() != searchService.MetricKind_METRIC_KIND_UNSPECIFIED {
acc := newSubAcc(agg)
for _, hit := range res.Hits {
accumulateHit(acc, agg, hit)
}
if r := emitAcc(acc, agg); r != nil {
out = append(out, r)
}
continue
}
fr, ok := res.Facets[agg.GetField()]
if !ok {
continue
}
buckets := make([]*searchService.Bucket, 0)
if len(aggregationRanges(agg)) > 0 {
for _, nr := range fr.NumericRanges {
buckets = append(buckets, &searchService.Bucket{
Key: nr.Name,
Count: int64(nr.Count),
})
}
for _, dr := range fr.DateRanges {
buckets = append(buckets, &searchService.Bucket{
Key: dr.Name,
Count: int64(dr.Count),
})
}
} else {
for _, t := range fr.Terms.Terms() {
buckets = append(buckets, &searchService.Bucket{
Key: t.Term,
Count: int64(t.Count),
})
}
}
if subAggs := agg.GetSubAggregations(); len(subAggs) > 0 {
attachSubAggregations(res, agg.GetField(), subAggs, buckets)
}
out = append(out, &searchService.AggregationResult{
Field: agg.GetField(),
Buckets: buckets,
})
}
return out
}
// subAcc is the recursive accumulator emulating composite aggregations: one
// node per sub-aggregation under a parent bucket.
type subAcc struct {
// terms: count + recursive accumulators per child value
termCount map[string]int64
termSubs map[string][]*subAcc
// metric
metricVal float64 // SUM/MIN/MAX
sum float64 // AVG transport: numerator
count int64 // AVG transport: denominator
seen bool // at least one hit contributed
}
// newSubAcc allocates an accumulator for the given sub-agg.
func newSubAcc(sa *searchService.AggregationOption) *subAcc {
a := &subAcc{}
if sa.GetMetricKind() == searchService.MetricKind_METRIC_KIND_UNSPECIFIED {
a.termCount = map[string]int64{}
if len(sa.GetSubAggregations()) > 0 {
a.termSubs = map[string][]*subAcc{}
}
}
return a
}
// accumulateHit folds one hit into a sub-agg accumulator, recursing into
// grand-sub-aggregations.
func accumulateHit(a *subAcc, sa *searchService.AggregationOption, hit *bleveSearch.DocumentMatch) {
switch sa.GetMetricKind() {
case searchService.MetricKind_METRIC_KIND_UNSPECIFIED:
val, ok := hit.Fields[sa.GetField()].(string)
if !ok || val == "" {
return
}
a.termCount[val]++
a.seen = true
if subs := sa.GetSubAggregations(); len(subs) > 0 {
childAccs, ok := a.termSubs[val]
if !ok {
childAccs = make([]*subAcc, len(subs))
for i, ssa := range subs {
childAccs[i] = newSubAcc(ssa)
}
a.termSubs[val] = childAccs
}
for i, ssa := range subs {
accumulateHit(childAccs[i], ssa, hit)
}
}
default:
v, ok := numericFieldValue(hit.Fields[sa.GetField()])
if !ok {
return
}
switch sa.GetMetricKind() {
case searchService.MetricKind_METRIC_KIND_SUM:
a.metricVal += v
case searchService.MetricKind_METRIC_KIND_MIN:
if !a.seen || v < a.metricVal {
a.metricVal = v
}
case searchService.MetricKind_METRIC_KIND_MAX:
if !a.seen || v > a.metricVal {
a.metricVal = v
}
case searchService.MetricKind_METRIC_KIND_AVG:
a.sum += v
a.count++
}
a.seen = true
}
}
// emitAcc materialises a sub-agg accumulator into the proto result.
func emitAcc(a *subAcc, sa *searchService.AggregationOption) *searchService.AggregationResult {
if sa.GetMetricKind() != searchService.MetricKind_METRIC_KIND_UNSPECIFIED {
if !a.seen {
return nil
}
r := &searchService.AggregationResult{
Field: sa.GetField(),
MetricKind: sa.GetMetricKind(),
}
if sa.GetMetricKind() == searchService.MetricKind_METRIC_KIND_AVG {
r.Sum = a.sum
r.Count = a.count
} else {
r.Value = a.metricVal
}
return r
}
subs := sa.GetSubAggregations()
childBuckets := make([]*searchService.Bucket, 0, len(a.termCount))
for term, count := range a.termCount {
b := &searchService.Bucket{Key: term, Count: count}
if len(subs) > 0 {
if childAccs, ok := a.termSubs[term]; ok {
for i, ssa := range subs {
if sub := emitAcc(childAccs[i], ssa); sub != nil {
b.SubAggregations = append(b.SubAggregations, sub)
}
}
}
}
childBuckets = append(childBuckets, b)
}
if sz := int(sa.GetSize()); sz > 0 && len(childBuckets) > sz {
childBuckets = childBuckets[:sz]
}
return &searchService.AggregationResult{
Field: sa.GetField(),
Buckets: childBuckets,
}
}
// attachSubAggregations folds the matched hits into nested aggregation results
// per parent bucket, via a single hit walk dispatched through the accumulator tree.
func attachSubAggregations(res *bleve.SearchResult, parentField string, subAggs []*searchService.AggregationOption, buckets []*searchService.Bucket) {
bucketByKey := make(map[string]*searchService.Bucket, len(buckets))
for _, b := range buckets {
bucketByKey[b.GetKey()] = b
}
perParent := make(map[string][]*subAcc, len(buckets))
for _, b := range buckets {
accs := make([]*subAcc, len(subAggs))
for i, sa := range subAggs {
accs[i] = newSubAcc(sa)
}
perParent[b.GetKey()] = accs
}
for _, hit := range res.Hits {
parentVal, ok := hit.Fields[parentField].(string)
if !ok || parentVal == "" {
continue
}
accs, ok := perParent[parentVal]
if !ok {
continue
}
for i, sa := range subAggs {
accumulateHit(accs[i], sa, hit)
}
}
for key, accs := range perParent {
b := bucketByKey[key]
for i, sa := range subAggs {
if r := emitAcc(accs[i], sa); r != nil {
b.SubAggregations = append(b.SubAggregations, r)
}
}
}
}
// numericFieldValue coerces a bleve stored value to float64 (also accepts
// string forms).
func numericFieldValue(raw interface{}) (float64, bool) {
switch v := raw.(type) {
case float64:
return v, true
case int64:
return float64(v), true
case int32:
return float64(v), true
case string:
f, err := strconv.ParseFloat(v, 64)
if err != nil {
return 0, false
}
return f, true
default:
return 0, false
}
}
func aggregationRanges(agg *searchService.AggregationOption) []*searchService.BucketRange {
bd := agg.GetBucketDefinition()
if bd == nil {
return nil
}
return bd.GetRanges()
}
// rangeBucketKey formats a range as "from-to" for stable merge keys; open sides
// render as "-N" or "N-".
func rangeBucketKey(r *searchService.BucketRange) string {
return r.GetFrom() + "-" + r.GetTo()
}
func parseFloatPtr(s string) *float64 {
if s == "" {
return nil
}
v, err := strconv.ParseFloat(s, 64)
if err != nil {
return nil
}
return &v
}
func (b *Backend) DocCount() (uint64, error) {
return b.index.DocCount()
}
+35 -1
View File
@@ -17,6 +17,7 @@ import (
"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/aggs"
"github.com/opencloud-eu/opencloud/services/search/pkg/opensearch/internal/convert"
"github.com/opencloud-eu/opencloud/services/search/pkg/opensearch/internal/osu"
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
@@ -72,7 +73,7 @@ func NewBackend(ctx context.Context, name string, client *opensearchgoAPI.Client
}
func (b *Backend) Search(ctx context.Context, sir *searchService.SearchIndexRequest) (*searchService.SearchIndexResponse, error) {
boolQuery, err := convert.KQLToOpenSearchBoolQuery(sir.Query)
boolQuery, err := convert.KQLToOpenSearchBoolQueryWithFilters(sir.Query, sir.GetAggregationFilters())
switch {
case kql.IsValidationError(err):
return nil, errtypes.BadRequest(err.Error())
@@ -122,6 +123,31 @@ func (b *Backend) Search(ctx context.Context, sir *searchService.SearchIndexRequ
searchParams.Size = conversions.ToPointer(int(sir.PageSize))
}
builtAggs, err := aggs.Build(sir.GetAggregations())
if err != nil {
return nil, err
}
// Sort natively in the index; the service layer re-establishes this order
// when merging matches across spaces. Score sorting (the default) stays in
// place when no order_by is given. Missing values sort last in both
// directions, matching the cross-space merge.
var sortClause []map[string]any
if orderBy := sir.GetOrderBy(); len(orderBy) > 0 {
sortClause = make([]map[string]any, 0, len(orderBy)+1)
for _, sp := range orderBy {
field, ok := search.SortIndexField(sp.GetName())
if !ok {
return nil, errtypes.BadRequest(fmt.Sprintf("field %q is not sortable", sp.GetName()))
}
order := "asc"
if sp.GetIsDescending() {
order = "desc"
}
sortClause = append(sortClause, map[string]any{field: map[string]any{"order": order, "missing": "_last"}})
}
sortClause = append(sortClause, map[string]any{"_score": map[string]any{"order": "desc"}})
}
req, err := osu.BuildSearchReq(&opensearchgoAPI.SearchReq{
Indices: []string{b.index},
Params: searchParams,
@@ -140,6 +166,8 @@ func (b *Backend) Search(ctx context.Context, sir *searchService.SearchIndexRequ
},
},
},
Aggs: builtAggs,
Sort: sortClause,
},
)
if err != nil {
@@ -162,9 +190,15 @@ func (b *Backend) Search(ctx context.Context, sir *searchService.SearchIndexRequ
matches = append(matches, match)
}
aggResults, err := aggs.Parse(resp.Aggregations, sir.GetAggregations())
if err != nil {
return nil, fmt.Errorf("failed to parse aggregations: %w", err)
}
return &searchService.SearchIndexResponse{
Matches: matches,
TotalMatches: int32(totalMatches),
Aggregations: aggResults,
}, nil
}
-1
View File
@@ -74,7 +74,6 @@ func buildResourceMapping() ([]byte, error) {
if err != nil {
return nil, err
}
index := map[string]any{
"settings": map[string]any{
"number_of_shards": "1",
@@ -0,0 +1,315 @@
// Package aggs translates proto aggregation options into the OpenSearch
// aggregation DSL and parses the response. Internal subpackage so its unit
// tests skip the parent package's Docker OpenSearch container.
package aggs
import (
"encoding/json"
"fmt"
"strconv"
"time"
searchsvc "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/services/search/v0"
)
// DefaultFacetSize matches the bleve backend: pull a generous bucket count per
// space, the service layer trims to top N after cross-space merge.
const DefaultFacetSize = 1000
// Build translates AggregationOptions into the OpenSearch aggregation DSL
// (terms, range, date_range, metric, nested). Entries get an index-derived
// name so repeated aggs on one field don't collide. A range bound that is
// neither a number nor a date is an error.
func Build(opts []*searchsvc.AggregationOption) (map[string]any, error) {
return buildLevel(opts, "a")
}
func buildLevel(opts []*searchsvc.AggregationOption, prefix string) (map[string]any, error) {
if len(opts) == 0 {
return nil, nil
}
aggs := map[string]any{}
for i, opt := range opts {
name := fmt.Sprintf("%s_%d", prefix, i)
entry, err := buildOne(opt, name)
if err != nil {
return nil, err
}
if entry != nil {
aggs[name] = entry
}
}
if len(aggs) == 0 {
return nil, nil
}
return aggs, nil
}
func buildOne(opt *searchsvc.AggregationOption, name string) (map[string]any, error) {
field := opt.GetField()
if mk := opt.GetMetricKind(); mk != searchsvc.MetricKind_METRIC_KIND_UNSPECIFIED {
return buildMetric(field, mk), nil
}
var entry map[string]any
if ranges := rangesOf(opt); len(ranges) > 0 {
built, kind, err := buildRanges(field, ranges)
if err != nil {
return nil, err
}
entry = map[string]any{
kind: map[string]any{
"field": field,
"ranges": built,
},
}
} else {
size := int(opt.GetSize())
if size <= 0 {
size = DefaultFacetSize
}
entry = map[string]any{
"terms": map[string]any{
"field": field,
"size": size,
},
}
}
if subs := opt.GetSubAggregations(); len(subs) > 0 {
nested, err := buildLevel(subs, name)
if err != nil {
return nil, err
}
if nested != nil {
entry["aggs"] = nested
}
}
return entry, nil
}
// buildMetric emits the sum/min/max metric. AVG uses a stats agg to transport
// (sum, count) for the cross-space merge; the service layer collapses to the average.
func buildMetric(field string, kind searchsvc.MetricKind) map[string]any {
switch kind {
case searchsvc.MetricKind_METRIC_KIND_SUM:
return map[string]any{"sum": map[string]any{"field": field}}
case searchsvc.MetricKind_METRIC_KIND_MIN:
return map[string]any{"min": map[string]any{"field": field}}
case searchsvc.MetricKind_METRIC_KIND_MAX:
return map[string]any{"max": map[string]any{"field": field}}
case searchsvc.MetricKind_METRIC_KIND_AVG:
return map[string]any{"stats": map[string]any{"field": field}}
}
return nil
}
func rangesOf(opt *searchsvc.AggregationOption) []*searchsvc.BucketRange {
bd := opt.GetBucketDefinition()
if bd == nil {
return nil
}
return bd.GetRanges()
}
// rangeTimeLayouts mirrors the bleve backend's accepted date bound formats.
var rangeTimeLayouts = []string{time.RFC3339, "2006-01-02"}
func boundIsDate(s string) bool {
for _, layout := range rangeTimeLayouts {
if _, err := time.Parse(layout, s); err == nil {
return true
}
}
return false
}
// buildRanges renders the ranges and decides between the numeric "range" and
// the "date_range" aggregation: one date-looking bound switches the whole
// aggregation to date mode, exactly like the bleve backend.
func buildRanges(field string, ranges []*searchsvc.BucketRange) ([]map[string]any, string, error) {
dates := false
for _, r := range ranges {
for _, s := range []string{r.GetFrom(), r.GetTo()} {
if s == "" {
continue
}
if _, err := strconv.ParseFloat(s, 64); err != nil {
dates = true
}
}
}
out := make([]map[string]any, 0, len(ranges))
for _, r := range ranges {
entry := map[string]any{
"key": RangeKey(r),
}
for side, s := range map[string]string{"from": r.GetFrom(), "to": r.GetTo()} {
if s == "" {
continue
}
if dates {
if !boundIsDate(s) {
return nil, "", fmt.Errorf("invalid date range bound %q on field %q", s, field)
}
entry[side] = s
continue
}
v, err := strconv.ParseFloat(s, 64)
if err != nil {
return nil, "", fmt.Errorf("invalid range bound %q on field %q", s, field)
}
entry[side] = v
}
out = append(out, entry)
}
kind := "range"
if dates {
kind = "date_range"
}
return out, kind, nil
}
// RangeKey mirrors the bleve backend so cross-space merging keys match.
func RangeKey(r *searchsvc.BucketRange) string {
return r.GetFrom() + "-" + r.GetTo()
}
// Parse converts the response aggregations block into proto results, preserving
// request order and recursing into sub-aggregations. Empty input yields
// (nil, nil); invalid JSON yields an error.
func Parse(raw json.RawMessage, opts []*searchsvc.AggregationOption) ([]*searchsvc.AggregationResult, error) {
if len(raw) == 0 || len(opts) == 0 {
return nil, nil
}
node, err := parseNode(raw)
if err != nil {
return nil, err
}
return parseLevel(node, opts, "a"), nil
}
// aggNode is a lazily-decoded cursor over one level of the aggs response.
type aggNode map[string]json.RawMessage
func parseNode(raw json.RawMessage) (aggNode, error) {
var m aggNode
if err := json.Unmarshal(raw, &m); err != nil {
return nil, fmt.Errorf("decode opensearch aggregations: %w", err)
}
return m, nil
}
func parseLevel(node aggNode, opts []*searchsvc.AggregationOption, prefix string) []*searchsvc.AggregationResult {
out := make([]*searchsvc.AggregationResult, 0, len(opts))
for i, opt := range opts {
name := fmt.Sprintf("%s_%d", prefix, i)
raw, ok := node[name]
if !ok {
continue
}
if res := parseOne(raw, opt, name); res != nil {
out = append(out, res)
}
}
return out
}
func parseOne(raw json.RawMessage, opt *searchsvc.AggregationOption, name string) *searchsvc.AggregationResult {
field := opt.GetField()
if mk := opt.GetMetricKind(); mk != searchsvc.MetricKind_METRIC_KIND_UNSPECIFIED {
return parseMetric(raw, field, mk)
}
var body struct {
Buckets []json.RawMessage `json:"buckets"`
}
if err := json.Unmarshal(raw, &body); err != nil {
return nil
}
buckets := make([]*searchsvc.Bucket, 0, len(body.Buckets))
for _, b := range body.Buckets {
if bucket := parseBucket(b, opt.GetSubAggregations(), name); bucket != nil {
buckets = append(buckets, bucket)
}
}
return &searchsvc.AggregationResult{
Field: field,
Buckets: buckets,
}
}
func parseBucket(raw json.RawMessage, subs []*searchsvc.AggregationOption, prefix string) *searchsvc.Bucket {
var head struct {
Key any `json:"key"`
DocCount int64 `json:"doc_count"`
}
if err := json.Unmarshal(raw, &head); err != nil {
return nil
}
b := &searchsvc.Bucket{
Key: bucketKeyToString(head.Key),
Count: head.DocCount,
}
if len(subs) > 0 {
node, err := parseNode(raw)
if err == nil {
b.SubAggregations = parseLevel(node, subs, prefix)
}
}
return b
}
func parseMetric(raw json.RawMessage, field string, kind searchsvc.MetricKind) *searchsvc.AggregationResult {
switch kind {
case searchsvc.MetricKind_METRIC_KIND_SUM,
searchsvc.MetricKind_METRIC_KIND_MIN,
searchsvc.MetricKind_METRIC_KIND_MAX:
var body struct {
Value *float64 `json:"value"`
}
if err := json.Unmarshal(raw, &body); err != nil {
return nil
}
res := &searchsvc.AggregationResult{
Field: field,
MetricKind: kind,
}
if body.Value != nil {
res.Value = *body.Value
}
return res
case searchsvc.MetricKind_METRIC_KIND_AVG:
var body struct {
Sum float64 `json:"sum"`
Count int64 `json:"count"`
}
if err := json.Unmarshal(raw, &body); err != nil {
return nil
}
return &searchsvc.AggregationResult{
Field: field,
Sum: body.Sum,
Count: body.Count,
MetricKind: kind,
}
}
return nil
}
// bucketKeyToString normalises a response key to a string (terms are strings,
// ranges use our "from-to" key, numeric terms come back as JSON numbers).
func bucketKeyToString(v any) string {
switch x := v.(type) {
case string:
return x
case float64:
// format without trailing zeros so keys match filter values
return strconv.FormatFloat(x, 'f', -1, 64)
case bool:
return strconv.FormatBool(x)
case nil:
return ""
default:
return ""
}
}
@@ -0,0 +1,13 @@
package aggs_test
import (
"testing"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestAggs(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Aggs Suite")
}
@@ -0,0 +1,268 @@
package aggs_test
import (
"encoding/json"
"fmt"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
searchsvc "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/services/search/v0"
"github.com/opencloud-eu/opencloud/services/search/pkg/opensearch/internal/aggs"
)
var _ = Describe("Build", func() {
build := func(opts []*searchsvc.AggregationOption) map[string]any {
res, err := aggs.Build(opts)
Expect(err).ToNot(HaveOccurred())
return res
}
It("builds a terms aggregation", func() {
res := build([]*searchsvc.AggregationOption{
{Field: "audio.artist", Size: 10},
})
Expect(res).ToNot(BeNil())
entry, ok := res["a_0"].(map[string]any)
Expect(ok).To(BeTrue())
terms, ok := entry["terms"].(map[string]any)
Expect(ok).To(BeTrue())
Expect(terms["field"]).To(Equal("audio.artist"))
Expect(terms["size"]).To(Equal(10))
})
It("builds a date_range aggregation for date bounds", func() {
res := build([]*searchsvc.AggregationOption{{
Field: "photo.takenDateTime",
BucketDefinition: &searchsvc.BucketDefinition{
Ranges: []*searchsvc.BucketRange{
{From: "2018-08-01", To: "2018-09-01"},
{From: "2018-08-11T00:00:00Z"},
},
},
}})
r := res["a_0"].(map[string]any)["date_range"].(map[string]any)
Expect(r["field"]).To(Equal("photo.takenDateTime"))
ranges := r["ranges"].([]map[string]any)
Expect(ranges).To(HaveLen(2))
Expect(ranges[0]).To(SatisfyAll(
HaveKeyWithValue("key", "2018-08-01-2018-09-01"),
HaveKeyWithValue("from", "2018-08-01"),
HaveKeyWithValue("to", "2018-09-01"),
))
Expect(ranges[1]).To(HaveKeyWithValue("from", "2018-08-11T00:00:00Z"))
Expect(ranges[1]).ToNot(HaveKey("to"))
})
It("rejects a bound that is neither number nor date", func() {
_, err := aggs.Build([]*searchsvc.AggregationOption{{
Field: "photo.takenDateTime",
BucketDefinition: &searchsvc.BucketDefinition{
Ranges: []*searchsvc.BucketRange{
{From: "2018-08-11T00:00:00Z", To: "not-a-date"},
},
},
}})
Expect(err).To(HaveOccurred())
})
It("builds a range aggregation with open-ended bounds", func() {
res := build([]*searchsvc.AggregationOption{{
Field: "audio.year",
BucketDefinition: &searchsvc.BucketDefinition{
Ranges: []*searchsvc.BucketRange{
{From: "1970", To: "1980"},
{To: "1970"},
{From: "2020"},
},
},
}})
r := res["a_0"].(map[string]any)["range"].(map[string]any)
Expect(r["field"]).To(Equal("audio.year"))
ranges := r["ranges"].([]map[string]any)
Expect(ranges).To(HaveLen(3))
Expect(ranges[0]).To(SatisfyAll(
HaveKeyWithValue("key", "1970-1980"),
HaveKeyWithValue("from", 1970.0),
HaveKeyWithValue("to", 1980.0),
))
Expect(ranges[1]).ToNot(HaveKey("from")) // open lower bound
Expect(ranges[2]).ToNot(HaveKey("to")) // open upper bound
})
DescribeTable("builds single-value metric aggregations",
func(kind searchsvc.MetricKind, esKind string) {
res := build([]*searchsvc.AggregationOption{
{Field: "audio.duration", MetricKind: kind},
})
body, ok := res["a_0"].(map[string]any)[esKind].(map[string]any)
Expect(ok).To(BeTrue())
Expect(body["field"]).To(Equal("audio.duration"))
},
Entry("sum", searchsvc.MetricKind_METRIC_KIND_SUM, "sum"),
Entry("min", searchsvc.MetricKind_METRIC_KIND_MIN, "min"),
Entry("max", searchsvc.MetricKind_METRIC_KIND_MAX, "max"),
)
It("uses a stats aggregation for AVG", func() {
res := build([]*searchsvc.AggregationOption{
{Field: "audio.duration", MetricKind: searchsvc.MetricKind_METRIC_KIND_AVG},
})
stats, ok := res["a_0"].(map[string]any)["stats"].(map[string]any)
Expect(ok).To(BeTrue())
Expect(stats["field"]).To(Equal("audio.duration"))
})
It("nests sub-aggregations under their parent bucket", func() {
res := build([]*searchsvc.AggregationOption{{
Field: "audio.artist", Size: 5,
SubAggregations: []*searchsvc.AggregationOption{{
Field: "audio.album", Size: 7,
SubAggregations: []*searchsvc.AggregationOption{
{Field: "audio.duration", MetricKind: searchsvc.MetricKind_METRIC_KIND_SUM},
},
}},
}})
album := res["a_0"].(map[string]any)["aggs"].(map[string]any)["a_0_0"].(map[string]any)
albumTerms := album["terms"].(map[string]any)
Expect(albumTerms["field"]).To(Equal("audio.album"))
Expect(albumTerms["size"]).To(Equal(7))
metric := album["aggs"].(map[string]any)["a_0_0_0"].(map[string]any)
Expect(metric["sum"].(map[string]any)["field"]).To(Equal("audio.duration"))
})
})
var _ = Describe("Parse", func() {
It("parses flat term and range buckets, stringifying numeric keys", func() {
raw := json.RawMessage(`{
"a_0": {"buckets": [
{"key": "Pink Floyd", "doc_count": 42},
{"key": "Motörhead", "doc_count": 35}
]},
"a_1": {"buckets": [
{"key": "1970-1980", "from": 1970.0, "to": 1980.0, "doc_count": 12}
]},
"a_2": {"buckets": [
{"key": 9, "doc_count": 3}
]}
}`)
out, err := aggs.Parse(raw, []*searchsvc.AggregationOption{
{Field: "audio.artist"},
{Field: "audio.year"},
{Field: "audio.track"},
})
Expect(err).ToNot(HaveOccurred())
Expect(out).To(HaveLen(3))
Expect(out[0].Field).To(Equal("audio.artist"))
Expect(out[0].Buckets).To(HaveLen(2))
Expect(out[0].Buckets[0].Key).To(Equal("Pink Floyd"))
Expect(out[0].Buckets[0].Count).To(Equal(int64(42)))
Expect(out[1].Buckets[0].Key).To(Equal("1970-1980"))
Expect(out[1].Buckets[0].Count).To(Equal(int64(12)))
// numeric term key stringified without trailing zeros
Expect(out[2].Buckets[0].Key).To(Equal("9"))
})
It("parses nested buckets carrying a metric", func() {
raw := json.RawMessage(`{
"a_0": {"buckets": [{
"key": "Iron Maiden", "doc_count": 300,
"a_0_0": {"buckets": [
{"key": "The Number of the Beast", "doc_count": 8, "a_0_0_0": {"value": 2756000.0}},
{"key": "Powerslave", "doc_count": 8, "a_0_0_0": {"value": 3061000.0}}
]}
}]}
}`)
out, err := aggs.Parse(raw, []*searchsvc.AggregationOption{{
Field: "audio.artist",
SubAggregations: []*searchsvc.AggregationOption{{
Field: "audio.album",
SubAggregations: []*searchsvc.AggregationOption{
{Field: "audio.duration", MetricKind: searchsvc.MetricKind_METRIC_KIND_SUM},
},
}},
}})
Expect(err).ToNot(HaveOccurred())
Expect(out).To(HaveLen(1))
Expect(out[0].Field).To(Equal("audio.artist"))
Expect(out[0].Buckets).To(HaveLen(1))
artistBucket := out[0].Buckets[0]
Expect(artistBucket.Key).To(Equal("Iron Maiden"))
Expect(artistBucket.Count).To(Equal(int64(300)))
Expect(artistBucket.SubAggregations).To(HaveLen(1))
albumAgg := artistBucket.SubAggregations[0]
Expect(albumAgg.Field).To(Equal("audio.album"))
Expect(albumAgg.Buckets).To(HaveLen(2))
nob := albumAgg.Buckets[0]
Expect(nob.Key).To(Equal("The Number of the Beast"))
Expect(nob.Count).To(Equal(int64(8)))
Expect(nob.SubAggregations).To(HaveLen(1))
metric := nob.SubAggregations[0]
Expect(metric.Field).To(Equal("audio.duration"))
Expect(metric.MetricKind).To(Equal(searchsvc.MetricKind_METRIC_KIND_SUM))
Expect(metric.Value).To(Equal(2756000.0))
})
DescribeTable("parses single-value metrics",
func(kind searchsvc.MetricKind, value float64) {
raw := json.RawMessage(fmt.Sprintf(`{"a_0": {"value": %g}}`, value))
out, err := aggs.Parse(raw, []*searchsvc.AggregationOption{
{Field: "audio.duration", MetricKind: kind},
})
Expect(err).ToNot(HaveOccurred())
Expect(out).To(HaveLen(1))
Expect(out[0].MetricKind).To(Equal(kind))
Expect(out[0].Value).To(Equal(value))
},
Entry("sum", searchsvc.MetricKind_METRIC_KIND_SUM, 1234.5),
Entry("min", searchsvc.MetricKind_METRIC_KIND_MIN, 10.0),
Entry("max", searchsvc.MetricKind_METRIC_KIND_MAX, 99.0),
)
It("decodes a null metric value to zero", func() {
// OpenSearch returns value: null when a metric has no matching docs.
out, err := aggs.Parse(json.RawMessage(`{"a_0": {"value": null}}`),
[]*searchsvc.AggregationOption{{Field: "audio.duration", MetricKind: searchsvc.MetricKind_METRIC_KIND_SUM}})
Expect(err).ToNot(HaveOccurred())
Expect(out).To(HaveLen(1))
Expect(out[0].Value).To(BeZero())
Expect(out[0].MetricKind).To(Equal(searchsvc.MetricKind_METRIC_KIND_SUM))
})
It("carries avg transport (sum + count) from a stats response", func() {
raw := json.RawMessage(`{
"a_0": {"count": 100, "min": 30000.0, "max": 500000.0, "avg": 245000.0, "sum": 24500000.0}
}`)
out, err := aggs.Parse(raw, []*searchsvc.AggregationOption{
{Field: "audio.duration", MetricKind: searchsvc.MetricKind_METRIC_KIND_AVG},
})
Expect(err).ToNot(HaveOccurred())
Expect(out).To(HaveLen(1))
Expect(out[0].MetricKind).To(Equal(searchsvc.MetricKind_METRIC_KIND_AVG))
Expect(out[0].Sum).To(Equal(24500000.0))
Expect(out[0].Count).To(Equal(int64(100)))
})
It("returns nil for empty raw or empty options", func() {
got, err := aggs.Parse(nil, []*searchsvc.AggregationOption{{Field: "x"}})
Expect(err).ToNot(HaveOccurred())
Expect(got).To(BeNil())
got, err = aggs.Parse(json.RawMessage(`{}`), nil)
Expect(err).ToNot(HaveOccurred())
Expect(got).To(BeNil())
})
It("errors on malformed json and returns no result", func() {
got, err := aggs.Parse(json.RawMessage(`not-json`), []*searchsvc.AggregationOption{{Field: "x"}})
Expect(err).To(HaveOccurred())
Expect(got).To(BeNil())
})
})
@@ -13,14 +13,19 @@ var (
)
func KQLToOpenSearchBoolQuery(kqlQuery string) (*osu.BoolQuery, error) {
kqlAst, err := kql.Builder{}.Build(kqlQuery)
return KQLToOpenSearchBoolQueryWithFilters(kqlQuery, nil)
}
// KQLToOpenSearchBoolQueryWithFilters compiles the query together with decoded
// aggregation filters, which are ANDed in as exact case-sensitive matches.
func KQLToOpenSearchBoolQueryWithFilters(kqlQuery string, filters []string) (*osu.BoolQuery, error) {
// shared lowering (field resolution, media-type expansion, value lowercasing)
// plus the filters, forced to exact case-sensitive matches, ANDed in.
kqlAst, err := query.MergeFilters(kql.Builder{}, kqlQuery, filters)
if err != nil {
return nil, err
}
// shared lowering: field resolution, media-type expansion, value lowercasing.
kqlAst = query.Normalize(kqlAst, query.ResolveField)
builder, err := TranspileKQLToOpenSearch(kqlAst.Nodes)
if err != nil {
return nil, fmt.Errorf("failed to compile query: %w", err)
@@ -95,6 +95,8 @@ func BuildSearchReq(req *opensearchgoAPI.SearchReq, q Builder, p ...SearchBodyPa
type SearchBodyParams struct {
Highlight *BodyParamHighlight `json:"highlight,omitempty"`
Aggs map[string]any `json:"aggs,omitempty"`
Sort []map[string]any `json:"sort,omitempty"`
}
//----------------------------------------------------------------------------//
+30
View File
@@ -693,3 +693,33 @@ Fixtures:
| METADATA-01 | `*song*` reads `Audio` | all 16 fields unchanged | all 16 fields unchanged | all 16 fields unchanged | ✅ |
| METADATA-02 | `*team*` reads `Location` | all 3 fields unchanged | all 3 fields unchanged | all 3 fields unchanged | ✅ |
| METADATA-03 | `*team*` reads `Audio` | none | none | none | ✅ |
## Aggregations
### aggregations
Fixtures:
- `a.mp3`, MimeType = audio/mpeg
- `b.mp3`, MimeType = audio/mpeg
- `c.mp3`, MimeType = audio/mpeg
- `d.mp3`, MimeType = audio/mpeg
- `e.mp3`, MimeType = audio/mpeg
- `f.mp3`, MimeType = audio/mpeg
- `g.mp3`, MimeType = audio/mpeg
- `a.jpg`, MimeType = image/jpeg
- `b.jpg`, MimeType = image/jpeg
- `c.jpg`, MimeType = image/jpeg
- `d.jpg`, MimeType = image/jpeg
| Case | Query | expected | bleve | OpenSearch | same? |
|---|---|---|---|---|---|
| AGG-01 | `mediatype:audio` reads `term buckets on audio.artist` | audio.arti... Floyd=2, audio.arti...¶rhead=3 | audio.arti... Floyd=2, audio.arti...¶rhead=3 | audio.arti... Floyd=2, audio.arti...¶rhead=3 | ✅ |
| AGG-02 | `mediatype:audio` reads `no aggregations requested` | no match | no match | no match | ✅ |
| AGG-03 | `mediatype:audio` reads `artist and album buckets in one request` | audio.albu...Spades=1, audio.album Bomber=2, audio.album The Wall=2, audio.arti... Floyd=2, audio.arti...¶rhead=3 | audio.albu...Spades=1, audio.album Bomber=2, audio.album The Wall=2, audio.arti... Floyd=2, audio.arti...¶rhead=3 | audio.albu...Spades=1, audio.album Bomber=2, audio.album The Wall=2, audio.arti... Floyd=2, audio.arti...¶rhead=3 | ✅ |
| AGG-04 | `mediatype:audio` reads `audio.year buckets per decade` | audio.year 1970-1980=2, audio.year 1980-1990=1, audio.year 1990-2000=1, audio.year 2000-2010=3 | audio.year 1970-1980=2, audio.year 1980-1990=1, audio.year 1990-2000=1, audio.year 2000-2010=3 | audio.year 1970-1980=2, audio.year 1980-1990=1, audio.year 1990-2000=1, audio.year 2000-2010=3 | ✅ |
| AGG-05 | `mediatype:audio` reads `open-ended audio.year ranges` | audio.year -1990=3, audio.year 2000-=3 | audio.year -1990=3, audio.year 2000-=3 | audio.year -1990=3, audio.year 2000-=3 | ✅ |
| AGG-06 | `mediatype:audio` reads `top-level metrics on audio.year` | audio.year max=2009, audio.year min=1971, audio.year sum=13942, audio.year... count=7 | audio.year max=2009, audio.year min=1971, audio.year sum=13942, audio.year... count=7 | audio.year max=2009, audio.year min=1971, audio.year sum=13942, audio.year... count=7 | ✅ |
| AGG-07 | `mediatype:image` reads `photo.takenDateTime buckets per date range` | photo.take...-01-01=1, photo.take...-09-01=2, photo.take...00:00Z=2 | photo.take...-01-01=1, photo.take...-09-01=2, photo.take...00:00Z=2 | photo.take...-01-01=1, photo.take...-09-01=2, photo.take...00:00Z=2 | ✅ |
| AGG-08 | `mediatype:image` reads `open-ended date ranges` | photo.take...-01-01=3, photo.take...01-01-=1 | photo.take...-01-01=3, photo.take...01-01-=1 | photo.take...-01-01=3, photo.take...01-01-=1 | ✅ |
| AGG-09 | `mediatype:image` reads `malformed date range bound` | error | error | error | ✅ |
@@ -0,0 +1,207 @@
package parity
import (
"context"
"fmt"
"strings"
"time"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
searchService "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/services/search/v0"
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
)
// aggCase is one aggregation request both engines have to answer alike. The
// answer is rendered to strings (one per non-empty bucket or metric), so the
// matrix machinery can carry it like any query answer.
type aggCase struct {
id int
query string
aggs []*searchService.AggregationOption
reads string
want []string
wantError bool
engineOverrides map[string]override
}
func (c aggCase) label() string { return fmt.Sprintf("AGG-%02d", c.id) }
func withYear(name string, year int32) search.Resource {
return fixtureDoc(name, withMime("audio/mpeg"), withAudio(&libregraph.Audio{Year: libregraph.PtrInt32(year)}))
}
func withTaken(name, taken string) search.Resource {
t, err := time.Parse(time.RFC3339, taken)
if err != nil {
panic(err)
}
return fixtureDoc(name, withMime("image/jpeg"), withPhoto(&libregraph.Photo{TakenDateTime: &t}))
}
func song(name, artist, album string, year int32) search.Resource {
return fixtureDoc(name, withMime("audio/mpeg"), withAudio(&libregraph.Audio{
Artist: libregraph.PtrString(artist),
Album: libregraph.PtrString(album),
Year: libregraph.PtrInt32(year),
}))
}
func aggregationFixtures() []search.Resource {
return []search.Resource{
// years: 1971, 1975, 1982, 1999, 2001, 2005, 2009
song("a.mp3", "Pink Floyd", "The Wall", 1971),
song("b.mp3", "Pink Floyd", "The Wall", 1975),
song("c.mp3", "Motörhead", "Bomber", 1982),
song("d.mp3", "Motörhead", "Bomber", 1999),
song("e.mp3", "Motörhead", "Ace of Spades", 2001),
withYear("f.mp3", 2005),
withYear("g.mp3", 2009),
withTaken("a.jpg", "2018-08-11T09:15:00Z"),
withTaken("b.jpg", "2018-08-11T19:42:00Z"),
withTaken("c.jpg", "2018-09-01T12:00:00Z"),
withTaken("d.jpg", "2021-08-11T08:00:00Z"),
}
}
func aggregationCases() []aggCase {
ranges := func(rs ...*searchService.BucketRange) *searchService.BucketDefinition {
return &searchService.BucketDefinition{Ranges: rs}
}
return []aggCase{
{id: 1, query: "mediatype:audio", reads: "term buckets on audio.artist",
aggs: []*searchService.AggregationOption{{Field: "audio.artist", Size: 10}},
want: []string{"audio.artist Pink Floyd=2", "audio.artist Motörhead=3"}},
{id: 2, query: "mediatype:audio", reads: "no aggregations requested"},
{id: 3, query: "mediatype:audio", reads: "artist and album buckets in one request",
aggs: []*searchService.AggregationOption{{Field: "audio.artist"}, {Field: "audio.album"}},
want: []string{
"audio.artist Pink Floyd=2", "audio.artist Motörhead=3",
"audio.album The Wall=2", "audio.album Bomber=2", "audio.album Ace of Spades=1",
}},
{id: 4, query: "mediatype:audio", reads: "audio.year buckets per decade",
aggs: []*searchService.AggregationOption{{Field: "audio.year", BucketDefinition: ranges(
&searchService.BucketRange{From: "1970", To: "1980"},
&searchService.BucketRange{From: "1980", To: "1990"},
&searchService.BucketRange{From: "1990", To: "2000"},
&searchService.BucketRange{From: "2000", To: "2010"},
)}},
want: []string{"audio.year 1970-1980=2", "audio.year 1980-1990=1", "audio.year 1990-2000=1", "audio.year 2000-2010=3"}},
{id: 5, query: "mediatype:audio", reads: "open-ended audio.year ranges",
aggs: []*searchService.AggregationOption{{Field: "audio.year", BucketDefinition: ranges(
&searchService.BucketRange{To: "1990"},
&searchService.BucketRange{From: "2000"},
)}},
want: []string{"audio.year -1990=3", "audio.year 2000-=3"}},
{id: 6, query: "mediatype:audio", reads: "top-level metrics on audio.year",
aggs: []*searchService.AggregationOption{
{Field: "audio.year", MetricKind: searchService.MetricKind_METRIC_KIND_SUM},
{Field: "audio.year", MetricKind: searchService.MetricKind_METRIC_KIND_MIN},
{Field: "audio.year", MetricKind: searchService.MetricKind_METRIC_KIND_MAX},
{Field: "audio.year", MetricKind: searchService.MetricKind_METRIC_KIND_AVG},
},
want: []string{"audio.year sum=13942", "audio.year min=1971", "audio.year max=2009", "audio.year avg sum=13942 count=7"}},
{id: 7, query: "mediatype:image", reads: "photo.takenDateTime buckets per date range",
aggs: []*searchService.AggregationOption{{Field: "photo.takenDateTime", BucketDefinition: ranges(
&searchService.BucketRange{From: "2018-08-11T00:00:00Z", To: "2018-08-12T00:00:00Z"},
&searchService.BucketRange{From: "2018-08-01", To: "2018-09-01"},
&searchService.BucketRange{From: "2021-01-01", To: "2022-01-01"},
&searchService.BucketRange{From: "2023-01-01", To: "2024-01-01"},
)}},
want: []string{
"photo.takenDateTime 2018-08-11T00:00:00Z-2018-08-12T00:00:00Z=2",
"photo.takenDateTime 2018-08-01-2018-09-01=2",
"photo.takenDateTime 2021-01-01-2022-01-01=1",
}},
{id: 8, query: "mediatype:image", reads: "open-ended date ranges",
aggs: []*searchService.AggregationOption{{Field: "photo.takenDateTime", BucketDefinition: ranges(
&searchService.BucketRange{To: "2019-01-01"},
&searchService.BucketRange{From: "2019-01-01"},
)}},
want: []string{"photo.takenDateTime -2019-01-01=3", "photo.takenDateTime 2019-01-01-=1"}},
{id: 9, query: "mediatype:image", reads: "malformed date range bound",
aggs: []*searchService.AggregationOption{{Field: "photo.takenDateTime", BucketDefinition: ranges(
&searchService.BucketRange{From: "2018-08-11T00:00:00Z", To: "not-a-date"},
)}},
wantError: true, want: []string{"error"}},
}
}
// renderAggregations flattens an answer into comparable strings; buckets with
// no hits are dropped, the engines differ in whether they emit them at all.
func renderAggregations(resp *searchService.SearchIndexResponse, err error) []string {
if err != nil {
return []string{"error"}
}
out := []string{}
for _, a := range resp.Aggregations {
if a.MetricKind != searchService.MetricKind_METRIC_KIND_UNSPECIFIED {
kind := strings.ToLower(strings.TrimPrefix(a.MetricKind.String(), "METRIC_KIND_"))
if a.MetricKind == searchService.MetricKind_METRIC_KIND_AVG {
out = append(out, fmt.Sprintf("%s avg sum=%v count=%d", a.Field, a.Sum, a.Count))
continue
}
out = append(out, fmt.Sprintf("%s %s=%v", a.Field, kind, a.Value))
continue
}
for _, b := range a.Buckets {
if b.Count == 0 {
continue
}
out = append(out, fmt.Sprintf("%s %s=%d", a.Field, b.Key, b.Count))
}
}
return out
}
var _ = Describe("Aggregations", func() {
Describe("aggregations", Ordered, ContinueOnFailure, func() {
var engines []testEngine
BeforeAll(func() {
engines = newEngines("opencloud-test-engine-parity-aggregations", aggregationFixtures())
})
for caseAt, c := range aggregationCases() {
row := matrixRow{
Section: "Aggregations", Group: "aggregations", ID: c.label(),
Query: c.query, Reads: c.reads,
Want: c.want, Overrides: renderOverrides(c.engineOverrides),
GroupAt: 100, CaseAt: caseAt,
}
planRow(row)
Describe(c.label()+" "+c.reads, func() {
for _, name := range engineNames {
It("on "+name, func() {
e := engineNamed(engines, name)
if e.unavailable != "" {
recordSkip(row, name)
Skip(e.unavailable)
}
resp, err := e.backend.Search(context.Background(), &searchService.SearchIndexRequest{
Query: c.query,
Aggregations: c.aggs,
})
answer := renderAggregations(resp, err)
recordAnswer(row, name, answer)
_, overridden := c.engineOverrides[name]
if !overridden && !c.wantError {
Expect(err).NotTo(HaveOccurred(), "the aggregation has to answer")
}
expectAnswer(name, answer, override{want: c.want}, c.engineOverrides)
})
}
})
}
})
})
@@ -50,6 +50,10 @@ func withAudio(audio *libregraph.Audio) fixtureOption {
return func(r *search.Resource) { r.Audio = audio }
}
func withPhoto(photo *libregraph.Photo) fixtureOption {
return func(r *search.Resource) { r.Photo = photo }
}
func withLocation(location *libregraph.GeoCoordinates) fixtureOption {
return func(r *search.Resource) { r.Location = location }
}
@@ -278,6 +278,10 @@ func matrixFixtures(group string) string {
fixtures = g.fixtures
}
if group == "aggregations" {
fixtures = aggregationFixtures()
}
if len(fixtures) == 0 {
return "Fixtures: none"
}
+11
View File
@@ -34,5 +34,16 @@ func (c Creator[T]) Create(qs string) (T, error) {
return t, nil
}
// CreateWithFilters compiles the query together with decoded aggregation
// filters, ANDing them in as exact case-sensitive matches.
func (c Creator[T]) CreateWithFilters(qs string, filters []string) (T, error) {
var t T
merged, err := query.MergeFilters(c.builder, qs, filters)
if err != nil {
return t, err
}
return c.compiler.Compile(merged)
}
// DefaultCreator exposes a kql to bleve query creator.
var DefaultCreator = Creator[bQuery.Query]{kql.Builder{}, Compiler{}}
@@ -0,0 +1,27 @@
package query
import "github.com/opencloud-eu/opencloud/pkg/ast"
// ForceCaseSensitive marks every string restriction in the tree as an exact,
// case-sensitive match. It is applied to a decoded aggregation filter after
// Normalize: the filter values are exact bucket keys the server issued, so they
// must match the case-preserving base field, not the lowercased sibling.
func ForceCaseSensitive(a *ast.Ast) *ast.Ast {
if a == nil {
return a
}
forceCaseSensitiveNodes(a.Nodes)
return a
}
func forceCaseSensitiveNodes(nodes []ast.Node) {
for _, n := range nodes {
switch node := n.(type) {
case *ast.StringNode:
node.Exact = true
node.CaseInsensitive = false
case *ast.GroupNode:
forceCaseSensitiveNodes(node.Nodes)
}
}
}
+44
View File
@@ -0,0 +1,44 @@
package query
import (
"github.com/opencloud-eu/opencloud/pkg/ast"
"github.com/opencloud-eu/opencloud/pkg/kql"
)
// MergeFilters parses and normalizes the main query and each decoded aggregation
// filter, forces the filters to exact case-sensitive matches, and ANDs
// everything into one AST ready to compile. The main query and every filter are
// wrapped in their own group so the AND binds across the whole query rather than
// tangling with the query's own operator precedence. With no filters the
// normalized main AST is returned unchanged.
func MergeFilters(b Builder, qs string, filters []string) (*ast.Ast, error) {
main, err := b.Build(qs)
if err != nil {
return nil, err
}
main = Normalize(main, ResolveField)
if len(filters) == 0 {
return main, nil
}
nodes := make([]ast.Node, 0, 2*len(filters)+1)
if len(main.Nodes) > 0 {
nodes = append(nodes, &ast.GroupNode{Base: &ast.Base{}, Nodes: main.Nodes})
}
for _, f := range filters {
fa, err := b.Build(f)
if err != nil {
return nil, err
}
fa = Normalize(fa, ResolveField)
ForceCaseSensitive(fa)
if len(fa.Nodes) == 0 {
continue
}
if len(nodes) > 0 {
nodes = append(nodes, &ast.OperatorNode{Value: kql.BoolAND})
}
nodes = append(nodes, &ast.GroupNode{Base: &ast.Base{}, Nodes: fa.Nodes})
}
return &ast.Ast{Nodes: nodes}, nil
}
+62
View File
@@ -0,0 +1,62 @@
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/pkg/kql"
"github.com/opencloud-eu/opencloud/services/search/pkg/query"
)
func collectStringNodes(nodes []ast.Node) []*ast.StringNode {
var out []*ast.StringNode
for _, n := range nodes {
switch node := n.(type) {
case *ast.StringNode:
out = append(out, node)
case *ast.GroupNode:
out = append(out, collectStringNodes(node.Nodes)...)
}
}
return out
}
var _ = Describe("MergeFilters", func() {
It("returns the normalized main query unchanged when there are no filters", func() {
a, err := query.MergeFilters(kql.Builder{}, `name:"hello"`, nil)
Expect(err).ToNot(HaveOccurred())
Expect(a.Nodes).To(HaveLen(1))
})
It("ANDs a filter in as an exact, case-sensitive match", func() {
a, err := query.MergeFilters(kql.Builder{}, `name:"hello"`, []string{`Tags:"Pink Floyd"`})
Expect(err).ToNot(HaveOccurred())
strs := collectStringNodes(a.Nodes)
var forced *ast.StringNode
for _, s := range strs {
if s.Value == "Pink Floyd" {
forced = s
}
}
Expect(forced).ToNot(BeNil(), "the decoded filter node should be present")
Expect(forced.Exact).To(BeTrue())
Expect(forced.CaseInsensitive).To(BeFalse())
})
It("forces every node of an OR filter", func() {
a, err := query.MergeFilters(kql.Builder{}, `name:"hello"`, []string{`(Tags:"a" OR Tags:"b")`})
Expect(err).ToNot(HaveOccurred())
forced := 0
for _, s := range collectStringNodes(a.Nodes) {
if s.Value != "a" && s.Value != "b" {
continue
}
forced++
Expect(s.Exact).To(BeTrue())
Expect(s.CaseInsensitive).To(BeFalse())
}
Expect(forced).To(Equal(2))
})
})
+3
View File
@@ -16,4 +16,7 @@ type Compiler[T any] interface {
// Creator is the interface that wraps the basic Create method.
type Creator[T any] interface {
Create(qs string) (T, error)
// CreateWithFilters compiles the query together with decoded aggregation
// filters, which are ANDed in as exact case-sensitive matches.
CreateWithFilters(qs string, filters []string) (T, error)
}
+71
View File
@@ -0,0 +1,71 @@
package search
import (
"reflect"
"strings"
"time"
)
// IsNumericField reports whether the indexed field at the dotted path holds a
// numeric or time value. Terms aggregations on those are rejected: bleve stores
// them as prefix-coded binary, so term buckets are meaningless. The set is built
// by walking the Resource type, so new facet fields are picked up automatically.
func IsNumericField(dottedPath string) bool {
return numericFields[dottedPath]
}
var numericFields = buildNumericFieldSet()
var timeType = reflect.TypeOf(time.Time{})
func buildNumericFieldSet() map[string]bool {
out := map[string]bool{}
walkStruct(out, "", reflect.TypeOf(Resource{}))
return out
}
func walkStruct(out map[string]bool, prefix string, t reflect.Type) {
for t.Kind() == reflect.Ptr {
t = t.Elem()
}
if t.Kind() != reflect.Struct {
return
}
for i := 0; i < t.NumField(); i++ {
f := t.Field(i)
if !f.IsExported() {
continue
}
if f.Anonymous {
// embedded: promote fields into the current prefix, like encoding/json.
walkStruct(out, prefix, f.Type)
continue
}
path := prefix + jsonFieldName(f)
ft := f.Type
for ft.Kind() == reflect.Ptr {
ft = ft.Elem()
}
switch ft.Kind() {
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:
out[path] = true
case reflect.Struct:
if ft == timeType {
// time.Time round-trips as RFC3339; treat as numeric.
out[path] = true
continue
}
walkStruct(out, path+".", ft)
}
}
}
func jsonFieldName(f reflect.StructField) string {
tag := f.Tag.Get("json")
if tag == "" {
return f.Name
}
return strings.Split(tag, ",")[0]
}
+46
View File
@@ -0,0 +1,46 @@
package search_test
import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
)
var _ = Describe("IsNumericField", func() {
DescribeTable("reports whether a field maps to a numeric type",
func(field string, numeric bool) {
Expect(search.IsNumericField(field)).To(Equal(numeric))
},
// top-level numeric fields on Resource / Document
Entry("Size (uint64 via embedded Document)", "Size", true),
Entry("Type (uint64 on Resource)", "Type", true),
// top-level string fields
Entry("Name", "Name", false),
Entry("Path", "Path", false),
Entry("MimeType", "MimeType", false),
// nested audio
Entry("audio.artist", "audio.artist", false),
Entry("audio.album", "audio.album", false),
Entry("audio.year", "audio.year", true),
Entry("audio.bitrate", "audio.bitrate", true),
Entry("audio.track", "audio.track", true),
Entry("audio.hasDrm (bool, not numeric)", "audio.hasDrm", false),
// nested image
Entry("image.width", "image.width", true),
Entry("image.height", "image.height", true),
// nested photo
Entry("photo.cameraMake", "photo.cameraMake", false),
Entry("photo.iso", "photo.iso", true),
Entry("photo.focalLength (float32)", "photo.focalLength", true),
Entry("photo.exposureDenominator (float32)", "photo.exposureDenominator", true),
Entry("photo.takenDateTime (time.Time, treated as numeric)", "photo.takenDateTime", true),
// nested location
Entry("location.altitude", "location.altitude", true),
Entry("location.latitude", "location.latitude", true),
Entry("location.longitude", "location.longitude", true),
// unknown fields the caller may still aggregate on
Entry("nonexistent", "nonexistent", false),
Entry("audio.nonexistent", "audio.nonexistent", false),
)
})
-10
View File
@@ -128,16 +128,6 @@ func ResolveReference(ctx context.Context, ref *provider.Reference, ri *provider
type matchArray []*searchmsg.Match
func (ma matchArray) Len() int {
return len(ma)
}
func (ma matchArray) Swap(i, j int) {
ma[i], ma[j] = ma[j], ma[i]
}
func (ma matchArray) Less(i, j int) bool {
return ma[i].GetScore() > ma[j].GetScore()
}
func logDocCount(engine Engine, logger log.Logger) {
c, err := engine.DocCount()
if err != nil {
+236 -2
View File
@@ -30,6 +30,8 @@ import (
"github.com/opencloud-eu/opencloud/pkg/log"
searchmsg "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/messages/search/v0"
searchsvc "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/services/search/v0"
"github.com/opencloud-eu/opencloud/services/graph/pkg/unifiedrole"
"github.com/opencloud-eu/opencloud/services/search/pkg/aggregation"
"github.com/opencloud-eu/opencloud/services/search/pkg/config"
"github.com/opencloud-eu/opencloud/services/search/pkg/content"
"github.com/opencloud-eu/opencloud/services/search/pkg/metrics"
@@ -134,6 +136,22 @@ func (s *Service) Search(ctx context.Context, req *searchsvc.SearchRequest) (*se
return nil, errtypes.BadRequest("empty query provided")
}
req.Query = query
// Decode the aggregation filters (opaque bucket tokens) into KQL fragments
// once, up front, so a malformed token fails the whole request rather than
// silently dropping. The engines force these to exact case-sensitive matches.
if raw := req.GetAggregationFilters(); len(raw) > 0 {
decoded := make([]string, 0, len(raw))
for _, f := range raw {
frag, err := aggregation.DecodeAggregationFilter(f)
if err != nil {
return nil, errtypes.BadRequest(err.Error())
}
decoded = append(decoded, frag)
}
req.AggregationFilters = decoded
}
if len(scope) > 0 {
scopedID, err := storagespace.ParseID(scope)
if err != nil {
@@ -286,6 +304,8 @@ func (s *Service) Search(ctx context.Context, req *searchsvc.SearchRequest) (*se
return nil, err
}
mergedAggregations := map[string]map[string]*searchmsgBucket{}
mergedMetrics := map[string]*searchsvc.AggregationResult{}
for _, res := range responses {
if res == nil {
continue
@@ -294,10 +314,68 @@ func (s *Service) Search(ctx context.Context, req *searchsvc.SearchRequest) (*se
for _, match := range res.Matches {
matches = append(matches, match)
}
for _, agg := range res.GetAggregations() {
// Top-level metric: reduce across spaces; keyed by field+kind so
// several metrics on the same field stay separate.
if kind := agg.GetMetricKind(); kind != searchsvc.MetricKind_METRIC_KIND_UNSPECIFIED {
key := agg.GetField() + "|" + kind.String()
existing, ok := mergedMetrics[key]
if !ok {
mergedMetrics[key] = agg
continue
}
if kind == searchsvc.MetricKind_METRIC_KIND_AVG {
existing.Sum += agg.GetSum()
existing.Count += agg.GetCount()
} else {
existing.Value = reduceMetric(kind, existing.GetValue(), agg.GetValue())
}
continue
}
field := agg.GetField()
if _, ok := mergedAggregations[field]; !ok {
mergedAggregations[field] = map[string]*searchmsgBucket{}
}
for _, b := range agg.GetBuckets() {
if existing, ok := mergedAggregations[field][b.GetKey()]; ok {
existing.Count += b.GetCount()
// union child buckets per sub-aggregation so counts stay
// right when a key spans multiple spaces
existing.SubAggregations = mergeSubAggregations(existing.GetSubAggregations(), b.GetSubAggregations())
continue
}
mergedAggregations[field][b.GetKey()] = &searchsvc.Bucket{
Key: b.GetKey(),
Count: b.GetCount(),
SubAggregations: b.GetSubAggregations(),
}
}
}
}
// compile one sorted list of matches from all spaces and apply the limit if needed
sort.Sort(matches)
//
// NOTE(perf): every space was searched with the caller's full page size,
// so serving one page costs O(spaces x page_size) fetched matches. With
// offset pagination (the graph layer maps from/size onto a single
// page_size) each deeper page re-fetches everything before it on top.
// Accepted for now. The known fix is field-sorted cursor pagination via
// the currently unused page_token request/response fields: each space
// then serves "sort key < cursor, limit size" and page cost becomes
// independent of depth. Pushing plain offsets down into the engines
// would only trim the transfer, not the per-space overfetch, so it is
// not worth doing on its own.
//
// Each engine already returns its matches in order_by order (or by score
// when no order_by is given); this merge re-establishes that order across
// spaces, with the score as tiebreaker.
orderBy := req.GetOrderBy()
sort.SliceStable(matches, func(i, j int) bool {
if c := CompareMatches(matches[i], matches[j], orderBy); c != 0 {
return c < 0
}
return matches[i].GetScore() > matches[j].GetScore()
})
limit := req.PageSize
if limit == 0 {
limit = 200
@@ -306,13 +384,163 @@ func (s *Service) Search(ctx context.Context, req *searchsvc.SearchRequest) (*se
matches = matches[0:limit]
}
aggregations := make([]*searchsvc.AggregationResult, 0, len(req.GetAggregations()))
for _, opt := range req.GetAggregations() {
field := opt.GetField()
if kind := opt.GetMetricKind(); kind != searchsvc.MetricKind_METRIC_KIND_UNSPECIFIED {
if m, ok := mergedMetrics[field+"|"+kind.String()]; ok {
aggregations = append(aggregations, m)
}
continue
}
bucketMap := mergedAggregations[field]
buckets := make([]*searchsvc.Bucket, 0, len(bucketMap))
for _, b := range bucketMap {
buckets = append(buckets, b)
}
aggregations = append(aggregations, &searchsvc.AggregationResult{
Field: field,
Buckets: postProcessBuckets(buckets, opt),
})
}
success = true
return &searchsvc.SearchResponse{
Matches: matches,
TotalMatches: total,
Aggregations: aggregations,
}, nil
}
// searchmsgBucket aliases the bucket type for the map-of-maps below.
type searchmsgBucket = searchsvc.Bucket
// mergeSubAggregations unions two nested-aggregation lists by field: terms
// union child buckets by key (summing, recursing); metrics apply their reducer
// (sum/min/max).
func mergeSubAggregations(a, b []*searchsvc.AggregationResult) []*searchsvc.AggregationResult {
if len(a) == 0 {
return b
}
if len(b) == 0 {
return a
}
byField := make(map[string]*searchsvc.AggregationResult, len(a))
for _, r := range a {
byField[r.GetField()] = r
}
for _, r := range b {
existing, ok := byField[r.GetField()]
if !ok {
byField[r.GetField()] = r
continue
}
if existing.GetMetricKind() != searchsvc.MetricKind_METRIC_KIND_UNSPECIFIED ||
r.GetMetricKind() != searchsvc.MetricKind_METRIC_KIND_UNSPECIFIED {
// Metric result: apply the kind's reducer; prefer existing's kind.
kind := existing.GetMetricKind()
if kind == searchsvc.MetricKind_METRIC_KIND_UNSPECIFIED {
kind = r.GetMetricKind()
}
existing.MetricKind = kind
if kind == searchsvc.MetricKind_METRIC_KIND_AVG {
existing.Sum += r.GetSum()
existing.Count += r.GetCount()
} else {
existing.Value = reduceMetric(kind, existing.GetValue(), r.GetValue())
}
continue
}
byKey := make(map[string]*searchsvc.Bucket, len(existing.Buckets))
for _, bk := range existing.Buckets {
byKey[bk.GetKey()] = bk
}
for _, bk := range r.GetBuckets() {
if prev, ok := byKey[bk.GetKey()]; ok {
prev.Count += bk.GetCount()
prev.SubAggregations = mergeSubAggregations(prev.GetSubAggregations(), bk.GetSubAggregations())
} else {
existing.Buckets = append(existing.Buckets, bk)
byKey[bk.GetKey()] = bk
}
}
}
out := make([]*searchsvc.AggregationResult, 0, len(byField))
for _, r := range byField {
out = append(out, r)
}
return out
}
// reduceMetric applies the metric's cross-shard reducer; only called when both
// sides carry a value.
func reduceMetric(kind searchsvc.MetricKind, a, b float64) float64 {
switch kind {
case searchsvc.MetricKind_METRIC_KIND_SUM:
return a + b
case searchsvc.MetricKind_METRIC_KIND_MIN:
if b < a {
return b
}
return a
case searchsvc.MetricKind_METRIC_KIND_MAX:
if b > a {
return b
}
return a
}
return a
}
// postProcessBuckets applies the BucketDefinition (minimumCount filter, sort by
// count/keyAsString/keyAsNumber, trim to Size). Defaults to count-descending.
func postProcessBuckets(buckets []*searchsvc.Bucket, opt *searchsvc.AggregationOption) []*searchsvc.Bucket {
bd := opt.GetBucketDefinition()
sortBy := "count"
desc := true
var minCount int64
if bd != nil {
if bd.GetSortBy() != "" {
sortBy = bd.GetSortBy()
}
desc = bd.GetIsDescending()
minCount = int64(bd.GetMinimumCount())
}
if minCount > 0 {
filtered := buckets[:0]
for _, b := range buckets {
if b.GetCount() >= minCount {
filtered = append(filtered, b)
}
}
buckets = filtered
}
sort.SliceStable(buckets, func(i, j int) bool {
less := false
switch sortBy {
case "keyAsString":
less = buckets[i].GetKey() < buckets[j].GetKey()
case "keyAsNumber":
iv, _ := strconv.ParseFloat(buckets[i].GetKey(), 64)
jv, _ := strconv.ParseFloat(buckets[j].GetKey(), 64)
less = iv < jv
default: // "count"
less = buckets[i].GetCount() < buckets[j].GetCount()
}
if desc {
return !less
}
return less
})
if size := opt.GetSize(); size > 0 && int32(len(buckets)) > size {
buckets = buckets[:size]
}
return buckets
}
func (s *Service) searchIndex(ctx context.Context, req *searchsvc.SearchRequest, space *provider.StorageSpace, mountpointID string) (*searchsvc.SearchIndexResponse, error) {
if req.Ref != nil &&
(req.Ref.ResourceId.StorageId != space.Root.StorageId ||
@@ -410,7 +638,10 @@ func (s *Service) searchIndex(ctx context.Context, req *searchsvc.SearchRequest,
}
searchRequest := &searchsvc.SearchIndexRequest{
Query: req.Query,
Query: req.Query,
Aggregations: req.GetAggregations(),
AggregationFilters: req.GetAggregationFilters(),
OrderBy: req.GetOrderBy(),
Ref: &searchmsg.Reference{
ResourceId: searchRootID,
Path: searchPathPrefix,
@@ -446,6 +677,9 @@ func (s *Service) searchIndex(ctx context.Context, req *searchsvc.SearchRequest,
isMountpoint := isShared && match.GetEntity().GetRef().GetPath() == "."
isDir := match.GetEntity().GetMimeType() == "httpd/unix-directory"
match.Entity.Permissions = convertToWebDAVPermissions(isShared, isMountpoint, isDir, permissions)
// allowedValues is the same effective permission set the WebDAV report's
// oc:permissions string projects, in libregraph action notation.
match.Entity.PermissionsActionsAllowedValues = unifiedrole.CS3ResourcePermissionsToLibregraphActions(permissions)
if req.Ref != nil && searchPathPrefix == "/"+match.Entity.Name {
continue
+251
View File
@@ -16,6 +16,7 @@ import (
cs3mocks "github.com/opencloud-eu/reva/v2/tests/cs3mocks/mocks"
"github.com/stretchr/testify/mock"
"google.golang.org/grpc"
typespb "google.golang.org/protobuf/types/known/timestamppb"
"github.com/opencloud-eu/opencloud/pkg/log"
searchmsg "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/messages/search/v0"
@@ -258,6 +259,256 @@ var _ = Describe("Searchprovider", func() {
Expect(match.Entity.Ref.ResourceId.OpaqueId).To(Equal(personalSpace.Root.OpaqueId))
Expect(match.Entity.Ref.Path).To(Equal("./path/to/Foo.pdf"))
})
It("forwards aggregations to the engine", func() {
_, err := s.Search(ctx, &searchsvc.SearchRequest{
Query: "foo",
Aggregations: []*searchsvc.AggregationOption{
{Field: "audio.artist", Size: 10},
},
})
Expect(err).ToNot(HaveOccurred())
indexClient.AssertCalled(GinkgoT(), "Search", mock.Anything, mock.MatchedBy(func(req *searchsvc.SearchIndexRequest) bool {
return len(req.Aggregations) == 1 &&
req.Aggregations[0].Field == "audio.artist" &&
req.Aggregations[0].Size == 10
}))
})
})
Context("with two personal spaces returning matches", func() {
var (
spaceA = &sprovider.StorageSpace{
Id: &sprovider.StorageSpaceId{OpaqueId: "storageid$a!a"},
Root: &sprovider.ResourceId{StorageId: "storageid", SpaceId: "a", OpaqueId: "a"},
Name: "space-a",
SpaceType: "personal",
}
spaceB = &sprovider.StorageSpace{
Id: &sprovider.StorageSpaceId{OpaqueId: "storageid$b!b"},
Root: &sprovider.ResourceId{StorageId: "storageid", SpaceId: "b", OpaqueId: "b"},
Name: "space-b",
SpaceType: "personal",
}
photoMatch = func(space string, name string, taken int64, score float32) *searchmsg.Match {
return &searchmsg.Match{
Score: score,
Entity: &searchmsg.Entity{
Ref: &searchmsg.Reference{
ResourceId: &searchmsg.ResourceID{StorageId: "storageid", SpaceId: space, OpaqueId: space},
Path: "./" + name,
},
Id: &searchmsg.ResourceID{StorageId: "storageid", SpaceId: space, OpaqueId: name},
Name: name,
Photo: &searchmsg.Photo{
TakenDateTime: &typespb.Timestamp{Seconds: taken},
},
},
}
}
)
BeforeEach(func() {
gatewayClient.On("ListStorageSpaces", mock.Anything, mock.Anything).Return(&sprovider.ListStorageSpacesResponse{
Status: status.NewOK(ctx),
StorageSpaces: []*sprovider.StorageSpace{spaceA, spaceB},
}, nil)
indexClient.On("Search", mock.Anything, mock.MatchedBy(func(req *searchsvc.SearchIndexRequest) bool {
return req.Ref != nil && req.Ref.ResourceId.SpaceId == "a"
})).Return(&searchsvc.SearchIndexResponse{
TotalMatches: 2,
Matches: []*searchmsg.Match{
photoMatch("a", "a-old.jpg", 100, 0.9),
photoMatch("a", "a-new.jpg", 300, 0.1),
},
}, nil)
indexClient.On("Search", mock.Anything, mock.MatchedBy(func(req *searchsvc.SearchIndexRequest) bool {
return req.Ref != nil && req.Ref.ResourceId.SpaceId == "b"
})).Return(&searchsvc.SearchIndexResponse{
TotalMatches: 2,
Matches: []*searchmsg.Match{
photoMatch("b", "b-newest.jpg", 400, 0.5),
photoMatch("b", "b-mid.jpg", 200, 0.4),
},
}, nil)
})
It("forwards order_by to the engine", func() {
_, err := s.Search(ctx, &searchsvc.SearchRequest{
Query: "mediatype:image",
OrderBy: []*searchsvc.SortProperty{{Name: "photo.takenDateTime", IsDescending: true}},
})
Expect(err).ToNot(HaveOccurred())
indexClient.AssertCalled(GinkgoT(), "Search", mock.Anything, mock.MatchedBy(func(req *searchsvc.SearchIndexRequest) bool {
return len(req.OrderBy) == 1 &&
req.OrderBy[0].Name == "photo.takenDateTime" &&
req.OrderBy[0].IsDescending
}))
})
It("merges matches across spaces in sort order", func() {
res, err := s.Search(ctx, &searchsvc.SearchRequest{
Query: "mediatype:image",
OrderBy: []*searchsvc.SortProperty{{Name: "photo.takenDateTime", IsDescending: true}},
})
Expect(err).ToNot(HaveOccurred())
names := []string{}
for _, m := range res.Matches {
names = append(names, m.Entity.Name)
}
Expect(names).To(Equal([]string{"b-newest.jpg", "a-new.jpg", "b-mid.jpg", "a-old.jpg"}))
})
It("merges matches by score when no order_by is given", func() {
res, err := s.Search(ctx, &searchsvc.SearchRequest{
Query: "mediatype:image",
})
Expect(err).ToNot(HaveOccurred())
names := []string{}
for _, m := range res.Matches {
names = append(names, m.Entity.Name)
}
Expect(names).To(Equal([]string{"a-old.jpg", "b-newest.jpg", "b-mid.jpg", "a-new.jpg"}))
})
})
Context("with two personal spaces returning aggregations", func() {
var (
spaceA = &sprovider.StorageSpace{
Id: &sprovider.StorageSpaceId{OpaqueId: "storageid$a!a"},
Root: &sprovider.ResourceId{StorageId: "storageid", SpaceId: "a", OpaqueId: "a"},
Name: "space-a",
SpaceType: "personal",
}
spaceB = &sprovider.StorageSpace{
Id: &sprovider.StorageSpaceId{OpaqueId: "storageid$b!b"},
Root: &sprovider.ResourceId{StorageId: "storageid", SpaceId: "b", OpaqueId: "b"},
Name: "space-b",
SpaceType: "personal",
}
)
BeforeEach(func() {
gatewayClient.On("ListStorageSpaces", mock.Anything, mock.Anything).Return(&sprovider.ListStorageSpacesResponse{
Status: status.NewOK(ctx),
StorageSpaces: []*sprovider.StorageSpace{spaceA, spaceB},
}, nil)
indexClient.On("Search", mock.Anything, mock.MatchedBy(func(req *searchsvc.SearchIndexRequest) bool {
return req.Ref != nil && req.Ref.ResourceId.SpaceId == "a"
})).Return(&searchsvc.SearchIndexResponse{
TotalMatches: 2,
Aggregations: []*searchsvc.AggregationResult{{
Field: "audio.artist",
Buckets: []*searchsvc.Bucket{
{Key: "Pink Floyd", Count: 2},
{Key: "Motörhead", Count: 1},
},
}},
}, nil)
indexClient.On("Search", mock.Anything, mock.MatchedBy(func(req *searchsvc.SearchIndexRequest) bool {
return req.Ref != nil && req.Ref.ResourceId.SpaceId == "b"
})).Return(&searchsvc.SearchIndexResponse{
TotalMatches: 3,
Aggregations: []*searchsvc.AggregationResult{{
Field: "audio.artist",
Buckets: []*searchsvc.Bucket{
{Key: "Pink Floyd", Count: 3},
{Key: "Led Zeppelin", Count: 1},
},
}},
}, nil)
})
It("merges bucket counts across spaces", func() {
res, err := s.Search(ctx, &searchsvc.SearchRequest{
Query: "mediatype:audio",
Aggregations: []*searchsvc.AggregationOption{
{Field: "audio.artist", Size: 10},
},
})
Expect(err).ToNot(HaveOccurred())
Expect(res.Aggregations).To(HaveLen(1))
agg := res.Aggregations[0]
Expect(agg.Field).To(Equal("audio.artist"))
counts := map[string]int64{}
for _, b := range agg.Buckets {
counts[b.Key] = b.Count
}
Expect(counts).To(HaveKeyWithValue("Pink Floyd", int64(5)))
Expect(counts).To(HaveKeyWithValue("Motörhead", int64(1)))
Expect(counts).To(HaveKeyWithValue("Led Zeppelin", int64(1)))
})
It("sorts buckets by count descending by default", func() {
res, err := s.Search(ctx, &searchsvc.SearchRequest{
Query: "mediatype:audio",
Aggregations: []*searchsvc.AggregationOption{
{Field: "audio.artist"},
},
})
Expect(err).ToNot(HaveOccurred())
keys := []string{}
for _, b := range res.Aggregations[0].Buckets {
keys = append(keys, b.Key)
}
// Pink Floyd:5, Motörhead:1, Led Zeppelin:1 (count desc)
Expect(keys[0]).To(Equal("Pink Floyd"))
})
It("sorts buckets alphabetically ascending with sortBy keyAsString", func() {
res, err := s.Search(ctx, &searchsvc.SearchRequest{
Query: "mediatype:audio",
Aggregations: []*searchsvc.AggregationOption{
{
Field: "audio.artist",
BucketDefinition: &searchsvc.BucketDefinition{
SortBy: "keyAsString",
},
},
},
})
Expect(err).ToNot(HaveOccurred())
keys := []string{}
for _, b := range res.Aggregations[0].Buckets {
keys = append(keys, b.Key)
}
Expect(keys).To(Equal([]string{"Led Zeppelin", "Motörhead", "Pink Floyd"}))
})
It("applies minimumCount filter and size cap", func() {
res, err := s.Search(ctx, &searchsvc.SearchRequest{
Query: "mediatype:audio",
Aggregations: []*searchsvc.AggregationOption{
{
Field: "audio.artist",
Size: 5,
BucketDefinition: &searchsvc.BucketDefinition{
SortBy: "count",
IsDescending: true,
MinimumCount: 2,
},
},
},
})
Expect(err).ToNot(HaveOccurred())
// only Pink Floyd has count >= 2
Expect(res.Aggregations[0].Buckets).To(HaveLen(1))
Expect(res.Aggregations[0].Buckets[0].Key).To(Equal("Pink Floyd"))
})
It("trims the bucket list to Size", func() {
res, err := s.Search(ctx, &searchsvc.SearchRequest{
Query: "mediatype:audio",
Aggregations: []*searchsvc.AggregationOption{
{Field: "audio.artist", Size: 1},
},
})
Expect(err).ToNot(HaveOccurred())
Expect(res.Aggregations[0].Buckets).To(HaveLen(1))
Expect(res.Aggregations[0].Buckets[0].Key).To(Equal("Pink Floyd"))
})
})
Context("with a personal space with a filter", func() {
+257
View File
@@ -0,0 +1,257 @@
package search
import (
"reflect"
"strings"
"google.golang.org/protobuf/reflect/protoreflect"
searchmsg "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/messages/search/v0"
searchsvc "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/services/search/v0"
)
// Sorting support for search results (graph sortProperties / proto order_by).
//
// A field is sortable when both of the following hold:
// - it is indexed as a scalar (string, number, bool or time), so the
// engines can sort on it natively; multivalued fields like Tags are not
// sortable
// - it is carried on the match entity, so the service layer can read the
// sort key when merging the per-space result streams
//
// Both sets are derived by reflection (index side: the Resource type, match
// side: the Entity proto), so new facet fields become sortable automatically.
// sortIndexAliases maps the graph-facing names of top-level fields to their
// index field names. Facet fields (photo.*, audio.*, ...) share the same
// dotted names in both worlds and need no alias. Top-level fields are only
// exposed under these graph names; internal fields like RootID or Deleted
// stay unsortable.
var sortIndexAliases = map[string]string{
"name": "Name",
"size": "Size",
"lastModifiedDateTime": "Mtime",
"mimeType": "MimeType",
}
// entityJSONAliases maps graph-facing names to the Entity proto's JSON names
// where the two disagree.
var entityJSONAliases = map[string]string{
"lastModifiedDateTime": "lastModifiedTime",
}
// IsSortableField reports whether results can be sorted by the field.
func IsSortableField(name string) bool {
_, ok := SortIndexField(name)
return ok
}
// SortIndexField translates a graph sortProperties name into the index field
// name to sort on, reporting whether the field is sortable at all.
func SortIndexField(name string) (string, bool) {
field := name
if alias, ok := sortIndexAliases[name]; ok {
field = alias
} else if !strings.Contains(name, ".") {
return "", false
}
if !sortableIndexFields[field] {
return "", false
}
if !entityFieldResolvable(name) {
return "", false
}
return field, true
}
// CompareMatches orders match a relative to b according to orderBy: -1 when a
// comes first, 1 when b comes first, 0 when the sort keys tie (callers fall
// back to the score). Matches missing a sort key sort after those that have
// it, regardless of direction.
func CompareMatches(a, b *searchmsg.Match, orderBy []*searchsvc.SortProperty) int {
for _, sp := range orderBy {
ka := matchSortKey(a, sp.GetName())
kb := matchSortKey(b, sp.GetName())
if !ka.present && !kb.present {
continue
}
if !ka.present {
return 1
}
if !kb.present {
return -1
}
c := 0
switch {
case ka.isString:
c = strings.Compare(ka.str, kb.str)
case ka.num < kb.num:
c = -1
case ka.num > kb.num:
c = 1
}
if c == 0 {
continue
}
if sp.GetIsDescending() {
c = -c
}
return c
}
return 0
}
// sortableIndexFields is the set of scalar indexed fields, keyed by index
// field name.
var sortableIndexFields = buildSortableFieldSet()
func buildSortableFieldSet() map[string]bool {
out := map[string]bool{}
collectScalarFields(out, "", reflect.TypeOf(Resource{}))
return out
}
func collectScalarFields(out map[string]bool, prefix string, t reflect.Type) {
for t.Kind() == reflect.Ptr {
t = t.Elem()
}
if t.Kind() != reflect.Struct {
return
}
for i := 0; i < t.NumField(); i++ {
f := t.Field(i)
if !f.IsExported() {
continue
}
if f.Anonymous {
collectScalarFields(out, prefix, f.Type)
continue
}
path := prefix + jsonFieldName(f)
ft := f.Type
for ft.Kind() == reflect.Ptr {
ft = ft.Elem()
}
switch ft.Kind() {
case reflect.String, reflect.Bool,
reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64,
reflect.Float32, reflect.Float64:
out[path] = true
case reflect.Struct:
if ft == timeType {
out[path] = true
continue
}
collectScalarFields(out, path+".", ft)
}
}
}
func entityPath(name string) []string {
if alias, ok := entityJSONAliases[name]; ok {
name = alias
}
return strings.Split(name, ".")
}
const timestampFullName = protoreflect.FullName("google.protobuf.Timestamp")
// entityFieldResolvable reports whether the graph field name resolves to a
// scalar (or timestamp) field on the match entity.
func entityFieldResolvable(name string) bool {
md := (&searchmsg.Entity{}).ProtoReflect().Descriptor()
segments := entityPath(name)
for i, seg := range segments {
fd := md.Fields().ByJSONName(seg)
if fd == nil || fd.IsList() || fd.IsMap() {
return false
}
if i < len(segments)-1 {
if fd.Kind() != protoreflect.MessageKind {
return false
}
md = fd.Message()
continue
}
switch fd.Kind() {
case protoreflect.StringKind, protoreflect.BoolKind,
protoreflect.Int32Kind, protoreflect.Int64Kind,
protoreflect.Sint32Kind, protoreflect.Sint64Kind,
protoreflect.Sfixed32Kind, protoreflect.Sfixed64Kind,
protoreflect.Uint32Kind, protoreflect.Uint64Kind,
protoreflect.Fixed32Kind, protoreflect.Fixed64Kind,
protoreflect.FloatKind, protoreflect.DoubleKind:
return true
case protoreflect.MessageKind:
return fd.Message().FullName() == timestampFullName
}
return false
}
return false
}
// sortKey is the comparable value of a sort field on a concrete match.
type sortKey struct {
present bool
isString bool
str string
num float64
}
// matchSortKey extracts the sort key for the graph field name from a match by
// walking the entity proto along the field's JSON names.
func matchSortKey(m *searchmsg.Match, name string) sortKey {
entity := m.GetEntity()
if entity == nil {
return sortKey{}
}
msg := entity.ProtoReflect()
segments := entityPath(name)
for i, seg := range segments {
fd := msg.Descriptor().Fields().ByJSONName(seg)
if fd == nil || fd.IsList() || fd.IsMap() {
return sortKey{}
}
if i < len(segments)-1 {
if fd.Kind() != protoreflect.MessageKind || !msg.Has(fd) {
return sortKey{}
}
msg = msg.Get(fd).Message()
continue
}
if fd.HasPresence() && !msg.Has(fd) {
return sortKey{}
}
v := msg.Get(fd)
switch fd.Kind() {
case protoreflect.StringKind:
return sortKey{present: true, isString: true, str: v.String()}
case protoreflect.BoolKind:
num := 0.0
if v.Bool() {
num = 1.0
}
return sortKey{present: true, num: num}
case protoreflect.Int32Kind, protoreflect.Int64Kind,
protoreflect.Sint32Kind, protoreflect.Sint64Kind,
protoreflect.Sfixed32Kind, protoreflect.Sfixed64Kind:
return sortKey{present: true, num: float64(v.Int())}
case protoreflect.Uint32Kind, protoreflect.Uint64Kind,
protoreflect.Fixed32Kind, protoreflect.Fixed64Kind:
return sortKey{present: true, num: float64(v.Uint())}
case protoreflect.FloatKind, protoreflect.DoubleKind:
return sortKey{present: true, num: v.Float()}
case protoreflect.MessageKind:
if fd.Message().FullName() != timestampFullName {
return sortKey{}
}
ts := v.Message()
seconds := ts.Get(ts.Descriptor().Fields().ByName("seconds")).Int()
nanos := ts.Get(ts.Descriptor().Fields().ByName("nanos")).Int()
return sortKey{present: true, num: float64(seconds) + float64(nanos)/1e9}
}
return sortKey{}
}
return sortKey{}
}
+125
View File
@@ -0,0 +1,125 @@
package search_test
import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"google.golang.org/protobuf/types/known/timestamppb"
searchmsg "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/messages/search/v0"
searchsvc "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/services/search/v0"
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
)
var _ = Describe("SortIndexField", func() {
DescribeTable("maps graph field names to index fields",
func(name string, wantField string, wantOK bool) {
field, ok := search.SortIndexField(name)
Expect(ok).To(Equal(wantOK))
Expect(field).To(Equal(wantField))
},
// top-level fields are exposed under their graph names
Entry("name", "name", "Name", true),
Entry("size", "size", "Size", true),
Entry("lastModifiedDateTime", "lastModifiedDateTime", "Mtime", true),
Entry("mimeType", "mimeType", "MimeType", true),
// facet fields keep their dotted names
Entry("photo.takenDateTime", "photo.takenDateTime", "photo.takenDateTime", true),
Entry("photo.iso", "photo.iso", "photo.iso", true),
Entry("photo.cameraModel", "photo.cameraModel", "photo.cameraModel", true),
Entry("audio.artist", "audio.artist", "audio.artist", true),
Entry("audio.year", "audio.year", "audio.year", true),
Entry("image.width", "image.width", "image.width", true),
Entry("location.latitude", "location.latitude", "location.latitude", true),
// bare facets (message-typed, no scalar value) are not sortable
Entry("audio (bare facet)", "audio", "", false),
Entry("photo (bare facet)", "photo", "", false),
Entry("location (bare facet)", "location", "", false),
Entry("location (dotted but not scalar)", "location.", "", false),
// multivalued fields are not sortable
Entry("tags (repeated)", "tags", "", false),
Entry("Tags (index name, repeated)", "Tags", "", false),
// internal index fields are not exposed under their index names
Entry("Name (index name)", "Name", "", false),
Entry("Mtime (index name)", "Mtime", "", false),
Entry("RootID", "RootID", "", false),
Entry("Deleted", "Deleted", "", false),
// unknown fields
Entry("unknown", "definitelyNotAField", "", false),
Entry("unknown facet field", "photo.definitelyNotAField", "", false),
Entry("empty", "", "", false),
)
})
var _ = Describe("CompareMatches", func() {
match := func(mutate func(e *searchmsg.Entity)) *searchmsg.Match {
e := &searchmsg.Entity{}
mutate(e)
return &searchmsg.Match{Entity: e}
}
asc := func(name string) []*searchsvc.SortProperty {
return []*searchsvc.SortProperty{{Name: name}}
}
desc := func(name string) []*searchsvc.SortProperty {
return []*searchsvc.SortProperty{{Name: name, IsDescending: true}}
}
It("compares string fields lexicographically", func() {
a := match(func(e *searchmsg.Entity) { e.Name = "a.jpg" })
b := match(func(e *searchmsg.Entity) { e.Name = "b.jpg" })
Expect(search.CompareMatches(a, b, asc("name"))).To(Equal(-1))
Expect(search.CompareMatches(b, a, asc("name"))).To(Equal(1))
Expect(search.CompareMatches(a, b, desc("name"))).To(Equal(1))
})
It("compares numeric fields numerically", func() {
small := match(func(e *searchmsg.Entity) { e.Size = 9 })
big := match(func(e *searchmsg.Entity) { e.Size = 10 })
Expect(search.CompareMatches(small, big, asc("size"))).To(Equal(-1))
Expect(search.CompareMatches(small, big, desc("size"))).To(Equal(1))
})
It("compares timestamps", func() {
older := match(func(e *searchmsg.Entity) {
e.Photo = &searchmsg.Photo{TakenDateTime: &timestamppb.Timestamp{Seconds: 100}}
})
newer := match(func(e *searchmsg.Entity) {
e.Photo = &searchmsg.Photo{TakenDateTime: &timestamppb.Timestamp{Seconds: 200}}
})
Expect(search.CompareMatches(older, newer, asc("photo.takenDateTime"))).To(Equal(-1))
Expect(search.CompareMatches(older, newer, desc("photo.takenDateTime"))).To(Equal(1))
})
It("compares lastModifiedDateTime via the entity's lastModifiedTime", func() {
older := match(func(e *searchmsg.Entity) {
e.LastModifiedTime = &timestamppb.Timestamp{Seconds: 100}
})
newer := match(func(e *searchmsg.Entity) {
e.LastModifiedTime = &timestamppb.Timestamp{Seconds: 200}
})
Expect(search.CompareMatches(older, newer, asc("lastModifiedDateTime"))).To(Equal(-1))
})
It("sorts matches missing the field after those that have it, in both directions", func() {
has := match(func(e *searchmsg.Entity) {
e.Photo = &searchmsg.Photo{TakenDateTime: &timestamppb.Timestamp{Seconds: 100}}
})
missing := match(func(e *searchmsg.Entity) {})
Expect(search.CompareMatches(has, missing, asc("photo.takenDateTime"))).To(Equal(-1))
Expect(search.CompareMatches(missing, has, asc("photo.takenDateTime"))).To(Equal(1))
Expect(search.CompareMatches(has, missing, desc("photo.takenDateTime"))).To(Equal(-1))
})
It("falls through to the next sort property on ties", func() {
a := match(func(e *searchmsg.Entity) { e.Size = 5; e.Name = "a" })
b := match(func(e *searchmsg.Entity) { e.Size = 5; e.Name = "b" })
orderBy := []*searchsvc.SortProperty{{Name: "size"}, {Name: "name"}}
Expect(search.CompareMatches(a, b, orderBy)).To(Equal(-1))
})
It("returns 0 for full ties and empty orderBy", func() {
a := match(func(e *searchmsg.Entity) { e.Size = 5 })
b := match(func(e *searchmsg.Entity) { e.Size = 5 })
Expect(search.CompareMatches(a, b, asc("size"))).To(Equal(0))
Expect(search.CompareMatches(a, b, nil)).To(Equal(0))
})
})
+22 -6
View File
@@ -22,6 +22,7 @@ import (
"go-micro.dev/v4/metadata"
"golang.org/x/sync/errgroup"
grpcmetadata "google.golang.org/grpc/metadata"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/durationpb"
"github.com/opencloud-eu/opencloud/pkg/log"
@@ -94,14 +95,16 @@ func (s Service) Search(ctx context.Context, in *searchsvc.SearchRequest, out *s
}
ctx = revactx.ContextSetUser(ctx, u)
key := cacheKey(in.Query, in.PageSize, in.Ref, u)
key := cacheKey(in.Query, in.PageSize, in.Ref, u, in.Aggregations, in.OrderBy)
res, ok := s.FromCache(key)
if !ok {
var err error
res, err = s.searcher.Search(ctx, &searchsvc.SearchRequest{
Query: in.Query,
PageSize: in.PageSize,
Ref: in.Ref,
Query: in.Query,
PageSize: in.PageSize,
Ref: in.Ref,
Aggregations: in.Aggregations,
OrderBy: in.OrderBy,
})
if err != nil {
switch err.(type) {
@@ -118,6 +121,7 @@ func (s Service) Search(ctx context.Context, in *searchsvc.SearchRequest, out *s
out.Matches = res.Matches
out.TotalMatches = res.TotalMatches
out.NextPageToken = res.NextPageToken
out.Aggregations = res.Aggregations
return nil
}
@@ -242,6 +246,18 @@ func (s Service) Cache(key string, res *searchsvc.SearchResponse) {
_ = s.cache.Set(key, res)
}
func cacheKey(query string, pagesize int32, ref *v0.Reference, user *user.User) string {
return fmt.Sprintf("%s|%d|%s$%s!%s/%s|%s", query, pagesize, ref.GetResourceId().GetStorageId(), ref.GetResourceId().GetSpaceId(), ref.GetResourceId().GetOpaqueId(), ref.GetPath(), user.GetId().GetOpaqueId())
// cacheKey builds the cache identity for a search. Every result-affecting field
// must be in the key, including aggregations and order_by (serialised via
// deterministic proto marshalling). If those protos ever gain a map field,
// determinism requires all writers to set Deterministic=true.
func cacheKey(query string, pagesize int32, ref *v0.Reference, user *user.User, aggs []*searchsvc.AggregationOption, orderBy []*searchsvc.SortProperty) string {
protoPart := ""
if len(aggs) > 0 || len(orderBy) > 0 {
b, _ := proto.MarshalOptions{Deterministic: true}.Marshal(&searchsvc.SearchRequest{Aggregations: aggs, OrderBy: orderBy})
protoPart = string(b)
}
return fmt.Sprintf("%s|%d|%s$%s!%s/%s|%s|%s",
query, pagesize,
ref.GetResourceId().GetStorageId(), ref.GetResourceId().GetSpaceId(), ref.GetResourceId().GetOpaqueId(),
ref.GetPath(), user.GetId().GetOpaqueId(), protoPart)
}
@@ -0,0 +1,111 @@
package service
import (
"context"
"testing"
"time"
user "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
"github.com/jellydator/ttlcache/v2"
"github.com/opencloud-eu/reva/v2/pkg/auth/scope"
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
"github.com/opencloud-eu/reva/v2/pkg/token/manager/jwt"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"go-micro.dev/v4/metadata"
"github.com/opencloud-eu/opencloud/pkg/log"
searchmsg "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/messages/search/v0"
searchsvc "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/services/search/v0"
searchmocks "github.com/opencloud-eu/opencloud/services/search/pkg/search/mocks"
)
func newTestService(t *testing.T, searcher *searchmocks.Searcher) (Service, context.Context) {
t.Helper()
tm, err := jwt.New(map[string]interface{}{"secret": "test-secret"})
require.NoError(t, err)
u := &user.User{Id: &user.UserId{OpaqueId: "test-user", Idp: "idp"}, Username: "test"}
scopes, err := scope.AddOwnerScope(nil)
require.NoError(t, err)
tok, err := tm.MintToken(context.Background(), u, scopes)
require.NoError(t, err)
ctx := metadata.Set(context.Background(), revactx.TokenHeader, tok)
cache := ttlcache.NewCache()
require.NoError(t, cache.SetTTL(30*time.Second))
logger := log.NopLogger()
return Service{
log: &logger,
searcher: searcher,
cache: cache,
tokenManager: tm,
}, ctx
}
func TestServiceSearchForwardsOrderBy(t *testing.T) {
searcher := searchmocks.NewSearcher(t)
svc, ctx := newTestService(t, searcher)
var captured *searchsvc.SearchRequest
searcher.EXPECT().
Search(mock.Anything, mock.Anything).
Run(func(_ context.Context, req *searchsvc.SearchRequest) {
captured = req
}).
Return(&searchsvc.SearchResponse{}, nil).
Once()
err := svc.Search(ctx, &searchsvc.SearchRequest{
Query: "mediatype:image",
OrderBy: []*searchsvc.SortProperty{
{Name: "photo.takenDateTime", IsDescending: true},
},
}, &searchsvc.SearchResponse{})
require.NoError(t, err)
require.NotNil(t, captured)
require.Len(t, captured.OrderBy, 1)
require.Equal(t, "photo.takenDateTime", captured.OrderBy[0].Name)
require.True(t, captured.OrderBy[0].IsDescending)
}
func TestServiceSearchCacheDistinguishesOrderBy(t *testing.T) {
searcher := searchmocks.NewSearcher(t)
svc, ctx := newTestService(t, searcher)
responseFor := func(name string) *searchsvc.SearchResponse {
return &searchsvc.SearchResponse{
TotalMatches: 1,
Matches: []*searchmsg.Match{{Entity: &searchmsg.Entity{Name: name}}},
}
}
searcher.EXPECT().
Search(mock.Anything, mock.MatchedBy(func(req *searchsvc.SearchRequest) bool {
return len(req.OrderBy) > 0 && req.OrderBy[0].IsDescending
})).
Return(responseFor("newest.jpg"), nil).
Once()
searcher.EXPECT().
Search(mock.Anything, mock.MatchedBy(func(req *searchsvc.SearchRequest) bool {
return len(req.OrderBy) > 0 && !req.OrderBy[0].IsDescending
})).
Return(responseFor("oldest.jpg"), nil).
Once()
descOut := &searchsvc.SearchResponse{}
require.NoError(t, svc.Search(ctx, &searchsvc.SearchRequest{
Query: "mediatype:image",
OrderBy: []*searchsvc.SortProperty{{Name: "photo.takenDateTime", IsDescending: true}},
}, descOut))
ascOut := &searchsvc.SearchResponse{}
require.NoError(t, svc.Search(ctx, &searchsvc.SearchRequest{
Query: "mediatype:image",
OrderBy: []*searchsvc.SortProperty{{Name: "photo.takenDateTime"}},
}, ascOut))
require.Equal(t, "newest.jpg", descOut.Matches[0].Entity.Name)
require.Equal(t, "oldest.jpg", ascOut.Matches[0].Entity.Name)
}
+13
View File
@@ -159,6 +159,7 @@ Class | Method | HTTP request | Description
*MeUserApi* | [**UpdateOwnUser**](docs/MeUserApi.md#updateownuser) | **Patch** /v1.0/me | Update the current user
*RoleManagementApi* | [**GetPermissionRoleDefinition**](docs/RoleManagementApi.md#getpermissionroledefinition) | **Get** /v1beta1/roleManagement/permissions/roleDefinitions/{role-id} | Get unifiedRoleDefinition
*RoleManagementApi* | [**ListPermissionRoleDefinitions**](docs/RoleManagementApi.md#listpermissionroledefinitions) | **Get** /v1beta1/roleManagement/permissions/roleDefinitions | List roleDefinitions
*SearchApi* | [**SearchQuery**](docs/SearchApi.md#searchquery) | **Post** /v1beta1/search/query | Search for resources
*TagsApi* | [**AssignTags**](docs/TagsApi.md#assigntags) | **Put** /v1.0/extensions/org.libregraph/tags | Assign tags to a resource
*TagsApi* | [**GetTags**](docs/TagsApi.md#gettags) | **Get** /v1.0/extensions/org.libregraph/tags | Get all known tags
*TagsApi* | [**UnassignTags**](docs/TagsApi.md#unassigntags) | **Delete** /v1.0/extensions/org.libregraph/tags | Unassign tags from a resource
@@ -182,10 +183,13 @@ Class | Method | HTTP request | Description
- [ActivityTemplate](docs/ActivityTemplate.md)
- [ActivityTimes](docs/ActivityTimes.md)
- [ActivityTopic](docs/ActivityTopic.md)
- [AggregationOption](docs/AggregationOption.md)
- [AppRole](docs/AppRole.md)
- [AppRoleAssignment](docs/AppRoleAssignment.md)
- [Application](docs/Application.md)
- [Audio](docs/Audio.md)
- [BucketAggregationRange](docs/BucketAggregationRange.md)
- [BucketDefinition](docs/BucketDefinition.md)
- [ClassMemberReference](docs/ClassMemberReference.md)
- [ClassReference](docs/ClassReference.md)
- [ClassTeacherReference](docs/ClassTeacherReference.md)
@@ -250,6 +254,15 @@ Class | Method | HTTP request | Description
- [Quota](docs/Quota.md)
- [Recipient](docs/Recipient.md)
- [RemoteItem](docs/RemoteItem.md)
- [SearchAggregation](docs/SearchAggregation.md)
- [SearchBucket](docs/SearchBucket.md)
- [SearchHit](docs/SearchHit.md)
- [SearchHitsContainer](docs/SearchHitsContainer.md)
- [SearchQuery](docs/SearchQuery.md)
- [SearchQuery200Response](docs/SearchQuery200Response.md)
- [SearchQueryRequest](docs/SearchQueryRequest.md)
- [SearchRequest](docs/SearchRequest.md)
- [SearchResponse](docs/SearchResponse.md)
- [SharePointIdentitySet](docs/SharePointIdentitySet.md)
- [SharingInvitation](docs/SharingInvitation.md)
- [SharingLink](docs/SharingLink.md)
+157
View File
@@ -0,0 +1,157 @@
/*
Libre Graph API
Libre Graph is a free API for cloud collaboration inspired by the MS Graph API.
API version: v1.0.8
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
package libregraph
import (
"bytes"
"context"
"io"
"net/http"
"net/url"
)
// SearchApiService SearchApi service
type SearchApiService service
type ApiSearchQueryRequest struct {
ctx context.Context
ApiService *SearchApiService
searchQueryRequest *SearchQueryRequest
}
func (r ApiSearchQueryRequest) SearchQueryRequest(searchQueryRequest SearchQueryRequest) ApiSearchQueryRequest {
r.searchQueryRequest = &searchQueryRequest
return r
}
func (r ApiSearchQueryRequest) Execute() (*SearchQuery200Response, *http.Response, error) {
return r.ApiService.SearchQueryExecute(r)
}
/*
SearchQuery Search for resources
Run a specified search query. Search results are provided in the response.
The search endpoint allows clients to search for resources across all
accessible spaces and retrieve aggregated metadata (facets) about the
result set.
Aggregations can be used to group results by properties such as file type,
author, or any indexed metadata field. This is useful for building faceted
search UIs or computing statistics about the result set.
The query string uses KQL (Keyword Query Language) syntax for filtering.
Modeled on the MS Graph search query endpoint
(https://learn.microsoft.com/en-us/graph/api/search-query). Request and
response follow the MS Graph resource types; libregraph additions carry
the `@libre.graph.` prefix.
@param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background().
@return ApiSearchQueryRequest
*/
func (a *SearchApiService) SearchQuery(ctx context.Context) ApiSearchQueryRequest {
return ApiSearchQueryRequest{
ApiService: a,
ctx: ctx,
}
}
// Execute executes the request
// @return SearchQuery200Response
func (a *SearchApiService) SearchQueryExecute(r ApiSearchQueryRequest) (*SearchQuery200Response, *http.Response, error) {
var (
localVarHTTPMethod = http.MethodPost
localVarPostBody interface{}
formFiles []formFile
localVarReturnValue *SearchQuery200Response
)
localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "SearchApiService.SearchQuery")
if err != nil {
return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()}
}
localVarPath := localBasePath + "/v1beta1/search/query"
localVarHeaderParams := make(map[string]string)
localVarQueryParams := url.Values{}
localVarFormParams := url.Values{}
if r.searchQueryRequest == nil {
return localVarReturnValue, nil, reportError("searchQueryRequest is required and must be specified")
}
// to determine the Content-Type header
localVarHTTPContentTypes := []string{"application/json"}
// set Content-Type header
localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes)
if localVarHTTPContentType != "" {
localVarHeaderParams["Content-Type"] = localVarHTTPContentType
}
// to determine the Accept header
localVarHTTPHeaderAccepts := []string{"application/json"}
// set Accept header
localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts)
if localVarHTTPHeaderAccept != "" {
localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept
}
// body params
localVarPostBody = r.searchQueryRequest
req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles)
if err != nil {
return localVarReturnValue, nil, err
}
localVarHTTPResponse, err := a.client.callAPI(req)
if err != nil || localVarHTTPResponse == nil {
return localVarReturnValue, localVarHTTPResponse, err
}
localVarBody, err := io.ReadAll(localVarHTTPResponse.Body)
localVarHTTPResponse.Body.Close()
localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody))
if err != nil {
return localVarReturnValue, localVarHTTPResponse, err
}
if localVarHTTPResponse.StatusCode >= 300 {
newErr := &GenericOpenAPIError{
body: localVarBody,
error: localVarHTTPResponse.Status,
}
var v OdataError
err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
if err != nil {
newErr.error = err.Error()
return localVarReturnValue, localVarHTTPResponse, newErr
}
newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v)
newErr.model = v
return localVarReturnValue, localVarHTTPResponse, newErr
}
err = a.client.decode(&localVarReturnValue, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
if err != nil {
newErr := &GenericOpenAPIError{
body: localVarBody,
error: err.Error(),
}
return localVarReturnValue, localVarHTTPResponse, newErr
}
return localVarReturnValue, localVarHTTPResponse, nil
}
+3
View File
@@ -93,6 +93,8 @@ type APIClient struct {
RoleManagementApi *RoleManagementApiService
SearchApi *SearchApiService
TagsApi *TagsApiService
UserApi *UserApiService
@@ -144,6 +146,7 @@ func NewAPIClient(cfg *Configuration) *APIClient {
c.MePhotoApi = (*MePhotoApiService)(&c.common)
c.MeUserApi = (*MeUserApiService)(&c.common)
c.RoleManagementApi = (*RoleManagementApiService)(&c.common)
c.SearchApi = (*SearchApiService)(&c.common)
c.TagsApi = (*TagsApiService)(&c.common)
c.UserApi = (*UserApiService)(&c.common)
c.UserAppRoleAssignmentApi = (*UserAppRoleAssignmentApiService)(&c.common)
@@ -0,0 +1,305 @@
/*
Libre Graph API
Libre Graph is a free API for cloud collaboration inspired by the MS Graph API.
API version: v1.0.8
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
package libregraph
import (
"encoding/json"
"bytes"
"fmt"
)
// checks if the AggregationOption type satisfies the MappedNullable interface at compile time
var _ MappedNullable = &AggregationOption{}
// AggregationOption Specifies an aggregation that should be computed and returned alongside search results. Follows the [MS Graph aggregationOption](https://learn.microsoft.com/en-us/graph/api/resources/aggregationoption) resource type. For string fields, terms aggregations return the distinct values and their counts. For numeric and date fields, range aggregations can be defined using the `ranges` property of `bucketDefinition`.
type AggregationOption struct {
// Specifies the field in the schema of the specified entity type that the aggregation should be computed on. Required. Examples: `audio.artist`, `audio.genre`, `audio.year`, `mimeType`.
Field string `json:"field"`
// The number of `searchBucket` resources to be returned. This is optional and only applies to terms aggregations. Combined with `bucketDefinition.sortBy` and `bucketDefinition.isDescending` to produce the top N results by count or key. When not specified, all buckets are returned.
Size *int32 `json:"size,omitempty"`
BucketDefinition *BucketDefinition `json:"bucketDefinition,omitempty"`
// Nested aggregations computed within each bucket of this aggregation. Libregraph extension not present in MS Graph. Backends that don't support native composite aggregations (e.g. bleve) emulate them by walking the matched result set; OpenSearch translates them to native composite aggregations.
LibreGraphSubAggregations []AggregationOption `json:"@libre.graph.subAggregations,omitempty"`
LibreGraphMetricDefinition *MetricDefinition `json:"@libre.graph.metricDefinition,omitempty"`
}
type _AggregationOption AggregationOption
// NewAggregationOption instantiates a new AggregationOption object
// This constructor will assign default values to properties that have it defined,
// and makes sure properties required by API are set, but the set of arguments
// will change when the set of required properties is changed
func NewAggregationOption(field string) *AggregationOption {
this := AggregationOption{}
this.Field = field
return &this
}
// NewAggregationOptionWithDefaults instantiates a new AggregationOption object
// This constructor will only assign default values to properties that have it defined,
// but it doesn't guarantee that properties required by API are set
func NewAggregationOptionWithDefaults() *AggregationOption {
this := AggregationOption{}
return &this
}
// GetField returns the Field field value
func (o *AggregationOption) GetField() string {
if o == nil {
var ret string
return ret
}
return o.Field
}
// GetFieldOk returns a tuple with the Field field value
// and a boolean to check if the value has been set.
func (o *AggregationOption) GetFieldOk() (*string, bool) {
if o == nil {
return nil, false
}
return &o.Field, true
}
// SetField sets field value
func (o *AggregationOption) SetField(v string) {
o.Field = v
}
// GetSize returns the Size field value if set, zero value otherwise.
func (o *AggregationOption) GetSize() int32 {
if o == nil || IsNil(o.Size) {
var ret int32
return ret
}
return *o.Size
}
// GetSizeOk returns a tuple with the Size field value if set, nil otherwise
// and a boolean to check if the value has been set.
func (o *AggregationOption) GetSizeOk() (*int32, bool) {
if o == nil || IsNil(o.Size) {
return nil, false
}
return o.Size, true
}
// HasSize returns a boolean if a field has been set.
func (o *AggregationOption) HasSize() bool {
if o != nil && !IsNil(o.Size) {
return true
}
return false
}
// SetSize gets a reference to the given int32 and assigns it to the Size field.
func (o *AggregationOption) SetSize(v int32) {
o.Size = &v
}
// GetBucketDefinition returns the BucketDefinition field value if set, zero value otherwise.
func (o *AggregationOption) GetBucketDefinition() BucketDefinition {
if o == nil || IsNil(o.BucketDefinition) {
var ret BucketDefinition
return ret
}
return *o.BucketDefinition
}
// GetBucketDefinitionOk returns a tuple with the BucketDefinition field value if set, nil otherwise
// and a boolean to check if the value has been set.
func (o *AggregationOption) GetBucketDefinitionOk() (*BucketDefinition, bool) {
if o == nil || IsNil(o.BucketDefinition) {
return nil, false
}
return o.BucketDefinition, true
}
// HasBucketDefinition returns a boolean if a field has been set.
func (o *AggregationOption) HasBucketDefinition() bool {
if o != nil && !IsNil(o.BucketDefinition) {
return true
}
return false
}
// SetBucketDefinition gets a reference to the given BucketDefinition and assigns it to the BucketDefinition field.
func (o *AggregationOption) SetBucketDefinition(v BucketDefinition) {
o.BucketDefinition = &v
}
// GetLibreGraphSubAggregations returns the LibreGraphSubAggregations field value if set, zero value otherwise.
func (o *AggregationOption) GetLibreGraphSubAggregations() []AggregationOption {
if o == nil || IsNil(o.LibreGraphSubAggregations) {
var ret []AggregationOption
return ret
}
return o.LibreGraphSubAggregations
}
// GetLibreGraphSubAggregationsOk returns a tuple with the LibreGraphSubAggregations field value if set, nil otherwise
// and a boolean to check if the value has been set.
func (o *AggregationOption) GetLibreGraphSubAggregationsOk() ([]AggregationOption, bool) {
if o == nil || IsNil(o.LibreGraphSubAggregations) {
return nil, false
}
return o.LibreGraphSubAggregations, true
}
// HasLibreGraphSubAggregations returns a boolean if a field has been set.
func (o *AggregationOption) HasLibreGraphSubAggregations() bool {
if o != nil && !IsNil(o.LibreGraphSubAggregations) {
return true
}
return false
}
// SetLibreGraphSubAggregations gets a reference to the given []AggregationOption and assigns it to the LibreGraphSubAggregations field.
func (o *AggregationOption) SetLibreGraphSubAggregations(v []AggregationOption) {
o.LibreGraphSubAggregations = v
}
// GetLibreGraphMetricDefinition returns the LibreGraphMetricDefinition field value if set, zero value otherwise.
func (o *AggregationOption) GetLibreGraphMetricDefinition() MetricDefinition {
if o == nil || IsNil(o.LibreGraphMetricDefinition) {
var ret MetricDefinition
return ret
}
return *o.LibreGraphMetricDefinition
}
// GetLibreGraphMetricDefinitionOk returns a tuple with the LibreGraphMetricDefinition field value if set, nil otherwise
// and a boolean to check if the value has been set.
func (o *AggregationOption) GetLibreGraphMetricDefinitionOk() (*MetricDefinition, bool) {
if o == nil || IsNil(o.LibreGraphMetricDefinition) {
return nil, false
}
return o.LibreGraphMetricDefinition, true
}
// HasLibreGraphMetricDefinition returns a boolean if a field has been set.
func (o *AggregationOption) HasLibreGraphMetricDefinition() bool {
if o != nil && !IsNil(o.LibreGraphMetricDefinition) {
return true
}
return false
}
// SetLibreGraphMetricDefinition gets a reference to the given MetricDefinition and assigns it to the LibreGraphMetricDefinition field.
func (o *AggregationOption) SetLibreGraphMetricDefinition(v MetricDefinition) {
o.LibreGraphMetricDefinition = &v
}
func (o AggregationOption) MarshalJSON() ([]byte, error) {
toSerialize,err := o.ToMap()
if err != nil {
return []byte{}, err
}
return json.Marshal(toSerialize)
}
func (o AggregationOption) ToMap() (map[string]interface{}, error) {
toSerialize := map[string]interface{}{}
toSerialize["field"] = o.Field
if !IsNil(o.Size) {
toSerialize["size"] = o.Size
}
if !IsNil(o.BucketDefinition) {
toSerialize["bucketDefinition"] = o.BucketDefinition
}
if !IsNil(o.LibreGraphSubAggregations) {
toSerialize["@libre.graph.subAggregations"] = o.LibreGraphSubAggregations
}
if !IsNil(o.LibreGraphMetricDefinition) {
toSerialize["@libre.graph.metricDefinition"] = o.LibreGraphMetricDefinition
}
return toSerialize, nil
}
func (o *AggregationOption) UnmarshalJSON(data []byte) (err error) {
// This validates that all required properties are included in the JSON object
// by unmarshalling the object into a generic map with string keys and checking
// that every required field exists as a key in the generic map.
requiredProperties := []string{
"field",
}
allProperties := make(map[string]interface{})
err = json.Unmarshal(data, &allProperties)
if err != nil {
return err;
}
for _, requiredProperty := range(requiredProperties) {
if _, exists := allProperties[requiredProperty]; !exists {
return fmt.Errorf("no value given for required property %v", requiredProperty)
}
}
varAggregationOption := _AggregationOption{}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
err = decoder.Decode(&varAggregationOption)
if err != nil {
return err
}
*o = AggregationOption(varAggregationOption)
return err
}
type NullableAggregationOption struct {
value *AggregationOption
isSet bool
}
func (v NullableAggregationOption) Get() *AggregationOption {
return v.value
}
func (v *NullableAggregationOption) Set(val *AggregationOption) {
v.value = val
v.isSet = true
}
func (v NullableAggregationOption) IsSet() bool {
return v.isSet
}
func (v *NullableAggregationOption) Unset() {
v.value = nil
v.isSet = false
}
func NewNullableAggregationOption(val *AggregationOption) *NullableAggregationOption {
return &NullableAggregationOption{value: val, isSet: true}
}
func (v NullableAggregationOption) MarshalJSON() ([]byte, error) {
return json.Marshal(v.value)
}
func (v *NullableAggregationOption) UnmarshalJSON(src []byte) error {
v.isSet = true
return json.Unmarshal(src, &v.value)
}
@@ -0,0 +1,164 @@
/*
Libre Graph API
Libre Graph is a free API for cloud collaboration inspired by the MS Graph API.
API version: v1.0.8
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
package libregraph
import (
"encoding/json"
)
// checks if the BucketAggregationRange type satisfies the MappedNullable interface at compile time
var _ MappedNullable = &BucketAggregationRange{}
// BucketAggregationRange Specifies the lower and upper bound to compute a range aggregation bucket. At least one of `from` or `to` must be provided.
type BucketAggregationRange struct {
// Defines the lower bound from which to compute the aggregation. The value is always a string. Numeric bounds must be provided as their string representation (e.g. `\"1980\"`). Date bounds must use the `YYYY-MM-DDTHH:mm:ssZ` format. Optional if `to` is provided.
From *string
// Defines the upper bound up to which to compute the aggregation. The value is always a string. Numeric bounds must be provided as their string representation (e.g. `\"2000\"`). Date bounds must use the `YYYY-MM-DDTHH:mm:ssZ` format. Optional if `from` is provided.
To *string
}
// NewBucketAggregationRange instantiates a new BucketAggregationRange object
// This constructor will assign default values to properties that have it defined,
// and makes sure properties required by API are set, but the set of arguments
// will change when the set of required properties is changed
func NewBucketAggregationRange() *BucketAggregationRange {
this := BucketAggregationRange{}
return &this
}
// NewBucketAggregationRangeWithDefaults instantiates a new BucketAggregationRange object
// This constructor will only assign default values to properties that have it defined,
// but it doesn't guarantee that properties required by API are set
func NewBucketAggregationRangeWithDefaults() *BucketAggregationRange {
this := BucketAggregationRange{}
return &this
}
// GetFrom returns the From field value if set, zero value otherwise.
func (o *BucketAggregationRange) GetFrom() string {
if o == nil || IsNil(o.From) {
var ret string
return ret
}
return *o.From
}
// GetFromOk returns a tuple with the From field value if set, nil otherwise
// and a boolean to check if the value has been set.
func (o *BucketAggregationRange) GetFromOk() (*string, bool) {
if o == nil || IsNil(o.From) {
return nil, false
}
return o.From, true
}
// HasFrom returns a boolean if a field has been set.
func (o *BucketAggregationRange) HasFrom() bool {
if o != nil && !IsNil(o.From) {
return true
}
return false
}
// SetFrom gets a reference to the given string and assigns it to the From field.
func (o *BucketAggregationRange) SetFrom(v string) {
o.From = &v
}
// GetTo returns the To field value if set, zero value otherwise.
func (o *BucketAggregationRange) GetTo() string {
if o == nil || IsNil(o.To) {
var ret string
return ret
}
return *o.To
}
// GetToOk returns a tuple with the To field value if set, nil otherwise
// and a boolean to check if the value has been set.
func (o *BucketAggregationRange) GetToOk() (*string, bool) {
if o == nil || IsNil(o.To) {
return nil, false
}
return o.To, true
}
// HasTo returns a boolean if a field has been set.
func (o *BucketAggregationRange) HasTo() bool {
if o != nil && !IsNil(o.To) {
return true
}
return false
}
// SetTo gets a reference to the given string and assigns it to the To field.
func (o *BucketAggregationRange) SetTo(v string) {
o.To = &v
}
func (o BucketAggregationRange) MarshalJSON() ([]byte, error) {
toSerialize,err := o.ToMap()
if err != nil {
return []byte{}, err
}
return json.Marshal(toSerialize)
}
func (o BucketAggregationRange) ToMap() (map[string]interface{}, error) {
toSerialize := map[string]interface{}{}
if !IsNil(o.From) {
toSerialize["from"] = o.From
}
if !IsNil(o.To) {
toSerialize["to"] = o.To
}
return toSerialize, nil
}
type NullableBucketAggregationRange struct {
value *BucketAggregationRange
isSet bool
}
func (v NullableBucketAggregationRange) Get() *BucketAggregationRange {
return v.value
}
func (v *NullableBucketAggregationRange) Set(val *BucketAggregationRange) {
v.value = val
v.isSet = true
}
func (v NullableBucketAggregationRange) IsSet() bool {
return v.isSet
}
func (v *NullableBucketAggregationRange) Unset() {
v.value = nil
v.isSet = false
}
func NewNullableBucketAggregationRange(val *BucketAggregationRange) *NullableBucketAggregationRange {
return &NullableBucketAggregationRange{value: val, isSet: true}
}
func (v NullableBucketAggregationRange) MarshalJSON() ([]byte, error) {
return json.Marshal(v.value)
}
func (v *NullableBucketAggregationRange) UnmarshalJSON(src []byte) error {
v.isSet = true
return json.Unmarshal(src, &v.value)
}
@@ -0,0 +1,278 @@
/*
Libre Graph API
Libre Graph is a free API for cloud collaboration inspired by the MS Graph API.
API version: v1.0.8
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
package libregraph
import (
"encoding/json"
"bytes"
"fmt"
)
// checks if the BucketDefinition type satisfies the MappedNullable interface at compile time
var _ MappedNullable = &BucketDefinition{}
// BucketDefinition Provides the details of how to generate the aggregation buckets in the response. Follows the [MS Graph bucketAggregationDefinition](https://learn.microsoft.com/en-us/graph/api/resources/bucketaggregationdefinition) resource type.
type BucketDefinition struct {
// The possible values are `count` to sort by the number of matches in the aggregation, `keyAsString` to sort alphabetically based on the key in the aggregation, and `keyAsNumber` to sort numerically based on the key in the aggregation. Required.
SortBy string `json:"sortBy"`
// Set to `true` to specify the sort order as descending. Optional, defaults to `false` (ascending).
IsDescending *bool `json:"isDescending,omitempty"`
// The minimum number of items that should be present in the aggregation for the bucket to be returned in the response. Optional, default is 0.
MinimumCount *int32 `json:"minimumCount,omitempty"`
// Specifies the manual ranges to compute the aggregation buckets. This is only valid for non-string facets of date or numeric type. Optional. Follows the [MS Graph bucketAggregationRange](https://learn.microsoft.com/en-us/graph/api/resources/bucketaggregationrange) resource type.
Ranges []BucketAggregationRange `json:"ranges,omitempty"`
}
type _BucketDefinition BucketDefinition
// NewBucketDefinition instantiates a new BucketDefinition object
// This constructor will assign default values to properties that have it defined,
// and makes sure properties required by API are set, but the set of arguments
// will change when the set of required properties is changed
func NewBucketDefinition(sortBy string) *BucketDefinition {
this := BucketDefinition{}
this.SortBy = sortBy
var isDescending bool = false
this.IsDescending = &isDescending
var minimumCount int32 = 0
this.MinimumCount = &minimumCount
return &this
}
// NewBucketDefinitionWithDefaults instantiates a new BucketDefinition object
// This constructor will only assign default values to properties that have it defined,
// but it doesn't guarantee that properties required by API are set
func NewBucketDefinitionWithDefaults() *BucketDefinition {
this := BucketDefinition{}
var isDescending bool = false
this.IsDescending = &isDescending
var minimumCount int32 = 0
this.MinimumCount = &minimumCount
return &this
}
// GetSortBy returns the SortBy field value
func (o *BucketDefinition) GetSortBy() string {
if o == nil {
var ret string
return ret
}
return o.SortBy
}
// GetSortByOk returns a tuple with the SortBy field value
// and a boolean to check if the value has been set.
func (o *BucketDefinition) GetSortByOk() (*string, bool) {
if o == nil {
return nil, false
}
return &o.SortBy, true
}
// SetSortBy sets field value
func (o *BucketDefinition) SetSortBy(v string) {
o.SortBy = v
}
// GetIsDescending returns the IsDescending field value if set, zero value otherwise.
func (o *BucketDefinition) GetIsDescending() bool {
if o == nil || IsNil(o.IsDescending) {
var ret bool
return ret
}
return *o.IsDescending
}
// GetIsDescendingOk returns a tuple with the IsDescending field value if set, nil otherwise
// and a boolean to check if the value has been set.
func (o *BucketDefinition) GetIsDescendingOk() (*bool, bool) {
if o == nil || IsNil(o.IsDescending) {
return nil, false
}
return o.IsDescending, true
}
// HasIsDescending returns a boolean if a field has been set.
func (o *BucketDefinition) HasIsDescending() bool {
if o != nil && !IsNil(o.IsDescending) {
return true
}
return false
}
// SetIsDescending gets a reference to the given bool and assigns it to the IsDescending field.
func (o *BucketDefinition) SetIsDescending(v bool) {
o.IsDescending = &v
}
// GetMinimumCount returns the MinimumCount field value if set, zero value otherwise.
func (o *BucketDefinition) GetMinimumCount() int32 {
if o == nil || IsNil(o.MinimumCount) {
var ret int32
return ret
}
return *o.MinimumCount
}
// GetMinimumCountOk returns a tuple with the MinimumCount field value if set, nil otherwise
// and a boolean to check if the value has been set.
func (o *BucketDefinition) GetMinimumCountOk() (*int32, bool) {
if o == nil || IsNil(o.MinimumCount) {
return nil, false
}
return o.MinimumCount, true
}
// HasMinimumCount returns a boolean if a field has been set.
func (o *BucketDefinition) HasMinimumCount() bool {
if o != nil && !IsNil(o.MinimumCount) {
return true
}
return false
}
// SetMinimumCount gets a reference to the given int32 and assigns it to the MinimumCount field.
func (o *BucketDefinition) SetMinimumCount(v int32) {
o.MinimumCount = &v
}
// GetRanges returns the Ranges field value if set, zero value otherwise.
func (o *BucketDefinition) GetRanges() []BucketAggregationRange {
if o == nil || IsNil(o.Ranges) {
var ret []BucketAggregationRange
return ret
}
return o.Ranges
}
// GetRangesOk returns a tuple with the Ranges field value if set, nil otherwise
// and a boolean to check if the value has been set.
func (o *BucketDefinition) GetRangesOk() ([]BucketAggregationRange, bool) {
if o == nil || IsNil(o.Ranges) {
return nil, false
}
return o.Ranges, true
}
// HasRanges returns a boolean if a field has been set.
func (o *BucketDefinition) HasRanges() bool {
if o != nil && !IsNil(o.Ranges) {
return true
}
return false
}
// SetRanges gets a reference to the given []BucketAggregationRange and assigns it to the Ranges field.
func (o *BucketDefinition) SetRanges(v []BucketAggregationRange) {
o.Ranges = v
}
func (o BucketDefinition) MarshalJSON() ([]byte, error) {
toSerialize,err := o.ToMap()
if err != nil {
return []byte{}, err
}
return json.Marshal(toSerialize)
}
func (o BucketDefinition) ToMap() (map[string]interface{}, error) {
toSerialize := map[string]interface{}{}
toSerialize["sortBy"] = o.SortBy
if !IsNil(o.IsDescending) {
toSerialize["isDescending"] = o.IsDescending
}
if !IsNil(o.MinimumCount) {
toSerialize["minimumCount"] = o.MinimumCount
}
if !IsNil(o.Ranges) {
toSerialize["ranges"] = o.Ranges
}
return toSerialize, nil
}
func (o *BucketDefinition) UnmarshalJSON(data []byte) (err error) {
// This validates that all required properties are included in the JSON object
// by unmarshalling the object into a generic map with string keys and checking
// that every required field exists as a key in the generic map.
requiredProperties := []string{
"sortBy",
}
allProperties := make(map[string]interface{})
err = json.Unmarshal(data, &allProperties)
if err != nil {
return err;
}
for _, requiredProperty := range(requiredProperties) {
if _, exists := allProperties[requiredProperty]; !exists {
return fmt.Errorf("no value given for required property %v", requiredProperty)
}
}
varBucketDefinition := _BucketDefinition{}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
err = decoder.Decode(&varBucketDefinition)
if err != nil {
return err
}
*o = BucketDefinition(varBucketDefinition)
return err
}
type NullableBucketDefinition struct {
value *BucketDefinition
isSet bool
}
func (v NullableBucketDefinition) Get() *BucketDefinition {
return v.value
}
func (v *NullableBucketDefinition) Set(val *BucketDefinition) {
v.value = val
v.isSet = true
}
func (v NullableBucketDefinition) IsSet() bool {
return v.isSet
}
func (v *NullableBucketDefinition) Unset() {
v.value = nil
v.isSet = false
}
func NewNullableBucketDefinition(val *BucketDefinition) *NullableBucketDefinition {
return &NullableBucketDefinition{value: val, isSet: true}
}
func (v NullableBucketDefinition) MarshalJSON() ([]byte, error) {
return json.Marshal(v.value)
}
func (v *NullableBucketDefinition) UnmarshalJSON(src []byte) error {
v.isSet = true
return json.Unmarshal(src, &v.value)
}
@@ -0,0 +1,159 @@
/*
Libre Graph API
Libre Graph is a free API for cloud collaboration inspired by the MS Graph API.
API version: v1.0.8
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
package libregraph
import (
"encoding/json"
"bytes"
"fmt"
)
// checks if the MetricDefinition type satisfies the MappedNullable interface at compile time
var _ MappedNullable = &MetricDefinition{}
// MetricDefinition Provides the details of how to compute a scalar metric over the aggregation `field`, the counterpart of `bucketDefinition` for metric aggregations. When set on an `aggregationOption`, `size` and `bucketDefinition` are ignored, and the corresponding `searchAggregation` in the response carries a `@libre.graph.metric` rather than `buckets`. Libregraph extension not present in MS Graph.
type MetricDefinition struct {
// The reducer applied to the field values of all matches. Required. `avg` is not a simple reducer (averages of averages are not averages): the backend carries `(sum, count)` internally and emits only the final value on the outermost merge.
Kind string `json:"kind"`
}
type _MetricDefinition MetricDefinition
// NewMetricDefinition instantiates a new MetricDefinition object
// This constructor will assign default values to properties that have it defined,
// and makes sure properties required by API are set, but the set of arguments
// will change when the set of required properties is changed
func NewMetricDefinition(kind string) *MetricDefinition {
this := MetricDefinition{}
this.Kind = kind
return &this
}
// NewMetricDefinitionWithDefaults instantiates a new MetricDefinition object
// This constructor will only assign default values to properties that have it defined,
// but it doesn't guarantee that properties required by API are set
func NewMetricDefinitionWithDefaults() *MetricDefinition {
this := MetricDefinition{}
return &this
}
// GetKind returns the Kind field value
func (o *MetricDefinition) GetKind() string {
if o == nil {
var ret string
return ret
}
return o.Kind
}
// GetKindOk returns a tuple with the Kind field value
// and a boolean to check if the value has been set.
func (o *MetricDefinition) GetKindOk() (*string, bool) {
if o == nil {
return nil, false
}
return &o.Kind, true
}
// SetKind sets field value
func (o *MetricDefinition) SetKind(v string) {
o.Kind = v
}
func (o MetricDefinition) MarshalJSON() ([]byte, error) {
toSerialize,err := o.ToMap()
if err != nil {
return []byte{}, err
}
return json.Marshal(toSerialize)
}
func (o MetricDefinition) ToMap() (map[string]interface{}, error) {
toSerialize := map[string]interface{}{}
toSerialize["kind"] = o.Kind
return toSerialize, nil
}
func (o *MetricDefinition) UnmarshalJSON(data []byte) (err error) {
// This validates that all required properties are included in the JSON object
// by unmarshalling the object into a generic map with string keys and checking
// that every required field exists as a key in the generic map.
requiredProperties := []string{
"kind",
}
allProperties := make(map[string]interface{})
err = json.Unmarshal(data, &allProperties)
if err != nil {
return err;
}
for _, requiredProperty := range(requiredProperties) {
if _, exists := allProperties[requiredProperty]; !exists {
return fmt.Errorf("no value given for required property %v", requiredProperty)
}
}
varMetricDefinition := _MetricDefinition{}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
err = decoder.Decode(&varMetricDefinition)
if err != nil {
return err
}
*o = MetricDefinition(varMetricDefinition)
return err
}
type NullableMetricDefinition struct {
value *MetricDefinition
isSet bool
}
func (v NullableMetricDefinition) Get() *MetricDefinition {
return v.value
}
func (v *NullableMetricDefinition) Set(val *MetricDefinition) {
v.value = val
v.isSet = true
}
func (v NullableMetricDefinition) IsSet() bool {
return v.isSet
}
func (v *NullableMetricDefinition) Unset() {
v.value = nil
v.isSet = false
}
func NewNullableMetricDefinition(val *MetricDefinition) *NullableMetricDefinition {
return &NullableMetricDefinition{value: val, isSet: true}
}
func (v NullableMetricDefinition) MarshalJSON() ([]byte, error) {
return json.Marshal(v.value)
}
func (v *NullableMetricDefinition) UnmarshalJSON(src []byte) error {
v.isSet = true
return json.Unmarshal(src, &v.value)
}
@@ -0,0 +1,200 @@
/*
Libre Graph API
Libre Graph is a free API for cloud collaboration inspired by the MS Graph API.
API version: v1.0.8
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
package libregraph
import (
"encoding/json"
)
// checks if the SearchAggregation type satisfies the MappedNullable interface at compile time
var _ MappedNullable = &SearchAggregation{}
// SearchAggregation Provides the details of a search aggregation in the search response. Follows the [MS Graph searchAggregation](https://learn.microsoft.com/en-us/graph/api/resources/searchaggregation) resource type.
type SearchAggregation struct {
// Defines the field in the request on which the aggregation was computed.
Field *string `json:"field,omitempty"`
// Defines the computed buckets for this aggregation. Buckets are sorted according to the `sortBy` and `isDescending` specified in the `bucketDefinition` of the corresponding `aggregationOption`.
Buckets []SearchBucket `json:"buckets,omitempty"`
LibreGraphMetric *SearchMetric `json:"@libre.graph.metric,omitempty"`
}
// NewSearchAggregation instantiates a new SearchAggregation object
// This constructor will assign default values to properties that have it defined,
// and makes sure properties required by API are set, but the set of arguments
// will change when the set of required properties is changed
func NewSearchAggregation() *SearchAggregation {
this := SearchAggregation{}
return &this
}
// NewSearchAggregationWithDefaults instantiates a new SearchAggregation object
// This constructor will only assign default values to properties that have it defined,
// but it doesn't guarantee that properties required by API are set
func NewSearchAggregationWithDefaults() *SearchAggregation {
this := SearchAggregation{}
return &this
}
// GetField returns the Field field value if set, zero value otherwise.
func (o *SearchAggregation) GetField() string {
if o == nil || IsNil(o.Field) {
var ret string
return ret
}
return *o.Field
}
// GetFieldOk returns a tuple with the Field field value if set, nil otherwise
// and a boolean to check if the value has been set.
func (o *SearchAggregation) GetFieldOk() (*string, bool) {
if o == nil || IsNil(o.Field) {
return nil, false
}
return o.Field, true
}
// HasField returns a boolean if a field has been set.
func (o *SearchAggregation) HasField() bool {
if o != nil && !IsNil(o.Field) {
return true
}
return false
}
// SetField gets a reference to the given string and assigns it to the Field field.
func (o *SearchAggregation) SetField(v string) {
o.Field = &v
}
// GetBuckets returns the Buckets field value if set, zero value otherwise.
func (o *SearchAggregation) GetBuckets() []SearchBucket {
if o == nil || IsNil(o.Buckets) {
var ret []SearchBucket
return ret
}
return o.Buckets
}
// GetBucketsOk returns a tuple with the Buckets field value if set, nil otherwise
// and a boolean to check if the value has been set.
func (o *SearchAggregation) GetBucketsOk() ([]SearchBucket, bool) {
if o == nil || IsNil(o.Buckets) {
return nil, false
}
return o.Buckets, true
}
// HasBuckets returns a boolean if a field has been set.
func (o *SearchAggregation) HasBuckets() bool {
if o != nil && !IsNil(o.Buckets) {
return true
}
return false
}
// SetBuckets gets a reference to the given []SearchBucket and assigns it to the Buckets field.
func (o *SearchAggregation) SetBuckets(v []SearchBucket) {
o.Buckets = v
}
// GetLibreGraphMetric returns the LibreGraphMetric field value if set, zero value otherwise.
func (o *SearchAggregation) GetLibreGraphMetric() SearchMetric {
if o == nil || IsNil(o.LibreGraphMetric) {
var ret SearchMetric
return ret
}
return *o.LibreGraphMetric
}
// GetLibreGraphMetricOk returns a tuple with the LibreGraphMetric field value if set, nil otherwise
// and a boolean to check if the value has been set.
func (o *SearchAggregation) GetLibreGraphMetricOk() (*SearchMetric, bool) {
if o == nil || IsNil(o.LibreGraphMetric) {
return nil, false
}
return o.LibreGraphMetric, true
}
// HasLibreGraphMetric returns a boolean if a field has been set.
func (o *SearchAggregation) HasLibreGraphMetric() bool {
if o != nil && !IsNil(o.LibreGraphMetric) {
return true
}
return false
}
// SetLibreGraphMetric gets a reference to the given SearchMetric and assigns it to the LibreGraphMetric field.
func (o *SearchAggregation) SetLibreGraphMetric(v SearchMetric) {
o.LibreGraphMetric = &v
}
func (o SearchAggregation) MarshalJSON() ([]byte, error) {
toSerialize,err := o.ToMap()
if err != nil {
return []byte{}, err
}
return json.Marshal(toSerialize)
}
func (o SearchAggregation) ToMap() (map[string]interface{}, error) {
toSerialize := map[string]interface{}{}
if !IsNil(o.Field) {
toSerialize["field"] = o.Field
}
if !IsNil(o.Buckets) {
toSerialize["buckets"] = o.Buckets
}
if !IsNil(o.LibreGraphMetric) {
toSerialize["@libre.graph.metric"] = o.LibreGraphMetric
}
return toSerialize, nil
}
type NullableSearchAggregation struct {
value *SearchAggregation
isSet bool
}
func (v NullableSearchAggregation) Get() *SearchAggregation {
return v.value
}
func (v *NullableSearchAggregation) Set(val *SearchAggregation) {
v.value = val
v.isSet = true
}
func (v NullableSearchAggregation) IsSet() bool {
return v.isSet
}
func (v *NullableSearchAggregation) Unset() {
v.value = nil
v.isSet = false
}
func NewNullableSearchAggregation(val *SearchAggregation) *NullableSearchAggregation {
return &NullableSearchAggregation{value: val, isSet: true}
}
func (v NullableSearchAggregation) MarshalJSON() ([]byte, error) {
return json.Marshal(v.value)
}
func (v *NullableSearchAggregation) UnmarshalJSON(src []byte) error {
v.isSet = true
return json.Unmarshal(src, &v.value)
}
@@ -0,0 +1,238 @@
/*
Libre Graph API
Libre Graph is a free API for cloud collaboration inspired by the MS Graph API.
API version: v1.0.8
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
package libregraph
import (
"encoding/json"
)
// checks if the SearchBucket type satisfies the MappedNullable interface at compile time
var _ MappedNullable = &SearchBucket{}
// SearchBucket Represents a single bucket in a search aggregation result. Follows the [MS Graph searchBucket](https://learn.microsoft.com/en-us/graph/api/resources/searchbucket) resource type.
type SearchBucket struct {
// The discrete value of the field that was used to compute the aggregation. For terms aggregations this is the field value. For range aggregations this is a string representation of the range.
Key *string `json:"key,omitempty"`
// The approximate number of search matches that share the same value specified in the `key` property.
Count *int64 `json:"count,omitempty"`
// A token containing the encoded filter that narrows search matches to this bucket. To use it, pass it as part of the `aggregationFilters` property of a subsequent `searchRequest` in the format `{field}:{aggregationFilterToken}`. The filter matches the bucket `key` exactly and case-sensitively, so the narrowed result set is the set of matches counted in this bucket. For terms buckets the token is the key encoded as lowercase hex of its UTF-8 bytes, prefixed with `ǂǂ` (U+01C2 twice) and wrapped in double quotes, e.g. `\"ǂǂ50696e6b20466c6f7964\"` for the key `Pink Floyd`. For range buckets the token is `range({from}, {to})` with the bounds of the matching `bucketAggregationRange`; an open lower bound is written as `min`, an open upper bound as `max` followed by `to=\"le\"`, e.g. `range(min, 1980)`, `range(1980, 1990)` and `range(2010, max, to=\"le\")`. This is the same encoding MS Graph uses.
AggregationFilterToken *string `json:"aggregationFilterToken,omitempty"`
// Nested aggregation results, one per sub-aggregation requested on the parent `aggregationOption`. Libregraph extension not present in MS Graph.
LibreGraphSubAggregations []SearchAggregation `json:"@libre.graph.subAggregations,omitempty"`
}
// NewSearchBucket instantiates a new SearchBucket object
// This constructor will assign default values to properties that have it defined,
// and makes sure properties required by API are set, but the set of arguments
// will change when the set of required properties is changed
func NewSearchBucket() *SearchBucket {
this := SearchBucket{}
return &this
}
// NewSearchBucketWithDefaults instantiates a new SearchBucket object
// This constructor will only assign default values to properties that have it defined,
// but it doesn't guarantee that properties required by API are set
func NewSearchBucketWithDefaults() *SearchBucket {
this := SearchBucket{}
return &this
}
// GetKey returns the Key field value if set, zero value otherwise.
func (o *SearchBucket) GetKey() string {
if o == nil || IsNil(o.Key) {
var ret string
return ret
}
return *o.Key
}
// GetKeyOk returns a tuple with the Key field value if set, nil otherwise
// and a boolean to check if the value has been set.
func (o *SearchBucket) GetKeyOk() (*string, bool) {
if o == nil || IsNil(o.Key) {
return nil, false
}
return o.Key, true
}
// HasKey returns a boolean if a field has been set.
func (o *SearchBucket) HasKey() bool {
if o != nil && !IsNil(o.Key) {
return true
}
return false
}
// SetKey gets a reference to the given string and assigns it to the Key field.
func (o *SearchBucket) SetKey(v string) {
o.Key = &v
}
// GetCount returns the Count field value if set, zero value otherwise.
func (o *SearchBucket) GetCount() int64 {
if o == nil || IsNil(o.Count) {
var ret int64
return ret
}
return *o.Count
}
// GetCountOk returns a tuple with the Count field value if set, nil otherwise
// and a boolean to check if the value has been set.
func (o *SearchBucket) GetCountOk() (*int64, bool) {
if o == nil || IsNil(o.Count) {
return nil, false
}
return o.Count, true
}
// HasCount returns a boolean if a field has been set.
func (o *SearchBucket) HasCount() bool {
if o != nil && !IsNil(o.Count) {
return true
}
return false
}
// SetCount gets a reference to the given int64 and assigns it to the Count field.
func (o *SearchBucket) SetCount(v int64) {
o.Count = &v
}
// GetAggregationFilterToken returns the AggregationFilterToken field value if set, zero value otherwise.
func (o *SearchBucket) GetAggregationFilterToken() string {
if o == nil || IsNil(o.AggregationFilterToken) {
var ret string
return ret
}
return *o.AggregationFilterToken
}
// GetAggregationFilterTokenOk returns a tuple with the AggregationFilterToken field value if set, nil otherwise
// and a boolean to check if the value has been set.
func (o *SearchBucket) GetAggregationFilterTokenOk() (*string, bool) {
if o == nil || IsNil(o.AggregationFilterToken) {
return nil, false
}
return o.AggregationFilterToken, true
}
// HasAggregationFilterToken returns a boolean if a field has been set.
func (o *SearchBucket) HasAggregationFilterToken() bool {
if o != nil && !IsNil(o.AggregationFilterToken) {
return true
}
return false
}
// SetAggregationFilterToken gets a reference to the given string and assigns it to the AggregationFilterToken field.
func (o *SearchBucket) SetAggregationFilterToken(v string) {
o.AggregationFilterToken = &v
}
// GetLibreGraphSubAggregations returns the LibreGraphSubAggregations field value if set, zero value otherwise.
func (o *SearchBucket) GetLibreGraphSubAggregations() []SearchAggregation {
if o == nil || IsNil(o.LibreGraphSubAggregations) {
var ret []SearchAggregation
return ret
}
return o.LibreGraphSubAggregations
}
// GetLibreGraphSubAggregationsOk returns a tuple with the LibreGraphSubAggregations field value if set, nil otherwise
// and a boolean to check if the value has been set.
func (o *SearchBucket) GetLibreGraphSubAggregationsOk() ([]SearchAggregation, bool) {
if o == nil || IsNil(o.LibreGraphSubAggregations) {
return nil, false
}
return o.LibreGraphSubAggregations, true
}
// HasLibreGraphSubAggregations returns a boolean if a field has been set.
func (o *SearchBucket) HasLibreGraphSubAggregations() bool {
if o != nil && !IsNil(o.LibreGraphSubAggregations) {
return true
}
return false
}
// SetLibreGraphSubAggregations gets a reference to the given []SearchAggregation and assigns it to the LibreGraphSubAggregations field.
func (o *SearchBucket) SetLibreGraphSubAggregations(v []SearchAggregation) {
o.LibreGraphSubAggregations = v
}
func (o SearchBucket) MarshalJSON() ([]byte, error) {
toSerialize,err := o.ToMap()
if err != nil {
return []byte{}, err
}
return json.Marshal(toSerialize)
}
func (o SearchBucket) ToMap() (map[string]interface{}, error) {
toSerialize := map[string]interface{}{}
if !IsNil(o.Key) {
toSerialize["key"] = o.Key
}
if !IsNil(o.Count) {
toSerialize["count"] = o.Count
}
if !IsNil(o.AggregationFilterToken) {
toSerialize["aggregationFilterToken"] = o.AggregationFilterToken
}
if !IsNil(o.LibreGraphSubAggregations) {
toSerialize["@libre.graph.subAggregations"] = o.LibreGraphSubAggregations
}
return toSerialize, nil
}
type NullableSearchBucket struct {
value *SearchBucket
isSet bool
}
func (v NullableSearchBucket) Get() *SearchBucket {
return v.value
}
func (v *NullableSearchBucket) Set(val *SearchBucket) {
v.value = val
v.isSet = true
}
func (v NullableSearchBucket) IsSet() bool {
return v.isSet
}
func (v *NullableSearchBucket) Unset() {
v.value = nil
v.isSet = false
}
func NewNullableSearchBucket(val *SearchBucket) *NullableSearchBucket {
return &NullableSearchBucket{value: val, isSet: true}
}
func (v NullableSearchBucket) MarshalJSON() ([]byte, error) {
return json.Marshal(v.value)
}
func (v *NullableSearchBucket) UnmarshalJSON(src []byte) error {
v.isSet = true
return json.Unmarshal(src, &v.value)
}
+237
View File
@@ -0,0 +1,237 @@
/*
Libre Graph API
Libre Graph is a free API for cloud collaboration inspired by the MS Graph API.
API version: v1.0.8
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
package libregraph
import (
"encoding/json"
)
// checks if the SearchHit type satisfies the MappedNullable interface at compile time
var _ MappedNullable = &SearchHit{}
// SearchHit Represents an individual search result. Follows the [MS Graph searchHit](https://learn.microsoft.com/en-us/graph/api/resources/searchhit) resource type.
type SearchHit struct {
// The internal identifier for the item.
HitId *string `json:"hitId,omitempty"`
// The rank or the order of the result.
Rank *int32 `json:"rank,omitempty"`
// A summary of the result, if a summary is available.
Summary *string `json:"summary,omitempty"`
Resource *DriveItem `json:"resource,omitempty"`
}
// NewSearchHit instantiates a new SearchHit object
// This constructor will assign default values to properties that have it defined,
// and makes sure properties required by API are set, but the set of arguments
// will change when the set of required properties is changed
func NewSearchHit() *SearchHit {
this := SearchHit{}
return &this
}
// NewSearchHitWithDefaults instantiates a new SearchHit object
// This constructor will only assign default values to properties that have it defined,
// but it doesn't guarantee that properties required by API are set
func NewSearchHitWithDefaults() *SearchHit {
this := SearchHit{}
return &this
}
// GetHitId returns the HitId field value if set, zero value otherwise.
func (o *SearchHit) GetHitId() string {
if o == nil || IsNil(o.HitId) {
var ret string
return ret
}
return *o.HitId
}
// GetHitIdOk returns a tuple with the HitId field value if set, nil otherwise
// and a boolean to check if the value has been set.
func (o *SearchHit) GetHitIdOk() (*string, bool) {
if o == nil || IsNil(o.HitId) {
return nil, false
}
return o.HitId, true
}
// HasHitId returns a boolean if a field has been set.
func (o *SearchHit) HasHitId() bool {
if o != nil && !IsNil(o.HitId) {
return true
}
return false
}
// SetHitId gets a reference to the given string and assigns it to the HitId field.
func (o *SearchHit) SetHitId(v string) {
o.HitId = &v
}
// GetRank returns the Rank field value if set, zero value otherwise.
func (o *SearchHit) GetRank() int32 {
if o == nil || IsNil(o.Rank) {
var ret int32
return ret
}
return *o.Rank
}
// GetRankOk returns a tuple with the Rank field value if set, nil otherwise
// and a boolean to check if the value has been set.
func (o *SearchHit) GetRankOk() (*int32, bool) {
if o == nil || IsNil(o.Rank) {
return nil, false
}
return o.Rank, true
}
// HasRank returns a boolean if a field has been set.
func (o *SearchHit) HasRank() bool {
if o != nil && !IsNil(o.Rank) {
return true
}
return false
}
// SetRank gets a reference to the given int32 and assigns it to the Rank field.
func (o *SearchHit) SetRank(v int32) {
o.Rank = &v
}
// GetSummary returns the Summary field value if set, zero value otherwise.
func (o *SearchHit) GetSummary() string {
if o == nil || IsNil(o.Summary) {
var ret string
return ret
}
return *o.Summary
}
// GetSummaryOk returns a tuple with the Summary field value if set, nil otherwise
// and a boolean to check if the value has been set.
func (o *SearchHit) GetSummaryOk() (*string, bool) {
if o == nil || IsNil(o.Summary) {
return nil, false
}
return o.Summary, true
}
// HasSummary returns a boolean if a field has been set.
func (o *SearchHit) HasSummary() bool {
if o != nil && !IsNil(o.Summary) {
return true
}
return false
}
// SetSummary gets a reference to the given string and assigns it to the Summary field.
func (o *SearchHit) SetSummary(v string) {
o.Summary = &v
}
// GetResource returns the Resource field value if set, zero value otherwise.
func (o *SearchHit) GetResource() DriveItem {
if o == nil || IsNil(o.Resource) {
var ret DriveItem
return ret
}
return *o.Resource
}
// GetResourceOk returns a tuple with the Resource field value if set, nil otherwise
// and a boolean to check if the value has been set.
func (o *SearchHit) GetResourceOk() (*DriveItem, bool) {
if o == nil || IsNil(o.Resource) {
return nil, false
}
return o.Resource, true
}
// HasResource returns a boolean if a field has been set.
func (o *SearchHit) HasResource() bool {
if o != nil && !IsNil(o.Resource) {
return true
}
return false
}
// SetResource gets a reference to the given DriveItem and assigns it to the Resource field.
func (o *SearchHit) SetResource(v DriveItem) {
o.Resource = &v
}
func (o SearchHit) MarshalJSON() ([]byte, error) {
toSerialize,err := o.ToMap()
if err != nil {
return []byte{}, err
}
return json.Marshal(toSerialize)
}
func (o SearchHit) ToMap() (map[string]interface{}, error) {
toSerialize := map[string]interface{}{}
if !IsNil(o.HitId) {
toSerialize["hitId"] = o.HitId
}
if !IsNil(o.Rank) {
toSerialize["rank"] = o.Rank
}
if !IsNil(o.Summary) {
toSerialize["summary"] = o.Summary
}
if !IsNil(o.Resource) {
toSerialize["resource"] = o.Resource
}
return toSerialize, nil
}
type NullableSearchHit struct {
value *SearchHit
isSet bool
}
func (v NullableSearchHit) Get() *SearchHit {
return v.value
}
func (v *NullableSearchHit) Set(val *SearchHit) {
v.value = val
v.isSet = true
}
func (v NullableSearchHit) IsSet() bool {
return v.isSet
}
func (v *NullableSearchHit) Unset() {
v.value = nil
v.isSet = false
}
func NewNullableSearchHit(val *SearchHit) *NullableSearchHit {
return &NullableSearchHit{value: val, isSet: true}
}
func (v NullableSearchHit) MarshalJSON() ([]byte, error) {
return json.Marshal(v.value)
}
func (v *NullableSearchHit) UnmarshalJSON(src []byte) error {
v.isSet = true
return json.Unmarshal(src, &v.value)
}
@@ -0,0 +1,238 @@
/*
Libre Graph API
Libre Graph is a free API for cloud collaboration inspired by the MS Graph API.
API version: v1.0.8
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
package libregraph
import (
"encoding/json"
)
// checks if the SearchHitsContainer type satisfies the MappedNullable interface at compile time
var _ MappedNullable = &SearchHitsContainer{}
// SearchHitsContainer Contains a collection of search results. Follows the [MS Graph searchHitsContainer](https://learn.microsoft.com/en-us/graph/api/resources/searchhitscontainer) resource type.
type SearchHitsContainer struct {
// A collection of the search results.
Hits []SearchHit `json:"hits,omitempty"`
// The total number of results. Note this is not the number of results on the page, but the total number of results satisfying the query.
Total *int64 `json:"total,omitempty"`
// Provides information if more results are available. Based on this information, you can adjust the `from` and `size` properties of the `searchRequest` accordingly.
MoreResultsAvailable *bool `json:"moreResultsAvailable,omitempty"`
// Contains the collection of aggregations computed based on the provided `aggregationOption` definitions in the request.
Aggregations []SearchAggregation `json:"aggregations,omitempty"`
}
// NewSearchHitsContainer instantiates a new SearchHitsContainer object
// This constructor will assign default values to properties that have it defined,
// and makes sure properties required by API are set, but the set of arguments
// will change when the set of required properties is changed
func NewSearchHitsContainer() *SearchHitsContainer {
this := SearchHitsContainer{}
return &this
}
// NewSearchHitsContainerWithDefaults instantiates a new SearchHitsContainer object
// This constructor will only assign default values to properties that have it defined,
// but it doesn't guarantee that properties required by API are set
func NewSearchHitsContainerWithDefaults() *SearchHitsContainer {
this := SearchHitsContainer{}
return &this
}
// GetHits returns the Hits field value if set, zero value otherwise.
func (o *SearchHitsContainer) GetHits() []SearchHit {
if o == nil || IsNil(o.Hits) {
var ret []SearchHit
return ret
}
return o.Hits
}
// GetHitsOk returns a tuple with the Hits field value if set, nil otherwise
// and a boolean to check if the value has been set.
func (o *SearchHitsContainer) GetHitsOk() ([]SearchHit, bool) {
if o == nil || IsNil(o.Hits) {
return nil, false
}
return o.Hits, true
}
// HasHits returns a boolean if a field has been set.
func (o *SearchHitsContainer) HasHits() bool {
if o != nil && !IsNil(o.Hits) {
return true
}
return false
}
// SetHits gets a reference to the given []SearchHit and assigns it to the Hits field.
func (o *SearchHitsContainer) SetHits(v []SearchHit) {
o.Hits = v
}
// GetTotal returns the Total field value if set, zero value otherwise.
func (o *SearchHitsContainer) GetTotal() int64 {
if o == nil || IsNil(o.Total) {
var ret int64
return ret
}
return *o.Total
}
// GetTotalOk returns a tuple with the Total field value if set, nil otherwise
// and a boolean to check if the value has been set.
func (o *SearchHitsContainer) GetTotalOk() (*int64, bool) {
if o == nil || IsNil(o.Total) {
return nil, false
}
return o.Total, true
}
// HasTotal returns a boolean if a field has been set.
func (o *SearchHitsContainer) HasTotal() bool {
if o != nil && !IsNil(o.Total) {
return true
}
return false
}
// SetTotal gets a reference to the given int64 and assigns it to the Total field.
func (o *SearchHitsContainer) SetTotal(v int64) {
o.Total = &v
}
// GetMoreResultsAvailable returns the MoreResultsAvailable field value if set, zero value otherwise.
func (o *SearchHitsContainer) GetMoreResultsAvailable() bool {
if o == nil || IsNil(o.MoreResultsAvailable) {
var ret bool
return ret
}
return *o.MoreResultsAvailable
}
// GetMoreResultsAvailableOk returns a tuple with the MoreResultsAvailable field value if set, nil otherwise
// and a boolean to check if the value has been set.
func (o *SearchHitsContainer) GetMoreResultsAvailableOk() (*bool, bool) {
if o == nil || IsNil(o.MoreResultsAvailable) {
return nil, false
}
return o.MoreResultsAvailable, true
}
// HasMoreResultsAvailable returns a boolean if a field has been set.
func (o *SearchHitsContainer) HasMoreResultsAvailable() bool {
if o != nil && !IsNil(o.MoreResultsAvailable) {
return true
}
return false
}
// SetMoreResultsAvailable gets a reference to the given bool and assigns it to the MoreResultsAvailable field.
func (o *SearchHitsContainer) SetMoreResultsAvailable(v bool) {
o.MoreResultsAvailable = &v
}
// GetAggregations returns the Aggregations field value if set, zero value otherwise.
func (o *SearchHitsContainer) GetAggregations() []SearchAggregation {
if o == nil || IsNil(o.Aggregations) {
var ret []SearchAggregation
return ret
}
return o.Aggregations
}
// GetAggregationsOk returns a tuple with the Aggregations field value if set, nil otherwise
// and a boolean to check if the value has been set.
func (o *SearchHitsContainer) GetAggregationsOk() ([]SearchAggregation, bool) {
if o == nil || IsNil(o.Aggregations) {
return nil, false
}
return o.Aggregations, true
}
// HasAggregations returns a boolean if a field has been set.
func (o *SearchHitsContainer) HasAggregations() bool {
if o != nil && !IsNil(o.Aggregations) {
return true
}
return false
}
// SetAggregations gets a reference to the given []SearchAggregation and assigns it to the Aggregations field.
func (o *SearchHitsContainer) SetAggregations(v []SearchAggregation) {
o.Aggregations = v
}
func (o SearchHitsContainer) MarshalJSON() ([]byte, error) {
toSerialize,err := o.ToMap()
if err != nil {
return []byte{}, err
}
return json.Marshal(toSerialize)
}
func (o SearchHitsContainer) ToMap() (map[string]interface{}, error) {
toSerialize := map[string]interface{}{}
if !IsNil(o.Hits) {
toSerialize["hits"] = o.Hits
}
if !IsNil(o.Total) {
toSerialize["total"] = o.Total
}
if !IsNil(o.MoreResultsAvailable) {
toSerialize["moreResultsAvailable"] = o.MoreResultsAvailable
}
if !IsNil(o.Aggregations) {
toSerialize["aggregations"] = o.Aggregations
}
return toSerialize, nil
}
type NullableSearchHitsContainer struct {
value *SearchHitsContainer
isSet bool
}
func (v NullableSearchHitsContainer) Get() *SearchHitsContainer {
return v.value
}
func (v *NullableSearchHitsContainer) Set(val *SearchHitsContainer) {
v.value = val
v.isSet = true
}
func (v NullableSearchHitsContainer) IsSet() bool {
return v.isSet
}
func (v *NullableSearchHitsContainer) Unset() {
v.value = nil
v.isSet = false
}
func NewNullableSearchHitsContainer(val *SearchHitsContainer) *NullableSearchHitsContainer {
return &NullableSearchHitsContainer{value: val, isSet: true}
}
func (v NullableSearchHitsContainer) MarshalJSON() ([]byte, error) {
return json.Marshal(v.value)
}
func (v *NullableSearchHitsContainer) UnmarshalJSON(src []byte) error {
v.isSet = true
return json.Unmarshal(src, &v.value)
}
@@ -0,0 +1,164 @@
/*
Libre Graph API
Libre Graph is a free API for cloud collaboration inspired by the MS Graph API.
API version: v1.0.8
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
package libregraph
import (
"encoding/json"
)
// checks if the SearchMetric type satisfies the MappedNullable interface at compile time
var _ MappedNullable = &SearchMetric{}
// SearchMetric The result of a metric aggregation, the counterpart of `buckets` for aggregations requested with a `@libre.graph.metricDefinition`. Absent for terms and range aggregations. Libregraph extension not present in MS Graph.
type SearchMetric struct {
// Echoes the `kind` of the corresponding `metricDefinition`, allowing consumers (and the search service's cross-space merge layer) to pick the right reducer when combining results.
Kind *string `json:"kind,omitempty"`
// The scalar result of the metric.
Value *float64 `json:"value,omitempty"`
}
// NewSearchMetric instantiates a new SearchMetric object
// This constructor will assign default values to properties that have it defined,
// and makes sure properties required by API are set, but the set of arguments
// will change when the set of required properties is changed
func NewSearchMetric() *SearchMetric {
this := SearchMetric{}
return &this
}
// NewSearchMetricWithDefaults instantiates a new SearchMetric object
// This constructor will only assign default values to properties that have it defined,
// but it doesn't guarantee that properties required by API are set
func NewSearchMetricWithDefaults() *SearchMetric {
this := SearchMetric{}
return &this
}
// GetKind returns the Kind field value if set, zero value otherwise.
func (o *SearchMetric) GetKind() string {
if o == nil || IsNil(o.Kind) {
var ret string
return ret
}
return *o.Kind
}
// GetKindOk returns a tuple with the Kind field value if set, nil otherwise
// and a boolean to check if the value has been set.
func (o *SearchMetric) GetKindOk() (*string, bool) {
if o == nil || IsNil(o.Kind) {
return nil, false
}
return o.Kind, true
}
// HasKind returns a boolean if a field has been set.
func (o *SearchMetric) HasKind() bool {
if o != nil && !IsNil(o.Kind) {
return true
}
return false
}
// SetKind gets a reference to the given string and assigns it to the Kind field.
func (o *SearchMetric) SetKind(v string) {
o.Kind = &v
}
// GetValue returns the Value field value if set, zero value otherwise.
func (o *SearchMetric) GetValue() float64 {
if o == nil || IsNil(o.Value) {
var ret float64
return ret
}
return *o.Value
}
// GetValueOk returns a tuple with the Value field value if set, nil otherwise
// and a boolean to check if the value has been set.
func (o *SearchMetric) GetValueOk() (*float64, bool) {
if o == nil || IsNil(o.Value) {
return nil, false
}
return o.Value, true
}
// HasValue returns a boolean if a field has been set.
func (o *SearchMetric) HasValue() bool {
if o != nil && !IsNil(o.Value) {
return true
}
return false
}
// SetValue gets a reference to the given float64 and assigns it to the Value field.
func (o *SearchMetric) SetValue(v float64) {
o.Value = &v
}
func (o SearchMetric) MarshalJSON() ([]byte, error) {
toSerialize,err := o.ToMap()
if err != nil {
return []byte{}, err
}
return json.Marshal(toSerialize)
}
func (o SearchMetric) ToMap() (map[string]interface{}, error) {
toSerialize := map[string]interface{}{}
if !IsNil(o.Kind) {
toSerialize["kind"] = o.Kind
}
if !IsNil(o.Value) {
toSerialize["value"] = o.Value
}
return toSerialize, nil
}
type NullableSearchMetric struct {
value *SearchMetric
isSet bool
}
func (v NullableSearchMetric) Get() *SearchMetric {
return v.value
}
func (v *NullableSearchMetric) Set(val *SearchMetric) {
v.value = val
v.isSet = true
}
func (v NullableSearchMetric) IsSet() bool {
return v.isSet
}
func (v *NullableSearchMetric) Unset() {
v.value = nil
v.isSet = false
}
func NewNullableSearchMetric(val *SearchMetric) *NullableSearchMetric {
return &NullableSearchMetric{value: val, isSet: true}
}
func (v NullableSearchMetric) MarshalJSON() ([]byte, error) {
return json.Marshal(v.value)
}
func (v *NullableSearchMetric) UnmarshalJSON(src []byte) error {
v.isSet = true
return json.Unmarshal(src, &v.value)
}
+159
View File
@@ -0,0 +1,159 @@
/*
Libre Graph API
Libre Graph is a free API for cloud collaboration inspired by the MS Graph API.
API version: v1.0.8
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
package libregraph
import (
"encoding/json"
"bytes"
"fmt"
)
// checks if the SearchQuery type satisfies the MappedNullable interface at compile time
var _ MappedNullable = &SearchQuery{}
// SearchQuery Represents the search query. Follows the [MS Graph searchQuery](https://learn.microsoft.com/en-us/graph/api/resources/searchquery) resource type.
type SearchQuery struct {
// The search query string in KQL (Keyword Query Language) format. The query string can contain free-text keywords and property filters. Examples: - `budget report`: free text search - `mediatype:audio`: filter by media type - `audio.artist:\"Pink Floyd\"`: filter by audio metadata - `audio.genre:Rock AND audio.year:1979`: combined filters
QueryString string `json:"queryString"`
}
type _SearchQuery SearchQuery
// NewSearchQuery instantiates a new SearchQuery object
// This constructor will assign default values to properties that have it defined,
// and makes sure properties required by API are set, but the set of arguments
// will change when the set of required properties is changed
func NewSearchQuery(queryString string) *SearchQuery {
this := SearchQuery{}
this.QueryString = queryString
return &this
}
// NewSearchQueryWithDefaults instantiates a new SearchQuery object
// This constructor will only assign default values to properties that have it defined,
// but it doesn't guarantee that properties required by API are set
func NewSearchQueryWithDefaults() *SearchQuery {
this := SearchQuery{}
return &this
}
// GetQueryString returns the QueryString field value
func (o *SearchQuery) GetQueryString() string {
if o == nil {
var ret string
return ret
}
return o.QueryString
}
// GetQueryStringOk returns a tuple with the QueryString field value
// and a boolean to check if the value has been set.
func (o *SearchQuery) GetQueryStringOk() (*string, bool) {
if o == nil {
return nil, false
}
return &o.QueryString, true
}
// SetQueryString sets field value
func (o *SearchQuery) SetQueryString(v string) {
o.QueryString = v
}
func (o SearchQuery) MarshalJSON() ([]byte, error) {
toSerialize,err := o.ToMap()
if err != nil {
return []byte{}, err
}
return json.Marshal(toSerialize)
}
func (o SearchQuery) ToMap() (map[string]interface{}, error) {
toSerialize := map[string]interface{}{}
toSerialize["queryString"] = o.QueryString
return toSerialize, nil
}
func (o *SearchQuery) UnmarshalJSON(data []byte) (err error) {
// This validates that all required properties are included in the JSON object
// by unmarshalling the object into a generic map with string keys and checking
// that every required field exists as a key in the generic map.
requiredProperties := []string{
"queryString",
}
allProperties := make(map[string]interface{})
err = json.Unmarshal(data, &allProperties)
if err != nil {
return err;
}
for _, requiredProperty := range(requiredProperties) {
if _, exists := allProperties[requiredProperty]; !exists {
return fmt.Errorf("no value given for required property %v", requiredProperty)
}
}
varSearchQuery := _SearchQuery{}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
err = decoder.Decode(&varSearchQuery)
if err != nil {
return err
}
*o = SearchQuery(varSearchQuery)
return err
}
type NullableSearchQuery struct {
value *SearchQuery
isSet bool
}
func (v NullableSearchQuery) Get() *SearchQuery {
return v.value
}
func (v *NullableSearchQuery) Set(val *SearchQuery) {
v.value = val
v.isSet = true
}
func (v NullableSearchQuery) IsSet() bool {
return v.isSet
}
func (v *NullableSearchQuery) Unset() {
v.value = nil
v.isSet = false
}
func NewNullableSearchQuery(val *SearchQuery) *NullableSearchQuery {
return &NullableSearchQuery{value: val, isSet: true}
}
func (v NullableSearchQuery) MarshalJSON() ([]byte, error) {
return json.Marshal(v.value)
}
func (v *NullableSearchQuery) UnmarshalJSON(src []byte) error {
v.isSet = true
return json.Unmarshal(src, &v.value)
}
@@ -0,0 +1,127 @@
/*
Libre Graph API
Libre Graph is a free API for cloud collaboration inspired by the MS Graph API.
API version: v1.0.8
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
package libregraph
import (
"encoding/json"
)
// checks if the SearchQuery200Response type satisfies the MappedNullable interface at compile time
var _ MappedNullable = &SearchQuery200Response{}
// SearchQuery200Response struct for SearchQuery200Response
type SearchQuery200Response struct {
// A collection of search response objects, one per request.
Value []SearchResponse `json:"value,omitempty"`
}
// NewSearchQuery200Response instantiates a new SearchQuery200Response object
// This constructor will assign default values to properties that have it defined,
// and makes sure properties required by API are set, but the set of arguments
// will change when the set of required properties is changed
func NewSearchQuery200Response() *SearchQuery200Response {
this := SearchQuery200Response{}
return &this
}
// NewSearchQuery200ResponseWithDefaults instantiates a new SearchQuery200Response object
// This constructor will only assign default values to properties that have it defined,
// but it doesn't guarantee that properties required by API are set
func NewSearchQuery200ResponseWithDefaults() *SearchQuery200Response {
this := SearchQuery200Response{}
return &this
}
// GetValue returns the Value field value if set, zero value otherwise.
func (o *SearchQuery200Response) GetValue() []SearchResponse {
if o == nil || IsNil(o.Value) {
var ret []SearchResponse
return ret
}
return o.Value
}
// GetValueOk returns a tuple with the Value field value if set, nil otherwise
// and a boolean to check if the value has been set.
func (o *SearchQuery200Response) GetValueOk() ([]SearchResponse, bool) {
if o == nil || IsNil(o.Value) {
return nil, false
}
return o.Value, true
}
// HasValue returns a boolean if a field has been set.
func (o *SearchQuery200Response) HasValue() bool {
if o != nil && !IsNil(o.Value) {
return true
}
return false
}
// SetValue gets a reference to the given []SearchResponse and assigns it to the Value field.
func (o *SearchQuery200Response) SetValue(v []SearchResponse) {
o.Value = v
}
func (o SearchQuery200Response) MarshalJSON() ([]byte, error) {
toSerialize,err := o.ToMap()
if err != nil {
return []byte{}, err
}
return json.Marshal(toSerialize)
}
func (o SearchQuery200Response) ToMap() (map[string]interface{}, error) {
toSerialize := map[string]interface{}{}
if !IsNil(o.Value) {
toSerialize["value"] = o.Value
}
return toSerialize, nil
}
type NullableSearchQuery200Response struct {
value *SearchQuery200Response
isSet bool
}
func (v NullableSearchQuery200Response) Get() *SearchQuery200Response {
return v.value
}
func (v *NullableSearchQuery200Response) Set(val *SearchQuery200Response) {
v.value = val
v.isSet = true
}
func (v NullableSearchQuery200Response) IsSet() bool {
return v.isSet
}
func (v *NullableSearchQuery200Response) Unset() {
v.value = nil
v.isSet = false
}
func NewNullableSearchQuery200Response(val *SearchQuery200Response) *NullableSearchQuery200Response {
return &NullableSearchQuery200Response{value: val, isSet: true}
}
func (v NullableSearchQuery200Response) MarshalJSON() ([]byte, error) {
return json.Marshal(v.value)
}
func (v *NullableSearchQuery200Response) UnmarshalJSON(src []byte) error {
v.isSet = true
return json.Unmarshal(src, &v.value)
}
@@ -0,0 +1,159 @@
/*
Libre Graph API
Libre Graph is a free API for cloud collaboration inspired by the MS Graph API.
API version: v1.0.8
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
package libregraph
import (
"encoding/json"
"bytes"
"fmt"
)
// checks if the SearchQueryRequest type satisfies the MappedNullable interface at compile time
var _ MappedNullable = &SearchQueryRequest{}
// SearchQueryRequest struct for SearchQueryRequest
type SearchQueryRequest struct {
// A collection of one or more search requests.
Requests []SearchRequest `json:"requests"`
}
type _SearchQueryRequest SearchQueryRequest
// NewSearchQueryRequest instantiates a new SearchQueryRequest object
// This constructor will assign default values to properties that have it defined,
// and makes sure properties required by API are set, but the set of arguments
// will change when the set of required properties is changed
func NewSearchQueryRequest(requests []SearchRequest) *SearchQueryRequest {
this := SearchQueryRequest{}
this.Requests = requests
return &this
}
// NewSearchQueryRequestWithDefaults instantiates a new SearchQueryRequest object
// This constructor will only assign default values to properties that have it defined,
// but it doesn't guarantee that properties required by API are set
func NewSearchQueryRequestWithDefaults() *SearchQueryRequest {
this := SearchQueryRequest{}
return &this
}
// GetRequests returns the Requests field value
func (o *SearchQueryRequest) GetRequests() []SearchRequest {
if o == nil {
var ret []SearchRequest
return ret
}
return o.Requests
}
// GetRequestsOk returns a tuple with the Requests field value
// and a boolean to check if the value has been set.
func (o *SearchQueryRequest) GetRequestsOk() ([]SearchRequest, bool) {
if o == nil {
return nil, false
}
return o.Requests, true
}
// SetRequests sets field value
func (o *SearchQueryRequest) SetRequests(v []SearchRequest) {
o.Requests = v
}
func (o SearchQueryRequest) MarshalJSON() ([]byte, error) {
toSerialize,err := o.ToMap()
if err != nil {
return []byte{}, err
}
return json.Marshal(toSerialize)
}
func (o SearchQueryRequest) ToMap() (map[string]interface{}, error) {
toSerialize := map[string]interface{}{}
toSerialize["requests"] = o.Requests
return toSerialize, nil
}
func (o *SearchQueryRequest) UnmarshalJSON(data []byte) (err error) {
// This validates that all required properties are included in the JSON object
// by unmarshalling the object into a generic map with string keys and checking
// that every required field exists as a key in the generic map.
requiredProperties := []string{
"requests",
}
allProperties := make(map[string]interface{})
err = json.Unmarshal(data, &allProperties)
if err != nil {
return err;
}
for _, requiredProperty := range(requiredProperties) {
if _, exists := allProperties[requiredProperty]; !exists {
return fmt.Errorf("no value given for required property %v", requiredProperty)
}
}
varSearchQueryRequest := _SearchQueryRequest{}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
err = decoder.Decode(&varSearchQueryRequest)
if err != nil {
return err
}
*o = SearchQueryRequest(varSearchQueryRequest)
return err
}
type NullableSearchQueryRequest struct {
value *SearchQueryRequest
isSet bool
}
func (v NullableSearchQueryRequest) Get() *SearchQueryRequest {
return v.value
}
func (v *NullableSearchQueryRequest) Set(val *SearchQueryRequest) {
v.value = val
v.isSet = true
}
func (v NullableSearchQueryRequest) IsSet() bool {
return v.isSet
}
func (v *NullableSearchQueryRequest) Unset() {
v.value = nil
v.isSet = false
}
func NewNullableSearchQueryRequest(val *SearchQueryRequest) *NullableSearchQueryRequest {
return &NullableSearchQueryRequest{value: val, isSet: true}
}
func (v NullableSearchQueryRequest) MarshalJSON() ([]byte, error) {
return json.Marshal(v.value)
}
func (v *NullableSearchQueryRequest) UnmarshalJSON(src []byte) error {
v.isSet = true
return json.Unmarshal(src, &v.value)
}
@@ -0,0 +1,345 @@
/*
Libre Graph API
Libre Graph is a free API for cloud collaboration inspired by the MS Graph API.
API version: v1.0.8
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
package libregraph
import (
"encoding/json"
"bytes"
"fmt"
)
// checks if the SearchRequest type satisfies the MappedNullable interface at compile time
var _ MappedNullable = &SearchRequest{}
// SearchRequest Represents an individual search request within a search query. Follows the [MS Graph searchRequest](https://learn.microsoft.com/en-us/graph/api/resources/searchrequest) resource type.
type SearchRequest struct {
// One or more types of resources expected in the response. Currently only `driveItem` is supported.
EntityTypes []string `json:"entityTypes"`
Query SearchQuery `json:"query"`
// Specifies the offset for the search results. Offset 0 returns the very first result. Used together with the `size` property for pagination.
From *int32 `json:"from,omitempty"`
// The size of the page to be retrieved. The maximum value is 500. Set to 0 to return only aggregations without any hits.
Size *int32 `json:"size,omitempty"`
// Specifies aggregations (also known as refiners or facets) to be returned alongside the search results. Optional.
Aggregations []AggregationOption `json:"aggregations,omitempty"`
// Contains one or more filters to narrow search results to specific buckets of a prior aggregation. Build each filter from the response of a prior search that aggregated on the same field: take the `aggregationFilterToken` of the wanted `searchBucket` and combine it with the field as `{field}:{aggregationFilterToken}`, e.g. `audio.artist:\"ǂǂ50696e6b20466c6f7964\"` for a terms bucket or `audio.year:range(1980, 1990)` for a range bucket. Several buckets of the same field are combined with `{field}:or({aggregationFilterToken},{aggregationFilterToken})`. Whitespace after the commas of `range(...)` and `or(...)` is optional. Multiple filters can be provided as separate array items. This results in a logical AND between the filters. Filters that are not built from server-issued tokens are rejected with `invalidRequest`.
AggregationFilters []string `json:"aggregationFilters,omitempty"`
// Contains the ordered collection of fields to sort the results on. If absent, the results are sorted by relevance. See `SortProperty.Name` for the set of sortable fields. Optional.
SortProperties []SortProperty `json:"sortProperties,omitempty"`
}
type _SearchRequest SearchRequest
// NewSearchRequest instantiates a new SearchRequest object
// This constructor will assign default values to properties that have it defined,
// and makes sure properties required by API are set, but the set of arguments
// will change when the set of required properties is changed
func NewSearchRequest(entityTypes []string, query SearchQuery) *SearchRequest {
this := SearchRequest{}
this.EntityTypes = entityTypes
this.Query = query
var from int32 = 0
this.From = &from
var size int32 = 25
this.Size = &size
return &this
}
// NewSearchRequestWithDefaults instantiates a new SearchRequest object
// This constructor will only assign default values to properties that have it defined,
// but it doesn't guarantee that properties required by API are set
func NewSearchRequestWithDefaults() *SearchRequest {
this := SearchRequest{}
var from int32 = 0
this.From = &from
var size int32 = 25
this.Size = &size
return &this
}
// GetEntityTypes returns the EntityTypes field value
func (o *SearchRequest) GetEntityTypes() []string {
if o == nil {
var ret []string
return ret
}
return o.EntityTypes
}
// GetEntityTypesOk returns a tuple with the EntityTypes field value
// and a boolean to check if the value has been set.
func (o *SearchRequest) GetEntityTypesOk() ([]string, bool) {
if o == nil {
return nil, false
}
return o.EntityTypes, true
}
// SetEntityTypes sets field value
func (o *SearchRequest) SetEntityTypes(v []string) {
o.EntityTypes = v
}
// GetQuery returns the Query field value
func (o *SearchRequest) GetQuery() SearchQuery {
if o == nil {
var ret SearchQuery
return ret
}
return o.Query
}
// GetQueryOk returns a tuple with the Query field value
// and a boolean to check if the value has been set.
func (o *SearchRequest) GetQueryOk() (*SearchQuery, bool) {
if o == nil {
return nil, false
}
return &o.Query, true
}
// SetQuery sets field value
func (o *SearchRequest) SetQuery(v SearchQuery) {
o.Query = v
}
// GetFrom returns the From field value if set, zero value otherwise.
func (o *SearchRequest) GetFrom() int32 {
if o == nil || IsNil(o.From) {
var ret int32
return ret
}
return *o.From
}
// GetFromOk returns a tuple with the From field value if set, nil otherwise
// and a boolean to check if the value has been set.
func (o *SearchRequest) GetFromOk() (*int32, bool) {
if o == nil || IsNil(o.From) {
return nil, false
}
return o.From, true
}
// HasFrom returns a boolean if a field has been set.
func (o *SearchRequest) HasFrom() bool {
if o != nil && !IsNil(o.From) {
return true
}
return false
}
// SetFrom gets a reference to the given int32 and assigns it to the From field.
func (o *SearchRequest) SetFrom(v int32) {
o.From = &v
}
// GetSize returns the Size field value if set, zero value otherwise.
func (o *SearchRequest) GetSize() int32 {
if o == nil || IsNil(o.Size) {
var ret int32
return ret
}
return *o.Size
}
// GetSizeOk returns a tuple with the Size field value if set, nil otherwise
// and a boolean to check if the value has been set.
func (o *SearchRequest) GetSizeOk() (*int32, bool) {
if o == nil || IsNil(o.Size) {
return nil, false
}
return o.Size, true
}
// HasSize returns a boolean if a field has been set.
func (o *SearchRequest) HasSize() bool {
if o != nil && !IsNil(o.Size) {
return true
}
return false
}
// SetSize gets a reference to the given int32 and assigns it to the Size field.
func (o *SearchRequest) SetSize(v int32) {
o.Size = &v
}
// GetAggregations returns the Aggregations field value if set, zero value otherwise.
func (o *SearchRequest) GetAggregations() []AggregationOption {
if o == nil || IsNil(o.Aggregations) {
var ret []AggregationOption
return ret
}
return o.Aggregations
}
// GetAggregationsOk returns a tuple with the Aggregations field value if set, nil otherwise
// and a boolean to check if the value has been set.
func (o *SearchRequest) GetAggregationsOk() ([]AggregationOption, bool) {
if o == nil || IsNil(o.Aggregations) {
return nil, false
}
return o.Aggregations, true
}
// HasAggregations returns a boolean if a field has been set.
func (o *SearchRequest) HasAggregations() bool {
if o != nil && !IsNil(o.Aggregations) {
return true
}
return false
}
// SetAggregations gets a reference to the given []AggregationOption and assigns it to the Aggregations field.
func (o *SearchRequest) SetAggregations(v []AggregationOption) {
o.Aggregations = v
}
// GetAggregationFilters returns the AggregationFilters field value if set, zero value otherwise.
func (o *SearchRequest) GetAggregationFilters() []string {
if o == nil || IsNil(o.AggregationFilters) {
var ret []string
return ret
}
return o.AggregationFilters
}
// GetAggregationFiltersOk returns a tuple with the AggregationFilters field value if set, nil otherwise
// and a boolean to check if the value has been set.
func (o *SearchRequest) GetAggregationFiltersOk() ([]string, bool) {
if o == nil || IsNil(o.AggregationFilters) {
return nil, false
}
return o.AggregationFilters, true
}
// HasAggregationFilters returns a boolean if a field has been set.
func (o *SearchRequest) HasAggregationFilters() bool {
if o != nil && !IsNil(o.AggregationFilters) {
return true
}
return false
}
// SetAggregationFilters gets a reference to the given []string and assigns it to the AggregationFilters field.
func (o *SearchRequest) SetAggregationFilters(v []string) {
o.AggregationFilters = v
}
func (o SearchRequest) MarshalJSON() ([]byte, error) {
toSerialize,err := o.ToMap()
if err != nil {
return []byte{}, err
}
return json.Marshal(toSerialize)
}
func (o SearchRequest) ToMap() (map[string]interface{}, error) {
toSerialize := map[string]interface{}{}
toSerialize["entityTypes"] = o.EntityTypes
toSerialize["query"] = o.Query
if !IsNil(o.From) {
toSerialize["from"] = o.From
}
if !IsNil(o.Size) {
toSerialize["size"] = o.Size
}
if !IsNil(o.Aggregations) {
toSerialize["aggregations"] = o.Aggregations
}
if !IsNil(o.AggregationFilters) {
toSerialize["aggregationFilters"] = o.AggregationFilters
}
return toSerialize, nil
}
func (o *SearchRequest) UnmarshalJSON(data []byte) (err error) {
// This validates that all required properties are included in the JSON object
// by unmarshalling the object into a generic map with string keys and checking
// that every required field exists as a key in the generic map.
requiredProperties := []string{
"entityTypes",
"query",
}
allProperties := make(map[string]interface{})
err = json.Unmarshal(data, &allProperties)
if err != nil {
return err;
}
for _, requiredProperty := range(requiredProperties) {
if _, exists := allProperties[requiredProperty]; !exists {
return fmt.Errorf("no value given for required property %v", requiredProperty)
}
}
varSearchRequest := _SearchRequest{}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
err = decoder.Decode(&varSearchRequest)
if err != nil {
return err
}
*o = SearchRequest(varSearchRequest)
return err
}
type NullableSearchRequest struct {
value *SearchRequest
isSet bool
}
func (v NullableSearchRequest) Get() *SearchRequest {
return v.value
}
func (v *NullableSearchRequest) Set(val *SearchRequest) {
v.value = val
v.isSet = true
}
func (v NullableSearchRequest) IsSet() bool {
return v.isSet
}
func (v *NullableSearchRequest) Unset() {
v.value = nil
v.isSet = false
}
func NewNullableSearchRequest(val *SearchRequest) *NullableSearchRequest {
return &NullableSearchRequest{value: val, isSet: true}
}
func (v NullableSearchRequest) MarshalJSON() ([]byte, error) {
return json.Marshal(v.value)
}
func (v *NullableSearchRequest) UnmarshalJSON(src []byte) error {
v.isSet = true
return json.Unmarshal(src, &v.value)
}
@@ -0,0 +1,164 @@
/*
Libre Graph API
Libre Graph is a free API for cloud collaboration inspired by the MS Graph API.
API version: v1.0.8
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
package libregraph
import (
"encoding/json"
)
// checks if the SearchResponse type satisfies the MappedNullable interface at compile time
var _ MappedNullable = &SearchResponse{}
// SearchResponse Represents the response for an individual search request. Follows the [MS Graph searchResponse](https://learn.microsoft.com/en-us/graph/api/resources/searchresponse) resource type.
type SearchResponse struct {
// Contains the search terms sent in the initial search query.
SearchTerms []string `json:"searchTerms,omitempty"`
// A collection of search result sets. One for each entity type that was queried.
HitsContainers []SearchHitsContainer `json:"hitsContainers,omitempty"`
}
// NewSearchResponse instantiates a new SearchResponse object
// This constructor will assign default values to properties that have it defined,
// and makes sure properties required by API are set, but the set of arguments
// will change when the set of required properties is changed
func NewSearchResponse() *SearchResponse {
this := SearchResponse{}
return &this
}
// NewSearchResponseWithDefaults instantiates a new SearchResponse object
// This constructor will only assign default values to properties that have it defined,
// but it doesn't guarantee that properties required by API are set
func NewSearchResponseWithDefaults() *SearchResponse {
this := SearchResponse{}
return &this
}
// GetSearchTerms returns the SearchTerms field value if set, zero value otherwise.
func (o *SearchResponse) GetSearchTerms() []string {
if o == nil || IsNil(o.SearchTerms) {
var ret []string
return ret
}
return o.SearchTerms
}
// GetSearchTermsOk returns a tuple with the SearchTerms field value if set, nil otherwise
// and a boolean to check if the value has been set.
func (o *SearchResponse) GetSearchTermsOk() ([]string, bool) {
if o == nil || IsNil(o.SearchTerms) {
return nil, false
}
return o.SearchTerms, true
}
// HasSearchTerms returns a boolean if a field has been set.
func (o *SearchResponse) HasSearchTerms() bool {
if o != nil && !IsNil(o.SearchTerms) {
return true
}
return false
}
// SetSearchTerms gets a reference to the given []string and assigns it to the SearchTerms field.
func (o *SearchResponse) SetSearchTerms(v []string) {
o.SearchTerms = v
}
// GetHitsContainers returns the HitsContainers field value if set, zero value otherwise.
func (o *SearchResponse) GetHitsContainers() []SearchHitsContainer {
if o == nil || IsNil(o.HitsContainers) {
var ret []SearchHitsContainer
return ret
}
return o.HitsContainers
}
// GetHitsContainersOk returns a tuple with the HitsContainers field value if set, nil otherwise
// and a boolean to check if the value has been set.
func (o *SearchResponse) GetHitsContainersOk() ([]SearchHitsContainer, bool) {
if o == nil || IsNil(o.HitsContainers) {
return nil, false
}
return o.HitsContainers, true
}
// HasHitsContainers returns a boolean if a field has been set.
func (o *SearchResponse) HasHitsContainers() bool {
if o != nil && !IsNil(o.HitsContainers) {
return true
}
return false
}
// SetHitsContainers gets a reference to the given []SearchHitsContainer and assigns it to the HitsContainers field.
func (o *SearchResponse) SetHitsContainers(v []SearchHitsContainer) {
o.HitsContainers = v
}
func (o SearchResponse) MarshalJSON() ([]byte, error) {
toSerialize,err := o.ToMap()
if err != nil {
return []byte{}, err
}
return json.Marshal(toSerialize)
}
func (o SearchResponse) ToMap() (map[string]interface{}, error) {
toSerialize := map[string]interface{}{}
if !IsNil(o.SearchTerms) {
toSerialize["searchTerms"] = o.SearchTerms
}
if !IsNil(o.HitsContainers) {
toSerialize["hitsContainers"] = o.HitsContainers
}
return toSerialize, nil
}
type NullableSearchResponse struct {
value *SearchResponse
isSet bool
}
func (v NullableSearchResponse) Get() *SearchResponse {
return v.value
}
func (v *NullableSearchResponse) Set(val *SearchResponse) {
v.value = val
v.isSet = true
}
func (v NullableSearchResponse) IsSet() bool {
return v.isSet
}
func (v *NullableSearchResponse) Unset() {
v.value = nil
v.isSet = false
}
func NewNullableSearchResponse(val *SearchResponse) *NullableSearchResponse {
return &NullableSearchResponse{value: val, isSet: true}
}
func (v NullableSearchResponse) MarshalJSON() ([]byte, error) {
return json.Marshal(v.value)
}
func (v *NullableSearchResponse) UnmarshalJSON(src []byte) error {
v.isSet = true
return json.Unmarshal(src, &v.value)
}
@@ -0,0 +1,196 @@
/*
Libre Graph API
Libre Graph is a free API for cloud collaboration inspired by the MS Graph API.
API version: v1.0.8
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
package libregraph
import (
"encoding/json"
"bytes"
"fmt"
)
// checks if the SortProperty type satisfies the MappedNullable interface at compile time
var _ MappedNullable = &SortProperty{}
// SortProperty Indicates the order to sort search results in. Follows the [MS Graph sortProperty](https://learn.microsoft.com/en-us/graph/api/resources/sortproperty) resource type.
type SortProperty struct {
// The name of the property to sort the search results by. Sortable are the scalar properties carried on the search hit's resource: `name`, `size`, `lastModifiedDateTime`, `mimeType` and the scalar facet properties such as `photo.takenDateTime`, `photo.iso`, `audio.artist`, `audio.year` or `image.width`. Strings sort lexicographically, numbers and dates by value. Multivalued properties (e.g. `tags`) and unknown properties are rejected with `invalidRequest`. Required.
Name string `json:"name"`
// Set to `true` to sort the results in descending order. Optional, defaults to `false` (ascending).
IsDescending *bool `json:"isDescending,omitempty"`
}
type _SortProperty SortProperty
// NewSortProperty instantiates a new SortProperty object
// This constructor will assign default values to properties that have it defined,
// and makes sure properties required by API are set, but the set of arguments
// will change when the set of required properties is changed
func NewSortProperty(name string) *SortProperty {
this := SortProperty{}
this.Name = name
var isDescending bool = false
this.IsDescending = &isDescending
return &this
}
// NewSortPropertyWithDefaults instantiates a new SortProperty object
// This constructor will only assign default values to properties that have it defined,
// but it doesn't guarantee that properties required by API are set
func NewSortPropertyWithDefaults() *SortProperty {
this := SortProperty{}
var isDescending bool = false
this.IsDescending = &isDescending
return &this
}
// GetName returns the Name field value
func (o *SortProperty) GetName() string {
if o == nil {
var ret string
return ret
}
return o.Name
}
// GetNameOk returns a tuple with the Name field value
// and a boolean to check if the value has been set.
func (o *SortProperty) GetNameOk() (*string, bool) {
if o == nil {
return nil, false
}
return &o.Name, true
}
// SetName sets field value
func (o *SortProperty) SetName(v string) {
o.Name = v
}
// GetIsDescending returns the IsDescending field value if set, zero value otherwise.
func (o *SortProperty) GetIsDescending() bool {
if o == nil || IsNil(o.IsDescending) {
var ret bool
return ret
}
return *o.IsDescending
}
// GetIsDescendingOk returns a tuple with the IsDescending field value if set, nil otherwise
// and a boolean to check if the value has been set.
func (o *SortProperty) GetIsDescendingOk() (*bool, bool) {
if o == nil || IsNil(o.IsDescending) {
return nil, false
}
return o.IsDescending, true
}
// HasIsDescending returns a boolean if a field has been set.
func (o *SortProperty) HasIsDescending() bool {
if o != nil && !IsNil(o.IsDescending) {
return true
}
return false
}
// SetIsDescending gets a reference to the given bool and assigns it to the IsDescending field.
func (o *SortProperty) SetIsDescending(v bool) {
o.IsDescending = &v
}
func (o SortProperty) MarshalJSON() ([]byte, error) {
toSerialize,err := o.ToMap()
if err != nil {
return []byte{}, err
}
return json.Marshal(toSerialize)
}
func (o SortProperty) ToMap() (map[string]interface{}, error) {
toSerialize := map[string]interface{}{}
toSerialize["name"] = o.Name
if !IsNil(o.IsDescending) {
toSerialize["isDescending"] = o.IsDescending
}
return toSerialize, nil
}
func (o *SortProperty) UnmarshalJSON(data []byte) (err error) {
// This validates that all required properties are included in the JSON object
// by unmarshalling the object into a generic map with string keys and checking
// that every required field exists as a key in the generic map.
requiredProperties := []string{
"name",
}
allProperties := make(map[string]interface{})
err = json.Unmarshal(data, &allProperties)
if err != nil {
return err;
}
for _, requiredProperty := range(requiredProperties) {
if _, exists := allProperties[requiredProperty]; !exists {
return fmt.Errorf("no value given for required property %v", requiredProperty)
}
}
varSortProperty := _SortProperty{}
decoder := json.NewDecoder(bytes.NewReader(data))
err = decoder.Decode(&varSortProperty)
if err != nil {
return err
}
*o = SortProperty(varSortProperty)
return err
}
type NullableSortProperty struct {
value *SortProperty
isSet bool
}
func (v NullableSortProperty) Get() *SortProperty {
return v.value
}
func (v *NullableSortProperty) Set(val *SortProperty) {
v.value = val
v.isSet = true
}
func (v NullableSortProperty) IsSet() bool {
return v.isSet
}
func (v *NullableSortProperty) Unset() {
v.value = nil
v.isSet = false
}
func NewNullableSortProperty(val *SortProperty) *NullableSortProperty {
return &NullableSortProperty{value: val, isSet: true}
}
func (v *NullableSortProperty) UnmarshalJSON(src []byte) error {
return json.Unmarshal(src, &v.value)
}
func (v NullableSortProperty) MarshalJSON() ([]byte, error) {
return json.Marshal(v.value)
}