Skip to content
Merged
Show file tree
Hide file tree
Changes from 21 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
38 changes: 27 additions & 11 deletions Sources/StructuredQueriesCore/AggregateFunctions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ extension QueryExpression where QueryValue: QueryBindable {
distinct isDistinct: Bool = false,
filter: (some QueryExpression<Bool>)? = Bool?.none
) -> some QueryExpression<Int> {
AggregateFunction(
AggregateFunctionExpression(
"count",
isDistinct: isDistinct,
[queryFragment],
Expand Down Expand Up @@ -51,7 +51,7 @@ where QueryValue: _OptionalPromotable, QueryValue._Optionalized.Wrapped == Strin
order: (some QueryExpression)? = Bool?.none,
filter: (some QueryExpression<Bool>)? = Bool?.none
) -> some QueryExpression<String?> {
AggregateFunction(
AggregateFunctionExpression(
"group_concat",
separator.map { [queryFragment, $0.queryFragment] } ?? [queryFragment],
order: order?.queryFragment,
Expand All @@ -74,7 +74,7 @@ where QueryValue: _OptionalPromotable, QueryValue._Optionalized.Wrapped == Strin
order: (some QueryExpression)? = Bool?.none,
filter: (some QueryExpression<Bool>)? = Bool?.none
) -> some QueryExpression<String?> {
AggregateFunction(
AggregateFunctionExpression(
"group_concat",
isDistinct: isDistinct,
[queryFragment],
Expand All @@ -97,7 +97,7 @@ extension QueryExpression where QueryValue: QueryBindable & _OptionalPromotable
public func max(
filter: (some QueryExpression<Bool>)? = Bool?.none
) -> some QueryExpression<QueryValue._Optionalized.Wrapped?> {
AggregateFunction("max", [queryFragment], filter: filter?.queryFragment)
AggregateFunctionExpression("max", [queryFragment], filter: filter?.queryFragment)
}

/// A minimum aggregate of this expression.
Expand All @@ -112,7 +112,7 @@ extension QueryExpression where QueryValue: QueryBindable & _OptionalPromotable
public func min(
filter: (some QueryExpression<Bool>)? = Bool?.none
) -> some QueryExpression<QueryValue._Optionalized.Wrapped?> {
AggregateFunction("min", [queryFragment], filter: filter?.queryFragment)
AggregateFunctionExpression("min", [queryFragment], filter: filter?.queryFragment)
}
}

Expand All @@ -134,7 +134,7 @@ where QueryValue: _OptionalPromotable, QueryValue._Optionalized.Wrapped: Numeric
distinct isDistinct: Bool = false,
filter: (some QueryExpression<Bool>)? = Bool?.none
) -> some QueryExpression<Double?> {
AggregateFunction("avg", isDistinct: isDistinct, [queryFragment], filter: filter?.queryFragment)
AggregateFunctionExpression("avg", isDistinct: isDistinct, [queryFragment], filter: filter?.queryFragment)
}

/// An sum aggregate of this expression.
Expand All @@ -156,7 +156,7 @@ where QueryValue: _OptionalPromotable, QueryValue._Optionalized.Wrapped: Numeric
// NB: We must explicitly erase here to avoid a runtime crash with opaque return types
// TODO: Report issue to Swift team.
SQLQueryExpression(
AggregateFunction<QueryValue._Optionalized>(
AggregateFunctionExpression<QueryValue._Optionalized>(
"sum",
isDistinct: isDistinct,
[queryFragment],
Expand All @@ -182,7 +182,7 @@ where QueryValue: _OptionalPromotable, QueryValue._Optionalized.Wrapped: Numeric
distinct isDistinct: Bool = false,
filter: (some QueryExpression<Bool>)? = Bool?.none
) -> some QueryExpression<QueryValue> {
AggregateFunction(
AggregateFunctionExpression(
"total",
isDistinct: isDistinct,
[queryFragment],
Expand All @@ -191,7 +191,7 @@ where QueryValue: _OptionalPromotable, QueryValue._Optionalized.Wrapped: Numeric
}
}

extension QueryExpression where Self == AggregateFunction<Int> {
extension QueryExpression where Self == AggregateFunctionExpression<Int> {
/// A `count(*)` aggregate.
///
/// ```swift
Expand All @@ -204,18 +204,34 @@ extension QueryExpression where Self == AggregateFunction<Int> {
public static func count(
filter: (any QueryExpression<Bool>)? = nil
) -> Self {
AggregateFunction("count", ["*"], filter: filter?.queryFragment)
AggregateFunctionExpression("count", ["*"], filter: filter?.queryFragment)
}
}

/// A query expression of an aggregate function.
public struct AggregateFunction<QueryValue>: QueryExpression, Sendable {
public struct AggregateFunctionExpression<QueryValue>: QueryExpression, Sendable {
var name: QueryFragment
var isDistinct: Bool
var arguments: [QueryFragment]
var order: QueryFragment?
var filter: QueryFragment?

public init<each Argument: QueryExpression>(
Copy link
Member Author

Choose a reason for hiding this comment

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

With this public, should we rename AggregateFunction to AggregateFunctionExpression?

Should we also rename QueryFunction to FunctionExpression (or ScalarFunctionExpression) and publicize its initializer?

_ name: String,
distinct isDistinct: Bool = false,
_ arguments: repeat each Argument,
order: (some QueryExpression)? = Bool?.none,
filter: (some QueryExpression<Bool>)? = Bool?.none
) {
self.init(
QueryFragment(quote: name),
isDistinct: isDistinct,
Array(repeat each arguments),
order: order?.queryFragment,
filter: filter?.queryFragment
)
}

package init(
_ name: QueryFragment,
isDistinct: Bool = false,
Expand Down
3 changes: 1 addition & 2 deletions Sources/StructuredQueriesCore/ScalarFunctions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -319,8 +319,7 @@ extension QueryExpression where QueryValue == [UInt8] {
}
}

/// A query expression of a generalized query function.
public struct QueryFunction<QueryValue>: QueryExpression {
package struct QueryFunction<QueryValue>: QueryExpression {
let name: QueryFragment
let arguments: [QueryFragment]

Expand Down
38 changes: 38 additions & 0 deletions Sources/StructuredQueriesSQLite/Macros.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,41 @@ public macro DatabaseFunction<each T: QueryRepresentable & QueryExpression>(
module: "StructuredQueriesSQLiteMacros",
type: "DatabaseFunctionMacro"
)

/// Defines and implements a conformance to the ``/StructuredQueriesSQLiteCore/DatabaseFunction``
/// protocol.
///
/// - Parameters
/// - name: The function's name. Defaults to the name of the function the macro is applied to.
/// - representableFunctionType: The function as represented in a query.
/// - isDeterministic: Whether or not the function is deterministic (or "pure" or "referentially
/// transparent"), _i.e._ given an input it will always return the same output.
@attached(peer, names: overloaded, prefixed(`$`))
public macro DatabaseFunction<each T: QueryRepresentable & QueryExpression, R: QueryBindable>(
_ name: String = "",
as representableFunctionType: ((any Sequence<(repeat each T)>) -> R).Type,
isDeterministic: Bool = false
) =
#externalMacro(
module: "StructuredQueriesSQLiteMacros",
type: "DatabaseFunctionMacro"
)

/// Defines and implements a conformance to the ``/StructuredQueriesSQLiteCore/DatabaseFunction``
/// protocol.
///
/// - Parameters
/// - name: The function's name. Defaults to the name of the function the macro is applied to.
/// - representableFunctionType: The function as represented in a query.
/// - isDeterministic: Whether or not the function is deterministic (or "pure" or "referentially
/// transparent"), _i.e._ given an input it will always return the same output.
@attached(peer, names: overloaded, prefixed(`$`))
public macro DatabaseFunction<each T: QueryRepresentable & QueryExpression>(
_ name: String = "",
as representableFunctionType: ((any Sequence<(repeat each T)>) -> Void).Type,
isDeterministic: Bool = false
) =
#externalMacro(
module: "StructuredQueriesSQLiteMacros",
type: "DatabaseFunctionMacro"
)
73 changes: 69 additions & 4 deletions Sources/StructuredQueriesSQLiteCore/DatabaseFunction.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/// A type representing a database function.
///
/// Don't conform to this protocol directly. Instead, use the `@DatabaseFunction` macro to generate
/// a conformance.
/// Don't conform to this protocol directly. Instead, use the
/// [`@DatabaseFunction`](<doc:CustomFunctions>) macro to generate a conformance.
public protocol DatabaseFunction<Input, Output> {
/// A type representing the function's arguments.
associatedtype Input
Expand All @@ -22,8 +22,8 @@ public protocol DatabaseFunction<Input, Output> {

/// A type representing a scalar database function.
///
/// Don't conform to this protocol directly. Instead, use the `@DatabaseFunction` macro to generate
/// a conformance.
/// Don't conform to this protocol directly. Instead, use the
/// [`@DatabaseFunction`](<doc:CustomFunctions#Scalar-functions>) macro to generate a conformance.
public protocol ScalarDatabaseFunction<Input, Output>: DatabaseFunction {
/// The function body. Uses a query decoder to process the input of a database function into a
/// bindable value.
Expand All @@ -50,3 +50,68 @@ extension ScalarDatabaseFunction {
}
}
}

/// A type representing an aggregate database function.
///
/// Don't conform to this protocol directly. Instead, use the
/// [`@DatabaseFunction`](<doc:CustomFunctions#Aggregate-functions>) macro to generate a
/// conformance.
public protocol AggregateDatabaseFunction<Input, Output>: DatabaseFunction {
/// A type representing one row of input to the aggregate function.
associatedtype Element = Input

/// Decodes a row into an element to aggregate a result from.
///
/// - Parameter decoder: A query decoder.
/// - Returns: An element to append to the sequence sent to the aggregate function.
func step(_ decoder: inout some QueryDecoder) throws -> Element

/// Aggregates elements into a bindable value.
///
/// - Parameter arguments: A sequence of elements to aggregate from.
/// - Returns: A binding returned from the aggregate function.
func invoke(_ arguments: some Sequence<Element>) throws -> QueryBinding
}

extension AggregateDatabaseFunction {
/// An aggregate function call expression.
///
/// - Parameters
/// - input: Expressions representing the arguments of the function.
/// - isDistinct: Whether or not to include a `DISTINCT` clause, which filters duplicates from
/// the aggregation.
/// - order: An `ORDER BY` clause to apply to the aggregation.
/// - filter: A `FILTER` clause to apply to the aggregation.
/// - Returns: An expression representing the function call.
@_disfavoredOverload
public func callAsFunction(
_ input: some QueryExpression<Input>,
distinct isDistinct: Bool = false,
order: (some QueryExpression)? = Bool?.none,
filter: (some QueryExpression<Bool>)? = Bool?.none
) -> some QueryExpression<Output>
where Input: QueryBindable {
$_isSelecting.withValue(false) {
AggregateFunctionExpression(name, distinct: isDistinct, input, order: order, filter: filter)
}
}

/// An aggregate function call expression.
///
/// - Parameters
/// - input: Expressions representing the arguments of the function.
/// - order: An `ORDER BY` clause to apply to the aggregation.
/// - filter: A `FILTER` clause to apply to the aggregation.
/// - Returns: An expression representing the function call.
@_disfavoredOverload
public func callAsFunction<each T: QueryExpression>(
_ input: repeat each T,
order: (some QueryExpression)? = Bool?.none,
filter: (some QueryExpression<Bool>)? = Bool?.none
) -> some QueryExpression<Output>
where Input == (repeat (each T).QueryValue) {
$_isSelecting.withValue(false) {
AggregateFunctionExpression(name, repeat each input, order: order, filter: filter)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ from SQLite.

## Overview

### Scalar functions

StructuredQueries defines a macro specifically for defining Swift functions that can be called from
a query. It's called `@DatabaseFunction`, and can annotate any function that works with
query-representable types.
Expand All @@ -18,11 +20,14 @@ func exclaim(_ string: String) -> String {
}
```

This defines a "scalar" function, which is called on a value for each row in a query, returning its
result.

> Note: If your project is using [default main actor isolation] then you further need to annotate
> your function as `nonisolated`.
[default main actor isolation]: https://github.com/swiftlang/swift-evolution/blob/main/proposals/0466-control-default-actor-isolation.md

And will be immediately callable in a query by prefixing the function with `$`:
Once defined, the function is immediately callable in a query by prefixing the function with `$`:

```swift
Reminder.select { $exclaim($0.title) }
Expand Down Expand Up @@ -52,9 +57,52 @@ configuration.prepareDatabase { db in
> }
> ```

### Aggregate functions

It is also possible to define a Swift function that builds a single result from multiple rows of a
query. The function must simply take a _sequence_ of query-representable types.

For example, suppose you want to compute the most common priority used across all reminders. This
computation is called the "mode" in statistics, and unfortunately SQLite does not supply such
a function. But it is quite easy to write this function in plain Swift:

```swift
@DatabaseFunction
func mode(priority priorities: some Sequence<Priority?>) -> Priority? {
var occurences: [Priority: Int] = [:]
for priority in priorities {
guard let priority
else { continue }
occurences[priority, default: 0] += 1
}
return occurences.max { $0.value < $1.value }?.key
}
```

This defines an "aggregate" function, and the sequence `priorities` that is passed to it represents
all of the data from the database passed to it while aggregating. It is now straightfoward
to compute the mode of priorities across all reminders:

```swift
Reminder
.select { $mode(priority: $0.priority) }
```

> Tip: Be sure to install the function in the database connection as discussed in
> <doc:CustomFunctions#Scalar-functions> above.

You can also compute the mode of priorities inside each reminders list:

```swift
RemindersList
.group(by: \.id)
.leftJoin(Reminder.all) { $0.id.eq($1.remindersListID) }
.select { ($0.title, $mode(priority: $1.priority)) }
```

### Custom representations

To define a type that works with a custom representation, i.e. anytime you use `@Column(as:)` in
To define a type that works with a custom representation, _i.e._ anytime you use `@Column(as:)` in
your data type, you can use the `as` parameter of the macro to specify those types. For example,
if your model holds onto a date and you want to store that date as a
[unix timestamp](<doc:Foundation/Date/UnixTimeRepresentation-struct>) (i.e. double),
Expand Down Expand Up @@ -93,9 +141,22 @@ func jsonArrayExclaim(_ strings: [String]) -> [String] {
}
```

It is also possible to do this with aggregate functions, but you must describe the sequence as an
`any Sequence` instead of a `some Sequence`:

```swift
@DatabaseFunction(
as: ((any Sequence<[String].JSONRepresentation>) -> [String].JSONRepresentation).self
)
func jsonJoined(_ arrays: some Sequence<[String]>) -> [String] {
arrays.flatMap(\.self)
}
```

## Topics

### Custom functions

- ``DatabaseFunction``
- ``ScalarDatabaseFunction``
- ``AggregateDatabaseFunction``
6 changes: 3 additions & 3 deletions Sources/StructuredQueriesSQLiteCore/JSONFunctions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ extension QueryExpression where QueryValue: Codable & QueryBindable {
order: (some QueryExpression)? = Bool?.none,
filter: (some QueryExpression<Bool>)? = Bool?.none
) -> some QueryExpression<[QueryValue].JSONRepresentation> {
AggregateFunction(
AggregateFunctionExpression(
"json_group_array",
isDistinct: isDistinct,
[queryFragment],
Expand Down Expand Up @@ -112,7 +112,7 @@ extension PrimaryKeyedTableDefinition where QueryValue: Codable {
order: (some QueryExpression)? = Bool?.none,
filter: (some QueryExpression<Bool>)? = Bool?.none
) -> some QueryExpression<[QueryValue].JSONRepresentation> {
AggregateFunction(
AggregateFunctionExpression(
"json_group_array",
isDistinct: isDistinct,
[jsonObject().queryFragment],
Expand Down Expand Up @@ -200,7 +200,7 @@ where
} else {
primaryKeyFilter.queryFragment
}
return AggregateFunction(
return AggregateFunctionExpression(
"json_group_array",
isDistinct: isDistinct,
[QueryValue.columns.jsonObject().queryFragment],
Expand Down
Loading