rhino-rails 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/README.md +59 -0
- data/lib/rhino/blueprint/blueprint_parser.rb +198 -0
- data/lib/rhino/blueprint/blueprint_validator.rb +209 -0
- data/lib/rhino/blueprint/generators/factory_generator.rb +74 -0
- data/lib/rhino/blueprint/generators/policy_generator.rb +154 -0
- data/lib/rhino/blueprint/generators/seeder_generator.rb +160 -0
- data/lib/rhino/blueprint/generators/test_generator.rb +291 -0
- data/lib/rhino/blueprint/manifest_manager.rb +81 -0
- data/lib/rhino/commands/base_command.rb +57 -0
- data/lib/rhino/commands/blueprint_command.rb +529 -0
- data/lib/rhino/commands/export_postman_command.rb +328 -0
- data/lib/rhino/commands/export_types_command.rb +202 -0
- data/lib/rhino/commands/generate_command.rb +535 -0
- data/lib/rhino/commands/install_command.rb +408 -0
- data/lib/rhino/commands/invitation_link_command.rb +107 -0
- data/lib/rhino/concerns/belongs_to_organization.rb +49 -0
- data/lib/rhino/concerns/has_audit_trail.rb +125 -0
- data/lib/rhino/concerns/has_auto_scope.rb +91 -0
- data/lib/rhino/concerns/has_permissions.rb +117 -0
- data/lib/rhino/concerns/has_rhino.rb +93 -0
- data/lib/rhino/concerns/has_uuid.rb +26 -0
- data/lib/rhino/concerns/has_validation.rb +250 -0
- data/lib/rhino/concerns/hidable_columns.rb +180 -0
- data/lib/rhino/configuration.rb +101 -0
- data/lib/rhino/controllers/auth_controller.rb +242 -0
- data/lib/rhino/controllers/invitations_controller.rb +231 -0
- data/lib/rhino/controllers/resources_controller.rb +813 -0
- data/lib/rhino/engine.rb +64 -0
- data/lib/rhino/mailers/invitation_mailer.rb +22 -0
- data/lib/rhino/middleware/resolve_organization_from_route.rb +72 -0
- data/lib/rhino/models/audit_log.rb +17 -0
- data/lib/rhino/models/organization_invitation.rb +57 -0
- data/lib/rhino/models/rhino_model.rb +387 -0
- data/lib/rhino/policies/invitation_policy.rb +54 -0
- data/lib/rhino/policies/resource_policy.rb +197 -0
- data/lib/rhino/query_builder.rb +278 -0
- data/lib/rhino/railtie.rb +11 -0
- data/lib/rhino/resource_scope.rb +59 -0
- data/lib/rhino/routes.rb +124 -0
- data/lib/rhino/tasks/rhino.rake +47 -0
- data/lib/rhino/templates/audit_trail/create_audit_logs.rb.erb +26 -0
- data/lib/rhino/templates/generate/factory.rb.erb +43 -0
- data/lib/rhino/templates/generate/migration.rb.erb +26 -0
- data/lib/rhino/templates/generate/model.rb.erb +55 -0
- data/lib/rhino/templates/generate/policy.rb.erb +52 -0
- data/lib/rhino/templates/generate/scope.rb.erb +31 -0
- data/lib/rhino/templates/multi_tenant/factories/organizations.rb.erb +9 -0
- data/lib/rhino/templates/multi_tenant/factories/roles.rb.erb +9 -0
- data/lib/rhino/templates/multi_tenant/factories/user_roles.rb.erb +10 -0
- data/lib/rhino/templates/multi_tenant/factories/users.rb.erb +9 -0
- data/lib/rhino/templates/multi_tenant/migrations/create_organizations.rb.erb +15 -0
- data/lib/rhino/templates/multi_tenant/migrations/create_roles.rb.erb +15 -0
- data/lib/rhino/templates/multi_tenant/migrations/create_user_roles.rb.erb +16 -0
- data/lib/rhino/templates/multi_tenant/migrations/create_users.rb.erb +15 -0
- data/lib/rhino/templates/multi_tenant/models/organization.rb.erb +18 -0
- data/lib/rhino/templates/multi_tenant/models/role.rb.erb +11 -0
- data/lib/rhino/templates/multi_tenant/models/user.rb.erb +14 -0
- data/lib/rhino/templates/multi_tenant/models/user_role.rb.erb +9 -0
- data/lib/rhino/templates/multi_tenant/policies/organization_policy.rb.erb +6 -0
- data/lib/rhino/templates/multi_tenant/policies/role_policy.rb.erb +6 -0
- data/lib/rhino/templates/multi_tenant/seeders/organization_seeder.rb.erb +9 -0
- data/lib/rhino/templates/multi_tenant/seeders/role_seeder.rb.erb +19 -0
- data/lib/rhino/templates/rhino.rb +71 -0
- data/lib/rhino/templates/rhino_model.rb +104 -0
- data/lib/rhino/templates/routes.rb +13 -0
- data/lib/rhino/version.rb +5 -0
- data/lib/rhino/views/lumina/invitation_mailer/invite.html.erb +29 -0
- data/lib/rhino-rails.rb +3 -0
- data/lib/rhino.rb +26 -0
- metadata +282 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tty-prompt"
|
|
4
|
+
|
|
5
|
+
module Rhino
|
|
6
|
+
module Commands
|
|
7
|
+
# Lightweight base for Rhino CLI commands.
|
|
8
|
+
# Uses tty-prompt for interactive, navigable terminal UI.
|
|
9
|
+
class BaseCommand
|
|
10
|
+
def initialize
|
|
11
|
+
@prompt = TTY::Prompt.new(
|
|
12
|
+
active_color: :cyan,
|
|
13
|
+
help_color: :dim
|
|
14
|
+
)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def say(message = "", color = nil)
|
|
20
|
+
if color
|
|
21
|
+
message = @prompt.decorate(message, color)
|
|
22
|
+
end
|
|
23
|
+
puts message
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def ask(message, **options)
|
|
27
|
+
@prompt.ask(message, **options)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def yes?(message)
|
|
31
|
+
@prompt.yes?(message)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def select(label, choices = nil, **options, &block)
|
|
35
|
+
if block
|
|
36
|
+
@prompt.select(label, **options, &block)
|
|
37
|
+
else
|
|
38
|
+
@prompt.select(label, choices, **options)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def multi_select(label, choices = nil, **options, &block)
|
|
43
|
+
if block
|
|
44
|
+
@prompt.multi_select(label, **options, &block)
|
|
45
|
+
else
|
|
46
|
+
@prompt.multi_select(label, choices, **options)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def task(description)
|
|
51
|
+
print " → #{@prompt.decorate(description + '...', :cyan)}"
|
|
52
|
+
yield
|
|
53
|
+
puts "\r ✓ #{@prompt.decorate(description, :green)} "
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rhino/commands/base_command"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "rhino/blueprint/blueprint_parser"
|
|
6
|
+
require "rhino/blueprint/blueprint_validator"
|
|
7
|
+
require "rhino/blueprint/manifest_manager"
|
|
8
|
+
require "rhino/blueprint/generators/policy_generator"
|
|
9
|
+
require "rhino/blueprint/generators/test_generator"
|
|
10
|
+
require "rhino/blueprint/generators/seeder_generator"
|
|
11
|
+
require "rhino/blueprint/generators/factory_generator"
|
|
12
|
+
|
|
13
|
+
module Rhino
|
|
14
|
+
module Commands
|
|
15
|
+
# Zero-token deterministic code generation from YAML blueprint specs.
|
|
16
|
+
# Port of rhino-server BlueprintCommand.php / rhino-adonis-server blueprint.ts.
|
|
17
|
+
#
|
|
18
|
+
# Usage: rails rhino:blueprint [OPTIONS]
|
|
19
|
+
class BlueprintCommand < BaseCommand
|
|
20
|
+
attr_accessor :options
|
|
21
|
+
|
|
22
|
+
def initialize
|
|
23
|
+
super
|
|
24
|
+
@options = {
|
|
25
|
+
dir: ".rhino/blueprints",
|
|
26
|
+
model: nil,
|
|
27
|
+
force: false,
|
|
28
|
+
dry_run: false,
|
|
29
|
+
skip_tests: false,
|
|
30
|
+
skip_seeders: false
|
|
31
|
+
}
|
|
32
|
+
@migration_timestamp_offset = 0
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def perform
|
|
36
|
+
print_banner
|
|
37
|
+
|
|
38
|
+
blueprints_dir = Rails.root.join(options[:dir]).to_s
|
|
39
|
+
|
|
40
|
+
unless Dir.exist?(blueprints_dir)
|
|
41
|
+
say "Blueprint directory not found: #{blueprints_dir}", :red
|
|
42
|
+
say "Run 'rails rhino:install' first, or create the directory manually.", :yellow
|
|
43
|
+
return
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
parser = Rhino::Blueprint::BlueprintParser.new
|
|
47
|
+
validator = Rhino::Blueprint::BlueprintValidator.new
|
|
48
|
+
manifest = Rhino::Blueprint::ManifestManager.new(blueprints_dir)
|
|
49
|
+
|
|
50
|
+
# 1. Parse roles
|
|
51
|
+
roles_file = File.join(blueprints_dir, "_roles.yaml")
|
|
52
|
+
roles = {}
|
|
53
|
+
|
|
54
|
+
if File.exist?(roles_file)
|
|
55
|
+
begin
|
|
56
|
+
roles = parser.parse_roles(roles_file)
|
|
57
|
+
role_result = validator.validate_roles(roles)
|
|
58
|
+
unless role_result[:valid]
|
|
59
|
+
say "Role validation errors:", :red
|
|
60
|
+
role_result[:errors].each { |e| say " • #{e}", :red }
|
|
61
|
+
return
|
|
62
|
+
end
|
|
63
|
+
say " ✓ Parsed #{roles.length} roles", :green
|
|
64
|
+
rescue => e
|
|
65
|
+
say " ✗ #{e.message}", :red
|
|
66
|
+
return
|
|
67
|
+
end
|
|
68
|
+
else
|
|
69
|
+
say " ⚠ No _roles.yaml found — role cross-reference disabled", :yellow
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# 2. Discover YAML files
|
|
73
|
+
yaml_files = Dir.glob(File.join(blueprints_dir, "*.yaml"))
|
|
74
|
+
.reject { |f| File.basename(f).start_with?("_", ".") }
|
|
75
|
+
.sort
|
|
76
|
+
|
|
77
|
+
if options[:model]
|
|
78
|
+
yaml_files = yaml_files.select { |f| File.basename(f, ".yaml") == options[:model] }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
if yaml_files.empty?
|
|
82
|
+
say "No blueprint YAML files found in #{blueprints_dir}", :yellow
|
|
83
|
+
return
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
say " Found #{yaml_files.length} blueprint(s)", :cyan
|
|
87
|
+
|
|
88
|
+
# 3. Process each blueprint
|
|
89
|
+
is_multi_tenant = multi_tenant_enabled?
|
|
90
|
+
org_identifier = detect_org_identifier
|
|
91
|
+
generated_count = 0
|
|
92
|
+
skipped_count = 0
|
|
93
|
+
all_blueprints = []
|
|
94
|
+
all_generated_files = {}
|
|
95
|
+
|
|
96
|
+
yaml_files.each do |yaml_file|
|
|
97
|
+
filename = File.basename(yaml_file)
|
|
98
|
+
|
|
99
|
+
begin
|
|
100
|
+
blueprint = parser.parse_model(yaml_file)
|
|
101
|
+
rescue => e
|
|
102
|
+
say " ✗ #{filename}: #{e.message}", :red
|
|
103
|
+
next
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Validate
|
|
107
|
+
result = validator.validate_model(blueprint, roles)
|
|
108
|
+
|
|
109
|
+
unless result[:valid]
|
|
110
|
+
say " ✗ #{filename}:", :red
|
|
111
|
+
result[:errors].each { |e| say " • #{e}", :red }
|
|
112
|
+
next
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
result[:warnings].each { |w| say " ⚠ #{w}", :yellow }
|
|
116
|
+
|
|
117
|
+
# Check manifest
|
|
118
|
+
current_hash = parser.compute_file_hash(yaml_file)
|
|
119
|
+
|
|
120
|
+
unless options[:force]
|
|
121
|
+
unless manifest.has_changed?(filename, current_hash)
|
|
122
|
+
say " ⊘ #{blueprint[:model]} — unchanged, skipping", :light_black
|
|
123
|
+
skipped_count += 1
|
|
124
|
+
all_blueprints << blueprint
|
|
125
|
+
next
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
all_blueprints << blueprint
|
|
130
|
+
generated_files = []
|
|
131
|
+
|
|
132
|
+
say " → #{blueprint[:model]}...", :cyan
|
|
133
|
+
|
|
134
|
+
unless options[:dry_run]
|
|
135
|
+
# Generate model
|
|
136
|
+
model_path = generate_model(blueprint, is_multi_tenant)
|
|
137
|
+
generated_files << model_path
|
|
138
|
+
say " ✓ Model: #{model_path}", :green
|
|
139
|
+
|
|
140
|
+
# Generate migration
|
|
141
|
+
migration_path = generate_migration(blueprint)
|
|
142
|
+
generated_files << migration_path
|
|
143
|
+
say " ✓ Migration: #{migration_path}", :green
|
|
144
|
+
|
|
145
|
+
# Generate factory
|
|
146
|
+
factory_path = generate_factory(blueprint)
|
|
147
|
+
generated_files << factory_path
|
|
148
|
+
say " ✓ Factory: #{factory_path}", :green
|
|
149
|
+
|
|
150
|
+
# Generate scope
|
|
151
|
+
scope_path = generate_scope(blueprint)
|
|
152
|
+
generated_files << scope_path
|
|
153
|
+
say " ✓ Scope: #{scope_path}", :green
|
|
154
|
+
|
|
155
|
+
# Generate policy
|
|
156
|
+
policy_path = generate_policy(blueprint)
|
|
157
|
+
generated_files << policy_path
|
|
158
|
+
say " ✓ Policy: #{policy_path}", :green
|
|
159
|
+
|
|
160
|
+
# Generate tests
|
|
161
|
+
unless options[:skip_tests]
|
|
162
|
+
test_path = generate_tests(blueprint, is_multi_tenant, org_identifier)
|
|
163
|
+
generated_files << test_path
|
|
164
|
+
say " ✓ Tests: #{test_path}", :green
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Register in config
|
|
168
|
+
register_model_in_config(blueprint[:model])
|
|
169
|
+
|
|
170
|
+
# Record in manifest
|
|
171
|
+
manifest.record_generation(filename, current_hash, generated_files)
|
|
172
|
+
all_generated_files[filename] = generated_files
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
generated_count += 1
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# 4. Generate cross-model seeders
|
|
179
|
+
unless options[:skip_seeders] || options[:dry_run] || all_blueprints.empty?
|
|
180
|
+
generate_seeders(roles, all_blueprints, is_multi_tenant)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# 5. Save manifest
|
|
184
|
+
manifest.save unless options[:dry_run]
|
|
185
|
+
|
|
186
|
+
# 6. Summary
|
|
187
|
+
say ""
|
|
188
|
+
say "Blueprint generation complete!", :green
|
|
189
|
+
say " Generated: #{generated_count} model(s)", :cyan
|
|
190
|
+
say " Skipped: #{skipped_count} (unchanged)", :light_black
|
|
191
|
+
say ""
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
private
|
|
195
|
+
|
|
196
|
+
# ----------------------------------------------------------------
|
|
197
|
+
# Banner
|
|
198
|
+
# ----------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
def print_banner
|
|
201
|
+
say ""
|
|
202
|
+
say " + Rhino :: Blueprint :: Zero-Token Code Generation +", :cyan
|
|
203
|
+
say ""
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# ----------------------------------------------------------------
|
|
207
|
+
# Model generation
|
|
208
|
+
# ----------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
def generate_model(blueprint, is_multi_tenant)
|
|
211
|
+
name = blueprint[:model]
|
|
212
|
+
table_name = blueprint[:table]
|
|
213
|
+
columns = blueprint[:columns]
|
|
214
|
+
opts = blueprint[:options]
|
|
215
|
+
|
|
216
|
+
belongs_to_org = opts[:belongs_to_organization]
|
|
217
|
+
soft_deletes = opts[:soft_deletes]
|
|
218
|
+
audit_trail = opts[:audit_trail]
|
|
219
|
+
|
|
220
|
+
fillable = columns.map { |c| c[:name] }.reject { |n| n == "organization_id" }
|
|
221
|
+
filter_cols = columns.reject { |c| %w[text json].include?(c[:type]) }.map { |c| c[:name] }
|
|
222
|
+
sort_cols = (columns.reject { |c| %w[text json].include?(c[:type]) }.map { |c| c[:name] } + ["created_at"]).uniq
|
|
223
|
+
field_cols = (["id"] + columns.map { |c| c[:name] } + ["created_at"]).uniq
|
|
224
|
+
include_cols = columns.select { |c| c[:type] == "foreignId" && c[:foreign_model] }
|
|
225
|
+
.map { |c| c[:name].sub(/_id\z/, "") }
|
|
226
|
+
|
|
227
|
+
content = <<~RUBY
|
|
228
|
+
# frozen_string_literal: true
|
|
229
|
+
|
|
230
|
+
class #{name} < Rhino::RhinoModel
|
|
231
|
+
RUBY
|
|
232
|
+
|
|
233
|
+
content += " include Rhino::BelongsToOrganization\n" if belongs_to_org
|
|
234
|
+
content += " include Discard::Model\n" if soft_deletes
|
|
235
|
+
content += " include Rhino::HasAuditTrail\n" if audit_trail
|
|
236
|
+
|
|
237
|
+
# Relationships
|
|
238
|
+
fk_columns = columns.select { |c| c[:type] == "foreignId" && c[:foreign_model] }
|
|
239
|
+
has_relationships = fk_columns.any? { |col| !(belongs_to_org && col[:foreign_model] == "Organization") }
|
|
240
|
+
|
|
241
|
+
if has_relationships
|
|
242
|
+
content += "\n"
|
|
243
|
+
fk_columns.each do |col|
|
|
244
|
+
relation_name = col[:name].sub(/_id\z/, "")
|
|
245
|
+
next if belongs_to_org && col[:foreign_model] == "Organization"
|
|
246
|
+
|
|
247
|
+
opts = "class_name: '#{col[:foreign_model]}'"
|
|
248
|
+
opts += ", optional: true" if col[:nullable]
|
|
249
|
+
content += " belongs_to :#{relation_name}, #{opts}\n"
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Query Builder configuration
|
|
254
|
+
content += "\n"
|
|
255
|
+
content += " rhino_filters #{filter_cols.map { |c| ":#{c}" }.join(', ')}\n" unless filter_cols.empty?
|
|
256
|
+
content += " rhino_sorts #{sort_cols.map { |c| ":#{c}" }.join(', ')}\n" unless sort_cols.empty?
|
|
257
|
+
content += " rhino_fields #{field_cols.map { |c| ":#{c}" }.join(', ')}\n" unless field_cols.empty?
|
|
258
|
+
content += " rhino_includes #{include_cols.map { |c| ":#{c}" }.join(', ')}\n" unless include_cols.empty?
|
|
259
|
+
|
|
260
|
+
# Validations
|
|
261
|
+
|
|
262
|
+
columns.each do |col|
|
|
263
|
+
validations = column_to_validations(col, table_name)
|
|
264
|
+
content += " validates :#{col[:name]}, #{validations}, allow_nil: true\n" unless validations.empty?
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
content += "end\n"
|
|
268
|
+
|
|
269
|
+
path = "app/models/#{name.underscore}.rb"
|
|
270
|
+
write_file(path, content)
|
|
271
|
+
path
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# ----------------------------------------------------------------
|
|
275
|
+
# Migration generation
|
|
276
|
+
# ----------------------------------------------------------------
|
|
277
|
+
|
|
278
|
+
def generate_migration(blueprint)
|
|
279
|
+
table_name = blueprint[:table]
|
|
280
|
+
columns = blueprint[:columns]
|
|
281
|
+
soft_deletes = blueprint[:options][:soft_deletes]
|
|
282
|
+
belongs_to_org = blueprint[:options][:belongs_to_organization]
|
|
283
|
+
|
|
284
|
+
timestamp = (Time.current + @migration_timestamp_offset).strftime("%Y%m%d%H%M%S")
|
|
285
|
+
@migration_timestamp_offset += 1
|
|
286
|
+
class_name = "Create#{blueprint[:model].pluralize}"
|
|
287
|
+
|
|
288
|
+
lines = []
|
|
289
|
+
|
|
290
|
+
# Add organization reference if belongs_to_organization
|
|
291
|
+
if belongs_to_org && multi_tenant_enabled?
|
|
292
|
+
lines << "t.references :organization, null: false, foreign_key: true"
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
columns.each do |col|
|
|
296
|
+
lines << column_to_migration_line(col)
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
content = <<~RUBY
|
|
300
|
+
# frozen_string_literal: true
|
|
301
|
+
|
|
302
|
+
class #{class_name} < ActiveRecord::Migration[8.0]
|
|
303
|
+
def change
|
|
304
|
+
create_table :#{table_name} do |t|
|
|
305
|
+
#{lines.map { |l| " #{l}" }.join("\n")}
|
|
306
|
+
#{" t.datetime :discarded_at\n t.index :discarded_at" if soft_deletes}
|
|
307
|
+
t.timestamps
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
RUBY
|
|
312
|
+
|
|
313
|
+
path = "db/migrate/#{timestamp}_create_#{table_name}.rb"
|
|
314
|
+
write_file(path, content)
|
|
315
|
+
path
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# ----------------------------------------------------------------
|
|
319
|
+
# Factory generation
|
|
320
|
+
# ----------------------------------------------------------------
|
|
321
|
+
|
|
322
|
+
def generate_factory(blueprint)
|
|
323
|
+
factory_gen = Rhino::Blueprint::Generators::FactoryGenerator.new
|
|
324
|
+
content = factory_gen.generate(blueprint)
|
|
325
|
+
|
|
326
|
+
path = "spec/factories/#{blueprint[:model].underscore.pluralize}.rb"
|
|
327
|
+
write_file(path, content)
|
|
328
|
+
path
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# ----------------------------------------------------------------
|
|
332
|
+
# Scope generation
|
|
333
|
+
# ----------------------------------------------------------------
|
|
334
|
+
|
|
335
|
+
def generate_scope(blueprint)
|
|
336
|
+
name = blueprint[:model]
|
|
337
|
+
|
|
338
|
+
content = <<~RUBY
|
|
339
|
+
# frozen_string_literal: true
|
|
340
|
+
|
|
341
|
+
module Scopes
|
|
342
|
+
class #{name}Scope < Rhino::ResourceScope
|
|
343
|
+
# Custom query scope for #{name}.
|
|
344
|
+
# Applied automatically to all #{name} queries via HasAutoScope.
|
|
345
|
+
#
|
|
346
|
+
# Available methods: user, organization, role
|
|
347
|
+
#
|
|
348
|
+
# def apply(relation)
|
|
349
|
+
# relation.where(active: true)
|
|
350
|
+
# end
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
RUBY
|
|
354
|
+
|
|
355
|
+
path = "app/models/scopes/#{name.underscore}_scope.rb"
|
|
356
|
+
write_file(path, content)
|
|
357
|
+
path
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# ----------------------------------------------------------------
|
|
361
|
+
# Policy generation
|
|
362
|
+
# ----------------------------------------------------------------
|
|
363
|
+
|
|
364
|
+
def generate_policy(blueprint)
|
|
365
|
+
policy_gen = Rhino::Blueprint::Generators::PolicyGenerator.new
|
|
366
|
+
content = policy_gen.generate(blueprint)
|
|
367
|
+
|
|
368
|
+
path = "app/policies/#{blueprint[:model].underscore}_policy.rb"
|
|
369
|
+
write_file(path, content)
|
|
370
|
+
path
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
# ----------------------------------------------------------------
|
|
374
|
+
# Test generation
|
|
375
|
+
# ----------------------------------------------------------------
|
|
376
|
+
|
|
377
|
+
def generate_tests(blueprint, is_multi_tenant, org_identifier)
|
|
378
|
+
test_gen = Rhino::Blueprint::Generators::TestGenerator.new
|
|
379
|
+
content = test_gen.generate(blueprint, is_multi_tenant, org_identifier)
|
|
380
|
+
|
|
381
|
+
path = "spec/models/#{blueprint[:model].underscore}_spec.rb"
|
|
382
|
+
write_file(path, content)
|
|
383
|
+
path
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
# ----------------------------------------------------------------
|
|
387
|
+
# Seeder generation
|
|
388
|
+
# ----------------------------------------------------------------
|
|
389
|
+
|
|
390
|
+
def generate_seeders(roles, blueprints, is_multi_tenant)
|
|
391
|
+
seeder_gen = Rhino::Blueprint::Generators::SeederGenerator.new
|
|
392
|
+
aggregated = seeder_gen.aggregate_permissions(blueprints)
|
|
393
|
+
|
|
394
|
+
if is_multi_tenant
|
|
395
|
+
# Role seeder
|
|
396
|
+
role_content = seeder_gen.generate_role_seeder(roles)
|
|
397
|
+
write_file("db/seeds/role_seeder.rb", role_content)
|
|
398
|
+
say " ✓ Seeder: db/seeds/role_seeder.rb", :green
|
|
399
|
+
|
|
400
|
+
# UserRole seeder
|
|
401
|
+
user_role_content = seeder_gen.generate_user_role_seeder(roles, aggregated)
|
|
402
|
+
write_file("db/seeds/user_role_seeder.rb", user_role_content)
|
|
403
|
+
say " ✓ Seeder: db/seeds/user_role_seeder.rb", :green
|
|
404
|
+
else
|
|
405
|
+
# UserPermission seeder
|
|
406
|
+
user_perm_content = seeder_gen.generate_user_permission_seeder(roles, aggregated)
|
|
407
|
+
write_file("db/seeds/user_permission_seeder.rb", user_perm_content)
|
|
408
|
+
say " ✓ Seeder: db/seeds/user_permission_seeder.rb", :green
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
# ----------------------------------------------------------------
|
|
413
|
+
# Config registration
|
|
414
|
+
# ----------------------------------------------------------------
|
|
415
|
+
|
|
416
|
+
def register_model_in_config(name)
|
|
417
|
+
config_path = Rails.root.join("config/initializers/rhino.rb")
|
|
418
|
+
return unless File.exist?(config_path)
|
|
419
|
+
|
|
420
|
+
content = File.read(config_path)
|
|
421
|
+
slug = name.underscore.pluralize
|
|
422
|
+
|
|
423
|
+
# Check if model is already registered (non-commented line)
|
|
424
|
+
return if content.match?(/^\s+\w+\.model\s+:#{slug}\b/)
|
|
425
|
+
|
|
426
|
+
# Detect the block variable name used in the config file (e.g., config, c, etc.)
|
|
427
|
+
block_var = content.match(/Rhino\.configure\s+do\s+\|(\w+)\|/)&.captures&.first || "config"
|
|
428
|
+
|
|
429
|
+
new_entry = " #{block_var}.model :#{slug}, '#{name}'"
|
|
430
|
+
|
|
431
|
+
# Try to insert before the commented-out example line (matching any variable name)
|
|
432
|
+
if content.include?("# #{block_var}.model :posts, 'Post'")
|
|
433
|
+
content = content.gsub(
|
|
434
|
+
"# #{block_var}.model :posts, 'Post'",
|
|
435
|
+
"#{new_entry}\n # #{block_var}.model :posts, 'Post'"
|
|
436
|
+
)
|
|
437
|
+
elsif content.match?(/# \w+\.model :posts, 'Post'/)
|
|
438
|
+
content = content.sub(
|
|
439
|
+
/# (\w+)\.model :posts, 'Post'/,
|
|
440
|
+
"#{new_entry}\n # \\1.model :posts, 'Post'"
|
|
441
|
+
)
|
|
442
|
+
else
|
|
443
|
+
# Fallback: insert after the Models section comment
|
|
444
|
+
content = content.sub(
|
|
445
|
+
/(# Register your models here.*?\n)/,
|
|
446
|
+
"\\1#{new_entry}\n"
|
|
447
|
+
)
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
File.write(config_path, content)
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
# ----------------------------------------------------------------
|
|
454
|
+
# Helpers
|
|
455
|
+
# ----------------------------------------------------------------
|
|
456
|
+
|
|
457
|
+
def multi_tenant_enabled?
|
|
458
|
+
config_path = Rails.root.join("config/initializers/rhino.rb")
|
|
459
|
+
return false unless File.exist?(config_path)
|
|
460
|
+
|
|
461
|
+
File.read(config_path).include?("route_group :tenant")
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
def detect_org_identifier
|
|
465
|
+
config_path = Rails.root.join("config/initializers/rhino.rb")
|
|
466
|
+
return "id" unless File.exist?(config_path)
|
|
467
|
+
|
|
468
|
+
content = File.read(config_path)
|
|
469
|
+
if content.match(/organization_identifier_column.*?['"](\w+)['"]/)
|
|
470
|
+
$1
|
|
471
|
+
else
|
|
472
|
+
"slug"
|
|
473
|
+
end
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
def write_file(relative_path, content)
|
|
477
|
+
full_path = Rails.root.join(relative_path)
|
|
478
|
+
FileUtils.mkdir_p(File.dirname(full_path))
|
|
479
|
+
File.write(full_path, content)
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
def column_to_validations(column, _table_name)
|
|
483
|
+
parts = []
|
|
484
|
+
|
|
485
|
+
case column[:type]
|
|
486
|
+
when "string"
|
|
487
|
+
parts << "length: { maximum: 255 }"
|
|
488
|
+
when "integer", "bigInteger"
|
|
489
|
+
parts << "numericality: { only_integer: true }"
|
|
490
|
+
when "boolean"
|
|
491
|
+
parts << "inclusion: { in: [true, false] }"
|
|
492
|
+
when "decimal", "float"
|
|
493
|
+
parts << "numericality: true"
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
parts.join(", ")
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
def column_to_migration_line(col)
|
|
500
|
+
case col[:type]
|
|
501
|
+
when "foreignId", "references"
|
|
502
|
+
ref_name = col[:name].sub(/_id\z/, "")
|
|
503
|
+
foreign_table = col[:foreign_model]&.underscore&.pluralize
|
|
504
|
+
|
|
505
|
+
# If the reference name doesn't match the foreign model's table,
|
|
506
|
+
# we need to specify the target table explicitly
|
|
507
|
+
if foreign_table && foreign_table != ref_name.pluralize
|
|
508
|
+
line = "t.references :#{ref_name}, foreign_key: { to_table: :#{foreign_table} }"
|
|
509
|
+
else
|
|
510
|
+
line = "t.references :#{ref_name}, foreign_key: true"
|
|
511
|
+
end
|
|
512
|
+
line += ", null: true" if col[:nullable]
|
|
513
|
+
line
|
|
514
|
+
when "decimal"
|
|
515
|
+
precision = col[:precision] || 8
|
|
516
|
+
scale = col[:scale] || 2
|
|
517
|
+
line = "t.decimal :#{col[:name]}, precision: #{precision}, scale: #{scale}"
|
|
518
|
+
line += ", null: true" if col[:nullable]
|
|
519
|
+
line
|
|
520
|
+
else
|
|
521
|
+
line = "t.#{col[:type]} :#{col[:name]}"
|
|
522
|
+
line += ", null: true" if col[:nullable]
|
|
523
|
+
line += ", default: #{col[:default].inspect}" if col[:default]
|
|
524
|
+
line
|
|
525
|
+
end
|
|
526
|
+
end
|
|
527
|
+
end
|
|
528
|
+
end
|
|
529
|
+
end
|