diff --git a/Package.swift b/Package.swift index 33ddf08c..d389be9e 100644 --- a/Package.swift +++ b/Package.swift @@ -19,6 +19,10 @@ let package = Package( name: "SharingGRDBCore", targets: ["SharingGRDBCore"] ), + .library( + name: "SharingGRDBTestSupport", + targets: ["SharingGRDBTestSupport"] + ), .library( name: "StructuredQueriesGRDB", targets: ["StructuredQueriesGRDB"] @@ -29,10 +33,12 @@ let package = Package( ), ], dependencies: [ + .package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "1.3.3"), .package(url: "https://github.com/groue/GRDB.swift", from: "7.4.0"), .package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.9.0"), .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "1.5.0"), .package(url: "https://github.com/pointfreeco/swift-sharing", from: "2.3.0"), + .package(url: "https://github.com/pointfreeco/swift-snapshot-testing", from: "1.18.4"), .package(url: "https://github.com/pointfreeco/swift-structured-queries", from: "0.13.0"), ], targets: [ @@ -55,10 +61,20 @@ let package = Package( name: "SharingGRDBTests", dependencies: [ "SharingGRDB", + "SharingGRDBTestSupport", .product(name: "DependenciesTestSupport", package: "swift-dependencies"), .product(name: "StructuredQueries", package: "swift-structured-queries"), ] ), + .target( + name: "SharingGRDBTestSupport", + dependencies: [ + "SharingGRDB", + .product(name: "CustomDump", package: "swift-custom-dump"), + .product(name: "InlineSnapshotTesting", package: "swift-snapshot-testing"), + .product(name: "StructuredQueriesTestSupport", package: "swift-structured-queries"), + ] + ), .target( name: "StructuredQueriesGRDBCore", dependencies: [ diff --git a/Sources/SharingGRDBTestSupport/AssertQuery.swift b/Sources/SharingGRDBTestSupport/AssertQuery.swift new file mode 100644 index 00000000..9ec1b2b8 --- /dev/null +++ b/Sources/SharingGRDBTestSupport/AssertQuery.swift @@ -0,0 +1,271 @@ +import CustomDump +import Dependencies +import Foundation +import GRDB +import InlineSnapshotTesting +import StructuredQueriesCore +import StructuredQueriesGRDBCore +import StructuredQueriesTestSupport + +/// An end-to-end snapshot testing helper for database content. +/// +/// This helper can be used to generate snapshots of both the given query and the results of the +/// query decoded back into Swift. +/// +/// ```swift +/// assertQuery( +/// Reminder.select(\.title).order(by: \.title) +/// } results: { +/// """ +/// ┌────────────────────────────┐ +/// │ "Buy concert tickets" │ +/// │ "Call accountant" │ +/// │ "Doctor appointment" │ +/// │ "Get laundry" │ +/// │ "Groceries" │ +/// │ "Haircut" │ +/// │ "Pick up kids from school" │ +/// │ "Send weekly emails" │ +/// │ "Take a walk" │ +/// │ "Take out trash" │ +/// └────────────────────────────┘ +/// """ +/// } +/// ``` +/// +/// - Parameters: +/// - includeSQL: Whether to snapshot the SQL fragment in addition to the results. +/// - query: A statement. +/// - database: The database to read from. A value of `nil` will use +/// `@Dependency(\.defaultDatabase)`. +/// - sql: A snapshot of the SQL produced by the statement. +/// - results: A snapshot of the results. +/// to `1` for invoking this helper directly, but if you write a wrapper function that automates +/// the `execute` trailing closure, you should pass `0` instead. +/// - fileID: The source `#fileID` associated with the assertion. +/// - filePath: The source `#filePath` associated with the assertion. +/// - function: The source `#function` associated with the assertion +/// - line: The source `#line` associated with the assertion. +/// - column: The source `#column` associated with the assertion. +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +@_disfavoredOverload +public func assertQuery>( + includeSQL: Bool = false, + _ query: S, + database: (any DatabaseReader)? = nil, + sql: (() -> String)? = nil, + results: (() -> String)? = nil, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + function: StaticString = #function, + line: UInt = #line, + column: UInt = #column +) { + if includeSQL { + assertInlineSnapshot( + of: query, + as: .sql, + message: "Query did not match", + syntaxDescriptor: InlineSnapshotSyntaxDescriptor( + trailingClosureLabel: "sql", + trailingClosureOffset: 0 + ), + matches: sql, + fileID: fileID, + file: filePath, + function: function, + line: line, + column: column + ) + } + do { + @Dependency(\.defaultDatabase) var defaultDatabase + let rows = try (database ?? defaultDatabase).read { try query.fetchAll($0) } + var table = "" + printTable(rows, to: &table) + if !table.isEmpty { + assertInlineSnapshot( + of: table, + as: .lines, + message: "Results did not match", + syntaxDescriptor: InlineSnapshotSyntaxDescriptor( + trailingClosureLabel: "results", + trailingClosureOffset: includeSQL ? 1 : 0 + ), + matches: includeSQL ? results : sql, + fileID: fileID, + file: filePath, + function: function, + line: line, + column: column + ) + } else if results != nil { + assertInlineSnapshot( + of: table, + as: .lines, + message: "Results expected to be empty", + syntaxDescriptor: InlineSnapshotSyntaxDescriptor( + trailingClosureLabel: "results", + trailingClosureOffset: includeSQL ? 1 : 0 + ), + matches: includeSQL ? results : sql, + fileID: fileID, + file: filePath, + function: function, + line: line, + column: column + ) + } + } catch { + assertInlineSnapshot( + of: error.localizedDescription, + as: .lines, + message: "Results did not match", + syntaxDescriptor: InlineSnapshotSyntaxDescriptor( + trailingClosureLabel: "results", + trailingClosureOffset: includeSQL ? 1 : 0 + ), + matches: includeSQL ? results : sql, + fileID: fileID, + file: filePath, + function: function, + line: line, + column: column + ) + } +} + +/// An end-to-end snapshot testing helper for database content. +/// +/// This helper can be used to generate snapshots of both the given query and the results of the +/// query decoded back into Swift. +/// +/// ```swift +/// assertQuery( +/// Reminder.select(\.title).order(by: \.title) +/// } results: { +/// """ +/// ┌────────────────────────────┐ +/// │ "Buy concert tickets" │ +/// │ "Call accountant" │ +/// │ "Doctor appointment" │ +/// │ "Get laundry" │ +/// │ "Groceries" │ +/// │ "Haircut" │ +/// │ "Pick up kids from school" │ +/// │ "Send weekly emails" │ +/// │ "Take a walk" │ +/// │ "Take out trash" │ +/// └────────────────────────────┘ +/// """ +/// } +/// ``` +/// +/// - Parameters: +/// - includeSQL: Whether to snapshot the SQL fragment in addition to the results. +/// - query: A statement. +/// - sql: A snapshot of the SQL produced by the statement. +/// - database: The database to read from. A value of `nil` will use +/// `@Dependency(\.defaultDatabase)`. +/// - results: A snapshot of the results. +/// to `1` for invoking this helper directly, but if you write a wrapper function that automates +/// the `execute` trailing closure, you should pass `0` instead. +/// - fileID: The source `#fileID` associated with the assertion. +/// - filePath: The source `#filePath` associated with the assertion. +/// - function: The source `#function` associated with the assertion +/// - line: The source `#line` associated with the assertion. +/// - column: The source `#column` associated with the assertion. +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +public func assertQuery( + includeSQL: Bool = false, + _ query: S, + database: (any DatabaseReader)? = nil, + sql: (() -> String)? = nil, + results: (() -> String)? = nil, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + function: StaticString = #function, + line: UInt = #line, + column: UInt = #column +) where S.QueryValue == (), S.Joins == (repeat each J) { + assertQuery( + includeSQL: includeSQL, + query.selectStar(), + database: database, + sql: sql, + results: results, + fileID: fileID, + filePath: filePath, + function: function, + line: line, + column: column + ) +} + +private func printTable(_ rows: [(repeat each C)], to output: inout some TextOutputStream) { + var maxColumnSpan: [Int] = [] + var hasMultiLineRows = false + for _ in repeat (each C).self { + maxColumnSpan.append(0) + } + var table: [([[Substring]], maxRowSpan: Int)] = [] + for row in rows { + var columns: [[Substring]] = [] + var index = 0 + var maxRowSpan = 0 + for column in repeat each row { + defer { index += 1 } + var cell = "" + customDump(column, to: &cell) + let lines = cell.split(separator: "\n") + hasMultiLineRows = hasMultiLineRows || lines.count > 1 + maxRowSpan = max(maxRowSpan, lines.count) + maxColumnSpan[index] = max(maxColumnSpan[index], lines.map(\.count).max() ?? 0) + columns.append(lines) + } + table.append((columns, maxRowSpan)) + } + guard !table.isEmpty else { return } + output.write("┌─") + output.write( + maxColumnSpan + .map { String(repeating: "─", count: $0) } + .joined(separator: "─┬─") + ) + output.write("─┐\n") + for (offset, rowAndMaxRowSpan) in table.enumerated() { + let (row, maxRowSpan) = rowAndMaxRowSpan + for rowOffset in 0.. DatabaseQueue { + let database = try DatabaseQueue() + try database.write { db in + try #sql( + """ + CREATE TABLE "records" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "date" INTEGER NOT NULL DEFAULT 42 + ) + """ + ) + .execute(db) + for _ in 1...3 { + _ = try Record.insert { Record.Draft() }.execute(db) + } + } + return database + } +}