Page updated Jan 16, 2024

Lazy loading and custom selection set

What is changing?

With the latest Amplify library for Swift, developers now have better query controls on connected models, described as lazy loading and eager loading a connected model.

  • API (GraphQL) now allows selection set customizations via the includes parameter to control the depth of the selection set specifying which connected model to eager load or lazy load.
  • Models now have extended support for lazy loading connected models to include @hasOne and @belongsTo relations.
  • DataStore (Swift) now has better support for cross platform app development. Model updates from Amplify Studio and the Amplify Libraries including JavaScript and Android can be now observed in real-time by DataStore apps built with Swift.
  • DataStore (Swift) now supports the bi-directional @hasOne data modeling use case.

Custom Selection Sets

Developers using API (GraphQL) can control the data returned from their GraphQL service. The request for a particular model can include or exclude connected models. Let’s take a look at the schema with Post and Comment models in the following examples. A comment belongs to a post and a post has many comments.

enum PostStatus { ACTIVE INACTIVE } type Post @model @auth(rules: [{ allow: public }]) { id: ID! title: String! rating: Int! status: PostStatus! comments: [Comment] @hasMany } type Comment @model @auth(rules: [{ allow: public }]) { id: ID! content: String post: Post @belongsTo }
1enum PostStatus {
2 ACTIVE
3 INACTIVE
4}
5
6type Post @model @auth(rules: [{ allow: public }]) {
7 id: ID!
8 title: String!
9 rating: Int!
10 status: PostStatus!
11 comments: [Comment] @hasMany
12}
13
14type Comment @model @auth(rules: [{ allow: public }]) {
15 id: ID!
16 content: String
17 post: Post @belongsTo
18}

Currently, developers querying for the Comment will contain the Post eager loaded:

let response = try await Amplify.API.query(request: .get(Comment.self, byId: "commentId")) if case .success(let queriedComment) = response { print("Queried Comment eager loaded post: \(queriedComment?.post)") }
1let response = try await Amplify.API.query(request: .get(Comment.self, byId: "commentId"))
2if case .success(let queriedComment) = response {
3 print("Queried Comment eager loaded post: \(queriedComment?.post)")
4}

With the new model types and library changes, the same request will no longer eager load the post. The post is lazy loaded from the GraphQL service at the time the post is accessed.

let response = try await Amplify.API.query(request: .get(Comment.self, byId: "commentId")) if case .success(let queriedComment) = response { print("Queried Comment \(String(describing: queriedComment))") // Lazy Load the post if let post = try await queriedComment?.post { print("Lazy loaded the post: \(post)") } }
1let response = try await Amplify.API.query(request: .get(Comment.self, byId: "commentId"))
2if case .success(let queriedComment) = response {
3 print("Queried Comment \(String(describing: queriedComment))")
4 // Lazy Load the post
5 if let post = try await queriedComment?.post {
6 print("Lazy loaded the post: \(post)")
7 }
8}

To achieve the previous behavior, specifying the model path using the new includes parameter:

let response = try await Amplify.API.query(request: .get(Comment.self, byId: "commentId", includes: { comment in [comment.post]} )) if case .success(let queriedComment) = response { print("Queried Comment \(String(describing: queriedComment))") if let post = try await queriedComment?.post { print("Eager loaded post: \(post)") } }
1let response = try await Amplify.API.query(request:
2 .get(Comment.self,
3 byId: "commentId",
4 includes: { comment in [comment.post]} ))
5if case .success(let queriedComment) = response {
6 print("Queried Comment \(String(describing: queriedComment))")
7
8 if let post = try await queriedComment?.post {
9 print("Eager loaded post: \(post)")
10 }
11}

This will populate the selection set of the post in the GraphQL document which indicates to your GraphQL service to retrieve the post model as part of the operation. Once you await on the post, the post model will immediately be returned without making a network request.

This customization extends to @hasMany relationships as well. Let's take for example, the queried post.

let response = try await Amplify.API.query(request: .get(Post.self, byId: "postId")) if case .success(let queriedPost) = response { print("Queried Post \(String(describing: queriedPost))") if let comments = queriedPost?.comments { // Lazy Load the comments try await comments.fetch() for comment in comments { print("Lazy loaded comment: \(comment)") } } }
1let response = try await Amplify.API.query(request:
2 .get(Post.self,
3 byId: "postId"))
4
5if case .success(let queriedPost) = response {
6 print("Queried Post \(String(describing: queriedPost))")
7 if let comments = queriedPost?.comments {
8 // Lazy Load the comments
9 try await comments.fetch()
10 for comment in comments {
11 print("Lazy loaded comment: \(comment)")
12 }
13 }
14}

The queried post allows you to lazy load the comments by calling fetch() and it will make a network request.

The comments can be eager loaded by including the post’s model path to the comment:

let response = try await Amplify.API.query(request: .get(Post.self, byId: "postId", includes: { post in [ post.comments] })) if case .success(let queriedPost) = response { print("Queried Post \(String(describing: queriedPost))") if let comments = queriedPost?.comments { for comment in comments { print("Eager loaded comment: \(comment)") } } }
1let response = try await Amplify.API.query(request:
2 .get(Post.self,
3 byId: "postId",
4 includes: { post in [ post.comments] }))
5
6if case .success(let queriedPost) = response {
7 print("Queried Post \(String(describing: queriedPost))")
8 if let comments = queriedPost?.comments {
9 for comment in comments {
10 print("Eager loaded comment: \(comment)")
11 }
12 }
13}

The network request for post includes the comments, eager loading the comments in a single network call.

This customization can be extended to including or excluding deeply connected models. If the Post and Comment each belong to a User specified by the field “author”, then a single request can be constructed to retrieve its nested models.

let response = try await Amplify.API.query(request: .get( Post.self, byId: "postId", includes: { post in [ post.author, post.comments, post.comments.author] }))
1let response = try await Amplify.API.query(request: .get(
2 Post.self,
3 byId: "postId",
4 includes: { post in [
5 post.author,
6 post.comments,
7 post.comments.author] }))

The post, its comments, and the author of the post and each of its comments will be retrieved in a single network call.

Lazy loading connected models

Whether you are using DataStore or API, once you have retrieved a model, you can traverse the model graph from a single model instance to its connected models through the APIs available.

For @hasOne and @belongsTo relations, access it by awaiting for the post. This will retrieve the model from your GraphQL service or local database in DataStore.

let comment = /* queried from Amplify.API or Amplify.DataStore, or lazy loaded from a post */ let post = try await comment.post let authorOfPost = try await post.author
1let comment = /* queried from Amplify.API or Amplify.DataStore, or lazy loaded from a post */
2let post = try await comment.post
3let authorOfPost = try await post.author

For @hasMany relations, call fetch() to load the posts. This will retrieve the list of models from your data source.

let post = /* queried from Amplify.API or Amplify.DataStore, or lazy loaded from a comment */ if let allCommentsForPost = post.comments { try await allCommentsForPost.fetch() for comment in allCommentsForPost { print("Comment \(comment) for post \(post)") } }
1let post = /* queried from Amplify.API or Amplify.DataStore, or lazy loaded from a comment */
2if let allCommentsForPost = post.comments {
3 try await allCommentsForPost.fetch()
4 for comment in allCommentsForPost {
5 print("Comment \(comment) for post \(post)")
6 }
7}

If there are additional pages of data available, hasNextPage() will return true. Call getNextPage() to get the next page of comments.

if allCommentsForPost.hasNextPage() { let nextPageOfComments = try await allCommentsForPost.getNextPage() }
1if allCommentsForPost.hasNextPage() {
2 let nextPageOfComments = try await allCommentsForPost.getNextPage()
3}

The following is a full example of lazy loading @belongsTo and @hasMany connected models.

let comment = /* queried from Amplify.API or Amplify.DataStore, or lazy loaded from a post */ guard let post = try await comment.post else { print("No post associated with this comment") return } let authorOfPost = try await post.author if let allCommentsForPost = post.comments { try await allCommentsForPost.fetch() for comment in allCommentsForPost { let commentAuthor = try await comment.author print("Author \(commentAuthor) wrote comment \(comment) for post \(post)") } if allCommentsForPost.hasNextPage() { let nextPageOfComments = try await allCommentsForPost.getNextPage() } }
1let comment = /* queried from Amplify.API or Amplify.DataStore, or lazy loaded from a post */
2guard let post = try await comment.post else {
3 print("No post associated with this comment")
4 return
5}
6let authorOfPost = try await post.author
7
8if let allCommentsForPost = post.comments {
9 try await allCommentsForPost.fetch()
10 for comment in allCommentsForPost {
11 let commentAuthor = try await comment.author
12 print("Author \(commentAuthor) wrote comment \(comment) for post \(post)")
13 }
14
15 if allCommentsForPost.hasNextPage() {
16 let nextPageOfComments = try await allCommentsForPost.getNextPage()
17 }
18}

The queried comment is used to lazy load its post. The author of the post is lazy loaded from the post. All of the comments for the post are lazy loaded as allCommentsForPost. For each comment, the author is loaded as commentAuthor. If there are more comments to load, hasNextPage() returns true and getNextPage() loads the next page from the underlying data source.

Cross platform app development with DataStore (Swift)

Developers building with DataStore (Swift) can now receive real-time model updates coming from other platforms such as Amplify Studio and Amplify JavaScript and Android libraries. Previously, model updates (save/update/deletes) from other platforms will not be observed successfully by your iOS/macOS app running DataStore (Swift). With the latest codegen and library changes, DataStore has been updated to successfully reconcile those model updates coming from other platforms, and will subsequently emit the event to your DataStore.observe API.

To try this out, launch your iOS app with verbose logging and DataStore started. Performing model updates using DataStore from any other platform and it will automatically be synchronized to your iOS/macOS app. You will see logs indicating that DataStore has successfully received and reconcile the model update immediately. To access the updates in your app, follow the Real time guide to observe updates of data.

DataStore (Swift) Bi-directional “has one” relationship support

DataStore (Swift) now supports Bi-directional “has one” relationship. Previously, due to Swift language limitations the generated Swift model types will not compile.

type Project @model { id: ID! name: String team: Team @hasOne } type Team @model { id: ID! name: String! project: Project @belongsTo }
1type Project @model {
2 id: ID!
3 name: String
4 team: Team @hasOne
5}
6
7type Team @model {
8 id: ID!
9 name: String!
10 project: Project @belongsTo
11}

Where do I make these changes?

  1. Update Amplify CLI to the latest version
amplify upgrade
1amplify upgrade
  1. The version should be at least 10.8.0
amplify --v # at least 10.8.0
1amplify --v # at least 10.8.0
  1. Set the feature flag generateModelsForLazyLoadAndCustomSelectionSet to true in cli.json at the amplify project root.

  2. Run amplify codegen models to generate the latest models.

  3. Upgrade Amplify libraries to 2.4.0 or greater.

  4. Open the App and make sure the app compiles with the latest generated models.

What are the breaking changes?

By explicitly enabling the feature flag generateModelsForLazyLoadAndCustomSelectionSet and using the latest Amplify Library, there are a few scenarios you may be in.

Scenario 1. Using API (GraphQL)

Amplify.API will no longer eager load the @belongsTo and @hasOne connected models when using the latest codegen. To allow your app backwards compatibility with previous versions of your app, specify the model path with includes for all @belongsTo and @hasOne relationships. This is crucial to allow previous versions of the app to decode mutations sourced from new versions of the app successfully.

Your released app makes subscription and mutation requests:

let subscription = Amplify.API.subscribe(request: .onCreate(Comment.self))
1let subscription = Amplify.API.subscribe(request: .onCreate(Comment.self))
let graphQLResponse = try await Amplify.API.mutate(request: .create(comment))
1let graphQLResponse = try await Amplify.API.mutate(request: .create(comment))

The selection set on the mutation request is aligned with the selection set on the subscription request. It will include the post fields and the response payload received by the subscription can be decoded to the previous Comment model type.

If the model types have been replaced with the latest codegen for lazy loading, the same mutation will no longer include the post fields, causing the subscription in the previous app to fail decoding the response payload. To make sure the new version of the app works with previous versions, include the @belongsTo and @hasOne connected models in the selection set using the includes parameter of your mutation request.

let graphQLResponse = try await Amplify.API.mutate(request: .create(comment, includes: { comment in [ comment.post ]}))
1let graphQLResponse = try await Amplify.API.mutate(request: .create(comment, includes: { comment in [ comment.post ]}))

Scenario 2. Using DataStore (Swift)

DataStore will no longer eager load the belongs-to and @hasOne connected models when using the latest codegen. Your new app will continue to be backwards compatible with previous versions, however the call pattern to retrieve these connected models have changed. See the next scenario for the changes you have to make at the call site.

Scenario 3. "Belongs to" / "Has One" access pattern

Previously

let comment = /* queried Comment through DataStore or API */ let post = comment.post
1let comment = /* queried Comment through DataStore or API */
2let post = comment.post

With the latest codegen

let comment = /* queried Comment through DataStore or API */ let post = try await comment.post
1let comment = /* queried Comment through DataStore or API */
2let post = try await comment.post