tina4ruby 3.10.50 → 3.10.55
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/lib/tina4/auto_crud.rb +4 -2
- data/lib/tina4/cli.rb +9 -2
- data/lib/tina4/cors.rb +1 -1
- data/lib/tina4/database.rb +8 -1
- data/lib/tina4/database_result.rb +6 -1
- data/lib/tina4/drivers/mongodb_driver.rb +561 -0
- data/lib/tina4/drivers/odbc_driver.rb +191 -0
- data/lib/tina4/orm.rb +45 -1
- data/lib/tina4/rack_app.rb +23 -3
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/webserver.rb +108 -0
- data/lib/tina4.rb +2 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 25b4d9017318ffe796da35f53d7865bd5ce5cad601a25d6dbffa512c649cd019
|
|
4
|
+
data.tar.gz: 6ee84f47f431b714ff0b4ac84ff606c3406ca5e2896d17a0835b98f23ac9385f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: eb0387a9321fb64193433c5d975bce3f7cfb2d25af20dcf9786cf361f6471cfecbbecad9a026e60f95f34446aa1fef65d77d93d19acb14ca7b1b6e615924cbb4
|
|
7
|
+
data.tar.gz: 73a21f6cd92dda7b2a747d824b68685e827d87b4e42ae92fb2313e69af5fef4e3704e7bca70674045ed5f149926ff4a9fc2ae8a1de2ea2a6bd41e70d4de14aa6
|
data/lib/tina4/auto_crud.rb
CHANGED
|
@@ -55,8 +55,10 @@ module Tina4
|
|
|
55
55
|
# GET /api/{table} -- list all with pagination, filtering, sorting
|
|
56
56
|
Tina4::Router.add_route("GET", "#{prefix}/#{table}", proc { |req, res|
|
|
57
57
|
begin
|
|
58
|
-
|
|
59
|
-
|
|
58
|
+
per_page = (req.query["per_page"] || req.query["limit"] || 10).to_i
|
|
59
|
+
page = (req.query["page"] || 1).to_i
|
|
60
|
+
limit = per_page
|
|
61
|
+
offset = req.query["offset"] ? req.query["offset"].to_i : (page - 1) * per_page
|
|
60
62
|
order_by = parse_sort(req.query["sort"])
|
|
61
63
|
|
|
62
64
|
# Filter support: ?filter[field]=value
|
data/lib/tina4/cli.rb
CHANGED
|
@@ -155,7 +155,7 @@ module Tina4
|
|
|
155
155
|
# ── start ─────────────────────────────────────────────────────────────
|
|
156
156
|
|
|
157
157
|
def cmd_start(argv)
|
|
158
|
-
options = { port: nil, host: nil, dev: false, no_browser: false, production: false }
|
|
158
|
+
options = { port: nil, host: nil, dev: false, no_browser: false, no_reload: false, production: false }
|
|
159
159
|
parser = OptionParser.new do |opts|
|
|
160
160
|
opts.banner = "Usage: tina4ruby start [options]"
|
|
161
161
|
opts.on("-p", "--port PORT", Integer, "Port (default: 7147)") { |v| options[:port] = v }
|
|
@@ -163,6 +163,7 @@ module Tina4
|
|
|
163
163
|
opts.on("-d", "--dev", "Enable dev mode with auto-reload") { options[:dev] = true }
|
|
164
164
|
opts.on("--production", "Use production server (Puma)") { options[:production] = true }
|
|
165
165
|
opts.on("--no-browser", "Do not open browser on start") { options[:no_browser] = true }
|
|
166
|
+
opts.on("--no-reload", "Disable file watcher / live-reload") { options[:no_reload] = true }
|
|
166
167
|
end
|
|
167
168
|
parser.parse!(argv)
|
|
168
169
|
|
|
@@ -172,6 +173,11 @@ module Tina4
|
|
|
172
173
|
options[:no_browser] = true
|
|
173
174
|
end
|
|
174
175
|
|
|
176
|
+
# --no-reload flag sets TINA4_NO_RELOAD so the existing env check picks it up
|
|
177
|
+
if options[:no_reload]
|
|
178
|
+
ENV["TINA4_NO_RELOAD"] = "true"
|
|
179
|
+
end
|
|
180
|
+
|
|
175
181
|
# Priority: CLI flag > ENV var > default
|
|
176
182
|
options[:port] = resolve_config(:port, options[:port])
|
|
177
183
|
options[:host] = resolve_config(:host, options[:host])
|
|
@@ -191,7 +197,8 @@ module Tina4
|
|
|
191
197
|
load_routes(root_dir)
|
|
192
198
|
|
|
193
199
|
if options[:dev]
|
|
194
|
-
|
|
200
|
+
no_reload = %w[true 1 yes].include?(ENV.fetch("TINA4_NO_RELOAD", "").downcase)
|
|
201
|
+
Tina4::DevReload.start(root_dir: root_dir) unless no_reload
|
|
195
202
|
Tina4::ScssCompiler.compile_all(root_dir)
|
|
196
203
|
end
|
|
197
204
|
|
data/lib/tina4/cors.rb
CHANGED
data/lib/tina4/database.rb
CHANGED
|
@@ -76,7 +76,10 @@ module Tina4
|
|
|
76
76
|
"mysql" => "Tina4::Drivers::MysqlDriver",
|
|
77
77
|
"mssql" => "Tina4::Drivers::MssqlDriver",
|
|
78
78
|
"sqlserver" => "Tina4::Drivers::MssqlDriver",
|
|
79
|
-
"firebird" => "Tina4::Drivers::FirebirdDriver"
|
|
79
|
+
"firebird" => "Tina4::Drivers::FirebirdDriver",
|
|
80
|
+
"mongodb" => "Tina4::Drivers::MongodbDriver",
|
|
81
|
+
"mongo" => "Tina4::Drivers::MongodbDriver",
|
|
82
|
+
"odbc" => "Tina4::Drivers::OdbcDriver"
|
|
80
83
|
}.freeze
|
|
81
84
|
|
|
82
85
|
def initialize(connection_string = nil, username: nil, password: nil, driver_name: nil, pool: 0)
|
|
@@ -491,6 +494,10 @@ module Tina4
|
|
|
491
494
|
"mssql"
|
|
492
495
|
when /firebird/, /\.fdb$/
|
|
493
496
|
"firebird"
|
|
497
|
+
when /mongodb/, /^mongo:/
|
|
498
|
+
"mongodb"
|
|
499
|
+
when /^odbc:/
|
|
500
|
+
"odbc"
|
|
494
501
|
else
|
|
495
502
|
"sqlite"
|
|
496
503
|
end
|
|
@@ -86,10 +86,15 @@ module Tina4
|
|
|
86
86
|
slice_offset = (page - 1) * per_page
|
|
87
87
|
page_records = @records[slice_offset, per_page] || []
|
|
88
88
|
{
|
|
89
|
+
records: page_records,
|
|
89
90
|
data: page_records,
|
|
91
|
+
count: total,
|
|
92
|
+
total: total,
|
|
93
|
+
limit: per_page,
|
|
94
|
+
offset: (page - 1) * per_page,
|
|
90
95
|
page: page,
|
|
91
96
|
per_page: per_page,
|
|
92
|
-
|
|
97
|
+
totalPages: total_pages,
|
|
93
98
|
total_pages: total_pages,
|
|
94
99
|
has_next: page < total_pages,
|
|
95
100
|
has_prev: page > 1
|
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tina4
|
|
4
|
+
module Drivers
|
|
5
|
+
class MongodbDriver
|
|
6
|
+
attr_reader :connection, :db
|
|
7
|
+
|
|
8
|
+
def connect(connection_string, username: nil, password: nil)
|
|
9
|
+
begin
|
|
10
|
+
require "mongo"
|
|
11
|
+
rescue LoadError
|
|
12
|
+
raise LoadError,
|
|
13
|
+
"The 'mongo' gem is required for MongoDB connections. " \
|
|
14
|
+
"Install: gem install mongo"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
uri = build_uri(connection_string, username, password)
|
|
18
|
+
@db_name = extract_db_name(connection_string)
|
|
19
|
+
@client = Mongo::Client.new(uri)
|
|
20
|
+
@db = @client.use(@db_name)
|
|
21
|
+
@connection = @db
|
|
22
|
+
@last_insert_id = nil
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def close
|
|
26
|
+
@client&.close
|
|
27
|
+
@client = nil
|
|
28
|
+
@db = nil
|
|
29
|
+
@connection = nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Execute a query (SELECT-like) and return array of symbol-keyed hashes
|
|
33
|
+
def execute_query(sql, params = [])
|
|
34
|
+
parsed = parse_sql(sql, params)
|
|
35
|
+
collection = @db[parsed[:collection]]
|
|
36
|
+
|
|
37
|
+
case parsed[:operation]
|
|
38
|
+
when :find
|
|
39
|
+
cursor = collection.find(parsed[:filter] || {})
|
|
40
|
+
cursor = cursor.projection(parsed[:projection]) if parsed[:projection] && !parsed[:projection].empty?
|
|
41
|
+
cursor = cursor.sort(parsed[:sort]) if parsed[:sort] && !parsed[:sort].empty?
|
|
42
|
+
cursor = cursor.skip(parsed[:skip]) if parsed[:skip] && parsed[:skip] > 0
|
|
43
|
+
cursor = cursor.limit(parsed[:limit]) if parsed[:limit] && parsed[:limit] > 0
|
|
44
|
+
cursor.map { |doc| mongo_doc_to_hash(doc) }
|
|
45
|
+
else
|
|
46
|
+
[]
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Execute a DML statement (INSERT, UPDATE, DELETE, CREATE)
|
|
51
|
+
def execute(sql, params = [])
|
|
52
|
+
parsed = parse_sql(sql, params)
|
|
53
|
+
collection = @db[parsed[:collection]]
|
|
54
|
+
|
|
55
|
+
case parsed[:operation]
|
|
56
|
+
when :insert
|
|
57
|
+
result = collection.insert_one(parsed[:document])
|
|
58
|
+
@last_insert_id = result.inserted_id.to_s
|
|
59
|
+
result
|
|
60
|
+
when :update
|
|
61
|
+
collection.update_many(parsed[:filter] || {}, { "$set" => parsed[:updates] })
|
|
62
|
+
when :delete
|
|
63
|
+
collection.delete_many(parsed[:filter] || {})
|
|
64
|
+
when :create_collection
|
|
65
|
+
begin
|
|
66
|
+
@db.command(create: parsed[:collection].to_s)
|
|
67
|
+
rescue Mongo::Error::OperationFailure
|
|
68
|
+
# Collection already exists — ignore
|
|
69
|
+
end
|
|
70
|
+
nil
|
|
71
|
+
when :find
|
|
72
|
+
execute_query(sql, params)
|
|
73
|
+
else
|
|
74
|
+
nil
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def last_insert_id
|
|
79
|
+
@last_insert_id
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def placeholder
|
|
83
|
+
"?"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def placeholders(count)
|
|
87
|
+
(["?"] * count).join(", ")
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# MongoDB has no LIMIT clause — ignore; already handled in execute_query
|
|
91
|
+
def apply_limit(sql, limit, offset = 0)
|
|
92
|
+
sql_up = sql.upcase
|
|
93
|
+
return sql if sql_up.include?("LIMIT")
|
|
94
|
+
modified = sql.dup
|
|
95
|
+
modified += " LIMIT #{limit}" if limit && limit > 0
|
|
96
|
+
modified += " OFFSET #{offset}" if offset && offset > 0
|
|
97
|
+
modified
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# MongoDB transactions require a replica set — wrap in session if available
|
|
101
|
+
def begin_transaction
|
|
102
|
+
# no-op for standalone; transaction support via session handled externally
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def commit
|
|
106
|
+
# no-op
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def rollback
|
|
110
|
+
# no-op
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def tables
|
|
114
|
+
@db.collection_names.reject { |n| n.start_with?("system.") }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def columns(table_name)
|
|
118
|
+
collection = @db[table_name.to_s]
|
|
119
|
+
sample = collection.find.limit(1).first
|
|
120
|
+
return [] unless sample
|
|
121
|
+
|
|
122
|
+
sample.keys.map do |key|
|
|
123
|
+
{
|
|
124
|
+
name: key,
|
|
125
|
+
type: sample[key].class.name,
|
|
126
|
+
nullable: true,
|
|
127
|
+
default: nil,
|
|
128
|
+
primary_key: key == "_id"
|
|
129
|
+
}
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
private
|
|
134
|
+
|
|
135
|
+
def build_uri(connection_string, username, password)
|
|
136
|
+
uri = connection_string.to_s
|
|
137
|
+
# Normalise scheme: mongodb:// stays, mongo:// becomes mongodb://
|
|
138
|
+
uri = uri.sub(/^mongo:\/\//, "mongodb://")
|
|
139
|
+
|
|
140
|
+
if username || password
|
|
141
|
+
# Inject credentials into the URI if not already present
|
|
142
|
+
if uri =~ /^mongodb:\/\/([^@]+@)/
|
|
143
|
+
# credentials already in URI — leave as-is
|
|
144
|
+
else
|
|
145
|
+
host_part = uri.sub(/^mongodb:\/\//, "")
|
|
146
|
+
creds = [username, password ? ":#{password}" : nil].compact.join
|
|
147
|
+
uri = "mongodb://#{creds}@#{host_part}"
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
uri
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def extract_db_name(connection_string)
|
|
154
|
+
# mongodb://host:port/dbname -> dbname
|
|
155
|
+
# Strip query string first
|
|
156
|
+
path = connection_string.to_s.split("?").first
|
|
157
|
+
db = path.split("/").last
|
|
158
|
+
db && !db.empty? ? db : "tina4"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# ── SQL-to-MongoDB translator ──────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
def parse_sql(sql, params = [])
|
|
164
|
+
sql_stripped = sql.strip
|
|
165
|
+
upper = sql_stripped.upcase
|
|
166
|
+
|
|
167
|
+
# Bind positional ? params
|
|
168
|
+
bound_sql = bind_params(sql_stripped, params)
|
|
169
|
+
|
|
170
|
+
if upper.start_with?("SELECT")
|
|
171
|
+
parse_select(bound_sql)
|
|
172
|
+
elsif upper.start_with?("INSERT INTO")
|
|
173
|
+
parse_insert(bound_sql)
|
|
174
|
+
elsif upper.start_with?("UPDATE")
|
|
175
|
+
parse_update(bound_sql)
|
|
176
|
+
elsif upper.start_with?("DELETE FROM")
|
|
177
|
+
parse_delete(bound_sql)
|
|
178
|
+
elsif upper.start_with?("CREATE TABLE") || upper.start_with?("CREATE COLLECTION")
|
|
179
|
+
parse_create(bound_sql)
|
|
180
|
+
else
|
|
181
|
+
{ operation: :unknown, collection: nil }
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def bind_params(sql, params)
|
|
186
|
+
return sql if params.nil? || params.empty?
|
|
187
|
+
|
|
188
|
+
idx = -1
|
|
189
|
+
sql.gsub("?") do
|
|
190
|
+
idx += 1
|
|
191
|
+
v = params[idx]
|
|
192
|
+
v.is_a?(String) ? "'#{v.gsub("'", "\\\\'")}'" : v.to_s
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# ── SELECT parsing ─────────────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
def parse_select(sql)
|
|
199
|
+
result = { operation: :find }
|
|
200
|
+
|
|
201
|
+
# Extract table name (FROM clause)
|
|
202
|
+
if (m = sql.match(/\bFROM\s+(\w+)/i))
|
|
203
|
+
result[:collection] = m[1].to_sym
|
|
204
|
+
else
|
|
205
|
+
result[:collection] = :unknown
|
|
206
|
+
return result
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Projection (columns)
|
|
210
|
+
result[:projection] = parse_projection(sql)
|
|
211
|
+
|
|
212
|
+
# WHERE clause
|
|
213
|
+
where_clause = extract_clause(sql, "WHERE", %w[ORDER GROUP LIMIT OFFSET HAVING])
|
|
214
|
+
result[:filter] = where_clause ? parse_where(where_clause) : {}
|
|
215
|
+
|
|
216
|
+
# ORDER BY
|
|
217
|
+
result[:sort] = parse_order_by(sql)
|
|
218
|
+
|
|
219
|
+
# LIMIT / OFFSET
|
|
220
|
+
result[:limit] = extract_limit(sql)
|
|
221
|
+
result[:skip] = extract_offset(sql)
|
|
222
|
+
|
|
223
|
+
result
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def parse_projection(sql)
|
|
227
|
+
m = sql.match(/^SELECT\s+(.*?)\s+FROM\b/im)
|
|
228
|
+
return {} unless m
|
|
229
|
+
|
|
230
|
+
cols = m[1].strip
|
|
231
|
+
return {} if cols == "*"
|
|
232
|
+
|
|
233
|
+
proj = {}
|
|
234
|
+
cols.split(",").each do |col|
|
|
235
|
+
col = col.strip
|
|
236
|
+
# Handle AS aliases — use the alias as field name
|
|
237
|
+
field = col.split(/\s+AS\s+/i).first.strip
|
|
238
|
+
proj[field] = 1
|
|
239
|
+
end
|
|
240
|
+
proj
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def parse_order_by(sql)
|
|
244
|
+
m = sql.match(/\bORDER\s+BY\s+(.*?)(?:\s+LIMIT|\s+OFFSET|\s*$)/im)
|
|
245
|
+
return {} unless m
|
|
246
|
+
|
|
247
|
+
sort = {}
|
|
248
|
+
m[1].split(",").each do |part|
|
|
249
|
+
part = part.strip
|
|
250
|
+
if (pm = part.match(/^(\w+)\s+(ASC|DESC)$/i))
|
|
251
|
+
sort[pm[1]] = pm[2].upcase == "DESC" ? -1 : 1
|
|
252
|
+
else
|
|
253
|
+
sort[part] = 1
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
sort
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def extract_limit(sql)
|
|
260
|
+
m = sql.match(/\bLIMIT\s+(\d+)/i)
|
|
261
|
+
m ? m[1].to_i : nil
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def extract_offset(sql)
|
|
265
|
+
m = sql.match(/\bOFFSET\s+(\d+)/i)
|
|
266
|
+
m ? m[1].to_i : nil
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# ── WHERE clause parser → Mongo filter hash ───────────────────────
|
|
270
|
+
|
|
271
|
+
def parse_where(clause)
|
|
272
|
+
clause = clause.strip
|
|
273
|
+
return {} if clause.empty?
|
|
274
|
+
|
|
275
|
+
# Handle OR at top level
|
|
276
|
+
or_parts = split_top_level(clause, /\bOR\b/i)
|
|
277
|
+
if or_parts.length > 1
|
|
278
|
+
return { "$or" => or_parts.map { |p| parse_where(p) } }
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# Handle AND at top level
|
|
282
|
+
and_parts = split_top_level(clause, /\bAND\b/i)
|
|
283
|
+
if and_parts.length > 1
|
|
284
|
+
conditions = and_parts.map { |p| parse_where(p) }
|
|
285
|
+
merged = {}
|
|
286
|
+
conditions.each { |c| merged.merge!(c) }
|
|
287
|
+
return merged
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
parse_condition(clause)
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# Split a string on a regex delimiter only at top level (not inside parens)
|
|
294
|
+
def split_top_level(str, delimiter_re)
|
|
295
|
+
parts = []
|
|
296
|
+
depth = 0
|
|
297
|
+
current = ""
|
|
298
|
+
tokens = str.split(/(\(|\)|\s+)/m)
|
|
299
|
+
|
|
300
|
+
# Rebuild token stream and split on delimiter
|
|
301
|
+
rebuilt = str
|
|
302
|
+
# Simple approach: scan character by character
|
|
303
|
+
parts = []
|
|
304
|
+
current = ""
|
|
305
|
+
i = 0
|
|
306
|
+
while i < str.length
|
|
307
|
+
ch = str[i]
|
|
308
|
+
if ch == "("
|
|
309
|
+
depth += 1
|
|
310
|
+
current += ch
|
|
311
|
+
elsif ch == ")"
|
|
312
|
+
depth -= 1
|
|
313
|
+
current += ch
|
|
314
|
+
elsif depth == 0
|
|
315
|
+
# Check for delimiter match at this position
|
|
316
|
+
remaining = str[i..]
|
|
317
|
+
m = remaining.match(/\A\s*#{delimiter_re.source}\s*/i)
|
|
318
|
+
if m
|
|
319
|
+
parts << current.strip
|
|
320
|
+
current = ""
|
|
321
|
+
i += m[0].length
|
|
322
|
+
next
|
|
323
|
+
else
|
|
324
|
+
current += ch
|
|
325
|
+
end
|
|
326
|
+
else
|
|
327
|
+
current += ch
|
|
328
|
+
end
|
|
329
|
+
i += 1
|
|
330
|
+
end
|
|
331
|
+
parts << current.strip unless current.strip.empty?
|
|
332
|
+
parts.length > 1 ? parts : [str]
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def parse_condition(clause)
|
|
336
|
+
clause = clause.strip.gsub(/^\(+/, "").gsub(/\)+$/, "").strip
|
|
337
|
+
|
|
338
|
+
# IS NULL / IS NOT NULL
|
|
339
|
+
if (m = clause.match(/^(\w+)\s+IS\s+NOT\s+NULL$/i))
|
|
340
|
+
return { m[1] => { "$ne" => nil } }
|
|
341
|
+
end
|
|
342
|
+
if (m = clause.match(/^(\w+)\s+IS\s+NULL$/i))
|
|
343
|
+
return { m[1] => nil }
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
# IN (...)
|
|
347
|
+
if (m = clause.match(/^(\w+)\s+IN\s*\((.+)\)$/i))
|
|
348
|
+
values = m[2].split(",").map { |v| parse_value(v.strip) }
|
|
349
|
+
return { m[1] => { "$in" => values } }
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
# NOT IN (...)
|
|
353
|
+
if (m = clause.match(/^(\w+)\s+NOT\s+IN\s*\((.+)\)$/i))
|
|
354
|
+
values = m[2].split(",").map { |v| parse_value(v.strip) }
|
|
355
|
+
return { m[1] => { "$nin" => values } }
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
# LIKE → $regex
|
|
359
|
+
if (m = clause.match(/^(\w+)\s+LIKE\s+'(.+)'$/i))
|
|
360
|
+
pattern = m[2].gsub("%", ".*").gsub("_", ".")
|
|
361
|
+
return { m[1] => { "$regex" => pattern, "$options" => "i" } }
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
# NOT LIKE → $not $regex
|
|
365
|
+
if (m = clause.match(/^(\w+)\s+NOT\s+LIKE\s+'(.+)'$/i))
|
|
366
|
+
pattern = m[2].gsub("%", ".*").gsub("_", ".")
|
|
367
|
+
return { m[1] => { "$not" => /#{pattern}/i } }
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
# Comparison operators: !=, <>, >=, <=, >, <, =
|
|
371
|
+
ops = [["!=", "$ne"], ["<>", "$ne"], [">=", "$gte"], ["<=", "$lte"],
|
|
372
|
+
[">", "$gt"], ["<", "$lt"], ["=", "$eq"]]
|
|
373
|
+
ops.each do |op, mongo_op|
|
|
374
|
+
if (m = clause.match(/^(\w+)\s*#{Regexp.escape(op)}\s*(.+)$/i))
|
|
375
|
+
field = m[1]
|
|
376
|
+
value = parse_value(m[2].strip)
|
|
377
|
+
if mongo_op == "$eq"
|
|
378
|
+
return { field => value }
|
|
379
|
+
else
|
|
380
|
+
return { field => { mongo_op => value } }
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
# Fallback — return as a raw string comment (best-effort)
|
|
386
|
+
{}
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
def parse_value(str)
|
|
390
|
+
str = str.strip
|
|
391
|
+
if str.start_with?("'") && str.end_with?("'")
|
|
392
|
+
str[1..-2]
|
|
393
|
+
elsif str =~ /\A-?\d+\z/
|
|
394
|
+
str.to_i
|
|
395
|
+
elsif str =~ /\A-?\d+\.\d+\z/
|
|
396
|
+
str.to_f
|
|
397
|
+
elsif str.upcase == "TRUE"
|
|
398
|
+
true
|
|
399
|
+
elsif str.upcase == "FALSE"
|
|
400
|
+
false
|
|
401
|
+
elsif str.upcase == "NULL"
|
|
402
|
+
nil
|
|
403
|
+
else
|
|
404
|
+
str
|
|
405
|
+
end
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
# ── INSERT parsing ─────────────────────────────────────────────────
|
|
409
|
+
|
|
410
|
+
def parse_insert(sql)
|
|
411
|
+
result = { operation: :insert }
|
|
412
|
+
|
|
413
|
+
m = sql.match(/INSERT\s+INTO\s+(\w+)\s*\(([^)]+)\)\s*VALUES\s*\(([^)]+)\)/im)
|
|
414
|
+
unless m
|
|
415
|
+
result[:collection] = :unknown
|
|
416
|
+
result[:document] = {}
|
|
417
|
+
return result
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
result[:collection] = m[1].to_sym
|
|
421
|
+
cols = m[2].split(",").map(&:strip)
|
|
422
|
+
vals = parse_value_list(m[3])
|
|
423
|
+
|
|
424
|
+
result[:document] = cols.each_with_object({}).with_index do |(col, doc), i|
|
|
425
|
+
doc[col] = vals[i]
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
result
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
def parse_value_list(str)
|
|
432
|
+
# Split on commas not inside quotes
|
|
433
|
+
vals = []
|
|
434
|
+
current = ""
|
|
435
|
+
in_quote = false
|
|
436
|
+
str.each_char do |ch|
|
|
437
|
+
if ch == "'" && !in_quote
|
|
438
|
+
in_quote = true
|
|
439
|
+
current += ch
|
|
440
|
+
elsif ch == "'" && in_quote
|
|
441
|
+
in_quote = false
|
|
442
|
+
current += ch
|
|
443
|
+
elsif ch == "," && !in_quote
|
|
444
|
+
vals << parse_value(current.strip)
|
|
445
|
+
current = ""
|
|
446
|
+
else
|
|
447
|
+
current += ch
|
|
448
|
+
end
|
|
449
|
+
end
|
|
450
|
+
vals << parse_value(current.strip) unless current.strip.empty?
|
|
451
|
+
vals
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
# ── UPDATE parsing ─────────────────────────────────────────────────
|
|
455
|
+
|
|
456
|
+
def parse_update(sql)
|
|
457
|
+
result = { operation: :update }
|
|
458
|
+
|
|
459
|
+
m = sql.match(/UPDATE\s+(\w+)\s+SET\s+(.+?)(?:\s+WHERE\s+(.+))?$/im)
|
|
460
|
+
unless m
|
|
461
|
+
result[:collection] = :unknown
|
|
462
|
+
result[:updates] = {}
|
|
463
|
+
result[:filter] = {}
|
|
464
|
+
return result
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
result[:collection] = m[1].to_sym
|
|
468
|
+
|
|
469
|
+
# Parse SET assignments
|
|
470
|
+
updates = {}
|
|
471
|
+
set_clause = m[2].strip
|
|
472
|
+
# Split on comma, skip commas inside quotes
|
|
473
|
+
assignments = split_assignments(set_clause)
|
|
474
|
+
assignments.each do |assign|
|
|
475
|
+
parts = assign.split("=", 2)
|
|
476
|
+
next unless parts.length == 2
|
|
477
|
+
|
|
478
|
+
key = parts[0].strip
|
|
479
|
+
val = parse_value(parts[1].strip)
|
|
480
|
+
updates[key] = val
|
|
481
|
+
end
|
|
482
|
+
result[:updates] = updates
|
|
483
|
+
|
|
484
|
+
# Parse WHERE
|
|
485
|
+
where_str = m[3]&.strip
|
|
486
|
+
result[:filter] = where_str && !where_str.empty? ? parse_where(where_str) : {}
|
|
487
|
+
|
|
488
|
+
result
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
def split_assignments(set_clause)
|
|
492
|
+
parts = []
|
|
493
|
+
current = ""
|
|
494
|
+
in_quote = false
|
|
495
|
+
set_clause.each_char do |ch|
|
|
496
|
+
if ch == "'" && !in_quote
|
|
497
|
+
in_quote = true
|
|
498
|
+
current += ch
|
|
499
|
+
elsif ch == "'" && in_quote
|
|
500
|
+
in_quote = false
|
|
501
|
+
current += ch
|
|
502
|
+
elsif ch == "," && !in_quote
|
|
503
|
+
parts << current.strip
|
|
504
|
+
current = ""
|
|
505
|
+
else
|
|
506
|
+
current += ch
|
|
507
|
+
end
|
|
508
|
+
end
|
|
509
|
+
parts << current.strip unless current.strip.empty?
|
|
510
|
+
parts
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
# ── DELETE parsing ─────────────────────────────────────────────────
|
|
514
|
+
|
|
515
|
+
def parse_delete(sql)
|
|
516
|
+
result = { operation: :delete }
|
|
517
|
+
|
|
518
|
+
m = sql.match(/DELETE\s+FROM\s+(\w+)(?:\s+WHERE\s+(.+))?$/im)
|
|
519
|
+
unless m
|
|
520
|
+
result[:collection] = :unknown
|
|
521
|
+
result[:filter] = {}
|
|
522
|
+
return result
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
result[:collection] = m[1].to_sym
|
|
526
|
+
where_str = m[2]&.strip
|
|
527
|
+
result[:filter] = where_str && !where_str.empty? ? parse_where(where_str) : {}
|
|
528
|
+
|
|
529
|
+
result
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
# ── CREATE TABLE parsing ───────────────────────────────────────────
|
|
533
|
+
|
|
534
|
+
def parse_create(sql)
|
|
535
|
+
m = sql.match(/CREATE\s+(?:TABLE|COLLECTION)\s+(?:IF\s+NOT\s+EXISTS\s+)?(\w+)/im)
|
|
536
|
+
{
|
|
537
|
+
operation: :create_collection,
|
|
538
|
+
collection: m ? m[1].to_sym : :unknown
|
|
539
|
+
}
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
# ── Extract a named clause from SQL ───────────────────────────────
|
|
543
|
+
|
|
544
|
+
def extract_clause(sql, clause_keyword, stop_keywords = [])
|
|
545
|
+
pattern_parts = stop_keywords.map { |kw| "\\b#{kw}\\b" }.join("|")
|
|
546
|
+
stop_pattern = pattern_parts.empty? ? "$" : "(?:#{pattern_parts}|$)"
|
|
547
|
+
m = sql.match(/\b#{clause_keyword}\s+(.*?)(?=\s*#{stop_pattern})/im)
|
|
548
|
+
m ? m[1].strip : nil
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
# ── Document conversion ────────────────────────────────────────────
|
|
552
|
+
|
|
553
|
+
def mongo_doc_to_hash(doc)
|
|
554
|
+
doc.each_with_object({}) do |(k, v), h|
|
|
555
|
+
key = k.to_s == "_id" ? :_id : k.to_s.to_sym
|
|
556
|
+
h[key] = v.is_a?(BSON::ObjectId) ? v.to_s : v
|
|
557
|
+
end
|
|
558
|
+
end
|
|
559
|
+
end
|
|
560
|
+
end
|
|
561
|
+
end
|
|
@@ -0,0 +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
|
data/lib/tina4/orm.rb
CHANGED
|
@@ -18,7 +18,7 @@ module Tina4
|
|
|
18
18
|
|
|
19
19
|
class << self
|
|
20
20
|
def db
|
|
21
|
-
@db || Tina4.database
|
|
21
|
+
@db || Tina4.database || auto_discover_db
|
|
22
22
|
end
|
|
23
23
|
|
|
24
24
|
# Per-model database binding
|
|
@@ -345,6 +345,13 @@ module Tina4
|
|
|
345
345
|
|
|
346
346
|
private
|
|
347
347
|
|
|
348
|
+
def auto_discover_db
|
|
349
|
+
url = ENV["DATABASE_URL"]
|
|
350
|
+
return nil unless url
|
|
351
|
+
Tina4.database = Tina4::Database.new(url, username: ENV.fetch("DATABASE_USERNAME", ""), password: ENV.fetch("DATABASE_PASSWORD", ""))
|
|
352
|
+
Tina4.database
|
|
353
|
+
end
|
|
354
|
+
|
|
348
355
|
def find_by_id(id)
|
|
349
356
|
pk = primary_key_field || :id
|
|
350
357
|
sql = "SELECT * FROM #{table_name} WHERE #{pk} = ?"
|
|
@@ -642,5 +649,42 @@ module Tina4
|
|
|
642
649
|
|
|
643
650
|
@relationship_cache[name] = klass.find(fk_value)
|
|
644
651
|
end
|
|
652
|
+
|
|
653
|
+
public
|
|
654
|
+
|
|
655
|
+
# ── Imperative relationship methods (ad-hoc, like Python/PHP/Node) ──
|
|
656
|
+
|
|
657
|
+
def query_has_one(related_class, foreign_key: nil)
|
|
658
|
+
pk = self.class.primary_key_field || :id
|
|
659
|
+
pk_value = __send__(pk)
|
|
660
|
+
return nil unless pk_value
|
|
661
|
+
|
|
662
|
+
fk = foreign_key || "#{self.class.name.split('::').last.downcase}_id"
|
|
663
|
+
result = related_class.db.fetch_one(
|
|
664
|
+
"SELECT * FROM #{related_class.table_name} WHERE #{fk} = ?", [pk_value]
|
|
665
|
+
)
|
|
666
|
+
result ? related_class.from_hash(result) : nil
|
|
667
|
+
end
|
|
668
|
+
|
|
669
|
+
def query_has_many(related_class, foreign_key: nil, limit: 100, offset: 0)
|
|
670
|
+
pk = self.class.primary_key_field || :id
|
|
671
|
+
pk_value = __send__(pk)
|
|
672
|
+
return [] unless pk_value
|
|
673
|
+
|
|
674
|
+
fk = foreign_key || "#{self.class.name.split('::').last.downcase}_id"
|
|
675
|
+
results = related_class.db.fetch(
|
|
676
|
+
"SELECT * FROM #{related_class.table_name} WHERE #{fk} = ?",
|
|
677
|
+
[pk_value], limit: limit, offset: offset
|
|
678
|
+
)
|
|
679
|
+
results.map { |row| related_class.from_hash(row) }
|
|
680
|
+
end
|
|
681
|
+
|
|
682
|
+
def query_belongs_to(related_class, foreign_key: nil)
|
|
683
|
+
fk = foreign_key || "#{related_class.name.split('::').last.downcase}_id"
|
|
684
|
+
fk_value = respond_to?(fk.to_sym) ? __send__(fk.to_sym) : nil
|
|
685
|
+
return nil unless fk_value
|
|
686
|
+
|
|
687
|
+
related_class.find(fk_value)
|
|
688
|
+
end
|
|
645
689
|
end
|
|
646
690
|
end
|
data/lib/tina4/rack_app.rb
CHANGED
|
@@ -3,6 +3,19 @@ require "json"
|
|
|
3
3
|
require "securerandom"
|
|
4
4
|
|
|
5
5
|
module Tina4
|
|
6
|
+
# Middleware wrapper that tags requests arriving on the AI dev port.
|
|
7
|
+
# Suppresses live-reload behaviour so AI tools get stable responses.
|
|
8
|
+
class AiPortRackApp
|
|
9
|
+
def initialize(app)
|
|
10
|
+
@app = app
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def call(env)
|
|
14
|
+
env["tina4.ai_port"] = true
|
|
15
|
+
@app.call(env)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
6
19
|
class RackApp
|
|
7
20
|
STATIC_DIRS = %w[public src/public src/assets assets].freeze
|
|
8
21
|
|
|
@@ -43,6 +56,10 @@ module Tina4
|
|
|
43
56
|
|
|
44
57
|
# Dev dashboard routes (handled before anything else)
|
|
45
58
|
if path.start_with?("/__dev")
|
|
59
|
+
# Block live-reload endpoint on the AI port — AI tools must get stable responses
|
|
60
|
+
if path == "/__dev_reload" && env["tina4.ai_port"]
|
|
61
|
+
return [404, { "content-type" => "text/plain" }, ["Not available on AI port"]]
|
|
62
|
+
end
|
|
46
63
|
dev_response = Tina4::DevAdmin.handle_request(env)
|
|
47
64
|
return dev_response if dev_response
|
|
48
65
|
end
|
|
@@ -95,7 +112,7 @@ module Tina4
|
|
|
95
112
|
matched_pattern: matched_pattern || "(no match)",
|
|
96
113
|
}
|
|
97
114
|
joined = body_parts.join
|
|
98
|
-
overlay = inject_dev_overlay(joined, request_info)
|
|
115
|
+
overlay = inject_dev_overlay(joined, request_info, ai_port: env["tina4.ai_port"])
|
|
99
116
|
rack_response = [status, headers, [overlay]]
|
|
100
117
|
end
|
|
101
118
|
end
|
|
@@ -630,7 +647,7 @@ module Tina4
|
|
|
630
647
|
[-1, {}, []]
|
|
631
648
|
end
|
|
632
649
|
|
|
633
|
-
def inject_dev_overlay(body, request_info)
|
|
650
|
+
def inject_dev_overlay(body, request_info, ai_port: false)
|
|
634
651
|
version = Tina4::VERSION
|
|
635
652
|
method = request_info[:method]
|
|
636
653
|
path = request_info[:path]
|
|
@@ -638,9 +655,11 @@ module Tina4
|
|
|
638
655
|
request_id = Tina4::Log.request_id || "-"
|
|
639
656
|
route_count = Tina4::Router.routes.length
|
|
640
657
|
|
|
658
|
+
ai_badge = ai_port ? '<span style="background:#7c3aed;color:#fff;font-size:10px;padding:1px 6px;border-radius:3px;font-weight:bold;">AI PORT</span>' : ""
|
|
659
|
+
|
|
641
660
|
toolbar = <<~HTML.strip
|
|
642
661
|
<div id="tina4-dev-toolbar" style="position:fixed;bottom:0;left:0;right:0;background:#333;color:#fff;font-family:monospace;font-size:12px;padding:6px 16px;z-index:99999;display:flex;align-items:center;gap:16px;">
|
|
643
|
-
<span id="tina4-ver-btn" style="color:#d32f2f;font-weight:bold;cursor:pointer;text-decoration:underline dotted;" onclick="tina4VersionModal()" title="Click to check for updates">Tina4 v#{version}</span>
|
|
662
|
+
#{ai_badge}<span id="tina4-ver-btn" style="color:#d32f2f;font-weight:bold;cursor:pointer;text-decoration:underline dotted;" onclick="tina4VersionModal()" title="Click to check for updates">Tina4 v#{version}</span>
|
|
644
663
|
<div id="tina4-ver-modal" style="display:none;position:fixed;bottom:3rem;left:1rem;background:#1e1e2e;border:1px solid #d32f2f;border-radius:8px;padding:16px 20px;z-index:100000;min-width:320px;box-shadow:0 8px 32px rgba(0,0,0,0.5);font-family:monospace;font-size:13px;color:#cdd6f4;">
|
|
645
664
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
|
|
646
665
|
<strong style="color:#89b4fa;">Version Info</strong>
|
|
@@ -709,6 +728,7 @@ module Tina4
|
|
|
709
728
|
el.style.color='#f38ba8';
|
|
710
729
|
});
|
|
711
730
|
}
|
|
731
|
+
#{ai_port ? "" : "/* tina4:reload-js */"}
|
|
712
732
|
</script>
|
|
713
733
|
HTML
|
|
714
734
|
|
data/lib/tina4/version.rb
CHANGED
data/lib/tina4/webserver.rb
CHANGED
|
@@ -11,6 +11,7 @@ module Tina4
|
|
|
11
11
|
def start
|
|
12
12
|
require "webrick"
|
|
13
13
|
require "stringio"
|
|
14
|
+
require "socket"
|
|
14
15
|
Tina4.print_banner(host: @host, port: @port)
|
|
15
16
|
Tina4::Log.info("Starting Tina4 WEBrick server on http://#{@host}:#{@port}")
|
|
16
17
|
@server = WEBrick::HTTPServer.new(
|
|
@@ -101,10 +102,117 @@ module Tina4
|
|
|
101
102
|
servlet.define_method(:webrick_req_port) { port }
|
|
102
103
|
|
|
103
104
|
@server.mount("/", servlet, rack_app)
|
|
105
|
+
|
|
106
|
+
# AI dev port (port + 1) — no-reload, no-browser
|
|
107
|
+
@ai_server = nil
|
|
108
|
+
@ai_thread = nil
|
|
109
|
+
no_ai_port = %w[true 1 yes].include?(ENV.fetch("TINA4_NO_AI_PORT", "").downcase)
|
|
110
|
+
is_debug = %w[true 1 yes].include?(ENV.fetch("TINA4_DEBUG", "").downcase)
|
|
111
|
+
|
|
112
|
+
if is_debug && !no_ai_port
|
|
113
|
+
ai_port = @port + 1
|
|
114
|
+
begin
|
|
115
|
+
test = TCPServer.new("0.0.0.0", ai_port)
|
|
116
|
+
test.close
|
|
117
|
+
|
|
118
|
+
@ai_server = WEBrick::HTTPServer.new(
|
|
119
|
+
BindAddress: @host,
|
|
120
|
+
Port: ai_port,
|
|
121
|
+
Logger: WEBrick::Log.new(File::NULL),
|
|
122
|
+
AccessLog: []
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
# Wrap the rack app so AI-port requests are tagged
|
|
126
|
+
ai_rack_app = Tina4::AiPortRackApp.new(@app)
|
|
127
|
+
|
|
128
|
+
# Build a servlet identical to the main one but bound to the AI port host/port
|
|
129
|
+
ai_host = @host
|
|
130
|
+
ai_port_str = ai_port.to_s
|
|
131
|
+
ai_servlet = Class.new(WEBrick::HTTPServlet::AbstractServlet) do
|
|
132
|
+
define_method(:initialize) do |server, app|
|
|
133
|
+
super(server)
|
|
134
|
+
@app = app
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
%w[GET POST PUT DELETE PATCH HEAD OPTIONS].each do |http_method|
|
|
138
|
+
define_method("do_#{http_method}") do |webrick_req, webrick_res|
|
|
139
|
+
handle_request(webrick_req, webrick_res)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
define_method(:handle_request) do |webrick_req, webrick_res|
|
|
144
|
+
if Tina4::Shutdown.shutting_down?
|
|
145
|
+
webrick_res.status = 503
|
|
146
|
+
webrick_res.body = '{"error":"Service shutting down"}'
|
|
147
|
+
webrick_res["content-type"] = "application/json"
|
|
148
|
+
return
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
Tina4::Shutdown.track_request do
|
|
152
|
+
env = build_rack_env(webrick_req)
|
|
153
|
+
status, headers, body = @app.call(env)
|
|
154
|
+
|
|
155
|
+
webrick_res.status = status
|
|
156
|
+
headers.each do |key, value|
|
|
157
|
+
if key.downcase == "set-cookie"
|
|
158
|
+
Array(value.split("\n")).each { |c| webrick_res.cookies << WEBrick::Cookie.parse_set_cookie(c) }
|
|
159
|
+
else
|
|
160
|
+
webrick_res[key] = value
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
response_body = ""
|
|
165
|
+
body.each { |chunk| response_body += chunk }
|
|
166
|
+
webrick_res.body = response_body
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
define_method(:build_rack_env) do |req|
|
|
171
|
+
input = StringIO.new(req.body || "")
|
|
172
|
+
env = {
|
|
173
|
+
"REQUEST_METHOD" => req.request_method,
|
|
174
|
+
"PATH_INFO" => req.path,
|
|
175
|
+
"QUERY_STRING" => req.query_string || "",
|
|
176
|
+
"SERVER_NAME" => webrick_req_host,
|
|
177
|
+
"SERVER_PORT" => webrick_req_port,
|
|
178
|
+
"CONTENT_TYPE" => req.content_type || "",
|
|
179
|
+
"CONTENT_LENGTH" => (req.content_length rescue 0).to_s,
|
|
180
|
+
"REMOTE_ADDR" => req.peeraddr&.last || "127.0.0.1",
|
|
181
|
+
"rack.input" => input,
|
|
182
|
+
"rack.errors" => $stderr,
|
|
183
|
+
"rack.url_scheme" => "http",
|
|
184
|
+
"rack.version" => [1, 3],
|
|
185
|
+
"rack.multithread" => true,
|
|
186
|
+
"rack.multiprocess" => false,
|
|
187
|
+
"rack.run_once" => false
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
req.header.each do |key, values|
|
|
191
|
+
env_key = "HTTP_#{key.upcase.gsub('-', '_')}"
|
|
192
|
+
env[env_key] = values.join(", ")
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
env
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
ai_servlet.define_method(:webrick_req_host) { ai_host }
|
|
200
|
+
ai_servlet.define_method(:webrick_req_port) { ai_port_str }
|
|
201
|
+
|
|
202
|
+
@ai_server.mount("/", ai_servlet, ai_rack_app)
|
|
203
|
+
@ai_thread = Thread.new { @ai_server.start }
|
|
204
|
+
puts " AI Port: http://localhost:#{ai_port} (no-reload)"
|
|
205
|
+
rescue Errno::EADDRINUSE
|
|
206
|
+
puts " AI Port: SKIPPED (port #{ai_port} in use)"
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
104
210
|
@server.start
|
|
105
211
|
end
|
|
106
212
|
|
|
107
213
|
def stop
|
|
214
|
+
@ai_server&.shutdown
|
|
215
|
+
@ai_thread&.join(5)
|
|
108
216
|
@server&.shutdown
|
|
109
217
|
end
|
|
110
218
|
end
|
data/lib/tina4.rb
CHANGED
|
@@ -59,6 +59,8 @@ module Tina4
|
|
|
59
59
|
autoload :MysqlDriver, File.expand_path("tina4/drivers/mysql_driver", __dir__)
|
|
60
60
|
autoload :MssqlDriver, File.expand_path("tina4/drivers/mssql_driver", __dir__)
|
|
61
61
|
autoload :FirebirdDriver, File.expand_path("tina4/drivers/firebird_driver", __dir__)
|
|
62
|
+
autoload :MongodbDriver, File.expand_path("tina4/drivers/mongodb_driver", __dir__)
|
|
63
|
+
autoload :OdbcDriver, File.expand_path("tina4/drivers/odbc_driver", __dir__)
|
|
62
64
|
end
|
|
63
65
|
|
|
64
66
|
# ── Lazy-loaded: session handlers ─────────────────────────────────────
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: tina4ruby
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 3.10.
|
|
4
|
+
version: 3.10.55
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Tina4 Team
|
|
@@ -294,8 +294,10 @@ files:
|
|
|
294
294
|
- lib/tina4/dev_mailbox.rb
|
|
295
295
|
- lib/tina4/dev_reload.rb
|
|
296
296
|
- lib/tina4/drivers/firebird_driver.rb
|
|
297
|
+
- lib/tina4/drivers/mongodb_driver.rb
|
|
297
298
|
- lib/tina4/drivers/mssql_driver.rb
|
|
298
299
|
- lib/tina4/drivers/mysql_driver.rb
|
|
300
|
+
- lib/tina4/drivers/odbc_driver.rb
|
|
299
301
|
- lib/tina4/drivers/postgres_driver.rb
|
|
300
302
|
- lib/tina4/drivers/sqlite_driver.rb
|
|
301
303
|
- lib/tina4/env.rb
|