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