diff --git a/GRDB/Core/Database.swift b/GRDB/Core/Database.swift index f226b455c4..9826711728 100644 --- a/GRDB/Core/Database.swift +++ b/GRDB/Core/Database.swift @@ -70,7 +70,7 @@ let SQLITE_TRANSIENT = unsafeBitCast(OpaquePointer(bitPattern: -1), to: sqlite3_ /// - ``dumpContent(format:to:)`` /// - ``dumpRequest(_:format:to:)`` /// - ``dumpSQL(_:format:to:)`` -/// - ``dumpTables(_:format:tableHeader:to:)`` +/// - ``dumpTables(_:format:tableHeader:stableOrder:to:)`` /// - ``DumpFormat`` /// - ``DumpTableHeaderOptions`` /// diff --git a/GRDB/Core/DatabaseReader.swift b/GRDB/Core/DatabaseReader.swift index 2f064dc153..a07abf9ec4 100644 --- a/GRDB/Core/DatabaseReader.swift +++ b/GRDB/Core/DatabaseReader.swift @@ -38,7 +38,7 @@ import Dispatch /// - ``dumpContent(format:to:)`` /// - ``dumpRequest(_:format:to:)`` /// - ``dumpSQL(_:format:to:)`` -/// - ``dumpTables(_:format:tableHeader:to:)`` +/// - ``dumpTables(_:format:tableHeader:stableOrder:to:)`` /// - ``DumpFormat`` /// - ``DumpTableHeaderOptions`` /// diff --git a/GRDB/Dump/Database+Dump.swift b/GRDB/Dump/Database+Dump.swift index eb327eb283..146efed162 100644 --- a/GRDB/Dump/Database+Dump.swift +++ b/GRDB/Dump/Database+Dump.swift @@ -59,7 +59,7 @@ extension Database { try _dumpRequest(request, format: format, to: &dumpStream) } - /// Prints the contents of the provided tables. + /// Prints the contents of the provided tables and views. /// /// For example: /// @@ -80,17 +80,29 @@ extension Database { /// - tables: The table names. /// - format: The output format. /// - tableHeader: Options for printing table names. + /// - stableOrder: A boolean value that controls the ordering of + /// rows fetched from views. If false (the default), rows are + /// printed in the order specified by the view (which may be + /// undefined). It true, outputted rows are always printed in the + /// same stable order. The purpose of this stable order is to make + /// the output suitable for testing. /// - stream: A stream for text output, which directs output to the /// console by default. public func dumpTables( _ tables: [String], format: some DumpFormat = .debug(), tableHeader: DumpTableHeaderOptions = .automatic, + stableOrder: Bool = false, to stream: (any TextOutputStream)? = nil) throws { var dumpStream = DumpStream(stream) - try _dumpTables(tables, format: format, tableHeader: tableHeader, to: &dumpStream) + try _dumpTables( + tables, + format: format, + tableHeader: tableHeader, + stableOrder: stableOrder, + to: &dumpStream) } /// Prints the contents of the database. @@ -186,7 +198,8 @@ extension Database { func _dumpTables( _ tables: [String], format: some DumpFormat, - tableHeader: DumpTableHeaderOptions = .automatic, + tableHeader: DumpTableHeaderOptions, + stableOrder: Bool, to stream: inout DumpStream) throws { @@ -203,10 +216,21 @@ extension Database { } else { stream.write("\n") } + if header { stream.writeln(table) } - try _dumpRequest(Table(table).orderByPrimaryKey(), format: format, to: &stream) + + if try tableExists(table) { + // Always sort tables by primary key + try _dumpRequest(Table(table).orderByPrimaryKey(), format: format, to: &stream) + } else if stableOrder { + // View with stable order + try _dumpRequest(Table(table).all().withStableOrder(), format: format, to: &stream) + } else { + // Use view ordering, if any (no guarantee of stable order). + try _dumpRequest(Table(table).all(), format: format, to: &stream) + } } } @@ -246,7 +270,7 @@ extension Database { } if tables.isEmpty { return } stream.write("\n") - try _dumpTables(tables, format: format, tableHeader: .always, to: &stream) + try _dumpTables(tables, format: format, tableHeader: .always, stableOrder: true, to: &stream) } } diff --git a/GRDB/Dump/DatabaseReader+dump.swift b/GRDB/Dump/DatabaseReader+dump.swift index bff43b777a..e18807d058 100644 --- a/GRDB/Dump/DatabaseReader+dump.swift +++ b/GRDB/Dump/DatabaseReader+dump.swift @@ -53,7 +53,7 @@ extension DatabaseReader { } } - /// Prints the contents of the provided tables. + /// Prints the contents of the provided tables and views. /// /// For example: /// @@ -72,17 +72,29 @@ extension DatabaseReader { /// - tables: The table names. /// - format: The output format. /// - tableHeader: Options for printing table names. + /// - stableOrder: A boolean value that controls the ordering of + /// rows fetched from views. If false (the default), rows are + /// printed in the order specified by the view (which may be + /// undefined). It true, outputted rows are always printed in the + /// same stable order. The purpose of this stable order is to make + /// the output suitable for testing. /// - stream: A stream for text output, which directs output to the /// console by default. public func dumpTables( _ tables: [String], format: some DumpFormat = .debug(), tableHeader: DumpTableHeaderOptions = .automatic, + stableOrder: Bool = false, to stream: (any TextOutputStream)? = nil) throws { try unsafeReentrantRead { db in - try db.dumpTables(tables, format: format, tableHeader: tableHeader, to: stream) + try db.dumpTables( + tables, + format: format, + tableHeader: tableHeader, + stableOrder: stableOrder, + to: stream) } } diff --git a/GRDB/QueryInterface/SQL/SQLRelation.swift b/GRDB/QueryInterface/SQL/SQLRelation.swift index a7e2063530..9f01841c2a 100644 --- a/GRDB/QueryInterface/SQL/SQLRelation.swift +++ b/GRDB/QueryInterface/SQL/SQLRelation.swift @@ -283,13 +283,20 @@ extension SQLRelation: Refinable { } func withStableOrder() -> Self { - with { - // Order by primary key. Don't order by rowid because those are - // not stable: rowids can change after a vacuum. - $0.ordering = $0.ordering.appending(Ordering(orderings: { db in - try db.primaryKey(source.tableName).columns.map { SQLExpression.column($0).sqlOrdering } + with { relation in + relation.ordering = relation.ordering.appending(Ordering(orderings: { [relation] db in + if try db.tableExists(source.tableName) { + // Order by primary key. Don't order by rowid because those are + // not stable: rowids can change after a vacuum. + return try db.primaryKey(source.tableName).columns.map { SQLExpression.column($0).sqlOrdering } + } else { + // Support for views: create a stable order from all columns: + // ORDER BY 1, 2, 3, ... + let columnCount = try SQLQueryGenerator(relation: relation).columnCount(db) + return (1...columnCount).map { SQL(sql: $0.description).sqlOrdering } + } })) - $0.children = children.mapValues { child in + relation.children = children.mapValues { child in child.with { $0.relation = $0.relation.withStableOrder() } diff --git a/Tests/GRDBTests/DatabaseDumpTests.swift b/Tests/GRDBTests/DatabaseDumpTests.swift index bedd64de07..0665e44eee 100644 --- a/Tests/GRDBTests/DatabaseDumpTests.swift +++ b/Tests/GRDBTests/DatabaseDumpTests.swift @@ -1072,7 +1072,7 @@ final class DatabaseDumpTests: GRDBTestCase { } } - func test_dumpTables_single() throws { + func test_dumpTables_single_table() throws { try makeRugbyDatabase().read { db in do { // Default format @@ -1099,6 +1099,66 @@ final class DatabaseDumpTests: GRDBTestCase { } } + func test_dumpTables_single_view() throws { + try makeRugbyDatabase().write { db in + try db.create(view: "playerName", as: Player + .orderByPrimaryKey() + .select(Column("name"))) + + do { + // Default order: use the view ordering + do { + // Default format + let stream = TestStream() + try db.dumpTables(["playerName"], to: stream) + XCTAssertEqual(stream.output, """ + Antoine Dupond + Owen Farrell + Gwendal Roué + + """) + } + do { + // Custom format + let stream = TestStream() + try db.dumpTables(["playerName"], format: .json(), to: stream) + XCTAssertEqual(stream.output, """ + [{"name":"Antoine Dupond"}, + {"name":"Owen Farrell"}, + {"name":"Gwendal Roué"}] + + """) + } + } + + do { + // Stable order + do { + // Default format + let stream = TestStream() + try db.dumpTables(["playerName"], stableOrder: true, to: stream) + XCTAssertEqual(stream.output, """ + Antoine Dupond + Gwendal Roué + Owen Farrell + + """) + } + do { + // Custom format + let stream = TestStream() + try db.dumpTables(["playerName"], format: .json(), stableOrder: true, to: stream) + XCTAssertEqual(stream.output, """ + [{"name":"Antoine Dupond"}, + {"name":"Gwendal Roué"}, + {"name":"Owen Farrell"}] + + """) + } + } + } + } + func test_dumpTables_multiple() throws { try makeRugbyDatabase().read { db in do { diff --git a/Tests/GRDBTests/DerivableRequestTests.swift b/Tests/GRDBTests/DerivableRequestTests.swift index 1987c4e3d7..1cb5f91b25 100644 --- a/Tests/GRDBTests/DerivableRequestTests.swift +++ b/Tests/GRDBTests/DerivableRequestTests.swift @@ -173,7 +173,13 @@ class DerivableRequestTests: GRDBTestCase { let dbQueue = try makeDatabaseQueue() try libraryMigrator.migrate(dbQueue) try dbQueue.inDatabase { db in - // ... for two requests (1) + try db.create(view: "authorView", as: Author.select( + AllColumns(), + [Column("firstName"), Column("lastName")] + .joined(operator: .concat) + .forKey("fullName"))) + + // ... for one table sqlQueries.removeAll() let authorNames = try Author.all() .orderByFullName() @@ -208,6 +214,14 @@ class DerivableRequestTests: GRDBTestCase { SELECT * FROM "author" """) + sqlQueries.removeAll() + _ /* stableOrderAuthors */ = try Author.all() + .withStableOrder() + .fetchAll(db) + XCTAssertEqual(lastSQLQuery, """ + SELECT * FROM "author" ORDER BY "id" + """) + sqlQueries.removeAll() _ /* stableOrderAuthors */ = try Author.all() .orderByFullName() @@ -217,7 +231,53 @@ class DerivableRequestTests: GRDBTestCase { SELECT * FROM "author" ORDER BY "lastName" COLLATE swiftLocalizedCaseInsensitiveCompare, "firstName" COLLATE swiftLocalizedCaseInsensitiveCompare, "id" """) - // ... for two requests (2) + // ... for one view + sqlQueries.removeAll() + _ /* authorViewNames */ = try Table("authorView").all() + .order(Column("fullName")) + .fetchAll(db) + XCTAssertEqual(lastSQLQuery, """ + SELECT * FROM "authorView" \ + ORDER BY "fullName" + """) + + sqlQueries.removeAll() + _ /* reversedAuthorViewNames */ = try Table("authorView").all() + .order(Column("fullName")) + .reversed() + .fetchAll(db) + XCTAssertEqual(lastSQLQuery, """ + SELECT * FROM "authorView" \ + ORDER BY "fullName" DESC + """) + + sqlQueries.removeAll() + _ /* unorderedAuthorViews */ = try Table("authorView").all() + .order(Column("fullName")) + .unordered() + .fetchAll(db) + XCTAssertEqual(lastSQLQuery, """ + SELECT * FROM "authorView" + """) + + sqlQueries.removeAll() + _ /* stableOrderAuthorViews */ = try Table("authorView").all() + .withStableOrder() + .fetchAll(db) + XCTAssertEqual(lastSQLQuery, """ + SELECT * FROM "authorView" ORDER BY 1, 2, 3, 4, 5 + """) + + sqlQueries.removeAll() + _ /* stableOrderAuthorViews */ = try Table("authorView").all() + .order(Column("fullName")) + .withStableOrder() + .fetchAll(db) + XCTAssertEqual(lastSQLQuery, """ + SELECT * FROM "authorView" ORDER BY "fullName", 1, 2, 3, 4, 5 + """) + + // ... for two tables (2) sqlQueries.removeAll() let bookTitles = try Book .joining(required: Book.author.orderByFullName())