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,380 +1,380 @@
1
- # frozen_string_literal: true
2
-
3
- module Tina4
4
- # QueryBuilder — Fluent SQL query builder.
5
- #
6
- # Usage:
7
- # # Standalone
8
- # result = Tina4::QueryBuilder.from_table("users", db: db)
9
- # .select("id", "name")
10
- # .where("active = ?", [1])
11
- # .order_by("name ASC")
12
- # .limit(10)
13
- # .get
14
- #
15
- # # From ORM model
16
- # result = User.query
17
- # .where("age > ?", [18])
18
- # .order_by("name")
19
- # .get
20
- #
21
- class QueryBuilder
22
- def initialize(table, db: nil)
23
- @table = table
24
- @db = db
25
- @columns = ["*"]
26
- @wheres = []
27
- @params = []
28
- @joins = []
29
- @group_by_cols = []
30
- @havings = []
31
- @having_params = []
32
- @order_by_cols = []
33
- @limit_val = nil
34
- @offset_val = nil
35
- end
36
-
37
- # Create a QueryBuilder for a table.
38
- #
39
- # @param table_name [String] The database table name.
40
- # @param db [Object, nil] Optional database connection.
41
- # @return [QueryBuilder]
42
- def self.from_table(table_name, db: nil)
43
- new(table_name, db: db)
44
- end
45
-
46
- # Set the columns to select.
47
- #
48
- # @param columns [Array<String>] Column names.
49
- # @return [self]
50
- def select(*columns)
51
- @columns = columns unless columns.empty?
52
- self
53
- end
54
-
55
- # Add a WHERE condition with AND.
56
- #
57
- # @param condition [String] SQL condition with ? placeholders.
58
- # @param params [Array] Parameter values.
59
- # @return [self]
60
- def where(condition, params = [])
61
- @wheres << ["AND", condition]
62
- @params.concat(params)
63
- self
64
- end
65
-
66
- # Add a WHERE condition with OR.
67
- #
68
- # @param condition [String] SQL condition with ? placeholders.
69
- # @param params [Array] Parameter values.
70
- # @return [self]
71
- def or_where(condition, params = [])
72
- @wheres << ["OR", condition]
73
- @params.concat(params)
74
- self
75
- end
76
-
77
- # Add an INNER JOIN.
78
- #
79
- # @param table [String] Table to join.
80
- # @param on_clause [String] Join condition.
81
- # @return [self]
82
- def join(table, on_clause)
83
- @joins << "INNER JOIN #{table} ON #{on_clause}"
84
- self
85
- end
86
-
87
- # Add a LEFT JOIN.
88
- #
89
- # @param table [String] Table to join.
90
- # @param on_clause [String] Join condition.
91
- # @return [self]
92
- def left_join(table, on_clause)
93
- @joins << "LEFT JOIN #{table} ON #{on_clause}"
94
- self
95
- end
96
-
97
- # Add a GROUP BY column.
98
- #
99
- # @param column [String] Column name.
100
- # @return [self]
101
- def group_by(column)
102
- @group_by_cols << column
103
- self
104
- end
105
-
106
- # Add a HAVING clause.
107
- #
108
- # @param expression [String] HAVING expression with ? placeholders.
109
- # @param params [Array] Parameter values.
110
- # @return [self]
111
- def having(expression, params = [])
112
- @havings << expression
113
- @having_params.concat(params)
114
- self
115
- end
116
-
117
- # Add an ORDER BY clause.
118
- #
119
- # @param expression [String] Column and direction (e.g. "name ASC").
120
- # @return [self]
121
- def order_by(expression)
122
- @order_by_cols << expression
123
- self
124
- end
125
-
126
- # Set LIMIT and optional OFFSET.
127
- #
128
- # @param count [Integer] Maximum rows to return.
129
- # @param offset [Integer, nil] Number of rows to skip.
130
- # @return [self]
131
- def limit(count, offset = nil)
132
- @limit_val = count
133
- @offset_val = offset unless offset.nil?
134
- self
135
- end
136
-
137
- # Build and return the SQL string without executing.
138
- #
139
- # @return [String] The constructed SQL query.
140
- def to_sql
141
- sql = "SELECT #{@columns.join(', ')} FROM #{@table}"
142
-
143
- sql += " #{@joins.join(' ')}" unless @joins.empty?
144
-
145
- sql += " WHERE #{build_where}" unless @wheres.empty?
146
-
147
- sql += " GROUP BY #{@group_by_cols.join(', ')}" unless @group_by_cols.empty?
148
-
149
- sql += " HAVING #{@havings.join(' AND ')}" unless @havings.empty?
150
-
151
- sql += " ORDER BY #{@order_by_cols.join(', ')}" unless @order_by_cols.empty?
152
-
153
- sql
154
- end
155
-
156
- # Execute the query and return the database result.
157
- #
158
- # @return [Object] The result from db.fetch.
159
- def get
160
- ensure_db!
161
- sql = to_sql
162
- all_params = @params + @having_params
163
-
164
- @db.fetch(
165
- sql,
166
- all_params.empty? ? [] : all_params,
167
- limit: @limit_val || 100,
168
- offset: @offset_val || 0
169
- )
170
- end
171
-
172
- # Execute the query and return a single row.
173
- #
174
- # @return [Hash, nil] A single row hash, or nil.
175
- def first
176
- ensure_db!
177
- sql = to_sql
178
- all_params = @params + @having_params
179
-
180
- @db.fetch_one(sql, all_params.empty? ? [] : all_params)
181
- end
182
-
183
- # Execute the query and return the row count.
184
- #
185
- # @return [Integer] Number of matching rows.
186
- def count
187
- ensure_db!
188
-
189
- # Build a count query by replacing columns
190
- original = @columns
191
- @columns = ["COUNT(*) as cnt"]
192
- sql = to_sql
193
- @columns = original
194
-
195
- all_params = @params + @having_params
196
-
197
- row = @db.fetch_one(sql, all_params.empty? ? [] : all_params)
198
- return 0 if row.nil?
199
-
200
- # Handle case-insensitive column names
201
- (row["cnt"] || row["CNT"] || row[:cnt] || row[:CNT] || 0).to_i
202
- end
203
-
204
- # Check whether any matching rows exist.
205
- #
206
- # @return [Boolean]
207
- def exists?
208
- count > 0
209
- end
210
-
211
- # Convert the fluent builder state into a MongoDB-compatible query hash.
212
- #
213
- # @return [Hash] with keys :filter, :projection, :sort, :limit, :skip (only non-empty).
214
- def to_mongo
215
- result = {}
216
-
217
- # -- projection --
218
- if @columns != ["*"]
219
- result[:projection] = @columns.each_with_object({}) { |col, h| h[col.strip] = 1 }
220
- end
221
-
222
- # -- filter --
223
- unless @wheres.empty?
224
- param_index = 0
225
- and_conditions = []
226
- or_conditions = []
227
-
228
- @wheres.each_with_index do |(connector, condition), i|
229
- mongo_cond, param_index = parse_condition_to_mongo(condition, param_index)
230
- if i == 0 || connector == "AND"
231
- and_conditions << mongo_cond
232
- else
233
- or_conditions << mongo_cond
234
- end
235
- end
236
-
237
- if or_conditions.any?
238
- and_merged = merge_mongo_conditions(and_conditions)
239
- all_branches = [and_merged] + or_conditions
240
- result[:filter] = { "$or" => all_branches }
241
- else
242
- result[:filter] = merge_mongo_conditions(and_conditions)
243
- end
244
- end
245
-
246
- # -- sort --
247
- unless @order_by_cols.empty?
248
- sort = {}
249
- @order_by_cols.each do |expr|
250
- parts = expr.strip.split(/\s+/)
251
- field = parts[0]
252
- direction = (parts[1] && parts[1].upcase == "DESC") ? -1 : 1
253
- sort[field] = direction
254
- end
255
- result[:sort] = sort
256
- end
257
-
258
- # -- limit / skip --
259
- result[:limit] = @limit_val unless @limit_val.nil?
260
- result[:skip] = @offset_val unless @offset_val.nil?
261
-
262
- result
263
- end
264
-
265
- private
266
-
267
- # Parse a single SQL condition into a MongoDB filter hash.
268
- #
269
- # @return [Array(Hash, Integer)] [mongo_condition, updated_param_index]
270
- def parse_condition_to_mongo(condition, param_index)
271
- cond = condition.strip
272
-
273
- # IS NOT NULL
274
- if cond.match?(/\A(\w+)\s+IS\s+NOT\s+NULL\z/i)
275
- field = cond.match(/\A(\w+)/)[1]
276
- return [{ field => { "$exists" => true, "$ne" => nil } }, param_index]
277
- end
278
-
279
- # IS NULL
280
- if cond.match?(/\A(\w+)\s+IS\s+NULL\z/i)
281
- field = cond.match(/\A(\w+)/)[1]
282
- return [{ field => { "$exists" => false } }, param_index]
283
- end
284
-
285
- # NOT IN
286
- if (m = cond.match(/\A(\w+)\s+NOT\s+IN\s*\(\s*\?\s*\)\z/i))
287
- val = @params[param_index]
288
- values = val.is_a?(Array) ? val : [val]
289
- return [{ m[1] => { "$nin" => values } }, param_index + 1]
290
- end
291
-
292
- # IN
293
- if (m = cond.match(/\A(\w+)\s+IN\s*\(\s*\?\s*\)\z/i))
294
- val = @params[param_index]
295
- values = val.is_a?(Array) ? val : [val]
296
- return [{ m[1] => { "$in" => values } }, param_index + 1]
297
- end
298
-
299
- # LIKE
300
- if (m = cond.match(/\A(\w+)\s+LIKE\s+\?\z/i))
301
- val = (@params[param_index] || "").to_s
302
- pattern = val.gsub("%", ".*").gsub("_", ".")
303
- return [{ m[1] => { "$regex" => pattern, "$options" => "i" } }, param_index + 1]
304
- end
305
-
306
- # Comparison operators: >=, <=, <>, !=, >, <, =
307
- if (m = cond.match(/\A(\w+)\s*(>=|<=|<>|!=|>|<|=)\s*\?\z/))
308
- field = m[1]
309
- op = m[2]
310
- val = @params[param_index]
311
-
312
- op_map = {
313
- "=" => nil, "!=" => "$ne", "<>" => "$ne",
314
- ">" => "$gt", ">=" => "$gte",
315
- "<" => "$lt", "<=" => "$lte"
316
- }
317
-
318
- mongo_op = op_map[op]
319
- if mongo_op.nil?
320
- return [{ field => val }, param_index + 1]
321
- end
322
- return [{ field => { mongo_op => val } }, param_index + 1]
323
- end
324
-
325
- # Fallback
326
- [{ "$where" => cond }, param_index]
327
- end
328
-
329
- # Merge multiple single-field mongo condition hashes into one.
330
- # Uses $and if field keys conflict.
331
- def merge_mongo_conditions(conditions)
332
- return conditions[0] if conditions.size == 1
333
-
334
- merged = {}
335
- has_conflict = false
336
-
337
- conditions.each do |cond|
338
- cond.each do |key, val|
339
- if merged.key?(key)
340
- has_conflict = true
341
- break
342
- end
343
- merged[key] = val
344
- end
345
- break if has_conflict
346
- end
347
-
348
- return { "$and" => conditions } if has_conflict
349
-
350
- merged
351
- end
352
-
353
- # Build the WHERE clause from accumulated conditions.
354
- def build_where
355
- parts = []
356
- @wheres.each_with_index do |(connector, condition), index|
357
- if index == 0
358
- parts << condition
359
- else
360
- parts << "#{connector} #{condition}"
361
- end
362
- end
363
- parts.join(" ")
364
- end
365
-
366
- # Ensure a database connection is available.
367
- def ensure_db!
368
- if @db.nil?
369
- @db = Tina4.database if defined?(Tina4.database) && Tina4.database
370
- end
371
-
372
- raise "QueryBuilder: No database connection provided." if @db.nil?
373
-
374
- # Check if the database connection is still open
375
- if @db.respond_to?(:connected) && !@db.connected
376
- raise "QueryBuilder: No database connection provided."
377
- end
378
- end
379
- end
380
- end
1
+ # frozen_string_literal: true
2
+
3
+ module Tina4
4
+ # QueryBuilder — Fluent SQL query builder.
5
+ #
6
+ # Usage:
7
+ # # Standalone
8
+ # result = Tina4::QueryBuilder.from_table("users", db: db)
9
+ # .select("id", "name")
10
+ # .where("active = ?", [1])
11
+ # .order_by("name ASC")
12
+ # .limit(10)
13
+ # .get
14
+ #
15
+ # # From ORM model
16
+ # result = User.query
17
+ # .where("age > ?", [18])
18
+ # .order_by("name")
19
+ # .get
20
+ #
21
+ class QueryBuilder
22
+ def initialize(table, db: nil)
23
+ @table = table
24
+ @db = db
25
+ @columns = ["*"]
26
+ @wheres = []
27
+ @params = []
28
+ @joins = []
29
+ @group_by_cols = []
30
+ @havings = []
31
+ @having_params = []
32
+ @order_by_cols = []
33
+ @limit_val = nil
34
+ @offset_val = nil
35
+ end
36
+
37
+ # Create a QueryBuilder for a table.
38
+ #
39
+ # @param table_name [String] The database table name.
40
+ # @param db [Object, nil] Optional database connection.
41
+ # @return [QueryBuilder]
42
+ def self.from_table(table_name, db: nil)
43
+ new(table_name, db: db)
44
+ end
45
+
46
+ # Set the columns to select.
47
+ #
48
+ # @param columns [Array<String>] Column names.
49
+ # @return [self]
50
+ def select(*columns)
51
+ @columns = columns unless columns.empty?
52
+ self
53
+ end
54
+
55
+ # Add a WHERE condition with AND.
56
+ #
57
+ # @param condition [String] SQL condition with ? placeholders.
58
+ # @param params [Array] Parameter values.
59
+ # @return [self]
60
+ def where(condition, params = [])
61
+ @wheres << ["AND", condition]
62
+ @params.concat(params)
63
+ self
64
+ end
65
+
66
+ # Add a WHERE condition with OR.
67
+ #
68
+ # @param condition [String] SQL condition with ? placeholders.
69
+ # @param params [Array] Parameter values.
70
+ # @return [self]
71
+ def or_where(condition, params = [])
72
+ @wheres << ["OR", condition]
73
+ @params.concat(params)
74
+ self
75
+ end
76
+
77
+ # Add an INNER JOIN.
78
+ #
79
+ # @param table [String] Table to join.
80
+ # @param on_clause [String] Join condition.
81
+ # @return [self]
82
+ def join(table, on_clause)
83
+ @joins << "INNER JOIN #{table} ON #{on_clause}"
84
+ self
85
+ end
86
+
87
+ # Add a LEFT JOIN.
88
+ #
89
+ # @param table [String] Table to join.
90
+ # @param on_clause [String] Join condition.
91
+ # @return [self]
92
+ def left_join(table, on_clause)
93
+ @joins << "LEFT JOIN #{table} ON #{on_clause}"
94
+ self
95
+ end
96
+
97
+ # Add a GROUP BY column.
98
+ #
99
+ # @param column [String] Column name.
100
+ # @return [self]
101
+ def group_by(column)
102
+ @group_by_cols << column
103
+ self
104
+ end
105
+
106
+ # Add a HAVING clause.
107
+ #
108
+ # @param expression [String] HAVING expression with ? placeholders.
109
+ # @param params [Array] Parameter values.
110
+ # @return [self]
111
+ def having(expression, params = [])
112
+ @havings << expression
113
+ @having_params.concat(params)
114
+ self
115
+ end
116
+
117
+ # Add an ORDER BY clause.
118
+ #
119
+ # @param expression [String] Column and direction (e.g. "name ASC").
120
+ # @return [self]
121
+ def order_by(expression)
122
+ @order_by_cols << expression
123
+ self
124
+ end
125
+
126
+ # Set LIMIT and optional OFFSET.
127
+ #
128
+ # @param count [Integer] Maximum rows to return.
129
+ # @param offset [Integer, nil] Number of rows to skip.
130
+ # @return [self]
131
+ def limit(count, offset = nil)
132
+ @limit_val = count
133
+ @offset_val = offset unless offset.nil?
134
+ self
135
+ end
136
+
137
+ # Build and return the SQL string without executing.
138
+ #
139
+ # @return [String] The constructed SQL query.
140
+ def to_sql
141
+ sql = "SELECT #{@columns.join(', ')} FROM #{@table}"
142
+
143
+ sql += " #{@joins.join(' ')}" unless @joins.empty?
144
+
145
+ sql += " WHERE #{build_where}" unless @wheres.empty?
146
+
147
+ sql += " GROUP BY #{@group_by_cols.join(', ')}" unless @group_by_cols.empty?
148
+
149
+ sql += " HAVING #{@havings.join(' AND ')}" unless @havings.empty?
150
+
151
+ sql += " ORDER BY #{@order_by_cols.join(', ')}" unless @order_by_cols.empty?
152
+
153
+ sql
154
+ end
155
+
156
+ # Execute the query and return the database result.
157
+ #
158
+ # @return [Object] The result from db.fetch.
159
+ def get
160
+ ensure_db!
161
+ sql = to_sql
162
+ all_params = @params + @having_params
163
+
164
+ @db.fetch(
165
+ sql,
166
+ all_params.empty? ? [] : all_params,
167
+ limit: @limit_val || 100,
168
+ offset: @offset_val || 0
169
+ )
170
+ end
171
+
172
+ # Execute the query and return a single row.
173
+ #
174
+ # @return [Hash, nil] A single row hash, or nil.
175
+ def first
176
+ ensure_db!
177
+ sql = to_sql
178
+ all_params = @params + @having_params
179
+
180
+ @db.fetch_one(sql, all_params.empty? ? [] : all_params)
181
+ end
182
+
183
+ # Execute the query and return the row count.
184
+ #
185
+ # @return [Integer] Number of matching rows.
186
+ def count
187
+ ensure_db!
188
+
189
+ # Build a count query by replacing columns
190
+ original = @columns
191
+ @columns = ["COUNT(*) as cnt"]
192
+ sql = to_sql
193
+ @columns = original
194
+
195
+ all_params = @params + @having_params
196
+
197
+ row = @db.fetch_one(sql, all_params.empty? ? [] : all_params)
198
+ return 0 if row.nil?
199
+
200
+ # Handle case-insensitive column names
201
+ (row["cnt"] || row["CNT"] || row[:cnt] || row[:CNT] || 0).to_i
202
+ end
203
+
204
+ # Check whether any matching rows exist.
205
+ #
206
+ # @return [Boolean]
207
+ def exists?
208
+ count > 0
209
+ end
210
+
211
+ # Convert the fluent builder state into a MongoDB-compatible query hash.
212
+ #
213
+ # @return [Hash] with keys :filter, :projection, :sort, :limit, :skip (only non-empty).
214
+ def to_mongo
215
+ result = {}
216
+
217
+ # -- projection --
218
+ if @columns != ["*"]
219
+ result[:projection] = @columns.each_with_object({}) { |col, h| h[col.strip] = 1 }
220
+ end
221
+
222
+ # -- filter --
223
+ unless @wheres.empty?
224
+ param_index = 0
225
+ and_conditions = []
226
+ or_conditions = []
227
+
228
+ @wheres.each_with_index do |(connector, condition), i|
229
+ mongo_cond, param_index = parse_condition_to_mongo(condition, param_index)
230
+ if i == 0 || connector == "AND"
231
+ and_conditions << mongo_cond
232
+ else
233
+ or_conditions << mongo_cond
234
+ end
235
+ end
236
+
237
+ if or_conditions.any?
238
+ and_merged = merge_mongo_conditions(and_conditions)
239
+ all_branches = [and_merged] + or_conditions
240
+ result[:filter] = { "$or" => all_branches }
241
+ else
242
+ result[:filter] = merge_mongo_conditions(and_conditions)
243
+ end
244
+ end
245
+
246
+ # -- sort --
247
+ unless @order_by_cols.empty?
248
+ sort = {}
249
+ @order_by_cols.each do |expr|
250
+ parts = expr.strip.split(/\s+/)
251
+ field = parts[0]
252
+ direction = (parts[1] && parts[1].upcase == "DESC") ? -1 : 1
253
+ sort[field] = direction
254
+ end
255
+ result[:sort] = sort
256
+ end
257
+
258
+ # -- limit / skip --
259
+ result[:limit] = @limit_val unless @limit_val.nil?
260
+ result[:skip] = @offset_val unless @offset_val.nil?
261
+
262
+ result
263
+ end
264
+
265
+ private
266
+
267
+ # Parse a single SQL condition into a MongoDB filter hash.
268
+ #
269
+ # @return [Array(Hash, Integer)] [mongo_condition, updated_param_index]
270
+ def parse_condition_to_mongo(condition, param_index)
271
+ cond = condition.strip
272
+
273
+ # IS NOT NULL
274
+ if cond.match?(/\A(\w+)\s+IS\s+NOT\s+NULL\z/i)
275
+ field = cond.match(/\A(\w+)/)[1]
276
+ return [{ field => { "$exists" => true, "$ne" => nil } }, param_index]
277
+ end
278
+
279
+ # IS NULL
280
+ if cond.match?(/\A(\w+)\s+IS\s+NULL\z/i)
281
+ field = cond.match(/\A(\w+)/)[1]
282
+ return [{ field => { "$exists" => false } }, param_index]
283
+ end
284
+
285
+ # NOT IN
286
+ if (m = cond.match(/\A(\w+)\s+NOT\s+IN\s*\(\s*\?\s*\)\z/i))
287
+ val = @params[param_index]
288
+ values = val.is_a?(Array) ? val : [val]
289
+ return [{ m[1] => { "$nin" => values } }, param_index + 1]
290
+ end
291
+
292
+ # IN
293
+ if (m = cond.match(/\A(\w+)\s+IN\s*\(\s*\?\s*\)\z/i))
294
+ val = @params[param_index]
295
+ values = val.is_a?(Array) ? val : [val]
296
+ return [{ m[1] => { "$in" => values } }, param_index + 1]
297
+ end
298
+
299
+ # LIKE
300
+ if (m = cond.match(/\A(\w+)\s+LIKE\s+\?\z/i))
301
+ val = (@params[param_index] || "").to_s
302
+ pattern = val.gsub("%", ".*").gsub("_", ".")
303
+ return [{ m[1] => { "$regex" => pattern, "$options" => "i" } }, param_index + 1]
304
+ end
305
+
306
+ # Comparison operators: >=, <=, <>, !=, >, <, =
307
+ if (m = cond.match(/\A(\w+)\s*(>=|<=|<>|!=|>|<|=)\s*\?\z/))
308
+ field = m[1]
309
+ op = m[2]
310
+ val = @params[param_index]
311
+
312
+ op_map = {
313
+ "=" => nil, "!=" => "$ne", "<>" => "$ne",
314
+ ">" => "$gt", ">=" => "$gte",
315
+ "<" => "$lt", "<=" => "$lte"
316
+ }
317
+
318
+ mongo_op = op_map[op]
319
+ if mongo_op.nil?
320
+ return [{ field => val }, param_index + 1]
321
+ end
322
+ return [{ field => { mongo_op => val } }, param_index + 1]
323
+ end
324
+
325
+ # Fallback
326
+ [{ "$where" => cond }, param_index]
327
+ end
328
+
329
+ # Merge multiple single-field mongo condition hashes into one.
330
+ # Uses $and if field keys conflict.
331
+ def merge_mongo_conditions(conditions)
332
+ return conditions[0] if conditions.size == 1
333
+
334
+ merged = {}
335
+ has_conflict = false
336
+
337
+ conditions.each do |cond|
338
+ cond.each do |key, val|
339
+ if merged.key?(key)
340
+ has_conflict = true
341
+ break
342
+ end
343
+ merged[key] = val
344
+ end
345
+ break if has_conflict
346
+ end
347
+
348
+ return { "$and" => conditions } if has_conflict
349
+
350
+ merged
351
+ end
352
+
353
+ # Build the WHERE clause from accumulated conditions.
354
+ def build_where
355
+ parts = []
356
+ @wheres.each_with_index do |(connector, condition), index|
357
+ if index == 0
358
+ parts << condition
359
+ else
360
+ parts << "#{connector} #{condition}"
361
+ end
362
+ end
363
+ parts.join(" ")
364
+ end
365
+
366
+ # Ensure a database connection is available.
367
+ def ensure_db!
368
+ if @db.nil?
369
+ @db = Tina4.database if defined?(Tina4.database) && Tina4.database
370
+ end
371
+
372
+ raise "QueryBuilder: No database connection provided." if @db.nil?
373
+
374
+ # Check if the database connection is still open
375
+ if @db.respond_to?(:connected) && !@db.connected
376
+ raise "QueryBuilder: No database connection provided."
377
+ end
378
+ end
379
+ end
380
+ end