tina4ruby 3.0.0 → 3.9.2
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/README.md +120 -32
- data/lib/tina4/auth.rb +137 -27
- data/lib/tina4/auto_crud.rb +55 -3
- data/lib/tina4/cli.rb +228 -28
- data/lib/tina4/cors.rb +1 -1
- data/lib/tina4/database.rb +230 -26
- data/lib/tina4/database_result.rb +122 -8
- data/lib/tina4/dev_mailbox.rb +1 -1
- data/lib/tina4/env.rb +1 -1
- data/lib/tina4/frond.rb +314 -7
- data/lib/tina4/gallery/queue/meta.json +1 -1
- data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +314 -16
- data/lib/tina4/localization.rb +1 -1
- data/lib/tina4/messenger.rb +111 -33
- data/lib/tina4/middleware.rb +349 -1
- data/lib/tina4/migration.rb +132 -11
- data/lib/tina4/orm.rb +149 -18
- data/lib/tina4/public/js/tina4-dev-admin.min.js +1 -1
- data/lib/tina4/public/js/tina4js.min.js +47 -0
- data/lib/tina4/query_builder.rb +374 -0
- data/lib/tina4/queue.rb +219 -61
- data/lib/tina4/queue_backends/lite_backend.rb +42 -7
- data/lib/tina4/queue_backends/mongo_backend.rb +126 -0
- data/lib/tina4/rack_app.rb +200 -11
- data/lib/tina4/request.rb +14 -1
- data/lib/tina4/response.rb +26 -0
- data/lib/tina4/response_cache.rb +446 -29
- data/lib/tina4/router.rb +127 -0
- data/lib/tina4/service_runner.rb +1 -1
- data/lib/tina4/session.rb +6 -1
- data/lib/tina4/session_handlers/database_handler.rb +66 -0
- data/lib/tina4/swagger.rb +1 -1
- data/lib/tina4/templates/errors/404.twig +2 -2
- data/lib/tina4/templates/errors/500.twig +1 -1
- data/lib/tina4/validator.rb +174 -0
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/websocket.rb +23 -4
- data/lib/tina4/websocket_backplane.rb +118 -0
- data/lib/tina4.rb +126 -5
- metadata +40 -3
|
@@ -0,0 +1,374 @@
|
|
|
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("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_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
|
+
return unless @db.nil?
|
|
369
|
+
|
|
370
|
+
@db = Tina4.database if defined?(Tina4.database) && Tina4.database
|
|
371
|
+
raise "QueryBuilder: No database connection provided." if @db.nil?
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
end
|