mysql-to-sqlite 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 1ab0d1cf766d8bdd6c149e69adc043a54e43f1c8e260c749004d03a03e50c8e3
4
+ data.tar.gz: 46b9f74caa805c880d96d48e9ce91560d8143a64b93795a02733e1ec20adf9f0
5
+ SHA512:
6
+ metadata.gz: 64e7c1ed0fc10ebedeb593cf55aba2ca0630eebaae6b12543476d7f7d93469986e99b40058add4bc966dad96507a5437e84df92ce021bd2fea5efde4f5a3e37e
7
+ data.tar.gz: da9e356fb107326a0ef4b5ac80b393bd42956cc299c2048563c28dc0978c5af2158cedeb9525636eafaffd814257c862195ed0c76c5eed1170d472b7168cf98b
data/LICENSE.txt ADDED
@@ -0,0 +1,15 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dean
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this
6
+ software and associated documentation files (the "Software"), to deal in the Software
7
+ without restriction, including without limitation the rights to use, copy, modify,
8
+ merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
9
+ permit persons to whom the Software is furnished to do so, subject to the following
10
+ conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all copies
13
+ or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED.
data/README.md ADDED
@@ -0,0 +1,107 @@
1
+ # mysql-to-sqlite
2
+
3
+ [![CI](https://github.com/deanpcmad/mysql-to-sqlite/actions/workflows/ci.yml/badge.svg)](https://github.com/deanpcmad/mysql-to-sqlite/actions/workflows/ci.yml)
4
+
5
+ A standalone, schema-aware copier that copies raw table data from MySQL to an existing
6
+ SQLite database through Active Record, without loading an application's models or
7
+ running callbacks.
8
+
9
+ The source and destination must have matching table columns and the same maximum
10
+ `schema_migrations.version`. The importer refuses a non-empty destination by default,
11
+ copies in batches, and validates row counts, foreign keys, and integer sequences.
12
+
13
+ ## Install
14
+
15
+ ### Requirements
16
+
17
+ - Ruby 3.3 or newer
18
+ - MySQL or MariaDB client libraries and development headers, required by `mysql2`
19
+ - SQLite libraries and development headers, required by `sqlite3`
20
+
21
+ The package names vary by operating system. Install the development packages provided
22
+ by your system package manager before installing the gem.
23
+
24
+ ### Install the command
25
+
26
+ Install the published gem for standalone use:
27
+
28
+ ```sh
29
+ gem install mysql-to-sqlite
30
+ ```
31
+
32
+ Confirm that the command is available:
33
+
34
+ ```sh
35
+ mysql-to-sqlite --version
36
+ mysql-to-sqlite --help
37
+ ```
38
+
39
+ ### Add it to an application
40
+
41
+ Add the gem to the application's development dependencies:
42
+
43
+ ```ruby
44
+ group :development do
45
+ gem "mysql-to-sqlite"
46
+ end
47
+ ```
48
+
49
+ Install the bundle and run the command through Bundler:
50
+
51
+ ```sh
52
+ bundle install
53
+ bundle exec mysql-to-sqlite --help
54
+ ```
55
+
56
+ ## Use
57
+
58
+ First create or migrate an empty SQLite database to exactly the schema version recorded
59
+ in MySQL. Then run:
60
+
61
+ ```sh
62
+ mysql-to-sqlite \
63
+ --source 'mysql2://user:password@127.0.0.1/database' \
64
+ --destination /absolute/path/to/database.sqlite3 \
65
+ --yes
66
+ ```
67
+
68
+ The URLs may instead be supplied as `MYSQL_SOURCE_URL` and
69
+ `SQLITE_DATABASE_PATH`. Run `mysql-to-sqlite --help` for all options, or prefix the
70
+ command with `bundle exec` when it is installed through an application's bundle.
71
+
72
+ `--replace` deletes records from matching destination tables before copying.
73
+ `--ignore-source-only` skips tables found only in MySQL; use it only after inspecting
74
+ why the schemas differ.
75
+
76
+ Stop all writes during the final import and keep verified backups. This tool copies
77
+ database records only; application uploads and encryption keys must be preserved
78
+ separately.
79
+
80
+ ## Test
81
+
82
+ The unit tests do not require a database server:
83
+
84
+ ```sh
85
+ bundle exec rake test
86
+ ```
87
+
88
+ The integration suite restores the SQL dumps under `test/fixtures/mysql` into a
89
+ disposable MySQL database and imports them into temporary SQLite files. Start the
90
+ provided MySQL 8.4 service and opt into those tests:
91
+
92
+ ```sh
93
+ docker compose -f compose.test.yml up --detach --wait
94
+
95
+ MYSQL_TEST_URL='mysql2://root:test-password@127.0.0.1:3307/mysql_to_sqlite_test' \
96
+ bundle exec rake test
97
+
98
+ docker compose -f compose.test.yml down
99
+ ```
100
+
101
+ Set `MYSQL_TEST_PORT` on both Compose commands and use the same port in
102
+ `MYSQL_TEST_URL` when port 3307 is unavailable.
103
+
104
+ The fixtures cover relationships, explicit primary keys and sequences, Unicode,
105
+ binary and null values, replacement mode, source-only tables, schema mismatches, and
106
+ migration-version mismatches. When `MYSQL_TEST_URL` is absent, integration cases are
107
+ reported as skipped.
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "optparse"
4
+
5
+ options = {
6
+ batch_size: ENV.fetch("IMPORT_BATCH_SIZE", 1_000),
7
+ destination: ENV["SQLITE_DATABASE_PATH"],
8
+ source_url: ENV["MYSQL_SOURCE_URL"]
9
+ }
10
+
11
+ parser = OptionParser.new do |opts|
12
+ opts.banner = "Usage: mysql-to-sqlite --source MYSQL_URL --destination FILE --yes"
13
+ opts.on("--source URL", "MySQL URL (or MYSQL_SOURCE_URL)") { |url| options[:source_url] = url }
14
+ opts.on("--destination FILE", "Existing SQLite database (or SQLITE_DATABASE_PATH)") { |file| options[:destination] = file }
15
+ opts.on("--batch-size N", Integer, "Rows copied per batch") { |size| options[:batch_size] = size }
16
+ opts.on("--ignore-source-only", "Skip tables that exist only in MySQL") { options[:ignore_source_only] = true }
17
+ opts.on("--replace", "Clear destination records before importing") { options[:replace] = true }
18
+ opts.on("--yes", "Confirm the import") { options[:confirmed] = true }
19
+ opts.on("-v", "--version", "Show version") do
20
+ require_relative "../lib/mysql_to_sqlite/version"
21
+ puts MysqlToSqlite::VERSION
22
+ exit
23
+ end
24
+ opts.on("-h", "--help", "Show this help") do
25
+ puts opts
26
+ exit
27
+ end
28
+ end
29
+ parser.parse!
30
+
31
+ abort "Pass --source or set MYSQL_SOURCE_URL" if options[:source_url].to_s.empty?
32
+ abort "Pass --destination or set SQLITE_DATABASE_PATH" if options[:destination].to_s.empty?
33
+ abort "Destination does not exist: #{options[:destination]}" unless File.file?(options[:destination])
34
+ abort "Pass --yes after verifying the destination path and backups" unless options[:confirmed]
35
+
36
+ require_relative "../lib/mysql_to_sqlite"
37
+
38
+ puts "Importing MySQL data into #{File.expand_path(options[:destination])}"
39
+ MysqlToSqlite::Importer.new(**options.slice(
40
+ :source_url, :destination, :batch_size, :ignore_source_only, :replace
41
+ )).run!
@@ -0,0 +1,237 @@
1
+ module MysqlToSqlite
2
+ class Importer
3
+ class SourceRecord < ActiveRecord::Base
4
+ self.abstract_class = true
5
+ end
6
+
7
+ class TargetRecord < ActiveRecord::Base
8
+ self.abstract_class = true
9
+ end
10
+
11
+ INTERNAL_TABLES = %w[ar_internal_metadata schema_migrations].freeze
12
+
13
+ def initialize(source_url:, destination:, batch_size: 1_000, replace: false,
14
+ ignore_source_only: false, output: $stdout)
15
+ @source_url = source_url
16
+ @destination = destination
17
+ @batch_size = Integer(batch_size)
18
+ raise ArgumentError, "batch size must be positive" unless @batch_size.positive?
19
+
20
+ @replace = replace
21
+ @ignore_source_only = ignore_source_only
22
+ @output = output
23
+ end
24
+
25
+ def run!
26
+ connect!
27
+ verify_databases!
28
+ discover_tables!
29
+ verify_migration_versions!
30
+ verify_table_sets!
31
+ verify_table_schemas!
32
+ report_plan
33
+
34
+ without_target_foreign_keys do
35
+ TargetRecord.transaction do
36
+ clear_target! if @replace
37
+ verify_target_is_empty!
38
+ reset_target_sequences!
39
+ tables.each { |table| copy_table(table) }
40
+ validate!
41
+ end
42
+ end
43
+
44
+ output.puts "Import completed successfully."
45
+ ensure
46
+ SourceRecord.connection_pool.disconnect! if SourceRecord.connected?
47
+ TargetRecord.connection_pool.disconnect! if TargetRecord.connected?
48
+ end
49
+
50
+ private
51
+
52
+ attr_reader :batch_size, :output, :source, :tables, :target
53
+
54
+ def connect!
55
+ require "mysql2"
56
+ require "sqlite3"
57
+
58
+ SourceRecord.establish_connection(@source_url)
59
+ TargetRecord.establish_connection(adapter: "sqlite3", database: @destination)
60
+ @source = SourceRecord.connection
61
+ @target = TargetRecord.connection
62
+ end
63
+
64
+ def verify_databases!
65
+ raise ArgumentError, "source URL must use the mysql2 adapter" unless source.adapter_name == "Mysql2"
66
+ raise "The destination database must use SQLite" unless target.adapter_name == "SQLite"
67
+ end
68
+
69
+ def discover_tables!
70
+ @source_tables = source.data_sources - INTERNAL_TABLES
71
+ @target_tables = target.data_sources - INTERNAL_TABLES
72
+ @tables = (@source_tables & @target_tables).sort
73
+ end
74
+
75
+ def verify_table_sets!
76
+ source_only = @source_tables - @target_tables
77
+ target_only = @target_tables - @source_tables
78
+
79
+ if source_only.any? && !@ignore_source_only
80
+ raise compact(<<~MESSAGE)
81
+ Source-only tables found: #{source_only.join(", ")}.
82
+ Prepare the destination at the source migration version, or pass
83
+ --ignore-source-only after verifying these tables can be skipped.
84
+ MESSAGE
85
+ end
86
+
87
+ if target_only.any?
88
+ raise compact(<<~MESSAGE)
89
+ Destination-only tables found: #{target_only.join(", ")}.
90
+ Prepare the destination at the same migration version as the source.
91
+ MESSAGE
92
+ end
93
+
94
+ raise "No application tables were found to import" if tables.empty?
95
+ output.puts "Skipping source-only tables: #{source_only.join(", ")}" if source_only.any?
96
+ end
97
+
98
+ def verify_table_schemas!
99
+ mismatches = tables.filter_map do |table|
100
+ source_columns = source.columns(table).map(&:name)
101
+ target_columns = target.columns(table).map(&:name)
102
+ next if source_columns.sort == target_columns.sort
103
+
104
+ missing = target_columns - source_columns
105
+ extra = source_columns - target_columns
106
+ "#{table} (missing: #{missing.inspect}, extra: #{extra.inspect})"
107
+ end
108
+ raise "Schema mismatch: #{mismatches.join("; ")}" if mismatches.any?
109
+ end
110
+
111
+ def verify_migration_versions!
112
+ source_version = migration_version(source)
113
+ target_version = migration_version(target)
114
+ return if source_version == target_version
115
+
116
+ raise compact(<<~MESSAGE)
117
+ Migration versions differ (source: #{source_version || "unknown"},
118
+ destination: #{target_version || "unknown"}). Prepare the destination at the
119
+ source version before importing.
120
+ MESSAGE
121
+ end
122
+
123
+ def report_plan
124
+ output.puts "Source migration version: #{migration_version(source) || "unknown"}"
125
+ output.puts "Destination migration version: #{migration_version(target) || "unknown"}"
126
+ output.puts "Tables to copy: #{tables.join(", ")}"
127
+ end
128
+
129
+ def migration_version(connection)
130
+ return unless connection.data_source_exists?("schema_migrations")
131
+
132
+ connection.select_value("SELECT MAX(version) FROM schema_migrations")
133
+ end
134
+
135
+ def without_target_foreign_keys
136
+ target.execute("PRAGMA foreign_keys = OFF")
137
+ yield
138
+ ensure
139
+ target.execute("PRAGMA foreign_keys = ON") if target
140
+ end
141
+
142
+ def verify_target_is_empty!
143
+ populated = tables.filter_map do |table|
144
+ count = target_count(table)
145
+ "#{table}=#{count}" if count.positive?
146
+ end
147
+ raise "Destination must be empty before import (#{populated.join(", ")})" if populated.any?
148
+ end
149
+
150
+ def clear_target!
151
+ tables.reverse_each do |table|
152
+ target.execute("DELETE FROM #{target.quote_table_name(table)}")
153
+ end
154
+ end
155
+
156
+ def reset_target_sequences!
157
+ return unless sqlite_sequence_table?
158
+
159
+ names = tables.map { |table| target.quote(table) }.join(", ")
160
+ target.execute("DELETE FROM sqlite_sequence WHERE name IN (#{names})")
161
+ end
162
+
163
+ def copy_table(table)
164
+ columns = source.columns(table).map(&:name)
165
+ record_class = Class.new(TargetRecord) do
166
+ self.table_name = table
167
+ self.inheritance_column = :_type_disabled
168
+ end
169
+ copied = 0
170
+ order = source.primary_key(table) || columns.first
171
+
172
+ loop do
173
+ rows = source.select_all(compact(<<~SQL)).to_a
174
+ SELECT #{columns.map { |column| source.quote_column_name(column) }.join(", ")}
175
+ FROM #{source.quote_table_name(table)}
176
+ ORDER BY #{source.quote_column_name(order)}
177
+ LIMIT #{batch_size} OFFSET #{copied}
178
+ SQL
179
+ break if rows.empty?
180
+
181
+ record_class.insert_all!(rows, record_timestamps: false)
182
+ copied += rows.length
183
+ output.puts "#{table}: copied #{copied}" if rows.length == batch_size
184
+ end
185
+ output.puts "#{table}: #{copied} row(s)"
186
+ end
187
+
188
+ def validate!
189
+ failures = tables.filter_map do |table|
190
+ source_count = source.select_value(
191
+ "SELECT COUNT(*) FROM #{source.quote_table_name(table)}"
192
+ ).to_i
193
+ destination_count = target_count(table)
194
+ "#{table}: source=#{source_count}, destination=#{destination_count}" unless source_count == destination_count
195
+ end
196
+ raise "Row-count validation failed (#{failures.join("; ")})" if failures.any?
197
+
198
+ violations = target.select_all("PRAGMA foreign_key_check")
199
+ raise "Foreign-key validation failed: #{violations.to_a.inspect}" if violations.any?
200
+
201
+ validate_sequences!
202
+ end
203
+
204
+ def validate_sequences!
205
+ return unless sqlite_sequence_table?
206
+
207
+ tables.each do |table|
208
+ primary_key = target.primary_key(table)
209
+ column = target.columns(table).find { |candidate| candidate.name == primary_key }
210
+ next unless column&.type == :integer
211
+
212
+ max_id = target.select_value(
213
+ "SELECT MAX(#{target.quote_column_name(primary_key)}) FROM #{target.quote_table_name(table)}"
214
+ ).to_i
215
+ sequence = target.select_value(
216
+ "SELECT seq FROM sqlite_sequence WHERE name = #{target.quote(table)}"
217
+ ).to_i
218
+ raise "Sequence validation failed for #{table}" unless max_id == sequence
219
+ end
220
+ end
221
+
222
+ def target_count(table)
223
+ target.select_value("SELECT COUNT(*) FROM #{target.quote_table_name(table)}").to_i
224
+ end
225
+
226
+ def sqlite_sequence_table?
227
+ target.select_value(<<~SQL)&.to_s&.length&.positive?
228
+ SELECT name FROM sqlite_master
229
+ WHERE type = 'table' AND name = 'sqlite_sequence'
230
+ SQL
231
+ end
232
+
233
+ def compact(string)
234
+ string.lines.map(&:strip).join(" ")
235
+ end
236
+ end
237
+ end
@@ -0,0 +1,3 @@
1
+ module MysqlToSqlite
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,4 @@
1
+ require "active_record"
2
+
3
+ require_relative "mysql_to_sqlite/importer"
4
+ require_relative "mysql_to_sqlite/version"
metadata ADDED
@@ -0,0 +1,94 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: mysql-to-sqlite
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Dean Perry
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: activerecord
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.2'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '9'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '7.2'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '9'
32
+ - !ruby/object:Gem::Dependency
33
+ name: mysql2
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - "~>"
37
+ - !ruby/object:Gem::Version
38
+ version: '0.5'
39
+ type: :runtime
40
+ prerelease: false
41
+ version_requirements: !ruby/object:Gem::Requirement
42
+ requirements:
43
+ - - "~>"
44
+ - !ruby/object:Gem::Version
45
+ version: '0.5'
46
+ - !ruby/object:Gem::Dependency
47
+ name: sqlite3
48
+ requirement: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - ">="
51
+ - !ruby/object:Gem::Version
52
+ version: '2.1'
53
+ type: :runtime
54
+ prerelease: false
55
+ version_requirements: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - ">="
58
+ - !ruby/object:Gem::Version
59
+ version: '2.1'
60
+ description: A schema-aware MySQL-to-SQLite data importer built on Active Record.
61
+ email:
62
+ - dean@deanpcmad.com
63
+ executables:
64
+ - mysql-to-sqlite
65
+ extensions: []
66
+ extra_rdoc_files: []
67
+ files:
68
+ - LICENSE.txt
69
+ - README.md
70
+ - bin/mysql-to-sqlite
71
+ - lib/mysql_to_sqlite.rb
72
+ - lib/mysql_to_sqlite/importer.rb
73
+ - lib/mysql_to_sqlite/version.rb
74
+ licenses:
75
+ - MIT
76
+ metadata: {}
77
+ rdoc_options: []
78
+ require_paths:
79
+ - lib
80
+ required_ruby_version: !ruby/object:Gem::Requirement
81
+ requirements:
82
+ - - ">="
83
+ - !ruby/object:Gem::Version
84
+ version: '3.3'
85
+ required_rubygems_version: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ requirements: []
91
+ rubygems_version: 4.0.16
92
+ specification_version: 4
93
+ summary: Copy a version-matched MySQL database into SQLite
94
+ test_files: []