Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions fit/Couchbase.FitPerformer/PerformerService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ public override Task<PerformerCapsFetchResponse> performerCapsFetch(PerformerCap
response.SdkImplementationCaps.Add(Grpc.Protocol.Sdk.Caps.SdkBucketSettingsNumVbuckets);
response.SdkImplementationCaps.Add(Grpc.Protocol.Sdk.Caps.SdkZoneAwareReadFromReplica);
response.SdkImplementationCaps.Add(Grpc.Protocol.Sdk.Caps.SdkGetReplica);
response.SdkImplementationCaps.Add(Grpc.Protocol.Sdk.Caps.SdkSearchCustomScriptQueries);
response.SdkImplementationCaps.Add(Grpc.Protocol.Sdk.Caps.SdkPrefilterVectorSearch);
response.SdkImplementationCaps.Add(Grpc.Protocol.Sdk.Caps.SdkCouchbase2Observability);
response.SdkImplementationCaps.Add(Grpc.Protocol.Sdk.Caps.SdkObservabilityRfcRev24);
Expand Down
40 changes: 38 additions & 2 deletions fit/Couchbase.FitPerformer/Workload/SearchHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
using BooleanFieldQuery = Couchbase.Search.Queries.Simple.BooleanFieldQuery;
using BooleanQuery = Couchbase.Search.Queries.Compound.BooleanQuery;
using ConjunctionQuery = Couchbase.Search.Queries.Compound.ConjunctionQuery;
using CustomFilterQuery = Couchbase.Search.Queries.Script.CustomFilterQuery;
using CustomScoreQuery = Couchbase.Search.Queries.Script.CustomScoreQuery;
using DateRangeQuery = Couchbase.Search.Queries.Range.DateRangeQuery;
using DisjunctionQuery = Couchbase.Search.Queries.Compound.DisjunctionQuery;
using DocIdQuery = Couchbase.Search.Queries.Simple.DocIdQuery;
Expand Down Expand Up @@ -302,8 +304,8 @@ private static Couchbase.Search.ISearchQuery ProtoSearchQueryToCore(Couchbase.Gr
{
//--------Initialise BooleanQuery--------
var coreQuery = new BooleanQuery();
if (protoQuery.Boolean.HasBoost) coreQuery.Boost(protoQuery.Boolean.Boost);
if (protoQuery.Boolean.HasShouldMin) coreQuery.ShouldMin((int)protoQuery.Boolean.ShouldMin);
if (innerProtoQuery.Boolean.HasBoost) coreQuery.Boost(innerProtoQuery.Boolean.Boost);
if (innerProtoQuery.Boolean.HasShouldMin) coreQuery.ShouldMin((int)innerProtoQuery.Boolean.ShouldMin);

//--------Recurse to add converted queries to the above compound queries--------
if (innerProtoQuery.Boolean.Must.Count > 0)
Expand Down Expand Up @@ -461,6 +463,26 @@ private static Couchbase.Search.ISearchQuery ProtoSearchQueryToCore(Couchbase.Gr
{
return new MatchNoneQuery();
}
case SearchQuery.QueryOneofCase.CustomScore:
{
var proto = innerProtoQuery.CustomScore;
// A null inner query would fall back to the root and recurse forever.
if (proto.Query is null) throw new UnsupportedException("CustomScoreQuery has no inner query");
var coreQuery = new CustomScoreQuery(ProtoSearchQueryToCore(protoQuery, proto.Query), proto.Source);
Comment thread
davidkelly marked this conversation as resolved.
Resolved
if (proto.Fields.Count > 0) coreQuery.Fields(proto.Fields.ToArray());
if (proto.Parameters is not null) coreQuery.Parameters(StructToDictionary(proto.Parameters));
return coreQuery;
}
case SearchQuery.QueryOneofCase.CustomFilter:
{
var proto = innerProtoQuery.CustomFilter;
// A null inner query would fall back to the root and recurse forever.
if (proto.Query is null) throw new UnsupportedException("CustomFilterQuery has no inner query");
var coreQuery = new CustomFilterQuery(ProtoSearchQueryToCore(protoQuery, proto.Query), proto.Source);
if (proto.Fields.Count > 0) coreQuery.Fields(proto.Fields.ToArray());
if (proto.Parameters is not null) coreQuery.Parameters(StructToDictionary(proto.Parameters));
return coreQuery;
}
case SearchQuery.QueryOneofCase.MatchPhrase:
{
var coreQuery = new MatchPhraseQuery(innerProtoQuery.MatchPhrase.MatchPhrase);
Expand Down Expand Up @@ -549,4 +571,18 @@ private static Couchbase.Search.ISearchQuery ProtoSearchQueryToCore(Couchbase.Gr

throw new UnsupportedException($"Could not determine Search Query type: {protoQuery}");
}

private static Dictionary<string, object?> StructToDictionary(Struct protoStruct) =>
protoStruct.Fields.ToDictionary(field => field.Key, field => ProtoValueToObject(field.Value));

private static object? ProtoValueToObject(Value value) => value.KindCase switch
{
Value.KindOneofCase.NullValue => null,
Value.KindOneofCase.NumberValue => value.NumberValue,
Value.KindOneofCase.StringValue => value.StringValue,
Value.KindOneofCase.BoolValue => value.BoolValue,
Value.KindOneofCase.StructValue => StructToDictionary(value.StructValue),
Value.KindOneofCase.ListValue => value.ListValue.Values.Select(ProtoValueToObject).ToList(),
_ => throw new UnsupportedException($"Unsupported protobuf Value kind: {value.KindCase}")
};
}
14 changes: 14 additions & 0 deletions fit/Couchbase.FitPerformer/gRPC/sdk.caps.proto
Original file line number Diff line number Diff line change
Expand Up @@ -162,4 +162,18 @@ enum Caps {
// The SDK has support for the Collection.GetReplica API. This includes the new DocumentNotFoundOnReplicaException,
// ReplicaIndexOutOfBoundsException and ReplicaIndexCurrentlyUnavailableException exceptions.
SDK_GET_REPLICA = 43;

// The performer supports the ScopeEventingFunctionManager API.
// This is everything under sdk.scope.eventing_function_manager
SDK_SCOPE_EVENTING_FUNCTION_MANAGER = 44;

// The SDK raises InvalidArgumentException for an HTTP 400 from any HTTP service when no more specific error applies
// (SDK-RFC 58 revision 22).
SDK_HTTP_400_INVALID_ARGUMENT = 45;

// The SDK has support for the eventing-specific error types defined in SDK-RFC 62 (see shared.exceptions.proto).
SDK_EVENTING_ERRORS = 46;

// The SDK has support for the CustomScoreQuery and CustomFilterQuery SearchQuery types
SDK_SEARCH_CUSTOM_SCRIPT_QUERIES = 47;
}
22 changes: 21 additions & 1 deletion fit/Couchbase.FitPerformer/gRPC/sdk.search.proto
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ option go_package = "github.com/couchbaselabs/transactions-fit-performer/protoco
option java_multiple_files = true;
option ruby_package = "FIT::Protocol::SDK::Search";

import "google/protobuf/timestamp.proto";
import "google/protobuf/struct.proto";

import "shared.content.proto";
import "shared.basic.proto";
import "google/protobuf/timestamp.proto";
import "streams.top_level.proto";
import "shared.transcoders.proto";

Expand Down Expand Up @@ -484,6 +486,20 @@ message MatchAllQuery {
message MatchNoneQuery {
}

message CustomScoreQuery {
SearchQuery query = 1;
string source = 2;
repeated string fields = 3;
optional google.protobuf.Struct parameters = 4;
}

message CustomFilterQuery {
SearchQuery query = 1;
string source = 2;
repeated string fields = 3;
optional google.protobuf.Struct parameters = 4;
}

message SearchQuery {
oneof query {
MatchQuery match = 1;
Expand All @@ -507,5 +523,9 @@ message SearchQuery {
PhraseQuery phrase = 17;
MatchAllQuery match_all = 18;
MatchNoneQuery match_none = 19;

// Support declared using SDK_SEARCH_CUSTOM_SCRIPT_QUERIES SDK cap.
CustomScoreQuery custom_score = 21;
CustomFilterQuery custom_filter = 22;
}
}
9 changes: 9 additions & 0 deletions src/Couchbase/Cluster.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
using Couchbase.Management.Users;
using Couchbase.Query;
using Couchbase.Search;
using Couchbase.Search.Queries.Script;
using Microsoft.Extensions.Logging;
using AnalyticsOptions = Couchbase.Analytics.AnalyticsOptions;
using Couchbase.Core.RateLimiting;
Expand All @@ -51,6 +52,8 @@ public partial class Cluster : ICluster, IBootstrappable
private readonly object _syncObject = new object();
private bool _disposed;
private readonly ClusterContext _context;
// Test seam: lets unit tests supply the GlobalConfig that capability checks read.
internal ClusterContext Context => _context;
private bool _hasBootStrapped = false;

// Warn once on entering the failed state, not on every bootstrapper poll (2.5s by
Expand Down Expand Up @@ -589,6 +592,12 @@ public async Task<ISearchResult> SearchAsync(
"Score fusion is not available on this version of Couchbase Server");
}

if (searchRequest.ContainsCustomScriptQuery())
{
_context.GlobalConfig?.AssertClusterCap(ClusterCapabilities.SEARCH_UDF_QUERY,
"Custom score or filter queries are not available on this version of Couchbase Server");
Comment thread
davidkelly marked this conversation as resolved.
Resolved
}

FtsSearchRequest ftsSearchRequest = new()
{
Index = indexName,
Expand Down
1 change: 1 addition & 0 deletions src/Couchbase/Core/Configuration/Server/BucketConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -599,6 +599,7 @@ internal class ClusterCapabilities
public const string SCOPED_SEARCH_INDEX = "search.scopedSearchIndex";
public const string VECTOR_SEARCH = "search.vectorSearch";
public const string SCORE_FUSION = "search.scoreFusion";
public const string SEARCH_UDF_QUERY = "search.udfQuery";

[JsonPropertyName("clusterCapabilitiesVer")] public IEnumerable<int> Version { get; set; }
[JsonPropertyName("clusterCapabilities")] public Dictionary<string, IEnumerable<string>> Capabilities { get; set; }
Expand Down
128 changes: 128 additions & 0 deletions src/Couchbase/Search/Queries/Script/CustomFilterQuery.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using Couchbase.Core.Compatibility;
using Newtonsoft.Json.Linq;

namespace Couchbase.Search.Queries.Script
{
/// <summary>
/// A query that passes the results of an inner query through a user-defined JavaScript filtering function.
/// Only the results for which the function returns true are kept. Their scores are not changed.
/// </summary>
/// <remarks>
/// <para>
/// Requires Couchbase Server 8.5.0 or later, Enterprise Edition. Custom script queries are disabled on the
/// Search service by default; enable them by setting <c>customScriptQueriesEnabled</c> to <c>"true"</c> with
/// <c>PUT /api/managerOptions</c> on a Search node. The SDK cannot detect this setting, so while it is disabled
/// the server rejects the query. Not available over couchbase2://.
/// </para>
/// <para>
/// The function is called once for each result, with <c>doc</c> and <c>params</c>:
/// <list type="bullet">
/// <item><description><c>doc._id</c>: the document ID.</description></item>
/// <item><description><c>doc.score</c>: the score from the inner query.</description></item>
/// <item><description><c>doc.fields</c>: only the fields requested with <see cref="Fields"/>; undefined if
/// none are requested. See <see cref="Fields"/> for how text values appear.</description></item>
/// <item><description><c>params</c>: the object given to <see cref="Parameters"/>, the same for every
/// result; <c>null</c> if none is given.</description></item>
/// </list>
/// An exception in the function fails the whole query.
/// </para>
/// </remarks>
[InterfaceStability(Level.Uncommitted)]
public sealed class CustomFilterQuery : ISearchQuery
{
private readonly CustomScriptQuery _query;

/// <summary>
/// Creates a <see cref="CustomFilterQuery"/>.
/// </summary>
/// <param name="query">The inner query whose results are passed through the filtering function.</param>
/// <param name="source">
/// A JavaScript function that takes <c>(doc, params)</c> and returns a boolean, for example
/// <c>function filter(doc, params) { return doc.score >= 0.5; }</c>.
/// </param>
public CustomFilterQuery(ISearchQuery query, string source)
{
_query = new CustomScriptQuery(query, source);
}

/// <summary>
/// The document fields made available to the filtering function, as <c>doc.fields</c>.
/// The function sees no fields unless this is set. Pass <c>"*"</c> for all fields.
/// </summary>
/// <remarks>
/// The values come from the Search index, not from the stored document, so a field must be indexed to be
/// available. Text values are the terms from the field's analyzer, not the original text. Use the
/// <c>keyword</c> analyzer to get the exact text.
/// </remarks>
/// <returns>This <see cref="CustomFilterQuery"/> for chaining.</returns>
public CustomFilterQuery Fields(params string[] fields)
{
_query.SetFields(fields);
return this;
}

/// <summary>
/// Parameters made available to the filtering function, as <c>params</c>.
/// Values may be any JSON-serializable value, including nested dictionaries and lists.
/// The same parameters are given to the function for every result.
/// </summary>
/// <remarks>
/// The values are serialized with Newtonsoft.Json, not with the cluster's serializer. Use plain .NET values;
/// System.Text.Json types such as <c>JsonElement</c> are not serialized correctly.
/// </remarks>
/// <returns>This <see cref="CustomFilterQuery"/> for chaining.</returns>
public CustomFilterQuery Parameters(IDictionary<string, object?> parameters)
{
_query.SetParameters(parameters);
return this;
}

/// <summary>
/// Has no effect. The Search service ignores a boost on this query type, so apply the boost to the inner
/// query instead.
/// </summary>
/// <returns>This <see cref="CustomFilterQuery"/>, unchanged.</returns>
[Obsolete("CustomFilterQuery does not support boost. Apply the boost to the inner query instead.")]
public ISearchQuery Boost(double boost) => this;

[RequiresUnreferencedCode(SearchClient.SearchRequiresUnreferencedMembersWarning)]
[RequiresDynamicCode(SearchClient.SearchRequiresDynamicCodeWarning)]
public JObject Export() => _query.Export("custom_filter");

public ReadOnly AsReadOnly() =>
new(_query.Query, _query.Source, _query.Fields, _query.Parameters);

public record ReadOnly(
ISearchQuery Query,
string Source,
IReadOnlyList<string>? Fields,
IReadOnlyDictionary<string, object?>? Parameters);
}
}

#region [ License information ]

/* ************************************************************
*
* @author Couchbase <info@couchbase.com>
* @copyright 2026 Couchbase, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* ************************************************************/

#endregion
Loading
Loading