From 335dcbc343f9a7df8ad12888b1ac6113881dadc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gwendal=20Roue=CC=81?= Date: Tue, 10 Oct 2023 08:24:50 +0200 Subject: [PATCH] List WIP --- GRDB/Dump/DumpFormats/ListDumpFormat.swift | 130 ++++++++ Tests/GRDBTests/DatabaseDumpTests.swift | 309 +++++++++++++++++- Tests/GRDBTests/DatabaseReaderDumpTests.swift | 209 +++++++++++- 3 files changed, 636 insertions(+), 12 deletions(-) create mode 100644 GRDB/Dump/DumpFormats/ListDumpFormat.swift diff --git a/GRDB/Dump/DumpFormats/ListDumpFormat.swift b/GRDB/Dump/DumpFormats/ListDumpFormat.swift new file mode 100644 index 0000000000..ac2a608cad --- /dev/null +++ b/GRDB/Dump/DumpFormats/ListDumpFormat.swift @@ -0,0 +1,130 @@ +import Foundation + +/// A format that prints one line per database row. +/// +/// On each line, database values are separated by a separator (`|` +/// by default). Blob values are interpreted as UTF8 strings. +/// +/// For example: +/// +/// ```swift +/// // Arthur|500 +/// // Barbara|1000 +/// // Craig|200 +/// try db.dumpRequest(Player.all(), format: .debug()) +/// ``` +public struct ListDumpFormat { + /// A boolean value indicating if column labels are printed as the first + /// line of output. + public var header: Bool + + /// The separator between values. + public var separator: String + + /// The string to print for NULL values. + public var nullValue: String + + private var firstRow = true + + /// Creates a `ListDumpFormat`. + /// + /// - Parameters: + /// - header: A boolean value indicating if column labels are printed + /// as the first line of output. + /// - separator: The separator between values. + /// - nullValue: The string to print for NULL values. + public init( + header: Bool = false, + separator: String = "|", + nullValue: String = "") + { + self.header = header + self.separator = separator + self.nullValue = nullValue + } +} + +extension ListDumpFormat: DumpFormat { + public mutating func writeRow( + _ db: Database, + statement: Statement, + to stream: inout some TextOutputStream) + { + if firstRow { + firstRow = false + if header { + stream.writeln(statement.columnNames.joined(separator: separator)) + } + } + + let sqliteStatement = statement.sqliteStatement + var first = true + for index in 0.. String { + switch sqlite3_column_type(sqliteStatement, index) { + case SQLITE_NULL: + return nullValue + + case SQLITE_INTEGER: + return Int64(sqliteStatement: sqliteStatement, index: index).description + + case SQLITE_FLOAT: + return Double(sqliteStatement: sqliteStatement, index: index).description + + case SQLITE_BLOB, SQLITE_TEXT: + return String(sqliteStatement: sqliteStatement, index: index) + + default: + return "" + } + } +} + +extension DumpFormat where Self == ListDumpFormat { + /// A format that prints one line per database row. + /// + /// On each line, database values are separated by a separator (`|` + /// by default). Blob values are interpreted as UTF8 strings. + /// + /// For example: + /// + /// ```swift + /// // Arthur|500 + /// // Barbara|1000 + /// // Craig|200 + /// try db.dumpRequest(Player.all(), format: .debug()) + /// ``` + /// + /// - Parameters: + /// - header: A boolean value indicating if column labels are printed + /// as the first line of output. + /// - separator: The separator between values. + /// - nullValue: The string to print for NULL values. + public static func list( + header: Bool = false, + separator: String = "|", + nullValue: String = "") + -> Self + { + ListDumpFormat(header: header, separator: separator, nullValue: nullValue) + } +} diff --git a/Tests/GRDBTests/DatabaseDumpTests.swift b/Tests/GRDBTests/DatabaseDumpTests.swift index 81a75a6bd7..63ac0a3a23 100644 --- a/Tests/GRDBTests/DatabaseDumpTests.swift +++ b/Tests/GRDBTests/DatabaseDumpTests.swift @@ -318,6 +318,215 @@ final class DatabaseDumpTests: GRDBTestCase { } } + func test_json_custom_encoder() throws { + try makeRugbyDatabase().read { db in + let encoder = JSONDumpFormat.defaultEncoder + encoder.outputFormatting = [.prettyPrinted, .sortedKeys /* ignored */] + let stream = TestStream() + try db.dumpTables(["player"], format: .json(encoder: encoder), to: stream) + XCTAssertEqual(stream.output, """ + [ + { + "id":1, + "teamId":"FRA", + "name":"Antoine Dupond" + }, + { + "id":2, + "teamId":"ENG", + "name":"Owen Farrell" + }, + { + "id":3, + "teamId":null, + "name":"Gwendal Roué" + } + ] + + """) + } + } + + // MARK: - List + + func test_list_value_formatting() throws { + try makeValuesDatabase().read { db in + let stream = TestStream() + try db.dumpSQL("SELECT * FROM value ORDER BY name", format: .list(), to: stream) + XCTAssertEqual(stream.output, """ + blob: ascii apostrophe|['] + blob: ascii double quote|["] + blob: ascii line feed|[ + ] + blob: ascii long|Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi tristique tempor condimentum. Pellentesque pharetra lacus non ante sollicitudin auctor. Vestibulum sit amet mauris vitae urna non luctus. + blob: ascii short|Hello + blob: ascii tab|[\t] + blob: binary long|X'48727307AA611C4F17BAE964AEA8196C77A90ED0135B0DEAB7CFEA7EA547DB8B16F16553E5F094DDA97C1E89223901BC167D240707D8492B7237BE60FEEB59C82A04331475A89AD77455CD6BD6F05A94AABB1E0997552AED8306386E9CD3E74A5694670245D06F9962B7A8B1D25775CFF12EFFEB2FD9FB80197FFF2747E3CAAF977391CE9861C9A56EA010B0BED4F7E7735E986D8655FBE446373C4D345CA8217876362AD1E5B1AEC55415D7264D825F46EFF017C98833F2C3E265491CF021F6BA3F3DEFB7ACFBF7' + blob: binary short|X'06F9962B7A' + blob: empty| + blob: utf8 short|您好🙂 + blob: uuid|69BF8A9C-D9F0-4777-BD11-93451D84CBCF + double: -1.0|-1.0 + double: -inf|-inf + double: 0.0|0.0 + double: 123.45|123.45 + double: inf|inf + double: nan| + integer: -1|-1 + integer: 0|0 + integer: 123|123 + integer: max|9223372036854775807 + integer: min|-9223372036854775808 + null| + text: ascii apostrophe|['] + text: ascii backslash|[\\] + text: ascii double quote|["] + text: ascii line feed|[ + ] + text: ascii long|Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi tristique tempor condimentum. Pellentesque pharetra lacus non ante sollicitudin auctor. Vestibulum sit amet mauris vitae urna non luctus. + text: ascii short|Hello + text: ascii slash|[/] + text: ascii tab|[\t] + text: ascii url|https://github.com/groue/GRDB.swift + text: empty| + text: utf8 short|您好🙂 + + """) + } + } + + func test_list_headers() throws { + try makeRugbyDatabase().read { db in + do { + // Headers on + let stream = TestStream() + try db.dumpSQL("SELECT * FROM player ORDER BY id", format: .list(header: true), to: stream) + XCTAssertEqual(stream.output, """ + id|teamId|name + 1|FRA|Antoine Dupond + 2|ENG|Owen Farrell + 3||Gwendal Roué + + """) + } + do { + // Headers on, no result + let stream = TestStream() + try db.dumpSQL("SELECT * FROM player WHERE 0", format: .list(header: true), to: stream) + XCTAssertEqual(stream.output, "") + } + do { + // Headers off + let stream = TestStream() + try db.dumpSQL("SELECT * FROM player ORDER BY id", format: .list(header: false), to: stream) + XCTAssertEqual(stream.output, """ + 1|FRA|Antoine Dupond + 2|ENG|Owen Farrell + 3||Gwendal Roué + + """) + } + do { + // Headers off, no result + let stream = TestStream() + try db.dumpSQL("SELECT * FROM player WHERE 0", format: .list(header: false), to: stream) + XCTAssertEqual(stream.output, "") + } + } + } + + func test_list_duplicate_columns() throws { + try makeDatabaseQueue().read { db in + let stream = TestStream() + try db.dumpSQL("SELECT 1 AS name, 'foo' AS name", format: .list(header: true), to: stream) + XCTAssertEqual(stream.output, """ + name|name + 1|foo + + """) + } + } + + func test_list_multiple_statements() throws { + try makeDatabaseQueue().write { db in + let stream = TestStream() + try db.dumpSQL( + """ + CREATE TABLE t(a, b); + INSERT INTO t VALUES (1, 'foo'); + INSERT INTO t VALUES (2, 'bar'); + SELECT * FROM t ORDER BY a; + SELECT b FROM t ORDER BY b; + SELECT NULL WHERE NULL; + """, + format: .list(), + to: stream) + XCTAssertEqual(stream.output, """ + 1|foo + 2|bar + bar + foo + + """) + } + } + + func test_list_separator() throws { + try makeRugbyDatabase().read { db in + do { + // Default separator + let stream = TestStream() + try db.dumpSQL("SELECT * FROM player ORDER BY id", format: .list(header: true), to: stream) + XCTAssertEqual(stream.output, """ + id|teamId|name + 1|FRA|Antoine Dupond + 2|ENG|Owen Farrell + 3||Gwendal Roué + + """) + } + do { + // Custom separator + let stream = TestStream() + try db.dumpSQL("SELECT * FROM player ORDER BY id", format: .list(header: true, separator: "---"), to: stream) + XCTAssertEqual(stream.output, """ + id---teamId---name + 1---FRA---Antoine Dupond + 2---ENG---Owen Farrell + 3------Gwendal Roué + + """) + } + } + } + + func test_list_nullValue() throws { + try makeRugbyDatabase().read { db in + do { + // Default null + let stream = TestStream() + try db.dumpSQL("SELECT * FROM player ORDER BY id", format: .list(), to: stream) + XCTAssertEqual(stream.output, """ + 1|FRA|Antoine Dupond + 2|ENG|Owen Farrell + 3||Gwendal Roué + + """) + } + do { + // Custom null + let stream = TestStream() + try db.dumpSQL("SELECT * FROM player ORDER BY id", format: .list(nullValue: "NULL"), to: stream) + XCTAssertEqual(stream.output, """ + 1|FRA|Antoine Dupond + 2|ENG|Owen Farrell + 3|NULL|Gwendal Roué + + """) + } + } + } + // MARK: - Quote func test_quote_value_formatting() throws { @@ -399,7 +608,7 @@ final class DatabaseDumpTests: GRDBTestCase { do { // Headers off, no result let stream = TestStream() - try db.dumpSQL("SELECT * FROM player WHERE 0", format: .debug(header: false), to: stream) + try db.dumpSQL("SELECT * FROM player WHERE 0", format: .quote(header: false), to: stream) XCTAssertEqual(stream.output, "") } } @@ -470,7 +679,7 @@ final class DatabaseDumpTests: GRDBTestCase { } } - // MARK: - Error dump + // MARK: - Dump error func test_dumpError() throws { try makeDatabaseQueue().read { db in @@ -667,12 +876,12 @@ final class DatabaseDumpTests: GRDBTestCase { try db.dumpContent(to: stream) XCTAssertEqual(stream.output, """ sqlite_master - CREATE TABLE "player" ("id" INTEGER PRIMARY KEY AUTOINCREMENT, "teamId" TEXT REFERENCES "team"("id"), "name" TEXT NOT NULL) - CREATE INDEX "player_on_teamId" ON "player"("teamId") + CREATE TABLE "player" ("id" INTEGER PRIMARY KEY AUTOINCREMENT, "teamId" TEXT REFERENCES "team"("id"), "name" TEXT NOT NULL); + CREATE INDEX "player_on_teamId" ON "player"("teamId"); CREATE VIEW "playerAndTeam" AS SELECT player.*, team.name AS teamName FROM player - LEFT JOIN team ON team.id = player.teamId - CREATE TABLE "team" ("id" TEXT PRIMARY KEY NOT NULL, "name" TEXT NOT NULL, "color" TEXT NOT NULL) + LEFT JOIN team ON team.id = player.teamId; + CREATE TABLE "team" ("id" TEXT PRIMARY KEY NOT NULL, "name" TEXT NOT NULL, "color" TEXT NOT NULL); player 1,'FRA','Antoine Dupond' @@ -691,12 +900,12 @@ final class DatabaseDumpTests: GRDBTestCase { try db.dumpContent(format: .json(), to: stream) XCTAssertEqual(stream.output, """ sqlite_master - CREATE TABLE "player" ("id" INTEGER PRIMARY KEY AUTOINCREMENT, "teamId" TEXT REFERENCES "team"("id"), "name" TEXT NOT NULL) - CREATE INDEX "player_on_teamId" ON "player"("teamId") + CREATE TABLE "player" ("id" INTEGER PRIMARY KEY AUTOINCREMENT, "teamId" TEXT REFERENCES "team"("id"), "name" TEXT NOT NULL); + CREATE INDEX "player_on_teamId" ON "player"("teamId"); CREATE VIEW "playerAndTeam" AS SELECT player.*, team.name AS teamName FROM player - LEFT JOIN team ON team.id = player.teamId - CREATE TABLE "team" ("id" TEXT PRIMARY KEY NOT NULL, "name" TEXT NOT NULL, "color" TEXT NOT NULL) + LEFT JOIN team ON team.id = player.teamId; + CREATE TABLE "team" ("id" TEXT PRIMARY KEY NOT NULL, "name" TEXT NOT NULL, "color" TEXT NOT NULL); player [{"id":1,"teamId":"FRA","name":"Antoine Dupond"}, @@ -723,6 +932,86 @@ final class DatabaseDumpTests: GRDBTestCase { } } + func test_dumpContent_empty_tables() throws { + try makeDatabaseQueue().write { db in + try db.execute(literal: """ + CREATE TABLE blue(name); + CREATE TABLE red(name); + CREATE TABLE yellow(name); + INSERT INTO red VALUES ('vermillon') + """) + let stream = TestStream() + try db.dumpContent(to: stream) + XCTAssertEqual(stream.output, """ + sqlite_master + CREATE TABLE blue(name); + CREATE TABLE red(name); + CREATE TABLE yellow(name); + + blue + + red + 'vermillon' + + yellow + + """) + } + } + + func test_dumpContent_sqlite_master_ordering() throws { + try makeDatabaseQueue().write { db in + try db.execute(literal: """ + CREATE TABLE blue(name); + CREATE TABLE RED(name); + CREATE TABLE yellow(name); + CREATE INDEX index_blue1 ON blue(name); + CREATE INDEX INDEX_blue2 ON blue(name); + CREATE INDEX indexRed1 ON RED(name); + CREATE INDEX INDEXRed2 ON RED(name); + CREATE VIEW colors1 AS SELECT name FROM blue; + CREATE VIEW COLORS2 AS SELECT name FROM blue UNION SELECT name FROM yellow; + CREATE TRIGGER update_blue UPDATE OF name ON blue + BEGIN + DELETE FROM RED; + END; + CREATE TRIGGER update_RED UPDATE OF name ON RED + BEGIN + DELETE FROM yellow; + END; + """) + let stream = TestStream() + try db.dumpContent(to: stream) + XCTAssertEqual(stream.output, """ + sqlite_master + CREATE TABLE blue(name); + CREATE INDEX index_blue1 ON blue(name); + CREATE INDEX INDEX_blue2 ON blue(name); + CREATE TRIGGER update_blue UPDATE OF name ON blue + BEGIN + DELETE FROM RED; + END; + CREATE VIEW colors1 AS SELECT name FROM blue; + CREATE VIEW COLORS2 AS SELECT name FROM blue UNION SELECT name FROM yellow; + CREATE TABLE RED(name); + CREATE INDEX indexRed1 ON RED(name); + CREATE INDEX INDEXRed2 ON RED(name); + CREATE TRIGGER update_RED UPDATE OF name ON RED + BEGIN + DELETE FROM yellow; + END; + CREATE TABLE yellow(name); + + blue + + RED + + yellow + + """) + } + } + // MARK: - Support Databases private func makeValuesDatabase() throws -> DatabaseQueue { diff --git a/Tests/GRDBTests/DatabaseReaderDumpTests.swift b/Tests/GRDBTests/DatabaseReaderDumpTests.swift index c01517ad87..f6ef177668 100644 --- a/Tests/GRDBTests/DatabaseReaderDumpTests.swift +++ b/Tests/GRDBTests/DatabaseReaderDumpTests.swift @@ -312,6 +312,211 @@ final class DatabaseReaderDumpTests: GRDBTestCase { """) } + func test_json_custom_encoder() throws { + let dbQueue = try makeRugbyDatabase() + let encoder = JSONDumpFormat.defaultEncoder + encoder.outputFormatting = [.prettyPrinted, .sortedKeys /* ignored */] + let stream = TestStream() + try dbQueue.dumpTables(["player"], format: .json(encoder: encoder), to: stream) + XCTAssertEqual(stream.output, """ + [ + { + "id":1, + "teamId":"FRA", + "name":"Antoine Dupond" + }, + { + "id":2, + "teamId":"ENG", + "name":"Owen Farrell" + }, + { + "id":3, + "teamId":null, + "name":"Gwendal Roué" + } + ] + + """) + } + + // MARK: - List + + func test_list_value_formatting() throws { + let dbQueue = try makeValuesDatabase() + let stream = TestStream() + try dbQueue.dumpSQL("SELECT * FROM value ORDER BY name", format: .list(), to: stream) + XCTAssertEqual(stream.output, """ + blob: ascii apostrophe|['] + blob: ascii double quote|["] + blob: ascii line feed|[ + ] + blob: ascii long|Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi tristique tempor condimentum. Pellentesque pharetra lacus non ante sollicitudin auctor. Vestibulum sit amet mauris vitae urna non luctus. + blob: ascii short|Hello + blob: ascii tab|[\t] + blob: binary long|X'48727307AA611C4F17BAE964AEA8196C77A90ED0135B0DEAB7CFEA7EA547DB8B16F16553E5F094DDA97C1E89223901BC167D240707D8492B7237BE60FEEB59C82A04331475A89AD77455CD6BD6F05A94AABB1E0997552AED8306386E9CD3E74A5694670245D06F9962B7A8B1D25775CFF12EFFEB2FD9FB80197FFF2747E3CAAF977391CE9861C9A56EA010B0BED4F7E7735E986D8655FBE446373C4D345CA8217876362AD1E5B1AEC55415D7264D825F46EFF017C98833F2C3E265491CF021F6BA3F3DEFB7ACFBF7' + blob: binary short|X'06F9962B7A' + blob: empty| + blob: utf8 short|您好🙂 + blob: uuid|69BF8A9C-D9F0-4777-BD11-93451D84CBCF + double: -1.0|-1.0 + double: -inf|-inf + double: 0.0|0.0 + double: 123.45|123.45 + double: inf|inf + double: nan| + integer: -1|-1 + integer: 0|0 + integer: 123|123 + integer: max|9223372036854775807 + integer: min|-9223372036854775808 + null| + text: ascii apostrophe|['] + text: ascii backslash|[\\] + text: ascii double quote|["] + text: ascii line feed|[ + ] + text: ascii long|Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi tristique tempor condimentum. Pellentesque pharetra lacus non ante sollicitudin auctor. Vestibulum sit amet mauris vitae urna non luctus. + text: ascii short|Hello + text: ascii slash|[/] + text: ascii tab|[\t] + text: ascii url|https://github.com/groue/GRDB.swift + text: empty| + text: utf8 short|您好🙂 + + """) + } + + func test_list_headers() throws { + let dbQueue = try makeRugbyDatabase() + + do { + // Headers on + let stream = TestStream() + try dbQueue.dumpSQL("SELECT * FROM player ORDER BY id", format: .list(header: true), to: stream) + XCTAssertEqual(stream.output, """ + id|teamId|name + 1|FRA|Antoine Dupond + 2|ENG|Owen Farrell + 3||Gwendal Roué + + """) + } + do { + // Headers on, no result + let stream = TestStream() + try dbQueue.dumpSQL("SELECT * FROM player WHERE 0", format: .list(header: true), to: stream) + XCTAssertEqual(stream.output, "") + } + do { + // Headers off + let stream = TestStream() + try dbQueue.dumpSQL("SELECT * FROM player ORDER BY id", format: .list(header: false), to: stream) + XCTAssertEqual(stream.output, """ + 1|FRA|Antoine Dupond + 2|ENG|Owen Farrell + 3||Gwendal Roué + + """) + } + do { + // Headers off, no result + let stream = TestStream() + try dbQueue.dumpSQL("SELECT * FROM player WHERE 0", format: .list(header: false), to: stream) + XCTAssertEqual(stream.output, "") + } + } + + func test_list_duplicate_columns() throws { + let dbQueue = try makeDatabaseQueue() + let stream = TestStream() + try dbQueue.dumpSQL("SELECT 1 AS name, 'foo' AS name", format: .list(header: true), to: stream) + XCTAssertEqual(stream.output, """ + name|name + 1|foo + + """) + } + + func test_list_multiple_statements() throws { + let dbQueue = try makeDatabaseQueue() + let stream = TestStream() + try dbQueue.dumpSQL( + """ + CREATE TABLE t(a, b); + INSERT INTO t VALUES (1, 'foo'); + INSERT INTO t VALUES (2, 'bar'); + SELECT * FROM t ORDER BY a; + SELECT b FROM t ORDER BY b; + SELECT NULL WHERE NULL; + """, + format: .list(), + to: stream) + XCTAssertEqual(stream.output, """ + 1|foo + 2|bar + bar + foo + + """) + } + + func test_list_separator() throws { + let dbQueue = try makeRugbyDatabase() + + do { + // Default separator + let stream = TestStream() + try dbQueue.dumpSQL("SELECT * FROM player ORDER BY id", format: .list(header: true), to: stream) + XCTAssertEqual(stream.output, """ + id|teamId|name + 1|FRA|Antoine Dupond + 2|ENG|Owen Farrell + 3||Gwendal Roué + + """) + } + do { + // Custom separator + let stream = TestStream() + try dbQueue.dumpSQL("SELECT * FROM player ORDER BY id", format: .list(header: true, separator: "---"), to: stream) + XCTAssertEqual(stream.output, """ + id---teamId---name + 1---FRA---Antoine Dupond + 2---ENG---Owen Farrell + 3------Gwendal Roué + + """) + } + } + + func test_list_nullValue() throws { + let dbQueue = try makeRugbyDatabase() + + do { + // Default null + let stream = TestStream() + try dbQueue.dumpSQL("SELECT * FROM player ORDER BY id", format: .list(), to: stream) + XCTAssertEqual(stream.output, """ + 1|FRA|Antoine Dupond + 2|ENG|Owen Farrell + 3||Gwendal Roué + + """) + } + do { + // Custom null + let stream = TestStream() + try dbQueue.dumpSQL("SELECT * FROM player ORDER BY id", format: .list(nullValue: "NULL"), to: stream) + XCTAssertEqual(stream.output, """ + 1|FRA|Antoine Dupond + 2|ENG|Owen Farrell + 3|NULL|Gwendal Roué + + """) + } + } + // MARK: - Quote func test_quote_value_formatting() throws { @@ -393,7 +598,7 @@ final class DatabaseReaderDumpTests: GRDBTestCase { do { // Headers off, no result let stream = TestStream() - try dbQueue.dumpSQL("SELECT * FROM player WHERE 0", format: .debug(header: false), to: stream) + try dbQueue.dumpSQL("SELECT * FROM player WHERE 0", format: .quote(header: false), to: stream) XCTAssertEqual(stream.output, "") } } @@ -461,7 +666,7 @@ final class DatabaseReaderDumpTests: GRDBTestCase { } } - // MARK: - Error dump + // MARK: - Dump error func test_dumpError() throws { let dbQueue = try makeDatabaseQueue()