tarsier 0.1.0

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 (47) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +175 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +984 -0
  5. data/exe/tarsier +7 -0
  6. data/lib/tarsier/application.rb +336 -0
  7. data/lib/tarsier/cli/commands/console.rb +87 -0
  8. data/lib/tarsier/cli/commands/generate.rb +85 -0
  9. data/lib/tarsier/cli/commands/help.rb +50 -0
  10. data/lib/tarsier/cli/commands/new.rb +59 -0
  11. data/lib/tarsier/cli/commands/routes.rb +139 -0
  12. data/lib/tarsier/cli/commands/server.rb +123 -0
  13. data/lib/tarsier/cli/commands/version.rb +14 -0
  14. data/lib/tarsier/cli/generators/app.rb +528 -0
  15. data/lib/tarsier/cli/generators/base.rb +93 -0
  16. data/lib/tarsier/cli/generators/controller.rb +91 -0
  17. data/lib/tarsier/cli/generators/middleware.rb +81 -0
  18. data/lib/tarsier/cli/generators/migration.rb +109 -0
  19. data/lib/tarsier/cli/generators/model.rb +109 -0
  20. data/lib/tarsier/cli/generators/resource.rb +27 -0
  21. data/lib/tarsier/cli/loader.rb +18 -0
  22. data/lib/tarsier/cli.rb +46 -0
  23. data/lib/tarsier/controller.rb +282 -0
  24. data/lib/tarsier/database.rb +588 -0
  25. data/lib/tarsier/errors.rb +77 -0
  26. data/lib/tarsier/middleware/base.rb +47 -0
  27. data/lib/tarsier/middleware/compression.rb +113 -0
  28. data/lib/tarsier/middleware/cors.rb +101 -0
  29. data/lib/tarsier/middleware/csrf.rb +88 -0
  30. data/lib/tarsier/middleware/logger.rb +74 -0
  31. data/lib/tarsier/middleware/rate_limit.rb +110 -0
  32. data/lib/tarsier/middleware/stack.rb +143 -0
  33. data/lib/tarsier/middleware/static.rb +124 -0
  34. data/lib/tarsier/model.rb +590 -0
  35. data/lib/tarsier/params.rb +269 -0
  36. data/lib/tarsier/query.rb +495 -0
  37. data/lib/tarsier/request.rb +274 -0
  38. data/lib/tarsier/response.rb +282 -0
  39. data/lib/tarsier/router/compiler.rb +173 -0
  40. data/lib/tarsier/router/node.rb +97 -0
  41. data/lib/tarsier/router/route.rb +119 -0
  42. data/lib/tarsier/router.rb +272 -0
  43. data/lib/tarsier/version.rb +5 -0
  44. data/lib/tarsier/websocket.rb +275 -0
  45. data/lib/tarsier.rb +167 -0
  46. data/sig/tarsier.rbs +485 -0
  47. metadata +230 -0
@@ -0,0 +1,495 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tarsier
4
+ # Database-agnostic query builder
5
+ #
6
+ # Provides a fluent, chainable interface for building SQL queries.
7
+ # All methods return a new Query instance, making queries immutable.
8
+ #
9
+ # @example Basic queries
10
+ # User.where(active: true)
11
+ # User.where(role: 'admin').order(:name)
12
+ # User.where(age: 18..65).limit(10)
13
+ #
14
+ # @example Chaining
15
+ # User.where(active: true)
16
+ # .where_not(role: 'guest')
17
+ # .order(created_at: :desc)
18
+ # .limit(10)
19
+ # .offset(20)
20
+ #
21
+ # @since 0.1.0
22
+ class Query
23
+ include Enumerable
24
+
25
+ # @return [Class] the model class
26
+ attr_reader :model
27
+
28
+ # @return [Array] query conditions
29
+ attr_reader :conditions
30
+
31
+ # @return [Array] order clauses
32
+ attr_reader :order_clauses
33
+
34
+ # @return [Integer, nil] limit value
35
+ attr_reader :limit_value
36
+
37
+ # @return [Integer, nil] offset value
38
+ attr_reader :offset_value
39
+
40
+ # @return [Array] columns to select
41
+ attr_reader :select_columns
42
+
43
+ # @return [Array] join clauses
44
+ attr_reader :join_clauses
45
+
46
+ # @return [Array] associations to eager load
47
+ attr_reader :include_associations
48
+
49
+ # Create a new query
50
+ #
51
+ # @param model [Class] the model class to query
52
+ def initialize(model)
53
+ @model = model
54
+ @conditions = []
55
+ @order_clauses = []
56
+ @limit_value = nil
57
+ @offset_value = nil
58
+ @select_columns = ["*"]
59
+ @join_clauses = []
60
+ @include_associations = []
61
+ @loaded = false
62
+ @records = nil
63
+ end
64
+
65
+ # Add WHERE conditions
66
+ #
67
+ # @param conditions [Hash, String] conditions
68
+ # @return [Query] new query with conditions
69
+ #
70
+ # @example
71
+ # User.where(active: true)
72
+ # User.where(age: 18..30)
73
+ # User.where('created_at > ?', 1.day.ago)
74
+ def where(conditions = nil, *values, **hash_conditions)
75
+ clone_with do |q|
76
+ case conditions
77
+ when Hash
78
+ conditions.each { |k, v| q.add_condition(k, :eq, v) }
79
+ when String
80
+ q.add_raw_condition(conditions, values)
81
+ when nil
82
+ hash_conditions.each { |k, v| q.add_condition(k, :eq, v) }
83
+ end
84
+ end
85
+ end
86
+
87
+ # Add WHERE NOT conditions
88
+ #
89
+ # @param conditions [Hash] conditions
90
+ # @return [Query] new query with conditions
91
+ #
92
+ # @example
93
+ # User.where_not(role: 'guest')
94
+ def where_not(**conditions)
95
+ clone_with do |q|
96
+ conditions.each { |k, v| q.add_condition(k, :neq, v) }
97
+ end
98
+ end
99
+
100
+ # Add ORDER BY clause
101
+ #
102
+ # @param columns [Symbol, Hash] columns to order by
103
+ # @return [Query] new query with ordering
104
+ #
105
+ # @example
106
+ # User.order(:name)
107
+ # User.order(created_at: :desc)
108
+ # User.order(:role, created_at: :desc)
109
+ def order(*columns, **hash_order)
110
+ clone_with do |q|
111
+ columns.each { |col| q.add_order(col, :asc) }
112
+ hash_order.each { |col, dir| q.add_order(col, dir) }
113
+ end
114
+ end
115
+
116
+ # Set LIMIT
117
+ #
118
+ # @param value [Integer] limit value
119
+ # @return [Query] new query with limit
120
+ def limit(value)
121
+ clone_with { |q| q.instance_variable_set(:@limit_value, value) }
122
+ end
123
+
124
+ # Set OFFSET
125
+ #
126
+ # @param value [Integer] offset value
127
+ # @return [Query] new query with offset
128
+ def offset(value)
129
+ clone_with { |q| q.instance_variable_set(:@offset_value, value) }
130
+ end
131
+
132
+ # Set SELECT columns
133
+ #
134
+ # @param columns [Array<Symbol>] columns to select
135
+ # @return [Query] new query with select
136
+ def select(*columns)
137
+ clone_with { |q| q.instance_variable_set(:@select_columns, columns.flatten) }
138
+ end
139
+
140
+ # Add JOIN clause
141
+ #
142
+ # @param table [Symbol, String] table to join
143
+ # @param on [String] join condition
144
+ # @param type [Symbol] join type (:inner, :left, :right)
145
+ # @return [Query] new query with join
146
+ #
147
+ # @example
148
+ # User.joins(:posts, on: 'users.id = posts.user_id')
149
+ # User.joins(:posts, type: :left)
150
+ def joins(table, on: nil, type: :inner)
151
+ clone_with do |q|
152
+ q.join_clauses << { table: table, on: on, type: type }
153
+ end
154
+ end
155
+
156
+ # Eager load associations
157
+ #
158
+ # @param associations [Array<Symbol>] associations to include
159
+ # @return [Query] new query with includes
160
+ #
161
+ # @example
162
+ # User.includes(:posts, :comments)
163
+ def includes(*associations)
164
+ clone_with do |q|
165
+ q.instance_variable_set(:@include_associations, associations.flatten)
166
+ end
167
+ end
168
+
169
+ # Get first record
170
+ #
171
+ # @return [Model, nil]
172
+ def first
173
+ limit(1).to_a.first
174
+ end
175
+
176
+ # Get last record
177
+ #
178
+ # @return [Model, nil]
179
+ def last
180
+ order(@model.primary_key => :desc).limit(1).to_a.first
181
+ end
182
+
183
+ # Get record count
184
+ #
185
+ # @return [Integer]
186
+ def count
187
+ sql, params = build_count_sql
188
+ result = @model.db.get(sql, *params)
189
+ result[:count] || result.values.first
190
+ end
191
+
192
+ # Check if any records exist
193
+ #
194
+ # @return [Boolean]
195
+ def exists?
196
+ return false unless database_available?
197
+
198
+ limit(1).count > 0
199
+ end
200
+
201
+ # Check if no records exist
202
+ #
203
+ # @return [Boolean]
204
+ def empty?
205
+ !exists?
206
+ end
207
+
208
+ # Find or create a record
209
+ #
210
+ # @param attributes [Hash] attributes to find/create by
211
+ # @param create_with [Hash] additional attributes for creation
212
+ # @return [Model]
213
+ def find_or_create_by(attributes, create_with: {})
214
+ where(attributes).first || @model.create(attributes.merge(create_with))
215
+ end
216
+
217
+ # Find or initialize a record
218
+ #
219
+ # @param attributes [Hash] attributes to find/initialize by
220
+ # @return [Model]
221
+ def find_or_initialize_by(attributes)
222
+ where(attributes).first || @model.new(attributes)
223
+ end
224
+
225
+ # Iterate over records
226
+ #
227
+ # @yield [Model] each record
228
+ def each(&block)
229
+ load_records unless @loaded
230
+ @records.each(&block)
231
+ end
232
+
233
+ # Convert to array
234
+ #
235
+ # @return [Array<Model>]
236
+ def to_a
237
+ load_records unless @loaded
238
+ @records
239
+ end
240
+
241
+ # Build SQL query string (for debugging)
242
+ #
243
+ # @return [String]
244
+ def to_sql
245
+ parts = []
246
+
247
+ # SELECT
248
+ columns = @select_columns.map { |c| c == "*" ? "*" : c.to_s }.join(", ")
249
+ parts << "SELECT #{columns}"
250
+
251
+ # FROM
252
+ parts << "FROM #{@model.table}"
253
+
254
+ # JOINS
255
+ @join_clauses.each do |join|
256
+ join_type = join[:type].to_s.upcase
257
+ parts << "#{join_type} JOIN #{join[:table]}"
258
+ parts << "ON #{join[:on]}" if join[:on]
259
+ end
260
+
261
+ # WHERE
262
+ unless @conditions.empty?
263
+ where_parts = @conditions.map { |c| condition_to_display_sql(c) }
264
+ parts << "WHERE #{where_parts.join(' AND ')}"
265
+ end
266
+
267
+ # ORDER BY
268
+ unless @order_clauses.empty?
269
+ order_parts = @order_clauses.map { |o| "#{o[:column]} #{o[:direction].to_s.upcase}" }
270
+ parts << "ORDER BY #{order_parts.join(', ')}"
271
+ end
272
+
273
+ # LIMIT
274
+ parts << "LIMIT #{@limit_value}" if @limit_value
275
+
276
+ # OFFSET
277
+ parts << "OFFSET #{@offset_value}" if @offset_value
278
+
279
+ parts.join(" ")
280
+ end
281
+
282
+ # Pluck specific columns
283
+ #
284
+ # @param columns [Array<Symbol>] columns to pluck
285
+ # @return [Array]
286
+ #
287
+ # @example
288
+ # User.pluck(:id)
289
+ # User.pluck(:id, :name)
290
+ def pluck(*columns)
291
+ select(*columns).to_a.map do |record|
292
+ if columns.size == 1
293
+ record.to_h[columns.first]
294
+ else
295
+ columns.map { |c| record.to_h[c] }
296
+ end
297
+ end
298
+ end
299
+
300
+ # Get IDs only
301
+ #
302
+ # @return [Array]
303
+ def ids
304
+ pluck(@model.primary_key)
305
+ end
306
+
307
+ # Update all matching records
308
+ #
309
+ # @param attributes [Hash] attributes to update
310
+ # @return [Integer] number of updated records
311
+ def update_all(attributes)
312
+ where_clause, where_params = build_where
313
+ set_clause = attributes.keys.map { |k| "#{k} = ?" }.join(", ")
314
+
315
+ sql = "UPDATE #{@model.table} SET #{set_clause}"
316
+ sql += " WHERE #{where_clause}" unless where_clause.empty?
317
+
318
+ @model.db.execute(sql, *attributes.values, *where_params)
319
+ @model.db.send(:affected_rows)
320
+ end
321
+
322
+ # Delete all matching records
323
+ #
324
+ # @return [Integer] number of deleted records
325
+ def delete_all
326
+ where_clause, where_params = build_where
327
+
328
+ sql = "DELETE FROM #{@model.table}"
329
+ sql += " WHERE #{where_clause}" unless where_clause.empty?
330
+
331
+ @model.db.execute(sql, *where_params)
332
+ @model.db.send(:affected_rows)
333
+ end
334
+
335
+ protected
336
+
337
+ def add_condition(column, operator, value)
338
+ @conditions << { column: column, operator: operator, value: value }
339
+ end
340
+
341
+ def add_raw_condition(sql, values)
342
+ @conditions << { raw: sql, values: values }
343
+ end
344
+
345
+ def add_order(column, direction)
346
+ @order_clauses << { column: column, direction: direction }
347
+ end
348
+
349
+ private
350
+
351
+ def clone_with
352
+ clone = self.class.new(@model)
353
+ clone.instance_variable_set(:@conditions, @conditions.dup)
354
+ clone.instance_variable_set(:@order_clauses, @order_clauses.dup)
355
+ clone.instance_variable_set(:@limit_value, @limit_value)
356
+ clone.instance_variable_set(:@offset_value, @offset_value)
357
+ clone.instance_variable_set(:@select_columns, @select_columns.dup)
358
+ clone.instance_variable_set(:@join_clauses, @join_clauses.dup)
359
+ clone.instance_variable_set(:@include_associations, @include_associations.dup)
360
+ yield clone
361
+ clone
362
+ end
363
+
364
+ def build_sql
365
+ parts = []
366
+ params = []
367
+
368
+ # SELECT
369
+ columns = @select_columns.map { |c| c == "*" ? "*" : c.to_s }.join(", ")
370
+ parts << "SELECT #{columns}"
371
+
372
+ # FROM
373
+ parts << "FROM #{@model.table}"
374
+
375
+ # WHERE
376
+ where_clause, where_params = build_where
377
+ unless where_clause.empty?
378
+ parts << "WHERE #{where_clause}"
379
+ params.concat(where_params)
380
+ end
381
+
382
+ # ORDER BY
383
+ unless @order_clauses.empty?
384
+ order_parts = @order_clauses.map { |o| "#{o[:column]} #{o[:direction].to_s.upcase}" }
385
+ parts << "ORDER BY #{order_parts.join(', ')}"
386
+ end
387
+
388
+ # LIMIT
389
+ parts << "LIMIT #{@limit_value}" if @limit_value
390
+
391
+ # OFFSET
392
+ parts << "OFFSET #{@offset_value}" if @offset_value
393
+
394
+ [parts.join(" "), params]
395
+ end
396
+
397
+ def build_count_sql
398
+ parts = ["SELECT COUNT(*) as count", "FROM #{@model.table}"]
399
+ params = []
400
+
401
+ where_clause, where_params = build_where
402
+ unless where_clause.empty?
403
+ parts << "WHERE #{where_clause}"
404
+ params.concat(where_params)
405
+ end
406
+
407
+ [parts.join(" "), params]
408
+ end
409
+
410
+ def build_where
411
+ return ["", []] if @conditions.empty?
412
+
413
+ clauses = []
414
+ params = []
415
+
416
+ @conditions.each do |condition|
417
+ if condition[:raw]
418
+ clauses << condition[:raw]
419
+ params.concat(condition[:values])
420
+ else
421
+ clause, value = condition_to_sql(condition)
422
+ clauses << clause
423
+ params.concat(Array(value)) unless value.nil?
424
+ end
425
+ end
426
+
427
+ [clauses.join(" AND "), params]
428
+ end
429
+
430
+ def condition_to_sql(condition)
431
+ column = condition[:column]
432
+ value = condition[:value]
433
+ operator = condition[:operator]
434
+
435
+ case value
436
+ when nil
437
+ [operator == :eq ? "#{column} IS NULL" : "#{column} IS NOT NULL", nil]
438
+ when Range
439
+ ["#{column} BETWEEN ? AND ?", [value.begin, value.end]]
440
+ when Array
441
+ placeholders = (["?"] * value.size).join(", ")
442
+ op = operator == :eq ? "IN" : "NOT IN"
443
+ ["#{column} #{op} (#{placeholders})", value]
444
+ else
445
+ op = operator == :eq ? "=" : "!="
446
+ ["#{column} #{op} ?", [value]]
447
+ end
448
+ end
449
+
450
+ def condition_to_display_sql(condition)
451
+ return condition[:raw] if condition[:raw]
452
+
453
+ column = condition[:column]
454
+ value = condition[:value]
455
+ operator = condition[:operator]
456
+
457
+ case value
458
+ when nil
459
+ operator == :eq ? "#{column} IS NULL" : "#{column} IS NOT NULL"
460
+ when Range
461
+ "#{column} BETWEEN #{quote_value(value.begin)} AND #{quote_value(value.end)}"
462
+ when Array
463
+ values = value.map { |v| quote_value(v) }.join(", ")
464
+ op = operator == :eq ? "IN" : "NOT IN"
465
+ "#{column} #{op} (#{values})"
466
+ else
467
+ op = operator == :eq ? "=" : "!="
468
+ "#{column} #{op} #{quote_value(value)}"
469
+ end
470
+ end
471
+
472
+ def quote_value(value)
473
+ case value
474
+ when String then "'#{value.gsub("'", "''")}'"
475
+ when nil then "NULL"
476
+ when true then "TRUE"
477
+ when false then "FALSE"
478
+ else value.to_s
479
+ end
480
+ end
481
+
482
+ def load_records
483
+ sql, params = build_sql
484
+ rows = @model.db.execute(sql, *params)
485
+ @records = rows.map { |row| @model.from_row(row) }
486
+ @loaded = true
487
+ end
488
+
489
+ def database_available?
490
+ Tarsier::Database.connected?
491
+ rescue StandardError
492
+ false
493
+ end
494
+ end
495
+ end