diff --git a/Parse/src/main/java/com/parse/NetworkQueryController.java b/Parse/src/main/java/com/parse/NetworkQueryController.java index 2899b8b77..5e166d9ab 100644 --- a/Parse/src/main/java/com/parse/NetworkQueryController.java +++ b/Parse/src/main/java/com/parse/NetworkQueryController.java @@ -136,7 +136,7 @@ public Integer then(Task task) throws Exception { } for (int i = 0; i < results.length(); ++i) { JSONObject data = results.getJSONObject(i); - T object = ParseObject.fromJSON(data, resultClassName, state.selectedKeys() == null); + T object = ParseObject.fromJSON(data, resultClassName, ParseDecoder.get(), state.selectedKeys()); answer.add(object); /* diff --git a/Parse/src/main/java/com/parse/ParseDecoder.java b/Parse/src/main/java/com/parse/ParseDecoder.java index f522e6154..bf7b0c6b0 100644 --- a/Parse/src/main/java/com/parse/ParseDecoder.java +++ b/Parse/src/main/java/com/parse/ParseDecoder.java @@ -122,7 +122,7 @@ public Object decode(Object object) { } if (typeString.equals("Object")) { - return ParseObject.fromJSON(jsonObject, null, true, this); + return ParseObject.fromJSON(jsonObject, null, this); } if (typeString.equals("Relation")) { diff --git a/Parse/src/main/java/com/parse/ParseObject.java b/Parse/src/main/java/com/parse/ParseObject.java index 699e9dda9..7dd162a4b 100644 --- a/Parse/src/main/java/com/parse/ParseObject.java +++ b/Parse/src/main/java/com/parse/ParseObject.java @@ -64,6 +64,9 @@ public class ParseObject { */ private static final String KEY_COMPLETE = "__complete"; private static final String KEY_OPERATIONS = "__operations"; + // Array of keys selected when querying for the object. Helps decoding nested {@code ParseObject}s + // correctly, and helps constructing the {@code State.availableKeys()} set. + private static final String KEY_SELECTED_KEYS = "__selectedKeys"; /* package */ static final String KEY_IS_DELETING_EVENTUALLY = "__isDeletingEventually"; // Because Grantland messed up naming this... We'll only try to read from this for backward // compat, but I think we can be safe to assume any deleteEventuallys from long ago are obsolete @@ -98,6 +101,7 @@ public static Init newBuilder(String className) { private long createdAt = -1; private long updatedAt = -1; private boolean isComplete; + private Set availableKeys = new HashSet<>(); /* package */ Map serverData = new HashMap<>(); public Init(String className) { @@ -109,8 +113,10 @@ public Init(String className) { objectId = state.objectId(); createdAt = state.createdAt(); updatedAt = state.updatedAt(); + availableKeys = state.availableKeys(); for (String key : state.keySet()) { serverData.put(key, state.get(key)); + availableKeys.add(key); } isComplete = state.isComplete(); } @@ -151,6 +157,7 @@ public T isComplete(boolean complete) { public T put(String key, Object value) { serverData.put(key, value); + availableKeys.add(key); return self(); } @@ -159,12 +166,20 @@ public T remove(String key) { return self(); } + public T availableKeys(Collection keys) { + for (String key : keys) { + availableKeys.add(key); + } + return self(); + } + public T clear() { objectId = null; createdAt = -1; updatedAt = -1; isComplete = false; serverData.clear(); + availableKeys.clear(); return self(); } @@ -188,6 +203,7 @@ public T apply(State other) { for (String key : other.keySet()) { put(key, other.get(key)); } + availableKeys(other.availableKeys()); return self(); } @@ -231,6 +247,7 @@ public State build() { private final long createdAt; private final long updatedAt; private final Map serverData; + private final Set availableKeys; private final boolean isComplete; /* package */ State(Init builder) { @@ -242,6 +259,7 @@ public State build() { : createdAt; serverData = Collections.unmodifiableMap(new HashMap<>(builder.serverData)); isComplete = builder.isComplete; + availableKeys = new HashSet<>(builder.availableKeys); } @SuppressWarnings("unchecked") @@ -277,11 +295,20 @@ public Set keySet() { return serverData.keySet(); } + // Available keys for this object. With respect to keySet(), this includes also keys that are + // undefined in the server, but that should be accessed without throwing. + // These extra keys come e.g. from ParseQuery.selectKeys(). Selected keys must be available to + // get() methods even if undefined, for consistency with complete objects. + // For a complete object, this set is equal to keySet(). + public Set availableKeys() { + return availableKeys; + } + @Override public String toString() { return String.format(Locale.US, "%s@%s[" + "className=%s, objectId=%s, createdAt=%d, updatedAt=%d, isComplete=%s, " + - "serverData=%s]", + "serverData=%s, availableKeys=%s]", getClass().getName(), Integer.toHexString(hashCode()), className, @@ -289,7 +316,8 @@ public String toString() { createdAt, updatedAt, isComplete, - serverData); + serverData, + availableKeys); } } @@ -578,38 +606,48 @@ public Void then(Task task) throws Exception { /** * Creates a new {@code ParseObject} based on data from the Parse server. - * * @param json * The object's data. * @param defaultClassName * The className of the object, if none is in the JSON. - * @param isComplete - * {@code true} if this is all of the data on the server for the object. + * @param decoder + * Delegate for knowing how to decode the values in the JSON. + * @param selectedKeys + * Set of keys selected when quering for this object. If none, the object is assumed to + * be complete, i.e. this is all the data for the object on the server. */ /* package */ static T fromJSON(JSONObject json, String defaultClassName, - boolean isComplete) { - return fromJSON(json, defaultClassName, isComplete, ParseDecoder.get()); + ParseDecoder decoder, + Set selectedKeys) { + if (selectedKeys != null && !selectedKeys.isEmpty()) { + JSONArray keys = new JSONArray(selectedKeys); + try { + json.put(KEY_SELECTED_KEYS, keys); + } catch (JSONException e) { + throw new RuntimeException(e); + } + } + return fromJSON(json, defaultClassName, decoder); } /** * Creates a new {@code ParseObject} based on data from the Parse server. - * * @param json - * The object's data. + * The object's data. It is assumed to be complete, unless the JSON has the + * {@link #KEY_SELECTED_KEYS} key. * @param defaultClassName * The className of the object, if none is in the JSON. - * @param isComplete - * {@code true} if this is all of the data on the server for the object. * @param decoder * Delegate for knowing how to decode the values in the JSON. */ /* package */ static T fromJSON(JSONObject json, String defaultClassName, - boolean isComplete, ParseDecoder decoder) { + ParseDecoder decoder) { String className = json.optString(KEY_CLASS_NAME, defaultClassName); if (className == null) { return null; } String objectId = json.optString(KEY_OBJECT_ID, null); + boolean isComplete = !json.has(KEY_SELECTED_KEYS); @SuppressWarnings("unchecked") T object = (T) ParseObject.createWithoutData(className, objectId); State newState = object.mergeFromServer(object.getState(), json, decoder, isComplete); @@ -622,7 +660,7 @@ public Void then(Task task) throws Exception { * * Method is used by parse server webhooks implementation to create a * new {@code ParseObject} from the incoming json payload. The method is different from - * {@link #fromJSON(JSONObject, String, boolean)} ()} in that it calls + * {@link #fromJSON(JSONObject, String, ParseDecoder, Set)} ()} in that it calls * {@link #build(JSONObject, ParseDecoder)} which populates operation queue * rather then the server data from the incoming JSON, as at external server the incoming * JSON may not represent the actual server data. Also it handles @@ -876,9 +914,9 @@ protected boolean visit(Object object) { } } + /** * Merges from JSON in REST format. - * * Updates this object with data from the server. * * @see #toJSONObjectForSaving(State, ParseOperationSet, ParseEncoder) @@ -921,8 +959,34 @@ protected boolean visit(Object object) { builder.put(KEY_ACL, acl); continue; } + if (key.equals(KEY_SELECTED_KEYS)) { + JSONArray safeKeys = json.getJSONArray(key); + if (safeKeys.length() > 0) { + Collection set = new HashSet<>(); + for (int i = 0; i < safeKeys.length(); i++) { + // Don't add nested keys. + String safeKey = safeKeys.getString(i); + if (safeKey.contains(".")) safeKey = safeKey.split("\\.")[0]; + set.add(safeKey); + } + builder.availableKeys(set); + } + continue; + } Object value = json.get(key); + if (value instanceof JSONObject && json.has(KEY_SELECTED_KEYS)) { + // This might be a ParseObject. Pass selected keys to understand if it is complete. + JSONArray selectedKeys = json.getJSONArray(KEY_SELECTED_KEYS); + JSONArray nestedKeys = new JSONArray(); + for (int i = 0; i < selectedKeys.length(); i++) { + String nestedKey = selectedKeys.getString(i); + if (nestedKey.startsWith(key + ".")) nestedKeys.put(nestedKey.substring(key.length() + 1)); + } + if (nestedKeys.length() > 0) { + ((JSONObject) value).put(KEY_SELECTED_KEYS, nestedKeys); + } + } Object decodedObject = decoder.decode(value); builder.put(key, decodedObject); } @@ -989,6 +1053,8 @@ protected boolean visit(Object object) { // using the REST api and want to send data to Parse. json.put(KEY_COMPLETE, state.isComplete()); json.put(KEY_IS_DELETING_EVENTUALLY, isDeletingEventually); + JSONArray availableKeys = new JSONArray(state.availableKeys()); + json.put(KEY_SELECTED_KEYS, availableKeys); // Operation Set Queue JSONArray operations = new JSONArray(); @@ -2872,7 +2938,7 @@ public void put(String key, Object value) { if (value instanceof JSONObject) { ParseDecoder decoder = ParseDecoder.get(); value = decoder.convertJSONObjectToMap((JSONObject) value); - } else if (value instanceof JSONArray){ + } else if (value instanceof JSONArray) { ParseDecoder decoder = ParseDecoder.get(); value = decoder.convertJSONArrayToList((JSONArray) value); } @@ -3036,6 +3102,7 @@ public boolean containsKey(String key) { } } + /** * Access a {@link String} value. * @@ -3375,9 +3442,17 @@ public boolean isDataAvailable() { } } - /* package for tests */ boolean isDataAvailable(String key) { + /** + * Gets whether the {@code ParseObject} specified key has been fetched. + * This means the property can be accessed safely. + * + * @return {@code true} if the {@code ParseObject} key is new or has been fetched or refreshed. {@code false} + * otherwise. + */ + public boolean isDataAvailable(String key) { synchronized (mutex) { - return isDataAvailable() || estimatedData.containsKey(key); + // Fallback to estimatedData to include dirty changes. + return isDataAvailable() || state.availableKeys().contains(key) || estimatedData.containsKey(key); } } diff --git a/Parse/src/test/java/com/parse/ParseDecoderTest.java b/Parse/src/test/java/com/parse/ParseDecoderTest.java index 8a8cc3920..0c8bf4013 100644 --- a/Parse/src/test/java/com/parse/ParseDecoderTest.java +++ b/Parse/src/test/java/com/parse/ParseDecoderTest.java @@ -27,6 +27,7 @@ import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; // For android.util.Base64 @RunWith(RobolectricTestRunner.class) @@ -198,6 +199,74 @@ public void testParseObject() throws JSONException { assertNotNull(parseObject); } + @Test + public void testIncludedParseObject() throws JSONException { + JSONObject json = new JSONObject(); + json.put("__type", "Object"); + json.put("className", "GameScore"); + json.put("createdAt", "2015-06-22T21:23:41.733Z"); + json.put("objectId", "TT1ZskATqS"); + json.put("updatedAt", "2015-06-22T22:06:18.104Z"); + + JSONObject child = new JSONObject(); + child.put("__type", "Object"); + child.put("className", "GameScore"); + child.put("createdAt", "2015-06-22T21:23:41.733Z"); + child.put("objectId", "TT1ZskATqR"); + child.put("updatedAt", "2015-06-22T22:06:18.104Z"); + + json.put("child", child); + ParseObject parseObject = (ParseObject) ParseDecoder.get().decode(json); + assertNotNull(parseObject.getParseObject("child")); + } + + @Test + public void testCompleteness() throws JSONException { + JSONObject json = new JSONObject(); + json.put("__type", "Object"); + json.put("className", "GameScore"); + json.put("createdAt", "2015-06-22T21:23:41.733Z"); + json.put("objectId", "TT1ZskATqS"); + json.put("updatedAt", "2015-06-22T22:06:18.104Z"); + json.put("foo", "foo"); + json.put("bar", "bar"); + ParseObject parseObject = (ParseObject) ParseDecoder.get().decode(json); + assertTrue(parseObject.isDataAvailable()); + + JSONArray arr = new JSONArray("[\"foo\"]"); + json.put("__selectedKeys", arr); + parseObject = (ParseObject) ParseDecoder.get().decode(json); + assertFalse(parseObject.isDataAvailable()); + } + + @Test + public void testCompletenessOfIncludedParseObject() throws JSONException { + JSONObject json = new JSONObject(); + json.put("__type", "Object"); + json.put("className", "GameScore"); + json.put("createdAt", "2015-06-22T21:23:41.733Z"); + json.put("objectId", "TT1ZskATqS"); + json.put("updatedAt", "2015-06-22T22:06:18.104Z"); + + JSONObject child = new JSONObject(); + child.put("__type", "Object"); + child.put("className", "GameScore"); + child.put("createdAt", "2015-06-22T21:23:41.733Z"); + child.put("objectId", "TT1ZskATqR"); + child.put("updatedAt", "2015-06-22T22:06:18.104Z"); + child.put("bar", "child bar"); + + JSONArray arr = new JSONArray("[\"foo.bar\"]"); + json.put("foo", child); + json.put("__selectedKeys", arr); + ParseObject parentObject = (ParseObject) ParseDecoder.get().decode(json); + assertFalse(parentObject.isDataAvailable()); + assertTrue(parentObject.isDataAvailable("foo")); + ParseObject childObject = parentObject.getParseObject("foo"); + assertFalse(childObject.isDataAvailable()); + assertTrue(childObject.isDataAvailable("bar")); + } + @Test public void testRelation() throws JSONException { JSONObject json = new JSONObject(); diff --git a/Parse/src/test/java/com/parse/ParseObjectStateTest.java b/Parse/src/test/java/com/parse/ParseObjectStateTest.java index bd2d84ef2..e4c906301 100644 --- a/Parse/src/test/java/com/parse/ParseObjectStateTest.java +++ b/Parse/src/test/java/com/parse/ParseObjectStateTest.java @@ -10,6 +10,7 @@ import org.junit.Test; +import java.util.Arrays; import java.util.Date; import static org.junit.Assert.assertEquals; @@ -17,7 +18,6 @@ import static org.junit.Assert.assertNull; import static org.junit.Assert.assertThat; import static org.junit.Assert.assertTrue; -import static org.mockito.Matchers.contains; public class ParseObjectStateTest { @@ -30,6 +30,7 @@ public void testDefaults() { assertEquals(-1, state.updatedAt()); assertFalse(state.isComplete()); assertTrue(state.keySet().isEmpty()); + assertTrue(state.availableKeys().isEmpty()); } @Test @@ -62,6 +63,7 @@ public void testCopy() { .isComplete(true) .put("foo", "bar") .put("baz", "qux") + .availableKeys(Arrays.asList("safe", "keys")) .build(); ParseObject.State copy = new ParseObject.State.Builder(state).build(); assertEquals(state.className(), copy.className()); @@ -72,6 +74,9 @@ public void testCopy() { assertEquals(state.keySet().size(), copy.keySet().size()); assertEquals(state.get("foo"), copy.get("foo")); assertEquals(state.get("baz"), copy.get("baz")); + assertEquals(state.availableKeys().size(), copy.availableKeys().size()); + assertTrue(state.availableKeys().containsAll(copy.availableKeys())); + assertTrue(copy.availableKeys().containsAll(state.availableKeys())); } @Test @@ -121,5 +126,6 @@ public void testToString() { assertTrue(string.contains("updatedAt")); assertTrue(string.contains("isComplete")); assertTrue(string.contains("serverData")); + assertTrue(string.contains("availableKeys")); } } diff --git a/Parse/src/test/java/com/parse/ParseObjectTest.java b/Parse/src/test/java/com/parse/ParseObjectTest.java index 863e00866..24aded591 100644 --- a/Parse/src/test/java/com/parse/ParseObjectTest.java +++ b/Parse/src/test/java/com/parse/ParseObjectTest.java @@ -21,6 +21,7 @@ import java.util.Collections; import java.util.Date; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; @@ -237,7 +238,16 @@ public void testGetUnavailable() { ParseObject.State state = mock(ParseObject.State.class); when(state.className()).thenReturn("TestObject"); when(state.isComplete()).thenReturn(false); + ParseObject object = ParseObject.from(state); + object.get("foo"); + } + @Test + public void testGetAvailableIfKeyAvailable() { + ParseObject.State state = mock(ParseObject.State.class); + when(state.className()).thenReturn("TestObject"); + when(state.isComplete()).thenReturn(false); + when(state.availableKeys()).thenReturn(new HashSet<>(Arrays.asList("foo"))); ParseObject object = ParseObject.from(state); object.get("foo"); }