tina4ruby 3.13.137 → 3.13.138

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 (160) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +36 -0
  3. data/COMMERCIAL-LICENSE.md +8 -0
  4. data/LICENSE +373 -0
  5. data/LICENSE.txt +373 -21
  6. data/NOTICE +11 -0
  7. data/README.md +11 -7
  8. data/exe/tina4ruby +6 -0
  9. data/lib/tina4/ai.rb +6 -0
  10. data/lib/tina4/ai_client.rb +10 -3
  11. data/lib/tina4/api.rb +57 -7
  12. data/lib/tina4/auth.rb +18 -12
  13. data/lib/tina4/auto_crud.rb +83 -27
  14. data/lib/tina4/background.rb +7 -1
  15. data/lib/tina4/base64.rb +59 -0
  16. data/lib/tina4/cache.rb +6 -0
  17. data/lib/tina4/cache_backends/base_backend.rb +6 -0
  18. data/lib/tina4/cache_backends/database_backend.rb +6 -0
  19. data/lib/tina4/cache_backends/file_backend.rb +6 -0
  20. data/lib/tina4/cache_backends/memcached_backend.rb +6 -0
  21. data/lib/tina4/cache_backends/memory_backend.rb +6 -0
  22. data/lib/tina4/cache_backends/mongo_backend.rb +6 -0
  23. data/lib/tina4/cache_backends/redis_backend.rb +6 -0
  24. data/lib/tina4/cache_backends/valkey_backend.rb +6 -0
  25. data/lib/tina4/cache_backends.rb +6 -0
  26. data/lib/tina4/cli.rb +22 -7
  27. data/lib/tina4/constants.rb +12 -8
  28. data/lib/tina4/container.rb +6 -0
  29. data/lib/tina4/context/chunker.rb +6 -0
  30. data/lib/tina4/context.rb +13 -5
  31. data/lib/tina4/cors.rb +66 -10
  32. data/lib/tina4/crud.rb +78 -9
  33. data/lib/tina4/database/sqlite3_adapter.rb +9 -1
  34. data/lib/tina4/database.rb +260 -44
  35. data/lib/tina4/database_adapter.rb +6 -0
  36. data/lib/tina4/database_result.rb +6 -0
  37. data/lib/tina4/database_url.rb +6 -0
  38. data/lib/tina4/debug.rb +6 -0
  39. data/lib/tina4/dev.rb +6 -0
  40. data/lib/tina4/dev_admin.rb +215 -89
  41. data/lib/tina4/dev_mailbox.rb +6 -1
  42. data/lib/tina4/dispatch_pipeline.rb +86 -1
  43. data/lib/tina4/docs.rb +6 -0
  44. data/lib/tina4/docstore.rb +23 -4
  45. data/lib/tina4/drivers/arango_graph_driver.rb +8 -1
  46. data/lib/tina4/drivers/bolt_graph_driver.rb +6 -0
  47. data/lib/tina4/drivers/firebird_driver.rb +95 -6
  48. data/lib/tina4/drivers/mongodb_driver.rb +6 -0
  49. data/lib/tina4/drivers/mssql_driver.rb +54 -39
  50. data/lib/tina4/drivers/mysql_driver.rb +6 -0
  51. data/lib/tina4/drivers/odbc_driver.rb +6 -0
  52. data/lib/tina4/drivers/postgres_driver.rb +35 -8
  53. data/lib/tina4/drivers/schema_split.rb +6 -0
  54. data/lib/tina4/drivers/sqlite_driver.rb +8 -1
  55. data/lib/tina4/drivers/ultipa_graph_driver.rb +6 -0
  56. data/lib/tina4/env.rb +6 -0
  57. data/lib/tina4/error_overlay.rb +6 -0
  58. data/lib/tina4/events.rb +6 -0
  59. data/lib/tina4/feedback.rb +9 -2
  60. data/lib/tina4/field_types.rb +6 -0
  61. data/lib/tina4/form_parser.rb +241 -0
  62. data/lib/tina4/frond.rb +196 -56
  63. data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +6 -0
  64. data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +6 -0
  65. data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +6 -0
  66. data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +6 -0
  67. data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +6 -0
  68. data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +6 -0
  69. data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +6 -0
  70. data/lib/tina4/graph.rb +6 -0
  71. data/lib/tina4/graphql.rb +28 -27
  72. data/lib/tina4/health.rb +6 -0
  73. data/lib/tina4/html_element.rb +6 -0
  74. data/lib/tina4/http_server.rb +690 -0
  75. data/lib/tina4/imap_client.rb +352 -0
  76. data/lib/tina4/import_helper.rb +6 -0
  77. data/lib/tina4/job.rb +6 -0
  78. data/lib/tina4/localization.rb +6 -0
  79. data/lib/tina4/log.rb +6 -0
  80. data/lib/tina4/mail_socket.rb +124 -0
  81. data/lib/tina4/mcp.rb +15 -8
  82. data/lib/tina4/messenger.rb +86 -55
  83. data/lib/tina4/metrics.rb +6 -0
  84. data/lib/tina4/middleware.rb +24 -9
  85. data/lib/tina4/migration.rb +6 -0
  86. data/lib/tina4/model_collection.rb +6 -0
  87. data/lib/tina4/mqtt.rb +9 -2
  88. data/lib/tina4/mqtt_message.rb +6 -0
  89. data/lib/tina4/orm.rb +72 -16
  90. data/lib/tina4/parse_json.rb +25 -0
  91. data/lib/tina4/plan.rb +9 -2
  92. data/lib/tina4/point.rb +6 -0
  93. data/lib/tina4/port_takeover.rb +6 -0
  94. data/lib/tina4/project_index.rb +6 -0
  95. data/lib/tina4/public/THIRD-PARTY-NOTICES.md +55 -0
  96. data/lib/tina4/public/bundled-component-provenance.json +48 -0
  97. data/lib/tina4/public/js/tina4js.min.js +13 -3
  98. data/lib/tina4/public/licenses/swagger-ui-3.10.0-original-notice.txt +11 -0
  99. data/lib/tina4/public/licenses/swagger-ui-5.17.14-NOTICE.txt +2 -0
  100. data/lib/tina4/public/licenses/swagger-ui-Apache-2.0.txt +202 -0
  101. data/lib/tina4/public/licenses/tina4-css-MIT.txt +21 -0
  102. data/lib/tina4/public/licenses/tina4-dev-admin-components.json +240 -0
  103. data/lib/tina4/public/licenses/tina4-dev-admin-third-party.txt +919 -0
  104. data/lib/tina4/public/licenses/tina4-js-MIT.txt +20 -0
  105. data/lib/tina4/push.rb +9 -3
  106. data/lib/tina4/query_builder.rb +6 -0
  107. data/lib/tina4/queue.rb +6 -0
  108. data/lib/tina4/queue_backends/kafka_backend.rb +10 -2
  109. data/lib/tina4/queue_backends/lite_backend.rb +6 -0
  110. data/lib/tina4/queue_backends/mongo_backend.rb +6 -0
  111. data/lib/tina4/queue_backends/rabbitmq_backend.rb +10 -2
  112. data/lib/tina4/rack_app.rb +11 -1
  113. data/lib/tina4/rate_limiter.rb +6 -0
  114. data/lib/tina4/realtime/attachment.rb +6 -0
  115. data/lib/tina4/realtime/channel.rb +6 -0
  116. data/lib/tina4/realtime/channel_member.rb +6 -0
  117. data/lib/tina4/realtime/local_storage.rb +6 -0
  118. data/lib/tina4/realtime/message.rb +6 -0
  119. data/lib/tina4/realtime/s3_storage.rb +6 -0
  120. data/lib/tina4/realtime/storage.rb +6 -0
  121. data/lib/tina4/realtime/storage_backend.rb +6 -0
  122. data/lib/tina4/realtime/workspace.rb +6 -0
  123. data/lib/tina4/realtime.rb +10 -3
  124. data/lib/tina4/request.rb +85 -127
  125. data/lib/tina4/response.rb +71 -9
  126. data/lib/tina4/response_cache.rb +6 -0
  127. data/lib/tina4/router.rb +6 -0
  128. data/lib/tina4/secret_file.rb +34 -0
  129. data/lib/tina4/seeder.rb +9 -3
  130. data/lib/tina4/service.rb +8 -2
  131. data/lib/tina4/service_runner.rb +6 -0
  132. data/lib/tina4/session.rb +6 -0
  133. data/lib/tina4/session_handlers/database_handler.rb +6 -0
  134. data/lib/tina4/session_handlers/file_handler.rb +6 -0
  135. data/lib/tina4/session_handlers/memcached_handler.rb +6 -0
  136. data/lib/tina4/session_handlers/mongo_handler.rb +108 -39
  137. data/lib/tina4/session_handlers/mongo_wire_client.rb +23 -9
  138. data/lib/tina4/session_handlers/redis_handler.rb +6 -0
  139. data/lib/tina4/session_handlers/resp_client.rb +6 -0
  140. data/lib/tina4/session_handlers/valkey_handler.rb +6 -0
  141. data/lib/tina4/shutdown.rb +17 -14
  142. data/lib/tina4/smtp_client.rb +222 -0
  143. data/lib/tina4/sql_translator.rb +93 -0
  144. data/lib/tina4/sqlite3_gem.rb +27 -0
  145. data/lib/tina4/sso.rb +25 -8
  146. data/lib/tina4/swagger.rb +6 -0
  147. data/lib/tina4/template.rb +6 -0
  148. data/lib/tina4/test.rb +8 -2
  149. data/lib/tina4/test_client.rb +8 -1
  150. data/lib/tina4/testing.rb +8 -1
  151. data/lib/tina4/validator.rb +6 -0
  152. data/lib/tina4/version.rb +7 -1
  153. data/lib/tina4/webserver.rb +25 -143
  154. data/lib/tina4/websocket.rb +10 -4
  155. data/lib/tina4/websocket_backplane.rb +19 -2
  156. data/lib/tina4/wsdl.rb +31 -16
  157. data/lib/tina4/xml_parser.rb +259 -0
  158. data/lib/tina4.rb +87 -31
  159. data/lib/tina4ruby.rb +6 -0
  160. metadata +43 -140
data/lib/tina4/cors.rb CHANGED
@@ -1,4 +1,10 @@
1
1
  # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
2
8
 
3
9
  module Tina4
4
10
  # CORS policy — reads config from env, computes the response headers.
@@ -37,6 +43,13 @@ module Tina4
37
43
  @warned = nil
38
44
  end
39
45
 
46
+ # The reasons warned about so far in this process - bounded by
47
+ # construction, never an origin (ADR-0048). Node's corsWarningReasons(),
48
+ # PHP's CorsMiddleware::warnedReasons().
49
+ def warned_reasons
50
+ (@warned || {}).keys.map(&:to_s)
51
+ end
52
+
40
53
  # The configured origins, split and emptied of blanks.
41
54
  def allowed_origins
42
55
  config[:origins].split(",").map(&:strip).reject(&:empty?)
@@ -56,8 +69,13 @@ module Tina4
56
69
  allowed = allowed_origins
57
70
  request_origin = env["HTTP_ORIGIN"]
58
71
 
72
+ # A same-origin request (browsers send Origin on every same-origin
73
+ # POST/PUT/PATCH/DELETE) is not a CORS request: it is never warned
74
+ # about. That ONLY silences the warning - it grants no CORS access.
75
+ cross_origin = present?(request_origin) && !same_origin?(env)
76
+
59
77
  if allowed.empty?
60
- warn_once(:unconfigured, request_origin) if present?(request_origin)
78
+ warn_once(:unconfigured, request_origin) if cross_origin
61
79
  return {}
62
80
  end
63
81
 
@@ -70,7 +88,7 @@ module Tina4
70
88
 
71
89
  origin = resolve_origin(env)
72
90
  if origin.nil?
73
- warn_once(:denied, request_origin) if present?(request_origin)
91
+ warn_once(:denied, request_origin) if cross_origin
74
92
  return headers
75
93
  end
76
94
 
@@ -150,6 +168,22 @@ module Tina4
150
168
  %w[true 1 yes].include?(config[:credentials].to_s.downcase)
151
169
  end
152
170
 
171
+ # Whether the request's Origin is the request's OWN origin
172
+ # (tina4-python #139). Own origin = scheme://host[:port], the scheme
173
+ # proxy-aware (Request.secure_scheme?, X-Forwarded-Proto first hop) and
174
+ # the host from X-Forwarded-Host, else Host. Both sides are normalised:
175
+ # lower-cased, default ports (http:80, https:443) dropped.
176
+ def same_origin?(env)
177
+ origin = normalize_origin(env["HTTP_ORIGIN"])
178
+ return false if origin.nil?
179
+
180
+ scheme = Tina4::Request.secure_scheme?(env) ? "https" : "http"
181
+ host = (env["HTTP_X_FORWARDED_HOST"] || env["HTTP_HOST"]).to_s.split(",").first.to_s.strip
182
+ return false if host.empty?
183
+
184
+ origin == normalize_origin("#{scheme}://#{host}")
185
+ end
186
+
153
187
  private
154
188
 
155
189
  def load_config
@@ -186,6 +220,20 @@ module Tina4
186
220
  !value.nil? && !value.to_s.empty?
187
221
  end
188
222
 
223
+ DEFAULT_PORTS = { "http" => 80, "https" => 443 }.freeze
224
+
225
+ # "scheme://host[:port]" lower-cased with a default port dropped, or nil
226
+ # for anything that is not an http(s) origin (e.g. "null").
227
+ def normalize_origin(value)
228
+ match = value.to_s.strip.chomp("/").match(%r{\A(https?)://([^/:\s]+|\[[^\]]+\])(?::(\d+))?\z}i)
229
+ return nil unless match
230
+
231
+ scheme = match[1].downcase
232
+ host = match[2].downcase
233
+ port = match[3]&.to_i
234
+ port.nil? || port == DEFAULT_PORTS[scheme] ? "#{scheme}://#{host}" : "#{scheme}://#{host}:#{port}"
235
+ end
236
+
189
237
  def merge_vary(current, field_name)
190
238
  parts = current.to_s.split(",").map(&:strip).reject(&:empty?)
191
239
  return parts.join(", ") if parts.any? { |p| p.casecmp?(field_name) }
@@ -193,17 +241,22 @@ module Tina4
193
241
  (parts + [field_name]).join(", ")
194
242
  end
195
243
 
196
- # Log an actionable warning at most once per reason per process.
244
+ # Log an actionable warning at most once per REASON per process.
197
245
  #
198
246
  # A rejected cross-origin request is otherwise invisible: the browser
199
247
  # reports a generic CORS failure and the server log says nothing, so the
200
248
  # operator has to read the framework source to find the env var to set.
249
+ #
250
+ # BOUNDED (ADR-0048): keyed by reason only, never by origin. Keying
251
+ # :denied by "denied:<origin>" gave every attacker-chosen Origin header its
252
+ # own @warned entry (never freed) and its own log line - an unbounded
253
+ # ledger and an unbounded log. The message still names the origin that
254
+ # triggered it, the first time per reason.
201
255
  def warn_once(reason, request_origin)
202
256
  @warned ||= {}
203
- key = reason == :denied ? "denied:#{request_origin}" : reason
204
- return if @warned[key]
257
+ return if @warned[reason]
205
258
 
206
- @warned[key] = true
259
+ @warned[reason] = true
207
260
  Tina4::Log.warning(warning_message(reason, request_origin))
208
261
  rescue StandardError
209
262
  nil # logging must never break a request
@@ -211,13 +264,16 @@ module Tina4
211
264
 
212
265
  def warning_message(reason, request_origin)
213
266
  case reason
267
+ # Neither message ever suggests the wildcard: allowing every website to
268
+ # silence one warning is the wrong fix. Each names the ONE origin to add.
214
269
  when :unconfigured
215
- "CORS: refused cross-origin request from #{request_origin} - no policy is configured. " \
216
- "Set TINA4_CORS_ORIGINS to the origins you want to allow, e.g. " \
217
- "TINA4_CORS_ORIGINS=https://app.example.com (or '*' to allow any origin)."
270
+ "CORS: cross-origin request from #{request_origin}, but no CORS policy is configured, " \
271
+ "so the browser will block this response. If that origin should be allowed, " \
272
+ "add it: TINA4_CORS_ORIGINS=#{request_origin}"
218
273
  when :denied
219
274
  "CORS: origin #{request_origin} is not in TINA4_CORS_ORIGINS (#{config[:origins]}) " \
220
- "- the browser will block this response."
275
+ "- the browser will block this response. If it should be allowed, add it: " \
276
+ "TINA4_CORS_ORIGINS=#{config[:origins]},#{request_origin}"
221
277
  else
222
278
  "CORS: TINA4_CORS_CREDENTIALS is true but TINA4_CORS_ORIGINS is '*'. The Fetch Standard " \
223
279
  "forbids Access-Control-Allow-Origin: * with credentials, so credentials are NOT being " \
data/lib/tina4/crud.rb CHANGED
@@ -1,4 +1,10 @@
1
1
  # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
2
8
  require "json"
3
9
 
4
10
  module Tina4
@@ -59,7 +65,7 @@ module Tina4
59
65
  query_params = request.respond_to?(:query) ? request.query : {}
60
66
  page = [(query_params["page"] || 1).to_i, 1].max
61
67
  search = query_params["search"].to_s.strip
62
- sort_col = query_params["sort"] || pk
68
+ sort_col = crud_sort_column(model, sql, query_params["sort"], pk)
63
69
  sort_dir = query_params["sort_dir"] == "desc" ? "desc" : "asc"
64
70
  offset = (page - 1) * limit
65
71
 
@@ -213,8 +219,8 @@ module Tina4
213
219
  # POST create
214
220
  Tina4::Router.add("POST", api_path, proc { |req, res|
215
221
  begin
216
- data = req.body_parsed
217
- result = db.insert(table_name, data)
222
+ data = table_column_attributes(db, table_name, req.body_parsed)
223
+ db.insert(table_name, data)
218
224
  res.json({ data: data, message: "Created" }, status: 201)
219
225
  rescue => e
220
226
  res.json({ error: e.message }, status: 500)
@@ -225,7 +231,8 @@ module Tina4
225
231
  Tina4::Router.add("PUT", "#{api_path}/{id}", proc { |req, res|
226
232
  begin
227
233
  id = req.params["id"]
228
- data = req.body_parsed
234
+ # The row is addressed by the URL id only, never by a body pk.
235
+ data = table_column_attributes(db, table_name, req.body_parsed, strip: pk)
229
236
  db.update(table_name, data, { pk => id })
230
237
  res.json({ data: data, message: "Updated" })
231
238
  rescue => e
@@ -245,6 +252,68 @@ module Tina4
245
252
  })
246
253
  end
247
254
 
255
+ # tina4: ADR-0069 - a SQL-mode write body is allow-listed against the
256
+ # table's REAL columns (matched case-insensitively, written in the
257
+ # introspected spelling); unknown keys are dropped, is_deleted is never
258
+ # client-writable, and +strip+ (the pk on update) is removed. Same write
259
+ # rule as AutoCrud (CRUD-DEC-02).
260
+ def table_column_attributes(db, table_name, data, strip: nil)
261
+ return {} unless data.is_a?(Hash)
262
+
263
+ columns = db.columns(table_name).to_h { |column| [column[:name].to_s.downcase, column[:name].to_s] }
264
+ blocked = ["is_deleted", strip.to_s.downcase]
265
+ data.each_with_object({}) do |(key, value), allowed|
266
+ column = columns[key.to_s.downcase]
267
+ next if column.nil? || blocked.include?(column.downcase)
268
+
269
+ allowed[column] = value
270
+ end
271
+ end
272
+
273
+ # tina4: ADR-0069 - ?sort reaches ORDER BY only as a column the source
274
+ # itself declares: a model's declared field (resolved to its DB column) or
275
+ # a column of the SQL query's own result set. Anything else falls back to
276
+ # the primary key - this is a rendered page, not an API, so no error.
277
+ def crud_sort_column(model, sql, requested, pk)
278
+ return pk if requested.nil? || requested.empty?
279
+ return model.resolve_field_column(requested) || pk if model
280
+
281
+ sql_result_columns(sql).include?(requested) ? requested : pk
282
+ end
283
+
284
+ # The query with any ORDER BY / LIMIT clause removed, then stripped.
285
+ #
286
+ # Line by line with plain string operations - a case-insensitive search
287
+ # for the keyword, then a cut to the end of that line - so it is linear on
288
+ # any input (the regex it replaces was flagged as polynomial by CodeQL).
289
+ # Same result: a keyword is removed with the rest of its line only when at
290
+ # least one character follows it, and the line break is kept.
291
+ def strip_order_and_limit(sql)
292
+ sql.to_s.each_line.map do |line|
293
+ cut_from_keyword(cut_from_keyword(line, /ORDER BY /i), /LIMIT /i)
294
+ end.join.strip
295
+ end
296
+
297
+ # +keyword+ is a fixed literal with no quantifier, so the search is linear.
298
+ def cut_from_keyword(line, keyword)
299
+ ending = line.end_with?("\n") ? "\n" : ""
300
+ content = ending.empty? ? line : line[0...-1]
301
+ at = content.index(keyword)
302
+ return line if at.nil? || content.length <= at + keyword.source.length
303
+
304
+ content[0...at] + ending
305
+ end
306
+
307
+ # Column names of the SQL query's result set, read from one probe row.
308
+ def sql_result_columns(sql)
309
+ db = Tina4.database
310
+ return [] unless db
311
+
312
+ base = strip_order_and_limit(sql)
313
+ row = db.fetch("SELECT * FROM (#{base}) AS _crud_sub", [], limit: 1).first
314
+ row ? row.keys.map(&:to_s) : []
315
+ end
316
+
248
317
  # Fetch data using an ORM model class
249
318
  def fetch_model_data(model, search: "", sort: "id", sort_dir: "asc", limit: 10, offset: 0)
250
319
  order_by = "#{sort} #{sort_dir.upcase}"
@@ -280,12 +349,12 @@ module Tina4
280
349
  return [[], 0] unless db
281
350
 
282
351
  # Wrap the original SQL for sorting
283
- query = sql.gsub(/ORDER BY .+$/i, "").gsub(/LIMIT .+$/i, "").strip
284
- query += " ORDER BY #{sort} #{sort_dir.upcase}"
352
+ base = strip_order_and_limit(sql)
353
+ query = "#{base} ORDER BY #{sort} #{sort_dir.upcase}"
285
354
 
286
355
  if !search.empty?
287
356
  # Wrap in a subquery to add search filtering
288
- wrapped = "SELECT * FROM (#{sql.gsub(/ORDER BY .+$/i, '').gsub(/LIMIT .+$/i, '').strip}) AS _crud_sub WHERE "
357
+ wrapped = "SELECT * FROM (#{base}) AS _crud_sub WHERE "
289
358
  columns = extract_columns(sql)
290
359
  search_parts = columns.map { |col| "CAST(#{col} AS TEXT) LIKE ?" }
291
360
  wrapped += search_parts.join(" OR ")
@@ -293,14 +362,14 @@ module Tina4
293
362
  params = columns.map { "%#{search}%" }
294
363
 
295
364
  # Get total count
296
- count_sql = "SELECT COUNT(*) as cnt FROM (#{sql.gsub(/ORDER BY .+$/i, '').gsub(/LIMIT .+$/i, '').strip}) AS _crud_cnt WHERE #{search_parts.join(' OR ')}"
365
+ count_sql = "SELECT COUNT(*) as cnt FROM (#{base}) AS _crud_cnt WHERE #{search_parts.join(' OR ')}"
297
366
  count_result = db.fetch_one(count_sql, params)
298
367
  total = count_result ? (count_result[:cnt] || count_result["cnt"] || 0).to_i : 0
299
368
 
300
369
  results = db.fetch(wrapped, params, limit: limit, offset: offset)
301
370
  else
302
371
  # Get total count
303
- count_sql = "SELECT COUNT(*) as cnt FROM (#{sql.gsub(/ORDER BY .+$/i, '').gsub(/LIMIT .+$/i, '').strip}) AS _crud_cnt"
372
+ count_sql = "SELECT COUNT(*) as cnt FROM (#{base}) AS _crud_cnt"
304
373
  count_result = db.fetch_one(count_sql)
305
374
  total = count_result ? (count_result[:cnt] || count_result["cnt"] || 0).to_i : 0
306
375
 
@@ -1,4 +1,12 @@
1
1
  # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
8
+
9
+ require_relative "../sqlite3_gem"
2
10
 
3
11
  module Tina4
4
12
  module Adapters
@@ -13,7 +21,7 @@ module Tina4
13
21
  end
14
22
 
15
23
  def connect(connection_string)
16
- require "sqlite3"
24
+ Tina4.require_sqlite3!
17
25
  @db_path = self.class.resolve_path(connection_string)
18
26
  @connection = SQLite3::Database.new(@db_path)
19
27
  @connection.results_as_hash = true