better_structure_sql 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 +7 -0
- data/CHANGELOG.md +41 -0
- data/LICENSE +21 -0
- data/README.md +557 -0
- data/app/controllers/better_structure_sql/application_controller.rb +61 -0
- data/app/controllers/better_structure_sql/schema_versions_controller.rb +243 -0
- data/app/helpers/better_structure_sql/schema_versions_helper.rb +46 -0
- data/app/views/better_structure_sql/schema_versions/index.html.erb +110 -0
- data/app/views/better_structure_sql/schema_versions/show.html.erb +186 -0
- data/app/views/layouts/better_structure_sql/application.html.erb +105 -0
- data/config/database.yml +3 -0
- data/config/routes.rb +12 -0
- data/lib/better_structure_sql/adapters/base_adapter.rb +234 -0
- data/lib/better_structure_sql/adapters/mysql_adapter.rb +476 -0
- data/lib/better_structure_sql/adapters/mysql_config.rb +32 -0
- data/lib/better_structure_sql/adapters/postgresql_adapter.rb +646 -0
- data/lib/better_structure_sql/adapters/postgresql_config.rb +25 -0
- data/lib/better_structure_sql/adapters/registry.rb +115 -0
- data/lib/better_structure_sql/adapters/sqlite_adapter.rb +644 -0
- data/lib/better_structure_sql/adapters/sqlite_config.rb +26 -0
- data/lib/better_structure_sql/configuration.rb +129 -0
- data/lib/better_structure_sql/database_version.rb +46 -0
- data/lib/better_structure_sql/dependency_resolver.rb +63 -0
- data/lib/better_structure_sql/dumper.rb +544 -0
- data/lib/better_structure_sql/engine.rb +28 -0
- data/lib/better_structure_sql/file_writer.rb +180 -0
- data/lib/better_structure_sql/formatter.rb +70 -0
- data/lib/better_structure_sql/generators/base.rb +33 -0
- data/lib/better_structure_sql/generators/domain_generator.rb +22 -0
- data/lib/better_structure_sql/generators/extension_generator.rb +23 -0
- data/lib/better_structure_sql/generators/foreign_key_generator.rb +43 -0
- data/lib/better_structure_sql/generators/function_generator.rb +33 -0
- data/lib/better_structure_sql/generators/index_generator.rb +50 -0
- data/lib/better_structure_sql/generators/materialized_view_generator.rb +31 -0
- data/lib/better_structure_sql/generators/pragma_generator.rb +23 -0
- data/lib/better_structure_sql/generators/sequence_generator.rb +27 -0
- data/lib/better_structure_sql/generators/table_generator.rb +126 -0
- data/lib/better_structure_sql/generators/trigger_generator.rb +54 -0
- data/lib/better_structure_sql/generators/type_generator.rb +47 -0
- data/lib/better_structure_sql/generators/view_generator.rb +27 -0
- data/lib/better_structure_sql/introspection/extensions.rb +29 -0
- data/lib/better_structure_sql/introspection/foreign_keys.rb +29 -0
- data/lib/better_structure_sql/introspection/functions.rb +29 -0
- data/lib/better_structure_sql/introspection/indexes.rb +29 -0
- data/lib/better_structure_sql/introspection/sequences.rb +29 -0
- data/lib/better_structure_sql/introspection/tables.rb +29 -0
- data/lib/better_structure_sql/introspection/triggers.rb +29 -0
- data/lib/better_structure_sql/introspection/types.rb +37 -0
- data/lib/better_structure_sql/introspection/views.rb +41 -0
- data/lib/better_structure_sql/introspection.rb +31 -0
- data/lib/better_structure_sql/manifest_generator.rb +65 -0
- data/lib/better_structure_sql/migration_patch.rb +196 -0
- data/lib/better_structure_sql/pg_version.rb +44 -0
- data/lib/better_structure_sql/railtie.rb +124 -0
- data/lib/better_structure_sql/schema_loader.rb +168 -0
- data/lib/better_structure_sql/schema_version.rb +86 -0
- data/lib/better_structure_sql/schema_versions.rb +213 -0
- data/lib/better_structure_sql/version.rb +5 -0
- data/lib/better_structure_sql/zip_generator.rb +81 -0
- data/lib/better_structure_sql.rb +81 -0
- data/lib/generators/better_structure_sql/install_generator.rb +44 -0
- data/lib/generators/better_structure_sql/migration_generator.rb +34 -0
- data/lib/generators/better_structure_sql/templates/README +49 -0
- data/lib/generators/better_structure_sql/templates/add_metadata_migration.rb.erb +25 -0
- data/lib/generators/better_structure_sql/templates/better_structure_sql.rb +46 -0
- data/lib/generators/better_structure_sql/templates/migration.rb.erb +26 -0
- data/lib/tasks/better_structure_sql.rake +190 -0
- metadata +299 -0
|
@@ -0,0 +1,644 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterStructureSql
|
|
4
|
+
module Adapters
|
|
5
|
+
# SQLite adapter implementing introspection via sqlite_master and PRAGMA statements
|
|
6
|
+
#
|
|
7
|
+
# Provides SQLite-specific SQL generation with proper dialect support.
|
|
8
|
+
# This adapter handles SQLite's unique features including PRAGMA settings,
|
|
9
|
+
# inline foreign keys, AUTOINCREMENT, and type affinities.
|
|
10
|
+
class SqliteAdapter < BaseAdapter
|
|
11
|
+
# Introspection methods using sqlite_master and PRAGMA
|
|
12
|
+
|
|
13
|
+
# Fetch extensions (PRAGMA settings for SQLite)
|
|
14
|
+
#
|
|
15
|
+
# @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] Database connection
|
|
16
|
+
# @return [Array<Hash>] Array of PRAGMA setting hashes with :name, :value, :sql
|
|
17
|
+
# @note SQLite doesn't support extensions like PostgreSQL, but returns important PRAGMA settings
|
|
18
|
+
def fetch_extensions(connection)
|
|
19
|
+
# SQLite doesn't support extensions like PostgreSQL, but we can fetch PRAGMA settings
|
|
20
|
+
# Return them in a format compatible with the extensions section
|
|
21
|
+
fetch_pragma_settings(connection)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Fetch important PRAGMA settings for SQLite
|
|
25
|
+
#
|
|
26
|
+
# @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] Database connection
|
|
27
|
+
# @return [Array<Hash>] Array of PRAGMA hashes with :name, :value, :sql
|
|
28
|
+
def fetch_pragma_settings(connection)
|
|
29
|
+
# List of important PRAGMAs to preserve in schema dump
|
|
30
|
+
important_pragmas = %w[
|
|
31
|
+
foreign_keys
|
|
32
|
+
recursive_triggers
|
|
33
|
+
defer_foreign_keys
|
|
34
|
+
journal_mode
|
|
35
|
+
synchronous
|
|
36
|
+
temp_store
|
|
37
|
+
locking_mode
|
|
38
|
+
auto_vacuum
|
|
39
|
+
cache_size
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
pragmas = []
|
|
43
|
+
important_pragmas.each do |pragma_name|
|
|
44
|
+
result = connection.execute("PRAGMA #{pragma_name}").first
|
|
45
|
+
next unless result
|
|
46
|
+
|
|
47
|
+
value = result.is_a?(Hash) ? (result[pragma_name] || result.values.first) : result[0]
|
|
48
|
+
|
|
49
|
+
# Only include non-default values that make sense to preserve
|
|
50
|
+
next if value.nil? || value.to_s.empty?
|
|
51
|
+
next if pragma_name == 'foreign_keys' && value.to_i.zero? # Skip if FK disabled
|
|
52
|
+
|
|
53
|
+
pragmas << {
|
|
54
|
+
name: pragma_name,
|
|
55
|
+
value: value,
|
|
56
|
+
sql: "PRAGMA #{pragma_name} = #{format_pragma_value(pragma_name, value)};"
|
|
57
|
+
}
|
|
58
|
+
rescue StandardError => e
|
|
59
|
+
# Skip PRAGMAs that fail (might not be supported in this SQLite version)
|
|
60
|
+
Rails.logger.debug { "Skipping PRAGMA #{pragma_name}: #{e.message}" } if defined?(Rails)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
pragmas
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Format PRAGMA value for SQL statement
|
|
67
|
+
#
|
|
68
|
+
# @param pragma_name [String] Name of the PRAGMA
|
|
69
|
+
# @param value [Object] Value of the PRAGMA
|
|
70
|
+
# @return [String] Formatted value (quoted if string, unquoted if numeric)
|
|
71
|
+
def format_pragma_value(pragma_name, value)
|
|
72
|
+
# String values need quotes, numeric values don't
|
|
73
|
+
case pragma_name
|
|
74
|
+
when 'journal_mode', 'locking_mode', 'temp_store', 'synchronous'
|
|
75
|
+
value.to_s.match?(/^\d+$/) ? value.to_s : "'#{value}'"
|
|
76
|
+
else
|
|
77
|
+
value.to_s
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Fetch custom types (not supported in SQLite)
|
|
82
|
+
#
|
|
83
|
+
# @param _connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] Database connection (unused)
|
|
84
|
+
# @return [Array] Empty array as SQLite doesn't support custom types
|
|
85
|
+
def fetch_custom_types(_connection)
|
|
86
|
+
# SQLite doesn't support custom types
|
|
87
|
+
[]
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Fetch all tables from the database
|
|
91
|
+
#
|
|
92
|
+
# @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] Database connection
|
|
93
|
+
# @return [Array<Hash>] Array of table hashes with :name, :schema, :sql, :columns, :primary_key, :constraints
|
|
94
|
+
def fetch_tables(connection)
|
|
95
|
+
query = <<~SQL.squish
|
|
96
|
+
SELECT name, sql
|
|
97
|
+
FROM sqlite_master
|
|
98
|
+
WHERE type = 'table'
|
|
99
|
+
AND name NOT LIKE 'sqlite_%'
|
|
100
|
+
AND name != 'schema_migrations'
|
|
101
|
+
AND name != 'ar_internal_metadata'
|
|
102
|
+
ORDER BY name
|
|
103
|
+
SQL
|
|
104
|
+
|
|
105
|
+
connection.execute(query).map do |row|
|
|
106
|
+
table_name = row['name'] || row[0]
|
|
107
|
+
{
|
|
108
|
+
name: table_name,
|
|
109
|
+
schema: 'main',
|
|
110
|
+
sql: row['sql'] || row[1],
|
|
111
|
+
columns: fetch_columns(connection, table_name),
|
|
112
|
+
primary_key: fetch_primary_key(connection, table_name),
|
|
113
|
+
constraints: fetch_constraints(connection, table_name)
|
|
114
|
+
}
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Fetch all indexes from the database
|
|
119
|
+
#
|
|
120
|
+
# @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] Database connection
|
|
121
|
+
# @return [Array<Hash>] Array of index hashes with :table, :name, :columns, :unique, :type, :definition
|
|
122
|
+
def fetch_indexes(connection)
|
|
123
|
+
tables = fetch_table_names(connection)
|
|
124
|
+
indexes = []
|
|
125
|
+
skip_origins = %w[pk u].freeze
|
|
126
|
+
|
|
127
|
+
tables.each do |table_name|
|
|
128
|
+
# Get list of indexes for this table
|
|
129
|
+
index_list = connection.execute("PRAGMA index_list(#{quote_identifier(table_name)})")
|
|
130
|
+
|
|
131
|
+
index_list.each do |index_row|
|
|
132
|
+
index_name = index_row['name'] || index_row[1]
|
|
133
|
+
is_unique = (index_row['unique'] || index_row[2]).to_i == 1
|
|
134
|
+
origin = index_row['origin'] || index_row[3] # 'c' = CREATE INDEX, 'u' = UNIQUE constraint, 'pk' = PRIMARY KEY
|
|
135
|
+
|
|
136
|
+
# Skip auto-generated indexes for PRIMARY KEY and UNIQUE constraints
|
|
137
|
+
next if skip_origins.include?(origin)
|
|
138
|
+
|
|
139
|
+
# Get columns for this index
|
|
140
|
+
index_info = connection.execute("PRAGMA index_info(#{quote_identifier(index_name)})")
|
|
141
|
+
columns = index_info.map { |col_row| col_row['name'] || col_row[2] }
|
|
142
|
+
|
|
143
|
+
# Generate CREATE INDEX SQL for compatibility with Dumper/IndexGenerator
|
|
144
|
+
unique_clause = is_unique ? 'UNIQUE ' : ''
|
|
145
|
+
columns_clause = columns.map { |col| quote_identifier(col) }.join(', ')
|
|
146
|
+
definition = "CREATE #{unique_clause}INDEX #{quote_identifier(index_name)} " \
|
|
147
|
+
"ON #{quote_identifier(table_name)} (#{columns_clause})"
|
|
148
|
+
|
|
149
|
+
indexes << {
|
|
150
|
+
table: table_name,
|
|
151
|
+
name: index_name,
|
|
152
|
+
columns: columns,
|
|
153
|
+
unique: is_unique,
|
|
154
|
+
type: 'BTREE', # SQLite uses B-tree by default
|
|
155
|
+
definition: definition # Add definition field for compatibility with IndexGenerator
|
|
156
|
+
}
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
indexes
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Fetch all foreign keys from the database
|
|
164
|
+
#
|
|
165
|
+
# @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] Database connection
|
|
166
|
+
# @return [Array<Hash>] Array of foreign key hashes with :table, :name, :column, :foreign_table, :foreign_column, :on_update, :on_delete
|
|
167
|
+
def fetch_foreign_keys(connection)
|
|
168
|
+
tables = fetch_table_names(connection)
|
|
169
|
+
foreign_keys = []
|
|
170
|
+
|
|
171
|
+
tables.each do |table_name|
|
|
172
|
+
fk_list = connection.execute("PRAGMA foreign_key_list(#{quote_identifier(table_name)})")
|
|
173
|
+
|
|
174
|
+
fk_list.each do |fk_row|
|
|
175
|
+
from_col = fk_row['from'] || fk_row[3]
|
|
176
|
+
to_table = fk_row['table'] || fk_row[2]
|
|
177
|
+
to_col = fk_row['to'] || fk_row[4]
|
|
178
|
+
|
|
179
|
+
foreign_keys << {
|
|
180
|
+
table: table_name,
|
|
181
|
+
name: "fk_#{table_name}_#{to_table}_#{from_col}", # Generate name
|
|
182
|
+
column: from_col,
|
|
183
|
+
foreign_table: to_table,
|
|
184
|
+
foreign_column: to_col,
|
|
185
|
+
on_update: fk_row['on_update'] || fk_row[5],
|
|
186
|
+
on_delete: fk_row['on_delete'] || fk_row[6]
|
|
187
|
+
}
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
foreign_keys
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Fetch all views from the database
|
|
195
|
+
#
|
|
196
|
+
# @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] Database connection
|
|
197
|
+
# @return [Array<Hash>] Array of view hashes with :schema, :name, :definition, :updatable
|
|
198
|
+
def fetch_views(connection)
|
|
199
|
+
query = <<~SQL.squish
|
|
200
|
+
SELECT name, sql
|
|
201
|
+
FROM sqlite_master
|
|
202
|
+
WHERE type = 'view'
|
|
203
|
+
ORDER BY name
|
|
204
|
+
SQL
|
|
205
|
+
|
|
206
|
+
connection.execute(query).map do |row|
|
|
207
|
+
sql = row['sql'] || row[1]
|
|
208
|
+
# Extract just the SELECT part from CREATE VIEW statement for compatibility
|
|
209
|
+
# with existing ViewGenerator
|
|
210
|
+
definition = if sql&.match(/CREATE\s+VIEW\s+\w+\s+AS\s+(.*)/im)
|
|
211
|
+
::Regexp.last_match(1)
|
|
212
|
+
else
|
|
213
|
+
sql
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
{
|
|
217
|
+
schema: 'main',
|
|
218
|
+
name: row['name'] || row[0],
|
|
219
|
+
definition: definition || '',
|
|
220
|
+
updatable: false # SQLite views are generally not updatable
|
|
221
|
+
}
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Fetch materialized views (not supported in SQLite)
|
|
226
|
+
#
|
|
227
|
+
# @param _connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] Database connection (unused)
|
|
228
|
+
# @return [Array] Empty array as SQLite doesn't support materialized views
|
|
229
|
+
def fetch_materialized_views(_connection)
|
|
230
|
+
# SQLite doesn't support materialized views
|
|
231
|
+
[]
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Fetch functions (not supported in SQLite)
|
|
235
|
+
#
|
|
236
|
+
# @param _connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] Database connection (unused)
|
|
237
|
+
# @return [Array] Empty array as SQLite doesn't support stored procedures/functions
|
|
238
|
+
# @note SQLite only supports user-defined functions in C/Ruby, not SQL stored procedures
|
|
239
|
+
def fetch_functions(_connection)
|
|
240
|
+
# SQLite doesn't support stored procedures/functions (only user-defined functions in C/Ruby)
|
|
241
|
+
[]
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Fetch sequences (not supported in SQLite)
|
|
245
|
+
#
|
|
246
|
+
# @param _connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] Database connection (unused)
|
|
247
|
+
# @return [Array] Empty array as SQLite doesn't support sequences (uses AUTOINCREMENT instead)
|
|
248
|
+
def fetch_sequences(_connection)
|
|
249
|
+
# SQLite doesn't have sequences (uses AUTOINCREMENT)
|
|
250
|
+
[]
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Fetch all triggers from the database
|
|
254
|
+
#
|
|
255
|
+
# @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] Database connection
|
|
256
|
+
# @return [Array<Hash>] Array of trigger hashes with :schema, :name, :table_name, :timing, :event, :definition
|
|
257
|
+
def fetch_triggers(connection)
|
|
258
|
+
query = <<~SQL.squish
|
|
259
|
+
SELECT name, tbl_name, sql
|
|
260
|
+
FROM sqlite_master
|
|
261
|
+
WHERE type = 'trigger'
|
|
262
|
+
ORDER BY tbl_name, name
|
|
263
|
+
SQL
|
|
264
|
+
|
|
265
|
+
connection.execute(query).map do |row|
|
|
266
|
+
# Parse timing and event from SQL
|
|
267
|
+
sql = row['sql'] || row[2] || ''
|
|
268
|
+
timing_match = sql.match(/\b(BEFORE|AFTER|INSTEAD OF)\b/i)
|
|
269
|
+
timing = timing_match ? timing_match.captures.first.upcase : 'AFTER'
|
|
270
|
+
|
|
271
|
+
event_match = sql.match(/\b(INSERT|UPDATE|DELETE)\b/i)
|
|
272
|
+
event = event_match ? event_match.captures.first.upcase : 'INSERT'
|
|
273
|
+
|
|
274
|
+
{
|
|
275
|
+
schema: 'main',
|
|
276
|
+
name: row['name'] || row[0],
|
|
277
|
+
table_name: row['tbl_name'] || row[1],
|
|
278
|
+
timing: timing,
|
|
279
|
+
event: event,
|
|
280
|
+
definition: sql # Use 'definition' to match PostgreSQL adapter
|
|
281
|
+
}
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# Capability methods - SQLite feature support
|
|
286
|
+
|
|
287
|
+
# Indicates whether SQLite supports extensions
|
|
288
|
+
#
|
|
289
|
+
# @return [Boolean] Always false for SQLite
|
|
290
|
+
def supports_extensions?
|
|
291
|
+
false
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# Indicates whether SQLite supports materialized views
|
|
295
|
+
#
|
|
296
|
+
# @return [Boolean] Always false for SQLite
|
|
297
|
+
def supports_materialized_views?
|
|
298
|
+
false
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# Indicates whether SQLite supports custom types
|
|
302
|
+
#
|
|
303
|
+
# @return [Boolean] Always false for SQLite
|
|
304
|
+
def supports_custom_types?
|
|
305
|
+
false
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# Indicates whether SQLite supports domains
|
|
309
|
+
#
|
|
310
|
+
# @return [Boolean] Always false for SQLite
|
|
311
|
+
def supports_domains?
|
|
312
|
+
false
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# Indicates whether SQLite supports stored procedures/functions
|
|
316
|
+
#
|
|
317
|
+
# @return [Boolean] Always false (no stored procedures/functions in SQLite)
|
|
318
|
+
def supports_functions?
|
|
319
|
+
false # No stored procedures/functions
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
# Indicates whether SQLite supports triggers
|
|
323
|
+
#
|
|
324
|
+
# @return [Boolean] Always true for SQLite
|
|
325
|
+
def supports_triggers?
|
|
326
|
+
true
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# Indicates whether SQLite supports sequences
|
|
330
|
+
#
|
|
331
|
+
# @return [Boolean] Always false (uses AUTOINCREMENT instead)
|
|
332
|
+
def supports_sequences?
|
|
333
|
+
false # Uses AUTOINCREMENT instead
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# Indicates whether SQLite supports check constraints
|
|
337
|
+
#
|
|
338
|
+
# @return [Boolean] Always true (SQLite has always supported CHECK constraints)
|
|
339
|
+
def supports_check_constraints?
|
|
340
|
+
true # SQLite has always supported CHECK constraints
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# SQL Generation methods - SQLite-specific syntax
|
|
344
|
+
|
|
345
|
+
# Generate CREATE TABLE statement for SQLite
|
|
346
|
+
#
|
|
347
|
+
# @param table [Hash] Table hash with :name, :columns, :primary_key, :foreign_keys
|
|
348
|
+
# @return [String] CREATE TABLE SQL statement
|
|
349
|
+
def generate_table(table)
|
|
350
|
+
sql = table[:sql]
|
|
351
|
+
return sql if sql # Use original SQL from sqlite_master if available
|
|
352
|
+
|
|
353
|
+
# Generate from columns if needed
|
|
354
|
+
lines = ["CREATE TABLE #{quote_identifier(table[:name])} ("]
|
|
355
|
+
|
|
356
|
+
column_defs = table[:columns].map do |col|
|
|
357
|
+
generate_column_definition(col, table[:primary_key])
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# Add foreign keys inline if present
|
|
361
|
+
table[:foreign_keys]&.each do |fk|
|
|
362
|
+
column_defs << generate_foreign_key_inline(fk)
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
lines << column_defs.map { |col_def| " #{col_def}" }.join(",\n")
|
|
366
|
+
lines << ');'
|
|
367
|
+
|
|
368
|
+
lines.join("\n")
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
# Generate CREATE INDEX statement for SQLite
|
|
372
|
+
#
|
|
373
|
+
# @param index [Hash] Index hash with :name, :table, :columns, :unique
|
|
374
|
+
# @return [String] CREATE INDEX SQL statement
|
|
375
|
+
def generate_index(index)
|
|
376
|
+
unique = index[:unique] ? 'UNIQUE ' : ''
|
|
377
|
+
columns = index[:columns].map { |col| quote_identifier(col) }.join(', ')
|
|
378
|
+
|
|
379
|
+
"CREATE #{unique}INDEX #{quote_identifier(index[:name])} " \
|
|
380
|
+
"ON #{quote_identifier(index[:table])} (#{columns});"
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
# Generate foreign key constraint (inline with table definition)
|
|
384
|
+
#
|
|
385
|
+
# @param fk [Hash] Foreign key hash with :column, :foreign_table, :foreign_column, :on_delete, :on_update
|
|
386
|
+
# @return [String] FOREIGN KEY constraint SQL
|
|
387
|
+
# @note SQLite requires foreign keys inline with CREATE TABLE
|
|
388
|
+
# rubocop:disable Naming/MethodParameterName
|
|
389
|
+
def generate_foreign_key(fk)
|
|
390
|
+
# SQLite requires foreign keys inline with CREATE TABLE
|
|
391
|
+
# This method is for documentation - actual usage is generate_foreign_key_inline
|
|
392
|
+
generate_foreign_key_inline(fk)
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
# Generate CREATE VIEW statement for SQLite
|
|
396
|
+
#
|
|
397
|
+
# @param view [Hash] View hash with :name, :definition
|
|
398
|
+
# @return [String] CREATE VIEW SQL statement
|
|
399
|
+
def generate_view(view)
|
|
400
|
+
definition = view[:definition]
|
|
401
|
+
return definition if /^CREATE\s+VIEW/i.match?(definition) # Already complete
|
|
402
|
+
|
|
403
|
+
"CREATE VIEW #{quote_identifier(view[:name])} AS\n#{definition};"
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
# Generate CREATE TRIGGER statement for SQLite
|
|
407
|
+
#
|
|
408
|
+
# @param trigger [Hash] Trigger hash with :name, :timing, :event, :table_name, :definition, :body
|
|
409
|
+
# @return [String] CREATE TRIGGER SQL statement
|
|
410
|
+
def generate_trigger(trigger)
|
|
411
|
+
definition = trigger[:definition]
|
|
412
|
+
return definition if /^CREATE\s+TRIGGER/i.match?(definition) # Already complete
|
|
413
|
+
|
|
414
|
+
# Generate from components
|
|
415
|
+
timing = trigger[:timing] || 'AFTER'
|
|
416
|
+
event = trigger[:event] || 'INSERT'
|
|
417
|
+
table = trigger[:table_name]
|
|
418
|
+
body = trigger[:body] || trigger[:definition]
|
|
419
|
+
|
|
420
|
+
<<~SQL.strip
|
|
421
|
+
CREATE TRIGGER #{quote_identifier(trigger[:name])}
|
|
422
|
+
#{timing} #{event} ON #{quote_identifier(table)}
|
|
423
|
+
BEGIN
|
|
424
|
+
#{body}
|
|
425
|
+
END;
|
|
426
|
+
SQL
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
# Version detection
|
|
430
|
+
|
|
431
|
+
# Get the current SQLite database version
|
|
432
|
+
#
|
|
433
|
+
# @return [String] Normalized version string (e.g., "3.45.1")
|
|
434
|
+
def database_version
|
|
435
|
+
@database_version ||= begin
|
|
436
|
+
version_string = connection.select_value('SELECT sqlite_version()')
|
|
437
|
+
parse_version(version_string)
|
|
438
|
+
end
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
# Parse SQLite version string into normalized format
|
|
442
|
+
#
|
|
443
|
+
# @param version_string [String] Raw version string from SQLite (e.g., "3.45.1")
|
|
444
|
+
# @return [String] Normalized version (e.g., "3.45.1") or "unknown" if parsing fails
|
|
445
|
+
def parse_version(version_string)
|
|
446
|
+
# Example: "3.45.1"
|
|
447
|
+
match = version_string.match(/(\d+\.\d+\.\d+)/)
|
|
448
|
+
return 'unknown' unless match
|
|
449
|
+
|
|
450
|
+
match[1]
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
private
|
|
454
|
+
|
|
455
|
+
# Helper methods for introspection
|
|
456
|
+
|
|
457
|
+
# Fetch table names from the database
|
|
458
|
+
#
|
|
459
|
+
# @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] Database connection
|
|
460
|
+
# @return [Array<String>] Array of table names
|
|
461
|
+
def fetch_table_names(connection)
|
|
462
|
+
query = <<~SQL.squish
|
|
463
|
+
SELECT name
|
|
464
|
+
FROM sqlite_master
|
|
465
|
+
WHERE type = 'table'
|
|
466
|
+
AND name NOT LIKE 'sqlite_%'
|
|
467
|
+
AND name != 'schema_migrations'
|
|
468
|
+
AND name != 'ar_internal_metadata'
|
|
469
|
+
ORDER BY name
|
|
470
|
+
SQL
|
|
471
|
+
|
|
472
|
+
connection.execute(query).map { |row| row['name'] || row[0] }
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
# Fetch columns for a specific table using PRAGMA table_info
|
|
476
|
+
#
|
|
477
|
+
# @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] Database connection
|
|
478
|
+
# @param table_name [String] Name of the table
|
|
479
|
+
# @return [Array<Hash>] Array of column hashes with :name, :type, :nullable, :default, :primary_key
|
|
480
|
+
def fetch_columns(connection, table_name)
|
|
481
|
+
table_info = connection.execute("PRAGMA table_info(#{quote_identifier(table_name)})")
|
|
482
|
+
|
|
483
|
+
table_info.map do |row|
|
|
484
|
+
{
|
|
485
|
+
name: row['name'] || row[1],
|
|
486
|
+
type: resolve_column_type(row['type'] || row[2]),
|
|
487
|
+
nullable: (row['notnull'] || row[3]).to_i.zero?,
|
|
488
|
+
default: row['dflt_value'] || row[4],
|
|
489
|
+
primary_key: (row['pk'] || row[5]).to_i == 1
|
|
490
|
+
}
|
|
491
|
+
end
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
# Fetch primary key columns for a specific table using PRAGMA table_info
|
|
495
|
+
#
|
|
496
|
+
# @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] Database connection
|
|
497
|
+
# @param table_name [String] Name of the table
|
|
498
|
+
# @return [Array<String>] Array of primary key column names in pk order
|
|
499
|
+
def fetch_primary_key(connection, table_name)
|
|
500
|
+
table_info = connection.execute("PRAGMA table_info(#{quote_identifier(table_name)})")
|
|
501
|
+
|
|
502
|
+
table_info
|
|
503
|
+
.select { |row| (row['pk'] || row[5]).to_i == 1 }
|
|
504
|
+
.sort_by { |row| row['pk'] || row[5] } # Sort by pk order
|
|
505
|
+
.map { |row| row['name'] || row[1] }
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
# Fetch CHECK constraints for a specific table by parsing table SQL
|
|
509
|
+
#
|
|
510
|
+
# @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] Database connection
|
|
511
|
+
# @param table_name [String] Name of the table
|
|
512
|
+
# @return [Array<Hash>] Array of constraint hashes with :name, :definition, :type
|
|
513
|
+
def fetch_constraints(connection, table_name)
|
|
514
|
+
# SQLite stores CHECK constraints in the table SQL
|
|
515
|
+
# Parse from sqlite_master
|
|
516
|
+
query = <<~SQL.squish
|
|
517
|
+
SELECT sql
|
|
518
|
+
FROM sqlite_master
|
|
519
|
+
WHERE type = 'table'
|
|
520
|
+
AND name = '#{table_name}'
|
|
521
|
+
SQL
|
|
522
|
+
|
|
523
|
+
result = connection.execute(query).first
|
|
524
|
+
return [] unless result
|
|
525
|
+
|
|
526
|
+
sql = result['sql'] || result[0]
|
|
527
|
+
return [] unless sql
|
|
528
|
+
|
|
529
|
+
# Extract CHECK constraints from SQL
|
|
530
|
+
# This is a simplified parser - real implementation would be more robust
|
|
531
|
+
checks = []
|
|
532
|
+
sql.scan(/CONSTRAINT\s+(\w+)\s+CHECK\s*\(([^)]+)\)/i) do |match|
|
|
533
|
+
checks << {
|
|
534
|
+
name: match[0],
|
|
535
|
+
definition: match[1],
|
|
536
|
+
type: :check
|
|
537
|
+
}
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
checks
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
# Resolve SQLite column type into normalized format using type affinity
|
|
544
|
+
#
|
|
545
|
+
# @param type_string [String] Raw column type from PRAGMA table_info
|
|
546
|
+
# @return [String] Normalized column type based on SQLite type affinity rules
|
|
547
|
+
def resolve_column_type(type_string)
|
|
548
|
+
# SQLite type affinity mapping
|
|
549
|
+
# Normalize common types
|
|
550
|
+
type_lower = type_string.to_s.downcase
|
|
551
|
+
|
|
552
|
+
case type_lower
|
|
553
|
+
when /^int/
|
|
554
|
+
'integer'
|
|
555
|
+
when /^varchar/, /^char/, /^text/
|
|
556
|
+
'text'
|
|
557
|
+
when /^real/, /^float/, /^double/
|
|
558
|
+
'real'
|
|
559
|
+
when /^decimal/, /^numeric/
|
|
560
|
+
# Keep precision if specified
|
|
561
|
+
type_string
|
|
562
|
+
when /^blob/
|
|
563
|
+
'blob'
|
|
564
|
+
when /^bool/
|
|
565
|
+
'boolean'
|
|
566
|
+
when /^date/, /^time/
|
|
567
|
+
# SQLite stores dates/times as TEXT or INTEGER
|
|
568
|
+
type_string
|
|
569
|
+
when /^json/
|
|
570
|
+
'json' # Stored as TEXT but semantically JSON
|
|
571
|
+
else
|
|
572
|
+
type_string
|
|
573
|
+
end
|
|
574
|
+
end
|
|
575
|
+
|
|
576
|
+
# Generate column definition for CREATE TABLE
|
|
577
|
+
#
|
|
578
|
+
# @param col [Hash] Column hash with :name, :type, :nullable, :default, :primary_key, :extra
|
|
579
|
+
# @param primary_keys [Array<String>] List of primary key column names
|
|
580
|
+
# @return [String] Column definition SQL
|
|
581
|
+
def generate_column_definition(col, primary_keys = [])
|
|
582
|
+
parts = [quote_identifier(col[:name]), col[:type].upcase]
|
|
583
|
+
|
|
584
|
+
# PRIMARY KEY for single-column pk with AUTOINCREMENT
|
|
585
|
+
if col[:primary_key] && primary_keys.length == 1
|
|
586
|
+
parts << 'PRIMARY KEY'
|
|
587
|
+
parts << 'AUTOINCREMENT' if col[:extra]&.include?('auto_increment') || col[:type]&.downcase == 'integer'
|
|
588
|
+
end
|
|
589
|
+
|
|
590
|
+
parts << 'NOT NULL' unless col[:nullable]
|
|
591
|
+
parts << "DEFAULT #{format_default_value(col[:default])}" if col[:default]
|
|
592
|
+
|
|
593
|
+
parts.join(' ')
|
|
594
|
+
end
|
|
595
|
+
|
|
596
|
+
# Generate inline foreign key constraint
|
|
597
|
+
#
|
|
598
|
+
# @param fk [Hash] Foreign key hash with :column, :foreign_table, :foreign_column, :on_delete, :on_update
|
|
599
|
+
# @return [String] FOREIGN KEY constraint SQL
|
|
600
|
+
def generate_foreign_key_inline(fk)
|
|
601
|
+
parts = ["FOREIGN KEY (#{quote_identifier(fk[:column])})"]
|
|
602
|
+
parts << "REFERENCES #{quote_identifier(fk[:foreign_table])}(#{quote_identifier(fk[:foreign_column])})"
|
|
603
|
+
parts << "ON DELETE #{fk[:on_delete]}" if fk[:on_delete]
|
|
604
|
+
parts << "ON UPDATE #{fk[:on_update]}" if fk[:on_update]
|
|
605
|
+
|
|
606
|
+
parts.join(' ')
|
|
607
|
+
end
|
|
608
|
+
# rubocop:enable Naming/MethodParameterName
|
|
609
|
+
|
|
610
|
+
# Quote identifier (table/column name) with double quotes
|
|
611
|
+
#
|
|
612
|
+
# @param name [String] Identifier to quote
|
|
613
|
+
# @return [String] Quoted identifier (e.g., "table_name")
|
|
614
|
+
def quote_identifier(name)
|
|
615
|
+
"\"#{name}\""
|
|
616
|
+
end
|
|
617
|
+
|
|
618
|
+
# Format default value for SQL statement
|
|
619
|
+
#
|
|
620
|
+
# @param value [Object] Default value (can be nil, String, Boolean, or other)
|
|
621
|
+
# @return [String] Formatted default value for SQL
|
|
622
|
+
def format_default_value(value)
|
|
623
|
+
case value
|
|
624
|
+
when nil
|
|
625
|
+
'NULL'
|
|
626
|
+
when String
|
|
627
|
+
# Check if it looks like a function call (uppercase letters/underscores followed by parentheses)
|
|
628
|
+
# or datetime/current_timestamp keywords
|
|
629
|
+
if value =~ /^[A-Z_]+\(/i || value =~ /^(CURRENT_|datetime|date|time)/i
|
|
630
|
+
value
|
|
631
|
+
else
|
|
632
|
+
"'#{value.gsub("'", "''")}'"
|
|
633
|
+
end
|
|
634
|
+
when TrueClass
|
|
635
|
+
'1'
|
|
636
|
+
when FalseClass
|
|
637
|
+
'0'
|
|
638
|
+
else
|
|
639
|
+
value.to_s
|
|
640
|
+
end
|
|
641
|
+
end
|
|
642
|
+
end
|
|
643
|
+
end
|
|
644
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterStructureSql
|
|
4
|
+
module Adapters
|
|
5
|
+
# SQLite-specific configuration settings
|
|
6
|
+
#
|
|
7
|
+
# Provides configuration options specific to SQLite database adapter.
|
|
8
|
+
# These settings control which SQLite features are included in schema dumps
|
|
9
|
+
# and database behavior.
|
|
10
|
+
class SqliteConfig
|
|
11
|
+
# @return [Boolean] Whether to include triggers in schema dump
|
|
12
|
+
# @return [Boolean] Whether to include views in schema dump
|
|
13
|
+
# @return [Boolean] Whether to enable foreign key constraints (PRAGMA foreign_keys=ON)
|
|
14
|
+
# @return [Boolean] Whether to use STRICT tables in SQLite 3.37+
|
|
15
|
+
attr_accessor :include_triggers, :include_views, :foreign_keys_enabled, :strict_mode
|
|
16
|
+
|
|
17
|
+
# Initialize SQLite configuration with default values
|
|
18
|
+
def initialize
|
|
19
|
+
@include_triggers = true
|
|
20
|
+
@include_views = true
|
|
21
|
+
@foreign_keys_enabled = true # PRAGMA foreign_keys=ON
|
|
22
|
+
@strict_mode = false # Use STRICT tables in SQLite 3.37+
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|