├── .gitattributes ├── .github ├── CODEOWNERS └── CONTRIBUTING.md ├── .gitignore ├── .jazzy.yaml ├── .ruby-version ├── .swiftlint.yml ├── .swiftpm └── xcode │ └── package.xcworkspace │ └── contents.xcworkspacedata ├── Example ├── AppDelegate.swift ├── AutomaticViewController.swift ├── Base.lproj │ ├── LaunchScreen.xib │ └── Main.storyboard ├── Core Data │ ├── FlapjackExample.xcdatamodeld │ │ └── FlapjackExample.xcdatamodel │ │ │ └── contents │ └── Pancake.swift ├── Images.xcassets │ ├── AppIcon.appiconset │ │ ├── Contents.json │ │ ├── Icon.png │ │ ├── icon_20pt@2x.png │ │ ├── icon_20pt@3x.png │ │ ├── icon_29pt@2x.png │ │ ├── icon_29pt@3x.png │ │ ├── icon_40pt@2x.png │ │ ├── icon_40pt@3x.png │ │ ├── icon_60pt@2x.png │ │ └── icon_60pt@3x.png │ ├── Contents.json │ └── pancakes.imageset │ │ ├── Contents.json │ │ └── pancakes.jpg ├── Info.plist ├── ManualViewController.swift └── PancakeMaker.swift ├── Flapjack.podspec ├── Flapjack.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist └── xcshareddata │ └── xcschemes │ ├── Example.xcscheme │ ├── Flapjack-iOS.xcscheme │ ├── Flapjack-macOS.xcscheme │ ├── Flapjack-tvOS.xcscheme │ ├── FlapjackCoreData-iOS.xcscheme │ ├── FlapjackCoreData-macOS.xcscheme │ ├── FlapjackCoreData-tvOS.xcscheme │ ├── FlapjackUIKit-iOS.xcscheme │ ├── FlapjackUIKit-tvOS.xcscheme │ ├── Tests-iOS.xcscheme │ ├── Tests-macOS.xcscheme │ └── Tests-tvOS.xcscheme ├── Gemfile ├── Gemfile.lock ├── LICENSE ├── Makefile ├── Package.swift ├── README.md ├── Sources ├── Core │ ├── DataAccess.swift │ ├── DataContext.swift │ ├── DataObject.swift │ ├── DataObjectID.swift │ ├── DataSource.swift │ ├── Extensions │ │ ├── Array+Extensions.swift │ │ ├── Collection+Extensions.swift │ │ ├── NSCompoundPredicate+Extensions.swift │ │ └── Set+Extensions.swift │ ├── Migrator.swift │ ├── SingleDataSource.swift │ ├── Support │ │ └── Logger.swift │ ├── Supporting Files │ │ └── Info.plist │ └── Types │ │ ├── DataAccessError.swift │ │ ├── DataContextError.swift │ │ ├── DataContextScheduledTaskType.swift │ │ ├── DataSourceChange.swift │ │ ├── DataSourceSectionChange.swift │ │ ├── PrimaryKey.swift │ │ └── SortDescriptor.swift ├── CoreData │ ├── CoreDataAccess.swift │ ├── CoreDataMigrator.swift │ ├── CoreDataSource.swift │ ├── CoreDataSourceFactory.swift │ ├── Extensions │ │ ├── Dictionary+Extensions.swift │ │ ├── NSFetchedResultsChangeType+Extensions.swift │ │ ├── NSManagedObject+DataObject.swift │ │ ├── NSManagedObjectContext+DataContext.swift │ │ ├── NSManagedObjectContext+Extensions.swift │ │ ├── NSManagedObjectID+DataObjectID.swift │ │ └── NSMigrationManager+Extensions.swift │ ├── MigrationPolicy.swift │ └── SingleCoreDataSource.swift └── UIKit │ └── Extensions │ ├── Set+DataSourceChange.swift │ ├── UICollectionView+Extensions.swift │ └── UITableView+Extensions.swift ├── Tests ├── Core │ ├── Extensions │ │ ├── Array+ExtensionsTests.swift │ │ ├── Collection+ExtensionsTests.swift │ │ └── NSCompoundPredicate+ExtensionsTests.swift │ └── Types │ │ ├── DataAccessErrorTests.swift │ │ ├── DataContextErrorTests.swift │ │ └── SortDescriptorTests.swift ├── CoreData │ ├── CoreDataAccessTests.swift │ ├── CoreDataMigratorTests.swift │ ├── CoreDataSourceTests.swift │ ├── Extensions │ │ ├── Dictionary+ExtensionsTests.swift │ │ ├── NSFetchedResultsChangeTypeExtensionsTests.swift │ │ └── NSManagedObjectContextDataContextTests.swift │ ├── Mocks │ │ ├── MockDataAccessDelegate.swift │ │ ├── MockEntity.swift │ │ ├── MockMigrationPolicy.swift │ │ └── MockMigrator.swift │ ├── Resources │ │ ├── Mapping Models │ │ │ ├── TestMigrationModel 3.xcmappingmodel │ │ │ │ └── xcmapping.xml │ │ │ └── TestMigrationModel 4.xcmappingmodel │ │ │ │ └── xcmapping.xml │ │ ├── TestMigrationModel.xcdatamodeld │ │ │ ├── .xccurrentversion │ │ │ ├── TestMigrationModel 2.xcdatamodel │ │ │ │ └── contents │ │ │ ├── TestMigrationModel 3.xcdatamodel │ │ │ │ └── contents │ │ │ ├── TestMigrationModel 4.xcdatamodel │ │ │ │ └── contents │ │ │ └── TestMigrationModel.xcdatamodel │ │ │ │ └── contents │ │ └── TestModel.xcdatamodeld │ │ │ └── TestModel.xcdatamodel │ │ │ └── contents │ └── SingleCoreDataSourceTests.swift ├── Info-iOS.plist ├── Info-macOS.plist └── Info-tvOS.plist └── docs ├── Classes.html ├── Classes ├── CoreDataAccess.html ├── CoreDataAccess │ └── StoreType.html ├── CoreDataSource.html ├── CoreDataSourceFactory.html ├── CoreSingleDataSource.html ├── FJLogger.html ├── Logger.html ├── MigrationPolicy.html └── SingleCoreDataSource.html ├── Enums.html ├── Enums ├── DataAccessError.html ├── DataContextError.html ├── DataSourceChange.html ├── DataSourceSectionChange.html ├── LoggerLevel.html └── MigratorError.html ├── Extensions.html ├── Extensions ├── Array.html ├── NSCompoundPredicate.html ├── NSFetchedResultsChangeType.html ├── NSManagedObjectContext.html ├── NSMigrationManager.html ├── NSMigrationManager │ └── Layer.html ├── NSPredicate.html ├── Sequence.html ├── Set.html ├── UICollectionView.html └── UITableView.html ├── Protocols.html ├── Protocols ├── DataAccess.html ├── DataAccessDelegate.html ├── DataContext.html ├── DataObject.html ├── DataSource.html ├── Migrator.html └── SingleDataSource.html ├── Structs.html ├── Structs └── SortDescriptor.html ├── Typealiases.html ├── badge.svg ├── css ├── highlight.css └── jazzy.css ├── docsets ├── Flapjack.docset │ └── Contents │ │ ├── Info.plist │ │ └── Resources │ │ ├── Documents │ │ ├── Classes.html │ │ ├── Classes │ │ │ ├── CoreDataAccess.html │ │ │ ├── CoreDataAccess │ │ │ │ └── StoreType.html │ │ │ ├── CoreDataSource.html │ │ │ ├── CoreDataSourceFactory.html │ │ │ ├── CoreSingleDataSource.html │ │ │ ├── FJLogger.html │ │ │ ├── Logger.html │ │ │ ├── MigrationPolicy.html │ │ │ └── SingleCoreDataSource.html │ │ ├── Enums.html │ │ ├── Enums │ │ │ ├── DataAccessError.html │ │ │ ├── DataContextError.html │ │ │ ├── DataSourceChange.html │ │ │ ├── DataSourceSectionChange.html │ │ │ ├── LoggerLevel.html │ │ │ └── MigratorError.html │ │ ├── Extensions.html │ │ ├── Extensions │ │ │ ├── Array.html │ │ │ ├── NSCompoundPredicate.html │ │ │ ├── NSFetchedResultsChangeType.html │ │ │ ├── NSManagedObjectContext.html │ │ │ ├── NSMigrationManager.html │ │ │ ├── NSMigrationManager │ │ │ │ └── Layer.html │ │ │ ├── NSPredicate.html │ │ │ ├── Sequence.html │ │ │ ├── Set.html │ │ │ ├── UICollectionView.html │ │ │ └── UITableView.html │ │ ├── Protocols.html │ │ ├── Protocols │ │ │ ├── DataAccess.html │ │ │ ├── DataAccessDelegate.html │ │ │ ├── DataContext.html │ │ │ ├── DataObject.html │ │ │ ├── DataSource.html │ │ │ ├── Migrator.html │ │ │ └── SingleDataSource.html │ │ ├── Structs.html │ │ ├── Structs │ │ │ └── SortDescriptor.html │ │ ├── Typealiases.html │ │ ├── badge.svg │ │ ├── css │ │ │ ├── highlight.css │ │ │ └── jazzy.css │ │ ├── img │ │ │ ├── carat.png │ │ │ ├── dash.png │ │ │ ├── gh.png │ │ │ └── spinner.gif │ │ ├── index.html │ │ ├── js │ │ │ ├── jazzy.js │ │ │ ├── jazzy.search.js │ │ │ ├── jquery.min.js │ │ │ ├── lunr.min.js │ │ │ └── typeahead.jquery.js │ │ ├── search.json │ │ └── undocumented.json │ │ └── docSet.dsidx └── Flapjack.tgz ├── img ├── carat.png ├── dash.png ├── gh.png └── spinner.gif ├── index.html ├── js ├── jazzy.js ├── jazzy.search.js ├── jquery.min.js ├── lunr.min.js └── typeahead.jquery.js ├── search.json └── undocumented.json /.gitattributes: -------------------------------------------------------------------------------- 1 | Gemfile* linguist-vendored 2 | README.md linguist-vendored 3 | .ruby-version linguist-vendored 4 | Example/* linguist-vendored 5 | Podfile* linguist-vendored 6 | Flapjack.podspec linguist-vendored 7 | "Flapjack/Core/Supporting Files/*" linguist-vendored 8 | docs/* linguist-vendored 9 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @oreillymedia/native-apps 2 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing guidelines 2 | 3 | So you've decided to contribute to Flapjack? That's great! We'd love to have you lend a hand and make our project even better. 4 | 5 | 6 | ## Git flow 7 | 8 | If you're outside the O'Reilly organization, then this one is a no brainer, but if you _are_ part of the O'Reilly organization, we prefer that you fork this repository to your personal GitHub account before you work on it. Perform your changes in a separate branch, push that branch to your personal repository, and then open a pull request from your branch to our main repository. In short: 9 | 10 | 1. Fork to your personal GitHub account 11 | 2. Create a separate branch off `master` 12 | 3. Commit your changes (more on this below) 13 | 4. Push branch up to your personal GitHub account 14 | 5. Open a pull request against our main repository 15 | 16 | When we approve your pull request, we will merge it into our `master` branch using GitHub's "squash-and-merge" functionality, which will combine your commits into a single commit, tagged with the pull request number. 17 | 18 | Additionally, you'll probably want to add our upstream main repository as a git remote to the one you've got sitting on your machine. It's easy! 19 | 20 | ```bash 21 | $ git remote add upstream https://github.com/oreillymedia/flapjack.git 22 | ``` 23 | 24 | Then any time you want to make your `master` match our `master`: 25 | 26 | ```bash 27 | $ git fetch upstream master 28 | $ git reset --hard upstream/master 29 | ``` 30 | 31 | It's always a great idea to make sure and perform this step before you start working on any new branches. It will likely save you rebase/merge headaches when it comes time to integrate your changes with our repository. 32 | 33 | 34 | ## Commits 35 | 36 | We'd prefer your commit messages follow a certain format. For super-simple changes, a one-line commit statement will do just fine, provided you keep it under 72 characters. For more complex changes, a simple one-line description under 72 characters should be in the first line, followed by 2 line breaks, and then a more detailed description of the changes in as many lines or paragraphs as you see fit. 37 | 38 | 39 | ## Coding conventions and linting 40 | 41 | We've got SwiftLint integration with our project which should define the coding conventions we like to stick to in this repository, and we expect all pull requests submitted should pass a linter check. If you've got CocoaPods installed and your `Pods` directory is up-to-date (it should be!), then Xcode will automatically run the linter as part of the normal build process, and any warnings will be revealed to you right within Xcode. 42 | 43 | 44 | ## Questions 45 | 46 | If you've got any questions at all, please reach out to us! Our contact information is [at the bottom of our README](https://github.com/oreillymedia/flapjack#authors). -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # OS X 2 | .DS_Store 3 | 4 | # Xcode 5 | build/ 6 | .build/ 7 | *.pbxuser 8 | !default.pbxuser 9 | *.mode1v3 10 | !default.mode1v3 11 | *.mode2v3 12 | !default.mode2v3 13 | *.perspectivev3 14 | !default.perspectivev3 15 | xcuserdata/ 16 | *.xccheckout 17 | profile 18 | *.moved-aside 19 | DerivedData 20 | *.hmap 21 | *.ipa 22 | 23 | # Bundler 24 | .bundle 25 | 26 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 27 | # Carthage/Checkouts 28 | 29 | Carthage/Build 30 | 31 | # We recommend against adding the Pods directory to your .gitignore. However 32 | # you should judge for yourself, the pros and cons are mentioned at: 33 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-ignore-the-pods-directory-in-source-control 34 | # 35 | # Note: if you ignore the Pods directory, make sure to uncomment 36 | # `pod install` in .travis.yml 37 | # 38 | Pods/ 39 | -------------------------------------------------------------------------------- /.jazzy.yaml: -------------------------------------------------------------------------------- 1 | sdk: iphoneos 2 | author: O'Reilly Media, Inc. 3 | author_url: https://oreilly.com 4 | readme: readme.md 5 | podspec: Flapjack.podspec 6 | github_url: https://github.com/oreillymedia/flapjack 7 | github_file_prefix: https://github.com/oreillymedia/flapjack/tree/master 8 | theme: fullwidth 9 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.0.3 2 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | # Reference: 2 | # https://github.com/realm/SwiftLint/blob/master/Rules.md 3 | 4 | disabled_rules: 5 | # Disable rules that are in the default set 6 | - cyclomatic_complexity # Disabled because we've got methods with lots of if nil checks 7 | - file_name 8 | - function_body_length 9 | - function_parameter_count 10 | - line_length 11 | - no_fallthrough_only 12 | - notification_center_detachment 13 | - type_body_length 14 | # The following are disabled by default anyway 15 | - function_default_parameter_at_end # I'd like to enable this someday 16 | - multiline_function_chains # I'd like to enable this someday 17 | - multiline_parameters # I'd like to enable this someday 18 | - multiline_arguments # I'd like to enable this someday 19 | - object_literal # I'd like to enable this someday 20 | - pattern_matching_keywords # I'd like to enable this someday 21 | - private_action # I'd like to enable this someday 22 | - private_outlet # I'd like to enable this someday 23 | - single_test_class # I'd like to enable this someday 24 | - todo # I'd like to enable this someday 25 | - unavailable_function # I'd like to enable this someday 26 | - xctfail_message # I'd like to enable this someday 27 | opt_in_rules: 28 | # Enable rules not in the default set 29 | - anyobject_protocol 30 | - array_init 31 | - attributes 32 | - closure_spacing 33 | - conditional_returns_on_newline 34 | - contains_over_first_not_nil 35 | - convenience_type 36 | - empty_count 37 | - empty_string 38 | - empty_xctest_method 39 | - explicit_init 40 | - extension_access_modifier # Maybe? 41 | - fallthrough 42 | - fatal_error_message 43 | - file_name # Maybe? 44 | - first_where 45 | - joined_default_parameter 46 | - lower_acl_than_parent 47 | - modifier_order 48 | - overridden_super_call 49 | - prohibited_super_call 50 | - redundant_nil_coalescing 51 | - required_enum_case # Maybe? 52 | - sorted_first_last 53 | - unneeded_parentheses_in_closure_argument 54 | - vertical_parameter_alignment_on_call 55 | - yoda_condition 56 | included: 57 | - Flapjack 58 | - Example 59 | - Tests 60 | 61 | colon: 62 | apply_to_dictionaries: true 63 | 64 | conditional_returns_on_newline: 65 | # applies only to if-statements 66 | if_only: true 67 | 68 | file_length: 69 | warning: 1000 70 | error: 5000 71 | 72 | identifier_name: 73 | excluded: 74 | - qa 75 | - i 76 | 77 | large_tuple: 78 | warning: 4 79 | error: 5 80 | 81 | type_name: 82 | excluded: 83 | - T 84 | 85 | vertical_whitespace: 86 | max_empty_lines: 2 87 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Example/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // Flapjack 4 | // 5 | // Created by kreeger on 07/19/2018. 6 | // Copyright (c) 2018 O'Reilly Media, Inc. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import Flapjack 11 | // This import is necessary if you're importing the framework manually or via Carthage. 12 | import FlapjackCoreData 13 | 14 | #if swift(>=4.2) 15 | #else 16 | extension UIApplication { 17 | typealias LaunchOptionsKey = UIApplicationLaunchOptionsKey 18 | } 19 | #endif 20 | 21 | @UIApplicationMain 22 | class AppDelegate: UIResponder, UIApplicationDelegate { 23 | var window: UIWindow? 24 | lazy var dataAccess: DataAccess = CoreDataAccess(name: "FlapjackExample", type: .memory(storeName: "FlapjackExample")) 25 | 26 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 27 | let window = UIWindow(frame: UIScreen.main.bounds) 28 | self.window = window 29 | 30 | let dataSourceFactory = CoreDataSourceFactory(dataAccess: dataAccess) 31 | let maker = PancakeMaker(dataAccess: dataAccess) 32 | 33 | guard 34 | let manualVC = UIStoryboard(name: "Main", bundle: .main).instantiateViewController(withIdentifier: "ManualViewController") as? ManualViewController, 35 | let autoVC = UIStoryboard(name: "Main", bundle: .main).instantiateViewController(withIdentifier: "AutomaticViewController") as? AutomaticViewController 36 | else { 37 | return false 38 | } 39 | let manualNav = UINavigationController(rootViewController: manualVC) 40 | manualVC.title = "Manual Refresh" 41 | manualVC.maker = maker 42 | 43 | let autoNav = UINavigationController(rootViewController: autoVC) 44 | autoVC.title = "Auto Refresh" 45 | autoVC.maker = maker 46 | 47 | dataAccess.prepareStack(asynchronously: true) { [weak self] error in 48 | if let error = error { 49 | print(error.localizedDescription) 50 | } 51 | 52 | manualVC.dataAccess = self?.dataAccess 53 | autoVC.dataSource = dataSourceFactory.vendObjectsDataSource(attributes: [:], sectionProperty: "flavor", limit: nil) 54 | } 55 | 56 | let tabVC = UITabBarController() 57 | tabVC.viewControllers = [manualNav, autoNav] 58 | window.rootViewController = tabVC 59 | window.makeKeyAndVisible() 60 | return true 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /Example/AutomaticViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AutomaticViewController.swift 3 | // Flapjack 4 | // 5 | // Created by kreeger on 07/19/2018. 6 | // Copyright (c) 2018 O'Reilly Media, Inc. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import Flapjack 11 | // These imports are necessary if you're importing the framework manually or via Carthage. 12 | import FlapjackCoreData 13 | import FlapjackUIKit 14 | 15 | class AutomaticViewController: UIViewController { 16 | @IBOutlet weak var tableView: UITableView! 17 | 18 | var maker: PancakeMaker! 19 | var dataSource: CoreDataSource! { 20 | didSet { 21 | dataSource.onChange = { itemChanges, sectionChanges in 22 | self.tableView.performBatchUpdates(itemChanges, sectionChanges: sectionChanges) 23 | } 24 | } 25 | } 26 | 27 | override func viewWillAppear(_ animated: Bool) { 28 | super.viewWillAppear(animated) 29 | dataSource.startListening() 30 | } 31 | 32 | 33 | // MARK: Actions 34 | 35 | @IBAction private func addButtonTapped(_ sender: UIBarButtonItem) { 36 | maker.makePancake { [weak self] _, error in 37 | if let error = error { 38 | self?.displayAlert(for: error.localizedDescription) 39 | } 40 | } 41 | } 42 | 43 | 44 | // MARK: Private functions 45 | 46 | private func displayAlert(for message: String) { 47 | let alert = UIAlertController(title: "Error", message: message, preferredStyle: .alert) 48 | alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil)) 49 | present(alert, animated: true, completion: nil) 50 | } 51 | } 52 | 53 | 54 | extension AutomaticViewController: UITableViewDataSource { 55 | func numberOfSections(in tableView: UITableView) -> Int { 56 | return dataSource.numberOfSections 57 | } 58 | 59 | func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 60 | return dataSource.numberOfObjects(in: section) 61 | } 62 | 63 | func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { 64 | guard section < dataSource.sectionNames.count else { 65 | return nil 66 | } 67 | return dataSource.sectionNames[section] 68 | } 69 | 70 | func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { 71 | let cell = tableView.dequeueReusableCell(withIdentifier: "UITableViewCell", for: indexPath) 72 | if let model = dataSource.object(at: indexPath) { 73 | cell.textLabel?.text = "\(model.radius)\" radius, \(model.height)\" tall" 74 | cell.detailTextLabel?.text = model.identifier 75 | } 76 | return cell 77 | } 78 | } 79 | 80 | 81 | extension AutomaticViewController: UITableViewDelegate { 82 | 83 | } 84 | -------------------------------------------------------------------------------- /Example/Base.lproj/LaunchScreen.xib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /Example/Core Data/FlapjackExample.xcdatamodeld/FlapjackExample.xcdatamodel/contents: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /Example/Core Data/Pancake.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Pancake.swift 3 | // FlapjackExample 4 | // 5 | // Created by Ben Kreeger on 7/19/18. 6 | // Copyright © 2018 CocoaPods. All rights reserved. 7 | // 8 | // 9 | 10 | import Foundation 11 | import CoreData 12 | import Flapjack 13 | 14 | @objc(Pancake) 15 | public class Pancake: NSManagedObject { 16 | @NSManaged public private(set) var identifier: String 17 | @NSManaged public var flavor: String? 18 | @NSManaged public var radius: Double 19 | @NSManaged public var height: Double 20 | @NSManaged public private(set) var toppings: [String] 21 | @NSManaged public private(set) var createdAt: Date 22 | 23 | override public func awakeFromInsert() { 24 | super.awakeFromInsert() 25 | setPrimitiveValue(UUID().uuidString, forKey: #keyPath(identifier)) 26 | setPrimitiveValue(Date(), forKey: #keyPath(createdAt)) 27 | setPrimitiveValue([String](), forKey: #keyPath(toppings)) 28 | } 29 | 30 | func addTopping(_ topping: String) { 31 | var mToppings = toppings 32 | mToppings.append(topping) 33 | toppings = mToppings 34 | } 35 | } 36 | 37 | 38 | extension Pancake: DataObject { 39 | public typealias PrimaryKeyType = String 40 | public static var representedName: String { return "Pancake" } 41 | public static var primaryKeyPath: String { return #keyPath(identifier) } 42 | public static var defaultSorters: [Flapjack.SortDescriptor] { 43 | return [ 44 | Flapjack.SortDescriptor(#keyPath(flavor), ascending: true, caseInsensitive: true), 45 | Flapjack.SortDescriptor(#keyPath(radius), ascending: false), 46 | Flapjack.SortDescriptor(#keyPath(height), ascending: false) 47 | ] 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Example/Images.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "icon_20pt@2x.png", 5 | "idiom" : "iphone", 6 | "scale" : "2x", 7 | "size" : "20x20" 8 | }, 9 | { 10 | "filename" : "icon_20pt@3x.png", 11 | "idiom" : "iphone", 12 | "scale" : "3x", 13 | "size" : "20x20" 14 | }, 15 | { 16 | "filename" : "icon_29pt@2x.png", 17 | "idiom" : "iphone", 18 | "scale" : "2x", 19 | "size" : "29x29" 20 | }, 21 | { 22 | "filename" : "icon_29pt@3x.png", 23 | "idiom" : "iphone", 24 | "scale" : "3x", 25 | "size" : "29x29" 26 | }, 27 | { 28 | "filename" : "icon_40pt@2x.png", 29 | "idiom" : "iphone", 30 | "scale" : "2x", 31 | "size" : "40x40" 32 | }, 33 | { 34 | "filename" : "icon_40pt@3x.png", 35 | "idiom" : "iphone", 36 | "scale" : "3x", 37 | "size" : "40x40" 38 | }, 39 | { 40 | "filename" : "icon_60pt@2x.png", 41 | "idiom" : "iphone", 42 | "scale" : "2x", 43 | "size" : "60x60" 44 | }, 45 | { 46 | "filename" : "icon_60pt@3x.png", 47 | "idiom" : "iphone", 48 | "scale" : "3x", 49 | "size" : "60x60" 50 | }, 51 | { 52 | "filename" : "Icon.png", 53 | "idiom" : "ios-marketing", 54 | "scale" : "1x", 55 | "size" : "1024x1024" 56 | } 57 | ], 58 | "info" : { 59 | "author" : "xcode", 60 | "version" : 1 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /Example/Images.xcassets/AppIcon.appiconset/Icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oreillymedia/flapjack/9a4f2c86ceae93f7247151236bcfc05f8fb125f3/Example/Images.xcassets/AppIcon.appiconset/Icon.png -------------------------------------------------------------------------------- /Example/Images.xcassets/AppIcon.appiconset/icon_20pt@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oreillymedia/flapjack/9a4f2c86ceae93f7247151236bcfc05f8fb125f3/Example/Images.xcassets/AppIcon.appiconset/icon_20pt@2x.png -------------------------------------------------------------------------------- /Example/Images.xcassets/AppIcon.appiconset/icon_20pt@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oreillymedia/flapjack/9a4f2c86ceae93f7247151236bcfc05f8fb125f3/Example/Images.xcassets/AppIcon.appiconset/icon_20pt@3x.png -------------------------------------------------------------------------------- /Example/Images.xcassets/AppIcon.appiconset/icon_29pt@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oreillymedia/flapjack/9a4f2c86ceae93f7247151236bcfc05f8fb125f3/Example/Images.xcassets/AppIcon.appiconset/icon_29pt@2x.png -------------------------------------------------------------------------------- /Example/Images.xcassets/AppIcon.appiconset/icon_29pt@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oreillymedia/flapjack/9a4f2c86ceae93f7247151236bcfc05f8fb125f3/Example/Images.xcassets/AppIcon.appiconset/icon_29pt@3x.png -------------------------------------------------------------------------------- /Example/Images.xcassets/AppIcon.appiconset/icon_40pt@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oreillymedia/flapjack/9a4f2c86ceae93f7247151236bcfc05f8fb125f3/Example/Images.xcassets/AppIcon.appiconset/icon_40pt@2x.png -------------------------------------------------------------------------------- /Example/Images.xcassets/AppIcon.appiconset/icon_40pt@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oreillymedia/flapjack/9a4f2c86ceae93f7247151236bcfc05f8fb125f3/Example/Images.xcassets/AppIcon.appiconset/icon_40pt@3x.png -------------------------------------------------------------------------------- /Example/Images.xcassets/AppIcon.appiconset/icon_60pt@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oreillymedia/flapjack/9a4f2c86ceae93f7247151236bcfc05f8fb125f3/Example/Images.xcassets/AppIcon.appiconset/icon_60pt@2x.png -------------------------------------------------------------------------------- /Example/Images.xcassets/AppIcon.appiconset/icon_60pt@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oreillymedia/flapjack/9a4f2c86ceae93f7247151236bcfc05f8fb125f3/Example/Images.xcassets/AppIcon.appiconset/icon_60pt@3x.png -------------------------------------------------------------------------------- /Example/Images.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /Example/Images.xcassets/pancakes.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "universal", 9 | "filename" : "pancakes.jpg", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "version" : 1, 19 | "author" : "xcode" 20 | } 21 | } -------------------------------------------------------------------------------- /Example/Images.xcassets/pancakes.imageset/pancakes.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oreillymedia/flapjack/9a4f2c86ceae93f7247151236bcfc05f8fb125f3/Example/Images.xcassets/pancakes.imageset/pancakes.jpg -------------------------------------------------------------------------------- /Example/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | FMWK 17 | CFBundleShortVersionString 18 | $(MARKETING_VERSION) 19 | CFBundleVersion 20 | $(CURRENT_PROJECT_VERSION) 21 | UILaunchStoryboardName 22 | LaunchScreen 23 | UIMainStoryboardFile 24 | Main 25 | UISupportedInterfaceOrientations 26 | 27 | UIInterfaceOrientationPortrait 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /Example/ManualViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ManualViewController.swift 3 | // Flapjack 4 | // 5 | // Created by kreeger on 07/19/2018. 6 | // Copyright (c) 2018 O'Reilly Media, Inc. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import Flapjack 11 | 12 | class ManualViewController: UIViewController { 13 | @IBOutlet weak var tableView: UITableView! 14 | 15 | var maker: PancakeMaker! 16 | var dataAccess: DataAccess! { 17 | didSet { 18 | pancakes = dataAccess.mainContext.objects(ofType: Pancake.self) 19 | tableView.reloadData() 20 | } 21 | } 22 | 23 | private var pancakes = [Pancake]() 24 | 25 | override func viewWillAppear(_ animated: Bool) { 26 | super.viewWillAppear(animated) 27 | 28 | pancakes = dataAccess?.mainContext.objects(ofType: Pancake.self) ?? [] 29 | tableView.reloadData() 30 | } 31 | 32 | 33 | // MARK: Actions 34 | 35 | @IBAction private func addButtonTapped(_ sender: UIBarButtonItem) { 36 | maker.makePancake { [weak self] _, error in 37 | guard let `self` = self else { 38 | return 39 | } 40 | if let error = error { 41 | self.displayAlert(for: error.localizedDescription) 42 | } 43 | self.pancakes = self.dataAccess.mainContext.objects(ofType: Pancake.self) 44 | self.tableView.reloadData() 45 | } 46 | } 47 | 48 | @IBAction private func refreshButtonTapped(_ sender: UIBarButtonItem) { 49 | pancakes = dataAccess.mainContext.objects(ofType: Pancake.self) 50 | tableView.reloadData() 51 | } 52 | 53 | 54 | // MARK: Private functions 55 | 56 | private func displayAlert(for message: String) { 57 | let alert = UIAlertController(title: "Error", message: message, preferredStyle: .alert) 58 | alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil)) 59 | present(alert, animated: true, completion: nil) 60 | } 61 | } 62 | 63 | 64 | extension ManualViewController: UITableViewDataSource { 65 | func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 66 | return pancakes.count 67 | } 68 | 69 | func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { 70 | let cell = tableView.dequeueReusableCell(withIdentifier: "UITableViewCell", for: indexPath) 71 | if indexPath.item < pancakes.count { 72 | let model = pancakes[indexPath.item] 73 | let display = "\(model.flavor ?? "Flavorless"), \(model.radius)\" radius, \(model.height)\" tall" 74 | cell.textLabel?.text = display 75 | cell.detailTextLabel?.text = model.identifier 76 | } 77 | return cell 78 | } 79 | } 80 | 81 | 82 | extension ManualViewController: UITableViewDelegate { 83 | 84 | } 85 | -------------------------------------------------------------------------------- /Example/PancakeMaker.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PancakeMaker.swift 3 | // FlapjackExample 4 | // 5 | // Created by Ben Kreeger on 7/26/18. 6 | // Copyright (c) 2018 O'Reilly Media, Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Flapjack 11 | 12 | class PancakeMaker { 13 | private let dataAccess: DataAccess 14 | 15 | init(dataAccess: DataAccess) { 16 | self.dataAccess = dataAccess 17 | } 18 | 19 | 20 | // MARK: Public functions 21 | 22 | func makePancake(_ completion: @escaping (Pancake?, Error?) -> Void) { 23 | let flavors = ["Raspberry", "Blueberry", "Chocolate Chip", "Banana", "Cheesecake", "Mango", "Rhubarb"] 24 | let flavor = flavors[Int(arc4random_uniform(UInt32(flavors.count)))] 25 | let radius = Double(arc4random_uniform(10)) 26 | let height = Double(arc4random_uniform(10)) 27 | 28 | dataAccess.performInBackground { [weak self] context in 29 | let pancake = context.create(Pancake.self, attributes: ["flavor": flavor, "radius": radius, "height": height]) 30 | let error = context.persist() 31 | 32 | DispatchQueue.main.async { 33 | guard let `self` = self else { 34 | return 35 | } 36 | let foregroundPancake = self.dataAccess.mainContext.object(ofType: Pancake.self, objectID: pancake.objectID) 37 | completion(foregroundPancake, error) 38 | } 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Flapjack.podspec: -------------------------------------------------------------------------------- 1 | Pod::Spec.new do |s| 2 | s.name = 'Flapjack' 3 | s.version = '1.0.2' 4 | s.summary = 'A Swift data persistence API with support for Core Data.' 5 | s.description = <<-DESC 6 | Flapjack is an iOS/macOS/tvOS framework with 2 primary goals. 7 | 8 | 1. Help you abstract your model-focused database persistence layer from the rest 9 | of your app 10 | 2. Simplify the database layer's API into an easy-to-use, easy-to-remember, full 11 | Swift one 12 | 13 | It lets you skip the boilerplate commonly associated with database layers like 14 | Core Data and lets you introduce structured, sane data persistence in your app 15 | sooner, letting you spend more of your time creating the app you really want. We 16 | use it at O'Reilly Media and Safari Books Online for our iOS apps, and if you 17 | like what you see, perhaps you will too. 18 | DESC 19 | s.homepage = 'https://github.com/oreillymedia/flapjack' 20 | s.license = { type: 'MIT', file: 'LICENSE' } 21 | s.author = { 'Ben Kreeger' => 'bkreeger@oreilly.com' } 22 | s.source = { 23 | git: 'https://github.com/oreillymedia/flapjack.git', 24 | tag: s.version.to_s 25 | } 26 | 27 | s.ios.deployment_target = '13.0' 28 | s.tvos.deployment_target = '13.0' 29 | s.osx.deployment_target = '10.15' 30 | 31 | s.frameworks = 'Foundation' 32 | s.swift_version = '5.4' 33 | 34 | s.default_subspec = 'Core' 35 | 36 | s.subspec 'Core' do |core| 37 | core.frameworks = 'CoreData' 38 | core.source_files = 'Sources/Core/**/*.swift' 39 | end 40 | 41 | s.subspec 'CoreData' do |core_data| 42 | core_data.dependency 'Flapjack/Core' 43 | core_data.frameworks = 'CoreData' 44 | core_data.source_files = 'Sources/CoreData/**/*' 45 | end 46 | 47 | s.subspec 'UIKit' do |uikit| 48 | uikit.dependency 'Flapjack/Core' 49 | uikit.ios.frameworks = 'UIKit' 50 | uikit.ios.source_files = 'Sources/UIKit/**/*' 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /Flapjack.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Flapjack.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Flapjack.xcodeproj/xcshareddata/xcschemes/Example.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 29 | 35 | 36 | 37 | 43 | 49 | 50 | 51 | 52 | 53 | 59 | 60 | 66 | 67 | 68 | 69 | 72 | 78 | 79 | 80 | 81 | 82 | 92 | 94 | 100 | 101 | 102 | 103 | 107 | 108 | 109 | 110 | 116 | 118 | 124 | 125 | 126 | 127 | 129 | 130 | 133 | 134 | 135 | -------------------------------------------------------------------------------- /Flapjack.xcodeproj/xcshareddata/xcschemes/Flapjack-iOS.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 43 | 44 | 50 | 51 | 52 | 53 | 59 | 60 | 66 | 67 | 68 | 69 | 71 | 72 | 75 | 76 | 77 | -------------------------------------------------------------------------------- /Flapjack.xcodeproj/xcshareddata/xcschemes/Flapjack-macOS.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 43 | 44 | 50 | 51 | 52 | 53 | 59 | 60 | 66 | 67 | 68 | 69 | 71 | 72 | 75 | 76 | 77 | -------------------------------------------------------------------------------- /Flapjack.xcodeproj/xcshareddata/xcschemes/Flapjack-tvOS.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 43 | 44 | 50 | 51 | 52 | 53 | 59 | 60 | 66 | 67 | 68 | 69 | 71 | 72 | 75 | 76 | 77 | -------------------------------------------------------------------------------- /Flapjack.xcodeproj/xcshareddata/xcschemes/FlapjackCoreData-iOS.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 43 | 44 | 50 | 51 | 52 | 53 | 59 | 60 | 66 | 67 | 68 | 69 | 71 | 72 | 75 | 76 | 77 | -------------------------------------------------------------------------------- /Flapjack.xcodeproj/xcshareddata/xcschemes/FlapjackCoreData-macOS.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 43 | 44 | 50 | 51 | 52 | 53 | 59 | 60 | 66 | 67 | 68 | 69 | 71 | 72 | 75 | 76 | 77 | -------------------------------------------------------------------------------- /Flapjack.xcodeproj/xcshareddata/xcschemes/FlapjackCoreData-tvOS.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 43 | 44 | 50 | 51 | 52 | 53 | 59 | 60 | 66 | 67 | 68 | 69 | 71 | 72 | 75 | 76 | 77 | -------------------------------------------------------------------------------- /Flapjack.xcodeproj/xcshareddata/xcschemes/FlapjackUIKit-iOS.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 43 | 44 | 50 | 51 | 52 | 53 | 59 | 60 | 66 | 67 | 68 | 69 | 71 | 72 | 75 | 76 | 77 | -------------------------------------------------------------------------------- /Flapjack.xcodeproj/xcshareddata/xcschemes/FlapjackUIKit-tvOS.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 43 | 44 | 50 | 51 | 52 | 53 | 59 | 60 | 66 | 67 | 68 | 69 | 71 | 72 | 75 | 76 | 77 | -------------------------------------------------------------------------------- /Flapjack.xcodeproj/xcshareddata/xcschemes/Tests-iOS.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 37 | 38 | 39 | 40 | 43 | 49 | 50 | 51 | 52 | 53 | 63 | 64 | 70 | 71 | 77 | 78 | 79 | 80 | 82 | 83 | 86 | 87 | 88 | -------------------------------------------------------------------------------- /Flapjack.xcodeproj/xcshareddata/xcschemes/Tests-macOS.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 34 | 40 | 41 | 42 | 43 | 44 | 54 | 55 | 61 | 62 | 68 | 69 | 70 | 71 | 73 | 74 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /Flapjack.xcodeproj/xcshareddata/xcschemes/Tests-tvOS.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 33 | 39 | 40 | 41 | 42 | 43 | 53 | 54 | 60 | 61 | 67 | 68 | 69 | 70 | 72 | 73 | 76 | 77 | 78 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'cocoapods', '~> 1.11.3' 4 | gem 'jazzy' 5 | gem 'xcpretty' 6 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | CFPropertyList (3.0.6) 5 | rexml 6 | activesupport (6.1.7.6) 7 | concurrent-ruby (~> 1.0, >= 1.0.2) 8 | i18n (>= 1.6, < 2) 9 | minitest (>= 5.1) 10 | tzinfo (~> 2.0) 11 | zeitwerk (~> 2.3) 12 | addressable (2.8.1) 13 | public_suffix (>= 2.0.2, < 6.0) 14 | algoliasearch (1.27.5) 15 | httpclient (~> 2.8, >= 2.8.3) 16 | json (>= 1.5.1) 17 | atomos (0.1.3) 18 | claide (1.1.0) 19 | cocoapods (1.11.3) 20 | addressable (~> 2.8) 21 | claide (>= 1.0.2, < 2.0) 22 | cocoapods-core (= 1.11.3) 23 | cocoapods-deintegrate (>= 1.0.3, < 2.0) 24 | cocoapods-downloader (>= 1.4.0, < 2.0) 25 | cocoapods-plugins (>= 1.0.0, < 2.0) 26 | cocoapods-search (>= 1.0.0, < 2.0) 27 | cocoapods-trunk (>= 1.4.0, < 2.0) 28 | cocoapods-try (>= 1.1.0, < 2.0) 29 | colored2 (~> 3.1) 30 | escape (~> 0.0.4) 31 | fourflusher (>= 2.3.0, < 3.0) 32 | gh_inspector (~> 1.0) 33 | molinillo (~> 0.8.0) 34 | nap (~> 1.0) 35 | ruby-macho (>= 1.0, < 3.0) 36 | xcodeproj (>= 1.21.0, < 2.0) 37 | cocoapods-core (1.11.3) 38 | activesupport (>= 5.0, < 7) 39 | addressable (~> 2.8) 40 | algoliasearch (~> 1.0) 41 | concurrent-ruby (~> 1.1) 42 | fuzzy_match (~> 2.0.4) 43 | nap (~> 1.0) 44 | netrc (~> 0.11) 45 | public_suffix (~> 4.0) 46 | typhoeus (~> 1.0) 47 | cocoapods-deintegrate (1.0.5) 48 | cocoapods-downloader (1.6.3) 49 | cocoapods-plugins (1.0.0) 50 | nap 51 | cocoapods-search (1.0.1) 52 | cocoapods-trunk (1.6.0) 53 | nap (>= 0.8, < 2.0) 54 | netrc (~> 0.11) 55 | cocoapods-try (1.2.0) 56 | colored2 (3.1.2) 57 | concurrent-ruby (1.2.2) 58 | escape (0.0.4) 59 | ethon (0.16.0) 60 | ffi (>= 1.15.0) 61 | ffi (1.15.5) 62 | fourflusher (2.3.1) 63 | fuzzy_match (2.0.4) 64 | gh_inspector (1.1.3) 65 | httpclient (2.8.3) 66 | i18n (1.14.1) 67 | concurrent-ruby (~> 1.0) 68 | jazzy (0.14.3) 69 | cocoapods (~> 1.5) 70 | mustache (~> 1.1) 71 | open4 (~> 1.3) 72 | redcarpet (~> 3.4) 73 | rexml (~> 3.2) 74 | rouge (>= 2.0.6, < 4.0) 75 | sassc (~> 2.1) 76 | sqlite3 (~> 1.3) 77 | xcinvoke (~> 0.3.0) 78 | json (2.6.3) 79 | liferaft (0.0.6) 80 | mini_portile2 (2.8.1) 81 | minitest (5.20.0) 82 | molinillo (0.8.0) 83 | mustache (1.1.1) 84 | nanaimo (0.3.0) 85 | nap (1.1.0) 86 | netrc (0.11.0) 87 | open4 (1.3.4) 88 | public_suffix (4.0.7) 89 | redcarpet (3.6.0) 90 | rexml (3.2.5) 91 | rouge (2.0.7) 92 | ruby-macho (2.5.1) 93 | sassc (2.4.0) 94 | ffi (~> 1.9) 95 | sqlite3 (1.6.1) 96 | mini_portile2 (~> 2.8.0) 97 | typhoeus (1.4.0) 98 | ethon (>= 0.9.0) 99 | tzinfo (2.0.6) 100 | concurrent-ruby (~> 1.0) 101 | xcinvoke (0.3.0) 102 | liferaft (~> 0.0.6) 103 | xcodeproj (1.22.0) 104 | CFPropertyList (>= 2.3.3, < 4.0) 105 | atomos (~> 0.1.3) 106 | claide (>= 1.0.2, < 2.0) 107 | colored2 (~> 3.1) 108 | nanaimo (~> 0.3.0) 109 | rexml (~> 3.2.4) 110 | xcpretty (0.3.0) 111 | rouge (~> 2.0.7) 112 | zeitwerk (2.6.12) 113 | 114 | PLATFORMS 115 | ruby 116 | 117 | DEPENDENCIES 118 | cocoapods (~> 1.11.3) 119 | jazzy 120 | xcpretty 121 | 122 | BUNDLED WITH 123 | 2.2.33 124 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018 O'Reilly Media, Inc. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: test 2 | 3 | test: test-ios test-macos test-tvos 4 | 5 | test-ios: 6 | xcodebuild -project Flapjack.xcodeproj -scheme "Tests-iOS" -destination 'platform=iOS Simulator,name=iPhone X,OS=latest' test | xcpretty --color 7 | 8 | test-macos: 9 | xcodebuild -project Flapjack.xcodeproj -scheme "Tests-macOS" test | xcpretty --color 10 | 11 | test-tvos: 12 | xcodebuild -project Flapjack.xcodeproj -scheme "Tests-tvOS" -destination 'platform=tvOS Simulator,name=Apple TV 4K,OS=latest' test | xcpretty --color 13 | 14 | cocoapods-deploy: 15 | pod trunk push Flapjack.podspec 16 | 17 | cocoapods-preflight: 18 | pod lib lint 19 | pod spec lint Flapjack.podspec 20 | 21 | release: 22 | jazzy 23 | git add docs/ 24 | git commit -m "Version $(VERSION)" 25 | git tag -s $(VERSION) -m "Version $(VERSION)" 26 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.4 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: "Flapjack", 8 | platforms: [ 9 | .macOS(.v10_15), 10 | .iOS(.v13), 11 | .tvOS(.v13) 12 | ], 13 | products: [ 14 | .library(name: "Flapjack", targets: ["Flapjack"]), 15 | .library(name: "FlapjackCoreData", targets: ["FlapjackCoreData"]), 16 | .library(name: "FlapjackUIKit", targets: ["FlapjackUIKit"]), 17 | ], 18 | dependencies: [], 19 | targets: [ 20 | .target( 21 | name: "Flapjack", 22 | path: "Sources/Core", 23 | exclude: ["Supporting Files/Info.plist"]), 24 | .target( 25 | name: "FlapjackCoreData", 26 | dependencies: ["Flapjack"], 27 | path: "Sources/CoreData", 28 | linkerSettings: [ 29 | .linkedFramework("CoreData") 30 | ] 31 | ), 32 | .target( 33 | name: "FlapjackUIKit", 34 | dependencies: ["Flapjack"], 35 | path: "Sources/UIKit", 36 | linkerSettings: [ 37 | .linkedFramework("UIKit", .when(platforms: [.iOS, .tvOS])) 38 | ] 39 | ), 40 | .testTarget( 41 | name: "FlapjackTests", 42 | dependencies: ["Flapjack"], 43 | path: "Tests/Core" 44 | ), 45 | .testTarget( 46 | name: "FlapjackCoreDataTests", 47 | dependencies: ["Flapjack", "FlapjackCoreData"], 48 | path: "Tests/CoreData", 49 | resources: [.process("Resources")] 50 | ) 51 | ] 52 | ) 53 | -------------------------------------------------------------------------------- /Sources/Core/DataAccess.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DataAccess.swift 3 | // Flapjack 4 | // 5 | // Created by Ben Kreeger on 10/13/17. 6 | // Copyright © 2017 O'Reilly Media, Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | 12 | // MARK: - DataAccess 13 | 14 | /** 15 | One of two prominently-used objects in Flapjack, the `DataAccess` protocol (and those that conform to it) preside over 16 | the setup and management of the entire data persistence stack, along with managing the lifecycle of background context 17 | operations. 18 | */ 19 | public protocol DataAccess { 20 | /// The main thread (or view-layer) context. Should generally only be used for read-only operations. 21 | var mainContext: DataContext { get } 22 | /// This should be `true` if the stack has been setup in memory and ready to go; `false` otherwise. 23 | var isStackReady: Bool { get } 24 | /// The object that agrees to be notified about special events and requests from `DataAccess`; optional. 25 | var delegate: DataAccessDelegate? { get set } 26 | 27 | /** 28 | Invoking this method should ask the `DataAccess` object to load up its store from disk (or in memory), ask for any 29 | migrations needed, populate the necessary instance variables for accessing it, and set the `isStackReady` property 30 | to `true` if everything succeeded. 31 | 32 | - parameter asynchronously: If `true`, the stack preparation should be performed in a background thread, and the 33 | `completion` block should return on the main thread. 34 | - parameter completion: A closure to be called upon completion. If `asynchronously` is `true`, this should be 35 | guaranteed to be called on the main thread. If `false`, it should be called on the calling 36 | thread. 37 | */ 38 | func prepareStack(asynchronously: Bool, completion: @escaping (DataAccessError?) -> Void) 39 | 40 | /** 41 | Invoking this method should ask the `DataAccess` object to prepare a background-thread `DataContext` for use, and 42 | then pop into a background thread and call the `operation`. 43 | 44 | - parameter operation: The actions to execute upon the background `DataContext`; will be passed said context. 45 | */ 46 | func performInBackground(operation: @escaping (_ context: DataContext) -> Void) 47 | 48 | @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) 49 | func performBackgroundTask(_ block: @escaping (DataContext) throws -> T) async rethrows -> T 50 | 51 | /** 52 | Invoking this method should ask the `DataAccess` object to prepare a background-thread `DataContext` for use, and 53 | then return that context right away on the calling thread. It should be the caller's responsibility to use the 54 | context responsibly. 55 | 56 | - returns: A background-thread-ready `DataContext`. 57 | */ 58 | func vendBackgroundContext() -> DataContext 59 | 60 | /** 61 | Invoking this method should ask the `DataAccess` object to delete the data store in a matter it sees fit. If asked 62 | to `rebuild` the database, it should do so in a manner consistent with the one performed in 63 | `prepareStack(asynchronously:completion:)`. 64 | 65 | - parameter rebuild: If `true`, the data store should be reconstructed after it's deleted. 66 | - parameter completion: A closure to be called upon completion. 67 | */ 68 | @available(*, renamed: "deleteDatabase(rebuild:)") 69 | func deleteDatabase(rebuild: Bool, completion: @escaping (DataAccessError?) -> Void) 70 | func deleteDatabase(rebuild: Bool) async throws 71 | } 72 | 73 | 74 | // MARK: - DataAccessDelegate 75 | 76 | /** 77 | An object conforming to the `DataAccessDelegate` agrees to be notified about events occurring outside the normal 78 | call-and-return lifecycle of the methods presented by conformists to `DataAccess`. 79 | */ 80 | public protocol DataAccessDelegate: AnyObject { 81 | /** 82 | Called when the data access object is about to setup the data stack (from on disk into application memory), and if 83 | necessary (given the on-disk store at the given URL), should be returned a `Migrator` object if migrations should 84 | be performed. The `DataAccess` object should check with the provider if migrations should actually be performed, 85 | and it's the `Migrator`'s responsibility to deal with the store at the URL to see if it's up-to-date (for more 86 | information, see the documentation for `Migrator`). 87 | 88 | - parameter dataAccess: The object calling this method. 89 | - parameter storeURL: The on-disk location where the store is located; if there is no on-disk store (like if the 90 | store is in-memory only), this will be `nil`. 91 | - returns: Should return a `Migrator` object if one needs to be consulted with to begin migrations; `nil` if not. 92 | */ 93 | func dataAccess(_ dataAccess: DataAccess, wantsMigratorForStoreAt storeURL: URL?) -> Migrator? 94 | } 95 | 96 | public extension DataAccessDelegate { 97 | /// The default implementation of this delegate method returns `nil` for the `Migrator`. 98 | func dataAccess(_ dataAccess: DataAccess, wantsMigratorForStoreAt storeURL: URL?) -> Migrator? { 99 | return nil 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /Sources/Core/DataObject.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DataObject.swift 3 | // Flapjack 4 | // 5 | // Created by Ben Kreeger on 11/4/17. 6 | // Copyright © 2017 O'Reilly Media, Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /** 12 | An abstraction on top of a model object, typically mapped one-to-one to a database entity such as Core Data's 13 | `NSManagedObject` or Realm's `Realm.Object`. Those conforming to this protocol should be sure to denote the type 14 | of `PrimaryKey` to expose (through the associated type `PrimaryKeyType`). 15 | */ 16 | public protocol DataObject { 17 | /// A generic reference to the type belonging to the primary key field of this model. 18 | associatedtype PrimaryKeyType: PrimaryKey 19 | 20 | /// The string representation of this object's type in a database (like Core Data's entity names). 21 | static var representedName: String { get } 22 | 23 | /// The keypath to primary key value (a string version of the attribute name). 24 | static var primaryKeyPath: String { get } 25 | 26 | /// An array of sorting criteria to be applied by default when fetching collections of these objects. 27 | static var defaultSorters: [SortDescriptor] { get } 28 | 29 | /// The primary key value itself for the object. 30 | var primaryKey: PrimaryKeyType? { get } 31 | 32 | /// The database context to which this object belongs, if it's part of one. 33 | var context: DataContext? { get } 34 | } 35 | -------------------------------------------------------------------------------- /Sources/Core/DataObjectID.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DataObjectID.swift 3 | // Flapjack 4 | // 5 | // Created by Ben Kreeger on 11/4/17. 6 | // Copyright © 2017 O'Reilly Media, Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /** 12 | A generic way to describe a `DataObject`'s data store identifier (not to be confused with its `PrimaryKey` without 13 | tying it to a specific underlying database technology. An object's `DataObjectID` should generally uniquely identify an 14 | object across multiple data stores. 15 | */ 16 | public protocol DataObjectID { } 17 | -------------------------------------------------------------------------------- /Sources/Core/DataSource.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DataSource.swift 3 | // Flapjack 4 | // 5 | // Created by Ben Kreeger on 2/15/18. 6 | // Copyright © 2018 O'Reilly Media, Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Combine 11 | 12 | /** 13 | A protocol describing a type that listens for changes in an underlying data set (powered by a `DataContext`) that match 14 | a given set of conditions, if necessary. Conforming declarations should invoke the `onChange` closure when results 15 | happen to change. 16 | */ 17 | public protocol DataSource { 18 | /** 19 | Describes a type of closure that is used as a communication channel for letting the owner know about object and 20 | section changes in the matched data set. 21 | */ 22 | typealias OnChangeClosure = ([DataSourceChange], [DataSourceSectionChange]) -> Void 23 | 24 | /// A generic alias for the underlying type of model object matched and managed by the data source. 25 | associatedtype ModelType: DataObject & Hashable 26 | 27 | /// A number of all objects matched in the data set, if fetched. Otherwise this should be `0`. 28 | var numberOfObjects: Int { get } 29 | /// An array of all objects in the matched data set, if fetched. Otherwise this should be empty. 30 | var allObjects: [ModelType] { get } 31 | /// Combine publisher of the current array of objects 32 | var objects: AnyPublisher<[ModelType], Never> { get } 33 | /// The number of sections detected in the matched data set, if grouped by section. Otherwise, this should be `1`. 34 | var numberOfSections: Int { get } 35 | /// Any full section titles for the sections found in the data set, if relevant. 36 | var sectionNames: [String] { get } 37 | /// Any abbreviated section titles for the sections found in the data set, if relevant. 38 | var sectionIndexTitles: [String] { get } 39 | /// A retained closure that is invoked when section and/or object changes are detected in the matched data set. 40 | var onChange: OnChangeClosure? { get set } 41 | 42 | /// Tells the data source to perform its operation and retain the matching results. 43 | func startListening() 44 | 45 | /** 46 | Provides a count of the number of objects in the section at a given section index. If the given section index 47 | exceeds the known bounds of possible sections, `0` should be returned. 48 | 49 | - parameter section: The index of the section to use in the lookup. 50 | - returns: The number of objects in that given section. 51 | */ 52 | func numberOfObjects(in section: Int) -> Int 53 | 54 | /** 55 | Provides the object matching the given index path, if found. If not found, this should return `nil`. 56 | 57 | - parameter indexPath: The index path to use in the lookup. 58 | - returns: The object, if one is found at the given index path. 59 | */ 60 | func object(at indexPath: IndexPath) -> ModelType? 61 | 62 | /** 63 | Provides the index path where the given object resides in the matched data set, if found. If not found, this 64 | should return `nil`. 65 | 66 | - parameter object: The object to use in the lookup. 67 | - returns: The index path, if found for the given object. 68 | */ 69 | func indexPath(for object: ModelType?) -> IndexPath? 70 | 71 | /** 72 | Provides the first object matching a given closure-based query, if found. If not found, this should return `nil`. 73 | This function should short circuit and return immediately as soon as a result is found. 74 | 75 | - parameter matching: The closure to use as a query; will be passed each model in the matched data set. 76 | - returns: The first object matching the query, if one as found. 77 | */ 78 | func firstObject(matching: (ModelType) -> Bool) -> ModelType? 79 | 80 | /** 81 | Provides the objects matching a given closure-based query, if found. If not found, this should return an empty 82 | array. 83 | 84 | - parameter matching: The closure to use as a query; will be passed each model in the matched data set. 85 | - returns: All objects matching the query, if any as found. 86 | */ 87 | func allObjects(matching: (ModelType) -> Bool) -> [ModelType] 88 | } 89 | 90 | /** 91 | This stub implementation of `DataSource` makes it relatively easy to build your own by only having to implement a few 92 | short properties/functions, especially if you're managing a non-grouped set of objects. Simply implement `allObjects` 93 | and `startListening()`, make sure you call `onChange` when the data set changes, and everything else should hinge on 94 | that. 95 | */ 96 | public extension DataSource { 97 | /// If not implemented, this returns the `count` of `allObjects`. 98 | var numberOfObjects: Int { 99 | return allObjects.count 100 | } 101 | 102 | /// If not implemented, this assumes there is only one section. 103 | var numberOfSections: Int { 104 | return 1 105 | } 106 | 107 | /// If not implemented, this assumes no section names are needed. 108 | var sectionNames: [String] { 109 | return [] 110 | } 111 | 112 | /// If not implemented, this assumes no section index titles are needed. 113 | var sectionIndexTitles: [String] { 114 | return [] 115 | } 116 | 117 | /// If not implemented, this returns the same value as `numberOfObjects`. 118 | func numberOfObjects(in section: Int) -> Int { 119 | // Default implementation assumes only one section. 120 | return numberOfObjects 121 | } 122 | 123 | /// If not implemented, this uses the `item` index to lookup the object in `allObjects`. 124 | func object(at indexPath: IndexPath) -> ModelType? { 125 | guard let index = indexPath[safe: 1] else { return nil } 126 | // Default implementation assumes only one section. 127 | return allObjects[safe: index] 128 | } 129 | 130 | /// If not implemented, only returns the index of the object in `allObjects`, and 0 for the section index. 131 | func indexPath(for object: ModelType?) -> IndexPath? { 132 | // Default implementation assumes only one section. 133 | guard let object = object, let foundIndex = allObjects.firstIndex(of: object) else { 134 | return nil 135 | } 136 | return IndexPath(indexes: [0, foundIndex]) 137 | } 138 | 139 | /// If not implemented, returns the first matching object from the `allObjects` array. 140 | func firstObject(matching: (ModelType) -> Bool) -> ModelType? { 141 | return allObjects.first(where: matching) 142 | } 143 | 144 | /// If not implemented, returns all matching objects from the `allObjects` array. 145 | func allObjects(matching: (ModelType) -> Bool) -> [ModelType] { 146 | return allObjects.filter(matching) 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /Sources/Core/Extensions/Array+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Array+Extensions.swift 3 | // Flapjack 4 | // 5 | // Created by Ben Kreeger on 2/7/18. 6 | // Copyright © 2018 O'Reilly Media, Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public extension Array where Element: AnyObject & Equatable { 12 | /** 13 | Returns a copy of this array sorted using an array of `SortDescriptor` objects. 14 | 15 | - parameter descriptors: The `SortDescriptor` objects to use; these will be transformed into `NSSortDescriptor` 16 | versions and run against the array as an `NSArray`. 17 | - returns: A new sorted array. 18 | */ 19 | func sorted(using descriptors: [SortDescriptor]) -> [Element] { 20 | return (self as NSArray).sortedArray(using: descriptors.asNSSortDescriptors) as? [Element] ?? [] 21 | } 22 | } 23 | 24 | 25 | internal extension Array where Element: AnyObject & Equatable { 26 | func firstIndex(of object: Element, pointerComparison: Bool) -> Index? { 27 | guard pointerComparison else { 28 | return firstIndex(of: object) 29 | } 30 | return enumerated().first { $0.element === object }?.offset 31 | } 32 | } 33 | 34 | internal extension RangeReplaceableCollection { 35 | mutating func remove(atIndexes indexes: S) where S.Iterator.Element == Self.Index { 36 | indexes.sorted().lazy.reversed().forEach { remove(at: $0) } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/Core/Extensions/Collection+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Collection+Extensions.swift 3 | // Flapjack 4 | // 5 | // Created by Ben Kreeger on 1/25/18. 6 | // Copyright © 2018 O'Reilly Media, Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | internal extension Collection { 12 | /** 13 | Returns an optional element. If the `index` does not exist in the collection, the subscript returns nil. 14 | 15 | - parameter safe: The index of the element to return, if it exists. 16 | - returns: An optional element from the collection at the specified index. 17 | */ 18 | subscript(safe index: Index) -> Self.Iterator.Element? { 19 | return indices.contains(index) ? self[index] : nil 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Sources/Core/Extensions/NSCompoundPredicate+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSCompoundPredicate+Extensions.swift 3 | // Flapjack 4 | // 5 | // Created by Ben Kreeger on 3/3/17. 6 | // Copyright © 2017 O'Reilly Media, Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public extension NSPredicate { 12 | class func fromConditions(_ dictionary: [String: Any?]) -> [NSPredicate] { 13 | return dictionary.compactMap { NSPredicate(key: $0, value: $1) } 14 | } 15 | 16 | convenience init(key: String, value: Any?) { 17 | let keyPath = key.hasPrefix("self") ? key : "%K" 18 | var args: [Any] = key.hasPrefix("self") ? [] : [key] 19 | 20 | guard let value = value else { 21 | self.init(format: "\(keyPath) == nil", argumentArray: args) 22 | return 23 | } 24 | 25 | args.append(value) 26 | 27 | switch value { 28 | case is [Any], is [AnyHashable], is Set: 29 | self.init(format: "(\(keyPath) IN %@)", argumentArray: args) 30 | case let range as Range: 31 | args = key.hasPrefix("self") ? [range.lowerBound, range.upperBound] : [key, range.lowerBound, key, range.upperBound] 32 | self.init(format: "\(keyPath) >= %@ AND \(keyPath) < %@", argumentArray: args) 33 | case let range as Range: 34 | args = key.hasPrefix("self") ? [range.lowerBound, range.upperBound] : [key, range.lowerBound, key, range.upperBound] 35 | self.init(format: "\(keyPath) >= %@ AND \(keyPath) < %@", argumentArray: args) 36 | case let range as Range: 37 | args = key.hasPrefix("self") ? [range.lowerBound, range.upperBound] : [key, range.lowerBound, key, range.upperBound] 38 | self.init(format: "\(keyPath) >= %@ AND \(keyPath) < %@", argumentArray: args) 39 | case let range as Range: 40 | args = key.hasPrefix("self") ? [range.lowerBound, range.upperBound] : [key, range.lowerBound, key, range.upperBound] 41 | self.init(format: "\(keyPath) >= %@ AND \(keyPath) < %@", argumentArray: args) 42 | case let range as ClosedRange: 43 | args = key.hasPrefix("self") ? [range.lowerBound, range.upperBound] : [key, range.lowerBound, key, range.upperBound] 44 | self.init(format: "\(keyPath) >= %@ AND \(keyPath) <= %@", argumentArray: args) 45 | case let range as ClosedRange: 46 | args = key.hasPrefix("self") ? [range.lowerBound, range.upperBound] : [key, range.lowerBound, key, range.upperBound] 47 | self.init(format: "\(keyPath) >= %@ AND \(keyPath) <= %@", argumentArray: args) 48 | case let range as ClosedRange: 49 | args = key.hasPrefix("self") ? [range.lowerBound, range.upperBound] : [key, range.lowerBound, key, range.upperBound] 50 | self.init(format: "\(keyPath) >= %@ AND \(keyPath) <= %@", argumentArray: args) 51 | case let range as ClosedRange: 52 | args = key.hasPrefix("self") ? [range.lowerBound, range.upperBound] : [key, range.lowerBound, key, range.upperBound] 53 | self.init(format: "\(keyPath) >= %@ AND \(keyPath) <= %@", argumentArray: args) 54 | case is CVarArg: 55 | self.init(format: "\(keyPath) == %@", argumentArray: args) 56 | default: 57 | print("Couldn't make a predicate out of value \(value)") 58 | self.init() 59 | } 60 | } 61 | } 62 | 63 | public extension NSCompoundPredicate { 64 | convenience init(andPredicateFrom dictionary: [String: Any?]) { 65 | self.init(andPredicateWithSubpredicates: NSPredicate.fromConditions(dictionary)) 66 | } 67 | 68 | convenience init(orPredicateFrom dictionary: [String: Any?]) { 69 | self.init(orPredicateWithSubpredicates: NSPredicate.fromConditions(dictionary)) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Sources/Core/Extensions/Set+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Set+Extensions.swift 3 | // Flapjack 4 | // 5 | // Created by Ben Kreeger on 2/22/18. 6 | // Copyright © 2018 O'Reilly Media, Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public extension Set where Element: AnyObject { 12 | /** 13 | Returns a copy of this Set asn an Array sorted using `SortDescriptor` objects. 14 | 15 | - parameter descriptors: The `SortDescriptor` objects to use; these will be transformed into `NSSortDescriptor` 16 | versions and run against the set as an `NSSet`. 17 | - returns: A new sorted array. 18 | */ 19 | func sortedArray(using descriptors: [SortDescriptor]) -> [Element] { 20 | return (self as NSSet).sortedArray(using: descriptors.asNSSortDescriptors) as? [Element] ?? [] 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/Core/Migrator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Migrator.swift 3 | // Flapjack 4 | // 5 | // Created by Ben Kreeger on 11/30/18. 6 | // Copyright © 2018 O'Reilly Media, Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /** 12 | Describes an object responsible for determining if content should be migrated, and for doing so. 13 | */ 14 | public protocol Migrator { 15 | /// If `true`, migrations do not need to be performed, and calling migrate() is a no-op. 16 | var storeIsUpToDate: Bool { get } 17 | 18 | /** 19 | Performs the migration by setting up temp directories and iterating over all model versions. If an error occurs, 20 | it will be thrown as a MigratorError. 21 | 22 | - returns: `true` if a migration was performed, `false` if one was not needed. 23 | */ 24 | @discardableResult 25 | func migrate() throws -> Bool 26 | } 27 | 28 | /** 29 | An error potentially thrown by an object conforming to the `Migrator` protocol. 30 | */ 31 | public enum MigratorError: Error { 32 | /// Indicates a failure creating a destination folder, or creating the database on the file system. 33 | case diskPreparationError 34 | /// Indicates a failure setting up the stack, by whatever database technology is doing so. 35 | case proceduralError(Error) 36 | /// Indicates a failure occurring during database removal or other cleanup procedure. 37 | case cleanupError(Error) 38 | } 39 | -------------------------------------------------------------------------------- /Sources/Core/SingleDataSource.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SingleDataSource.swift 3 | // Flapjack 4 | // 5 | // Created by Ben Kreeger on 2/15/18. 6 | // Copyright © 2018 O'Reilly Media, Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /** 12 | A protocol describing a type that listens for changes in an underlying data set (powered by a `DataContext`) on a 13 | single object described by a set of `attributes`. Conforming declarations should invoke the `onChange` closure when the 14 | monitored object happens to change. 15 | */ 16 | public protocol SingleDataSource { 17 | /// A generic alias for the underlying type of model object matched and monitored by the data source. 18 | associatedtype ModelType: DataObject 19 | 20 | /// The criteria being used for finding the object being observed by this data source. 21 | var predicate: NSPredicate { get } 22 | /// The object being observed by this data source, if found. 23 | var object: ModelType? { get } 24 | /// If `true`, at least one fetch has been attempted. 25 | var hasFetched: Bool { get } 26 | /// A closure to be called whenever a change is detected to the object being observed. 27 | var onChange: ((ModelType?) -> Void)? { get set } 28 | 29 | /// Tells the data source to perform its operation and retain the matching result. 30 | func startListening() 31 | } 32 | -------------------------------------------------------------------------------- /Sources/Core/Support/Logger.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FJLogger.swift 3 | // Flapjack 4 | // 5 | // Created by Ben Kreeger on 5/17/18. 6 | // Copyright © 2018 O'Reilly Media, Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import os.log 11 | 12 | /// An enumeration describing the logging severity level used inside of Flapjack. 13 | @available(iOS 10.0, *) 14 | public enum LoggerLevel: Int, CustomStringConvertible { 15 | case debug = 0 16 | case info 17 | case error 18 | 19 | var osLogType: OSLogType { 20 | switch self { 21 | case .debug: return OSLogType.debug 22 | case .info: return OSLogType.info 23 | case .error: return OSLogType.error 24 | } 25 | } 26 | 27 | public var description: String { 28 | switch self { 29 | case .debug: return "Debug" 30 | case .info: return "Info" 31 | case .error: return "Error" 32 | } 33 | } 34 | } 35 | 36 | 37 | /// A logger object used for printing out relevant statements to `os_log`. 38 | @available(iOS 10.0, *) 39 | public final class FJLogger: NSObject { 40 | /// The minimum severity level to log; default is `.info`. 41 | public static var logLevel: LoggerLevel = .info 42 | /// The `OSLog` to which statements are delivered. 43 | public static var osLog = OSLog(subsystem: "com.oreillymedia.flapjack", category: "Flapjack") 44 | 45 | private static func logLn(_ level: LoggerLevel, with message: String, isPrivate: Bool) { 46 | guard level.rawValue >= logLevel.rawValue else { 47 | return 48 | } 49 | 50 | let staticString: StaticString = isPrivate ? "[%@] > %{private}@" : "[%@] > %{public}@" 51 | os_log(staticString, log: osLog, type: level.osLogType, level.description, message) 52 | } 53 | 54 | private static func logLn(_ level: LoggerLevel, with object: CustomStringConvertible, isPrivate: Bool) { 55 | logLn(level, with: object.description, isPrivate: isPrivate) 56 | } 57 | 58 | /** 59 | Convenience function for logging a debugging statement with a logging severity level of `.debug`. 60 | 61 | - parameter object: The string or string-convertible object to log. 62 | - parameter isPrivate: If `true`, the line will be redacted from the log when not attached to Xcode. Defaults to `true`. 63 | */ 64 | public static func debug(_ object: CustomStringConvertible, isPrivate: Bool = true) { 65 | logLn(.debug, with: object, isPrivate: isPrivate) 66 | } 67 | 68 | /** 69 | Convenience function for logging a debugging statement with a logging severity level of `.info`. 70 | 71 | - parameter object: The string or string-convertible object to log. 72 | */ 73 | public static func info(_ object: CustomStringConvertible) { 74 | logLn(.info, with: object, isPrivate: false) 75 | } 76 | 77 | /** 78 | Convenience function for logging a debugging statement with a logging severity level of `.error`. 79 | 80 | - parameter object: The string or string-convertible object to log. 81 | */ 82 | public static func error(_ object: CustomStringConvertible) { 83 | logLn(.error, with: object, isPrivate: false) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /Sources/Core/Supporting Files/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | FMWK 17 | CFBundleShortVersionString 18 | $(MARKETING_VERSION) 19 | CFBundleVersion 20 | $(CURRENT_PROJECT_VERSION) 21 | NSPrincipalClass 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /Sources/Core/Types/DataAccessError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DataAccessError.swift 3 | // Flapjack 4 | // 5 | // Created by Ben Kreeger on 11/4/17. 6 | // Copyright © 2017 O'Reilly Media, Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /** 12 | Encapsulates error state when thrown by the `DataAccess` object. Generally this is related to stack preparation. 13 | */ 14 | public enum DataAccessError: LocalizedError, CustomStringConvertible { 15 | /// Indicates an error was thrown while preparing the stack; includes the underlying error. 16 | case preparationError(Error) 17 | 18 | public var description: String { 19 | switch self { 20 | case .preparationError: 21 | return "DataAccessError.preparationError: \(localizedDescription)" 22 | } 23 | } 24 | 25 | public var localizedDescription: String { 26 | switch self { 27 | case .preparationError(let error): 28 | if let error = error as? LocalizedError { 29 | return error.localizedDescription 30 | } 31 | return (error as NSError?)?.localizedDescription ?? "unknown error" 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Sources/Core/Types/DataContextError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DataContextError.swift 3 | // Flapjack 4 | // 5 | // Created by Ben Kreeger on 11/4/17. 6 | // Copyright © 2017 O'Reilly Media, Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /** 12 | Encapsulates error state when thrown by a `DataContext`. 13 | */ 14 | public enum DataContextError: LocalizedError, CustomStringConvertible { 15 | /// Indicates an error during a save/persist action. Includes the underlying framework error thrown. 16 | case saveError(Error) 17 | /// Indicates an error when trying to cast fetched results. Includes the intended type, and the received result. 18 | case fetchTypeError(String, Any?) 19 | 20 | public var description: String { 21 | switch self { 22 | case .saveError(let error): 23 | return (error as NSError).description 24 | case .fetchTypeError(let typeName, let received): 25 | return "DataContextError.fetchTypeError: expected fetch of type \(typeName); got \(String(describing: received))" 26 | } 27 | } 28 | 29 | public var localizedDescription: String { 30 | switch self { 31 | case .saveError(let error): 32 | return (error as NSError).localizedDescription 33 | case .fetchTypeError(let typeName, let received): 34 | return "DataContextError.fetchTypeError: expected fetch of type \(typeName); got \(String(describing: received))" 35 | } 36 | } 37 | 38 | public var errorDescription: String? { 39 | return description 40 | } 41 | 42 | public var recoverySuggestion: String? { 43 | switch self { 44 | case .saveError(let error): 45 | return (error as NSError).localizedRecoverySuggestion 46 | case .fetchTypeError: 47 | return "Check your requested type information and try again; make sure your type conforms to the necessary protocol" 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Sources/Core/Types/DataContextScheduledTaskType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DataContextScheduledTaskType.swift 3 | // Flapjack 4 | // 5 | // Created by Laura Dickey on 8/23/23. 6 | // Copyright © 2023 O'Reilly Media, Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public enum DataContextScheduledTaskType: Hashable, Equatable { 12 | case enqueued 13 | case immediate 14 | } 15 | -------------------------------------------------------------------------------- /Sources/Core/Types/DataSourceChange.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DataSourceChange.swift 3 | // Flapjack 4 | // 5 | // Created by Ben Kreeger on 5/18/18. 6 | // Copyright © 2018 O'Reilly Media, Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /** 12 | Describes a change in position for an element observed by a data source. This can be an insertion, a deletion, a move, 13 | or an update-in-place. Each case comes with the relevant index path information. 14 | */ 15 | public enum DataSourceChange: CustomStringConvertible, Hashable, Equatable { 16 | /// Describes an insertion into the data source's object set at a given index path. 17 | case insert(path: IndexPath) 18 | /// Describes a deletion from the data source's object set at a given index path. 19 | case delete(path: IndexPath) 20 | /// Describes a positional move from the data source's object set from an index path to another index path. 21 | case move(from: IndexPath, toPath: IndexPath) 22 | /// Describes an in-place update in the data source's object set (the object should then be refreshed). 23 | case update(path: IndexPath) 24 | 25 | public var description: String { 26 | switch self { 27 | case .insert(let path): 28 | return "insert (path: \(path))" 29 | case .delete(let path): 30 | return "delete (path: \(path))" 31 | case .move(let from, let toPath): 32 | return "move (from: \(from), toPath: \(toPath))" 33 | case .update(let path): 34 | return "update (path: \(path))" 35 | } 36 | } 37 | 38 | public func hash(into hasher: inout Hasher) { 39 | hasher.combine(description) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Sources/Core/Types/DataSourceSectionChange.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DataSourceSectionChange.swift 3 | // Flapjack 4 | // 5 | // Created by Ben Kreeger on 5/18/18. 6 | // Copyright © 2018 O'Reilly Media, Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /** 12 | Describes a change in position for a section grouping of content observed by a data source. This can be an insertion or 13 | a deletion. Each case comes with the relevant section index information. 14 | */ 15 | public enum DataSourceSectionChange: CustomStringConvertible, Hashable, Equatable { 16 | /// Describes an insertion of a new section into the data source's grouped object set. 17 | case insert(section: Int) 18 | /// Describes a deletion of an existing section from the data source's grouped object set. 19 | case delete(section: Int) 20 | 21 | public var description: String { 22 | switch self { 23 | case .insert(let section): return "insert (section: \(section))" 24 | case .delete(let section): return "delete (section: \(section))" 25 | } 26 | } 27 | 28 | public func hash(into hasher: inout Hasher) { 29 | hasher.combine(description) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/Core/Types/PrimaryKey.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PrimaryKey.swift 3 | // Flapjack 4 | // 5 | // Created by Ben Kreeger on 7/26/18. 6 | // 7 | 8 | import Foundation 9 | 10 | /** 11 | A generic way to describe a `DataObject`'s primary key without tying it to a specific underlying type. An object's 12 | `PrimaryKey` field should uniquely identify an object in an entity table/group. 13 | */ 14 | public protocol PrimaryKey { } 15 | 16 | extension String: PrimaryKey { } 17 | extension Int16: PrimaryKey { } 18 | extension Int32: PrimaryKey { } 19 | extension Int64: PrimaryKey { } 20 | extension UUID: PrimaryKey { } 21 | extension URL: PrimaryKey { } 22 | extension Data: PrimaryKey { } 23 | -------------------------------------------------------------------------------- /Sources/Core/Types/SortDescriptor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SortDescriptor.swift 3 | // Flapjack 4 | // 5 | // Created by Ben Kreeger on 11/4/17. 6 | // Copyright © 2017 O'Reilly Media, Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /** 12 | A simple struct for encapsulating rules for sorting a list. Conveniently maps to `NSSortDescriptor` when necessary. 13 | */ 14 | public struct SortDescriptor { 15 | /// The stringified version of the object's keypath by which to sort. Can also be supplied as `#keyPath(...)`. 16 | public var keyPath: String 17 | /// Whether or not to sort ascending; default is `true`. 18 | public var ascending: Bool 19 | /// Whether or not to disregard case sensitivity on sorting strings; default is `false` (case matters). 20 | public var caseInsensitive: Bool 21 | 22 | /** 23 | Initializes a new `SortDescriptor`, ready to use. 24 | 25 | - parameter keyPath: The stringified version of the object's keypath by which to sort. Can also be supplied as 26 | `#keyPath(...)`. 27 | - parameter ascending: Whether or not to sort ascending; default is `true`. 28 | - parameter caseInsensitive: Whether or not to disregard case sensitivity on sorting strings; default is `false` 29 | (case matters). 30 | */ 31 | public init(_ keyPath: String, ascending: Bool = true, caseInsensitive: Bool = false) { 32 | self.keyPath = keyPath 33 | self.ascending = ascending 34 | self.caseInsensitive = caseInsensitive 35 | } 36 | 37 | /// Converts this into an `NSSortDescriptor`, using `NSString.caseInsensitiveCompare` if necessary. 38 | public var asNSSortDescriptor: NSSortDescriptor { 39 | if caseInsensitive { 40 | return NSSortDescriptor(key: keyPath, ascending: ascending, selector: #selector(NSString.caseInsensitiveCompare(_:))) 41 | } 42 | return NSSortDescriptor(key: keyPath, ascending: ascending) 43 | } 44 | 45 | /// A unique string version of the contents of this sort descriptor, handy for injecting into a longer cache key. 46 | public var cacheKey: String { 47 | return "\(keyPath).\(ascending ? 1 : 0).\(caseInsensitive ? 1 : 0)" 48 | } 49 | } 50 | 51 | 52 | public extension Sequence where Element == SortDescriptor { 53 | /// Converts a sequence of `SortDescriptor` objects into its `NSSortDescriptor` array equivalent. 54 | var asNSSortDescriptors: [NSSortDescriptor] { 55 | return map { $0.asNSSortDescriptor } 56 | } 57 | 58 | /// Converts a sequence of `SortDescriptor` objects into a unique string for injecting into a longer cache key. 59 | var cacheKey: String { 60 | return map { $0.cacheKey }.joined(separator: "-") 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /Sources/CoreData/CoreDataSourceFactory.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CoreDataSourceFactory.swift 3 | // Flapjack+CoreData 4 | // 5 | // Created by Ben Kreeger on 2/15/18. 6 | // Copyright © 2018 O'Reilly Media, Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | #if !COCOAPODS 11 | import Flapjack 12 | #endif 13 | 14 | 15 | // MARK: - CoreDataSourceFactory 16 | 17 | public class CoreDataSourceFactory { 18 | private let dataAccess: DataAccess 19 | 20 | 21 | // MARK: Lifecycle 22 | 23 | public init(dataAccess: DataAccess) { 24 | self.dataAccess = dataAccess 25 | } 26 | 27 | 28 | // MARK: Public functions 29 | 30 | /** 31 | Creates and returns a data source that will get notified when any objects of a given type are changed, inserted, or 32 | deleted. 33 | 34 | - returns: A data source that will get notified when any objects of a given type are changed, inserted, or deleted. 35 | */ 36 | public func vendAllObjectsDataSource() -> CoreDataSource { 37 | return CoreDataSource(dataAccess: dataAccess) 38 | } 39 | 40 | /** 41 | Creates and returns a data source that will get notified when any objects of a given type are changed, inserted, or 42 | deleted so long as it matches the provided attributes. Can be grouped into sections by a `sectionProperty`, and 43 | limited by a `limit` parameter. 44 | 45 | - parameter attributes: An optional dictionary of key-value query constraints to apply to the lookup. 46 | - parameter sectionProperty: A keypath to a property to use when grouping together results, if desired. 47 | - parameter limit: An optional limit to be applied to the results. 48 | - returns: A data source that will get notified when any objects matching the attributes are changed, inserted, or 49 | deleted. 50 | */ 51 | public func vendObjectsDataSource(attributes: DataContext.Attributes, sectionProperty: String?, limit: Int?) -> CoreDataSource { 52 | return CoreDataSource(dataAccess: dataAccess, attributes: attributes, sectionProperty: sectionProperty, limit: limit) 53 | } 54 | 55 | /** 56 | Creates and returns a data source that will get notified when any specific object of a given type is changed, 57 | inserted, or deleted. 58 | 59 | - parameter uniqueID: The unique identifier of the object to find and then listen for. 60 | - parameter context: The context on which to listen for object changes. 61 | - returns: A data source that will get notified when the matching object is changed, inserted, or deleted. 62 | */ 63 | public func vendObjectDataSource(uniqueID: T.PrimaryKeyType, context: DataContext) -> SingleCoreDataSource { 64 | return SingleCoreDataSource(context: context, uniqueID: uniqueID, prefetch: []) 65 | } 66 | 67 | /** 68 | Creates and returns a data source that will get notified when any specific object of a given type is changed, 69 | inserted, or deleted. 70 | 71 | - parameter attributes: An optional dictionary of key-value query constraints to apply to the lookup. 72 | - parameter context: The context on which to listen for object changes. 73 | - returns: A data source that will get notified when the matching object is changed, inserted, or deleted. 74 | */ 75 | public func vendObjectDataSource(attributes: DataContext.Attributes, context: DataContext) -> SingleCoreDataSource { 76 | return SingleCoreDataSource(context: context, attributes: attributes, prefetch: []) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /Sources/CoreData/Extensions/Dictionary+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Dictionary+Extensions.swift 3 | // Flapjack 4 | // 5 | // Created by Ben Kreeger on 9/12/18. 6 | // Copyright © 2018 O'Reilly Media, Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | internal extension Dictionary where Key == String, Value == Any? { 12 | var cacheKey: String { 13 | return self.keys.sorted().compactMap { key in 14 | guard let value = self[key] else { 15 | return nil 16 | } 17 | switch value { 18 | case .some(let inner): 19 | return "\(key).\(String(describing: inner))" 20 | case .none: 21 | return "\(key).nil" 22 | } 23 | }.joined(separator: "-") 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/CoreData/Extensions/NSFetchedResultsChangeType+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSFetchedResultsChangeType+Extensions.swift 3 | // Flapjack+CoreData 4 | // 5 | // Created by Ben Kreeger on 5/17/18. 6 | // Copyright © 2018 O'Reilly Media, Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import CoreData 11 | #if !COCOAPODS 12 | import Flapjack 13 | #endif 14 | 15 | extension NSFetchedResultsChangeType: @retroactive CustomStringConvertible { 16 | public var description: String { 17 | switch self { 18 | case .delete: return "delete" 19 | case .insert: return "insert" 20 | case .update: return "update" 21 | case .move: return "move" 22 | @unknown default: return "@unknown" 23 | } 24 | } 25 | 26 | internal func asDataSourceSectionChange(section: Int) -> DataSourceSectionChange? { 27 | switch self { 28 | case .insert: return .insert(section: section) 29 | case .delete: return .delete(section: section) 30 | case .update, .move: return nil 31 | @unknown default: return nil 32 | } 33 | } 34 | 35 | internal func asDataSourceChange(at path: IndexPath?, newPath: IndexPath?) -> DataSourceChange? { 36 | switch self { 37 | case .insert: 38 | guard let newPath = newPath else { 39 | return nil 40 | } 41 | return .insert(path: newPath) 42 | case .delete: 43 | guard let path = path else { 44 | return nil 45 | } 46 | return .delete(path: path) 47 | case .move: 48 | guard let path = path, let newPath = newPath else { 49 | return nil 50 | } 51 | if path == newPath { 52 | return .update(path: path) 53 | } 54 | return .move(from: path, toPath: newPath) 55 | case .update: 56 | guard let path = path else { 57 | return nil 58 | } 59 | return .update(path: path) 60 | @unknown default: 61 | return nil 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /Sources/CoreData/Extensions/NSManagedObject+DataObject.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSManagedObject+DataObject.swift 3 | // Flapjack 4 | // 5 | // Created by Ben Kreeger on 8/16/18. 6 | // 7 | 8 | import Foundation 9 | import CoreData 10 | #if !COCOAPODS 11 | import Flapjack 12 | #endif 13 | 14 | public extension DataObject where Self: NSManagedObject { 15 | /// The primary key value itself for this object. 16 | var primaryKey: PrimaryKeyType? { 17 | return self.value(forKey: type(of: self).primaryKeyPath) as? PrimaryKeyType 18 | } 19 | 20 | /// The database context to which this object belongs, if it's part of one. 21 | var context: DataContext? { 22 | return managedObjectContext 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sources/CoreData/Extensions/NSManagedObjectContext+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSManagedObjectContext+Extensions.swift 3 | // Flapjack+CoreData 4 | // 5 | // Created by Ben Kreeger on 11/4/17. 6 | // Copyright © 2017 O'Reilly Media, Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import CoreData 11 | #if !COCOAPODS 12 | import Flapjack 13 | #endif 14 | 15 | public extension NSManagedObjectContext { 16 | /** 17 | A tuple that represents a series of refreshed, inserted, updated, and deleted `DataObjects`. 18 | */ 19 | typealias NotificationObjectSet = (refreshes: Set, inserts: Set, updates: Set, deletes: Set) 20 | /** 21 | A tuple that represents a series of refreshed, inserted, updated, and deleted objects based on their 22 | `NSManagedObjectID`s. 23 | */ 24 | typealias NotificationObjectIDSet = (refreshes: Set, inserts: Set, updates: Set, deletes: Set) 25 | 26 | /** 27 | Obtains the refreshed, updated, inserted, and deleted object identifiers from a Core Data object-did-change or 28 | context-did-save notification. 29 | 30 | - parameter notification: The notification object from an `NSManagedObjectContextObjectsDidChange` or 31 | `NSManagedObjectContextDidSave` notification. 32 | - returns: A tuple containing the object IDs from the notification. 33 | */ 34 | class func objectIDsFrom(notification: Notification) -> NotificationObjectIDSet { 35 | let refreshed = notification.userInfo?[NSRefreshedObjectsKey] as? Set 36 | let updated = notification.userInfo?[NSUpdatedObjectsKey] as? Set 37 | let deleted = notification.userInfo?[NSDeletedObjectsKey] as? Set 38 | let inserted = notification.userInfo?[NSInsertedObjectsKey] as? Set 39 | return ( 40 | Set(refreshed?.map { $0.objectID } ?? []), 41 | Set(inserted?.map { $0.objectID } ?? []), 42 | Set(updated?.map { $0.objectID } ?? []), 43 | Set(deleted?.map { $0.objectID } ?? []) 44 | ) 45 | } 46 | 47 | /** 48 | Obtains the refreshed, updated, inserted, and deleted object identifiers from a Core Data object-did-change or 49 | context-did-save notification matching a given type. 50 | 51 | - parameter notification: The notification object from an `NSManagedObjectContextObjectsDidChange` or 52 | `NSManagedObjectContextDidSave` notification. 53 | - parameter type: The type of the objects to retrieve; only those matching this type will be returned. 54 | - returns: A tuple containing the object IDs from the notification. 55 | */ 56 | class func objectsFrom(notification: Notification, ofType type: T.Type) -> NotificationObjectSet { 57 | let refreshed = notification.userInfo?[NSRefreshedObjectsKey] as? Set 58 | let updated = notification.userInfo?[NSUpdatedObjectsKey] as? Set 59 | let deleted = notification.userInfo?[NSDeletedObjectsKey] as? Set 60 | let inserted = notification.userInfo?[NSInsertedObjectsKey] as? Set 61 | return ( 62 | Set(refreshed?.compactMap { $0 as? T } ?? []), 63 | Set(inserted?.compactMap { $0 as? T } ?? []), 64 | Set(updated?.compactMap { $0 as? T } ?? []), 65 | Set(deleted?.compactMap { $0 as? T } ?? []) 66 | ) 67 | } 68 | 69 | /** 70 | Obtains the object from an `NSManagedObjectContextObjectsDidChange` or `NSManagedObjectContextDidSave` notification 71 | based on that object's `NSManagedObjectID`. 72 | 73 | - parameter objectID: The managed object identifier of the object to retrieve. 74 | - parameter type: The type of the object to retrieve. 75 | - parameter notification: The notification object from an `NSManagedObjectContextObjectsDidChange` or 76 | `NSManagedObjectContextDidSave` notification. 77 | - parameter refetch: If `true`, the object will be refreshed again from Core Data. Generally this should be 78 | left to the default value `false` unless you have a good reason. 79 | - returns: A tuple containing the object and whether or not it came through as a deletion, if found. 80 | */ 81 | class func referencedObject(for objectID: NSManagedObjectID, type: T.Type, in notification: Notification, refetch: Bool = false) -> (object: T, deleted: Bool)? { 82 | let (refreshes, inserts, updates, deletes) = objectsFrom(notification: notification, ofType: type) 83 | guard let context = notification.object as? NSManagedObjectContext else { 84 | return nil 85 | } 86 | 87 | for object in refreshes { 88 | guard let managed = object as? NSManagedObject, managed.objectID == objectID else { continue } 89 | if refetch, let refetched = context.object(with: objectID) as? T { 90 | return (refetched, false) 91 | } 92 | return (object, false) 93 | } 94 | for object in inserts { 95 | guard let managed = object as? NSManagedObject, managed.objectID == objectID else { continue } 96 | if refetch, let refetched = context.object(with: objectID) as? T { 97 | return (refetched, false) 98 | } 99 | return (object, false) 100 | } 101 | for object in updates { 102 | guard let managed = object as? NSManagedObject, managed.objectID == objectID else { continue } 103 | if refetch, let refetched = context.object(with: objectID) as? T { 104 | return (refetched, false) 105 | } 106 | return (object, false) 107 | } 108 | for object in deletes { 109 | guard let managed = object as? NSManagedObject, managed.objectID == objectID else { continue } 110 | return (object, true) 111 | } 112 | return nil 113 | } 114 | 115 | /** 116 | Checks if the given object is referenced in the given `NSManagedObjectContextObjectsDidChange` or 117 | `NSManagedObjectContextDidSave` notification. 118 | 119 | - parameter object: The object to look for in the change notification. 120 | - parameter notification: The notification object from an `NSManagedObjectContextObjectsDidChange` or 121 | `NSManagedObjectContextDidSave` notification. 122 | - returns: `true` if found. 123 | */ 124 | class func isObject(_ object: NSManagedObject, referencedIn notification: Notification) -> Bool { 125 | let objectID = object.objectID 126 | let (refreshes, inserts, updates, deletes) = objectIDsFrom(notification: notification) 127 | return refreshes.contains(objectID) || inserts.contains(objectID) || updates.contains(objectID) || deletes.contains(objectID) 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /Sources/CoreData/Extensions/NSManagedObjectID+DataObjectID.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSManagedObjectID+DataObjectID.swift 3 | // Flapjack+CoreData 4 | // 5 | // Created by Ben Kreeger on 11/4/17. 6 | // Copyright © 2017 O'Reilly Media, Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import CoreData 11 | #if !COCOAPODS 12 | import Flapjack 13 | #endif 14 | 15 | extension NSManagedObjectID: @retroactive DataObjectID { 16 | 17 | } 18 | -------------------------------------------------------------------------------- /Sources/CoreData/Extensions/NSMigrationManager+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSMigrationManager+Extensions.swift 3 | // Flapjack+CoreData 4 | // 5 | // Created by Ben Kreeger on 12/14/18. 6 | // Copyright © 2018 O'Reilly Media, Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import CoreData 11 | #if !COCOAPODS 12 | import Flapjack 13 | #endif 14 | 15 | public extension NSMigrationManager { 16 | /// A convenience enum for picking either the source managed object context or the destination one. 17 | enum Layer { 18 | /// The original managed object context (unmigrated). 19 | case source 20 | /// The new managed object context (to which migrated structure and objects are applied). 21 | case destination 22 | } 23 | 24 | /** 25 | A wrapper for calling `destinationInstances(forEntityMappingName:sourceInstances:)` with a single source, expecting 26 | a single result. 27 | 28 | - parameter mapping: The name of the mapping in the mapping model currently being run. 29 | - parameter source: The version of the managed object from the original context. 30 | - returns: The object belonging to the destination context, if found. 31 | */ 32 | func destinationObject(in mapping: String, source: NSManagedObject) -> NSManagedObject? { 33 | let objects = destinationInstances(forEntityMappingName: mapping, sourceInstances: [source]) 34 | return objects.first 35 | } 36 | 37 | /** 38 | Executes a fetch request in the requested context for objects matching a given entity name and a set of attributes. 39 | 40 | - parameter name: The entity name of the objects to be returned. 41 | - parameter attributes: A dictionary of key-value query constraints to apply to the lookup. 42 | - parameter layer: The context to fetch from; either `.source` or `.destination`. 43 | - returns: An array of objects found; if none were found or an error occurred, this is an empty array. 44 | */ 45 | func findEntities(_ name: String, attributes: [String: Any], from layer: Layer) -> [NSManagedObject] { 46 | let request = NSFetchRequest(entityName: name) 47 | request.predicate = NSCompoundPredicate(andPredicateFrom: attributes) 48 | do { 49 | switch layer { 50 | case .source: 51 | return try sourceContext.fetch(request) 52 | case .destination: 53 | return try destinationContext.fetch(request) 54 | } 55 | } catch let error { 56 | FJLogger.error(error.localizedDescription) 57 | return [] 58 | } 59 | } 60 | 61 | /** 62 | Executes a fetch request in the requested context for a single object with a matching value at a given key path. 63 | 64 | - parameter object: The entity to be looked for in the context. 65 | - parameter lookupKeyPath: The key path whose value will be used to match the two objects. 66 | - parameter layer: The context to fetch from; either `.source` or `.destination`. 67 | - returns: The object, if found. 68 | */ 69 | func findEntity(_ object: NSManagedObject, by lookupKeyPath: String, in layer: Layer) -> NSManagedObject? { 70 | guard let entityName = object.entity.name, let lookupValue = object.value(forKey: lookupKeyPath) else { 71 | return nil 72 | } 73 | 74 | return findEntities(entityName, attributes: [lookupKeyPath: lookupValue], from: layer).first 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /Sources/CoreData/MigrationPolicy.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MigrationPolicy.swift 3 | // FlapjackCoreData 4 | // 5 | // Created by Ben Kreeger on 12/14/18. 6 | // Copyright © 2018 O'Reilly Media, Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import CoreData 11 | 12 | /** 13 | A common subclass for "hooking in" to the existing Core Data APIs for handling mapping model migrations in order to 14 | make them Swift-y and much easier to deal with. 15 | 16 | Subclasses must override the `migrations` property, where keys should be the _names_ of the mappings (like 17 | `PlaylistToPlaylist`), and the _values_ should be the properties vending functions to run. 18 | 19 | See the docstring for `migrations` for more information. 20 | */ 21 | @objc 22 | open class MigrationPolicy: NSEntityMigrationPolicy { 23 | public typealias MigrationOperation = (_ manager: NSMigrationManager, _ source: NSManagedObject, _ destination: NSManagedObject?) throws -> Void 24 | 25 | @objc 26 | override open func createDestinationInstances(forSource sourceInstance: NSManagedObject, in mapping: NSEntityMapping, manager: NSMigrationManager) throws { 27 | if !migrationsWithoutNecessaryCreationStep.contains(mapping.name) { 28 | try super.createDestinationInstances(forSource: sourceInstance, in: mapping, manager: manager) 29 | } 30 | 31 | let destination = manager.destinationObject(in: mapping.name, source: sourceInstance) 32 | let migrationOperation = migrations[mapping.name] 33 | try migrationOperation?(manager, sourceInstance, destination) 34 | 35 | if let destination = destination, migrationsWithoutNecessaryCreationStep.contains(mapping.name) { 36 | manager.associate(sourceInstance: sourceInstance, withDestinationInstance: destination, for: mapping) 37 | } 38 | } 39 | 40 | /** 41 | Subclasses should override this method and return a dictionary whose keys are mapped directly 42 | to Core Data Mapping Model entity mappings (like `PlaylistToPlaylist`), and whose values are 43 | properties vending functions to run (conforming to the `MigrationOperation` typealias). 44 | */ 45 | open var migrations: [String: MigrationOperation] { 46 | return [:] 47 | } 48 | 49 | /** 50 | For second-tier mappings (to be run after the initial mapping runs for an entity), subclasses should override 51 | this property and define their names as the return value to this function. This tells the parent whether or not 52 | it needs to _create_ the entities in the destination context (second-tier mappings should not create entities 53 | otherwise duplication of entities will occur). 54 | */ 55 | open var migrationsWithoutNecessaryCreationStep: [String] { 56 | return [] 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Sources/CoreData/SingleCoreDataSource.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SingleCoreDataSource.swift 3 | // Flapjack+CoreData 4 | // 5 | // Created by Ben Kreeger on 2/15/18. 6 | // Copyright © 2018 O'Reilly Media, Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import CoreData 11 | #if !COCOAPODS 12 | import Flapjack 13 | #endif 14 | 15 | /** 16 | Listens for changes in an `NSManagedObjectContext` based on the `NSManagedObjectContextDidChange` notification for a 17 | single object described by a set of `attributes`. Invokes the `onChange` clsoure when the monitored object changes in 18 | the given `DataContext`. 19 | */ 20 | public class SingleCoreDataSource: NSObject, SingleDataSource { 21 | public let predicate: NSPredicate 22 | public private(set) var object: T? 23 | public private(set) var hasFetched = false 24 | public var onChange: ((T?) -> Void)? 25 | 26 | // This can only change if the context gets torn down and we get notified about a new one. 27 | private var context: DataContext 28 | private let prefetch: [String] 29 | /// If this is true, our context has been deleted and we're waiting for a new one. 30 | private var isContextAZombie: Bool = false 31 | private var isListening: Bool = false 32 | 33 | 34 | // MARK: Lifecycle 35 | 36 | /** 37 | Creates and returns an instance of this data source, but does not execute any fetch operations. 38 | 39 | - parameter context: The context on which to listen for object changes. 40 | - parameter predicate: The predicate of the object to find and then listen for, if applicable. 41 | - parameter prefetch: An array of keypaths of relationships to be eagerly-fetched, if applicable. 42 | */ 43 | public init(context: DataContext, predicate: NSPredicate, prefetch: [String] = []) { 44 | self.context = context 45 | self.predicate = predicate 46 | self.prefetch = prefetch 47 | super.init() 48 | } 49 | 50 | /** 51 | Creates and returns an instance of this data source, but does not execute any fetch operations. 52 | 53 | - parameter context: The context on which to listen for object changes. 54 | - parameter attributes: The attributes of the object to find and then listen for, if applicable. 55 | - parameter prefetch: An array of keypaths of relationships to be eagerly-fetched, if applicable. 56 | */ 57 | public convenience init(context: DataContext, attributes: DataContext.Attributes, prefetch: [String] = []) { 58 | let predicate = NSCompoundPredicate(andPredicateFrom: attributes) 59 | self.init(context: context, predicate: predicate, prefetch: prefetch) 60 | } 61 | 62 | /** 63 | Creates and returns an instance of this data source, but does not execute any fetch operations. 64 | 65 | - parameter context: The context on which to listen for object changes. 66 | - parameter uniqueID: The unique identifier of the object to find and then listen for. 67 | - parameter prefetch: An array of keypaths of relationships to be eagerly-fetched, if applicable. 68 | */ 69 | public convenience init(context: DataContext, uniqueID: T.PrimaryKeyType, prefetch: [String] = []) { 70 | let predicate = NSCompoundPredicate(andPredicateFrom: [T.primaryKeyPath: uniqueID]) 71 | self.init(context: context, predicate: predicate, prefetch: prefetch) 72 | } 73 | 74 | /** 75 | Creates and returns an instance of this data source, but does not execute any fetch operations. 76 | 77 | - parameter context: The managed object context instance on which to listen for changes. 78 | - parameter object: The object for which to observe changes. 79 | - parameter prefetch: An optional array of relationship keypaths to pre-fill faults on initial fetch. 80 | */ 81 | public convenience init(context: DataContext, object: T, prefetch: [String] = []) { 82 | self.init(context: context, predicate: NSPredicate(key: "self", value: object), prefetch: prefetch) 83 | } 84 | 85 | deinit { 86 | guard isListening else { 87 | return 88 | } 89 | NotificationCenter.default.removeObserver(self, name: .NSManagedObjectContextObjectsDidChange, object: nil) 90 | } 91 | 92 | 93 | // MARK: SingleDataSource 94 | 95 | /** 96 | Begins listening for `NSManagedObjectContextObjectsDidChange` notifications, and fetches the initial object result 97 | into the `object` property. Immediately invokes the `onChange` block upon completion and passes in the object if 98 | found. 99 | */ 100 | public func startListening() { 101 | if !isListening { 102 | NotificationCenter.default.addObserver(self, selector: #selector(objectsDidChange(_:)), name: .NSManagedObjectContextObjectsDidChange, object: nil) 103 | NotificationCenter.default.addObserver(self, selector: #selector(contextWasCreated(_:)), name: CoreDataAccess.didCreateNewMainContextNotification, object: nil) 104 | NotificationCenter.default.addObserver(self, selector: #selector(contextWillBeDestroyed(_:)), name: CoreDataAccess.willDestroyMainContextNotification, object: nil) 105 | isListening = true 106 | } 107 | 108 | object = context.object(ofType: T.self, predicate: predicate, prefetch: prefetch, sortBy: []) 109 | onChange?(object) 110 | } 111 | 112 | 113 | // MARK: Private functions 114 | 115 | private func findObjectFrom(objects: Set) -> T? { 116 | return (objects as NSSet).filtered(using: predicate).first(where: { $0 is T }) as? T 117 | } 118 | 119 | @objc 120 | private func objectsDidChange(_ notification: Notification) { 121 | guard context as? NSManagedObjectContext === notification.object as? NSManagedObjectContext else { return } 122 | 123 | let (refreshes, inserts, updates, deletes) = NSManagedObjectContext.objectsFrom(notification: notification, ofType: T.self) 124 | 125 | if findObjectFrom(objects: deletes) != nil { 126 | hasFetched = true 127 | object = nil 128 | onChange?(nil) 129 | } 130 | 131 | let theRest = inserts.union(updates).union(refreshes) 132 | if let filtered = findObjectFrom(objects: theRest) { 133 | hasFetched = true 134 | object = filtered 135 | onChange?(object) 136 | } 137 | } 138 | 139 | @objc 140 | private func contextWasCreated(_ notification: Notification) { 141 | guard isContextAZombie else { return } 142 | guard let newContext = notification.object as? DataContext else { return } 143 | 144 | // Store our newly-minted context, and refetch. 145 | self.context = newContext 146 | startListening() 147 | } 148 | 149 | @objc 150 | private func contextWillBeDestroyed(_ notification: Notification) { 151 | guard context as? NSManagedObjectContext === notification.object as? NSManagedObjectContext else { return } 152 | 153 | isContextAZombie = true 154 | hasFetched = false 155 | object = nil 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /Sources/UIKit/Extensions/Set+DataSourceChange.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Set+DataSourceChange.swift 3 | // FlapjackUIKit 4 | // 5 | // Created by Ben Kreeger on 12/20/18. 6 | // Copyright © 2018 O'Reilly Media, Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | #if !COCOAPODS 11 | import Flapjack 12 | #endif 13 | 14 | internal extension Set where Element == DataSourceChange { 15 | var components: (inserts: [IndexPath], deletes: [IndexPath], moves: [(from: IndexPath, to: IndexPath)], updates: [IndexPath]) { 16 | var tuple: (inserts: [IndexPath], deletes: [IndexPath], moves: [(from: IndexPath, to: IndexPath)], updates: [IndexPath]) = ([], [], [], []) 17 | forEach { element in 18 | switch element { 19 | case .insert(let path): tuple.inserts.append(path) 20 | case .delete(let path): tuple.deletes.append(path) 21 | case .move(let fromPath, let toPath): tuple.moves.append((fromPath, toPath)) 22 | case .update(let path): tuple.updates.append(path) 23 | } 24 | } 25 | return tuple 26 | } 27 | } 28 | 29 | internal extension Set where Element == DataSourceSectionChange { 30 | var components: (inserts: IndexSet, deletes: IndexSet) { 31 | var tuple: (inserts: IndexSet, deletes: IndexSet) = ([], []) 32 | forEach { element in 33 | switch element { 34 | case .insert(let section): tuple.inserts.insert(section) 35 | case .delete(let section): tuple.deletes.insert(section) 36 | } 37 | } 38 | return tuple 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Sources/UIKit/Extensions/UICollectionView+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UICollectionView+Extensions.swift 3 | // Flapjack 4 | // 5 | // Created by Ben Kreeger on 07/19/2018. 6 | // Copyright (c) 2018 O'Reilly Media, Inc. All rights reserved. 7 | // 8 | 9 | #if canImport(UIKit) 10 | import Foundation 11 | import UIKit 12 | #if !COCOAPODS 13 | import Flapjack 14 | #endif 15 | 16 | public extension UICollectionView { 17 | /** 18 | Divvies up the changes passed in and forwards the calls to native iOS APIs for performing batch changes in a 19 | `UICollectionView`. Provides a convenience API for not having to do this yourself! 20 | 21 | - parameter objectChanges: A set of change enumerations coming from a collection-observing `DataSource`. 22 | - parameter sectionChanges: A set of section change enumerations coming from a collection-observing `DataSource`. 23 | - parameter completion: A block to be called upon completion. The included boolean indicates if the animations 24 | finished successfully. 25 | */ 26 | func performBatchUpdates(_ objectChanges: [DataSourceChange], sectionChanges: [DataSourceSectionChange] = [], completion: ((Bool) -> Void)? = nil) { 27 | guard superview != nil, !objectChanges.isEmpty else { 28 | return 29 | } 30 | performBatchUpdates({ 31 | for change in objectChanges { 32 | switch change { 33 | case .insert(let path): 34 | insertItems(at: [path]) 35 | case .delete(let path): 36 | deleteItems(at: [path]) 37 | case .move(let fromPath, let toPath): 38 | deleteItems(at: [fromPath]) 39 | insertItems(at: [toPath]) 40 | case .update(let path): 41 | reloadItems(at: [path]) 42 | } 43 | } 44 | for sectionChange in sectionChanges { 45 | switch sectionChange { 46 | case .insert(let section): 47 | insertSections(IndexSet(integer: section)) 48 | case .delete(let section): 49 | deleteSections(IndexSet(integer: section)) 50 | } 51 | } 52 | }, completion: completion) 53 | } 54 | } 55 | #endif 56 | -------------------------------------------------------------------------------- /Sources/UIKit/Extensions/UITableView+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UITableView+Extensions.swift 3 | // Flapjack 4 | // 5 | // Created by Ben Kreeger on 07/19/2018. 6 | // Copyright (c) 2018 O'Reilly Media, Inc. All rights reserved. 7 | // 8 | 9 | #if canImport(UIKit) 10 | import Foundation 11 | import UIKit 12 | #if !COCOAPODS 13 | import Flapjack 14 | #endif 15 | 16 | public extension UITableView { 17 | /** 18 | Divvies up the changes passed in and forwards the calls to native iOS APIs for performing batch changes in a 19 | `UITableView`. Provides a convenience API for not having to do this yourself! 20 | 21 | - parameter objectChanges: A set of change enumerations coming from a collection-observing `DataSource`. 22 | - parameter sectionChanges: A set of section change enumerations coming from a collection-observing `DataSource`. 23 | - parameter completion: A block to be called upon completion. The included boolean indicates if the animations 24 | finished successfully. 25 | */ 26 | func performBatchUpdates(_ objectChanges: [DataSourceChange], sectionChanges: [DataSourceSectionChange] = [], completion: ((Bool) -> Void)? = nil) { 27 | guard superview != nil, !objectChanges.isEmpty else { 28 | return 29 | } 30 | 31 | performBatchUpdates({ 32 | for change in objectChanges { 33 | switch change { 34 | case .insert(let path): 35 | insertRows(at: [path], with: .automatic) 36 | case .delete(let path): 37 | deleteRows(at: [path], with: .automatic) 38 | case .move(let fromPath, let toPath): 39 | deleteRows(at: [fromPath], with: .automatic) 40 | insertRows(at: [toPath], with: .automatic) 41 | case .update(let path): 42 | reloadRows(at: [path], with: .automatic) 43 | } 44 | } 45 | for sectionChange in sectionChanges { 46 | switch sectionChange { 47 | case .insert(let section): 48 | insertSections(IndexSet(integer: section), with: .automatic) 49 | case .delete(let section): 50 | deleteSections(IndexSet(integer: section), with: .automatic) 51 | } 52 | } 53 | }, completion: completion) 54 | } 55 | } 56 | #endif 57 | -------------------------------------------------------------------------------- /Tests/Core/Extensions/Array+ExtensionsTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Array+ExtensionsTests.swift 3 | // Flapjack 4 | // 5 | // Created by Ben Kreeger on 9/12/18. 6 | // Copyright © 2018 O'Reilly Media, Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import XCTest 11 | @testable import Flapjack 12 | 13 | class ArrayExtensionsTests: XCTestCase { 14 | func testIndexOfObjectWithIdentityMatch() { 15 | // WITHOUT using pointer comparison, we settle for equality using index(of:) 16 | let toFind2 = MockEquatable("peach") 17 | let array2 = [MockEquatable("raspberry"), MockEquatable("peach"), toFind2] 18 | XCTAssertEqual(array2.firstIndex(of: toFind2, pointerComparison: false), 1) 19 | XCTAssertEqual(array2.firstIndex(of: toFind2), 1) 20 | 21 | // But again, pointer comparison gets us true object identity 22 | XCTAssertEqual(array2.firstIndex(of: toFind2, pointerComparison: true), 2) 23 | } 24 | 25 | func testSortedUsingDescriptors() { 26 | let testArray = [MockEquatable("s"), MockEquatable("z"), MockEquatable("y"), MockEquatable("a"), MockEquatable("b")] 27 | let expected = [MockEquatable("a"), MockEquatable("b"), MockEquatable("s"), MockEquatable("y"), MockEquatable("z")] 28 | XCTAssertEqual(testArray.sorted(using: [SortDescriptor("string")]), expected) 29 | 30 | let expected2 = Array(expected.reversed()) 31 | XCTAssertEqual(testArray.sorted(using: [SortDescriptor("string", ascending: false)]), expected2) 32 | } 33 | } 34 | 35 | 36 | private class MockEquatable: NSObject { 37 | // To be key-value compliant. 38 | @objc fileprivate let string: String 39 | 40 | fileprivate init(_ string: String) { 41 | self.string = string 42 | super.init() 43 | } 44 | 45 | override fileprivate func isEqual(_ object: Any?) -> Bool { 46 | guard let object = object as? MockEquatable else { 47 | return false 48 | } 49 | return object.string == self.string 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Tests/Core/Extensions/Collection+ExtensionsTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Collection+ExtensionsTests.swift 3 | // Flapjack 4 | // 5 | // Created by Ben Kreeger on 9/12/18. 6 | // Copyright © 2018 O'Reilly Media, Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import XCTest 11 | @testable import Flapjack 12 | 13 | class CollectionExtensionsTests: XCTestCase { 14 | func testSafeSubscripting() { 15 | var mutableArray: [String] = [] 16 | XCTAssertNil(mutableArray[safe: 0]) 17 | 18 | mutableArray.append("abc") 19 | XCTAssertEqual(mutableArray[safe: 0], "abc") 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Tests/Core/Extensions/NSCompoundPredicate+ExtensionsTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSCompoundPredicate+ExtensionsTests.swift 3 | // Flapjack 4 | // 5 | // Created by Ben Kreeger on 9/12/18. 6 | // Copyright © 2018 O'Reilly Media, Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import XCTest 11 | @testable import Flapjack 12 | 13 | class NSCompoundPredicateExtensionsTests: XCTestCase { 14 | func testKeyValueInitializerWithCVarArgTypes() { 15 | XCTAssertEqual(NSPredicate(key: "keypath", value: "bob").predicateFormat, "keypath == \"bob\"") 16 | XCTAssertEqual(NSPredicate(key: "self", value: "bob").predicateFormat, "SELF == \"bob\"") 17 | XCTAssertEqual(NSPredicate(key: "keypath", value: 3).predicateFormat, "keypath == 3") 18 | } 19 | 20 | func testKeyValueInitializerWithArrays() { 21 | XCTAssertEqual(NSPredicate(key: "keypath", value: ["bob", "ed"]).predicateFormat, "keypath IN {\"bob\", \"ed\"}") 22 | } 23 | 24 | func testKeyValueInitializerWithSets() { 25 | let format = NSPredicate(key: "keypath", value: Set(["bob", "ed"])).predicateFormat 26 | let oneWay = format == "keypath IN {\"bob\", \"ed\"}" 27 | let another = format == "keypath IN {\"ed\", \"bob\"}" 28 | XCTAssertTrue(oneWay || another) 29 | } 30 | 31 | func testKeyValueInitializerWithRanges() { 32 | XCTAssertEqual(NSPredicate(key: "keypath", value: 1..<2).predicateFormat, "keypath >= 1 AND keypath < 2") 33 | XCTAssertEqual(NSPredicate(key: "keypath", value: 1...2).predicateFormat, "keypath >= 1 AND keypath <= 2") 34 | } 35 | 36 | func testInitializeFromConditions() { 37 | let predicates = NSPredicate.fromConditions(["one": "two", "three": 4]) 38 | XCTAssertTrue(predicates.contains { $0.predicateFormat == "one == \"two\"" }) 39 | XCTAssertTrue(predicates.contains { $0.predicateFormat == "three == 4" }) 40 | } 41 | 42 | func testInitializeFromNull() { 43 | XCTAssertEqual(NSPredicate(key: "keypath", value: nil).predicateFormat, "keypath == nil") 44 | XCTAssertEqual(NSPredicate(key: "self", value: nil).predicateFormat, "SELF == nil") 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Tests/Core/Types/DataAccessErrorTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DataAccessErrorTests.swift 3 | // Flapjack 4 | // 5 | // Created by Ben Kreeger on 9/12/18. 6 | // Copyright © 2018 O'Reilly Media, Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import XCTest 11 | @testable import Flapjack 12 | 13 | class DataAccessErrorTests: XCTestCase { 14 | func testPreparationError() { 15 | let nserror = NSError(domain: "Domain", code: 0, userInfo: nil) 16 | XCTAssertFalse(DataAccessError.preparationError(nserror).description.isEmpty) 17 | XCTAssertFalse(DataAccessError.preparationError(nserror).localizedDescription.isEmpty) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Tests/Core/Types/DataContextErrorTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DataContextErrorTests.swift 3 | // Flapjack 4 | // 5 | // Created by Ben Kreeger on 10/26/18. 6 | // Copyright © 2018 O'Reilly Media, Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import XCTest 11 | @testable import Flapjack 12 | 13 | class DataContextErrorTests: XCTestCase { 14 | func testPreparationError() { 15 | let nserror = NSError(domain: "Domain", code: 0, userInfo: nil) 16 | XCTAssertFalse(DataContextError.saveError(nserror).description.isEmpty) 17 | XCTAssertFalse(DataContextError.saveError(nserror).localizedDescription.isEmpty) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Tests/Core/Types/SortDescriptorTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SortDescriptorTests.swift 3 | // Flapjack-Unit-Tests 4 | // 5 | // Created by Ben Kreeger on 10/26/18. 6 | // 7 | 8 | import Foundation 9 | import XCTest 10 | @testable import Flapjack 11 | 12 | class SortDescriptorTests: XCTestCase { 13 | func testInitializeWithKeypath() { 14 | let initialized = SortDescriptor("abc123") 15 | XCTAssertEqual(initialized.keyPath, "abc123") 16 | XCTAssertTrue(initialized.ascending) 17 | XCTAssertFalse(initialized.caseInsensitive) 18 | XCTAssertEqual(initialized.asNSSortDescriptor, NSSortDescriptor(key: "abc123", ascending: true, selector: nil)) 19 | XCTAssertEqual(initialized.cacheKey, "abc123.1.0") 20 | } 21 | 22 | func testInitializeWithKeypathAndAscending() { 23 | let initialized = SortDescriptor("abc123", ascending: false) 24 | XCTAssertEqual(initialized.keyPath, "abc123") 25 | XCTAssertFalse(initialized.ascending) 26 | XCTAssertFalse(initialized.caseInsensitive) 27 | XCTAssertEqual(initialized.asNSSortDescriptor, NSSortDescriptor(key: "abc123", ascending: false, selector: nil)) 28 | XCTAssertEqual(initialized.cacheKey, "abc123.0.0") 29 | } 30 | 31 | func testInitializeWithKeypathAndAscendingAndCaseInsensitivity() { 32 | let initialized = SortDescriptor("abc123", ascending: false, caseInsensitive: true) 33 | XCTAssertEqual(initialized.keyPath, "abc123") 34 | XCTAssertFalse(initialized.ascending) 35 | XCTAssertTrue(initialized.caseInsensitive) 36 | XCTAssertEqual(initialized.asNSSortDescriptor, NSSortDescriptor(key: "abc123", ascending: false, selector: #selector(NSString.caseInsensitiveCompare(_:)))) 37 | XCTAssertEqual(initialized.cacheKey, "abc123.0.1") 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Tests/CoreData/CoreDataMigratorTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CoreDataMigratorTests.swift 3 | // Tests 4 | // 5 | // Created by Ben Kreeger on 11/30/18. 6 | // Copyright © 2018 O'Reilly Media, Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import XCTest 11 | import CoreData 12 | 13 | @testable import Flapjack 14 | @testable import FlapjackCoreData 15 | 16 | class CoreDataMigratorTests: XCTestCase { 17 | private var urlsToCleanup = [URL]() 18 | private let modelName = "TestMigrationModel" 19 | private var bundle: Bundle { 20 | #if COCOAPODS 21 | return Bundle(for: type(of: self)) 22 | #else 23 | return Bundle.module 24 | #endif 25 | } 26 | private var compiledModelURL: URL { 27 | return bundle.url(forResource: modelName, withExtension: "momd")! 28 | } 29 | private lazy var versionInfo: [String: Any] = { 30 | guard FileManager.default.fileExists(atPath: compiledModelURL.path) else { return [:] } 31 | return NSDictionary(contentsOf: compiledModelURL.appendingPathComponent("VersionInfo.plist", isDirectory: false)) as? [String: Any] ?? [:] 32 | }() 33 | private lazy var knownVersions: [String] = { 34 | let versionHashes = versionInfo["NSManagedObjectModel_VersionHashes"] as? [String: Any] 35 | return versionHashes?.keys.sorted { $0.localizedStandardCompare($1) == .orderedAscending } ?? [] 36 | }() 37 | private var currentVersion: String { 38 | return versionInfo["NSManagedObjectModel_CurrentVersionName"] as? String ?? "" 39 | } 40 | 41 | override func setUp() { 42 | super.setUp() 43 | FJLogger.logLevel = .debug 44 | urlsToCleanup = [URL]() 45 | } 46 | 47 | override func tearDown() { 48 | urlsToCleanup.forEach { url in 49 | try? FileManager.default.removeItem(at: url) 50 | } 51 | urlsToCleanup.removeAll() 52 | super.tearDown() 53 | } 54 | 55 | 56 | // MARK: storeIsUpToDate 57 | 58 | func testStateWithFreshDatabase() throws { 59 | let (storeURL, filename, _) = try persistentStoreContainer(version: currentVersion) 60 | urlsToCleanup.append(storeURL) 61 | let migrator = CoreDataMigrator(storeURL: storeURL, bundle: bundle, modelName: "TestMigrationModel", storeType: .sql(filename: filename)) 62 | XCTAssertTrue(migrator.storeIsUpToDate) 63 | } 64 | 65 | func testWithStaleDatabases() throws { 66 | try knownVersions.forEach { versionString in 67 | guard versionString != currentVersion else { return } 68 | let (storeURL, filename, _) = try persistentStoreContainer(version: versionString) 69 | urlsToCleanup.append(storeURL) 70 | let migrator = CoreDataMigrator(storeURL: storeURL, bundle: bundle, modelName: "TestMigrationModel", storeType: .sql(filename: filename)) 71 | XCTAssertFalse(migrator.storeIsUpToDate, "\(versionString) should be stale.") 72 | } 73 | } 74 | 75 | 76 | // MARK: migrate() throws 77 | 78 | func testMigrateEmptyDataStoreFromOriginalVersion() throws { 79 | let original = knownVersions[0] 80 | let (storeURL, filename, _) = try persistentStoreContainer(version: original) 81 | urlsToCleanup.append(storeURL) 82 | let migrator = CoreDataMigrator(storeURL: storeURL, bundle: bundle, modelName: "TestMigrationModel", storeType: .sql(filename: filename)) 83 | 84 | do { 85 | let success = try migrator.migrate() 86 | XCTAssertTrue(success) 87 | } catch let error { 88 | XCTFail("Got error: \(error)") 89 | } 90 | } 91 | 92 | func testMigratePopulatedDataStoreFromOriginalVersion() throws { 93 | let original = knownVersions[0] 94 | let (storeURL, filename, oldContainer) = try persistentStoreContainer(version: original) 95 | urlsToCleanup.append(storeURL) 96 | prepareContainer(oldContainer) 97 | 98 | let migrator = CoreDataMigrator(storeURL: storeURL, bundle: bundle, modelName: "TestMigrationModel", storeType: .sql(filename: filename)) 99 | 100 | do { 101 | let success = try migrator.migrate() 102 | XCTAssertTrue(success) 103 | 104 | let (url, _, container) = try persistentStoreContainer(version: knownVersions.last!, filename: filename) 105 | urlsToCleanup.append(url) 106 | guard let entityDescription = NSEntityDescription.entity(forEntityName: "MigratedEntity", in: container.viewContext) else { 107 | XCTFail("Couldn't get MigratedEntity NSEntityDescription.") 108 | return 109 | } 110 | 111 | let fetch = NSFetchRequest() 112 | fetch.entity = entityDescription 113 | let results = try container.viewContext.fetch(fetch) 114 | XCTAssertEqual(results.count, 1) 115 | XCTAssertEqual((results.first as? NSManagedObject)?.value(forKey: "convertedProperty") as? String, "1234") 116 | } catch let error { 117 | XCTFail("Got error: \(error)") 118 | } 119 | } 120 | 121 | 122 | // MARK: - Private functions 123 | 124 | private func persistentStoreContainer(version: String, filename: String? = nil) throws -> (onDiskURL: URL, filename: String, container: NSPersistentContainer) { 125 | let modelURL = try XCTUnwrap(bundle.url(forResource: version, withExtension: "mom", subdirectory: "TestMigrationModel.momd"), "Unable to load \(version).mom") 126 | let model = NSManagedObjectModel(contentsOf: modelURL)! 127 | let filenameToUse = filename ?? "\(UUID().uuidString).sqlite" 128 | let storeURL = NSPersistentContainer.defaultDirectoryURL().appendingPathComponent(filenameToUse) 129 | let container = NSPersistentContainer(name: "CoreDataMigratorTests", managedObjectModel: model) 130 | container.persistentStoreDescriptions = [NSPersistentStoreDescription(url: storeURL)] 131 | container.loadPersistentStores { desc, err in 132 | XCTAssertEqual(desc, container.persistentStoreDescriptions.first) 133 | XCTAssertNil(err) 134 | } 135 | return (storeURL, filenameToUse, container) 136 | } 137 | 138 | private func prepareContainer(_ container: NSPersistentContainer) { 139 | let context = container.viewContext 140 | let managedObject = NSEntityDescription.insertNewObject(forEntityName: "EntityToMigrate", into: context) 141 | managedObject.setPrimitiveValue(1234, forKey: "oldProperty") 142 | do { 143 | try context.save() 144 | } catch let error { 145 | XCTFail("Got error: \(error)") 146 | } 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /Tests/CoreData/Extensions/Dictionary+ExtensionsTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Dictionary+ExtensionsTests.swift 3 | // Flapjack 4 | // 5 | // Created by Ben Kreeger on 9/12/18. 6 | // Copyright © 2018 O'Reilly Media, Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import XCTest 11 | @testable import Flapjack 12 | @testable import FlapjackCoreData 13 | 14 | class DictionaryExtensionsTests: XCTestCase { 15 | func testCacheKeyGeneration() { 16 | // Sorts keys and then spits out a key 17 | let source: [String: Any?] = ["this": "should", "produce": true, "aCacheKey": 42] 18 | XCTAssertEqual(source.cacheKey, "aCacheKey.42-produce.true-this.should") 19 | 20 | let empty = [String: Any?]() 21 | XCTAssertEqual(empty.cacheKey, "") 22 | 23 | let withNil: [String: Any?] = ["this": "should", "appear": nil] 24 | XCTAssertEqual(withNil.cacheKey, "appear.nil-this.should") 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Tests/CoreData/Extensions/NSFetchedResultsChangeTypeExtensionsTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSFetchedResultsChangeTypeExtensionsTests.swift 3 | // Flapjack+CoreData 4 | // 5 | // Created by Ben Kreeger on 10/26/18. 6 | // Copyright © 2018 O'Reilly Media, Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import XCTest 11 | import CoreData 12 | 13 | @testable import Flapjack 14 | @testable import FlapjackCoreData 15 | 16 | class NSFRCTExtensionsTests: XCTestCase { 17 | func testDescription() { 18 | XCTAssertEqual(NSFetchedResultsChangeType.delete.description, "delete") 19 | XCTAssertEqual(NSFetchedResultsChangeType.insert.description, "insert") 20 | XCTAssertEqual(NSFetchedResultsChangeType.update.description, "update") 21 | XCTAssertEqual(NSFetchedResultsChangeType.move.description, "move") 22 | } 23 | 24 | func testAsDataSourceSectionChange() { 25 | XCTAssertNil(NSFetchedResultsChangeType.update.asDataSourceSectionChange(section: 0)) 26 | XCTAssertNil(NSFetchedResultsChangeType.move.asDataSourceSectionChange(section: 0)) 27 | XCTAssertEqual(NSFetchedResultsChangeType.insert.asDataSourceSectionChange(section: 0), .insert(section: 0)) 28 | XCTAssertEqual(NSFetchedResultsChangeType.delete.asDataSourceSectionChange(section: 0), .delete(section: 0)) 29 | } 30 | 31 | func testAsDataSourceChangeAtNewPathForInsert() { 32 | let indexPath = IndexPath(item: 0, section: 0) 33 | let newPath = IndexPath(item: 1, section: 0) 34 | XCTAssertNil(NSFetchedResultsChangeType.insert.asDataSourceChange(at: nil, newPath: nil)) 35 | XCTAssertNil(NSFetchedResultsChangeType.insert.asDataSourceChange(at: indexPath, newPath: nil)) 36 | XCTAssertEqual(NSFetchedResultsChangeType.insert.asDataSourceChange(at: nil, newPath: newPath), .insert(path: newPath)) 37 | XCTAssertEqual(NSFetchedResultsChangeType.insert.asDataSourceChange(at: indexPath, newPath: newPath), .insert(path: newPath)) 38 | } 39 | 40 | func testAsDataSourceChangeAtNewPathForDelete() { 41 | let indexPath = IndexPath(item: 0, section: 0) 42 | XCTAssertNil(NSFetchedResultsChangeType.delete.asDataSourceChange(at: nil, newPath: indexPath)) 43 | XCTAssertEqual(NSFetchedResultsChangeType.delete.asDataSourceChange(at: indexPath, newPath: nil), .delete(path: indexPath)) 44 | } 45 | 46 | func testAsDataSourceChangeAtNewPathForMove() { 47 | let indexPath = IndexPath(item: 0, section: 0) 48 | let newPath = IndexPath(item: 1, section: 0) 49 | XCTAssertNil(NSFetchedResultsChangeType.move.asDataSourceChange(at: nil, newPath: nil)) 50 | XCTAssertNil(NSFetchedResultsChangeType.move.asDataSourceChange(at: nil, newPath: indexPath)) 51 | XCTAssertNil(NSFetchedResultsChangeType.move.asDataSourceChange(at: indexPath, newPath: nil)) 52 | XCTAssertEqual(NSFetchedResultsChangeType.move.asDataSourceChange(at: indexPath, newPath: newPath), .move(from: indexPath, toPath: newPath)) 53 | XCTAssertEqual(NSFetchedResultsChangeType.move.asDataSourceChange(at: indexPath, newPath: indexPath), .update(path: indexPath)) 54 | } 55 | 56 | func testAsDataSourceChangeAtNewPathForUpdate() { 57 | let indexPath = IndexPath(item: 0, section: 0) 58 | XCTAssertNil(NSFetchedResultsChangeType.update.asDataSourceChange(at: nil, newPath: indexPath)) 59 | XCTAssertEqual(NSFetchedResultsChangeType.update.asDataSourceChange(at: indexPath, newPath: nil), .update(path: indexPath)) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Tests/CoreData/Mocks/MockDataAccessDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockDataAccessDelegate.swift 3 | // Tests 4 | // 5 | // Created by Ben Kreeger on 12/18/18. 6 | // Copyright © 2018 O'Reilly Media, Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import CoreData 11 | 12 | @testable import Flapjack 13 | @testable import FlapjackCoreData 14 | 15 | class MockDataAccessDelegate: DataAccessDelegate { 16 | var migrator: Migrator? 17 | 18 | var wantsMigratorForStoreAtCalled: (called: Bool, url: URL?) = (false, nil) 19 | func dataAccess(_ dataAccess: DataAccess, wantsMigratorForStoreAt storeURL: URL?) -> Migrator? { 20 | wantsMigratorForStoreAtCalled = (true, storeURL) 21 | return migrator 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Tests/CoreData/Mocks/MockEntity.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockEntity+CoreDataClass.swift 3 | // Flapjack-Unit-CoreData-Tests 4 | // 5 | // Created by Ben Kreeger on 11/1/18. 6 | // 7 | // 8 | 9 | import Foundation 10 | import CoreData 11 | 12 | @testable import Flapjack 13 | @testable import FlapjackCoreData 14 | 15 | @objc(MockEntity) 16 | public class MockEntity: NSManagedObject { 17 | @NSManaged public var someProperty: String? 18 | @NSManaged public var identifier: String 19 | @NSManaged public var someTransientProperty: Date? 20 | 21 | override public func awakeFromInsert() { 22 | super.awakeFromInsert() 23 | setPrimitiveValue(UUID().uuidString, forKey: #keyPath(identifier)) 24 | } 25 | } 26 | 27 | 28 | extension MockEntity: DataObject { 29 | public typealias PrimaryKeyType = String 30 | 31 | public static var representedName: String { 32 | return "MockEntity" 33 | } 34 | 35 | public static var primaryKeyPath: String { 36 | return #keyPath(MockEntity.identifier) 37 | } 38 | 39 | public static var defaultSorters: [Flapjack.SortDescriptor] { 40 | return [SortDescriptor(#keyPath(MockEntity.identifier))] 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Tests/CoreData/Mocks/MockMigrationPolicy.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockMigrationPolicy.swift 3 | // Tests 4 | // 5 | // Created by Ben Kreeger on 12/14/18. 6 | // Copyright © 2018 O'Reilly Media, Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import CoreData 11 | 12 | @testable import Flapjack 13 | @testable import FlapjackCoreData 14 | 15 | @objc(MockMigrationPolicy) 16 | class MockMigrationPolicy: MigrationPolicy { 17 | override var migrations: [String: MigrationOperation] { 18 | return [ 19 | "EntityToMigrateToMigratedEntity": entityMigration 20 | ] 21 | } 22 | 23 | private var entityMigration: MigrationOperation { 24 | return { _, source, destination in 25 | if let sourceValue = source.value(forKey: "renamedProperty") as? Int32 { 26 | destination?.setValue(String(sourceValue), forKey: "convertedProperty") 27 | } 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Tests/CoreData/Mocks/MockMigrator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockMigrator.swift 3 | // Tests 4 | // 5 | // Created by Ben Kreeger on 12/18/18. 6 | // Copyright © 2018 O'Reilly Media, Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | @testable import Flapjack 12 | @testable import FlapjackCoreData 13 | 14 | class MockMigrator: Migrator { 15 | var storeIsUpToDate: Bool = false 16 | var errorToThrow: MigratorError? 17 | var simulateMigration: Bool = false 18 | 19 | func migrate() throws -> Bool { 20 | if let error = errorToThrow { 21 | throw error 22 | } 23 | return simulateMigration 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Tests/CoreData/Resources/TestMigrationModel.xcdatamodeld/.xccurrentversion: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | _XCCurrentVersionName 6 | TestMigrationModel 4.xcdatamodel 7 | 8 | 9 | -------------------------------------------------------------------------------- /Tests/CoreData/Resources/TestMigrationModel.xcdatamodeld/TestMigrationModel 2.xcdatamodel/contents: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /Tests/CoreData/Resources/TestMigrationModel.xcdatamodeld/TestMigrationModel 3.xcdatamodel/contents: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /Tests/CoreData/Resources/TestMigrationModel.xcdatamodeld/TestMigrationModel 4.xcdatamodel/contents: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /Tests/CoreData/Resources/TestMigrationModel.xcdatamodeld/TestMigrationModel.xcdatamodel/contents: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Tests/CoreData/Resources/TestModel.xcdatamodeld/TestModel.xcdatamodel/contents: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /Tests/CoreData/SingleCoreDataSourceTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SingleCoreDataSourceTests.swift 3 | // Tests 4 | // 5 | // Created by Ben Kreeger on 11/30/18. 6 | // Copyright © 2018 O'Reilly Media, Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import XCTest 11 | import CoreData 12 | 13 | @testable import Flapjack 14 | @testable import FlapjackCoreData 15 | 16 | class SingleCoreDataSourceTests: XCTestCase { 17 | private var dataAccess: DataAccess! 18 | private var dataSource: SingleCoreDataSource! 19 | private var attributes: [String: String] { 20 | return ["someProperty": "someValue"] 21 | } 22 | private var entity: MockEntity! 23 | private var bundle: Bundle { 24 | #if COCOAPODS 25 | return Bundle(for: type(of: self)) 26 | #else 27 | return Bundle.module 28 | #endif 29 | } 30 | 31 | override func setUpWithError() throws { 32 | try super.setUpWithError() 33 | let modelFile = try XCTUnwrap(bundle.url(forResource: "TestModel", withExtension: "momd"), "Unable to load TestModel.momd") 34 | let model = NSManagedObjectModel(contentsOf: modelFile) 35 | dataAccess = CoreDataAccess(name: "TestModel", type: .memory(storeName: "TestModel"), model: model) 36 | dataAccess.prepareStack(asynchronously: false, completion: { _ in }) 37 | 38 | entity = dataAccess.mainContext.create(MockEntity.self, attributes: attributes) 39 | dataSource = SingleCoreDataSource(context: dataAccess.mainContext, attributes: attributes, prefetch: []) 40 | } 41 | 42 | override func tearDownWithError() throws { 43 | dataAccess = nil 44 | entity = nil 45 | dataSource = nil 46 | try super.tearDownWithError() 47 | } 48 | 49 | func testObjectIsNilOnInit() { 50 | XCTAssertNil(dataSource.object) 51 | XCTAssertNotNil(dataSource.predicate) 52 | XCTAssertNil(dataSource.onChange) 53 | } 54 | 55 | func testExecutionFetchesRightAway() { 56 | XCTAssertNil(dataSource.object) 57 | dataSource.startListening() 58 | XCTAssertEqual(dataSource.object, entity) 59 | } 60 | 61 | func testCoreDataNotificationNotPickedUpBeforestartListening() { 62 | let expect = expectation(description: "did change block") 63 | expect.isInverted = true 64 | dataSource.onChange = { _ in 65 | expect.fulfill() 66 | } 67 | // Should not fire the expectation 68 | entity.someProperty = "some other value" 69 | dataAccess.mainContext.persist() 70 | waitForExpectations(timeout: 0.25) { XCTAssertNil($0) } 71 | } 72 | 73 | func testExecutionSubscribesToNotificationAndDidChangeFiresRightAway() { 74 | let expect = expectation(description: "did change block") 75 | expect.expectedFulfillmentCount = 1 76 | dataSource.onChange = { _ in 77 | expect.fulfill() 78 | } 79 | dataSource.startListening() 80 | waitForExpectations(timeout: 0.5) { XCTAssertNil($0) } 81 | } 82 | 83 | func testObjectDidChangeBlockFiresForSavesInvolvingObject() { 84 | let expect = expectation(description: "did change block") 85 | dataSource.onChange = { _ in 86 | expect.fulfill() 87 | } 88 | dataSource.startListening() 89 | entity.someProperty = "some new value" 90 | dataAccess.mainContext.persist() 91 | waitForExpectations(timeout: 0.5) { XCTAssertNil($0) } 92 | } 93 | 94 | func testObjectDidChangeBlockFiresForChangeProcessesInvolvingObject() { 95 | let expect = expectation(description: "did change block") 96 | dataSource.onChange = { _ in 97 | expect.fulfill() 98 | } 99 | dataSource.startListening() 100 | entity.someProperty = "some new value" 101 | dataAccess.mainContext.processPendingChanges() 102 | waitForExpectations(timeout: 0.5) { XCTAssertNil($0) } 103 | } 104 | 105 | func testObjectDidChangeBlockFiresForOnlyChangesAndSavesInvolvingObject() { 106 | let otherEntity = dataAccess.mainContext.create(MockEntity.self, attributes: ["someProperty": "other value"]) 107 | dataAccess.mainContext.persist() 108 | 109 | let expect = expectation(description: "did change block") 110 | dataSource.onChange = { _ in 111 | expect.fulfill() 112 | } 113 | dataSource.startListening() 114 | entity.someProperty = "some new value" 115 | otherEntity.someProperty = "other other value" 116 | dataAccess.mainContext.persist() 117 | waitForExpectations(timeout: 0.5) { XCTAssertNil($0) } 118 | } 119 | 120 | func testObjectIsNilIfDestroyed() { 121 | dataAccess.mainContext.destroy(object: entity) 122 | dataAccess.mainContext.persist() 123 | 124 | dataSource.startListening() 125 | XCTAssertNil(dataSource.object) 126 | } 127 | 128 | func testDidChangeIsCalledWithNilIfDestroyed() { 129 | let expect = expectation(description: "did change block") 130 | expect.expectedFulfillmentCount = 2 131 | dataSource.onChange = { _ in 132 | expect.fulfill() 133 | } 134 | dataSource.startListening() 135 | dataAccess.mainContext.destroy(object: entity) 136 | waitForExpectations(timeout: 0.5) { XCTAssertNil($0) } 137 | XCTAssertNil(dataSource.object) 138 | } 139 | 140 | func testObjectDidChangeBlockFiresWhenObjectStartsToExist() { 141 | dataAccess.mainContext.destroy(object: entity) 142 | dataAccess.mainContext.persist() 143 | 144 | let expect = expectation(description: "did change block") 145 | expect.expectedFulfillmentCount = 2 146 | dataSource.onChange = { _ in 147 | expect.fulfill() 148 | } 149 | dataSource.startListening() 150 | XCTAssertNil(dataSource.object) 151 | 152 | let newEntity = dataAccess.mainContext.create(MockEntity.self, attributes: attributes) 153 | waitForExpectations(timeout: 0.5) { XCTAssertNil($0) } 154 | 155 | XCTAssertEqual(dataSource.object, newEntity) 156 | } 157 | 158 | func testObjectDidChangeBlockFiresWhenObjectMatchingQueryIsReplaced() { 159 | let newEntity = dataAccess.mainContext.create(MockEntity.self, attributes: attributes) 160 | dataAccess.mainContext.persist() 161 | 162 | let expect = expectation(description: "did change block") 163 | expect.expectedFulfillmentCount = 2 164 | dataSource.onChange = { _ in 165 | expect.fulfill() 166 | } 167 | dataSource.startListening() 168 | 169 | entity.someProperty = "some new value" 170 | newEntity.someProperty = attributes["someProperty"] 171 | dataAccess.mainContext.persist() 172 | 173 | waitForExpectations(timeout: 0.5) { XCTAssertNil($0) } 174 | XCTAssertEqual(dataSource.object, newEntity) 175 | } 176 | 177 | func testObjectDidChangeBlockFiresTwiceWhenOldObjectIsDeletedAndNewOneCreated() { 178 | let expect = expectation(description: "did change block") 179 | expect.expectedFulfillmentCount = 3 180 | dataSource.onChange = { _ in 181 | expect.fulfill() 182 | } 183 | dataSource.startListening() 184 | 185 | let newEntity = dataAccess.mainContext.create(MockEntity.self, attributes: attributes) 186 | newEntity.someProperty = attributes["someProperty"] 187 | dataAccess.mainContext.destroy(object: entity) 188 | dataAccess.mainContext.persist() 189 | 190 | waitForExpectations(timeout: 0.5) { XCTAssertNil($0) } 191 | XCTAssertEqual(dataSource.object, newEntity) 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /Tests/Info-iOS.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | BNDL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | 2 23 | 24 | 25 | -------------------------------------------------------------------------------- /Tests/Info-macOS.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | BNDL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 2 21 | 22 | 23 | -------------------------------------------------------------------------------- /Tests/Info-tvOS.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | BNDL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 2 21 | 22 | 23 | -------------------------------------------------------------------------------- /docs/badge.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | documentation 17 | 18 | 19 | documentation 20 | 21 | 22 | 91% 23 | 24 | 25 | 91% 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /docs/css/highlight.css: -------------------------------------------------------------------------------- 1 | /*! Jazzy - https://github.com/realm/jazzy 2 | * Copyright Realm Inc. 3 | * SPDX-License-Identifier: MIT 4 | */ 5 | /* Credit to https://gist.github.com/wataru420/2048287 */ 6 | .highlight .c { 7 | color: #999988; 8 | font-style: italic; } 9 | 10 | .highlight .err { 11 | color: #a61717; 12 | background-color: #e3d2d2; } 13 | 14 | .highlight .k { 15 | color: #000000; 16 | font-weight: bold; } 17 | 18 | .highlight .o { 19 | color: #000000; 20 | font-weight: bold; } 21 | 22 | .highlight .cm { 23 | color: #999988; 24 | font-style: italic; } 25 | 26 | .highlight .cp { 27 | color: #999999; 28 | font-weight: bold; } 29 | 30 | .highlight .c1 { 31 | color: #999988; 32 | font-style: italic; } 33 | 34 | .highlight .cs { 35 | color: #999999; 36 | font-weight: bold; 37 | font-style: italic; } 38 | 39 | .highlight .gd { 40 | color: #000000; 41 | background-color: #ffdddd; } 42 | 43 | .highlight .gd .x { 44 | color: #000000; 45 | background-color: #ffaaaa; } 46 | 47 | .highlight .ge { 48 | color: #000000; 49 | font-style: italic; } 50 | 51 | .highlight .gr { 52 | color: #aa0000; } 53 | 54 | .highlight .gh { 55 | color: #999999; } 56 | 57 | .highlight .gi { 58 | color: #000000; 59 | background-color: #ddffdd; } 60 | 61 | .highlight .gi .x { 62 | color: #000000; 63 | background-color: #aaffaa; } 64 | 65 | .highlight .go { 66 | color: #888888; } 67 | 68 | .highlight .gp { 69 | color: #555555; } 70 | 71 | .highlight .gs { 72 | font-weight: bold; } 73 | 74 | .highlight .gu { 75 | color: #aaaaaa; } 76 | 77 | .highlight .gt { 78 | color: #aa0000; } 79 | 80 | .highlight .kc { 81 | color: #000000; 82 | font-weight: bold; } 83 | 84 | .highlight .kd { 85 | color: #000000; 86 | font-weight: bold; } 87 | 88 | .highlight .kp { 89 | color: #000000; 90 | font-weight: bold; } 91 | 92 | .highlight .kr { 93 | color: #000000; 94 | font-weight: bold; } 95 | 96 | .highlight .kt { 97 | color: #445588; } 98 | 99 | .highlight .m { 100 | color: #009999; } 101 | 102 | .highlight .s { 103 | color: #d14; } 104 | 105 | .highlight .na { 106 | color: #008080; } 107 | 108 | .highlight .nb { 109 | color: #0086B3; } 110 | 111 | .highlight .nc { 112 | color: #445588; 113 | font-weight: bold; } 114 | 115 | .highlight .no { 116 | color: #008080; } 117 | 118 | .highlight .ni { 119 | color: #800080; } 120 | 121 | .highlight .ne { 122 | color: #990000; 123 | font-weight: bold; } 124 | 125 | .highlight .nf { 126 | color: #990000; } 127 | 128 | .highlight .nn { 129 | color: #555555; } 130 | 131 | .highlight .nt { 132 | color: #000080; } 133 | 134 | .highlight .nv { 135 | color: #008080; } 136 | 137 | .highlight .ow { 138 | color: #000000; 139 | font-weight: bold; } 140 | 141 | .highlight .w { 142 | color: #bbbbbb; } 143 | 144 | .highlight .mf { 145 | color: #009999; } 146 | 147 | .highlight .mh { 148 | color: #009999; } 149 | 150 | .highlight .mi { 151 | color: #009999; } 152 | 153 | .highlight .mo { 154 | color: #009999; } 155 | 156 | .highlight .sb { 157 | color: #d14; } 158 | 159 | .highlight .sc { 160 | color: #d14; } 161 | 162 | .highlight .sd { 163 | color: #d14; } 164 | 165 | .highlight .s2 { 166 | color: #d14; } 167 | 168 | .highlight .se { 169 | color: #d14; } 170 | 171 | .highlight .sh { 172 | color: #d14; } 173 | 174 | .highlight .si { 175 | color: #d14; } 176 | 177 | .highlight .sx { 178 | color: #d14; } 179 | 180 | .highlight .sr { 181 | color: #009926; } 182 | 183 | .highlight .s1 { 184 | color: #d14; } 185 | 186 | .highlight .ss { 187 | color: #990073; } 188 | 189 | .highlight .bp { 190 | color: #999999; } 191 | 192 | .highlight .vc { 193 | color: #008080; } 194 | 195 | .highlight .vg { 196 | color: #008080; } 197 | 198 | .highlight .vi { 199 | color: #008080; } 200 | 201 | .highlight .il { 202 | color: #009999; } 203 | -------------------------------------------------------------------------------- /docs/docsets/Flapjack.docset/Contents/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleIdentifier 6 | com.jazzy.flapjack 7 | CFBundleName 8 | Flapjack 9 | DocSetPlatformFamily 10 | flapjack 11 | isDashDocset 12 | 13 | dashIndexFilePath 14 | index.html 15 | isJavaScriptEnabled 16 | 17 | DashDocSetFamily 18 | dashtoc 19 | 20 | 21 | -------------------------------------------------------------------------------- /docs/docsets/Flapjack.docset/Contents/Resources/Documents/badge.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | documentation 17 | 18 | 19 | documentation 20 | 21 | 22 | 93% 23 | 24 | 25 | 93% 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /docs/docsets/Flapjack.docset/Contents/Resources/Documents/css/highlight.css: -------------------------------------------------------------------------------- 1 | /*! Jazzy - https://github.com/realm/jazzy 2 | * Copyright Realm Inc. 3 | * SPDX-License-Identifier: MIT 4 | */ 5 | /* Credit to https://gist.github.com/wataru420/2048287 */ 6 | .highlight .c { 7 | color: #999988; 8 | font-style: italic; } 9 | 10 | .highlight .err { 11 | color: #a61717; 12 | background-color: #e3d2d2; } 13 | 14 | .highlight .k { 15 | color: #000000; 16 | font-weight: bold; } 17 | 18 | .highlight .o { 19 | color: #000000; 20 | font-weight: bold; } 21 | 22 | .highlight .cm { 23 | color: #999988; 24 | font-style: italic; } 25 | 26 | .highlight .cp { 27 | color: #999999; 28 | font-weight: bold; } 29 | 30 | .highlight .c1 { 31 | color: #999988; 32 | font-style: italic; } 33 | 34 | .highlight .cs { 35 | color: #999999; 36 | font-weight: bold; 37 | font-style: italic; } 38 | 39 | .highlight .gd { 40 | color: #000000; 41 | background-color: #ffdddd; } 42 | 43 | .highlight .gd .x { 44 | color: #000000; 45 | background-color: #ffaaaa; } 46 | 47 | .highlight .ge { 48 | color: #000000; 49 | font-style: italic; } 50 | 51 | .highlight .gr { 52 | color: #aa0000; } 53 | 54 | .highlight .gh { 55 | color: #999999; } 56 | 57 | .highlight .gi { 58 | color: #000000; 59 | background-color: #ddffdd; } 60 | 61 | .highlight .gi .x { 62 | color: #000000; 63 | background-color: #aaffaa; } 64 | 65 | .highlight .go { 66 | color: #888888; } 67 | 68 | .highlight .gp { 69 | color: #555555; } 70 | 71 | .highlight .gs { 72 | font-weight: bold; } 73 | 74 | .highlight .gu { 75 | color: #aaaaaa; } 76 | 77 | .highlight .gt { 78 | color: #aa0000; } 79 | 80 | .highlight .kc { 81 | color: #000000; 82 | font-weight: bold; } 83 | 84 | .highlight .kd { 85 | color: #000000; 86 | font-weight: bold; } 87 | 88 | .highlight .kp { 89 | color: #000000; 90 | font-weight: bold; } 91 | 92 | .highlight .kr { 93 | color: #000000; 94 | font-weight: bold; } 95 | 96 | .highlight .kt { 97 | color: #445588; } 98 | 99 | .highlight .m { 100 | color: #009999; } 101 | 102 | .highlight .s { 103 | color: #d14; } 104 | 105 | .highlight .na { 106 | color: #008080; } 107 | 108 | .highlight .nb { 109 | color: #0086B3; } 110 | 111 | .highlight .nc { 112 | color: #445588; 113 | font-weight: bold; } 114 | 115 | .highlight .no { 116 | color: #008080; } 117 | 118 | .highlight .ni { 119 | color: #800080; } 120 | 121 | .highlight .ne { 122 | color: #990000; 123 | font-weight: bold; } 124 | 125 | .highlight .nf { 126 | color: #990000; } 127 | 128 | .highlight .nn { 129 | color: #555555; } 130 | 131 | .highlight .nt { 132 | color: #000080; } 133 | 134 | .highlight .nv { 135 | color: #008080; } 136 | 137 | .highlight .ow { 138 | color: #000000; 139 | font-weight: bold; } 140 | 141 | .highlight .w { 142 | color: #bbbbbb; } 143 | 144 | .highlight .mf { 145 | color: #009999; } 146 | 147 | .highlight .mh { 148 | color: #009999; } 149 | 150 | .highlight .mi { 151 | color: #009999; } 152 | 153 | .highlight .mo { 154 | color: #009999; } 155 | 156 | .highlight .sb { 157 | color: #d14; } 158 | 159 | .highlight .sc { 160 | color: #d14; } 161 | 162 | .highlight .sd { 163 | color: #d14; } 164 | 165 | .highlight .s2 { 166 | color: #d14; } 167 | 168 | .highlight .se { 169 | color: #d14; } 170 | 171 | .highlight .sh { 172 | color: #d14; } 173 | 174 | .highlight .si { 175 | color: #d14; } 176 | 177 | .highlight .sx { 178 | color: #d14; } 179 | 180 | .highlight .sr { 181 | color: #009926; } 182 | 183 | .highlight .s1 { 184 | color: #d14; } 185 | 186 | .highlight .ss { 187 | color: #990073; } 188 | 189 | .highlight .bp { 190 | color: #999999; } 191 | 192 | .highlight .vc { 193 | color: #008080; } 194 | 195 | .highlight .vg { 196 | color: #008080; } 197 | 198 | .highlight .vi { 199 | color: #008080; } 200 | 201 | .highlight .il { 202 | color: #009999; } 203 | -------------------------------------------------------------------------------- /docs/docsets/Flapjack.docset/Contents/Resources/Documents/img/carat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oreillymedia/flapjack/9a4f2c86ceae93f7247151236bcfc05f8fb125f3/docs/docsets/Flapjack.docset/Contents/Resources/Documents/img/carat.png -------------------------------------------------------------------------------- /docs/docsets/Flapjack.docset/Contents/Resources/Documents/img/dash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oreillymedia/flapjack/9a4f2c86ceae93f7247151236bcfc05f8fb125f3/docs/docsets/Flapjack.docset/Contents/Resources/Documents/img/dash.png -------------------------------------------------------------------------------- /docs/docsets/Flapjack.docset/Contents/Resources/Documents/img/gh.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oreillymedia/flapjack/9a4f2c86ceae93f7247151236bcfc05f8fb125f3/docs/docsets/Flapjack.docset/Contents/Resources/Documents/img/gh.png -------------------------------------------------------------------------------- /docs/docsets/Flapjack.docset/Contents/Resources/Documents/img/spinner.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oreillymedia/flapjack/9a4f2c86ceae93f7247151236bcfc05f8fb125f3/docs/docsets/Flapjack.docset/Contents/Resources/Documents/img/spinner.gif -------------------------------------------------------------------------------- /docs/docsets/Flapjack.docset/Contents/Resources/Documents/js/jazzy.js: -------------------------------------------------------------------------------- 1 | // Jazzy - https://github.com/realm/jazzy 2 | // Copyright Realm Inc. 3 | // SPDX-License-Identifier: MIT 4 | 5 | window.jazzy = {'docset': false} 6 | if (typeof window.dash != 'undefined') { 7 | document.documentElement.className += ' dash' 8 | window.jazzy.docset = true 9 | } 10 | if (navigator.userAgent.match(/xcode/i)) { 11 | document.documentElement.className += ' xcode' 12 | window.jazzy.docset = true 13 | } 14 | 15 | function toggleItem($link, $content) { 16 | var animationDuration = 300; 17 | $link.toggleClass('token-open'); 18 | $content.slideToggle(animationDuration); 19 | } 20 | 21 | function itemLinkToContent($link) { 22 | return $link.parent().parent().next(); 23 | } 24 | 25 | // On doc load + hash-change, open any targetted item 26 | function openCurrentItemIfClosed() { 27 | if (window.jazzy.docset) { 28 | return; 29 | } 30 | var $link = $(`a[name="${location.hash.substring(1)}"]`).nextAll('.token'); 31 | $content = itemLinkToContent($link); 32 | if ($content.is(':hidden')) { 33 | toggleItem($link, $content); 34 | } 35 | } 36 | 37 | $(openCurrentItemIfClosed); 38 | $(window).on('hashchange', openCurrentItemIfClosed); 39 | 40 | // On item link ('token') click, toggle its discussion 41 | $('.token').on('click', function(event) { 42 | if (window.jazzy.docset) { 43 | return; 44 | } 45 | var $link = $(this); 46 | toggleItem($link, itemLinkToContent($link)); 47 | 48 | // Keeps the document from jumping to the hash. 49 | var href = $link.attr('href'); 50 | if (history.pushState) { 51 | history.pushState({}, '', href); 52 | } else { 53 | location.hash = href; 54 | } 55 | event.preventDefault(); 56 | }); 57 | 58 | // Clicks on links to the current, closed, item need to open the item 59 | $("a:not('.token')").on('click', function() { 60 | if (location == this.href) { 61 | openCurrentItemIfClosed(); 62 | } 63 | }); 64 | 65 | // KaTeX rendering 66 | if ("katex" in window) { 67 | $($('.math').each( (_, element) => { 68 | katex.render(element.textContent, element, { 69 | displayMode: $(element).hasClass('m-block'), 70 | throwOnError: false, 71 | trust: true 72 | }); 73 | })) 74 | } 75 | -------------------------------------------------------------------------------- /docs/docsets/Flapjack.docset/Contents/Resources/Documents/js/jazzy.search.js: -------------------------------------------------------------------------------- 1 | // Jazzy - https://github.com/realm/jazzy 2 | // Copyright Realm Inc. 3 | // SPDX-License-Identifier: MIT 4 | 5 | $(function(){ 6 | var $typeahead = $('[data-typeahead]'); 7 | var $form = $typeahead.parents('form'); 8 | var searchURL = $form.attr('action'); 9 | 10 | function displayTemplate(result) { 11 | return result.name; 12 | } 13 | 14 | function suggestionTemplate(result) { 15 | var t = '
'; 16 | t += '' + result.name + ''; 17 | if (result.parent_name) { 18 | t += '' + result.parent_name + ''; 19 | } 20 | t += '
'; 21 | return t; 22 | } 23 | 24 | $typeahead.one('focus', function() { 25 | $form.addClass('loading'); 26 | 27 | $.getJSON(searchURL).then(function(searchData) { 28 | const searchIndex = lunr(function() { 29 | this.ref('url'); 30 | this.field('name'); 31 | this.field('abstract'); 32 | for (const [url, doc] of Object.entries(searchData)) { 33 | this.add({url: url, name: doc.name, abstract: doc.abstract}); 34 | } 35 | }); 36 | 37 | $typeahead.typeahead( 38 | { 39 | highlight: true, 40 | minLength: 3, 41 | autoselect: true 42 | }, 43 | { 44 | limit: 10, 45 | display: displayTemplate, 46 | templates: { suggestion: suggestionTemplate }, 47 | source: function(query, sync) { 48 | const lcSearch = query.toLowerCase(); 49 | const results = searchIndex.query(function(q) { 50 | q.term(lcSearch, { boost: 100 }); 51 | q.term(lcSearch, { 52 | boost: 10, 53 | wildcard: lunr.Query.wildcard.TRAILING 54 | }); 55 | }).map(function(result) { 56 | var doc = searchData[result.ref]; 57 | doc.url = result.ref; 58 | return doc; 59 | }); 60 | sync(results); 61 | } 62 | } 63 | ); 64 | $form.removeClass('loading'); 65 | $typeahead.trigger('focus'); 66 | }); 67 | }); 68 | 69 | var baseURL = searchURL.slice(0, -"search.json".length); 70 | 71 | $typeahead.on('typeahead:select', function(e, result) { 72 | window.location = baseURL + result.url; 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /docs/docsets/Flapjack.docset/Contents/Resources/docSet.dsidx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oreillymedia/flapjack/9a4f2c86ceae93f7247151236bcfc05f8fb125f3/docs/docsets/Flapjack.docset/Contents/Resources/docSet.dsidx -------------------------------------------------------------------------------- /docs/docsets/Flapjack.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oreillymedia/flapjack/9a4f2c86ceae93f7247151236bcfc05f8fb125f3/docs/docsets/Flapjack.tgz -------------------------------------------------------------------------------- /docs/img/carat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oreillymedia/flapjack/9a4f2c86ceae93f7247151236bcfc05f8fb125f3/docs/img/carat.png -------------------------------------------------------------------------------- /docs/img/dash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oreillymedia/flapjack/9a4f2c86ceae93f7247151236bcfc05f8fb125f3/docs/img/dash.png -------------------------------------------------------------------------------- /docs/img/gh.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oreillymedia/flapjack/9a4f2c86ceae93f7247151236bcfc05f8fb125f3/docs/img/gh.png -------------------------------------------------------------------------------- /docs/img/spinner.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oreillymedia/flapjack/9a4f2c86ceae93f7247151236bcfc05f8fb125f3/docs/img/spinner.gif -------------------------------------------------------------------------------- /docs/js/jazzy.js: -------------------------------------------------------------------------------- 1 | // Jazzy - https://github.com/realm/jazzy 2 | // Copyright Realm Inc. 3 | // SPDX-License-Identifier: MIT 4 | 5 | window.jazzy = {'docset': false} 6 | if (typeof window.dash != 'undefined') { 7 | document.documentElement.className += ' dash' 8 | window.jazzy.docset = true 9 | } 10 | if (navigator.userAgent.match(/xcode/i)) { 11 | document.documentElement.className += ' xcode' 12 | window.jazzy.docset = true 13 | } 14 | 15 | function toggleItem($link, $content) { 16 | var animationDuration = 300; 17 | $link.toggleClass('token-open'); 18 | $content.slideToggle(animationDuration); 19 | } 20 | 21 | function itemLinkToContent($link) { 22 | return $link.parent().parent().next(); 23 | } 24 | 25 | // On doc load + hash-change, open any targetted item 26 | function openCurrentItemIfClosed() { 27 | if (window.jazzy.docset) { 28 | return; 29 | } 30 | var $link = $(`a[name="${location.hash.substring(1)}"]`).nextAll('.token'); 31 | $content = itemLinkToContent($link); 32 | if ($content.is(':hidden')) { 33 | toggleItem($link, $content); 34 | } 35 | } 36 | 37 | $(openCurrentItemIfClosed); 38 | $(window).on('hashchange', openCurrentItemIfClosed); 39 | 40 | // On item link ('token') click, toggle its discussion 41 | $('.token').on('click', function(event) { 42 | if (window.jazzy.docset) { 43 | return; 44 | } 45 | var $link = $(this); 46 | toggleItem($link, itemLinkToContent($link)); 47 | 48 | // Keeps the document from jumping to the hash. 49 | var href = $link.attr('href'); 50 | if (history.pushState) { 51 | history.pushState({}, '', href); 52 | } else { 53 | location.hash = href; 54 | } 55 | event.preventDefault(); 56 | }); 57 | 58 | // Clicks on links to the current, closed, item need to open the item 59 | $("a:not('.token')").on('click', function() { 60 | if (location == this.href) { 61 | openCurrentItemIfClosed(); 62 | } 63 | }); 64 | 65 | // KaTeX rendering 66 | if ("katex" in window) { 67 | $($('.math').each( (_, element) => { 68 | katex.render(element.textContent, element, { 69 | displayMode: $(element).hasClass('m-block'), 70 | throwOnError: false, 71 | trust: true 72 | }); 73 | })) 74 | } 75 | -------------------------------------------------------------------------------- /docs/js/jazzy.search.js: -------------------------------------------------------------------------------- 1 | // Jazzy - https://github.com/realm/jazzy 2 | // Copyright Realm Inc. 3 | // SPDX-License-Identifier: MIT 4 | 5 | $(function(){ 6 | var $typeahead = $('[data-typeahead]'); 7 | var $form = $typeahead.parents('form'); 8 | var searchURL = $form.attr('action'); 9 | 10 | function displayTemplate(result) { 11 | return result.name; 12 | } 13 | 14 | function suggestionTemplate(result) { 15 | var t = '
'; 16 | t += '' + result.name + ''; 17 | if (result.parent_name) { 18 | t += '' + result.parent_name + ''; 19 | } 20 | t += '
'; 21 | return t; 22 | } 23 | 24 | $typeahead.one('focus', function() { 25 | $form.addClass('loading'); 26 | 27 | $.getJSON(searchURL).then(function(searchData) { 28 | const searchIndex = lunr(function() { 29 | this.ref('url'); 30 | this.field('name'); 31 | this.field('abstract'); 32 | for (const [url, doc] of Object.entries(searchData)) { 33 | this.add({url: url, name: doc.name, abstract: doc.abstract}); 34 | } 35 | }); 36 | 37 | $typeahead.typeahead( 38 | { 39 | highlight: true, 40 | minLength: 3, 41 | autoselect: true 42 | }, 43 | { 44 | limit: 10, 45 | display: displayTemplate, 46 | templates: { suggestion: suggestionTemplate }, 47 | source: function(query, sync) { 48 | const lcSearch = query.toLowerCase(); 49 | const results = searchIndex.query(function(q) { 50 | q.term(lcSearch, { boost: 100 }); 51 | q.term(lcSearch, { 52 | boost: 10, 53 | wildcard: lunr.Query.wildcard.TRAILING 54 | }); 55 | }).map(function(result) { 56 | var doc = searchData[result.ref]; 57 | doc.url = result.ref; 58 | return doc; 59 | }); 60 | sync(results); 61 | } 62 | } 63 | ); 64 | $form.removeClass('loading'); 65 | $typeahead.trigger('focus'); 66 | }); 67 | }); 68 | 69 | var baseURL = searchURL.slice(0, -"search.json".length); 70 | 71 | $typeahead.on('typeahead:select', function(e, result) { 72 | window.location = baseURL + result.url; 73 | }); 74 | }); 75 | --------------------------------------------------------------------------------