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.
Files changed (71) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +59 -0
  3. data/lib/rhino/blueprint/blueprint_parser.rb +198 -0
  4. data/lib/rhino/blueprint/blueprint_validator.rb +209 -0
  5. data/lib/rhino/blueprint/generators/factory_generator.rb +74 -0
  6. data/lib/rhino/blueprint/generators/policy_generator.rb +154 -0
  7. data/lib/rhino/blueprint/generators/seeder_generator.rb +160 -0
  8. data/lib/rhino/blueprint/generators/test_generator.rb +291 -0
  9. data/lib/rhino/blueprint/manifest_manager.rb +81 -0
  10. data/lib/rhino/commands/base_command.rb +57 -0
  11. data/lib/rhino/commands/blueprint_command.rb +529 -0
  12. data/lib/rhino/commands/export_postman_command.rb +328 -0
  13. data/lib/rhino/commands/export_types_command.rb +202 -0
  14. data/lib/rhino/commands/generate_command.rb +535 -0
  15. data/lib/rhino/commands/install_command.rb +408 -0
  16. data/lib/rhino/commands/invitation_link_command.rb +107 -0
  17. data/lib/rhino/concerns/belongs_to_organization.rb +49 -0
  18. data/lib/rhino/concerns/has_audit_trail.rb +125 -0
  19. data/lib/rhino/concerns/has_auto_scope.rb +91 -0
  20. data/lib/rhino/concerns/has_permissions.rb +117 -0
  21. data/lib/rhino/concerns/has_rhino.rb +93 -0
  22. data/lib/rhino/concerns/has_uuid.rb +26 -0
  23. data/lib/rhino/concerns/has_validation.rb +250 -0
  24. data/lib/rhino/concerns/hidable_columns.rb +180 -0
  25. data/lib/rhino/configuration.rb +101 -0
  26. data/lib/rhino/controllers/auth_controller.rb +242 -0
  27. data/lib/rhino/controllers/invitations_controller.rb +231 -0
  28. data/lib/rhino/controllers/resources_controller.rb +813 -0
  29. data/lib/rhino/engine.rb +64 -0
  30. data/lib/rhino/mailers/invitation_mailer.rb +22 -0
  31. data/lib/rhino/middleware/resolve_organization_from_route.rb +72 -0
  32. data/lib/rhino/models/audit_log.rb +17 -0
  33. data/lib/rhino/models/organization_invitation.rb +57 -0
  34. data/lib/rhino/models/rhino_model.rb +387 -0
  35. data/lib/rhino/policies/invitation_policy.rb +54 -0
  36. data/lib/rhino/policies/resource_policy.rb +197 -0
  37. data/lib/rhino/query_builder.rb +278 -0
  38. data/lib/rhino/railtie.rb +11 -0
  39. data/lib/rhino/resource_scope.rb +59 -0
  40. data/lib/rhino/routes.rb +124 -0
  41. data/lib/rhino/tasks/rhino.rake +47 -0
  42. data/lib/rhino/templates/audit_trail/create_audit_logs.rb.erb +26 -0
  43. data/lib/rhino/templates/generate/factory.rb.erb +43 -0
  44. data/lib/rhino/templates/generate/migration.rb.erb +26 -0
  45. data/lib/rhino/templates/generate/model.rb.erb +55 -0
  46. data/lib/rhino/templates/generate/policy.rb.erb +52 -0
  47. data/lib/rhino/templates/generate/scope.rb.erb +31 -0
  48. data/lib/rhino/templates/multi_tenant/factories/organizations.rb.erb +9 -0
  49. data/lib/rhino/templates/multi_tenant/factories/roles.rb.erb +9 -0
  50. data/lib/rhino/templates/multi_tenant/factories/user_roles.rb.erb +10 -0
  51. data/lib/rhino/templates/multi_tenant/factories/users.rb.erb +9 -0
  52. data/lib/rhino/templates/multi_tenant/migrations/create_organizations.rb.erb +15 -0
  53. data/lib/rhino/templates/multi_tenant/migrations/create_roles.rb.erb +15 -0
  54. data/lib/rhino/templates/multi_tenant/migrations/create_user_roles.rb.erb +16 -0
  55. data/lib/rhino/templates/multi_tenant/migrations/create_users.rb.erb +15 -0
  56. data/lib/rhino/templates/multi_tenant/models/organization.rb.erb +18 -0
  57. data/lib/rhino/templates/multi_tenant/models/role.rb.erb +11 -0
  58. data/lib/rhino/templates/multi_tenant/models/user.rb.erb +14 -0
  59. data/lib/rhino/templates/multi_tenant/models/user_role.rb.erb +9 -0
  60. data/lib/rhino/templates/multi_tenant/policies/organization_policy.rb.erb +6 -0
  61. data/lib/rhino/templates/multi_tenant/policies/role_policy.rb.erb +6 -0
  62. data/lib/rhino/templates/multi_tenant/seeders/organization_seeder.rb.erb +9 -0
  63. data/lib/rhino/templates/multi_tenant/seeders/role_seeder.rb.erb +19 -0
  64. data/lib/rhino/templates/rhino.rb +71 -0
  65. data/lib/rhino/templates/rhino_model.rb +104 -0
  66. data/lib/rhino/templates/routes.rb +13 -0
  67. data/lib/rhino/version.rb +5 -0
  68. data/lib/rhino/views/lumina/invitation_mailer/invite.html.erb +29 -0
  69. data/lib/rhino-rails.rb +3 -0
  70. data/lib/rhino.rb +26 -0
  71. 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