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