1 /*
2  * Copyright 2022 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package androidx.appsearch.app;
18 
19 import android.os.Parcel;
20 import android.os.Parcelable;
21 
22 import androidx.annotation.IntDef;
23 import androidx.annotation.NonNull;
24 import androidx.annotation.RestrictTo;
25 import androidx.appsearch.annotation.CanIgnoreReturnValue;
26 import androidx.appsearch.flags.FlaggedApi;
27 import androidx.appsearch.flags.Flags;
28 import androidx.appsearch.safeparcel.AbstractSafeParcelable;
29 import androidx.appsearch.safeparcel.SafeParcelable;
30 import androidx.appsearch.safeparcel.stub.StubCreators.JoinSpecCreator;
31 import androidx.core.util.Preconditions;
32 
33 import java.lang.annotation.Retention;
34 import java.lang.annotation.RetentionPolicy;
35 import java.util.Objects;
36 
37 /**
38  * This class represents the specifications for the joining operation in search.
39  *
40  * <p> Joins are only possible for matching on the qualified id of an outer document and a
41  * property value within a subquery document. In the subquery documents, these values may be
42  * referred to with a property path such as "email.recipient.id" or "entityId" or a property
43  * expression. One such property expression is "this.qualifiedId()", which refers to the
44  * document's combined package, database, namespace, and id.
45  *
46  * <p>Note that in order for perform the join, the property referred to by
47  * {@link #getChildPropertyExpression} has to be a property with
48  * {@link AppSearchSchema.StringPropertyConfig#getJoinableValueType} set to
49  * {@link AppSearchSchema.StringPropertyConfig#JOINABLE_VALUE_TYPE_QUALIFIED_ID}. Otherwise no
50  * documents will be joined to any {@link SearchResult}.
51  *
52  * <p> Take these outer query and subquery results for example:
53  *
54  * <pre>{@code
55  * Outer result {
56  *   id: id1
57  *   score: 5
58  * }
59  * Subquery result 1 {
60  *   id: id2
61  *   score: 2
62  *   entityId: pkg$db/ns#id1
63  *   notes: This is some doc
64  * }
65  * Subquery result 2 {
66  *   id: id3
67  *   score: 3
68  *   entityId: pkg$db/ns#id2
69  *   notes: This is another doc
70  * }
71  * }</pre>
72  *
73  * <p> In this example, subquery result 1 contains a property "entityId" whose value is
74  * "pkg$db/ns#id1", referring to the outer result. If you call {@link Builder} with "entityId", we
75  * will retrieve the value of the property "entityId" from the child document, which is
76  * "pkg$db#ns/id1". Let's say the qualified id of the outer result is "pkg$db#ns/id1". This would
77  * mean the subquery result 1 document will be matched to that parent document. This is done by
78  * adding a {@link SearchResult} containing the child document to the top-level parent
79  * {@link SearchResult#getJoinedResults}.
80  *
81  * <p> If {@link #getChildPropertyExpression} is "notes", we will check the values of the notes
82  * property in the subquery results. In subquery result 1, this values is "This is some doc", which
83  * does not equal the qualified id of the outer query result. As such, subquery result 1 will not be
84  * joined to the outer query result.
85  *
86  * <p> It's possible to define an advanced ranking strategy in the nested {@link SearchSpec} and
87  * also use {@link SearchSpec#RANKING_STRATEGY_JOIN_AGGREGATE_SCORE} in the outer
88  * {@link SearchSpec}. In this case, the parents will be ranked based on an aggregation, such as
89  * the sum, of the signals calculated by scoring the joined documents with the advanced ranking
90  * strategy.
91  *
92  * <p> In terms of scoring, if {@link SearchSpec#RANKING_STRATEGY_JOIN_AGGREGATE_SCORE} is set in
93  * {@link SearchSpec#getRankingStrategy}, the scores of the outer SearchResults can be influenced
94  * by the ranking signals of the subquery results. For example, if the
95  * {@link JoinSpec#getAggregationScoringStrategy} is set to:
96  * <ul><li>
97  * {@link JoinSpec#AGGREGATION_SCORING_MIN_RANKING_SIGNAL}, the ranking signal of the outer
98  * {@link SearchResult} will be set to the minimum of the ranking signals of the subquery results.
99  * In this case, it will be the minimum of 2 and 3, which is 2.
100  * </li>
101  * <li>
102  * {@link JoinSpec#AGGREGATION_SCORING_MAX_RANKING_SIGNAL}, the ranking signal of the outer
103  * {@link SearchResult} will be 3.
104  * </li>
105  * <li>
106  * {@link JoinSpec#AGGREGATION_SCORING_AVG_RANKING_SIGNAL}, the ranking signal of the outer
107  * {@link SearchResult} will be 2.5.
108  * </li>
109  * <li>
110  * {@link JoinSpec#AGGREGATION_SCORING_RESULT_COUNT}, the ranking signal of the outer
111  * {@link SearchResult} will be 2 as there are two joined results.
112  * </li>
113  * <li>
114  * {@link JoinSpec#AGGREGATION_SCORING_SUM_RANKING_SIGNAL}, the ranking signal of the outer
115  * {@link SearchResult} will be 5, the sum of 2 and 3.
116  * </li>
117  * <li>
118  * {@link JoinSpec#AGGREGATION_SCORING_OUTER_RESULT_RANKING_SIGNAL}, the ranking signal of the outer
119  * {@link SearchResult} will stay as it is.
120  * </li>
121  * </ul>
122  *
123  * <p> Referring to "this.childrenRankingSignals()" in the ranking signal of the outer query will
124  * return the signals calculated by scoring the joined documents using the scoring strategy in the
125  * nested {@link SearchSpec}, as in {@link SearchResult#getRankingSignal}.
126  */
127 @SafeParcelable.Class(creator = "JoinSpecCreator")
128 // TODO(b/384721898): Switch to JSpecify annotations
129 @SuppressWarnings({"HiddenSuperclass", "JSpecifyNullness"})
130 public final class JoinSpec extends AbstractSafeParcelable {
131     /** Creator class for {@link JoinSpec}. */
132     @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
133     @FlaggedApi(Flags.FLAG_ENABLE_SAFE_PARCELABLE_2)
134     public static final @NonNull Parcelable.Creator<JoinSpec> CREATOR = new JoinSpecCreator();
135 
136     @Field(id = 1, getter = "getNestedQuery")
137     private final String mNestedQuery;
138 
139     @Field(id = 2, getter = "getNestedSearchSpec")
140     private final SearchSpec mNestedSearchSpec;
141 
142     @Field(id = 3, getter = "getChildPropertyExpression")
143     private final String mChildPropertyExpression;
144 
145     @Field(id = 4, getter = "getMaxJoinedResultCount")
146     private final int mMaxJoinedResultCount;
147 
148     @Field(id = 5, getter = "getAggregationScoringStrategy")
149     private final int mAggregationScoringStrategy;
150 
151     private static final int DEFAULT_MAX_JOINED_RESULT_COUNT = 10;
152 
153     /**
154      * A property expression referring to the combined package name, database name, namespace, and
155      * id of the document.
156      *
157      * <p> For instance, if a document with an id of "id1" exists in the namespace "ns" within
158      * the database "db" created by package "pkg", this would evaluate to "pkg$db/ns#id1".
159      *
160      * <!--@exportToFramework:hide-->
161      */
162     @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
163     public static final String QUALIFIED_ID = "this.qualifiedId()";
164 
165     /**
166      * Aggregation scoring strategy for join spec.
167      *
168      * @exportToFramework:hide
169      */
170     // NOTE: The integer values of these constants must match the proto enum constants in
171     // {@link JoinSpecProto.AggregationScoreStrategy.Code}
172     @IntDef(value = {
173             AGGREGATION_SCORING_OUTER_RESULT_RANKING_SIGNAL,
174             AGGREGATION_SCORING_RESULT_COUNT,
175             AGGREGATION_SCORING_MIN_RANKING_SIGNAL,
176             AGGREGATION_SCORING_AVG_RANKING_SIGNAL,
177             AGGREGATION_SCORING_MAX_RANKING_SIGNAL,
178             AGGREGATION_SCORING_SUM_RANKING_SIGNAL
179     })
180     @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
181     @Retention(RetentionPolicy.SOURCE)
182     public @interface AggregationScoringStrategy {
183     }
184 
185     /**
186      * Do not score the aggregation of joined documents. This is for the case where we want to
187      * perform a join, but keep the parent ranking signal.
188      */
189     public static final int AGGREGATION_SCORING_OUTER_RESULT_RANKING_SIGNAL = 0;
190     /** Score the aggregation of joined documents by counting the number of results. */
191     public static final int AGGREGATION_SCORING_RESULT_COUNT = 1;
192     /** Score the aggregation of joined documents using the smallest ranking signal. */
193     public static final int AGGREGATION_SCORING_MIN_RANKING_SIGNAL = 2;
194     /** Score the aggregation of joined documents using the average ranking signal. */
195     public static final int AGGREGATION_SCORING_AVG_RANKING_SIGNAL = 3;
196     /** Score the aggregation of joined documents using the largest ranking signal. */
197     public static final int AGGREGATION_SCORING_MAX_RANKING_SIGNAL = 4;
198     /** Score the aggregation of joined documents using the sum of ranking signal. */
199     public static final int AGGREGATION_SCORING_SUM_RANKING_SIGNAL = 5;
200 
201     @Constructor
JoinSpec( @aramid = 1) @onNull String nestedQuery, @Param(id = 2) @NonNull SearchSpec nestedSearchSpec, @Param(id = 3) @NonNull String childPropertyExpression, @Param(id = 4) int maxJoinedResultCount, @Param(id = 5) @AggregationScoringStrategy int aggregationScoringStrategy)202     JoinSpec(
203             @Param(id = 1) @NonNull String nestedQuery,
204             @Param(id = 2) @NonNull SearchSpec nestedSearchSpec,
205             @Param(id = 3) @NonNull String childPropertyExpression,
206             @Param(id = 4) int maxJoinedResultCount,
207             @Param(id = 5) @AggregationScoringStrategy int aggregationScoringStrategy) {
208         mNestedQuery = Objects.requireNonNull(nestedQuery);
209         mNestedSearchSpec = Objects.requireNonNull(nestedSearchSpec);
210         mChildPropertyExpression = Objects.requireNonNull(childPropertyExpression);
211         mMaxJoinedResultCount = maxJoinedResultCount;
212         mAggregationScoringStrategy = aggregationScoringStrategy;
213     }
214 
215 
216     /**
217      * Returns the query to run on the joined documents.
218      */
getNestedQuery()219     public @NonNull String getNestedQuery() {
220         return mNestedQuery;
221     }
222 
223     /**
224      * The property expression that is used to get values from child documents, returned from the
225      * nested search. These values are then used to match them to parent documents. These are
226      * analogous to foreign keys.
227      *
228      * @return the property expression to match in the child documents.
229      * @see Builder
230      */
getChildPropertyExpression()231     public @NonNull String getChildPropertyExpression() {
232         return mChildPropertyExpression;
233     }
234 
235     /**
236      * Returns the max amount of {@link SearchResult} objects to return with the parent document,
237      * with a default of 10 SearchResults.
238      */
getMaxJoinedResultCount()239     public int getMaxJoinedResultCount() {
240         return mMaxJoinedResultCount;
241     }
242 
243     /**
244      * Returns the search spec used to retrieve the joined documents.
245      *
246      * <p> If {@link Builder#setNestedSearch} is never called, this will return a {@link SearchSpec}
247      * with all default values. This will match every document, as the nested search query will
248      * be "" and no schema will be filtered out.
249      */
getNestedSearchSpec()250     public @NonNull SearchSpec getNestedSearchSpec() {
251         return mNestedSearchSpec;
252     }
253 
254     /**
255      * Gets the joined document list scoring strategy.
256      *
257      * <p> The default scoring strategy is {@link #AGGREGATION_SCORING_OUTER_RESULT_RANKING_SIGNAL},
258      * which specifies that the score of the outer parent document will be used.
259      *
260      * @see SearchSpec#RANKING_STRATEGY_JOIN_AGGREGATE_SCORE
261      */
262     @AggregationScoringStrategy
getAggregationScoringStrategy()263     public int getAggregationScoringStrategy() {
264         return mAggregationScoringStrategy;
265     }
266 
267     @Override
268     @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
269     @FlaggedApi(Flags.FLAG_ENABLE_SAFE_PARCELABLE_2)
writeToParcel(@onNull Parcel dest, int flags)270     public void writeToParcel(@NonNull Parcel dest, int flags) {
271         JoinSpecCreator.writeToParcel(this, dest, flags);
272     }
273 
274     /** Builder for {@link JoinSpec objects}. */
275     public static final class Builder {
276 
277         // The default nested SearchSpec.
278         private static final SearchSpec EMPTY_SEARCH_SPEC = new SearchSpec.Builder().build();
279 
280         private String mNestedQuery = "";
281         private SearchSpec mNestedSearchSpec = EMPTY_SEARCH_SPEC;
282         private String mChildPropertyExpression;
283         private int mMaxJoinedResultCount = DEFAULT_MAX_JOINED_RESULT_COUNT;
284         @AggregationScoringStrategy
285         private int mAggregationScoringStrategy =
286                 AGGREGATION_SCORING_OUTER_RESULT_RANKING_SIGNAL;
287 
288         /**
289          * Create a specification for the joining operation in search.
290          *
291          * <p> The child property expression specifies how to join documents. Documents with
292          * a child property expression equal to the qualified id of the parent will be retrieved.
293          *
294          * <p> Property expressions differ from {@link PropertyPath} as property expressions may
295          * refer to document properties or nested document properties such as "person.business.id"
296          * as well as a property expression. Currently the only property expression is
297          * "this.qualifiedId()". {@link PropertyPath} objects may only reference document properties
298          * and nested document properties.
299          *
300          * <p> In order to join a child document to a parent document, the child document must
301          * contain the parent's qualified id at the property expression specified by this
302          * method.
303          *
304          * @param childPropertyExpression the property to match in the child documents.
305          */
306         // TODO(b/256022027): Reword comments to reference either "expression" or "PropertyPath"
307         //  once wording is finalized.
308         // TODO(b/256022027): Add another method to allow providing PropertyPath objects as
309         //  equality constraints.
310         // TODO(b/256022027): Change to allow for multiple child property expressions if multiple
311         //  parent property expressions get supported.
Builder(@onNull String childPropertyExpression)312         public Builder(@NonNull String childPropertyExpression) {
313             Preconditions.checkNotNull(childPropertyExpression);
314             mChildPropertyExpression = childPropertyExpression;
315         }
316 
317         /** Creates a new {@link Builder} from the given {@link JoinSpec}. */
318         @ExperimentalAppSearchApi
319         @FlaggedApi(Flags.FLAG_ENABLE_ADDITIONAL_BUILDER_COPY_CONSTRUCTORS)
Builder(@onNull JoinSpec joinSpec)320         public Builder(@NonNull JoinSpec joinSpec) {
321             Preconditions.checkNotNull(joinSpec);
322             mNestedQuery = joinSpec.getNestedQuery();
323             mNestedSearchSpec = joinSpec.getNestedSearchSpec();
324             mChildPropertyExpression = joinSpec.getChildPropertyExpression();
325             mMaxJoinedResultCount = joinSpec.getMaxJoinedResultCount();
326             mAggregationScoringStrategy = joinSpec.getAggregationScoringStrategy();
327         }
328 
329         /**
330          * Sets the child property expression.
331          *
332          * <p> The child property expression specifies how to join documents. Documents with
333          * a child property expression equal to the qualified id of the parent will be retrieved.
334          *
335          * <p> Property expressions differ from {@link PropertyPath} as property expressions may
336          * refer to document properties or nested document properties such as "person.business.id"
337          * as well as a property expression. Currently the only property expression is
338          * "this.qualifiedId()". {@link PropertyPath} objects may only reference document properties
339          * and nested document properties.
340          *
341          * <p> In order to join a child document to a parent document, the child document must
342          * contain the parent's qualified id at the property expression specified by this
343          * method.
344          *
345          * @param childPropertyExpression the property to match in the child documents.
346          */
347         @ExperimentalAppSearchApi
348         @FlaggedApi(Flags.FLAG_ENABLE_ADDITIONAL_BUILDER_COPY_CONSTRUCTORS)
349         @CanIgnoreReturnValue
setChildPropertyExpression( @onNull String childPropertyExpression)350         public @NonNull Builder setChildPropertyExpression(
351                 @NonNull String childPropertyExpression) {
352             Preconditions.checkNotNull(childPropertyExpression);
353             mChildPropertyExpression = childPropertyExpression;
354             return this;
355         }
356 
357         /**
358          * Sets the query and the SearchSpec for the documents being joined. This will score and
359          * rank the joined documents as well as filter the joined documents.
360          *
361          * <p>If {@link SearchSpec#RANKING_STRATEGY_JOIN_AGGREGATE_SCORE} is set in
362          * the outer {@link SearchSpec}, the resulting signals will be used to rank the parent
363          * documents. Note that the aggregation strategy also needs to be set with
364          * {@link JoinSpec.Builder#setAggregationScoringStrategy}, otherwise the default will be
365          * {@link JoinSpec#AGGREGATION_SCORING_OUTER_RESULT_RANKING_SIGNAL}, which will just use
366          * the parent documents ranking signal.
367          *
368          * <p> If this method is never called, {@link JoinSpec#getNestedQuery} will return an empty
369          * string, meaning we will join with every possible document that matches the equality
370          * constraints and hasn't been filtered out by the type or namespace filters.
371          *
372          * @see JoinSpec#getNestedQuery
373          * @see JoinSpec#getNestedSearchSpec
374          */
375         @SuppressWarnings("MissingGetterMatchingBuilder")
376         // See getNestedQuery & getNestedSearchSpec
377         @CanIgnoreReturnValue
setNestedSearch(@onNull String nestedQuery, @NonNull SearchSpec nestedSearchSpec)378         public @NonNull Builder setNestedSearch(@NonNull String nestedQuery,
379                 @NonNull SearchSpec nestedSearchSpec) {
380             Preconditions.checkNotNull(nestedQuery);
381             Preconditions.checkNotNull(nestedSearchSpec);
382             mNestedQuery = nestedQuery;
383             mNestedSearchSpec = nestedSearchSpec;
384             return this;
385         }
386 
387         /**
388          * Sets the max amount of {@link SearchResults} to return with the parent document, with a
389          * default of 10 SearchResults.
390          *
391          * <p>This does NOT limit the number of results that are joined with the parent
392          * document for scoring. This means that, when set, only a maximum of
393          * {@code maxJoinedResultCount} results will be returned with each parent document, but
394          * all results that are joined with a parent will factor into the score.
395          */
396         @CanIgnoreReturnValue
setMaxJoinedResultCount(int maxJoinedResultCount)397         public @NonNull Builder setMaxJoinedResultCount(int maxJoinedResultCount) {
398             mMaxJoinedResultCount = maxJoinedResultCount;
399             return this;
400         }
401 
402         /**
403          * Sets how we derive a single score from a list of joined documents.
404          *
405          * <p> The default scoring strategy is
406          * {@link #AGGREGATION_SCORING_OUTER_RESULT_RANKING_SIGNAL}, which specifies that the
407          * ranking signal of the outer parent document will be used.
408          *
409          * @see SearchSpec#RANKING_STRATEGY_JOIN_AGGREGATE_SCORE
410          */
411         @CanIgnoreReturnValue
setAggregationScoringStrategy( @ggregationScoringStrategy int aggregationScoringStrategy)412         public @NonNull Builder setAggregationScoringStrategy(
413                 @AggregationScoringStrategy int aggregationScoringStrategy) {
414             Preconditions.checkArgumentInRange(aggregationScoringStrategy,
415                     AGGREGATION_SCORING_OUTER_RESULT_RANKING_SIGNAL,
416                     AGGREGATION_SCORING_SUM_RANKING_SIGNAL, "aggregationScoringStrategy");
417             mAggregationScoringStrategy = aggregationScoringStrategy;
418             return this;
419         }
420 
421         /**
422          * Constructs a new {@link JoinSpec} from the contents of this builder.
423          */
build()424         public @NonNull JoinSpec build() {
425             return new JoinSpec(
426                     mNestedQuery,
427                     mNestedSearchSpec,
428                     mChildPropertyExpression,
429                     mMaxJoinedResultCount,
430                     mAggregationScoringStrategy
431             );
432         }
433     }
434 }
435