Skip to content

Commit

Permalink
Add support for embedding auto-assigned IDs (#104)
Browse files Browse the repository at this point in the history
  • Loading branch information
kilnerm authored and djones6 committed Mar 19, 2019
1 parent c448d1b commit bbfbb38
Show file tree
Hide file tree
Showing 4 changed files with 212 additions and 8 deletions.
103 changes: 103 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
54 changes: 50 additions & 4 deletions Sources/SwiftKueryORM/Model.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Self, Int?>?

/// 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

Expand Down Expand Up @@ -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 {

Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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 {
Expand All @@ -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)
}
}
}
}
Expand Down Expand Up @@ -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)
}

/**
Expand Down
13 changes: 9 additions & 4 deletions Sources/SwiftKueryORM/TableInfo.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,11 @@ public class TableInfo {
private var codableMapQueue = DispatchQueue(label: "codableMap.queue", attributes: .concurrent)

/// Get the table for a model
func getTable<T: Decodable>(_ idColumn: (name: String, type: SQLDataType.Type), _ tableName: String, for type: T.Type) throws -> Table {
func getTable<T: Decodable>(_ 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<T: Decodable>(_ idColumn: (name: String, type: SQLDataType.Type), _ tableName: String, _ type: T.Type) throws -> (info: TypeInfo, table: Table) {
func getInfo<T: Decodable>(_ 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
Expand All @@ -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 {
Expand Down Expand Up @@ -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))
Expand Down
50 changes: 50 additions & 0 deletions Tests/SwiftKueryORMTests/TestId.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ class TestId: XCTestCase {
("testFind", testFind),
("testUpdate", testUpdate),
("testDelete", testDelete),
("testNilIDInsert", testNilIDInsert),
("testNonAutoNilIDInsert", testNonAutoNilIDInsert),
]
}

Expand Down Expand Up @@ -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()
}
})
}
}

0 comments on commit bbfbb38

Please sign in to comment.