diff --git a/CHANGELOG.md b/CHANGELOG.md index 113f8b633a..0f5b0c22d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -127,6 +127,7 @@ GRDB adheres to [Semantic Versioning](https://semver.org/), with one exception: - **New**: [#1503](https://github.com/groue/GRDB.swift/pull/1503) by [@simba909](https://github.com/simba909): Conform Database.ColumnType to Sendable - **New**: [#1510](https://github.com/groue/GRDB.swift/pull/1510) by [@groue](https://github.com/groue): Add Sendable conformances and unavailabilities +- **New**: [#1511](https://github.com/groue/GRDB.swift/pull/1511) by [@groue](https://github.com/groue): Database schema dump - **Fixed**: [#1508](https://github.com/groue/GRDB.swift/pull/1508) by [@groue](https://github.com/groue): Fix ValueObservation mishandling of database schema modification ## 6.25.0 diff --git a/GRDB/Core/Database.swift b/GRDB/Core/Database.swift index ae705dbe1c..c460a2e6fe 100644 --- a/GRDB/Core/Database.swift +++ b/GRDB/Core/Database.swift @@ -70,6 +70,7 @@ let SQLITE_TRANSIENT = unsafeBitCast(OpaquePointer(bitPattern: -1), to: sqlite3_ /// /// - ``dumpContent(format:to:)`` /// - ``dumpRequest(_:format:to:)`` +/// - ``dumpSchema(to:)`` /// - ``dumpSQL(_:format:to:)`` /// - ``dumpTables(_:format:tableHeader:stableOrder:to:)`` /// - ``DumpFormat`` diff --git a/GRDB/Core/DatabaseReader.swift b/GRDB/Core/DatabaseReader.swift index a07abf9ec4..5fac0df44e 100644 --- a/GRDB/Core/DatabaseReader.swift +++ b/GRDB/Core/DatabaseReader.swift @@ -37,6 +37,7 @@ import Dispatch /// /// - ``dumpContent(format:to:)`` /// - ``dumpRequest(_:format:to:)`` +/// - ``dumpSchema(to:)`` /// - ``dumpSQL(_:format:to:)`` /// - ``dumpTables(_:format:tableHeader:stableOrder:to:)`` /// - ``DumpFormat`` diff --git a/GRDB/Dump/Database+Dump.swift b/GRDB/Dump/Database+Dump.swift index 33edfda640..d4abfb653a 100644 --- a/GRDB/Dump/Database+Dump.swift +++ b/GRDB/Dump/Database+Dump.swift @@ -12,7 +12,7 @@ extension Database { /// // Prints /// // 1|Arthur|500 /// // 2|Barbara|1000 - /// db.dumpSQL("SELECT * FROM player ORDER BY id") + /// try db.dumpSQL("SELECT * FROM player ORDER BY id") /// } /// ``` /// @@ -40,7 +40,7 @@ extension Database { /// // Prints /// // 1|Arthur|500 /// // 2|Barbara|1000 - /// db.dumpRequest(Player.orderByPrimaryKey()) + /// try db.dumpRequest(Player.orderByPrimaryKey()) /// } /// ``` /// @@ -72,7 +72,7 @@ extension Database { /// // team /// // 1|Red /// // 2|Blue - /// db.dumpTables(["player", "team"]) + /// try db.dumpTables(["player", "team"]) /// } /// ``` /// @@ -111,7 +111,7 @@ extension Database { /// /// ```swift /// try dbQueue.read { db in - /// db.dumpContent() + /// try db.dumpContent() /// } /// ``` /// @@ -145,6 +145,40 @@ extension Database { var dumpStream = DumpStream(stream) try _dumpContent(format: format, to: &dumpStream) } + + /// Prints the schema of the database. + /// + /// For example: + /// + /// ```swift + /// try dbQueue.read { db in + /// try db.dumpSchema() + /// } + /// ``` + /// + /// This prints the database schema. For example: + /// + /// ``` + /// sqlite_master + /// CREATE TABLE player (id INTEGER PRIMARY KEY, name TEXT, score INTEGER) + /// ``` + /// + /// > Note: Internal SQLite and GRDB schema objects are not recorded + /// > (those with a name that starts with "sqlite_" or "grdb_"). + /// > + /// > [Shadow tables](https://www.sqlite.org/vtab.html#xshadowname) are + /// > not recorded, starting SQLite 3.37+. + /// + /// - Parameters: + /// - stream: A stream for text output, which directs output to the + /// console by default. + public func dumpSchema( + to stream: (any TextOutputStream)? = nil) + throws + { + var dumpStream = DumpStream(stream) + try _dumpSchema(to: &dumpStream) + } } // MARK: - @@ -241,6 +275,26 @@ extension Database { format: some DumpFormat, to stream: inout DumpStream) throws + { + try _dumpSchema(to: &stream) + stream.margin() + + let tables = try String + .fetchAll(self, sql: """ + SELECT name + FROM sqlite_master + WHERE type = 'table' + ORDER BY name COLLATE NOCASE + """) + .filter { + try !ignoresObject(named: $0) + } + try _dumpTables(tables, format: format, tableHeader: .always, stableOrder: true, to: &stream) + } + + func _dumpSchema( + to stream: inout DumpStream) + throws { stream.writeln("sqlite_master") let sqlRows = try Row.fetchAll(self, sql: """ @@ -260,20 +314,6 @@ extension Database { } stream.writeln(row[0]) } - - let tables = try String - .fetchAll(self, sql: """ - SELECT name - FROM sqlite_master - WHERE type = 'table' - ORDER BY name COLLATE NOCASE - """) - .filter { - try !ignoresObject(named: $0) - } - if tables.isEmpty { return } - stream.write("\n") - try _dumpTables(tables, format: format, tableHeader: .always, stableOrder: true, to: &stream) } private func ignoresObject(named name: String) throws -> Bool { diff --git a/GRDB/Dump/DatabaseReader+dump.swift b/GRDB/Dump/DatabaseReader+dump.swift index cd3649caee..55e829c89d 100644 --- a/GRDB/Dump/DatabaseReader+dump.swift +++ b/GRDB/Dump/DatabaseReader+dump.swift @@ -7,7 +7,7 @@ extension DatabaseReader { /// // Prints /// // 1|Arthur|500 /// // 2|Barbara|1000 - /// dbQueue.dumpSQL("SELECT * FROM player ORDER BY id") + /// try dbQueue.dumpSQL("SELECT * FROM player ORDER BY id") /// ``` /// /// - Parameters: @@ -34,7 +34,7 @@ extension DatabaseReader { /// // Prints /// // 1|Arthur|500 /// // 2|Barbara|1000 - /// dbQueue.dumpRequest(Player.orderByPrimaryKey()) + /// try dbQueue.dumpRequest(Player.orderByPrimaryKey()) /// ``` /// /// - Parameters: @@ -65,7 +65,7 @@ extension DatabaseReader { /// // team /// // 1|Red /// // 2|Blue - /// dbQueue.dumpTables(["player", "team"]) + /// try dbQueue.dumpTables(["player", "team"]) /// ``` /// /// - Parameters: @@ -103,7 +103,7 @@ extension DatabaseReader { /// For example: /// /// ```swift - /// dbQueue.dumpContent() + /// try dbQueue.dumpContent() /// ``` /// /// This prints the database schema as well as the content of all @@ -137,4 +137,37 @@ extension DatabaseReader { try db.dumpContent(format: format, to: stream) } } + + /// Prints the schema of the database. + /// + /// For example: + /// + /// ```swift + /// try dbQueue.dumpSchema() + /// ``` + /// + /// This prints the database schema. For example: + /// + /// ``` + /// sqlite_master + /// CREATE TABLE player (id INTEGER PRIMARY KEY, name TEXT, score INTEGER) + /// ``` + /// + /// > Note: Internal SQLite and GRDB schema objects are not recorded + /// > (those with a name that starts with "sqlite_" or "grdb_"). + /// > + /// > [Shadow tables](https://www.sqlite.org/vtab.html#xshadowname) are + /// > not recorded, starting SQLite 3.37+. + /// + /// - Parameters: + /// - stream: A stream for text output, which directs output to the + /// console by default. + public func dumpSchema( + to stream: (any TextOutputStream)? = nil) + throws + { + try unsafeReentrantRead { db in + try db.dumpSchema(to: stream) + } + } } diff --git a/Tests/GRDBTests/DatabaseDumpTests.swift b/Tests/GRDBTests/DatabaseDumpTests.swift index 3dc49a9fc8..c0551a0e88 100644 --- a/Tests/GRDBTests/DatabaseDumpTests.swift +++ b/Tests/GRDBTests/DatabaseDumpTests.swift @@ -1196,6 +1196,164 @@ final class DatabaseDumpTests: GRDBTestCase { } } + // MARK: - Database schema dump + + func test_dumpSchema() throws { + try makeRugbyDatabase().read { db in + let stream = TestStream() + try db.dumpSchema(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 "team" ("id" TEXT PRIMARY KEY NOT NULL, "name" TEXT NOT NULL, "color" TEXT NOT NULL); + + """) + } + } + + func test_dumpSchema_empty_database() throws { + try makeDatabaseQueue().read { db in + let stream = TestStream() + try db.dumpSchema(to: stream) + XCTAssertEqual(stream.output, """ + sqlite_master + + """) + } + } + + func test_dumpSchema_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.dumpSchema(to: stream) + XCTAssertEqual(stream.output, """ + sqlite_master + CREATE TABLE blue(name); + CREATE TABLE red(name); + CREATE TABLE yellow(name); + + """) + } + } + + func test_dumpSchema_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.dumpSchema(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); + + """) + } + } + + func test_dumpSchema_ignores_shadow_tables() throws { + guard sqlite3_libversion_number() >= 3037000 else { + throw XCTSkip("Can't detect shadow tables") + } + + try makeDatabaseQueue().write { db in + try db.create(table: "document") { t in + t.autoIncrementedPrimaryKey("id") + t.column("body") + } + + try db.execute(sql: "INSERT INTO document VALUES (1, 'Hello world!')") + + try db.create(virtualTable: "document_ft", using: FTS4()) { t in + t.synchronize(withTable: "document") + t.column("body") + } + + let stream = TestStream() + try db.dumpSchema(to: stream) + print(stream.output) + XCTAssertEqual(stream.output, """ + sqlite_master + CREATE TABLE "document" ("id" INTEGER PRIMARY KEY AUTOINCREMENT, "body"); + CREATE TRIGGER "__document_ft_ai" AFTER INSERT ON "document" BEGIN + INSERT INTO "document_ft"("docid", "body") VALUES(new."id", new."body"); + END; + CREATE TRIGGER "__document_ft_au" AFTER UPDATE ON "document" BEGIN + INSERT INTO "document_ft"("docid", "body") VALUES(new."id", new."body"); + END; + CREATE TRIGGER "__document_ft_bd" BEFORE DELETE ON "document" BEGIN + DELETE FROM "document_ft" WHERE docid=old."id"; + END; + CREATE TRIGGER "__document_ft_bu" BEFORE UPDATE ON "document" BEGIN + DELETE FROM "document_ft" WHERE docid=old."id"; + END; + CREATE VIRTUAL TABLE "document_ft" USING fts4(body, content="document"); + + """) + } + } + + func test_dumpSchema_ignores_GRDB_internal_tables() throws { + let dbQueue = try makeDatabaseQueue() + var migrator = DatabaseMigrator() + migrator.registerMigration("v1") { db in + try db.create(table: "player") { t in + t.autoIncrementedPrimaryKey("id") + } + } + try migrator.migrate(dbQueue) + + try dbQueue.read { db in + let stream = TestStream() + try db.dumpSchema(to: stream) + print(stream.output) + XCTAssertEqual(stream.output, """ + sqlite_master + CREATE TABLE "player" ("id" INTEGER PRIMARY KEY AUTOINCREMENT); + + """) + } + } + // MARK: - Database content dump func test_dumpContent() throws {