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
Original file line number Diff line number Diff line change
Expand Up @@ -12,84 +12,6 @@ The library provides a few tools to help mitigate this problem so that you can c
benefits of type-safety and expressivity in your queries, while also helping out Swift in compiling
your queries.

### Method operators
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can kill this doc since we found that == and != were the operators that strained the compiler the most in practice (we freely use &&, <, and others).


By far the easiest way to mitigate compiler performance problems in complex expressions is to use
the method version of the various operators SQL has to offer, _e.g._ using
[`eq`](<doc:QueryExpression/eq(_:)>) instead of [`==`](<doc:QueryExpression/==(_:_:)>). Consider a
database schema that has a "reminders" table, a "tags" table, as well as a many-to-many join table
that can associated any number of tags to any number of reminders:

```swift
@Table struct Reminder: Identifiable {
let id: Int
var title = ""
var isCompleted = false
}
@Table struct Tag: Identifiable {
let id: Int
var title = ""
}
@Table struct ReminderTag {
let reminderID: Reminder.ID
let tagID: Tag.ID
}
```

With this schema it is possible to write a query that selects all reminders' titles, along with a
comma-separated string of every tag associated with each reminder:

```swift
Reminder
.group(by: \.id)
.join(ReminderTag.all) { $0.id == $1.reminderID }
.join(Tag.all) { $1.tagID == $2.id }
.select { ($0.title, $2.title.groupConcat()) }
```

While this is a moderately complex query, it is definitely something that should compile quite
quickly, but unfortunately Swift currently cannot type-check it quickly enough (as of Swift 6.1).
The problem is that the overload space of `==` is so large that Swift has too many possibilities
to choose from when compiling this expression.

The easiest fix is to use the dedicated [`eq`](<doc:QueryExpression/eq(_:)>) methods that have a
much smaller overload space:

```diff
Reminder
.group(by: \.id)
- .join(ReminderTag.all) { $0.id == $1.reminderID }
+ .join(ReminderTag.all) { $0.id.eq($1.reminderID) }
- .join(Tag.all) { $1.tagID == $2.id }
+ .join(Tag.all) { $1.tagID.eq($2.id) }
.select { ($0.title, $2.title.groupConcat()) }
```

With that one change the expression now compiles immediately. We find that the equality operator
is by far the worst when it comes to compilation speed, and so we always recommend using
[`eq`](<doc:QueryExpression/eq(_:)>) over [`==`](<doc:QueryExpression/==(_:_:)>), but other
operators can benefit from this too if you notice problems, such as
[`neq`](<doc:QueryExpression/neq(_:)>) over [`!=`](<doc:QueryExpression/!=(_:_:)>),
[`gt`](<doc:QueryExpression/gt(_:)>) over [`>`](<doc:QueryExpression/\>(_:_:)>), and so on. Here is
a table of method equivalents to the most common operators:

| Operator | Method |
| ---------------------------------- | ---------------- |
| `lhs == rhs` | `lhs.eq(rhs)` |
| `lhs == rhs` (involving optionals) | `lhs.is(rhs)` |
| `lhs != rhs` | `lhs.neq(rhs)` |
| `lhs != rhs` (involving optionals) | `lhs.isNot(rhs)` |
| `lhs && rhs` | `lhs.and(rhs)` |
| `lhs \|\| rhs` | `lhs.or(rhs)` |
| `!value` | `value.not()` |
| `lhs < rhs` | `lhs.lt(rhs)` |
| `lhs > rhs` | `lhs.gt(rhs)` |
| `lhs <= rhs` | `lhs.lte(rhs)` |
| `lhs >= rhs` | `lhs.gte(rhs)` |

Often one does not need to convert _every_ operator to the method style. You can usually do it for
just a few operators to get a big boost, and we recommend starting with `==` and `!=`.

### The #sql macro

The library ships with a tool that allows one to write safe SQL strings _via_ the `#sql` macro (see
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,21 @@ used for string concatenation, not `||`:

| Swift | SQL equivalent |
| --------------------------- | ---------------- |
| `==` | `=`, `IS` |
| `!=` | `<>`, `IS NOT` |
| `&&` | `AND` |
| `\|\|` | `OR` |
| `!` | `NOT` |
| `+` (string concatenation) | `\|\|` |
| `??` | `coalesce(_, _)` |

> Tip: Heavily overloaded Swift operators can tax the compiler, and so the library often provides
> method equivalents to alleviate this. For example, `==` is aliased to `eq`. See
> <doc:CompilerPerformance#Method-operators> for a full list and more.
> `==` and `!=` are _not_ overloaded in Swift for a couple reasons:
>
> 1. Equality in SQL involves "three-valued logic": true, false, and null. Because of this there
> is more than one equality operation: `=` and `IS`, along with their negations, `<>` and
> `IS NOT`. To best preserve the intent of the generated SQL, StructuredQueries provides `eq`
> and `is`, along with their negations, `neq` and `isNot`.
>
> 2. These operators are the most heavily overloaded in Swift and can strain the compiler in
> complex queries.

Other SQL operators are translated to method equivalents in Swift:

Expand Down Expand Up @@ -56,8 +60,8 @@ Explore the full list of operators below.

### Equality

- ``QueryExpression/==(_:_:)``
- ``QueryExpression/!=(_:_:)``
- ``QueryExpression/eq(_:)``
- ``QueryExpression/neq(_:)``
- ``QueryExpression/is(_:)``
- ``QueryExpression/isNot(_:)``

Expand Down
206 changes: 85 additions & 121 deletions Sources/StructuredQueriesCore/Operators.swift
Original file line number Diff line number Diff line change
@@ -1,46 +1,4 @@
extension QueryExpression where QueryValue: QueryRepresentable {
/// A predicate expression indicating whether two query expressions are equal.
///
/// ```swift
/// Reminder.where { $0.title == "Buy milk" }
/// // SELECT … FROM "reminders" WHERE "reminders"."title" = 'Buy milk'
/// ```
///
/// > Important: Overloaded operators can strain the Swift compiler's type checking ability.
/// > Consider using ``eq(_:)``, instead.
///
/// - Parameters:
/// - lhs: An expression to compare.
/// - rhs: Another expression to compare.
/// - Returns: A predicate expression.
public static func == (
lhs: Self,
rhs: some QueryExpression<QueryValue>
) -> some QueryExpression<Bool> {
lhs.eq(rhs)
}

/// A predicate expression indicating whether two query expressions are not equal.
///
/// ```swift
/// Reminder.where { $0.title != "Buy milk" }
/// // SELECT … FROM "reminders" WHERE "reminders"."title" <> 'Buy milk'
/// ```
///
/// > Important: Overloaded operators can strain the Swift compiler's type checking ability.
/// > Consider using ``neq(_:)``, instead.
///
/// - Parameters:
/// - lhs: An expression to compare.
/// - rhs: Another expression to compare.
/// - Returns: A predicate expression.
public static func != (
lhs: Self,
rhs: some QueryExpression<QueryValue>
) -> some QueryExpression<Bool> {
lhs.neq(rhs)
}

/// Returns a predicate expression indicating whether two query expressions are equal.
///
/// ```swift
Expand Down Expand Up @@ -100,10 +58,92 @@ extension QueryExpression where QueryValue: QueryRepresentable {
where QueryValue._Optionalized.Wrapped == Other._Optionalized.Wrapped {
BinaryOperator(lhs: self, operator: "IS NOT", rhs: other)
}
}

private func isNull<Value>(_ expression: some QueryExpression<Value>) -> Bool {
(expression as? any _OptionalProtocol).map { $0._wrapped == nil } ?? false
@available(*, unavailable, message: "Use 'eq' (or 'is') instead.")
public static func == (
lhs: Self,
rhs: some QueryExpression<QueryValue>
) -> some QueryExpression<Bool> {
BinaryOperator(lhs: lhs, operator: "=", rhs: rhs)
}

@available(*, unavailable, message: "Use 'eq' (or 'is') instead.")
public static func == (
lhs: Self,
rhs: some QueryExpression<QueryValue?>
) -> some QueryExpression<Bool> {
BinaryOperator(lhs: lhs, operator: "=", rhs: rhs)
}

@available(*, unavailable, message: "Use 'eq' (or 'is') instead.")
public static func == (
lhs: Self,
rhs: some QueryExpression<QueryValue.Wrapped>
) -> some QueryExpression<Bool> where QueryValue: _OptionalProtocol {
BinaryOperator(lhs: lhs, operator: "=", rhs: rhs)
}

@available(*, deprecated, message: "Use 'is' instead.")
public static func == (
lhs: Self,
rhs: _Null<QueryValue>
) -> some QueryExpression<Bool> where QueryValue: _OptionalProtocol {
BinaryOperator(lhs: lhs, operator: "IS", rhs: rhs)
}

@available(*, deprecated, message: "Use 'is' instead.")
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few of these are simply deprecated because they otherwise fall down to Swift's built-in == on optional values.

public static func == (
lhs: _Null<QueryValue>,
rhs: Self
) -> some QueryExpression<Bool> where QueryValue: _OptionalProtocol {
BinaryOperator(lhs: lhs, operator: "IS", rhs: rhs)
}

@available(
*, unavailable, message: "Use 'neq' or (or 'isNot') instead."
)
public static func != (
lhs: Self,
rhs: some QueryExpression<QueryValue>
) -> some QueryExpression<Bool> {
BinaryOperator(lhs: lhs, operator: "<>", rhs: rhs)
}

@available(
*, unavailable, message: "Use 'neq' or (or 'isNot') instead."
)
public static func != (
lhs: Self,
rhs: some QueryExpression<QueryValue?>
) -> some QueryExpression<Bool> {
BinaryOperator(lhs: lhs, operator: "<>", rhs: rhs)
}

@available(
*, unavailable, message: "Use 'neq' or (or 'isNot') instead."
)
public static func != (
lhs: Self,
rhs: some QueryExpression<QueryValue.Wrapped>
) -> some QueryExpression<Bool> where QueryValue: _OptionalProtocol {
BinaryOperator(lhs: lhs, operator: "<>", rhs: rhs)
}

@available(*, deprecated, message: "Use 'isNot' instead.")
public static func != (
lhs: Self,
rhs: _Null<QueryValue>
) -> some QueryExpression<Bool> where QueryValue: _OptionalProtocol {
BinaryOperator(lhs: lhs, operator: "<>", rhs: rhs)
}

@available(*, deprecated, message: "Use 'isNot' instead.")
public static func != (
lhs: _Null<QueryValue>,
rhs: Self
) -> some QueryExpression<Bool> where QueryValue: _OptionalProtocol {
BinaryOperator(lhs: lhs, operator: "<>", rhs: rhs)
}
}

extension QueryExpression where QueryValue: QueryRepresentable & QueryExpression {
Expand Down Expand Up @@ -170,82 +210,6 @@ extension QueryExpression where QueryValue: QueryRepresentable & _OptionalProtoc
}
}

// NB: This overload is required due to an overload resolution bug of 'Updates[dynamicMember:]'.
@_disfavoredOverload
@_documentation(visibility: private)
public func == <QueryValue>(
lhs: any QueryExpression<QueryValue>,
rhs: some QueryExpression<QueryValue?>
) -> some QueryExpression<Bool> {
BinaryOperator(lhs: lhs, operator: isNull(rhs) ? "IS" : "=", rhs: rhs)
}

// NB: This overload is required due to an overload resolution bug of 'Updates[dynamicMember:]'.
@_disfavoredOverload
@_documentation(visibility: private)
public func != <QueryValue>(
lhs: any QueryExpression<QueryValue>,
rhs: some QueryExpression<QueryValue?>
) -> some QueryExpression<Bool> {
BinaryOperator(lhs: lhs, operator: isNull(rhs) ? "IS NOT" : "<>", rhs: rhs)
}

// NB: This overload is required due to an overload resolution bug of 'Updates[dynamicMember:]'.
@_documentation(visibility: private)
@_disfavoredOverload
public func == <QueryValue: _OptionalProtocol>(
lhs: any QueryExpression<QueryValue>,
rhs: some QueryExpression<QueryValue.Wrapped>
) -> some QueryExpression<Bool> {
BinaryOperator(lhs: lhs, operator: "IS", rhs: rhs)
}

// NB: This overload is required due to an overload resolution bug of 'Updates[dynamicMember:]'.
@_documentation(visibility: private)
@_disfavoredOverload
public func != <QueryValue: _OptionalProtocol>(
lhs: any QueryExpression<QueryValue>,
rhs: some QueryExpression<QueryValue.Wrapped>
) -> some QueryExpression<Bool> {
BinaryOperator(lhs: lhs, operator: "IS NOT", rhs: rhs)
}

// NB: This overload is required due to an overload resolution bug of 'Updates[dynamicMember:]'.
@_documentation(visibility: private)
public func == <QueryValue: _OptionalProtocol>(
lhs: any QueryExpression<QueryValue>,
rhs: some QueryExpression<QueryValue>
) -> some QueryExpression<Bool> {
BinaryOperator(lhs: lhs, operator: "IS", rhs: rhs)
}

// NB: This overload is required due to an overload resolution bug of 'Updates[dynamicMember:]'.
@_documentation(visibility: private)
public func != <QueryValue: _OptionalProtocol>(
lhs: any QueryExpression<QueryValue>,
rhs: some QueryExpression<QueryValue>
) -> some QueryExpression<Bool> {
BinaryOperator(lhs: lhs, operator: "IS NOT", rhs: rhs)
}

// NB: This overload is required due to an overload resolution bug of 'Updates[dynamicMember:]'.
@_documentation(visibility: private)
public func == <QueryValue: QueryBindable>(
lhs: any QueryExpression<QueryValue>,
rhs: _Null<QueryValue>
) -> some QueryExpression<Bool> {
SQLQueryExpression(lhs).is(rhs)
}

// NB: This overload is required due to an overload resolution bug of 'Updates[dynamicMember:]'.
@_documentation(visibility: private)
public func != <QueryValue: QueryBindable>(
lhs: any QueryExpression<QueryValue>,
rhs: _Null<QueryValue>
) -> some QueryExpression<Bool> {
SQLQueryExpression(lhs).isNot(rhs)
}

extension QueryExpression where QueryValue: _OptionalPromotable {
/// Returns a predicate expression indicating whether the value of the first expression is less
/// than that of the second expression.
Expand Down
4 changes: 2 additions & 2 deletions Tests/StructuredQueriesTests/DeleteTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ extension SnapshotTests {
}

@Test func deleteID1() {
assertQuery(Reminder.delete().where { $0.id == 1 }.returning(\.self)) {
assertQuery(Reminder.delete().where { $0.id.eq(1) }.returning(\.self)) {
"""
DELETE FROM "reminders"
WHERE (("reminders"."id") = (1))
Expand Down Expand Up @@ -129,7 +129,7 @@ extension SnapshotTests {
enum R: AliasName {}
assertQuery(
RemindersList.as(R.self)
.where { $0.id == 1 }
.where { $0.id.eq(1) }
.delete()
.returning(\.self)
) {
Expand Down
2 changes: 1 addition & 1 deletion Tests/StructuredQueriesTests/InsertTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -384,7 +384,7 @@ extension SnapshotTests {
}

@Test func upsertWithID() {
assertQuery(Reminder.where { $0.id == 1 }) {
assertQuery(Reminder.where { $0.id.eq(1) }) {
"""
SELECT "reminders"."id", "reminders"."assignedUserID", "reminders"."dueDate", "reminders"."isCompleted", "reminders"."isFlagged", "reminders"."notes", "reminders"."priority", "reminders"."remindersListID", "reminders"."title", "reminders"."updatedAt"
FROM "reminders"
Expand Down
Loading