tina4ruby 3.11.13 → 3.11.15
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 +4 -4
- data/CHANGELOG.md +80 -80
- data/LICENSE.txt +21 -21
- data/README.md +137 -137
- data/exe/tina4ruby +5 -5
- data/lib/tina4/ai.rb +696 -696
- data/lib/tina4/api.rb +189 -189
- data/lib/tina4/auth.rb +305 -305
- data/lib/tina4/auto_crud.rb +244 -244
- data/lib/tina4/cache.rb +154 -154
- data/lib/tina4/cli.rb +1449 -1449
- data/lib/tina4/constants.rb +46 -46
- data/lib/tina4/container.rb +74 -74
- data/lib/tina4/cors.rb +74 -74
- data/lib/tina4/crud.rb +692 -692
- data/lib/tina4/database/sqlite3_adapter.rb +165 -165
- data/lib/tina4/database.rb +625 -625
- data/lib/tina4/database_result.rb +208 -208
- data/lib/tina4/debug.rb +8 -8
- data/lib/tina4/dev.rb +14 -14
- data/lib/tina4/dev_admin.rb +935 -935
- data/lib/tina4/dev_mailbox.rb +191 -191
- data/lib/tina4/drivers/firebird_driver.rb +124 -110
- data/lib/tina4/drivers/mongodb_driver.rb +561 -561
- data/lib/tina4/drivers/mssql_driver.rb +112 -112
- data/lib/tina4/drivers/mysql_driver.rb +90 -90
- data/lib/tina4/drivers/odbc_driver.rb +191 -191
- data/lib/tina4/drivers/postgres_driver.rb +116 -106
- data/lib/tina4/drivers/sqlite_driver.rb +122 -122
- data/lib/tina4/env.rb +95 -95
- data/lib/tina4/error_overlay.rb +252 -252
- data/lib/tina4/events.rb +109 -109
- data/lib/tina4/field_types.rb +154 -154
- data/lib/tina4/frond.rb +2025 -2025
- data/lib/tina4/gallery/auth/meta.json +1 -1
- data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +114 -114
- data/lib/tina4/gallery/database/meta.json +1 -1
- data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +43 -43
- data/lib/tina4/gallery/error-overlay/meta.json +1 -1
- data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +17 -17
- data/lib/tina4/gallery/orm/meta.json +1 -1
- data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +16 -16
- data/lib/tina4/gallery/queue/meta.json +1 -1
- data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +325 -325
- data/lib/tina4/gallery/rest-api/meta.json +1 -1
- data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +14 -14
- data/lib/tina4/gallery/templates/meta.json +1 -1
- data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +12 -12
- data/lib/tina4/gallery/templates/src/templates/gallery_page.twig +257 -257
- data/lib/tina4/graphql.rb +966 -966
- data/lib/tina4/health.rb +39 -39
- data/lib/tina4/html_element.rb +170 -170
- data/lib/tina4/job.rb +80 -80
- data/lib/tina4/localization.rb +168 -168
- data/lib/tina4/log.rb +203 -203
- data/lib/tina4/mcp.rb +696 -696
- data/lib/tina4/messenger.rb +587 -587
- data/lib/tina4/metrics.rb +793 -793
- data/lib/tina4/middleware.rb +445 -445
- data/lib/tina4/migration.rb +451 -451
- data/lib/tina4/orm.rb +790 -790
- data/lib/tina4/public/css/tina4.css +2463 -2463
- data/lib/tina4/public/css/tina4.min.css +1 -1
- data/lib/tina4/public/images/logo.svg +5 -5
- data/lib/tina4/public/js/frond.min.js +2 -2
- data/lib/tina4/public/js/tina4-dev-admin.js +565 -565
- data/lib/tina4/public/js/tina4-dev-admin.min.js +480 -480
- data/lib/tina4/public/js/tina4.min.js +92 -92
- data/lib/tina4/public/js/tina4js.min.js +48 -48
- data/lib/tina4/public/swagger/index.html +90 -90
- data/lib/tina4/public/swagger/oauth2-redirect.html +63 -63
- data/lib/tina4/query_builder.rb +380 -380
- data/lib/tina4/queue.rb +366 -366
- data/lib/tina4/queue_backends/kafka_backend.rb +80 -80
- data/lib/tina4/queue_backends/lite_backend.rb +298 -298
- data/lib/tina4/queue_backends/mongo_backend.rb +126 -126
- data/lib/tina4/queue_backends/rabbitmq_backend.rb +73 -73
- data/lib/tina4/rack_app.rb +817 -817
- data/lib/tina4/rate_limiter.rb +130 -130
- data/lib/tina4/request.rb +268 -255
- data/lib/tina4/response.rb +346 -346
- data/lib/tina4/response_cache.rb +551 -551
- data/lib/tina4/router.rb +406 -406
- data/lib/tina4/scss/tina4css/_alerts.scss +34 -34
- data/lib/tina4/scss/tina4css/_badges.scss +22 -22
- data/lib/tina4/scss/tina4css/_buttons.scss +69 -69
- data/lib/tina4/scss/tina4css/_cards.scss +49 -49
- data/lib/tina4/scss/tina4css/_forms.scss +156 -156
- data/lib/tina4/scss/tina4css/_grid.scss +81 -81
- data/lib/tina4/scss/tina4css/_modals.scss +84 -84
- data/lib/tina4/scss/tina4css/_nav.scss +149 -149
- data/lib/tina4/scss/tina4css/_reset.scss +94 -94
- data/lib/tina4/scss/tina4css/_tables.scss +54 -54
- data/lib/tina4/scss/tina4css/_typography.scss +55 -55
- data/lib/tina4/scss/tina4css/_utilities.scss +197 -197
- data/lib/tina4/scss/tina4css/_variables.scss +117 -117
- data/lib/tina4/scss/tina4css/base.scss +1 -1
- data/lib/tina4/scss/tina4css/colors.scss +48 -48
- data/lib/tina4/scss/tina4css/tina4.scss +17 -17
- data/lib/tina4/scss_compiler.rb +178 -178
- data/lib/tina4/seeder.rb +567 -567
- data/lib/tina4/service_runner.rb +303 -303
- data/lib/tina4/session.rb +297 -297
- data/lib/tina4/session_handlers/database_handler.rb +72 -72
- data/lib/tina4/session_handlers/file_handler.rb +67 -67
- data/lib/tina4/session_handlers/mongo_handler.rb +49 -49
- data/lib/tina4/session_handlers/redis_handler.rb +43 -43
- data/lib/tina4/session_handlers/valkey_handler.rb +43 -43
- data/lib/tina4/shutdown.rb +84 -84
- data/lib/tina4/sql_translation.rb +158 -158
- data/lib/tina4/swagger.rb +124 -124
- data/lib/tina4/template.rb +894 -894
- data/lib/tina4/templates/base.twig +26 -26
- data/lib/tina4/templates/errors/302.twig +14 -14
- data/lib/tina4/templates/errors/401.twig +9 -9
- data/lib/tina4/templates/errors/403.twig +29 -29
- data/lib/tina4/templates/errors/404.twig +29 -29
- data/lib/tina4/templates/errors/500.twig +38 -38
- data/lib/tina4/templates/errors/502.twig +9 -9
- data/lib/tina4/templates/errors/503.twig +12 -12
- data/lib/tina4/templates/errors/base.twig +37 -37
- data/lib/tina4/test_client.rb +159 -159
- data/lib/tina4/testing.rb +340 -340
- data/lib/tina4/validator.rb +174 -174
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/webserver.rb +312 -312
- data/lib/tina4/websocket.rb +343 -343
- data/lib/tina4/websocket_backplane.rb +190 -190
- data/lib/tina4/wsdl.rb +564 -564
- data/lib/tina4.rb +458 -458
- data/lib/tina4ruby.rb +4 -4
- metadata +3 -3
data/lib/tina4/migration.rb
CHANGED
|
@@ -1,451 +1,451 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
require "fileutils"
|
|
3
|
-
|
|
4
|
-
module Tina4
|
|
5
|
-
class Migration
|
|
6
|
-
TRACKING_TABLE = "tina4_migration"
|
|
7
|
-
|
|
8
|
-
attr_reader :db, :migrations_dir
|
|
9
|
-
|
|
10
|
-
def initialize(db, migrations_dir: nil)
|
|
11
|
-
@db = db
|
|
12
|
-
@migrations_dir = migrations_dir || resolve_migrations_dir
|
|
13
|
-
ensure_tracking_table
|
|
14
|
-
end
|
|
15
|
-
|
|
16
|
-
# Run all pending migrations
|
|
17
|
-
def migrate
|
|
18
|
-
pending = pending_migrations
|
|
19
|
-
if pending.empty?
|
|
20
|
-
Tina4::Log.info("No pending migrations")
|
|
21
|
-
return []
|
|
22
|
-
end
|
|
23
|
-
|
|
24
|
-
batch = next_batch_number
|
|
25
|
-
results = []
|
|
26
|
-
pending.each do |file|
|
|
27
|
-
result = run_migration(file, batch)
|
|
28
|
-
results << result
|
|
29
|
-
# Stop on failure
|
|
30
|
-
break if result[:status] == "failed"
|
|
31
|
-
end
|
|
32
|
-
results
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
alias run migrate
|
|
36
|
-
|
|
37
|
-
# Rollback last batch (or N steps)
|
|
38
|
-
def rollback(steps = 1)
|
|
39
|
-
completed = completed_migrations_with_batch
|
|
40
|
-
return [] if completed.empty?
|
|
41
|
-
|
|
42
|
-
# Get the last N unique batches
|
|
43
|
-
batches = completed.map { |m| m[:batch] }.uniq.sort.reverse
|
|
44
|
-
batches_to_rollback = batches.first(steps)
|
|
45
|
-
|
|
46
|
-
results = []
|
|
47
|
-
completed.select { |m| batches_to_rollback.include?(m[:batch]) }
|
|
48
|
-
.sort_by { |m| -m[:id] }
|
|
49
|
-
.each do |migration|
|
|
50
|
-
result = rollback_migration(migration[:migration_name])
|
|
51
|
-
results << result
|
|
52
|
-
end
|
|
53
|
-
results
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
def status
|
|
57
|
-
{
|
|
58
|
-
completed: completed_migrations,
|
|
59
|
-
pending: pending_migrations.map { |f| File.basename(f) }
|
|
60
|
-
}
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
# Create a new migration file
|
|
64
|
-
#
|
|
65
|
-
# kind="ruby" — creates {timestamp}_{description}.rb with MigrationBase subclass (default)
|
|
66
|
-
# kind="sql" — creates {timestamp}_{description}.sql + .down.sql
|
|
67
|
-
# kind="python" — alias for "ruby" (class-based scaffold for cross-framework parity)
|
|
68
|
-
def create(description, kind = "sql")
|
|
69
|
-
FileUtils.mkdir_p(@migrations_dir)
|
|
70
|
-
timestamp = Time.now.strftime("%Y%m%d%H%M%S")
|
|
71
|
-
created_at = Time.now.utc.strftime("%Y-%m-%d %H:%M:%S UTC")
|
|
72
|
-
safe_name = description.gsub(/[^a-z0-9]+/i, "_").downcase.gsub(/^_|_$/, "")
|
|
73
|
-
|
|
74
|
-
if kind == "ruby" || kind == "python"
|
|
75
|
-
filename = "#{timestamp}_#{safe_name}.rb"
|
|
76
|
-
filepath = File.join(@migrations_dir, filename)
|
|
77
|
-
|
|
78
|
-
File.write(filepath, <<~RUBY)
|
|
79
|
-
# frozen_string_literal: true
|
|
80
|
-
# Migration: #{description}
|
|
81
|
-
# Created: #{created_at}
|
|
82
|
-
|
|
83
|
-
class #{classify(description)} < Tina4::MigrationBase
|
|
84
|
-
def up(db = nil)
|
|
85
|
-
# db.execute("CREATE TABLE ...")
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
def down(db = nil)
|
|
89
|
-
# db.execute("DROP TABLE IF EXISTS ...")
|
|
90
|
-
end
|
|
91
|
-
end
|
|
92
|
-
RUBY
|
|
93
|
-
|
|
94
|
-
Tina4::Log.info("Created migration: #{filename}")
|
|
95
|
-
return filepath
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
# Default: SQL
|
|
99
|
-
up_filename = "#{timestamp}_#{safe_name}.sql"
|
|
100
|
-
down_filename = "#{timestamp}_#{safe_name}.down.sql"
|
|
101
|
-
up_path = File.join(@migrations_dir, up_filename)
|
|
102
|
-
down_path = File.join(@migrations_dir, down_filename)
|
|
103
|
-
|
|
104
|
-
File.write(up_path, "-- Migration: #{description}\n-- Created: #{created_at}\n\n")
|
|
105
|
-
File.write(down_path, "-- Rollback: #{description}\n-- Created: #{created_at}\n\n")
|
|
106
|
-
|
|
107
|
-
Tina4::Log.info("Created migration: #{up_filename}")
|
|
108
|
-
up_path
|
|
109
|
-
end
|
|
110
|
-
|
|
111
|
-
# Insert a record into the migration tracking table.
|
|
112
|
-
#
|
|
113
|
-
# @param name [String] Migration filename (e.g. "20240101000000_create_users.sql")
|
|
114
|
-
# @param batch [Integer] Batch number this migration belongs to
|
|
115
|
-
# @param passed [Integer] 1 if successful (default), 0 if failed
|
|
116
|
-
def record_migration(name, batch, passed: 1)
|
|
117
|
-
_record_migration(name, batch, passed: passed)
|
|
118
|
-
end
|
|
119
|
-
|
|
120
|
-
# Delete a migration record from the tracking table by filename.
|
|
121
|
-
#
|
|
122
|
-
# @param name [String] Migration filename to remove
|
|
123
|
-
def remove_migration_record(name)
|
|
124
|
-
_remove_migration_record(name)
|
|
125
|
-
end
|
|
126
|
-
|
|
127
|
-
# Create a migration file — static helper for parity with Python/Node.
|
|
128
|
-
# @param description [String] Human-readable migration name
|
|
129
|
-
# @param migrations_dir [String] Directory for migration files (default: 'migrations')
|
|
130
|
-
# @param kind [String] File kind: 'sql' or 'ruby' (default: 'sql')
|
|
131
|
-
def self.create_migration(description, migrations_dir: "migrations", kind: "sql")
|
|
132
|
-
new(nil, migrations_dir: migrations_dir).create(description, kind)
|
|
133
|
-
end
|
|
134
|
-
|
|
135
|
-
# Get list of applied migration records (public alias for completed_migrations)
|
|
136
|
-
def get_applied
|
|
137
|
-
completed_migrations
|
|
138
|
-
end
|
|
139
|
-
|
|
140
|
-
# Alias for get_applied — parity with PHP/Node
|
|
141
|
-
def get_applied_migrations
|
|
142
|
-
get_applied
|
|
143
|
-
end
|
|
144
|
-
|
|
145
|
-
# Get list of pending migration filenames (public alias for pending_migrations)
|
|
146
|
-
def get_pending
|
|
147
|
-
pending_migrations.map { |f| File.basename(f) }
|
|
148
|
-
end
|
|
149
|
-
|
|
150
|
-
# Get all migration files on disk, excluding .down files
|
|
151
|
-
def get_files
|
|
152
|
-
migration_files = Dir.glob(File.join(@migrations_dir, "*.sql")).reject { |f| f.end_with?(".down.sql") }
|
|
153
|
-
migration_files += Dir.glob(File.join(@migrations_dir, "*.rb"))
|
|
154
|
-
migration_files.map { |f| File.basename(f) }.sort
|
|
155
|
-
end
|
|
156
|
-
|
|
157
|
-
private
|
|
158
|
-
|
|
159
|
-
# Resolve migrations directory: prefer src/migrations, fall back to migrations/
|
|
160
|
-
def resolve_migrations_dir
|
|
161
|
-
src_dir = File.join(Dir.pwd, "src", "migrations")
|
|
162
|
-
return src_dir if Dir.exist?(src_dir)
|
|
163
|
-
|
|
164
|
-
root_dir = File.join(Dir.pwd, "migrations")
|
|
165
|
-
return root_dir if Dir.exist?(root_dir)
|
|
166
|
-
|
|
167
|
-
# Default to src/migrations (will be created when needed)
|
|
168
|
-
src_dir
|
|
169
|
-
end
|
|
170
|
-
|
|
171
|
-
def ensure_tracking_table
|
|
172
|
-
return unless @db
|
|
173
|
-
unless @db.table_exists?(TRACKING_TABLE)
|
|
174
|
-
if firebird?
|
|
175
|
-
# Firebird: no AUTOINCREMENT, no TEXT type, use generator for IDs
|
|
176
|
-
begin
|
|
177
|
-
@db.execute("CREATE GENERATOR GEN_TINA4_MIGRATION_ID")
|
|
178
|
-
@db.execute("COMMIT") rescue nil
|
|
179
|
-
rescue
|
|
180
|
-
# Generator may already exist
|
|
181
|
-
end
|
|
182
|
-
@db.execute(<<~SQL)
|
|
183
|
-
CREATE TABLE #{TRACKING_TABLE} (
|
|
184
|
-
id INTEGER NOT NULL PRIMARY KEY,
|
|
185
|
-
migration_name VARCHAR(500) NOT NULL,
|
|
186
|
-
description VARCHAR(500) DEFAULT '',
|
|
187
|
-
batch INTEGER NOT NULL DEFAULT 1,
|
|
188
|
-
executed_at VARCHAR(50) DEFAULT CURRENT_TIMESTAMP,
|
|
189
|
-
passed INTEGER NOT NULL DEFAULT 1
|
|
190
|
-
)
|
|
191
|
-
SQL
|
|
192
|
-
else
|
|
193
|
-
@db.execute(<<~SQL)
|
|
194
|
-
CREATE TABLE #{TRACKING_TABLE} (
|
|
195
|
-
id INTEGER PRIMARY KEY,
|
|
196
|
-
migration_name VARCHAR(255) NOT NULL,
|
|
197
|
-
description VARCHAR(255) DEFAULT '',
|
|
198
|
-
batch INTEGER NOT NULL DEFAULT 1,
|
|
199
|
-
executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
200
|
-
passed INTEGER NOT NULL DEFAULT 1
|
|
201
|
-
)
|
|
202
|
-
SQL
|
|
203
|
-
end
|
|
204
|
-
Tina4::Log.info("Created migrations tracking table")
|
|
205
|
-
end
|
|
206
|
-
end
|
|
207
|
-
|
|
208
|
-
def completed_migrations
|
|
209
|
-
result = @db.fetch("SELECT migration_name FROM #{TRACKING_TABLE} WHERE passed = 1 ORDER BY id")
|
|
210
|
-
result.map { |r| r[:migration_name] }
|
|
211
|
-
end
|
|
212
|
-
|
|
213
|
-
def completed_migrations_with_batch
|
|
214
|
-
result = @db.fetch("SELECT id, migration_name, batch FROM #{TRACKING_TABLE} WHERE passed = 1 ORDER BY id")
|
|
215
|
-
result.map { |r| { id: r[:id], migration_name: r[:migration_name], batch: r[:batch] } }
|
|
216
|
-
end
|
|
217
|
-
|
|
218
|
-
def next_batch_number
|
|
219
|
-
result = @db.fetch_one("SELECT MAX(batch) as max_batch FROM #{TRACKING_TABLE} WHERE passed = 1")
|
|
220
|
-
(result && result[:max_batch] ? result[:max_batch].to_i : 0) + 1
|
|
221
|
-
end
|
|
222
|
-
|
|
223
|
-
def pending_migrations
|
|
224
|
-
return [] unless Dir.exist?(@migrations_dir)
|
|
225
|
-
|
|
226
|
-
completed = completed_migrations
|
|
227
|
-
# Support both .rb and .sql migration files
|
|
228
|
-
# Accept both 000001_name.sql (sequential) and YYYYMMDDHHMMSS_name.sql (timestamp) patterns
|
|
229
|
-
Dir.glob(File.join(@migrations_dir, "*.{rb,sql}"))
|
|
230
|
-
.reject { |f| f.end_with?(".down.sql") }
|
|
231
|
-
.sort_by { |f| migration_sort_key(File.basename(f)) }
|
|
232
|
-
.reject { |f| completed.include?(File.basename(f)) }
|
|
233
|
-
end
|
|
234
|
-
|
|
235
|
-
# Sort key that handles both 000001_name.sql and 20240315120000_name.sql patterns.
|
|
236
|
-
# Both are zero-padded numeric prefixes so alphabetical sorting works, but we
|
|
237
|
-
# extract the prefix explicitly to guarantee correct ordering when mixed.
|
|
238
|
-
def migration_sort_key(filename)
|
|
239
|
-
if filename =~ /\A(\d+)/
|
|
240
|
-
[$1.to_i, filename]
|
|
241
|
-
else
|
|
242
|
-
[0, filename]
|
|
243
|
-
end
|
|
244
|
-
end
|
|
245
|
-
|
|
246
|
-
def run_migration(file, batch)
|
|
247
|
-
name = File.basename(file)
|
|
248
|
-
Tina4::Log.info("Running migration: #{name}")
|
|
249
|
-
begin
|
|
250
|
-
if file.end_with?(".rb")
|
|
251
|
-
execute_ruby_migration(file, :up)
|
|
252
|
-
else
|
|
253
|
-
execute_sql_file(file)
|
|
254
|
-
end
|
|
255
|
-
_record_migration(name, batch, passed: 1)
|
|
256
|
-
{ name: name, status: "success" }
|
|
257
|
-
rescue => e
|
|
258
|
-
Tina4::Log.error("Migration failed: #{name} - #{e.message}")
|
|
259
|
-
_record_migration(name, batch, passed: 0)
|
|
260
|
-
{ name: name, status: "failed", error: e.message }
|
|
261
|
-
end
|
|
262
|
-
end
|
|
263
|
-
|
|
264
|
-
def rollback_migration(name)
|
|
265
|
-
Tina4::Log.info("Rolling back: #{name}")
|
|
266
|
-
begin
|
|
267
|
-
file = File.join(@migrations_dir, name)
|
|
268
|
-
if name.end_with?(".rb") && File.exist?(file)
|
|
269
|
-
execute_ruby_migration(file, :down)
|
|
270
|
-
elsif name.end_with?(".sql")
|
|
271
|
-
down_file = File.join(@migrations_dir, name.sub(".sql", ".down.sql"))
|
|
272
|
-
if File.exist?(down_file)
|
|
273
|
-
execute_sql_file(down_file)
|
|
274
|
-
else
|
|
275
|
-
Tina4::Log.warning("No rollback file for: #{name}")
|
|
276
|
-
end
|
|
277
|
-
end
|
|
278
|
-
_remove_migration_record(name)
|
|
279
|
-
{ name: name, status: "rolled_back" }
|
|
280
|
-
rescue => e
|
|
281
|
-
Tina4::Log.error("Rollback failed: #{name} - #{e.message}")
|
|
282
|
-
{ name: name, status: "failed", error: e.message }
|
|
283
|
-
end
|
|
284
|
-
end
|
|
285
|
-
|
|
286
|
-
def execute_ruby_migration(file, direction)
|
|
287
|
-
# Load the migration class
|
|
288
|
-
content = File.read(file)
|
|
289
|
-
# Evaluate in a clean binding
|
|
290
|
-
eval(content, TOPLEVEL_BINDING, file)
|
|
291
|
-
|
|
292
|
-
# Find the migration class (last class defined that inherits from MigrationBase)
|
|
293
|
-
class_name = extract_class_name(content)
|
|
294
|
-
klass = Object.const_get(class_name)
|
|
295
|
-
migration = klass.new
|
|
296
|
-
migration.__send__(direction, @db)
|
|
297
|
-
end
|
|
298
|
-
|
|
299
|
-
# Split SQL into individual statements, handling:
|
|
300
|
-
# - $$ delimited stored procedure blocks
|
|
301
|
-
# - // delimited blocks
|
|
302
|
-
# - Block comments /* ... */
|
|
303
|
-
# - Line comments -- ...
|
|
304
|
-
# Matches the Python/Node.js approach: extract blocks first, split on ;, restore blocks.
|
|
305
|
-
def split_sql_statements(sql, delimiter = ";")
|
|
306
|
-
blocks = []
|
|
307
|
-
|
|
308
|
-
# Extract $$ ... $$ blocks (stored procedures, triggers, etc.)
|
|
309
|
-
processed = sql.gsub(/\$\$(.*?)\$\$/m) do
|
|
310
|
-
blocks << $~.to_s
|
|
311
|
-
"__BLOCK_#{blocks.length - 1}__"
|
|
312
|
-
end
|
|
313
|
-
|
|
314
|
-
# Extract // ... // blocks
|
|
315
|
-
processed = processed.gsub(/\/\/(.*?)\/\//m) do
|
|
316
|
-
blocks << $~.to_s
|
|
317
|
-
"__BLOCK_#{blocks.length - 1}__"
|
|
318
|
-
end
|
|
319
|
-
|
|
320
|
-
# Remove block comments (/* ... */) but not inside stored proc blocks (already extracted)
|
|
321
|
-
clean = processed.gsub(/\/\*.*?\*\//m, "")
|
|
322
|
-
|
|
323
|
-
statements = []
|
|
324
|
-
clean.split(delimiter).each do |stmt|
|
|
325
|
-
lines = []
|
|
326
|
-
stmt.split("\n").each do |line|
|
|
327
|
-
stripped = line.strip
|
|
328
|
-
next if stripped.empty? || stripped.start_with?("--")
|
|
329
|
-
# Remove inline comments (-- after SQL)
|
|
330
|
-
comment_pos = line.index("--")
|
|
331
|
-
line = line[0...comment_pos] if comment_pos && comment_pos >= 0
|
|
332
|
-
lines << line
|
|
333
|
-
end
|
|
334
|
-
cleaned = lines.join("\n").strip
|
|
335
|
-
|
|
336
|
-
# Restore block placeholders
|
|
337
|
-
blocks.each_with_index do |block, i|
|
|
338
|
-
cleaned = cleaned.gsub("__BLOCK_#{i}__", block)
|
|
339
|
-
end
|
|
340
|
-
|
|
341
|
-
statements << cleaned unless cleaned.empty?
|
|
342
|
-
end
|
|
343
|
-
|
|
344
|
-
statements
|
|
345
|
-
end
|
|
346
|
-
|
|
347
|
-
def execute_sql_file(file)
|
|
348
|
-
sql = File.read(file)
|
|
349
|
-
statements = split_sql_statements(sql)
|
|
350
|
-
statements.each do |stmt|
|
|
351
|
-
# Firebird lacks IF NOT EXISTS for ALTER TABLE ADD.
|
|
352
|
-
# Pre-check the system catalogue so duplicate columns are
|
|
353
|
-
# silently skipped instead of raising an error.
|
|
354
|
-
skip_reason = should_skip_for_firebird(stmt)
|
|
355
|
-
if skip_reason
|
|
356
|
-
Tina4::Log.info("Migration #{File.basename(file)}: #{skip_reason}")
|
|
357
|
-
next
|
|
358
|
-
end
|
|
359
|
-
result = @db.execute(stmt)
|
|
360
|
-
if result == false
|
|
361
|
-
raise RuntimeError, @db.get_error || "SQL execution failed: #{stmt}"
|
|
362
|
-
end
|
|
363
|
-
end
|
|
364
|
-
end
|
|
365
|
-
|
|
366
|
-
# Regex to match ALTER TABLE <table> ADD <column> ...
|
|
367
|
-
ALTER_ADD_RE = /\A\s*ALTER\s+TABLE\s+(?:"([^"]+)"|(\S+))\s+ADD\s+(?:"([^"]+)"|(\S+))/i
|
|
368
|
-
|
|
369
|
-
def firebird?
|
|
370
|
-
@db.driver_name == "firebird"
|
|
371
|
-
end
|
|
372
|
-
|
|
373
|
-
# Check if a column already exists in a Firebird table via RDB$RELATION_FIELDS.
|
|
374
|
-
# Firebird stores unquoted identifiers in upper-case.
|
|
375
|
-
def firebird_column_exists?(table, column)
|
|
376
|
-
row = @db.fetch_one(
|
|
377
|
-
"SELECT 1 FROM RDB\$RELATION_FIELDS WHERE RDB\$RELATION_NAME = ? AND TRIM(RDB\$FIELD_NAME) = ?",
|
|
378
|
-
[table.upcase, column.upcase]
|
|
379
|
-
)
|
|
380
|
-
!row.nil?
|
|
381
|
-
end
|
|
382
|
-
|
|
383
|
-
# If stmt is an ALTER TABLE ... ADD on Firebird and the column already exists,
|
|
384
|
-
# returns a skip reason. Returns nil if the statement should execute normally.
|
|
385
|
-
def should_skip_for_firebird(stmt)
|
|
386
|
-
return nil unless firebird?
|
|
387
|
-
|
|
388
|
-
m = stmt.match(ALTER_ADD_RE)
|
|
389
|
-
return nil unless m
|
|
390
|
-
|
|
391
|
-
table = m[1] || m[2]
|
|
392
|
-
column = m[3] || m[4]
|
|
393
|
-
|
|
394
|
-
if firebird_column_exists?(table, column)
|
|
395
|
-
"Column #{column} already exists in #{table}, skipping"
|
|
396
|
-
end
|
|
397
|
-
end
|
|
398
|
-
|
|
399
|
-
def _record_migration(name, batch, passed: 1)
|
|
400
|
-
# Extract description from filename (strip numeric prefix and extension)
|
|
401
|
-
stem = File.basename(name, File.extname(name))
|
|
402
|
-
desc = stem.sub(/\A\d+_/, "").tr("_", " ")
|
|
403
|
-
if firebird?
|
|
404
|
-
# Firebird: generate ID from sequence
|
|
405
|
-
row = @db.fetch_one(
|
|
406
|
-
"SELECT GEN_ID(GEN_TINA4_MIGRATION_ID, 1) AS NEXT_ID FROM RDB\$DATABASE"
|
|
407
|
-
)
|
|
408
|
-
next_id = row ? (row[:NEXT_ID] || row[:next_id] || 1).to_i : 1
|
|
409
|
-
@db.execute(
|
|
410
|
-
"INSERT INTO #{TRACKING_TABLE} (id, migration_name, description, batch, passed) VALUES (?, ?, ?, ?, ?)",
|
|
411
|
-
[next_id, name, desc, batch, passed]
|
|
412
|
-
)
|
|
413
|
-
else
|
|
414
|
-
@db.execute(
|
|
415
|
-
"INSERT INTO #{TRACKING_TABLE} (migration_name, description, batch, passed) VALUES (?, ?, ?, ?)",
|
|
416
|
-
[name, desc, batch, passed]
|
|
417
|
-
)
|
|
418
|
-
end
|
|
419
|
-
end
|
|
420
|
-
|
|
421
|
-
def _remove_migration_record(name)
|
|
422
|
-
@db.delete(TRACKING_TABLE, { migration_name: name })
|
|
423
|
-
end
|
|
424
|
-
|
|
425
|
-
def classify(name)
|
|
426
|
-
name.gsub(/[^a-zA-Z0-9_]/, "_")
|
|
427
|
-
.split("_")
|
|
428
|
-
.map(&:capitalize)
|
|
429
|
-
.join
|
|
430
|
-
end
|
|
431
|
-
|
|
432
|
-
def extract_class_name(content)
|
|
433
|
-
if content =~ /class\s+(\w+)\s*<\s*Tina4::MigrationBase/
|
|
434
|
-
$1
|
|
435
|
-
else
|
|
436
|
-
raise "No migration class found inheriting from Tina4::MigrationBase"
|
|
437
|
-
end
|
|
438
|
-
end
|
|
439
|
-
end
|
|
440
|
-
|
|
441
|
-
# Base class for Ruby migrations
|
|
442
|
-
class MigrationBase
|
|
443
|
-
def up(db = nil)
|
|
444
|
-
raise NotImplementedError, "Implement #up in your migration"
|
|
445
|
-
end
|
|
446
|
-
|
|
447
|
-
def down(db = nil)
|
|
448
|
-
raise NotImplementedError, "Implement #down in your migration"
|
|
449
|
-
end
|
|
450
|
-
end
|
|
451
|
-
end
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "fileutils"
|
|
3
|
+
|
|
4
|
+
module Tina4
|
|
5
|
+
class Migration
|
|
6
|
+
TRACKING_TABLE = "tina4_migration"
|
|
7
|
+
|
|
8
|
+
attr_reader :db, :migrations_dir
|
|
9
|
+
|
|
10
|
+
def initialize(db, migrations_dir: nil)
|
|
11
|
+
@db = db
|
|
12
|
+
@migrations_dir = migrations_dir || resolve_migrations_dir
|
|
13
|
+
ensure_tracking_table
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Run all pending migrations
|
|
17
|
+
def migrate
|
|
18
|
+
pending = pending_migrations
|
|
19
|
+
if pending.empty?
|
|
20
|
+
Tina4::Log.info("No pending migrations")
|
|
21
|
+
return []
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
batch = next_batch_number
|
|
25
|
+
results = []
|
|
26
|
+
pending.each do |file|
|
|
27
|
+
result = run_migration(file, batch)
|
|
28
|
+
results << result
|
|
29
|
+
# Stop on failure
|
|
30
|
+
break if result[:status] == "failed"
|
|
31
|
+
end
|
|
32
|
+
results
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
alias run migrate
|
|
36
|
+
|
|
37
|
+
# Rollback last batch (or N steps)
|
|
38
|
+
def rollback(steps = 1)
|
|
39
|
+
completed = completed_migrations_with_batch
|
|
40
|
+
return [] if completed.empty?
|
|
41
|
+
|
|
42
|
+
# Get the last N unique batches
|
|
43
|
+
batches = completed.map { |m| m[:batch] }.uniq.sort.reverse
|
|
44
|
+
batches_to_rollback = batches.first(steps)
|
|
45
|
+
|
|
46
|
+
results = []
|
|
47
|
+
completed.select { |m| batches_to_rollback.include?(m[:batch]) }
|
|
48
|
+
.sort_by { |m| -m[:id] }
|
|
49
|
+
.each do |migration|
|
|
50
|
+
result = rollback_migration(migration[:migration_name])
|
|
51
|
+
results << result
|
|
52
|
+
end
|
|
53
|
+
results
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def status
|
|
57
|
+
{
|
|
58
|
+
completed: completed_migrations,
|
|
59
|
+
pending: pending_migrations.map { |f| File.basename(f) }
|
|
60
|
+
}
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Create a new migration file
|
|
64
|
+
#
|
|
65
|
+
# kind="ruby" — creates {timestamp}_{description}.rb with MigrationBase subclass (default)
|
|
66
|
+
# kind="sql" — creates {timestamp}_{description}.sql + .down.sql
|
|
67
|
+
# kind="python" — alias for "ruby" (class-based scaffold for cross-framework parity)
|
|
68
|
+
def create(description, kind = "sql")
|
|
69
|
+
FileUtils.mkdir_p(@migrations_dir)
|
|
70
|
+
timestamp = Time.now.strftime("%Y%m%d%H%M%S")
|
|
71
|
+
created_at = Time.now.utc.strftime("%Y-%m-%d %H:%M:%S UTC")
|
|
72
|
+
safe_name = description.gsub(/[^a-z0-9]+/i, "_").downcase.gsub(/^_|_$/, "")
|
|
73
|
+
|
|
74
|
+
if kind == "ruby" || kind == "python"
|
|
75
|
+
filename = "#{timestamp}_#{safe_name}.rb"
|
|
76
|
+
filepath = File.join(@migrations_dir, filename)
|
|
77
|
+
|
|
78
|
+
File.write(filepath, <<~RUBY)
|
|
79
|
+
# frozen_string_literal: true
|
|
80
|
+
# Migration: #{description}
|
|
81
|
+
# Created: #{created_at}
|
|
82
|
+
|
|
83
|
+
class #{classify(description)} < Tina4::MigrationBase
|
|
84
|
+
def up(db = nil)
|
|
85
|
+
# db.execute("CREATE TABLE ...")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def down(db = nil)
|
|
89
|
+
# db.execute("DROP TABLE IF EXISTS ...")
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
RUBY
|
|
93
|
+
|
|
94
|
+
Tina4::Log.info("Created migration: #{filename}")
|
|
95
|
+
return filepath
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Default: SQL
|
|
99
|
+
up_filename = "#{timestamp}_#{safe_name}.sql"
|
|
100
|
+
down_filename = "#{timestamp}_#{safe_name}.down.sql"
|
|
101
|
+
up_path = File.join(@migrations_dir, up_filename)
|
|
102
|
+
down_path = File.join(@migrations_dir, down_filename)
|
|
103
|
+
|
|
104
|
+
File.write(up_path, "-- Migration: #{description}\n-- Created: #{created_at}\n\n")
|
|
105
|
+
File.write(down_path, "-- Rollback: #{description}\n-- Created: #{created_at}\n\n")
|
|
106
|
+
|
|
107
|
+
Tina4::Log.info("Created migration: #{up_filename}")
|
|
108
|
+
up_path
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Insert a record into the migration tracking table.
|
|
112
|
+
#
|
|
113
|
+
# @param name [String] Migration filename (e.g. "20240101000000_create_users.sql")
|
|
114
|
+
# @param batch [Integer] Batch number this migration belongs to
|
|
115
|
+
# @param passed [Integer] 1 if successful (default), 0 if failed
|
|
116
|
+
def record_migration(name, batch, passed: 1)
|
|
117
|
+
_record_migration(name, batch, passed: passed)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Delete a migration record from the tracking table by filename.
|
|
121
|
+
#
|
|
122
|
+
# @param name [String] Migration filename to remove
|
|
123
|
+
def remove_migration_record(name)
|
|
124
|
+
_remove_migration_record(name)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Create a migration file — static helper for parity with Python/Node.
|
|
128
|
+
# @param description [String] Human-readable migration name
|
|
129
|
+
# @param migrations_dir [String] Directory for migration files (default: 'migrations')
|
|
130
|
+
# @param kind [String] File kind: 'sql' or 'ruby' (default: 'sql')
|
|
131
|
+
def self.create_migration(description, migrations_dir: "migrations", kind: "sql")
|
|
132
|
+
new(nil, migrations_dir: migrations_dir).create(description, kind)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Get list of applied migration records (public alias for completed_migrations)
|
|
136
|
+
def get_applied
|
|
137
|
+
completed_migrations
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Alias for get_applied — parity with PHP/Node
|
|
141
|
+
def get_applied_migrations
|
|
142
|
+
get_applied
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Get list of pending migration filenames (public alias for pending_migrations)
|
|
146
|
+
def get_pending
|
|
147
|
+
pending_migrations.map { |f| File.basename(f) }
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Get all migration files on disk, excluding .down files
|
|
151
|
+
def get_files
|
|
152
|
+
migration_files = Dir.glob(File.join(@migrations_dir, "*.sql")).reject { |f| f.end_with?(".down.sql") }
|
|
153
|
+
migration_files += Dir.glob(File.join(@migrations_dir, "*.rb"))
|
|
154
|
+
migration_files.map { |f| File.basename(f) }.sort
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
private
|
|
158
|
+
|
|
159
|
+
# Resolve migrations directory: prefer src/migrations, fall back to migrations/
|
|
160
|
+
def resolve_migrations_dir
|
|
161
|
+
src_dir = File.join(Dir.pwd, "src", "migrations")
|
|
162
|
+
return src_dir if Dir.exist?(src_dir)
|
|
163
|
+
|
|
164
|
+
root_dir = File.join(Dir.pwd, "migrations")
|
|
165
|
+
return root_dir if Dir.exist?(root_dir)
|
|
166
|
+
|
|
167
|
+
# Default to src/migrations (will be created when needed)
|
|
168
|
+
src_dir
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def ensure_tracking_table
|
|
172
|
+
return unless @db
|
|
173
|
+
unless @db.table_exists?(TRACKING_TABLE)
|
|
174
|
+
if firebird?
|
|
175
|
+
# Firebird: no AUTOINCREMENT, no TEXT type, use generator for IDs
|
|
176
|
+
begin
|
|
177
|
+
@db.execute("CREATE GENERATOR GEN_TINA4_MIGRATION_ID")
|
|
178
|
+
@db.execute("COMMIT") rescue nil
|
|
179
|
+
rescue
|
|
180
|
+
# Generator may already exist
|
|
181
|
+
end
|
|
182
|
+
@db.execute(<<~SQL)
|
|
183
|
+
CREATE TABLE #{TRACKING_TABLE} (
|
|
184
|
+
id INTEGER NOT NULL PRIMARY KEY,
|
|
185
|
+
migration_name VARCHAR(500) NOT NULL,
|
|
186
|
+
description VARCHAR(500) DEFAULT '',
|
|
187
|
+
batch INTEGER NOT NULL DEFAULT 1,
|
|
188
|
+
executed_at VARCHAR(50) DEFAULT CURRENT_TIMESTAMP,
|
|
189
|
+
passed INTEGER NOT NULL DEFAULT 1
|
|
190
|
+
)
|
|
191
|
+
SQL
|
|
192
|
+
else
|
|
193
|
+
@db.execute(<<~SQL)
|
|
194
|
+
CREATE TABLE #{TRACKING_TABLE} (
|
|
195
|
+
id INTEGER PRIMARY KEY,
|
|
196
|
+
migration_name VARCHAR(255) NOT NULL,
|
|
197
|
+
description VARCHAR(255) DEFAULT '',
|
|
198
|
+
batch INTEGER NOT NULL DEFAULT 1,
|
|
199
|
+
executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
200
|
+
passed INTEGER NOT NULL DEFAULT 1
|
|
201
|
+
)
|
|
202
|
+
SQL
|
|
203
|
+
end
|
|
204
|
+
Tina4::Log.info("Created migrations tracking table")
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def completed_migrations
|
|
209
|
+
result = @db.fetch("SELECT migration_name FROM #{TRACKING_TABLE} WHERE passed = 1 ORDER BY id")
|
|
210
|
+
result.map { |r| r[:migration_name] }
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def completed_migrations_with_batch
|
|
214
|
+
result = @db.fetch("SELECT id, migration_name, batch FROM #{TRACKING_TABLE} WHERE passed = 1 ORDER BY id")
|
|
215
|
+
result.map { |r| { id: r[:id], migration_name: r[:migration_name], batch: r[:batch] } }
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def next_batch_number
|
|
219
|
+
result = @db.fetch_one("SELECT MAX(batch) as max_batch FROM #{TRACKING_TABLE} WHERE passed = 1")
|
|
220
|
+
(result && result[:max_batch] ? result[:max_batch].to_i : 0) + 1
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def pending_migrations
|
|
224
|
+
return [] unless Dir.exist?(@migrations_dir)
|
|
225
|
+
|
|
226
|
+
completed = completed_migrations
|
|
227
|
+
# Support both .rb and .sql migration files
|
|
228
|
+
# Accept both 000001_name.sql (sequential) and YYYYMMDDHHMMSS_name.sql (timestamp) patterns
|
|
229
|
+
Dir.glob(File.join(@migrations_dir, "*.{rb,sql}"))
|
|
230
|
+
.reject { |f| f.end_with?(".down.sql") }
|
|
231
|
+
.sort_by { |f| migration_sort_key(File.basename(f)) }
|
|
232
|
+
.reject { |f| completed.include?(File.basename(f)) }
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Sort key that handles both 000001_name.sql and 20240315120000_name.sql patterns.
|
|
236
|
+
# Both are zero-padded numeric prefixes so alphabetical sorting works, but we
|
|
237
|
+
# extract the prefix explicitly to guarantee correct ordering when mixed.
|
|
238
|
+
def migration_sort_key(filename)
|
|
239
|
+
if filename =~ /\A(\d+)/
|
|
240
|
+
[$1.to_i, filename]
|
|
241
|
+
else
|
|
242
|
+
[0, filename]
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def run_migration(file, batch)
|
|
247
|
+
name = File.basename(file)
|
|
248
|
+
Tina4::Log.info("Running migration: #{name}")
|
|
249
|
+
begin
|
|
250
|
+
if file.end_with?(".rb")
|
|
251
|
+
execute_ruby_migration(file, :up)
|
|
252
|
+
else
|
|
253
|
+
execute_sql_file(file)
|
|
254
|
+
end
|
|
255
|
+
_record_migration(name, batch, passed: 1)
|
|
256
|
+
{ name: name, status: "success" }
|
|
257
|
+
rescue => e
|
|
258
|
+
Tina4::Log.error("Migration failed: #{name} - #{e.message}")
|
|
259
|
+
_record_migration(name, batch, passed: 0)
|
|
260
|
+
{ name: name, status: "failed", error: e.message }
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def rollback_migration(name)
|
|
265
|
+
Tina4::Log.info("Rolling back: #{name}")
|
|
266
|
+
begin
|
|
267
|
+
file = File.join(@migrations_dir, name)
|
|
268
|
+
if name.end_with?(".rb") && File.exist?(file)
|
|
269
|
+
execute_ruby_migration(file, :down)
|
|
270
|
+
elsif name.end_with?(".sql")
|
|
271
|
+
down_file = File.join(@migrations_dir, name.sub(".sql", ".down.sql"))
|
|
272
|
+
if File.exist?(down_file)
|
|
273
|
+
execute_sql_file(down_file)
|
|
274
|
+
else
|
|
275
|
+
Tina4::Log.warning("No rollback file for: #{name}")
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
_remove_migration_record(name)
|
|
279
|
+
{ name: name, status: "rolled_back" }
|
|
280
|
+
rescue => e
|
|
281
|
+
Tina4::Log.error("Rollback failed: #{name} - #{e.message}")
|
|
282
|
+
{ name: name, status: "failed", error: e.message }
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def execute_ruby_migration(file, direction)
|
|
287
|
+
# Load the migration class
|
|
288
|
+
content = File.read(file)
|
|
289
|
+
# Evaluate in a clean binding
|
|
290
|
+
eval(content, TOPLEVEL_BINDING, file)
|
|
291
|
+
|
|
292
|
+
# Find the migration class (last class defined that inherits from MigrationBase)
|
|
293
|
+
class_name = extract_class_name(content)
|
|
294
|
+
klass = Object.const_get(class_name)
|
|
295
|
+
migration = klass.new
|
|
296
|
+
migration.__send__(direction, @db)
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# Split SQL into individual statements, handling:
|
|
300
|
+
# - $$ delimited stored procedure blocks
|
|
301
|
+
# - // delimited blocks
|
|
302
|
+
# - Block comments /* ... */
|
|
303
|
+
# - Line comments -- ...
|
|
304
|
+
# Matches the Python/Node.js approach: extract blocks first, split on ;, restore blocks.
|
|
305
|
+
def split_sql_statements(sql, delimiter = ";")
|
|
306
|
+
blocks = []
|
|
307
|
+
|
|
308
|
+
# Extract $$ ... $$ blocks (stored procedures, triggers, etc.)
|
|
309
|
+
processed = sql.gsub(/\$\$(.*?)\$\$/m) do
|
|
310
|
+
blocks << $~.to_s
|
|
311
|
+
"__BLOCK_#{blocks.length - 1}__"
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# Extract // ... // blocks
|
|
315
|
+
processed = processed.gsub(/\/\/(.*?)\/\//m) do
|
|
316
|
+
blocks << $~.to_s
|
|
317
|
+
"__BLOCK_#{blocks.length - 1}__"
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# Remove block comments (/* ... */) but not inside stored proc blocks (already extracted)
|
|
321
|
+
clean = processed.gsub(/\/\*.*?\*\//m, "")
|
|
322
|
+
|
|
323
|
+
statements = []
|
|
324
|
+
clean.split(delimiter).each do |stmt|
|
|
325
|
+
lines = []
|
|
326
|
+
stmt.split("\n").each do |line|
|
|
327
|
+
stripped = line.strip
|
|
328
|
+
next if stripped.empty? || stripped.start_with?("--")
|
|
329
|
+
# Remove inline comments (-- after SQL)
|
|
330
|
+
comment_pos = line.index("--")
|
|
331
|
+
line = line[0...comment_pos] if comment_pos && comment_pos >= 0
|
|
332
|
+
lines << line
|
|
333
|
+
end
|
|
334
|
+
cleaned = lines.join("\n").strip
|
|
335
|
+
|
|
336
|
+
# Restore block placeholders
|
|
337
|
+
blocks.each_with_index do |block, i|
|
|
338
|
+
cleaned = cleaned.gsub("__BLOCK_#{i}__", block)
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
statements << cleaned unless cleaned.empty?
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
statements
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def execute_sql_file(file)
|
|
348
|
+
sql = File.read(file)
|
|
349
|
+
statements = split_sql_statements(sql)
|
|
350
|
+
statements.each do |stmt|
|
|
351
|
+
# Firebird lacks IF NOT EXISTS for ALTER TABLE ADD.
|
|
352
|
+
# Pre-check the system catalogue so duplicate columns are
|
|
353
|
+
# silently skipped instead of raising an error.
|
|
354
|
+
skip_reason = should_skip_for_firebird(stmt)
|
|
355
|
+
if skip_reason
|
|
356
|
+
Tina4::Log.info("Migration #{File.basename(file)}: #{skip_reason}")
|
|
357
|
+
next
|
|
358
|
+
end
|
|
359
|
+
result = @db.execute(stmt)
|
|
360
|
+
if result == false
|
|
361
|
+
raise RuntimeError, @db.get_error || "SQL execution failed: #{stmt}"
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
# Regex to match ALTER TABLE <table> ADD <column> ...
|
|
367
|
+
ALTER_ADD_RE = /\A\s*ALTER\s+TABLE\s+(?:"([^"]+)"|(\S+))\s+ADD\s+(?:"([^"]+)"|(\S+))/i
|
|
368
|
+
|
|
369
|
+
def firebird?
|
|
370
|
+
@db.driver_name == "firebird"
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
# Check if a column already exists in a Firebird table via RDB$RELATION_FIELDS.
|
|
374
|
+
# Firebird stores unquoted identifiers in upper-case.
|
|
375
|
+
def firebird_column_exists?(table, column)
|
|
376
|
+
row = @db.fetch_one(
|
|
377
|
+
"SELECT 1 FROM RDB\$RELATION_FIELDS WHERE RDB\$RELATION_NAME = ? AND TRIM(RDB\$FIELD_NAME) = ?",
|
|
378
|
+
[table.upcase, column.upcase]
|
|
379
|
+
)
|
|
380
|
+
!row.nil?
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
# If stmt is an ALTER TABLE ... ADD on Firebird and the column already exists,
|
|
384
|
+
# returns a skip reason. Returns nil if the statement should execute normally.
|
|
385
|
+
def should_skip_for_firebird(stmt)
|
|
386
|
+
return nil unless firebird?
|
|
387
|
+
|
|
388
|
+
m = stmt.match(ALTER_ADD_RE)
|
|
389
|
+
return nil unless m
|
|
390
|
+
|
|
391
|
+
table = m[1] || m[2]
|
|
392
|
+
column = m[3] || m[4]
|
|
393
|
+
|
|
394
|
+
if firebird_column_exists?(table, column)
|
|
395
|
+
"Column #{column} already exists in #{table}, skipping"
|
|
396
|
+
end
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
def _record_migration(name, batch, passed: 1)
|
|
400
|
+
# Extract description from filename (strip numeric prefix and extension)
|
|
401
|
+
stem = File.basename(name, File.extname(name))
|
|
402
|
+
desc = stem.sub(/\A\d+_/, "").tr("_", " ")
|
|
403
|
+
if firebird?
|
|
404
|
+
# Firebird: generate ID from sequence
|
|
405
|
+
row = @db.fetch_one(
|
|
406
|
+
"SELECT GEN_ID(GEN_TINA4_MIGRATION_ID, 1) AS NEXT_ID FROM RDB\$DATABASE"
|
|
407
|
+
)
|
|
408
|
+
next_id = row ? (row[:NEXT_ID] || row[:next_id] || 1).to_i : 1
|
|
409
|
+
@db.execute(
|
|
410
|
+
"INSERT INTO #{TRACKING_TABLE} (id, migration_name, description, batch, passed) VALUES (?, ?, ?, ?, ?)",
|
|
411
|
+
[next_id, name, desc, batch, passed]
|
|
412
|
+
)
|
|
413
|
+
else
|
|
414
|
+
@db.execute(
|
|
415
|
+
"INSERT INTO #{TRACKING_TABLE} (migration_name, description, batch, passed) VALUES (?, ?, ?, ?)",
|
|
416
|
+
[name, desc, batch, passed]
|
|
417
|
+
)
|
|
418
|
+
end
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
def _remove_migration_record(name)
|
|
422
|
+
@db.delete(TRACKING_TABLE, { migration_name: name })
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
def classify(name)
|
|
426
|
+
name.gsub(/[^a-zA-Z0-9_]/, "_")
|
|
427
|
+
.split("_")
|
|
428
|
+
.map(&:capitalize)
|
|
429
|
+
.join
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
def extract_class_name(content)
|
|
433
|
+
if content =~ /class\s+(\w+)\s*<\s*Tina4::MigrationBase/
|
|
434
|
+
$1
|
|
435
|
+
else
|
|
436
|
+
raise "No migration class found inheriting from Tina4::MigrationBase"
|
|
437
|
+
end
|
|
438
|
+
end
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
# Base class for Ruby migrations
|
|
442
|
+
class MigrationBase
|
|
443
|
+
def up(db = nil)
|
|
444
|
+
raise NotImplementedError, "Implement #up in your migration"
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
def down(db = nil)
|
|
448
|
+
raise NotImplementedError, "Implement #down in your migration"
|
|
449
|
+
end
|
|
450
|
+
end
|
|
451
|
+
end
|