├── .github └── workflows │ └── build.yml ├── .gitignore ├── .swiftformat ├── LICENSE ├── Package.resolved ├── Package.swift ├── README.md ├── Sources ├── AsyncDataLoader │ ├── Channel │ │ ├── Channel.swift │ │ └── State.swift │ ├── DataLoader.swift │ ├── DataLoaderError.swift │ └── DataLoaderOptions.swift └── DataLoader │ ├── DataLoader.swift │ ├── DataLoaderError.swift │ └── DataLoaderOptions.swift └── Tests ├── AsyncDataLoaderTests ├── DataLoaderAbuseTests.swift └── DataLoaderTests.swift └── DataLoaderTests ├── DataLoaderAbuseTests.swift ├── DataLoaderAsyncTests.swift └── DataLoaderTests.swift /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | paths-ignore: [ README.md ] 7 | pull_request: 8 | branches: [ main ] 9 | paths-ignore: [ README.md ] 10 | workflow_dispatch: 11 | 12 | jobs: 13 | formatlint: 14 | name: Format linting 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v3 19 | - name: Set up Docker Buildx 20 | uses: docker/setup-buildx-action@v2 21 | - name: Pull formatting docker image 22 | run: docker pull ghcr.io/nicklockwood/swiftformat:latest 23 | - name: Run format linting 24 | run: docker run --rm -v ${{ github.workspace }}:/repo ghcr.io/nicklockwood/swiftformat:latest /repo --lint 25 | 26 | macos: 27 | name: Test on macOS 28 | runs-on: macOS-latest 29 | steps: 30 | - uses: maxim-lobanov/setup-xcode@v1 31 | with: 32 | xcode-version: latest-stable 33 | - uses: actions/checkout@v3 34 | - name: Build and test 35 | run: swift test --parallel --enable-test-discovery 36 | 37 | linux: 38 | name: Test on Linux 39 | runs-on: ubuntu-latest 40 | steps: 41 | - uses: swift-actions/setup-swift@v2 42 | - uses: actions/checkout@v3 43 | - name: Test 44 | run: swift test --parallel --enable-code-coverage 45 | - name: Get test coverage html 46 | run: | 47 | llvm-cov show \ 48 | $(swift build --show-bin-path)/DataLoaderPackageTests.xctest \ 49 | --instr-profile $(swift build --show-bin-path)/codecov/default.profdata \ 50 | --ignore-filename-regex="\.build|Tests" \ 51 | --format html \ 52 | --output-dir=.test-coverage 53 | - name: Upload test coverage html 54 | uses: actions/upload-artifact@v3 55 | with: 56 | name: test-coverage-report 57 | path: .test-coverage 58 | 59 | backcompat-ubuntu-22_04: 60 | name: Test Swift ${{ matrix.swift }} on Ubuntu 22.04 61 | runs-on: ubuntu-22.04 62 | strategy: 63 | matrix: 64 | swift: ["5.9", "5.10"] 65 | steps: 66 | - uses: swift-actions/setup-swift@v2 67 | with: 68 | swift-version: ${{ matrix.swift }} 69 | - uses: actions/checkout@v3 70 | - name: Test 71 | run: swift test --parallel -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## User settings 6 | xcuserdata/ 7 | 8 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 9 | *.xcscmblueprint 10 | *.xccheckout 11 | 12 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 13 | build/ 14 | DerivedData/ 15 | *.moved-aside 16 | *.pbxuser 17 | !default.pbxuser 18 | *.mode1v3 19 | !default.mode1v3 20 | *.mode2v3 21 | !default.mode2v3 22 | *.perspectivev3 23 | !default.perspectivev3 24 | 25 | ## Obj-C/Swift specific 26 | *.hmap 27 | 28 | ## App packaging 29 | *.ipa 30 | *.dSYM.zip 31 | *.dSYM 32 | 33 | ## Playgrounds 34 | timeline.xctimeline 35 | playground.xcworkspace 36 | 37 | # Swift Package Manager 38 | # 39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 40 | # Packages/ 41 | # Package.pins 42 | # Package.resolved 43 | # *.xcodeproj 44 | # 45 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 46 | # hence it is not needed unless you have added a package configuration file to your project 47 | .swiftpm 48 | 49 | .build/ 50 | 51 | # CocoaPods 52 | # 53 | # We recommend against adding the Pods directory to your .gitignore. However 54 | # you should judge for yourself, the pros and cons are mentioned at: 55 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 56 | # 57 | # Pods/ 58 | # 59 | # Add this line if you want to avoid checking in source code from the Xcode workspace 60 | # *.xcworkspace 61 | 62 | # Carthage 63 | # 64 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 65 | # Carthage/Checkouts 66 | 67 | Carthage/Build/ 68 | 69 | # Accio dependency management 70 | Dependencies/ 71 | .accio/ 72 | 73 | # fastlane 74 | # 75 | # It is recommended to not store the screenshots in the git repo. 76 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 77 | # For more information about the recommended setup visit: 78 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 79 | 80 | fastlane/report.xml 81 | fastlane/Preview.html 82 | fastlane/screenshots/**/*.png 83 | fastlane/test_output 84 | 85 | # Code Injection 86 | # 87 | # After new code Injection tools there's a generated folder /iOSInjectionProject 88 | # https://github.com/johnno1962/injectionforxcode 89 | 90 | iOSInjectionProject/ 91 | 92 | # VS Code 93 | .vscode/ 94 | -------------------------------------------------------------------------------- /.swiftformat: -------------------------------------------------------------------------------- 1 | --maxwidth 100 2 | --semicolons never 3 | --xcodeindentation enabled 4 | --wraparguments before-first 5 | --wrapcollections before-first 6 | --wrapconditions before-first 7 | --wrapparameters before-first 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Kim de Vos 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "async-collections", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/adam-fowler/async-collections", 7 | "state" : { 8 | "revision" : "726af96095a19df6b8053ddbaed0a727aa70ccb2", 9 | "version" : "0.1.0" 10 | } 11 | }, 12 | { 13 | "identity" : "swift-algorithms", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/apple/swift-algorithms.git", 16 | "state" : { 17 | "revision" : "f6919dfc309e7f1b56224378b11e28bab5bccc42", 18 | "version" : "1.2.0" 19 | } 20 | }, 21 | { 22 | "identity" : "swift-atomics", 23 | "kind" : "remoteSourceControl", 24 | "location" : "https://github.com/apple/swift-atomics.git", 25 | "state" : { 26 | "revision" : "cd142fd2f64be2100422d658e7411e39489da985", 27 | "version" : "1.2.0" 28 | } 29 | }, 30 | { 31 | "identity" : "swift-collections", 32 | "kind" : "remoteSourceControl", 33 | "location" : "https://github.com/apple/swift-collections.git", 34 | "state" : { 35 | "revision" : "671108c96644956dddcd89dd59c203dcdb36cec7", 36 | "version" : "1.1.4" 37 | } 38 | }, 39 | { 40 | "identity" : "swift-nio", 41 | "kind" : "remoteSourceControl", 42 | "location" : "https://github.com/apple/swift-nio.git", 43 | "state" : { 44 | "revision" : "f7dc3f527576c398709b017584392fb58592e7f5", 45 | "version" : "2.75.0" 46 | } 47 | }, 48 | { 49 | "identity" : "swift-numerics", 50 | "kind" : "remoteSourceControl", 51 | "location" : "https://github.com/apple/swift-numerics.git", 52 | "state" : { 53 | "revision" : "0a5bc04095a675662cf24757cc0640aa2204253b", 54 | "version" : "1.0.2" 55 | } 56 | }, 57 | { 58 | "identity" : "swift-system", 59 | "kind" : "remoteSourceControl", 60 | "location" : "https://github.com/apple/swift-system.git", 61 | "state" : { 62 | "revision" : "c8a44d836fe7913603e246acab7c528c2e780168", 63 | "version" : "1.4.0" 64 | } 65 | } 66 | ], 67 | "version" : 2 68 | } 69 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.9 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "DataLoader", 8 | platforms: [.macOS(.v12), .iOS(.v15), .tvOS(.v15), .watchOS(.v8)], 9 | products: [ 10 | .library(name: "DataLoader", targets: ["DataLoader"]), 11 | .library(name: "AsyncDataLoader", targets: ["AsyncDataLoader"]), 12 | ], 13 | dependencies: [ 14 | .package(url: "https://github.com/apple/swift-algorithms.git", from: "1.0.0"), 15 | .package(url: "https://github.com/adam-fowler/async-collections", from: "0.0.1"), 16 | .package(url: "https://github.com/apple/swift-nio.git", from: "2.0.0"), 17 | ], 18 | targets: [ 19 | .target( 20 | name: "DataLoader", 21 | dependencies: [ 22 | .product(name: "NIO", package: "swift-nio"), 23 | .product(name: "NIOConcurrencyHelpers", package: "swift-nio"), 24 | ] 25 | ), 26 | .target( 27 | name: "AsyncDataLoader", 28 | dependencies: [ 29 | .product(name: "Algorithms", package: "swift-algorithms"), 30 | .product(name: "AsyncCollections", package: "async-collections"), 31 | ] 32 | ), 33 | .testTarget(name: "DataLoaderTests", dependencies: ["DataLoader"]), 34 | .testTarget(name: "AsyncDataLoaderTests", dependencies: ["AsyncDataLoader"]), 35 | ] 36 | ) 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DataLoader 2 | DataLoader is a generic utility to be used as part of your application's data fetching layer to provide a simplified and consistent API over various remote data sources such as databases or web services via batching and caching. 3 | 4 | This is a Swift version of the Facebook [DataLoader](https://github.com/facebook/dataloader). 5 | 6 | [![Swift][swift-badge]][swift-url] 7 | [![License][mit-badge]][mit-url] 8 | 9 | ## Gettings started 🚀 10 | 11 | Include this repo in your `Package.swift` file. 12 | 13 | ```swift 14 | .Package(url: "https://github.com/GraphQLSwift/DataLoader.git", .upToNextMajor(from: "1.1.0")) 15 | ``` 16 | 17 | To get started, create a DataLoader. Each DataLoader instance represents a unique cache. Typically instances are created per request when used 18 | within a web-server if different users can see different things. 19 | 20 | ## Batching 🍪 21 | Batching is not an advanced feature, it's DataLoader's primary feature. 22 | 23 | Create a DataLoader by providing a batch loading function: 24 | ```swift 25 | let userLoader = Dataloader(batchLoadFunction: { keys in 26 | try User.query(on: req).filter(\User.id ~~ keys).all().map { users in 27 | keys.map { key in 28 | DataLoaderFutureValue.success(users.filter{ $0.id == key }) 29 | } 30 | } 31 | }) 32 | ``` 33 | 34 | The order of the returned DataLoaderFutureValues must match the order of the keys. 35 | 36 | ### Load individual keys 37 | ```swift 38 | let future1 = try userLoader.load(key: 1, on: eventLoopGroup) 39 | let future2 = try userLoader.load(key: 2, on: eventLoopGroup) 40 | let future3 = try userLoader.load(key: 1, on: eventLoopGroup) 41 | ``` 42 | 43 | The example above will only fetch two users, because the user with key `1` is present twice in the list. 44 | 45 | ### Load multiple keys 46 | There is also a method to load multiple keys at once 47 | ```swift 48 | try userLoader.loadMany(keys: [1, 2, 3], on: eventLoopGroup) 49 | ``` 50 | 51 | ### Execution 52 | By default, a DataLoader will wait for a short time from the moment `load` is called to collect keys prior 53 | to running the `batchLoadFunction` and completing the `load` futures. This is to let keys accumulate and 54 | batch into a smaller number of total requests. This amount of time is configurable using the `executionPeriod` 55 | option: 56 | 57 | ```swift 58 | let myLoader = DataLoader( 59 | options: DataLoaderOptions(executionPeriod: .milliseconds(50)), 60 | batchLoadFunction: { keys in 61 | self.someBatchLoader(keys: keys).map { DataLoaderFutureValue.success($0) } 62 | } 63 | ) 64 | ``` 65 | 66 | Longer execution periods reduce the number of total data requests, but also reduce the responsiveness of the 67 | `load` futures. 68 | 69 | If desired, you can manually execute the `batchLoadFunction` and complete the futures at any time, using the 70 | `.execute()` method. 71 | 72 | Scheduled execution can be disabled by setting `executionPeriod` to `nil`, but be careful - you *must* call `.execute()` 73 | manually in this case. Otherwise, the futures will never complete! 74 | 75 | ### Disable batching 76 | It is possible to disable batching by setting the `batchingEnabled` option to `false`. 77 | In this case, the `batchLoadFunction` will be invoked immediately when a key is loaded. 78 | 79 | 80 | ## Caching 💰 81 | DataLoader provides a memoization cache. After `.load()` is called with a key, the resulting value is cached 82 | for the lifetime of the DataLoader object. This eliminates redundant loads. 83 | 84 | In addition to relieving pressure on your data storage, caching results also creates fewer objects which may 85 | relieve memory pressure on your application: 86 | 87 | ```swift 88 | let userLoader = DataLoader(...) 89 | let future1 = userLoader.load(key: 1, on: eventLoopGroup) 90 | let future2 = userLoader.load(key: 1, on: eventLoopGroup) 91 | print(future1 == future2) // true 92 | ``` 93 | 94 | ### Caching per-Request 95 | 96 | DataLoader caching *does not* replace Redis, Memcache, or any other shared 97 | application-level cache. DataLoader is first and foremost a data loading mechanism, 98 | and its cache only serves the purpose of not repeatedly loading the same data in 99 | the context of a single request to your Application. To do this, it maintains a 100 | simple in-memory memoization cache (more accurately: `.load()` is a memoized function). 101 | 102 | Avoid multiple requests from different users using the DataLoader instance, which 103 | could result in cached data incorrectly appearing in each request. Typically, 104 | DataLoader instances are created when a Request begins, and are not used once the 105 | Request ends. 106 | 107 | ### Clearing Cache 108 | 109 | In certain uncommon cases, clearing the request cache may be necessary. 110 | 111 | The most common example when clearing the loader's cache is necessary is after 112 | a mutation or update within the same request, when a cached value could be out of 113 | date and future loads should not use any possibly cached value. 114 | 115 | Here's a simple example using SQL UPDATE to illustrate. 116 | 117 | ```swift 118 | // Request begins... 119 | let userLoader = DataLoader(...) 120 | 121 | // And a value happens to be loaded (and cached). 122 | userLoader.load(key: 4, on: eventLoopGroup) 123 | 124 | // A mutation occurs, invalidating what might be in cache. 125 | sqlRun('UPDATE users WHERE id=4 SET username="zuck"').whenComplete { userLoader.clear(key: 4) } 126 | 127 | // Later the value load is loaded again so the mutated data appears. 128 | userLoader.load(key: 4, on: eventLoopGroup) 129 | 130 | // Request completes. 131 | ``` 132 | 133 | ### Caching Errors 134 | 135 | If a batch load fails (that is, a batch function throws or returns a DataLoaderFutureValue.failure(Error)), 136 | then the requested values will not be cached. However if a batch 137 | function returns an `Error` instance for an individual value, that `Error` will 138 | be cached to avoid frequently loading the same `Error`. 139 | 140 | In some circumstances you may wish to clear the cache for these individual Errors: 141 | 142 | ```swift 143 | userLoader.load(key: 1, on: eventLoopGroup).whenFailure { error in 144 | if (/* determine if should clear error */) { 145 | userLoader.clear(key: 1); 146 | } 147 | throw error 148 | } 149 | ``` 150 | 151 | ### Disabling Cache 152 | 153 | In certain uncommon cases, a DataLoader which *does not* cache may be desirable. 154 | Calling `DataLoader(options: DataLoaderOptions(cachingEnabled: false), batchLoadFunction: batchLoadFunction)` will ensure that every 155 | call to `.load()` will produce a *new* Future, and previously requested keys will not be 156 | saved in memory. 157 | 158 | However, when the memoization cache is disabled, your batch function will 159 | receive an array of keys which may contain duplicates! Each key will be 160 | associated with each call to `.load()`. Your batch loader should provide a value 161 | for each instance of the requested key. 162 | 163 | For example: 164 | 165 | ```swift 166 | let myLoader = DataLoader( 167 | options: DataLoaderOptions(cachingEnabled: false), 168 | batchLoadFunction: { keys in 169 | self.someBatchLoader(keys: keys).map { DataLoaderFutureValue.success($0) } 170 | } 171 | ) 172 | 173 | myLoader.load(key: "A", on: eventLoopGroup) 174 | myLoader.load(key: "B", on: eventLoopGroup) 175 | myLoader.load(key: "A", on: eventLoopGroup) 176 | 177 | // > [ "A", "B", "A" ] 178 | ``` 179 | 180 | More complex cache behavior can be achieved by calling `.clear()` or `.clearAll()` 181 | rather than disabling the cache completely. For example, this DataLoader will 182 | provide unique keys to a batch function due to the memoization cache being 183 | enabled, but will immediately clear its cache when the batch function is called 184 | so later requests will load new values. 185 | 186 | ```swift 187 | let myLoader = DataLoader(batchLoadFunction: { keys in 188 | identityLoader.clearAll() 189 | return someBatchLoad(keys: keys) 190 | }) 191 | ``` 192 | 193 | ## Using with GraphQL 🎀 194 | 195 | DataLoader pairs nicely well with [GraphQL](https://github.com/GraphQLSwift/GraphQL) and 196 | [Graphiti](https://github.com/GraphQLSwift/Graphiti). GraphQL fields are designed to be 197 | stand-alone functions. Without a caching or batching mechanism, 198 | it's easy for a naive GraphQL server to issue new database requests each time a 199 | field is resolved. 200 | 201 | Consider the following GraphQL request: 202 | 203 | ``` 204 | { 205 | me { 206 | name 207 | bestFriend { 208 | name 209 | } 210 | friends(first: 5) { 211 | name 212 | bestFriend { 213 | name 214 | } 215 | } 216 | } 217 | } 218 | ``` 219 | 220 | Naively, if `me`, `bestFriend` and `friends` each need to request the backend, 221 | there could be at most 12 database requests! 222 | 223 | By using DataLoader, we could batch our requests to a `User` type, and 224 | only require at most 4 database requests, and possibly fewer if there are cache hits. 225 | Here's a full example using Graphiti: 226 | 227 | ```swift 228 | struct User : Codable { 229 | let id: Int 230 | let name: String 231 | let bestFriendID: Int 232 | let friendIDs: [Int] 233 | 234 | func getBestFriend(context: UserContext, arguments: NoArguments, group: EventLoopGroup) throws -> EventLoopFuture { 235 | return try context.userLoader.load(key: user.bestFriendID, on: group) 236 | } 237 | 238 | struct FriendArguments { 239 | first: Int 240 | } 241 | func getFriends(context: UserContext, arguments: FriendArguments, group: EventLoopGroup) throws -> EventLoopFuture<[User]> { 242 | return try context.userLoader.loadMany(keys: user.friendIDs[0.. User { 248 | ... 249 | } 250 | } 251 | 252 | class UserContext { 253 | let database = ... 254 | let userLoader = DataLoader() { [weak self] keys in 255 | guard let self = self else { throw ContextError } 256 | return User.query(on: self.database).filter(\.$id ~~ keys).all().map { users in 257 | keys.map { key in 258 | users.first { $0.id == key }! 259 | } 260 | } 261 | } 262 | } 263 | 264 | struct UserAPI : API { 265 | let resolver = UserResolver() 266 | let schema = Schema { 267 | Type(User.self) { 268 | Field("name", at: \.content) 269 | Field("bestFriend", at: \.getBestFriend, as: TypeReference.self) 270 | Field("friends", at: \.getFriends, as: [TypeReference]?.self) { 271 | Argument("first", at: .\first) 272 | } 273 | } 274 | 275 | Query { 276 | Field("me", at: UserResolver.hero, as: User.self) 277 | } 278 | } 279 | } 280 | ``` 281 | 282 | ## Contributing 🤘 283 | 284 | All your feedback and help to improve this project is very welcome. Please create issues for your bugs, ideas and 285 | enhancement requests, or better yet, contribute directly by creating a PR. 😎 286 | 287 | When reporting an issue, please add a detailed example, and if possible a code snippet or test 288 | to reproduce your problem. 💥 289 | 290 | When creating a pull request, please adhere to the current coding style where possible, and create tests with your 291 | code so it keeps providing an awesome test coverage level 💪 292 | 293 | This repo uses [SwiftFormat](https://github.com/nicklockwood/SwiftFormat), and includes lint checks to enforce these formatting standards. 294 | To format your code, install `swiftformat` and run: 295 | 296 | ```bash 297 | swiftformat . 298 | ``` 299 | 300 | ## Acknowledgements 👏 301 | 302 | This library is entirely a Swift version of Facebooks [DataLoader](https://github.com/facebook/dataloader). 303 | Developed by [Lee Byron](https://github.com/leebyron) and [Nicholas Schrock](https://github.com/schrockn) 304 | from [Facebook](https://www.facebook.com/). 305 | 306 | 307 | 308 | [swift-badge]: https://img.shields.io/badge/Swift-5.2-orange.svg?style=flat 309 | [swift-url]: https://swift.org 310 | 311 | [mit-badge]: https://img.shields.io/badge/License-MIT-blue.svg?style=flat 312 | [mit-url]: https://tldrlegal.com/license/mit-license 313 | -------------------------------------------------------------------------------- /Sources/AsyncDataLoader/Channel/Channel.swift: -------------------------------------------------------------------------------- 1 | actor Channel: Sendable { 2 | private var state = State() 3 | } 4 | 5 | extension Channel { 6 | @discardableResult 7 | func fulfill(_ value: Success) async -> Bool { 8 | if await state.result == nil { 9 | await state.setResult(result: value) 10 | 11 | for waiters in await state.waiters { 12 | waiters.resume(returning: value) 13 | } 14 | 15 | await state.removeAllWaiters() 16 | 17 | return false 18 | } 19 | 20 | return true 21 | } 22 | 23 | @discardableResult 24 | func fail(_ failure: Failure) async -> Bool { 25 | if await state.failure == nil { 26 | await state.setFailure(failure: failure) 27 | 28 | for waiters in await state.waiters { 29 | waiters.resume(throwing: failure) 30 | } 31 | 32 | await state.removeAllWaiters() 33 | 34 | return false 35 | } 36 | 37 | return true 38 | } 39 | 40 | var value: Success { 41 | get async throws { 42 | try await withCheckedThrowingContinuation { continuation in 43 | Task { 44 | if let result = await state.result { 45 | continuation.resume(returning: result) 46 | } else if let failure = await self.state.failure { 47 | continuation.resume(throwing: failure) 48 | } else { 49 | await state.appendWaiters(waiters: continuation) 50 | } 51 | } 52 | } 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Sources/AsyncDataLoader/Channel/State.swift: -------------------------------------------------------------------------------- 1 | typealias Waiter = CheckedContinuation 2 | 3 | actor State { 4 | var waiters = [Waiter]() 5 | var result: Success? 6 | var failure: Failure? 7 | } 8 | 9 | extension State { 10 | func setResult(result: Success) { 11 | self.result = result 12 | } 13 | 14 | func setFailure(failure: Failure) { 15 | self.failure = failure 16 | } 17 | 18 | func appendWaiters(waiters: Waiter...) { 19 | self.waiters.append(contentsOf: waiters) 20 | } 21 | 22 | func removeAllWaiters() { 23 | waiters.removeAll() 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/AsyncDataLoader/DataLoader.swift: -------------------------------------------------------------------------------- 1 | import Algorithms 2 | import AsyncCollections 3 | 4 | public enum DataLoaderValue: Sendable { 5 | case success(T) 6 | case failure(Error) 7 | } 8 | 9 | public typealias BatchLoadFunction = 10 | @Sendable (_ keys: [Key]) async throws -> [DataLoaderValue] 11 | private typealias LoaderQueue = [( 12 | key: Key, 13 | channel: Channel 14 | )] 15 | 16 | /// DataLoader creates a public API for loading data from a particular 17 | /// data back-end with unique keys such as the id column of a SQL table 18 | /// or document name in a MongoDB database, given a batch loading function. 19 | /// 20 | /// Each DataLoader instance contains a unique memoized cache. Use caution 21 | /// when used in long-lived applications or those which serve many users 22 | /// with different access permissions and consider creating a new instance 23 | /// per data request. 24 | public actor DataLoader { 25 | private let batchLoadFunction: BatchLoadFunction 26 | private let options: DataLoaderOptions 27 | 28 | private var cache = [Key: Channel]() 29 | private var queue = LoaderQueue() 30 | 31 | private var dispatchScheduled = false 32 | 33 | public init( 34 | options: DataLoaderOptions = DataLoaderOptions(), 35 | batchLoadFunction: @escaping BatchLoadFunction 36 | ) { 37 | self.options = options 38 | self.batchLoadFunction = batchLoadFunction 39 | } 40 | 41 | /// Loads a key, returning the value represented by that key. 42 | public func load(key: Key) async throws -> Value { 43 | let cacheKey = options.cacheKeyFunction?(key) ?? key 44 | 45 | if options.cachingEnabled, let cached = cache[cacheKey] { 46 | return try await cached.value 47 | } 48 | 49 | let channel = Channel() 50 | 51 | if options.batchingEnabled { 52 | queue.append((key: key, channel: channel)) 53 | 54 | if let executionPeriod = options.executionPeriod, !dispatchScheduled { 55 | Task.detached { 56 | try await Task.sleep(nanoseconds: executionPeriod) 57 | try await self.execute() 58 | } 59 | 60 | dispatchScheduled = true 61 | } 62 | } else { 63 | Task.detached { 64 | do { 65 | let results = try await self.batchLoadFunction([key]) 66 | 67 | if results.isEmpty { 68 | await channel 69 | .fail( 70 | DataLoaderError 71 | .noValueForKey("Did not return value for key: \(key)") 72 | ) 73 | } else { 74 | let result = results[0] 75 | 76 | switch result { 77 | case let .success(value): 78 | await channel.fulfill(value) 79 | case let .failure(error): 80 | await channel.fail(error) 81 | } 82 | } 83 | } catch { 84 | await channel.fail(error) 85 | } 86 | } 87 | } 88 | 89 | if options.cachingEnabled { 90 | cache[cacheKey] = channel 91 | } 92 | 93 | return try await channel.value 94 | } 95 | 96 | /// Loads multiple keys, promising an array of values: 97 | /// 98 | /// ```swift 99 | /// async let aAndB = try myLoader.loadMany(keys: [ "a", "b" ]) 100 | /// ``` 101 | /// 102 | /// This is equivalent to the more verbose: 103 | /// 104 | /// ```swift 105 | /// async let aAndB = [ 106 | /// myLoader.load(key: "a"), 107 | /// myLoader.load(key: "b") 108 | /// ] 109 | /// ``` 110 | /// or 111 | /// ```swift 112 | /// async let a = myLoader.load(key: "a") 113 | /// async let b = myLoader.load(key: "b") 114 | /// ``` 115 | public func loadMany(keys: [Key]) async throws -> [Value] { 116 | guard !keys.isEmpty else { 117 | return [] 118 | } 119 | 120 | return try await keys.concurrentMap { try await self.load(key: $0) } 121 | } 122 | 123 | /// Clears the value at `key` from the cache, if it exists. Returns itself for 124 | /// method chaining. 125 | @discardableResult 126 | public func clear(key: Key) -> DataLoader { 127 | let cacheKey = options.cacheKeyFunction?(key) ?? key 128 | 129 | cache.removeValue(forKey: cacheKey) 130 | 131 | return self 132 | } 133 | 134 | /// Clears the entire cache. To be used when some event results in unknown 135 | /// invalidations across this particular `DataLoader`. Returns itself for 136 | /// method chaining. 137 | @discardableResult 138 | public func clearAll() -> DataLoader { 139 | cache.removeAll() 140 | 141 | return self 142 | } 143 | 144 | /// Adds the provied key and value to the cache. If the key already exists, no 145 | /// change is made. Returns itself for method chaining. 146 | @discardableResult 147 | public func prime(key: Key, value: Value) async throws -> DataLoader { 148 | let cacheKey = options.cacheKeyFunction?(key) ?? key 149 | 150 | if cache[cacheKey] == nil { 151 | let channel = Channel() 152 | 153 | Task.detached { 154 | await channel.fulfill(value) 155 | } 156 | 157 | cache[cacheKey] = channel 158 | } 159 | 160 | return self 161 | } 162 | 163 | public func execute() async throws { 164 | // Take the current loader queue, replacing it with an empty queue. 165 | let batch = queue 166 | 167 | queue = [] 168 | 169 | if dispatchScheduled { 170 | dispatchScheduled = false 171 | } 172 | 173 | guard !batch.isEmpty else { 174 | return () 175 | } 176 | 177 | // If a maxBatchSize was provided and the queue is longer, then segment the 178 | // queue into multiple batches, otherwise treat the queue as a single batch. 179 | if let maxBatchSize = options.maxBatchSize, maxBatchSize > 0, maxBatchSize < batch.count { 180 | try await batch.chunks(ofCount: maxBatchSize).asyncForEach { slicedBatch in 181 | try await self.executeBatch(batch: Array(slicedBatch)) 182 | } 183 | } else { 184 | try await executeBatch(batch: batch) 185 | } 186 | } 187 | 188 | private func executeBatch(batch: LoaderQueue) async throws { 189 | let keys = batch.map { $0.key } 190 | 191 | if keys.isEmpty { 192 | return 193 | } 194 | 195 | // Step through the values, resolving or rejecting each Promise in the 196 | // loaded queue. 197 | do { 198 | let values = try await batchLoadFunction(keys) 199 | 200 | if values.count != keys.count { 201 | throw DataLoaderError 202 | .typeError( 203 | "The function did not return an array of the same length as the array of keys. \nKeys count: \(keys.count)\nValues count: \(values.count)" 204 | ) 205 | } 206 | 207 | for entry in batch.enumerated() { 208 | let result = values[entry.offset] 209 | 210 | switch result { 211 | case let .failure(error): 212 | await entry.element.channel.fail(error) 213 | case let .success(value): 214 | await entry.element.channel.fulfill(value) 215 | } 216 | } 217 | } catch { 218 | await failedExecution(batch: batch, error: error) 219 | } 220 | } 221 | 222 | private func failedExecution(batch: LoaderQueue, error: Error) async { 223 | for (key, channel) in batch { 224 | _ = clear(key: key) 225 | 226 | await channel.fail(error) 227 | } 228 | } 229 | } 230 | -------------------------------------------------------------------------------- /Sources/AsyncDataLoader/DataLoaderError.swift: -------------------------------------------------------------------------------- 1 | public enum DataLoaderError: Error { 2 | case typeError(String) 3 | case noValueForKey(String) 4 | } 5 | -------------------------------------------------------------------------------- /Sources/AsyncDataLoader/DataLoaderOptions.swift: -------------------------------------------------------------------------------- 1 | public struct DataLoaderOptions: Sendable { 2 | /// Default `true`. Set to `false` to disable batching, invoking 3 | /// `batchLoadFunction` with a single load key. This is 4 | /// equivalent to setting `maxBatchSize` to `1`. 5 | public let batchingEnabled: Bool 6 | 7 | /// Default `nil`. Limits the number of items that get passed in to the 8 | /// `batchLoadFn`. May be set to `1` to disable batching. 9 | public let maxBatchSize: Int? 10 | 11 | /// Default `true`. Set to `false` to disable memoization caching, creating a 12 | /// new `EventLoopFuture` and new key in the `batchLoadFunction` 13 | /// for every load of the same key. 14 | public let cachingEnabled: Bool 15 | 16 | /// Default `2ms`. Defines the period of time that the DataLoader should 17 | /// wait and collect its queue before executing. Faster times result 18 | /// in smaller batches quicker resolution, slower times result in larger 19 | /// batches but slower resolution. 20 | /// This is irrelevant if batching is disabled. 21 | public let executionPeriod: UInt64? 22 | 23 | /// Default `nil`. Produces cache key for a given load key. Useful 24 | /// when objects are keys and two objects should be considered equivalent. 25 | public let cacheKeyFunction: (@Sendable (Key) -> Key)? 26 | 27 | public init( 28 | batchingEnabled: Bool = true, 29 | cachingEnabled: Bool = true, 30 | maxBatchSize: Int? = nil, 31 | executionPeriod: UInt64? = 2_000_000, 32 | cacheKeyFunction: (@Sendable (Key) -> Key)? = nil 33 | ) { 34 | self.batchingEnabled = batchingEnabled 35 | self.cachingEnabled = cachingEnabled 36 | self.executionPeriod = executionPeriod 37 | self.maxBatchSize = maxBatchSize 38 | self.cacheKeyFunction = cacheKeyFunction 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Sources/DataLoader/DataLoader.swift: -------------------------------------------------------------------------------- 1 | import NIO 2 | import NIOConcurrencyHelpers 3 | 4 | public enum DataLoaderFutureValue { 5 | case success(T) 6 | case failure(Error) 7 | } 8 | 9 | public typealias BatchLoadFunction = (_ keys: [Key]) throws 10 | -> EventLoopFuture<[DataLoaderFutureValue]> 11 | private typealias LoaderQueue = [(key: Key, promise: EventLoopPromise)] 12 | 13 | /// DataLoader creates a public API for loading data from a particular 14 | /// data back-end with unique keys such as the id column of a SQL table 15 | /// or document name in a MongoDB database, given a batch loading function. 16 | /// 17 | /// Each DataLoader instance contains a unique memoized cache. Use caution 18 | /// when used in long-lived applications or those which serve many users 19 | /// with different access permissions and consider creating a new instance 20 | /// per data request. 21 | public final class DataLoader { 22 | private let batchLoadFunction: BatchLoadFunction 23 | private let options: DataLoaderOptions 24 | 25 | private var cache = [Key: EventLoopFuture]() 26 | private var queue = LoaderQueue() 27 | 28 | private var dispatchScheduled = false 29 | private let lock = NIOLock() 30 | 31 | public init( 32 | options: DataLoaderOptions = DataLoaderOptions(), 33 | batchLoadFunction: @escaping BatchLoadFunction 34 | ) { 35 | self.options = options 36 | self.batchLoadFunction = batchLoadFunction 37 | } 38 | 39 | /// Loads a key, returning an `EventLoopFuture` for the value represented by that key. 40 | public func load(key: Key, on eventLoopGroup: EventLoopGroup) throws -> EventLoopFuture { 41 | let cacheKey = options.cacheKeyFunction?(key) ?? key 42 | 43 | return lock.withLock { 44 | if options.cachingEnabled, let cachedFuture = cache[cacheKey] { 45 | return cachedFuture 46 | } 47 | 48 | let promise: EventLoopPromise = eventLoopGroup.next().makePromise() 49 | 50 | if options.batchingEnabled { 51 | queue.append((key: key, promise: promise)) 52 | if let executionPeriod = options.executionPeriod, !dispatchScheduled { 53 | eventLoopGroup.next().scheduleTask(in: executionPeriod) { 54 | try self.execute() 55 | } 56 | dispatchScheduled = true 57 | } 58 | } else { 59 | do { 60 | _ = try batchLoadFunction([key]).map { results in 61 | if results.isEmpty { 62 | promise 63 | .fail( 64 | DataLoaderError 65 | .noValueForKey("Did not return value for key: \(key)") 66 | ) 67 | } else { 68 | let result = results[0] 69 | switch result { 70 | case let .success(value): promise.succeed(value) 71 | case let .failure(error): promise.fail(error) 72 | } 73 | } 74 | } 75 | } catch { 76 | promise.fail(error) 77 | } 78 | } 79 | 80 | let future = promise.futureResult 81 | 82 | if options.cachingEnabled { 83 | cache[cacheKey] = future 84 | } 85 | 86 | return future 87 | } 88 | } 89 | 90 | /// Loads multiple keys, promising an array of values: 91 | /// 92 | /// ``` 93 | /// let aAndB = myLoader.loadMany(keys: [ "a", "b" ], on: eventLoopGroup).wait() 94 | /// ``` 95 | /// 96 | /// This is equivalent to the more verbose: 97 | /// 98 | /// ``` 99 | /// let aAndB = [ 100 | /// myLoader.load(key: "a", on: eventLoopGroup), 101 | /// myLoader.load(key: "b", on: eventLoopGroup) 102 | /// ].flatten(on: eventLoopGroup).wait() 103 | /// ``` 104 | public func loadMany( 105 | keys: [Key], 106 | on eventLoopGroup: EventLoopGroup 107 | ) throws -> EventLoopFuture<[Value]> { 108 | guard !keys.isEmpty else { 109 | return eventLoopGroup.next().makeSucceededFuture([]) 110 | } 111 | let futures = try keys.map { try load(key: $0, on: eventLoopGroup) } 112 | return EventLoopFuture.whenAllSucceed(futures, on: eventLoopGroup.next()) 113 | } 114 | 115 | /// Clears the value at `key` from the cache, if it exists. Returns itself for 116 | /// method chaining. 117 | @discardableResult 118 | public func clear(key: Key) -> DataLoader { 119 | let cacheKey = options.cacheKeyFunction?(key) ?? key 120 | lock.withLockVoid { 121 | cache.removeValue(forKey: cacheKey) 122 | } 123 | return self 124 | } 125 | 126 | /// Clears the entire cache. To be used when some event results in unknown 127 | /// invalidations across this particular `DataLoader`. Returns itself for 128 | /// method chaining. 129 | @discardableResult 130 | public func clearAll() -> DataLoader { 131 | lock.withLockVoid { 132 | cache.removeAll() 133 | } 134 | return self 135 | } 136 | 137 | /// Adds the provied key and value to the cache. If the key already exists, no 138 | /// change is made. Returns itself for method chaining. 139 | @discardableResult 140 | public func prime( 141 | key: Key, 142 | value: Value, 143 | on eventLoop: EventLoopGroup 144 | ) -> DataLoader { 145 | let cacheKey = options.cacheKeyFunction?(key) ?? key 146 | 147 | lock.withLockVoid { 148 | if cache[cacheKey] == nil { 149 | let promise: EventLoopPromise = eventLoop.next().makePromise() 150 | promise.succeed(value) 151 | 152 | cache[cacheKey] = promise.futureResult 153 | } 154 | } 155 | 156 | return self 157 | } 158 | 159 | /// Executes the queue of keys, completing the `EventLoopFutures`. 160 | /// 161 | /// If `executionPeriod` was provided in the options, this method is run automatically 162 | /// after the specified time period. If `executionPeriod` was nil, the client must 163 | /// run this manually to compete the `EventLoopFutures` of the keys. 164 | public func execute() throws { 165 | // Take the current loader queue, replacing it with an empty queue. 166 | var batch = LoaderQueue() 167 | lock.withLockVoid { 168 | batch = self.queue 169 | self.queue = [] 170 | if dispatchScheduled { 171 | dispatchScheduled = false 172 | } 173 | } 174 | 175 | guard batch.count > 0 else { 176 | return () 177 | } 178 | 179 | // If a maxBatchSize was provided and the queue is longer, then segment the 180 | // queue into multiple batches, otherwise treat the queue as a single batch. 181 | if let maxBatchSize = options.maxBatchSize, maxBatchSize > 0, maxBatchSize < batch.count { 182 | for i in 0 ... (batch.count / maxBatchSize) { 183 | let startIndex = i * maxBatchSize 184 | let endIndex = (i + 1) * maxBatchSize 185 | let slicedBatch = batch[startIndex ..< min(endIndex, batch.count)] 186 | try executeBatch(batch: Array(slicedBatch)) 187 | } 188 | } else { 189 | try executeBatch(batch: batch) 190 | } 191 | } 192 | 193 | private func executeBatch(batch: LoaderQueue) throws { 194 | let keys = batch.map(\.key) 195 | 196 | if keys.isEmpty { 197 | return 198 | } 199 | 200 | // Step through the values, resolving or rejecting each Promise in the 201 | // loaded queue. 202 | do { 203 | _ = try batchLoadFunction(keys).flatMapThrowing { values in 204 | if values.count != keys.count { 205 | throw DataLoaderError 206 | .typeError( 207 | "The function did not return an array of the same length as the array of keys. \nKeys count: \(keys.count)\nValues count: \(values.count)" 208 | ) 209 | } 210 | 211 | for entry in batch.enumerated() { 212 | let result = values[entry.offset] 213 | 214 | switch result { 215 | case let .failure(error): entry.element.promise.fail(error) 216 | case let .success(value): entry.element.promise.succeed(value) 217 | } 218 | } 219 | }.recover { error in 220 | self.failedExecution(batch: batch, error: error) 221 | } 222 | } catch { 223 | failedExecution(batch: batch, error: error) 224 | } 225 | } 226 | 227 | private func failedExecution(batch: LoaderQueue, error: Error) { 228 | for (key, promise) in batch { 229 | _ = clear(key: key) 230 | promise.fail(error) 231 | } 232 | } 233 | } 234 | 235 | #if compiler(>=5.5) && canImport(_Concurrency) 236 | 237 | /// Batch load function using async await 238 | public typealias ConcurrentBatchLoadFunction = 239 | @Sendable (_ keys: [Key]) async throws -> [DataLoaderFutureValue] 240 | 241 | public extension DataLoader { 242 | @available(macOS 12, iOS 15, watchOS 8, tvOS 15, *) 243 | convenience init( 244 | on eventLoop: EventLoop, 245 | options: DataLoaderOptions = DataLoaderOptions(), 246 | throwing asyncThrowingLoadFunction: @escaping ConcurrentBatchLoadFunction 247 | ) { 248 | self.init(options: options, batchLoadFunction: { keys in 249 | let promise = eventLoop.next().makePromise(of: [DataLoaderFutureValue].self) 250 | promise.completeWithTask { 251 | try await asyncThrowingLoadFunction(keys) 252 | } 253 | return promise.futureResult 254 | }) 255 | } 256 | 257 | /// Asynchronously loads a key, returning the value represented by that key. 258 | @available(macOS 12, iOS 15, watchOS 8, tvOS 15, *) 259 | func load(key: Key, on eventLoopGroup: EventLoopGroup) async throws -> Value { 260 | try await load(key: key, on: eventLoopGroup).get() 261 | } 262 | 263 | /// Asynchronously loads multiple keys, promising an array of values: 264 | /// 265 | /// ``` 266 | /// let aAndB = try await myLoader.loadMany(keys: [ "a", "b" ], on: eventLoopGroup) 267 | /// ``` 268 | /// 269 | /// This is equivalent to the more verbose: 270 | /// 271 | /// ``` 272 | /// async let a = myLoader.load(key: "a", on: eventLoopGroup) 273 | /// async let b = myLoader.load(key: "b", on: eventLoopGroup) 274 | /// let aAndB = try await a + b 275 | /// ``` 276 | @available(macOS 12, iOS 15, watchOS 8, tvOS 15, *) 277 | func loadMany(keys: [Key], on eventLoopGroup: EventLoopGroup) async throws -> [Value] { 278 | try await loadMany(keys: keys, on: eventLoopGroup).get() 279 | } 280 | } 281 | 282 | #endif 283 | -------------------------------------------------------------------------------- /Sources/DataLoader/DataLoaderError.swift: -------------------------------------------------------------------------------- 1 | public enum DataLoaderError: Error { 2 | case typeError(String) 3 | case noValueForKey(String) 4 | } 5 | -------------------------------------------------------------------------------- /Sources/DataLoader/DataLoaderOptions.swift: -------------------------------------------------------------------------------- 1 | import NIO 2 | 3 | public struct DataLoaderOptions { 4 | /// Default `true`. Set to `false` to disable batching, invoking 5 | /// `batchLoadFunction` with a single load key. This is 6 | /// equivalent to setting `maxBatchSize` to `1`. 7 | public let batchingEnabled: Bool 8 | 9 | /// Default `nil`. Limits the number of items that get passed in to the 10 | /// `batchLoadFn`. May be set to `1` to disable batching. 11 | public let maxBatchSize: Int? 12 | 13 | /// Default `true`. Set to `false` to disable memoization caching, creating a 14 | /// new `EventLoopFuture` and new key in the `batchLoadFunction` 15 | /// for every load of the same key. 16 | public let cachingEnabled: Bool 17 | 18 | /// Default `2ms`. Defines the period of time that the DataLoader should 19 | /// wait and collect its queue before executing. Faster times result 20 | /// in smaller batches quicker resolution, slower times result in larger 21 | /// batches but slower resolution. 22 | /// This is irrelevant if batching is disabled. 23 | public let executionPeriod: TimeAmount? 24 | 25 | /// Default `nil`. Produces cache key for a given load key. Useful 26 | /// when objects are keys and two objects should be considered equivalent. 27 | public let cacheKeyFunction: ((Key) -> Key)? 28 | 29 | public init( 30 | batchingEnabled: Bool = true, 31 | cachingEnabled: Bool = true, 32 | maxBatchSize: Int? = nil, 33 | executionPeriod: TimeAmount? = .milliseconds(2), 34 | cacheKeyFunction: ((Key) -> Key)? = nil 35 | ) { 36 | self.batchingEnabled = batchingEnabled 37 | self.cachingEnabled = cachingEnabled 38 | self.executionPeriod = executionPeriod 39 | self.maxBatchSize = maxBatchSize 40 | self.cacheKeyFunction = cacheKeyFunction 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Tests/AsyncDataLoaderTests/DataLoaderAbuseTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | @testable import AsyncDataLoader 4 | 5 | /// Provides descriptive error messages for API abuse 6 | class DataLoaderAbuseTests: XCTestCase { 7 | func testFuntionWithNoValues() async throws { 8 | let identityLoader = DataLoader( 9 | options: DataLoaderOptions(batchingEnabled: false) 10 | ) { _ in 11 | [] 12 | } 13 | 14 | async let value = identityLoader.load(key: 1) 15 | 16 | var didFailWithError: Error? 17 | 18 | do { 19 | _ = try await value 20 | } catch { 21 | didFailWithError = error 22 | } 23 | 24 | XCTAssertNotNil(didFailWithError) 25 | } 26 | 27 | func testBatchFuntionMustPromiseAnArrayOfCorrectLength() async { 28 | let identityLoader = DataLoader() { _ in 29 | [] 30 | } 31 | 32 | async let value = identityLoader.load(key: 1) 33 | 34 | var didFailWithError: Error? 35 | 36 | do { 37 | _ = try await value 38 | } catch { 39 | didFailWithError = error 40 | } 41 | 42 | XCTAssertNotNil(didFailWithError) 43 | } 44 | 45 | func testBatchFuntionWithSomeValues() async throws { 46 | let identityLoader = DataLoader() { keys in 47 | var results = [DataLoaderValue]() 48 | 49 | for key in keys { 50 | if key == 1 { 51 | results.append(.success(key)) 52 | } else { 53 | results.append(.failure("Test error")) 54 | } 55 | } 56 | 57 | return results 58 | } 59 | 60 | async let value1 = identityLoader.load(key: 1) 61 | async let value2 = identityLoader.load(key: 2) 62 | 63 | var didFailWithError: Error? 64 | 65 | do { 66 | _ = try await value2 67 | } catch { 68 | didFailWithError = error 69 | } 70 | 71 | XCTAssertNotNil(didFailWithError) 72 | 73 | let value = try await value1 74 | 75 | XCTAssertTrue(value == 1) 76 | } 77 | 78 | func testFuntionWithSomeValues() async throws { 79 | let identityLoader = DataLoader( 80 | options: DataLoaderOptions(batchingEnabled: false) 81 | ) { keys in 82 | var results = [DataLoaderValue]() 83 | 84 | for key in keys { 85 | if key == 1 { 86 | results.append(.success(key)) 87 | } else { 88 | results.append(.failure("Test error")) 89 | } 90 | } 91 | 92 | return results 93 | } 94 | 95 | async let value1 = identityLoader.load(key: 1) 96 | async let value2 = identityLoader.load(key: 2) 97 | 98 | var didFailWithError: Error? 99 | 100 | do { 101 | _ = try await value2 102 | } catch { 103 | didFailWithError = error 104 | } 105 | 106 | XCTAssertNotNil(didFailWithError) 107 | 108 | let value = try await value1 109 | 110 | XCTAssertTrue(value == 1) 111 | } 112 | } 113 | 114 | extension String: Swift.Error {} 115 | -------------------------------------------------------------------------------- /Tests/AsyncDataLoaderTests/DataLoaderTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | @testable import AsyncDataLoader 4 | 5 | let sleepConstant = UInt64(2_000_000) 6 | 7 | actor Concurrent { 8 | var wrappedValue: T 9 | 10 | func nonmutating(_ action: (T) throws -> Returned) async rethrows -> Returned { 11 | try action(wrappedValue) 12 | } 13 | 14 | func mutating(_ action: (inout T) throws -> Returned) async rethrows -> Returned { 15 | try action(&wrappedValue) 16 | } 17 | 18 | init(_ value: T) { 19 | wrappedValue = value 20 | } 21 | } 22 | 23 | /// Primary API 24 | /// The `try await Task.sleep(nanoseconds: 2_000_000)` introduces a small delay to simulate 25 | /// asynchronous behavior and ensure that concurrent requests (`value1`, `value2`...) 26 | /// are grouped into a single batch for processing, as intended by the batching settings. 27 | final class DataLoaderTests: XCTestCase { 28 | /// Builds a really really simple data loader' 29 | func testReallyReallySimpleDataLoader() async throws { 30 | let identityLoader = DataLoader( 31 | options: DataLoaderOptions(batchingEnabled: false) 32 | ) { keys in 33 | keys.map { DataLoaderValue.success($0) } 34 | } 35 | 36 | let value = try await identityLoader.load(key: 1) 37 | 38 | XCTAssertEqual(value, 1) 39 | } 40 | 41 | /// Supports loading multiple keys in one call 42 | func testLoadingMultipleKeys() async throws { 43 | let identityLoader = DataLoader() { keys in 44 | keys.map { DataLoaderValue.success($0) } 45 | } 46 | 47 | let values = try await identityLoader.loadMany(keys: [1, 2]) 48 | 49 | XCTAssertEqual(values, [1, 2]) 50 | 51 | let empty = try await identityLoader.loadMany(keys: []) 52 | 53 | XCTAssertTrue(empty.isEmpty) 54 | } 55 | 56 | // Batches multiple requests 57 | func testMultipleRequests() async throws { 58 | let loadCalls = Concurrent<[[Int]]>([]) 59 | 60 | let identityLoader = DataLoader( 61 | options: DataLoaderOptions( 62 | batchingEnabled: true, 63 | executionPeriod: nil 64 | ) 65 | ) { keys in 66 | await loadCalls.mutating { $0.append(keys) } 67 | 68 | return keys.map { DataLoaderValue.success($0) } 69 | } 70 | 71 | async let value1 = identityLoader.load(key: 1) 72 | async let value2 = identityLoader.load(key: 2) 73 | 74 | try await Task.sleep(nanoseconds: sleepConstant) 75 | 76 | var didFailWithError: Error? 77 | 78 | do { 79 | _ = try await identityLoader.execute() 80 | } catch { 81 | didFailWithError = error 82 | } 83 | 84 | XCTAssertNil(didFailWithError) 85 | 86 | let result1 = try await value1 87 | let result2 = try await value2 88 | 89 | XCTAssertEqual(result1, 1) 90 | XCTAssertEqual(result2, 2) 91 | 92 | let calls = await loadCalls.wrappedValue 93 | 94 | XCTAssertEqual(calls.map { $0.sorted() }, [[1, 2]]) 95 | } 96 | 97 | /// Batches multiple requests with max batch sizes 98 | func testMultipleRequestsWithMaxBatchSize() async throws { 99 | let loadCalls = Concurrent<[[Int]]>([]) 100 | 101 | let identityLoader = DataLoader( 102 | options: DataLoaderOptions( 103 | batchingEnabled: true, 104 | maxBatchSize: 2, 105 | executionPeriod: nil 106 | ) 107 | ) { keys in 108 | await loadCalls.mutating { $0.append(keys) } 109 | 110 | return keys.map { DataLoaderValue.success($0) } 111 | } 112 | 113 | async let value1 = identityLoader.load(key: 1) 114 | async let value2 = identityLoader.load(key: 2) 115 | async let value3 = identityLoader.load(key: 3) 116 | 117 | try await Task.sleep(nanoseconds: sleepConstant) 118 | 119 | var didFailWithError: Error? 120 | 121 | do { 122 | _ = try await identityLoader.execute() 123 | } catch { 124 | didFailWithError = error 125 | } 126 | 127 | XCTAssertNil(didFailWithError) 128 | 129 | let result1 = try await value1 130 | let result2 = try await value2 131 | let result3 = try await value3 132 | 133 | XCTAssertEqual(result1, 1) 134 | XCTAssertEqual(result2, 2) 135 | XCTAssertEqual(result3, 3) 136 | 137 | let calls = await loadCalls.wrappedValue 138 | 139 | XCTAssertEqual(calls.first?.count, 2) 140 | XCTAssertEqual(calls.last?.count, 1) 141 | } 142 | 143 | /// Coalesces identical requests 144 | func testCoalescesIdenticalRequests() async throws { 145 | let loadCalls = Concurrent<[[Int]]>([]) 146 | 147 | let identityLoader = DataLoader( 148 | options: DataLoaderOptions(executionPeriod: nil) 149 | ) { keys in 150 | await loadCalls.mutating { $0.append(keys) } 151 | 152 | return keys.map { DataLoaderValue.success($0) } 153 | } 154 | 155 | async let value1 = identityLoader.load(key: 1) 156 | async let value2 = identityLoader.load(key: 1) 157 | 158 | try await Task.sleep(nanoseconds: sleepConstant) 159 | 160 | var didFailWithError: Error? 161 | 162 | do { 163 | _ = try await identityLoader.execute() 164 | } catch { 165 | didFailWithError = error 166 | } 167 | 168 | XCTAssertNil(didFailWithError) 169 | 170 | let result1 = try await value1 171 | let result2 = try await value2 172 | 173 | XCTAssertTrue(result1 == 1) 174 | XCTAssertTrue(result2 == 1) 175 | 176 | let calls = await loadCalls.wrappedValue 177 | 178 | XCTAssertTrue(calls.map { $0.sorted() } == [[1]]) 179 | } 180 | 181 | // Caches repeated requests 182 | func testCachesRepeatedRequests() async throws { 183 | let loadCalls = Concurrent<[[String]]>([]) 184 | 185 | let identityLoader = DataLoader( 186 | options: DataLoaderOptions(executionPeriod: nil) 187 | ) { keys in 188 | await loadCalls.mutating { $0.append(keys) } 189 | 190 | return keys.map { DataLoaderValue.success($0) } 191 | } 192 | 193 | async let value1 = identityLoader.load(key: "A") 194 | async let value2 = identityLoader.load(key: "B") 195 | 196 | try await Task.sleep(nanoseconds: sleepConstant) 197 | 198 | var didFailWithError: Error? 199 | 200 | do { 201 | _ = try await identityLoader.execute() 202 | } catch { 203 | didFailWithError = error 204 | } 205 | 206 | XCTAssertNil(didFailWithError) 207 | 208 | let result1 = try await value1 209 | let result2 = try await value2 210 | 211 | XCTAssertTrue(result1 == "A") 212 | XCTAssertTrue(result2 == "B") 213 | 214 | let calls = await loadCalls.wrappedValue 215 | 216 | XCTAssertTrue(calls.map { $0.sorted() } == [["A", "B"]]) 217 | 218 | async let value3 = identityLoader.load(key: "A") 219 | async let value4 = identityLoader.load(key: "C") 220 | 221 | try await Task.sleep(nanoseconds: sleepConstant) 222 | 223 | var didFailWithError2: Error? 224 | 225 | do { 226 | _ = try await identityLoader.execute() 227 | } catch { 228 | didFailWithError2 = error 229 | } 230 | 231 | XCTAssertNil(didFailWithError2) 232 | 233 | let result3 = try await value3 234 | let result4 = try await value4 235 | 236 | XCTAssertTrue(result3 == "A") 237 | XCTAssertTrue(result4 == "C") 238 | 239 | let calls2 = await loadCalls.wrappedValue 240 | 241 | XCTAssertTrue(calls2.map { $0.sorted() } == [["A", "B"], ["C"]]) 242 | 243 | async let value5 = identityLoader.load(key: "A") 244 | async let value6 = identityLoader.load(key: "B") 245 | async let value7 = identityLoader.load(key: "C") 246 | 247 | try await Task.sleep(nanoseconds: sleepConstant) 248 | 249 | var didFailWithError3: Error? 250 | 251 | do { 252 | _ = try await identityLoader.execute() 253 | } catch { 254 | didFailWithError3 = error 255 | } 256 | 257 | XCTAssertNil(didFailWithError3) 258 | 259 | let result5 = try await value5 260 | let result6 = try await value6 261 | let result7 = try await value7 262 | 263 | XCTAssertTrue(result5 == "A") 264 | XCTAssertTrue(result6 == "B") 265 | XCTAssertTrue(result7 == "C") 266 | 267 | let calls3 = await loadCalls.wrappedValue 268 | 269 | XCTAssertTrue(calls3.map { $0.sorted() } == [["A", "B"], ["C"]]) 270 | } 271 | 272 | /// Clears single value in loader 273 | func testClearSingleValueLoader() async throws { 274 | let loadCalls = Concurrent<[[String]]>([]) 275 | 276 | let identityLoader = DataLoader( 277 | options: DataLoaderOptions(executionPeriod: nil) 278 | ) { keys in 279 | await loadCalls.mutating { $0.append(keys) } 280 | 281 | return keys.map { DataLoaderValue.success($0) } 282 | } 283 | 284 | async let value1 = identityLoader.load(key: "A") 285 | async let value2 = identityLoader.load(key: "B") 286 | 287 | try await Task.sleep(nanoseconds: sleepConstant) 288 | 289 | var didFailWithError: Error? 290 | 291 | do { 292 | _ = try await identityLoader.execute() 293 | } catch { 294 | didFailWithError = error 295 | } 296 | 297 | XCTAssertNil(didFailWithError) 298 | 299 | let result1 = try await value1 300 | let result2 = try await value2 301 | 302 | XCTAssertTrue(result1 == "A") 303 | XCTAssertTrue(result2 == "B") 304 | 305 | let calls = await loadCalls.wrappedValue 306 | 307 | XCTAssertTrue(calls.map { $0.sorted() } == [["A", "B"]]) 308 | 309 | await identityLoader.clear(key: "A") 310 | 311 | async let value3 = identityLoader.load(key: "A") 312 | async let value4 = identityLoader.load(key: "B") 313 | 314 | try await Task.sleep(nanoseconds: sleepConstant) 315 | 316 | var didFailWithError2: Error? 317 | 318 | do { 319 | _ = try await identityLoader.execute() 320 | } catch { 321 | didFailWithError2 = error 322 | } 323 | 324 | XCTAssertNil(didFailWithError2) 325 | 326 | let result3 = try await value3 327 | let result4 = try await value4 328 | 329 | XCTAssertTrue(result3 == "A") 330 | XCTAssertTrue(result4 == "B") 331 | 332 | let calls2 = await loadCalls.wrappedValue 333 | 334 | XCTAssertTrue(calls2.map { $0.sorted() } == [["A", "B"], ["A"]]) 335 | } 336 | 337 | /// Clears all values in loader 338 | func testClearsAllValuesInLoader() async throws { 339 | let loadCalls = Concurrent<[[String]]>([]) 340 | 341 | let identityLoader = DataLoader( 342 | options: DataLoaderOptions(executionPeriod: nil) 343 | ) { keys in 344 | await loadCalls.mutating { $0.append(keys) } 345 | 346 | return keys.map { DataLoaderValue.success($0) } 347 | } 348 | 349 | async let value1 = identityLoader.load(key: "A") 350 | async let value2 = identityLoader.load(key: "B") 351 | 352 | try await Task.sleep(nanoseconds: sleepConstant) 353 | 354 | var didFailWithError: Error? 355 | 356 | do { 357 | _ = try await identityLoader.execute() 358 | } catch { 359 | didFailWithError = error 360 | } 361 | 362 | XCTAssertNil(didFailWithError) 363 | 364 | let result1 = try await value1 365 | let result2 = try await value2 366 | 367 | XCTAssertTrue(result1 == "A") 368 | XCTAssertTrue(result2 == "B") 369 | 370 | let calls = await loadCalls.wrappedValue 371 | 372 | XCTAssertTrue(calls.map { $0.sorted() } == [["A", "B"]]) 373 | 374 | await identityLoader.clearAll() 375 | 376 | async let value3 = identityLoader.load(key: "A") 377 | async let value4 = identityLoader.load(key: "B") 378 | 379 | try await Task.sleep(nanoseconds: sleepConstant) 380 | 381 | var didFailWithError2: Error? 382 | 383 | do { 384 | _ = try await identityLoader.execute() 385 | } catch { 386 | didFailWithError2 = error 387 | } 388 | 389 | XCTAssertNil(didFailWithError2) 390 | 391 | let result3 = try await value3 392 | let result4 = try await value4 393 | 394 | XCTAssertTrue(result3 == "A") 395 | XCTAssertTrue(result4 == "B") 396 | 397 | let calls2 = await loadCalls.wrappedValue 398 | 399 | XCTAssertTrue(calls2.map { $0.sorted() } == [["A", "B"], ["A", "B"]]) 400 | } 401 | 402 | // Allows priming the cache 403 | func testAllowsPrimingTheCache() async throws { 404 | let loadCalls = Concurrent<[[String]]>([]) 405 | 406 | let identityLoader = DataLoader( 407 | options: DataLoaderOptions(executionPeriod: nil) 408 | ) { keys in 409 | await loadCalls.mutating { $0.append(keys) } 410 | 411 | return keys.map { DataLoaderValue.success($0) } 412 | } 413 | 414 | try await identityLoader.prime(key: "A", value: "A") 415 | 416 | async let value1 = identityLoader.load(key: "A") 417 | async let value2 = identityLoader.load(key: "B") 418 | 419 | try await Task.sleep(nanoseconds: sleepConstant) 420 | 421 | var didFailWithError: Error? 422 | 423 | do { 424 | _ = try await identityLoader.execute() 425 | } catch { 426 | didFailWithError = error 427 | } 428 | 429 | XCTAssertNil(didFailWithError) 430 | 431 | let result1 = try await value1 432 | let result2 = try await value2 433 | 434 | XCTAssertTrue(result1 == "A") 435 | XCTAssertTrue(result2 == "B") 436 | 437 | let calls = await loadCalls.wrappedValue 438 | 439 | XCTAssertTrue(calls.map { $0.sorted() } == [["B"]]) 440 | } 441 | 442 | /// Does not prime keys that already exist 443 | func testDoesNotPrimeKeysThatAlreadyExist() async throws { 444 | let loadCalls = Concurrent<[[String]]>([]) 445 | 446 | let identityLoader = DataLoader( 447 | options: DataLoaderOptions(executionPeriod: nil) 448 | ) { keys in 449 | await loadCalls.mutating { $0.append(keys) } 450 | 451 | return keys.map { DataLoaderValue.success($0) } 452 | } 453 | 454 | try await identityLoader.prime(key: "A", value: "X") 455 | 456 | async let value1 = identityLoader.load(key: "A") 457 | async let value2 = identityLoader.load(key: "B") 458 | 459 | try await Task.sleep(nanoseconds: sleepConstant) 460 | 461 | var didFailWithError: Error? 462 | 463 | do { 464 | _ = try await identityLoader.execute() 465 | } catch { 466 | didFailWithError = error 467 | } 468 | 469 | XCTAssertNil(didFailWithError) 470 | 471 | let result1 = try await value1 472 | let result2 = try await value2 473 | 474 | XCTAssertTrue(result1 == "X") 475 | XCTAssertTrue(result2 == "B") 476 | 477 | try await identityLoader.prime(key: "A", value: "Y") 478 | try await identityLoader.prime(key: "B", value: "Y") 479 | 480 | async let value3 = identityLoader.load(key: "A") 481 | async let value4 = identityLoader.load(key: "B") 482 | 483 | try await Task.sleep(nanoseconds: sleepConstant) 484 | 485 | var didFailWithError2: Error? 486 | 487 | do { 488 | _ = try await identityLoader.execute() 489 | } catch { 490 | didFailWithError2 = error 491 | } 492 | 493 | XCTAssertNil(didFailWithError2) 494 | 495 | let result3 = try await value3 496 | let result4 = try await value4 497 | 498 | XCTAssertTrue(result3 == "X") 499 | XCTAssertTrue(result4 == "B") 500 | 501 | let calls = await loadCalls.wrappedValue 502 | 503 | XCTAssertTrue(calls.map { $0.sorted() } == [["B"]]) 504 | } 505 | 506 | /// Allows forcefully priming the cache 507 | func testAllowsForcefullyPrimingTheCache() async throws { 508 | let loadCalls = Concurrent<[[String]]>([]) 509 | 510 | let identityLoader = DataLoader( 511 | options: DataLoaderOptions(executionPeriod: nil) 512 | ) { keys in 513 | await loadCalls.mutating { $0.append(keys) } 514 | 515 | return keys.map { DataLoaderValue.success($0) } 516 | } 517 | 518 | try await identityLoader.prime(key: "A", value: "X") 519 | 520 | async let value1 = identityLoader.load(key: "A") 521 | async let value2 = identityLoader.load(key: "B") 522 | 523 | try await Task.sleep(nanoseconds: sleepConstant) 524 | 525 | var didFailWithError: Error? 526 | 527 | do { 528 | _ = try await identityLoader.execute() 529 | } catch { 530 | didFailWithError = error 531 | } 532 | 533 | XCTAssertNil(didFailWithError) 534 | 535 | let result1 = try await value1 536 | let result2 = try await value2 537 | 538 | XCTAssertTrue(result1 == "X") 539 | XCTAssertTrue(result2 == "B") 540 | 541 | try await identityLoader.clear(key: "A").prime(key: "A", value: "Y") 542 | try await identityLoader.clear(key: "B").prime(key: "B", value: "Y") 543 | 544 | async let value3 = identityLoader.load(key: "A") 545 | async let value4 = identityLoader.load(key: "B") 546 | 547 | try await Task.sleep(nanoseconds: sleepConstant) 548 | 549 | var didFailWithError2: Error? 550 | 551 | do { 552 | _ = try await identityLoader.execute() 553 | } catch { 554 | didFailWithError2 = error 555 | } 556 | 557 | XCTAssertNil(didFailWithError2) 558 | 559 | let result3 = try await value3 560 | let result4 = try await value4 561 | 562 | XCTAssertTrue(result3 == "Y") 563 | XCTAssertTrue(result4 == "Y") 564 | 565 | let calls = await loadCalls.wrappedValue 566 | 567 | XCTAssertTrue(calls.map { $0.sorted() } == [["B"]]) 568 | } 569 | 570 | func testAutoExecute() async throws { 571 | let identityLoader = DataLoader( 572 | options: DataLoaderOptions(executionPeriod: sleepConstant) 573 | ) { keys in 574 | 575 | keys.map { DataLoaderValue.success($0) } 576 | } 577 | 578 | async let value = identityLoader.load(key: "A") 579 | 580 | // Don't manually call execute, but wait for more than 2ms 581 | usleep(3000) 582 | 583 | let result = try await value 584 | 585 | XCTAssertNotNil(result) 586 | } 587 | 588 | func testErrorResult() async throws { 589 | let loaderErrorMessage = "TEST" 590 | 591 | // Test throwing loader without auto-executing 592 | let throwLoader = DataLoader( 593 | options: DataLoaderOptions(executionPeriod: nil) 594 | ) { _ in 595 | throw DataLoaderError.typeError(loaderErrorMessage) 596 | } 597 | 598 | async let value = throwLoader.load(key: 1) 599 | 600 | try await Task.sleep(nanoseconds: sleepConstant) 601 | 602 | var didFailWithError: DataLoaderError? 603 | 604 | do { 605 | _ = try await throwLoader.execute() 606 | } catch { 607 | didFailWithError = error as? DataLoaderError 608 | } 609 | 610 | XCTAssertNil(didFailWithError) 611 | 612 | var didFailWithError2: DataLoaderError? 613 | 614 | do { 615 | _ = try await value 616 | } catch { 617 | didFailWithError2 = error as? DataLoaderError 618 | } 619 | 620 | var didFailWithErrorText2 = "" 621 | 622 | switch didFailWithError2 { 623 | case let .typeError(text): 624 | didFailWithErrorText2 = text 625 | case .noValueForKey: 626 | break 627 | case .none: 628 | break 629 | } 630 | 631 | XCTAssertEqual(didFailWithErrorText2, loaderErrorMessage) 632 | 633 | // Test throwing loader with auto-executing 634 | let throwLoaderAutoExecute = DataLoader( 635 | options: DataLoaderOptions() 636 | ) { _ in 637 | throw DataLoaderError.typeError(loaderErrorMessage) 638 | } 639 | 640 | async let valueAutoExecute = throwLoaderAutoExecute.load(key: 1) 641 | 642 | var didFailWithError3: DataLoaderError? 643 | 644 | do { 645 | _ = try await valueAutoExecute 646 | } catch { 647 | didFailWithError3 = error as? DataLoaderError 648 | } 649 | 650 | var didFailWithErrorText3 = "" 651 | 652 | switch didFailWithError3 { 653 | case let .typeError(text): 654 | didFailWithErrorText3 = text 655 | case .noValueForKey: 656 | break 657 | case .none: 658 | break 659 | } 660 | 661 | XCTAssertEqual(didFailWithErrorText3, loaderErrorMessage) 662 | } 663 | } 664 | -------------------------------------------------------------------------------- /Tests/DataLoaderTests/DataLoaderAbuseTests.swift: -------------------------------------------------------------------------------- 1 | import NIO 2 | import XCTest 3 | 4 | @testable import DataLoader 5 | 6 | /// Provides descriptive error messages for API abuse 7 | class DataLoaderAbuseTests: XCTestCase { 8 | func testFuntionWithNoValues() throws { 9 | let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1) 10 | defer { 11 | XCTAssertNoThrow(try eventLoopGroup.syncShutdownGracefully()) 12 | } 13 | 14 | let identityLoader = DataLoader( 15 | options: DataLoaderOptions(batchingEnabled: false) 16 | ) { _ in 17 | eventLoopGroup.next().makeSucceededFuture([]) 18 | } 19 | 20 | let value = try identityLoader.load(key: 1, on: eventLoopGroup) 21 | 22 | XCTAssertThrowsError( 23 | try value.wait(), 24 | "Did not return value for key: 1" 25 | ) 26 | } 27 | 28 | func testBatchFuntionMustPromiseAnArrayOfCorrectLength() throws { 29 | let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1) 30 | defer { 31 | XCTAssertNoThrow(try eventLoopGroup.syncShutdownGracefully()) 32 | } 33 | 34 | let identityLoader = DataLoader() { _ in 35 | eventLoopGroup.next().makeSucceededFuture([]) 36 | } 37 | 38 | let value = try identityLoader.load(key: 1, on: eventLoopGroup) 39 | 40 | XCTAssertThrowsError( 41 | try value.wait(), 42 | "The function did not return an array of the same length as the array of keys. \nKeys count: 1\nValues count: 0" 43 | ) 44 | } 45 | 46 | func testBatchFuntionWithSomeValues() throws { 47 | let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1) 48 | defer { 49 | XCTAssertNoThrow(try eventLoopGroup.syncShutdownGracefully()) 50 | } 51 | 52 | let identityLoader = DataLoader() { keys in 53 | var results = [DataLoaderFutureValue]() 54 | 55 | for key in keys { 56 | if key == 1 { 57 | results.append(DataLoaderFutureValue.success(key)) 58 | } else { 59 | results.append( 60 | DataLoaderFutureValue.failure(DataLoaderError.typeError("Test error")) 61 | ) 62 | } 63 | } 64 | 65 | return eventLoopGroup.next().makeSucceededFuture(results) 66 | } 67 | 68 | let value1 = try identityLoader.load(key: 1, on: eventLoopGroup) 69 | let value2 = try identityLoader.load(key: 2, on: eventLoopGroup) 70 | 71 | XCTAssertThrowsError(try value2.wait()) 72 | 73 | XCTAssertTrue(try value1.wait() == 1) 74 | } 75 | 76 | func testFuntionWithSomeValues() throws { 77 | let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1) 78 | defer { 79 | XCTAssertNoThrow(try eventLoopGroup.syncShutdownGracefully()) 80 | } 81 | 82 | let identityLoader = DataLoader( 83 | options: DataLoaderOptions(batchingEnabled: false) 84 | ) { keys in 85 | var results = [DataLoaderFutureValue]() 86 | 87 | for key in keys { 88 | if key == 1 { 89 | results.append(DataLoaderFutureValue.success(key)) 90 | } else { 91 | results.append( 92 | DataLoaderFutureValue.failure(DataLoaderError.typeError("Test error")) 93 | ) 94 | } 95 | } 96 | 97 | return eventLoopGroup.next().makeSucceededFuture(results) 98 | } 99 | 100 | let value1 = try identityLoader.load(key: 1, on: eventLoopGroup) 101 | let value2 = try identityLoader.load(key: 2, on: eventLoopGroup) 102 | 103 | XCTAssertThrowsError(try value2.wait()) 104 | 105 | XCTAssertTrue(try value1.wait() == 1) 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /Tests/DataLoaderTests/DataLoaderAsyncTests.swift: -------------------------------------------------------------------------------- 1 | import NIO 2 | import XCTest 3 | 4 | @testable import DataLoader 5 | 6 | #if compiler(>=5.5) && canImport(_Concurrency) 7 | 8 | @available(macOS 12, iOS 15, watchOS 8, tvOS 15, *) 9 | actor Concurrent { 10 | var wrappedValue: T 11 | 12 | func nonmutating(_ action: (T) throws -> Returned) async rethrows -> Returned { 13 | try action(wrappedValue) 14 | } 15 | 16 | func mutating(_ action: (inout T) throws -> Returned) async rethrows -> Returned { 17 | try action(&wrappedValue) 18 | } 19 | 20 | init(_ value: T) { 21 | wrappedValue = value 22 | } 23 | } 24 | 25 | /// Primary API 26 | @available(macOS 12, iOS 15, watchOS 8, tvOS 15, *) 27 | final class DataLoaderAsyncTests: XCTestCase { 28 | /// Builds a really really simple data loader with async await 29 | func testReallyReallySimpleDataLoader() async throws { 30 | let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1) 31 | defer { 32 | XCTAssertNoThrow(try eventLoopGroup.syncShutdownGracefully()) 33 | } 34 | 35 | let identityLoader = DataLoader( 36 | on: eventLoopGroup.next(), 37 | options: DataLoaderOptions(batchingEnabled: false) 38 | ) { keys async in 39 | let task = Task { 40 | keys.map { DataLoaderFutureValue.success($0) } 41 | } 42 | return await task.value 43 | } 44 | 45 | let value = try await identityLoader.load(key: 1, on: eventLoopGroup) 46 | 47 | XCTAssertEqual(value, 1) 48 | } 49 | 50 | /// Supports loading multiple keys in one call 51 | func testLoadingMultipleKeys() async throws { 52 | let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1) 53 | defer { 54 | XCTAssertNoThrow(try eventLoopGroup.syncShutdownGracefully()) 55 | } 56 | 57 | let identityLoader = DataLoader(on: eventLoopGroup.next()) { keys in 58 | let task = Task { 59 | keys.map { DataLoaderFutureValue.success($0) } 60 | } 61 | return await task.value 62 | } 63 | 64 | let values = try await identityLoader.loadMany(keys: [1, 2], on: eventLoopGroup) 65 | 66 | XCTAssertEqual(values, [1, 2]) 67 | 68 | let empty = try await identityLoader.loadMany(keys: [], on: eventLoopGroup) 69 | 70 | XCTAssertTrue(empty.isEmpty) 71 | } 72 | 73 | // Batches multiple requests 74 | func testMultipleRequests() async throws { 75 | let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1) 76 | defer { 77 | XCTAssertNoThrow(try eventLoopGroup.syncShutdownGracefully()) 78 | } 79 | 80 | let loadCalls = Concurrent<[[Int]]>([]) 81 | 82 | let identityLoader = DataLoader( 83 | on: eventLoopGroup.next(), 84 | options: DataLoaderOptions( 85 | batchingEnabled: true, 86 | executionPeriod: nil 87 | ) 88 | ) { keys in 89 | await loadCalls.mutating { $0.append(keys) } 90 | let task = Task { 91 | keys.map { DataLoaderFutureValue.success($0) } 92 | } 93 | return await task.value 94 | } 95 | 96 | async let value1 = identityLoader.load(key: 1, on: eventLoopGroup) 97 | async let value2 = identityLoader.load(key: 2, on: eventLoopGroup) 98 | 99 | /// Have to wait for a split second because Tasks may not be executed before this 100 | /// statement 101 | try await Task.sleep(nanoseconds: 500_000_000) 102 | 103 | XCTAssertNoThrow(try identityLoader.execute()) 104 | 105 | let result1 = try await value1 106 | XCTAssertEqual(result1, 1) 107 | let result2 = try await value2 108 | XCTAssertEqual(result2, 2) 109 | 110 | let calls = await loadCalls.wrappedValue 111 | XCTAssertEqual(calls.count, 1) 112 | XCTAssertEqual(calls.map { $0.sorted() }, [[1, 2]]) 113 | } 114 | } 115 | 116 | #endif 117 | -------------------------------------------------------------------------------- /Tests/DataLoaderTests/DataLoaderTests.swift: -------------------------------------------------------------------------------- 1 | import NIO 2 | import XCTest 3 | 4 | @testable import DataLoader 5 | 6 | /// Primary API 7 | final class DataLoaderTests: XCTestCase { 8 | /// Builds a really really simple data loader' 9 | func testReallyReallySimpleDataLoader() throws { 10 | let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1) 11 | defer { 12 | XCTAssertNoThrow(try eventLoopGroup.syncShutdownGracefully()) 13 | } 14 | 15 | let identityLoader = DataLoader( 16 | options: DataLoaderOptions(batchingEnabled: false) 17 | ) { keys in 18 | let results = keys.map { DataLoaderFutureValue.success($0) } 19 | 20 | return eventLoopGroup.next().makeSucceededFuture(results) 21 | } 22 | 23 | let value = try identityLoader.load(key: 1, on: eventLoopGroup).wait() 24 | 25 | XCTAssertEqual(value, 1) 26 | } 27 | 28 | /// Supports loading multiple keys in one call 29 | func testLoadingMultipleKeys() throws { 30 | let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1) 31 | defer { 32 | XCTAssertNoThrow(try eventLoopGroup.syncShutdownGracefully()) 33 | } 34 | 35 | let identityLoader = DataLoader() { keys in 36 | let results = keys.map { DataLoaderFutureValue.success($0) } 37 | 38 | return eventLoopGroup.next().makeSucceededFuture(results) 39 | } 40 | 41 | let values = try identityLoader.loadMany(keys: [1, 2], on: eventLoopGroup).wait() 42 | 43 | XCTAssertEqual(values, [1, 2]) 44 | 45 | let empty = try identityLoader.loadMany(keys: [], on: eventLoopGroup).wait() 46 | 47 | XCTAssertTrue(empty.isEmpty) 48 | } 49 | 50 | // Batches multiple requests 51 | func testMultipleRequests() throws { 52 | let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1) 53 | defer { 54 | XCTAssertNoThrow(try eventLoopGroup.syncShutdownGracefully()) 55 | } 56 | 57 | var loadCalls = [[Int]]() 58 | 59 | let identityLoader = DataLoader( 60 | options: DataLoaderOptions( 61 | batchingEnabled: true, 62 | executionPeriod: nil 63 | ) 64 | ) { keys in 65 | loadCalls.append(keys) 66 | let results = keys.map { DataLoaderFutureValue.success($0) } 67 | 68 | return eventLoopGroup.next().makeSucceededFuture(results) 69 | } 70 | 71 | let value1 = try identityLoader.load(key: 1, on: eventLoopGroup) 72 | let value2 = try identityLoader.load(key: 2, on: eventLoopGroup) 73 | 74 | XCTAssertNoThrow(try identityLoader.execute()) 75 | 76 | XCTAssertEqual(try value1.wait(), 1) 77 | XCTAssertEqual(try value2.wait(), 2) 78 | 79 | XCTAssertEqual(loadCalls, [[1, 2]]) 80 | } 81 | 82 | /// Batches multiple requests with max batch sizes 83 | func testMultipleRequestsWithMaxBatchSize() throws { 84 | let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1) 85 | defer { 86 | XCTAssertNoThrow(try eventLoopGroup.syncShutdownGracefully()) 87 | } 88 | 89 | var loadCalls = [[Int]]() 90 | 91 | let identityLoader = DataLoader( 92 | options: DataLoaderOptions( 93 | batchingEnabled: true, 94 | maxBatchSize: 2, 95 | executionPeriod: nil 96 | ) 97 | ) { keys in 98 | loadCalls.append(keys) 99 | let results = keys.map { DataLoaderFutureValue.success($0) } 100 | 101 | return eventLoopGroup.next().makeSucceededFuture(results) 102 | } 103 | 104 | let value1 = try identityLoader.load(key: 1, on: eventLoopGroup) 105 | let value2 = try identityLoader.load(key: 2, on: eventLoopGroup) 106 | let value3 = try identityLoader.load(key: 3, on: eventLoopGroup) 107 | 108 | XCTAssertNoThrow(try identityLoader.execute()) 109 | 110 | XCTAssertEqual(try value1.wait(), 1) 111 | XCTAssertEqual(try value2.wait(), 2) 112 | XCTAssertEqual(try value3.wait(), 3) 113 | 114 | XCTAssertEqual(loadCalls, [[1, 2], [3]]) 115 | } 116 | 117 | /// Coalesces identical requests 118 | func testCoalescesIdenticalRequests() throws { 119 | let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1) 120 | defer { 121 | XCTAssertNoThrow(try eventLoopGroup.syncShutdownGracefully()) 122 | } 123 | 124 | var loadCalls = [[Int]]() 125 | 126 | let identityLoader = DataLoader( 127 | options: DataLoaderOptions(executionPeriod: nil) 128 | ) { keys in 129 | loadCalls.append(keys) 130 | let results = keys.map { DataLoaderFutureValue.success($0) } 131 | 132 | return eventLoopGroup.next().makeSucceededFuture(results) 133 | } 134 | 135 | let value1 = try identityLoader.load(key: 1, on: eventLoopGroup) 136 | let value2 = try identityLoader.load(key: 1, on: eventLoopGroup) 137 | 138 | XCTAssertNoThrow(try identityLoader.execute()) 139 | 140 | XCTAssertTrue(try value1.map { $0 }.wait() == 1) 141 | XCTAssertTrue(try value2.map { $0 }.wait() == 1) 142 | 143 | XCTAssertTrue(loadCalls == [[1]]) 144 | } 145 | 146 | // Caches repeated requests 147 | func testCachesRepeatedRequests() throws { 148 | let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1) 149 | defer { 150 | XCTAssertNoThrow(try eventLoopGroup.syncShutdownGracefully()) 151 | } 152 | 153 | var loadCalls = [[String]]() 154 | 155 | let identityLoader = DataLoader( 156 | options: DataLoaderOptions(executionPeriod: nil) 157 | ) { keys in 158 | loadCalls.append(keys) 159 | let results = keys.map { DataLoaderFutureValue.success($0) } 160 | 161 | return eventLoopGroup.next().makeSucceededFuture(results) 162 | } 163 | 164 | let value1 = try identityLoader.load(key: "A", on: eventLoopGroup) 165 | let value2 = try identityLoader.load(key: "B", on: eventLoopGroup) 166 | 167 | XCTAssertNoThrow(try identityLoader.execute()) 168 | 169 | XCTAssertTrue(try value1.wait() == "A") 170 | XCTAssertTrue(try value2.wait() == "B") 171 | XCTAssertTrue(loadCalls == [["A", "B"]]) 172 | 173 | let value3 = try identityLoader.load(key: "A", on: eventLoopGroup) 174 | let value4 = try identityLoader.load(key: "C", on: eventLoopGroup) 175 | 176 | XCTAssertNoThrow(try identityLoader.execute()) 177 | 178 | XCTAssertTrue(try value3.wait() == "A") 179 | XCTAssertTrue(try value4.wait() == "C") 180 | XCTAssertTrue(loadCalls == [["A", "B"], ["C"]]) 181 | 182 | let value5 = try identityLoader.load(key: "A", on: eventLoopGroup) 183 | let value6 = try identityLoader.load(key: "B", on: eventLoopGroup) 184 | let value7 = try identityLoader.load(key: "C", on: eventLoopGroup) 185 | 186 | XCTAssertNoThrow(try identityLoader.execute()) 187 | 188 | XCTAssertTrue(try value5.wait() == "A") 189 | XCTAssertTrue(try value6.wait() == "B") 190 | XCTAssertTrue(try value7.wait() == "C") 191 | XCTAssertTrue(loadCalls == [["A", "B"], ["C"]]) 192 | } 193 | 194 | /// Clears single value in loader 195 | func testClearSingleValueLoader() throws { 196 | let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1) 197 | defer { 198 | XCTAssertNoThrow(try eventLoopGroup.syncShutdownGracefully()) 199 | } 200 | 201 | var loadCalls = [[String]]() 202 | 203 | let identityLoader = DataLoader( 204 | options: DataLoaderOptions(executionPeriod: nil) 205 | ) { keys in 206 | loadCalls.append(keys) 207 | let results = keys.map { DataLoaderFutureValue.success($0) } 208 | 209 | return eventLoopGroup.next().makeSucceededFuture(results) 210 | } 211 | 212 | let value1 = try identityLoader.load(key: "A", on: eventLoopGroup) 213 | let value2 = try identityLoader.load(key: "B", on: eventLoopGroup) 214 | 215 | XCTAssertNoThrow(try identityLoader.execute()) 216 | 217 | XCTAssertTrue(try value1.wait() == "A") 218 | XCTAssertTrue(try value2.wait() == "B") 219 | XCTAssertTrue(loadCalls == [["A", "B"]]) 220 | 221 | _ = identityLoader.clear(key: "A") 222 | 223 | let value3 = try identityLoader.load(key: "A", on: eventLoopGroup) 224 | let value4 = try identityLoader.load(key: "B", on: eventLoopGroup) 225 | 226 | XCTAssertNoThrow(try identityLoader.execute()) 227 | 228 | XCTAssertTrue(try value3.wait() == "A") 229 | XCTAssertTrue(try value4.wait() == "B") 230 | XCTAssertTrue(loadCalls == [["A", "B"], ["A"]]) 231 | } 232 | 233 | /// Clears all values in loader 234 | func testClearsAllValuesInLoader() throws { 235 | let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1) 236 | defer { 237 | XCTAssertNoThrow(try eventLoopGroup.syncShutdownGracefully()) 238 | } 239 | 240 | var loadCalls = [[String]]() 241 | 242 | let identityLoader = DataLoader( 243 | options: DataLoaderOptions(executionPeriod: nil) 244 | ) { keys in 245 | loadCalls.append(keys) 246 | let results = keys.map { DataLoaderFutureValue.success($0) } 247 | 248 | return eventLoopGroup.next().makeSucceededFuture(results) 249 | } 250 | 251 | let value1 = try identityLoader.load(key: "A", on: eventLoopGroup) 252 | let value2 = try identityLoader.load(key: "B", on: eventLoopGroup) 253 | 254 | XCTAssertNoThrow(try identityLoader.execute()) 255 | 256 | XCTAssertTrue(try value1.wait() == "A") 257 | XCTAssertTrue(try value2.wait() == "B") 258 | XCTAssertTrue(loadCalls == [["A", "B"]]) 259 | 260 | _ = identityLoader.clearAll() 261 | 262 | let value3 = try identityLoader.load(key: "A", on: eventLoopGroup) 263 | let value4 = try identityLoader.load(key: "B", on: eventLoopGroup) 264 | 265 | XCTAssertNoThrow(try identityLoader.execute()) 266 | 267 | XCTAssertTrue(try value3.wait() == "A") 268 | XCTAssertTrue(try value4.wait() == "B") 269 | XCTAssertTrue(loadCalls == [["A", "B"], ["A", "B"]]) 270 | } 271 | 272 | // Allows priming the cache 273 | func testAllowsPrimingTheCache() throws { 274 | let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1) 275 | defer { 276 | XCTAssertNoThrow(try eventLoopGroup.syncShutdownGracefully()) 277 | } 278 | 279 | var loadCalls = [[String]]() 280 | 281 | let identityLoader = DataLoader( 282 | options: DataLoaderOptions(executionPeriod: nil) 283 | ) { keys in 284 | loadCalls.append(keys) 285 | let results = keys.map { DataLoaderFutureValue.success($0) } 286 | 287 | return eventLoopGroup.next().makeSucceededFuture(results) 288 | } 289 | 290 | _ = identityLoader.prime(key: "A", value: "A", on: eventLoopGroup) 291 | 292 | let value1 = try identityLoader.load(key: "A", on: eventLoopGroup) 293 | let value2 = try identityLoader.load(key: "B", on: eventLoopGroup) 294 | 295 | XCTAssertNoThrow(try identityLoader.execute()) 296 | 297 | XCTAssertTrue(try value1.wait() == "A") 298 | XCTAssertTrue(try value2.wait() == "B") 299 | XCTAssertTrue(loadCalls == [["B"]]) 300 | } 301 | 302 | /// Does not prime keys that already exist 303 | func testDoesNotPrimeKeysThatAlreadyExist() throws { 304 | let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1) 305 | defer { 306 | XCTAssertNoThrow(try eventLoopGroup.syncShutdownGracefully()) 307 | } 308 | 309 | var loadCalls = [[String]]() 310 | 311 | let identityLoader = DataLoader( 312 | options: DataLoaderOptions(executionPeriod: nil) 313 | ) { keys in 314 | loadCalls.append(keys) 315 | let results = keys.map { DataLoaderFutureValue.success($0) } 316 | 317 | return eventLoopGroup.next().makeSucceededFuture(results) 318 | } 319 | 320 | _ = identityLoader.prime(key: "A", value: "X", on: eventLoopGroup) 321 | 322 | let value1 = try identityLoader.load(key: "A", on: eventLoopGroup) 323 | let value2 = try identityLoader.load(key: "B", on: eventLoopGroup) 324 | 325 | XCTAssertNoThrow(try identityLoader.execute()) 326 | 327 | XCTAssertTrue(try value1.wait() == "X") 328 | XCTAssertTrue(try value2.wait() == "B") 329 | 330 | _ = identityLoader.prime(key: "A", value: "Y", on: eventLoopGroup) 331 | _ = identityLoader.prime(key: "B", value: "Y", on: eventLoopGroup) 332 | 333 | let value3 = try identityLoader.load(key: "A", on: eventLoopGroup) 334 | let value4 = try identityLoader.load(key: "B", on: eventLoopGroup) 335 | 336 | XCTAssertNoThrow(try identityLoader.execute()) 337 | 338 | XCTAssertTrue(try value3.wait() == "X") 339 | XCTAssertTrue(try value4.wait() == "B") 340 | 341 | XCTAssertTrue(loadCalls == [["B"]]) 342 | } 343 | 344 | /// Allows forcefully priming the cache 345 | func testAllowsForcefullyPrimingTheCache() throws { 346 | let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1) 347 | defer { 348 | XCTAssertNoThrow(try eventLoopGroup.syncShutdownGracefully()) 349 | } 350 | 351 | var loadCalls = [[String]]() 352 | 353 | let identityLoader = DataLoader( 354 | options: DataLoaderOptions(executionPeriod: nil) 355 | ) { keys in 356 | loadCalls.append(keys) 357 | let results = keys.map { DataLoaderFutureValue.success($0) } 358 | 359 | return eventLoopGroup.next().makeSucceededFuture(results) 360 | } 361 | 362 | _ = identityLoader.prime(key: "A", value: "X", on: eventLoopGroup) 363 | 364 | let value1 = try identityLoader.load(key: "A", on: eventLoopGroup) 365 | let value2 = try identityLoader.load(key: "B", on: eventLoopGroup) 366 | 367 | XCTAssertNoThrow(try identityLoader.execute()) 368 | 369 | XCTAssertTrue(try value1.wait() == "X") 370 | XCTAssertTrue(try value2.wait() == "B") 371 | 372 | _ = identityLoader.clear(key: "A").prime(key: "A", value: "Y", on: eventLoopGroup) 373 | _ = identityLoader.clear(key: "B").prime(key: "B", value: "Y", on: eventLoopGroup) 374 | 375 | let value3 = try identityLoader.load(key: "A", on: eventLoopGroup) 376 | let value4 = try identityLoader.load(key: "B", on: eventLoopGroup) 377 | 378 | XCTAssertNoThrow(try identityLoader.execute()) 379 | 380 | XCTAssertTrue(try value3.wait() == "Y") 381 | XCTAssertTrue(try value4.wait() == "Y") 382 | 383 | XCTAssertTrue(loadCalls == [["B"]]) 384 | } 385 | 386 | // Caches repeated requests, even if initiated asyncronously 387 | func testCacheConcurrency() throws { 388 | let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1) 389 | defer { 390 | XCTAssertNoThrow(try eventLoopGroup.syncShutdownGracefully()) 391 | } 392 | 393 | let identityLoader = DataLoader(options: DataLoaderOptions()) { keys in 394 | let results = keys.map { DataLoaderFutureValue.success($0) } 395 | 396 | return eventLoopGroup.next().makeSucceededFuture(results) 397 | } 398 | 399 | // Populate values from two different dispatch queues, running asynchronously 400 | var value1: EventLoopFuture = eventLoopGroup.next().makeSucceededFuture("") 401 | var value2: EventLoopFuture = eventLoopGroup.next().makeSucceededFuture("") 402 | DispatchQueue(label: "").async { 403 | value1 = try! identityLoader.load(key: "A", on: eventLoopGroup) 404 | } 405 | DispatchQueue(label: "").async { 406 | value2 = try! identityLoader.load(key: "A", on: eventLoopGroup) 407 | } 408 | 409 | // Sleep for a few ms ensure that value1 & value2 are populated before continuing 410 | usleep(1000) 411 | 412 | XCTAssertNoThrow(try identityLoader.execute()) 413 | 414 | // Test that the futures themselves are equal (not just the value). 415 | XCTAssertEqual(value1, value2) 416 | } 417 | 418 | func testAutoExecute() throws { 419 | let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1) 420 | defer { 421 | XCTAssertNoThrow(try eventLoopGroup.syncShutdownGracefully()) 422 | } 423 | 424 | let identityLoader = DataLoader( 425 | options: DataLoaderOptions(executionPeriod: .milliseconds(2)) 426 | ) { keys in 427 | let results = keys.map { DataLoaderFutureValue.success($0) } 428 | 429 | return eventLoopGroup.next().makeSucceededFuture(results) 430 | } 431 | 432 | var value: String? 433 | _ = try identityLoader.load(key: "A", on: eventLoopGroup).map { result in 434 | value = result 435 | } 436 | 437 | // Don't manually call execute, but wait for more than 2ms 438 | usleep(3000) 439 | 440 | XCTAssertNotNil(value) 441 | } 442 | 443 | func testErrorResult() throws { 444 | let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1) 445 | defer { 446 | XCTAssertNoThrow(try eventLoopGroup.syncShutdownGracefully()) 447 | } 448 | 449 | let loaderErrorMessage = "TEST" 450 | 451 | // Test throwing loader without auto-executing 452 | let throwLoader = DataLoader( 453 | options: DataLoaderOptions(executionPeriod: nil) 454 | ) { _ in 455 | throw DataLoaderError.typeError(loaderErrorMessage) 456 | } 457 | 458 | let value = try throwLoader.load(key: 1, on: eventLoopGroup) 459 | XCTAssertNoThrow(try throwLoader.execute()) 460 | XCTAssertThrowsError( 461 | try value.wait(), 462 | loaderErrorMessage 463 | ) 464 | 465 | // Test throwing loader with auto-executing 466 | let throwLoaderAutoExecute = DataLoader( 467 | options: DataLoaderOptions() 468 | ) { _ in 469 | throw DataLoaderError.typeError(loaderErrorMessage) 470 | } 471 | 472 | XCTAssertThrowsError( 473 | try throwLoaderAutoExecute.load(key: 1, on: eventLoopGroup).wait(), 474 | loaderErrorMessage 475 | ) 476 | } 477 | } 478 | --------------------------------------------------------------------------------