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