Skip to content

Commit c863124

Browse files
gwynneptoffy
andauthored
Add variadic columns(of:, ...) helpers in several places. (#8)
Add several new helpers: - `SQLRow.decode(columnsOf:_:)` - `SQLQueryFetcher.first(decodingColumnsOf:_:)` - `SQLQueryFetcher.all(decodingColumnsOf:_:)` - `SQLUnqualifiedColumnListBuilder.columns(of:_:)` - `SQLInsertBuilder.columns(of:_:)` These helpers work exactly the same as the variadic `.columns(_:)` helpers, allowing you to specify multiple Fluent key paths in one call, except that these helpers require you to specify a single model at the start of the list to which all of the key paths apply. In other words, this line: ```swift insert.column(of: FooModel.self, \.$field1, \.$field2, \.$field3) ``` is exactly the same as this line: ```swift insert.columns(\FooModel.$field1, \FooModel.$field2, \FooModel.$field3) ``` Which style you prefer when dealing with lots of columns from the same model is up to you; there is no difference in behavior or performance. --------- Co-authored-by: Paul Toffoloni <[email protected]>
1 parent 9619f0f commit c863124

File tree

6 files changed

+163
-21
lines changed

6 files changed

+163
-21
lines changed

.github/workflows/test.yml

Lines changed: 6 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ jobs:
1515
swift:
1616
- swift:6.0-noble
1717
- swift:6.1-noble
18+
- swift:6.2-noble
1819
runs-on: ubuntu-latest
1920
container: ${{ matrix.swift }}
2021
steps:
@@ -24,9 +25,9 @@ jobs:
2425
uses: actions/checkout@v5
2526
- name: Run unit tests
2627
env:
27-
TRAITS_FLAG: ${{ matrix.swift == 'swift:6.1-noble' && '--enable-all-traits' || '' }}
28+
TRAITS_FLAG: ${{ matrix.swift != 'swift:6.0-noble' && '--enable-all-traits' || '' }}
2829
run: |
29-
swift test --enable-code-coverage -Xswiftc -warnings-as-errors ${TRAITS_FLAG}
30+
swift test --enable-code-coverage --explicit-target-dependency-import-check error -Xswiftc -require-explicit-sendable -Xswiftc -warnings-as-errors ${TRAITS_FLAG}
3031
- name: Upload coverage data
3132
uses: vapor/[email protected]
3233
with:
@@ -40,6 +41,8 @@ jobs:
4041
include:
4142
- macos-version: macos-15
4243
xcode-version: latest-stable
44+
- macos-version: macos-26
45+
xcode-version: latest-stable
4346
runs-on: ${{ matrix.macos-version }}
4447
steps:
4548
- name: Select appropriate Xcode version
@@ -50,29 +53,11 @@ jobs:
5053
uses: actions/checkout@v5
5154
- name: Run unit tests
5255
run: |
53-
swift test --enable-code-coverage -Xswiftc -warnings-as-errors --enable-all-traits
56+
swift test --enable-code-coverage --explicit-target-dependency-import-check error -Xswiftc -require-explicit-sendable -Xswiftc -warnings-as-errors --enable-all-traits
5457
- name: Upload coverage data
5558
uses: vapor/[email protected]
5659
with:
5760
codecov_token: ${{ secrets.CODECOV_TOKEN || '' }}
5861

5962
# Can't test on Windows, uses NIO
6063
# Can't test Musl because of FluentBenchmarks and SQLKitBenchmarks
61-
62-
#android-unit:
63-
# if: ${{ !(github.event.pull_request.draft || false) }}
64-
# strategy:
65-
# fail-fast: false
66-
# matrix:
67-
# swift-version:
68-
# - 6.1
69-
# runs-on: ubuntu-latest
70-
# timeout-minutes: 60
71-
# steps:
72-
# - name: Check out code
73-
# uses: actions/checkout@v4
74-
# - name: Run unit tests
75-
# uses: skiptools/swift-android-action@v2
76-
# with:
77-
# swift-version: ${{ matrix.swift-version }}
78-
# swift-test-flags: --enable-all-traits

Sources/SQLKitExtras/FluentSQLKitExtras/Builders/SQLInsertBuilder+FluentKeypaths.swift

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,25 @@ extension SQLInsertBuilder {
1919
return self
2020
}
2121

22+
/// Allow specifying a set of unqualified column names for an insert builder using Fluent model keypaths which all
23+
/// belong to the same model. This is identical to ``columns<each Schema, each QueryAddressableProperty>(_: repeat...)``,
24+
/// except that on that method, each KeyPath can refer to a different Schema, forcing the caller to specify the root
25+
/// type on all of them. However, it is a very common use case to specify many keypaths from the same model in a row,
26+
/// e.g., `.columns(\MyModel.$foo, \MyModel.$bar, \MyModel.$baz, \MyModel.$bam)`. This quickly becomes quite tedious.
27+
/// By contrast, this method accepts only a single `Schema` type, and all KeyPaths are assumed to refer to it, allowing
28+
/// the previous example to be written as `.columns(of: MyModel.self, \.$bar, \.$baz, \.$bam)`. As with all other
29+
/// `.columns()` methods of `SQLInsertBuilder`, this method _replaces_ all existing columns.
30+
@discardableResult
31+
public func columns<S: Schema, each P: QueryAddressableProperty>(
32+
of: S.Type, _ keypaths: repeat KeyPath<S, each P>
33+
) -> Self {
34+
self.insert.columns = []
35+
for keypath in repeat each keypaths {
36+
self.insert.columns.append(.identifier(keypath))
37+
}
38+
return self
39+
}
40+
2241
/// Allow specifying a column or columns for ignoring insert conflicts using Fluent model keypaths.
2342
@discardableResult
2443
public func ignoringConflicts<each F: Fields, each P: QueryAddressableProperty>(with keypaths: repeat KeyPath<each F, each P>) -> Self {

Sources/SQLKitExtras/FluentSQLKitExtras/Builders/SQLUnqualifiedColumnListBuilder+FluentKeypaths.swift

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,5 +23,22 @@ extension SQLUnqualifiedColumnListBuilder {
2323
repeat _ = self.column(each keypaths)
2424
return self
2525
}
26+
27+
/// Despite the name of the builder protocol, this method specifies a variable number of _fully qualified_ columns
28+
/// using Fluent model keypaths. To specify _unqualified_ columns with keypaths, consider using
29+
/// `SQLUnqualifiedColumnListBuilder.column(.identifier(\Model.$property))`. This is identical to
30+
/// ``columns<each Schema, each QueryAddressableProperty>(_: repeat...)``, except that on that method, each KeyPath
31+
/// can refer to a different Schema, forcing the caller to specify the root type on all of them. However, it is a very
32+
/// common use case to specify many keypaths from the same model in a row, e.g.,
33+
/// `.columns(\MyModel.$foo, \MyModel.$bar, \MyModel.$baz, \MyModel.$bam)`. This quickly becomes quite tedious. By
34+
/// contrast, this method accepts only a single `Schema` type, and all KeyPaths are assumed to refer to it, allowing
35+
/// the previous example to be written as `.columns(of: MyModel.self, \.$bar, \.$baz, \.$bam)`.
36+
@discardableResult
37+
public func columns<S: Schema, each P: QueryAddressableProperty>(
38+
of: S.Type, _ keypaths: repeat KeyPath<S, each P>
39+
) -> Self {
40+
repeat _ = self.column(each keypaths)
41+
return self
42+
}
2643
}
2744
#endif

Sources/SQLKitExtras/FluentSQLKitExtras/SQLQueryFetcher+FluentKeypaths.swift

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,42 @@ extension SQLQueryFetcher {
4040
}
4141
}
4242

43+
/// For each keypath in an arbitrary list of Fluent model keypaths, decodes the appropriate column from the result
44+
/// row (if any), returning the combined results as a tuple where the types of each item of the tuple are inferred
45+
/// from the property referenced by the corresponding keypath. If the query produced no results, `nil` is returned.
46+
/// This is identical to ``first<each Schema, each QueryAddressableProperty>(decodingColumns: repeat...)``, except
47+
/// that on that method, each KeyPath can refer to a different Schema, forcing the caller to specify the root type on
48+
/// all of them. However, it is a very common use case to specify many keypaths from the same model in a row, e.g.,
49+
/// `.first(decodingColumns: \MyModel.$foo, \MyModel.$bar, \MyModel.$baz, \MyModel.$bam)`. This quickly becomes quite
50+
/// tedious. By contrast, this method accepts only a single `Schema` type, and all KeyPaths are assumed to refer to
51+
/// it, allowing the previous example to be written as `.first(decodingColumnsOf: MyModel.self, \.$bar, \.$baz, \.$bam)`.
52+
///
53+
/// Example:
54+
///
55+
/// ```swift
56+
/// final class MyModel: FluentKit.Model, @unchecked Sendable {
57+
/// @ID(custom: .id) var id: Int?
58+
/// @Field(key: "field1") var field1: String
59+
/// @Parent(key: "parent_id") var parent: ParentModel
60+
/// @Enum(key: "field2") var field2: SomeEnum
61+
/// init() {}
62+
/// }
63+
///
64+
/// let tuple/*(id, field1, parentId, field2)*/ = try await sqlDatabase.select()
65+
/// .columns(\MyModel.$id, \MyModel.$field1, \MyModel.$parent, \MyModel.$field2)
66+
/// .from(MyModel.self)
67+
/// .first(decodingColumns: \MyModel.$id, \MyModel$field1, \MyModel.$parent, \MyModel.$field2)
68+
///
69+
/// // type(of: tuple) == (Int, String, ParentModel.IDValue, SomeEnum).self
70+
/// ```
71+
public func first<S: Schema, each P: QueryAddressableProperty>(
72+
decodingColumnsOf: S.Type, _ keypaths: repeat KeyPath<S, each P>
73+
) async throws -> (repeat (each P).QueryablePropertyType.Value)? {
74+
try await self.first().map {
75+
try $0.decode(columnsOf: S.self, repeat each keypaths)
76+
}
77+
}
78+
4379
/// Allow specifying a Fluent model keypath as a column name when decoding multiple query fetcher results.
4480
public func all<M: Schema, P: QueryAddressableProperty>(
4581
decodingColumn keypath: KeyPath<M, P>
@@ -76,5 +112,41 @@ extension SQLQueryFetcher {
76112
try $0.decode(columns: repeat each keypaths)
77113
}
78114
}
115+
116+
/// For each keypath in an arbitrary list of Fluent model keypaths, decodes the appropriate column from each result
117+
/// row, returning the combined results as an array of tuples where the types of each item of the tuple are inferred
118+
/// from the property referenced by the corresponding keypath. This is identical to
119+
/// ``all<each Schema, each QueryAddressableProperty>(decodingColumns: repeat...)``, except that on that method, each
120+
/// KeyPath can refer to a different Schema, forcing the caller to specify the root type on all of them. However, it is
121+
/// a very common use case to specify many keypaths from the same model in a row, e.g.,
122+
/// `.all(decodingColumns: \MyModel.$foo, \MyModel.$bar, \MyModel.$baz, \MyModel.$bam)`. This quickly becomes quite
123+
/// tedious. By contrast, this method accepts only a single `Schema` type, and all KeyPaths are assumed to refer to
124+
/// it, allowing the previous example to be written as `.all(decodingColumnsOf: MyModel.self, \.$bar, \.$baz, \.$bam)`.
125+
///
126+
/// Example:
127+
///
128+
/// ```swift
129+
/// final class MyModel: FluentKit.Model, @unchecked Sendable {
130+
/// @ID(custom: .id) var id: Int?
131+
/// @Field(key: "field1") var field1: String
132+
/// @Parent(key: "parent_id") var parent: ParentModel
133+
/// @Enum(key: "field2") var field2: SomeEnum
134+
/// init() {}
135+
/// }
136+
///
137+
/// let tuples = try await sqlDatabase.select()
138+
/// .columns(\MyModel.$id, \MyModel.$field1, \MyModel.$parent, \MyModel.$field2)
139+
/// .from(MyModel.self)
140+
/// .all(decodingColumns: \MyModel.$id, \MyModel$field1, \MyModel.$parent, \MyModel.$field2)
141+
///
142+
/// // type(of: tuples) == Array<(Int, String, ParentModel.IDValue, SomeEnum)>.self
143+
/// ```
144+
public func all<S: Schema, each P: QueryAddressableProperty>(
145+
decodingColumnsOf: S.Type, _ keypaths: repeat KeyPath<S, each P>
146+
) async throws -> [(repeat (each P).QueryablePropertyType.Value)] {
147+
try await self.all().map {
148+
try $0.decode(columnsOf: S.self, repeat each keypaths)
149+
}
150+
}
79151
}
80152
#endif

Sources/SQLKitExtras/FluentSQLKitExtras/SQLRow+FluentKeypaths.swift

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,5 +57,44 @@ extension SQLRow {
5757
) throws -> (repeat (each P).QueryablePropertyType.Value) {
5858
(repeat try self.decode(column: each keypaths))
5959
}
60+
61+
/// For each keypath in an arbitrary list of Fluent model keypaths, decode the appropriate column from the
62+
/// `SQLRow`, returning the combined results as a tuple where the types of each item of the tuple are inferred
63+
/// from the property referenced by the corresponding keypath. This is identical to
64+
/// ``decode<each Schema, each QueryAddressableProperty>(decodingColumns: repeat...)``, except that on that method,
65+
/// each KeyPath can refer to a different Schema, forcing the caller to specify the root type on all of them. However,
66+
/// it is a very common use case to specify many keypaths from the same model in a row, e.g.,
67+
/// `.decode(columns: \MyModel.$foo, \MyModel.$bar, \MyModel.$baz, \MyModel.$bam)`. This quickly becomes quite
68+
/// tedious. By contrast, this method accepts only a single `Schema` type, and all KeyPaths are assumed to refer to
69+
/// it, allowing the previous example to be written as `.decode(columnsOf: MyModel.self, \.$bar, \.$baz, \.$bam)`.
70+
///
71+
/// Example:
72+
///
73+
/// ```swift
74+
/// final class MyModel: FluentKit.Model, @unchecked Sendable {
75+
/// @ID(custom: .id) var id: Int?
76+
/// @Field(key: "field1") var field1: String
77+
/// @Parent(key: "parent_id") var parent: ParentModel
78+
/// @Enum(key: "field2") var field2: SomeEnum
79+
/// init() {}
80+
/// }
81+
///
82+
/// let rows = try await sqlDatabase.select()
83+
/// .columns(\MyModel.$id, \MyModel.$field1, \MyModel.$parent, \MyModel.$field2)
84+
/// .from(MyModel.self)
85+
/// .all()
86+
///
87+
/// for row in rows {
88+
/// let tuple/*(id, field1, parentId, field2)*/ = try row.decode(columns:
89+
/// \MyModel.$id, \MyModel$field1, \MyModel.$parent, \MyModel.$field2
90+
/// )
91+
/// // type(of: tuple) == (Int, String, ParentModel.IDValue, SomeEnum).self
92+
/// }
93+
/// ```
94+
public func decode<S: Schema, each P: QueryAddressableProperty>(
95+
columnsOf: S.Type, _ keypaths: repeat KeyPath<S, each P>
96+
) throws -> (repeat (each P).QueryablePropertyType.Value) {
97+
(repeat try self.decode(column: each keypaths))
98+
}
6099
}
61100
#endif

Tests/SQLKitExtrasTests/FluentSQLKitExtrasTests.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ struct FluentSQLKitExtrasTests {
8181
#expect(throws: Never.self) { () throws in #expect(try ([:] as ThinSQLRow).decodeNil(column: \FooModel.$id)) }
8282
#expect(throws: Never.self) { () throws in #expect(try (["field": "foo"] as ThinSQLRow).decode(column: \FooModel.$field) == "foo") }
8383
#expect(throws: Never.self) { () throws in #expect(try (["field": "", "parent_id": 1] as ThinSQLRow).decode(columns: \FooModel.$field, \FooModel.$parent) == ("", 1)) }
84+
#expect(throws: Never.self) { () throws in #expect(try (["field": "", "parent_id": 1] as ThinSQLRow).decode(columnsOf: FooModel.self, \.$field, \.$parent) == ("", 1)) }
8485
}
8586

8687
@Test
@@ -92,13 +93,20 @@ struct FluentSQLKitExtrasTests {
9293
#expect(try await MockSQLDatabase(resultSet: [["field": "a", "parent_id": 1]]).select()
9394
.first(decodingColumns: \FooModel.$field, \FooModel.$parent) ?? ("", 0) == ("a", 1))
9495
}
96+
await #expect(throws: Never.self) { () async throws in
97+
#expect(try await MockSQLDatabase(resultSet: [["field": "a", "parent_id": 1]]).select()
98+
.first(decodingColumnsOf: FooModel.self, \.$field, \.$parent) ?? ("", 0) == ("a", 1))
99+
}
95100

96101
await #expect(throws: Never.self) { () async throws in
97102
#expect(try await MockSQLDatabase(resultSet: [["field": "a"], ["field": "b"]]).select().all(decodingColumn: \FooModel.$field) == ["a", "b"])
98103
}
99104
await #expect(throws: Never.self) { () async throws in
100105
#expect(try await MockSQLDatabase(resultSet: [["field": "a"], ["field": "b"]]).select().all(decodingColumns: \FooModel.$field) == ["a", "b"])
101106
}
107+
await #expect(throws: Never.self) { () async throws in
108+
#expect(try await MockSQLDatabase(resultSet: [["field": "a"], ["field": "b"]]).select().all(decodingColumnsOf: FooModel.self, \.$field) == ["a", "b"])
109+
}
102110
}
103111
}
104112

@@ -164,6 +172,7 @@ struct FluentSQLKitExtrasTests {
164172
@Test
165173
func insertBuilderExtensions() {
166174
#expect(serialize(MockSQLDatabase().insert(into: FooModel.self).columns(\FooModel.$field, \FooModel.$parent)) == #"INSERT INTO "foos" ("field", "parent_id")"#)
175+
#expect(serialize(MockSQLDatabase().insert(into: FooModel.self).columns(of: FooModel.self, \.$field, \.$parent)) == #"INSERT INTO "foos" ("field", "parent_id")"#)
167176
#expect(serialize(MockSQLDatabase().insert(into: FooModel.self).ignoringConflicts(with: \FooModel.$field)) == #"INSERT INTO "foos" () ON CONFLICT ("field") DO NOTHING"#)
168177
#expect(serialize(MockSQLDatabase().insert(into: FooModel.self).onConflict(with: \FooModel.$field, do: { $0 })) == #"INSERT INTO "foos" () ON CONFLICT ("field") DO UPDATE SET"#)
169178
}
@@ -251,6 +260,7 @@ struct FluentSQLKitExtrasTests {
251260
func unqualifiedColumnListBuilderExtensions() {
252261
#expect(serialize(selectBuilder().column(\FooModel.$field)) == #"SELECT "foos"."field""#)
253262
#expect(serialize(selectBuilder().columns(\FooModel.$field, \FooModel.$id)) == #"SELECT "foos"."field", "foos"."id""#)
263+
#expect(serialize(selectBuilder().columns(of: FooModel.self, \.$field, \.$id)) == #"SELECT "foos"."field", "foos"."id""#)
254264
}
255265

256266
@Test

0 commit comments

Comments
 (0)