Data Storage Guide for Java
With the Data Storage service, you can have your app persist data on the cloud and query them at any time. The code below shows how you can create an object and store it into the cloud:
// Create an object
LCObject todo = new LCObject("Todo");
// Set values of fields
todo.put("title", "R&D Weekly Meeting");
todo.put("content", "All team members, Tue 2pm");
// Save the object to the cloud
todo.saveInBackground().subscribe(new Observer<LCObject>() {
public void onSubscribe(Disposable disposable) {}
public void onNext(LCObject todo) {
// Execute any logic that should take place after the object is saved
System.out.println("Object saved. objectId: " + todo.getObjectId());
}
public void onError(Throwable throwable) {
// Execute any logic that should take place if the save fails
}
public void onComplete() {}
});
The SDK designed for each language interacts with the same REST API via HTTPS, offering fully functional interfaces for you to manipulate the data in the cloud.
Installing SDK
See Installing Java SDK.
Objects
LCObject
The objects on the cloud are built around LCObject. Each LCObject contains key-value pairs of JSON-compatible data. This data is schema-free, which means that you don't need to specify ahead of time what keys exist on each LCObject. Simply set whatever key-value pairs you want, and our backend will store them.
For example, the LCObject storing a simple todo item may contain the following data:
title: "Email Linda to Confirm Appointment",
isComplete: false,
priority: 2,
tags: ["work", "sales"]
Data Types
LCObject supports a wide range of data types to be used for each field, including common ones like String, Number, Boolean, Object, Array, and Date. You can nest objects in JSON format to store more structured data within a single Object or Array field.
Special data types supported by LCObject include Pointer and File, which are used to store a reference to another LCObject and binary data respectively.
LCObject also supports GeoPoint, a special data type you can use to store location-based data. See GeoPoints for more details.
Some examples:
// Basic types
boolean bool = true;
int number = 2018;
String string = number + " Top Hits";
Date date = new Date();
byte[] data = "Hello world!".getBytes();
ArrayList<Object> arrayList = new ArrayList<>();
arrayList.add(number);
arrayList.add(string);
HashMap<Object, Object> hashMap = new HashMap<>();
hashMap.put("number", number);
hashMap.put("string", string);
// Create an object
LCObject testObject = new LCObject("TestObject");
testObject.put("testBoolean", bool);
testObject.put("testInteger", number);
testObject.put("testDate", date);
testObject.put("testData", data);
testObject.put("testArrayList", arrayList);
testObject.put("testHashMap", hashMap);
testObject.save();
We do not recommend storing large pieces of binary data like images or documents with LCObject using byte[]. The size of each LCObject should not exceed 128 KB. We recommend using LCFile for storing images, documents, and other types of files. To do so, create LCFile objects and assign them to fields of LCObject. See Files for details.
Keep in mind that our backend stores dates in UTC format and the SDK will convert them to local times upon retrieval.
The date values displayed on Developer Center > Your game > Game Services > Cloud Services > Data Storage > Data are also converted to match your operating system's time zone. The only exception is that when you retrieve these date values through our REST API, they will remain in UTC format. You can manually convert them using appropriate time zones when necessary.
To learn about how you can protect the data stored on the cloud, see Data Security.
Creating Objects
The code below creates a new instance of LCObject with class Todo:
LCObject todo = new LCObject("Todo");
The constructor takes a class name as a parameter so that the cloud knows the class you are using to create the object. A class is comparable to a table in a relational database. A class name starts with a letter and can only contain numbers, letters, and underscores.
Saving Objects
The following code saves a new object with class Todo to the cloud:
// Create an object
LCObject todo = new LCObject("Todo");
// Set values of fields
todo.put("title", "Sign up for Marathon");
todo.put("priority", 2);
// Save the object to the cloud
todo.saveInBackground().subscribe(new Observer<LCObject>() {
public void onSubscribe(Disposable disposable) {}
public void onNext(LCObject todo) {
// Execute any logic that should take place after the object is saved
System.out.println("Object saved. objectId: " + todo.getObjectId());
}
public void onError(Throwable throwable) {
// Execute any logic that should take place if the save fails
}
public void onComplete() {}
});
To make sure the object is successfully saved, take a look at Developer Center > Your game > Game Services > Cloud Services > Data Storage > Data > Todo in your app. You should see a new entry of data with something like this when you click on its objectId:
{
"title": "Sign up for Marathon",
"priority": 2,
"ACL": {
"*": {
"read": true,
"write": true
}
},
"objectId": "582570f38ac247004f39c24b",
"createdAt": "2017-11-11T07:19:15.549Z",
"updatedAt": "2017-11-11T07:19:15.549Z"
}
You don't have to create or set up a new class called Todo in Developer Center > Your game > Game Services > Cloud Services > Data Storage > Data before running the code above. If the class doesn't exist, it will be automatically created.
Several built-in fields are provided by default which you don't need to specify in your code:
| Built-in Field | Type | Description |
|---|---|---|
objectId | String | A unique identifier for each saved object. |
ACL | LCACL | Access Control List, a special object defining the read and write permissions of other people. |
createdAt | Date | The time the object was created. |
updatedAt | Date | The time the object was last modified. |
Each of these fields is filled in by the cloud automatically and doesn't exist on the local LCObject until a save operation has been completed.
Field names, or keys, can only contain letters, numbers, and underscores. A custom key can neither start with double underscores __, nor be identical to any system reserved words or built-in field names (ACL, className, createdAt, objectId, and updatedAt) regardless of letter cases.
Values can be strings, numbers, booleans, or even arrays and dictionaries — anything that can be JSON-encoded. See Data Types for more information.
We recommend that you adopt CamelCase naming convention to NameYourClassesLikeThis and nameYourKeysLikeThis, which keeps your code more readable.
Retrieving Objects
If an LCObject is already in the cloud, you can retrieve it using its objectId with the following code:
LCQuery<LCObject> query = new LCQuery<>("Todo");
query.getInBackground("582570f38ac247004f39c24b").subscribe(new Observer<LCObject>() {
public void onSubscribe(Disposable disposable) {}
public void onNext(LCObject todo) {
// todo is the instance of the Todo object with objectId 582570f38ac247004f39c24b
String title = todo.getString("title");
int priority = todo.getInt("priority");
// Get special properties
String objectId = todo.getObjectId();
Date updatedAt = todo.getUpdatedAt();
Date createdAt = todo.getCreatedAt();
}
public void onError(Throwable throwable) {}
public void onComplete() {}
});
If you try to access a field or property that doesn't exist, the SDK will not raise an error. Instead, it will return null.
Refreshing Objects
If you need to refresh a local object with the latest version of it in the cloud, call the fetchInBackground method on it:
LCObject todo = LCObject.createWithoutData("Todo", "582570f38ac247004f39c24b");
todo.fetchInBackground().subscribe(new Observer<LCObject>() {
public void onSubscribe(Disposable disposable) {}
public void onNext(LCObject todo) {
// todo is refreshed
}
public void onError(Throwable throwable) {}
public void onComplete() {}
});
Keep in mind that any unsaved changes made to the object prior to calling fetchInBackground will be discarded. To avoid this, you have the option to provide a list of keys when calling the method so that only the fields being specified are retrieved and refreshed (including special built-in fields such as objectId, createdAt, and updatedAt). Changes made to other fields will remain intact.
LCObject todo = LCObject.createWithoutData("Todo", "582570f38ac247004f39c24b");
String keys = "priority, location";
todo.fetchInBackground(keys).subscribe(new Observer<LCObject>() {
public void onSubscribe(Disposable disposable) {}
public void onNext(LCObject todo) {
// Only priority and location will be retrieved and refreshed
}
public void onError(Throwable throwable) {}
public void onComplete() {}
});
Updating Objects
To update an existing object, assign the new data to each field and call the saveInBackground method. For example:
LCObject todo = LCObject.createWithoutData("Todo", "582570f38ac247004f39c24b");
todo.put("content", "Weekly meeting has been rescheduled to Wed 3pm for this week.");
todo.saveInBackground().subscribe(new Observer<LCObject>() {
public void onSubscribe(Disposable disposable) {}
public void onNext(LCObject savedTodo) {
System.out.println("Saved.");
}
public void onError(Throwable throwable) {
System.out.println("Failed to save.");
}
public void onComplete() {}
});;
The cloud automatically figures out which data has changed and only the fields with changes will be sent to the cloud. The fields you didn't update will remain intact.
Updating Data Conditionally
By passing in a query option when saving, you can specify conditions on the save operation so that the object can be updated atomically only when those conditions are met. If no object matches the conditions, the cloud will return error 305 to indicate that there was no update taking place.
For example, in the class Account there is a field called balance, and there are multiple incoming requests that want to modify this field. Since an account cannot have a negative balance, we can only allow a request to update the balance when the amount requested is lower than or equal to the balance:
LCObject account = LCObject.createWithoutData("Account", "5745557f71cfe40068c6abe0");
// Atomically decrease balance by 100
final int amount = -100;
account.increment("balance", amount);
// Add the condition
LCSaveOption option = new LCSaveOption();
option.query(new LCQuery<>("Account").whereGreaterThanOrEqualTo("balance", -amount));
// Return the latest data in the cloud upon completion.
// All the fields will be returned if the object is new,
// otherwise only fields with changes will be returned.
option.setFetchWhenSave(true);
account.saveInBackground(option).subscribe(new Observer<LCObject>() {
public void onSubscribe(Disposable disposable) {}
public void onNext(LCObject account) {
System.out.println("Balance: " + account.get("balance"));
}
public void onError(Throwable throwable) {
System.out.println("Insufficient balance. Operation failed!");
}
public void onComplete() {}
});
The query option only works for existing objects. In other words, it does not affect objects that haven't been saved to the cloud yet.
The benefit of using the query option instead of combining LCQuery and LCObject shows up when you have multiple clients trying to update the same field at the same time. The latter way is more cumbersome and may lead to potential inconsistencies.
Updating Counters
Take Twitter as an example, we need to keep track of how many Likes and Retweets a tweet has gained so far. Since a Like or Retweet action can be triggered simultaneously by multiple clients, saving objects with updated values directly can lead to inaccurate results. To make sure that the total number is stored correctly, you can atomically increase (or decrease) the value of a number field:
post.increment("likes", 1);
You can specify the amount of increment (or decrement) by providing an additional argument. If the argument is not provided, 1 is used by default.
Updating Arrays
There are several operations that can be used to atomically update an array associated with a given key:
add()appends the given object to the end of an array.addUnique()adds the given object into an array only if it is not in it. The object will be inserted at a random position.removeAll()removes all instances of the given object from an array.
For example, Todo has a field named alarms for keeping track of the times at which a user wants to be alerted. The following code adds the times to the alarms field:
Date getDateWithDateString(String dateString) {
SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
Date date = dateFormat.parse(dateString);
return date;
}
Date alarm1 = getDateWithDateString("2018-04-30 07:10:00");
Date alarm2 = getDateWithDateString("2018-04-30 07:20:00");
Date alarm3 = getDateWithDateString("2018-04-30 07:30:00");
LCObject todo = new LCObject("Todo");
todo.addAllUnique("alarms", Arrays.asList(alarm1, alarm2, alarm3));
todo.save();
Deleting Objects
The following code deletes a Todo object from the cloud:
LCObject todo = LCObject.createWithoutData("Todo", "582570f38ac247004f39c24b");
todo.deleteInBackground().subscribe(new Observer<LCNull>() {
@Override
public void onSubscribe(@NonNull Disposable d) {}
@Override
public void onNext(LCNull response) {
// succeed to delete a todo.
}
@Override
public void onError(@NonNull Throwable e) {
System.out.println("failed to delete a todo: " + e.getMessage());
}
@Override
public void onComplete() {}
});
Removing data from the cloud should always be dealt with great caution as it may lead to non-recoverable data loss. We strongly advise that you read ACL Guide to understand the risks thoroughly. You should also consider implementing class-level, object-level, and field-level permissions for your classes in the cloud to guard against unauthorized data operations.
Batch Processing
// Batch create and update
saveAll()
saveAllInBackground()
// Batch delete
deleteAll()
deleteAllInBackground()
// Batch fetch
fetchAll()
fetchAllInBackground()
The following code sets isComplete of all Todo objects to be true:
LCQuery<LCObject> query = new LCQuery<>("Todo");
query.findInBackground().subscribe(new Observer<List<LCObject>>() {
public void onSubscribe(Disposable disposable) {}
public void onNext(List<LCObject> todos) {
// Get a collection of todos to work on
for (LCObject todo : todos) {
// Update value
todo.put("isComplete", true);
}
// Save all at once
LCObject.saveAll(todos);
}
public void onError(Throwable throwable) {}
public void onComplete() {}
});
Although each function call sends multiple operations in one single network request, saving operations and fetching operations are billed as separate API calls for each object in the collection, while deleting operations are billed as a single API call.
Running in the Background
You may have noticed from the examples above that we have been accessing the cloud asynchronously in our code. The methods with names like xxxxInBackground are provided for you to implement asynchronous calls so that your main thread will not be blocked.
Storing Data Locally
Most of the operations for saving objects can be executed immediately and the program will be notified once the operation is done. However, if the program does not need to know when the operation is done, you can use saveEventually instead.
The benefit of this function is that if the device is offline, saveEventually will cache the data locally and send them to the server once the device gets online again. If the app is closed before the device gets online, the SDK will try to send the data to the server when the app is opened again.
It is safe to call saveEventually (or deleteEventually) multiple times since all the operations will be performed in the order they are initiated.
Data Models
Objects may have relationships with other objects. For example, in a blogging application, a Post object may have relationships with many Comment objects. The Data Storage service supports three kinds of relationships, including one-to-one, one-to-many, and many-to-many.
One-to-One and One-to-Many Relationships
One-to-one and one-to-many relationships are modeled by saving LCObject as a value in the other object. For example, each Comment in a blogging app might correspond to one Post.
The following code creates a new Post with a single Comment:
// Create a post
LCObject post = new LCObject("Post");
post.put("title", "I am starving!");
post.put("content", "Hmmm, where should I go for lunch?");
// Create a comment
LCObject comment = new LCObject("Comment");
comment.put("content", "How about KFC?");
// Add the post as a property of the comment
comment.put("parent", post);
// This will save both post and comment
comment.save();
Internally, the backend will store the referred-to object with the Pointer type in just one place in order to maintain consistency. You can also link objects using their objectIds like this:
LCObject post = LCObject.createWithoutData("Post", "57328ca079bc44005c2472d0");
comment.put("post", post);
See Relational Queries for instructions on how to query relational data.
Many-to-Many Relationships
The easiest way to model many-to-many relationships is to use arrays. In most cases, using arrays helps you reduce the number of queries you need to make and leads to better performance. However, if additional properties need to be attached to the relationships between two classes, using join tables would be a better choice. Keep in mind that the additional properties are used to describe the relationships between classes rather than any single class.
We recommend you to use join tables if the total number of objects of any class exceeds 100.
Serialization and Deserialization
If you need to pass an LCObject to a method as an argument, you may want to first serialize the object to avoid certain problems. You can use the following ways to serialize and deserialize LCObjects.
Serialization:
LCObject todo = new LCObject("Todo"); // Create object
todo.put("title", "Sign up for Marathon"); // Set title
todo.put("priority", 2); // Set priority
todo.put("owner", LCUser.getCurrentUser()); // A Pointer pointing to the current user
String serializedString = todo.toString();
Deserialization:
LCObject deserializedObject = LCObject.parseLCObject(serializedString);
deserializedObject.save(); // Save to the cloud
Queries
We've already seen how you can retrieve a single object from the cloud with LCObject, but it doesn't seem to be powerful enough when you need to retrieve multiple objects that match certain conditions at once. In such a situation, LCQuery would be a more efficient tool you can use.
Basic Queries
The general steps of performing a basic query include:
- Creating
LCQuery. - Putting conditions on it.
- Retrieving an array of objects matching the conditions.
The code below retrieves all Student objects whose lastName is Smith:
LCQuery<LCObject> query = new LCQuery<>("Student");
query.whereEqualTo("lastName", "Smith");
query.findInBackground().subscribe(new Observer<List<LCObject>>() {
public void onSubscribe(Disposable disposable) {}
public void onNext(List<LCObject> students) {
// students is an array of Student objects satisfying conditions
}
public void onError(Throwable throwable) {}
public void onComplete() {}
});
Query Constraints
There are several ways to put constraints on the objects found by LCObject.
The code below filters out objects with Jack as firstName:
query.whereNotEqualTo("firstName", "Jack");
For sortable types like numbers and strings, you can use comparisons in queries:
// Restricts to age < 18
query.whereLessThan("age", 18);
// Restricts to age <= 18
query.whereLessThanOrEqualTo("age", 18);
// Restricts to age > 18
query.whereGreaterThan("age", 18);
// Restricts to age >= 18
query.whereGreaterThanOrEqualTo("age", 18);
You can apply multiple constraints to a single query, and objects will only be in the results if they match all of the constraints. In other words, it's like concatenating constraints with AND:
query.whereEqualTo("firstName", "Jack");
query.whereGreaterThan("age", 18);
You can limit the number of results by setting limit (defaults to 100):
// Get at most 10 results
query.limit(10);
For performance reasons, the maximum value allowed for limit is 1000, meaning that the cloud would only return 1,000 results even if it is set to be greater than 1000.
If you need exactly one result, you may use getFirstInBackground for convenience:
LCQuery<LCObject> query = new LCQuery<>("Todo");
query.whereEqualTo("priority", 2);
query.getFirstInBackground().subscribe(new Observer<LCObject>() {
public void onSubscribe(Disposable disposable) {}
public void onNext(LCObject todo) {
// todo is the first Todo object satisfying conditions
}
public void onError(Throwable throwable) {}
public void onComplete() {}
});
You can skip a certain number of results by setting skip:
// Skip the first 20 results
query.skip(20);
You can implement pagination in your app by using skip together with limit:
LCQuery<LCObject> query = new LCQuery<>("Todo");
query.whereEqualTo("priority", 2);
query.limit(10);
query.skip(20);
Keep in mind that the higher the skip goes, the slower the query will run. You may consider using createdAt or updatedAt (which are indexed) to set range boundaries for large datasets to make queries more efficient.
You may also use the last value returned from an auto-increment field along with limit for pagination.
For sortable types, you can control the order in which results are returned:
// Sorts the results in ascending order by the createdAt property
query.orderByAscending("createdAt");
// Sorts the results in descending order by the createdAt property
query.orderByDescending("createdAt");
You can even attach multiple sorting rules to a single query:
query.addAscendingOrder("priority");
query.addDescendingOrder("createdAt");
To retrieve objects that have or do not have particular fields:
// Finds objects that have the "images" field
query.whereExists("images");
// Finds objects that don't have the 'images' field
query.whereDoesNotExist("images");
You can restrict the fields returned by providing a list of keys with selectKeys. The code below retrieves todos with only the title and content fields (and also special built-in fields including objectId, createdAt, and updatedAt):
LCQuery<LCObject> query = new LCQuery<>("Todo");
query.selectKeys(Arrays.asList("title", "content"));
query.getFirstInBackground().subscribe(new Observer<LCObject>() {
public void onSubscribe(Disposable disposable) {}
public void onNext(LCObject todo) {
String title = todo.getString("title"); // √
String content = todo.getString("content"); // √
String notes = todo.getString("notes"); // An error will occur
}
public void onError(Throwable throwable) {}
public void onComplete() {}
});
You can add a minus prefix to the attribute name for inverted selection.
For example, if you do not care about the post author, use -author.
The inverted selection also applies to preserved attributes and can be used with dot notations, e.g., -pubUser.createdAt.
The unselected fields can be fetched later with fetchInBackground. See Refreshing Objects.
Queries on String Values
Use whereStartsWith to restrict to string values that start with a particular string. Similar to a LIKE operator in SQL, it is indexed so it is efficient for large datasets:
LCQuery<LCObject> query = new LCQuery<>("Todo");
// SQL equivalent: title LIKE 'lunch%'
query.whereStartsWith("title", "lunch");
Use whereContains to restrict to string values that contain a particular string:
LCQuery<LCObject> query = new LCQuery<>("Todo");
// SQL equivalent: title LIKE '%lunch%'
query.whereContains("title", "lunch");
Unlike whereStartsWith, whereContains can't take advantage of indexes, so it is not encouraged to be used for large datasets.
Please note that both whereStartsWith and whereContains perform case-sensitive matching, so the examples above will not look for string values containing Lunch, LUNCH, etc.
If you are looking for string values that do not contain a particular string, use whereMatches with regular expressions:
LCQuery<LCObject> query = new LCQuery<>("Todo");
// "title" without "ticket" (case-insensitive)
query.whereMatches("title", "^((?!ticket).)*$", "i");
However, performing queries with regular expressions as constraints can be very expensive, especially for classes with over 100,000 records. The reason behind this is that queries like this can't take advantage of indexes and will lead to exhaustive scanning of the whole dataset to find the matching objects. We recommend that you take a look at our In-App Searching feature, a full-text search solution we provide to improve your app's searching ability and user experience.
If you are facing performance issues with queries, please refer to Optimizing Performance for possible workarounds and best practices.
Queries on Array Values
The code below looks for all the objects with work as an element of its array field tags:
query.whereEqualTo("tags", "work");
To look for objects whose array field tags contains three elements:
query.whereSizeEqual("tags", 3);
You can also look for objects whose array field tags contains work, sales, and appointment:
query.whereContainsAll("tags", Arrays.asList("work", "sales", "appointment"));
To retrieve objects whose field matches any one of the values in a given list, you can use whereContainedIn instead of performing multiple queries. The code below constructs a query that retrieves todo items with priority to be 1 or 2:
// Single query
LCQuery<LCObject> priorityOneOrTwo = new LCQuery<>("Todo");
priorityOneOrTwo.whereContainedIn("priority", Arrays.asList(1, 2));
// Mission completed :)
// ---------------
// vs.
// ---------------
// Multiple queries
final LCQuery<LCObject> priorityOne = new LCQuery<>("Todo");
priorityOne.whereEqualTo("priority", 1);
final LCQuery<LCObject> priorityTwo = new LCQuery<>("Todo");
priorityTwo.whereEqualTo("priority", 2);
LCQuery<LCObject> priorityOneOrTwo = LCQuery.or(Arrays.asList(priorityOne, priorityTwo));
// Kind of verbose :(
Conversely, you can use whereNotContainedIn if you want to retrieve objects that do not match any of the values in a list.