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.
Files changed (132) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +80 -80
  3. data/LICENSE.txt +21 -21
  4. data/README.md +137 -137
  5. data/exe/tina4ruby +5 -5
  6. data/lib/tina4/ai.rb +696 -696
  7. data/lib/tina4/api.rb +189 -189
  8. data/lib/tina4/auth.rb +305 -305
  9. data/lib/tina4/auto_crud.rb +244 -244
  10. data/lib/tina4/cache.rb +154 -154
  11. data/lib/tina4/cli.rb +1449 -1449
  12. data/lib/tina4/constants.rb +46 -46
  13. data/lib/tina4/container.rb +74 -74
  14. data/lib/tina4/cors.rb +74 -74
  15. data/lib/tina4/crud.rb +692 -692
  16. data/lib/tina4/database/sqlite3_adapter.rb +165 -165
  17. data/lib/tina4/database.rb +625 -625
  18. data/lib/tina4/database_result.rb +208 -208
  19. data/lib/tina4/debug.rb +8 -8
  20. data/lib/tina4/dev.rb +14 -14
  21. data/lib/tina4/dev_admin.rb +935 -935
  22. data/lib/tina4/dev_mailbox.rb +191 -191
  23. data/lib/tina4/drivers/firebird_driver.rb +124 -110
  24. data/lib/tina4/drivers/mongodb_driver.rb +561 -561
  25. data/lib/tina4/drivers/mssql_driver.rb +112 -112
  26. data/lib/tina4/drivers/mysql_driver.rb +90 -90
  27. data/lib/tina4/drivers/odbc_driver.rb +191 -191
  28. data/lib/tina4/drivers/postgres_driver.rb +116 -106
  29. data/lib/tina4/drivers/sqlite_driver.rb +122 -122
  30. data/lib/tina4/env.rb +95 -95
  31. data/lib/tina4/error_overlay.rb +252 -252
  32. data/lib/tina4/events.rb +109 -109
  33. data/lib/tina4/field_types.rb +154 -154
  34. data/lib/tina4/frond.rb +2025 -2025
  35. data/lib/tina4/gallery/auth/meta.json +1 -1
  36. data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +114 -114
  37. data/lib/tina4/gallery/database/meta.json +1 -1
  38. data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +43 -43
  39. data/lib/tina4/gallery/error-overlay/meta.json +1 -1
  40. data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +17 -17
  41. data/lib/tina4/gallery/orm/meta.json +1 -1
  42. data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +16 -16
  43. data/lib/tina4/gallery/queue/meta.json +1 -1
  44. data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +325 -325
  45. data/lib/tina4/gallery/rest-api/meta.json +1 -1
  46. data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +14 -14
  47. data/lib/tina4/gallery/templates/meta.json +1 -1
  48. data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +12 -12
  49. data/lib/tina4/gallery/templates/src/templates/gallery_page.twig +257 -257
  50. data/lib/tina4/graphql.rb +966 -966
  51. data/lib/tina4/health.rb +39 -39
  52. data/lib/tina4/html_element.rb +170 -170
  53. data/lib/tina4/job.rb +80 -80
  54. data/lib/tina4/localization.rb +168 -168
  55. data/lib/tina4/log.rb +203 -203
  56. data/lib/tina4/mcp.rb +696 -696
  57. data/lib/tina4/messenger.rb +587 -587
  58. data/lib/tina4/metrics.rb +793 -793
  59. data/lib/tina4/middleware.rb +445 -445
  60. data/lib/tina4/migration.rb +451 -451
  61. data/lib/tina4/orm.rb +790 -790
  62. data/lib/tina4/public/css/tina4.css +2463 -2463
  63. data/lib/tina4/public/css/tina4.min.css +1 -1
  64. data/lib/tina4/public/images/logo.svg +5 -5
  65. data/lib/tina4/public/js/frond.min.js +2 -2
  66. data/lib/tina4/public/js/tina4-dev-admin.js +565 -565
  67. data/lib/tina4/public/js/tina4-dev-admin.min.js +480 -480
  68. data/lib/tina4/public/js/tina4.min.js +92 -92
  69. data/lib/tina4/public/js/tina4js.min.js +48 -48
  70. data/lib/tina4/public/swagger/index.html +90 -90
  71. data/lib/tina4/public/swagger/oauth2-redirect.html +63 -63
  72. data/lib/tina4/query_builder.rb +380 -380
  73. data/lib/tina4/queue.rb +366 -366
  74. data/lib/tina4/queue_backends/kafka_backend.rb +80 -80
  75. data/lib/tina4/queue_backends/lite_backend.rb +298 -298
  76. data/lib/tina4/queue_backends/mongo_backend.rb +126 -126
  77. data/lib/tina4/queue_backends/rabbitmq_backend.rb +73 -73
  78. data/lib/tina4/rack_app.rb +817 -817
  79. data/lib/tina4/rate_limiter.rb +130 -130
  80. data/lib/tina4/request.rb +268 -255
  81. data/lib/tina4/response.rb +346 -346
  82. data/lib/tina4/response_cache.rb +551 -551
  83. data/lib/tina4/router.rb +406 -406
  84. data/lib/tina4/scss/tina4css/_alerts.scss +34 -34
  85. data/lib/tina4/scss/tina4css/_badges.scss +22 -22
  86. data/lib/tina4/scss/tina4css/_buttons.scss +69 -69
  87. data/lib/tina4/scss/tina4css/_cards.scss +49 -49
  88. data/lib/tina4/scss/tina4css/_forms.scss +156 -156
  89. data/lib/tina4/scss/tina4css/_grid.scss +81 -81
  90. data/lib/tina4/scss/tina4css/_modals.scss +84 -84
  91. data/lib/tina4/scss/tina4css/_nav.scss +149 -149
  92. data/lib/tina4/scss/tina4css/_reset.scss +94 -94
  93. data/lib/tina4/scss/tina4css/_tables.scss +54 -54
  94. data/lib/tina4/scss/tina4css/_typography.scss +55 -55
  95. data/lib/tina4/scss/tina4css/_utilities.scss +197 -197
  96. data/lib/tina4/scss/tina4css/_variables.scss +117 -117
  97. data/lib/tina4/scss/tina4css/base.scss +1 -1
  98. data/lib/tina4/scss/tina4css/colors.scss +48 -48
  99. data/lib/tina4/scss/tina4css/tina4.scss +17 -17
  100. data/lib/tina4/scss_compiler.rb +178 -178
  101. data/lib/tina4/seeder.rb +567 -567
  102. data/lib/tina4/service_runner.rb +303 -303
  103. data/lib/tina4/session.rb +297 -297
  104. data/lib/tina4/session_handlers/database_handler.rb +72 -72
  105. data/lib/tina4/session_handlers/file_handler.rb +67 -67
  106. data/lib/tina4/session_handlers/mongo_handler.rb +49 -49
  107. data/lib/tina4/session_handlers/redis_handler.rb +43 -43
  108. data/lib/tina4/session_handlers/valkey_handler.rb +43 -43
  109. data/lib/tina4/shutdown.rb +84 -84
  110. data/lib/tina4/sql_translation.rb +158 -158
  111. data/lib/tina4/swagger.rb +124 -124
  112. data/lib/tina4/template.rb +894 -894
  113. data/lib/tina4/templates/base.twig +26 -26
  114. data/lib/tina4/templates/errors/302.twig +14 -14
  115. data/lib/tina4/templates/errors/401.twig +9 -9
  116. data/lib/tina4/templates/errors/403.twig +29 -29
  117. data/lib/tina4/templates/errors/404.twig +29 -29
  118. data/lib/tina4/templates/errors/500.twig +38 -38
  119. data/lib/tina4/templates/errors/502.twig +9 -9
  120. data/lib/tina4/templates/errors/503.twig +12 -12
  121. data/lib/tina4/templates/errors/base.twig +37 -37
  122. data/lib/tina4/test_client.rb +159 -159
  123. data/lib/tina4/testing.rb +340 -340
  124. data/lib/tina4/validator.rb +174 -174
  125. data/lib/tina4/version.rb +1 -1
  126. data/lib/tina4/webserver.rb +312 -312
  127. data/lib/tina4/websocket.rb +343 -343
  128. data/lib/tina4/websocket_backplane.rb +190 -190
  129. data/lib/tina4/wsdl.rb +564 -564
  130. data/lib/tina4.rb +458 -458
  131. data/lib/tina4ruby.rb +4 -4
  132. metadata +3 -3
@@ -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