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,191 +1,191 @@
1
- # frozen_string_literal: true
2
-
3
- require "json"
4
- require "securerandom"
5
- require "time"
6
- require "fileutils"
7
- require "base64"
8
-
9
- module Tina4
10
- class DevMailbox
11
- attr_reader :mailbox_dir
12
-
13
- def initialize(mailbox_dir: nil)
14
- @mailbox_dir = mailbox_dir || ENV["TINA4_MAILBOX_DIR"] || "data/mailbox"
15
- ensure_dirs
16
- end
17
-
18
- # Capture an outgoing email to the local filesystem instead of sending
19
- def capture(to:, subject:, body:, html: false, cc: [], bcc: [],
20
- reply_to: nil, from_address: nil, from_name: nil, attachments: [])
21
- msg_id = SecureRandom.uuid
22
- timestamp = Time.now
23
-
24
- message = {
25
- id: msg_id,
26
- from: { name: from_name, email: from_address },
27
- to: normalize_recipients(to),
28
- cc: normalize_recipients(cc),
29
- bcc: normalize_recipients(bcc),
30
- reply_to: reply_to,
31
- subject: subject,
32
- body: body,
33
- html: html,
34
- attachments: store_attachments(msg_id, attachments),
35
- read: false,
36
- folder: "outbox",
37
- created_at: timestamp.strftime("%Y-%m-%dT%H:%M:%S.%6N%:z"),
38
- updated_at: timestamp.iso8601
39
- }
40
-
41
- write_message(msg_id, message)
42
-
43
- Tina4::Log.debug("DevMailbox captured email: #{subject} -> #{Array(to).join(', ')}")
44
- { success: true, message: "Email captured to dev mailbox", id: msg_id }
45
- end
46
-
47
- # List messages in the mailbox
48
- def inbox(limit: 50, offset: 0, folder: nil)
49
- messages = load_all_messages
50
- messages = messages.select { |m| m[:folder] == folder } if folder
51
- messages.sort_by { |m| m[:created_at] || "" }.reverse[offset, limit] || []
52
- end
53
-
54
- # Read a single message by ID
55
- def read(msg_id)
56
- path = message_path(msg_id)
57
- return nil unless File.exist?(path)
58
-
59
- message = JSON.parse(File.read(path), symbolize_names: true)
60
- unless message[:read]
61
- message[:read] = true
62
- message[:updated_at] = Time.now.iso8601
63
- File.write(path, JSON.pretty_generate(message))
64
- end
65
- message
66
- end
67
-
68
- # Count unread messages
69
- def unread_count
70
- load_all_messages.count { |m| m[:read] == false }
71
- end
72
-
73
- # Delete a message by ID
74
- def delete(msg_id)
75
- path = message_path(msg_id)
76
- return false unless File.exist?(path)
77
-
78
- File.delete(path)
79
- # Clean up attachments directory
80
- att_dir = File.join(@mailbox_dir, "attachments", msg_id)
81
- FileUtils.rm_rf(att_dir) if Dir.exist?(att_dir)
82
- true
83
- end
84
-
85
- # Clear all messages, optionally by folder
86
- def clear(folder: nil)
87
- if folder
88
- load_all_messages.each do |msg|
89
- delete(msg[:id]) if msg[:folder] == folder
90
- end
91
- else
92
- messages_dir = File.join(@mailbox_dir, "messages")
93
- FileUtils.rm_rf(messages_dir)
94
- FileUtils.rm_rf(File.join(@mailbox_dir, "attachments"))
95
- ensure_dirs
96
- end
97
- end
98
-
99
- # Seed the mailbox with sample messages for development
100
- def seed(count: 5)
101
- fake = Tina4::FakeData.new
102
- count.times do |i|
103
- name = fake.name
104
- email = fake.email(from_name: name)
105
- capture(
106
- to: "dev@localhost",
107
- subject: fake.sentence(words: 4 + rand(4)),
108
- body: Array.new(2 + rand(3)) { fake.sentence(words: 8 + rand(8)) }.join("\n\n"),
109
- html: i.even?,
110
- from_address: email,
111
- from_name: name
112
- )
113
- end
114
- Tina4::Log.info("DevMailbox seeded with #{count} messages")
115
- end
116
-
117
- # Count messages by folder
118
- # Returns { inbox: N, outbox: N, total: N }
119
- def count(folder: nil)
120
- messages = load_all_messages
121
- if folder
122
- n = messages.count { |m| m[:folder] == folder }
123
- { folder.to_sym => n, total: n }
124
- else
125
- inbox_count = messages.count { |m| m[:folder] == "inbox" }
126
- outbox_count = messages.count { |m| m[:folder] == "outbox" }
127
- { inbox: inbox_count, outbox: outbox_count, total: messages.length }
128
- end
129
- end
130
-
131
- private
132
-
133
- def ensure_dirs
134
- FileUtils.mkdir_p(File.join(@mailbox_dir, "messages"))
135
- FileUtils.mkdir_p(File.join(@mailbox_dir, "attachments"))
136
- end
137
-
138
- def message_path(msg_id)
139
- File.join(@mailbox_dir, "messages", "#{msg_id}.json")
140
- end
141
-
142
- def write_message(msg_id, message)
143
- File.write(message_path(msg_id), JSON.pretty_generate(message))
144
- end
145
-
146
- def load_all_messages
147
- pattern = File.join(@mailbox_dir, "messages", "*.json")
148
- Dir.glob(pattern).filter_map do |path|
149
- JSON.parse(File.read(path), symbolize_names: true)
150
- rescue JSON::ParserError => e
151
- Tina4::Log.error("DevMailbox: corrupt message file #{path}: #{e.message}")
152
- nil
153
- end
154
- end
155
-
156
- def normalize_recipients(value)
157
- case value
158
- when nil then []
159
- when String then [value]
160
- when Array then value.flatten.compact
161
- else [value.to_s]
162
- end
163
- end
164
-
165
- def store_attachments(msg_id, attachments)
166
- return [] if attachments.nil? || attachments.empty?
167
-
168
- att_dir = File.join(@mailbox_dir, "attachments", msg_id)
169
- FileUtils.mkdir_p(att_dir)
170
-
171
- attachments.map do |attachment|
172
- if attachment.is_a?(Hash)
173
- filename = attachment[:filename] || attachment[:name] || "attachment"
174
- content = attachment[:content] || ""
175
- mime = attachment[:mime_type] || attachment[:content_type] || "application/octet-stream"
176
- elsif attachment.is_a?(String) && File.exist?(attachment)
177
- filename = File.basename(attachment)
178
- content = File.binread(attachment)
179
- mime = "application/octet-stream"
180
- else
181
- next nil
182
- end
183
-
184
- file_path = File.join(att_dir, filename)
185
- File.binwrite(file_path, content)
186
-
187
- { filename: filename, mime_type: mime, size: content.bytesize, path: file_path }
188
- end.compact
189
- end
190
- end
191
- end
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "securerandom"
5
+ require "time"
6
+ require "fileutils"
7
+ require "base64"
8
+
9
+ module Tina4
10
+ class DevMailbox
11
+ attr_reader :mailbox_dir
12
+
13
+ def initialize(mailbox_dir: nil)
14
+ @mailbox_dir = mailbox_dir || ENV["TINA4_MAILBOX_DIR"] || "data/mailbox"
15
+ ensure_dirs
16
+ end
17
+
18
+ # Capture an outgoing email to the local filesystem instead of sending
19
+ def capture(to:, subject:, body:, html: false, cc: [], bcc: [],
20
+ reply_to: nil, from_address: nil, from_name: nil, attachments: [])
21
+ msg_id = SecureRandom.uuid
22
+ timestamp = Time.now
23
+
24
+ message = {
25
+ id: msg_id,
26
+ from: { name: from_name, email: from_address },
27
+ to: normalize_recipients(to),
28
+ cc: normalize_recipients(cc),
29
+ bcc: normalize_recipients(bcc),
30
+ reply_to: reply_to,
31
+ subject: subject,
32
+ body: body,
33
+ html: html,
34
+ attachments: store_attachments(msg_id, attachments),
35
+ read: false,
36
+ folder: "outbox",
37
+ created_at: timestamp.strftime("%Y-%m-%dT%H:%M:%S.%6N%:z"),
38
+ updated_at: timestamp.iso8601
39
+ }
40
+
41
+ write_message(msg_id, message)
42
+
43
+ Tina4::Log.debug("DevMailbox captured email: #{subject} -> #{Array(to).join(', ')}")
44
+ { success: true, message: "Email captured to dev mailbox", id: msg_id }
45
+ end
46
+
47
+ # List messages in the mailbox
48
+ def inbox(limit: 50, offset: 0, folder: nil)
49
+ messages = load_all_messages
50
+ messages = messages.select { |m| m[:folder] == folder } if folder
51
+ messages.sort_by { |m| m[:created_at] || "" }.reverse[offset, limit] || []
52
+ end
53
+
54
+ # Read a single message by ID
55
+ def read(msg_id)
56
+ path = message_path(msg_id)
57
+ return nil unless File.exist?(path)
58
+
59
+ message = JSON.parse(File.read(path), symbolize_names: true)
60
+ unless message[:read]
61
+ message[:read] = true
62
+ message[:updated_at] = Time.now.iso8601
63
+ File.write(path, JSON.pretty_generate(message))
64
+ end
65
+ message
66
+ end
67
+
68
+ # Count unread messages
69
+ def unread_count
70
+ load_all_messages.count { |m| m[:read] == false }
71
+ end
72
+
73
+ # Delete a message by ID
74
+ def delete(msg_id)
75
+ path = message_path(msg_id)
76
+ return false unless File.exist?(path)
77
+
78
+ File.delete(path)
79
+ # Clean up attachments directory
80
+ att_dir = File.join(@mailbox_dir, "attachments", msg_id)
81
+ FileUtils.rm_rf(att_dir) if Dir.exist?(att_dir)
82
+ true
83
+ end
84
+
85
+ # Clear all messages, optionally by folder
86
+ def clear(folder: nil)
87
+ if folder
88
+ load_all_messages.each do |msg|
89
+ delete(msg[:id]) if msg[:folder] == folder
90
+ end
91
+ else
92
+ messages_dir = File.join(@mailbox_dir, "messages")
93
+ FileUtils.rm_rf(messages_dir)
94
+ FileUtils.rm_rf(File.join(@mailbox_dir, "attachments"))
95
+ ensure_dirs
96
+ end
97
+ end
98
+
99
+ # Seed the mailbox with sample messages for development
100
+ def seed(count: 5)
101
+ fake = Tina4::FakeData.new
102
+ count.times do |i|
103
+ name = fake.name
104
+ email = fake.email(from_name: name)
105
+ capture(
106
+ to: "dev@localhost",
107
+ subject: fake.sentence(words: 4 + rand(4)),
108
+ body: Array.new(2 + rand(3)) { fake.sentence(words: 8 + rand(8)) }.join("\n\n"),
109
+ html: i.even?,
110
+ from_address: email,
111
+ from_name: name
112
+ )
113
+ end
114
+ Tina4::Log.info("DevMailbox seeded with #{count} messages")
115
+ end
116
+
117
+ # Count messages by folder
118
+ # Returns { inbox: N, outbox: N, total: N }
119
+ def count(folder: nil)
120
+ messages = load_all_messages
121
+ if folder
122
+ n = messages.count { |m| m[:folder] == folder }
123
+ { folder.to_sym => n, total: n }
124
+ else
125
+ inbox_count = messages.count { |m| m[:folder] == "inbox" }
126
+ outbox_count = messages.count { |m| m[:folder] == "outbox" }
127
+ { inbox: inbox_count, outbox: outbox_count, total: messages.length }
128
+ end
129
+ end
130
+
131
+ private
132
+
133
+ def ensure_dirs
134
+ FileUtils.mkdir_p(File.join(@mailbox_dir, "messages"))
135
+ FileUtils.mkdir_p(File.join(@mailbox_dir, "attachments"))
136
+ end
137
+
138
+ def message_path(msg_id)
139
+ File.join(@mailbox_dir, "messages", "#{msg_id}.json")
140
+ end
141
+
142
+ def write_message(msg_id, message)
143
+ File.write(message_path(msg_id), JSON.pretty_generate(message))
144
+ end
145
+
146
+ def load_all_messages
147
+ pattern = File.join(@mailbox_dir, "messages", "*.json")
148
+ Dir.glob(pattern).filter_map do |path|
149
+ JSON.parse(File.read(path), symbolize_names: true)
150
+ rescue JSON::ParserError => e
151
+ Tina4::Log.error("DevMailbox: corrupt message file #{path}: #{e.message}")
152
+ nil
153
+ end
154
+ end
155
+
156
+ def normalize_recipients(value)
157
+ case value
158
+ when nil then []
159
+ when String then [value]
160
+ when Array then value.flatten.compact
161
+ else [value.to_s]
162
+ end
163
+ end
164
+
165
+ def store_attachments(msg_id, attachments)
166
+ return [] if attachments.nil? || attachments.empty?
167
+
168
+ att_dir = File.join(@mailbox_dir, "attachments", msg_id)
169
+ FileUtils.mkdir_p(att_dir)
170
+
171
+ attachments.map do |attachment|
172
+ if attachment.is_a?(Hash)
173
+ filename = attachment[:filename] || attachment[:name] || "attachment"
174
+ content = attachment[:content] || ""
175
+ mime = attachment[:mime_type] || attachment[:content_type] || "application/octet-stream"
176
+ elsif attachment.is_a?(String) && File.exist?(attachment)
177
+ filename = File.basename(attachment)
178
+ content = File.binread(attachment)
179
+ mime = "application/octet-stream"
180
+ else
181
+ next nil
182
+ end
183
+
184
+ file_path = File.join(att_dir, filename)
185
+ File.binwrite(file_path, content)
186
+
187
+ { filename: filename, mime_type: mime, size: content.bytesize, path: file_path }
188
+ end.compact
189
+ end
190
+ end
191
+ end
@@ -1,110 +1,124 @@
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, username: nil, password: nil)
9
- require "fb"
10
- require "uri"
11
- uri = URI.parse(connection_string)
12
- host = uri.host
13
- port = uri.port || 3050
14
- db_path = uri.path&.sub(/^\//, "")
15
- db_user = username || uri.user
16
- db_pass = password || uri.password
17
-
18
- database = if host
19
- "#{host}/#{port}:#{db_path}"
20
- else
21
- db_path || connection_string.sub(/^firebird:\/\//, "")
22
- end
23
-
24
- opts = { database: database }
25
- opts[:username] = db_user if db_user
26
- opts[:password] = db_pass if db_pass
27
- @connection = Fb::Database.new(**opts).connect
28
- rescue LoadError
29
- raise "Firebird driver requires the 'fb' gem. Install it with: gem install fb"
30
- end
31
-
32
- def close
33
- @connection&.close
34
- end
35
-
36
- def execute_query(sql, params = [])
37
- if params.empty?
38
- @connection.query(:hash, sql)
39
- else
40
- @connection.query(:hash, sql, *params)
41
- end.map { |row| stringify_keys(row) }
42
- end
43
-
44
- def execute(sql, params = [])
45
- if params.empty?
46
- @connection.execute(sql)
47
- else
48
- @connection.execute(sql, *params)
49
- end
50
- end
51
-
52
- def last_insert_id
53
- nil
54
- end
55
-
56
- def placeholder
57
- "?"
58
- end
59
-
60
- def placeholders(count)
61
- (["?"] * count).join(", ")
62
- end
63
-
64
- def apply_limit(sql, limit, offset = 0)
65
- "SELECT FIRST #{limit} SKIP #{offset} * FROM (#{sql})"
66
- end
67
-
68
- def begin_transaction
69
- @transaction = @connection.transaction
70
- end
71
-
72
- def commit
73
- @transaction&.commit
74
- end
75
-
76
- def rollback
77
- @transaction&.rollback
78
- end
79
-
80
- def tables
81
- sql = "SELECT RDB\$RELATION_NAME FROM RDB\$RELATIONS WHERE RDB\$SYSTEM_FLAG = 0 AND RDB\$VIEW_BLR IS NULL"
82
- rows = execute_query(sql)
83
- rows.map { |r| (r["RDB\$RELATION_NAME"] || r["rdb\$relation_name"] || "").strip }
84
- end
85
-
86
- def columns(table_name)
87
- sql = "SELECT RF.RDB\$FIELD_NAME, F.RDB\$FIELD_TYPE, RF.RDB\$NULL_FLAG, RF.RDB\$DEFAULT_SOURCE " \
88
- "FROM RDB\$RELATION_FIELDS RF " \
89
- "JOIN RDB\$FIELDS F ON RF.RDB\$FIELD_SOURCE = F.RDB\$FIELD_NAME " \
90
- "WHERE RF.RDB\$RELATION_NAME = ?"
91
- rows = execute_query(sql, [table_name.upcase])
92
- rows.map do |r|
93
- {
94
- name: (r["RDB\$FIELD_NAME"] || r["rdb\$field_name"] || "").strip,
95
- type: r["RDB\$FIELD_TYPE"] || r["rdb\$field_type"],
96
- nullable: (r["RDB\$NULL_FLAG"] || r["rdb\$null_flag"]).nil?,
97
- default: r["RDB\$DEFAULT_SOURCE"] || r["rdb\$default_source"],
98
- primary_key: false
99
- }
100
- end
101
- end
102
-
103
- private
104
-
105
- def stringify_keys(hash)
106
- hash.each_with_object({}) { |(k, v), h| h[k.to_s] = v }
107
- end
108
- end
109
- end
110
- end
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, username: nil, password: nil)
9
+ require "fb"
10
+ require "uri"
11
+ uri = URI.parse(connection_string)
12
+ host = uri.host
13
+ port = uri.port || 3050
14
+ db_path = uri.path&.sub(/^\//, "")
15
+ db_user = username || uri.user
16
+ db_pass = password || uri.password
17
+
18
+ database = if host
19
+ "#{host}/#{port}:#{db_path}"
20
+ else
21
+ db_path || connection_string.sub(/^firebird:\/\//, "")
22
+ end
23
+
24
+ opts = { database: database }
25
+ opts[:username] = db_user if db_user
26
+ opts[:password] = db_pass if db_pass
27
+ @connection = Fb::Database.new(**opts).connect
28
+ rescue LoadError
29
+ raise "Firebird driver requires the 'fb' gem. Install it with: gem install fb"
30
+ end
31
+
32
+ def close
33
+ @connection&.close
34
+ end
35
+
36
+ def execute_query(sql, params = [])
37
+ rows = if params.empty?
38
+ @connection.query(:hash, sql)
39
+ else
40
+ @connection.query(:hash, sql, *params)
41
+ end
42
+ rows.map { |row| decode_blobs(stringify_keys(row)) }
43
+ end
44
+
45
+ def execute(sql, params = [])
46
+ if params.empty?
47
+ @connection.execute(sql)
48
+ else
49
+ @connection.execute(sql, *params)
50
+ end
51
+ end
52
+
53
+ def last_insert_id
54
+ nil
55
+ end
56
+
57
+ def placeholder
58
+ "?"
59
+ end
60
+
61
+ def placeholders(count)
62
+ (["?"] * count).join(", ")
63
+ end
64
+
65
+ def apply_limit(sql, limit, offset = 0)
66
+ "SELECT FIRST #{limit} SKIP #{offset} * FROM (#{sql})"
67
+ end
68
+
69
+ def begin_transaction
70
+ @transaction = @connection.transaction
71
+ end
72
+
73
+ def commit
74
+ @transaction&.commit
75
+ end
76
+
77
+ def rollback
78
+ @transaction&.rollback
79
+ end
80
+
81
+ def tables
82
+ sql = "SELECT RDB\$RELATION_NAME FROM RDB\$RELATIONS WHERE RDB\$SYSTEM_FLAG = 0 AND RDB\$VIEW_BLR IS NULL"
83
+ rows = execute_query(sql)
84
+ rows.map { |r| (r["RDB\$RELATION_NAME"] || r["rdb\$relation_name"] || "").strip }
85
+ end
86
+
87
+ def columns(table_name)
88
+ sql = "SELECT RF.RDB\$FIELD_NAME, F.RDB\$FIELD_TYPE, RF.RDB\$NULL_FLAG, RF.RDB\$DEFAULT_SOURCE " \
89
+ "FROM RDB\$RELATION_FIELDS RF " \
90
+ "JOIN RDB\$FIELDS F ON RF.RDB\$FIELD_SOURCE = F.RDB\$FIELD_NAME " \
91
+ "WHERE RF.RDB\$RELATION_NAME = ?"
92
+ rows = execute_query(sql, [table_name.upcase])
93
+ rows.map do |r|
94
+ {
95
+ name: (r["RDB\$FIELD_NAME"] || r["rdb\$field_name"] || "").strip,
96
+ type: r["RDB\$FIELD_TYPE"] || r["rdb\$field_type"],
97
+ nullable: (r["RDB\$NULL_FLAG"] || r["rdb\$null_flag"]).nil?,
98
+ default: r["RDB\$DEFAULT_SOURCE"] || r["rdb\$default_source"],
99
+ primary_key: false
100
+ }
101
+ end
102
+ end
103
+
104
+ private
105
+
106
+ def stringify_keys(hash)
107
+ hash.each_with_object({}) { |(k, v), h| h[k.to_s] = v }
108
+ end
109
+
110
+ # Ensure Firebird BLOB columns are proper byte strings.
111
+ # The Fb gem may return BLOBs as resource handles or IO objects —
112
+ # read them into strings if needed.
113
+ def decode_blobs(row)
114
+ row.each do |key, value|
115
+ if value.respond_to?(:read)
116
+ row[key] = value.read
117
+ value.close if value.respond_to?(:close)
118
+ end
119
+ end
120
+ row
121
+ end
122
+ end
123
+ end
124
+ end