diff --git a/Sources/Squeal/Api/DML/TSQL+AND.swift b/Sources/Squeal/Api/DML/TSQL+AND.swift index 115a2c1..1328acc 100644 --- a/Sources/Squeal/Api/DML/TSQL+AND.swift +++ b/Sources/Squeal/Api/DML/TSQL+AND.swift @@ -71,4 +71,18 @@ public extension ANDableQuery { func AND(_ kp: KeyPath>, LIKE value: String) -> TypedWhereSQLQuery { return TypedWhereSQLQuery(for: table, query: query + " AND \(table[keyPath: kp].name) LIKE \(nextDollarSign())", parameters: parameters + [value]) } + + /// Adds a condition written as a SQL expression whose `?` placeholders are bound as parameters. + /// + /// Use it to group conditions with parentheses: + /// + /// ```swift + /// .WHERE(\.age >= 18) + /// .AND(SQLExpr("(name != ? OR bio IS NULL)", "admin")) + /// // WHERE age >= $1 AND (name != $2 OR bio IS NULL) + /// ``` + func AND(_ expression: SQLExpr) -> TypedWhereSQLQuery { + let (fragments, newParameters) = renderSQLValues([expression], parameterOffset: parameterNumber()) + return TypedWhereSQLQuery(for: table, query: query + " AND \(fragments[0])", parameters: parameters + newParameters) + } } diff --git a/Sources/Squeal/Api/DML/TSQL+HAVING.swift b/Sources/Squeal/Api/DML/TSQL+HAVING.swift index 9c34b48..9f1f7e5 100644 --- a/Sources/Squeal/Api/DML/TSQL+HAVING.swift +++ b/Sources/Squeal/Api/DML/TSQL+HAVING.swift @@ -24,27 +24,90 @@ public struct TypedHavingSQLQuery: TableSQLQuery, LimitableQuery, /// A query that can be followed by a `HAVING` clause. public protocol HavingableQuery: TableSQLQuery { - /// Filters groups with a condition written as SQL: `HAVING clause`. - /// - /// ```swift - /// .GROUP_BY(\.author_id).HAVING("COUNT(*) > 5") - /// ``` - /// - /// > Warning: The clause is inserted verbatim and isn't parameterized. Never build it from user input. - func HAVING(_ clause: String) -> TypedHavingSQLQuery + } public extension HavingableQuery { - /// Filters groups with a condition written as SQL: `HAVING clause`. + /// Filters groups by comparing an aggregate to a value, which is bound as a parameter. /// /// ```swift - /// .GROUP_BY(\.author_id).HAVING("COUNT(*) > 5") + /// SQL.SELECT(posts.author_id, COUNT(*)) + /// .FROM(posts) + /// .GROUP_BY(\.author_id) + /// .HAVING(COUNT(*) >= minimumPosts) + /// // SELECT posts.author_id, COUNT(*) FROM posts GROUP BY author_id HAVING COUNT(*) >= $1 /// ``` + func HAVING(_ predicate: AggregatePredicate) -> TypedHavingSQLQuery { + return TypedHavingSQLQuery(for: table, + query: query + " HAVING \(predicate.aggregate) \(predicate.sign) \(nextDollarSign())", + parameters: parameters + [predicate.value]) + } + + /// Filters groups with a SQL expression whose `?` placeholders are bound as parameters. /// - /// > Warning: The clause is inserted verbatim and isn't parameterized. Never build it from user input. - func HAVING(_ clause: String) -> TypedHavingSQLQuery { - return TypedHavingSQLQuery(for: table, query: query + " HAVING \(clause)", parameters: parameters) + /// Use it for conditions a single comparison can't express: + /// + /// ```swift + /// .GROUP_BY(\.author_id) + /// .HAVING(SQLExpr("COUNT(*) >= ? AND SUM(likes) > ?", 5, 100)) + /// // HAVING COUNT(*) >= $1 AND SUM(likes) > $2 + /// ``` + func HAVING(_ expression: SQLExpr) -> TypedHavingSQLQuery { + let (fragments, newParameters) = renderSQLValues([expression], parameterOffset: parameterNumber()) + return TypedHavingSQLQuery(for: table, query: query + " HAVING \(fragments[0])", parameters: parameters + newParameters) } } +// MARK: - Aggregate comparisons + +/// An aggregate function that can be compared in a `HAVING` clause: +/// ``COUNT``, ``SUM``, ``AVG``, ``MIN`` or ``MAX``. +public protocol SQLAggregate: SelectField {} + +extension COUNT: SQLAggregate {} +extension SUM: SQLAggregate {} +extension AVG: SQLAggregate {} +extension MIN: SQLAggregate {} +extension MAX: SQLAggregate {} + +/// A comparison between an aggregate and a value, for `HAVING`. +/// +/// You create one by comparing an aggregate with an operator, such as `COUNT(*) >= 5` or +/// `SUM(posts.likes) > 100`. The value is bound as a parameter. +public struct AggregatePredicate { + let aggregate: String + let sign: String + let value: any Encodable +} + +/// A condition that the aggregate equals the value: `COUNT(*) = $n`. +public func == (left: A, right: V) -> AggregatePredicate { + AggregatePredicate(aggregate: left.toString(), sign: "=", value: right) +} + +/// A condition that the aggregate doesn't equal the value: `COUNT(*) != $n`. +public func != (left: A, right: V) -> AggregatePredicate { + AggregatePredicate(aggregate: left.toString(), sign: "!=", value: right) +} + +/// A condition that the aggregate is greater than the value: `COUNT(*) > $n`. +public func > (left: A, right: V) -> AggregatePredicate { + AggregatePredicate(aggregate: left.toString(), sign: ">", value: right) +} + +/// A condition that the aggregate is less than the value: `COUNT(*) < $n`. +public func < (left: A, right: V) -> AggregatePredicate { + AggregatePredicate(aggregate: left.toString(), sign: "<", value: right) +} + +/// A condition that the aggregate is greater than or equal to the value: `COUNT(*) >= $n`. +public func >= (left: A, right: V) -> AggregatePredicate { + AggregatePredicate(aggregate: left.toString(), sign: ">=", value: right) +} + +/// A condition that the aggregate is less than or equal to the value: `COUNT(*) <= $n`. +public func <= (left: A, right: V) -> AggregatePredicate { + AggregatePredicate(aggregate: left.toString(), sign: "<=", value: right) +} + diff --git a/Sources/Squeal/Api/DML/TSQL+INSERT.swift b/Sources/Squeal/Api/DML/TSQL+INSERT.swift index bef9280..e819d99 100644 --- a/Sources/Squeal/Api/DML/TSQL+INSERT.swift +++ b/Sources/Squeal/Api/DML/TSQL+INSERT.swift @@ -77,7 +77,6 @@ public extension SQL { newParams += queryParams let queryValues = queryParams.enumerated().map { i, _ in "$\(nextPIndex + i)"}.joined(separator: ", ") - print(queryValues) nextPIndex += queryParams.count valueRows.append("(" + queryValues + ")") } @@ -242,13 +241,6 @@ public extension TypedInsertSQLQuery { return TypedSQLQuery(for: table, query: query + returningClause(repeat each fields), parameters: parameters) } - /// Appends a parenthesized list of single-quoted strings. - /// - /// > Warning: The strings are inlined without escaping or parameters. Prefer the `VALUES:` argument of `INSERT`, which binds every value. - func VALUES(_ values: String...) -> TypedSQLQuery { - TypedSQLQuery(for: table, query: query + " (\(values.map{"'\($0)'"}.joined(separator: ", ")))", parameters: parameters) - } - /// Returns a column of the inserted row: `RETURNING column`. func RETURNING(_ kp: KeyPath>) -> TypedSQLQuery { return TypedSQLQuery(for: table, query: query + " RETURNING \(table[keyPath: kp].name)", parameters: parameters) @@ -398,7 +390,7 @@ public extension TypedLoneInsertSQLQuery { /// Handles a conflict on a named constraint: `ON CONFLICT ON CONSTRAINT name DO NOTHING`. func ON_CONFLICT(ON_CONSTRAINT name: String, DO: ConflictAction) -> OnConflictInsertQuery { - return OnConflictInsertQuery(for: table, query: query + " ON CONFLICT ON CONSTRAINT \(name)" + "DO \(DO.rawValue)", parameters: parameters) + return OnConflictInsertQuery(for: table, query: query + " ON CONFLICT ON CONSTRAINT \(name)" + " DO \(DO.rawValue)", parameters: parameters) } } diff --git a/Sources/Squeal/Api/DML/TSQL+LIMIT.swift b/Sources/Squeal/Api/DML/TSQL+LIMIT.swift index 07a3d9a..e06cf26 100644 --- a/Sources/Squeal/Api/DML/TSQL+LIMIT.swift +++ b/Sources/Squeal/Api/DML/TSQL+LIMIT.swift @@ -25,15 +25,15 @@ public struct TypedLimitSQLQuery: TableSQLQuery, OffsetableQuery /// A query that can be followed by a `LIMIT` clause. public protocol LimitableQuery: TableSQLQuery { - /// Returns at most `value` rows: `LIMIT value`. + /// Returns at most `value` rows: `LIMIT $n`, with `value` bound as a parameter. func LIMIT(_ value: Int) -> TypedLimitSQLQuery } public extension LimitableQuery { - /// Returns at most `value` rows: `LIMIT value`. + /// Returns at most `value` rows: `LIMIT $n`, with `value` bound as a parameter. func LIMIT(_ value: Int) -> TypedLimitSQLQuery { - return TypedLimitSQLQuery(for: table, query: query + " " + "LIMIT \(value)", parameters: parameters) + return TypedLimitSQLQuery(for: table, query: query + " LIMIT \(nextDollarSign())", parameters: parameters + [value]) } } diff --git a/Sources/Squeal/Api/DML/TSQL+OFFSET.swift b/Sources/Squeal/Api/DML/TSQL+OFFSET.swift index 79c0229..7ba4fa4 100644 --- a/Sources/Squeal/Api/DML/TSQL+OFFSET.swift +++ b/Sources/Squeal/Api/DML/TSQL+OFFSET.swift @@ -25,7 +25,7 @@ public struct TypedOffsetSQLQuery: TableSQLQuery { /// A query that can be followed by an `OFFSET` clause. public protocol OffsetableQuery: TableSQLQuery { - /// Skips the first `value` rows: `OFFSET value`. + /// Skips the first `value` rows: `OFFSET $n`, with `value` bound as a parameter. /// /// ```swift /// .ORDER_BY(\.id).LIMIT(20).OFFSET(40) // the third page of 20 @@ -35,13 +35,13 @@ public protocol OffsetableQuery: TableSQLQuery { public extension OffsetableQuery { - /// Skips the first `value` rows: `OFFSET value`. + /// Skips the first `value` rows: `OFFSET $n`, with `value` bound as a parameter. /// /// ```swift /// .ORDER_BY(\.id).LIMIT(20).OFFSET(40) // the third page of 20 /// ``` func OFFSET(_ value: Int) -> TypedOffsetSQLQuery { - return TypedOffsetSQLQuery(for: table, query: query + " " + "OFFSET \(value)", parameters: parameters) + return TypedOffsetSQLQuery(for: table, query: query + " OFFSET \(nextDollarSign())", parameters: parameters + [value]) } } diff --git a/Sources/Squeal/Api/DML/TSQL+OR.swift b/Sources/Squeal/Api/DML/TSQL+OR.swift index b006ea1..3f57b7f 100644 --- a/Sources/Squeal/Api/DML/TSQL+OR.swift +++ b/Sources/Squeal/Api/DML/TSQL+OR.swift @@ -49,4 +49,17 @@ public extension ORableQuery { func OR(_ kp: KeyPath>, LIKE value: String) -> TypedWhereSQLQuery { return TypedWhereSQLQuery(for: table, query: query + " OR \(table[keyPath: kp].name) LIKE \(nextDollarSign())", parameters: parameters + [value]) } + + /// Adds an alternative condition written as a SQL expression whose `?` placeholders are + /// bound as parameters. + /// + /// ```swift + /// .WHERE(\.name == "admin") + /// .OR(SQLExpr("(age >= ? AND bio IS NOT NULL)", 18)) + /// // WHERE name = $1 OR (age >= $2 AND bio IS NOT NULL) + /// ``` + func OR(_ expression: SQLExpr) -> TypedWhereSQLQuery { + let (fragments, newParameters) = renderSQLValues([expression], parameterOffset: parameterNumber()) + return TypedWhereSQLQuery(for: table, query: query + " OR \(fragments[0])", parameters: parameters + newParameters) + } } diff --git a/Sources/Squeal/Api/DML/TSQL+WHERE.swift b/Sources/Squeal/Api/DML/TSQL+WHERE.swift index 41bc165..6cefce1 100644 --- a/Sources/Squeal/Api/DML/TSQL+WHERE.swift +++ b/Sources/Squeal/Api/DML/TSQL+WHERE.swift @@ -136,12 +136,6 @@ public protocol WHEREableQuery: TableSQLQuery { func WHERE(_ predicate: SQLPredicate) -> TypedWhereSQLQuery /// Starts a `NULL` test on a column. Follow it with `IS_NULL` or `IS_NOT_NULL`. func WHERE(_ kp: KeyPath>) -> PartialTypedWhereSQLQuery - /// Filters rows with a condition written as SQL. - /// - /// If the string has the form `column value`, where the operator is `=`, `!=`, `<`, `<=`, `>` or `>=` with a space on each side, the value is bound as a parameter. Otherwise the string is inserted verbatim. - /// - /// > Warning: The string is split at the first spaced comparison operator, so compound conditions containing one aren't supported. Verbatim strings aren't parameterized, so never build them from user input. - func WHERE(_ rawString: String) -> TypedWhereSQLQuery /// Filters rows with a comparison on a table-qualified column, which can belong to any table in the query. /// /// ```swift @@ -283,35 +277,17 @@ public extension WHEREableQuery { return TypedWhereSQLQuery(for: table, query: q, parameters: parameters + [predicate.right]) } - /// Filters rows with a condition written as SQL. + /// Filters rows with a SQL expression whose `?` placeholders are bound as parameters. /// - /// If the string has the form `column value`, where the operator is `=`, `!=`, `<`, `<=`, `>` or `>=` with a space on each side, the value is bound as a parameter. Otherwise the string is inserted verbatim. + /// Use it for conditions the typed comparisons can't express, such as grouped conditions: /// - /// > Warning: The string is split at the first spaced comparison operator, so compound conditions containing one aren't supported. Verbatim strings aren't parameterized, so never build them from user input. - func WHERE(_ rawString: String) -> TypedWhereSQLQuery { - let operators = [">=", "<=", "!=", ">", "<", "="] - for op in operators { - let parts = rawString.components(separatedBy: " \(op) ") - if parts.count == 2 { - let column = parts[0].trimmingCharacters(in: .whitespaces) - let rawValue = parts[1].trimmingCharacters(in: .whitespaces) - let value: any Encodable - if let intValue = Int(rawValue) { - value = intValue - } else if let doubleValue = Double(rawValue) { - value = doubleValue - } else if (rawValue.hasPrefix("'") && rawValue.hasSuffix("'")) - || (rawValue.hasPrefix("\"") && rawValue.hasSuffix("\"")) { - value = String(rawValue.dropFirst().dropLast()) - } else { - value = rawValue - } - let q = query + " WHERE \(column) \(op) \(nextDollarSign())" - return TypedWhereSQLQuery(for: table, query: q, parameters: parameters + [value]) - } - } -// // Fallback: append raw string as-is with no parameters - return TypedWhereSQLQuery(for: table, query: query + " WHERE \(rawString)", parameters: parameters) + /// ```swift + /// .WHERE(SQLExpr("(age >= ? OR bio IS NULL)", 18)) + /// // WHERE (age >= $1 OR bio IS NULL) + /// ``` + func WHERE(_ expression: SQLExpr) -> TypedWhereSQLQuery { + let (fragments, newParameters) = renderSQLValues([expression], parameterOffset: parameterNumber()) + return TypedWhereSQLQuery(for: table, query: query + " WHERE \(fragments[0])", parameters: parameters + newParameters) } } diff --git a/Sources/Squeal/Api/SQL.swift b/Sources/Squeal/Api/SQL.swift index c0fbc3c..8bbab8b 100644 --- a/Sources/Squeal/Api/SQL.swift +++ b/Sources/Squeal/Api/SQL.swift @@ -18,7 +18,7 @@ import Foundation /// .FROM(users) /// .WHERE(\.age >= 18) /// .LIMIT(10) -/// // SELECT id, name FROM users WHERE age >= $1 LIMIT 10 +/// // SELECT id, name FROM users WHERE age >= $1 LIMIT $2 /// ``` /// /// `SQL` is a namespace and is never instantiated. diff --git a/Sources/Squeal/Macros.swift b/Sources/Squeal/Macros.swift index ab4caa2..df89fdf 100644 --- a/Sources/Squeal/Macros.swift +++ b/Sources/Squeal/Macros.swift @@ -21,8 +21,33 @@ /// /// The macro adds a peer struct named after the annotated struct with a `Table` suffix. /// It declares one ``TableColumn`` per stored property, with the property's name and type. -/// Computed properties are skipped. +/// Computed properties are skipped. To use a different column name than the property +/// name, mark the property with ``ColumnName(_:)``. /// /// - Parameter schema: The name of the table in the database. @attached(peer, names: suffixed(Table)) public macro Table(schema: String) = #externalMacro(module: "SquealMacros", type: "TableMacro") + +/// Sets the database column name of a property in a ``Table(schema:)`` struct. +/// +/// Use it when your Swift property name differs from the column name, such as a +/// `camelCase` property for a `snake_case` column: +/// +/// ```swift +/// @Table(schema: "users") +/// struct User { +/// let id: UUID +/// @ColumnName("email_address") let email: String +/// @ColumnName("created_at") let createdAt: Date +/// } +/// +/// SQL.SELECT(\.id, \.email).FROM(users) +/// // SELECT id, email_address FROM users +/// ``` +/// +/// In Swift you keep using the property name (`\.email`, `users.email`). The generated SQL +/// uses the column name. The attribute has no effect outside a `@Table` struct. +/// +/// - Parameter name: The name of the column in the database. +@attached(peer) +public macro ColumnName(_ name: String) = #externalMacro(module: "SquealMacros", type: "ColumnNameMacro") diff --git a/Sources/Squeal/Squeal.docc/DefiningTables.md b/Sources/Squeal/Squeal.docc/DefiningTables.md index 32ed84f..50ccdaa 100644 --- a/Sources/Squeal/Squeal.docc/DefiningTables.md +++ b/Sources/Squeal/Squeal.docc/DefiningTables.md @@ -39,7 +39,8 @@ struct PostTable: Table { The macro's rules: -- Each stored `let` or `var` becomes a column with the same name and type. +- Each stored `let` or `var` becomes a column with the same name and type, unless you set + a different name with ``ColumnName(_:)``. - Computed properties are skipped. - Optional properties become nullable columns. You can compare them with `IS_NULL` and `IS_NOT_NULL`, and use them in subqueries that return the non-optional type. @@ -47,15 +48,30 @@ The macro's rules: The original struct is left as is, so you can also use it as your row model. For example, make it `Decodable` or decode rows into it. -> Tip: Property names are used verbatim as column names. If your columns are in -> `snake_case`, name your properties in `snake_case` too, or write the table by hand as -> shown below. +### Naming columns differently from properties + +By default, a property's name is its column's name. When they differ, for example with a +`camelCase` property for a `snake_case` column, mark the property with ``ColumnName(_:)``: + +```swift +@Table(schema: "users") +struct User { + let id: UUID + @ColumnName("email_address") let email: String + @ColumnName("created_at") let createdAt: Date +} + +SQL.SELECT(\.id, \.email).FROM(users).ORDER_BY(\.createdAt, .DESC) +// SELECT id, email_address FROM users ORDER BY created_at DESC +``` + +In Swift you keep using the property name, as in `\.email` or `users.email`. Every clause +writes the column name into the SQL. ### Writing a table by hand -The macro is optional. Any type that conforms to ``Table`` works. Write one by hand when a -column name differs from the property you want in Swift, or when you'd rather not depend on -macros: +The macro is optional. Any type that conforms to ``Table`` works, which is useful if you'd +rather not depend on macros. Pass each column's database name to `Column`: ```swift struct UsersTable: Table { diff --git a/Sources/Squeal/Squeal.docc/EscapeHatches.md b/Sources/Squeal/Squeal.docc/EscapeHatches.md index 245be61..1c4c4fb 100644 --- a/Sources/Squeal/Squeal.docc/EscapeHatches.md +++ b/Sources/Squeal/Squeal.docc/EscapeHatches.md @@ -32,27 +32,21 @@ SQLExpr("ST_SetSRID(ST_MakePoint(?, ?), 4326)::geography", longitude, latitude) `SQLExpr` works anywhere a value does in `INSERT … VALUES` and `UPDATE … SET`. -### WHERE with a string +### Conditions in SQL -`WHERE(_:)` also accepts a string, for conditions Squeal can't express yet: +`WHERE`, `AND`, `OR` and `HAVING` also accept an ``SQLExpr``, for conditions the typed +comparisons can't express. The most common case is grouping conditions with parentheses: ```swift -SQL.SELECT(\.id).FROM(users).WHERE("bio IS NULL OR email LIKE '%@example.com'") -// SELECT id FROM users WHERE bio IS NULL OR email LIKE '%@example.com' +SQL.SELECT(\.id) + .FROM(users) + .WHERE(\.age >= 18) + .AND(SQLExpr("(name != ? OR bio IS NULL)", "admin")) +// SELECT id FROM users WHERE age >= $1 AND (name != $2 OR bio IS NULL) ``` -If the string has the form `column value`, where the operator is `=`, `!=`, -`<`, `<=`, `>` or `>=` with a space on each side, Squeal binds the value as a parameter: - -```swift -SQL.SELECT(\.id).FROM(users).WHERE("age >= 18") -// SELECT id FROM users WHERE age >= $1 -``` - -> Warning: Squeal splits the string at the first spaced comparison operator it finds. A -> compound condition that contains one, such as `"(age >= 18 OR bio IS NULL)"`, won't -> produce the SQL you expect. Strings without a spaced comparison operator are inserted -> verbatim and are never parameterized, so don't build them from user input. +The values are bound like any other, and the placeholders are numbered after the ones +already in the query. ### Reading and running the generated SQL diff --git a/Sources/Squeal/Squeal.docc/GettingStarted.md b/Sources/Squeal/Squeal.docc/GettingStarted.md index 4e4d158..fde99c2 100644 --- a/Sources/Squeal/Squeal.docc/GettingStarted.md +++ b/Sources/Squeal/Squeal.docc/GettingStarted.md @@ -76,10 +76,10 @@ The query holds the SQL text and its parameters separately: ```swift query.query -// SELECT id, name, email FROM users WHERE age >= $1 AND name = $2 ORDER BY name ASC LIMIT 10 +// SELECT id, name, email FROM users WHERE age >= $1 AND name = $2 ORDER BY name ASC LIMIT $3 query.parameters -// [18, "Ada"] +// [18, "Ada", 10] ``` For logging and debugging, `description` inlines the parameters so you can read the query diff --git a/Sources/Squeal/Squeal.docc/ParametersAndSafety.md b/Sources/Squeal/Squeal.docc/ParametersAndSafety.md index f304ba6..f57818f 100644 --- a/Sources/Squeal/Squeal.docc/ParametersAndSafety.md +++ b/Sources/Squeal/Squeal.docc/ParametersAndSafety.md @@ -21,8 +21,8 @@ query.query // SELECT id FROM users WHERE name = $1 query.parameters // ["Robert'); DROP TABLE users;--"] ``` -Values passed to `WHERE`, `AND`, `OR`, `IN`, `LIKE`, `BETWEEN`, `INSERT … VALUES` and -`UPDATE … SET` are all bound this way. `nil` becomes `NULL`. +Values passed to `WHERE`, `AND`, `OR`, `IN`, `LIKE`, `BETWEEN`, `HAVING`, `LIMIT`, `OFFSET`, +`INSERT … VALUES` and `UPDATE … SET` are all bound this way. `nil` becomes `NULL`. ### Placeholders compose @@ -60,10 +60,8 @@ print(query) A few APIs take SQL text as is, as escape hatches. Never build their text from user input: - ``RawSQL``. Use ``SQLExpr`` and its `?` placeholders when an expression needs values. -- `HAVING(_:)`, which takes a SQL string. -- `WHERE(_:)` with a string. Its value is bound only when the string has the exact form - `column value`, and anything else is inserted verbatim. +- The SQL text of an ``SQLExpr``. Its values are bound, so put every value behind a `?`. +- The constraint name in `ON_CONFLICT(ON_CONSTRAINT:)`. - Table and column names, which come from your ``Table`` definitions. -- `LIMIT` and `OFFSET`, which take an `Int`, so they can't carry SQL. See for how to use these safely. diff --git a/Sources/Squeal/Squeal.docc/Resources/across-tables-main-02.swift b/Sources/Squeal/Squeal.docc/Resources/across-tables-main-02.swift index 87d4172..e3acd2e 100644 --- a/Sources/Squeal/Squeal.docc/Resources/across-tables-main-02.swift +++ b/Sources/Squeal/Squeal.docc/Resources/across-tables-main-02.swift @@ -8,4 +8,4 @@ let feed = SQL .ORDER_BY(posts.likes, .DESC) .LIMIT(50) // SELECT posts.title, users.name FROM posts JOIN users ON posts.author_id = users.id -// WHERE users.age >= $1 ORDER BY posts.likes DESC LIMIT 50 +// WHERE users.age >= $1 ORDER BY posts.likes DESC LIMIT $2 diff --git a/Sources/Squeal/Squeal.docc/Resources/across-tables-main-03.swift b/Sources/Squeal/Squeal.docc/Resources/across-tables-main-03.swift index 1d7eb86..7a1610f 100644 --- a/Sources/Squeal/Squeal.docc/Resources/across-tables-main-03.swift +++ b/Sources/Squeal/Squeal.docc/Resources/across-tables-main-03.swift @@ -8,7 +8,7 @@ let feed = SQL .ORDER_BY(posts.likes, .DESC) .LIMIT(50) // SELECT posts.title, users.name FROM posts JOIN users ON posts.author_id = users.id -// WHERE users.age >= $1 ORDER BY posts.likes DESC LIMIT 50 +// WHERE users.age >= $1 ORDER BY posts.likes DESC LIMIT $2 let popularAuthorIds = SQL .SELECT(\.author_id) diff --git a/Sources/Squeal/Squeal.docc/Resources/across-tables-main-04.swift b/Sources/Squeal/Squeal.docc/Resources/across-tables-main-04.swift index 7cba848..7b9103b 100644 --- a/Sources/Squeal/Squeal.docc/Resources/across-tables-main-04.swift +++ b/Sources/Squeal/Squeal.docc/Resources/across-tables-main-04.swift @@ -8,7 +8,7 @@ let feed = SQL .ORDER_BY(posts.likes, .DESC) .LIMIT(50) // SELECT posts.title, users.name FROM posts JOIN users ON posts.author_id = users.id -// WHERE users.age >= $1 ORDER BY posts.likes DESC LIMIT 50 +// WHERE users.age >= $1 ORDER BY posts.likes DESC LIMIT $2 let popularAuthorIds = SQL .SELECT(\.author_id) diff --git a/Sources/Squeal/Squeal.docc/Resources/across-tables-main-05.swift b/Sources/Squeal/Squeal.docc/Resources/across-tables-main-05.swift index 19f941f..dd645e9 100644 --- a/Sources/Squeal/Squeal.docc/Resources/across-tables-main-05.swift +++ b/Sources/Squeal/Squeal.docc/Resources/across-tables-main-05.swift @@ -8,7 +8,7 @@ let feed = SQL .ORDER_BY(posts.likes, .DESC) .LIMIT(50) // SELECT posts.title, users.name FROM posts JOIN users ON posts.author_id = users.id -// WHERE users.age >= $1 ORDER BY posts.likes DESC LIMIT 50 +// WHERE users.age >= $1 ORDER BY posts.likes DESC LIMIT $2 let popularAuthorIds = SQL .SELECT(\.author_id) diff --git a/Sources/Squeal/Squeal.docc/Resources/across-tables-main-06.swift b/Sources/Squeal/Squeal.docc/Resources/across-tables-main-06.swift index 982fda8..79e8a20 100644 --- a/Sources/Squeal/Squeal.docc/Resources/across-tables-main-06.swift +++ b/Sources/Squeal/Squeal.docc/Resources/across-tables-main-06.swift @@ -8,7 +8,7 @@ let feed = SQL .ORDER_BY(posts.likes, .DESC) .LIMIT(50) // SELECT posts.title, users.name FROM posts JOIN users ON posts.author_id = users.id -// WHERE users.age >= $1 ORDER BY posts.likes DESC LIMIT 50 +// WHERE users.age >= $1 ORDER BY posts.likes DESC LIMIT $2 let popularAuthorIds = SQL .SELECT(\.author_id) @@ -37,8 +37,8 @@ let prolificAuthors = SQL .FROM(users) .LEFT_JOIN(posts, ON: users.id == posts.author_id) .GROUP_BY(\.name) - .HAVING("COUNT(posts.id) >= 3") + .HAVING(COUNT(posts.id) >= 3) .ORDER_BY(\.name, .ASC) // SELECT users.name, COUNT(posts.id), SUM(posts.likes) FROM users // LEFT JOIN posts ON users.id = posts.author_id GROUP BY name -// HAVING COUNT(posts.id) >= 3 ORDER BY name ASC +// HAVING COUNT(posts.id) >= $1 ORDER BY name ASC diff --git a/Sources/Squeal/Squeal.docc/Resources/first-query-main-04.swift b/Sources/Squeal/Squeal.docc/Resources/first-query-main-04.swift index 4cc4dd1..5f89898 100644 --- a/Sources/Squeal/Squeal.docc/Resources/first-query-main-04.swift +++ b/Sources/Squeal/Squeal.docc/Resources/first-query-main-04.swift @@ -24,4 +24,4 @@ let usersPage = SQL .ORDER_BY(\.name, .ASC) .LIMIT(20) .OFFSET(page * 20) -// SELECT id, name, email FROM users ORDER BY name ASC LIMIT 20 OFFSET 40 +// SELECT id, name, email FROM users ORDER BY name ASC LIMIT $1 OFFSET $2 diff --git a/Sources/Squeal/Squeal.docc/Resources/first-query-main-05.swift b/Sources/Squeal/Squeal.docc/Resources/first-query-main-05.swift index 8fbda22..633821c 100644 --- a/Sources/Squeal/Squeal.docc/Resources/first-query-main-05.swift +++ b/Sources/Squeal/Squeal.docc/Resources/first-query-main-05.swift @@ -24,7 +24,7 @@ let usersPage = SQL .ORDER_BY(\.name, .ASC) .LIMIT(20) .OFFSET(page * 20) -// SELECT id, name, email FROM users ORDER BY name ASC LIMIT 20 OFFSET 40 +// SELECT id, name, email FROM users ORDER BY name ASC LIMIT $1 OFFSET $2 print(adults.query) // SELECT id, name, email FROM users WHERE age >= $1 AND name = $2 diff --git a/Sources/Squeal/Squeal.docc/Resources/first-query-main-06.swift b/Sources/Squeal/Squeal.docc/Resources/first-query-main-06.swift index 1ba424c..71fa171 100644 --- a/Sources/Squeal/Squeal.docc/Resources/first-query-main-06.swift +++ b/Sources/Squeal/Squeal.docc/Resources/first-query-main-06.swift @@ -24,7 +24,7 @@ let usersPage = SQL .ORDER_BY(\.name, .ASC) .LIMIT(20) .OFFSET(page * 20) -// SELECT id, name, email FROM users ORDER BY name ASC LIMIT 20 OFFSET 40 +// SELECT id, name, email FROM users ORDER BY name ASC LIMIT $1 OFFSET $2 print(adults.query) // SELECT id, name, email FROM users WHERE age >= $1 AND name = $2 diff --git a/Sources/Squeal/Squeal.docc/Resources/first-query-main-07.swift b/Sources/Squeal/Squeal.docc/Resources/first-query-main-07.swift index f247913..3cab8ab 100644 --- a/Sources/Squeal/Squeal.docc/Resources/first-query-main-07.swift +++ b/Sources/Squeal/Squeal.docc/Resources/first-query-main-07.swift @@ -24,7 +24,7 @@ let usersPage = SQL .ORDER_BY(\.name, .ASC) .LIMIT(20) .OFFSET(page * 20) -// SELECT id, name, email FROM users ORDER BY name ASC LIMIT 20 OFFSET 40 +// SELECT id, name, email FROM users ORDER BY name ASC LIMIT $1 OFFSET $2 print(adults.query) // SELECT id, name, email FROM users WHERE age >= $1 AND name = $2 diff --git a/Sources/Squeal/Squeal.docc/SelectingData.md b/Sources/Squeal/Squeal.docc/SelectingData.md index 3cb0604..6008f1f 100644 --- a/Sources/Squeal/Squeal.docc/SelectingData.md +++ b/Sources/Squeal/Squeal.docc/SelectingData.md @@ -98,8 +98,9 @@ dedicated labels: `IN:` subquery (see ). > Note: Conditions are appended in the order you write them, without parentheses, so -> Postgres's precedence applies: `AND` binds tighter than `OR`. Squeal can't group -> conditions with parentheses yet. +> Postgres's precedence applies: `AND` binds tighter than `OR`. To group conditions, pass +> an ``SQLExpr``, such as `.AND(SQLExpr("(name != ? OR bio IS NULL)", "admin"))`. See +> . ### Building filters dynamically @@ -121,7 +122,7 @@ SQL.SELECT(\.id, \.title) .ORDER_BY((\.likes, .DESC), (\.title, .ASC)) .LIMIT(20) .OFFSET(40) -// SELECT id, title FROM posts ORDER BY likes DESC, title ASC LIMIT 20 OFFSET 40 +// SELECT id, title FROM posts ORDER BY likes DESC, title ASC LIMIT $1 OFFSET $2 ``` `ORDER_BY` also accepts a single column, with or without a direction @@ -137,12 +138,18 @@ Use ``COUNT``, ``SUM``, ``AVG``, ``MIN`` and ``MAX`` in `SELECT`, together with SQL.SELECT(posts.author_id, COUNT(*), SUM(posts.likes)) .FROM(posts) .GROUP_BY(\.author_id) - .HAVING("COUNT(*) > 5") -// SELECT posts.author_id, COUNT(*), SUM(posts.likes) FROM posts GROUP BY author_id HAVING COUNT(*) > 5 + .HAVING(COUNT(*) > 5) +// SELECT posts.author_id, COUNT(*), SUM(posts.likes) FROM posts GROUP BY author_id HAVING COUNT(*) > $1 ``` -> Warning: `HAVING` takes raw SQL and doesn't bind parameters. Don't build its text from -> user input. +`HAVING` compares an aggregate to a value with the usual operators, and binds the value as +a parameter. For conditions a single comparison can't express, pass an ``SQLExpr``. Each +`?` in it is bound to a value: + +```swift +.HAVING(SQLExpr("COUNT(*) >= ? AND SUM(likes) > ?", 5, 100)) +// HAVING COUNT(*) >= $1 AND SUM(likes) > $2 +``` ## Topics diff --git a/Sources/Squeal/Squeal.docc/Squeal.md b/Sources/Squeal/Squeal.docc/Squeal.md index c0b50e6..b385317 100644 --- a/Sources/Squeal/Squeal.docc/Squeal.md +++ b/Sources/Squeal/Squeal.docc/Squeal.md @@ -20,7 +20,7 @@ let query = SQL ``` ```sql -SELECT id, name, email FROM users WHERE age >= $1 AND name = $2 ORDER BY name ASC LIMIT 10 +SELECT id, name, email FROM users WHERE age >= $1 AND name = $2 ORDER BY name ASC LIMIT $3 ``` Every value becomes a bound parameter (`$1`, `$2`, …) and is never pasted into the SQL @@ -71,6 +71,7 @@ doesn't catch yet. ### Schema - ``Table(schema:)`` +- ``ColumnName(_:)`` - ``Table`` - ``TableColumn`` - ``TableColumnProperty`` @@ -81,6 +82,7 @@ doesn't catch yet. - ``SQLPredicate`` - ``TableColumnPredicate`` - ``JOINPredicate`` +- ``AggregatePredicate`` ### Selectable Expressions @@ -90,6 +92,7 @@ doesn't catch yet. - ``AVG`` - ``MIN`` - ``MAX`` +- ``SQLAggregate`` - ``RawSQL`` - ``SQLExpr`` diff --git a/Sources/Squeal/Squeal.docc/Subqueries.md b/Sources/Squeal/Squeal.docc/Subqueries.md index 37eb68b..2598385 100644 --- a/Sources/Squeal/Squeal.docc/Subqueries.md +++ b/Sources/Squeal/Squeal.docc/Subqueries.md @@ -41,14 +41,14 @@ let prolificAuthors = SQL .SELECT(\.author_id) .FROM(posts) .GROUP_BY(\.author_id) - .HAVING("COUNT(*) >= 5") + .HAVING(COUNT(*) >= 5) SQL.SELECT(posts.title, users.name) .FROM(posts) .JOIN(users, ON: posts.author_id == users.id) .WHERE(users.id, IN: prolificAuthors) // SELECT posts.title, users.name FROM posts JOIN users ON posts.author_id = users.id -// WHERE users.id IN (SELECT author_id FROM posts GROUP BY author_id HAVING COUNT(*) >= 5) +// WHERE users.id IN (SELECT author_id FROM posts GROUP BY author_id HAVING COUNT(*) >= $1) ``` ### Subqueries as values @@ -58,7 +58,7 @@ A subquery can provide the value of a column in `UPDATE … SET`: ```swift SQL.UPDATE(posts, SET: (\.author_id, SQL.SELECT(\.id).FROM(users).WHERE(\.email == "ada@example.com").LIMIT(1))) .WHERE(\.id == postId) -// UPDATE posts SET author_id = (SELECT id FROM users WHERE email = $1 LIMIT 1) WHERE id = $2 +// UPDATE posts SET author_id = (SELECT id FROM users WHERE email = $1 LIMIT $2) WHERE id = $3 ``` It can also appear in `INSERT … VALUES`, next to plain values: @@ -67,7 +67,7 @@ It can also appear in `INSERT … VALUES`, next to plain values: SQL.INSERT(INTO: posts, columns: \.id, \.author_id, \.title, VALUES: postId, SQL.SELECT(\.id).FROM(users).WHERE(\.email == "ada@example.com").LIMIT(1), "Hello") -// INSERT INTO posts (id, author_id, title) VALUES ($1, (SELECT id FROM users WHERE email = $2 LIMIT 1), $3) +// INSERT INTO posts (id, author_id, title) VALUES ($1, (SELECT id FROM users WHERE email = $2 LIMIT $3), $4) ``` > Note: A subquery used as a value must return at most one row. That's a Postgres rule, so diff --git a/Sources/Squeal/Squeal.docc/Tutorials/QueryAcrossTables.tutorial b/Sources/Squeal/Squeal.docc/Tutorials/QueryAcrossTables.tutorial index e457c38..1e42a0a 100644 --- a/Sources/Squeal/Squeal.docc/Tutorials/QueryAcrossTables.tutorial +++ b/Sources/Squeal/Squeal.docc/Tutorials/QueryAcrossTables.tutorial @@ -70,7 +70,8 @@ @Step { Keep only prolific authors with `HAVING`, and sort them by name. - `HAVING` takes raw SQL, so only use it with fixed text. + `HAVING` compares an aggregate with the usual operators. Like every value + in Squeal, the `3` is bound as a parameter. @Code(name: "main.swift", file: across-tables-main-06.swift) } diff --git a/Sources/Squeal/Squeal.docc/WhatSquealChecks.md b/Sources/Squeal/Squeal.docc/WhatSquealChecks.md index 401b32f..e13de4f 100644 --- a/Sources/Squeal/Squeal.docc/WhatSquealChecks.md +++ b/Sources/Squeal/Squeal.docc/WhatSquealChecks.md @@ -118,8 +118,9 @@ 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. -- **SQL semantics.** Squeal doesn't add parentheses around `AND` and `OR` conditions. 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 row. -- **Escape hatches.** ``RawSQL``, `HAVING` strings and `WHERE` with a string are inserted as - you write them. See . +- **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 + row. +- **Escape hatches.** The text of ``RawSQL`` and ``SQLExpr`` is inserted as you write it. + See . diff --git a/Sources/SquealMacros/ColumnNameMacro.swift b/Sources/SquealMacros/ColumnNameMacro.swift new file mode 100644 index 0000000..c838cce --- /dev/null +++ b/Sources/SquealMacros/ColumnNameMacro.swift @@ -0,0 +1,19 @@ +// +// ColumnNameMacro.swift +// Squeal +// + +import SwiftSyntax +import SwiftSyntaxMacros + +/// `@ColumnName("…")` generates nothing. It marks a property so `TableMacro` uses the given +/// name as the database column name. +public struct ColumnNameMacro: PeerMacro { + public static func expansion( + of node: AttributeSyntax, + providingPeersOf declaration: some DeclSyntaxProtocol, + in context: some MacroExpansionContext + ) throws -> [DeclSyntax] { + return [] + } +} diff --git a/Sources/SquealMacros/Macro.swift b/Sources/SquealMacros/Macro.swift index 4357be8..fe96c52 100644 --- a/Sources/SquealMacros/Macro.swift +++ b/Sources/SquealMacros/Macro.swift @@ -4,5 +4,5 @@ import SwiftSyntaxMacros @main struct SquealMacros: CompilerPlugin { - var providingMacros: [Macro.Type] = [TableMacro.self] + var providingMacros: [Macro.Type] = [TableMacro.self, ColumnNameMacro.self] } diff --git a/Tests/SquealTests/DocumentationExamplesTests.swift b/Tests/SquealTests/DocumentationExamplesTests.swift index e45e00c..adcc62e 100644 --- a/Tests/SquealTests/DocumentationExamplesTests.swift +++ b/Tests/SquealTests/DocumentationExamplesTests.swift @@ -48,8 +48,8 @@ struct DocumentationExamplesTests { .AND(\.name == "Ada") .ORDER_BY(\.name, .ASC) .LIMIT(10) - #expect(query.query == "SELECT id, name, email FROM users WHERE age >= $1 AND name = $2 ORDER BY name ASC LIMIT 10") - #expect(query.parameters.count == 2) + #expect(query.query == "SELECT id, name, email FROM users WHERE age >= $1 AND name = $2 ORDER BY name ASC LIMIT $3") + #expect(query.parameters.count == 3) #expect(query.parameters[0] as? Int == 18) #expect(query.parameters[1] as? String == "Ada") #expect("\(query)" == "SELECT id, name, email FROM users WHERE age >= 18 AND name = 'Ada' ORDER BY name ASC LIMIT 10") @@ -144,7 +144,7 @@ struct DocumentationExamplesTests { .ORDER_BY((\.likes, .DESC), (\.title, .ASC)) .LIMIT(20) .OFFSET(40) - #expect(query.query == "SELECT id, title FROM posts ORDER BY likes DESC, title ASC LIMIT 20 OFFSET 40") + #expect(query.query == "SELECT id, title FROM posts ORDER BY likes DESC, title ASC LIMIT $1 OFFSET $2") } @Test @@ -154,8 +154,12 @@ struct DocumentationExamplesTests { .SELECT(posts.author_id, COUNT(*), SUM(posts.likes)) .FROM(posts) .GROUP_BY(\.author_id) - .HAVING("COUNT(*) > 5") - #expect(query.query == "SELECT posts.author_id, COUNT(*), SUM(posts.likes) FROM posts GROUP BY author_id HAVING COUNT(*) > 5") + .HAVING(COUNT(*) > 5) + #expect(query.query == "SELECT posts.author_id, COUNT(*), SUM(posts.likes) FROM posts GROUP BY author_id HAVING COUNT(*) > $1") + #expect(query.parameters[0] as? Int == 5) + let compound = SQL.SELECT(posts.author_id).FROM(posts).GROUP_BY(\.author_id) + .HAVING(SQLExpr("COUNT(*) >= ? AND SUM(likes) > ?", 5, 100)) + #expect(compound.query.hasSuffix("HAVING COUNT(*) >= $1 AND SUM(likes) > $2")) } @Test @@ -248,7 +252,7 @@ struct DocumentationExamplesTests { let query = SQL .UPDATE(posts, SET: (\.author_id, SQL.SELECT(\.id).FROM(users).WHERE(\.email == "ada@example.com").LIMIT(1))) .WHERE(\.id == postId) - #expect(query.query == "UPDATE posts SET author_id = (SELECT id FROM users WHERE email = $1 LIMIT 1) WHERE id = $2") + #expect(query.query == "UPDATE posts SET author_id = (SELECT id FROM users WHERE email = $1 LIMIT $2) WHERE id = $3") } // MARK: - Inserting Data @@ -305,8 +309,8 @@ struct DocumentationExamplesTests { let query = SQL.INSERT(INTO: posts, columns: \.id, \.author_id, \.title, VALUES: postId, SQL.SELECT(\.id).FROM(users).WHERE(\.email == "ada@example.com").LIMIT(1), "Hello") - #expect(query.query == "INSERT INTO posts (id, author_id, title) VALUES ($1, (SELECT id FROM users WHERE email = $2 LIMIT 1), $3)") - #expect(query.parameters.count == 3) + #expect(query.query == "INSERT INTO posts (id, author_id, title) VALUES ($1, (SELECT id FROM users WHERE email = $2 LIMIT $3), $4)") + #expect(query.parameters.count == 4) } @Test @@ -317,14 +321,14 @@ struct DocumentationExamplesTests { .SELECT(\.author_id) .FROM(posts) .GROUP_BY(\.author_id) - .HAVING("COUNT(*) >= 5") + .HAVING(COUNT(*) >= 5) let query = SQL .SELECT(posts.title, users.name) .FROM(posts) .JOIN(users, ON: posts.author_id == users.id) .WHERE(users.id, IN: prolificAuthors) - #expect(query.query == "SELECT posts.title, users.name FROM posts JOIN users ON posts.author_id = users.id WHERE users.id IN (SELECT author_id FROM posts GROUP BY author_id HAVING COUNT(*) >= 5)") - #expect(query.parameters.isEmpty) + #expect(query.query == "SELECT posts.title, users.name FROM posts JOIN users ON posts.author_id = users.id WHERE users.id IN (SELECT author_id FROM posts GROUP BY author_id HAVING COUNT(*) >= $1)") + #expect(query.parameters.count == 1) } @Test @@ -334,12 +338,20 @@ struct DocumentationExamplesTests { } @Test - func whereString() { + func groupedConditionsWithSQLExpr() { let users = usersTable - #expect(SQL.SELECT(\.id).FROM(users).WHERE("age >= 18").query == "SELECT id FROM users WHERE age >= $1") - #expect(SQL.SELECT(\.id).FROM(users).WHERE("bio IS NULL OR email LIKE '%@example.com'").query == "SELECT id FROM users WHERE bio IS NULL OR email LIKE '%@example.com'") - // A string containing a spaced comparison operator is split on it: the right-hand side is bound as one value. - #expect(SQL.SELECT(\.id).FROM(users).WHERE("(age >= 18 OR bio IS NULL)").query == "SELECT id FROM users WHERE (age >= $1") + let query = SQL + .SELECT(\.id) + .FROM(users) + .WHERE(\.age >= 18) + .AND(SQLExpr("(name != ? OR bio IS NULL)", "admin")) + #expect(query.query == "SELECT id FROM users WHERE age >= $1 AND (name != $2 OR bio IS NULL)") + #expect(query.parameters.count == 2) + #expect(query.parameters[1] as? String == "admin") + let whereOnly = SQL.SELECT(\.id).FROM(users).WHERE(SQLExpr("(age >= ? OR bio IS NULL)", 18)) + #expect(whereOnly.query == "SELECT id FROM users WHERE (age >= $1 OR bio IS NULL)") + let orGroup = SQL.SELECT(\.id).FROM(users).WHERE(\.name == "admin").OR(SQLExpr("(age >= ? AND bio IS NOT NULL)", 18)) + #expect(orGroup.query == "SELECT id FROM users WHERE name = $1 OR (age >= $2 AND bio IS NOT NULL)") } @Test diff --git a/Tests/SquealTests/HAVINGTests.swift b/Tests/SquealTests/HAVINGTests.swift index 11b1e59..63cc1ff 100644 --- a/Tests/SquealTests/HAVINGTests.swift +++ b/Tests/SquealTests/HAVINGTests.swift @@ -12,14 +12,88 @@ import Squeal struct HAVINGTests { @Test - func HAVINGafterSELECT() throws { + func HAVING_aggregateComparison_bindsValue() { let query = SQL .SELECT(\.name) .FROM(users) .GROUP_BY(\.name) - .HAVING("COUNT(*) > 500") - #expect(query.parameters.count == 0) - #expect(query.query == "SELECT name FROM users GROUP BY name HAVING COUNT(*) > 500") + .HAVING(COUNT(*) > 500) + #expect(query.query == "SELECT name FROM users GROUP BY name HAVING COUNT(*) > $1") + #expect(query.parameters.count == 1) + #expect(query.parameters[0] as? Int == 500) #expect("\(query)" == "SELECT name FROM users GROUP BY name HAVING COUNT(*) > 500") } + + @Test + func HAVING_aggregateOperators() { + let base = SQL.SELECT(\.name).FROM(users).GROUP_BY(\.name) + #expect(base.HAVING(COUNT(*) == 1).query.hasSuffix("HAVING COUNT(*) = $1")) + #expect(base.HAVING(COUNT(*) != 1).query.hasSuffix("HAVING COUNT(*) != $1")) + #expect(base.HAVING(COUNT(*) < 1).query.hasSuffix("HAVING COUNT(*) < $1")) + #expect(base.HAVING(COUNT(*) <= 1).query.hasSuffix("HAVING COUNT(*) <= $1")) + #expect(base.HAVING(COUNT(*) >= 1).query.hasSuffix("HAVING COUNT(*) >= $1")) + #expect(base.HAVING(SUM(users.age) > 100).query.hasSuffix("HAVING SUM(users.age) > $1")) + #expect(base.HAVING(AVG(\UsersTable.age) >= 30.5).query.hasSuffix("HAVING AVG(age) >= $1")) + #expect(base.HAVING(MIN(users.age) >= 18).query.hasSuffix("HAVING MIN(users.age) >= $1")) + #expect(base.HAVING(MAX(users.age) < 99).query.hasSuffix("HAVING MAX(users.age) < $1")) + } + + @Test + func HAVING_afterWHERE_numbersParametersAfterExistingOnes() { + let query = SQL + .SELECT(\.name) + .FROM(users) + .WHERE(\.age >= 18) + .GROUP_BY(\.name) + .HAVING(COUNT(*) >= 2) + .ORDER_BY(\.name) + .LIMIT(10) + #expect(query.query == "SELECT name FROM users WHERE age >= $1 GROUP BY name HAVING COUNT(*) >= $2 ORDER BY name LIMIT $3") + #expect(query.parameters.count == 3) + #expect(query.parameters[0] as? Int == 18) + #expect(query.parameters[1] as? Int == 2) + } + + @Test + func HAVING_SQLExpr_bindsEachPlaceholder() { + let query = SQL + .SELECT(\.name) + .FROM(users) + .WHERE(\.age >= 18) + .GROUP_BY(\.name) + .HAVING(SQLExpr("COUNT(*) >= ? AND SUM(age) > ?", 5, 100)) + #expect(query.query == "SELECT name FROM users WHERE age >= $1 GROUP BY name HAVING COUNT(*) >= $2 AND SUM(age) > $3") + #expect(query.parameters.count == 3) + #expect(query.parameters[1] as? Int == 5) + #expect(query.parameters[2] as? Int == 100) + } + + @Test + func HAVING_SQLExpr_keepsUserInputOutOfTheSQL() { + let malicious = "0; DROP TABLE users;--" + let query = SQL + .SELECT(\.name) + .FROM(users) + .GROUP_BY(\.name) + .HAVING(SQLExpr("MAX(name) = ?", malicious)) + #expect(query.query == "SELECT name FROM users GROUP BY name HAVING MAX(name) = $1") + #expect(query.parameters[0] as? String == malicious) + } + + @Test + func HAVING_asSubquery_keepsRowTypeAndRenumbers() { + let prolificAuthors = SQL + .SELECT(\.user_id) + .FROM(orders) + .GROUP_BY(\.user_id) + .HAVING(COUNT(*) >= 5) + let query = SQL + .SELECT(\.name) + .FROM(users) + .WHERE(\.age >= 18) + .AND(\.uuid, IN: prolificAuthors) + #expect(query.query == "SELECT name FROM users WHERE age >= $1 AND uuid IN (SELECT user_id FROM orders GROUP BY user_id HAVING COUNT(*) >= $2)") + #expect(query.parameters.count == 2) + #expect(query.parameters[1] as? Int == 5) + } } diff --git a/Tests/SquealTests/INSERT_INTOTests.swift b/Tests/SquealTests/INSERT_INTOTests.swift index 74451f9..f2b47f6 100644 --- a/Tests/SquealTests/INSERT_INTOTests.swift +++ b/Tests/SquealTests/INSERT_INTOTests.swift @@ -196,7 +196,17 @@ struct INSERT_INTOTests { .ON_CONFLICT(\.firstname, \.lastname, DO: .NOTHING) #expect(query.query == "INSERT INTO people (firstname, lastname) VALUES ($1, $2) ON CONFLICT (firstname, lastname) DO NOTHING") } - + + @Test + @available(macOS 14.0.0, *) + func INSERT_lone_ON_CONFLICT_ON_CONSTRAINT_DO_NOTHING() { + let query = SQL + .INSERT(INTO: people, columns: \.firstname, \.lastname) + .VALUES("John", "Doe") + .ON_CONFLICT(ON_CONSTRAINT: "people_pkey", DO: .NOTHING) + #expect(query.query == "INSERT INTO people (firstname, lastname) VALUES ($1, $2) ON CONFLICT ON CONSTRAINT people_pkey DO NOTHING") + } + @Test func INSERT_ON_CONFLICT_partialIndex_DO_NOTHING() { let query = SQL diff --git a/Tests/SquealTests/JOINTests.swift b/Tests/SquealTests/JOINTests.swift index 69f16b3..646dba3 100644 --- a/Tests/SquealTests/JOINTests.swift +++ b/Tests/SquealTests/JOINTests.swift @@ -90,17 +90,6 @@ struct JOINTests { } - @Test - func JOINWhere() { - // List user departments many to many relationship - let userId = UUID(uuidString: "485DBC0B-4C82-4442-BB2A-1879D4E28A14")! - let query = SQL - .SELECT(departments.id, departments.name) - .FROM(departments) - .JOIN(users_departments, ON: departments.id == users_departments.user_id) - .WHERE("users_departments.user_id = \(userId)") - #expect("\(query)" == "SELECT departments.id, departments.name FROM departments JOIN users_departments ON departments.id = users_departments.user_id WHERE users_departments.user_id = '485DBC0B-4C82-4442-BB2A-1879D4E28A14'") - } @Test diff --git a/Tests/SquealTests/LIMITTests.swift b/Tests/SquealTests/LIMITTests.swift index b5ebea5..67fdd50 100644 --- a/Tests/SquealTests/LIMITTests.swift +++ b/Tests/SquealTests/LIMITTests.swift @@ -17,8 +17,8 @@ struct LimitTests { .SELECT(*) .FROM(users) .LIMIT(17) - #expect(query.parameters.count == 0) - #expect(query.query == "SELECT * FROM users LIMIT 17") + #expect(query.parameters.count == 1) + #expect(query.query == "SELECT * FROM users LIMIT $1") #expect("\(query)" == "SELECT * FROM users LIMIT 17") } @@ -29,8 +29,8 @@ struct LimitTests { .FROM(users) .WHERE(\.id == 34) .LIMIT(17) - #expect(query.parameters.count == 1) - #expect(query.query == "SELECT * FROM users WHERE id = $1 LIMIT 17") + #expect(query.parameters.count == 2) + #expect(query.query == "SELECT * FROM users WHERE id = $1 LIMIT $2") #expect("\(query)" == "SELECT * FROM users WHERE id = 34 LIMIT 17") } @@ -42,10 +42,10 @@ struct LimitTests { .WHERE(\.id == 1) .AND(\UsersTable.name == "jack") .LIMIT(1) - #expect(query.parameters.count == 2) + #expect(query.parameters.count == 3) #expect(query.parameters[0] as? Int == 1) #expect(query.parameters[1] as? String == "jack") - #expect(query.query == "SELECT * FROM users WHERE id = $1 AND name = $2 LIMIT 1") + #expect(query.query == "SELECT * FROM users WHERE id = $1 AND name = $2 LIMIT $3") #expect("\(query)" == "SELECT * FROM users WHERE id = 1 AND name = 'jack' LIMIT 1") } @@ -58,10 +58,10 @@ struct LimitTests { .AND(\.name == "jack") .GROUP_BY(\.name) .LIMIT(3) - #expect(query.parameters.count == 2) + #expect(query.parameters.count == 3) #expect(query.parameters[0] as? Int == 1) #expect(query.parameters[1] as? String == "jack") - #expect(query.query == "SELECT name FROM users WHERE id = $1 AND name = $2 GROUP BY name LIMIT 3") + #expect(query.query == "SELECT name FROM users WHERE id = $1 AND name = $2 GROUP BY name LIMIT $3") #expect("\(query)" == "SELECT name FROM users WHERE id = 1 AND name = 'jack' GROUP BY name LIMIT 3") } @@ -71,9 +71,9 @@ struct LimitTests { .SELECT(\.name) .FROM(users) .GROUP_BY(\.name) - .HAVING("COUNT(*) > 5") + .HAVING(COUNT(*) > 5) .LIMIT(10) - #expect(query.query == "SELECT name FROM users GROUP BY name HAVING COUNT(*) > 5 LIMIT 10") + #expect(query.query == "SELECT name FROM users GROUP BY name HAVING COUNT(*) > $1 LIMIT $2") } @Test @@ -83,7 +83,7 @@ struct LimitTests { .FROM(users) .ORDER_BY(\.name) .LIMIT(10) - #expect(query.query == "SELECT * FROM users ORDER BY name LIMIT 10") + #expect(query.query == "SELECT * FROM users ORDER BY name LIMIT $1") } } diff --git a/Tests/SquealTests/OFFSETTests.swift b/Tests/SquealTests/OFFSETTests.swift index 2bb0b3e..252120c 100644 --- a/Tests/SquealTests/OFFSETTests.swift +++ b/Tests/SquealTests/OFFSETTests.swift @@ -11,14 +11,29 @@ import Squeal struct OffsetTests { + @Test + func LIMIT_OFFSET_bindValuesAfterExistingParameters() { + let query = SQL + .SELECT(*) + .FROM(users) + .WHERE(\.age >= 18) + .LIMIT(10) + .OFFSET(20) + #expect(query.query == "SELECT * FROM users WHERE age >= $1 LIMIT $2 OFFSET $3") + #expect(query.parameters.count == 3) + #expect(query.parameters[1] as? Int == 10) + #expect(query.parameters[2] as? Int == 20) + #expect("\(query)" == "SELECT * FROM users WHERE age >= 18 LIMIT 10 OFFSET 20") + } + @Test func OFFSETAfterFROM() { let query = SQL .SELECT(*) .FROM(users) .OFFSET(20) - #expect(query.parameters.count == 0) - #expect(query.query == "SELECT * FROM users OFFSET 20") + #expect(query.parameters.count == 1) + #expect(query.query == "SELECT * FROM users OFFSET $1") #expect("\(query)" == "SELECT * FROM users OFFSET 20") } @@ -29,8 +44,8 @@ struct OffsetTests { .FROM(users) .WHERE(\.id == 34) .OFFSET(12) - #expect(query.parameters.count == 1) - #expect(query.query == "SELECT * FROM users WHERE id = $1 OFFSET 12") + #expect(query.parameters.count == 2) + #expect(query.query == "SELECT * FROM users WHERE id = $1 OFFSET $2") #expect("\(query)" == "SELECT * FROM users WHERE id = 34 OFFSET 12") } @@ -42,10 +57,10 @@ struct OffsetTests { .WHERE(\.id == 1) .AND(\UsersTable.name == "jack") .OFFSET(3) - #expect(query.parameters.count == 2) + #expect(query.parameters.count == 3) #expect(query.parameters[0] as? Int == 1) #expect(query.parameters[1] as? String == "jack") - #expect(query.query == "SELECT * FROM users WHERE id = $1 AND name = $2 OFFSET 3") + #expect(query.query == "SELECT * FROM users WHERE id = $1 AND name = $2 OFFSET $3") #expect("\(query)" == "SELECT * FROM users WHERE id = 1 AND name = 'jack' OFFSET 3") } @@ -58,10 +73,10 @@ struct OffsetTests { .AND(\.name == "jack") .GROUP_BY(\.name) .OFFSET(34) - #expect(query.parameters.count == 2) + #expect(query.parameters.count == 3) #expect(query.parameters[0] as? Int == 1) #expect(query.parameters[1] as? String == "jack") - #expect(query.query == "SELECT name FROM users WHERE id = $1 AND name = $2 GROUP BY name OFFSET 34") + #expect(query.query == "SELECT name FROM users WHERE id = $1 AND name = $2 GROUP BY name OFFSET $3") #expect("\(query)" == "SELECT name FROM users WHERE id = 1 AND name = 'jack' GROUP BY name OFFSET 34") } @@ -72,8 +87,8 @@ struct OffsetTests { .FROM(users) .LIMIT(10) .OFFSET(20) - #expect(query.parameters.count == 0) - #expect(query.query == "SELECT * FROM users LIMIT 10 OFFSET 20") + #expect(query.parameters.count == 2) + #expect(query.query == "SELECT * FROM users LIMIT $1 OFFSET $2") #expect("\(query)" == "SELECT * FROM users LIMIT 10 OFFSET 20") } @@ -83,9 +98,9 @@ struct OffsetTests { .SELECT(\.name) .FROM(users) .GROUP_BY(\.name) - .HAVING("COUNT(*) > 5") + .HAVING(COUNT(*) > 5) .OFFSET(20) - #expect(query.query == "SELECT name FROM users GROUP BY name HAVING COUNT(*) > 5 OFFSET 20") + #expect(query.query == "SELECT name FROM users GROUP BY name HAVING COUNT(*) > $1 OFFSET $2") } @Test @@ -95,7 +110,7 @@ struct OffsetTests { .FROM(users) .ORDER_BY(\.name) .OFFSET(20) - #expect(query.query == "SELECT * FROM users ORDER BY name OFFSET 20") + #expect(query.query == "SELECT * FROM users ORDER BY name OFFSET $1") } @Test @@ -106,6 +121,6 @@ struct OffsetTests { .ORDER_BY(\.name, .ASC) .LIMIT(10) .OFFSET(20) - #expect(query.query == "SELECT * FROM users ORDER BY name ASC LIMIT 10 OFFSET 20") + #expect(query.query == "SELECT * FROM users ORDER BY name ASC LIMIT $1 OFFSET $2") } } diff --git a/Tests/SquealTests/ORDER_BYTests.swift b/Tests/SquealTests/ORDER_BYTests.swift index 9646284..de6cebc 100644 --- a/Tests/SquealTests/ORDER_BYTests.swift +++ b/Tests/SquealTests/ORDER_BYTests.swift @@ -116,8 +116,8 @@ struct ORDER_BYTests { .SELECT(\.name) .FROM(users) .GROUP_BY(\.name) - .HAVING("COUNT(*) > 5") + .HAVING(COUNT(*) > 5) .ORDER_BY(\.name, .DESC) - #expect(query.query == "SELECT name FROM users GROUP BY name HAVING COUNT(*) > 5 ORDER BY name DESC") + #expect(query.query == "SELECT name FROM users GROUP BY name HAVING COUNT(*) > $1 ORDER BY name DESC") } } diff --git a/Tests/SquealTests/SQLCoverageTests.swift b/Tests/SquealTests/SQLCoverageTests.swift index 696d38b..b691178 100644 --- a/Tests/SquealTests/SQLCoverageTests.swift +++ b/Tests/SquealTests/SQLCoverageTests.swift @@ -25,7 +25,7 @@ struct ClauseCombinationTests { .ORDER_BY(\.name, .ASC) .LIMIT(25) .OFFSET(50) - #expect(query.query == "SELECT id, name FROM users WHERE age > $1 AND name = $2 ORDER BY name ASC LIMIT 25 OFFSET 50") + #expect(query.query == "SELECT id, name FROM users WHERE age > $1 AND name = $2 ORDER BY name ASC LIMIT $3 OFFSET $4") } @Test @@ -38,7 +38,7 @@ struct ClauseCombinationTests { .ORDER_BY(\.id, .ASC) .LIMIT(pageSize) .OFFSET((page - 1) * pageSize) - #expect(query.query == "SELECT * FROM users ORDER BY id ASC LIMIT 20 OFFSET 40") + #expect(query.query == "SELECT * FROM users ORDER BY id ASC LIMIT $1 OFFSET $2") } @Test @@ -50,7 +50,7 @@ struct ClauseCombinationTests { .WHERE(\.name == "Alice") .ORDER_BY(\.name, .DESC) .LIMIT(10) - #expect(query.query == "SELECT name AS username FROM users JOIN orders ON users.uuid = orders.user_id WHERE name = $1 ORDER BY name DESC LIMIT 10") + #expect(query.query == "SELECT name AS username FROM users JOIN orders ON users.uuid = orders.user_id WHERE name = $1 ORDER BY name DESC LIMIT $2") } @Test @@ -60,9 +60,9 @@ struct ClauseCombinationTests { .FROM(users) .WHERE(\.age > 18) .GROUP_BY(\.name) - .HAVING("COUNT(*) > 1") + .HAVING(COUNT(*) > 1) .LIMIT(50) - #expect(query.query == "SELECT name FROM users WHERE age > $1 GROUP BY name HAVING COUNT(*) > 1 LIMIT 50") + #expect(query.query == "SELECT name FROM users WHERE age > $1 GROUP BY name HAVING COUNT(*) > $2 LIMIT $3") } // MARK: - WHERE LIKE variants @@ -135,11 +135,11 @@ func fullGroupedQuery() { .FROM(users) .WHERE(\.age > 18) .GROUP_BY(\.name) - .HAVING("COUNT(*) > 1") + .HAVING(COUNT(*) > 1) .ORDER_BY(\.name, .ASC) .LIMIT(10) .OFFSET(0) - #expect(query.query == "SELECT name FROM users WHERE age > $1 GROUP BY name HAVING COUNT(*) > 1 ORDER BY name ASC LIMIT 10 OFFSET 0") + #expect(query.query == "SELECT name FROM users WHERE age > $1 GROUP BY name HAVING COUNT(*) > $2 ORDER BY name ASC LIMIT $3 OFFSET $4") } @@ -148,15 +148,15 @@ func fullGroupedQuery() { // Needs: Add typed HAVING overloads that accept aggregate conditions instead of raw strings. // e.g. .HAVING(COUNT(*) > 5) or .HAVING(SUM(\.age) > 100) // -// @Test -// func HAVINGtyped() { -// let query = SQL -// .SELECT(\.name) -// .FROM(users) -// .GROUP_BY(\.name) -// .HAVING(COUNT(*) > 5) -// #expect(query.query == "SELECT name FROM users GROUP BY name HAVING COUNT(*) > 5") -// } + @Test + func HAVINGtyped() { + let query = SQL + .SELECT(\.name) + .FROM(users) + .GROUP_BY(\.name) + .HAVING(COUNT(*) > 5) + #expect(query.query == "SELECT name FROM users GROUP BY name HAVING COUNT(*) > $1") + } // MARK: - CROSS JOIN @@ -192,20 +192,16 @@ func fullGroupedQuery() { // MARK: - INSERT ... ON CONFLICT (UPSERT) -// -// Needs: Add ON_CONFLICT method to TypedInsertSQLQuery: -// func ON_CONFLICT(_ columns: ..., DO_UPDATE_SET: ...) -> ... -// func ON_CONFLICT(DO_NOTHING: ...) -> ... -// -// @Test -// func INSERT_ON_CONFLICT_DO_NOTHING() { -// let query = SQL -// .INSERT(INTO: users, columns: \.id, \.name, -// VALUES: 1, "Alice") -// .ON_CONFLICT(\.id, DO: .NOTHING) -// #expect(query.query == "INSERT INTO users (id, name) VALUES ($1, $2) ON CONFLICT (id) DO NOTHING") -// } -// + + @Test + func INSERT_ON_CONFLICT_DO_NOTHING() { + let query = SQL + .INSERT(INTO: users, columns: \.id, \.name, + VALUES: 1, "Alice") + .ON_CONFLICT(\.id, DO: .NOTHING) + #expect(query.query == "INSERT INTO users (id, name) VALUES ($1, $2) ON CONFLICT (id) DO NOTHING") + } + // @Test // func INSERT_ON_CONFLICT_DO_UPDATE() { // let query = SQL diff --git a/Tests/SquealTests/SubqueryTests.swift b/Tests/SquealTests/SubqueryTests.swift index 08f4826..1ec903d 100644 --- a/Tests/SquealTests/SubqueryTests.swift +++ b/Tests/SquealTests/SubqueryTests.swift @@ -38,8 +38,8 @@ struct SubqueryTests { .WHERE(\.id == departmentId) .AND(\.id, IN: departmentIdsOfUser) .LIMIT(1) - #expect(query.query == "SELECT name FROM departments WHERE id = $1 AND id IN (SELECT department_id FROM users_departments WHERE user_id = $2) LIMIT 1") - #expect(query.parameters.count == 2) + #expect(query.query == "SELECT name FROM departments WHERE id = $1 AND id IN (SELECT department_id FROM users_departments WHERE user_id = $2) LIMIT $3") + #expect(query.parameters.count == 3) #expect(query.parameters[0] as? UUID == departmentId) #expect(query.parameters[1] as? UUID == userId) } @@ -93,10 +93,10 @@ struct SubqueryTests { let query = SQL .UPDATE(projects, SET: (\.department_id, SQL.SELECT(\.department_id).FROM(users_departments).WHERE(\.user_id == userId).ORDER_BY(\.department_id, .ASC).LIMIT(1))) .WHERE(\.id == projectId) - #expect(query.query == "UPDATE projects SET department_id = (SELECT department_id FROM users_departments WHERE user_id = $1 ORDER BY department_id ASC LIMIT 1) WHERE id = $2") - #expect(query.parameters.count == 2) + #expect(query.query == "UPDATE projects SET department_id = (SELECT department_id FROM users_departments WHERE user_id = $1 ORDER BY department_id ASC LIMIT $2) WHERE id = $3") + #expect(query.parameters.count == 3) #expect(query.parameters[0] as? UUID == userId) - #expect(query.parameters[1] as? UUID == projectId) + #expect(query.parameters[2] as? UUID == projectId) } @Test @@ -104,9 +104,9 @@ struct SubqueryTests { let query = SQL .UPDATE(departments, SET: (\.id, SQL.SELECT(\.department_id).FROM(users_departments).WHERE(\.user_id == userId).LIMIT(1))) .WHERE(\.name == "R&D") - #expect(query.query == "UPDATE departments SET id = (SELECT department_id FROM users_departments WHERE user_id = $1 LIMIT 1) WHERE name = $2") - #expect(query.parameters.count == 2) - #expect(query.parameters[1] as? String == "R&D") + #expect(query.query == "UPDATE departments SET id = (SELECT department_id FROM users_departments WHERE user_id = $1 LIMIT $2) WHERE name = $3") + #expect(query.parameters.count == 3) + #expect(query.parameters[2] as? String == "R&D") } @Test @@ -117,10 +117,10 @@ struct SubqueryTests { VALUES: SQL.SELECT(\.department_id).FROM(users_departments).WHERE(\.user_id == userId).LIMIT(1), "Squeal") .RETURNING(projects.id, RawSQL("upper(name)")) - #expect(query.query == "INSERT INTO projects (department_id, name) VALUES ((SELECT department_id FROM users_departments WHERE user_id = $1 LIMIT 1), $2) RETURNING projects.id, upper(name)") - #expect(query.parameters.count == 2) + #expect(query.query == "INSERT INTO projects (department_id, name) VALUES ((SELECT department_id FROM users_departments WHERE user_id = $1 LIMIT $2), $3) RETURNING projects.id, upper(name)") + #expect(query.parameters.count == 3) #expect(query.parameters[0] as? UUID == userId) - #expect(query.parameters[1] as? String == "Squeal") + #expect(query.parameters[2] as? String == "Squeal") } @Test @@ -132,11 +132,11 @@ struct SubqueryTests { VALUES: projectId, SQL.SELECT(\.department_id).FROM(users_departments).WHERE(\.user_id == userId).LIMIT(1), SQLExpr("lower(?)", "SQUEAL")) - #expect(query.query == "INSERT INTO projects (id, department_id, name) VALUES ($1, (SELECT department_id FROM users_departments WHERE user_id = $2 LIMIT 1), lower($3))") - #expect(query.parameters.count == 3) + #expect(query.query == "INSERT INTO projects (id, department_id, name) VALUES ($1, (SELECT department_id FROM users_departments WHERE user_id = $2 LIMIT $3), lower($4))") + #expect(query.parameters.count == 4) #expect(query.parameters[0] as? UUID == projectId) #expect(query.parameters[1] as? UUID == userId) - #expect(query.parameters[2] as? String == "SQUEAL") + #expect(query.parameters[3] as? String == "SQUEAL") } @available(macOS 14.0.0, *) @@ -145,8 +145,8 @@ struct SubqueryTests { let query = SQL .INSERT(INTO: projects, columns: \.name, \.department_id) .VALUES("Squeal", SQL.SELECT(\.department_id).FROM(users_departments).WHERE(\.user_id == userId).LIMIT(1)) - #expect(query.query == "INSERT INTO projects (name, department_id) VALUES ($1, (SELECT department_id FROM users_departments WHERE user_id = $2 LIMIT 1))") - #expect(query.parameters.count == 2) + #expect(query.query == "INSERT INTO projects (name, department_id) VALUES ($1, (SELECT department_id FROM users_departments WHERE user_id = $2 LIMIT $3))") + #expect(query.parameters.count == 3) } @Test @@ -155,8 +155,8 @@ struct SubqueryTests { .UPDATE(projects, SET: (\.name, "Squeal"), (\.department_id, SQL.SELECT(\.department_id).FROM(users_departments).WHERE(\.user_id == userId).LIMIT(1))) .WHERE(\.name == "Old") - #expect(query.query == "UPDATE projects SET name = $1, department_id = (SELECT department_id FROM users_departments WHERE user_id = $2 LIMIT 1) WHERE name = $3") - #expect(query.parameters.count == 3) - #expect(query.parameters[2] as? String == "Old") + #expect(query.query == "UPDATE projects SET name = $1, department_id = (SELECT department_id FROM users_departments WHERE user_id = $2 LIMIT $3) WHERE name = $4") + #expect(query.parameters.count == 4) + #expect(query.parameters[3] as? String == "Old") } } diff --git a/Tests/SquealTests/TableMacroTests.swift b/Tests/SquealTests/TableMacroTests.swift index 7a2da84..11cce07 100644 --- a/Tests/SquealTests/TableMacroTests.swift +++ b/Tests/SquealTests/TableMacroTests.swift @@ -65,3 +65,70 @@ struct TableMacroTests { #expect("\(query)" == "SELECT id, name FROM products") } } + +// MARK: - @ColumnName + +@Table(schema: "accounts") +struct Account { + let id: UUID + @ColumnName("email_address") let email: String + @ColumnName("display_name") var displayName: String? + @ColumnName("created_at") let createdAt: Date +} + +struct ColumnNameTests { + + let accounts = AccountTable() + + @Test + func generatedColumnsUseTheCustomName() { + #expect(accounts.email.name == "email_address") + #expect(accounts.displayName.name == "display_name") + #expect(accounts.createdAt.name == "created_at") + #expect(accounts.id.name == "id") + } + + @Test + func originalStructIsUnchanged() { + let account = Account(id: UUID(), email: "ada@example.com", displayName: nil, createdAt: Date()) + #expect(account.email == "ada@example.com") + } + + @Test + func keyPathsRenderTheColumnName() { + let query = SQL + .SELECT(\.id, \.email) + .FROM(accounts) + .WHERE(\.email == "ada@example.com") + .ORDER_BY(\.createdAt, .DESC) + #expect(query.query == "SELECT id, email_address FROM accounts WHERE email_address = $1 ORDER BY created_at DESC") + } + + @Test + func qualifiedColumnsRenderTheColumnName() { + let query = SQL + .SELECT(accounts.id, accounts.displayName) + .FROM(accounts) + .WHERE(accounts.email == "ada@example.com") + #expect(query.query == "SELECT accounts.id, accounts.display_name FROM accounts WHERE accounts.email_address = $1") + } + + @Test + func nullableCustomColumn() { + let query = SQL.SELECT(\.id).FROM(accounts).WHERE(\.displayName).IS_NULL + #expect(query.query == "SELECT id FROM accounts WHERE display_name IS NULL") + } + + @Test + func insertAndUpdateUseTheColumnName() { + let id = UUID() + let insert = SQL + .INSERT(INTO: accounts, columns: \.id, \.email, VALUES: id, "ada@example.com") + .RETURNING(\.createdAt) + #expect(insert.query == "INSERT INTO accounts (id, email_address) VALUES ($1, $2) RETURNING created_at") + let update = SQL + .UPDATE(accounts, SET: (\.displayName, "Ada")) + .WHERE(\.id == id) + #expect(update.query == "UPDATE accounts SET display_name = $1 WHERE id = $2") + } +} diff --git a/Tests/SquealTests/WHERETests.swift b/Tests/SquealTests/WHERETests.swift index c9242ee..f9c3105 100644 --- a/Tests/SquealTests/WHERETests.swift +++ b/Tests/SquealTests/WHERETests.swift @@ -12,17 +12,6 @@ import Foundation struct WHERETests { - @Test - func WHEREFullString() { - let query = SQL - .SELECT(\.id) - .FROM(users) - .WHERE("id = 1") - #expect(query.parameters.count == 1) - #expect(query.parameters[0] as? Int == 1) - #expect(query.query == "SELECT id FROM users WHERE id = $1") - #expect("\(query)" == "SELECT id FROM users WHERE id = 1") - } @Test func WHEREInt() { @@ -306,3 +295,58 @@ struct WHERETests { #expect("\(query)" == "SELECT * FROM users WHERE name like '%son'") } } + +struct WHERE_SQLExprTests { + + @Test + func valuesAreBoundAndNumberedAfterExistingParameters() { + let query = SQL + .SELECT(\.id) + .FROM(users) + .WHERE(\.age >= 18) + .AND(SQLExpr("(name = ? OR name = ?)", "Ada", "Grace")) + .OR(SQLExpr("id IN (?, ?)", 1, 2)) + .LIMIT(10) + #expect(query.query == "SELECT id FROM users WHERE age >= $1 AND (name = $2 OR name = $3) OR id IN ($4, $5) LIMIT $6") + #expect(query.parameters.count == 6) + #expect(query.parameters[2] as? String == "Grace") + #expect(query.parameters[5] as? Int == 10) + } + + @Test + func userInputStaysOutOfTheSQL() { + let input = "x' OR name = name OR 'a" + let query = SQL.SELECT(\.id).FROM(users).WHERE(SQLExpr("name = ?", input)) + #expect(query.query == "SELECT id FROM users WHERE name = $1") + #expect(query.parameters[0] as? String == input) + } + + @Test + func worksInUpdateAndDelete() { + let update = SQL + .UPDATE(users, SET: (\.name, "Ada")) + .WHERE(SQLExpr("(age < ? OR age > ?)", 18, 65)) + .RETURNING(\.id) + #expect(update.query == "UPDATE users SET name = $1 WHERE (age < $2 OR age > $3) RETURNING id") + let delete = SQL + .DELETE(FROM: users) + .WHERE(\.id == 1) + .AND(SQLExpr("created_at < now() - interval '30 days'")) + #expect(delete.query == "DELETE FROM users WHERE id = $1 AND created_at < now() - interval '30 days'") + #expect(delete.parameters.count == 1) + } + + @Test + func groupedConditionAsSubqueryIsRenumbered() { + let buyers = SQL + .SELECT(\.user_id) + .FROM(orders) + let query = SQL + .SELECT(\.name) + .FROM(users) + .WHERE(SQLExpr("(age >= ? OR name = ?)", 18, "anonymous")) + .AND(\.uuid, IN: buyers) + .AND(\.id == 7) + #expect(query.query == "SELECT name FROM users WHERE (age >= $1 OR name = $2) AND uuid IN (SELECT user_id FROM orders) AND id = $3") + } +}