` will be placed.
13 | public struct PreseedConfiguration {
14 | /// e.g. "SeedDB" (no “.sqlite”)
15 | public let resourceName: String
16 |
17 | /// e.g. "sqlite"
18 | public let resourceExtension: String
19 |
20 | /// Optional bundle subfolder
21 | public let subdirectory: String?
22 |
23 | /// Bundle containing the resource (default: .main)
24 | public let bundle: Bundle
25 |
26 | /// Directory on disk where the main `.sqlite` lives. **Required.**
27 | public let sqliteContainerPath: URL
28 |
29 | /// The version that this bundled seed represents.
30 | public let dbVersion: Int
31 |
32 | /// - Parameters:
33 | /// - resourceName: Base name of the bundle file.
34 | /// - resourceExtension: Extension (e.g. "sqlite").
35 | /// - subdirectory: Bundle subfolder (nil = top).
36 | /// - bundle: The bundle containing it.
37 | /// - overrideStoreDirectory: On-disk folder to wipe & seed.
38 | /// - dbVersion: The migration version for this seed.
39 | public init(resourceName: String,
40 | resourceExtension: String,
41 | subdirectory: String? = nil,
42 | bundle: Bundle = .main,
43 | sqliteContainerPath: URL,
44 | dbVersion: Int) {
45 | self.resourceName = resourceName
46 | self.resourceExtension = resourceExtension
47 | self.subdirectory = subdirectory
48 | self.bundle = bundle
49 | self.sqliteContainerPath = sqliteContainerPath
50 | self.dbVersion = dbVersion
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/Sources/ContentfulPersistence/Relationships/RelationshipsManager.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ContentfulPersistenceSwift
3 | //
4 |
5 | import Foundation
6 |
7 | /// Manages relationships of the entries using internal cache. It is used to recreate relationship when
8 | /// unpublished entry is published again.
9 | final class RelationshipsManager {
10 |
11 | private let cache: RelationshipCache
12 |
13 | var relationships: RelationshipData {
14 | cache.relationships
15 | }
16 |
17 | init(cacheFileName: String) {
18 | self.cache = RelationshipCache(cacheFileName: cacheFileName)
19 | }
20 |
21 | /// Creates one-to-one relationship if does not exist yet.
22 | func cacheToOneRelationship(
23 | parent: EntryPersistable,
24 | childId: RelationshipChildId,
25 | fieldName: String
26 | ) {
27 |
28 | let parentType = type(of: parent).contentTypeId
29 |
30 | let relationship = Relationship(
31 | parentType: parentType,
32 | parentId: parent.id,
33 | fieldName: fieldName,
34 | childId: childId
35 | )
36 |
37 | cache.add(relationship: relationship)
38 | }
39 |
40 | func cacheToManyRelationship(
41 | parent: EntryPersistable,
42 | childIds: [RelationshipChildId],
43 | fieldName: String
44 | ) {
45 | let parentType = type(of: parent).contentTypeId
46 |
47 | let relationship = Relationship(
48 | parentType: parentType,
49 | parentId: parent.id,
50 | fieldName: fieldName,
51 | childIds: childIds
52 | )
53 |
54 | cache.add(relationship: relationship)
55 | }
56 |
57 | func delete(parentId: String) {
58 | cache.delete(parentId: parentId)
59 | }
60 |
61 | func delete(parentId: String, fieldName: String, localeCode: String?) {
62 | cache.delete(parentId: parentId, fieldName: fieldName, localeCode: localeCode)
63 | }
64 |
65 | func save() {
66 | cache.save()
67 | }
68 |
69 | func wipe() {
70 | cache.wipe()
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/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/docsets/ContentfulPersistence.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/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/ContentfulPersistence.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 |
--------------------------------------------------------------------------------
/Tests/ContentfulPersistenceTests/ComplexTestStubs/simple-update-initial-sync-page2.json:
--------------------------------------------------------------------------------
1 | {
2 | "sys": {
3 | "type": "Array"
4 | },
5 | "items": [
6 | {
7 | "sys": {
8 | "space": {
9 | "sys": {
10 | "type": "Link",
11 | "linkType": "Space",
12 | "id": "smf0sqiu0c5s"
13 | }
14 | },
15 | "id": "7vervB1KtUYWC22OCcmsKc",
16 | "type": "Entry",
17 | "createdAt": "2017-06-20T12:51:06.955Z",
18 | "updatedAt": "2017-06-20T12:51:06.955Z",
19 | "revision": 1,
20 | "contentType": {
21 | "sys": {
22 | "type": "Link",
23 | "linkType": "ContentType",
24 | "id": "singleRecord"
25 | }
26 | }
27 | },
28 | "fields": {
29 | "textBody": {
30 | "en-US": "3"
31 | }
32 | }
33 | },
34 | {
35 | "sys": {
36 | "space": {
37 | "sys": {
38 | "type": "Link",
39 | "linkType": "Space",
40 | "id": "smf0sqiu0c5s"
41 | }
42 | },
43 | "id": "NNPT58qeyYKauym8S0MUk",
44 | "type": "Entry",
45 | "createdAt": "2017-06-20T12:51:00.210Z",
46 | "updatedAt": "2017-06-20T12:51:00.210Z",
47 | "revision": 1,
48 | "contentType": {
49 | "sys": {
50 | "type": "Link",
51 | "linkType": "ContentType",
52 | "id": "singleRecord"
53 | }
54 | }
55 | },
56 | "fields": {
57 | "textBody": {
58 | "en-US": "2"
59 | }
60 | }
61 | },
62 | {
63 | "sys": {
64 | "space": {
65 | "sys": {
66 | "type": "Link",
67 | "linkType": "Space",
68 | "id": "smf0sqiu0c5s"
69 | }
70 | },
71 | "id": "3PbLvOJldSc6MqKEaIE6Ce",
72 | "type": "Entry",
73 | "createdAt": "2017-06-20T12:50:15.724Z",
74 | "updatedAt": "2017-06-20T12:50:15.724Z",
75 | "revision": 1,
76 | "contentType": {
77 | "sys": {
78 | "type": "Link",
79 | "linkType": "ContentType",
80 | "id": "singleRecord"
81 | }
82 | }
83 | },
84 | "fields": {
85 | "textBody": {
86 | "en-US": "1"
87 | }
88 | }
89 | }
90 | ],
91 | "nextSyncUrl": "https://cdn.contentful.com/spaces/smf0sqiu0c5s/environments/master/sync?sync_token=w5ZGw6JFwqZmVcKsE8Kow4grw45QdyYxPMK6EcOWw7swwqJhAcOTwqwMwoLCkUFqwpkGMHnDicOeCls-wpHCtSHDiMOJwp9Zw6URwqHCqcK4fFNpDV12wpzDpcKAw71Uw53Dk8KdwrYDw7DCqQU1w4bCg8KkwojDhsOb"
92 | }
93 |
--------------------------------------------------------------------------------
/Sources/ContentfulPersistence/Seeding/BundledDatabase/FilePreseedManager.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FilePreseedManager.swift
3 | // ContentfulPersistence
4 | //
5 | // Created by Marius Kurgonas on 30/05/2025.
6 | // Copyright © 2025 Contentful GmbH. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | /// Default strategy that:
12 | /// 1) calls `onStorePreseedingWillBegin(at:)`
13 | /// 2) wipes the entire seed folder
14 | /// 3) copies `.`
15 | /// 4) calls `onStorePreseedingCompleted(at:)`
16 | /// 5) writes back `dbVersion`
17 | public class FilePreseedManager: PreseedStrategy {
18 | private let fileManager: FileManaging
19 |
20 | /// - Parameter fileManager: Test‐injectable; default = `FileManager.default`
21 | public init(fileManager: FileManaging = FileManager.default) {
22 | self.fileManager = fileManager
23 | }
24 |
25 | public func apply(to store: PersistenceStore,
26 | with config: PreseedConfiguration,
27 | spaceType: SyncSpacePersistable.Type) throws
28 | {
29 | let filename = "\(config.resourceName).\(config.resourceExtension)"
30 | let dbURL = config.sqliteContainerPath.appendingPathComponent(filename)
31 |
32 | guard let seedURL = config.bundle.url(
33 | forResource: config.resourceName,
34 | withExtension: config.resourceExtension,
35 | subdirectory: config.subdirectory)
36 | else {
37 | throw NSError(
38 | domain: "ContentfulPersistence",
39 | code: 1,
40 | userInfo: [NSLocalizedDescriptionKey:
41 | "Seed file not found: \(config.resourceName).\(config.resourceExtension)"])
42 | }
43 |
44 | // Read existing version (0 if none)
45 | let lastVersion: Int = {
46 | do {
47 | let spaces: [SyncSpacePersistable] = try store.fetchAll(type: spaceType, predicate: NSPredicate(value: true))
48 | return spaces.count > 0 ? spaces[0].dbVersion?.intValue ?? 0 : 0
49 | } catch {
50 | return 0
51 | }
52 | }()
53 |
54 | // Only seed if fresh or version bumped
55 | let missing = !fileManager.fileExists(atPath: dbURL.path)
56 | guard missing || config.dbVersion > lastVersion else { return }
57 |
58 | // Prepare the store
59 | try store.onStorePreseedingWillBegin(at: dbURL)
60 |
61 | // Remove existing
62 | try? fileManager.removeItem(at: config.sqliteContainerPath)
63 |
64 | try fileManager.createDirectory(
65 | at: config.sqliteContainerPath,
66 | withIntermediateDirectories: true,
67 | attributes: nil
68 | )
69 |
70 | try fileManager.copyItem(at: seedURL, to: dbURL)
71 |
72 | // Re-open the store
73 | try store.onStorePreseedingCompleted(at: dbURL)
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/Sources/ContentfulPersistence/Relationships/RelationshipCache.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ContentfulPersistence
3 | //
4 |
5 | import Foundation
6 |
7 | /**
8 | Stores all relationships in the database. It acts like a backup for relationships in case an entry has been
9 | unpublished and is published in the future.
10 |
11 | With this class the library can bring back a relationship on the Core Data Model level. Otherwise, in the
12 | following scenario, the model would not reflect a correct state of the model.
13 |
14 | Scenario:
15 | 1) Fetch all data.
16 | 2) Unpublish entry that is referenced by other entry.
17 | 3) See the unpublished entry reference is represented by `nil` in Core Data. Relationship is `nil`.
18 | 4) Publish entry again.
19 | 5) See the relationship is broken. The reference is still `nil`. instead of the published entry.
20 | */
21 | final class RelationshipCache {
22 |
23 | private let cacheFileName: String
24 |
25 | init(cacheFileName: String) {
26 | self.cacheFileName = cacheFileName
27 | }
28 |
29 | private(set) lazy var relationships: RelationshipData = loadFromCache()
30 |
31 | func add(relationship: Relationship) {
32 | relationships.append(relationship)
33 | }
34 |
35 | func delete(parentId: String) {
36 | relationships.delete(parentId: parentId)
37 | }
38 |
39 | func delete(parentId: String, fieldName: String, localeCode: String?) {
40 | relationships.delete(parentId: parentId, fieldName: fieldName, localeCode: localeCode)
41 | }
42 |
43 | func save() {
44 | do {
45 | guard let localUrl = cacheUrl() else { return }
46 | let data = try JSONEncoder().encode(relationships)
47 | try data.write(to: localUrl)
48 | } catch let error {
49 | print("Couldn't persist relationships: \(error)")
50 | }
51 | }
52 |
53 | func wipe() {
54 | do {
55 | guard let localUrl = cacheUrl() else { return }
56 | try FileManager.default.removeItem(at: localUrl)
57 | } catch let error {
58 | print("Couldn't delete relationships: \(error)")
59 | }
60 | }
61 |
62 | private func cacheUrl() -> URL? {
63 | guard let url = try? FileManager.default.url(
64 | for: .documentDirectory,
65 | in: .userDomainMask,
66 | appropriateFor: nil,
67 | create: true
68 | ) else {
69 | return nil
70 | }
71 |
72 | return url.appendingPathComponent(cacheFileName)
73 | }
74 |
75 | private func loadFromCache() -> RelationshipData {
76 | do {
77 | guard let localURL = cacheUrl() else { return .init() }
78 | let data = try Data(contentsOf: localURL, options: [])
79 | return try JSONDecoder().decode(RelationshipData.self, from: data)
80 | } catch {
81 | return .init()
82 | }
83 | }
84 | }
85 |
--------------------------------------------------------------------------------
/ContentfulPersistence.xcodeproj/xcshareddata/xcschemes/ContentfulPersistence_watchOS.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 |
--------------------------------------------------------------------------------
/Tests/ContentfulPersistenceTests/ComplexTestStubs/clear-field-initial-sync.json:
--------------------------------------------------------------------------------
1 | {
2 | "sys": {
3 | "type": "Array"
4 | },
5 | "items": [
6 | {
7 | "sys": {
8 | "space": {
9 | "sys": {
10 | "type": "Link",
11 | "linkType": "Space",
12 | "id": "smf0sqiu0c5s"
13 | }
14 | },
15 | "id": "aNt2d7YR4AIwEAMcG4OwI",
16 | "type": "Entry",
17 | "createdAt": "2017-06-20T14:03:28.985Z",
18 | "updatedAt": "2017-07-13T09:49:17.125Z",
19 | "revision": 3,
20 | "contentType": {
21 | "sys": {
22 | "type": "Link",
23 | "linkType": "ContentType",
24 | "id": "singleRecord"
25 | }
26 | }
27 | },
28 | "fields": {
29 | "textBody": {
30 | "en-US": "Hello"
31 | }
32 | }
33 | },
34 | {
35 | "sys": {
36 | "space": {
37 | "sys": {
38 | "type": "Link",
39 | "linkType": "Space",
40 | "id": "smf0sqiu0c5s"
41 | }
42 | },
43 | "id": "14XouHzspI44uKCcMicWUY",
44 | "type": "Entry",
45 | "createdAt": "2017-06-20T14:03:29.046Z",
46 | "updatedAt": "2017-06-22T09:09:16.166Z",
47 | "revision": 2,
48 | "contentType": {
49 | "sys": {
50 | "type": "Link",
51 | "linkType": "ContentType",
52 | "id": "singleRecord"
53 | }
54 | }
55 | },
56 | "fields": {
57 | "textBody": {
58 | "en-US": "12"
59 | }
60 | }
61 | },
62 | {
63 | "sys": {
64 | "space": {
65 | "sys": {
66 | "type": "Link",
67 | "linkType": "Space",
68 | "id": "smf0sqiu0c5s"
69 | }
70 | },
71 | "id": "5GiLOZvY7SiMeUIgIIAssS",
72 | "type": "Entry",
73 | "createdAt": "2017-06-20T14:03:29.319Z",
74 | "updatedAt": "2017-06-20T14:03:29.319Z",
75 | "revision": 1,
76 | "contentType": {
77 | "sys": {
78 | "type": "Link",
79 | "linkType": "ContentType",
80 | "id": "singleRecord"
81 | }
82 | }
83 | },
84 | "fields": {
85 | "textBody": {
86 | "en-US": "INITIAL TEXT BODY"
87 | }
88 | }
89 | },
90 | {
91 | "sys": {
92 | "space": {
93 | "sys": {
94 | "type": "Link",
95 | "linkType": "Space",
96 | "id": "smf0sqiu0c5s"
97 | }
98 | },
99 | "id": "2eXtYKYxYAue2IQgaucoYW",
100 | "type": "Entry",
101 | "createdAt": "2017-06-20T12:51:13.910Z",
102 | "updatedAt": "2017-06-20T12:51:13.910Z",
103 | "revision": 1,
104 | "contentType": {
105 | "sys": {
106 | "type": "Link",
107 | "linkType": "ContentType",
108 | "id": "singleRecord"
109 | }
110 | }
111 | },
112 | "fields": {
113 | "textBody": {
114 | "en-US": "4"
115 | }
116 | }
117 | }
118 | ],
119 | "nextSyncUrl": "https://cdn.contentful.com/spaces/smf0sqiu0c5s/environments/master/sync?sync_token=clear-field-sync-token"
120 | }
121 |
--------------------------------------------------------------------------------
/Tests/ContentfulPersistenceTests/ContentStubs/single-post.json:
--------------------------------------------------------------------------------
1 | {
2 | "sys": {
3 | "space": {
4 | "sys": {
5 | "type": "Link",
6 | "linkType": "Space",
7 | "id": "dqpnpm0n4e75"
8 | }
9 | },
10 | "id": "1asN98Ph3mUiCYIYiiqwko",
11 | "type": "Entry",
12 | "createdAt": "2015-02-05T12:11:56.916Z",
13 | "updatedAt": "2015-02-05T12:11:56.916Z",
14 | "revision": 1,
15 | "contentType": {
16 | "sys": {
17 | "type": "Link",
18 | "linkType": "ContentType",
19 | "id": "2wKn6yEnZewu2SCCkus4as"
20 | }
21 | },
22 | "locale": "en-US"
23 | },
24 | "fields": {
25 | "title": "Down the Rabbit Hole",
26 | "slug": "down-the-rabbit-hole",
27 | "author": [
28 | {
29 | "sys": {
30 | "type": "Link",
31 | "linkType": "Entry",
32 | "id": "6EczfGnuHCIYGGwEwIqiq2"
33 | }
34 | }
35 | ],
36 | "body": "Alice was beginning to get very tired of sitting by her sister on the bank, and of having nothing to do: once or twice she had peeped into the book her sister was reading, but it had no pictures or conversations in it, 'and what is the use of a book,' thought Alice 'without pictures or conversation?' So she was considering in her own mind (as well as she could, for the hot day made her feel very sleepy and stupid), whether the pleasure of making a daisy- chain would be worth the trouble of getting up and picking the daisies, when suddenly a White Rabbit with pink eyes ran close by her. There was nothing so very remarkable in that; nor did Alice think it so very much out of the way to hear the Rabbit say to itself, `Oh dear! Oh dear! I shall be late!' (when she thought it over afterwards, it occurred to her that she ought to have wondered at this, but at the time it all seemed quite natural); but when the Rabbit actually took a watch out of its waistcoat- pocket, and looked at it, and then hurried on, Alice started to her feet, for it flashed across her mind that she had never before seen a rabbit with either a waistcoat-pocket, or a watch to take out of it, and burning with curiosity, she ran across the field after it, and fortunately was...",
37 | "category": [
38 | {
39 | "sys": {
40 | "type": "Link",
41 | "linkType": "Entry",
42 | "id": "6XL7nwqRZ6yEw0cUe4y0y6"
43 | }
44 | },
45 | {
46 | "sys": {
47 | "type": "Link",
48 | "linkType": "Entry",
49 | "id": "FJlJfypzaewiwyukGi2kI"
50 | }
51 | }
52 | ],
53 | "tags": [
54 | "Literature",
55 | "fantasy",
56 | "children",
57 | "novel",
58 | "fiction",
59 | "animals",
60 | "rabbit",
61 | "girl"
62 | ],
63 | "featuredImage": {
64 | "sys": {
65 | "type": "Link",
66 | "linkType": "Asset",
67 | "id": "bXvdSYHB3Guy2uUmuEco8"
68 | }
69 | },
70 | "date": "1865-11-26",
71 | "comments": false
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/Sources/ContentfulPersistence/PersistenceStore.swift:
--------------------------------------------------------------------------------
1 | //
2 | // PersistentStore.swift
3 | // ContentfulPersistence
4 | //
5 | // Created by JP Wright on 16.06.17.
6 | // Copyright © 2017 Contentful GmbH. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | /// Protocol for persistence stores used by `SynchronizationManager`
12 | public protocol PersistenceStore {
13 | /**
14 | Create a new object of the given type.
15 |
16 | - parameter type: The type of which a new object should be created
17 |
18 | - throws: If a invalid type was specified
19 |
20 | - returns: A newly created object of the given type
21 | */
22 | func create(type: Any.Type) throws -> T
23 |
24 | /**
25 | Delete objects of the given type which also match the predicate.
26 |
27 | - parameter type: The type of which objects should be deleted
28 | - parameter predicate: The predicate used for matching objects to delete
29 |
30 | - throws: If an invalid type was specified
31 | */
32 | func delete(type: Any.Type, predicate: NSPredicate) throws
33 |
34 | /**
35 | Fetches all objects of a specific type which also match the predicate.
36 |
37 | - parameter type: The type of which objects should be fetched
38 | - parameter predicate: The predicate used for matching object to fetch
39 |
40 | - throws: If an invalid type was specified
41 |
42 | - returns: An array of matching objects
43 | */
44 | func fetchAll(type: Any.Type, predicate: NSPredicate) throws -> [T]
45 |
46 | /**
47 | Fetches one object of a specific type which matches the predicate.
48 |
49 | - parameter type: Type of which object should be fetched.
50 | - parameter predicate: The predicate used for matching object to fetch.
51 |
52 | - throws: If an invalid type was specified
53 |
54 | - returns: Matching object
55 | */
56 | func fetchOne(type: Any.Type, predicate: NSPredicate) throws -> T
57 |
58 | /**
59 | Returns an array of names of properties the given type stores persistently.
60 |
61 | This should omit any properties returned by `relationshipsFor(type:)`.
62 |
63 | - parameter type: The type of which properties should be returned for
64 |
65 | - throws: If an invalid type was specified
66 |
67 | - returns: An array of property names
68 | */
69 | func properties(for type: Any.Type) throws -> [String]
70 |
71 | /**
72 | Returns an array of names of properties for any relationship the given type stores persistently.
73 |
74 | - parameter type: The type of which properties should be returned for
75 |
76 | - throws: If an invalid type was specified
77 |
78 | - returns: An array of property names
79 | */
80 | func relationships(for type: Any.Type) throws -> [String]
81 |
82 | /**
83 | Performs the actual save to the persistence store.
84 |
85 | - throws: If any error occured during the save operation
86 | */
87 | func save() throws
88 |
89 | /// Deletes all the data in the database
90 | func wipe() throws
91 |
92 | func performBlock(block: @escaping () -> Void)
93 |
94 | func performAndWait(block: @escaping () -> Void)
95 |
96 | /// Called **before** the main `.sqlite` is swapped. Gives you the full file URL.
97 | func onStorePreseedingWillBegin(at storeFileURL: URL) throws
98 |
99 | /// Called **after** the main file is in place.
100 | func onStorePreseedingCompleted(at seededFileURL: URL) throws
101 | }
102 |
--------------------------------------------------------------------------------
/Sources/ContentfulPersistence/Relationships/Relationship.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ContentfulPersistenceSwift
3 | //
4 |
5 | import Contentful
6 |
7 | /// Represents a relationship between two entries.
8 | struct Relationship: Codable, Equatable, Identifiable {
9 |
10 | typealias ID = String
11 | typealias ParentId = String
12 | typealias FieldName = String
13 | typealias LocaleCode = String?
14 |
15 | let id: ID
16 | let parentType: ContentTypeId
17 | let parentId: ParentId
18 | let fieldName: FieldName
19 | let children: RelationshipChildren
20 |
21 | var localeCode: LocaleCode {
22 | Self.localeCode(for: children)
23 | }
24 |
25 | init(parentType: ContentTypeId, parentId: ParentId, fieldName: FieldName, childId: RelationshipChildId) {
26 | self.init(parentType: parentType, parentId: parentId, fieldName: fieldName, children: .one(childId))
27 | }
28 |
29 | init(parentType: ContentTypeId, parentId: ParentId, fieldName: FieldName, childIds: [RelationshipChildId]) {
30 | self.init(parentType: parentType, parentId: parentId, fieldName: fieldName, children: .many(childIds))
31 | }
32 |
33 | private init(parentType: ContentTypeId, parentId: ParentId, fieldName: FieldName, children: RelationshipChildren) {
34 | self.parentType = parentType
35 | self.parentId = parentId
36 | self.fieldName = fieldName
37 | self.children = children
38 | self.id = [parentType, parentId, fieldName, Self.localeCode(for: children) ?? "-"].joined(separator: ",")
39 | }
40 |
41 | private static func localeCode(for children: RelationshipChildren) -> LocaleCode {
42 | switch children {
43 | case .one(let childId):
44 | return childId.localeCode
45 | case .many(let childIds):
46 | return childIds.first?.localeCode
47 | }
48 | }
49 |
50 | }
51 |
52 | enum RelationshipChildren: Codable, Equatable {
53 |
54 | private enum CodingKeys: CodingKey {
55 | case kind
56 | case value
57 | }
58 |
59 | private enum Kind: String, Codable {
60 | case one
61 | case many
62 | }
63 |
64 | case one(RelationshipChildId)
65 | case many([RelationshipChildId])
66 |
67 | var elements: [RelationshipChildId] {
68 | switch self {
69 | case .one(let relationshipChildId):
70 | return [relationshipChildId]
71 | case .many(let relationshipChildIds):
72 | return relationshipChildIds
73 | }
74 | }
75 |
76 | // MARK: Codable
77 |
78 | init(from decoder: Decoder) throws {
79 | let container = try decoder.container(keyedBy: CodingKeys.self)
80 | let kind = try container.decode(Kind.self, forKey: .kind)
81 |
82 | switch kind {
83 | case .one:
84 | self = .one(try container.decode(RelationshipChildId.self, forKey: .value))
85 | case .many:
86 | self = .many(try container.decode([RelationshipChildId].self, forKey: .value))
87 | }
88 | }
89 |
90 | func encode(to encoder: Encoder) throws {
91 | var container = encoder.container(keyedBy: CodingKeys.self)
92 |
93 | switch self {
94 | case .one(let childId):
95 | try container.encode(Kind.one, forKey: .kind)
96 | try container.encode(childId, forKey: .value)
97 | case .many(let childIds):
98 | try container.encode(Kind.many, forKey: .kind)
99 | try container.encode(childIds, forKey: .value)
100 | }
101 | }
102 |
103 | }
104 |
--------------------------------------------------------------------------------
/Tests/ContentfulPersistenceTests/ComplexTestStubs/shared-linked-asset.json:
--------------------------------------------------------------------------------
1 | {
2 | "sys": {
3 | "type": "Array"
4 | },
5 | "items": [
6 | {
7 | "sys": {
8 | "space": {
9 | "sys": {
10 | "type": "Link",
11 | "linkType": "Space",
12 | "id": "smf0sqiu0c5s"
13 | }
14 | },
15 | "id": "12f37qR1CGOOcqoWOgqC2o",
16 | "type": "Entry",
17 | "createdAt": "2017-12-20T19:04:05.031Z",
18 | "updatedAt": "2017-12-20T19:04:05.031Z",
19 | "revision": 1,
20 | "contentType": {
21 | "sys": {
22 | "type": "Link",
23 | "linkType": "ContentType",
24 | "id": "singleRecord"
25 | }
26 | }
27 | },
28 | "fields": {
29 | "textBody": {
30 | "en-US": "Second record with shared asset"
31 | },
32 | "assetLinkField": {
33 | "en-US": {
34 | "sys": {
35 | "type": "Link",
36 | "linkType": "Asset",
37 | "id": "6Wsz8owhtCGSICg44IUYAm"
38 | }
39 | }
40 | }
41 | }
42 | },
43 | {
44 | "sys": {
45 | "space": {
46 | "sys": {
47 | "type": "Link",
48 | "linkType": "Space",
49 | "id": "smf0sqiu0c5s"
50 | }
51 | },
52 | "id": "4DiVtM6u08uMA2QSgg0OoY",
53 | "type": "Entry",
54 | "createdAt": "2017-12-20T19:03:45.612Z",
55 | "updatedAt": "2017-12-20T19:03:45.612Z",
56 | "revision": 1,
57 | "contentType": {
58 | "sys": {
59 | "type": "Link",
60 | "linkType": "ContentType",
61 | "id": "singleRecord"
62 | }
63 | }
64 | },
65 | "fields": {
66 | "textBody": {
67 | "en-US": "First record with shared asset"
68 | },
69 | "assetLinkField": {
70 | "en-US": {
71 | "sys": {
72 | "type": "Link",
73 | "linkType": "Asset",
74 | "id": "6Wsz8owhtCGSICg44IUYAm"
75 | }
76 | }
77 | }
78 | }
79 | },
80 | {
81 | "sys": {
82 | "space": {
83 | "sys": {
84 | "type": "Link",
85 | "linkType": "Space",
86 | "id": "smf0sqiu0c5s"
87 | }
88 | },
89 | "id": "6Wsz8owhtCGSICg44IUYAm",
90 | "type": "Asset",
91 | "createdAt": "2017-11-27T09:23:17.444Z",
92 | "updatedAt": "2017-11-27T09:23:17.444Z",
93 | "revision": 1
94 | },
95 | "fields": {
96 | "title": {
97 | "en-US": "First asset in array"
98 | },
99 | "file": {
100 | "en-US": {
101 | "url": "//images.contentful.com/smf0sqiu0c5s/6Wsz8owhtCGSICg44IUYAm/4061427d7cba2050a87579033efb3fb9/dog.jpg",
102 | "details": {
103 | "size": 79169,
104 | "image": {
105 | "width": 830,
106 | "height": 830
107 | }
108 | },
109 | "fileName": "dog.jpg",
110 | "contentType": "image/jpeg"
111 | }
112 | }
113 | }
114 | }
115 | ],
116 | "nextSyncUrl": "http://cdn.contentful.com/spaces/smf0sqiu0c5s/environments/master/sync?sync_token=wonDrcKnRgcSOF4-wrDCgcKefWzCgsOxwrfCq8KOfMOdXUPCvEnChwEEO8KFwqHDj8KpJMKkJn0Xw5LCgcO3w5oOLkJxwpPDr8KZV1rCh3nDi8Obwq0-wp1EwpRGwrApMWvDkjNgI3TCgWvDmH4zVFHDpBgkwpoCflTDg8KqbmHCpMKuU8KTHcONX8Ogw67CusO_wo3DucO9w6jDj8Kiw6TDpMKUw44bwr_CqsOqMsOkwqYWKBrCvcK0BlzDmsOtw4nDgMK2"
117 | }
118 |
--------------------------------------------------------------------------------
/Tests/ContentfulPersistenceTests/TestHelpers.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TestHelpers.swift
3 | // ContentfulPersistence
4 | //
5 | // Created by JP Wright on 12.07.17.
6 | // Copyright © 2017 Contentful GmbH. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import CoreData
11 | import XCTest
12 |
13 | class TestHelpers {
14 |
15 | static func jsonData(_ fileName: String) -> Data {
16 | let bundle = Bundle(for: TestHelpers.self)
17 | let urlPath = bundle.path(forResource: fileName, ofType: "json")!
18 | return try! Data(contentsOf: URL(fileURLWithPath: urlPath))
19 | }
20 |
21 | static func managedObjectContext(forMOMInTestBundleNamed momName: String) -> NSManagedObjectContext {
22 | let modelURL = Bundle(for: TestHelpers.self).url(forResource: momName, withExtension: "momd")
23 | let mom = NSManagedObjectModel(contentsOf: modelURL!)
24 | XCTAssertNotNil(mom)
25 |
26 | let psc = NSPersistentStoreCoordinator(managedObjectModel: mom!)
27 |
28 | do {
29 | // Store in memory so there is no caching between test methods.
30 | let store = try psc.addPersistentStore(ofType: NSInMemoryStoreType, configurationName: nil, at: nil, options: nil)
31 | XCTAssertNotNil(store)
32 | } catch {
33 | XCTAssert(false, "Recreating the persistent store SQL files should not throw an error")
34 | }
35 |
36 | let managedObjectContext = NSManagedObjectContext(concurrencyType: NSManagedObjectContextConcurrencyType.mainQueueConcurrencyType)
37 | managedObjectContext.persistentStoreCoordinator = psc
38 | return managedObjectContext
39 | }
40 |
41 | /// Spins up a file-backed Core Data stack for testing.
42 | ///
43 | /// - Parameter momName: The `.momd` name in your test bundle.
44 | /// - Returns: A tuple `(context, sqliteURL)` where `context` is
45 | /// an `NSManagedObjectContext` backed by the sqlite at `sqliteURL`.
46 | static func sqliteBackedContext(forMOMInTestBundleNamed momName: String)
47 | throws
48 | -> (context: NSManagedObjectContext, sqliteURL: URL, storeContainerPath: URL)
49 | {
50 | let bundle = Bundle(for: TestHelpers.self)
51 | guard
52 | let modelURL = bundle.url(forResource: momName, withExtension: "momd"),
53 | let mom = NSManagedObjectModel(contentsOf: modelURL)
54 | else {
55 | XCTFail("Couldn’t load model \(momName).momd from test bundle")
56 | fatalError()
57 | }
58 |
59 | let psc = NSPersistentStoreCoordinator(managedObjectModel: mom)
60 | let context = NSManagedObjectContext(concurrencyType: .mainQueueConcurrencyType)
61 | context.persistentStoreCoordinator = psc
62 |
63 | // Create a unique temp directory for the sqlite file
64 | let tempDir = URL(fileURLWithPath: NSTemporaryDirectory())
65 | .appendingPathComponent(UUID().uuidString)
66 | try FileManager.default.createDirectory(
67 | at: tempDir,
68 | withIntermediateDirectories: true,
69 | attributes: nil
70 | )
71 |
72 | let sqliteURL = tempDir.appendingPathComponent("\(momName).sqlite")
73 | do {
74 | let store = try psc.addPersistentStore(
75 | ofType: NSSQLiteStoreType,
76 | configurationName: nil,
77 | at: sqliteURL,
78 | options: nil
79 | )
80 | XCTAssertNotNil(store, "Failed to add SQLite store at \(sqliteURL)")
81 | } catch {
82 | XCTFail("Error adding SQLite store: \(error)")
83 | }
84 |
85 | return (context, sqliteURL, tempDir)
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/Tests/ContentfulPersistenceTests/ComplexTestStubs/linked-assets-array.json:
--------------------------------------------------------------------------------
1 | {
2 | "sys": {
3 | "type": "Array"
4 | },
5 | "items": [
6 | {
7 | "sys": {
8 | "space": {
9 | "sys": {
10 | "type": "Link",
11 | "linkType": "Space",
12 | "id": "smf0sqiu0c5s"
13 | }
14 | },
15 | "id": "2JFSeiPTZYm4goMSUeYSCU",
16 | "type": "Entry",
17 | "createdAt": "2017-11-27T09:33:47.250Z",
18 | "updatedAt": "2017-11-27T09:33:47.250Z",
19 | "revision": 1,
20 | "contentType": {
21 | "sys": {
22 | "type": "Link",
23 | "linkType": "ContentType",
24 | "id": "singleRecord"
25 | }
26 | }
27 | },
28 | "fields": {
29 | "textBody": {
30 | "en-US": "Record with array of assets"
31 | },
32 | "assetsArrayLinkField": {
33 | "en-US": [
34 | {
35 | "sys": {
36 | "type": "Link",
37 | "linkType": "Asset",
38 | "id": "6Wsz8owhtCGSICg44IUYAm"
39 | }
40 | },
41 | {
42 | "sys": {
43 | "type": "Link",
44 | "linkType": "Asset",
45 | "id": "6G30n5w3sWcS6yEWo8q6CI"
46 | }
47 | }
48 | ]
49 | }
50 | }
51 | },
52 | {
53 | "sys": {
54 | "space": {
55 | "sys": {
56 | "type": "Link",
57 | "linkType": "Space",
58 | "id": "smf0sqiu0c5s"
59 | }
60 | },
61 | "id": "6G30n5w3sWcS6yEWo8q6CI",
62 | "type": "Asset",
63 | "createdAt": "2017-11-27T09:24:19.117Z",
64 | "updatedAt": "2017-11-27T09:24:19.117Z",
65 | "revision": 1
66 | },
67 | "fields": {
68 | "title": {
69 | "en-US": "Second asset in array"
70 | },
71 | "file": {
72 | "en-US": {
73 | "url": "//images.contentful.com/smf0sqiu0c5s/6G30n5w3sWcS6yEWo8q6CI/6d875fdfb4bbdb7fce1a96d7d8ccb559/cat.jpg",
74 | "details": {
75 | "size": 1168727,
76 | "image": {
77 | "width": 2067,
78 | "height": 1163
79 | }
80 | },
81 | "fileName": "cat.jpg",
82 | "contentType": "image/jpeg"
83 | }
84 | }
85 | }
86 | },
87 | {
88 | "sys": {
89 | "space": {
90 | "sys": {
91 | "type": "Link",
92 | "linkType": "Space",
93 | "id": "smf0sqiu0c5s"
94 | }
95 | },
96 | "id": "6Wsz8owhtCGSICg44IUYAm",
97 | "type": "Asset",
98 | "createdAt": "2017-11-27T09:23:17.444Z",
99 | "updatedAt": "2017-11-27T09:23:17.444Z",
100 | "revision": 1
101 | },
102 | "fields": {
103 | "title": {
104 | "en-US": "First asset in array"
105 | },
106 | "file": {
107 | "en-US": {
108 | "url": "//images.contentful.com/smf0sqiu0c5s/6Wsz8owhtCGSICg44IUYAm/4061427d7cba2050a87579033efb3fb9/dog.jpg",
109 | "details": {
110 | "size": 79169,
111 | "image": {
112 | "width": 830,
113 | "height": 830
114 | }
115 | },
116 | "fileName": "dog.jpg",
117 | "contentType": "image/jpeg"
118 | }
119 | }
120 | }
121 | }
122 | ],
123 | "nextSyncUrl": "http://cdn.contentful.com/spaces/smf0sqiu0c5s/environments/master/sync?sync_token=wonDrcKnRgcSOF4-wrDCgcKefWzCgsOxwrfCq8KOfMOdXUPCvEnChwEEO8KFwqHDj8KpJMKkJn0Xw5LCgcO3w5oOLkJxwpPDr8KZV1rCh3nDi8Obwq0-wp1EwpRGwrApMWvDkjNgI3TCgWvDmH4zVFHDpBgkwpoCflTDg8KqbmHCpMKuU8KTHcONX8Ogw67CusO_wo3DucO9w6jDj8Kiw6TDpMKUw44bwr_CqsOqMsOkwqYWKBrCvcK0BlzDmsOtw4nDgMK2"
124 | }
125 |
--------------------------------------------------------------------------------
/docs/docsets/ContentfulPersistence.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 |
--------------------------------------------------------------------------------
/Gemfile.lock:
--------------------------------------------------------------------------------
1 | GEM
2 | remote: https://rubygems.org/
3 | specs:
4 | CFPropertyList (3.0.7)
5 | base64
6 | nkf
7 | rexml
8 | activesupport (7.1.3.2)
9 | base64
10 | bigdecimal
11 | concurrent-ruby (~> 1.0, >= 1.0.2)
12 | connection_pool (>= 2.2.5)
13 | drb
14 | i18n (>= 1.6, < 2)
15 | minitest (>= 5.1)
16 | mutex_m
17 | tzinfo (~> 2.0)
18 | addressable (2.8.6)
19 | public_suffix (>= 2.0.2, < 6.0)
20 | algoliasearch (1.27.5)
21 | httpclient (~> 2.8, >= 2.8.3)
22 | json (>= 1.5.1)
23 | atomos (0.1.3)
24 | base64 (0.2.0)
25 | bigdecimal (3.1.7)
26 | claide (1.1.0)
27 | clamp (1.3.2)
28 | cocoapods (1.15.2)
29 | addressable (~> 2.8)
30 | claide (>= 1.0.2, < 2.0)
31 | cocoapods-core (= 1.15.2)
32 | cocoapods-deintegrate (>= 1.0.3, < 2.0)
33 | cocoapods-downloader (>= 2.1, < 3.0)
34 | cocoapods-plugins (>= 1.0.0, < 2.0)
35 | cocoapods-search (>= 1.0.0, < 2.0)
36 | cocoapods-trunk (>= 1.6.0, < 2.0)
37 | cocoapods-try (>= 1.1.0, < 2.0)
38 | colored2 (~> 3.1)
39 | escape (~> 0.0.4)
40 | fourflusher (>= 2.3.0, < 3.0)
41 | gh_inspector (~> 1.0)
42 | molinillo (~> 0.8.0)
43 | nap (~> 1.0)
44 | ruby-macho (>= 2.3.0, < 3.0)
45 | xcodeproj (>= 1.23.0, < 2.0)
46 | cocoapods-core (1.15.2)
47 | activesupport (>= 5.0, < 8)
48 | addressable (~> 2.8)
49 | algoliasearch (~> 1.0)
50 | concurrent-ruby (~> 1.1)
51 | fuzzy_match (~> 2.0.4)
52 | nap (~> 1.0)
53 | netrc (~> 0.11)
54 | public_suffix (~> 4.0)
55 | typhoeus (~> 1.0)
56 | cocoapods-deintegrate (1.0.5)
57 | cocoapods-downloader (2.1)
58 | cocoapods-plugins (1.0.0)
59 | nap
60 | cocoapods-search (1.0.1)
61 | cocoapods-trunk (1.6.0)
62 | nap (>= 0.8, < 2.0)
63 | netrc (~> 0.11)
64 | cocoapods-try (1.2.0)
65 | colored2 (3.1.2)
66 | concurrent-ruby (1.2.3)
67 | connection_pool (2.4.1)
68 | dotenv (3.1.0)
69 | drb (2.2.1)
70 | escape (0.0.4)
71 | ethon (0.16.0)
72 | ffi (>= 1.15.0)
73 | ffi (1.16.3)
74 | fourflusher (2.3.1)
75 | fuzzy_match (2.0.4)
76 | gh_inspector (1.1.3)
77 | httpclient (2.8.3)
78 | i18n (1.14.4)
79 | concurrent-ruby (~> 1.0)
80 | jazzy (0.14.4)
81 | cocoapods (~> 1.5)
82 | mustache (~> 1.1)
83 | open4 (~> 1.3)
84 | redcarpet (~> 3.4)
85 | rexml (~> 3.2)
86 | rouge (>= 2.0.6, < 5.0)
87 | sassc (~> 2.1)
88 | sqlite3 (~> 1.3)
89 | xcinvoke (~> 0.3.0)
90 | json (2.7.2)
91 | liferaft (0.0.6)
92 | minitest (5.22.3)
93 | molinillo (0.8.0)
94 | mustache (1.1.1)
95 | mutex_m (0.2.0)
96 | nanaimo (0.3.0)
97 | nap (1.1.0)
98 | netrc (0.11.0)
99 | nkf (0.2.0)
100 | nokogiri (1.16.4-arm64-darwin)
101 | racc (~> 1.4)
102 | open4 (1.3.4)
103 | public_suffix (4.0.7)
104 | racc (1.7.3)
105 | redcarpet (3.6.0)
106 | rexml (3.2.6)
107 | rouge (2.0.7)
108 | ruby-macho (2.5.1)
109 | sassc (2.4.0)
110 | ffi (~> 1.9)
111 | slather (2.8.0)
112 | CFPropertyList (>= 2.2, < 4)
113 | activesupport
114 | clamp (~> 1.3)
115 | nokogiri (>= 1.14.3)
116 | xcodeproj (~> 1.21)
117 | sqlite3 (1.7.3-arm64-darwin)
118 | typhoeus (1.4.1)
119 | ethon (>= 0.9.0)
120 | tzinfo (2.0.6)
121 | concurrent-ruby (~> 1.0)
122 | xcinvoke (0.3.0)
123 | liferaft (~> 0.0.6)
124 | xcodeproj (1.24.0)
125 | CFPropertyList (>= 2.3.3, < 4.0)
126 | atomos (~> 0.1.3)
127 | claide (>= 1.0.2, < 2.0)
128 | colored2 (~> 3.1)
129 | nanaimo (~> 0.3.0)
130 | rexml (~> 3.2.4)
131 | xcpretty (0.3.0)
132 | rouge (~> 2.0.7)
133 |
134 | PLATFORMS
135 | arm64-darwin-21
136 |
137 | DEPENDENCIES
138 | cocoapods
139 | dotenv
140 | jazzy
141 | slather
142 | xcpretty
143 |
144 | BUNDLED WITH
145 | 2.4.3
146 |
--------------------------------------------------------------------------------
/Tests/ContentfulPersistenceTests/BundledPreseededDatabaseTests/CoreDataStorePreseedTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CoreDataStorePreseedTests.swift
3 | // ContentfulPersistence
4 | //
5 | // Created by Marius Kurgonas on 30/05/2025.
6 | // Copyright © 2025 Contentful GmbH. All rights reserved.
7 | //
8 |
9 | import XCTest
10 | @testable import ContentfulPersistence
11 | import CoreData
12 |
13 | class CoreDataStorePreseedTests: XCTestCase {
14 | var ctx: NSManagedObjectContext!
15 | var store: CoreDataStore!
16 | var sqliteURL: URL!
17 | var syncManager: SynchronizationManager!
18 |
19 | override func setUpWithError() throws {
20 | // Obtain a file-backed Core Data stack and its SQLite URL:
21 | let data = try TestHelpers.sqliteBackedContext(
22 | forMOMInTestBundleNamed: "Test"
23 | )
24 | ctx = data.context
25 | sqliteURL = data.sqliteURL
26 |
27 | store = CoreDataStore(context: ctx)
28 |
29 | let entryTypes: [EntryPersistable.Type] = [Author.self, Category.self, Post.self]
30 |
31 | let persistenceModel = PersistenceModel(spaceType: SyncInfo.self, assetType: Asset.self, entryTypes: entryTypes)
32 |
33 | syncManager = SynchronizationManager(localizationScheme: .default, persistenceStore: store, persistenceModel: persistenceModel)
34 | }
35 |
36 | func testWillBegin_removesSideCarsAndStore() throws {
37 | let directoryName = "PreseedJSONFiles"
38 | let testBundle = Bundle(for: Swift.type(of: self))
39 | do {
40 | try syncManager.seedDBFromJSONFiles(in: directoryName, in: testBundle)
41 | } catch let error {
42 | XCTFail(error.localizedDescription)
43 | }
44 |
45 | // At this point Core Data should have created:
46 | // .sqlite-wal and .sqlite-shm
47 | let wal = URL(fileURLWithPath: sqliteURL.path + "-wal")
48 | let shm = URL(fileURLWithPath: sqliteURL.path + "-shm")
49 | XCTAssertTrue(FileManager.default.fileExists(atPath: wal.path),
50 | "WAL file should exist after saving.")
51 | XCTAssertTrue(FileManager.default.fileExists(atPath: shm.path),
52 | "SHM file should exist after saving.")
53 | XCTAssertEqual(ctx.persistentStoreCoordinator?.persistentStores.count, 1,
54 | "There should be exactly one persistent store before wiping.")
55 |
56 | // 2) Call the hook under test:
57 | try store.onStorePreseedingWillBegin(at: sqliteURL)
58 |
59 | // 3) Verify WAL and SHM have been deleted:
60 | XCTAssertFalse(FileManager.default.fileExists(atPath: wal.path),
61 | "WAL must be removed by onStorePreseedingWillBegin.")
62 | XCTAssertFalse(FileManager.default.fileExists(atPath: shm.path),
63 | "SHM must be removed by onStorePreseedingWillBegin.")
64 |
65 | // 4) Verify the persistent store was removed from the coordinator:
66 | XCTAssertEqual(ctx.persistentStoreCoordinator?.persistentStores.count, 0,
67 | "Persistent store should be removed by onStorePreseedingWillBegin.")
68 | }
69 |
70 | func testCompleted_readdsStoreAndResetsContext() throws {
71 | // First remove the store so the coordinator is empty:
72 | try store.onStorePreseedingWillBegin(at: sqliteURL)
73 | XCTAssertEqual(ctx.persistentStoreCoordinator?.persistentStores.count, 0,
74 | "Store should be gone after onStorePreseedingWillBegin.")
75 |
76 | // Now re-open the store and reset the context:
77 | try store.onStorePreseedingCompleted(at: sqliteURL)
78 |
79 | // Verify the store was re-added:
80 | XCTAssertEqual(ctx.persistentStoreCoordinator?.persistentStores.count, 1,
81 | "Persistent store should be re-added by onStorePreseedingCompleted.")
82 |
83 | // Verify the context was reset (no remaining registered objects):
84 | XCTAssertTrue(ctx.registeredObjects.isEmpty,
85 | "Context must be reset after onStorePreseedingCompleted.")
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/Tests/ContentfulPersistenceTests/UnresolvedRelationshipCacheTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UnresolvedRelationshipCacheTests.swift
3 | // ContentfulPersistence
4 | //
5 | // Created by JP Wright on 02.07.18.
6 | // Copyright © 2018 Contentful GmbH. All rights reserved.
7 | //
8 |
9 | @testable import ContentfulPersistence
10 | import Contentful
11 | import XCTest
12 | import Foundation
13 | import CoreData
14 | import OHHTTPStubs
15 | import CoreLocation
16 |
17 | class UnresolvedRelationshipCacheTests: XCTestCase {
18 |
19 | var syncManager: SynchronizationManager!
20 |
21 | var client: Client!
22 |
23 | lazy var store: CoreDataStore = {
24 | return CoreDataStore(context: self.managedObjectContext)
25 | }()
26 |
27 | lazy var managedObjectContext: NSManagedObjectContext = {
28 | return TestHelpers.managedObjectContext(forMOMInTestBundleNamed: "ComplexTest")
29 | }()
30 |
31 | // Before each test.
32 | override func setUp() {
33 | HTTPStubs.removeAllStubs()
34 |
35 | let persistenceModel = PersistenceModel(spaceType: ComplexSyncInfo.self, assetType: ComplexAsset.self, entryTypes: [SingleRecord.self, Link.self])
36 |
37 |
38 | client = Client(spaceId: "smf0sqiu0c5s",
39 | accessToken: "14d305ad526d4487e21a99b5b9313a8877ce6fbf540f02b12189eea61550ef34")
40 | self.syncManager = SynchronizationManager(client: client,
41 | localizationScheme: .default,
42 | persistenceStore: self.store,
43 | persistenceModel: persistenceModel)
44 | }
45 |
46 | // After each test.
47 | override func tearDown() {
48 | HTTPStubs.removeAllStubs()
49 | }
50 |
51 | func testRelationshipsAreCachedMidSync() {
52 | var syncSpace: SyncSpace!
53 |
54 | let expectation = self.expectation(description: "Initial sync succeeded")
55 |
56 | stub(condition: isPath("/spaces/smf0sqiu0c5s/environments/master/sync")) { request -> HTTPStubsResponse in
57 | let stubPath = OHPathForFile("unresolvable-links.json", UnresolvedRelationshipCacheTests.self)
58 | return fixture(filePath: stubPath!, headers: ["Content-Type": "application/json"])
59 |
60 | }.name = "Initial sync stub"
61 |
62 | client.sync() { result in
63 | switch result {
64 | case .success(let space):
65 | XCTAssertFalse(self.syncManager.cachedUnresolvedRelationships!.isEmpty)
66 | XCTAssertEqual((self.syncManager.cachedUnresolvedRelationships?["14XouHzspI44uKCcMicWUY_en-US"])?["linkField"] as? String, "2XYdAPiR0I6SMAGiCOEukU_en-US")
67 | syncSpace = space
68 | case .failure(let error):
69 | XCTFail("\(error)")
70 |
71 | }
72 | expectation.fulfill()
73 | }
74 | waitForExpectations(timeout: 10.0, handler: nil)
75 | HTTPStubs.removeAllStubs()
76 |
77 | // ============================NEXT SYNC==================================================
78 | let nextExpectation = self.expectation(description: "Next sync clears the cached JSON after relationships are resolved")
79 |
80 | stub(condition: isPath("/spaces/smf0sqiu0c5s/environments/master/sync")) { request -> HTTPStubsResponse in
81 | let stubPath = OHPathForFile("now-resolvable-relationships.json", UnresolvedRelationshipCacheTests.self)
82 | return fixture(filePath: stubPath!, headers: ["Content-Type": "application/json"])
83 | }.name = "Next sync: relationships resolved."
84 |
85 | client.sync(for: syncSpace) { result in
86 | switch result {
87 | case .success:
88 | XCTAssertNil(self.syncManager.cachedUnresolvedRelationships)
89 | case .failure(let error):
90 | XCTFail("\(error)")
91 |
92 | }
93 | nextExpectation.fulfill()
94 | }
95 |
96 | waitForExpectations(timeout: 10.0, handler: nil)
97 | }
98 |
99 | }
100 |
--------------------------------------------------------------------------------
/ContentfulPersistence.xcodeproj/xcshareddata/xcschemes/ContentfulPersistence_iOS.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
24 |
25 |
31 |
32 |
38 |
39 |
40 |
41 |
43 |
49 |
50 |
51 |
52 |
53 |
63 |
64 |
70 |
71 |
72 |
73 |
79 |
80 |
86 |
87 |
88 |
89 |
91 |
92 |
95 |
96 |
97 |
--------------------------------------------------------------------------------
/ContentfulPersistence.xcodeproj/xcshareddata/xcschemes/ContentfulPersistence_tvOS.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
24 |
25 |
31 |
32 |
38 |
39 |
40 |
41 |
43 |
49 |
50 |
51 |
52 |
53 |
63 |
64 |
70 |
71 |
72 |
73 |
79 |
80 |
86 |
87 |
88 |
89 |
91 |
92 |
95 |
96 |
97 |
--------------------------------------------------------------------------------
/ContentfulPersistence.xcodeproj/xcshareddata/xcschemes/ContentfulPersistence_macOS.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
24 |
25 |
31 |
32 |
38 |
39 |
40 |
41 |
43 |
49 |
50 |
51 |
52 |
53 |
63 |
64 |
70 |
71 |
72 |
73 |
79 |
80 |
86 |
87 |
88 |
89 |
91 |
92 |
95 |
96 |
97 |
--------------------------------------------------------------------------------
/Sources/ContentfulPersistence/Relationships/RelationshipData.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ContentfulPersistence
3 | //
4 |
5 | import Foundation
6 | import Contentful
7 |
8 | struct RelationshipData: Codable {
9 |
10 | private typealias FieldId = String
11 | private typealias ChildLookupKey = String
12 |
13 | private struct RelationshipKeyPath: Codable, Hashable {
14 |
15 | var parentId: Relationship.ParentId
16 | var fieldId: FieldId
17 |
18 | init(parentId: Relationship.ParentId, fieldName: Relationship.FieldName, localeCode: Relationship.LocaleCode) {
19 | let fieldId = "\(fieldName),\(localeCode ?? "-")"
20 | self.init(parentId: parentId, fieldId: fieldId)
21 | }
22 |
23 | init(parentId: Relationship.ParentId, fieldId: FieldId) {
24 | self.parentId = parentId
25 | self.fieldId = fieldId
26 | }
27 |
28 | }
29 |
30 | var count: Int {
31 | relationships.reduce(0) { $0 + $1.value.count }
32 | }
33 |
34 | var isEmpty: Bool {
35 | relationships.isEmpty
36 | }
37 |
38 | private var relationships: [Relationship.ParentId: [FieldId: Relationship]] = [:]
39 | private var relationshipKeyPathsByChild: [RelationshipChildId.RawValue: Set] = [:]
40 |
41 | mutating func append(_ relationship: Relationship) {
42 | let keyPath = Self.keyPath(for: relationship)
43 | setRelationship(relationship, for: keyPath)
44 | }
45 |
46 | mutating func delete(parentId: Relationship.ParentId) {
47 | let keyPaths = relationships[parentId]?.keys
48 | .map { RelationshipKeyPath(parentId: parentId, fieldId: $0) } ?? []
49 |
50 | for keyPath in keyPaths {
51 | setRelationship(nil, for: keyPath)
52 | }
53 | }
54 |
55 | mutating func delete(parentId: Relationship.ParentId, fieldName: Relationship.FieldName, localeCode: Relationship.LocaleCode) {
56 | let keyPath = RelationshipKeyPath(parentId: parentId, fieldName: fieldName, localeCode: localeCode)
57 | setRelationship(nil, for: keyPath)
58 | }
59 |
60 | func relationships(for childId: RelationshipChildId) -> [Relationship] {
61 | relationshipKeyPathsByChild[childId.rawValue]?
62 | .compactMap(relationship) ?? []
63 | }
64 |
65 | private func relationship(keyPath: RelationshipKeyPath) -> Relationship? {
66 | relationships[keyPath.parentId]?[keyPath.fieldId]
67 | }
68 |
69 | private mutating func setRelationship(_ relationship: Relationship?, for keyPath: RelationshipKeyPath) {
70 | var relationshipsByFieldIdentifier = relationships[keyPath.parentId] ?? [:]
71 |
72 | let newChildIds = Set(relationship?.children.elements.map { $0.id } ?? [])
73 |
74 | if let existingRelationship = relationshipsByFieldIdentifier[keyPath.fieldId] {
75 | let existingChildIds = Set(existingRelationship.children.elements.map { $0.id })
76 | let removedChildIds = existingChildIds.subtracting(newChildIds)
77 |
78 | for childId in removedChildIds {
79 | if var keyPaths = relationshipKeyPathsByChild[childId] {
80 | keyPaths.remove(keyPath)
81 | relationshipKeyPathsByChild[childId] = keyPaths
82 | }
83 | }
84 | }
85 |
86 | for childId in newChildIds {
87 | var keyPaths = relationshipKeyPathsByChild[childId] ?? Set()
88 | keyPaths.insert(keyPath)
89 | relationshipKeyPathsByChild[childId] = keyPaths
90 | }
91 |
92 | relationshipsByFieldIdentifier[keyPath.fieldId] = relationship
93 |
94 | if relationshipsByFieldIdentifier.isEmpty {
95 | relationships[keyPath.parentId] = nil
96 | } else {
97 | relationships[keyPath.parentId] = relationshipsByFieldIdentifier
98 | }
99 | }
100 |
101 | private static func keyPath(for relationship: Relationship) -> RelationshipKeyPath {
102 | RelationshipKeyPath(parentId: relationship.parentId, fieldName: relationship.fieldName, localeCode: relationship.localeCode)
103 | }
104 |
105 | }
106 |
--------------------------------------------------------------------------------
/Tests/ContentfulPersistenceTests/ComplexTestStubs/multi-page-link-resolution2.json:
--------------------------------------------------------------------------------
1 | {
2 | "sys": {
3 | "type": "Array"
4 | },
5 | "items": [
6 | {
7 | "sys": {
8 | "space": {
9 | "sys": {
10 | "type": "Link",
11 | "linkType": "Space",
12 | "id": "smf0sqiu0c5s"
13 | }
14 | },
15 | "id": "2XYdAPiR0I6SMAGiCOEukU",
16 | "type": "Entry",
17 | "createdAt": "2017-07-14T09:10:20.703Z",
18 | "updatedAt": "2017-07-14T09:10:20.703Z",
19 | "revision": 1,
20 | "contentType": {
21 | "sys": {
22 | "type": "Link",
23 | "linkType": "ContentType",
24 | "id": "link"
25 | }
26 | }
27 | },
28 | "fields": {
29 | "awesomeLinkTitle": {
30 | "en-US": "AWESOMELINK!!!"
31 | }
32 | }
33 | },
34 | {
35 | "sys": {
36 | "space": {
37 | "sys": {
38 | "type": "Link",
39 | "linkType": "Space",
40 | "id": "smf0sqiu0c5s"
41 | }
42 | },
43 | "id": "2eXtYKYxYAue2IQgaucoYW",
44 | "type": "Entry",
45 | "createdAt": "2017-06-20T12:51:13.910Z",
46 | "updatedAt": "2017-06-20T12:51:13.910Z",
47 | "revision": 1,
48 | "contentType": {
49 | "sys": {
50 | "type": "Link",
51 | "linkType": "ContentType",
52 | "id": "singleRecord"
53 | }
54 | }
55 | },
56 | "fields": {
57 | "textBody": {
58 | "en-US": "4"
59 | }
60 | }
61 | },
62 | {
63 | "sys": {
64 | "space": {
65 | "sys": {
66 | "type": "Link",
67 | "linkType": "Space",
68 | "id": "smf0sqiu0c5s"
69 | }
70 | },
71 | "id": "7vervB1KtUYWC22OCcmsKc",
72 | "type": "Entry",
73 | "createdAt": "2017-06-20T12:51:06.955Z",
74 | "updatedAt": "2017-06-20T12:51:06.955Z",
75 | "revision": 1,
76 | "contentType": {
77 | "sys": {
78 | "type": "Link",
79 | "linkType": "ContentType",
80 | "id": "singleRecord"
81 | }
82 | }
83 | },
84 | "fields": {
85 | "textBody": {
86 | "en-US": "3"
87 | }
88 | }
89 | },
90 | {
91 | "sys": {
92 | "space": {
93 | "sys": {
94 | "type": "Link",
95 | "linkType": "Space",
96 | "id": "smf0sqiu0c5s"
97 | }
98 | },
99 | "id": "NNPT58qeyYKauym8S0MUk",
100 | "type": "Entry",
101 | "createdAt": "2017-06-20T12:51:00.210Z",
102 | "updatedAt": "2017-06-20T12:51:00.210Z",
103 | "revision": 1,
104 | "contentType": {
105 | "sys": {
106 | "type": "Link",
107 | "linkType": "ContentType",
108 | "id": "singleRecord"
109 | }
110 | }
111 | },
112 | "fields": {
113 | "textBody": {
114 | "en-US": "2"
115 | }
116 | }
117 | },
118 | {
119 | "sys": {
120 | "space": {
121 | "sys": {
122 | "type": "Link",
123 | "linkType": "Space",
124 | "id": "smf0sqiu0c5s"
125 | }
126 | },
127 | "id": "3PbLvOJldSc6MqKEaIE6Ce",
128 | "type": "Entry",
129 | "createdAt": "2017-06-20T12:50:15.724Z",
130 | "updatedAt": "2017-06-20T12:50:15.724Z",
131 | "revision": 1,
132 | "contentType": {
133 | "sys": {
134 | "type": "Link",
135 | "linkType": "ContentType",
136 | "id": "singleRecord"
137 | }
138 | }
139 | },
140 | "fields": {
141 | "textBody": {
142 | "en-US": "1"
143 | }
144 | }
145 | }
146 | ],
147 | "nextSyncUrl": "http://cdn.contentful.com/spaces/smf0sqiu0c5s/environments/master/sync?sync_token=w5ZGw6JFwqZmVcKsE8Kow4grw45QdyYJwpfCm3bCmxJXGsOQIUbCk8OeIMKTw6nCvsOCQsOAw4VTEsO5DCLDkldRwrfDmCgFw78vHcOaCMKcW0fDmMO3ORw_ZMKdbCfDrQ4NPMOOwpPDjWxqesK5e8KKwok"
148 | }
149 |
--------------------------------------------------------------------------------
/Tests/ContentfulPersistenceTests/ComplexTestStubs/now-resolvable-relationships.json:
--------------------------------------------------------------------------------
1 | {
2 | "sys": {
3 | "type": "Array"
4 | },
5 | "items": [
6 | {
7 | "sys": {
8 | "space": {
9 | "sys": {
10 | "type": "Link",
11 | "linkType": "Space",
12 | "id": "smf0sqiu0c5s"
13 | }
14 | },
15 | "id": "2XYdAPiR0I6SMAGiCOEukU",
16 | "type": "Entry",
17 | "createdAt": "2017-07-14T09:10:20.703Z",
18 | "updatedAt": "2017-07-14T09:10:20.703Z",
19 | "revision": 1,
20 | "contentType": {
21 | "sys": {
22 | "type": "Link",
23 | "linkType": "ContentType",
24 | "id": "link"
25 | }
26 | }
27 | },
28 | "fields": {
29 | "awesomeLinkTitle": {
30 | "en-US": "AWESOMELINK!!!"
31 | }
32 | }
33 | },
34 | {
35 | "sys": {
36 | "space": {
37 | "sys": {
38 | "type": "Link",
39 | "linkType": "Space",
40 | "id": "smf0sqiu0c5s"
41 | }
42 | },
43 | "id": "2eXtYKYxYAue2IQgaucoYW",
44 | "type": "Entry",
45 | "createdAt": "2017-06-20T12:51:13.910Z",
46 | "updatedAt": "2017-06-20T12:51:13.910Z",
47 | "revision": 1,
48 | "contentType": {
49 | "sys": {
50 | "type": "Link",
51 | "linkType": "ContentType",
52 | "id": "singleRecord"
53 | }
54 | }
55 | },
56 | "fields": {
57 | "textBody": {
58 | "en-US": "4"
59 | }
60 | }
61 | },
62 | {
63 | "sys": {
64 | "space": {
65 | "sys": {
66 | "type": "Link",
67 | "linkType": "Space",
68 | "id": "smf0sqiu0c5s"
69 | }
70 | },
71 | "id": "7vervB1KtUYWC22OCcmsKc",
72 | "type": "Entry",
73 | "createdAt": "2017-06-20T12:51:06.955Z",
74 | "updatedAt": "2017-06-20T12:51:06.955Z",
75 | "revision": 1,
76 | "contentType": {
77 | "sys": {
78 | "type": "Link",
79 | "linkType": "ContentType",
80 | "id": "singleRecord"
81 | }
82 | }
83 | },
84 | "fields": {
85 | "textBody": {
86 | "en-US": "3"
87 | }
88 | }
89 | },
90 | {
91 | "sys": {
92 | "space": {
93 | "sys": {
94 | "type": "Link",
95 | "linkType": "Space",
96 | "id": "smf0sqiu0c5s"
97 | }
98 | },
99 | "id": "NNPT58qeyYKauym8S0MUk",
100 | "type": "Entry",
101 | "createdAt": "2017-06-20T12:51:00.210Z",
102 | "updatedAt": "2017-06-20T12:51:00.210Z",
103 | "revision": 1,
104 | "contentType": {
105 | "sys": {
106 | "type": "Link",
107 | "linkType": "ContentType",
108 | "id": "singleRecord"
109 | }
110 | }
111 | },
112 | "fields": {
113 | "textBody": {
114 | "en-US": "2"
115 | }
116 | }
117 | },
118 | {
119 | "sys": {
120 | "space": {
121 | "sys": {
122 | "type": "Link",
123 | "linkType": "Space",
124 | "id": "smf0sqiu0c5s"
125 | }
126 | },
127 | "id": "3PbLvOJldSc6MqKEaIE6Ce",
128 | "type": "Entry",
129 | "createdAt": "2017-06-20T12:50:15.724Z",
130 | "updatedAt": "2017-06-20T12:50:15.724Z",
131 | "revision": 1,
132 | "contentType": {
133 | "sys": {
134 | "type": "Link",
135 | "linkType": "ContentType",
136 | "id": "singleRecord"
137 | }
138 | }
139 | },
140 | "fields": {
141 | "textBody": {
142 | "en-US": "1"
143 | }
144 | }
145 | }
146 | ],
147 | "nextSyncUrl": "http://cdn.contentful.com/spaces/smf0sqiu0c5s/environments/master/sync?sync_token=w5ZGw6JFwqZmVcKsE8Kow4grw45QdyYJwpfCm3bCmxJXGsOQIUbCk8OeIMKTw6nCvsOCQsOAw4VTEsO5DCLDkldRwrfDmCgFw78vHcOaCMKcW0fDmMO3ORw_ZMKdbCfDrQ4NPMOOwpPDjWxqesK5e8KKwok"
148 | }
149 |
--------------------------------------------------------------------------------
/Sources/ContentfulPersistence/DataCache.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DataCache.swift
3 | // ContentfulPersistence
4 | //
5 | // Created by Boris Bügling on 15/04/16.
6 | // Copyright © 2016 Contentful GmbH. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import Contentful
11 |
12 | typealias CacheKey = String
13 |
14 | protocol DataCacheProtocol {
15 | init(persistenceStore: PersistenceStore, assetType: AssetPersistable.Type, entryTypes: [EntryPersistable.Type])
16 |
17 | func entry(for cacheKey: CacheKey) -> EntryPersistable?
18 | func item(for cacheKey: CacheKey) -> NSObject?
19 | }
20 |
21 | /// Does not actually cache anything, but directly uses the persistence store instead
22 | class NoDataCache: DataCacheProtocol {
23 | fileprivate let assetType: AssetPersistable.Type
24 | fileprivate let entryTypes: [EntryPersistable.Type]
25 | fileprivate let store: PersistenceStore
26 |
27 | required init(persistenceStore: PersistenceStore, assetType: AssetPersistable.Type, entryTypes: [EntryPersistable.Type]) {
28 | self.assetType = assetType
29 | self.entryTypes = entryTypes
30 | self.store = persistenceStore
31 | }
32 |
33 | fileprivate func itemsOf(_ types: [ContentSysPersistable.Type], cacheKey: CacheKey) -> EntryPersistable? {
34 | let predicate = ContentfulPersistence.predicate(for: cacheKey)
35 |
36 | let items: [EntryPersistable] = types.compactMap {
37 | if let result = try? store.fetchAll(type: $0, predicate: predicate) as [EntryPersistable] {
38 | return result.first
39 | }
40 | return nil
41 | }
42 |
43 | return items.first
44 | }
45 |
46 | func entry(for cacheKey: CacheKey) -> EntryPersistable? {
47 | return itemsOf(entryTypes, cacheKey: cacheKey)
48 | }
49 |
50 | func item(for cacheKey: CacheKey) -> NSObject? {
51 | return itemsOf([assetType] + entryTypes, cacheKey: cacheKey)
52 | }
53 | }
54 |
55 |
56 | /// Implemented using `NSCache`
57 | class DataCache: DataCacheProtocol {
58 |
59 | public static func cacheKey(for resource: ContentSysPersistable) -> CacheKey {
60 | let localeCode = resource.localeCode ?? ""
61 | let cacheKey = resource.id + "_" + localeCode
62 | return cacheKey
63 | }
64 |
65 | public static func cacheKey(for resource: LocalizableResource) -> CacheKey {
66 | let cacheKey = resource.id + "_" + resource.currentlySelectedLocale.code
67 | return cacheKey
68 | }
69 |
70 | public static func cacheKey(for identifier: String, localeCode: String?) -> CacheKey {
71 | let cacheKey = identifier + "_" + (localeCode ?? "")
72 | return cacheKey
73 | }
74 |
75 | fileprivate let assetCache = NSCache()
76 | fileprivate let entryCache = NSCache()
77 |
78 | required init(persistenceStore: PersistenceStore, assetType: AssetPersistable.Type, entryTypes: [EntryPersistable.Type]) {
79 | let truePredicate = NSPredicate(value: true)
80 |
81 | let assets: [AssetPersistable]? = try? persistenceStore.fetchAll(type: assetType, predicate: truePredicate)
82 | assets?.forEach { type(of: self).cacheResource(in: assetCache, resource: $0) }
83 |
84 | for entryType in entryTypes {
85 | let entries: [EntryPersistable]? = try? persistenceStore.fetchAll(type: entryType, predicate: truePredicate)
86 | entries?.forEach { type(of: self).cacheResource(in: entryCache, resource: $0) }
87 | }
88 | }
89 |
90 | func asset(for cacheKey: CacheKey) -> AssetPersistable? {
91 | return assetCache.object(forKey: cacheKey as AnyObject) as? AssetPersistable
92 | }
93 |
94 | func entry(for cacheKey: CacheKey) -> EntryPersistable? {
95 | return entryCache.object(forKey: cacheKey as AnyObject) as? EntryPersistable
96 | }
97 |
98 | func item(for cacheKey: CacheKey) -> NSObject? {
99 | var target: NSObject? = self.asset(for: cacheKey)
100 |
101 | if target == nil {
102 | target = self.entry(for: cacheKey)
103 | }
104 |
105 | return target
106 | }
107 |
108 | fileprivate static func cacheResource(in cache: NSCache, resource: ContentSysPersistable) {
109 | let cacheKey = DataCache.cacheKey(for: resource)
110 | cache.setObject(resource as AnyObject, forKey: cacheKey as AnyObject)
111 | }
112 | }
113 |
--------------------------------------------------------------------------------
/Tests/ContentfulPersistenceTests/BatchSyncTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // BatchSyncTests.swift
3 | // ContentfulPersistence
4 | //
5 | // Created by Marius Kurgonas on 29/02/2024.
6 | // Copyright © 2024 Contentful GmbH. All rights reserved.
7 | //
8 |
9 | @testable import ContentfulPersistence
10 | import Contentful
11 | import XCTest
12 | import Foundation
13 | import CoreData
14 | import OHHTTPStubs
15 | import CoreLocation
16 |
17 | class BatchSyncTests: XCTestCase {
18 |
19 | var syncManager: SynchronizationManager!
20 |
21 | var client: Client!
22 |
23 | lazy var store: CoreDataStore = {
24 | return CoreDataStore(context: self.managedObjectContext)
25 | }()
26 |
27 | lazy var managedObjectContext: NSManagedObjectContext = {
28 | return TestHelpers.managedObjectContext(forMOMInTestBundleNamed: "ComplexTest")
29 | }()
30 |
31 | // Before each test.
32 | override func setUp() {
33 | HTTPStubs.removeAllStubs()
34 |
35 | let persistenceModel = PersistenceModel(spaceType: ComplexSyncInfo.self, assetType: ComplexAsset.self, entryTypes: [SingleRecord.self, Link.self, RecordWithNonOptionalRelation.self])
36 |
37 |
38 | client = Client(spaceId: "smf0sqiu0c5s",
39 | accessToken: "14d305ad526d4487e21a99b5b9313a8877ce6fbf540f02b12189eea61550ef34")
40 | self.syncManager = SynchronizationManager(client: client,
41 | localizationScheme: .default,
42 | persistenceStore: self.store,
43 | persistenceModel: persistenceModel)
44 | }
45 |
46 | // After each test.
47 | override func tearDown() {
48 | HTTPStubs.removeAllStubs()
49 | }
50 |
51 | func testBatchUpdating() {
52 |
53 | let expectation = self.expectation(description: "testBatchUpdating")
54 |
55 | stub(condition: isPath("/spaces/smf0sqiu0c5s/environments/master/sync")) { request -> HTTPStubsResponse in
56 | let urlString = request.url!.absoluteString
57 | let queryItems = URLComponents(string: urlString)!.queryItems!
58 | for queryItem in queryItems {
59 | if queryItem.name == "initial" {
60 | let stubPath = OHPathForFile("batch-page.json", ComplexSyncTests.self)
61 | return fixture(filePath: stubPath!, headers: ["Content-Type": "application/json"])
62 | } else if queryItem.name == "sync_token" && queryItem.value == "wonDrcKnRgcSOF4-wrDCgcKefWzCgsOxwrfCq8KOfMOdXUPCvEnChwEEO8KFwqHDj8KIKHJYwr0Mw4wKw7UAVcOWYsOtw4nCvTUvDn0pw4gBRMKrGyVxIsOcQMKrwpfDhcOZwq3CkzDDvcKsJ8Oew58fJAQLwr3Cv2MGw4h6LcK_w4whQ8KMwr3ClMOhw5zDjMOswqAyw7XDpXDDsiXCvsKfw7JcwqjDucKmKMODwpDDlyvCrCnCjsOww43ClMKUwq8Xw75pXA" {
63 | let stubPath = OHPathForFile("batch-page2.json", ComplexSyncTests.self)
64 | return fixture(filePath: stubPath!, headers: ["Content-Type": "application/json"])
65 | }
66 | }
67 | let stubPath = OHPathForFile("simple-update-initial-sync.json", ComplexSyncTests.self)
68 | return fixture(filePath: stubPath!, headers: ["Content-Type": "application/json"])
69 | }.name = "Initial sync stub"
70 |
71 | client.sync() { result in
72 | switch result {
73 | case .success(let space):
74 | // There are only SingleRecords, Links and ComplexAssets in stub files
75 | // All should be saved to CoreData
76 | self.managedObjectContext.perform {
77 | do {
78 | let records: [SingleRecord] = try self.store.fetchAll(type: SingleRecord.self, predicate: NSPredicate(value: true))
79 | let links: [Link] = try self.store.fetchAll(type: Link.self, predicate: NSPredicate(value: true))
80 | XCTAssertEqual(records.count + links.count, space.entries.count)
81 | let assets: [ComplexAsset] = try self.store.fetchAll(type: ComplexAsset.self, predicate: NSPredicate(value: true))
82 | XCTAssertEqual(assets.count, space.assets.count)
83 | } catch {
84 | XCTAssert(false, "Fetching posts should not throw an error")
85 | }
86 | expectation.fulfill()
87 | }
88 | case .failure(let error):
89 | XCTFail("\(error)")
90 | expectation.fulfill()
91 | }
92 | }
93 |
94 | waitForExpectations(timeout: 10.0, handler: nil)
95 | HTTPStubs.removeAllStubs()
96 | }
97 |
98 | }
99 |
--------------------------------------------------------------------------------
/Sources/ContentfulPersistence/Persistable.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Persistable.swift
3 | // ContentfulPersistence
4 | //
5 | // Created by JP Wright on 15.06.17.
6 | // Copyright © 2017 Contentful GmbH. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import Contentful
11 |
12 | /**
13 | Structure used to define the schema of your CoreData model and it's correlation to your Content Model
14 | in Contentful. Pass in your `NSManagedObject` subclasses conforming to `SyncSpacePersistable`,
15 | `AssetPersistable` and `EntryPersistable` to properly map your content to CoreData entities.
16 | */
17 | public struct PersistenceModel {
18 | public let spaceType: SyncSpacePersistable.Type
19 | public let assetType: AssetPersistable.Type
20 | public let entryTypes: [EntryPersistable.Type]
21 |
22 | public init(spaceType: SyncSpacePersistable.Type,
23 | assetType: AssetPersistable.Type,
24 | entryTypes: [EntryPersistable.Type]) {
25 |
26 | self.spaceType = spaceType
27 | self.assetType = assetType
28 | self.entryTypes = entryTypes
29 | }
30 | }
31 |
32 | /**
33 | Base protocol for all `AssetPersistable` and `EntryPersistable`.
34 | */
35 | // Protocols are marked with @objc attribute for two reasons:
36 | // 1) CoreData requires that model classes inherit from `NSManagedObject`
37 | // 2) @objc enables optional protocol methods that don't require implementation.
38 | public protocol ContentSysPersistable: NSObject {
39 | /// The unique identifier of the Resource.
40 | var id: String { get set }
41 |
42 | /// The code which represents which locale the Resource of interest contains data for.
43 | var localeCode: String? { get set }
44 |
45 | /// The date representing the last time the Contentful Resource was updated.
46 | var updatedAt: Date? { get set }
47 |
48 | /// The date that the Contentful Resource was first created.
49 | var createdAt: Date? { get set }
50 | }
51 |
52 | /**
53 | Your `NSManagedObject` subclass should conform to this `SyncSpacePersistable` to enable continuing
54 | a sync from a sync token on subsequent launches of your application rather than re-fetching all data
55 | during an initialSync. See [Contentful's Content Delivery API docs](https://www.contentful.com/developers/docs/references/content-delivery-api/#/reference/synchronization/pagination-and-subsequent-syncs)
56 | for more information.
57 | */
58 | public protocol SyncSpacePersistable: AnyObject {
59 | /// The current synchronization token
60 | var syncToken: String? { get set }
61 |
62 | /// Database version to do migrations
63 | var dbVersion: NSNumber? { get set }
64 | }
65 |
66 | /**
67 | Conform to `AssetPersistable` protocol to enable mapping of your Contentful media Assets to
68 | your `NSManagedObject` subclass.
69 | */
70 | public protocol AssetPersistable: ContentSysPersistable, AssetProtocol {
71 |
72 | /// The title of the Asset.
73 | var title: String? { get set }
74 |
75 | /// The description of the asset. Named `assetDescription` to avoid clashing with `description`
76 | /// property that all NSObject's have.
77 | var assetDescription: String? { get set }
78 |
79 | /// URL of the Asset.
80 | var urlString: String? { get set }
81 |
82 | /// The name of the underlying binary media file.
83 | var fileName: String? { get set }
84 |
85 | /// The type of the underlying binary media file: e.g. `image/png`
86 | var fileType: String? { get set }
87 |
88 | /// The byte size of the underlying binary media file.
89 | var size: NSNumber? { get set }
90 |
91 | /// If the binary media file is an image, this property describes the images width in pixels.
92 | var width: NSNumber? { get set }
93 |
94 | /// If the binary media file is an image, this property describes the images height in pixels.
95 | var height: NSNumber? { get set }
96 | }
97 |
98 | /**
99 | Conform to `EntryPersistable` protocol to enable mapping of your Contentful content type to
100 | your `NSManagedObject` subclass.
101 | */
102 | public protocol EntryPersistable: ContentSysPersistable {
103 | /// The identifier of the Contentful content type that will map to this type of `EntryPersistable`
104 | static var contentTypeId: ContentTypeId { get }
105 |
106 | /// This method must be implemented to provide a custom mapping from Contentful fields to Swift properties.
107 | /// Note that after Swift 4 is release, this method will be deprecated in favor of leveraging the
108 | /// auto-synthesized `CodingKeys` enum that is generated for all types conforming to `Codable`.
109 | static func fieldMapping() -> [FieldName: String]
110 | }
111 |
--------------------------------------------------------------------------------
/Tests/ContentfulPersistenceTests/Relationships/RelationshipCacheTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ContentfulPersistence
3 | //
4 |
5 | @testable import ContentfulPersistence
6 | import XCTest
7 |
8 | class RelationshipCacheTests: XCTestCase {
9 |
10 | func test_relationships_areCachedOnDisk() {
11 | let fileName = makeFileName()
12 | let cache = RelationshipCache(cacheFileName: fileName)
13 |
14 | XCTAssertEqual(cache.relationships.count, 0)
15 |
16 | cache.add(relationship: makeToOne1())
17 | cache.add(relationship: makeToOne2())
18 | cache.add(relationship: makeToMany1())
19 |
20 | cache.save()
21 |
22 | // Verify
23 | let verifyCache = RelationshipCache(cacheFileName: fileName)
24 |
25 | verifyCache.add(relationship: makeToOne1(localeCode: "en-US"))
26 |
27 | XCTAssertEqual(verifyCache.relationships.count, 4)
28 | }
29 |
30 | func test_relationship_isDeleted() {
31 | let fileName = makeFileName()
32 | let cache = RelationshipCache(cacheFileName: fileName)
33 |
34 | XCTAssertEqual(cache.relationships.count, 0)
35 |
36 | cache.add(relationship: makeToOne1())
37 |
38 | let toOne2 = makeToOne2()
39 | cache.add(relationship: toOne2)
40 |
41 | let toMany1 = makeToMany1()
42 | cache.add(relationship: toMany1)
43 |
44 | XCTAssertEqual(cache.relationships.count, 3)
45 |
46 | cache.delete(parentId: toOne2.parentId)
47 | XCTAssertEqual(cache.relationships.count, 2)
48 |
49 | cache.delete(parentId: toMany1.parentId)
50 | XCTAssertEqual(cache.relationships.count, 1)
51 |
52 | cache.add(relationship: toOne2)
53 | cache.add(relationship: toMany1)
54 |
55 | cache.delete(
56 | parentId: toOne2.parentId,
57 | fieldName: toOne2.fieldName,
58 | localeCode: toOne2.localeCode
59 | )
60 |
61 | XCTAssertEqual(cache.relationships.count, 2)
62 |
63 | cache.delete(
64 | parentId: toMany1.parentId,
65 | fieldName: toMany1.fieldName,
66 | localeCode: toMany1.localeCode
67 | )
68 |
69 | XCTAssertEqual(cache.relationships.count, 1)
70 | }
71 |
72 | func test_deleteRelationship_byLocale() {
73 | let fileName = makeFileName()
74 | let cache = RelationshipCache(cacheFileName: fileName)
75 |
76 | XCTAssertEqual(cache.relationships.count, 0)
77 |
78 | let toOne1a = makeToOne1(localeCode: "en-US")
79 | let toOne1b = makeToOne1(localeCode: "pl-PL")
80 | cache.add(relationship: toOne1a)
81 | cache.add(relationship: toOne1b)
82 |
83 | cache.delete(
84 | parentId: toOne1a.parentId,
85 | fieldName: toOne1a.fieldName,
86 | localeCode: nil
87 | )
88 |
89 | XCTAssertEqual(cache.relationships.count, 2)
90 |
91 | cache.delete(
92 | parentId: toOne1a.parentId,
93 | fieldName: toOne1a.fieldName,
94 | localeCode: "en-GB"
95 | )
96 |
97 | XCTAssertEqual(cache.relationships.count, 2)
98 |
99 | cache.delete(
100 | parentId: toOne1a.parentId,
101 | fieldName: toOne1a.fieldName,
102 | localeCode: toOne1a.localeCode
103 | )
104 |
105 | XCTAssertEqual(cache.relationships.count, 1)
106 |
107 | cache.delete(
108 | parentId: toOne1b.parentId,
109 | fieldName: toOne1b.fieldName,
110 | localeCode: toOne1b.localeCode
111 | )
112 |
113 | XCTAssertEqual(cache.relationships.count, 0)
114 | }
115 |
116 | private func makeToOne1(localeCode: String? = nil) -> Relationship {
117 | var childId = "dog-1"
118 | if let localeCode = localeCode {
119 | childId += "_\(localeCode)"
120 | }
121 |
122 | return .init(
123 | parentType: "person",
124 | parentId: "person-1",
125 | fieldName: "dog",
126 | childId: .init(rawValue: childId)
127 | )
128 | }
129 |
130 | private func makeToOne2() -> Relationship {
131 | .init(
132 | parentType: "person",
133 | parentId: "person-2",
134 | fieldName: "cat",
135 | childId: .init(rawValue: "cat-1")
136 | )
137 | }
138 |
139 | private func makeToMany1() -> Relationship {
140 | .init(
141 | parentType: "person",
142 | parentId: "person-3",
143 | fieldName: "things",
144 | childIds: [
145 | .init(rawValue: "cat-1"),
146 | .init(rawValue: "dog-2")
147 | ]
148 | )
149 | }
150 |
151 | private func makeFileName() -> String {
152 | UUID().uuidString + "-\(Date().timeIntervalSince1970)"
153 | }
154 | }
155 |
--------------------------------------------------------------------------------
/docs/undocumented.json:
--------------------------------------------------------------------------------
1 | {
2 | "warnings": [
3 | {
4 | "file": "/Volumes/Extreme_SSD/Dev/Contentful/ContentfulTests/contentful_persistence/Sources/ContentfulPersistence/CoreDataStore.swift",
5 | "line": 31,
6 | "symbol": "CoreDataStore.fetchRequest(for:predicate:)",
7 | "symbol_kind": "source.lang.swift.decl.function.method.instance",
8 | "warning": "undocumented"
9 | },
10 | {
11 | "file": "/Volumes/Extreme_SSD/Dev/Contentful/ContentfulTests/contentful_persistence/Sources/ContentfulPersistence/CoreDataStore.swift",
12 | "line": 188,
13 | "symbol": "CoreDataStore.performBlock(block:)",
14 | "symbol_kind": "source.lang.swift.decl.function.method.instance",
15 | "warning": "undocumented"
16 | },
17 | {
18 | "file": "/Volumes/Extreme_SSD/Dev/Contentful/ContentfulTests/contentful_persistence/Sources/ContentfulPersistence/CoreDataStore.swift",
19 | "line": 201,
20 | "symbol": "CoreDataStore.performAndWait(block:)",
21 | "symbol_kind": "source.lang.swift.decl.function.method.instance",
22 | "warning": "undocumented"
23 | },
24 | {
25 | "file": "/Volumes/Extreme_SSD/Dev/Contentful/ContentfulTests/contentful_persistence/Sources/ContentfulPersistence/Persistable.swift",
26 | "line": 18,
27 | "symbol": "PersistenceModel.spaceType",
28 | "symbol_kind": "source.lang.swift.decl.var.instance",
29 | "warning": "undocumented"
30 | },
31 | {
32 | "file": "/Volumes/Extreme_SSD/Dev/Contentful/ContentfulTests/contentful_persistence/Sources/ContentfulPersistence/Persistable.swift",
33 | "line": 19,
34 | "symbol": "PersistenceModel.assetType",
35 | "symbol_kind": "source.lang.swift.decl.var.instance",
36 | "warning": "undocumented"
37 | },
38 | {
39 | "file": "/Volumes/Extreme_SSD/Dev/Contentful/ContentfulTests/contentful_persistence/Sources/ContentfulPersistence/Persistable.swift",
40 | "line": 20,
41 | "symbol": "PersistenceModel.entryTypes",
42 | "symbol_kind": "source.lang.swift.decl.var.instance",
43 | "warning": "undocumented"
44 | },
45 | {
46 | "file": "/Volumes/Extreme_SSD/Dev/Contentful/ContentfulTests/contentful_persistence/Sources/ContentfulPersistence/Persistable.swift",
47 | "line": 22,
48 | "symbol": "PersistenceModel.init(spaceType:assetType:entryTypes:)",
49 | "symbol_kind": "source.lang.swift.decl.function.method.instance",
50 | "warning": "undocumented"
51 | },
52 | {
53 | "file": "/Volumes/Extreme_SSD/Dev/Contentful/ContentfulTests/contentful_persistence/Sources/ContentfulPersistence/Persistable.swift",
54 | "line": 38,
55 | "symbol": "ContentSysPersistable",
56 | "symbol_kind": "source.lang.swift.decl.protocol",
57 | "warning": "undocumented"
58 | },
59 | {
60 | "file": "/Volumes/Extreme_SSD/Dev/Contentful/ContentfulTests/contentful_persistence/Sources/ContentfulPersistence/PersistenceStore.swift",
61 | "line": 92,
62 | "symbol": "PersistenceStore.performBlock(block:)",
63 | "symbol_kind": "source.lang.swift.decl.function.method.instance",
64 | "warning": "undocumented"
65 | },
66 | {
67 | "file": "/Volumes/Extreme_SSD/Dev/Contentful/ContentfulTests/contentful_persistence/Sources/ContentfulPersistence/PersistenceStore.swift",
68 | "line": 94,
69 | "symbol": "PersistenceStore.performAndWait(block:)",
70 | "symbol_kind": "source.lang.swift.decl.function.method.instance",
71 | "warning": "undocumented"
72 | },
73 | {
74 | "file": "/Volumes/Extreme_SSD/Dev/Contentful/ContentfulTests/contentful_persistence/Sources/ContentfulPersistence/SynchronizationManager.swift",
75 | "line": 54,
76 | "symbol": "SynchronizationManager.DBVersions",
77 | "symbol_kind": "source.lang.swift.decl.enum",
78 | "warning": "undocumented"
79 | },
80 | {
81 | "file": "/Volumes/Extreme_SSD/Dev/Contentful/ContentfulTests/contentful_persistence/Sources/ContentfulPersistence/SynchronizationManager.swift",
82 | "line": 55,
83 | "symbol": "SynchronizationManager.DBVersions.default",
84 | "symbol_kind": "source.lang.swift.decl.enumelement",
85 | "warning": "undocumented"
86 | },
87 | {
88 | "file": "/Volumes/Extreme_SSD/Dev/Contentful/ContentfulTests/contentful_persistence/Sources/ContentfulPersistence/SynchronizationManager.swift",
89 | "line": 155,
90 | "symbol": "SynchronizationManager.syncToken",
91 | "symbol_kind": "source.lang.swift.decl.var.instance",
92 | "warning": "undocumented"
93 | },
94 | {
95 | "file": "/Volumes/Extreme_SSD/Dev/Contentful/ContentfulTests/contentful_persistence/Sources/ContentfulPersistence/SynchronizationManager.swift",
96 | "line": 163,
97 | "symbol": "SynchronizationManager.performAndWait(block:)",
98 | "symbol_kind": "source.lang.swift.decl.function.method.instance",
99 | "warning": "undocumented"
100 | }
101 | ],
102 | "source_directory": "/Volumes/Extreme_SSD/Dev/Contentful/ContentfulTests/contentful_persistence"
103 | }
--------------------------------------------------------------------------------
/Tests/ContentfulPersistenceTests/Relationships/RelationshipManagerTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ContentfulPersistence
3 | //
4 |
5 | import Contentful
6 | @testable import ContentfulPersistence
7 | import XCTest
8 |
9 | class RelationshipManagerTests: XCTestCase {
10 |
11 | func test_manager_worksCorrectly() {
12 | let manager = RelationshipsManager(cacheFileName: makeFileName())
13 |
14 | let entry1 = EntryA(id: "person-1")
15 |
16 | for _ in 0..<5 {
17 | manager.cacheToOneRelationship(
18 | parent: entry1,
19 | childId: RelationshipChildId(rawValue: "dog-1"),
20 | fieldName: "dog"
21 | )
22 | }
23 |
24 | XCTAssertEqual(manager.relationships.count, 1)
25 |
26 | let entry2 = EntryA(id: "person-2")
27 |
28 | for _ in 0..<5 {
29 | manager.cacheToOneRelationship(
30 | parent: entry2,
31 | childId: RelationshipChildId(rawValue: "dog-1"),
32 | fieldName: "dog"
33 | )
34 | }
35 |
36 | XCTAssertEqual(manager.relationships.count, 2)
37 |
38 | for _ in 0..<5 {
39 | manager.cacheToManyRelationship(
40 | parent: entry1,
41 | childIds: [
42 | RelationshipChildId(rawValue: "cat-1"),
43 | RelationshipChildId(rawValue: "cat-2"),
44 | RelationshipChildId(rawValue: "cat-3")
45 | ],
46 | fieldName: "cats"
47 | )
48 | }
49 |
50 | XCTAssertEqual(manager.relationships.count, 3)
51 |
52 | manager.delete(parentId: entry1.id, fieldName: "cats", localeCode: nil)
53 | XCTAssertEqual(manager.relationships.count, 2)
54 |
55 | manager.delete(parentId: entry2.id)
56 | XCTAssertEqual(manager.relationships.count, 1)
57 |
58 | manager.delete(parentId: entry1.id)
59 | XCTAssertEqual(manager.relationships.count, 0)
60 | }
61 |
62 | func testStaleToOneRelationshipsAreRemoved() {
63 | let manager = RelationshipsManager(cacheFileName: makeFileName())
64 |
65 | let entry1 = EntryA(id: "person-1")
66 | let dog1 = RelationshipChildId(rawValue: "dog-1")
67 | let dog2 = RelationshipChildId(rawValue: "dog-2")
68 |
69 | manager.cacheToOneRelationship(
70 | parent: entry1,
71 | childId: dog1,
72 | fieldName: "dog"
73 | )
74 |
75 | XCTAssertNotNil(manager.relationships.relationships(for: dog1))
76 |
77 | manager.cacheToOneRelationship(
78 | parent: entry1,
79 | childId: dog2,
80 | fieldName: "dog"
81 | )
82 |
83 | XCTAssertTrue(manager.relationships.relationships(for: dog1).isEmpty)
84 | XCTAssertFalse(manager.relationships.relationships(for: dog2).isEmpty)
85 | }
86 |
87 | func testStaleToManyRelationshipsAreRemoved() {
88 | let manager = RelationshipsManager(cacheFileName: makeFileName())
89 |
90 | let entry1 = EntryA(id: "person-1")
91 | let cat1 = RelationshipChildId(rawValue: "cat-1")
92 | let cat2 = RelationshipChildId(rawValue: "cat-2")
93 | let cat3 = RelationshipChildId(rawValue: "cat-3")
94 | let cat4 = RelationshipChildId(rawValue: "cat-4")
95 |
96 | manager.cacheToManyRelationship(
97 | parent: entry1,
98 | childIds: [
99 | cat1,
100 | cat2,
101 | cat3
102 | ],
103 | fieldName: "cats"
104 | )
105 |
106 | XCTAssertFalse(manager.relationships.relationships(for: cat1).isEmpty)
107 | XCTAssertFalse(manager.relationships.relationships(for: cat2).isEmpty)
108 | XCTAssertFalse(manager.relationships.relationships(for: cat3).isEmpty)
109 |
110 | manager.cacheToManyRelationship(
111 | parent: entry1,
112 | childIds: [
113 | cat1,
114 | cat2,
115 | cat4
116 | ],
117 | fieldName: "cats"
118 | )
119 |
120 | XCTAssertFalse(manager.relationships.relationships(for: cat1).isEmpty)
121 | XCTAssertFalse(manager.relationships.relationships(for: cat2).isEmpty)
122 | XCTAssertTrue(manager.relationships.relationships(for: cat3).isEmpty)
123 | XCTAssertFalse(manager.relationships.relationships(for: cat4).isEmpty)
124 | }
125 |
126 | private func makeFileName() -> String {
127 | UUID().uuidString + "-\(Date().timeIntervalSince1970)"
128 | }
129 | }
130 |
131 | private class EntryA: NSObject, EntryPersistable {
132 |
133 | static var contentTypeId: ContentTypeId = "entry-a"
134 |
135 | static func fieldMapping() -> [FieldName : String] {
136 | [:]
137 | }
138 |
139 | var id: String
140 | var localeCode: String?
141 | var updatedAt: Date? = nil
142 | var createdAt: Date? = nil
143 |
144 | init(id: String = "", localeCode: String? = nil) {
145 | self.id = id
146 | self.localeCode = localeCode
147 | }
148 | }
149 |
--------------------------------------------------------------------------------
/Tests/ContentfulPersistenceTests/RichTextDocumentTransformableTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RichTextDocumentTransformableTests.swift
3 | // ContentfulPersistence
4 | //
5 | // Created by Manuel Maly on 23.07.19.
6 | // Copyright © 2019 Contentful GmbH. All rights reserved.
7 | //
8 |
9 |
10 | @testable import ContentfulPersistence
11 | import Contentful
12 | import XCTest
13 | import Foundation
14 | import CoreData
15 |
16 | class RichTextDocumentTransformableTests: XCTestCase {
17 |
18 | lazy var store: CoreDataStore = {
19 | return CoreDataStore(context: self.managedObjectContext)
20 | }()
21 |
22 | lazy var managedObjectContext: NSManagedObjectContext = {
23 | return TestHelpers.managedObjectContext(forMOMInTestBundleNamed: "RichTextDocumentTransformableTest")
24 | }()
25 |
26 | override func tearDown() {
27 | try? store.delete(type: RichTextDocumentRecord.self, predicate: NSPredicate(value: true))
28 | }
29 |
30 | func testRichTextDocumentPersistence() {
31 |
32 | guard let richTextDocumentRecord: RichTextDocumentRecord = try? store.create(type: RichTextDocumentRecord.self) else {
33 | XCTFail()
34 | return
35 | }
36 |
37 | let document = richTextDocument()
38 | richTextDocumentRecord.richTextDocument = document
39 |
40 | try? store.save()
41 | managedObjectContext.reset()
42 |
43 | // For some reason, resetting the managed object context does indeed
44 | // lead to `richTextDocumentRecord` being faulted, but its `richTextDocument`
45 | // seems to live on (maybe in _CDSnapshot_RichTextDocumentRecord_?) and
46 | // comes up again when fetching the first record from the store. A better way
47 | // of destroying the linked `RichTextDocument` needs to be found to make this
48 | // test case actually useful.
49 |
50 | let documents: [RichTextDocumentRecord]? = try? store.fetchAll(type: RichTextDocumentRecord.self, predicate: NSPredicate(value: true))
51 | guard let fetchedDocument = documents?.first else {
52 | XCTFail()
53 | return
54 | }
55 |
56 | XCTAssertEqual(fetchedDocument.richTextDocument?.content.count ?? 0, document.content.count)
57 | }
58 |
59 | private func richTextDocument() -> RichTextDocument {
60 | let paragraphText1: Text = {
61 | return Text(value: "paragraphText1", marks: [])
62 | }()
63 |
64 | let paragraphText2: Text = {
65 | return Text(value: "paragraphText2", marks: [])
66 | }()
67 |
68 | let paragraph = Paragraph(nodeType: .paragraph, content: [paragraphText1, paragraphText2])
69 |
70 | let headingText: Text = {
71 | let bold = Text.Mark(type: Text.MarkType.bold)
72 | let italic = Text.Mark(type: Text.MarkType.italic)
73 | return Text(value: "headingText", marks: [bold, italic])
74 | }()
75 |
76 | let headingH1 = Heading(level: 1, content: [headingText])!
77 | let headingH2 = Heading(level: 2, content: [headingText])! // test copy of headingText
78 |
79 |
80 | let blockQuoteText: Text = {
81 | let code = Text.Mark(type: Text.MarkType.code)
82 | return Text(value: "blockQuoteText", marks: [code])
83 | }()
84 | let blockQuote = BlockQuote(nodeType: NodeType.blockquote, content: [blockQuoteText])
85 |
86 | let horizontalRule = HorizontalRule(nodeType: NodeType.horizontalRule, content: [])
87 |
88 | let listItem1 = ListItem(nodeType: .listItem, content: [paragraphText1])
89 | let listItem2 = ListItem(nodeType: .listItem, content: [paragraphText2])
90 | let listItem3 = ListItem(nodeType: .listItem, content: [paragraph])
91 | let orderedList = OrderedList(nodeType: .orderedList, content: [listItem1, listItem2, listItem3])
92 |
93 | // Use listItem2 twice:
94 | let unorderedList = OrderedList(nodeType: .orderedList, content: [listItem1, listItem2, listItem2])
95 |
96 | let link = Contentful.Link
97 | .unresolved(Contentful.Link.Sys(id: "unlinked-entry", linkType: "Entry", type: "Entry"))
98 | let embeddedAssetBlock = ResourceLinkBlock(
99 | resolvedData: ResourceLinkData(resolvedTarget: link, title: "linkTitle"),
100 | nodeType: NodeType.embeddedAssetBlock,
101 | content: []
102 | )
103 |
104 | let hyperlink = Hyperlink(
105 | data: Hyperlink.Data(uri: "https://contentful.com", title: "Contentful"),
106 | content: []
107 | )
108 |
109 | return RichTextDocument(
110 | content: (
111 | [
112 | paragraphText1,
113 | paragraph,
114 | headingH1,
115 | headingH2,
116 | blockQuote,
117 | horizontalRule,
118 | orderedList,
119 | unorderedList,
120 | embeddedAssetBlock,
121 | hyperlink
122 | ] as [Node?] // compiler needs this cast
123 | ).compactMap { $0 }
124 | )
125 | }
126 |
127 | }
128 |
--------------------------------------------------------------------------------