Compare commits
No commits in common. 'main' and 'sync2' have entirely different histories.
@ -1,11 +0,0 @@ |
|||||||
|
|
||||||
### Le Storage |
|
||||||
|
|
||||||
LeStorage is used to store objects into json files, and it can also be used to synchronize those objects to a django server properly configured. |
|
||||||
|
|
||||||
Here are the most important classes: |
|
||||||
- StoredCollection: stores object of one class in a json file |
|
||||||
- SyncedCollection: stores object of one class in a json file and synchronizes changes with the server |
|
||||||
- ApiCallCollection: provision HTTP calls and tries to execute them again |
|
||||||
- StoreCenter: The central class to manages all collections through Store instances |
|
||||||
|
|
||||||
@ -1,34 +0,0 @@ |
|||||||
// |
|
||||||
// WaitingOperation.swift |
|
||||||
// LeStorage |
|
||||||
// |
|
||||||
// Created by Laurent Morvillier on 01/04/2025. |
|
||||||
// |
|
||||||
|
|
||||||
import Foundation |
|
||||||
|
|
||||||
enum StorageMethod: String, Codable { |
|
||||||
case add |
|
||||||
case update |
|
||||||
case delete |
|
||||||
case deleteUnusedShared |
|
||||||
} |
|
||||||
|
|
||||||
class PendingOperation<T : Storable>: Codable, Equatable { |
|
||||||
|
|
||||||
var id: String = Store.randomId() |
|
||||||
var method: StorageMethod |
|
||||||
var data: T |
|
||||||
var actionOption: ActionOption |
|
||||||
|
|
||||||
init(method: StorageMethod, data: T, actionOption: ActionOption) { |
|
||||||
self.method = method |
|
||||||
self.data = data |
|
||||||
self.actionOption = actionOption |
|
||||||
} |
|
||||||
|
|
||||||
static func == (lhs: PendingOperation, rhs: PendingOperation) -> Bool { |
|
||||||
return lhs.id == rhs.id |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
@ -1,83 +0,0 @@ |
|||||||
// |
|
||||||
// SyncData.swift |
|
||||||
// LeStorage |
|
||||||
// |
|
||||||
// Created by Laurent Morvillier on 02/05/2025. |
|
||||||
// |
|
||||||
|
|
||||||
import Foundation |
|
||||||
|
|
||||||
enum SyncDataError: Error { |
|
||||||
case invalidFormat |
|
||||||
} |
|
||||||
|
|
||||||
struct SyncedStorableArray { |
|
||||||
var type: any SyncedStorable.Type |
|
||||||
var items: [any SyncedStorable] |
|
||||||
} |
|
||||||
|
|
||||||
struct ObjectIdentifierArray { |
|
||||||
var type: any SyncedStorable.Type |
|
||||||
var items: [ObjectIdentifier] |
|
||||||
} |
|
||||||
|
|
||||||
class SyncData { |
|
||||||
|
|
||||||
var updates: [SyncedStorableArray] = [] |
|
||||||
var deletions: [ObjectIdentifierArray] = [] |
|
||||||
var shared: [SyncedStorableArray] = [] |
|
||||||
var grants: [SyncedStorableArray] = [] |
|
||||||
var revocations: [ObjectIdentifierArray] = [] |
|
||||||
var revocationParents: [[ObjectIdentifierArray]] = [] |
|
||||||
// var relationshipSets: [SyncedStorableArray] = [] |
|
||||||
// var relationshipRemovals: [ObjectIdentifierArray] = [] |
|
||||||
var sharedRelationshipSets: [SyncedStorableArray] = [] |
|
||||||
var sharedRelationshipRemovals: [ObjectIdentifierArray] = [] |
|
||||||
var date: String? |
|
||||||
|
|
||||||
init(data: Data, storeCenter: StoreCenter) throws { |
|
||||||
guard let json = try JSONSerialization.jsonObject(with: data, options: []) |
|
||||||
as? [String : Any] |
|
||||||
else { |
|
||||||
throw SyncDataError.invalidFormat |
|
||||||
} |
|
||||||
|
|
||||||
if let updates = json["updates"] as? [String: Any] { |
|
||||||
self.updates = try storeCenter.decodeDictionary(updates) |
|
||||||
} |
|
||||||
if let deletions = json["deletions"] as? [String: Any] { |
|
||||||
self.deletions = try storeCenter.decodeObjectIdentifierDictionary(deletions) |
|
||||||
} |
|
||||||
if let shared = json["shared"] as? [String: Any] { |
|
||||||
self.shared = try storeCenter.decodeDictionary(shared) |
|
||||||
} |
|
||||||
if let grants = json["grants"] as? [String: Any] { |
|
||||||
self.grants = try storeCenter.decodeDictionary(grants) |
|
||||||
} |
|
||||||
if let revocations = json["revocations"] as? [String: Any] { |
|
||||||
self.revocations = try storeCenter.decodeObjectIdentifierDictionary(revocations) |
|
||||||
} |
|
||||||
if let revocationParents = json["revocated_relations"] as? [[String: Any]] { |
|
||||||
for level in revocationParents { |
|
||||||
let decodedLevel = try storeCenter.decodeObjectIdentifierDictionary(level) |
|
||||||
self.revocationParents.append(decodedLevel) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
// if let relationshipSets = json["relationship_sets"] as? [String: Any] { |
|
||||||
// self.relationshipSets = try storeCenter.decodeDictionary(relationshipSets) |
|
||||||
// } |
|
||||||
// if let relationshipRemovals = json["relationship_removals"] as? [String: Any] { |
|
||||||
// self.relationshipRemovals = try storeCenter.decodeObjectIdentifierDictionary(relationshipRemovals) |
|
||||||
// } |
|
||||||
if let sharedRelationshipSets = json["shared_relationship_sets"] as? [String: Any] { |
|
||||||
self.sharedRelationshipSets = try storeCenter.decodeDictionary(sharedRelationshipSets) |
|
||||||
} |
|
||||||
if let sharedRelationshipRemovals = json["shared_relationship_removals"] as? [String: Any] { |
|
||||||
self.sharedRelationshipRemovals = try storeCenter.decodeObjectIdentifierDictionary(sharedRelationshipRemovals) |
|
||||||
} |
|
||||||
|
|
||||||
self.date = json["date"] as? String |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
@ -1,66 +0,0 @@ |
|||||||
// |
|
||||||
// PendingOperationManager.swift |
|
||||||
// LeStorage |
|
||||||
// |
|
||||||
// Created by Laurent Morvillier on 01/04/2025. |
|
||||||
// |
|
||||||
|
|
||||||
import Foundation |
|
||||||
|
|
||||||
class PendingOperationManager<T: Storable> { |
|
||||||
|
|
||||||
fileprivate(set) var items: [PendingOperation<T>] = [] |
|
||||||
|
|
||||||
fileprivate var _fileName: String |
|
||||||
|
|
||||||
fileprivate var _inMemory: Bool = false |
|
||||||
|
|
||||||
init(store: Store, inMemory: Bool) { |
|
||||||
self._fileName = "\(store.storeCenter.directoryName)/pending_\(T.resourceName()).json" |
|
||||||
|
|
||||||
self._inMemory = inMemory |
|
||||||
if !inMemory { |
|
||||||
do { |
|
||||||
let url = try store.fileURL(fileName: self._fileName) |
|
||||||
if FileManager.default.fileExists(atPath: url.path()) { |
|
||||||
let jsonString = try FileUtils.readDocumentFile(fileName: self._fileName) |
|
||||||
if let decoded: [PendingOperation<T>] = try jsonString.decode() { |
|
||||||
self.items = decoded |
|
||||||
} |
|
||||||
} |
|
||||||
} catch { |
|
||||||
Logger.error(error) |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
var typeName: String { return String(describing: T.self) } |
|
||||||
|
|
||||||
func addPendingOperation(method: StorageMethod, instance: T, actionOption: ActionOption) { |
|
||||||
Logger.log("addPendingOperation: \(method), \(instance)") |
|
||||||
|
|
||||||
let operation = PendingOperation<T>(method: method, data: instance, actionOption: actionOption) |
|
||||||
self.items.append(operation) |
|
||||||
|
|
||||||
self._writeIfNecessary() |
|
||||||
} |
|
||||||
|
|
||||||
func reset() { |
|
||||||
self.items.removeAll() |
|
||||||
self._writeIfNecessary() |
|
||||||
} |
|
||||||
|
|
||||||
fileprivate func _writeIfNecessary() { |
|
||||||
guard !self._inMemory else { return } |
|
||||||
do { |
|
||||||
let jsonString: String = try self.items.jsonString() |
|
||||||
Task(priority: .background) { |
|
||||||
let _ = try FileUtils.writeToDocumentDirectory(content: jsonString, fileName: self._fileName) |
|
||||||
} |
|
||||||
} catch { |
|
||||||
Logger.error(error) |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,64 +0,0 @@ |
|||||||
// |
|
||||||
// StoreLibrary.swift |
|
||||||
// LeStorage |
|
||||||
// |
|
||||||
// Created by Laurent Morvillier on 02/06/2025. |
|
||||||
// |
|
||||||
|
|
||||||
import Foundation |
|
||||||
|
|
||||||
class StoreLibrary { |
|
||||||
|
|
||||||
private let storeCenter: StoreCenter |
|
||||||
|
|
||||||
/// A dictionary of Stores associated to their id |
|
||||||
fileprivate var _stores: [String: Store] = [:] |
|
||||||
|
|
||||||
init(storeCenter: StoreCenter) { |
|
||||||
self.storeCenter = storeCenter |
|
||||||
} |
|
||||||
|
|
||||||
subscript(identifier: String) -> Store? { |
|
||||||
get { |
|
||||||
return self._stores[identifier] |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
/// Registers a store into the list of stores |
|
||||||
/// - Parameters: |
|
||||||
/// - store: A store to save |
|
||||||
fileprivate func _registerStore(store: Store) { |
|
||||||
guard let identifier = store.identifier else { |
|
||||||
fatalError("The store has no identifier") |
|
||||||
} |
|
||||||
if self._stores[identifier] != nil { |
|
||||||
fatalError("A store with this identifier has already been registered: \(identifier)") |
|
||||||
} |
|
||||||
self._stores[identifier] = store |
|
||||||
} |
|
||||||
|
|
||||||
/// Returns a store using its identifier, and registers it if it does not exists |
|
||||||
/// - Parameters: |
|
||||||
/// - identifier: The store identifer |
|
||||||
/// - parameter: The parameter name used to filter data on the server |
|
||||||
func requestStore(identifier: String) -> Store { |
|
||||||
if let store = self._stores[identifier] { |
|
||||||
return store |
|
||||||
} else { |
|
||||||
let store = Store(storeCenter: self.storeCenter, identifier: identifier) |
|
||||||
self._registerStore(store: store) |
|
||||||
return store |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
public func destroyStore(identifier: String) { |
|
||||||
let directory = "\(self.storeCenter.directoryName)/\(identifier)" |
|
||||||
FileManager.default.deleteDirectoryInDocuments(directoryName: directory) |
|
||||||
self._stores[identifier]?.reset() |
|
||||||
self._stores.removeValue(forKey: identifier) |
|
||||||
} |
|
||||||
|
|
||||||
func reset() { |
|
||||||
self._stores.removeAll() |
|
||||||
} |
|
||||||
} |
|
||||||
@ -0,0 +1,342 @@ |
|||||||
|
// |
||||||
|
// StoredCollection.swift |
||||||
|
// LeStorage |
||||||
|
// |
||||||
|
// Created by Laurent Morvillier on 11/10/2024. |
||||||
|
// |
||||||
|
|
||||||
|
import Foundation |
||||||
|
|
||||||
|
extension StoredCollection: SomeSyncedCollection where T : SyncedStorable { |
||||||
|
|
||||||
|
/// Migrates if necessary and asynchronously decodes the json file |
||||||
|
func load() async { |
||||||
|
do { |
||||||
|
if self.inMemory { |
||||||
|
try await self.loadDataFromServerIfAllowed() |
||||||
|
} else { |
||||||
|
try self.loadFromFile() |
||||||
|
} |
||||||
|
} catch { |
||||||
|
Logger.error(error) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/// Loads the collection using the server data only if the collection file doesn't exists |
||||||
|
func loadCollectionsFromServerIfNoFile() async throws { |
||||||
|
let fileURL: URL = try self.store.fileURL(type: T.self) |
||||||
|
if !FileManager.default.fileExists(atPath: fileURL.path()) { |
||||||
|
try await self.loadDataFromServerIfAllowed() |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
func loadDataFromServerIfAllowed() async throws { |
||||||
|
try await self.loadDataFromServerIfAllowed(clear: false) |
||||||
|
} |
||||||
|
|
||||||
|
/// Retrieves the data from the server and loads it into the items array |
||||||
|
public func loadDataFromServerIfAllowed(clear: Bool = false) async throws { |
||||||
|
guard !(self is StoredSingleton<T>) else { |
||||||
|
throw StoreError.cannotSyncCollection(name: self.resourceName) |
||||||
|
} |
||||||
|
do { |
||||||
|
try await StoreCenter.main.sendGetRequest(T.self, storeId: self.storeId) |
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// let items: [T] = try await self.store.getItems() |
||||||
|
// if items.count > 0 { |
||||||
|
// DispatchQueue.main.async { |
||||||
|
// if clear { |
||||||
|
// self.clear() |
||||||
|
// } |
||||||
|
// self.addOrUpdateNoSync(contentOfs: items) |
||||||
|
// } |
||||||
|
// } |
||||||
|
// self.setAsLoaded() |
||||||
|
} catch { |
||||||
|
Logger.error(error) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/// Updates a local item from a server instance. This method is typically used when the server makes update |
||||||
|
/// to an object when it's inserted. The StoredCollection possibly needs to update its own copy with new values. |
||||||
|
/// - serverInstance: the instance of the object on the server |
||||||
|
func updateFromServerInstance(_ serverInstance: T) { |
||||||
|
|
||||||
|
guard T.copyServerResponse else { |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
DispatchQueue.main.async { |
||||||
|
if let localInstance = self.findById(serverInstance.id) { |
||||||
|
localInstance.copy(from: serverInstance) |
||||||
|
self.setChanged() |
||||||
|
// let modified = localInstance.copyFromServerInstance(serverInstance) |
||||||
|
// if modified { |
||||||
|
// self.setChanged() |
||||||
|
// } |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// MARK: - Basic operations with sync |
||||||
|
|
||||||
|
/// Adds or update an instance and writes |
||||||
|
public func addOrUpdate(instance: T) { |
||||||
|
// Logger.log("\(T.resourceName()) : one item") |
||||||
|
defer { |
||||||
|
self.setChanged() |
||||||
|
} |
||||||
|
|
||||||
|
instance.lastUpdate = Date() |
||||||
|
if let index = self.items.firstIndex(where: { $0.id == instance.id }) { |
||||||
|
self.updateItem(instance, index: index) |
||||||
|
self._sendUpdate(instance) |
||||||
|
} else { |
||||||
|
self.addItem(instance: instance) |
||||||
|
self._sendInsertion(instance) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/// Adds or update a sequence and writes |
||||||
|
public func addOrUpdate(contentOfs sequence: any Sequence<T>) { |
||||||
|
// Logger.log("\(T.resourceName()) : \(sequence.underestimatedCount) items") |
||||||
|
defer { |
||||||
|
self.setChanged() |
||||||
|
} |
||||||
|
|
||||||
|
let date = Date() |
||||||
|
let batch = OperationBatch<T>() |
||||||
|
|
||||||
|
for instance in sequence { |
||||||
|
instance.lastUpdate = date |
||||||
|
if let index = self.items.firstIndex(where: { $0.id == instance.id }) { |
||||||
|
self.updateItem(instance, index: index) |
||||||
|
batch.addUpdate(instance) |
||||||
|
// self._sendUpdateIfNecessary(instance) |
||||||
|
} else { // insert |
||||||
|
self.addItem(instance: instance) |
||||||
|
batch.addInsert(instance) |
||||||
|
// self._sendInsertionIfNecessary(instance) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
self._sendOperationBatch(batch) |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
/// Proceeds to delete all instance of the collection, properly cleaning up dependencies and sending API calls |
||||||
|
public func deleteAll() throws { |
||||||
|
self.delete(contentOfs: self.items) |
||||||
|
} |
||||||
|
|
||||||
|
/// Deletes all items of the sequence by id and sets the collection as changed to trigger a write |
||||||
|
public func delete(contentOfs sequence: any RandomAccessCollection<T>) { |
||||||
|
|
||||||
|
defer { |
||||||
|
self.setChanged() |
||||||
|
} |
||||||
|
|
||||||
|
guard sequence.isNotEmpty else { return } |
||||||
|
|
||||||
|
for instance in sequence { |
||||||
|
// print(">>> SEND DELETE for \(instance.id)") |
||||||
|
self.deleteItem(instance) |
||||||
|
StoreCenter.main.createDeleteLog(instance) |
||||||
|
} |
||||||
|
|
||||||
|
let batch = OperationBatch<T>() |
||||||
|
batch.deletes = Array(sequence) |
||||||
|
self._sendOperationBatch(batch) |
||||||
|
} |
||||||
|
|
||||||
|
/// Deletes an instance and writes |
||||||
|
public func delete(instance: T) { |
||||||
|
defer { |
||||||
|
self.setChanged() |
||||||
|
} |
||||||
|
self._deleteNoWrite(instance: instance) |
||||||
|
} |
||||||
|
|
||||||
|
/// Deletes an instance without writing, logs the operation and sends an API call |
||||||
|
fileprivate func _deleteNoWrite(instance: T) { |
||||||
|
self.deleteItem(instance) |
||||||
|
StoreCenter.main.createDeleteLog(instance) |
||||||
|
|
||||||
|
self._sendDeletion(instance) |
||||||
|
} |
||||||
|
|
||||||
|
public func deleteDependencies(_ items: any RandomAccessCollection<T>) { |
||||||
|
guard items.isNotEmpty else { return } |
||||||
|
delete(contentOfs: items) // MUST NOT ADD "self" before delete, otherwise it will call the delete method of StoredCollection without sync |
||||||
|
} |
||||||
|
|
||||||
|
// MARK: - Basic operations without sync |
||||||
|
|
||||||
|
/// Adds or update an instance without synchronizing it |
||||||
|
func addOrUpdateNoSync(_ instance: T) throws { |
||||||
|
self.addOrUpdateItem(instance: instance) |
||||||
|
} |
||||||
|
|
||||||
|
/// Adds or update a sequence of elements without synchronizing it |
||||||
|
func addOrUpdateNoSync(contentOfs sequence: any Sequence<T>) { |
||||||
|
self.addSequence(sequence) |
||||||
|
} |
||||||
|
|
||||||
|
/// Deletes the instance in the collection without synchronization |
||||||
|
func deleteNoSync(instance: T) throws { |
||||||
|
defer { |
||||||
|
self.setChanged() |
||||||
|
} |
||||||
|
self.deleteItem(instance) |
||||||
|
} |
||||||
|
|
||||||
|
/// Deletes the instance in the collection without synchronization |
||||||
|
func deleteByStringIdNoSync(_ id: String) { |
||||||
|
defer { |
||||||
|
self.setChanged() |
||||||
|
} |
||||||
|
let realId = T.buildRealId(id: id) |
||||||
|
if let instance = self.findById(realId) { |
||||||
|
self.deleteItem(instance) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// MARK: - Send requests |
||||||
|
|
||||||
|
fileprivate func _sendInsertion(_ instance: T) { |
||||||
|
self._sendOperationBatch(OperationBatch(insert: instance)) |
||||||
|
} |
||||||
|
|
||||||
|
fileprivate func _sendUpdate(_ instance: T) { |
||||||
|
self._sendOperationBatch(OperationBatch(update: instance)) |
||||||
|
} |
||||||
|
|
||||||
|
fileprivate func _sendDeletion(_ instance: T) { |
||||||
|
self._sendOperationBatch(OperationBatch(delete: instance)) |
||||||
|
} |
||||||
|
|
||||||
|
fileprivate func _sendOperationBatch(_ batch: OperationBatch<T>) { |
||||||
|
Task { |
||||||
|
do { |
||||||
|
try await StoreCenter.main.sendOperationBatch(batch) |
||||||
|
// let success = try await StoreCenter.main.sendOperationBatch(batch) |
||||||
|
// for item in success { |
||||||
|
// if let data = item.data { |
||||||
|
// self.updateFromServerInstance(data) |
||||||
|
// } |
||||||
|
// } |
||||||
|
} catch { |
||||||
|
Logger.error(error) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/// Sends an insert api call for the provided |
||||||
|
/// Calls copyFromServerInstance on the instance with the result of the HTTP call |
||||||
|
/// - Parameters: |
||||||
|
/// - instance: the object to POST |
||||||
|
// fileprivate func _sendInsertionIfNecessary(_ instance: T) { |
||||||
|
// |
||||||
|
// Task { |
||||||
|
// do { |
||||||
|
// if let result = try await self.store.sendInsertion(instance) { |
||||||
|
// self.updateFromServerInstance(result) |
||||||
|
// } |
||||||
|
// } catch { |
||||||
|
// Logger.error(error) |
||||||
|
// } |
||||||
|
// } |
||||||
|
// } |
||||||
|
// |
||||||
|
// /// Sends an update api call for the provided [instance] |
||||||
|
// /// - Parameters: |
||||||
|
// /// - instance: the object to PUT |
||||||
|
// fileprivate func _sendUpdateIfNecessary(_ instance: T) { |
||||||
|
// Task { |
||||||
|
// do { |
||||||
|
// try await self.store.sendUpdate(instance) |
||||||
|
// } catch { |
||||||
|
// Logger.error(error) |
||||||
|
// } |
||||||
|
// } |
||||||
|
// } |
||||||
|
// |
||||||
|
// /// Sends an delete api call for the provided [instance] |
||||||
|
// /// - Parameters: |
||||||
|
// /// - instance: the object to DELETE |
||||||
|
// fileprivate func _sendDeletionIfNecessary(_ instance: T) { |
||||||
|
// Task { |
||||||
|
// do { |
||||||
|
// try await self.store.sendDeletion(instance) |
||||||
|
// } catch { |
||||||
|
// Logger.error(error) |
||||||
|
// } |
||||||
|
// } |
||||||
|
// } |
||||||
|
|
||||||
|
// MARK: - Synchronization |
||||||
|
|
||||||
|
/// Adds or update an instance if it is newer than the local instance |
||||||
|
func addOrUpdateIfNewer(_ instance: T, shared: Bool) { |
||||||
|
defer { |
||||||
|
self.setChanged() |
||||||
|
} |
||||||
|
|
||||||
|
if let index = self.items.firstIndex(where: { $0.id == instance.id }) { |
||||||
|
let localInstance = self.items[index] |
||||||
|
if instance.lastUpdate > localInstance.lastUpdate { |
||||||
|
self.updateItem(instance, index: index) |
||||||
|
} else { |
||||||
|
print("do not update \(T.resourceName()): \(instance.lastUpdate.timeIntervalSince1970) / local: \(localInstance.lastUpdate.timeIntervalSince1970)") |
||||||
|
} |
||||||
|
} else { // insert |
||||||
|
if shared { |
||||||
|
instance.shared = true |
||||||
|
} |
||||||
|
self.addItem(instance: instance) |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
// MARK: - Migrations |
||||||
|
|
||||||
|
/// Sends a POST request for the instance, and changes the collection to perform a write |
||||||
|
public func writeChangeAndInsertOnServer(instance: T) { |
||||||
|
defer { |
||||||
|
self.setChanged() |
||||||
|
} |
||||||
|
self._sendInsertion(instance) |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
class OperationBatch<T> { |
||||||
|
var inserts: [T] = [] |
||||||
|
var updates: [T] = [] |
||||||
|
var deletes: [T] = [] |
||||||
|
|
||||||
|
init() { |
||||||
|
|
||||||
|
} |
||||||
|
init(insert: T) { |
||||||
|
self.inserts = [insert] |
||||||
|
} |
||||||
|
init(update: T) { |
||||||
|
self.updates = [update] |
||||||
|
} |
||||||
|
init(delete: T) { |
||||||
|
self.deletes = [delete] |
||||||
|
} |
||||||
|
|
||||||
|
func addInsert(_ instance: T) { |
||||||
|
self.inserts.append(instance) |
||||||
|
} |
||||||
|
func addUpdate(_ instance: T) { |
||||||
|
self.updates.append(instance) |
||||||
|
} |
||||||
|
func addDelete(_ instance: T) { |
||||||
|
self.deletes.append(instance) |
||||||
|
} |
||||||
|
} |
||||||
@ -1,478 +0,0 @@ |
|||||||
// |
|
||||||
// SyncedCollection.swift |
|
||||||
// LeStorage |
|
||||||
// |
|
||||||
// Created by Laurent Morvillier on 11/10/2024. |
|
||||||
// |
|
||||||
|
|
||||||
import Foundation |
|
||||||
|
|
||||||
protocol SomeSyncedCollection: SomeCollection { |
|
||||||
func loadDataFromServerIfAllowed(clear: Bool) async throws |
|
||||||
func loadCollectionsFromServerIfNoFile() async throws |
|
||||||
} |
|
||||||
|
|
||||||
public class SyncedCollection<T : SyncedStorable>: SomeSyncedCollection, CollectionDelegate { |
|
||||||
|
|
||||||
public typealias Item = T |
|
||||||
|
|
||||||
let store: Store |
|
||||||
let collection: StoredCollection<T> |
|
||||||
|
|
||||||
init(store: Store, indexed: Bool = false, inMemory: Bool = false, limit: Int? = nil, noLoad: Bool = false) { |
|
||||||
|
|
||||||
self.store = store |
|
||||||
self.collection = StoredCollection<T>(store: store, indexed: indexed, inMemory: inMemory, limit: limit, noLoad: noLoad) |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
init(store: Store, inMemory: Bool) async { |
|
||||||
self.store = store |
|
||||||
self.collection = await StoredCollection(store: store, inMemory: inMemory) |
|
||||||
} |
|
||||||
|
|
||||||
var storeCenter: StoreCenter { return self.store.storeCenter } |
|
||||||
|
|
||||||
public var storeId: String? { |
|
||||||
return self.store.identifier |
|
||||||
} |
|
||||||
|
|
||||||
/// Returns a dummy SyncedCollection instance |
|
||||||
public static func placeholder() -> SyncedCollection<T> { |
|
||||||
return SyncedCollection<T>(store: Store(storeCenter: StoreCenter.main)) |
|
||||||
} |
|
||||||
|
|
||||||
/// Loads the collection using the server data only if the collection file doesn't exists |
|
||||||
func loadCollectionsFromServerIfNoFile() async throws { |
|
||||||
let fileURL: URL = try self.store.fileURL(type: T.self) |
|
||||||
if !FileManager.default.fileExists(atPath: fileURL.path()) { |
|
||||||
try await self.loadDataFromServerIfAllowed() |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
/// Retrieves the data from the server and loads it into the items array |
|
||||||
public func loadDataFromServerIfAllowed(clear: Bool = false) async throws { |
|
||||||
do { |
|
||||||
try await self.storeCenter.sendGetRequest(T.self, storeId: self.storeId, clear: clear) |
|
||||||
} catch { |
|
||||||
Logger.error(error) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
func loadOnceAsync() async throws { |
|
||||||
let items: [T] = try await self.storeCenter.service().get() |
|
||||||
await self.loadItems(items, clear: true) |
|
||||||
} |
|
||||||
|
|
||||||
/// Updates a local item from a server instance. This method is typically used when the server makes update |
|
||||||
/// to an object when it's inserted. The SyncedCollection possibly needs to update its own copy with new values. |
|
||||||
/// - serverInstance: the instance of the object on the server |
|
||||||
func updateFromServerInstance(_ serverInstance: T) { |
|
||||||
|
|
||||||
guard T.copyServerResponse else { |
|
||||||
return |
|
||||||
} |
|
||||||
|
|
||||||
Task { |
|
||||||
await self.collection.updateLocalInstance(serverInstance) |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
@MainActor |
|
||||||
func loadItems(_ items: [T], clear: Bool = false) { |
|
||||||
self.collection.loadAndWrite(items, clear: clear) |
|
||||||
} |
|
||||||
|
|
||||||
// MARK: - Basic operations with sync |
|
||||||
|
|
||||||
/// Adds or update an instance synchronously, dispatching network operations to background tasks |
|
||||||
public func addOrUpdate(instance: T) { |
|
||||||
let result = _addOrUpdateCore(instance: instance) |
|
||||||
if result.method == .insert { |
|
||||||
Task { await self._sendInsertion(instance) } |
|
||||||
} else { |
|
||||||
Task { await self._sendUpdate(instance) } |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
/// Private helper function that contains the shared logic |
|
||||||
private func _addOrUpdateCore(instance: T) -> ActionResult<T> { |
|
||||||
instance.lastUpdate = Date() |
|
||||||
|
|
||||||
let result = self.collection.addOrUpdate(instance: instance) |
|
||||||
if result.method == .update { |
|
||||||
if instance.sharing != nil { |
|
||||||
self._cleanUpSharedDependencies() |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
return result |
|
||||||
} |
|
||||||
|
|
||||||
fileprivate func _addOrUpdateCore(contentOfs sequence: any Sequence<T>) -> OperationBatch<T> { |
|
||||||
|
|
||||||
let date = Date() |
|
||||||
let batch = OperationBatch<T>() |
|
||||||
|
|
||||||
for instance in sequence { |
|
||||||
|
|
||||||
instance.lastUpdate = date |
|
||||||
let result = self.collection.addOrUpdate(instance: instance) |
|
||||||
|
|
||||||
if result.method == .insert { |
|
||||||
batch.addInsert(instance) |
|
||||||
} else { |
|
||||||
batch.addUpdate(instance) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
self._cleanUpSharedDependencies() |
|
||||||
|
|
||||||
return batch |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
/// Adds or update a sequence and writes |
|
||||||
public func addOrUpdate(contentOfs sequence: any Sequence<T>) { |
|
||||||
let batch = self._addOrUpdateCore(contentOfs: sequence) |
|
||||||
Task { await self._sendOperationBatch(batch) } |
|
||||||
} |
|
||||||
|
|
||||||
/// Deletes an instance and writes |
|
||||||
public func delete(instance: T) { |
|
||||||
|
|
||||||
self.collection.delete(instance: instance, actionOption: .syncedCascade) |
|
||||||
self.storeCenter.createDeleteLog(instance) |
|
||||||
Task { await self._sendDeletion(instance) } |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
/// Deletes all items of the sequence by id and sets the collection as changed to trigger a write |
|
||||||
public func delete(contentOfs sequence: any RandomAccessCollection<T>) { |
|
||||||
self.delete(contentOfs: sequence, actionOption: .syncedCascade) |
|
||||||
} |
|
||||||
|
|
||||||
func delete(contentOfs sequence: any RandomAccessCollection<T>, actionOption: ActionOption) { |
|
||||||
guard sequence.isNotEmpty else { return } |
|
||||||
let batch = self._deleteCore(contentOfs: sequence, actionOption: actionOption) |
|
||||||
if actionOption.synchronize { |
|
||||||
Task { await self._sendOperationBatch(batch) } |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
/// Deletes all items of the sequence by id and sets the collection as changed to trigger a write |
|
||||||
fileprivate func _deleteCore(contentOfs sequence: any RandomAccessCollection<T>, actionOption: ActionOption) -> OperationBatch<T> { |
|
||||||
|
|
||||||
var deleted: [T] = [] |
|
||||||
self.collection.delete(contentOfs: sequence, actionOption: actionOption) { result in |
|
||||||
self.storeCenter.createDeleteLog(result.instance) |
|
||||||
if !result.pending { |
|
||||||
deleted.append(result.instance) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
let batch = OperationBatch<T>() |
|
||||||
batch.deletes = deleted |
|
||||||
return batch |
|
||||||
} |
|
||||||
|
|
||||||
fileprivate func _cleanUpSharedDependencies() { |
|
||||||
for relationship in T.relationships() { |
|
||||||
if let syncedType = relationship.type as? (any SyncedStorable.Type) { |
|
||||||
do { |
|
||||||
try self._deleteUnusedSharedInstances(relationship: relationship, type: syncedType, originStoreId: self.storeId) |
|
||||||
} catch { |
|
||||||
Logger.error(error) |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
fileprivate func _deleteUnusedSharedInstances<S: SyncedStorable>(relationship: Relationship, type: S.Type, originStoreId: String?) throws { |
|
||||||
|
|
||||||
let store: Store |
|
||||||
switch relationship.storeLookup { |
|
||||||
case .main: store = self.store.storeCenter.mainStore |
|
||||||
case .same: store = self.store |
|
||||||
case .child: |
|
||||||
throw StoreError.invalidStoreLookup(from: type, to: relationship.type) |
|
||||||
} |
|
||||||
|
|
||||||
let collection: SyncedCollection<S> = try store.syncedCollection() |
|
||||||
collection._deleteUnusedGrantedInstances(originStoreId: originStoreId) |
|
||||||
} |
|
||||||
|
|
||||||
fileprivate func _deleteUnusedGrantedInstances(originStoreId: String?) { |
|
||||||
|
|
||||||
let sharedItems = self.collection.items.filter { $0.sharing == .granted } |
|
||||||
for sharedItem in sharedItems { |
|
||||||
self.store.deleteUnusedGrantedIfNecessary(sharedItem, originStoreId: originStoreId |
|
||||||
) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
public func deleteAllItemsAndDependencies(actionOption: ActionOption) { |
|
||||||
if actionOption.synchronize { |
|
||||||
self.delete(contentOfs: self.items, actionOption: actionOption) |
|
||||||
} else { |
|
||||||
self.collection.deleteAllItemsAndDependencies(actionOption: actionOption) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
public func deleteDependencies(actionOption: ActionOption, _ isIncluded: (T) -> Bool) { |
|
||||||
let items = self.items.filter(isIncluded) |
|
||||||
if actionOption.synchronize { |
|
||||||
self.delete(contentOfs: items, actionOption: actionOption) |
|
||||||
} else { |
|
||||||
self.collection.delete(contentOfs: items) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
// MARK: - Asynchronous operations |
|
||||||
|
|
||||||
/// Adds or update an instance asynchronously and waits for network operations |
|
||||||
public func addOrUpdateAsync(instance: T) async throws { |
|
||||||
let result = _addOrUpdateCore(instance: instance) |
|
||||||
if result.method == .insert { |
|
||||||
try await self._executeBatchOnce(OperationBatch(insert: instance)) |
|
||||||
} else { |
|
||||||
try await self._executeBatchOnce(OperationBatch(update: instance)) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
public func addOrUpdateAsync(contentOfs sequence: any Sequence<T>) async throws { |
|
||||||
let batch = self._addOrUpdateCore(contentOfs: sequence) |
|
||||||
try await self._executeBatchOnce(batch) |
|
||||||
} |
|
||||||
|
|
||||||
/// Deletes all items of the sequence by id and sets the collection as changed to trigger a write |
|
||||||
public func deleteAsync(contentOfs sequence: any RandomAccessCollection<T>) async throws { |
|
||||||
guard sequence.isNotEmpty else { return } |
|
||||||
let batch = self._deleteCore(contentOfs: sequence, actionOption: .syncedCascade) |
|
||||||
try await self._executeBatchOnce(batch) |
|
||||||
} |
|
||||||
|
|
||||||
/// Deletes an instance and writes |
|
||||||
public func deleteAsync(instance: T) async throws { |
|
||||||
self.collection.delete(instance: instance, actionOption: .syncedCascade) |
|
||||||
self.storeCenter.createDeleteLog(instance) |
|
||||||
try await self._executeBatchOnce(OperationBatch(delete: instance)) |
|
||||||
} |
|
||||||
|
|
||||||
// MARK: - Basic operations without sync |
|
||||||
|
|
||||||
/// Adds or update an instance without synchronizing it |
|
||||||
func addOrUpdateNoSync(_ instance: T) { |
|
||||||
self.collection.addOrUpdate(instance: instance) |
|
||||||
// self.addOrUpdateItem(instance: instance) |
|
||||||
} |
|
||||||
|
|
||||||
/// Adds or update a sequence of elements without synchronizing it |
|
||||||
func addOrUpdateNoSync(contentOfs sequence: any Sequence<T>) { |
|
||||||
self.collection.addOrUpdate(contentOfs: sequence) |
|
||||||
} |
|
||||||
|
|
||||||
public func deleteNoSync(contentOfs sequence: any RandomAccessCollection<T>) { |
|
||||||
self.collection.delete(contentOfs: sequence) |
|
||||||
} |
|
||||||
|
|
||||||
/// Deletes the instance in the collection without synchronization |
|
||||||
public func deleteNoSync(instance: T, cascading: Bool = false) { |
|
||||||
self.collection.delete(instance: instance, actionOption: .cascade) |
|
||||||
} |
|
||||||
|
|
||||||
func deleteUnusedGranted(instance: T) { |
|
||||||
guard instance.sharing != nil else { return } |
|
||||||
self.deleteByStringId(instance.stringId) |
|
||||||
instance.deleteUnusedSharedDependencies(store: self.store) |
|
||||||
} |
|
||||||
|
|
||||||
func deleteByStringId(_ id: String, actionOption: ActionOption = .standard) { |
|
||||||
self.collection.deleteByStringId(id, actionOption: actionOption) |
|
||||||
} |
|
||||||
|
|
||||||
// MARK: - Collection Delegate |
|
||||||
|
|
||||||
func loadingForMemoryCollection() async { |
|
||||||
do { |
|
||||||
try await self.loadDataFromServerIfAllowed() |
|
||||||
} catch { |
|
||||||
Logger.error(error) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
func itemMerged(_ pendingOperation: PendingOperation<T>) { |
|
||||||
|
|
||||||
let batch = OperationBatch<T>() |
|
||||||
switch pendingOperation.method { |
|
||||||
case .add: |
|
||||||
batch.inserts.append(pendingOperation.data) |
|
||||||
case .update: |
|
||||||
batch.updates.append(pendingOperation.data) |
|
||||||
case .delete: |
|
||||||
batch.deletes.append(pendingOperation.data) |
|
||||||
case .deleteUnusedShared: |
|
||||||
break |
|
||||||
} |
|
||||||
|
|
||||||
Task { await self._sendOperationBatch(batch) } |
|
||||||
} |
|
||||||
|
|
||||||
// MARK: - Send requests |
|
||||||
|
|
||||||
fileprivate func _sendInsertion(_ instance: T) async { |
|
||||||
await self._sendOperationBatch(OperationBatch(insert: instance)) |
|
||||||
} |
|
||||||
|
|
||||||
fileprivate func _sendUpdate(_ instance: T) async { |
|
||||||
await self._sendOperationBatch(OperationBatch(update: instance)) |
|
||||||
} |
|
||||||
|
|
||||||
fileprivate func _sendDeletion(_ instance: T) async { |
|
||||||
await self._sendOperationBatch(OperationBatch(delete: instance)) |
|
||||||
} |
|
||||||
|
|
||||||
fileprivate func _sendOperationBatch(_ batch: OperationBatch<T>) async { |
|
||||||
do { |
|
||||||
try await self.storeCenter.sendOperationBatch(batch) |
|
||||||
} catch { |
|
||||||
Logger.error(error) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
fileprivate func _executeBatchOnce(_ batch: OperationBatch<T>) async throws { |
|
||||||
try await self.storeCenter.singleBatchExecution(batch) |
|
||||||
} |
|
||||||
|
|
||||||
// MARK: Single calls |
|
||||||
|
|
||||||
public func addsIfPostSucceeds(_ instance: T) async throws { |
|
||||||
if let result = try await self.storeCenter.service().post(instance) { |
|
||||||
self.addOrUpdateNoSync(result) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
public func updateIfPutSucceeds(_ instance: T) async throws { |
|
||||||
if let result = try await self.storeCenter.service().put(instance) { |
|
||||||
self.addOrUpdateNoSync(result) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
// MARK: - Synchronization |
|
||||||
|
|
||||||
/// Adds or update an instance if it is newer than the local instance |
|
||||||
func addOrUpdateIfNewer(_ instance: T, shared: SharingStatus?) { |
|
||||||
|
|
||||||
if let index = self.collection.items.firstIndex(where: { $0.id == instance.id }) { |
|
||||||
let localInstance = self.collection.items[index] |
|
||||||
if instance.lastUpdate > localInstance.lastUpdate { |
|
||||||
self.collection.update(instance, index: index, actionOption: .standard) |
|
||||||
} else { |
|
||||||
// print("do not update \(T.resourceName()): \(instance.lastUpdate.timeIntervalSince1970) / local: \(localInstance.lastUpdate.timeIntervalSince1970)") |
|
||||||
} |
|
||||||
} else { // insert |
|
||||||
instance.sharing = shared |
|
||||||
self.collection.add(instance: instance, actionOption: .standard) |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
// MARK: - Others |
|
||||||
|
|
||||||
/// Sends a POST request for the instance, and changes the collection to perform a write |
|
||||||
public func writeChangeAndInsertOnServer(instance: T) { |
|
||||||
|
|
||||||
self.collection.addOrUpdate(instance: instance) |
|
||||||
Task { |
|
||||||
await self._sendInsertion(instance) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
// MARK: - SomeCollection |
|
||||||
|
|
||||||
public var hasLoaded: Bool { return self.collection.hasLoaded} |
|
||||||
|
|
||||||
public var inMemory: Bool { return self.collection.inMemory } |
|
||||||
|
|
||||||
public var type: any Storable.Type { return T.self } |
|
||||||
|
|
||||||
public func hasParentReferences<S>(type: S.Type, id: String) -> Bool where S : Storable { |
|
||||||
return self.collection.hasParentReferences(type: type, id: id) |
|
||||||
} |
|
||||||
|
|
||||||
public func reset() { |
|
||||||
self.collection.reset() |
|
||||||
} |
|
||||||
|
|
||||||
public func findById(_ id: T.ID) -> T? { |
|
||||||
return self.collection.findById(id) |
|
||||||
} |
|
||||||
|
|
||||||
public var items: [T] { |
|
||||||
return self.collection.items |
|
||||||
} |
|
||||||
|
|
||||||
public func requestWriteIfNecessary() { |
|
||||||
self.collection.requestWriteIfNecessary() |
|
||||||
} |
|
||||||
|
|
||||||
// MARK: - Cached queries |
|
||||||
|
|
||||||
public func cached<Result>( |
|
||||||
key: AnyHashable, |
|
||||||
compute: ([T]) -> Result |
|
||||||
) -> Result { |
|
||||||
return self.collection.cached(key: key, compute: compute) |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
class OperationBatch<T> { |
|
||||||
var inserts: [T] = [] |
|
||||||
var updates: [T] = [] |
|
||||||
var deletes: [T] = [] |
|
||||||
|
|
||||||
init() { |
|
||||||
|
|
||||||
} |
|
||||||
init(insert: T) { |
|
||||||
self.inserts = [insert] |
|
||||||
} |
|
||||||
init(update: T) { |
|
||||||
self.updates = [update] |
|
||||||
} |
|
||||||
init(delete: T) { |
|
||||||
self.deletes = [delete] |
|
||||||
} |
|
||||||
|
|
||||||
func addInsert(_ instance: T) { |
|
||||||
self.inserts.append(instance) |
|
||||||
} |
|
||||||
func addUpdate(_ instance: T) { |
|
||||||
self.updates.append(instance) |
|
||||||
} |
|
||||||
func addDelete(_ instance: T) { |
|
||||||
self.deletes.append(instance) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
extension SyncedCollection: RandomAccessCollection { |
|
||||||
|
|
||||||
public var startIndex: Int { return self.collection.items.startIndex } |
|
||||||
|
|
||||||
public var endIndex: Int { return self.collection.items.endIndex } |
|
||||||
|
|
||||||
public func index(after i: Int) -> Int { |
|
||||||
return self.collection.items.index(after: i) |
|
||||||
} |
|
||||||
|
|
||||||
public subscript(index: Int) -> T { |
|
||||||
get { |
|
||||||
return self.collection.items[index] |
|
||||||
} |
|
||||||
set(newValue) { |
|
||||||
self.collection.update(newValue, index: index, actionOption: .standard) |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
@ -1,44 +0,0 @@ |
|||||||
// |
|
||||||
// MockKeychainStore.swift |
|
||||||
// LeStorage |
|
||||||
// |
|
||||||
// Created by Laurent Morvillier on 17/04/2025. |
|
||||||
// |
|
||||||
|
|
||||||
import Foundation |
|
||||||
|
|
||||||
class TokenStore: MicroStorable { |
|
||||||
required init() { |
|
||||||
|
|
||||||
} |
|
||||||
var token: String? |
|
||||||
} |
|
||||||
|
|
||||||
class MockKeychainStore: MicroStorage<TokenStore>, KeychainService { |
|
||||||
|
|
||||||
let key = "store" |
|
||||||
|
|
||||||
func add(username: String, value: String) throws { |
|
||||||
try self.add(value: value) |
|
||||||
} |
|
||||||
|
|
||||||
func add(value: String) throws { |
|
||||||
self.update { tokenStore in |
|
||||||
tokenStore.token = value |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
func getValue() throws -> String { |
|
||||||
if let value = self.item.token { |
|
||||||
return value |
|
||||||
} |
|
||||||
throw KeychainError.keychainItemNotFound(serverId: "mock") |
|
||||||
} |
|
||||||
|
|
||||||
func deleteValue() throws { |
|
||||||
self.update { tokenStore in |
|
||||||
tokenStore.token = nil |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
@ -1,17 +0,0 @@ |
|||||||
// |
|
||||||
// String+Extensions.swift |
|
||||||
// LeStorage |
|
||||||
// |
|
||||||
// Created by Laurent Morvillier on 19/05/2025. |
|
||||||
// |
|
||||||
|
|
||||||
import Foundation |
|
||||||
|
|
||||||
public extension String { |
|
||||||
|
|
||||||
static func random(length: Int = 10) -> String { |
|
||||||
let letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" |
|
||||||
return String((0..<length).map{ _ in letters.randomElement()! }) |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
Loading…
Reference in new issue