• 커스텀 데이터 저장소는 임의의 문서, 파일 포맷 혹은 영속성 백엔드를 추가할 수 있는 새로운 기능

    • 기존 SwiftData 코드와 잘 동작한다.
  • ModelConfiguration을 바꾸기만 하면 된다.

    • JSONStoreConfiguration는 커스텀으로 만든 타입이고, 세션을 통해서 구현해갈 예정
    • DataStoreConfiguration ****프로토콜을 채택했다.
    • 모델이나 뷰 코드는 전혀 수정하지 않아도 된다.
    import SwiftUI
    import SwiftData
    
    struct TripsApp: App {
    	var container: ModelContainer = {
    		do {
    			let configuration = JSONStoreConfiguration(schema: Schema([Trip.self]), url: fileURL)
    				return try ModelContainer(for: Trip.self, configuration: configuration)
    		} catch { ... }
    	} ()
    }
    
  • Overview

    • 저장소: 영속성 모델을 지원하기 위해 필요한 모든 데이터를 가져오고 저장하는 책임을 가지고 있는 개체

    • modelContext와 저장소의 상호작용 예시

      • 기본적인 앱 구조.

      스크린샷 2024-06-25 오후 12.48.29.png

      • ModelContext는 뷰에 보여질 영속성 모델을 인스턴스화하고 각 모델이 가진 ID를 기반으로 변경을 추적하여 필요할 때 저장소에 저장하는 책임을 가진다.

      스크린샷 2024-06-25 오후 12.51.20.png

      • 삭제와 추가 모두 처리하는데, 이때 추가되는 것에는 임시 ID를 발급한다.

        스크린샷 2024-06-25 오후 12.52.23.png

      • 저장소는 추가된 모델에 영구적인 ID를 발급하고 임시 ID를 여기에 매핑한다(remapping)

      • 저장소는 modelContext에 업데이트된 ID를 응답으로 주고 Context는 이를 받아서 자신을 업데이트한다.

        스크린샷 2024-06-25 오후 12.58.21.png

      • 이제 뷰를 업데이트 한다.

        스크린샷 2024-06-25 오후 12.59.05.png

      • 변경 뿐 아니라 데이터 가져오기와 저장등의 요청 셋을 처리한다.

    • 저장소는 모델이 실제로 저장되는 구현을 제공한다.

    • 상호작용은 DataStoreSnapshot이라는 sendable하고 codable한 표현을 사용한다.

      • 특정 시점의 스냅샷을 서로 주고받으면서 저장소는 값을 반영하고, Context는 모델을 만들거나 업데이트한다.
    • 이렇든 저장소는 ModelContext가 어떠한 포맷이던 읽을 수 있게 만들어주는 중요한 역할을 한다.

  • Meet DataStore

    • DataStore의 핵심 구성요소 3가지
      • Configuration: DataStoreConfiguration
        • 기본 구현체: ModelConfiguration
      • Communication: DataStoreSnapshot
        • 기본 구현체: DefaultSnapshot
      • Implementation: DataStore
        • 기본 구현체: DefaultStore

          • 마이그레이션, 히스토리 추적, CloudKit 싱크 등 SwiftData의 풍부한 기능을 모두 제공하고, 성능과 확장성에 있어서 플랫폼의 모범 사례들을 캡슐화하여 제공한다.

          스크린샷 2024-06-25 오후 1.13.54.png

        • ModelContext가 사용하기 위해서 필요한 기능들을 정의한다.

          • 저장
          • 가져오기
          • 캐싱
        • 추가 프로토콜로 추가 기능 구현 가능

          • 히스토리: History.HistoryProviding
          • 배치 삭제: DataStoreBatching
    • modelContext는 store와 request와 response로 서로 통신한다.
      • ex. fetch(DataStoreFetchRequest, DataStoreFetchResult)

        스크린샷 2024-06-25 오후 1.19.35.png

        스크린샷 2024-06-25 오후 1.19.52.png

        스크린샷 2024-06-25 오후 1.20.36.png

      • ex. save(DataStoreSaveChangeRequest, DataStoreSaveChangeResult)

        스크린샷 2024-06-25 오후 1.21.47.png

        스크린샷 2024-06-25 오후 1.22.05.png

        스크린샷 2024-06-25 오후 1.22.26.png

  • Example store

    • JSON 파일을 저장소를 사용해보자.
      • Archival store: 읽고 쓸 때 전체 파일을 사용한다.
      • Foundation에 JSONEncoder/Decoder를 사용해서 스냅샷 배열로 데이터를 저장할 것이다.
    1. 타입 정의

      class JSONStoreConfiguration: DataStoreConfiguration {
      }
      
      class JSONStore: DataStore {
      }
      
    2. 연관 타입 정의

      class JSONStoreConfiguration: DataStoreConfiguration {
      	typealias Store = JSONStore
      }
      
      class JSONStore: DataStore {
      	typealias Configuration = JSONStoreConfiguration
      	typealias Snapshot = DefaultSnapshot
      }
      
    3. fetch 구현

      1. 여기서는 predicate나 sort comparator 처리는 안하고 있다.
      class JSONStore: DataStore {
      	func fetch<T>(_ request: DataStoreFetchRequest<T>) throws -> DataStoreFetchRequest<T, DefaultSnapshot> where T: PersistentModel {
      		if request.descriptor.predicate != nil {
      			throw DataStoreError.preferInMemoryFilter
      		} else if request.desriptor.sortBy.count > 0 {
      			throw DataSourceError.preferInMemorySort
      		} 
      		let decoder = JSONDecoder()
      		let data = try Data(contentsOf: configuration.fileURL)
      		let trips = try decoder.decode([DefaultSnapshot].self, from: data)
      		
      		return DataSourceFetchResult(descriptor: request.descriptor,
      																fetchedSnapshots: trips)
      	}
      }
      
    4. save 구현

      • 여기서 추가, 업데이트, 삭제를 처리한다.
      class JSONStore: DataStore {
      	func save(_ request: DataStoreSaveChangesRequest<DefaultSnapshot>) throws -> DataStoreSaveChangesResult<DefaultSnapshot> {
      		/// 별도로 정의한 메소드를 통해서 데이터를 읽은 다음에 스냅샷을 분류한다.
      		var snapshotsByIdentifier = [PersistentIdentifier: DefaultSnapshot]()
      		try self.read().forEach { snapshotsByIdentifier[$0.persistentIdentifier] = $0 }
      		
      		/// 추가 요청을 처리한다. 여기서 remapping도 해준다.
      		var remappedIdentifiers = [PersistentIdentifier: PersistentIdentifier]()
      		for snapshot in request.inserted {
      			let entityName = snapshot.persistentIdentifier.entityName
      			let permanentIdentifier = try PersistentIdentifier.identifier(for: identifier,
      																																		entityName: entityName,
      																																		primaryKey: UUID())
      			let snapshotCopy = snapshot.copy(persistentIdentifier: permanentIdentifier)
      			remappedIdentifiers[snapshot.persistentIdentifier] = permanentIdentifier
      			snapshotsByIdentifier[permanentIdentifier] = snapshotCopy
      		}
      		
      		// 업데이트 처리
      		for snapshot in request.updated {
      			snapshotsByIdentifier[snapshot.persistentIdentifier] = snapshot
      		}
      		
      		// 삭제 처리
      		for snapshot in request.deleted {
      			snapshotsByIdentifier[snapshot.persistentIdentifier] = nil
      		}
      		
      		// 저장
      		
      		let snapshots = snapshotsByIdentifier.values.map { $0 }
      		let encoder = JSONEncoder()
      		let jsonData = try encoder.encode(snapshots)
      		try jsonData.write(to: configuration.fileURL)
      		return DataStoreSaveChangesResults(for: self.identifier,
      																			 remappedIdentifiers: remappedIdentifiers)
      }
      
  • Configuration 구현

    final class JSONStoreConfiguration: DataStoreConfiguration {
        typealias StoreType = JSONStore
      
        var name: String
        var schema: Schema?
        var fileURL: URL
    
        init(name: String, schema: Schema? = nil, fileURL: URL) {
            self.name = name
            self.schema = schema
            self.fileURL = fileURL
        }
    
        static func == (lhs: JSONStoreConfiguration, rhs: JSONStoreConfiguration) -> Bool {
            return lhs.name == rhs.name
        }
    
        func hash(into hasher: inout Hasher) {
            hasher.combine(name)
        }
    }
    
    
  • JSONStore 전체 구현

    final class JSONStore: DataStore {
        typealias Configuration = JSONStoreConfiguration
        typealias Snapshot = DefaultSnapshot
    
        var configuration: JSONStoreConfiguration
        var name: String
        var schema: Schema
        var identifier: String
    
        init(_ configuration: JSONStoreConfiguration, migrationPlan: (any SchemaMigrationPlan.Type)?) throws {
            self.configuration = configuration
            self.name = configuration.name
            self.schema = configuration.schema!
            self.identifier = configuration.fileURL.lastPathComponent
        }
    
        func save(_ request: DataStoreSaveChangesRequest<DefaultSnapshot>) throws -> DataStoreSaveChangesResult<DefaultSnapshot> {
            var remappedIdentifiers = [PersistentIdentifier: PersistentIdentifier]()
            var serializedTrips = try self.read()
    
            for snapshot in request.inserted {
                let permanentIdentifier = try PersistentIdentifier.identifier(for: identifier, 
                                                                              entityName: snapshot.persistentIdentifier.entityName,
                                                                              primaryKey: UUID())
                let permanentSnapshot = snapshot.copy(persistentIdentifier: permanentIdentifier)
                serializedTrips[permanentIdentifier] = permanentSnapshot
                remappedIdentifiers[snapshot.persistentIdentifier] = permanentIdentifier
            }
    
            for snapshot in request.updated {
                serializedTrips[snapshot.persistentIdentifier] = snapshot
            }
    
            for snapshot in request.deleted {
                serializedTrips[snapshot.persistentIdentifier] = nil
            }
          
            try self.write(serializedTrips)
            return DataStoreSaveChangesResult<DefaultSnapshot>(for: self.identifier,
                                                               remappedPersistentIdentifiers: remappedIdentifiers,
                                                               deletedIdentifiers: request.deleted.map({ $0.persistentIdentifier }))
        }
    
        func fetch<T>(_ request: DataStoreFetchRequest<T>) throws -> DataStoreFetchResult<T, DefaultSnapshot> where T : PersistentModel {
            if request.descriptor.predicate != nil {
                throw DataStoreError.preferInMemoryFilter
            } else if request.descriptor.sortBy.count > 0 {
                throw DataStoreError.preferInMemorySort
            }
    
            let objs = try self.read()
            let snapshots = objs.values.map({ $0 })
            return DataStoreFetchResult(descriptor: request.descriptor, fetchedSnapshots: snapshots, relatedSnapshots: objs)
        }
    
        func read() throws -> [PersistentIdentifier: DefaultSnapshot] {
            if FileManager.default.fileExists(atPath: configuration.fileURL.path(percentEncoded: false)) {
                let decoder = JSONDecoder()
                decoder.dateDecodingStrategy = .iso8601
    
                let trips = try decoder.decode([DefaultSnapshot].self, from: try Data(contentsOf: configuration.fileURL))
                var result = [PersistentIdentifier: DefaultSnapshot]()
                trips.forEach { s in
                    result[s.persistentIdentifier] = s
                }
                return result
            } else {
                return [:]
            }
        }
    
        func write(_ trips: [PersistentIdentifier: DefaultSnapshot]) throws {
            let encoder = JSONEncoder()
            encoder.dateEncodingStrategy = .iso8601
            encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
            let jsonData = try encoder.encode(trips.values.map({ $0 }))
            try jsonData.write(to: configuration.fileURL)
        }
    }