tina4ruby 3.11.13 → 3.11.15

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 (132) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +80 -80
  3. data/LICENSE.txt +21 -21
  4. data/README.md +137 -137
  5. data/exe/tina4ruby +5 -5
  6. data/lib/tina4/ai.rb +696 -696
  7. data/lib/tina4/api.rb +189 -189
  8. data/lib/tina4/auth.rb +305 -305
  9. data/lib/tina4/auto_crud.rb +244 -244
  10. data/lib/tina4/cache.rb +154 -154
  11. data/lib/tina4/cli.rb +1449 -1449
  12. data/lib/tina4/constants.rb +46 -46
  13. data/lib/tina4/container.rb +74 -74
  14. data/lib/tina4/cors.rb +74 -74
  15. data/lib/tina4/crud.rb +692 -692
  16. data/lib/tina4/database/sqlite3_adapter.rb +165 -165
  17. data/lib/tina4/database.rb +625 -625
  18. data/lib/tina4/database_result.rb +208 -208
  19. data/lib/tina4/debug.rb +8 -8
  20. data/lib/tina4/dev.rb +14 -14
  21. data/lib/tina4/dev_admin.rb +935 -935
  22. data/lib/tina4/dev_mailbox.rb +191 -191
  23. data/lib/tina4/drivers/firebird_driver.rb +124 -110
  24. data/lib/tina4/drivers/mongodb_driver.rb +561 -561
  25. data/lib/tina4/drivers/mssql_driver.rb +112 -112
  26. data/lib/tina4/drivers/mysql_driver.rb +90 -90
  27. data/lib/tina4/drivers/odbc_driver.rb +191 -191
  28. data/lib/tina4/drivers/postgres_driver.rb +116 -106
  29. data/lib/tina4/drivers/sqlite_driver.rb +122 -122
  30. data/lib/tina4/env.rb +95 -95
  31. data/lib/tina4/error_overlay.rb +252 -252
  32. data/lib/tina4/events.rb +109 -109
  33. data/lib/tina4/field_types.rb +154 -154
  34. data/lib/tina4/frond.rb +2025 -2025
  35. data/lib/tina4/gallery/auth/meta.json +1 -1
  36. data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +114 -114
  37. data/lib/tina4/gallery/database/meta.json +1 -1
  38. data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +43 -43
  39. data/lib/tina4/gallery/error-overlay/meta.json +1 -1
  40. data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +17 -17
  41. data/lib/tina4/gallery/orm/meta.json +1 -1
  42. data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +16 -16
  43. data/lib/tina4/gallery/queue/meta.json +1 -1
  44. data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +325 -325
  45. data/lib/tina4/gallery/rest-api/meta.json +1 -1
  46. data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +14 -14
  47. data/lib/tina4/gallery/templates/meta.json +1 -1
  48. data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +12 -12
  49. data/lib/tina4/gallery/templates/src/templates/gallery_page.twig +257 -257
  50. data/lib/tina4/graphql.rb +966 -966
  51. data/lib/tina4/health.rb +39 -39
  52. data/lib/tina4/html_element.rb +170 -170
  53. data/lib/tina4/job.rb +80 -80
  54. data/lib/tina4/localization.rb +168 -168
  55. data/lib/tina4/log.rb +203 -203
  56. data/lib/tina4/mcp.rb +696 -696
  57. data/lib/tina4/messenger.rb +587 -587
  58. data/lib/tina4/metrics.rb +793 -793
  59. data/lib/tina4/middleware.rb +445 -445
  60. data/lib/tina4/migration.rb +451 -451
  61. data/lib/tina4/orm.rb +790 -790
  62. data/lib/tina4/public/css/tina4.css +2463 -2463
  63. data/lib/tina4/public/css/tina4.min.css +1 -1
  64. data/lib/tina4/public/images/logo.svg +5 -5
  65. data/lib/tina4/public/js/frond.min.js +2 -2
  66. data/lib/tina4/public/js/tina4-dev-admin.js +565 -565
  67. data/lib/tina4/public/js/tina4-dev-admin.min.js +480 -480
  68. data/lib/tina4/public/js/tina4.min.js +92 -92
  69. data/lib/tina4/public/js/tina4js.min.js +48 -48
  70. data/lib/tina4/public/swagger/index.html +90 -90
  71. data/lib/tina4/public/swagger/oauth2-redirect.html +63 -63
  72. data/lib/tina4/query_builder.rb +380 -380
  73. data/lib/tina4/queue.rb +366 -366
  74. data/lib/tina4/queue_backends/kafka_backend.rb +80 -80
  75. data/lib/tina4/queue_backends/lite_backend.rb +298 -298
  76. data/lib/tina4/queue_backends/mongo_backend.rb +126 -126
  77. data/lib/tina4/queue_backends/rabbitmq_backend.rb +73 -73
  78. data/lib/tina4/rack_app.rb +817 -817
  79. data/lib/tina4/rate_limiter.rb +130 -130
  80. data/lib/tina4/request.rb +268 -255
  81. data/lib/tina4/response.rb +346 -346
  82. data/lib/tina4/response_cache.rb +551 -551
  83. data/lib/tina4/router.rb +406 -406
  84. data/lib/tina4/scss/tina4css/_alerts.scss +34 -34
  85. data/lib/tina4/scss/tina4css/_badges.scss +22 -22
  86. data/lib/tina4/scss/tina4css/_buttons.scss +69 -69
  87. data/lib/tina4/scss/tina4css/_cards.scss +49 -49
  88. data/lib/tina4/scss/tina4css/_forms.scss +156 -156
  89. data/lib/tina4/scss/tina4css/_grid.scss +81 -81
  90. data/lib/tina4/scss/tina4css/_modals.scss +84 -84
  91. data/lib/tina4/scss/tina4css/_nav.scss +149 -149
  92. data/lib/tina4/scss/tina4css/_reset.scss +94 -94
  93. data/lib/tina4/scss/tina4css/_tables.scss +54 -54
  94. data/lib/tina4/scss/tina4css/_typography.scss +55 -55
  95. data/lib/tina4/scss/tina4css/_utilities.scss +197 -197
  96. data/lib/tina4/scss/tina4css/_variables.scss +117 -117
  97. data/lib/tina4/scss/tina4css/base.scss +1 -1
  98. data/lib/tina4/scss/tina4css/colors.scss +48 -48
  99. data/lib/tina4/scss/tina4css/tina4.scss +17 -17
  100. data/lib/tina4/scss_compiler.rb +178 -178
  101. data/lib/tina4/seeder.rb +567 -567
  102. data/lib/tina4/service_runner.rb +303 -303
  103. data/lib/tina4/session.rb +297 -297
  104. data/lib/tina4/session_handlers/database_handler.rb +72 -72
  105. data/lib/tina4/session_handlers/file_handler.rb +67 -67
  106. data/lib/tina4/session_handlers/mongo_handler.rb +49 -49
  107. data/lib/tina4/session_handlers/redis_handler.rb +43 -43
  108. data/lib/tina4/session_handlers/valkey_handler.rb +43 -43
  109. data/lib/tina4/shutdown.rb +84 -84
  110. data/lib/tina4/sql_translation.rb +158 -158
  111. data/lib/tina4/swagger.rb +124 -124
  112. data/lib/tina4/template.rb +894 -894
  113. data/lib/tina4/templates/base.twig +26 -26
  114. data/lib/tina4/templates/errors/302.twig +14 -14
  115. data/lib/tina4/templates/errors/401.twig +9 -9
  116. data/lib/tina4/templates/errors/403.twig +29 -29
  117. data/lib/tina4/templates/errors/404.twig +29 -29
  118. data/lib/tina4/templates/errors/500.twig +38 -38
  119. data/lib/tina4/templates/errors/502.twig +9 -9
  120. data/lib/tina4/templates/errors/503.twig +12 -12
  121. data/lib/tina4/templates/errors/base.twig +37 -37
  122. data/lib/tina4/test_client.rb +159 -159
  123. data/lib/tina4/testing.rb +340 -340
  124. data/lib/tina4/validator.rb +174 -174
  125. data/lib/tina4/version.rb +1 -1
  126. data/lib/tina4/webserver.rb +312 -312
  127. data/lib/tina4/websocket.rb +343 -343
  128. data/lib/tina4/websocket_backplane.rb +190 -190
  129. data/lib/tina4/wsdl.rb +564 -564
  130. data/lib/tina4.rb +458 -458
  131. data/lib/tina4ruby.rb +4 -4
  132. metadata +3 -3
data/lib/tina4/seeder.rb CHANGED
@@ -1,567 +1,567 @@
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
- JOB_TITLES = [
89
- "Software Engineer", "Product Manager", "Designer", "Data Analyst",
90
- "DevOps Engineer", "CEO", "CTO", "Sales Manager", "Marketing Lead",
91
- "Accountant", "Operations Manager", "QA Engineer", "UX Researcher",
92
- "Support Specialist", "HR Manager", "Technical Writer"
93
- ].freeze
94
- CURRENCIES = %w[USD EUR GBP JPY CAD AUD CHF ZAR INR CNY].freeze
95
- CREDIT_CARD_PREFIXES = %w[4111 4242 5500 5105].freeze
96
-
97
- def initialize(seed: nil)
98
- @rng = seed ? Random.new(seed) : Random.new
99
- end
100
-
101
- # Static factory — create a seeded FakeData instance.
102
- # fake = FakeData.seed(42)
103
- # fake.name # deterministic
104
- def self.seed(seed)
105
- new(seed: seed)
106
- end
107
-
108
- def first_name
109
- FIRST_NAMES[@rng.rand(FIRST_NAMES.length)]
110
- end
111
-
112
- def last_name
113
- LAST_NAMES[@rng.rand(LAST_NAMES.length)]
114
- end
115
-
116
- def name
117
- "#{first_name} #{last_name}"
118
- end
119
-
120
- def email(from_name: nil)
121
- if from_name
122
- local = from_name.downcase.split.join(".")
123
- else
124
- local = "#{first_name.downcase}.#{last_name.downcase}"
125
- end
126
- local += @rng.rand(1..999).to_s
127
- "#{local}@#{DOMAINS[@rng.rand(DOMAINS.length)]}"
128
- end
129
-
130
- def phone
131
- area = @rng.rand(200..999)
132
- mid = @rng.rand(100..999)
133
- tail = @rng.rand(1000..9999)
134
- "+1 (#{area}) #{mid}-#{tail}"
135
- end
136
-
137
- def sentence(words: 6)
138
- w = Array.new(words) { WORDS[@rng.rand(WORDS.length)] }
139
- w[0] = w[0].capitalize
140
- "#{w.join(' ')}."
141
- end
142
-
143
- def paragraph(sentences: 3)
144
- Array.new(sentences) { sentence(words: @rng.rand(5..12)) }.join(" ")
145
- end
146
-
147
- def text(max_length: 200)
148
- t = paragraph(sentences: 2)
149
- t.length > max_length ? t[0...max_length] : t
150
- end
151
-
152
- def word
153
- WORDS[@rng.rand(WORDS.length)]
154
- end
155
-
156
- def slug(words: 3)
157
- Array.new(words) { WORDS[@rng.rand(WORDS.length)] }.join("-")
158
- end
159
-
160
- def url
161
- "https://#{DOMAINS[@rng.rand(DOMAINS.length)]}/#{slug}"
162
- end
163
-
164
- def integer(min: 0, max: 10_000)
165
- @rng.rand(min..max)
166
- end
167
-
168
- def numeric(min: 0.0, max: 1000.0, decimals: 2)
169
- val = min + @rng.rand * (max - min)
170
- val.round(decimals)
171
- end
172
-
173
- def boolean
174
- @rng.rand(2)
175
- end
176
-
177
- def datetime(start_year: 2020, end_year: 2026)
178
- start_time = Time.new(start_year, 1, 1)
179
- end_time = Time.new(end_year, 12, 31, 23, 59, 59)
180
- delta = (end_time - start_time).to_i
181
- Time.at(start_time.to_i + @rng.rand(0..delta))
182
- end
183
-
184
- def date(start_year: 2020, end_year: 2026)
185
- datetime(start_year: start_year, end_year: end_year).strftime("%Y-%m-%d")
186
- end
187
-
188
- def timestamp(start_year: 2020, end_year: 2026)
189
- datetime(start_year: start_year, end_year: end_year).strftime("%Y-%m-%d %H:%M:%S")
190
- end
191
-
192
- def blob(size: 64)
193
- SecureRandom.random_bytes(size)
194
- end
195
-
196
- def json_data(keys: nil)
197
- if keys
198
- keys.each_with_object({}) { |k, h| h[k] = word }
199
- else
200
- n = @rng.rand(2..5)
201
- n.times.each_with_object({}) { |_, h| h[word] = word }
202
- end
203
- end
204
-
205
- def choice(items)
206
- items[@rng.rand(items.length)]
207
- end
208
-
209
- def city
210
- CITIES[@rng.rand(CITIES.length)]
211
- end
212
-
213
- def country
214
- COUNTRIES[@rng.rand(COUNTRIES.length)]
215
- end
216
-
217
- def address
218
- "#{@rng.rand(1..9999)} #{STREETS[@rng.rand(STREETS.length)]} #{STREET_TYPES[@rng.rand(STREET_TYPES.length)]}"
219
- end
220
-
221
- def zip_code
222
- @rng.rand(10_000..99_999).to_s
223
- end
224
-
225
- def company
226
- w1 = COMPANY_WORDS[@rng.rand(COMPANY_WORDS.length)]
227
- w2 = COMPANY_WORDS[@rng.rand(COMPANY_WORDS.length)]
228
- suffix = COMPANY_SUFFIXES[@rng.rand(COMPANY_SUFFIXES.length)]
229
- "#{w1}#{w2} #{suffix}"
230
- end
231
-
232
- def job_title
233
- JOB_TITLES[@rng.rand(JOB_TITLES.length)]
234
- end
235
-
236
- def currency
237
- CURRENCIES[@rng.rand(CURRENCIES.length)]
238
- end
239
-
240
- def ip_address
241
- "#{@rng.rand(1..255)}.#{@rng.rand(0..255)}.#{@rng.rand(0..255)}.#{@rng.rand(1..254)}"
242
- end
243
-
244
- # Generate a fake credit card number (test numbers only, e.g. 4111...).
245
- def credit_card
246
- prefix = CREDIT_CARD_PREFIXES[@rng.rand(CREDIT_CARD_PREFIXES.length)]
247
- rest = Array.new(12) { @rng.rand(0..9) }.join
248
- prefix + rest
249
- end
250
-
251
- def color_hex
252
- "#%06x" % @rng.rand(0..0xFFFFFF)
253
- end
254
-
255
- def uuid
256
- h = Array.new(32) { "0123456789abcdef"[@rng.rand(16)] }.join
257
- "#{h[0..7]}-#{h[8..11]}-#{h[12..15]}-#{h[16..19]}-#{h[20..31]}"
258
- end
259
-
260
- def password(length: 16)
261
- chars = [*"a".."z", *"A".."Z", *"0".."9"]
262
- Array.new(length) { chars[@rng.rand(chars.length)] }.join
263
- end
264
-
265
- # Run a generator block `count` times and return the results.
266
- def run(count = 1, &block)
267
- Array.new(count) { block.call }
268
- end
269
-
270
- # Generate appropriate data based on field definition and column name.
271
- def for_field(field_def, column_name = nil)
272
- col = (column_name || "").to_s.downcase
273
- type = field_def[:type]
274
-
275
- # Skip auto-increment primary keys
276
- return nil if field_def[:primary_key] && field_def[:auto_increment]
277
-
278
- case type
279
- when :integer
280
- return integer(min: 18, max: 85) if col.include?("age")
281
- return integer(min: 1950, max: 2026) if col.include?("year")
282
- return integer(min: 1, max: 100) if col =~ /quantity|qty|count/
283
- return boolean if col =~ /active|enabled|visible|^is_/
284
- return integer(min: 1, max: 10) if col =~ /rating|score/
285
- integer(min: 1, max: 10_000)
286
-
287
- when :float, :decimal
288
- decimals = field_def[:scale] || 2
289
- return numeric(min: 0.01, max: 9999.99, decimals: decimals) if col =~ /price|cost|amount|total|fee/
290
- return numeric(min: 0.0, max: 100.0, decimals: decimals) if col =~ /rate|percent|ratio/
291
- return numeric(min: -90.0, max: 90.0, decimals: 6) if col.include?("lat")
292
- return numeric(min: -180.0, max: 180.0, decimals: 6) if col =~ /lon|lng/
293
- numeric(min: 0.0, max: 10_000.0, decimals: decimals)
294
-
295
- when :date
296
- date
297
-
298
- when :datetime, :timestamp
299
- timestamp
300
-
301
- when :boolean
302
- boolean
303
-
304
- when :blob
305
- blob
306
-
307
- when :json
308
- json_data
309
-
310
- when :string, :text
311
- max_len = field_def[:length] || 255
312
- val = generate_string_for(col, max_len)
313
- val.length > max_len ? val[0...max_len] : val
314
-
315
- else
316
- word
317
- end
318
- end
319
-
320
- private
321
-
322
- def generate_string_for(col, max_len)
323
- return email[0...max_len] if col.include?("email")
324
- return name[0...max_len] if %w[name full_name fullname display_name].include?(col)
325
- return first_name[0...max_len] if col.include?("first") && col.include?("name")
326
- return last_name[0...max_len] if col.include?("last") && col.include?("name")
327
- return last_name[0...max_len] if col =~ /surname|family_name/
328
- return phone[0...max_len] if col =~ /phone|tel|mobile|cell/
329
- return url[0...max_len] if col =~ /url|website|link|href/
330
- return address[0...max_len] if col =~ /address|street/
331
- return city[0...max_len] if col =~ /city|town/
332
- return country[0...max_len] if col.include?("country")
333
- return zip_code[0...max_len] if col =~ /zip|postal/
334
- return company[0...max_len] if col =~ /company|organization|org/
335
- return color_hex[0...max_len] if col =~ /color|colour/
336
- return uuid[0...max_len] if col =~ /uuid|guid/
337
- return slug[0...max_len] if col.include?("slug")
338
- return sentence(words: @rng.rand(3..6)).chomp(".")[0...max_len] if col =~ /title|subject|heading/
339
- return text(max_length: max_len) if col =~ /description|summary|bio|about/
340
- return paragraph(sentences: 2)[0...max_len] if col =~ /content|body|text|note|comment/
341
- return choice(%w[active inactive pending archived])[0...max_len] if col.include?("status")
342
- return choice(%w[standard premium basic enterprise custom])[0...max_len] if col =~ /type|category|kind/
343
- return word[0...max_len] if col =~ /tag|label/
344
- return password(length: [16, max_len].min) if col =~ /password|pass|secret/
345
- return password(length: [32, max_len].min) if col =~ /token|key|hash/
346
- return "#{first_name.downcase}#{@rng.rand(1..99)}"[0...max_len] if col =~ /username|user_name|login/
347
-
348
- sentence(words: @rng.rand(2..5)).chomp(".")[0...max_len]
349
- end
350
- end
351
-
352
- # Seed an ORM class with auto-generated fake data.
353
- #
354
- # @param orm_class [Class] ORM subclass (e.g., User, Product)
355
- # @param count [Integer] number of records to insert
356
- # @param overrides [Hash] field overrides — static values or lambdas receiving FakeData
357
- # @param clear [Boolean] delete existing records before seeding
358
- # @param seed [Integer, nil] random seed for reproducible data
359
- # @return [Integer] number of records inserted
360
- #
361
- # @example
362
- # Tina4.seed_orm(User, count: 50)
363
- # Tina4.seed_orm(Order, count: 200, overrides: { status: ->(f) { f.choice(%w[pending shipped]) } })
364
- def self.seed_orm(orm_class, count: 10, overrides: {}, clear: false, seed: nil)
365
- fake = FakeData.new(seed: seed)
366
- fields = orm_class.field_definitions
367
- table = orm_class.table_name
368
-
369
- if fields.empty?
370
- Tina4::Log.error("Seeder: No fields found on #{orm_class.name}")
371
- return 0
372
- end
373
-
374
- db = Tina4.database
375
- unless db
376
- Tina4::Log.error("Seeder: No database connection. Set Tina4.database first.")
377
- return 0
378
- end
379
-
380
- # Idempotency check
381
- unless clear
382
- begin
383
- result = db.fetch_one("SELECT count(*) as cnt FROM #{table}")
384
- if result && result[:cnt].to_i >= count
385
- Tina4::Log.info("Seeder: #{table} already has #{result[:cnt]} records, skipping")
386
- return 0
387
- end
388
- rescue => e
389
- # Table might not exist
390
- end
391
- end
392
-
393
- # Clear if requested
394
- if clear
395
- begin
396
- db.execute("DELETE FROM #{table}")
397
- Tina4::Log.info("Seeder: Cleared #{table}")
398
- rescue => e
399
- Tina4::Log.warn("Seeder: Could not clear #{table}: #{e.message}")
400
- end
401
- end
402
-
403
- # Identify fields to populate
404
- pk_field = orm_class.primary_key_field
405
- insert_fields = fields.reject { |name, opts| opts[:primary_key] && opts[:auto_increment] }
406
-
407
- inserted = 0
408
- count.times do |i|
409
- attrs = {}
410
-
411
- insert_fields.each do |name, field_def|
412
- if overrides.key?(name)
413
- val = overrides[name]
414
- attrs[name] = val.respond_to?(:call) ? val.call(fake) : val
415
- else
416
- generated = fake.for_field(field_def, name)
417
- attrs[name] = generated unless generated.nil?
418
- end
419
- end
420
-
421
- begin
422
- obj = orm_class.new(attrs)
423
- if obj.save
424
- inserted += 1
425
- else
426
- Tina4::Log.warn("Seeder: Insert failed for #{table} row #{i + 1}: #{obj.errors.join(', ')}")
427
- end
428
- rescue => e
429
- Tina4::Log.warn("Seeder: Insert failed for #{table} row #{i + 1}: #{e.message}")
430
- end
431
- end
432
-
433
- Tina4::Log.info("Seeder: Inserted #{inserted}/#{count} records into #{table}")
434
- inserted
435
- end
436
-
437
- # Seed a raw database table (no ORM class needed).
438
- #
439
- # @param table_name [String] name of the table
440
- # @param columns [Hash] { column_name: type_string } — supports :integer, :string, :text, etc.
441
- # @param count [Integer] number of records to insert
442
- # @param overrides [Hash] field overrides
443
- # @param clear [Boolean] delete before seeding
444
- # @param seed [Integer, nil] random seed
445
- # @return [Integer] records inserted
446
- def self.seed_table(table_name, columns, count: 10, overrides: {}, clear: false, seed: nil)
447
- fake = FakeData.new(seed: seed)
448
- db = Tina4.database
449
-
450
- unless db
451
- Tina4::Log.error("Seeder: No database connection.")
452
- return 0
453
- end
454
-
455
- if clear
456
- begin
457
- db.execute("DELETE FROM #{table_name}")
458
- rescue => e
459
- Tina4::Log.warn("Seeder: Could not clear #{table_name}: #{e.message}")
460
- end
461
- end
462
-
463
- inserted = 0
464
- count.times do |i|
465
- row = {}
466
- columns.each do |col_name, type_str|
467
- if overrides.key?(col_name)
468
- val = overrides[col_name]
469
- row[col_name] = val.respond_to?(:call) ? val.call(fake) : val
470
- else
471
- field_def = { type: type_str.to_sym }
472
- row[col_name] = fake.for_field(field_def, col_name)
473
- end
474
- end
475
-
476
- begin
477
- db.insert(table_name, row)
478
- inserted += 1
479
- rescue => e
480
- Tina4::Log.warn("Seeder: Insert failed for #{table_name} row #{i + 1}: #{e.message}")
481
- end
482
- end
483
-
484
- Tina4::Log.info("Seeder: Inserted #{inserted}/#{count} records into #{table_name}")
485
- inserted
486
- end
487
-
488
- # Seed multiple ORM classes in batch with optional dependency-aware clearing.
489
- #
490
- # @param tasks [Array<Hash>] each hash has :orm_class, :count, :overrides, :seed
491
- # @param clear [Boolean] delete existing records (in reverse order) before seeding
492
- # @return [Hash] { "ClassName" => inserted_count, ... }
493
- #
494
- # @example
495
- # Tina4.seed_batch([
496
- # { orm_class: User, count: 20 },
497
- # { orm_class: Order, count: 100, overrides: { status: "pending" } }
498
- # ], clear: true)
499
- def self.seed_batch(tasks, clear: false)
500
- results = {}
501
-
502
- if clear
503
- tasks.reverse_each do |task|
504
- begin
505
- Tina4.database&.execute("DELETE FROM #{task[:orm_class].table_name}")
506
- Tina4::Log.info("Seeder: Cleared #{task[:orm_class].table_name}")
507
- rescue => e
508
- Tina4::Log.warn("Seeder: Could not clear #{task[:orm_class].table_name}: #{e.message}")
509
- end
510
- end
511
- end
512
-
513
- tasks.each do |task|
514
- n = Tina4.seed_orm(
515
- task[:orm_class],
516
- count: task[:count] || 10,
517
- overrides: task[:overrides] || {},
518
- clear: false,
519
- seed: task[:seed]
520
- )
521
- results[task[:orm_class].name] = n
522
- end
523
-
524
- results
525
- end
526
-
527
- # Run all seed files in the given folder.
528
- #
529
- # Parity: Python/PHP/Node use `seed(n)` to set the PRNG seed on FakeData.
530
- # Ruby's FakeData.seed already does that — this folder-runner is named
531
- # differently to avoid the collision.
532
- #
533
- # @param seed_folder [String] path to seed files (default: "seeds")
534
- def self.run_seeds(seed_folder: "seeds", clear: false)
535
- seed_dir(seed_folder: seed_folder, clear: clear)
536
- end
537
-
538
- # Run all seed files in the given folder.
539
- #
540
- # @param seed_folder [String] path to seed files (default: "seeds")
541
- def self.seed_dir(seed_folder: "seeds", clear: false)
542
- unless Dir.exist?(seed_folder)
543
- Tina4::Log.info("Seeder: No seeds folder found at #{seed_folder}")
544
- return
545
- end
546
-
547
- files = Dir.glob(File.join(seed_folder, "*.rb")).sort
548
- files.reject! { |f| File.basename(f).start_with?("_") }
549
-
550
- if files.empty?
551
- Tina4::Log.info("Seeder: No seed files found in #{seed_folder}")
552
- return
553
- end
554
-
555
- Tina4::Log.info("Seeder: Found #{files.length} seed file(s) in #{seed_folder}")
556
-
557
- files.each do |filepath|
558
- begin
559
- Tina4::Log.info("Seeder: Running #{File.basename(filepath)}...")
560
- load filepath
561
- Tina4::Log.info("Seeder: Completed #{File.basename(filepath)}")
562
- rescue => e
563
- Tina4::Log.error("Seeder: Failed to run #{File.basename(filepath)}: #{e.message}")
564
- end
565
- end
566
- end
567
- end
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
+ JOB_TITLES = [
89
+ "Software Engineer", "Product Manager", "Designer", "Data Analyst",
90
+ "DevOps Engineer", "CEO", "CTO", "Sales Manager", "Marketing Lead",
91
+ "Accountant", "Operations Manager", "QA Engineer", "UX Researcher",
92
+ "Support Specialist", "HR Manager", "Technical Writer"
93
+ ].freeze
94
+ CURRENCIES = %w[USD EUR GBP JPY CAD AUD CHF ZAR INR CNY].freeze
95
+ CREDIT_CARD_PREFIXES = %w[4111 4242 5500 5105].freeze
96
+
97
+ def initialize(seed: nil)
98
+ @rng = seed ? Random.new(seed) : Random.new
99
+ end
100
+
101
+ # Static factory — create a seeded FakeData instance.
102
+ # fake = FakeData.seed(42)
103
+ # fake.name # deterministic
104
+ def self.seed(seed)
105
+ new(seed: seed)
106
+ end
107
+
108
+ def first_name
109
+ FIRST_NAMES[@rng.rand(FIRST_NAMES.length)]
110
+ end
111
+
112
+ def last_name
113
+ LAST_NAMES[@rng.rand(LAST_NAMES.length)]
114
+ end
115
+
116
+ def name
117
+ "#{first_name} #{last_name}"
118
+ end
119
+
120
+ def email(from_name: nil)
121
+ if from_name
122
+ local = from_name.downcase.split.join(".")
123
+ else
124
+ local = "#{first_name.downcase}.#{last_name.downcase}"
125
+ end
126
+ local += @rng.rand(1..999).to_s
127
+ "#{local}@#{DOMAINS[@rng.rand(DOMAINS.length)]}"
128
+ end
129
+
130
+ def phone
131
+ area = @rng.rand(200..999)
132
+ mid = @rng.rand(100..999)
133
+ tail = @rng.rand(1000..9999)
134
+ "+1 (#{area}) #{mid}-#{tail}"
135
+ end
136
+
137
+ def sentence(words: 6)
138
+ w = Array.new(words) { WORDS[@rng.rand(WORDS.length)] }
139
+ w[0] = w[0].capitalize
140
+ "#{w.join(' ')}."
141
+ end
142
+
143
+ def paragraph(sentences: 3)
144
+ Array.new(sentences) { sentence(words: @rng.rand(5..12)) }.join(" ")
145
+ end
146
+
147
+ def text(max_length: 200)
148
+ t = paragraph(sentences: 2)
149
+ t.length > max_length ? t[0...max_length] : t
150
+ end
151
+
152
+ def word
153
+ WORDS[@rng.rand(WORDS.length)]
154
+ end
155
+
156
+ def slug(words: 3)
157
+ Array.new(words) { WORDS[@rng.rand(WORDS.length)] }.join("-")
158
+ end
159
+
160
+ def url
161
+ "https://#{DOMAINS[@rng.rand(DOMAINS.length)]}/#{slug}"
162
+ end
163
+
164
+ def integer(min: 0, max: 10_000)
165
+ @rng.rand(min..max)
166
+ end
167
+
168
+ def numeric(min: 0.0, max: 1000.0, decimals: 2)
169
+ val = min + @rng.rand * (max - min)
170
+ val.round(decimals)
171
+ end
172
+
173
+ def boolean
174
+ @rng.rand(2)
175
+ end
176
+
177
+ def datetime(start_year: 2020, end_year: 2026)
178
+ start_time = Time.new(start_year, 1, 1)
179
+ end_time = Time.new(end_year, 12, 31, 23, 59, 59)
180
+ delta = (end_time - start_time).to_i
181
+ Time.at(start_time.to_i + @rng.rand(0..delta))
182
+ end
183
+
184
+ def date(start_year: 2020, end_year: 2026)
185
+ datetime(start_year: start_year, end_year: end_year).strftime("%Y-%m-%d")
186
+ end
187
+
188
+ def timestamp(start_year: 2020, end_year: 2026)
189
+ datetime(start_year: start_year, end_year: end_year).strftime("%Y-%m-%d %H:%M:%S")
190
+ end
191
+
192
+ def blob(size: 64)
193
+ SecureRandom.random_bytes(size)
194
+ end
195
+
196
+ def json_data(keys: nil)
197
+ if keys
198
+ keys.each_with_object({}) { |k, h| h[k] = word }
199
+ else
200
+ n = @rng.rand(2..5)
201
+ n.times.each_with_object({}) { |_, h| h[word] = word }
202
+ end
203
+ end
204
+
205
+ def choice(items)
206
+ items[@rng.rand(items.length)]
207
+ end
208
+
209
+ def city
210
+ CITIES[@rng.rand(CITIES.length)]
211
+ end
212
+
213
+ def country
214
+ COUNTRIES[@rng.rand(COUNTRIES.length)]
215
+ end
216
+
217
+ def address
218
+ "#{@rng.rand(1..9999)} #{STREETS[@rng.rand(STREETS.length)]} #{STREET_TYPES[@rng.rand(STREET_TYPES.length)]}"
219
+ end
220
+
221
+ def zip_code
222
+ @rng.rand(10_000..99_999).to_s
223
+ end
224
+
225
+ def company
226
+ w1 = COMPANY_WORDS[@rng.rand(COMPANY_WORDS.length)]
227
+ w2 = COMPANY_WORDS[@rng.rand(COMPANY_WORDS.length)]
228
+ suffix = COMPANY_SUFFIXES[@rng.rand(COMPANY_SUFFIXES.length)]
229
+ "#{w1}#{w2} #{suffix}"
230
+ end
231
+
232
+ def job_title
233
+ JOB_TITLES[@rng.rand(JOB_TITLES.length)]
234
+ end
235
+
236
+ def currency
237
+ CURRENCIES[@rng.rand(CURRENCIES.length)]
238
+ end
239
+
240
+ def ip_address
241
+ "#{@rng.rand(1..255)}.#{@rng.rand(0..255)}.#{@rng.rand(0..255)}.#{@rng.rand(1..254)}"
242
+ end
243
+
244
+ # Generate a fake credit card number (test numbers only, e.g. 4111...).
245
+ def credit_card
246
+ prefix = CREDIT_CARD_PREFIXES[@rng.rand(CREDIT_CARD_PREFIXES.length)]
247
+ rest = Array.new(12) { @rng.rand(0..9) }.join
248
+ prefix + rest
249
+ end
250
+
251
+ def color_hex
252
+ "#%06x" % @rng.rand(0..0xFFFFFF)
253
+ end
254
+
255
+ def uuid
256
+ h = Array.new(32) { "0123456789abcdef"[@rng.rand(16)] }.join
257
+ "#{h[0..7]}-#{h[8..11]}-#{h[12..15]}-#{h[16..19]}-#{h[20..31]}"
258
+ end
259
+
260
+ def password(length: 16)
261
+ chars = [*"a".."z", *"A".."Z", *"0".."9"]
262
+ Array.new(length) { chars[@rng.rand(chars.length)] }.join
263
+ end
264
+
265
+ # Run a generator block `count` times and return the results.
266
+ def run(count = 1, &block)
267
+ Array.new(count) { block.call }
268
+ end
269
+
270
+ # Generate appropriate data based on field definition and column name.
271
+ def for_field(field_def, column_name = nil)
272
+ col = (column_name || "").to_s.downcase
273
+ type = field_def[:type]
274
+
275
+ # Skip auto-increment primary keys
276
+ return nil if field_def[:primary_key] && field_def[:auto_increment]
277
+
278
+ case type
279
+ when :integer
280
+ return integer(min: 18, max: 85) if col.include?("age")
281
+ return integer(min: 1950, max: 2026) if col.include?("year")
282
+ return integer(min: 1, max: 100) if col =~ /quantity|qty|count/
283
+ return boolean if col =~ /active|enabled|visible|^is_/
284
+ return integer(min: 1, max: 10) if col =~ /rating|score/
285
+ integer(min: 1, max: 10_000)
286
+
287
+ when :float, :decimal
288
+ decimals = field_def[:scale] || 2
289
+ return numeric(min: 0.01, max: 9999.99, decimals: decimals) if col =~ /price|cost|amount|total|fee/
290
+ return numeric(min: 0.0, max: 100.0, decimals: decimals) if col =~ /rate|percent|ratio/
291
+ return numeric(min: -90.0, max: 90.0, decimals: 6) if col.include?("lat")
292
+ return numeric(min: -180.0, max: 180.0, decimals: 6) if col =~ /lon|lng/
293
+ numeric(min: 0.0, max: 10_000.0, decimals: decimals)
294
+
295
+ when :date
296
+ date
297
+
298
+ when :datetime, :timestamp
299
+ timestamp
300
+
301
+ when :boolean
302
+ boolean
303
+
304
+ when :blob
305
+ blob
306
+
307
+ when :json
308
+ json_data
309
+
310
+ when :string, :text
311
+ max_len = field_def[:length] || 255
312
+ val = generate_string_for(col, max_len)
313
+ val.length > max_len ? val[0...max_len] : val
314
+
315
+ else
316
+ word
317
+ end
318
+ end
319
+
320
+ private
321
+
322
+ def generate_string_for(col, max_len)
323
+ return email[0...max_len] if col.include?("email")
324
+ return name[0...max_len] if %w[name full_name fullname display_name].include?(col)
325
+ return first_name[0...max_len] if col.include?("first") && col.include?("name")
326
+ return last_name[0...max_len] if col.include?("last") && col.include?("name")
327
+ return last_name[0...max_len] if col =~ /surname|family_name/
328
+ return phone[0...max_len] if col =~ /phone|tel|mobile|cell/
329
+ return url[0...max_len] if col =~ /url|website|link|href/
330
+ return address[0...max_len] if col =~ /address|street/
331
+ return city[0...max_len] if col =~ /city|town/
332
+ return country[0...max_len] if col.include?("country")
333
+ return zip_code[0...max_len] if col =~ /zip|postal/
334
+ return company[0...max_len] if col =~ /company|organization|org/
335
+ return color_hex[0...max_len] if col =~ /color|colour/
336
+ return uuid[0...max_len] if col =~ /uuid|guid/
337
+ return slug[0...max_len] if col.include?("slug")
338
+ return sentence(words: @rng.rand(3..6)).chomp(".")[0...max_len] if col =~ /title|subject|heading/
339
+ return text(max_length: max_len) if col =~ /description|summary|bio|about/
340
+ return paragraph(sentences: 2)[0...max_len] if col =~ /content|body|text|note|comment/
341
+ return choice(%w[active inactive pending archived])[0...max_len] if col.include?("status")
342
+ return choice(%w[standard premium basic enterprise custom])[0...max_len] if col =~ /type|category|kind/
343
+ return word[0...max_len] if col =~ /tag|label/
344
+ return password(length: [16, max_len].min) if col =~ /password|pass|secret/
345
+ return password(length: [32, max_len].min) if col =~ /token|key|hash/
346
+ return "#{first_name.downcase}#{@rng.rand(1..99)}"[0...max_len] if col =~ /username|user_name|login/
347
+
348
+ sentence(words: @rng.rand(2..5)).chomp(".")[0...max_len]
349
+ end
350
+ end
351
+
352
+ # Seed an ORM class with auto-generated fake data.
353
+ #
354
+ # @param orm_class [Class] ORM subclass (e.g., User, Product)
355
+ # @param count [Integer] number of records to insert
356
+ # @param overrides [Hash] field overrides — static values or lambdas receiving FakeData
357
+ # @param clear [Boolean] delete existing records before seeding
358
+ # @param seed [Integer, nil] random seed for reproducible data
359
+ # @return [Integer] number of records inserted
360
+ #
361
+ # @example
362
+ # Tina4.seed_orm(User, count: 50)
363
+ # Tina4.seed_orm(Order, count: 200, overrides: { status: ->(f) { f.choice(%w[pending shipped]) } })
364
+ def self.seed_orm(orm_class, count: 10, overrides: {}, clear: false, seed: nil)
365
+ fake = FakeData.new(seed: seed)
366
+ fields = orm_class.field_definitions
367
+ table = orm_class.table_name
368
+
369
+ if fields.empty?
370
+ Tina4::Log.error("Seeder: No fields found on #{orm_class.name}")
371
+ return 0
372
+ end
373
+
374
+ db = Tina4.database
375
+ unless db
376
+ Tina4::Log.error("Seeder: No database connection. Set Tina4.database first.")
377
+ return 0
378
+ end
379
+
380
+ # Idempotency check
381
+ unless clear
382
+ begin
383
+ result = db.fetch_one("SELECT count(*) as cnt FROM #{table}")
384
+ if result && result[:cnt].to_i >= count
385
+ Tina4::Log.info("Seeder: #{table} already has #{result[:cnt]} records, skipping")
386
+ return 0
387
+ end
388
+ rescue => e
389
+ # Table might not exist
390
+ end
391
+ end
392
+
393
+ # Clear if requested
394
+ if clear
395
+ begin
396
+ db.execute("DELETE FROM #{table}")
397
+ Tina4::Log.info("Seeder: Cleared #{table}")
398
+ rescue => e
399
+ Tina4::Log.warn("Seeder: Could not clear #{table}: #{e.message}")
400
+ end
401
+ end
402
+
403
+ # Identify fields to populate
404
+ pk_field = orm_class.primary_key_field
405
+ insert_fields = fields.reject { |name, opts| opts[:primary_key] && opts[:auto_increment] }
406
+
407
+ inserted = 0
408
+ count.times do |i|
409
+ attrs = {}
410
+
411
+ insert_fields.each do |name, field_def|
412
+ if overrides.key?(name)
413
+ val = overrides[name]
414
+ attrs[name] = val.respond_to?(:call) ? val.call(fake) : val
415
+ else
416
+ generated = fake.for_field(field_def, name)
417
+ attrs[name] = generated unless generated.nil?
418
+ end
419
+ end
420
+
421
+ begin
422
+ obj = orm_class.new(attrs)
423
+ if obj.save
424
+ inserted += 1
425
+ else
426
+ Tina4::Log.warn("Seeder: Insert failed for #{table} row #{i + 1}: #{obj.errors.join(', ')}")
427
+ end
428
+ rescue => e
429
+ Tina4::Log.warn("Seeder: Insert failed for #{table} row #{i + 1}: #{e.message}")
430
+ end
431
+ end
432
+
433
+ Tina4::Log.info("Seeder: Inserted #{inserted}/#{count} records into #{table}")
434
+ inserted
435
+ end
436
+
437
+ # Seed a raw database table (no ORM class needed).
438
+ #
439
+ # @param table_name [String] name of the table
440
+ # @param columns [Hash] { column_name: type_string } — supports :integer, :string, :text, etc.
441
+ # @param count [Integer] number of records to insert
442
+ # @param overrides [Hash] field overrides
443
+ # @param clear [Boolean] delete before seeding
444
+ # @param seed [Integer, nil] random seed
445
+ # @return [Integer] records inserted
446
+ def self.seed_table(table_name, columns, count: 10, overrides: {}, clear: false, seed: nil)
447
+ fake = FakeData.new(seed: seed)
448
+ db = Tina4.database
449
+
450
+ unless db
451
+ Tina4::Log.error("Seeder: No database connection.")
452
+ return 0
453
+ end
454
+
455
+ if clear
456
+ begin
457
+ db.execute("DELETE FROM #{table_name}")
458
+ rescue => e
459
+ Tina4::Log.warn("Seeder: Could not clear #{table_name}: #{e.message}")
460
+ end
461
+ end
462
+
463
+ inserted = 0
464
+ count.times do |i|
465
+ row = {}
466
+ columns.each do |col_name, type_str|
467
+ if overrides.key?(col_name)
468
+ val = overrides[col_name]
469
+ row[col_name] = val.respond_to?(:call) ? val.call(fake) : val
470
+ else
471
+ field_def = { type: type_str.to_sym }
472
+ row[col_name] = fake.for_field(field_def, col_name)
473
+ end
474
+ end
475
+
476
+ begin
477
+ db.insert(table_name, row)
478
+ inserted += 1
479
+ rescue => e
480
+ Tina4::Log.warn("Seeder: Insert failed for #{table_name} row #{i + 1}: #{e.message}")
481
+ end
482
+ end
483
+
484
+ Tina4::Log.info("Seeder: Inserted #{inserted}/#{count} records into #{table_name}")
485
+ inserted
486
+ end
487
+
488
+ # Seed multiple ORM classes in batch with optional dependency-aware clearing.
489
+ #
490
+ # @param tasks [Array<Hash>] each hash has :orm_class, :count, :overrides, :seed
491
+ # @param clear [Boolean] delete existing records (in reverse order) before seeding
492
+ # @return [Hash] { "ClassName" => inserted_count, ... }
493
+ #
494
+ # @example
495
+ # Tina4.seed_batch([
496
+ # { orm_class: User, count: 20 },
497
+ # { orm_class: Order, count: 100, overrides: { status: "pending" } }
498
+ # ], clear: true)
499
+ def self.seed_batch(tasks, clear: false)
500
+ results = {}
501
+
502
+ if clear
503
+ tasks.reverse_each do |task|
504
+ begin
505
+ Tina4.database&.execute("DELETE FROM #{task[:orm_class].table_name}")
506
+ Tina4::Log.info("Seeder: Cleared #{task[:orm_class].table_name}")
507
+ rescue => e
508
+ Tina4::Log.warn("Seeder: Could not clear #{task[:orm_class].table_name}: #{e.message}")
509
+ end
510
+ end
511
+ end
512
+
513
+ tasks.each do |task|
514
+ n = Tina4.seed_orm(
515
+ task[:orm_class],
516
+ count: task[:count] || 10,
517
+ overrides: task[:overrides] || {},
518
+ clear: false,
519
+ seed: task[:seed]
520
+ )
521
+ results[task[:orm_class].name] = n
522
+ end
523
+
524
+ results
525
+ end
526
+
527
+ # Run all seed files in the given folder.
528
+ #
529
+ # Parity: Python/PHP/Node use `seed(n)` to set the PRNG seed on FakeData.
530
+ # Ruby's FakeData.seed already does that — this folder-runner is named
531
+ # differently to avoid the collision.
532
+ #
533
+ # @param seed_folder [String] path to seed files (default: "seeds")
534
+ def self.run_seeds(seed_folder: "seeds", clear: false)
535
+ seed_dir(seed_folder: seed_folder, clear: clear)
536
+ end
537
+
538
+ # Run all seed files in the given folder.
539
+ #
540
+ # @param seed_folder [String] path to seed files (default: "seeds")
541
+ def self.seed_dir(seed_folder: "seeds", clear: false)
542
+ unless Dir.exist?(seed_folder)
543
+ Tina4::Log.info("Seeder: No seeds folder found at #{seed_folder}")
544
+ return
545
+ end
546
+
547
+ files = Dir.glob(File.join(seed_folder, "*.rb")).sort
548
+ files.reject! { |f| File.basename(f).start_with?("_") }
549
+
550
+ if files.empty?
551
+ Tina4::Log.info("Seeder: No seed files found in #{seed_folder}")
552
+ return
553
+ end
554
+
555
+ Tina4::Log.info("Seeder: Found #{files.length} seed file(s) in #{seed_folder}")
556
+
557
+ files.each do |filepath|
558
+ begin
559
+ Tina4::Log.info("Seeder: Running #{File.basename(filepath)}...")
560
+ load filepath
561
+ Tina4::Log.info("Seeder: Completed #{File.basename(filepath)}")
562
+ rescue => e
563
+ Tina4::Log.error("Seeder: Failed to run #{File.basename(filepath)}: #{e.message}")
564
+ end
565
+ end
566
+ end
567
+ end