diff --git a/ext/sqlite3/database.c b/ext/sqlite3/database.c index a35ff3a1..7092a5d9 100644 --- a/ext/sqlite3/database.c +++ b/ext/sqlite3/database.c @@ -923,6 +923,110 @@ db_filename(VALUE self, VALUE db_name) return Qnil; } +#ifdef HAVE_SQLITE3_SERIALIZE +/* call-seq: db.serialize(database_name = "main") + * + * Serialize the database into a binary string. + * + * [database_name] The name of the database to serialize. The default value + * is +main+ which is the main database file. For ATTACHed + * databases, use the name given in the ATTACH statement. + * + * Returns the contents of the database as a binary string. For an ordinary + * on-disk database file, this is just a copy of the disk file. For an + * in-memory database or a "TEMP" database, this is the same sequence of bytes + * which would be written to disk if that database were backed up to disk. + * + * Note: This method will only be defined if SQLite was compiled without + * +SQLITE_OMIT_DESERIALIZE+. + * + * See also: https://www.sqlite.org/c3ref/serialize.html + */ +static VALUE +serialize(int argc, VALUE *argv, VALUE self) +{ + sqlite3RubyPtr ctx; + VALUE db_name; + const char *zSchema; + sqlite3_int64 size; + unsigned char *data; + VALUE result; + + TypedData_Get_Struct(self, sqlite3Ruby, &database_type, ctx); + REQUIRE_OPEN_DB(ctx); + + rb_scan_args(argc, argv, "01", &db_name); + + zSchema = NIL_P(db_name) ? NULL : StringValueCStr(db_name); + + data = sqlite3_serialize(ctx->db, zSchema, &size, 0); + + if (data == NULL && size != 0) { + rb_raise(rb_eNoMemError, "failed to allocate %"PRId64" bytes for serialization", size); + } + + result = rb_str_new((const char *)data, (long)size); + sqlite3_free(data); + + return result; +} +#endif + +#ifdef HAVE_SQLITE3_DESERIALIZE +/* call-seq: db.deserialize(data, database_name = "main") + * + * Deserialize a binary string into the database. The database is replaced with + * the contents of the serialized data. + * + * [data] A binary string containing the serialized database content. + * This is typically obtained from a prior call to #serialize. + * + * [database_name] The name of the database to replace. The default value is + * +main+ which is the main database file. For ATTACHed + * databases, use the name given in the ATTACH statement. + * + * The database connection must not be in a transaction or involved in a backup + * operation when this method is called, or it will raise SQLite3::BusyException. + * + * Note: This method will only be defined if SQLite was compiled without + * +SQLITE_OMIT_DESERIALIZE+. + * + * See also: https://www.sqlite.org/c3ref/deserialize.html + */ +static VALUE +deserialize(int argc, VALUE *argv, VALUE self) +{ + sqlite3RubyPtr ctx; + VALUE data, db_name; + const char *zSchema; + unsigned char *pData; + sqlite3_int64 szDb; + int status; + + TypedData_Get_Struct(self, sqlite3Ruby, &database_type, ctx); + REQUIRE_OPEN_DB(ctx); + + rb_scan_args(argc, argv, "11", &data, &db_name); + + StringValue(data); + zSchema = NIL_P(db_name) ? NULL : StringValueCStr(db_name); + szDb = RSTRING_LEN(data); + + pData = sqlite3_malloc64(szDb); + if (pData == NULL) { + rb_raise(rb_eNoMemError, "failed to allocate %"PRId64" bytes for deserialization", szDb); + } + memcpy(pData, RSTRING_PTR(data), (size_t)szDb); + + status = sqlite3_deserialize(ctx->db, zSchema, pData, szDb, szDb, + SQLITE_DESERIALIZE_FREEONCLOSE | SQLITE_DESERIALIZE_RESIZEABLE); + + CHECK(ctx->db, status); + + return self; +} +#endif + static VALUE rb_sqlite3_open16(VALUE self, VALUE file) { @@ -997,6 +1101,13 @@ init_sqlite3_database(void) #endif #endif +#ifdef HAVE_SQLITE3_SERIALIZE + rb_define_method(cSqlite3Database, "serialize", serialize, -1); +#endif + +#ifdef HAVE_SQLITE3_DESERIALIZE + rb_define_method(cSqlite3Database, "deserialize", deserialize, -1); +#endif rb_sqlite3_aggregator_init(); } diff --git a/ext/sqlite3/extconf.rb b/ext/sqlite3/extconf.rb index d6acea6f..c6f8ea88 100644 --- a/ext/sqlite3/extconf.rb +++ b/ext/sqlite3/extconf.rb @@ -139,6 +139,8 @@ def configure_extension have_func("sqlite3_prepare_v2") have_func("sqlite3_db_name", "sqlite3.h") # v3.39.0 have_func("sqlite3_error_offset", "sqlite3.h") # v3.38.0 + have_func("sqlite3_serialize", "sqlite3.h") # v3.23.0 + have_func("sqlite3_deserialize", "sqlite3.h") # v3.23.0 have_type("sqlite3_int64", "sqlite3.h") have_type("sqlite3_uint64", "sqlite3.h") diff --git a/test/test_database.rb b/test/test_database.rb index 3140a6a1..eac70068 100644 --- a/test/test_database.rb +++ b/test/test_database.rb @@ -869,5 +869,117 @@ def test_dbstat_table_exists assert_nothing_raised { @db.execute("select * from dbstat") } end + + def test_serialize + skip("serialize not supported") unless @db.respond_to?(:serialize) + + @db.execute("create table foo (a integer, b text)") + @db.execute("insert into foo (a, b) values (1, 'hello')") + @db.execute("insert into foo (a, b) values (2, 'world')") + + data = @db.serialize + assert_instance_of(String, data) + assert_equal(Encoding::ASCII_8BIT, data.encoding) + refute_empty(data) + assert_equal("SQLite format 3\0", data[0, 16]) + end + + def test_serialize_with_database_name + skip("serialize not supported") unless @db.respond_to?(:serialize) + + @db.execute("create table foo (a integer)") + @db.execute("insert into foo values (42)") + + data = @db.serialize("main") + assert_instance_of(String, data) + assert_equal("SQLite format 3\0", data[0, 16]) + end + + def test_serialize_closed_database + skip("serialize not supported") unless @db.respond_to?(:serialize) + + @db.close + assert_raise(SQLite3::Exception) { @db.serialize } + end + + def test_deserialize + skip("deserialize not supported") unless @db.respond_to?(:deserialize) + + # Create a source database with data + source = SQLite3::Database.new(":memory:") + source.execute("create table foo (a integer, b text)") + source.execute("insert into foo values (1, 'hello')") + source.execute("insert into foo values (2, 'world')") + + # Serialize and deserialize into target + data = source.serialize + @db.deserialize(data) + + # Verify the data was transferred + rows = @db.execute("select * from foo order by a") + assert_equal [[1, "hello"], [2, "world"]], rows + ensure + source&.close + end + + def test_deserialize_round_trip + skip("serialize/deserialize not supported") unless @db.respond_to?(:serialize) && @db.respond_to?(:deserialize) + + # Create and populate a database + @db.execute("create table users (id integer primary key, name text, score real)") + @db.execute("insert into users values (1, 'Alice', 95.5)") + @db.execute("insert into users values (2, 'Bob', 87.3)") + @db.execute("insert into users values (3, 'Charlie', 92.1)") + + # Serialize + data = @db.serialize + + # Create a new database and deserialize into it + db2 = SQLite3::Database.new(":memory:") + db2.deserialize(data) + + # Verify the data matches + original_rows = @db.execute("select * from users order by id") + restored_rows = db2.execute("select * from users order by id") + assert_equal original_rows, restored_rows + + # Verify we can still modify the restored database + db2.execute("insert into users values (4, 'Diana', 88.8)") + assert_equal 4, db2.execute("select count(*) from users").first.first + ensure + db2&.close + end + + def test_deserialize_with_database_name + skip("deserialize not supported") unless @db.respond_to?(:deserialize) + + # Create a source database + source = SQLite3::Database.new(":memory:") + source.execute("create table items (x integer)") + source.execute("insert into items values (42)") + data = source.serialize + + # Deserialize into main + @db.deserialize(data, "main") + assert_equal [[42]], @db.execute("select x from items") + ensure + source&.close + end + + def test_deserialize_closed_database + skip("deserialize not supported") unless @db.respond_to?(:deserialize) + + data = "SQLite format 3\0" + ("\0" * 84) # minimal header + @db.close + assert_raise(SQLite3::Exception) { @db.deserialize(data) } + end + + def test_deserialize_invalid_data + skip("deserialize not supported") unless @db.respond_to?(:deserialize) + + # Deserializing invalid data should raise an error when accessed + @db.deserialize("not a valid database") + assert_raise(SQLite3::Exception) { @db.execute("select 1") } + end end end