tina4ruby 3.11.15 → 3.11.17

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 (134) 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 +1291 -935
  22. data/lib/tina4/dev_mailbox.rb +191 -191
  23. data/lib/tina4/drivers/firebird_driver.rb +124 -124
  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 -116
  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 +2087 -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 +871 -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/plan.rb +471 -0
  63. data/lib/tina4/project_index.rb +366 -0
  64. data/lib/tina4/public/css/tina4.css +2463 -2463
  65. data/lib/tina4/public/css/tina4.min.css +1 -1
  66. data/lib/tina4/public/images/logo.svg +5 -5
  67. data/lib/tina4/public/js/frond.min.js +2 -2
  68. data/lib/tina4/public/js/tina4-dev-admin.js +1264 -565
  69. data/lib/tina4/public/js/tina4-dev-admin.min.js +1264 -480
  70. data/lib/tina4/public/js/tina4.min.js +92 -92
  71. data/lib/tina4/public/js/tina4js.min.js +48 -48
  72. data/lib/tina4/public/swagger/index.html +90 -90
  73. data/lib/tina4/public/swagger/oauth2-redirect.html +63 -63
  74. data/lib/tina4/query_builder.rb +380 -380
  75. data/lib/tina4/queue.rb +366 -366
  76. data/lib/tina4/queue_backends/kafka_backend.rb +80 -80
  77. data/lib/tina4/queue_backends/lite_backend.rb +298 -298
  78. data/lib/tina4/queue_backends/mongo_backend.rb +126 -126
  79. data/lib/tina4/queue_backends/rabbitmq_backend.rb +73 -73
  80. data/lib/tina4/rack_app.rb +817 -817
  81. data/lib/tina4/rate_limiter.rb +130 -130
  82. data/lib/tina4/request.rb +268 -268
  83. data/lib/tina4/response.rb +346 -346
  84. data/lib/tina4/response_cache.rb +551 -551
  85. data/lib/tina4/router.rb +406 -406
  86. data/lib/tina4/scss/tina4css/_alerts.scss +34 -34
  87. data/lib/tina4/scss/tina4css/_badges.scss +22 -22
  88. data/lib/tina4/scss/tina4css/_buttons.scss +69 -69
  89. data/lib/tina4/scss/tina4css/_cards.scss +49 -49
  90. data/lib/tina4/scss/tina4css/_forms.scss +156 -156
  91. data/lib/tina4/scss/tina4css/_grid.scss +81 -81
  92. data/lib/tina4/scss/tina4css/_modals.scss +84 -84
  93. data/lib/tina4/scss/tina4css/_nav.scss +149 -149
  94. data/lib/tina4/scss/tina4css/_reset.scss +94 -94
  95. data/lib/tina4/scss/tina4css/_tables.scss +54 -54
  96. data/lib/tina4/scss/tina4css/_typography.scss +55 -55
  97. data/lib/tina4/scss/tina4css/_utilities.scss +197 -197
  98. data/lib/tina4/scss/tina4css/_variables.scss +117 -117
  99. data/lib/tina4/scss/tina4css/base.scss +1 -1
  100. data/lib/tina4/scss/tina4css/colors.scss +48 -48
  101. data/lib/tina4/scss/tina4css/tina4.scss +17 -17
  102. data/lib/tina4/scss_compiler.rb +178 -178
  103. data/lib/tina4/seeder.rb +567 -567
  104. data/lib/tina4/service_runner.rb +303 -303
  105. data/lib/tina4/session.rb +297 -297
  106. data/lib/tina4/session_handlers/database_handler.rb +72 -72
  107. data/lib/tina4/session_handlers/file_handler.rb +67 -67
  108. data/lib/tina4/session_handlers/mongo_handler.rb +49 -49
  109. data/lib/tina4/session_handlers/redis_handler.rb +43 -43
  110. data/lib/tina4/session_handlers/valkey_handler.rb +43 -43
  111. data/lib/tina4/shutdown.rb +84 -84
  112. data/lib/tina4/sql_translation.rb +158 -158
  113. data/lib/tina4/swagger.rb +124 -124
  114. data/lib/tina4/template.rb +894 -894
  115. data/lib/tina4/templates/base.twig +26 -26
  116. data/lib/tina4/templates/errors/302.twig +14 -14
  117. data/lib/tina4/templates/errors/401.twig +9 -9
  118. data/lib/tina4/templates/errors/403.twig +29 -29
  119. data/lib/tina4/templates/errors/404.twig +29 -29
  120. data/lib/tina4/templates/errors/500.twig +38 -38
  121. data/lib/tina4/templates/errors/502.twig +9 -9
  122. data/lib/tina4/templates/errors/503.twig +12 -12
  123. data/lib/tina4/templates/errors/base.twig +37 -37
  124. data/lib/tina4/test_client.rb +159 -159
  125. data/lib/tina4/testing.rb +340 -340
  126. data/lib/tina4/validator.rb +174 -174
  127. data/lib/tina4/version.rb +1 -1
  128. data/lib/tina4/webserver.rb +312 -312
  129. data/lib/tina4/websocket.rb +343 -343
  130. data/lib/tina4/websocket_backplane.rb +190 -190
  131. data/lib/tina4/wsdl.rb +564 -564
  132. data/lib/tina4.rb +460 -458
  133. data/lib/tina4ruby.rb +4 -4
  134. metadata +5 -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,124 +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
- 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
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