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