Tighten SQLJoin

This commit is contained in:
Gwendal Roué 2019-03-15 08:41:57 +01:00
parent df84654554
commit 784ebcc8e9
8 changed files with 159 additions and 160 deletions

View File

@ -246,28 +246,28 @@ extension Association {
/// associated record are selected. The returned association does not
/// require that the associated database table contains a matching row.
public func including<A: Association>(optional association: A) -> Self where A.OriginRowDecoder == RowDecoder {
return mapRelation { association.sqlAssociation.relation(from: $0, joinOperator: .optional) }
return mapRelation { association.sqlAssociation.relation(from: $0, joinKind: .optional) }
}
/// Creates an association that includes another one. The columns of the
/// associated record are selected. The returned association requires
/// that the associated database table contains a matching row.
public func including<A: Association>(required association: A) -> Self where A.OriginRowDecoder == RowDecoder {
return mapRelation { association.sqlAssociation.relation(from: $0, joinOperator: .required) }
return mapRelation { association.sqlAssociation.relation(from: $0, joinKind: .required) }
}
/// Creates an association that joins another one. The columns of the
/// associated record are not selected. The returned association does not
/// require that the associated database table contains a matching row.
public func joining<A: Association>(optional association: A) -> Self where A.OriginRowDecoder == RowDecoder {
return mapRelation { association.select([]).sqlAssociation.relation(from: $0, joinOperator: .optional) }
return mapRelation { association.select([]).sqlAssociation.relation(from: $0, joinKind: .optional) }
}
/// Creates an association that joins another one. The columns of the
/// associated record are not selected. The returned association requires
/// that the associated database table contains a matching row.
public func joining<A: Association>(required association: A) -> Self where A.OriginRowDecoder == RowDecoder {
return mapRelation { association.select([]).sqlAssociation.relation(from: $0, joinOperator: .required) }
return mapRelation { association.select([]).sqlAssociation.relation(from: $0, joinKind: .required) }
}
}
@ -402,7 +402,7 @@ public /* TODO: internal */ struct SQLAssociation {
// SQLAssociation is a non-empty array of association elements
private struct Element {
var key: String
var joinCondition: JoinCondition
var condition: SQLJoin.Condition
var relation: SQLRelation
}
private var head: Element
@ -415,8 +415,8 @@ public /* TODO: internal */ struct SQLAssociation {
self.tail = tail
}
init(key: String, joinCondition: JoinCondition, relation: SQLRelation) {
head = Element(key: key, joinCondition: joinCondition, relation: relation)
init(key: String, condition: SQLJoin.Condition, relation: SQLRelation) {
head = Element(key: key, condition: condition, relation: relation)
tail = []
}
@ -440,10 +440,10 @@ public /* TODO: internal */ struct SQLAssociation {
}
/// Support for joining methods joining(optional:), etc.
func relation(from origin: SQLRelation, joinOperator: JoinOperator) -> SQLRelation {
func relation(from origin: SQLRelation, joinKind: SQLJoin.Kind) -> SQLRelation {
let headJoin = SQLJoin(
joinOperator: joinOperator,
joinCondition: head.joinCondition,
kind: joinKind,
condition: head.condition,
relation: head.relation)
// Recursion step: remove one element from tail by shifting the next
@ -463,10 +463,10 @@ public /* TODO: internal */ struct SQLAssociation {
}
let nextRelation = next.relation.select([]).appendingJoin(headJoin, forKey: head.key)
let reducedHead = Element(key: next.key, joinCondition: next.joinCondition, relation: nextRelation)
let reducedHead = Element(key: next.key, condition: next.condition, relation: nextRelation)
let reducedTail = Array(tail.dropFirst())
let reducedAssociation = SQLAssociation(head: reducedHead, tail: reducedTail)
return reducedAssociation.relation(from: origin, joinOperator: joinOperator)
return reducedAssociation.relation(from: origin, joinKind: joinKind)
}
/// Support for (TableRecord & EncodableRecord).request(for:).
@ -483,7 +483,7 @@ public /* TODO: internal */ struct SQLAssociation {
func relation(to originTable: String, container originContainer: @escaping (Database) throws -> PersistenceContainer) -> SQLRelation {
// Build a "pivot" relation whose filter is the pivot condition
// injected with values contained in originContainer.
let pivotCondition = pivot.joinCondition
let pivotCondition = pivot.condition
let pivotAlias = TableAlias()
let pivotRelation = pivot.relation
.qualified(with: pivotAlias)
@ -499,7 +499,7 @@ public /* TODO: internal */ struct SQLAssociation {
// We use elements backward: join conditions have to be reversed.
let reversedElements = zip([head] + tail, tail)
.map { Element(key: $1.key, joinCondition: $0.joinCondition.reversed, relation: $1.relation.select([])) }
.map { Element(key: $1.key, condition: $0.condition.reversed, relation: $1.relation.select([])) }
.reversed()
// Empty tail?
@ -510,6 +510,6 @@ public /* TODO: internal */ struct SQLAssociation {
reversedHead.relation = pivotRelation.select([])
let reversedTail = Array(reversedElements.dropFirst())
let reversedAssociation = SQLAssociation(head: reversedHead, tail: reversedTail)
return reversedAssociation.relation(from: head.relation, joinOperator: .required)
return reversedAssociation.relation(from: head.relation, joinKind: .required)
}
}

View File

@ -146,13 +146,13 @@ extension TableRecord {
destinationTable: Destination.databaseTableName,
foreignKey: foreignKey)
let joinCondition = JoinCondition(
let condition = SQLJoin.Condition(
foreignKeyRequest: foreignKeyRequest,
originIsLeft: true)
return BelongsToAssociation(sqlAssociation: SQLAssociation(
key: key ?? Destination.databaseTableName,
joinCondition: joinCondition,
condition: condition,
relation: Destination.all().relation))
}
}

View File

@ -19,14 +19,15 @@ struct ForeignKeyRequest: Equatable {
self.destinationColumns = foreignKey?.destinationColumns
}
func fetch(_ db: Database) throws -> ForeignKeyInfo {
/// The (origin, destination) column pairs that join a left table to a right table.
func fetchMapping(_ db: Database) throws -> [(origin: String, destination: String)] {
if let originColumns = originColumns, let destinationColumns = 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 ForeignKeyInfo(destinationTable: destinationTable, mapping: mapping)
return mapping
}
// Incomplete information: let's look for schema foreign keys
@ -56,7 +57,7 @@ struct ForeignKeyRequest: Equatable {
if let foreignKey = foreignKeys.first {
if foreignKeys.count == 1 {
// Non-ambiguous
return foreignKey
return foreignKey.mapping
} else {
// Ambiguous: can't choose
fatalError("Ambiguous foreign key from \(originTable) to \(destinationTable)")
@ -70,13 +71,10 @@ struct ForeignKeyRequest: Equatable {
let mapping = zip(originColumns, destinationColumns).map {
(origin: $0, destination: $1)
}
return ForeignKeyInfo(destinationTable: destinationTable, mapping: mapping)
return mapping
}
}
fatalError("Could not infer foreign key from \(originTable) to \(destinationTable)")
}
}
/// The (origin, destination) column pairs that join a left table to a right table.
typealias ForeignKeyMapping = [(origin: String, destination: String)]

View File

@ -146,13 +146,13 @@ extension TableRecord {
destinationTable: databaseTableName,
foreignKey: foreignKey)
let joinCondition = JoinCondition(
let condition = SQLJoin.Condition(
foreignKeyRequest: foreignKeyRequest,
originIsLeft: false)
return HasManyAssociation(sqlAssociation: SQLAssociation(
key: key ?? Destination.databaseTableName,
joinCondition: joinCondition,
condition: condition,
relation: Destination.all().relation))
}
}

View File

@ -148,13 +148,13 @@ extension TableRecord {
destinationTable: databaseTableName,
foreignKey: foreignKey)
let joinCondition = JoinCondition(
let condition = SQLJoin.Condition(
foreignKeyRequest: foreignKeyRequest,
originIsLeft: false)
return HasOneAssociation(sqlAssociation: SQLAssociation(
key: key ?? Destination.databaseTableName,
joinCondition: joinCondition,
condition: condition,
relation: Destination.all().relation))
}
}

View File

@ -7,7 +7,7 @@ extension QueryInterfaceRequest where RowDecoder: TableRecord {
public func including<A: Association>(optional association: A) -> QueryInterfaceRequest where A.OriginRowDecoder == RowDecoder {
return mapQuery {
$0.mapRelation {
association.sqlAssociation.relation(from: $0, joinOperator: .optional)
association.sqlAssociation.relation(from: $0, joinKind: .optional)
}
}
}
@ -18,7 +18,7 @@ extension QueryInterfaceRequest where RowDecoder: TableRecord {
public func including<A: Association>(required association: A) -> QueryInterfaceRequest where A.OriginRowDecoder == RowDecoder {
return mapQuery {
$0.mapRelation {
association.sqlAssociation.relation(from: $0, joinOperator: .required)
association.sqlAssociation.relation(from: $0, joinKind: .required)
}
}
}
@ -29,7 +29,7 @@ extension QueryInterfaceRequest where RowDecoder: TableRecord {
public func joining<A: Association>(optional association: A) -> QueryInterfaceRequest where A.OriginRowDecoder == RowDecoder {
return mapQuery {
$0.mapRelation {
association.select([]).sqlAssociation.relation(from: $0, joinOperator: .optional)
association.select([]).sqlAssociation.relation(from: $0, joinKind: .optional)
}
}
}
@ -40,7 +40,7 @@ extension QueryInterfaceRequest where RowDecoder: TableRecord {
public func joining<A: Association>(required association: A) -> QueryInterfaceRequest where A.OriginRowDecoder == RowDecoder {
return mapQuery {
$0.mapRelation {
association.select([]).sqlAssociation.relation(from: $0, joinOperator: .required)
association.select([]).sqlAssociation.relation(from: $0, joinKind: .required)
}
}
}

View File

@ -200,134 +200,135 @@ extension SQLRelation {
// MARK: - SQLJoin
/// Not to be mismatched with SQL join operators (inner join, left join).
///
/// JoinOperator is designed to be hierarchically nested, unlike
/// SQL join operators.
///
/// Consider the following request for (A, B, C) tuples:
///
/// let r = A.including(optional: A.b.including(required: B.c))
///
/// It chains three associations, the first optional, the second required.
///
/// It looks like it means: "Give me all As, along with their Bs, granted those
/// Bs have their Cs. For As whose B has no C, give me a nil B".
///
/// It can not be expressed as one left join, and a regular join, as below,
/// Because this would not honor the first optional:
///
/// -- dubious
/// SELECT a.*, b.*, c.*
/// FROM a
/// LEFT JOIN b ON ...
/// JOIN c ON ...
///
/// Instead, it should:
/// - allow (A + missing (B + C))
/// - prevent (A + (B + missing C)).
///
/// This can be expressed in SQL with two left joins, and an extra condition:
///
/// -- likely correct
/// SELECT a.*, b.*, c.*
/// FROM a
/// LEFT JOIN b ON ...
/// LEFT JOIN c ON ...
/// WHERE NOT((b.id IS NOT NULL) AND (c.id IS NULL)) -- no B without C
///
/// This is currently not implemented, and requires a little more thought.
/// I don't even know if inventing a whole new way to perform joins should even
/// be on the table. But we have a hierarchical way to express joined queries,
/// and they have a meaning:
///
/// // what is my meaning?
/// A.including(optional: A.b.including(required: B.c))
enum JoinOperator {
case required, optional
}
/// The condition that links two joined tables.
///
/// Currently, we only support one kind of join condition: foreign keys.
///
/// SELECT ... FROM book JOIN author ON author.id = book.authorId
/// <- the join condition -->
///
/// When we eventually add support for new ways to join tables, JoinCondition
/// is the type we'll need to update.
///
/// JoinCondition equality allows merging of associations:
///
/// // request1 and request2 are equivalent
/// let request1 = Book
/// .including(required: Book.author)
/// let request2 = Book
/// .including(required: Book.author)
/// .including(required: Book.author)
///
/// // request3 and request4 are equivalent
/// let request3 = Book
/// .including(required: Book.author.filter(condition1 && condition2))
/// let request4 = Book
/// .joining(required: Book.author.filter(condition1))
/// .including(optional: Book.author.filter(condition2))
struct JoinCondition: Equatable {
/// Definition of a foreign key
var foreignKeyRequest: ForeignKeyRequest
struct SQLJoin {
/// True if the table at the origin of the foreign key is on the left of
/// the sql JOIN operator.
/// Not to be mismatched with SQL join operators (inner join, left join).
///
/// Let's consider the `book.authorId -> author.id` foreign key.
/// Its origin table is `book`.
/// SQLJoin.Kind is designed to be hierarchically nested, unlike
/// SQL join operators.
///
/// The origin table `book` is on the left of the JOIN operator for
/// the BelongsTo association:
/// Consider the following request for (A, B, C) tuples:
///
/// -- Book.including(required: Book.author)
/// SELECT ... FROM book JOIN author ON author.id = book.authorId
/// let r = A.including(optional: A.b.including(required: B.c))
///
/// The origin table `book`is on the right of the JOIN operator for
/// the HasMany and HasOne associations:
/// It chains three associations, the first optional, the second required.
///
/// -- Author.including(required: Author.books)
/// SELECT ... FROM author JOIN book ON author.id = book.authorId
var originIsLeft: Bool
var reversed: JoinCondition {
return JoinCondition(foreignKeyRequest: foreignKeyRequest, originIsLeft: !originIsLeft)
/// It looks like it means: "Give me all As, along with their Bs, granted those
/// Bs have their Cs. For As whose B has no C, give me a nil B".
///
/// It can not be expressed as one left join, and a regular join, as below,
/// Because this would not honor the first optional:
///
/// -- dubious
/// SELECT a.*, b.*, c.*
/// FROM a
/// LEFT JOIN b ON ...
/// JOIN c ON ...
///
/// Instead, it should:
/// - allow (A + missing (B + C))
/// - prevent (A + (B + missing C)).
///
/// This can be expressed in SQL with two left joins, and an extra condition:
///
/// -- likely correct
/// SELECT a.*, b.*, c.*
/// FROM a
/// LEFT JOIN b ON ...
/// LEFT JOIN c ON ...
/// WHERE NOT((b.id IS NOT NULL) AND (c.id IS NULL)) -- no B without C
///
/// This is currently not implemented, and requires a little more thought.
/// I don't even know if inventing a whole new way to perform joins should even
/// be on the table. But we have a hierarchical way to express joined queries,
/// and they have a meaning:
///
/// // what is my meaning?
/// A.including(optional: A.b.including(required: B.c))
enum Kind {
case required, optional
}
/// Returns an SQL expression for the join condition.
/// The condition that links two joined tables.
///
/// Currently, we only support one kind of join condition: foreign keys.
///
/// SELECT ... FROM book JOIN author ON author.id = book.authorId
/// <- the SQL expression -->
/// <- the join condition -->
///
/// - parameter db: A database connection.
/// - parameter leftAlias: A TableAlias for the table on the left of the
/// JOIN operator.
/// - parameter rightAlias: A TableAlias for the table on the right of the
/// JOIN operator.
/// - Returns: An SQL expression.
func sqlExpression(_ db: Database, leftAlias: TableAlias, rightAlias: TableAlias) throws -> SQLExpression {
let foreignKeyMapping = try foreignKeyRequest.fetch(db).mapping
let columnMapping: [(left: Column, right: Column)]
if originIsLeft {
columnMapping = foreignKeyMapping.map { (left: Column($0.origin), right: Column($0.destination)) }
} else {
columnMapping = foreignKeyMapping.map { (left: Column($0.destination), right: Column($0.origin)) }
/// When we eventually add support for new ways to join tables, Condition
/// is the type we'll need to update.
///
/// Condition equality allows merging of associations:
///
/// // request1 and request2 are equivalent
/// let request1 = Book
/// .including(required: Book.author)
/// let request2 = Book
/// .including(required: Book.author)
/// .including(required: Book.author)
///
/// // request3 and request4 are equivalent
/// let request3 = Book
/// .including(required: Book.author.filter(condition1 && condition2))
/// let request4 = Book
/// .joining(required: Book.author.filter(condition1))
/// .including(optional: Book.author.filter(condition2))
struct Condition: Equatable {
/// Definition of a foreign key
var foreignKeyRequest: ForeignKeyRequest
/// True if the table at the origin of the foreign key is on the left of
/// the sql JOIN operator.
///
/// Let's consider the `book.authorId -> author.id` foreign key.
/// Its origin table is `book`.
///
/// The origin table `book` is on the left of the JOIN operator for
/// the BelongsTo association:
///
/// -- Book.including(required: Book.author)
/// SELECT ... FROM book JOIN author ON author.id = book.authorId
///
/// The origin table `book`is on the right of the JOIN operator for
/// the HasMany and HasOne associations:
///
/// -- Author.including(required: Author.books)
/// SELECT ... FROM author JOIN book ON author.id = book.authorId
var originIsLeft: Bool
var reversed: Condition {
return Condition(foreignKeyRequest: foreignKeyRequest, originIsLeft: !originIsLeft)
}
return columnMapping
.map { $0.right.qualifiedExpression(with: rightAlias) == $0.left.qualifiedExpression(with: leftAlias) }
.joined(operator: .and)
/// Returns an SQL expression for the join condition.
///
/// SELECT ... FROM book JOIN author ON author.id = book.authorId
/// <- the SQL expression -->
///
/// - parameter db: A database connection.
/// - parameter leftAlias: A TableAlias for the table on the left of the
/// JOIN operator.
/// - parameter rightAlias: A TableAlias for the table on the right of the
/// JOIN operator.
/// - Returns: An SQL expression.
func sqlExpression(_ db: Database, leftAlias: TableAlias, rightAlias: TableAlias) throws -> SQLExpression {
let foreignKeyMapping = try foreignKeyRequest.fetchMapping(db)
let columnMapping: [(left: Column, right: Column)]
if originIsLeft {
columnMapping = foreignKeyMapping.map { (left: Column($0.origin), right: Column($0.destination)) }
} else {
columnMapping = foreignKeyMapping.map { (left: Column($0.destination), right: Column($0.origin)) }
}
return columnMapping
.map { $0.right.qualifiedExpression(with: rightAlias) == $0.left.qualifiedExpression(with: leftAlias) }
.joined(operator: .and)
}
}
}
struct SQLJoin {
var joinOperator: JoinOperator
var joinCondition: JoinCondition
var kind: Kind
var condition: Condition
var relation: SQLRelation
}
@ -428,7 +429,7 @@ extension SQLSource {
extension SQLJoin {
/// Returns nil if joins can't be merged (conflict in condition, relation...)
func merged(with other: SQLJoin) -> SQLJoin? {
guard joinCondition == other.joinCondition else {
guard condition == other.condition else {
// can't merge
return nil
}
@ -438,17 +439,17 @@ extension SQLJoin {
return nil
}
let mergedJoinOperator: JoinOperator
switch (joinOperator, other.joinOperator) {
let mergedKind: SQLJoin.Kind
switch (kind, other.kind) {
case (.required, _), (_, .required):
mergedJoinOperator = .required
mergedKind = .required
default:
mergedJoinOperator = .optional
mergedKind = .optional
}
return SQLJoin(
joinOperator: mergedJoinOperator,
joinCondition: joinCondition,
kind: mergedKind,
condition: condition,
relation: mergedRelation)
}
}

View File

@ -404,20 +404,20 @@ private enum SQLQualifiedSource {
/// A "qualified" join, where all tables are identified with a table alias.
private struct SQLQualifiedJoin {
private let joinOperator: JoinOperator
private let joinCondition: JoinCondition
private let kind: SQLJoin.Kind
private let condition: SQLJoin.Condition
let relation: SQLQualifiedRelation
init(_ join: SQLJoin) {
self.joinOperator = join.joinOperator
self.joinCondition = join.joinCondition
self.kind = join.kind
self.condition = join.condition
self.relation = SQLQualifiedRelation(join.relation)
}
func sql(_ db: Database,_ context: inout SQLGenerationContext, leftAlias: TableAlias, isRequiredAllowed: Bool) throws -> String {
var isRequiredAllowed = isRequiredAllowed
var sql = ""
switch joinOperator {
switch kind {
case .optional:
isRequiredAllowed = false
sql += "LEFT JOIN"
@ -433,7 +433,7 @@ private struct SQLQualifiedJoin {
let rightAlias = relation.alias
let filters = try [
joinCondition.sqlExpression(db, leftAlias: leftAlias, rightAlias: rightAlias),
condition.sqlExpression(db, leftAlias: leftAlias, rightAlias: rightAlias),
relation.filterPromise.resolve(db)
].compactMap { $0 }
if !filters.isEmpty {