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.NonNull; 20 import android.annotation.Nullable; 21 import android.annotation.SuppressLint; 22 import android.app.appsearch.checker.initialization.qual.UnderInitialization; 23 import android.app.appsearch.checker.nullness.qual.RequiresNonNull; 24 25 import java.util.ArrayList; 26 import java.util.Iterator; 27 import java.util.List; 28 import java.util.Objects; 29 30 /** 31 * Represents a property path returned from searching the AppSearch Database. 32 * 33 * <p>One of the use cases for this class is when searching the AppSearch Database for the snippet 34 * matching use case. In this case you will get back {@link SearchResult.MatchInfo} objects that 35 * contain a property path signifying the location of a match within the database. This is a string 36 * that may look something like "foo.bar[0]". {@link PropertyPath} parses this string and breaks it 37 * up into a List of {@link PathSegment}s. These may represent either a property or a property and a 38 * 0-based index into the property. For instance, "foo.bar[1]" would be parsed into a {@link 39 * PathSegment} with a property name of foo and a {@link PathSegment} with a property name of bar 40 * and an index of 1. This allows for easier manipulation of the property path. 41 * 42 * <p>This class won't perform any retrievals, it will only parse the path string. As such, it may 43 * not necessarily refer to a valid path in the database. 44 * 45 * @see SearchResult.MatchInfo 46 */ 47 public class PropertyPath implements Iterable<PropertyPath.PathSegment> { 48 private final List<PathSegment> mPathList; 49 50 /** 51 * Constructor directly accepting a path list 52 * 53 * @param pathList a list of PathSegments 54 */ PropertyPath(@onNull List<PathSegment> pathList)55 public PropertyPath(@NonNull List<PathSegment> pathList) { 56 mPathList = new ArrayList<>(pathList); 57 } 58 59 /** 60 * Constructor that parses a string representing the path to populate a List of PathSegments 61 * 62 * @param path the string to be validated and parsed into PathSegments 63 * @throws IllegalArgumentException when the path is invalid or malformed 64 */ PropertyPath(@onNull String path)65 public PropertyPath(@NonNull String path) { 66 Objects.requireNonNull(path); 67 mPathList = new ArrayList<>(); 68 try { 69 recursivePathScan(path); 70 } catch (IllegalArgumentException e) { 71 // Throw the entire path in a new exception, recursivePathScan may only know about part 72 // of the path. 73 throw new IllegalArgumentException(e.getMessage() + ": " + path); 74 } 75 } 76 77 @RequiresNonNull("mPathList") recursivePathScan(@nderInitialization PropertyPath this, String path)78 private void recursivePathScan(@UnderInitialization PropertyPath this, String path) 79 throws IllegalArgumentException { 80 // Determine whether the path is just a raw property name with no control characters 81 int controlPos = -1; 82 boolean controlIsIndex = false; 83 for (int i = 0; i < path.length(); i++) { 84 char c = path.charAt(i); 85 if (c == ']') { 86 throw new IllegalArgumentException("Malformed path (no starting '[')"); 87 } 88 if (c == '[' || c == '.') { 89 controlPos = i; 90 controlIsIndex = c == '['; 91 break; 92 } 93 } 94 95 if (controlPos == 0 || path.isEmpty()) { 96 throw new IllegalArgumentException("Malformed path (blank property name)"); 97 } 98 99 // If the path has no further elements, we're done. 100 if (controlPos == -1) { 101 // The property's cardinality may be REPEATED, but this path isn't indexing into it 102 mPathList.add(new PathSegment(path, PathSegment.NON_REPEATED_CARDINALITY)); 103 return; 104 } 105 106 String remainingPath; 107 if (!controlIsIndex) { 108 String propertyName = path.substring(0, controlPos); 109 // Remaining path is everything after the . 110 remainingPath = path.substring(controlPos + 1); 111 mPathList.add(new PathSegment(propertyName, PathSegment.NON_REPEATED_CARDINALITY)); 112 } else { 113 remainingPath = consumePropertyWithIndex(path, controlPos); 114 // No more path remains, we have nothing to recurse into 115 if (remainingPath == null) { 116 return; 117 } 118 } 119 120 // More of the path remains; recursively evaluate it 121 recursivePathScan(remainingPath); 122 } 123 124 /** 125 * Helper method to parse the parts of the path String that signify indices with square brackets 126 * 127 * <p>For example, when parsing the path "foo[3]", this will be used to parse the "[3]" part of 128 * the path to determine the index into the preceding "foo" property. 129 * 130 * @param path the string we are parsing 131 * @param controlPos the position of the start bracket 132 * @return the rest of the path after the end brackets, or null if there is nothing after them 133 */ 134 @Nullable 135 @RequiresNonNull("mPathList") consumePropertyWithIndex( @nderInitialization PropertyPath this, @NonNull String path, int controlPos)136 private String consumePropertyWithIndex( 137 @UnderInitialization PropertyPath this, @NonNull String path, int controlPos) { 138 Objects.requireNonNull(path); 139 String propertyName = path.substring(0, controlPos); 140 int endBracketIdx = path.indexOf(']', controlPos); 141 if (endBracketIdx == -1) { 142 throw new IllegalArgumentException("Malformed path (no ending ']')"); 143 } 144 if (endBracketIdx + 1 < path.length() && path.charAt(endBracketIdx + 1) != '.') { 145 throw new IllegalArgumentException("Malformed path (']' not followed by '.'): " + path); 146 } 147 String indexStr = path.substring(controlPos + 1, endBracketIdx); 148 int index; 149 try { 150 index = Integer.parseInt(indexStr); 151 } catch (NumberFormatException e) { 152 throw new IllegalArgumentException( 153 "Malformed path (\"" + indexStr + "\" as path index)"); 154 } 155 if (index < 0) { 156 throw new IllegalArgumentException("Malformed path (path index less than 0)"); 157 } 158 mPathList.add(new PathSegment(propertyName, index)); 159 // Remaining path is everything after the [n] 160 if (endBracketIdx + 1 < path.length()) { 161 // More path remains, and we've already checked that charAt(endBracketIdx+1) == . 162 return path.substring(endBracketIdx + 2); 163 } else { 164 return null; 165 } 166 } 167 168 /** 169 * Returns the {@link PathSegment} at a specified index of the PropertyPath. 170 * 171 * <p>Calling {@code get(1)} on a {@link PropertyPath} representing "foo.bar[1]" will return a 172 * {@link PathSegment} representing "bar[1]". {@link PathSegment}s both with and without a 173 * property index of {@link PathSegment#NON_REPEATED_CARDINALITY} are retrieved the same. 174 * 175 * @param index the position into the PropertyPath 176 * @throws ArrayIndexOutOfBoundsException if index is not a valid index in the path list 177 */ 178 // Allow use of the Kotlin indexing operator 179 @SuppressWarnings("KotlinOperator") 180 @SuppressLint("KotlinOperator") 181 @NonNull get(int index)182 public PathSegment get(int index) { 183 return mPathList.get(index); 184 } 185 186 /** 187 * Returns the number of {@link PathSegment}s in the PropertyPath. 188 * 189 * <p>Paths representing "foo.bar" and "foo[1].bar[1]" will have the same size, as a property 190 * and an index into that property are stored in one {@link PathSegment}. 191 */ size()192 public int size() { 193 return mPathList.size(); 194 } 195 196 /** Returns a valid path string representing this PropertyPath */ 197 @Override 198 @NonNull toString()199 public String toString() { 200 StringBuilder result = new StringBuilder(); 201 for (int i = 0; i < mPathList.size(); i++) { 202 result.append(get(i).toString()); 203 if (i < mPathList.size() - 1) { 204 result.append('.'); 205 } 206 } 207 208 return result.toString(); 209 } 210 211 /** Returns an iterator over the PathSegments within the PropertyPath */ 212 @NonNull 213 @Override iterator()214 public Iterator<PathSegment> iterator() { 215 return mPathList.iterator(); 216 } 217 218 @Override equals(@ullable Object o)219 public boolean equals(@Nullable Object o) { 220 if (this == o) { 221 return true; 222 } 223 if (o == null) { 224 return false; 225 } 226 if (!(o instanceof PropertyPath)) { 227 return false; 228 } 229 PropertyPath that = (PropertyPath) o; 230 return Objects.equals(mPathList, that.mPathList); 231 } 232 233 @Override hashCode()234 public int hashCode() { 235 return Objects.hashCode(mPathList); 236 } 237 238 /** 239 * A segment of a PropertyPath, which includes the name of the property and a 0-based index into 240 * this property. 241 * 242 * <p>If the property index is not set to {@link #NON_REPEATED_CARDINALITY}, this represents a 243 * schema property with the "repeated" cardinality, or a path like "foo[1]". Otherwise, this 244 * represents a schema property that could have any cardinality, or a path like "foo". 245 */ 246 public static class PathSegment { 247 /** 248 * A marker variable to signify that a PathSegment represents a schema property that isn't 249 * indexed into. The value is chosen to be invalid if used as an array index. 250 */ 251 public static final int NON_REPEATED_CARDINALITY = -1; 252 253 @NonNull private final String mPropertyName; 254 private final int mPropertyIndex; 255 256 /** 257 * Creation method that accepts and validates both a property name and the index into the 258 * property. 259 * 260 * <p>The property name may not be blank. It also may not contain square brackets or dots, 261 * as they are control characters in property paths. The index into the property may not be 262 * negative, unless it is {@link #NON_REPEATED_CARDINALITY}, as these are invalid array 263 * indices. 264 * 265 * @param propertyName the name of the property 266 * @param propertyIndex the index into the property 267 * @return A new PathSegment 268 * @throws IllegalArgumentException if the property name or index is invalid. 269 */ 270 @NonNull create(@onNull String propertyName, int propertyIndex)271 public static PathSegment create(@NonNull String propertyName, int propertyIndex) { 272 Objects.requireNonNull(propertyName); 273 // A path may contain control characters, but a PathSegment may not 274 if (propertyName.isEmpty() 275 || propertyName.contains("[") 276 || propertyName.contains("]") 277 || propertyName.contains(".")) { 278 throw new IllegalArgumentException("Invalid propertyName value:" + propertyName); 279 } 280 // Has to be a positive integer or the special marker 281 if (propertyIndex < 0 && propertyIndex != NON_REPEATED_CARDINALITY) { 282 throw new IllegalArgumentException("Invalid propertyIndex value:" + propertyIndex); 283 } 284 return new PathSegment(propertyName, propertyIndex); 285 } 286 287 /** 288 * Creation method that accepts and validates a property name 289 * 290 * <p>The property index is set to {@link #NON_REPEATED_CARDINALITY} 291 * 292 * @param propertyName the name of the property 293 * @return A new PathSegment 294 */ 295 @NonNull create(@onNull String propertyName)296 public static PathSegment create(@NonNull String propertyName) { 297 return create(Objects.requireNonNull(propertyName), NON_REPEATED_CARDINALITY); 298 } 299 300 /** 301 * Package-private constructor that accepts a property name and an index into the property 302 * without validating either of them 303 * 304 * @param propertyName the name of the property 305 * @param propertyIndex the index into the property 306 */ PathSegment(@onNull String propertyName, int propertyIndex)307 PathSegment(@NonNull String propertyName, int propertyIndex) { 308 mPropertyName = Objects.requireNonNull(propertyName); 309 mPropertyIndex = propertyIndex; 310 } 311 312 /** Returns the name of the property. */ 313 @NonNull getPropertyName()314 public String getPropertyName() { 315 return mPropertyName; 316 } 317 318 /** 319 * Returns the index into the property, or {@link #NON_REPEATED_CARDINALITY} if this does 320 * not represent a PathSegment with an index. 321 */ getPropertyIndex()322 public int getPropertyIndex() { 323 return mPropertyIndex; 324 } 325 326 /** Returns a path representing a PathSegment, either "foo" or "foo[1]" */ 327 @Override 328 @NonNull toString()329 public String toString() { 330 if (mPropertyIndex != NON_REPEATED_CARDINALITY) { 331 return mPropertyName + "[" + mPropertyIndex + "]"; 332 } 333 return mPropertyName; 334 } 335 336 @Override equals(@ullable Object o)337 public boolean equals(@Nullable Object o) { 338 if (this == o) { 339 return true; 340 } 341 if (o == null) { 342 return false; 343 } 344 if (!(o instanceof PathSegment)) { 345 return false; 346 } 347 PathSegment that = (PathSegment) o; 348 return mPropertyIndex == that.mPropertyIndex 349 && mPropertyName.equals(that.mPropertyName); 350 } 351 352 @Override hashCode()353 public int hashCode() { 354 return Objects.hash(mPropertyName, mPropertyIndex); 355 } 356 } 357 } 358