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
- module Tina4
4
- module Drivers
5
- class OdbcDriver
6
- attr_reader :connection
7
-
8
- # Connect to an ODBC data source.
9
- #
10
- # Connection string formats:
11
- # odbc:///DSN=MyDSN
12
- # odbc:///DSN=MyDSN;UID=user;PWD=pass
13
- # odbc:///DRIVER={SQL Server};SERVER=host;DATABASE=db
14
- #
15
- # The leading scheme prefix "odbc:///" is stripped; the remainder is
16
- # passed verbatim to ODBC::Database.new as a connection string.
17
- # username: and password: are appended as UID/PWD if not already present
18
- # in the connection string.
19
- def connect(connection_string, username: nil, password: nil)
20
- begin
21
- require "odbc"
22
- rescue LoadError
23
- raise LoadError,
24
- "The 'ruby-odbc' gem is required for ODBC connections. " \
25
- "Install: gem install ruby-odbc"
26
- end
27
-
28
- dsn_string = connection_string.to_s
29
- .sub(/^odbc:\/\/\//, "")
30
- .sub(/^odbc:\/\//, "")
31
- .sub(/^odbc:/, "")
32
-
33
- # Append credentials if provided and not already embedded
34
- if username && !dsn_string.match?(/\bUID=/i)
35
- dsn_string = "#{dsn_string};UID=#{username}"
36
- end
37
- if password && !dsn_string.match?(/\bPWD=/i)
38
- dsn_string = "#{dsn_string};PWD=#{password}"
39
- end
40
-
41
- @connection = ODBC::Database.new(dsn_string)
42
- @in_transaction = false
43
- self
44
- end
45
-
46
- def close
47
- @connection&.disconnect
48
- @connection = nil
49
- end
50
-
51
- def connected?
52
- !@connection.nil?
53
- end
54
-
55
- # Execute a SELECT query and return rows as an array of symbol-keyed hashes.
56
- def execute_query(sql, params = [])
57
- stmt = if params && !params.empty?
58
- s = @connection.prepare(sql)
59
- s.execute(*params)
60
- s
61
- else
62
- @connection.run(sql)
63
- end
64
-
65
- columns = stmt.columns(true).map { |c| c.name.to_s.to_sym }
66
- rows = []
67
- while (row = stmt.fetch)
68
- rows << columns.zip(row).to_h
69
- end
70
- stmt.drop
71
- rows
72
- rescue => e
73
- stmt&.drop rescue nil
74
- raise e
75
- end
76
-
77
- # Execute DDL or DML without returning rows.
78
- def execute(sql, params = [])
79
- if params && !params.empty?
80
- stmt = @connection.prepare(sql)
81
- stmt.execute(*params)
82
- stmt.drop
83
- else
84
- @connection.do(sql)
85
- end
86
- nil
87
- end
88
-
89
- # ODBC does not expose a universal last-insert-id API.
90
- # Drivers that support it can be queried via execute_query after insert.
91
- def last_insert_id
92
- nil
93
- end
94
-
95
- def placeholder
96
- "?"
97
- end
98
-
99
- def placeholders(count)
100
- (["?"] * count).join(", ")
101
- end
102
-
103
- # Build paginated SQL.
104
- # Tries OFFSET/FETCH NEXT (SQL Server, newer ODBC sources) first.
105
- # Falls back to LIMIT/OFFSET for sources that support it (MySQL, PostgreSQL via ODBC).
106
- # The caller (Database#fetch) already gates on whether LIMIT is already present.
107
- def apply_limit(sql, limit, offset = 0)
108
- offset ||= 0
109
- if offset > 0
110
- # SQL Server / ANSI syntax — requires ORDER BY; add a no-op if absent
111
- if sql.upcase.include?("ORDER BY")
112
- "#{sql} OFFSET #{offset} ROWS FETCH NEXT #{limit} ROWS ONLY"
113
- else
114
- # LIMIT/OFFSET fallback (MySQL, PostgreSQL via ODBC, SQLite via ODBC)
115
- "#{sql} LIMIT #{limit} OFFSET #{offset}"
116
- end
117
- else
118
- "#{sql} LIMIT #{limit}"
119
- end
120
- end
121
-
122
- def begin_transaction
123
- return if @in_transaction
124
- @connection.autocommit = false
125
- @in_transaction = true
126
- end
127
-
128
- def commit
129
- return unless @in_transaction
130
- @connection.commit
131
- @connection.autocommit = true
132
- @in_transaction = false
133
- end
134
-
135
- def rollback
136
- return unless @in_transaction
137
- @connection.rollback
138
- @connection.autocommit = true
139
- @in_transaction = false
140
- end
141
-
142
- # List all user tables via ODBC metadata.
143
- def tables
144
- stmt = @connection.tables
145
- rows = []
146
- while (row = stmt.fetch_hash)
147
- type = row["TABLE_TYPE"] || row[:TABLE_TYPE] || ""
148
- name = row["TABLE_NAME"] || row[:TABLE_NAME]
149
- rows << name.to_s if type.to_s.upcase == "TABLE" && name
150
- end
151
- stmt.drop
152
- rows
153
- rescue => e
154
- stmt&.drop rescue nil
155
- raise e
156
- end
157
-
158
- # Return column metadata for a table via ODBC metadata.
159
- def columns(table_name)
160
- stmt = @connection.columns(table_name.to_s)
161
- result = []
162
- while (row = stmt.fetch_hash)
163
- name = row["COLUMN_NAME"] || row[:COLUMN_NAME]
164
- type = row["TYPE_NAME"] || row[:TYPE_NAME]
165
- nullable_val = row["NULLABLE"] || row[:NULLABLE]
166
- default = row["COLUMN_DEF"] || row[:COLUMN_DEF]
167
- result << {
168
- name: name.to_s,
169
- type: type.to_s,
170
- nullable: nullable_val.to_i == 1,
171
- default: default,
172
- primary_key: false # ODBC metadata does not reliably expose PK flag here
173
- }
174
- end
175
- stmt.drop
176
- result
177
- rescue => e
178
- stmt&.drop rescue nil
179
- raise e
180
- end
181
-
182
- private
183
-
184
- def symbolize_keys(hash)
185
- hash.each_with_object({}) do |(k, v), h|
186
- h[k.to_s.to_sym] = v if k.is_a?(String) || k.is_a?(Symbol)
187
- end
188
- end
189
- end
190
- end
191
- end
1
+ # frozen_string_literal: true
2
+
3
+ module Tina4
4
+ module Drivers
5
+ class OdbcDriver
6
+ attr_reader :connection
7
+
8
+ # Connect to an ODBC data source.
9
+ #
10
+ # Connection string formats:
11
+ # odbc:///DSN=MyDSN
12
+ # odbc:///DSN=MyDSN;UID=user;PWD=pass
13
+ # odbc:///DRIVER={SQL Server};SERVER=host;DATABASE=db
14
+ #
15
+ # The leading scheme prefix "odbc:///" is stripped; the remainder is
16
+ # passed verbatim to ODBC::Database.new as a connection string.
17
+ # username: and password: are appended as UID/PWD if not already present
18
+ # in the connection string.
19
+ def connect(connection_string, username: nil, password: nil)
20
+ begin
21
+ require "odbc"
22
+ rescue LoadError
23
+ raise LoadError,
24
+ "The 'ruby-odbc' gem is required for ODBC connections. " \
25
+ "Install: gem install ruby-odbc"
26
+ end
27
+
28
+ dsn_string = connection_string.to_s
29
+ .sub(/^odbc:\/\/\//, "")
30
+ .sub(/^odbc:\/\//, "")
31
+ .sub(/^odbc:/, "")
32
+
33
+ # Append credentials if provided and not already embedded
34
+ if username && !dsn_string.match?(/\bUID=/i)
35
+ dsn_string = "#{dsn_string};UID=#{username}"
36
+ end
37
+ if password && !dsn_string.match?(/\bPWD=/i)
38
+ dsn_string = "#{dsn_string};PWD=#{password}"
39
+ end
40
+
41
+ @connection = ODBC::Database.new(dsn_string)
42
+ @in_transaction = false
43
+ self
44
+ end
45
+
46
+ def close
47
+ @connection&.disconnect
48
+ @connection = nil
49
+ end
50
+
51
+ def connected?
52
+ !@connection.nil?
53
+ end
54
+
55
+ # Execute a SELECT query and return rows as an array of symbol-keyed hashes.
56
+ def execute_query(sql, params = [])
57
+ stmt = if params && !params.empty?
58
+ s = @connection.prepare(sql)
59
+ s.execute(*params)
60
+ s
61
+ else
62
+ @connection.run(sql)
63
+ end
64
+
65
+ columns = stmt.columns(true).map { |c| c.name.to_s.to_sym }
66
+ rows = []
67
+ while (row = stmt.fetch)
68
+ rows << columns.zip(row).to_h
69
+ end
70
+ stmt.drop
71
+ rows
72
+ rescue => e
73
+ stmt&.drop rescue nil
74
+ raise e
75
+ end
76
+
77
+ # Execute DDL or DML without returning rows.
78
+ def execute(sql, params = [])
79
+ if params && !params.empty?
80
+ stmt = @connection.prepare(sql)
81
+ stmt.execute(*params)
82
+ stmt.drop
83
+ else
84
+ @connection.do(sql)
85
+ end
86
+ nil
87
+ end
88
+
89
+ # ODBC does not expose a universal last-insert-id API.
90
+ # Drivers that support it can be queried via execute_query after insert.
91
+ def last_insert_id
92
+ nil
93
+ end
94
+
95
+ def placeholder
96
+ "?"
97
+ end
98
+
99
+ def placeholders(count)
100
+ (["?"] * count).join(", ")
101
+ end
102
+
103
+ # Build paginated SQL.
104
+ # Tries OFFSET/FETCH NEXT (SQL Server, newer ODBC sources) first.
105
+ # Falls back to LIMIT/OFFSET for sources that support it (MySQL, PostgreSQL via ODBC).
106
+ # The caller (Database#fetch) already gates on whether LIMIT is already present.
107
+ def apply_limit(sql, limit, offset = 0)
108
+ offset ||= 0
109
+ if offset > 0
110
+ # SQL Server / ANSI syntax — requires ORDER BY; add a no-op if absent
111
+ if sql.upcase.include?("ORDER BY")
112
+ "#{sql} OFFSET #{offset} ROWS FETCH NEXT #{limit} ROWS ONLY"
113
+ else
114
+ # LIMIT/OFFSET fallback (MySQL, PostgreSQL via ODBC, SQLite via ODBC)
115
+ "#{sql} LIMIT #{limit} OFFSET #{offset}"
116
+ end
117
+ else
118
+ "#{sql} LIMIT #{limit}"
119
+ end
120
+ end
121
+
122
+ def begin_transaction
123
+ return if @in_transaction
124
+ @connection.autocommit = false
125
+ @in_transaction = true
126
+ end
127
+
128
+ def commit
129
+ return unless @in_transaction
130
+ @connection.commit
131
+ @connection.autocommit = true
132
+ @in_transaction = false
133
+ end
134
+
135
+ def rollback
136
+ return unless @in_transaction
137
+ @connection.rollback
138
+ @connection.autocommit = true
139
+ @in_transaction = false
140
+ end
141
+
142
+ # List all user tables via ODBC metadata.
143
+ def tables
144
+ stmt = @connection.tables
145
+ rows = []
146
+ while (row = stmt.fetch_hash)
147
+ type = row["TABLE_TYPE"] || row[:TABLE_TYPE] || ""
148
+ name = row["TABLE_NAME"] || row[:TABLE_NAME]
149
+ rows << name.to_s if type.to_s.upcase == "TABLE" && name
150
+ end
151
+ stmt.drop
152
+ rows
153
+ rescue => e
154
+ stmt&.drop rescue nil
155
+ raise e
156
+ end
157
+
158
+ # Return column metadata for a table via ODBC metadata.
159
+ def columns(table_name)
160
+ stmt = @connection.columns(table_name.to_s)
161
+ result = []
162
+ while (row = stmt.fetch_hash)
163
+ name = row["COLUMN_NAME"] || row[:COLUMN_NAME]
164
+ type = row["TYPE_NAME"] || row[:TYPE_NAME]
165
+ nullable_val = row["NULLABLE"] || row[:NULLABLE]
166
+ default = row["COLUMN_DEF"] || row[:COLUMN_DEF]
167
+ result << {
168
+ name: name.to_s,
169
+ type: type.to_s,
170
+ nullable: nullable_val.to_i == 1,
171
+ default: default,
172
+ primary_key: false # ODBC metadata does not reliably expose PK flag here
173
+ }
174
+ end
175
+ stmt.drop
176
+ result
177
+ rescue => e
178
+ stmt&.drop rescue nil
179
+ raise e
180
+ end
181
+
182
+ private
183
+
184
+ def symbolize_keys(hash)
185
+ hash.each_with_object({}) do |(k, v), h|
186
+ h[k.to_s.to_sym] = v if k.is_a?(String) || k.is_a?(Symbol)
187
+ end
188
+ end
189
+ end
190
+ end
191
+ end
@@ -1,106 +1,116 @@
1
- # frozen_string_literal: true
2
-
3
- module Tina4
4
- module Drivers
5
- class PostgresDriver
6
- attr_reader :connection
7
-
8
- def connect(connection_string, username: nil, password: nil)
9
- require "pg"
10
- url = connection_string
11
- if username || password
12
- uri = URI.parse(url)
13
- uri.user = username if username
14
- uri.password = password if password
15
- url = uri.to_s
16
- end
17
- @connection = PG.connect(url)
18
- end
19
-
20
- def close
21
- @connection&.close
22
- end
23
-
24
- def execute_query(sql, params = [])
25
- converted_sql = convert_placeholders(sql)
26
- result = if params.empty?
27
- @connection.exec(converted_sql)
28
- else
29
- @connection.exec_params(converted_sql, params)
30
- end
31
- result.map { |row| symbolize_keys(row) }
32
- end
33
-
34
- def execute(sql, params = [])
35
- converted_sql = convert_placeholders(sql)
36
- if params.empty?
37
- @connection.exec(converted_sql)
38
- else
39
- @connection.exec_params(converted_sql, params)
40
- end
41
- end
42
-
43
- def last_insert_id
44
- result = @connection.exec("SELECT lastval()")
45
- result.first["lastval"].to_i
46
- rescue PG::Error
47
- nil
48
- end
49
-
50
- def placeholder
51
- "?"
52
- end
53
-
54
- def placeholders(count)
55
- (1..count).map { |i| "$#{i}" }.join(", ")
56
- end
57
-
58
- def apply_limit(sql, limit, offset = 0)
59
- "#{sql} LIMIT #{limit} OFFSET #{offset}"
60
- end
61
-
62
- def begin_transaction
63
- @connection.exec("BEGIN")
64
- end
65
-
66
- def commit
67
- @connection.exec("COMMIT")
68
- end
69
-
70
- def rollback
71
- @connection.exec("ROLLBACK")
72
- end
73
-
74
- def tables
75
- sql = "SELECT tablename FROM pg_tables WHERE schemaname = 'public'"
76
- rows = execute_query(sql)
77
- rows.map { |r| r[:tablename] }
78
- end
79
-
80
- def columns(table_name)
81
- sql = "SELECT column_name, data_type, is_nullable, column_default FROM information_schema.columns WHERE table_name = $1"
82
- rows = execute_query(sql, [table_name])
83
- rows.map do |r|
84
- {
85
- name: r[:column_name],
86
- type: r[:data_type],
87
- nullable: r[:is_nullable] == "YES",
88
- default: r[:column_default],
89
- primary_key: false
90
- }
91
- end
92
- end
93
-
94
- private
95
-
96
- def convert_placeholders(sql)
97
- counter = 0
98
- sql.gsub("?") { counter += 1; "$#{counter}" }
99
- end
100
-
101
- def symbolize_keys(hash)
102
- hash.each_with_object({}) { |(k, v), h| h[k.to_sym] = v }
103
- end
104
- end
105
- end
106
- end
1
+ # frozen_string_literal: true
2
+
3
+ module Tina4
4
+ module Drivers
5
+ class PostgresDriver
6
+ attr_reader :connection
7
+
8
+ def connect(connection_string, username: nil, password: nil)
9
+ require "pg"
10
+ url = connection_string
11
+ if username || password
12
+ uri = URI.parse(url)
13
+ uri.user = username if username
14
+ uri.password = password if password
15
+ url = uri.to_s
16
+ end
17
+ @connection = PG.connect(url)
18
+ end
19
+
20
+ def close
21
+ @connection&.close
22
+ end
23
+
24
+ def execute_query(sql, params = [])
25
+ converted_sql = convert_placeholders(sql)
26
+ result = if params.empty?
27
+ @connection.exec(converted_sql)
28
+ else
29
+ @connection.exec_params(converted_sql, params)
30
+ end
31
+ result.map { |row| decode_blobs(symbolize_keys(row)) }
32
+ end
33
+
34
+ def execute(sql, params = [])
35
+ converted_sql = convert_placeholders(sql)
36
+ if params.empty?
37
+ @connection.exec(converted_sql)
38
+ else
39
+ @connection.exec_params(converted_sql, params)
40
+ end
41
+ end
42
+
43
+ def last_insert_id
44
+ result = @connection.exec("SELECT lastval()")
45
+ result.first["lastval"].to_i
46
+ rescue PG::Error
47
+ nil
48
+ end
49
+
50
+ def placeholder
51
+ "?"
52
+ end
53
+
54
+ def placeholders(count)
55
+ (1..count).map { |i| "$#{i}" }.join(", ")
56
+ end
57
+
58
+ def apply_limit(sql, limit, offset = 0)
59
+ "#{sql} LIMIT #{limit} OFFSET #{offset}"
60
+ end
61
+
62
+ def begin_transaction
63
+ @connection.exec("BEGIN")
64
+ end
65
+
66
+ def commit
67
+ @connection.exec("COMMIT")
68
+ end
69
+
70
+ def rollback
71
+ @connection.exec("ROLLBACK")
72
+ end
73
+
74
+ def tables
75
+ sql = "SELECT tablename FROM pg_tables WHERE schemaname = 'public'"
76
+ rows = execute_query(sql)
77
+ rows.map { |r| r[:tablename] }
78
+ end
79
+
80
+ def columns(table_name)
81
+ sql = "SELECT column_name, data_type, is_nullable, column_default FROM information_schema.columns WHERE table_name = $1"
82
+ rows = execute_query(sql, [table_name])
83
+ rows.map do |r|
84
+ {
85
+ name: r[:column_name],
86
+ type: r[:data_type],
87
+ nullable: r[:is_nullable] == "YES",
88
+ default: r[:column_default],
89
+ primary_key: false
90
+ }
91
+ end
92
+ end
93
+
94
+ private
95
+
96
+ def convert_placeholders(sql)
97
+ counter = 0
98
+ sql.gsub("?") { counter += 1; "$#{counter}" }
99
+ end
100
+
101
+ def symbolize_keys(hash)
102
+ hash.each_with_object({}) { |(k, v), h| h[k.to_sym] = v }
103
+ end
104
+
105
+ # Ensure binary (bytea) columns are proper byte strings.
106
+ # PostgreSQL's pg gem returns bytea as ASCII-8BIT encoded strings —
107
+ # they're already raw bytes, just tag them so Ruby treats them right.
108
+ def decode_blobs(row)
109
+ # No conversion needed — pg gem returns bytea as ASCII-8BIT strings
110
+ # which are raw bytes. Users can .force_encoding("UTF-8") for text
111
+ # BLOBs or use the bytes directly for binary data.
112
+ row
113
+ end
114
+ end
115
+ end
116
+ end