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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +36 -0
- data/COMMERCIAL-LICENSE.md +8 -0
- data/LICENSE +373 -0
- data/LICENSE.txt +373 -21
- data/NOTICE +11 -0
- data/README.md +11 -7
- data/exe/tina4ruby +6 -0
- data/lib/tina4/ai.rb +6 -0
- data/lib/tina4/ai_client.rb +10 -3
- data/lib/tina4/api.rb +57 -7
- data/lib/tina4/auth.rb +18 -12
- data/lib/tina4/auto_crud.rb +83 -27
- data/lib/tina4/background.rb +7 -1
- data/lib/tina4/base64.rb +59 -0
- data/lib/tina4/cache.rb +6 -0
- data/lib/tina4/cache_backends/base_backend.rb +6 -0
- data/lib/tina4/cache_backends/database_backend.rb +6 -0
- data/lib/tina4/cache_backends/file_backend.rb +6 -0
- data/lib/tina4/cache_backends/memcached_backend.rb +6 -0
- data/lib/tina4/cache_backends/memory_backend.rb +6 -0
- data/lib/tina4/cache_backends/mongo_backend.rb +6 -0
- data/lib/tina4/cache_backends/redis_backend.rb +6 -0
- data/lib/tina4/cache_backends/valkey_backend.rb +6 -0
- data/lib/tina4/cache_backends.rb +6 -0
- data/lib/tina4/cli.rb +22 -7
- data/lib/tina4/constants.rb +12 -8
- data/lib/tina4/container.rb +6 -0
- data/lib/tina4/context/chunker.rb +6 -0
- data/lib/tina4/context.rb +13 -5
- data/lib/tina4/cors.rb +66 -10
- data/lib/tina4/crud.rb +78 -9
- data/lib/tina4/database/sqlite3_adapter.rb +9 -1
- data/lib/tina4/database.rb +260 -44
- data/lib/tina4/database_adapter.rb +6 -0
- data/lib/tina4/database_result.rb +6 -0
- data/lib/tina4/database_url.rb +6 -0
- data/lib/tina4/debug.rb +6 -0
- data/lib/tina4/dev.rb +6 -0
- data/lib/tina4/dev_admin.rb +215 -89
- data/lib/tina4/dev_mailbox.rb +6 -1
- data/lib/tina4/dispatch_pipeline.rb +86 -1
- data/lib/tina4/docs.rb +6 -0
- data/lib/tina4/docstore.rb +23 -4
- data/lib/tina4/drivers/arango_graph_driver.rb +8 -1
- data/lib/tina4/drivers/bolt_graph_driver.rb +6 -0
- data/lib/tina4/drivers/firebird_driver.rb +95 -6
- data/lib/tina4/drivers/mongodb_driver.rb +6 -0
- data/lib/tina4/drivers/mssql_driver.rb +54 -39
- data/lib/tina4/drivers/mysql_driver.rb +6 -0
- data/lib/tina4/drivers/odbc_driver.rb +6 -0
- data/lib/tina4/drivers/postgres_driver.rb +35 -8
- data/lib/tina4/drivers/schema_split.rb +6 -0
- data/lib/tina4/drivers/sqlite_driver.rb +8 -1
- data/lib/tina4/drivers/ultipa_graph_driver.rb +6 -0
- data/lib/tina4/env.rb +6 -0
- data/lib/tina4/error_overlay.rb +6 -0
- data/lib/tina4/events.rb +6 -0
- data/lib/tina4/feedback.rb +9 -2
- data/lib/tina4/field_types.rb +6 -0
- data/lib/tina4/form_parser.rb +241 -0
- data/lib/tina4/frond.rb +196 -56
- data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +6 -0
- data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +6 -0
- data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +6 -0
- data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +6 -0
- data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +6 -0
- data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +6 -0
- data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +6 -0
- data/lib/tina4/graph.rb +6 -0
- data/lib/tina4/graphql.rb +28 -27
- data/lib/tina4/health.rb +6 -0
- data/lib/tina4/html_element.rb +6 -0
- data/lib/tina4/http_server.rb +690 -0
- data/lib/tina4/imap_client.rb +352 -0
- data/lib/tina4/import_helper.rb +6 -0
- data/lib/tina4/job.rb +6 -0
- data/lib/tina4/localization.rb +6 -0
- data/lib/tina4/log.rb +6 -0
- data/lib/tina4/mail_socket.rb +124 -0
- data/lib/tina4/mcp.rb +15 -8
- data/lib/tina4/messenger.rb +86 -55
- data/lib/tina4/metrics.rb +6 -0
- data/lib/tina4/middleware.rb +24 -9
- data/lib/tina4/migration.rb +6 -0
- data/lib/tina4/model_collection.rb +6 -0
- data/lib/tina4/mqtt.rb +9 -2
- data/lib/tina4/mqtt_message.rb +6 -0
- data/lib/tina4/orm.rb +72 -16
- data/lib/tina4/parse_json.rb +25 -0
- data/lib/tina4/plan.rb +9 -2
- data/lib/tina4/point.rb +6 -0
- data/lib/tina4/port_takeover.rb +6 -0
- data/lib/tina4/project_index.rb +6 -0
- data/lib/tina4/public/THIRD-PARTY-NOTICES.md +55 -0
- data/lib/tina4/public/bundled-component-provenance.json +48 -0
- data/lib/tina4/public/js/tina4js.min.js +13 -3
- data/lib/tina4/public/licenses/swagger-ui-3.10.0-original-notice.txt +11 -0
- data/lib/tina4/public/licenses/swagger-ui-5.17.14-NOTICE.txt +2 -0
- data/lib/tina4/public/licenses/swagger-ui-Apache-2.0.txt +202 -0
- data/lib/tina4/public/licenses/tina4-css-MIT.txt +21 -0
- data/lib/tina4/public/licenses/tina4-dev-admin-components.json +240 -0
- data/lib/tina4/public/licenses/tina4-dev-admin-third-party.txt +919 -0
- data/lib/tina4/public/licenses/tina4-js-MIT.txt +20 -0
- data/lib/tina4/push.rb +9 -3
- data/lib/tina4/query_builder.rb +6 -0
- data/lib/tina4/queue.rb +6 -0
- data/lib/tina4/queue_backends/kafka_backend.rb +10 -2
- data/lib/tina4/queue_backends/lite_backend.rb +6 -0
- data/lib/tina4/queue_backends/mongo_backend.rb +6 -0
- data/lib/tina4/queue_backends/rabbitmq_backend.rb +10 -2
- data/lib/tina4/rack_app.rb +11 -1
- data/lib/tina4/rate_limiter.rb +6 -0
- data/lib/tina4/realtime/attachment.rb +6 -0
- data/lib/tina4/realtime/channel.rb +6 -0
- data/lib/tina4/realtime/channel_member.rb +6 -0
- data/lib/tina4/realtime/local_storage.rb +6 -0
- data/lib/tina4/realtime/message.rb +6 -0
- data/lib/tina4/realtime/s3_storage.rb +6 -0
- data/lib/tina4/realtime/storage.rb +6 -0
- data/lib/tina4/realtime/storage_backend.rb +6 -0
- data/lib/tina4/realtime/workspace.rb +6 -0
- data/lib/tina4/realtime.rb +10 -3
- data/lib/tina4/request.rb +85 -127
- data/lib/tina4/response.rb +71 -9
- data/lib/tina4/response_cache.rb +6 -0
- data/lib/tina4/router.rb +6 -0
- data/lib/tina4/secret_file.rb +34 -0
- data/lib/tina4/seeder.rb +9 -3
- data/lib/tina4/service.rb +8 -2
- data/lib/tina4/service_runner.rb +6 -0
- data/lib/tina4/session.rb +6 -0
- data/lib/tina4/session_handlers/database_handler.rb +6 -0
- data/lib/tina4/session_handlers/file_handler.rb +6 -0
- data/lib/tina4/session_handlers/memcached_handler.rb +6 -0
- data/lib/tina4/session_handlers/mongo_handler.rb +108 -39
- data/lib/tina4/session_handlers/mongo_wire_client.rb +23 -9
- data/lib/tina4/session_handlers/redis_handler.rb +6 -0
- data/lib/tina4/session_handlers/resp_client.rb +6 -0
- data/lib/tina4/session_handlers/valkey_handler.rb +6 -0
- data/lib/tina4/shutdown.rb +17 -14
- data/lib/tina4/smtp_client.rb +222 -0
- data/lib/tina4/sql_translator.rb +93 -0
- data/lib/tina4/sqlite3_gem.rb +27 -0
- data/lib/tina4/sso.rb +25 -8
- data/lib/tina4/swagger.rb +6 -0
- data/lib/tina4/template.rb +6 -0
- data/lib/tina4/test.rb +8 -2
- data/lib/tina4/test_client.rb +8 -1
- data/lib/tina4/testing.rb +8 -1
- data/lib/tina4/validator.rb +6 -0
- data/lib/tina4/version.rb +7 -1
- data/lib/tina4/webserver.rb +25 -143
- data/lib/tina4/websocket.rb +10 -4
- data/lib/tina4/websocket_backplane.rb +19 -2
- data/lib/tina4/wsdl.rb +31 -16
- data/lib/tina4/xml_parser.rb +259 -0
- data/lib/tina4.rb +87 -31
- data/lib/tina4ruby.rb +6 -0
- 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
|
|
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
|
|
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
|
|
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
|
-
|
|
204
|
-
return if @warned[key]
|
|
257
|
+
return if @warned[reason]
|
|
205
258
|
|
|
206
|
-
@warned[
|
|
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:
|
|
216
|
-
"
|
|
217
|
-
"
|
|
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"]
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
284
|
-
query
|
|
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 (#{
|
|
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 (#{
|
|
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 (#{
|
|
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
|
-
|
|
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
|