AIFaker 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/AIFaker/client.rb +133 -0
- data/lib/AIFaker/errors.rb +9 -0
- data/lib/AIFaker/llm_adapters/ruby_llm.rb +482 -0
- data/lib/AIFaker/model_introspector.rb +89 -0
- data/lib/AIFaker/schema_introspector.rb +25 -0
- data/lib/AIFaker/seed_planner.rb +64 -0
- data/lib/AIFaker/seed_runner.rb +281 -0
- data/lib/AIFaker/ui.rb +175 -0
- data/lib/AIFaker/version.rb +1 -1
- metadata +11 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ddbf0a8c9db82f66bf23693b731ccebf9998277c13f290257d50ce6b67042fa9
|
|
4
|
+
data.tar.gz: fd3219303d5164f2df18255fbd8bb6c3820cbaf17c48c5a94bd8361ab8e78dcb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: cca0aaba360d89afd686dfc4b46e08ad32aa8c737ab14589ee585c7b8813dff0bac9755625baea8b1628bda2e5cbb341f479ae40cc04c34914ff99d2a4d59c84
|
|
7
|
+
data.tar.gz: a2ef54fd5527801185e47f265d0ba75f2035ebbeeb13fe1a8ebe1a4ba02defa97d8c3334345d4e4b0cd302cf0da8097c15b93a252ad2f8a11327a23de9994572
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "stringio"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
|
|
6
|
+
require_relative "errors"
|
|
7
|
+
require_relative "ui"
|
|
8
|
+
require_relative "schema_introspector"
|
|
9
|
+
require_relative "model_introspector"
|
|
10
|
+
require_relative "seed_planner"
|
|
11
|
+
require_relative "seed_runner"
|
|
12
|
+
require_relative "llm_adapters/ruby_llm"
|
|
13
|
+
|
|
14
|
+
module AIFaker
|
|
15
|
+
class Client
|
|
16
|
+
def self.connect(provider = nil, **options)
|
|
17
|
+
provider = normalize_provider(provider || ENV["AIFAKER_PROVIDER"] || "openai")
|
|
18
|
+
|
|
19
|
+
ui = options.delete(:ui) || UI.new
|
|
20
|
+
|
|
21
|
+
unless LLMAdapters::RubyLLM.available?
|
|
22
|
+
raise ConnectionError, 'Missing dependency: add `gem "ruby_llm"` to your Gemfile.'
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
adapter = LLMAdapters::RubyLLM.new(provider:, ui:, **options)
|
|
26
|
+
|
|
27
|
+
new(adapter:, ui:)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.normalize_provider(raw)
|
|
31
|
+
value = raw.to_s.strip.downcase
|
|
32
|
+
value = "anthropic" if value == "claude"
|
|
33
|
+
value
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def initialize(adapter:, ui: UI.new)
|
|
37
|
+
@adapter = adapter
|
|
38
|
+
@ui = ui
|
|
39
|
+
@schema = nil
|
|
40
|
+
@models = nil
|
|
41
|
+
@associations = nil
|
|
42
|
+
@plan = nil
|
|
43
|
+
@automode = false
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
attr_reader :automode
|
|
47
|
+
|
|
48
|
+
def automode=(value)
|
|
49
|
+
@automode = !!value
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Enables/disables non-interactive mode.
|
|
53
|
+
# When enabled, AIFaker assumes "yes" for all prompts and picks a random count 10..20.
|
|
54
|
+
def automode!(value = true)
|
|
55
|
+
self.automode = value
|
|
56
|
+
self
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def connected?
|
|
60
|
+
@adapter.connected?
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def connect!
|
|
64
|
+
@adapter.connect!
|
|
65
|
+
@ui.banner("AIFaker successfully connected")
|
|
66
|
+
self
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def read_schema
|
|
70
|
+
@schema = SchemaIntrospector.dump_schema
|
|
71
|
+
@adapter.share_schema!(@schema)
|
|
72
|
+
@ui.section("Schema shared")
|
|
73
|
+
@schema
|
|
74
|
+
end
|
|
75
|
+
alias_method :read_db_schema, :read_schema
|
|
76
|
+
|
|
77
|
+
def read_model_associations
|
|
78
|
+
data = ModelIntrospector.describe_models_and_associations
|
|
79
|
+
@models = data.fetch(:models)
|
|
80
|
+
@associations = data.fetch(:associations)
|
|
81
|
+
@adapter.share_models!(@models, @associations)
|
|
82
|
+
@ui.section("Model associations shared")
|
|
83
|
+
data
|
|
84
|
+
end
|
|
85
|
+
alias_method :read_associations, :read_model_associations
|
|
86
|
+
|
|
87
|
+
def plan_seed
|
|
88
|
+
ensure_schema_and_models!
|
|
89
|
+
@plan = SeedPlanner.new(models: @models, associations: @associations).plan
|
|
90
|
+
@adapter.share_seed_plan!(@plan)
|
|
91
|
+
@plan
|
|
92
|
+
end
|
|
93
|
+
alias_method :seed_plan, :plan_seed
|
|
94
|
+
|
|
95
|
+
def seed!
|
|
96
|
+
connect! unless connected?
|
|
97
|
+
read_schema if @schema.nil?
|
|
98
|
+
read_model_associations if @models.nil?
|
|
99
|
+
plan_seed if @plan.nil?
|
|
100
|
+
|
|
101
|
+
proceed =
|
|
102
|
+
if automode
|
|
103
|
+
true
|
|
104
|
+
else
|
|
105
|
+
@ui.ask_yes_no("Do you want to proceed with creation of dummy realistic data?")
|
|
106
|
+
end
|
|
107
|
+
return :skipped unless proceed
|
|
108
|
+
|
|
109
|
+
count =
|
|
110
|
+
if automode
|
|
111
|
+
rand(10..20)
|
|
112
|
+
else
|
|
113
|
+
@ui.ask_integer("How much count you needed approx for creation dummy data in each tables?", min: 5, max: 50)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
SeedRunner.new(
|
|
117
|
+
ui: @ui,
|
|
118
|
+
adapter: @adapter,
|
|
119
|
+
plan: @plan,
|
|
120
|
+
default_count: count
|
|
121
|
+
).run(assume_yes: automode)
|
|
122
|
+
end
|
|
123
|
+
alias_method :run!, :seed!
|
|
124
|
+
|
|
125
|
+
private
|
|
126
|
+
|
|
127
|
+
def ensure_schema_and_models!
|
|
128
|
+
read_schema if @schema.nil?
|
|
129
|
+
read_model_associations if @models.nil?
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AIFaker
|
|
4
|
+
module LLMAdapters
|
|
5
|
+
class RubyLLM
|
|
6
|
+
def self.available?
|
|
7
|
+
require "ruby_llm"
|
|
8
|
+
true
|
|
9
|
+
rescue LoadError
|
|
10
|
+
false
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def initialize(provider:, ui: nil, **options)
|
|
14
|
+
@provider = provider
|
|
15
|
+
@ui = ui
|
|
16
|
+
@options = options
|
|
17
|
+
@connected = false
|
|
18
|
+
@schema = nil
|
|
19
|
+
@models = nil
|
|
20
|
+
@associations = nil
|
|
21
|
+
@plan = nil
|
|
22
|
+
@generated_uniques = Hash.new { |h, k| h[k] = Hash.new { |hh, kk| hh[kk] = [] } }
|
|
23
|
+
@failure_feedback = {}
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def connected?
|
|
27
|
+
@connected
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def connect!
|
|
31
|
+
self.class.available? or raise ConnectionError, "Missing dependency: add `gem \"ruby_llm\"` to your Gemfile."
|
|
32
|
+
|
|
33
|
+
@provider_sym = @provider.to_sym
|
|
34
|
+
configure_ruby_llm!
|
|
35
|
+
@connected = true
|
|
36
|
+
rescue StandardError => e
|
|
37
|
+
raise ConnectionError, "Failed to connect via ruby_llm (provider=#{@provider}): #{e.class}: #{e.message}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def share_schema!(schema)
|
|
41
|
+
@schema = schema
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def share_models!(models, associations)
|
|
45
|
+
@models = models
|
|
46
|
+
@associations = associations
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def share_seed_plan!(plan)
|
|
50
|
+
@plan = plan
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def generate_attributes(model)
|
|
54
|
+
prompt = build_prompt_for(model)
|
|
55
|
+
response_text = chat_with_indicator(prompt, label: "Searching #{model.name}")
|
|
56
|
+
parsed = parse_json_hash(response_text)
|
|
57
|
+
attrs = coerce_attributes(model, parsed)
|
|
58
|
+
remember_generated_unique_values(model, attrs)
|
|
59
|
+
attrs
|
|
60
|
+
rescue StandardError => e
|
|
61
|
+
raise Error, "LLM attribute generation failed for #{model.name}: #{e.class}: #{e.message}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def suggest_fix(model, error:, last_attributes:)
|
|
65
|
+
prompt = build_fix_prompt_for(model, error:, last_attributes:)
|
|
66
|
+
chat_with_indicator(prompt, label: "Searching fix for #{model.name}")
|
|
67
|
+
rescue StandardError
|
|
68
|
+
nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def register_failure(model, error:, attempted_attributes:)
|
|
72
|
+
taken_by_column = uniqueness_taken_values(model, error, attempted_attributes)
|
|
73
|
+
@failure_feedback[model.name] = {
|
|
74
|
+
message: "#{error.class}: #{error.message}",
|
|
75
|
+
details: validation_error_details(error),
|
|
76
|
+
attempted_attributes: attempted_attributes,
|
|
77
|
+
dont_suggest: taken_by_column
|
|
78
|
+
}
|
|
79
|
+
rescue StandardError
|
|
80
|
+
nil
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def apply_fix!(_fix)
|
|
84
|
+
# The fix is typically guidance; in v0 we just retry with fresh attributes.
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def build_prompt_for(model)
|
|
90
|
+
{
|
|
91
|
+
role: "system",
|
|
92
|
+
content: <<~TEXT
|
|
93
|
+
You are generating realistic seed data for a Rails app.
|
|
94
|
+
|
|
95
|
+
Return ONLY a JSON object of attributes for the ActiveRecord model "#{model.name}".
|
|
96
|
+
Rules:
|
|
97
|
+
- Do NOT include id, created_at, updated_at
|
|
98
|
+
- For belongs_to associations, include the foreign key field (e.g. user_id) as an integer id that exists.
|
|
99
|
+
- Use the schema + model context provided below.
|
|
100
|
+
|
|
101
|
+
SCHEMA:
|
|
102
|
+
#{@schema}
|
|
103
|
+
|
|
104
|
+
MODELS:
|
|
105
|
+
#{@models}
|
|
106
|
+
|
|
107
|
+
ASSOCIATIONS:
|
|
108
|
+
#{@associations}
|
|
109
|
+
|
|
110
|
+
SEED PLAN:
|
|
111
|
+
#{@plan}
|
|
112
|
+
|
|
113
|
+
UNIQUENESS CONSTRAINTS FOR #{model.name}:
|
|
114
|
+
#{uniqueness_constraints_text(model)}
|
|
115
|
+
|
|
116
|
+
VALIDATIONS FOR #{model.name}:
|
|
117
|
+
#{model_validations_text(model)}
|
|
118
|
+
|
|
119
|
+
LAST FAILURE FOR #{model.name} (if any):
|
|
120
|
+
#{failure_feedback_text(model)}
|
|
121
|
+
|
|
122
|
+
DO NOT SUGGEST THESE VALUES FOR #{model.name}:
|
|
123
|
+
#{dont_suggest_text(model)}
|
|
124
|
+
|
|
125
|
+
Important:
|
|
126
|
+
- If a previous attempt failed due to uniqueness, generate NEW values for those fields.
|
|
127
|
+
- Never reuse exact values shown in attempted_attributes/recent_generated/existing_sample/dont_suggest for unique columns.
|
|
128
|
+
TEXT
|
|
129
|
+
}
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def build_fix_prompt_for(model, error:, last_attributes:)
|
|
133
|
+
require "json"
|
|
134
|
+
{
|
|
135
|
+
role: "system",
|
|
136
|
+
content: <<~TEXT
|
|
137
|
+
We failed to create a #{model.name} record.
|
|
138
|
+
Error: #{error.class}: #{error.message}
|
|
139
|
+
Validation details: #{validation_error_details(error)}
|
|
140
|
+
Last attributes (JSON): #{JSON.generate(last_attributes)}
|
|
141
|
+
|
|
142
|
+
Uniqueness constraints:
|
|
143
|
+
#{uniqueness_constraints_text(model)}
|
|
144
|
+
|
|
145
|
+
Validations:
|
|
146
|
+
#{model_validations_text(model)}
|
|
147
|
+
|
|
148
|
+
Do not suggest:
|
|
149
|
+
#{dont_suggest_text(model)}
|
|
150
|
+
|
|
151
|
+
Provide a corrected JSON object of attributes ONLY.
|
|
152
|
+
TEXT
|
|
153
|
+
}
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def parse_json_hash(text)
|
|
157
|
+
require "json"
|
|
158
|
+
JSON.parse(text)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def coerce_attributes(model, attrs)
|
|
162
|
+
attrs = attrs.transform_keys(&:to_s)
|
|
163
|
+
allowed = model.columns.map(&:name) - [model.primary_key, "created_at", "updated_at"]
|
|
164
|
+
attrs.slice(*allowed)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def chat_with_indicator(prompt, label:)
|
|
168
|
+
runner = lambda do
|
|
169
|
+
last_error = nil
|
|
170
|
+
|
|
171
|
+
candidate_chat_models.each do |model_id|
|
|
172
|
+
begin
|
|
173
|
+
chat = ::RubyLLM.chat(model: model_id, provider: @provider_sym, **@options)
|
|
174
|
+
chat.with_instructions(prompt.fetch(:content), replace: false)
|
|
175
|
+
response = chat.ask("Return only the JSON object.")
|
|
176
|
+
return message_to_text(response)
|
|
177
|
+
rescue StandardError => e
|
|
178
|
+
last_error = e
|
|
179
|
+
next if model_not_supported_error?(e)
|
|
180
|
+
raise
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
raise(last_error || "LLM call failed")
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
if @ui && @ui.respond_to?(:with_searching)
|
|
188
|
+
@ui.with_searching(label) { runner.call }
|
|
189
|
+
else
|
|
190
|
+
runner.call
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def message_to_text(message)
|
|
195
|
+
content = message.respond_to?(:content) ? message.content : message
|
|
196
|
+
return content if content.is_a?(String)
|
|
197
|
+
return "" if content.nil?
|
|
198
|
+
|
|
199
|
+
if content.is_a?(Array)
|
|
200
|
+
content.filter_map { |p| p.is_a?(Hash) ? p["text"] || p[:text] : nil }.join
|
|
201
|
+
else
|
|
202
|
+
content.to_s
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def configure_ruby_llm!
|
|
207
|
+
::RubyLLM.configure do |config|
|
|
208
|
+
# Provider credentials (prefer existing config; otherwise env).
|
|
209
|
+
case @provider_sym
|
|
210
|
+
when :openai
|
|
211
|
+
config.openai_api_key ||= ENV["OPENAI_API_KEY"]
|
|
212
|
+
when :anthropic, :claude
|
|
213
|
+
config.anthropic_api_key ||= ENV["ANTHROPIC_API_KEY"]
|
|
214
|
+
when :gemini
|
|
215
|
+
config.gemini_api_key ||= ENV["GEMINI_API_KEY"]
|
|
216
|
+
when :deepseek
|
|
217
|
+
config.deepseek_api_key ||= ENV["DEEPSEEK_API_KEY"]
|
|
218
|
+
when :bedrock
|
|
219
|
+
config.bedrock_api_key ||= ENV["BEDROCK_API_KEY"]
|
|
220
|
+
config.bedrock_secret_key ||= ENV["BEDROCK_SECRET_KEY"]
|
|
221
|
+
config.bedrock_region ||= ENV["BEDROCK_REGION"]
|
|
222
|
+
config.bedrock_session_token ||= ENV["BEDROCK_SESSION_TOKEN"]
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Optional global tuning
|
|
226
|
+
config.default_model ||= ENV["AIFAKER_MODEL"] if ENV["AIFAKER_MODEL"]
|
|
227
|
+
config.request_timeout ||= ENV["AIFAKER_TIMEOUT"].to_i if ENV["AIFAKER_TIMEOUT"]
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# If the configured default model doesn't exist for this provider, pick one dynamically.
|
|
231
|
+
begin
|
|
232
|
+
current = ::RubyLLM.config.default_model
|
|
233
|
+
::RubyLLM.models.find(current, @provider_sym)
|
|
234
|
+
rescue StandardError
|
|
235
|
+
picked = provider_default_chat_model
|
|
236
|
+
::RubyLLM.configure { |c| c.default_model = picked } if picked
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Fail fast if still not configured (ruby_llm will also raise later).
|
|
240
|
+
provider = ::RubyLLM::Provider.providers[@provider_sym]
|
|
241
|
+
return if provider && provider.configured?
|
|
242
|
+
|
|
243
|
+
missing =
|
|
244
|
+
case @provider_sym
|
|
245
|
+
when :openai then "OPENAI_API_KEY"
|
|
246
|
+
when :anthropic, :claude then "ANTHROPIC_API_KEY"
|
|
247
|
+
when :gemini then "GEMINI_API_KEY"
|
|
248
|
+
when :deepseek then "DEEPSEEK_API_KEY"
|
|
249
|
+
when :bedrock then "BEDROCK_API_KEY/BEDROCK_SECRET_KEY/BEDROCK_REGION"
|
|
250
|
+
else "provider credentials"
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
raise ConnectionError, "#{@provider_sym} is not configured. Set #{missing} (or configure RubyLLM in an initializer)."
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def provider_default_chat_model
|
|
257
|
+
provider_chat_model_ids.first
|
|
258
|
+
rescue StandardError
|
|
259
|
+
nil
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def candidate_chat_models
|
|
263
|
+
# 1) explicit override always wins
|
|
264
|
+
explicit = ENV["AIFAKER_MODEL"]&.strip
|
|
265
|
+
return [explicit] if explicit && !explicit.empty?
|
|
266
|
+
|
|
267
|
+
# 2) RubyLLM global default, if set, then try all provider chat models
|
|
268
|
+
begin
|
|
269
|
+
current = ::RubyLLM.config.default_model
|
|
270
|
+
if current && !current.to_s.strip.empty?
|
|
271
|
+
return [current.to_s.strip, *provider_chat_model_ids].compact.uniq
|
|
272
|
+
end
|
|
273
|
+
rescue StandardError
|
|
274
|
+
# ignore and fall back to provider models
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# 3) provider chat models
|
|
278
|
+
provider_chat_model_ids
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def provider_chat_model_ids
|
|
282
|
+
models = ::RubyLLM.models.by_provider(@provider_sym).chat_models.all
|
|
283
|
+
ids = models.map(&:id).compact.map(&:to_s).map(&:strip).reject(&:empty?)
|
|
284
|
+
|
|
285
|
+
# RubyLLM may surface Gemini models that the API rejects (e.g. `models/aqa`).
|
|
286
|
+
# We'll both filter obvious mismatches here and also rely on runtime fallback.
|
|
287
|
+
ids = ids.reject { |id| id.match?(/\/aqa\b/i) } if @provider_sym == :gemini
|
|
288
|
+
|
|
289
|
+
ids.uniq
|
|
290
|
+
rescue StandardError
|
|
291
|
+
[]
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def model_not_supported_error?(error)
|
|
295
|
+
msg = error.message.to_s
|
|
296
|
+
return false if msg.empty?
|
|
297
|
+
|
|
298
|
+
msg.include?("is not found for API version") ||
|
|
299
|
+
msg.include?("is not supported for generateContent") ||
|
|
300
|
+
msg.match?(/models\/aqa/i)
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def uniqueness_constraints_text(model)
|
|
304
|
+
constraints = unique_columns_for(model)
|
|
305
|
+
return "none" if constraints.empty?
|
|
306
|
+
|
|
307
|
+
constraints.map do |col|
|
|
308
|
+
recent = @generated_uniques.dig(model.name, col)&.last(8) || []
|
|
309
|
+
existing = safe_existing_values(model, col, limit: 8)
|
|
310
|
+
<<~ROW.chomp
|
|
311
|
+
- #{col}: must be unique
|
|
312
|
+
recent_generated: #{recent.empty? ? "[]" : recent.inspect}
|
|
313
|
+
existing_sample: #{existing.empty? ? "[]" : existing.inspect}
|
|
314
|
+
ROW
|
|
315
|
+
end.join("\n")
|
|
316
|
+
rescue StandardError
|
|
317
|
+
"none"
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def unique_columns_for(model)
|
|
321
|
+
columns = []
|
|
322
|
+
|
|
323
|
+
begin
|
|
324
|
+
idx_cols =
|
|
325
|
+
model.connection.indexes(model.table_name)
|
|
326
|
+
.select(&:unique)
|
|
327
|
+
.select { |i| i.columns.size == 1 }
|
|
328
|
+
.map { |i| i.columns.first.to_s }
|
|
329
|
+
columns.concat(idx_cols)
|
|
330
|
+
rescue StandardError
|
|
331
|
+
# ignore
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
begin
|
|
335
|
+
validator_cols =
|
|
336
|
+
model.validators
|
|
337
|
+
.select { |v| v.kind == :uniqueness }
|
|
338
|
+
.flat_map { |v| v.attributes.map(&:to_s) }
|
|
339
|
+
columns.concat(validator_cols)
|
|
340
|
+
rescue StandardError
|
|
341
|
+
# ignore
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
columns.uniq
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def remember_generated_unique_values(model, attrs)
|
|
348
|
+
return unless attrs.is_a?(Hash)
|
|
349
|
+
|
|
350
|
+
unique_columns_for(model).each do |col|
|
|
351
|
+
value = attrs[col] || attrs[col.to_sym]
|
|
352
|
+
next if value.nil?
|
|
353
|
+
|
|
354
|
+
bucket = @generated_uniques[model.name][col]
|
|
355
|
+
bucket << value
|
|
356
|
+
bucket.shift while bucket.length > 30
|
|
357
|
+
end
|
|
358
|
+
rescue StandardError
|
|
359
|
+
nil
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def safe_existing_values(model, column, limit:)
|
|
363
|
+
model.where.not(column => nil).limit(limit).pluck(column).compact
|
|
364
|
+
rescue StandardError
|
|
365
|
+
[]
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def failure_feedback_text(model)
|
|
369
|
+
data = @failure_feedback[model.name]
|
|
370
|
+
return "none" unless data
|
|
371
|
+
|
|
372
|
+
attrs_json =
|
|
373
|
+
begin
|
|
374
|
+
require "json"
|
|
375
|
+
JSON.generate(data[:attempted_attributes] || {})
|
|
376
|
+
rescue StandardError
|
|
377
|
+
(data[:attempted_attributes] || {}).to_s
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
<<~TEXT.chomp
|
|
381
|
+
message: #{data[:message]}
|
|
382
|
+
details: #{data[:details]}
|
|
383
|
+
attempted_attributes: #{attrs_json}
|
|
384
|
+
dont_suggest: #{data[:dont_suggest].to_s}
|
|
385
|
+
TEXT
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def dont_suggest_text(model)
|
|
389
|
+
data = @failure_feedback[model.name]
|
|
390
|
+
suggestions = data && data[:dont_suggest].is_a?(Hash) ? data[:dont_suggest] : {}
|
|
391
|
+
return "none" if suggestions.empty?
|
|
392
|
+
|
|
393
|
+
suggestions.map do |column, values|
|
|
394
|
+
clean = Array(values).compact.map(&:to_s).map(&:strip).reject(&:empty?).uniq
|
|
395
|
+
"- #{column}: #{clean.first(25).inspect}"
|
|
396
|
+
end.join("\n")
|
|
397
|
+
rescue StandardError
|
|
398
|
+
"none"
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
def validation_error_details(error)
|
|
402
|
+
return "none" unless defined?(ActiveRecord::RecordInvalid) && error.is_a?(ActiveRecord::RecordInvalid)
|
|
403
|
+
|
|
404
|
+
record = error.respond_to?(:record) ? error.record : nil
|
|
405
|
+
return "none" unless record && record.respond_to?(:errors)
|
|
406
|
+
|
|
407
|
+
details = record.errors.details
|
|
408
|
+
return "none" unless details.is_a?(Hash)
|
|
409
|
+
|
|
410
|
+
details.transform_values do |v|
|
|
411
|
+
Array(v).map { |d| d.is_a?(Hash) ? d[:error] : d }.compact
|
|
412
|
+
end.to_s
|
|
413
|
+
rescue StandardError
|
|
414
|
+
"none"
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
def model_validations_text(model)
|
|
418
|
+
validators = model.validators
|
|
419
|
+
return "none" if validators.nil? || validators.empty?
|
|
420
|
+
|
|
421
|
+
validators.map do |validator|
|
|
422
|
+
attributes = validator.attributes.map(&:to_s)
|
|
423
|
+
kind = validator.kind.to_s
|
|
424
|
+
options = validator.options.dup
|
|
425
|
+
options.delete(:class)
|
|
426
|
+
options_text = options.empty? ? "{}" : serialize_validator_options(options)
|
|
427
|
+
|
|
428
|
+
"- #{kind} on #{attributes.join(', ')} options=#{options_text}"
|
|
429
|
+
end.join("\n")
|
|
430
|
+
rescue StandardError
|
|
431
|
+
"none"
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
def uniqueness_taken_values(model, error, attempted_attributes)
|
|
435
|
+
cols = uniqueness_error_columns(error)
|
|
436
|
+
return {} if cols.empty?
|
|
437
|
+
|
|
438
|
+
attrs = attempted_attributes.is_a?(Hash) ? attempted_attributes.transform_keys(&:to_s) : {}
|
|
439
|
+
cols.each_with_object({}) do |col, out|
|
|
440
|
+
values = []
|
|
441
|
+
values.concat(safe_existing_values(model, col, limit: 30))
|
|
442
|
+
attempted = attrs[col]
|
|
443
|
+
values << attempted unless attempted.nil?
|
|
444
|
+
values.concat(@generated_uniques.dig(model.name, col) || [])
|
|
445
|
+
out[col] = values.compact.map(&:to_s).map(&:strip).reject(&:empty?).uniq.first(30)
|
|
446
|
+
end
|
|
447
|
+
rescue StandardError
|
|
448
|
+
{}
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
def uniqueness_error_columns(error)
|
|
452
|
+
return [] unless defined?(ActiveRecord::RecordInvalid) && error.is_a?(ActiveRecord::RecordInvalid)
|
|
453
|
+
|
|
454
|
+
record = error.respond_to?(:record) ? error.record : nil
|
|
455
|
+
return [] unless record && record.respond_to?(:errors) && record.errors.respond_to?(:details)
|
|
456
|
+
|
|
457
|
+
record.errors.details.each_with_object([]) do |(attr, details), out|
|
|
458
|
+
next unless Array(details).any? { |d| d.is_a?(Hash) && d[:error] == :taken }
|
|
459
|
+
out << attr.to_s
|
|
460
|
+
end
|
|
461
|
+
rescue StandardError
|
|
462
|
+
[]
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
def serialize_validator_options(options)
|
|
466
|
+
sanitized = options.transform_values do |v|
|
|
467
|
+
case v
|
|
468
|
+
when Regexp then v.inspect
|
|
469
|
+
when Proc then "proc"
|
|
470
|
+
else v
|
|
471
|
+
end
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
require "json"
|
|
475
|
+
JSON.generate(sanitized)
|
|
476
|
+
rescue StandardError
|
|
477
|
+
options.to_s
|
|
478
|
+
end
|
|
479
|
+
end
|
|
480
|
+
end
|
|
481
|
+
end
|
|
482
|
+
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AIFaker
|
|
4
|
+
module ModelIntrospector
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def describe_models_and_associations
|
|
8
|
+
ensure_active_record!
|
|
9
|
+
|
|
10
|
+
eager_load_models!
|
|
11
|
+
models = app_models
|
|
12
|
+
associations = models.to_h { |m| [m.name, describe_associations(m)] }
|
|
13
|
+
|
|
14
|
+
{
|
|
15
|
+
models: models.map { |m| describe_model(m) },
|
|
16
|
+
associations:
|
|
17
|
+
}
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def ensure_active_record!
|
|
21
|
+
return if defined?(ActiveRecord::Base)
|
|
22
|
+
|
|
23
|
+
raise MissingRailsError, "ActiveRecord is not loaded. Run AIFaker from a Rails app."
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def eager_load_models!
|
|
27
|
+
return unless defined?(Rails) && Rails.respond_to?(:application) && Rails.application
|
|
28
|
+
|
|
29
|
+
# In development, Rails often doesn't eager-load models, so descendants can be empty.
|
|
30
|
+
# We force-load model files so association introspection works during `db:seed`.
|
|
31
|
+
Rails.application.eager_load!
|
|
32
|
+
|
|
33
|
+
models_dir = Rails.root.join("app/models")
|
|
34
|
+
loader =
|
|
35
|
+
if defined?(ActiveSupport::Dependencies) && ActiveSupport::Dependencies.respond_to?(:require_dependency)
|
|
36
|
+
ActiveSupport::Dependencies.method(:require_dependency)
|
|
37
|
+
else
|
|
38
|
+
method(:require)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
Dir.glob(models_dir.join("**/*.rb")).sort.each { |f| loader.call(f) }
|
|
42
|
+
rescue LoadError, StandardError
|
|
43
|
+
# best-effort; not fatal (but if this fails, seeding may not see all models)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def app_models
|
|
47
|
+
base =
|
|
48
|
+
if defined?(ApplicationRecord)
|
|
49
|
+
ApplicationRecord
|
|
50
|
+
else
|
|
51
|
+
ActiveRecord::Base
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
base.descendants
|
|
55
|
+
.reject(&:abstract_class?)
|
|
56
|
+
.reject { |m| m.name.nil? || m.name.empty? }
|
|
57
|
+
.sort_by(&:name)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def describe_model(model)
|
|
61
|
+
{
|
|
62
|
+
name: model.name,
|
|
63
|
+
table_name: model.table_name,
|
|
64
|
+
primary_key: model.primary_key,
|
|
65
|
+
columns: model.columns.map do |c|
|
|
66
|
+
{
|
|
67
|
+
name: c.name,
|
|
68
|
+
type: c.type.to_s,
|
|
69
|
+
null: c.null,
|
|
70
|
+
default: c.default
|
|
71
|
+
}
|
|
72
|
+
end
|
|
73
|
+
}
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def describe_associations(model)
|
|
77
|
+
model.reflections.values.map do |r|
|
|
78
|
+
{
|
|
79
|
+
name: r.name.to_s,
|
|
80
|
+
macro: r.macro.to_s,
|
|
81
|
+
class_name: r.class_name.to_s,
|
|
82
|
+
foreign_key: r.foreign_key.to_s,
|
|
83
|
+
optional: (r.respond_to?(:options) ? !!r.options[:optional] : false)
|
|
84
|
+
}
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "stringio"
|
|
4
|
+
|
|
5
|
+
module AIFaker
|
|
6
|
+
module SchemaIntrospector
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def dump_schema
|
|
10
|
+
ensure_active_record!
|
|
11
|
+
|
|
12
|
+
conn = ActiveRecord::Base.connection
|
|
13
|
+
io = StringIO.new
|
|
14
|
+
ActiveRecord::SchemaDumper.dump(conn, io)
|
|
15
|
+
io.string
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def ensure_active_record!
|
|
19
|
+
return if defined?(ActiveRecord::Base)
|
|
20
|
+
|
|
21
|
+
raise MissingRailsError, "ActiveRecord is not loaded. Run AIFaker from a Rails app (or require ActiveRecord)."
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AIFaker
|
|
4
|
+
class SeedPlanner
|
|
5
|
+
def initialize(models:, associations:)
|
|
6
|
+
@models = models
|
|
7
|
+
@associations = associations
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def plan
|
|
11
|
+
graph = build_dependency_graph
|
|
12
|
+
order = topo_sort(graph)
|
|
13
|
+
|
|
14
|
+
{
|
|
15
|
+
order:,
|
|
16
|
+
graph:
|
|
17
|
+
}
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def build_dependency_graph
|
|
23
|
+
# edge: model -> depends_on_model
|
|
24
|
+
by_name = @models.to_h { |m| [m.fetch(:name), m] }
|
|
25
|
+
|
|
26
|
+
graph = Hash.new { |h, k| h[k] = [] }
|
|
27
|
+
by_name.each_key { |name| graph[name] ||= [] }
|
|
28
|
+
|
|
29
|
+
@associations.each do |model_name, refs|
|
|
30
|
+
deps =
|
|
31
|
+
refs
|
|
32
|
+
.select { |r| r[:macro] == "belongs_to" }
|
|
33
|
+
.map { |r| r[:class_name] }
|
|
34
|
+
.select { |klass| by_name.key?(klass) }
|
|
35
|
+
|
|
36
|
+
graph[model_name].concat(deps).uniq!
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
graph
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def topo_sort(graph)
|
|
43
|
+
# `graph` maps: node -> dependencies (deps must come before node)
|
|
44
|
+
deps = graph.transform_values(&:dup)
|
|
45
|
+
order = []
|
|
46
|
+
|
|
47
|
+
loop do
|
|
48
|
+
ready = deps.select { |_, v| v.empty? }.keys.sort
|
|
49
|
+
break if ready.empty?
|
|
50
|
+
|
|
51
|
+
ready.each do |n|
|
|
52
|
+
order << n
|
|
53
|
+
deps.delete(n)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
deps.each_value { |v| v.reject! { |d| order.include?(d) } }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# If there is a cycle (or missing nodes), append remaining in stable order.
|
|
60
|
+
order.concat(deps.keys.sort)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AIFaker
|
|
4
|
+
class SeedRunner
|
|
5
|
+
def initialize(ui:, adapter:, plan:, default_count:)
|
|
6
|
+
@ui = ui
|
|
7
|
+
@adapter = adapter
|
|
8
|
+
@plan = plan
|
|
9
|
+
@default_count = default_count
|
|
10
|
+
@created = {}
|
|
11
|
+
@seeding_stack = {}
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def run(assume_yes: false)
|
|
15
|
+
order = @plan.fetch(:order)
|
|
16
|
+
|
|
17
|
+
if order.empty?
|
|
18
|
+
@ui.info("No seedable models found. Create some models/migrations first, then re-run `rails db:seed`.")
|
|
19
|
+
return :noop
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
order.each do |model_name|
|
|
23
|
+
next unless constantize(model_name)
|
|
24
|
+
|
|
25
|
+
should_seed =
|
|
26
|
+
if assume_yes
|
|
27
|
+
true
|
|
28
|
+
else
|
|
29
|
+
@ui.ask_yes_no("Do you want to feed #{model_name} table data?")
|
|
30
|
+
end
|
|
31
|
+
next unless should_seed
|
|
32
|
+
|
|
33
|
+
seed_model(model_name, @default_count)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
print_summary(order)
|
|
37
|
+
:ok
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def seed_model(model_name, count)
|
|
43
|
+
model = constantize(model_name)
|
|
44
|
+
return unless model
|
|
45
|
+
|
|
46
|
+
return if @seeding_stack[model_name]
|
|
47
|
+
@seeding_stack[model_name] = true
|
|
48
|
+
|
|
49
|
+
ensure_required_parents!(model)
|
|
50
|
+
@ui.table_start(model_name)
|
|
51
|
+
|
|
52
|
+
idx = 0
|
|
53
|
+
attempts = 0
|
|
54
|
+
|
|
55
|
+
while idx < count
|
|
56
|
+
attempts += 1
|
|
57
|
+
begin
|
|
58
|
+
attrs = @adapter.generate_attributes(model)
|
|
59
|
+
record = model.create!(attrs)
|
|
60
|
+
@created[model_name] ||= []
|
|
61
|
+
@created[model_name] << record
|
|
62
|
+
idx += 1
|
|
63
|
+
@ui.record_created(idx, model_name, record.id)
|
|
64
|
+
rescue StandardError => e
|
|
65
|
+
@ui.error("#{model_name} create failed: #{e.class}: #{e.message}")
|
|
66
|
+
@adapter.register_failure(model, error: e, attempted_attributes: attrs) if @adapter.respond_to?(:register_failure)
|
|
67
|
+
|
|
68
|
+
# Auto-repair common relational validations before asking the adapter.
|
|
69
|
+
repair_notes = []
|
|
70
|
+
repaired =
|
|
71
|
+
if @ui.respond_to?(:with_auto_repair)
|
|
72
|
+
@ui.with_auto_repair("Auto-repairing #{model_name}") { auto_repair!(model, attrs, e, notes: repair_notes) }
|
|
73
|
+
else
|
|
74
|
+
auto_repair!(model, attrs, e, notes: repair_notes)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
repair_notes.each do |note|
|
|
78
|
+
if @ui.respond_to?(:auto_repair)
|
|
79
|
+
@ui.auto_repair(note)
|
|
80
|
+
else
|
|
81
|
+
@ui.info(note)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
if repaired
|
|
86
|
+
# retry quickly after repair
|
|
87
|
+
next
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
fix = @adapter.suggest_fix(model, error: e, last_attributes: attrs)
|
|
91
|
+
@adapter.apply_fix!(fix) if fix
|
|
92
|
+
raise e if attempts > (count * 10)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
ensure
|
|
96
|
+
@seeding_stack.delete(model_name)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def auto_repair!(model, attrs, error, notes:)
|
|
100
|
+
return false unless defined?(ActiveRecord::RecordInvalid) && error.is_a?(ActiveRecord::RecordInvalid)
|
|
101
|
+
|
|
102
|
+
record = error.respond_to?(:record) ? error.record : nil
|
|
103
|
+
return false unless record
|
|
104
|
+
|
|
105
|
+
repaired = false
|
|
106
|
+
|
|
107
|
+
# A) If any required parent table is empty, seed it.
|
|
108
|
+
record.class.reflections.values.select { |r| r.macro == :belongs_to }.each do |r|
|
|
109
|
+
assoc_class = constantize(r.class_name)
|
|
110
|
+
next unless assoc_class
|
|
111
|
+
next if assoc_class.count.positive?
|
|
112
|
+
|
|
113
|
+
notes << "Seeding missing parent #{assoc_class.name} (needed for #{record.class.name})"
|
|
114
|
+
seed_model(assoc_class.name, [@default_count, 5].min)
|
|
115
|
+
repaired = true
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# B) If we reference multiple parents, ensure join rows exist for fk pairs.
|
|
119
|
+
repaired ||= ensure_join_rows_exist!(record, attrs, notes:)
|
|
120
|
+
repaired ||= repair_uniqueness!(record, attrs, notes:)
|
|
121
|
+
|
|
122
|
+
repaired
|
|
123
|
+
rescue StandardError => e
|
|
124
|
+
@ui.error("Auto-repair failed: #{e.class}: #{e.message}")
|
|
125
|
+
false
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def ensure_join_rows_exist!(record, attrs, notes:)
|
|
129
|
+
return false unless attrs.is_a?(Hash)
|
|
130
|
+
|
|
131
|
+
belongs_to_refs = record.class.reflections.values.select { |r| r.macro == :belongs_to }
|
|
132
|
+
return false if belongs_to_refs.size < 2
|
|
133
|
+
|
|
134
|
+
fk_values = belongs_to_refs.to_h do |r|
|
|
135
|
+
fk = r.foreign_key.to_s
|
|
136
|
+
value = attrs[fk] || attrs[fk.to_sym]
|
|
137
|
+
[fk, value]
|
|
138
|
+
end
|
|
139
|
+
fk_values.compact!
|
|
140
|
+
|
|
141
|
+
# Only attempt join repair when we have at least 2 fk values present.
|
|
142
|
+
present = fk_values.select { |_, v| !v.nil? }
|
|
143
|
+
return false if present.size < 2
|
|
144
|
+
|
|
145
|
+
repaired_any = false
|
|
146
|
+
present.keys.combination(2).each do |fk_a, fk_b|
|
|
147
|
+
id_a = fk_values[fk_a]
|
|
148
|
+
id_b = fk_values[fk_b]
|
|
149
|
+
next if id_a.nil? || id_b.nil?
|
|
150
|
+
|
|
151
|
+
join_models_for(fk_a, fk_b).each do |join_model|
|
|
152
|
+
begin
|
|
153
|
+
join_model.find_or_create_by!(fk_a => id_a, fk_b => id_b)
|
|
154
|
+
notes << "Ensured #{join_model.name}(#{fk_a}=#{id_a}, #{fk_b}=#{id_b}) exists"
|
|
155
|
+
repaired_any = true
|
|
156
|
+
rescue StandardError
|
|
157
|
+
# ignore and try other join models / pairs
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
repaired_any
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def repair_uniqueness!(record, attrs, notes:)
|
|
166
|
+
return false unless attrs.is_a?(Hash)
|
|
167
|
+
return false unless record.respond_to?(:errors) && record.errors.respond_to?(:details)
|
|
168
|
+
|
|
169
|
+
taken_fields =
|
|
170
|
+
record.errors.details.each_with_object([]) do |(attr, details), out|
|
|
171
|
+
next unless details.is_a?(Array)
|
|
172
|
+
out << attr.to_s if details.any? { |d| d.is_a?(Hash) && d[:error] == :taken }
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
return false if taken_fields.empty?
|
|
176
|
+
|
|
177
|
+
mutated = attrs.transform_keys(&:to_s).dup
|
|
178
|
+
changed = false
|
|
179
|
+
|
|
180
|
+
taken_fields.each do |field|
|
|
181
|
+
original = mutated[field]
|
|
182
|
+
next if original.nil?
|
|
183
|
+
|
|
184
|
+
unique_value = build_unique_value(original, field)
|
|
185
|
+
next if unique_value == original
|
|
186
|
+
|
|
187
|
+
mutated[field] = unique_value
|
|
188
|
+
changed = true
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
return false unless changed
|
|
192
|
+
|
|
193
|
+
fresh = record.class.create!(mutated)
|
|
194
|
+
@created[record.class.name] ||= []
|
|
195
|
+
@created[record.class.name] << fresh
|
|
196
|
+
notes << "Uniqueness conflict fixed for #{record.class.name} (created id: #{fresh.id})"
|
|
197
|
+
true
|
|
198
|
+
rescue StandardError
|
|
199
|
+
false
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def build_unique_value(value, field)
|
|
203
|
+
suffix = Time.now.to_i.to_s(36) + rand(10_000).to_s(36)
|
|
204
|
+
return value unless value.is_a?(String)
|
|
205
|
+
|
|
206
|
+
if field.to_s.include?("domain")
|
|
207
|
+
if value.include?(".")
|
|
208
|
+
base, ext = value.split(".", 2)
|
|
209
|
+
"#{base}-#{suffix}.#{ext}"
|
|
210
|
+
else
|
|
211
|
+
"#{value}-#{suffix}"
|
|
212
|
+
end
|
|
213
|
+
elsif value.match?(/\A[^@\s]+@[^@\s]+\z/)
|
|
214
|
+
local, domain = value.split("@", 2)
|
|
215
|
+
"#{local}+#{suffix}@#{domain}"
|
|
216
|
+
else
|
|
217
|
+
"#{value} #{suffix}"
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def join_models_for(fk_a, fk_b)
|
|
222
|
+
all_application_models.select do |m|
|
|
223
|
+
next false if m.abstract_class?
|
|
224
|
+
next false unless m.table_exists? rescue false
|
|
225
|
+
|
|
226
|
+
refs = m.reflections.values.select { |r| r.macro == :belongs_to }
|
|
227
|
+
fks = refs.map { |r| r.foreign_key.to_s }
|
|
228
|
+
(fks.include?(fk_a) && fks.include?(fk_b))
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def all_application_models
|
|
233
|
+
base =
|
|
234
|
+
if defined?(ApplicationRecord)
|
|
235
|
+
ApplicationRecord
|
|
236
|
+
else
|
|
237
|
+
ActiveRecord::Base
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
base.descendants.reject(&:abstract_class?).reject { |m| m.name.nil? || m.name.empty? }
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def ensure_required_parents!(model)
|
|
244
|
+
model.reflections.values
|
|
245
|
+
.select { |r| r.macro == :belongs_to }
|
|
246
|
+
.each do |r|
|
|
247
|
+
assoc_class = constantize(r.class_name)
|
|
248
|
+
next unless assoc_class
|
|
249
|
+
|
|
250
|
+
next if assoc_class.count.positive?
|
|
251
|
+
|
|
252
|
+
@ui.info("Auto-seeding required parent table #{assoc_class.name} (needed for #{model.name})")
|
|
253
|
+
seed_model(assoc_class.name, [@default_count, 5].min)
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def constantize(name)
|
|
258
|
+
Object.const_get(name)
|
|
259
|
+
rescue NameError
|
|
260
|
+
nil
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def print_summary(order)
|
|
264
|
+
rows =
|
|
265
|
+
order.filter_map do |model_name|
|
|
266
|
+
model = constantize(model_name)
|
|
267
|
+
next if model.nil?
|
|
268
|
+
next if model.respond_to?(:abstract_class?) && model.abstract_class?
|
|
269
|
+
|
|
270
|
+
table = (model.respond_to?(:table_name) ? model.table_name : model_name)
|
|
271
|
+
count = model.respond_to?(:count) ? model.count : 0
|
|
272
|
+
|
|
273
|
+
{ model: model_name, table: table, count: count }
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
@ui.summary("✅ Seeding Summary", rows)
|
|
277
|
+
rescue StandardError => e
|
|
278
|
+
@ui.error("Summary failed: #{e.class}: #{e.message}")
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
end
|
data/lib/AIFaker/ui.rb
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AIFaker
|
|
4
|
+
class UI
|
|
5
|
+
RESET = "\e[0m"
|
|
6
|
+
COLORS = {
|
|
7
|
+
primary: "\e[1;36m", # bold cyan
|
|
8
|
+
section: "\e[1;35m", # bold magenta
|
|
9
|
+
info: "\e[0;34m", # blue
|
|
10
|
+
success: "\e[0;32m", # green
|
|
11
|
+
warning: "\e[0;33m", # yellow
|
|
12
|
+
error: "\e[1;31m", # bold red
|
|
13
|
+
muted: "\e[0;90m" # gray
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
def initialize(io: $stdout, input: $stdin)
|
|
17
|
+
@io = io
|
|
18
|
+
@input = input
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def banner(text)
|
|
22
|
+
@io.puts paint("✨ ##### #{text} #####", :primary)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def section(text)
|
|
26
|
+
@io.puts paint("🧭 ##====== #{text} =====##", :section)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def ask_yes_no(prompt)
|
|
30
|
+
loop do
|
|
31
|
+
@io.print paint("❓ #{prompt} y/n\n", :warning)
|
|
32
|
+
answer = @input.gets&.strip&.downcase
|
|
33
|
+
return true if answer == "y"
|
|
34
|
+
return false if answer == "n"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def ask_integer(prompt, min:, max:)
|
|
39
|
+
loop do
|
|
40
|
+
@io.print paint("🔢 #{prompt}\n", :warning)
|
|
41
|
+
raw = @input.gets&.strip
|
|
42
|
+
next if raw.nil? || raw.empty?
|
|
43
|
+
int = Integer(raw, exception: false)
|
|
44
|
+
next if int.nil?
|
|
45
|
+
next if int < min || int > max
|
|
46
|
+
return int
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def info(text)
|
|
51
|
+
@io.puts paint("ℹ️ #{text}", :info)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def auto_repair(text)
|
|
55
|
+
@io.puts paint("🛠️ #{text}", :warning)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def table_start(name)
|
|
59
|
+
section("🏗️ #{name} Creating")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def record_created(idx, model_name, id)
|
|
63
|
+
@io.puts paint("✅ ====> #{idx}. #{model_name} created successfully with id: #{id}", :success)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def error(text)
|
|
67
|
+
@io.puts paint("❌ !! ERROR: #{text}", :error)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def summary(title, rows)
|
|
71
|
+
section("📊 #{title}")
|
|
72
|
+
@io.puts paint(render_table(rows), :muted)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Shows a "Searching..." animation while the block runs.
|
|
76
|
+
# Only animates on TTY; otherwise prints a single line.
|
|
77
|
+
def with_searching(label = "Searching")
|
|
78
|
+
with_activity(
|
|
79
|
+
label,
|
|
80
|
+
emoji: "🔎",
|
|
81
|
+
color: :info,
|
|
82
|
+
interval: 0.2,
|
|
83
|
+
frames: [".", "..", "...", "....", ".....", "......"]
|
|
84
|
+
) { yield }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Shows an "Auto-repairing..." animation while a repair block runs.
|
|
88
|
+
def with_auto_repair(label = "Auto-repairing")
|
|
89
|
+
with_activity(
|
|
90
|
+
label,
|
|
91
|
+
emoji: "🛠️",
|
|
92
|
+
color: :warning,
|
|
93
|
+
interval: 0.08,
|
|
94
|
+
frames: %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏],
|
|
95
|
+
min_duration: 1.0
|
|
96
|
+
) { yield }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
def with_activity(label, emoji:, color:, interval:, frames:, min_duration: 0.0)
|
|
102
|
+
return yield unless block_given?
|
|
103
|
+
|
|
104
|
+
unless @io.respond_to?(:tty?) && @io.tty?
|
|
105
|
+
@io.puts paint("#{emoji} #{label}...", color)
|
|
106
|
+
return yield
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
110
|
+
stop = false
|
|
111
|
+
tick = 0
|
|
112
|
+
last_line = ""
|
|
113
|
+
thread =
|
|
114
|
+
Thread.new do
|
|
115
|
+
Thread.current.abort_on_exception = false
|
|
116
|
+
while !stop
|
|
117
|
+
frame = frames[tick % frames.length]
|
|
118
|
+
line = "#{frame} #{emoji} #{label} "
|
|
119
|
+
@io.print "\r#{paint(line, color)}"
|
|
120
|
+
@io.flush if @io.respond_to?(:flush)
|
|
121
|
+
last_line = line
|
|
122
|
+
tick += 1
|
|
123
|
+
sleep interval
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
yield
|
|
128
|
+
ensure
|
|
129
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at if defined?(started_at)
|
|
130
|
+
remaining = min_duration.to_f - elapsed.to_f
|
|
131
|
+
sleep remaining if remaining.positive?
|
|
132
|
+
stop = true if defined?(stop)
|
|
133
|
+
thread&.join(0.5)
|
|
134
|
+
if @io.respond_to?(:tty?) && @io.tty?
|
|
135
|
+
@io.print "\r#{' ' * last_line.length}\r"
|
|
136
|
+
@io.puts
|
|
137
|
+
@io.flush if @io.respond_to?(:flush)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def paint(text, color_key)
|
|
142
|
+
return text unless color_enabled?
|
|
143
|
+
|
|
144
|
+
color = COLORS[color_key] || ""
|
|
145
|
+
"#{color}#{text}#{RESET}"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def color_enabled?
|
|
149
|
+
return false if ENV["NO_COLOR"]
|
|
150
|
+
@io.respond_to?(:tty?) && @io.tty?
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def render_table(rows)
|
|
154
|
+
headers = ["📦 Model", "🗃️ Table", "🔢 Count"]
|
|
155
|
+
data = rows.map { |r| [r.fetch(:model), r.fetch(:table), r.fetch(:count).to_s] }
|
|
156
|
+
|
|
157
|
+
widths = headers.map(&:length)
|
|
158
|
+
data.each do |row|
|
|
159
|
+
row.each_with_index { |cell, i| widths[i] = [widths[i], cell.to_s.length].max }
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
line = "+-#{widths.map { |w| "-" * w }.join("-+-")}-+"
|
|
163
|
+
out = []
|
|
164
|
+
out << line
|
|
165
|
+
out << "| #{headers.each_with_index.map { |h, i| h.ljust(widths[i]) }.join(" | ")} |"
|
|
166
|
+
out << line
|
|
167
|
+
data.each do |row|
|
|
168
|
+
out << "| #{row.each_with_index.map { |c, i| c.to_s.ljust(widths[i]) }.join(" | ")} |"
|
|
169
|
+
end
|
|
170
|
+
out << line
|
|
171
|
+
out.join("\n")
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
data/lib/AIFaker/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: AIFaker
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Vaibhav Jain
|
|
@@ -37,6 +37,14 @@ files:
|
|
|
37
37
|
- README.md
|
|
38
38
|
- Rakefile
|
|
39
39
|
- lib/AIFaker.rb
|
|
40
|
+
- lib/AIFaker/client.rb
|
|
41
|
+
- lib/AIFaker/errors.rb
|
|
42
|
+
- lib/AIFaker/llm_adapters/ruby_llm.rb
|
|
43
|
+
- lib/AIFaker/model_introspector.rb
|
|
44
|
+
- lib/AIFaker/schema_introspector.rb
|
|
45
|
+
- lib/AIFaker/seed_planner.rb
|
|
46
|
+
- lib/AIFaker/seed_runner.rb
|
|
47
|
+
- lib/AIFaker/ui.rb
|
|
40
48
|
- lib/AIFaker/version.rb
|
|
41
49
|
- sig/AIFaker.rbs
|
|
42
50
|
homepage: https://github.com/VaibhavDJain/AIFaker
|
|
@@ -45,7 +53,8 @@ licenses:
|
|
|
45
53
|
metadata:
|
|
46
54
|
homepage_uri: https://github.com/VaibhavDJain/AIFaker
|
|
47
55
|
source_code_uri: https://github.com/VaibhavDJain/AIFaker
|
|
48
|
-
changelog_uri: https://github.com/VaibhavDJain/AIFaker
|
|
56
|
+
changelog_uri: https://github.com/VaibhavDJain/AIFaker/releases
|
|
57
|
+
documentation_uri: https://www.rubydoc.info/gems/AIFaker
|
|
49
58
|
post_install_message:
|
|
50
59
|
rdoc_options: []
|
|
51
60
|
require_paths:
|