tina4ruby 0.4.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.
Files changed (74) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +80 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +768 -0
  5. data/exe/tina4 +4 -0
  6. data/lib/tina4/api.rb +152 -0
  7. data/lib/tina4/auth.rb +139 -0
  8. data/lib/tina4/cli.rb +349 -0
  9. data/lib/tina4/crud.rb +124 -0
  10. data/lib/tina4/database.rb +135 -0
  11. data/lib/tina4/database_result.rb +89 -0
  12. data/lib/tina4/debug.rb +83 -0
  13. data/lib/tina4/dev.rb +15 -0
  14. data/lib/tina4/dev_reload.rb +68 -0
  15. data/lib/tina4/drivers/firebird_driver.rb +94 -0
  16. data/lib/tina4/drivers/mssql_driver.rb +112 -0
  17. data/lib/tina4/drivers/mysql_driver.rb +90 -0
  18. data/lib/tina4/drivers/postgres_driver.rb +99 -0
  19. data/lib/tina4/drivers/sqlite_driver.rb +85 -0
  20. data/lib/tina4/env.rb +55 -0
  21. data/lib/tina4/field_types.rb +84 -0
  22. data/lib/tina4/graphql.rb +837 -0
  23. data/lib/tina4/localization.rb +100 -0
  24. data/lib/tina4/middleware.rb +59 -0
  25. data/lib/tina4/migration.rb +124 -0
  26. data/lib/tina4/orm.rb +168 -0
  27. data/lib/tina4/public/css/tina4.css +2286 -0
  28. data/lib/tina4/public/css/tina4.min.css +2 -0
  29. data/lib/tina4/public/js/tina4.js +134 -0
  30. data/lib/tina4/public/js/tina4helper.js +387 -0
  31. data/lib/tina4/queue.rb +117 -0
  32. data/lib/tina4/queue_backends/kafka_backend.rb +80 -0
  33. data/lib/tina4/queue_backends/lite_backend.rb +79 -0
  34. data/lib/tina4/queue_backends/rabbitmq_backend.rb +73 -0
  35. data/lib/tina4/rack_app.rb +150 -0
  36. data/lib/tina4/request.rb +158 -0
  37. data/lib/tina4/response.rb +172 -0
  38. data/lib/tina4/router.rb +148 -0
  39. data/lib/tina4/scss/tina4css/_alerts.scss +34 -0
  40. data/lib/tina4/scss/tina4css/_badges.scss +22 -0
  41. data/lib/tina4/scss/tina4css/_buttons.scss +69 -0
  42. data/lib/tina4/scss/tina4css/_cards.scss +49 -0
  43. data/lib/tina4/scss/tina4css/_forms.scss +156 -0
  44. data/lib/tina4/scss/tina4css/_grid.scss +81 -0
  45. data/lib/tina4/scss/tina4css/_modals.scss +84 -0
  46. data/lib/tina4/scss/tina4css/_nav.scss +149 -0
  47. data/lib/tina4/scss/tina4css/_reset.scss +94 -0
  48. data/lib/tina4/scss/tina4css/_tables.scss +54 -0
  49. data/lib/tina4/scss/tina4css/_typography.scss +55 -0
  50. data/lib/tina4/scss/tina4css/_utilities.scss +197 -0
  51. data/lib/tina4/scss/tina4css/_variables.scss +117 -0
  52. data/lib/tina4/scss/tina4css/base.scss +1 -0
  53. data/lib/tina4/scss/tina4css/colors.scss +48 -0
  54. data/lib/tina4/scss/tina4css/tina4.scss +17 -0
  55. data/lib/tina4/scss_compiler.rb +131 -0
  56. data/lib/tina4/seeder.rb +529 -0
  57. data/lib/tina4/session.rb +145 -0
  58. data/lib/tina4/session_handlers/file_handler.rb +55 -0
  59. data/lib/tina4/session_handlers/mongo_handler.rb +49 -0
  60. data/lib/tina4/session_handlers/redis_handler.rb +43 -0
  61. data/lib/tina4/swagger.rb +123 -0
  62. data/lib/tina4/template.rb +478 -0
  63. data/lib/tina4/templates/base.twig +26 -0
  64. data/lib/tina4/templates/errors/403.twig +22 -0
  65. data/lib/tina4/templates/errors/404.twig +22 -0
  66. data/lib/tina4/templates/errors/500.twig +22 -0
  67. data/lib/tina4/testing.rb +213 -0
  68. data/lib/tina4/version.rb +5 -0
  69. data/lib/tina4/webserver.rb +101 -0
  70. data/lib/tina4/websocket.rb +167 -0
  71. data/lib/tina4/wsdl.rb +164 -0
  72. data/lib/tina4.rb +259 -0
  73. data/lib/tina4ruby.rb +4 -0
  74. metadata +324 -0
data/lib/tina4/crud.rb ADDED
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tina4
4
+ module Crud
5
+ class << self
6
+ def generate_table(records, table_name: "data", primary_key: "id", editable: true)
7
+ return "<p>No records found.</p>" if records.nil? || records.empty?
8
+
9
+ columns = records.first.keys
10
+
11
+ html = <<~HTML
12
+ <div class="table-responsive">
13
+ <table class="table table-striped table-hover" id="crud-#{table_name}">
14
+ <thead class="table-dark"><tr>
15
+ HTML
16
+
17
+ columns.each do |col|
18
+ html += "<th>#{col}</th>"
19
+ end
20
+ html += "<th>Actions</th>" if editable
21
+ html += "</tr></thead><tbody>"
22
+
23
+ records.each do |row|
24
+ pk_value = row[primary_key.to_sym] || row[primary_key.to_s]
25
+ html += "<tr data-id=\"#{pk_value}\">"
26
+ columns.each do |col|
27
+ value = row[col]
28
+ if editable
29
+ html += "<td contenteditable=\"true\" data-field=\"#{col}\">#{value}</td>"
30
+ else
31
+ html += "<td>#{value}</td>"
32
+ end
33
+ end
34
+ if editable
35
+ html += "<td>"
36
+ html += "<button class=\"btn btn-sm btn-primary me-1\" onclick=\"crudSave('#{table_name}', '#{pk_value}')\">Save</button>"
37
+ html += "<button class=\"btn btn-sm btn-danger\" onclick=\"crudDelete('#{table_name}', '#{pk_value}')\">Delete</button>"
38
+ html += "</td>"
39
+ end
40
+ html += "</tr>"
41
+ end
42
+
43
+ html += "</tbody></table></div>"
44
+
45
+ if editable
46
+ html += crud_javascript(table_name)
47
+ end
48
+
49
+ html
50
+ end
51
+
52
+ def generate_form(fields, action: "/", method: "POST", table_name: "data")
53
+ html = "<form action=\"#{action}\" method=\"#{method}\" class=\"needs-validation\" novalidate>"
54
+ html += "<input type=\"hidden\" name=\"_method\" value=\"#{method}\">" if %w[PUT PATCH DELETE].include?(method.upcase)
55
+
56
+ fields.each do |field|
57
+ name = field[:name]
58
+ type = field[:type] || :string
59
+ label = field[:label] || name.to_s.capitalize
60
+ value = field[:value] || ""
61
+ required = field[:required] || false
62
+
63
+ html += "<div class=\"mb-3\">"
64
+ html += "<label for=\"#{name}\" class=\"form-label\">#{label}</label>"
65
+
66
+ case type.to_sym
67
+ when :text
68
+ html += "<textarea class=\"form-control\" id=\"#{name}\" name=\"#{name}\" #{'required' if required}>#{value}</textarea>"
69
+ when :boolean
70
+ checked = value ? "checked" : ""
71
+ html += "<div class=\"form-check\">"
72
+ html += "<input class=\"form-check-input\" type=\"checkbox\" id=\"#{name}\" name=\"#{name}\" #{checked}>"
73
+ html += "</div>"
74
+ when :select
75
+ html += "<select class=\"form-select\" id=\"#{name}\" name=\"#{name}\" #{'required' if required}>"
76
+ (field[:options] || []).each do |opt|
77
+ selected = opt[:value].to_s == value.to_s ? "selected" : ""
78
+ html += "<option value=\"#{opt[:value]}\" #{selected}>#{opt[:label]}</option>"
79
+ end
80
+ html += "</select>"
81
+ when :date
82
+ html += "<input type=\"date\" class=\"form-control\" id=\"#{name}\" name=\"#{name}\" value=\"#{value}\" #{'required' if required}>"
83
+ when :integer, :number
84
+ html += "<input type=\"number\" class=\"form-control\" id=\"#{name}\" name=\"#{name}\" value=\"#{value}\" #{'required' if required}>"
85
+ else
86
+ html += "<input type=\"text\" class=\"form-control\" id=\"#{name}\" name=\"#{name}\" value=\"#{value}\" #{'required' if required}>"
87
+ end
88
+ html += "</div>"
89
+ end
90
+
91
+ html += "<button type=\"submit\" class=\"btn btn-primary\">Submit</button>"
92
+ html += "</form>"
93
+ html
94
+ end
95
+
96
+ private
97
+
98
+ def crud_javascript(table_name)
99
+ <<~JS
100
+ <script>
101
+ function crudSave(table, id) {
102
+ const row = document.querySelector(`tr[data-id="${id}"]`);
103
+ const cells = row.querySelectorAll('td[data-field]');
104
+ const data = {};
105
+ cells.forEach(cell => { data[cell.dataset.field] = cell.textContent; });
106
+ fetch(`/api/${table}/${id}`, {
107
+ method: 'PUT',
108
+ headers: { 'Content-Type': 'application/json' },
109
+ body: JSON.stringify(data)
110
+ }).then(r => r.json()).then(d => { alert('Saved!'); }).catch(e => alert('Error: ' + e));
111
+ }
112
+ function crudDelete(table, id) {
113
+ if (!confirm('Delete this record?')) return;
114
+ fetch(`/api/${table}/${id}`, { method: 'DELETE' })
115
+ .then(r => r.json())
116
+ .then(d => { document.querySelector(`tr[data-id="${id}"]`).remove(); })
117
+ .catch(e => alert('Error: ' + e));
118
+ }
119
+ </script>
120
+ JS
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+ require "json"
3
+
4
+ module Tina4
5
+ class Database
6
+ attr_reader :driver, :driver_name, :connected
7
+
8
+ DRIVERS = {
9
+ "sqlite" => "Tina4::Drivers::SqliteDriver",
10
+ "sqlite3" => "Tina4::Drivers::SqliteDriver",
11
+ "postgres" => "Tina4::Drivers::PostgresDriver",
12
+ "postgresql" => "Tina4::Drivers::PostgresDriver",
13
+ "mysql" => "Tina4::Drivers::MysqlDriver",
14
+ "mysql2" => "Tina4::Drivers::MysqlDriver",
15
+ "mssql" => "Tina4::Drivers::MssqlDriver",
16
+ "sqlserver" => "Tina4::Drivers::MssqlDriver",
17
+ "firebird" => "Tina4::Drivers::FirebirdDriver"
18
+ }.freeze
19
+
20
+ def initialize(connection_string, driver_name: nil)
21
+ @connection_string = connection_string
22
+ @driver_name = driver_name || detect_driver(connection_string)
23
+ @driver = create_driver
24
+ @connected = false
25
+ connect
26
+ end
27
+
28
+ def connect
29
+ @driver.connect(@connection_string)
30
+ @connected = true
31
+ Tina4::Debug.info("Database connected: #{@driver_name}")
32
+ rescue => e
33
+ Tina4::Debug.error("Database connection failed: #{e.message}")
34
+ @connected = false
35
+ end
36
+
37
+ def close
38
+ @driver.close if @connected
39
+ @connected = false
40
+ end
41
+
42
+ def fetch(sql, params = [], limit: nil, skip: nil)
43
+ effective_sql = sql
44
+ if limit
45
+ effective_sql = @driver.apply_limit(effective_sql, limit, skip || 0)
46
+ end
47
+ rows = @driver.execute_query(effective_sql, params)
48
+ Tina4::DatabaseResult.new(rows, sql: effective_sql)
49
+ end
50
+
51
+ def fetch_one(sql, params = [])
52
+ result = fetch(sql, params, limit: 1)
53
+ result.first
54
+ end
55
+
56
+ def insert(table, data)
57
+ columns = data.keys.map(&:to_s)
58
+ placeholders = @driver.placeholders(columns.length)
59
+ sql = "INSERT INTO #{table} (#{columns.join(', ')}) VALUES (#{placeholders})"
60
+ @driver.execute(sql, data.values)
61
+ { success: true, last_id: @driver.last_insert_id }
62
+ end
63
+
64
+ def update(table, data, filter = {})
65
+ set_parts = data.keys.map { |k| "#{k} = #{@driver.placeholder}" }
66
+ where_parts = filter.keys.map { |k| "#{k} = #{@driver.placeholder}" }
67
+ sql = "UPDATE #{table} SET #{set_parts.join(', ')}"
68
+ sql += " WHERE #{where_parts.join(' AND ')}" unless filter.empty?
69
+ values = data.values + filter.values
70
+ @driver.execute(sql, values)
71
+ { success: true }
72
+ end
73
+
74
+ def delete(table, filter = {})
75
+ where_parts = filter.keys.map { |k| "#{k} = #{@driver.placeholder}" }
76
+ sql = "DELETE FROM #{table}"
77
+ sql += " WHERE #{where_parts.join(' AND ')}" unless filter.empty?
78
+ @driver.execute(sql, filter.values)
79
+ { success: true }
80
+ end
81
+
82
+ def execute(sql, params = [])
83
+ @driver.execute(sql, params)
84
+ end
85
+
86
+ def transaction
87
+ @driver.begin_transaction
88
+ yield self
89
+ @driver.commit
90
+ rescue => e
91
+ @driver.rollback
92
+ raise e
93
+ end
94
+
95
+ def tables
96
+ @driver.tables
97
+ end
98
+
99
+ def columns(table_name)
100
+ @driver.columns(table_name)
101
+ end
102
+
103
+ def table_exists?(table_name)
104
+ tables.any? { |t| t.downcase == table_name.to_s.downcase }
105
+ end
106
+
107
+ private
108
+
109
+ def detect_driver(conn)
110
+ case conn.to_s.downcase
111
+ when /\.db$/, /\.sqlite/, /sqlite/
112
+ "sqlite"
113
+ when /postgres/, /^pg:/
114
+ "postgres"
115
+ when /mysql/
116
+ "mysql"
117
+ when /mssql/, /sqlserver/
118
+ "mssql"
119
+ when /firebird/, /\.fdb$/
120
+ "firebird"
121
+ else
122
+ "sqlite"
123
+ end
124
+ end
125
+
126
+ def create_driver
127
+ klass_name = DRIVERS[@driver_name]
128
+ raise "Unknown database driver: #{@driver_name}" unless klass_name
129
+ klass = Object.const_get(klass_name)
130
+ klass.new
131
+ rescue NameError
132
+ raise "Driver #{klass_name} not loaded. Install the required gem."
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+ require "json"
3
+
4
+ module Tina4
5
+ class DatabaseResult
6
+ include Enumerable
7
+
8
+ attr_reader :records, :sql, :count
9
+
10
+ def initialize(records = [], sql: "")
11
+ @records = records || []
12
+ @sql = sql
13
+ @count = @records.length
14
+ end
15
+
16
+ def each(&block)
17
+ @records.each(&block)
18
+ end
19
+
20
+ def first
21
+ @records.first
22
+ end
23
+
24
+ def last
25
+ @records.last
26
+ end
27
+
28
+ def empty?
29
+ @records.empty?
30
+ end
31
+
32
+ def [](index)
33
+ @records[index]
34
+ end
35
+
36
+ def to_array
37
+ @records.map do |record|
38
+ record.is_a?(Hash) ? record : record.to_h
39
+ end
40
+ end
41
+
42
+ def to_json(*_args)
43
+ JSON.generate(to_array)
44
+ end
45
+
46
+ def to_csv(separator: ",", headers: true)
47
+ return "" if @records.empty?
48
+ lines = []
49
+ cols = @records.first.keys
50
+ lines << cols.join(separator) if headers
51
+ @records.each do |row|
52
+ lines << cols.map { |c| escape_csv(row[c], separator) }.join(separator)
53
+ end
54
+ lines.join("\n")
55
+ end
56
+
57
+ def to_paginate(page: 1, per_page: 10)
58
+ total = @records.length
59
+ total_pages = (total.to_f / per_page).ceil
60
+ offset = (page - 1) * per_page
61
+ page_records = @records[offset, per_page] || []
62
+ {
63
+ data: page_records,
64
+ page: page,
65
+ per_page: per_page,
66
+ total: total,
67
+ total_pages: total_pages,
68
+ has_next: page < total_pages,
69
+ has_prev: page > 1
70
+ }
71
+ end
72
+
73
+ def to_crud(table_name: "data", primary_key: "id", editable: true)
74
+ Tina4::Crud.generate_table(@records, table_name: table_name,
75
+ primary_key: primary_key, editable: editable)
76
+ end
77
+
78
+ private
79
+
80
+ def escape_csv(value, separator)
81
+ str = value.to_s
82
+ if str.include?(separator) || str.include?('"') || str.include?("\n")
83
+ "\"#{str.gsub('"', '""')}\""
84
+ else
85
+ str
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+ require "logger"
3
+ require "fileutils"
4
+
5
+ module Tina4
6
+ module Debug
7
+ LEVELS = {
8
+ "[TINA4_LOG_ALL]" => Logger::DEBUG,
9
+ "[TINA4_LOG_DEBUG]" => Logger::DEBUG,
10
+ "[TINA4_LOG_INFO]" => Logger::INFO,
11
+ "[TINA4_LOG_WARNING]" => Logger::WARN,
12
+ "[TINA4_LOG_ERROR]" => Logger::ERROR,
13
+ "[TINA4_LOG_NONE]" => Logger::FATAL
14
+ }.freeze
15
+
16
+ COLORS = {
17
+ reset: "\e[0m", red: "\e[31m", green: "\e[32m",
18
+ yellow: "\e[33m", blue: "\e[34m", magenta: "\e[35m",
19
+ cyan: "\e[36m", gray: "\e[90m"
20
+ }.freeze
21
+
22
+ class << self
23
+ def setup(root_dir = Dir.pwd)
24
+ log_dir = File.join(root_dir, "logs")
25
+ FileUtils.mkdir_p(log_dir)
26
+ log_file = File.join(log_dir, "debug.log")
27
+ @file_logger = Logger.new(log_file, 10, 5 * 1024 * 1024)
28
+ @file_logger.level = Logger::DEBUG
29
+ @console_logger = Logger.new($stdout)
30
+ @console_logger.level = resolve_level
31
+ @console_logger.formatter = method(:color_formatter)
32
+ @file_logger.formatter = method(:plain_formatter)
33
+ @initialized = true
34
+ end
35
+
36
+ def info(message, *args)
37
+ log(:info, message, *args)
38
+ end
39
+
40
+ def debug(message, *args)
41
+ log(:debug, message, *args)
42
+ end
43
+
44
+ def warning(message, *args)
45
+ log(:warn, message, *args)
46
+ end
47
+
48
+ def error(message, *args)
49
+ log(:error, message, *args)
50
+ end
51
+
52
+ private
53
+
54
+ def log(level, message, *args)
55
+ setup unless @initialized
56
+ full_message = args.empty? ? message.to_s : "#{message} #{args.map(&:to_s).join(' ')}"
57
+ @console_logger.send(level, full_message)
58
+ @file_logger.send(level, full_message)
59
+ end
60
+
61
+ def resolve_level
62
+ env_level = ENV["TINA4_DEBUG_LEVEL"] || "[TINA4_LOG_ALL]"
63
+ LEVELS[env_level] || Logger::DEBUG
64
+ end
65
+
66
+ def color_formatter(severity, datetime, _progname, message)
67
+ color = case severity
68
+ when "DEBUG" then COLORS[:gray]
69
+ when "INFO" then COLORS[:green]
70
+ when "WARN" then COLORS[:yellow]
71
+ when "ERROR" then COLORS[:red]
72
+ else COLORS[:reset]
73
+ end
74
+ ts = datetime.strftime("%Y-%m-%d %H:%M:%S")
75
+ "#{COLORS[:gray]}[#{ts}]#{COLORS[:reset]} #{color}[#{severity}]#{COLORS[:reset]} #{message}\n"
76
+ end
77
+
78
+ def plain_formatter(severity, datetime, _progname, message)
79
+ "[#{datetime.strftime('%Y-%m-%d %H:%M:%S')}] [#{severity}] #{message}\n"
80
+ end
81
+ end
82
+ end
83
+ end
data/lib/tina4/dev.rb ADDED
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Convenience require for all development/optional tools.
4
+ # Usage: require "tina4/dev"
5
+
6
+ require_relative "dev_reload"
7
+ require_relative "scss_compiler"
8
+ require_relative "testing"
9
+ require_relative "graphql"
10
+ require_relative "websocket"
11
+ require_relative "wsdl"
12
+ require_relative "swagger"
13
+ require_relative "seeder"
14
+ require_relative "crud"
15
+ require_relative "api"
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tina4
4
+ module DevReload
5
+ WATCH_EXTENSIONS = %w[.rb .twig .html .erb .scss .css .js].freeze
6
+ WATCH_DIRS = %w[src routes lib templates public].freeze
7
+ IGNORE_DIRS = %w[.git node_modules vendor logs sessions .queue .keys].freeze
8
+
9
+ class << self
10
+ def start(root_dir: Dir.pwd, &on_change)
11
+ require "listen"
12
+
13
+ dirs = WATCH_DIRS
14
+ .map { |d| File.join(root_dir, d) }
15
+ .select { |d| Dir.exist?(d) }
16
+
17
+ # Also watch root for .rb files
18
+ dirs << root_dir
19
+
20
+ Tina4::Debug.info("Dev reload watching: #{dirs.join(', ')}")
21
+
22
+ @listener = Listen.to(*dirs, only: /\.(#{WATCH_EXTENSIONS.map { |e| e.delete('.') }.join('|')})$/, ignore: build_ignore_regex) do |modified, added, removed|
23
+ changes = { modified: modified, added: added, removed: removed }
24
+ all_files = modified + added + removed
25
+ next if all_files.empty?
26
+
27
+ Tina4::Debug.info("File changes detected:")
28
+ modified.each { |f| Tina4::Debug.debug(" Modified: #{f}") }
29
+ added.each { |f| Tina4::Debug.debug(" Added: #{f}") }
30
+ removed.each { |f| Tina4::Debug.debug(" Removed: #{f}") }
31
+
32
+ # Reload Ruby files
33
+ modified.select { |f| f.end_with?(".rb") }.each do |file|
34
+ begin
35
+ load file
36
+ Tina4::Debug.info("Reloaded: #{file}")
37
+ rescue => e
38
+ Tina4::Debug.error("Reload failed: #{file} - #{e.message}")
39
+ end
40
+ end
41
+
42
+ # Recompile SCSS
43
+ scss_changes = all_files.select { |f| f.end_with?(".scss") }
44
+ if scss_changes.any?
45
+ Tina4::ScssCompiler.compile_all(root_dir)
46
+ end
47
+
48
+ on_change&.call(changes)
49
+ end
50
+
51
+ @listener.start
52
+ Tina4::Debug.info("Dev reload started")
53
+ end
54
+
55
+ def stop
56
+ @listener&.stop
57
+ Tina4::Debug.info("Dev reload stopped")
58
+ end
59
+
60
+ private
61
+
62
+ def build_ignore_regex
63
+ pattern = IGNORE_DIRS.map { |d| Regexp.escape(d) }.join("|")
64
+ /#{pattern}/
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tina4
4
+ module Drivers
5
+ class FirebirdDriver
6
+ attr_reader :connection
7
+
8
+ def connect(connection_string)
9
+ require "fb"
10
+ db_path = connection_string.sub(/^firebird:\/\//, "")
11
+ @connection = Fb::Database.new(database: db_path).connect
12
+ rescue LoadError
13
+ raise "Firebird driver requires the 'fb' gem. Install it with: gem install fb"
14
+ end
15
+
16
+ def close
17
+ @connection&.close
18
+ end
19
+
20
+ def execute_query(sql, params = [])
21
+ if params.empty?
22
+ @connection.query(:hash, sql)
23
+ else
24
+ @connection.query(:hash, sql, *params)
25
+ end.map { |row| stringify_keys(row) }
26
+ end
27
+
28
+ def execute(sql, params = [])
29
+ if params.empty?
30
+ @connection.execute(sql)
31
+ else
32
+ @connection.execute(sql, *params)
33
+ end
34
+ end
35
+
36
+ def last_insert_id
37
+ nil
38
+ end
39
+
40
+ def placeholder
41
+ "?"
42
+ end
43
+
44
+ def placeholders(count)
45
+ (["?"] * count).join(", ")
46
+ end
47
+
48
+ def apply_limit(sql, limit, offset = 0)
49
+ "SELECT FIRST #{limit} SKIP #{offset} * FROM (#{sql})"
50
+ end
51
+
52
+ def begin_transaction
53
+ @transaction = @connection.transaction
54
+ end
55
+
56
+ def commit
57
+ @transaction&.commit
58
+ end
59
+
60
+ def rollback
61
+ @transaction&.rollback
62
+ end
63
+
64
+ def tables
65
+ sql = "SELECT RDB\$RELATION_NAME FROM RDB\$RELATIONS WHERE RDB\$SYSTEM_FLAG = 0 AND RDB\$VIEW_BLR IS NULL"
66
+ rows = execute_query(sql)
67
+ rows.map { |r| (r["RDB\$RELATION_NAME"] || r["rdb\$relation_name"] || "").strip }
68
+ end
69
+
70
+ def columns(table_name)
71
+ sql = "SELECT RF.RDB\$FIELD_NAME, F.RDB\$FIELD_TYPE, RF.RDB\$NULL_FLAG, RF.RDB\$DEFAULT_SOURCE " \
72
+ "FROM RDB\$RELATION_FIELDS RF " \
73
+ "JOIN RDB\$FIELDS F ON RF.RDB\$FIELD_SOURCE = F.RDB\$FIELD_NAME " \
74
+ "WHERE RF.RDB\$RELATION_NAME = ?"
75
+ rows = execute_query(sql, [table_name.upcase])
76
+ rows.map do |r|
77
+ {
78
+ name: (r["RDB\$FIELD_NAME"] || r["rdb\$field_name"] || "").strip,
79
+ type: r["RDB\$FIELD_TYPE"] || r["rdb\$field_type"],
80
+ nullable: (r["RDB\$NULL_FLAG"] || r["rdb\$null_flag"]).nil?,
81
+ default: r["RDB\$DEFAULT_SOURCE"] || r["rdb\$default_source"],
82
+ primary_key: false
83
+ }
84
+ end
85
+ end
86
+
87
+ private
88
+
89
+ def stringify_keys(hash)
90
+ hash.each_with_object({}) { |(k, v), h| h[k.to_s] = v }
91
+ end
92
+ end
93
+ end
94
+ end