178 lines
7.1 KiB
Swift
178 lines
7.1 KiB
Swift
/// SQLForeignKeyRequest looks for the foreign keys associations need to
|
|
/// join tables.
|
|
///
|
|
/// Columns mapping come from foreign keys, when they exist in the
|
|
/// database schema.
|
|
///
|
|
/// When the schema does not define any foreign key, we can still infer complete
|
|
/// mapping from partial information and primary keys.
|
|
struct SQLForeignKeyRequest {
|
|
let originTable: String
|
|
let destinationTable: String
|
|
let originColumns: [String]?
|
|
let destinationColumns: [String]?
|
|
|
|
init(originTable: String, destinationTable: String, foreignKey: ForeignKey?) {
|
|
self.originTable = originTable
|
|
self.destinationTable = destinationTable
|
|
|
|
self.originColumns = foreignKey?.originColumns
|
|
self.destinationColumns = foreignKey?.destinationColumns
|
|
}
|
|
|
|
/// The (origin, destination) column pairs that join a left table to a right table.
|
|
func fetchForeignKeyMapping(_ db: Database) throws -> ForeignKeyMapping {
|
|
if let originColumns, let destinationColumns {
|
|
// Total information: no need to query the database schema.
|
|
GRDBPrecondition(originColumns.count == destinationColumns.count, "Number of columns don't match")
|
|
let mapping = zip(originColumns, destinationColumns).map {
|
|
(origin: $0, destination: $1)
|
|
}
|
|
return mapping
|
|
}
|
|
|
|
// Incomplete information: let's look for schema foreign keys
|
|
//
|
|
// But maybe the tables are views. In this case, don't throw
|
|
// "no such table" error, because this is confusing for the user,
|
|
// as discovered in <https://github.com/groue/GRDB.swift/discussions/1481>.
|
|
// Instead, we'll crash with a clear message.
|
|
|
|
guard let originType = try tableType(db, for: originTable) else {
|
|
throw DatabaseError.noSuchTable(originTable)
|
|
}
|
|
|
|
if originType.isView {
|
|
if originColumns == nil {
|
|
fatalError("""
|
|
Could not infer foreign key from '\(originTable)' \
|
|
to '\(destinationTable)'. To fix this error, provide an \
|
|
explicit `ForeignKey` in the association definition.
|
|
""")
|
|
}
|
|
} else {
|
|
let foreignKeys = try db.foreignKeys(on: originTable).filter { foreignKey in
|
|
if destinationTable.lowercased() != foreignKey.destinationTable.lowercased() {
|
|
return false
|
|
}
|
|
if let originColumns {
|
|
let originColumns = Set(originColumns.lazy.map { $0.lowercased() })
|
|
let foreignKeyColumns = Set(foreignKey.mapping.lazy.map { $0.origin.lowercased() })
|
|
if originColumns != foreignKeyColumns {
|
|
return false
|
|
}
|
|
}
|
|
if let destinationColumns {
|
|
// TODO: test
|
|
let destinationColumns = Set(destinationColumns.lazy.map { $0.lowercased() })
|
|
let foreignKeyColumns = Set(foreignKey.mapping.lazy.map { $0.destination.lowercased() })
|
|
if destinationColumns != foreignKeyColumns {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// Matching foreign key(s) found
|
|
if let foreignKey = foreignKeys.first {
|
|
if foreignKeys.count == 1 {
|
|
// Non-ambiguous
|
|
return foreignKey.mapping
|
|
} else {
|
|
// Ambiguous: can't choose
|
|
fatalError("""
|
|
Ambiguous foreign key from '\(originTable)' to \
|
|
'\(destinationTable)'. To fix this error, provide an \
|
|
explicit `ForeignKey` in the association definition.
|
|
""")
|
|
}
|
|
}
|
|
}
|
|
|
|
// No matching foreign key found: use the destination primary key
|
|
if let originColumns {
|
|
guard let destinationType = try tableType(db, for: destinationTable) else {
|
|
throw DatabaseError.noSuchTable(destinationTable)
|
|
}
|
|
if destinationType.isView {
|
|
fatalError("""
|
|
Could not infer foreign key from '\(originTable)' \
|
|
to '\(destinationTable)'. To fix this error, provide an \
|
|
explicit `ForeignKey` in the association definition, \
|
|
with both origin and destination columns.
|
|
""")
|
|
}
|
|
let destinationColumns = try db.primaryKey(destinationTable).columns
|
|
if originColumns.count == destinationColumns.count {
|
|
let mapping = zip(originColumns, destinationColumns).map {
|
|
(origin: $0, destination: $1)
|
|
}
|
|
return mapping
|
|
}
|
|
}
|
|
|
|
fatalError("""
|
|
Could not infer foreign key from '\(originTable)' to \
|
|
'\(destinationTable)'. To fix this error, provide an \
|
|
explicit `ForeignKey` in the association definition.
|
|
""")
|
|
}
|
|
|
|
private struct TableType {
|
|
var isView: Bool
|
|
}
|
|
|
|
private func tableType(_ db: Database, for name: String) throws -> TableType? {
|
|
for schemaID in try db.schemaIdentifiers() {
|
|
if try db.schema(schemaID).containsObjectNamed(name, ofType: .table) {
|
|
return TableType(isView: false)
|
|
}
|
|
if try db.schema(schemaID).containsObjectNamed(name, ofType: .view) {
|
|
return TableType(isView: true)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// Foreign key columns mapping
|
|
typealias ForeignKeyMapping = [(origin: String, destination: String)]
|
|
|
|
// Join columns mapping
|
|
typealias JoinMapping = [(left: String, right: String)]
|
|
|
|
extension ForeignKeyMapping {
|
|
/// Orient the foreign key mapping for a SQL join.
|
|
///
|
|
/// - parameter originIsLeft: Whether the table at the origin of a
|
|
/// foreign key is on the left of a JOIN clause.
|
|
///
|
|
/// For example, the two requests below use the same
|
|
/// `ForeignKeyMapping` from `book.authorID` (origin of the foreign key)
|
|
/// to `author.id` (destination).
|
|
///
|
|
/// In the first request, the book origin is on the left of the
|
|
/// join clause:
|
|
///
|
|
/// // SELECT book.*, author.*
|
|
/// // FROM book
|
|
/// // JOIN author ON author.id = book.authorID
|
|
/// Book.including(required: Book.author)
|
|
///
|
|
/// In the second request, the book origin is on the right of the
|
|
/// join clause:
|
|
///
|
|
/// // SELECT author.*, book.*
|
|
/// // FROM author
|
|
/// // JOIN book ON book.authorID = author.id
|
|
/// Author.including(required: Author.books)
|
|
func joinMapping(originIsLeft: Bool) -> JoinMapping {
|
|
if originIsLeft {
|
|
return map { (left: $0.origin, right: $0.destination) }
|
|
} else {
|
|
return map { (left: $0.destination, right: $0.origin) }
|
|
}
|
|
}
|
|
}
|