Data Store

The content in this guide refers to the newly released version 3 of the library. You can still access the version 1.x guides here.

Version 1.x is now deprecated. We will continue to support 1.x through March 31, 2017 (End of Support - EOS). If you are currently using 1.x in your apps, you will need to upgrade your apps to 3.x before the EOS date. We have created a migration guide to help you.

The simplest use case of Kinvey is storing and retrieving data to and from your cloud backend.

The basic unit of data is an entity and entities of the same kind are organized in collections. An entity is a set of key-value pairs which are stored in the backend in JSON format. Kinvey's libraries automatically translate your native objects to JSON.

Kinvey's data store provides simple CRUD operations on data, as well as powerful filtering and aggregation.

Each DataStore in your application represents a collection on your backend. Create a DataStore to start working with the data in a collection.

//we use the example of a "Book" datastore
let dataStore = DataStore<Book>.collection()

Through the remainder of this guide, we refer to the instance we just retrieved as dataStore.

By default, the library creates a DataStore of type Cache. To understand the types of datastores and when to use each type, please refer to the section on DataStore Types

Entities

The Kinvey service has the concept of entities, which represent a single resource.

Model

In the Kinvey iOS library, an entity is represented by a subclass of the Entity class.

import Kinvey

class Book: Entity {

    dynamic var title: String?
    dynamic var authorName: String?
    dynamic var publishDate: NSDate?

    override class func collectionName() -> String {
        //return the name of the backend collection corresponding to this entity
        return "Book"
    }

    //Map properties in your backend collection to the members of this entity
    override func propertyMapping(map: Map) {
        //This maps the "_id", "_kmd" and "_acl" properties
        super.propertyMapping(map)

        //Each property in your entity should be mapped using the following scheme:
        //<member variable> <- ("<backend property>", map["<backend property>"])
        title <- ("title", map["title"])
        authorName <- ("authorName", map["author"])

        //this maps the backend property "date" to the member variable "publishDate" using the Kinvey date transform
        publishDate <- ("publishDate", map["date"], KinveyDateTransform())

    }
}
import Kinvey

class Book: Entity {

    dynamic var title: String?
    dynamic var authorName: String?
    dynamic var publishDate: Date?

    override class func collectionName() -> String {
        //return the name of the backend collection corresponding to this entity
        return "Book"
    }

    //Map properties in your backend collection to the members of this entity
    override func propertyMapping(_ map: Map) {
        //This maps the "_id", "_kmd" and "_acl" properties
        super.propertyMapping(map)

        //Each property in your entity should be mapped using the following scheme:
        //<member variable> <- ("<backend property>", map["<backend property>"])
        title <- ("title", map["title"])
        authorName <- ("authorName", map["author"])

        //this maps the backend property "date" to the member variable "publishDate" using a ISO 8601 date transform
        publishDate <- ("publishDate", map["date"], KinveyDateTransform())
    }
}

Supported Types

The following property types are supported by the library:

Swift TypeJSON TypeNotes
Stringstring
Int, Float, DoublenumberVariations of Int, such asInt8, Int16, Int32, Int64, are also supported
Boolboolean
DatestringKinvey uses the ISO8601 date format. Use KinveyDateTransform to map date objects
GeoPointarrayRefer to the location guide to understand how to persist geolocation information

The library also provides a way to represent lists of each supported primitive type, as shown below:

Swift TypeSample code
Stringlet authorNames = List<StringValue>()
Intlet editionsYear = List<IntValue>()
Floatlet editionsRetailPrice = List<FloatValue>()
Doublelet editionsRating = List<DoubleValue>()
Boollet editionsAvailable = List<BoolValue>()
Tlet authors = List<Author>

  • List<T> types must be unmutable using the keyword let instead of the usual declaration dynamic var

  • For List<T> to work wih a custom object type such as Author, the type must adhere to the guidelines listed in the custom types section.

Custom Types

This section describes the guidelines to persist a type that is not covered in Kinvey's supported types, including any types that you define in your app.

Enabling your Type to be Cached

The Kinvey iOS SDK uses Realm for persisting data locally in cache. Caching for all the Kinvey-supported types is handled automatically by the SDK. For custom types, you need to extend the Realm Object class to declare that your type should be cached.

When you subclass RealmSwift.Object, all the properties of your custom type are enabled for caching, as long as they are in the Realm supported types. If you need help defining the properties of your type, take a look at this cheatsheet.

Mapping Your Type to JSON

The Kinvey iOS SDK uses the ObjectMapper library to map JSON from the backend to the entities in the app. To persist your custom type to the backend, you need to tell Kinvey how to map your object to and from JSON. ObjectMapper provides you with two ways to do this:

  • Implement Mappable: if your custom type is a class with properties that easily map to Kinvey-supported types, you should extend the Mappable protocol.
import RealmSwift
import ObjectMapper

class Person: Entity {

    dynamic var name: String?
    dynamic var address: Address?

    override class func collectionName() -> String {
        //name of the collection in your Kinvey console
        return "Person"
    }

    override func propertyMapping(_ map: Map) {
        super.propertyMapping(map)

        name <- ("name", map["name"])
        address <- ("address", map["address"])
    }
}

class Address: Object, Mappable {

    dynamic var city: String?
    dynamic var state: String?
    dynamic var country: String?

    convenience required init?(map: Map) {
        self.init()
    }

    func mapping(map: Map) {
        city <- ("city", map["city"])
        state <- ("state", map["state"])
        country <- ("country", map["country"])
    }    
}
  • Implement a custom transform: Custom Transforms provide a flexible way to map complex types. Your transform programmatically defines the mapping of your custom type to JSON.
class Person: Entity {

    dynamic var name: String?

    //Here the Person has an imageURL of type URL, which is not one of the supported Types
    //To allow the imageURL to be persisted in the cache, we use a computed variable of type String
    private dynamic var imageURLValue: String?

    var imageURL: URL? {
        get {
            if let urlValue = imageURLValue {
                return URL(string: urlValue)
            }
            return nil
        }
        set {
            imageURLValue = newValue?.absoluteString
        }
    }

    override class func collectionName() -> String {
        return "Person"
    }

    override func propertyMapping(_ map: Map) {
        super.propertyMapping(map)

        name <- ("name", map["name"])
        imageURL <- ("imageURL", map["imageURL"], URLTransform())
    }

}

//This transform allows the URL type to be mapped to JSON in the Kinvey backend
class URLTransform: TransformType {

    public typealias Object = URL
    public typealias JSON = String

    open func transformFromJSON(_ value: Any?) -> URL? {
        guard let urlString = value as? String else {
            return nil
        }
        return URL(string: urlString)
    }

    open func transformToJSON(_ value: URL?) -> String? {
        return value?.absoluteString
    }
}

Saving

You can save an entity by calling dataStore.save.

dataStore.save(book) { book, error in
    if let book = book {
        //succeed
        print("Book: \(book)")
    } else {
        //fail
    }
}

The library uses the _id property of the entity to distinguish between updates and inserts.

  • If the entity has an _id, the library treats it as an update to an existing entity.

  • If the entity does not have an _id, the library treats it as a new entity. The Kinvey backend assigns an automatic _id for a new entity.

Fetching

You can retrieve entities by either looking them up using an id, or by querying a collection.

Fetching by Id

To fetch an (one) entity by id, call dataStore.findById.

let id = "<#entity id#>"
dataStore.findById(id) { book, error in
    if let book = book {
        //succeed
        print("Book: \(book)")
    } else {
        //fail
    }
}
let id = "<#entity id#>"
dataStore.find(byId: id) { book, error in
    if let book = book {
        //succeed
        print("Book: \(book)")
    } else {
        //fail
    }
}

Fetching by Query

To fetch all entities in a collection, call dataStore.find.

dataStore.find() { books, error in
    if let books = books {
        //succeed
        print("Books: \(books)")
    } else {
        //fail
    }
}

To fetch multiple entities using a query, call dataStore.find and pass in a query.

let query = Query(format: "title == %@", "The Hobbit")

dataStore.find(query) { books, error in
    if let books = books {
        //succeed
        print("Books: \(books)")
    } else {
        //fail
    }
}

Deleting

To delete an entity, call dataStore.removeById and pass in the entity _id.

let id = "<#entity id#>"
dataStore.removeById(id) { count, error in
    if let count = count {
        //succeed
        print("Count: \(count)")
    } else {
        //fail
    }
}

Deleting Multiple Entities at Once

To delete multiple entities at once, call dataStore.remove. Optionally, you can pass in a query to only delete entities matching the query.

let query = Query(format: "title == %@", "The Hobbit")

dataStore.remove(query) { count, error in
    if let count = count {
        //succeed
        print("Count: \(count)")
    } else {
        //fail
    }
}

Querying

Queries to the backend are represented as instances of Query. Queries have the ability to gather filter statements using NSPredicates and order statements using NSSortDescriptors. These queries are used with DataStore methods.

Query provides a number of factory methods to query-creating convenience.

The default constructor makes a query that matches all values in a collection.

let query = Query()

Query instances can be created the same way that NSPredicate instances are created. For instance, the code below show how to find all entities that match the exactly supplied value.

let query = Query(format: "location == %@", "Mike's House")

Query(format: "field = nil") creates a query that matches entities where the specified field is empty or unset. This will not match fields with the value null.

For a complete guide of NSPredicate, please check the Predicate Programming Guide

Operators

In addition to exact match and null queries, you can query based upon an several types of expressions: comparison, set match, string and array operations.

Conditional queries are built in Query.

Comparison operators

  • =, == The left-hand expression is equal to the right-hand expression.
  • >=, => The left-hand expression is greater than or equal to the right-hand expression.
  • <=, =< The left-hand expression is less than or equal to the right-hand expression.
  • > The left-hand expression is greater than the right-hand expression.
  • < The left-hand expression is less than the right-hand expression.
  • !=, <> The left-hand expression is not equal to the right-hand expression.
  • BETWEEN The left-hand expression is between, or equal to either of, the values specified in the right-hand side. The right-hand side is a two value array (an array is required to specify order) giving upper and lower bounds. For example, 1 BETWEEN { 0 , 33 }, or $INPUT BETWEEN { $LOWER, $UPPER }.

For a complete guide of NSPredicate Syntax, please check the Predicate Format String Syntax

Modifiers

Queries can also have optional modifiers to control how the results are presented. Query allows for limit & skip modifiers and sorting.

Limit and Skip Modifiers

Limit and Skip allow for paging of results. For example, if there are 100 possible results, you an break them up into pages of 20 items by setting the limit to 20, and then incrementing the skip modifier by 0, 20, 40, 60, and 80 in separate queries to eventually retrieve them all.

let pageOne = Query {
    $0.skip = 0
    $0.limit = 20
}

let pageTwo = Query {
    $0.skip = 20
    $0.limit = 20
}

...

There is an automatic limit of 10,000 entities on any query. If exactly 10,000 items are returned and you think there may be more (you can use count to find the total number), then retry with limit and skip modifier to get all the entities.

Sort

Query results can also be sorted by adding sort modifiers. Queries are sorted by field values in ascending or descending order, by using NSSortDescriptors.

For example,

let predicate = NSPredicate(format: "name == %@", "Ronald")
let sortDescriptor = NSSortDescriptor(key: "date", ascending: true)
let query = Query(predicate: predicate, sortDescriptors: [sortDescriptor])

To sort by a field and then sub-sort those results by another field, just keep adding additional sort modifiers.

Compound Queries

You can make expressive queries by stringing together several queries. For example, you can query for dates in a range or events at a particular day and place.

NSCompoundPredicate can be used to create a query that is a combination of conditionals. For example, the following code uses a NSCompoundPredicate to query for cities with 2 different filter statements combined by an AND operator.

//Search for cities in Massachusetts with a population bigger than 50k.
let query = Query(format: "state == %@ AND population >= %@", "MA", 50000)

The code below does the same thing, but using NSCompoundPredicate.

let predicateState = NSPredicate(format: "state == %@", "MA")
let predicatePopulation = NSPredicate(format: "population >= %@", 50000)
let predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [predicateState, predicatePopulation])
let query = Query(predicate: predicate)

Special Keys

The following pre-defined constant keys map to Kinvey-specific fields. These can be used in querying and sorting.

  • PersistableIdKey the object id
  • PersistableAclKey the ACL object which containts the creator of the object
  • PersistableMetadataKey the metadata object which contains the entity create time and last modification time

Reduce Function

There are five pre-defined reduce functions:

  • @count - counts all elements in the group
  • @sum: - sums together the numeric values for the input field
  • @min: - finds the minimum of the numeric values of the input field
  • @max: - finds the maximum of the numeric values of the input field
  • @avg: - finds the average of the numeric values of the input field

Counting

To count the number of objects in a collection, use the @count keyword.

let query = Query(format: "children.@count == 2")

To count the number of entities in a collection you should use DataStore.count()

dataStore.count { (count, error) in
    if let count = count {
        //succeed
        print("Count: \(count)")
    } else {
        //fail
    }
}

You can also pass an optional query parameter to DataStore.count() in order to performe a count operation in a specific query

let query: Query = <#Query(format: "myProperty == %@", myValue)#>
dataStore.count(query) { (count, error) in
    if let count = count {
        //succeed
        print("Count: \(count)")
    } else {
        //fail
    }
}

Aggregation / Grouping

DataStore.group() is a overloaded method that allows you do groupig and aggregation against your data in the backend. You can write your own reduce function using JavaScript or use pre-defined functions like:

  • Count
  • Sum
  • Average (avg)
  • Min (minimum)
  • Max (maximum)

For example:

dataStore.group(keys: ["country"], avg: "age") { (result: Result<[AggregationAvgResult<Person, Int>], Swift.Error>) in
    switch result {
    case .success(let array):
        //succeed
    case .failure(let error):
        //failed
    }
}

If you wold like to see additional functions, email support@kinvey.com.

DataStore Types

A key aspect of good mobile apps is their ability to render responsive UIs, even under conditions of poor or missing network connectivity. The Kinvey library provides caching and offline capabilities for you to easily manage data access and synchronization between the device and the backend.

Kinvey’s DataStore provides configuration options to solve common caching and offline requirements.

When you get an instance of a datastore in your application, select which scenario from the following list most closely resembles your data requirements:

  1. You want a copy of some (or all) the data from your backend to be available locally on the device and you'd like to sync it periodically with the backend then you would use DataStoreType.Sync

  2. You want data stored on the device to optimize your app’s performance and/or provide offline support for short periods of network loss then you would use DataStoreType.Cache

  3. You want the data in your backend to never be stored locally on the device, even if it means that the app cannot support offline usage then you would use DataStoreType.Network

Sync

Configuring your datastore as a Sync datastore allows you to pull a copy of your data to the device and work with it completely offline. The library provides APIs to synchronize local data with the backend.

This type of datastore is ideal for apps that need to work for long periods without a network connection.

Here is how you should use a Sync store -

// Get an instance
let dataStore = DataStore<Book>.collection(.Sync)

// Pull data from your backend and save it locally on the device.
dataStore.pull() { (books, error) -> Void in
    if let books = books {
        //succeed
        print("Books: \(books)")
    } else {
        //fail
    }
}

// Find data locally on the device.
dataStore.find() { (books, error) -> Void in
    if let books = books {
        //succeed
        print("Books: \(books)")
    } else {
        //fail
    }
}

// Save an entity locally on the device. This will add the item to the
// sync table to be pushed to your backend at a later time.

let book = Book()
dataStore.save(book) { (book, error) -> Void in
    if let book = book {
        //succeed
        print("Book: \(book)")
    } else {
        //fail
    }
}

// Sync local data with the backend
// This will push data that is saved locally on the device to your backend;
// and then pull any new data on the backend and save it locally.
dataStore.sync() { (count, books, error) -> Void in
    if let books = books, let count = count {
        //succeed
        print("\(count) Books: \(books)")
    } else {
        //fail
    }
}
// Get an instance
let dataStore = DataStore<Book>.collection(.sync)

// Pull data from your backend and save it locally on the device.
dataStore.pull() { (books, error) -> Void in
    if let books = books {
        //succeed
        print("Books: \(books)")
    } else {
        //fail
    }
}

// Find data locally on the device.
dataStore.find() { (books, error) -> Void in
    if let books = books {
        //succeed
        print("Books: \(books)")
    } else {
        //fail
    }
}

// Save an entity locally on the device. This will add the item to the
// sync table to be pushed to your backend at a later time.

let book = Book()
dataStore.save(book) { (book, error) -> Void in
    if let book = book {
        //succeed
        print("Book: \(book)")
    } else {
        //fail
    }
}

// Sync local data with the backend
// This will push data that is saved locally on the device to your backend;
// and then pull any new data on the backend and save it locally.
dataStore.sync() { (count, books, error) -> Void in
    if let books = books, let count = count {
        //succeed
        print("\(count) Books: \(books)")
    } else {
        //fail
    }
}

The pull, push and sync APIs allow you to to synchronize data between the application and the backend. This section describes how these APIs work with examples.

Pull

Calling pull() retrieves data from the backend and stores it locally in the Sync Store.

By default, pulling will retrieve the entire collection to the device. Optionally, you can provide a query parameter to pull to restrict what entities are retrieved. If you prefer to only retrieve the changes since your last pull operation, you should enable delta set caching.

The pull API needs a network connection in order to succeed.

let dataStore = DataStore<Book>.collection(.Sync)

//In this example, we pull all the data in the collection from the backend
//to the Sync Store
dataStore.pull { books, error in
    if let books = books {
        //succeed
        print("Books: \(books)")
    } else {
        //fail
    }
}
let dataStore = DataStore<Book>.collection(.sync)

//In this example, we pull all the data in the collection from the backend
//to the Sync Store
dataStore.pull { books, error in
    if let books = books {
        //succeed
        print("Books: \(books)")
    } else {
        //fail
    }
}

If your Sync Store has pending local changes, they must be pushed to the backend before pulling data to the store.

Push

Calling push() kicks off a uni-directional push of data from the library to the backend.

The library goes through the following steps to push entities modified in local storage to the backend -

  • Reads from the "pending writes queue" to determine what entities have been changed locally. The "pending writes queue" maintains a reference for each entity in local storage that has been modified by the app. For an entity that gets modified multiple times in local storage, the queue only references the last modification on the entity.

  • Creates a REST API request for each pending change in the queue. The type of request depends on the type of modification that was performed locally on the entity.

    • If an entity is newly created, the library builds a POST request.

    • If an entity is modified, the library builds a PUT request.

    • If an entity is deleted, the library builds a DELETE request.

  • Makes the REST API requests against the backend concurrently. Requests are batched to avoid hitting platform limits on the number of open network requests.

    • For each successful request, the corresponding reference in the queue is removed.

    • For each failed request, the corresponding reference remains persisted in the queue. The library adds information in the push/sync response to indicate that a failure occurred. Push failures are discussed below.

  • Returns a response to the application indicating the count of entities that were successfully synced, and a list of errors for entities that failed to sync.

let dataStore = DataStore<Book>.collection(.Sync)

//In this example, we push all local data in this datastore to the backend
//No data is retrieved from the backend.
dataStore.push { count, error in
    if let count = count {
        //succeed
        print("Count: \(count)")
    } else {
        //fail
    }
}
let dataStore = DataStore<Book>.collection(.sync)

//In this example, we push all local data in this datastore to the backend
//No data is retrieved from the backend.
dataStore.push { count, error in
    if let count = count {
        //succeed
        print("Count: \(count)")
    } else {
        //fail
    }
}
Handling Failures

The push response contains information about the entities that failed to push to the backend. For each failed entity, the corresponding reference in the pending writes queue is retained. This is to prevent any data loss during the push operation. The developer can decide how to handle failures. Here are some options -

  • Retry pushing your changes at a later time. You can simply call push again on the datastore to attempt again.
  • Ignore the failed changes. You can call purge on the datastore, which will remove all pending writes from the queue. The failed entity remains in your local cache, but the library will not attempt to push it again to the backend.
  • Destroy the local cache. You call clear on the datastore, which destroys the local cache for the store. You will need to pull fresh data from the backend to start using the cache again.

Each of the APIs mentioned in this section are described in more detail in the datastore API reference.

Sync

Calling sync() on a Sync Store kicks off a bi-directional synchronization of data between the library and the backend. First, the library calls push to send local changes to the backend. Subsequently, the library calls pull to fetch data in the collection from the backend and stores it on the device.

You can provide a query as a parameter to the sync API, to restrict the data that is pulled from the backend. The query does not affect what data gets pushed to the backend. If you prefer to only retrieve the changes since your last sync operation, you should enable delta set caching.

let dataStore = DataStore<Book>.collection(.Sync)

//In this sample, we push all local data for this datastore to the backend, and then
//pull the entire collection from the backend to the local storage
dataStore.sync() { count, books, error in
    if let books = books, let count = count {
        //succeed
        print("\(count) Books: \(books)")
    } else {
        //fail
    }
}

//In this example, we restrict the data that is pulled from the backend to the local storage,
//by specifying a query in the sync API
let query = Query()

dataStore.sync(query) { count, books, error in
    if let books = books, let count = count {
        //succeed
        print("\(count) Books: \(books)")
    } else {
        //fail
    }
}
let dataStore = DataStore<Book>.collection(.sync)

//In this sample, we push all local data for this datastore to the backend, and then 
//pull the entire collection from the backend to the local storage
dataStore.sync() { count, books, error in
    if let books = books, let count = count {
        //succeed
        print("\(count) Books: \(books)")
    } else {
        //fail
    }
}

//In this example, we restrict the data that is pulled from the backend to the local storage,
//by specifying a query in the sync API
let query = Query()

dataStore.sync(query) { count, books, error in
    if let books = books, let count = count {
        //succeed
        print("\(count) Books: \(books)")
    } else {
        //fail
    }
}

Sync Count

You can retrieve a count of entities modified locally and pending a push to the backend.

// Number of entities modified offline.
let count = dataStore.syncCount()

Cache

Configuring your datastore as a Cache datastore allows you to use the performance optimizations provided by the library. In addition, the cache allows you to work with data when the device goes offline.

This type of datastore is ideal for apps that are generally used with an active network, but may experience short periods of network loss.

Here is how you should use a Cache store -

// Get an instance
let dataStore = DataStore<Book>.collection(.Cache)

// The completion handler block will be called twice,
// #1 call: will return the cached data stored locally
// #2 call: will return the data retrieved from the network
dataStore.find() { books, error in
    if let books = books {
        //succeed
        print("Books: \(books)")
    } else {
        //fail
    }
}

// Save an entity. The entity will be saved to the device and your backend.
// If you do not have a network connection, the entity will be stored locally. You will need to push it to the server when network reconnects, using dataStore.push().
let book = Book()
dataStore.save(book) { book, error in
    if let book = book {
        //succeed
        print("Book: \(book)")
    } else {
        //fail
    }
}
// Get an instance
let dataStore = DataStore<Book>.collection(.cache)

// The completion handler block will be called twice,
// #1 call: will return the cached data stored locally
// #2 call: will return the data retrieved from the network
dataStore.find() { books, error in
    if let books = books {
        //succeed
        print("Books: \(books)")
    } else {
        //fail
    }
}

// Save an entity. The entity will be saved to the device and your backend. 
// If you do not have a network connection, the entity will be stored locally. You will need to push it to the server when network reconnects, using dataStore.push().
let book = Book()
dataStore.save(book) { book, error in
    if let book = book {
        //succeed
        print("Book: \(book)")
    } else {
        //fail
    }
}

The Cache Store executes all CRUD requests against local storage as well as the backend. Any data retrieved from backend is stored in the cache. This allows the app to work offline by fetching data that has been cached from past usage.

The Cache Store also stores pending write operations when the app is offline. However, the developer is required to push these pending operations to the backend when the network resumes. The push API should be used to accomplish this.

Network

Configuring your datastore as Network turns off all caching in the library. All requests to fetch and save data are sent to the backend.

We don’t recommend this type of datastore for apps in production, since the app will not work without network connectivity. However, it may be useful in a development scenario to validate backend data without a device cache.

Here is how you should use a Network store -

// Get an instance
let dataStore = DataStore<Book>.collection(.Network)

// Retrieve data from your backend
dataStore.find() { (books, error) -> Void in
    if let books = books {
        //succeed
        print("Books: \(books)")
    } else {
        //fail
    }
}

// Save an entity to your backend.
let book = Book()
dataStore.save(book) { (book, error) -> Void in
    if let book = book {
        //succeed
        print("Book: \(book)")
    } else {
        //fail
    }
}
// Get an instance
let dataStore = DataStore<Book>.collection(.network)

// Retrieve data from your backend
dataStore.find() { (books, error) -> Void in
    if let books = books {
        //succeed
        print("Books: \(books)")
    } else {
        //fail
    }
}

// Save an entity to your backend.
let book = Book()
dataStore.save(book) { (book, error) -> Void in
    if let book = book {
        //succeed
        print("Book: \(book)")
    } else {
        //fail
    }
}

Related Samples

Got a question?