Accept storable ids to not only be strings

sync2
Laurent 1 year ago
parent ecc4791342
commit 65b2f75160
  1. 78
      LeStorage.xcodeproj/project.pbxproj
  2. 55
      LeStorage.xcodeproj/xcshareddata/xcschemes/LeStorageTests.xcscheme
  3. 4
      LeStorage/ApiCallCollection.swift
  4. 7
      LeStorage/Services.swift
  5. 13
      LeStorage/Storable.swift
  6. 2
      LeStorage/Store.swift
  7. 16
      LeStorage/StoredCollection.swift
  8. 68
      LeStorageTests/LeStorageTests.swift

@ -8,8 +8,6 @@
/* Begin PBXBuildFile section */
C425D4392B6D24E1002A7B48 /* LeStorage.docc in Sources */ = {isa = PBXBuildFile; fileRef = C425D4382B6D24E1002A7B48 /* LeStorage.docc */; };
C425D43F2B6D24E1002A7B48 /* LeStorage.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C425D4342B6D24E1002A7B48 /* LeStorage.framework */; };
C425D4442B6D24E1002A7B48 /* LeStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C425D4432B6D24E1002A7B48 /* LeStorageTests.swift */; };
C425D4452B6D24E1002A7B48 /* LeStorage.h in Headers */ = {isa = PBXBuildFile; fileRef = C425D4372B6D24E1002A7B48 /* LeStorage.h */; settings = {ATTRIBUTES = (Public, ); }; };
C425D4582B6D2519002A7B48 /* Store.swift in Sources */ = {isa = PBXBuildFile; fileRef = C425D4572B6D2519002A7B48 /* Store.swift */; };
C456EFE22BE52379007388E2 /* StoredSingleton.swift in Sources */ = {isa = PBXBuildFile; fileRef = C456EFE12BE52379007388E2 /* StoredSingleton.swift */; };
@ -31,12 +29,13 @@
C4A47D9B2B7CFFDA00ADC637 /* ApiCall.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D992B7CFFC500ADC637 /* ApiCall.swift */; };
C4A47D9C2B7CFFE000ADC637 /* Settings.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D9A2B7CFFC500ADC637 /* Settings.swift */; };
C4A47DAF2B85FD3800ADC637 /* Errors.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47DAE2B85FD3800ADC637 /* Errors.swift */; };
C4C33F6F2C9B06B7006316DE /* LeStorage.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C425D4342B6D24E1002A7B48 /* LeStorage.framework */; };
C4FC2E292C2B2EC30021F3BF /* StoreCenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4FC2E282C2B2EC30021F3BF /* StoreCenter.swift */; };
C4FC2E312C353E7B0021F3BF /* Log.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4FC2E302C353E7B0021F3BF /* Log.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
C425D4402B6D24E1002A7B48 /* PBXContainerItemProxy */ = {
C4C33F702C9B06B7006316DE /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = C425D42B2B6D24E1002A7B48 /* Project object */;
proxyType = 1;
@ -49,8 +48,6 @@
C425D4342B6D24E1002A7B48 /* LeStorage.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = LeStorage.framework; sourceTree = BUILT_PRODUCTS_DIR; };
C425D4372B6D24E1002A7B48 /* LeStorage.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = LeStorage.h; sourceTree = "<group>"; };
C425D4382B6D24E1002A7B48 /* LeStorage.docc */ = {isa = PBXFileReference; lastKnownFileType = folder.documentationcatalog; path = LeStorage.docc; sourceTree = "<group>"; };
C425D43E2B6D24E1002A7B48 /* LeStorageTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = LeStorageTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
C425D4432B6D24E1002A7B48 /* LeStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LeStorageTests.swift; sourceTree = "<group>"; };
C425D4572B6D2519002A7B48 /* Store.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Store.swift; sourceTree = "<group>"; };
C456EFE12BE52379007388E2 /* StoredSingleton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredSingleton.swift; sourceTree = "<group>"; };
C45D35902C0A1DB5000F379F /* FailedAPICall.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FailedAPICall.swift; sourceTree = "<group>"; };
@ -71,10 +68,15 @@
C4A47D992B7CFFC500ADC637 /* ApiCall.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiCall.swift; sourceTree = "<group>"; };
C4A47D9A2B7CFFC500ADC637 /* Settings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Settings.swift; sourceTree = "<group>"; };
C4A47DAE2B85FD3800ADC637 /* Errors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Errors.swift; sourceTree = "<group>"; };
C4C33F6B2C9B06B7006316DE /* LeStorageTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = LeStorageTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
C4FC2E282C2B2EC30021F3BF /* StoreCenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreCenter.swift; sourceTree = "<group>"; };
C4FC2E302C353E7B0021F3BF /* Log.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Log.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
C4C33F6C2C9B06B7006316DE /* LeStorageTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = LeStorageTests; sourceTree = "<group>"; };
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
C425D4312B6D24E1002A7B48 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
@ -83,11 +85,11 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
C425D43B2B6D24E1002A7B48 /* Frameworks */ = {
C4C33F682C9B06B7006316DE /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
C425D43F2B6D24E1002A7B48 /* LeStorage.framework in Frameworks */,
C4C33F6F2C9B06B7006316DE /* LeStorage.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -98,7 +100,7 @@
isa = PBXGroup;
children = (
C425D4362B6D24E1002A7B48 /* LeStorage */,
C425D4422B6D24E1002A7B48 /* LeStorageTests */,
C4C33F6C2C9B06B7006316DE /* LeStorageTests */,
C425D4352B6D24E1002A7B48 /* Products */,
);
sourceTree = "<group>";
@ -107,7 +109,7 @@
isa = PBXGroup;
children = (
C425D4342B6D24E1002A7B48 /* LeStorage.framework */,
C425D43E2B6D24E1002A7B48 /* LeStorageTests.xctest */,
C4C33F6B2C9B06B7006316DE /* LeStorageTests.xctest */,
);
name = Products;
sourceTree = "<group>";
@ -134,14 +136,6 @@
path = LeStorage;
sourceTree = "<group>";
};
C425D4422B6D24E1002A7B48 /* LeStorageTests */ = {
isa = PBXGroup;
children = (
C425D4432B6D24E1002A7B48 /* LeStorageTests.swift */,
);
path = LeStorageTests;
sourceTree = "<group>";
};
C4A47D582B6D352900ADC637 /* Utils */ = {
isa = PBXGroup;
children = (
@ -207,22 +201,27 @@
productReference = C425D4342B6D24E1002A7B48 /* LeStorage.framework */;
productType = "com.apple.product-type.framework";
};
C425D43D2B6D24E1002A7B48 /* LeStorageTests */ = {
C4C33F6A2C9B06B7006316DE /* LeStorageTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = C425D44B2B6D24E1002A7B48 /* Build configuration list for PBXNativeTarget "LeStorageTests" */;
buildConfigurationList = C4C33F722C9B06B7006316DE /* Build configuration list for PBXNativeTarget "LeStorageTests" */;
buildPhases = (
C425D43A2B6D24E1002A7B48 /* Sources */,
C425D43B2B6D24E1002A7B48 /* Frameworks */,
C425D43C2B6D24E1002A7B48 /* Resources */,
C4C33F672C9B06B7006316DE /* Sources */,
C4C33F682C9B06B7006316DE /* Frameworks */,
C4C33F692C9B06B7006316DE /* Resources */,
);
buildRules = (
);
dependencies = (
C425D4412B6D24E1002A7B48 /* PBXTargetDependency */,
C4C33F712C9B06B7006316DE /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
C4C33F6C2C9B06B7006316DE /* LeStorageTests */,
);
name = LeStorageTests;
packageProductDependencies = (
);
productName = LeStorageTests;
productReference = C425D43E2B6D24E1002A7B48 /* LeStorageTests.xctest */;
productReference = C4C33F6B2C9B06B7006316DE /* LeStorageTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
/* End PBXNativeTarget section */
@ -232,14 +231,14 @@
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1520;
LastSwiftUpdateCheck = 1600;
LastUpgradeCheck = 1520;
TargetAttributes = {
C425D4332B6D24E1002A7B48 = {
CreatedOnToolsVersion = 15.2;
};
C425D43D2B6D24E1002A7B48 = {
CreatedOnToolsVersion = 15.2;
C4C33F6A2C9B06B7006316DE = {
CreatedOnToolsVersion = 16.0;
};
};
};
@ -257,7 +256,7 @@
projectRoot = "";
targets = (
C425D4332B6D24E1002A7B48 /* LeStorage */,
C425D43D2B6D24E1002A7B48 /* LeStorageTests */,
C4C33F6A2C9B06B7006316DE /* LeStorageTests */,
);
};
/* End PBXProject section */
@ -271,7 +270,7 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
C425D43C2B6D24E1002A7B48 /* Resources */ = {
C4C33F692C9B06B7006316DE /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
@ -310,21 +309,20 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
C425D43A2B6D24E1002A7B48 /* Sources */ = {
C4C33F672C9B06B7006316DE /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
C425D4442B6D24E1002A7B48 /* LeStorageTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
C425D4412B6D24E1002A7B48 /* PBXTargetDependency */ = {
C4C33F712C9B06B7006316DE /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = C425D4332B6D24E1002A7B48 /* LeStorage */;
targetProxy = C425D4402B6D24E1002A7B48 /* PBXContainerItemProxy */;
targetProxy = C4C33F702C9B06B7006316DE /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
@ -518,14 +516,14 @@
};
name = Release;
};
C425D44C2B6D24E1002A7B48 /* Debug */ = {
C4C33F732C9B06B7006316DE /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 526E96RFNP;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.staxriver.LeStorageTests;
PRODUCT_NAME = "$(TARGET_NAME)";
@ -535,14 +533,14 @@
};
name = Debug;
};
C425D44D2B6D24E1002A7B48 /* Release */ = {
C4C33F742C9B06B7006316DE /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 526E96RFNP;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.staxriver.LeStorageTests;
PRODUCT_NAME = "$(TARGET_NAME)";
@ -573,11 +571,11 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
C425D44B2B6D24E1002A7B48 /* Build configuration list for PBXNativeTarget "LeStorageTests" */ = {
C4C33F722C9B06B7006316DE /* Build configuration list for PBXNativeTarget "LeStorageTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
C425D44C2B6D24E1002A7B48 /* Debug */,
C425D44D2B6D24E1002A7B48 /* Release */,
C4C33F732C9B06B7006316DE /* Debug */,
C4C33F742C9B06B7006316DE /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;

@ -0,0 +1,55 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1600"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "C4C33F6A2C9B06B7006316DE"
BuildableName = "LeStorageTests.xctest"
BlueprintName = "LeStorageTests"
ReferencedContainer = "container:LeStorage.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

@ -182,7 +182,7 @@ actor ApiCallCollection<T: Storable>: SomeCallCollection {
/// The method updates existing calls or creates a new one
fileprivate func _callForInstance(_ instance: T, method: HTTPMethod) throws -> ApiCall<T>? {
if let existingCall = self.items.first(where: { $0.dataId == instance.id }) {
if let existingCall = self.items.first(where: { $0.dataId == instance.stringId }) {
switch method {
case .delete:
self.deleteById(existingCall.id) // delete the existing call as we don't need it
@ -203,7 +203,7 @@ actor ApiCallCollection<T: Storable>: SomeCallCollection {
/// Creates an API call for the Storable [instance] and an HTTP [method]
fileprivate func _createCall(_ instance: T, method: HTTPMethod) throws -> ApiCall<T> {
let jsonString = try instance.jsonString()
return ApiCall(method: method, dataId: String(instance.id), body: jsonString)
return ApiCall(method: method, dataId: instance.stringId, body: jsonString)
}
/// Prepares a call for execution by updating its properties and adding it to its collection for storage

@ -111,13 +111,14 @@ public class Services {
/// - request: the URLRequest to run
/// - apiCallId: the id of the ApiCall to delete in case of success, or to schedule for a rerun in case of failure
fileprivate func _runRequest<T: Decodable>(_ request: URLRequest, apiCallId: String? = nil) async throws -> T {
Logger.log("Run \(request.httpMethod ?? "") \(request.url?.absoluteString ?? "")")
let debugURL = request.url?.absoluteString ?? ""
print("Run \(request.httpMethod ?? "") \(debugURL)")
let task: (Data, URLResponse) = try await URLSession.shared.data(for: request)
Logger.log("response = \(String(data: task.0, encoding: .utf8) ?? "")")
print("response = \(String(data: task.0, encoding: .utf8) ?? "")")
if let response = task.1 as? HTTPURLResponse {
let statusCode = response.statusCode
Logger.log("request ended with status code = \(statusCode)")
print("\(debugURL) ended, status code = \(statusCode)")
switch statusCode {
case 200..<300: // success
if let apiCallId {

@ -8,7 +8,7 @@
import Foundation
/// A protocol describing classes that can be stored locally in JSON and synchronized on our django server
public protocol Storable: Codable, Identifiable where ID : StringProtocol {
public protocol Storable: Codable, Identifiable {
/// The store containing a reference to the instance
var store: Store? { get set }
@ -48,7 +48,16 @@ extension Storable {
}
/// Returns a string id for the instance
var stringId: String { return String(self.id) }
var stringId: String {
switch self.id {
case let sp as any StringProtocol:
return String(sp)
case let intLitteral as any ExpressibleByIntegerLiteral:
return "\(intLitteral)"
default:
fatalError("id not convertible to string")
}
}
/// Returns the relative path of the instance for the django server
static func path(id: String? = nil) -> String {

@ -114,7 +114,7 @@ open class Store {
/// Looks for an instance by id
/// - Parameters:
/// - id: the id of the data
public func findById<T: Storable>(_ id: String) -> T? {
public func findById<T: Storable>(_ id: T.ID) -> T? {
guard let collection = self._collections[T.resourceName()] as? StoredCollection<T> else {
Logger.w("Collection \(T.resourceName()) not registered")
return nil

@ -55,7 +55,7 @@ public class StoredCollection<T: Storable>: RandomAccessCollection, SomeCollecti
fileprivate var _store: Store
/// Provides fast access for instances if the collection has been instanced with [indexed] = true
fileprivate var _indexes: [String : T]? = nil
fileprivate var _indexes: [T.ID : T]? = nil
/// Indicates whether the collection has changed, thus requiring a write operation
fileprivate var _hasChanged: Bool = false {
@ -178,7 +178,7 @@ public class StoredCollection<T: Storable>: RandomAccessCollection, SomeCollecti
/// Updates the whole index with the items array
fileprivate func _updateIndexIfNecessary() {
if let _ = self._indexes {
self._indexes = self.items.dictionary { $0.stringId }
self._indexes = self.items.dictionary { $0.id }
}
}
@ -229,7 +229,7 @@ public class StoredCollection<T: Storable>: RandomAccessCollection, SomeCollecti
self.items.append(instance)
self._sendInsertionIfNecessary(instance)
}
self._indexes?[instance.stringId] = instance
self._indexes?[instance.id] = instance
}
@ -259,7 +259,7 @@ public class StoredCollection<T: Storable>: RandomAccessCollection, SomeCollecti
try instance.deleteDependencies()
self.items.removeAll { $0.id == instance.id }
self._indexes?.removeValue(forKey: instance.stringId)
self._indexes?.removeValue(forKey: instance.id)
self._sendDeletionIfNecessary(instance)
}
@ -274,7 +274,7 @@ public class StoredCollection<T: Storable>: RandomAccessCollection, SomeCollecti
for instance in sequence {
try instance.deleteDependencies()
self.items.removeAll { $0.id == instance.id }
self._indexes?.removeValue(forKey: instance.stringId)
self._indexes?.removeValue(forKey: instance.id)
self._sendDeletionIfNecessary(instance)
}
}
@ -308,13 +308,13 @@ public class StoredCollection<T: Storable>: RandomAccessCollection, SomeCollecti
}
}
instance.store = self._store
self._indexes?[instance.stringId] = instance
self._indexes?[instance.id] = instance
}
}
/// Returns the instance corresponding to the provided [id]
public func findById(_ id: String) -> T? {
public func findById(_ id: T.ID) -> T? {
if let index = self._indexes, let instance = index[id] {
return instance
}
@ -322,7 +322,7 @@ public class StoredCollection<T: Storable>: RandomAccessCollection, SomeCollecti
}
/// Deletes the instance corresponding to the provided [id]
public func deleteById(_ id: String) throws {
public func deleteById(_ id: T.ID) throws {
if let instance = self.findById(id) {
try self.delete(instance: instance)
}

@ -0,0 +1,68 @@
//
// LeStorageTests.swift
// LeStorageTests
//
// Created by Laurent Morvillier on 18/09/2024.
//
import Testing
import LeStorage
class IntObject: ModelObject, Storable {
static func resourceName() -> String { "int" }
static func tokenExemptedMethods() -> [LeStorage.HTTPMethod] { [] }
static func filterByStoreIdentifier() -> Bool { false }
static var relationshipNames: [String] = []
var id: Int
var name: String
init(id: Int, name: String) {
self.id = id
self.name = name
}
}
class StringObject: ModelObject, Storable {
static func resourceName() -> String { "string" }
static func tokenExemptedMethods() -> [LeStorage.HTTPMethod] { [] }
static func filterByStoreIdentifier() -> Bool { false }
static var relationshipNames: [String] = []
var id: String
var name: String
init(id: String, name: String) {
self.id = id
self.name = name
}
}
struct LeStorageTests {
@Test func testIntIds() async throws {
let intObjects: StoredCollection<IntObject> = Store.main.registerCollection(synchronized: false)
let int = IntObject(id: 12, name: "test")
try? intObjects.addOrUpdate(instance: int)
if let search = intObjects.findById(12) {
#expect(search.id == 12)
} else {
Issue.record("object is missing")
}
}
@Test func testStringIds() async throws {
let stringObjects: StoredCollection<StringObject> = Store.main.registerCollection(synchronized: false)
let string = StringObject(id: "coco", name: "name")
try? stringObjects.addOrUpdate(instance: string)
if let search = stringObjects.findById("coco") {
#expect(search.id == "coco")
} else {
Issue.record("object is missing")
}
}
}
Loading…
Cancel
Save