diff --git a/README.md b/README.md index c9b9409..1651792 100644 --- a/README.md +++ b/README.md @@ -291,6 +291,109 @@ Person.getOverTwenties() { result, error in If you'd like to learn more about how you can customize queries, check out the [Swift-Kuery](https://github.com/IBM-Swift/Swift-Kuery) repository for more information. +## Model Identifiers + +The ORM has several options available for identifying an instance of a model. + +### Automatic ID assignment + +If you define your `Model` without specifying an ID property, either by using the `idColumnName` property or the default name of `id`, then the ORM will create an auto-incrementing column named `id` in the database table for the model, eg. + +```swift +struct Person: Model { + var firstname: String + var surname: String + var age: Int +} +``` + +The model does not contain a property for the ID. The ORM provides a specific `save` API that will return the ID that was assigned. It is important to note the ORM will not link the returned ID to the instance of the Model in any way; you are responsible for maintaining this relationship if necessary. Below is an example of retrieving an ID for an instance of the `Person` model defined above: + +```swift +let person = Person(firstname: "example", surname: "person", age: 21) +person.save() { (id: Int?, person, error) in + guard let id = id, let person = person else{ + // Handle error + return + } + // Use person and id +} +``` +The compiler requires you to declare the type of the ID received by your completion handler; the type should be `Int?` for an ID that has been automatically assigned. + +### Manual ID assignment + +You can manage the assignment of IDs yourself by adding an `id` property to your model. You may customise the name of this property by defining `idColumnName`. For example: + +```swift +struct Person: Model { + var myIDField: Int + var firstname: String + var surname: String + var age: Int + + static var idColumnName = "myIDField" + static var idColumnType = Int.self +} +``` + +When using a `Model` defined in this way, you are responsible for the assignment and management of IDs. Below is an example of saving an instance of the `Person` model defined above: + +```swift +let person = Person(myIDField: 1, firstname: "example", surname: "person", age: 21) +person.save() { (person, error) in + guard let person = person else { + // Handle error + return + } + // Use newly saved person +} +``` + +### Using `optional` ID properties + +Declaring your ID property as optional allows the ORM to assign the ID automatically when the model is saved. If the value of ID is `nil`, the database will assign an auto-incremented value. At present this is only support for an `Int?` type. + +You may instead provide an explicit value, which will be used instead of automatic assignment. + +Optional IDs must be identified by defining the `idKeypath: IDKeyPath` property, as in the example below: + +```swift +struct Person: Model { + var id: Int? + var firstname: String + var surname: String + var age: Int + + static var idKeyPath: IDKeyPath = \Person.id +} +``` + +In the example above, the `Model` is defined with an ID property matching the default `idColumnName` value, but should you wish to use an alternative name you must define `idColumnName` accordingly. + +Below is an example of saving an instance of the `Person` defined above, both with an explicitly defined ID and without: + +```swift +let person = Person(id: nil, firstname: “Banana”, surname: “Man”, age: 21) +let specificPerson = Person(id: 5, firstname: “Super”, surname: “Ted”, age: 26) + +person.save() { (savedPerson, error) in + guard let newPerson = savedPerson else { + // Handle error + } + print(newPerson.id) // Prints the next value in the databases identifier sequence, eg. 1 +} + +specificPerson.save() { (savedPerson, error) in + guard let newPerson = savedPerson else { + // Handle error + } + print(newPerson.id) // Prints 5 +} +``` + +**NOTE** - When using manual or optional ID properties, you should be prepared to handle violation of unique identifier constraints. These can occur if you attempt to save a model with an ID that already exists, or in the case of Postgres, if the auto-incremented value collides with an ID that was previously inserted explicitly. + ## List of plugins * [PostgreSQL](https://github.com/IBM-Swift/Swift-Kuery-PostgreSQL) diff --git a/Sources/SwiftKueryORM/Model.swift b/Sources/SwiftKueryORM/Model.swift index 31b70d0..a6d68a0 100644 --- a/Sources/SwiftKueryORM/Model.swift +++ b/Sources/SwiftKueryORM/Model.swift @@ -28,6 +28,12 @@ public protocol Model: Codable { /// Defines the id column type in the Database static var idColumnType: SQLDataType.Type {get} + /// Defines typealias for the id fields keypath + typealias IDKeyPath = WritableKeyPath? + + /// Defines the keypath to the Models id field + static var idKeypath: IDKeyPath {get} + /// Call to create the table in the database synchronously static func createTableSync(using db: Database?) throws -> Bool @@ -112,6 +118,8 @@ public extension Model { /// Defaults to Int64 static var idColumnType: SQLDataType.Type { return Int64.self } + static var idKeypath: IDKeyPath { return nil } + private static func executeTask(using db: Database? = nil, task: @escaping ((Connection?, QueryError?) -> ())) { guard let database = db ?? Database.default else { @@ -231,10 +239,11 @@ public extension Model { return } - let columns = table.columns.filter({$0.autoIncrement != true && values[$0.name] != nil}) + let columns = table.columns.filter({values[$0.name] != nil}) let parameters: [Any?] = columns.map({values[$0.name]!}) let parameterPlaceHolders: [Parameter] = parameters.map {_ in return Parameter()} - let query = Insert(into: table, columns: columns, values: parameterPlaceHolders) + let returnID: Bool = Self.idKeypath != nil + let query = Insert(into: table, columns: columns, values: parameterPlaceHolders, returnID: returnID) self.executeQuery(query: query, parameters: parameters, using: db, onCompletion) } @@ -350,6 +359,7 @@ public extension Model { } private func executeQuery(query: Query, parameters: [Any?], using db: Database? = nil, _ onCompletion: @escaping (Self?, RequestError?) -> Void ) { + var dictionaryTitleToValue = [String: Any?]() Self.executeTask() { connection, error in guard let connection = connection else { guard let error = error else { @@ -366,8 +376,43 @@ public extension Model { onCompletion(nil, Self.convertError(error)) return } + if let insertQuery = query as? Insert, insertQuery.returnID { + result.asRows() { rows, error in + guard let rows = rows, rows.count > 0 else { + onCompletion(nil, RequestError(.ormNotFound, reason: "Could not retrieve value for Query: \(String(describing: query))")) + return + } + + dictionaryTitleToValue = rows[0] + + guard let value = dictionaryTitleToValue[Self.idColumnName] else { + onCompletion(nil, RequestError(.ormNotFound, reason: "Could not find return id")) + return + } + + guard let unwrappedValue: Any = value else { + onCompletion(nil, RequestError(.ormNotFound, reason: "Return id is nil")) + return + } + + guard let idKeyPath = Self.idKeypath else { + // We should not be here if keypath is nil + return onCompletion(nil, RequestError(.ormInternalError, reason: "id Keypath is nil")) + } + var newValue: Int? = nil + do { + newValue = try Int(value: String(describing: unwrappedValue)) + } catch { + return onCompletion(nil, RequestError(.ormInternalError, reason: "Unable to convert identifier")) + } + var newSelf = self + newSelf[keyPath: idKeyPath] = newValue - onCompletion(self, nil) + return onCompletion(newSelf, nil) + } + } else { + return onCompletion(self, nil) + } } } } @@ -708,7 +753,8 @@ public extension Model { } static func getTable() throws -> Table { - return try Database.tableInfo.getTable((Self.idColumnName, Self.idColumnType), Self.tableName, for: Self.self) + let idKeyPathSet: Bool = Self.idKeypath != nil + return try Database.tableInfo.getTable((Self.idColumnName, Self.idColumnType, idKeyPathSet), Self.tableName, for: Self.self) } /** diff --git a/Sources/SwiftKueryORM/TableInfo.swift b/Sources/SwiftKueryORM/TableInfo.swift index 5e0b47e..8385d78 100644 --- a/Sources/SwiftKueryORM/TableInfo.swift +++ b/Sources/SwiftKueryORM/TableInfo.swift @@ -29,11 +29,11 @@ public class TableInfo { private var codableMapQueue = DispatchQueue(label: "codableMap.queue", attributes: .concurrent) /// Get the table for a model - func getTable(_ idColumn: (name: String, type: SQLDataType.Type), _ tableName: String, for type: T.Type) throws -> Table { + func getTable(_ idColumn: (name: String, type: SQLDataType.Type, idKeyPathSet: Bool), _ tableName: String, for type: T.Type) throws -> Table { return try getInfo(idColumn, tableName, type).table } - func getInfo(_ idColumn: (name: String, type: SQLDataType.Type), _ tableName: String, _ type: T.Type) throws -> (info: TypeInfo, table: Table) { + func getInfo(_ idColumn: (name: String, type: SQLDataType.Type, idKeyPathSet: Bool), _ tableName: String, _ type: T.Type) throws -> (info: TypeInfo, table: Table) { let typeString = "\(type)" var result: (TypeInfo, Table)? = nil // Read from codableMap when no concurrent write is occurring @@ -57,7 +57,7 @@ public class TableInfo { } /// Construct the table for a Model - func constructTable(_ idColumn: (name: String, type: SQLDataType.Type), _ tableName: String, _ typeInfo: TypeInfo) throws -> Table { + func constructTable(_ idColumn: (name: String, type: SQLDataType.Type, idKeyPathSet: Bool), _ tableName: String, _ typeInfo: TypeInfo) throws -> Table { var columns: [Column] = [] var idColumnIsSet = false switch typeInfo { @@ -91,7 +91,12 @@ public class TableInfo { } if let SQLType = valueType as? SQLDataType.Type { if key == idColumn.name && !idColumnIsSet { - columns.append(Column(key, SQLType, primaryKey: true, notNull: !optionalBool)) + // If this is an optional id field create an autoincrementing column + if optionalBool && idColumn.idKeyPathSet { + columns.append(Column(key, SQLType, autoIncrement: true, primaryKey: true)) + } else { + columns.append(Column(key, SQLType, primaryKey: true, notNull: !optionalBool)) + } idColumnIsSet = true } else { columns.append(Column(key, SQLType, notNull: !optionalBool)) diff --git a/Tests/SwiftKueryORMTests/TestId.swift b/Tests/SwiftKueryORMTests/TestId.swift index 09ed8a8..0337819 100644 --- a/Tests/SwiftKueryORMTests/TestId.swift +++ b/Tests/SwiftKueryORMTests/TestId.swift @@ -10,6 +10,8 @@ class TestId: XCTestCase { ("testFind", testFind), ("testUpdate", testUpdate), ("testDelete", testDelete), + ("testNilIDInsert", testNilIDInsert), + ("testNonAutoNilIDInsert", testNonAutoNilIDInsert), ] } @@ -105,4 +107,52 @@ class TestId: XCTestCase { } }) } + + struct IdentifiedPerson: Model { + static var tableName = "People" + static var idKeypath: IDKeyPath = \IdentifiedPerson.id + + var id: Int? + var name: String + var age: Int + } + + func testNilIDInsert() { + let connection: TestConnection = createConnection(.returnOneRow) //[1, "Joe", Int32(38)] + Database.default = Database(single: connection) + performTest(asyncTasks: { expectation in + let myIPerson = IdentifiedPerson(id: nil, name: "Joe", age: 38) + myIPerson.save() { identifiedPerson, error in + XCTAssertNil(error, "Error on IdentifiedPerson.save") + if let newPerson = identifiedPerson { + XCTAssertEqual(newPerson.id, 1, "Id not stored on IdentifiedPerson") + } + expectation.fulfill() + } + }) + } + + struct NonAutoIDPerson: Model { + static var tableName = "People" + + var id: Int? + var name: String + var age: Int + } + + func testNonAutoNilIDInsert() { + let connection: TestConnection = createConnection(.returnOneRow) //[1, "Joe", Int32(38)] + Database.default = Database(single: connection) + performTest(asyncTasks: { expectation in + NonAutoIDPerson.createTable { result, error in + XCTAssertNil(error, "Table Creation Failed: \(String(describing: error))") + XCTAssertNotNil(connection.raw, "Table Creation Failed: Query is nil") + if let raw = connection.raw { + let expectedQuery = "CREATE TABLE \"People\" (\"id\" type PRIMARY KEY, \"name\" type NOT NULL, \"age\" type NOT NULL)" + XCTAssertEqual(raw, expectedQuery, "Table Creation Failed: Invalid query") + } + expectation.fulfill() + } + }) + } }