1 /*
2  * Copyright 2024 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.localstorage;
18 
19 import androidx.annotation.RestrictTo;
20 import androidx.annotation.VisibleForTesting;
21 import androidx.appsearch.exceptions.AppSearchException;
22 import androidx.appsearch.flags.Flags;
23 import androidx.appsearch.localstorage.util.PrefixUtil;
24 import androidx.collection.ArrayMap;
25 import androidx.collection.ArraySet;
26 import androidx.core.util.Preconditions;
27 
28 import com.google.android.icing.proto.SchemaTypeConfigProto;
29 
30 import org.jspecify.annotations.NonNull;
31 
32 import java.util.ArrayDeque;
33 import java.util.ArrayList;
34 import java.util.Collections;
35 import java.util.List;
36 import java.util.Map;
37 import java.util.Queue;
38 import java.util.Set;
39 
40 /**
41  * Caches and manages schema information for AppSearch.
42  *
43  * @exportToFramework:hide
44  */
45 @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
46 public class SchemaCache {
47     /**
48      * A map that contains schema types and SchemaTypeConfigProtos for all package-database
49      * prefixes. It maps each package-database prefix to an inner-map. The inner-map maps each
50      * prefixed schema type to its respective SchemaTypeConfigProto.
51      */
52     private final Map<String, Map<String, SchemaTypeConfigProto>> mSchemaMap = new ArrayMap<>();
53 
54     /**
55      * A map that contains schema types and all children schema types for all package-database
56      * prefixes. It maps each package-database prefix to an inner-map. The inner-map maps each
57      * prefixed schema type to its respective list of children prefixed schema types.
58      */
59     private final Map<String, Map<String, List<String>>> mSchemaParentToChildrenMap =
60             new ArrayMap<>();
61 
62     /**
63      * A map that contains schema types and all parent schema types for all package-database
64      * prefixes. It maps each package-database prefix to an inner-map. The inner-map maps each
65      * prefixed schema type to its respective list of unprefixed parent schema types including
66      * transitive parents. It's guaranteed that child types always appear before parent types in
67      * the list.
68      */
69     private final Map<String, Map<String, List<String>>>
70             mSchemaChildToTransitiveUnprefixedParentsMap = new ArrayMap<>();
71 
SchemaCache()72     public SchemaCache() {
73     }
74 
75     @VisibleForTesting
SchemaCache(@onNull Map<String, Map<String, SchemaTypeConfigProto>> schemaMap)76     public SchemaCache(@NonNull Map<String, Map<String, SchemaTypeConfigProto>> schemaMap)
77             throws AppSearchException {
78         mSchemaMap.putAll(Preconditions.checkNotNull(schemaMap));
79         rebuildCache();
80     }
81 
82     /**
83      * Returns the schema map for the given prefix.
84      */
getSchemaMapForPrefix( @onNull String prefix)85     public @NonNull Map<String, SchemaTypeConfigProto> getSchemaMapForPrefix(
86             @NonNull String prefix) {
87         Preconditions.checkNotNull(prefix);
88 
89         Map<String, SchemaTypeConfigProto> schemaMap = mSchemaMap.get(prefix);
90         if (schemaMap == null) {
91             return Collections.emptyMap();
92         }
93         return schemaMap;
94     }
95 
96     /**
97      * Returns a set of all prefixes stored in the cache.
98      */
getAllPrefixes()99     public @NonNull Set<String> getAllPrefixes() {
100         return Collections.unmodifiableSet(mSchemaMap.keySet());
101     }
102 
103     /**
104      * Returns all prefixed schema types stored in the cache.
105      *
106      * <p>This method is inefficient to call repeatedly.
107      */
getAllPrefixedSchemaTypes()108     public @NonNull List<String> getAllPrefixedSchemaTypes() {
109         List<String> cachedPrefixedSchemaTypes = new ArrayList<>();
110         for (Map<String, SchemaTypeConfigProto> value : mSchemaMap.values()) {
111             cachedPrefixedSchemaTypes.addAll(value.keySet());
112         }
113         return cachedPrefixedSchemaTypes;
114     }
115 
116     /**
117      * Returns the schema types for the given set of prefixed schema types with their
118      * descendants, based on the schema parent-to-children map held in the cache.
119      */
getSchemaTypesWithDescendants(@onNull String prefix, @NonNull Set<String> prefixedSchemaTypes)120     public @NonNull Set<String> getSchemaTypesWithDescendants(@NonNull String prefix,
121             @NonNull Set<String> prefixedSchemaTypes) {
122         Preconditions.checkNotNull(prefix);
123         Preconditions.checkNotNull(prefixedSchemaTypes);
124         Map<String, List<String>> parentToChildrenMap = mSchemaParentToChildrenMap.get(prefix);
125         if (parentToChildrenMap == null) {
126             parentToChildrenMap = Collections.emptyMap();
127         }
128 
129         // Perform a BFS search on the inheritance graph started by the set of prefixedSchemaTypes.
130         Set<String> visited = new ArraySet<>();
131         Queue<String> prefixedSchemaQueue = new ArrayDeque<>(prefixedSchemaTypes);
132         while (!prefixedSchemaQueue.isEmpty()) {
133             String currentPrefixedSchema = prefixedSchemaQueue.poll();
134             if (visited.contains(currentPrefixedSchema)) {
135                 continue;
136             }
137             visited.add(currentPrefixedSchema);
138             List<String> children = parentToChildrenMap.get(currentPrefixedSchema);
139             if (children == null) {
140                 continue;
141             }
142             prefixedSchemaQueue.addAll(children);
143         }
144 
145         return visited;
146     }
147 
148     /**
149      * Returns the unprefixed parent schema types, including transitive parents, for the given
150      * prefixed schema type, based on the schema child-to-parents map held in the cache. It's
151      * guaranteed that child types always appear before parent types in the list.
152      */
getTransitiveUnprefixedParentSchemaTypes(@onNull String prefix, @NonNull String prefixedSchemaType)153     public @NonNull List<String> getTransitiveUnprefixedParentSchemaTypes(@NonNull String prefix,
154             @NonNull String prefixedSchemaType) throws AppSearchException {
155         Preconditions.checkNotNull(prefix);
156         Preconditions.checkNotNull(prefixedSchemaType);
157 
158         // If the flag is on, retrieve the parent types from the cache as it is available.
159         // Otherwise, recalculate the parent types.
160         if (Flags.enableSearchResultParentTypes()) {
161             Map<String, List<String>> unprefixedChildToParentsMap =
162                     mSchemaChildToTransitiveUnprefixedParentsMap.get(prefix);
163             if (unprefixedChildToParentsMap == null) {
164                 return Collections.emptyList();
165             }
166             List<String> parents = unprefixedChildToParentsMap.get(prefixedSchemaType);
167             return parents == null ? Collections.emptyList() : parents;
168         } else {
169             return calculateTransitiveUnprefixedParentSchemaTypes(prefixedSchemaType,
170                     getSchemaMapForPrefix(prefix));
171         }
172     }
173 
174     /**
175      * Rebuilds the schema parent-to-children and child-to-parents maps for the given prefix,
176      * based on the current schema map.
177      *
178      * <p>The schema parent-to-children and child-to-parents maps must be updated when
179      * {@link #addToSchemaMap} or {@link #removeFromSchemaMap} has been called. Otherwise, the
180      * results from {@link #getSchemaTypesWithDescendants} and
181      * {@link #getTransitiveUnprefixedParentSchemaTypes} would be stale.
182      */
rebuildCacheForPrefix(@onNull String prefix)183     public void rebuildCacheForPrefix(@NonNull String prefix)
184             throws AppSearchException {
185         Preconditions.checkNotNull(prefix);
186 
187         mSchemaParentToChildrenMap.remove(prefix);
188         mSchemaChildToTransitiveUnprefixedParentsMap.remove(prefix);
189         Map<String, SchemaTypeConfigProto> prefixedSchemaMap = mSchemaMap.get(prefix);
190         if (prefixedSchemaMap == null) {
191             return;
192         }
193 
194         // Build the parent-to-children map for the current prefix.
195         Map<String, List<String>> parentToChildrenMap = new ArrayMap<>();
196         for (SchemaTypeConfigProto childSchemaConfig : prefixedSchemaMap.values()) {
197             for (int i = 0; i < childSchemaConfig.getParentTypesCount(); i++) {
198                 String parent = childSchemaConfig.getParentTypes(i);
199                 List<String> children = parentToChildrenMap.get(parent);
200                 if (children == null) {
201                     children = new ArrayList<>();
202                     parentToChildrenMap.put(parent, children);
203                 }
204                 children.add(childSchemaConfig.getSchemaType());
205             }
206         }
207         // Record the map for the current prefix.
208         if (!parentToChildrenMap.isEmpty()) {
209             mSchemaParentToChildrenMap.put(prefix, parentToChildrenMap);
210         }
211 
212         // If the flag is on, build the child-to-parent maps as caches. Otherwise, this
213         // information will have to be recalculated when needed.
214         if (Flags.enableSearchResultParentTypes()) {
215             // Build the child-to-parents maps for the current prefix.
216             Map<String, List<String>> childToTransitiveUnprefixedParentsMap = new ArrayMap<>();
217             for (SchemaTypeConfigProto childSchemaConfig : prefixedSchemaMap.values()) {
218                 if (childSchemaConfig.getParentTypesCount() > 0) {
219                     childToTransitiveUnprefixedParentsMap.put(
220                             childSchemaConfig.getSchemaType(),
221                             calculateTransitiveUnprefixedParentSchemaTypes(
222                                     childSchemaConfig.getSchemaType(),
223                                     prefixedSchemaMap));
224                 }
225             }
226             // Record the map for the current prefix.
227             if (!childToTransitiveUnprefixedParentsMap.isEmpty()) {
228                 mSchemaChildToTransitiveUnprefixedParentsMap.put(prefix,
229                         childToTransitiveUnprefixedParentsMap);
230             }
231         }
232     }
233 
234     /**
235      * Rebuilds the schema parent-to-children and child-to-parents maps based on the current
236      * schema map.
237      *
238      * <p>The schema parent-to-children and child-to-parents maps must be updated when
239      * {@link #addToSchemaMap} or {@link #removeFromSchemaMap} has been called. Otherwise, the
240      * results from {@link #getSchemaTypesWithDescendants} and
241      * {@link #getTransitiveUnprefixedParentSchemaTypes} would be stale.
242      */
rebuildCache()243     public void rebuildCache() throws AppSearchException {
244         mSchemaParentToChildrenMap.clear();
245         mSchemaChildToTransitiveUnprefixedParentsMap.clear();
246         for (String prefix : mSchemaMap.keySet()) {
247             rebuildCacheForPrefix(prefix);
248         }
249     }
250 
251     /**
252      * Adds a schema to the schema map.
253      *
254      * <p>Note that this method will invalidate the schema parent-to-children and
255      * child-to-parents maps in the cache, and either {@link #rebuildCache} or
256      * {@link #rebuildCacheForPrefix} is required to be called to update the cache.
257      */
addToSchemaMap(@onNull String prefix, @NonNull SchemaTypeConfigProto schemaTypeConfigProto)258     public void addToSchemaMap(@NonNull String prefix,
259             @NonNull SchemaTypeConfigProto schemaTypeConfigProto) {
260         Preconditions.checkNotNull(prefix);
261         Preconditions.checkNotNull(schemaTypeConfigProto);
262 
263         Map<String, SchemaTypeConfigProto> schemaTypeMap = mSchemaMap.get(prefix);
264         if (schemaTypeMap == null) {
265             schemaTypeMap = new ArrayMap<>();
266             mSchemaMap.put(prefix, schemaTypeMap);
267         }
268         schemaTypeMap.put(schemaTypeConfigProto.getSchemaType(), schemaTypeConfigProto);
269     }
270 
271     /**
272      * Removes a schema from the schema map.
273      *
274      * <p>Note that this method will invalidate the schema parent-to-children and
275      * child-to-parents maps in the cache, and either {@link #rebuildCache} or
276      * {@link #rebuildCacheForPrefix} is required to be called to update the cache.
277      */
removeFromSchemaMap(@onNull String prefix, @NonNull String schemaType)278     public void removeFromSchemaMap(@NonNull String prefix, @NonNull String schemaType) {
279         Preconditions.checkNotNull(prefix);
280         Preconditions.checkNotNull(schemaType);
281 
282         Map<String, SchemaTypeConfigProto> schemaTypeMap = mSchemaMap.get(prefix);
283         if (schemaTypeMap != null) {
284             schemaTypeMap.remove(schemaType);
285         }
286     }
287 
288     /**
289      * Removes the entry of the given prefix from the schema map, the schema parent-to-children
290      * map and the child-to-parents map, and returns the set of removed prefixed schema type.
291      */
removePrefix(@onNull String prefix)292     public @NonNull Set<String> removePrefix(@NonNull String prefix) {
293         Preconditions.checkNotNull(prefix);
294 
295         Map<String, SchemaTypeConfigProto> removedSchemas =
296                 Preconditions.checkNotNull(mSchemaMap.remove(prefix));
297         mSchemaParentToChildrenMap.remove(prefix);
298         mSchemaChildToTransitiveUnprefixedParentsMap.remove(prefix);
299         return removedSchemas.keySet();
300     }
301 
302     /**
303      * Clears all data in the cache.
304      */
clear()305     public void clear() {
306         mSchemaMap.clear();
307         mSchemaParentToChildrenMap.clear();
308         mSchemaChildToTransitiveUnprefixedParentsMap.clear();
309     }
310 
311     /**
312      * Get the list of unprefixed transitive parent type names of {@code prefixedSchemaType}.
313      *
314      * <p>It's guaranteed that child types always appear before parent types in the list.
315      */
calculateTransitiveUnprefixedParentSchemaTypes( @onNull String prefixedSchemaType, @NonNull Map<String, SchemaTypeConfigProto> prefixedSchemaMap)316     private @NonNull List<String> calculateTransitiveUnprefixedParentSchemaTypes(
317             @NonNull String prefixedSchemaType,
318             @NonNull Map<String, SchemaTypeConfigProto> prefixedSchemaMap)
319             throws AppSearchException {
320         // Please note that neither DFS nor BFS order is guaranteed to always put child types
321         // before parent types (due to the diamond problem), so a topological sorting algorithm
322         // is required.
323         Map<String, Integer> inDegreeMap = new ArrayMap<>();
324         collectParentTypeInDegrees(prefixedSchemaType, prefixedSchemaMap,
325                 /* visited= */new ArraySet<>(), inDegreeMap);
326 
327         List<String> result = new ArrayList<>();
328         Queue<String> queue = new ArrayDeque<>();
329         // prefixedSchemaType is the only type that has zero in-degree at this point.
330         queue.add(prefixedSchemaType);
331         while (!queue.isEmpty()) {
332             SchemaTypeConfigProto currentSchema = Preconditions.checkNotNull(
333                     prefixedSchemaMap.get(queue.poll()));
334             for (int i = 0; i < currentSchema.getParentTypesCount(); ++i) {
335                 String prefixedParentType = currentSchema.getParentTypes(i);
336                 int parentInDegree =
337                         Preconditions.checkNotNull(inDegreeMap.get(prefixedParentType)) - 1;
338                 inDegreeMap.put(prefixedParentType, parentInDegree);
339                 if (parentInDegree == 0) {
340                     result.add(PrefixUtil.removePrefix(prefixedParentType));
341                     queue.add(prefixedParentType);
342                 }
343             }
344         }
345         return result;
346     }
347 
collectParentTypeInDegrees( @onNull String prefixedSchemaType, @NonNull Map<String, SchemaTypeConfigProto> schemaTypeMap, @NonNull Set<String> visited, @NonNull Map<String, Integer> inDegreeMap)348     private void collectParentTypeInDegrees(
349             @NonNull String prefixedSchemaType,
350             @NonNull Map<String, SchemaTypeConfigProto> schemaTypeMap,
351             @NonNull Set<String> visited, @NonNull Map<String, Integer> inDegreeMap) {
352         if (visited.contains(prefixedSchemaType)) {
353             return;
354         }
355         visited.add(prefixedSchemaType);
356         SchemaTypeConfigProto schema =
357                 Preconditions.checkNotNull(schemaTypeMap.get(prefixedSchemaType));
358         for (int i = 0; i < schema.getParentTypesCount(); ++i) {
359             String prefixedParentType = schema.getParentTypes(i);
360             Integer parentInDegree = inDegreeMap.get(prefixedParentType);
361             if (parentInDegree == null) {
362                 parentInDegree = 0;
363             }
364             inDegreeMap.put(prefixedParentType, parentInDegree + 1);
365             collectParentTypeInDegrees(prefixedParentType, schemaTypeMap, visited, inDegreeMap);
366         }
367     }
368 }
369