tina4ruby 0.4.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 (74) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +80 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +768 -0
  5. data/exe/tina4 +4 -0
  6. data/lib/tina4/api.rb +152 -0
  7. data/lib/tina4/auth.rb +139 -0
  8. data/lib/tina4/cli.rb +349 -0
  9. data/lib/tina4/crud.rb +124 -0
  10. data/lib/tina4/database.rb +135 -0
  11. data/lib/tina4/database_result.rb +89 -0
  12. data/lib/tina4/debug.rb +83 -0
  13. data/lib/tina4/dev.rb +15 -0
  14. data/lib/tina4/dev_reload.rb +68 -0
  15. data/lib/tina4/drivers/firebird_driver.rb +94 -0
  16. data/lib/tina4/drivers/mssql_driver.rb +112 -0
  17. data/lib/tina4/drivers/mysql_driver.rb +90 -0
  18. data/lib/tina4/drivers/postgres_driver.rb +99 -0
  19. data/lib/tina4/drivers/sqlite_driver.rb +85 -0
  20. data/lib/tina4/env.rb +55 -0
  21. data/lib/tina4/field_types.rb +84 -0
  22. data/lib/tina4/graphql.rb +837 -0
  23. data/lib/tina4/localization.rb +100 -0
  24. data/lib/tina4/middleware.rb +59 -0
  25. data/lib/tina4/migration.rb +124 -0
  26. data/lib/tina4/orm.rb +168 -0
  27. data/lib/tina4/public/css/tina4.css +2286 -0
  28. data/lib/tina4/public/css/tina4.min.css +2 -0
  29. data/lib/tina4/public/js/tina4.js +134 -0
  30. data/lib/tina4/public/js/tina4helper.js +387 -0
  31. data/lib/tina4/queue.rb +117 -0
  32. data/lib/tina4/queue_backends/kafka_backend.rb +80 -0
  33. data/lib/tina4/queue_backends/lite_backend.rb +79 -0
  34. data/lib/tina4/queue_backends/rabbitmq_backend.rb +73 -0
  35. data/lib/tina4/rack_app.rb +150 -0
  36. data/lib/tina4/request.rb +158 -0
  37. data/lib/tina4/response.rb +172 -0
  38. data/lib/tina4/router.rb +148 -0
  39. data/lib/tina4/scss/tina4css/_alerts.scss +34 -0
  40. data/lib/tina4/scss/tina4css/_badges.scss +22 -0
  41. data/lib/tina4/scss/tina4css/_buttons.scss +69 -0
  42. data/lib/tina4/scss/tina4css/_cards.scss +49 -0
  43. data/lib/tina4/scss/tina4css/_forms.scss +156 -0
  44. data/lib/tina4/scss/tina4css/_grid.scss +81 -0
  45. data/lib/tina4/scss/tina4css/_modals.scss +84 -0
  46. data/lib/tina4/scss/tina4css/_nav.scss +149 -0
  47. data/lib/tina4/scss/tina4css/_reset.scss +94 -0
  48. data/lib/tina4/scss/tina4css/_tables.scss +54 -0
  49. data/lib/tina4/scss/tina4css/_typography.scss +55 -0
  50. data/lib/tina4/scss/tina4css/_utilities.scss +197 -0
  51. data/lib/tina4/scss/tina4css/_variables.scss +117 -0
  52. data/lib/tina4/scss/tina4css/base.scss +1 -0
  53. data/lib/tina4/scss/tina4css/colors.scss +48 -0
  54. data/lib/tina4/scss/tina4css/tina4.scss +17 -0
  55. data/lib/tina4/scss_compiler.rb +131 -0
  56. data/lib/tina4/seeder.rb +529 -0
  57. data/lib/tina4/session.rb +145 -0
  58. data/lib/tina4/session_handlers/file_handler.rb +55 -0
  59. data/lib/tina4/session_handlers/mongo_handler.rb +49 -0
  60. data/lib/tina4/session_handlers/redis_handler.rb +43 -0
  61. data/lib/tina4/swagger.rb +123 -0
  62. data/lib/tina4/template.rb +478 -0
  63. data/lib/tina4/templates/base.twig +26 -0
  64. data/lib/tina4/templates/errors/403.twig +22 -0
  65. data/lib/tina4/templates/errors/404.twig +22 -0
  66. data/lib/tina4/templates/errors/500.twig +22 -0
  67. data/lib/tina4/testing.rb +213 -0
  68. data/lib/tina4/version.rb +5 -0
  69. data/lib/tina4/webserver.rb +101 -0
  70. data/lib/tina4/websocket.rb +167 -0
  71. data/lib/tina4/wsdl.rb +164 -0
  72. data/lib/tina4.rb +259 -0
  73. data/lib/tina4ruby.rb +4 -0
  74. metadata +324 -0
@@ -0,0 +1,529 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "json"
5
+
6
+ module Tina4
7
+ # Zero-dependency fake data generator with deterministic seeding.
8
+ # Uses Ruby's built-in Random for reproducible data generation.
9
+ #
10
+ # @example
11
+ # fake = Tina4::FakeData.new(seed: 42)
12
+ # fake.name # => "Sarah Johnson"
13
+ # fake.email # => "sarah.johnson123@example.com"
14
+ # fake.integer(1, 100)
15
+ class FakeData
16
+ FIRST_NAMES = %w[
17
+ James Mary Robert Patricia John Jennifer Michael Linda David Elizabeth
18
+ William Barbara Richard Susan Joseph Jessica Thomas Sarah Charles Karen
19
+ Christopher Lisa Daniel Nancy Matthew Betty Anthony Margaret Mark Sandra
20
+ Donald Ashley Steven Dorothy Paul Kimberly Andrew Emily Joshua Donna
21
+ Kenneth Michelle Kevin Carol Brian Amanda George Melissa Timothy Deborah
22
+ Ronald Stephanie Edward Rebecca Jason Sharon Jeffrey Laura Ryan Cynthia
23
+ Jacob Kathleen Gary Amy Nicholas Angela Eric Shirley Jonathan Anna
24
+ Stephen Brenda Larry Pamela Justin Emma Scott Nicole Brandon Helen
25
+ Benjamin Samantha Samuel Katherine Raymond Christine Gregory Debra
26
+ Frank Rachel Alexander Carolyn Patrick Janet Jack Catherine Andre Aisha
27
+ Wei Yuki Carlos Fatima Raj Priya Mohammed Sophia Liam Olivia Noah Ava
28
+ Ethan Mia Lucas Isabella Mason Charlotte Logan Amelia Aiden Harper
29
+ ].freeze
30
+
31
+ LAST_NAMES = %w[
32
+ Smith Johnson Williams Brown Jones Garcia Miller Davis Rodriguez Martinez
33
+ Hernandez Lopez Gonzalez Wilson Anderson Thomas Taylor Moore Jackson Martin
34
+ Lee Perez Thompson White Harris Sanchez Clark Ramirez Lewis Robinson Walker
35
+ Young Allen King Wright Scott Torres Nguyen Hill Flores Green Adams Nelson
36
+ Baker Hall Rivera Campbell Mitchell Carter Roberts Gomez Phillips Evans
37
+ Turner Diaz Parker Cruz Edwards Collins Reyes Stewart Morris Morales
38
+ Murphy Cook Rogers Gutierrez Ortiz Morgan Cooper Peterson Bailey Reed
39
+ Kelly Howard Ramos Kim Cox Ward Richardson Watson Brooks Chavez Wood
40
+ James Bennett Gray Mendoza Ruiz Hughes Price Alvarez Castillo Sanders
41
+ Patel Müller Nakamura Singh Chen Silva Ali Okafor
42
+ ].freeze
43
+
44
+ WORDS = %w[
45
+ the be to of and a in that have it for not on with he as you do at
46
+ this but his by from they we say her she or an will my one all would
47
+ there their what so up out if about who get which go me when make can
48
+ like time no just him know take people into year your good some could
49
+ them see other than then now look only come its over think also back
50
+ after use two how our work first well way even new want because any
51
+ these give day most us great small large every found still between name
52
+ should home big end along each much both help line turn move thing right
53
+ same old better point long real system data report order product service
54
+ customer account payment record total status market world company project
55
+ team value process business group result information development management
56
+ quality performance technology support research design program network
57
+ ].freeze
58
+
59
+ CITIES = [
60
+ "New York", "London", "Tokyo", "Paris", "Berlin", "Sydney", "Toronto",
61
+ "Mumbai", "São Paulo", "Cairo", "Lagos", "Dubai", "Singapore",
62
+ "Hong Kong", "Seoul", "Mexico City", "Bangkok", "Istanbul", "Moscow",
63
+ "Rome", "Barcelona", "Amsterdam", "Nairobi", "Cape Town", "Johannesburg",
64
+ "Buenos Aires", "Lima", "Santiago", "Jakarta", "Manila", "Kuala Lumpur",
65
+ "Auckland", "Vancouver", "Chicago", "San Francisco", "Los Angeles",
66
+ "Miami", "Boston", "Seattle", "Denver"
67
+ ].freeze
68
+
69
+ COUNTRIES = [
70
+ "United States", "United Kingdom", "Canada", "Australia", "Germany",
71
+ "France", "Japan", "Brazil", "India", "South Africa", "Nigeria",
72
+ "Egypt", "Kenya", "Mexico", "Argentina", "Chile", "Colombia", "Spain",
73
+ "Italy", "Netherlands", "Sweden", "Norway", "Denmark", "Finland",
74
+ "Switzerland", "Belgium", "Austria", "New Zealand", "Singapore",
75
+ "South Korea", "Thailand", "Indonesia", "Philippines", "Vietnam",
76
+ "Malaysia", "United Arab Emirates", "Saudi Arabia", "Turkey", "Poland"
77
+ ].freeze
78
+
79
+ DOMAINS = %w[
80
+ example.com test.org sample.net demo.io mail.com
81
+ inbox.org webmail.net company.com corp.io biz.net
82
+ ].freeze
83
+
84
+ STREETS = %w[Main Oak Pine Maple Cedar Elm Park Lake Hill River Church Market King Queen High].freeze
85
+ STREET_TYPES = %w[Street Avenue Road Drive Lane Boulevard Way Place].freeze
86
+ COMPANY_WORDS = %w[Tech Global Apex Nova Core Prime Next Blue Bright Smart Swift Peak Fusion Pulse Vertex].freeze
87
+ COMPANY_SUFFIXES = %w[Inc Corp Ltd LLC Group Solutions Systems Labs].freeze
88
+
89
+ def initialize(seed: nil)
90
+ @rng = seed ? Random.new(seed) : Random.new
91
+ end
92
+
93
+ def first_name
94
+ FIRST_NAMES[@rng.rand(FIRST_NAMES.length)]
95
+ end
96
+
97
+ def last_name
98
+ LAST_NAMES[@rng.rand(LAST_NAMES.length)]
99
+ end
100
+
101
+ def name
102
+ "#{first_name} #{last_name}"
103
+ end
104
+
105
+ def email(from_name: nil)
106
+ if from_name
107
+ local = from_name.downcase.split.join(".")
108
+ else
109
+ local = "#{first_name.downcase}.#{last_name.downcase}"
110
+ end
111
+ local += @rng.rand(1..999).to_s
112
+ "#{local}@#{DOMAINS[@rng.rand(DOMAINS.length)]}"
113
+ end
114
+
115
+ def phone
116
+ area = @rng.rand(200..999)
117
+ mid = @rng.rand(100..999)
118
+ tail = @rng.rand(1000..9999)
119
+ "+1 (#{area}) #{mid}-#{tail}"
120
+ end
121
+
122
+ def sentence(words: 6)
123
+ w = Array.new(words) { WORDS[@rng.rand(WORDS.length)] }
124
+ w[0] = w[0].capitalize
125
+ "#{w.join(' ')}."
126
+ end
127
+
128
+ def paragraph(sentences: 3)
129
+ Array.new(sentences) { sentence(words: @rng.rand(5..12)) }.join(" ")
130
+ end
131
+
132
+ def text(max_length: 200)
133
+ t = paragraph(sentences: 2)
134
+ t.length > max_length ? t[0...max_length] : t
135
+ end
136
+
137
+ def word
138
+ WORDS[@rng.rand(WORDS.length)]
139
+ end
140
+
141
+ def slug(words: 3)
142
+ Array.new(words) { WORDS[@rng.rand(WORDS.length)] }.join("-")
143
+ end
144
+
145
+ def url
146
+ "https://#{DOMAINS[@rng.rand(DOMAINS.length)]}/#{slug}"
147
+ end
148
+
149
+ def integer(min: 0, max: 10_000)
150
+ @rng.rand(min..max)
151
+ end
152
+
153
+ def numeric(min: 0.0, max: 1000.0, decimals: 2)
154
+ val = min + @rng.rand * (max - min)
155
+ val.round(decimals)
156
+ end
157
+
158
+ def boolean
159
+ @rng.rand(2)
160
+ end
161
+
162
+ def datetime(start_year: 2020, end_year: 2026)
163
+ start_time = Time.new(start_year, 1, 1)
164
+ end_time = Time.new(end_year, 12, 31, 23, 59, 59)
165
+ delta = (end_time - start_time).to_i
166
+ Time.at(start_time.to_i + @rng.rand(0..delta))
167
+ end
168
+
169
+ def date(start_year: 2020, end_year: 2026)
170
+ datetime(start_year: start_year, end_year: end_year).strftime("%Y-%m-%d")
171
+ end
172
+
173
+ def timestamp(start_year: 2020, end_year: 2026)
174
+ datetime(start_year: start_year, end_year: end_year).strftime("%Y-%m-%d %H:%M:%S")
175
+ end
176
+
177
+ def blob(size: 64)
178
+ SecureRandom.random_bytes(size)
179
+ end
180
+
181
+ def json_data(keys: nil)
182
+ if keys
183
+ keys.each_with_object({}) { |k, h| h[k] = word }
184
+ else
185
+ n = @rng.rand(2..5)
186
+ n.times.each_with_object({}) { |_, h| h[word] = word }
187
+ end
188
+ end
189
+
190
+ def choice(items)
191
+ items[@rng.rand(items.length)]
192
+ end
193
+
194
+ def city
195
+ CITIES[@rng.rand(CITIES.length)]
196
+ end
197
+
198
+ def country
199
+ COUNTRIES[@rng.rand(COUNTRIES.length)]
200
+ end
201
+
202
+ def address
203
+ "#{@rng.rand(1..9999)} #{STREETS[@rng.rand(STREETS.length)]} #{STREET_TYPES[@rng.rand(STREET_TYPES.length)]}"
204
+ end
205
+
206
+ def zip_code
207
+ @rng.rand(10_000..99_999).to_s
208
+ end
209
+
210
+ def company
211
+ w1 = COMPANY_WORDS[@rng.rand(COMPANY_WORDS.length)]
212
+ w2 = COMPANY_WORDS[@rng.rand(COMPANY_WORDS.length)]
213
+ suffix = COMPANY_SUFFIXES[@rng.rand(COMPANY_SUFFIXES.length)]
214
+ "#{w1}#{w2} #{suffix}"
215
+ end
216
+
217
+ def color_hex
218
+ "#%06x" % @rng.rand(0..0xFFFFFF)
219
+ end
220
+
221
+ def uuid
222
+ h = Array.new(32) { "0123456789abcdef"[@rng.rand(16)] }.join
223
+ "#{h[0..7]}-#{h[8..11]}-#{h[12..15]}-#{h[16..19]}-#{h[20..31]}"
224
+ end
225
+
226
+ def password(length: 16)
227
+ chars = [*"a".."z", *"A".."Z", *"0".."9"]
228
+ Array.new(length) { chars[@rng.rand(chars.length)] }.join
229
+ end
230
+
231
+ # Generate appropriate data based on field definition and column name.
232
+ def for_field(field_def, column_name = nil)
233
+ col = (column_name || "").to_s.downcase
234
+ type = field_def[:type]
235
+
236
+ # Skip auto-increment primary keys
237
+ return nil if field_def[:primary_key] && field_def[:auto_increment]
238
+
239
+ case type
240
+ when :integer
241
+ return integer(min: 18, max: 85) if col.include?("age")
242
+ return integer(min: 1950, max: 2026) if col.include?("year")
243
+ return integer(min: 1, max: 100) if col =~ /quantity|qty|count/
244
+ return boolean if col =~ /active|enabled|visible|^is_/
245
+ return integer(min: 1, max: 10) if col =~ /rating|score/
246
+ integer(min: 1, max: 10_000)
247
+
248
+ when :float, :decimal
249
+ decimals = field_def[:scale] || 2
250
+ return numeric(min: 0.01, max: 9999.99, decimals: decimals) if col =~ /price|cost|amount|total|fee/
251
+ return numeric(min: 0.0, max: 100.0, decimals: decimals) if col =~ /rate|percent|ratio/
252
+ return numeric(min: -90.0, max: 90.0, decimals: 6) if col.include?("lat")
253
+ return numeric(min: -180.0, max: 180.0, decimals: 6) if col =~ /lon|lng/
254
+ numeric(min: 0.0, max: 10_000.0, decimals: decimals)
255
+
256
+ when :date
257
+ date
258
+
259
+ when :datetime, :timestamp
260
+ timestamp
261
+
262
+ when :boolean
263
+ boolean
264
+
265
+ when :blob
266
+ blob
267
+
268
+ when :json
269
+ json_data
270
+
271
+ when :string, :text
272
+ max_len = field_def[:length] || 255
273
+ val = generate_string_for(col, max_len)
274
+ val.length > max_len ? val[0...max_len] : val
275
+
276
+ else
277
+ word
278
+ end
279
+ end
280
+
281
+ private
282
+
283
+ def generate_string_for(col, max_len)
284
+ return email[0...max_len] if col.include?("email")
285
+ return name[0...max_len] if %w[name full_name fullname display_name].include?(col)
286
+ return first_name[0...max_len] if col.include?("first") && col.include?("name")
287
+ return last_name[0...max_len] if col.include?("last") && col.include?("name")
288
+ return last_name[0...max_len] if col =~ /surname|family_name/
289
+ return phone[0...max_len] if col =~ /phone|tel|mobile|cell/
290
+ return url[0...max_len] if col =~ /url|website|link|href/
291
+ return address[0...max_len] if col =~ /address|street/
292
+ return city[0...max_len] if col =~ /city|town/
293
+ return country[0...max_len] if col.include?("country")
294
+ return zip_code[0...max_len] if col =~ /zip|postal/
295
+ return company[0...max_len] if col =~ /company|organization|org/
296
+ return color_hex[0...max_len] if col =~ /color|colour/
297
+ return uuid[0...max_len] if col =~ /uuid|guid/
298
+ return slug[0...max_len] if col.include?("slug")
299
+ return sentence(words: @rng.rand(3..6)).chomp(".")[0...max_len] if col =~ /title|subject|heading/
300
+ return text(max_length: max_len) if col =~ /description|summary|bio|about/
301
+ return paragraph(sentences: 2)[0...max_len] if col =~ /content|body|text|note|comment/
302
+ return choice(%w[active inactive pending archived])[0...max_len] if col.include?("status")
303
+ return choice(%w[standard premium basic enterprise custom])[0...max_len] if col =~ /type|category|kind/
304
+ return word[0...max_len] if col =~ /tag|label/
305
+ return password(length: [16, max_len].min) if col =~ /password|pass|secret/
306
+ return password(length: [32, max_len].min) if col =~ /token|key|hash/
307
+ return "#{first_name.downcase}#{@rng.rand(1..99)}"[0...max_len] if col =~ /username|user_name|login/
308
+
309
+ sentence(words: @rng.rand(2..5)).chomp(".")[0...max_len]
310
+ end
311
+ end
312
+
313
+ # Seed an ORM class with auto-generated fake data.
314
+ #
315
+ # @param orm_class [Class] ORM subclass (e.g., User, Product)
316
+ # @param count [Integer] number of records to insert
317
+ # @param overrides [Hash] field overrides — static values or lambdas receiving FakeData
318
+ # @param clear [Boolean] delete existing records before seeding
319
+ # @param seed [Integer, nil] random seed for reproducible data
320
+ # @return [Integer] number of records inserted
321
+ #
322
+ # @example
323
+ # Tina4.seed_orm(User, count: 50)
324
+ # Tina4.seed_orm(Order, count: 200, overrides: { status: ->(f) { f.choice(%w[pending shipped]) } })
325
+ def self.seed_orm(orm_class, count: 10, overrides: {}, clear: false, seed: nil)
326
+ fake = FakeData.new(seed: seed)
327
+ fields = orm_class.field_definitions
328
+ table = orm_class.table_name
329
+
330
+ if fields.empty?
331
+ Tina4::Debug.error("Seeder: No fields found on #{orm_class.name}")
332
+ return 0
333
+ end
334
+
335
+ db = Tina4.database
336
+ unless db
337
+ Tina4::Debug.error("Seeder: No database connection. Set Tina4.database first.")
338
+ return 0
339
+ end
340
+
341
+ # Idempotency check
342
+ unless clear
343
+ begin
344
+ result = db.fetch_one("SELECT count(*) as cnt FROM #{table}")
345
+ if result && result[:cnt].to_i >= count
346
+ Tina4::Debug.info("Seeder: #{table} already has #{result[:cnt]} records, skipping")
347
+ return 0
348
+ end
349
+ rescue => e
350
+ # Table might not exist
351
+ end
352
+ end
353
+
354
+ # Clear if requested
355
+ if clear
356
+ begin
357
+ db.execute("DELETE FROM #{table}")
358
+ Tina4::Debug.info("Seeder: Cleared #{table}")
359
+ rescue => e
360
+ Tina4::Debug.warn("Seeder: Could not clear #{table}: #{e.message}")
361
+ end
362
+ end
363
+
364
+ # Identify fields to populate
365
+ pk_field = orm_class.primary_key_field
366
+ insert_fields = fields.reject { |name, opts| opts[:primary_key] && opts[:auto_increment] }
367
+
368
+ inserted = 0
369
+ count.times do |i|
370
+ attrs = {}
371
+
372
+ insert_fields.each do |name, field_def|
373
+ if overrides.key?(name)
374
+ val = overrides[name]
375
+ attrs[name] = val.respond_to?(:call) ? val.call(fake) : val
376
+ else
377
+ generated = fake.for_field(field_def, name)
378
+ attrs[name] = generated unless generated.nil?
379
+ end
380
+ end
381
+
382
+ begin
383
+ obj = orm_class.new(attrs)
384
+ if obj.save
385
+ inserted += 1
386
+ else
387
+ Tina4::Debug.warn("Seeder: Insert failed for #{table} row #{i + 1}: #{obj.errors.join(', ')}")
388
+ end
389
+ rescue => e
390
+ Tina4::Debug.warn("Seeder: Insert failed for #{table} row #{i + 1}: #{e.message}")
391
+ end
392
+ end
393
+
394
+ Tina4::Debug.info("Seeder: Inserted #{inserted}/#{count} records into #{table}")
395
+ inserted
396
+ end
397
+
398
+ # Seed a raw database table (no ORM class needed).
399
+ #
400
+ # @param table_name [String] name of the table
401
+ # @param columns [Hash] { column_name: type_string } — supports :integer, :string, :text, etc.
402
+ # @param count [Integer] number of records to insert
403
+ # @param overrides [Hash] field overrides
404
+ # @param clear [Boolean] delete before seeding
405
+ # @param seed [Integer, nil] random seed
406
+ # @return [Integer] records inserted
407
+ def self.seed_table(table_name, columns, count: 10, overrides: {}, clear: false, seed: nil)
408
+ fake = FakeData.new(seed: seed)
409
+ db = Tina4.database
410
+
411
+ unless db
412
+ Tina4::Debug.error("Seeder: No database connection.")
413
+ return 0
414
+ end
415
+
416
+ if clear
417
+ begin
418
+ db.execute("DELETE FROM #{table_name}")
419
+ rescue => e
420
+ Tina4::Debug.warn("Seeder: Could not clear #{table_name}: #{e.message}")
421
+ end
422
+ end
423
+
424
+ inserted = 0
425
+ count.times do |i|
426
+ row = {}
427
+ columns.each do |col_name, type_str|
428
+ if overrides.key?(col_name)
429
+ val = overrides[col_name]
430
+ row[col_name] = val.respond_to?(:call) ? val.call(fake) : val
431
+ else
432
+ field_def = { type: type_str.to_sym }
433
+ row[col_name] = fake.for_field(field_def, col_name)
434
+ end
435
+ end
436
+
437
+ begin
438
+ db.insert(table_name, row)
439
+ inserted += 1
440
+ rescue => e
441
+ Tina4::Debug.warn("Seeder: Insert failed for #{table_name} row #{i + 1}: #{e.message}")
442
+ end
443
+ end
444
+
445
+ Tina4::Debug.info("Seeder: Inserted #{inserted}/#{count} records into #{table_name}")
446
+ inserted
447
+ end
448
+
449
+ # Builder class for seeding multiple ORM classes with dependency resolution.
450
+ #
451
+ # @example
452
+ # seeder = Tina4::Seeder.new
453
+ # seeder.add(User, count: 20)
454
+ # seeder.add(Order, count: 100, overrides: { status: "pending" })
455
+ # seeder.run(clear: true)
456
+ class Seeder
457
+ def initialize
458
+ @tasks = []
459
+ end
460
+
461
+ def add(orm_class, count: 10, overrides: {}, seed: nil)
462
+ @tasks << {
463
+ orm_class: orm_class,
464
+ count: count,
465
+ overrides: overrides,
466
+ seed: seed
467
+ }
468
+ self
469
+ end
470
+
471
+ def run(clear: false)
472
+ results = {}
473
+
474
+ if clear
475
+ @tasks.reverse_each do |task|
476
+ begin
477
+ Tina4.database&.execute("DELETE FROM #{task[:orm_class].table_name}")
478
+ Tina4::Debug.info("Seeder: Cleared #{task[:orm_class].table_name}")
479
+ rescue => e
480
+ Tina4::Debug.warn("Seeder: Could not clear #{task[:orm_class].table_name}: #{e.message}")
481
+ end
482
+ end
483
+ end
484
+
485
+ @tasks.each do |task|
486
+ n = Tina4.seed_orm(
487
+ task[:orm_class],
488
+ count: task[:count],
489
+ overrides: task[:overrides],
490
+ clear: false,
491
+ seed: task[:seed]
492
+ )
493
+ results[task[:orm_class].name] = n
494
+ end
495
+
496
+ results
497
+ end
498
+ end
499
+
500
+ # Run all seed files in the given folder.
501
+ #
502
+ # @param seed_folder [String] path to seed files (default: "seeds")
503
+ def self.seed(seed_folder: "seeds", clear: false)
504
+ unless Dir.exist?(seed_folder)
505
+ Tina4::Debug.info("Seeder: No seeds folder found at #{seed_folder}")
506
+ return
507
+ end
508
+
509
+ files = Dir.glob(File.join(seed_folder, "*.rb")).sort
510
+ files.reject! { |f| File.basename(f).start_with?("_") }
511
+
512
+ if files.empty?
513
+ Tina4::Debug.info("Seeder: No seed files found in #{seed_folder}")
514
+ return
515
+ end
516
+
517
+ Tina4::Debug.info("Seeder: Found #{files.length} seed file(s) in #{seed_folder}")
518
+
519
+ files.each do |filepath|
520
+ begin
521
+ Tina4::Debug.info("Seeder: Running #{File.basename(filepath)}...")
522
+ load filepath
523
+ Tina4::Debug.info("Seeder: Completed #{File.basename(filepath)}")
524
+ rescue => e
525
+ Tina4::Debug.error("Seeder: Failed to run #{File.basename(filepath)}: #{e.message}")
526
+ end
527
+ end
528
+ end
529
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+ require "securerandom"
3
+ require "json"
4
+
5
+ module Tina4
6
+ class Session
7
+ DEFAULT_OPTIONS = {
8
+ cookie_name: "tina4_session",
9
+ secret: nil,
10
+ max_age: 86400,
11
+ handler: :file,
12
+ handler_options: {}
13
+ }.freeze
14
+
15
+ attr_reader :id, :data
16
+
17
+ def initialize(env, options = {})
18
+ @options = DEFAULT_OPTIONS.merge(options)
19
+ @options[:secret] ||= ENV["SECRET"] || "tina4-default-secret"
20
+ @handler = create_handler
21
+ @id = extract_session_id(env) || SecureRandom.hex(32)
22
+ @data = load_session
23
+ @modified = false
24
+ end
25
+
26
+ def [](key)
27
+ @data[key.to_s]
28
+ end
29
+
30
+ def []=(key, value)
31
+ @data[key.to_s] = value
32
+ @modified = true
33
+ end
34
+
35
+ def delete(key)
36
+ @data.delete(key.to_s)
37
+ @modified = true
38
+ end
39
+
40
+ def clear
41
+ @data = {}
42
+ @modified = true
43
+ end
44
+
45
+ def to_hash
46
+ @data.dup
47
+ end
48
+
49
+ def save
50
+ return unless @modified
51
+ @handler.write(@id, @data)
52
+ @modified = false
53
+ end
54
+
55
+ def destroy
56
+ @handler.destroy(@id)
57
+ @data = {}
58
+ end
59
+
60
+ def cookie_header
61
+ "#{@options[:cookie_name]}=#{@id}; Path=/; HttpOnly; SameSite=Lax; Max-Age=#{@options[:max_age]}"
62
+ end
63
+
64
+ private
65
+
66
+ def extract_session_id(env)
67
+ cookie_str = env["HTTP_COOKIE"] || ""
68
+ cookie_str.split(";").each do |pair|
69
+ key, value = pair.strip.split("=", 2)
70
+ return value if key == @options[:cookie_name]
71
+ end
72
+ nil
73
+ end
74
+
75
+ def load_session
76
+ existing = @handler.read(@id)
77
+ existing || {}
78
+ end
79
+
80
+ def create_handler
81
+ case @options[:handler].to_sym
82
+ when :file
83
+ Tina4::SessionHandlers::FileHandler.new(@options[:handler_options])
84
+ when :redis
85
+ Tina4::SessionHandlers::RedisHandler.new(@options[:handler_options])
86
+ when :mongo, :mongodb
87
+ Tina4::SessionHandlers::MongoHandler.new(@options[:handler_options])
88
+ else
89
+ Tina4::SessionHandlers::FileHandler.new(@options[:handler_options])
90
+ end
91
+ end
92
+ end
93
+
94
+ class LazySession
95
+ def initialize(env, options = {})
96
+ @env = env
97
+ @options = options
98
+ @session = nil
99
+ end
100
+
101
+ def [](key)
102
+ ensure_loaded
103
+ @session[key]
104
+ end
105
+
106
+ def []=(key, value)
107
+ ensure_loaded
108
+ @session[key] = value
109
+ end
110
+
111
+ def delete(key)
112
+ ensure_loaded
113
+ @session.delete(key)
114
+ end
115
+
116
+ def clear
117
+ ensure_loaded
118
+ @session.clear
119
+ end
120
+
121
+ def save
122
+ @session&.save
123
+ end
124
+
125
+ def destroy
126
+ @session&.destroy
127
+ end
128
+
129
+ def cookie_header
130
+ ensure_loaded
131
+ @session.cookie_header
132
+ end
133
+
134
+ def to_hash
135
+ ensure_loaded
136
+ @session.to_hash
137
+ end
138
+
139
+ private
140
+
141
+ def ensure_loaded
142
+ @session ||= Session.new(@env, @options)
143
+ end
144
+ end
145
+ end