diff --git a/Sources/Squeal/Api/DML/TSQL+JOIN.swift b/Sources/Squeal/Api/DML/TSQL+JOIN.swift index aaba43b..b2e6213 100644 --- a/Sources/Squeal/Api/DML/TSQL+JOIN.swift +++ b/Sources/Squeal/Api/DML/TSQL+JOIN.swift @@ -24,83 +24,171 @@ public struct TypedJOINSQLQuery: TableSQLQuery { /// A query that can be followed by a join. public protocol JoinableQuery: TableSQLQuery { - /// Joins another table: `JOIN table2 ON a = b`. - func JOIN(_ table2: Y, ON: JOINPredicate) -> TypedFromSQLQuery + /// + /// The condition compares a column of the joined table with a column of the same type + /// from any table in the query: the `FROM` table or one joined earlier. The columns can be + /// written in either order. + func JOIN(_ table2: Y, ON: JOINPredicate) -> TypedFromSQLQuery + /// Joins another table: `JOIN table2 ON a = b`. + /// + /// The condition compares a column of the joined table with a column of the same type + /// from any table in the query: the `FROM` table or one joined earlier. The columns can be + /// written in either order. + func JOIN(_ table2: Y, ON: JOINPredicate) -> TypedFromSQLQuery + /// Joins another table: `INNER JOIN table2 ON a = b`. + /// + /// The condition compares a column of the joined table with a column of the same type + /// from any table in the query: the `FROM` table or one joined earlier. The columns can be + /// written in either order. + func INNER_JOIN(_ table2: Y, ON: JOINPredicate) -> TypedFromSQLQuery /// Joins another table: `INNER JOIN table2 ON a = b`. /// - /// Put the `FROM` table's column on the left of the condition. - func INNER_JOIN(_ table2: Y, ON: JOINPredicate) -> TypedFromSQLQuery + /// The condition compares a column of the joined table with a column of the same type + /// from any table in the query: the `FROM` table or one joined earlier. The columns can be + /// written in either order. + func INNER_JOIN(_ table2: Y, ON: JOINPredicate) -> TypedFromSQLQuery /// Joins another table, keeping rows with no match: `LEFT JOIN table2 ON a = b`. /// - /// Put the `FROM` table's column on the left of the condition. - func LEFT_JOIN(_ table2: Y, ON: JOINPredicate) -> TypedFromSQLQuery + /// The condition compares a column of the joined table with a column of the same type + /// from any table in the query: the `FROM` table or one joined earlier. The columns can be + /// written in either order. + func LEFT_JOIN(_ table2: Y, ON: JOINPredicate) -> TypedFromSQLQuery + /// Joins another table, keeping rows with no match: `LEFT JOIN table2 ON a = b`. + /// + /// The condition compares a column of the joined table with a column of the same type + /// from any table in the query: the `FROM` table or one joined earlier. The columns can be + /// written in either order. + func LEFT_JOIN(_ table2: Y, ON: JOINPredicate) -> TypedFromSQLQuery + /// Joins another table, keeping its rows with no match: `RIGHT JOIN table2 ON a = b`. + /// + /// The condition compares a column of the joined table with a column of the same type + /// from any table in the query: the `FROM` table or one joined earlier. The columns can be + /// written in either order. + func RIGHT_JOIN(_ table2: Y, ON: JOINPredicate) -> TypedFromSQLQuery /// Joins another table, keeping its rows with no match: `RIGHT JOIN table2 ON a = b`. /// - /// Put the `FROM` table's column on the left of the condition. - func RIGHT_JOIN(_ table2: Y, ON: JOINPredicate) -> TypedFromSQLQuery + /// The condition compares a column of the joined table with a column of the same type + /// from any table in the query: the `FROM` table or one joined earlier. The columns can be + /// written in either order. + func RIGHT_JOIN(_ table2: Y, ON: JOINPredicate) -> TypedFromSQLQuery + /// Joins another table, keeping unmatched rows from both: `FULL OUTER JOIN table2 ON a = b`. + /// + /// The condition compares a column of the joined table with a column of the same type + /// from any table in the query: the `FROM` table or one joined earlier. The columns can be + /// written in either order. + func FULL_OUTER_JOIN(_ table2: Y, ON: JOINPredicate) -> TypedFromSQLQuery /// Joins another table, keeping unmatched rows from both: `FULL OUTER JOIN table2 ON a = b`. /// - /// Put the `FROM` table's column on the left of the condition. - func FULL_OUTER_JOIN(_ table2: Y, ON: JOINPredicate) -> TypedFromSQLQuery + /// The condition compares a column of the joined table with a column of the same type + /// from any table in the query: the `FROM` table or one joined earlier. The columns can be + /// written in either order. + func FULL_OUTER_JOIN(_ table2: Y, ON: JOINPredicate) -> TypedFromSQLQuery } public extension JoinableQuery { - + /// Joins another table: `JOIN table2 ON a = b`. /// + /// The condition compares a column of the joined table with a column of the same type + /// from any table in the query: the `FROM` table or one joined earlier. The columns can be + /// written in either order. + /// /// ```swift - /// SQL.SELECT(posts.title, users.name) - /// .FROM(posts) - /// .JOIN(users, ON: posts.author_id == users.id) - /// // SELECT posts.title, users.name FROM posts JOIN users ON posts.author_id = users.id + /// SQL.SELECT(users.name, posts.title, comments.body) + /// .FROM(users) + /// .JOIN(posts, ON: posts.author_id == users.id) + /// .JOIN(comments, ON: comments.post_id == posts.id) + /// // SELECT users.name, posts.title, comments.body FROM users + /// // JOIN posts ON posts.author_id = users.id JOIN comments ON comments.post_id = posts.id /// ``` - func JOIN(_ table2: Y, ON predicate: JOINPredicate) -> TypedFromSQLQuery { - return join(word: "", table2, ON: predicate) + func JOIN(_ table2: Y, ON predicate: JOINPredicate) -> TypedFromSQLQuery { + return join(word: "", Y.schema, ON: predicate) } - - /// Joins another table, with the joined table's column on the left of the condition. - func JOIN(_ table2: Y, ON predicate: JOINPredicate) -> TypedFromSQLQuery { - return joinReverse(word: "", table2, ON: predicate) + + /// Joins another table: `JOIN table2 ON a = b`. + /// + /// The condition compares a column of the joined table with a column of the same type + /// from any table in the query: the `FROM` table or one joined earlier. The columns can be + /// written in either order. + func JOIN(_ table2: Y, ON predicate: JOINPredicate) -> TypedFromSQLQuery { + return join(word: "", Y.schema, ON: predicate) } - + /// Joins another table: `INNER JOIN table2 ON a = b`. /// - /// Put the `FROM` table's column on the left of the condition. - func INNER_JOIN(_ table2: Y, ON predicate: JOINPredicate) -> TypedFromSQLQuery { - return join(word: " INNER", table2, ON: predicate) + /// The condition compares a column of the joined table with a column of the same type + /// from any table in the query: the `FROM` table or one joined earlier. The columns can be + /// written in either order. + func INNER_JOIN(_ table2: Y, ON predicate: JOINPredicate) -> TypedFromSQLQuery { + return join(word: " INNER", Y.schema, ON: predicate) } - + + /// Joins another table: `INNER JOIN table2 ON a = b`. + /// + /// The condition compares a column of the joined table with a column of the same type + /// from any table in the query: the `FROM` table or one joined earlier. The columns can be + /// written in either order. + func INNER_JOIN(_ table2: Y, ON predicate: JOINPredicate) -> TypedFromSQLQuery { + return join(word: " INNER", Y.schema, ON: predicate) + } + /// Joins another table, keeping rows with no match: `LEFT JOIN table2 ON a = b`. /// - /// Put the `FROM` table's column on the left of the condition. - func LEFT_JOIN(_ table2: Y, ON predicate: JOINPredicate) -> TypedFromSQLQuery { - return join(word: " LEFT", table2, ON: predicate) + /// The condition compares a column of the joined table with a column of the same type + /// from any table in the query: the `FROM` table or one joined earlier. The columns can be + /// written in either order. + func LEFT_JOIN(_ table2: Y, ON predicate: JOINPredicate) -> TypedFromSQLQuery { + return join(word: " LEFT", Y.schema, ON: predicate) } - + + /// Joins another table, keeping rows with no match: `LEFT JOIN table2 ON a = b`. + /// + /// The condition compares a column of the joined table with a column of the same type + /// from any table in the query: the `FROM` table or one joined earlier. The columns can be + /// written in either order. + func LEFT_JOIN(_ table2: Y, ON predicate: JOINPredicate) -> TypedFromSQLQuery { + return join(word: " LEFT", Y.schema, ON: predicate) + } + /// Joins another table, keeping its rows with no match: `RIGHT JOIN table2 ON a = b`. /// - /// Put the `FROM` table's column on the left of the condition. - func RIGHT_JOIN(_ table2: Y, ON predicate: JOINPredicate) -> TypedFromSQLQuery { - return join(word: " RIGHT", table2, ON: predicate) + /// The condition compares a column of the joined table with a column of the same type + /// from any table in the query: the `FROM` table or one joined earlier. The columns can be + /// written in either order. + func RIGHT_JOIN(_ table2: Y, ON predicate: JOINPredicate) -> TypedFromSQLQuery { + return join(word: " RIGHT", Y.schema, ON: predicate) } - + + /// Joins another table, keeping its rows with no match: `RIGHT JOIN table2 ON a = b`. + /// + /// The condition compares a column of the joined table with a column of the same type + /// from any table in the query: the `FROM` table or one joined earlier. The columns can be + /// written in either order. + func RIGHT_JOIN(_ table2: Y, ON predicate: JOINPredicate) -> TypedFromSQLQuery { + return join(word: " RIGHT", Y.schema, ON: predicate) + } + /// Joins another table, keeping unmatched rows from both: `FULL OUTER JOIN table2 ON a = b`. /// - /// Put the `FROM` table's column on the left of the condition. - func FULL_OUTER_JOIN(_ table2: Y, ON predicate: JOINPredicate) -> TypedFromSQLQuery { - return join(word: " FULL OUTER", table2, ON: predicate) + /// The condition compares a column of the joined table with a column of the same type + /// from any table in the query: the `FROM` table or one joined earlier. The columns can be + /// written in either order. + func FULL_OUTER_JOIN(_ table2: Y, ON predicate: JOINPredicate) -> TypedFromSQLQuery { + return join(word: " FULL OUTER", Y.schema, ON: predicate) } - - private func join(word: String, _ table2: Y, ON predicate: JOINPredicate) -> TypedFromSQLQuery { - let statement = "\(word) JOIN \(Y.schema) ON \(predicate.left.tableName).\(predicate.left.name) = \(predicate.right.tableName).\(predicate.right.name)" - return TypedFromSQLQuery(for: table, - query: query + statement, - parameters: parameters) + + /// Joins another table, keeping unmatched rows from both: `FULL OUTER JOIN table2 ON a = b`. + /// + /// The condition compares a column of the joined table with a column of the same type + /// from any table in the query: the `FROM` table or one joined earlier. The columns can be + /// written in either order. + func FULL_OUTER_JOIN(_ table2: Y, ON predicate: JOINPredicate) -> TypedFromSQLQuery { + return join(word: " FULL OUTER", Y.schema, ON: predicate) } - - private func joinReverse(word: String, _ table2: Y, ON predicate: JOINPredicate) -> TypedFromSQLQuery { - let statement = "\(word) JOIN \(Y.schema) ON \(predicate.left.tableName).\(predicate.left.name) = \(predicate.right.tableName).\(predicate.right.name)" + + private func join(word: String, _ schema: String, ON predicate: JOINPredicate) -> TypedFromSQLQuery { + let statement = "\(word) JOIN \(schema) ON \(predicate.left.tableName).\(predicate.left.name) = \(predicate.right.tableName).\(predicate.right.name)" return TypedFromSQLQuery(for: table, query: query + statement, parameters: parameters) diff --git a/Sources/Squeal/Squeal.docc/Joins.md b/Sources/Squeal/Squeal.docc/Joins.md index 0047c7d..b6891bd 100644 --- a/Sources/Squeal/Squeal.docc/Joins.md +++ b/Sources/Squeal/Squeal.docc/Joins.md @@ -30,28 +30,35 @@ table and are never ambiguous. .FULL_OUTER_JOIN(users, ON: …) // FULL OUTER JOIN ``` -`JOIN` accepts the condition in either order. The other join kinds expect the `FROM` -table's column on the left: +Every join kind accepts the condition's columns in either order: ```swift SQL.SELECT(users.name, COUNT(posts.id)) .FROM(users) - .LEFT_JOIN(posts, ON: users.id == posts.author_id) + .LEFT_JOIN(posts, ON: posts.author_id == users.id) .GROUP_BY(\.name) -// SELECT users.name, COUNT(posts.id) FROM users LEFT JOIN posts ON users.id = posts.author_id GROUP BY name +// SELECT users.name, COUNT(posts.id) FROM users LEFT JOIN posts ON posts.author_id = users.id GROUP BY name ``` ### Joining several tables -Joins chain, so you can follow a many-to-many relationship through a link table: +Joins chain, and each condition can refer to the `FROM` table or to any table joined before +it. For example, you can follow a many-to-many relationship through a link table: ```swift SQL.SELECT(users.name, departments.name) - .FROM(users_departments) - .JOIN(users, ON: users.id == users_departments.user_id) + .FROM(users) + .JOIN(users_departments, ON: users_departments.user_id == users.id) .JOIN(departments, ON: departments.id == users_departments.department_id) +// SELECT users.name, departments.name FROM users +// JOIN users_departments ON users_departments.user_id = users.id +// JOIN departments ON departments.id = users_departments.department_id ``` +A join's condition must use a column of the table being joined, and both columns must have +the same type. Squeal doesn't check that the condition's other table is already part of +the query. If it isn't, Postgres reports a missing `FROM`-clause entry when the query runs. + ### Filtering and sorting on joined tables `WHERE`, `AND` and `ORDER_BY` accept columns from any table in the query. These columns diff --git a/Sources/Squeal/Squeal.docc/WhatSquealChecks.md b/Sources/Squeal/Squeal.docc/WhatSquealChecks.md index e13de4f..289101e 100644 --- a/Sources/Squeal/Squeal.docc/WhatSquealChecks.md +++ b/Sources/Squeal/Squeal.docc/WhatSquealChecks.md @@ -118,6 +118,8 @@ Squeal checks queries against your Swift description of the schema. It doesn't c your ``Table`` definitions and your migrations disagree, queries fail at runtime. - **How you decode rows.** With PostgresNIO, `rows.decode((UUID, String).self)` isn't checked against the columns you selected. +- **That a join's other table is in the query.** A join condition must use the joined + table, but its other column can come from any table, even one you haven't added yet. - **SQL semantics.** Squeal doesn't add parentheses around `AND` and `OR` conditions. Group them yourself with an ``SQLExpr``. It lets you write `UPDATE` and `DELETE` without `WHERE`, just like SQL. It can't know whether a subquery used as a value returns a single diff --git a/Tests/SquealTests/DocumentationExamplesTests.swift b/Tests/SquealTests/DocumentationExamplesTests.swift index adcc62e..739b272 100644 --- a/Tests/SquealTests/DocumentationExamplesTests.swift +++ b/Tests/SquealTests/DocumentationExamplesTests.swift @@ -195,9 +195,9 @@ struct DocumentationExamplesTests { let query = SQL .SELECT(users.name, COUNT(posts.id)) .FROM(users) - .LEFT_JOIN(posts, ON: users.id == posts.author_id) + .LEFT_JOIN(posts, ON: posts.author_id == users.id) .GROUP_BY(\.name) - #expect(query.query == "SELECT users.name, COUNT(posts.id) FROM users LEFT JOIN posts ON users.id = posts.author_id GROUP BY name") + #expect(query.query == "SELECT users.name, COUNT(posts.id) FROM users LEFT JOIN posts ON posts.author_id = users.id GROUP BY name") } @Test @@ -218,10 +218,10 @@ struct DocumentationExamplesTests { func manyToManyJoin() { let query = SQL .SELECT(users.name, departments.name) - .FROM(users_departments) - .JOIN(users, ON: users.uuid == users_departments.user_id) + .FROM(users) + .JOIN(users_departments, ON: users_departments.user_id == users.uuid) .JOIN(departments, ON: departments.id == users_departments.department_id) - #expect(query.query == "SELECT users.name, departments.name FROM users_departments JOIN users ON users.uuid = users_departments.user_id JOIN departments ON departments.id = users_departments.department_id") + #expect(query.query == "SELECT users.name, departments.name FROM users JOIN users_departments ON users_departments.user_id = users.uuid JOIN departments ON departments.id = users_departments.department_id") } // MARK: - Subqueries diff --git a/Tests/SquealTests/JOINTests.swift b/Tests/SquealTests/JOINTests.swift index 646dba3..4727399 100644 --- a/Tests/SquealTests/JOINTests.swift +++ b/Tests/SquealTests/JOINTests.swift @@ -188,3 +188,32 @@ struct JOIN_WHERE_qualifiedColumnsTests { #expect(query.parameters.count == 5) } } + +struct JOIN_conditionOrderTests { + + @Test + func everyJoinKindAcceptsBothOrders() { + let base = SQL.SELECT(users.name).FROM(users) + let joined = "orders ON orders.user_id = users.uuid" + #expect(base.JOIN(orders, ON: orders.user_id == users.uuid).query == "SELECT users.name FROM users JOIN \(joined)") + #expect(base.INNER_JOIN(orders, ON: orders.user_id == users.uuid).query == "SELECT users.name FROM users INNER JOIN \(joined)") + #expect(base.LEFT_JOIN(orders, ON: orders.user_id == users.uuid).query == "SELECT users.name FROM users LEFT JOIN \(joined)") + #expect(base.RIGHT_JOIN(orders, ON: orders.user_id == users.uuid).query == "SELECT users.name FROM users RIGHT JOIN \(joined)") + #expect(base.FULL_OUTER_JOIN(orders, ON: orders.user_id == users.uuid).query == "SELECT users.name FROM users FULL OUTER JOIN \(joined)") + } + + @Test + func chainThroughAnEarlierJoinedTable_eitherOrder() { + let userId = UUID() + let query = SQL + .SELECT(users.name, departments.name) + .FROM(users) + .JOIN(users_departments, ON: users_departments.user_id == users.uuid) + .LEFT_JOIN(departments, ON: departments.id == users_departments.department_id) + .WHERE(users_departments.user_id == userId) + .ORDER_BY(departments.name, .ASC) + .LIMIT(5) + #expect(query.query == "SELECT users.name, departments.name FROM users JOIN users_departments ON users_departments.user_id = users.uuid LEFT JOIN departments ON departments.id = users_departments.department_id WHERE users_departments.user_id = $1 ORDER BY departments.name ASC LIMIT $2") + #expect(query.parameters.count == 2) + } +} diff --git a/Tests/SquealTests/SQLCoverageTests.swift b/Tests/SquealTests/SQLCoverageTests.swift index b691178..6bc67e0 100644 --- a/Tests/SquealTests/SQLCoverageTests.swift +++ b/Tests/SquealTests/SQLCoverageTests.swift @@ -175,20 +175,16 @@ func fullGroupedQuery() { // MARK: - Multiple JOINs (intermediate table references) -// -// Currently JOIN ON clauses must reference the original FROM table on one side. -// Needs: More flexible JOIN overloads that accept JOINPredicates between any two tables: -// func JOIN(_ table2: B, ON: JOINPredicate) -> TypedFromSQLQuery -// -// @Test -// func multipleJOINsWithIntermediateTable() { -// let query = SQL -// .SELECT(users.name, departments.name) -// .FROM(users) -// .JOIN(users_departments, ON: users.uuid == users_departments.user_id) -// .JOIN(departments, ON: users_departments.department_id == departments.id) -// #expect(query.query == "SELECT users.name, departments.name FROM users JOIN users_departments ON users.uuid = users_departments.user_id JOIN departments ON users_departments.department_id = departments.id") -// } + +@Test +func multipleJOINsWithIntermediateTable() { + let query = SQL + .SELECT(users.name, departments.name) + .FROM(users) + .JOIN(users_departments, ON: users.uuid == users_departments.user_id) + .JOIN(departments, ON: users_departments.department_id == departments.id) + #expect(query.query == "SELECT users.name, departments.name FROM users JOIN users_departments ON users.uuid = users_departments.user_id JOIN departments ON users_departments.department_id = departments.id") +} // MARK: - INSERT ... ON CONFLICT (UPSERT)