Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions Sources/Squeal/Api/DML/TSQL+AND.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,4 +71,18 @@ public extension ANDableQuery {
func AND<U>(_ kp: KeyPath<T, TableColumn<T,U>>, LIKE value: String) -> TypedWhereSQLQuery<T, Row> {
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<T, Row> {
let (fragments, newParameters) = renderSQLValues([expression], parameterOffset: parameterNumber())
return TypedWhereSQLQuery(for: table, query: query + " AND \(fragments[0])", parameters: parameters + newParameters)
}
}
89 changes: 76 additions & 13 deletions Sources/Squeal/Api/DML/TSQL+HAVING.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,27 +24,90 @@ public struct TypedHavingSQLQuery<T: Table, Row>: 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<T, Row>

}

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<T, Row> {
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<T, Row> {
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<T, Row> {
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 == <A: SQLAggregate, V: Encodable>(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 != <A: SQLAggregate, V: Encodable>(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 > <A: SQLAggregate, V: Encodable>(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 < <A: SQLAggregate, V: Encodable>(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 >= <A: SQLAggregate, V: Encodable>(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 <= <A: SQLAggregate, V: Encodable>(left: A, right: V) -> AggregatePredicate {
AggregatePredicate(aggregate: left.toString(), sign: "<=", value: right)
}

10 changes: 1 addition & 9 deletions Sources/Squeal/Api/DML/TSQL+INSERT.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 + ")")
}
Expand Down Expand Up @@ -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<T, Void> {
TypedSQLQuery(for: table, query: query + " (\(values.map{"'\($0)'"}.joined(separator: ", ")))", parameters: parameters)
}

/// Returns a column of the inserted row: `RETURNING column`.
func RETURNING<U>(_ kp: KeyPath<T, TableColumn<T, U>>) -> TypedSQLQuery<T, Void> {
return TypedSQLQuery(for: table, query: query + " RETURNING \(table[keyPath: kp].name)", parameters: parameters)
Expand Down Expand Up @@ -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<T> {
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)
}
}

Expand Down
6 changes: 3 additions & 3 deletions Sources/Squeal/Api/DML/TSQL+LIMIT.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,15 @@ public struct TypedLimitSQLQuery<T: Table, Row>: 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<T, Row>
}

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<T, Row> {
return TypedLimitSQLQuery(for: table, query: query + " " + "LIMIT \(value)", parameters: parameters)
return TypedLimitSQLQuery(for: table, query: query + " LIMIT \(nextDollarSign())", parameters: parameters + [value])
}
}

Expand Down
6 changes: 3 additions & 3 deletions Sources/Squeal/Api/DML/TSQL+OFFSET.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ public struct TypedOffsetSQLQuery<T: Table, Row>: 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
Expand All @@ -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<T, Row> {
return TypedOffsetSQLQuery(for: table, query: query + " " + "OFFSET \(value)", parameters: parameters)
return TypedOffsetSQLQuery(for: table, query: query + " OFFSET \(nextDollarSign())", parameters: parameters + [value])
}
}

Expand Down
13 changes: 13 additions & 0 deletions Sources/Squeal/Api/DML/TSQL+OR.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,17 @@ public extension ORableQuery {
func OR<U>(_ kp: KeyPath<T, TableColumn<T,U>>, LIKE value: String) -> TypedWhereSQLQuery<T, Row> {
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<T, Row> {
let (fragments, newParameters) = renderSQLValues([expression], parameterOffset: parameterNumber())
return TypedWhereSQLQuery(for: table, query: query + " OR \(fragments[0])", parameters: parameters + newParameters)
}
}
42 changes: 9 additions & 33 deletions Sources/Squeal/Api/DML/TSQL+WHERE.swift
Original file line number Diff line number Diff line change
Expand Up @@ -136,12 +136,6 @@ public protocol WHEREableQuery: TableSQLQuery {
func WHERE<Y>(_ predicate: SQLPredicate<T, Y?>) -> TypedWhereSQLQuery<T, Row>
/// Starts a `NULL` test on a column. Follow it with `IS_NULL` or `IS_NOT_NULL`.
func WHERE<U>(_ kp: KeyPath<T, TableColumn<T,U>>) -> PartialTypedWhereSQLQuery<T, U, Row>
/// Filters rows with a condition written as SQL.
///
/// If the string has the form `column <operator> 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<T, Row>
/// Filters rows with a comparison on a table-qualified column, which can belong to any table in the query.
///
/// ```swift
Expand Down Expand Up @@ -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 <operator> 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<T, Row> {
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<T, Row> {
let (fragments, newParameters) = renderSQLValues([expression], parameterOffset: parameterNumber())
return TypedWhereSQLQuery(for: table, query: query + " WHERE \(fragments[0])", parameters: parameters + newParameters)
}
}

2 changes: 1 addition & 1 deletion Sources/Squeal/Api/SQL.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
27 changes: 26 additions & 1 deletion Sources/Squeal/Macros.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
30 changes: 23 additions & 7 deletions Sources/Squeal/Squeal.docc/DefiningTables.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,23 +39,39 @@ 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.

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 {
Expand Down
Loading
Loading