|
| 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