Skip to content

Commit 43441e4

Browse files
SharingGRDBTestSupport with assertQuery (#133)
* create SharingGRDBTestSupport with assertQuery from StructuredQueries * rework the api to assertQuery(includeSQL) * remove execute arg, using the defaultDatabase instead * add database parameter, defaulting to dependency * wrap sql pretty-printing in DEBUG --------- Co-authored-by: Stephen Celis <[email protected]>
1 parent cba9a45 commit 43441e4

File tree

3 files changed

+402
-0
lines changed

3 files changed

+402
-0
lines changed

Package.swift

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ let package = Package(
1919
name: "SharingGRDBCore",
2020
targets: ["SharingGRDBCore"]
2121
),
22+
.library(
23+
name: "SharingGRDBTestSupport",
24+
targets: ["SharingGRDBTestSupport"]
25+
),
2226
.library(
2327
name: "StructuredQueriesGRDB",
2428
targets: ["StructuredQueriesGRDB"]
@@ -29,10 +33,12 @@ let package = Package(
2933
),
3034
],
3135
dependencies: [
36+
.package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "1.3.3"),
3237
.package(url: "https://github.com/groue/GRDB.swift", from: "7.4.0"),
3338
.package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.9.0"),
3439
.package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "1.5.0"),
3540
.package(url: "https://github.com/pointfreeco/swift-sharing", from: "2.3.0"),
41+
.package(url: "https://github.com/pointfreeco/swift-snapshot-testing", from: "1.18.4"),
3642
.package(url: "https://github.com/pointfreeco/swift-structured-queries", from: "0.13.0"),
3743
],
3844
targets: [
@@ -55,10 +61,20 @@ let package = Package(
5561
name: "SharingGRDBTests",
5662
dependencies: [
5763
"SharingGRDB",
64+
"SharingGRDBTestSupport",
5865
.product(name: "DependenciesTestSupport", package: "swift-dependencies"),
5966
.product(name: "StructuredQueries", package: "swift-structured-queries"),
6067
]
6168
),
69+
.target(
70+
name: "SharingGRDBTestSupport",
71+
dependencies: [
72+
"SharingGRDB",
73+
.product(name: "CustomDump", package: "swift-custom-dump"),
74+
.product(name: "InlineSnapshotTesting", package: "swift-snapshot-testing"),
75+
.product(name: "StructuredQueriesTestSupport", package: "swift-structured-queries"),
76+
]
77+
),
6278
.target(
6379
name: "StructuredQueriesGRDBCore",
6480
dependencies: [
Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
import CustomDump
2+
import Dependencies
3+
import Foundation
4+
import GRDB
5+
import InlineSnapshotTesting
6+
import StructuredQueriesCore
7+
import StructuredQueriesGRDBCore
8+
import StructuredQueriesTestSupport
9+
10+
/// An end-to-end snapshot testing helper for database content.
11+
///
12+
/// This helper can be used to generate snapshots of both the given query and the results of the
13+
/// query decoded back into Swift.
14+
///
15+
/// ```swift
16+
/// assertQuery(
17+
/// Reminder.select(\.title).order(by: \.title)
18+
/// } results: {
19+
/// """
20+
/// ┌────────────────────────────┐
21+
/// │ "Buy concert tickets" │
22+
/// │ "Call accountant" │
23+
/// │ "Doctor appointment" │
24+
/// │ "Get laundry" │
25+
/// │ "Groceries" │
26+
/// │ "Haircut" │
27+
/// │ "Pick up kids from school" │
28+
/// │ "Send weekly emails" │
29+
/// │ "Take a walk" │
30+
/// │ "Take out trash" │
31+
/// └────────────────────────────┘
32+
/// """
33+
/// }
34+
/// ```
35+
///
36+
/// - Parameters:
37+
/// - includeSQL: Whether to snapshot the SQL fragment in addition to the results.
38+
/// - query: A statement.
39+
/// - database: The database to read from. A value of `nil` will use
40+
/// `@Dependency(\.defaultDatabase)`.
41+
/// - sql: A snapshot of the SQL produced by the statement.
42+
/// - results: A snapshot of the results.
43+
/// to `1` for invoking this helper directly, but if you write a wrapper function that automates
44+
/// the `execute` trailing closure, you should pass `0` instead.
45+
/// - fileID: The source `#fileID` associated with the assertion.
46+
/// - filePath: The source `#filePath` associated with the assertion.
47+
/// - function: The source `#function` associated with the assertion
48+
/// - line: The source `#line` associated with the assertion.
49+
/// - column: The source `#column` associated with the assertion.
50+
@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
51+
@_disfavoredOverload
52+
public func assertQuery<each V: QueryRepresentable, S: StructuredQueriesCore.Statement<(repeat each V)>>(
53+
includeSQL: Bool = false,
54+
_ query: S,
55+
database: (any DatabaseReader)? = nil,
56+
sql: (() -> String)? = nil,
57+
results: (() -> String)? = nil,
58+
fileID: StaticString = #fileID,
59+
filePath: StaticString = #filePath,
60+
function: StaticString = #function,
61+
line: UInt = #line,
62+
column: UInt = #column
63+
) {
64+
if includeSQL {
65+
assertInlineSnapshot(
66+
of: query,
67+
as: .sql,
68+
message: "Query did not match",
69+
syntaxDescriptor: InlineSnapshotSyntaxDescriptor(
70+
trailingClosureLabel: "sql",
71+
trailingClosureOffset: 0
72+
),
73+
matches: sql,
74+
fileID: fileID,
75+
file: filePath,
76+
function: function,
77+
line: line,
78+
column: column
79+
)
80+
}
81+
do {
82+
@Dependency(\.defaultDatabase) var defaultDatabase
83+
let rows = try (database ?? defaultDatabase).read { try query.fetchAll($0) }
84+
var table = ""
85+
printTable(rows, to: &table)
86+
if !table.isEmpty {
87+
assertInlineSnapshot(
88+
of: table,
89+
as: .lines,
90+
message: "Results did not match",
91+
syntaxDescriptor: InlineSnapshotSyntaxDescriptor(
92+
trailingClosureLabel: "results",
93+
trailingClosureOffset: includeSQL ? 1 : 0
94+
),
95+
matches: includeSQL ? results : sql,
96+
fileID: fileID,
97+
file: filePath,
98+
function: function,
99+
line: line,
100+
column: column
101+
)
102+
} else if results != nil {
103+
assertInlineSnapshot(
104+
of: table,
105+
as: .lines,
106+
message: "Results expected to be empty",
107+
syntaxDescriptor: InlineSnapshotSyntaxDescriptor(
108+
trailingClosureLabel: "results",
109+
trailingClosureOffset: includeSQL ? 1 : 0
110+
),
111+
matches: includeSQL ? results : sql,
112+
fileID: fileID,
113+
file: filePath,
114+
function: function,
115+
line: line,
116+
column: column
117+
)
118+
}
119+
} catch {
120+
assertInlineSnapshot(
121+
of: error.localizedDescription,
122+
as: .lines,
123+
message: "Results did not match",
124+
syntaxDescriptor: InlineSnapshotSyntaxDescriptor(
125+
trailingClosureLabel: "results",
126+
trailingClosureOffset: includeSQL ? 1 : 0
127+
),
128+
matches: includeSQL ? results : sql,
129+
fileID: fileID,
130+
file: filePath,
131+
function: function,
132+
line: line,
133+
column: column
134+
)
135+
}
136+
}
137+
138+
/// An end-to-end snapshot testing helper for database content.
139+
///
140+
/// This helper can be used to generate snapshots of both the given query and the results of the
141+
/// query decoded back into Swift.
142+
///
143+
/// ```swift
144+
/// assertQuery(
145+
/// Reminder.select(\.title).order(by: \.title)
146+
/// } results: {
147+
/// """
148+
/// ┌────────────────────────────┐
149+
/// │ "Buy concert tickets" │
150+
/// │ "Call accountant" │
151+
/// │ "Doctor appointment" │
152+
/// │ "Get laundry" │
153+
/// │ "Groceries" │
154+
/// │ "Haircut" │
155+
/// │ "Pick up kids from school" │
156+
/// │ "Send weekly emails" │
157+
/// │ "Take a walk" │
158+
/// │ "Take out trash" │
159+
/// └────────────────────────────┘
160+
/// """
161+
/// }
162+
/// ```
163+
///
164+
/// - Parameters:
165+
/// - includeSQL: Whether to snapshot the SQL fragment in addition to the results.
166+
/// - query: A statement.
167+
/// - sql: A snapshot of the SQL produced by the statement.
168+
/// - database: The database to read from. A value of `nil` will use
169+
/// `@Dependency(\.defaultDatabase)`.
170+
/// - results: A snapshot of the results.
171+
/// to `1` for invoking this helper directly, but if you write a wrapper function that automates
172+
/// the `execute` trailing closure, you should pass `0` instead.
173+
/// - fileID: The source `#fileID` associated with the assertion.
174+
/// - filePath: The source `#filePath` associated with the assertion.
175+
/// - function: The source `#function` associated with the assertion
176+
/// - line: The source `#line` associated with the assertion.
177+
/// - column: The source `#column` associated with the assertion.
178+
@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
179+
public func assertQuery<S: SelectStatement, each J: StructuredQueriesCore.Table>(
180+
includeSQL: Bool = false,
181+
_ query: S,
182+
database: (any DatabaseReader)? = nil,
183+
sql: (() -> String)? = nil,
184+
results: (() -> String)? = nil,
185+
fileID: StaticString = #fileID,
186+
filePath: StaticString = #filePath,
187+
function: StaticString = #function,
188+
line: UInt = #line,
189+
column: UInt = #column
190+
) where S.QueryValue == (), S.Joins == (repeat each J) {
191+
assertQuery(
192+
includeSQL: includeSQL,
193+
query.selectStar(),
194+
database: database,
195+
sql: sql,
196+
results: results,
197+
fileID: fileID,
198+
filePath: filePath,
199+
function: function,
200+
line: line,
201+
column: column
202+
)
203+
}
204+
205+
private func printTable<each C>(_ rows: [(repeat each C)], to output: inout some TextOutputStream) {
206+
var maxColumnSpan: [Int] = []
207+
var hasMultiLineRows = false
208+
for _ in repeat (each C).self {
209+
maxColumnSpan.append(0)
210+
}
211+
var table: [([[Substring]], maxRowSpan: Int)] = []
212+
for row in rows {
213+
var columns: [[Substring]] = []
214+
var index = 0
215+
var maxRowSpan = 0
216+
for column in repeat each row {
217+
defer { index += 1 }
218+
var cell = ""
219+
customDump(column, to: &cell)
220+
let lines = cell.split(separator: "\n")
221+
hasMultiLineRows = hasMultiLineRows || lines.count > 1
222+
maxRowSpan = max(maxRowSpan, lines.count)
223+
maxColumnSpan[index] = max(maxColumnSpan[index], lines.map(\.count).max() ?? 0)
224+
columns.append(lines)
225+
}
226+
table.append((columns, maxRowSpan))
227+
}
228+
guard !table.isEmpty else { return }
229+
output.write("┌─")
230+
output.write(
231+
maxColumnSpan
232+
.map { String(repeating: "", count: $0) }
233+
.joined(separator: "─┬─")
234+
)
235+
output.write("─┐\n")
236+
for (offset, rowAndMaxRowSpan) in table.enumerated() {
237+
let (row, maxRowSpan) = rowAndMaxRowSpan
238+
for rowOffset in 0..<maxRowSpan {
239+
output.write("")
240+
var line: [String] = []
241+
for (columns, maxColumnSpan) in zip(row, maxColumnSpan) {
242+
if columns.count <= rowOffset {
243+
line.append(String(repeating: " ", count: maxColumnSpan))
244+
} else {
245+
line.append(
246+
columns[rowOffset]
247+
+ String(repeating: " ", count: maxColumnSpan - columns[rowOffset].count)
248+
)
249+
}
250+
}
251+
output.write(line.joined(separator: ""))
252+
output.write("\n")
253+
}
254+
if hasMultiLineRows, offset != table.count - 1 {
255+
output.write("├─")
256+
output.write(
257+
maxColumnSpan
258+
.map { String(repeating: "", count: $0) }
259+
.joined(separator: "─┼─")
260+
)
261+
output.write("─┤\n")
262+
}
263+
}
264+
output.write("└─")
265+
output.write(
266+
maxColumnSpan
267+
.map { String(repeating: "", count: $0) }
268+
.joined(separator: "─┴─")
269+
)
270+
output.write("─┘")
271+
}

0 commit comments

Comments
 (0)