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