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