belt 0.2.13 → 0.2.14

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 43bc6e6cb774005faa3bab6b5133403255636327e3dd017ad4acad8fd9a2c884
4
- data.tar.gz: fe11f89e1efa821750dea2953c70bbc21fe24bea394d418c2b870a2804adf331
3
+ metadata.gz: 45b24007aa3cc00eb76d188f6958cf3db681c0a5365aff6419183064b36af8c8
4
+ data.tar.gz: 9dc9e908e5c46be7ec2ee2b835fd08be862de554a6c1ccafe04c9003d5245274
5
5
  SHA512:
6
- metadata.gz: d5c4900624a6435b178455b5aa7e6f34c08d7c2e75e2d1ec3c1b69e717a5964a390ba59a4ff8ccad1be797d29273bddc4354c235f83070d00bb7517d60bcb38e
7
- data.tar.gz: a47bc3063743a278e4241faf65c510ede23ec922c167469f2654627f3079bb558893194f717979002354e07153e41cee257785e8b0f7dea187fc391fb351e5f3
6
+ metadata.gz: 017ed10dec340386b86ed3f398225c53764f70845c3938c878bfa9033c0b81d53fb34797aca786258097a692fcaf7a7a72bbf22b8ba0a8e111982bd2d847b18a
7
+ data.tar.gz: ad9e69adcda08161b2992b3899822931c7144cba29ea5d3ef7e12d175c13d8572d9d0de50491959e2280cd3fb2509e814d85d2e9ed36ddf6964fff431f3b4d20
@@ -18,7 +18,7 @@ module Belt
18
18
 
19
19
  include AppDetection
20
20
 
21
- FIELD_TYPES = %w[string text integer float boolean date datetime].freeze
21
+ FIELD_TYPES = %w[string text integer float boolean date datetime references].freeze
22
22
 
23
23
  GENERATOR_HELP = {
24
24
  'scaffold' => {
@@ -30,7 +30,7 @@ module Belt
30
30
  ],
31
31
  examples: [
32
32
  ['belt g scaffold post title body:text'],
33
- ['belt g scaffold comment author body:text status'],
33
+ ['belt g scaffold comment post:references body:text'],
34
34
  ['belt g scaffold task --skip-views'],
35
35
  ['belt g scaffold post title body:text --force']
36
36
  ],
@@ -44,6 +44,16 @@ module Belt
44
44
  infrastructure/modules/app/dynamodb.tf DynamoDB table generated
45
45
  frontend/src/pages/<names>/ React pages (if frontend exists)
46
46
 
47
+ Nested Resources:
48
+ Use `<parent>:references` to create a nested resource. This will:
49
+ - Add `belongs_to :<parent>` in the child model
50
+ - Add `has_many :<children>` in the parent model (if it exists)
51
+ - Generate a GSI on `<parent>_id` for efficient queries
52
+ - Nest routes under the parent (e.g., /posts/{post_id}/comments)
53
+ - Scope the controller index action to the parent
54
+
55
+ Example: `belt g scaffold comment post:references body:text`
56
+
47
57
  Alias: `belt g resource` works the same way.
48
58
  NOTES
49
59
  },
@@ -132,7 +142,13 @@ module Belt
132
142
 
133
143
  def self.parse_field(arg)
134
144
  name, type = arg.split(':', 2)
135
- { name: name, type: type || 'string' }
145
+ type ||= 'string'
146
+
147
+ if %w[references belongs_to].include?(type)
148
+ { name: name, type: 'references', referenced_model: name }
149
+ else
150
+ { name: name, type: type }
151
+ end
136
152
  end
137
153
 
138
154
  RESERVED_NAMES = %w[
@@ -259,6 +275,7 @@ module Belt
259
275
  @singular_name = Belt::Inflector.singularize(@name)
260
276
  @resource_name = Belt::Inflector.pluralize(@singular_name)
261
277
  @class_name = Belt::Inflector.classify(@singular_name)
278
+ @references, @regular_fields = @fields.partition { |f| f[:type] == 'references' }
262
279
  end
263
280
 
264
281
  def generate
@@ -327,6 +344,7 @@ module Belt
327
344
  generate_controller
328
345
  inject_routes
329
346
  inject_schema
347
+ inject_parent_associations
330
348
  sync_tables
331
349
  generate_views_if_frontend
332
350
  puts "\n✓ Scaffold '#{@singular_name}' generated!"
@@ -337,11 +355,16 @@ module Belt
337
355
  puts " #{find_contracts_file_path || 'config/contracts.rb'} (updated)"
338
356
  puts " lambda/lib/routes/#{@app_name}_routes.rb (updated)"
339
357
  puts " frontend/src/pages/#{@resource_name}/ (views)" if Dir.exist?('frontend/src')
358
+ @references.each do |ref|
359
+ parent_model_path = "lambda/models/#{ref[:referenced_model]}.rb"
360
+ puts " #{parent_model_path} (updated — added has_many)" if File.exist?(parent_model_path)
361
+ end
340
362
  end
341
363
 
342
364
  def generate_model_standalone
343
365
  generate_model
344
366
  inject_schema
367
+ inject_parent_associations
345
368
  sync_tables
346
369
  end
347
370
 
@@ -363,6 +386,59 @@ module Belt
363
386
 
364
387
  content = File.read(routes_file)
365
388
  tables_arg = @fields.any? ? ", tables: [:#{@resource_name}]" : ''
389
+
390
+ # If this resource has a reference to a parent that already has routes, nest it
391
+ parent_ref = @references.find do |ref|
392
+ parent_resource = Belt::Inflector.pluralize(ref[:referenced_model])
393
+ content.match?(/resources :#{Regexp.escape(parent_resource)}\b/)
394
+ end
395
+
396
+ if parent_ref
397
+ inject_nested_route(content, routes_file, parent_ref, tables_arg)
398
+ else
399
+ inject_top_level_route(content, routes_file, tables_arg)
400
+ end
401
+
402
+ # Also update route manifest
403
+ inject_route_manifest
404
+ end
405
+
406
+ def inject_nested_route(content, routes_file, parent_ref, tables_arg)
407
+ parent_resource = Belt::Inflector.pluralize(parent_ref[:referenced_model])
408
+ resource_line = "resources :#{@resource_name}#{tables_arg}"
409
+
410
+ # Find the parent resources line and convert to a block if needed
411
+ parent_pattern = /^(\s*)resources :#{Regexp.escape(parent_resource)}\b([^\n]*?)(?:\s*do\s*)?$/
412
+
413
+ if content.match?(/resources :#{Regexp.escape(parent_resource)}\b[^\n]*\bdo\b/)
414
+ # Parent already has a block — insert before its closing end
415
+ # Match the parent block: resources :parent do ... end
416
+ block_pattern = /^(\s*)(resources :#{Regexp.escape(parent_resource)}\b[^\n]*do\s*\n)(.*?)^\1(end)/m
417
+ if content.match?(block_pattern)
418
+ content.sub!(block_pattern) do
419
+ indent = ::Regexp.last_match(1)
420
+ opener = ::Regexp.last_match(2)
421
+ body = ::Regexp.last_match(3)
422
+ closer = ::Regexp.last_match(4)
423
+ "#{indent}#{opener}#{body}#{indent} #{resource_line}\n#{indent}#{closer}"
424
+ end
425
+ end
426
+ else
427
+ # Parent is a single line — convert to block form
428
+ content.sub!(parent_pattern) do
429
+ indent = ::Regexp.last_match(1)
430
+ parent_line_rest = ::Regexp.last_match(2).rstrip
431
+ "#{indent}resources :#{parent_resource}#{parent_line_rest} do\n" \
432
+ "#{indent} #{resource_line}\n" \
433
+ "#{indent}end"
434
+ end
435
+ end
436
+
437
+ File.write(routes_file, content)
438
+ puts " update #{routes_file}"
439
+ end
440
+
441
+ def inject_top_level_route(content, routes_file, tables_arg)
366
442
  resource_line = "resources :#{@resource_name}#{tables_arg}"
367
443
 
368
444
  # If this resource already exists in routes (force mode), replace it
@@ -377,17 +453,14 @@ module Belt
377
453
  content.sub!('# resources :posts', resource_line)
378
454
  else
379
455
  # Find the target namespace block and insert before its closing `end`
380
- # Match: `namespace :<name> do` ... `end` (the first `end` at the same indent level)
381
456
  namespace_pattern = /^(\s*)namespace :#{Regexp.escape(@app_name)}\b[^\n]*do\s*\n(.*?)^\1end/m
382
457
 
383
458
  if content.match?(namespace_pattern)
384
459
  content.sub!(namespace_pattern) do |match|
385
460
  indent = ::Regexp.last_match(1)
386
- # Insert the resource line before the namespace's closing `end`
387
461
  match.sub(/^(#{indent})end\z/m, "#{indent} #{resource_line}\n#{indent}end")
388
462
  end
389
463
  else
390
- # Fallback: find any single namespace block and insert into it
391
464
  single_ns_pattern = /^(\s*)namespace :\w+\b[^\n]*do\s*\n(.*?)^\1end/m
392
465
  if content.match?(single_ns_pattern)
393
466
  content.sub!(single_ns_pattern) do |match|
@@ -395,7 +468,6 @@ module Belt
395
468
  match.sub(/^(#{indent})end\z/m, "#{indent} #{resource_line}\n#{indent}end")
396
469
  end
397
470
  else
398
- # No namespace block found at all — insert at top level before final end
399
471
  content.sub!(/^(\s*end\s*)\z/m, " #{resource_line}\n\\1")
400
472
  end
401
473
  end
@@ -403,9 +475,6 @@ module Belt
403
475
 
404
476
  File.write(routes_file, content)
405
477
  puts " update #{routes_file}"
406
-
407
- # Also update route manifest
408
- inject_route_manifest
409
478
  end
410
479
 
411
480
  def inject_route_manifest
@@ -414,15 +483,38 @@ module Belt
414
483
 
415
484
  id_param = "#{@singular_name}_id"
416
485
 
417
- new_routes = [
418
- "{ verb: 'GET', path: '/#{@resource_name}', controller: '#{@resource_name}', action: 'index' }",
419
- "{ verb: 'POST', path: '/#{@resource_name}', controller: '#{@resource_name}', action: 'create' }",
420
- "{ verb: 'GET', path: '/#{@resource_name}/{#{id_param}}', controller: '#{@resource_name}', action: 'show' }",
421
- "{ verb: 'PUT', path: '/#{@resource_name}/{#{id_param}}', " \
422
- "controller: '#{@resource_name}', action: 'update' }",
423
- "{ verb: 'DELETE', path: '/#{@resource_name}/{#{id_param}}', " \
424
- "controller: '#{@resource_name}', action: 'destroy' }"
425
- ]
486
+ # Determine if this is nested under a parent
487
+ parent_ref = @references.first
488
+ if parent_ref
489
+ parent_resource = Belt::Inflector.pluralize(parent_ref[:referenced_model])
490
+ parent_singular = parent_ref[:referenced_model]
491
+ parent_id_param = "#{parent_singular}_id"
492
+ path_prefix = "/#{parent_resource}/{#{parent_id_param}}"
493
+
494
+ new_routes = [
495
+ "{ verb: 'GET', path: '#{path_prefix}/#{@resource_name}', " \
496
+ "controller: '#{@resource_name}', action: 'index' }",
497
+ "{ verb: 'POST', path: '#{path_prefix}/#{@resource_name}', " \
498
+ "controller: '#{@resource_name}', action: 'create' }",
499
+ "{ verb: 'GET', path: '#{path_prefix}/#{@resource_name}/{#{id_param}}', " \
500
+ "controller: '#{@resource_name}', action: 'show' }",
501
+ "{ verb: 'PUT', path: '#{path_prefix}/#{@resource_name}/{#{id_param}}', " \
502
+ "controller: '#{@resource_name}', action: 'update' }",
503
+ "{ verb: 'DELETE', path: '#{path_prefix}/#{@resource_name}/{#{id_param}}', " \
504
+ "controller: '#{@resource_name}', action: 'destroy' }"
505
+ ]
506
+ else
507
+ new_routes = [
508
+ "{ verb: 'GET', path: '/#{@resource_name}', controller: '#{@resource_name}', action: 'index' }",
509
+ "{ verb: 'POST', path: '/#{@resource_name}', controller: '#{@resource_name}', action: 'create' }",
510
+ "{ verb: 'GET', path: '/#{@resource_name}/{#{id_param}}', " \
511
+ "controller: '#{@resource_name}', action: 'show' }",
512
+ "{ verb: 'PUT', path: '/#{@resource_name}/{#{id_param}}', " \
513
+ "controller: '#{@resource_name}', action: 'update' }",
514
+ "{ verb: 'DELETE', path: '/#{@resource_name}/{#{id_param}}', " \
515
+ "controller: '#{@resource_name}', action: 'destroy' }"
516
+ ]
517
+ end
426
518
 
427
519
  existing_content = File.read(manifest_file)
428
520
  constant = @app_name.upcase
@@ -456,7 +548,8 @@ module Belt
456
548
  content = File.read(schema_file)
457
549
 
458
550
  # Generate OpenAPI-style model block for API response contracts
459
- response_fields = @fields.map { |f| " #{schema_type_for(f[:type])} :#{f[:name]}" }
551
+ response_fields = @references.map { |ref| " string :#{ref[:referenced_model]}_id" }
552
+ response_fields += @regular_fields.map { |f| " #{schema_type_for(f[:type])} :#{f[:name]}" }
460
553
  response_fields << ' string :created_at'
461
554
  response_fields << ' string :updated_at'
462
555
 
@@ -488,10 +581,38 @@ module Belt
488
581
  when 'boolean' then 'boolean'
489
582
  when 'date' then 'date'
490
583
  when 'datetime' then 'datetime'
584
+ when 'references' then 'string'
491
585
  else 'string'
492
586
  end
493
587
  end
494
588
 
589
+ def inject_parent_associations
590
+ @references.each do |ref|
591
+ parent_model_path = "lambda/models/#{ref[:referenced_model]}.rb"
592
+ next unless File.exist?(parent_model_path)
593
+
594
+ content = File.read(parent_model_path)
595
+ has_many_line = " has_many :#{@resource_name}, foreign_key: '#{ref[:referenced_model]}_id', " \
596
+ "index: '#{Belt::Inflector.classify(ref[:referenced_model])}Index'"
597
+
598
+ next if content.include?("has_many :#{@resource_name}")
599
+
600
+ # Insert after the last belongs_to/has_many line, or after class declaration
601
+ if content.match?(/^\s*(has_many|belongs_to)\s+:/)
602
+ content.sub!(/^(\s*(?:has_many|belongs_to)\s+:[^\n]+\n)(?!.*(?:has_many|belongs_to))/m) do |match|
603
+ "#{match}#{has_many_line}\n"
604
+ end
605
+ elsif content.match?(/^class\s+\w+\s*<\s*\w+/)
606
+ content.sub!(/^(class\s+\w+\s*<\s*\w+.*\n)/) do |match|
607
+ "#{match}#{has_many_line}\n\n"
608
+ end
609
+ end
610
+
611
+ File.write(parent_model_path, content)
612
+ puts " update #{parent_model_path} (added has_many :#{@resource_name})"
613
+ end
614
+ end
615
+
495
616
  def sync_tables
496
617
  Belt::CLI::TablesCommand.sync_all_environments
497
618
  end
data/lib/belt/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Belt
4
- VERSION = '0.2.13'
4
+ VERSION = '0.2.14'
5
5
  end
@@ -4,6 +4,58 @@ require_relative 'application_controller'
4
4
 
5
5
  module <%= @module_name %>Controllers
6
6
  class <%= @class_name %>sController < ApplicationController
7
+ <% if @references.any? -%>
8
+ <% parent = @references.first -%>
9
+ <% parent_resource = Belt::Inflector.pluralize(parent[:referenced_model]) -%>
10
+ <% parent_class = Belt::Inflector.classify(parent[:referenced_model]) -%>
11
+ # GET /<%= parent_resource %>/:id/<%= @resource_name %>
12
+ def index
13
+ @<%= @resource_name %> = <%= @class_name %>.where(<%= parent[:referenced_model] %>_id: params[:<%= parent[:referenced_model] %>_id], index: '<%= Belt::Inflector.classify(parent[:referenced_model]) %>Index')
14
+ end
15
+
16
+ # POST /<%= parent_resource %>/:id/<%= @resource_name %>
17
+ def create
18
+ @<%= @singular_name %> = <%= @class_name %>.new(<%= (["#{parent[:referenced_model]}_id: params[:#{parent[:referenced_model]}_id]"] + @regular_fields.map { |f| "#{f[:name]}: params[:#{f[:name]}]" }).join(', ') %>)
19
+
20
+ if @<%= @singular_name %>.save
21
+ response_status :created
22
+ else
23
+ error_response(@<%= @singular_name %>.errors.full_messages.join(', '), :unprocessable_entity)
24
+ end
25
+ end
26
+
27
+ # GET /<%= parent_resource %>/:id/<%= @resource_name %>/:<%= @singular_name %>_id
28
+ def show
29
+ @<%= @singular_name %> = <%= @class_name %>.find(params[:<%= @singular_name %>_id])
30
+ return error_response('<%= @class_name %> not found', :not_found) unless @<%= @singular_name %>
31
+ end
32
+
33
+ # PUT /<%= parent_resource %>/:id/<%= @resource_name %>/:<%= @singular_name %>_id
34
+ def update
35
+ @<%= @singular_name %> = <%= @class_name %>.find(params[:<%= @singular_name %>_id])
36
+ return error_response('<%= @class_name %> not found', :not_found) unless @<%= @singular_name %>
37
+
38
+ attrs = {}
39
+ <% @regular_fields.each do |field| -%>
40
+ attrs[:<%= field[:name] %>] = params[:<%= field[:name] %>] if params.key?(:<%= field[:name] %>)
41
+ <% end -%>
42
+
43
+ if @<%= @singular_name %>.update(attrs)
44
+ # implicit response
45
+ else
46
+ error_response(@<%= @singular_name %>.errors.full_messages.join(', '), :unprocessable_entity)
47
+ end
48
+ end
49
+
50
+ # DELETE /<%= parent_resource %>/:id/<%= @resource_name %>/:<%= @singular_name %>_id
51
+ def destroy
52
+ <%= @singular_name %> = <%= @class_name %>.find(params[:<%= @singular_name %>_id])
53
+ return error_response('<%= @class_name %> not found', :not_found) unless <%= @singular_name %>
54
+
55
+ <%= @singular_name %>.destroy
56
+ head :no_content
57
+ end
58
+ <% else -%>
7
59
  # GET /<%= @resource_name %>
8
60
  def index
9
61
  @<%= @resource_name %> = <%= @class_name %>.all
@@ -11,7 +63,7 @@ module <%= @module_name %>Controllers
11
63
 
12
64
  # POST /<%= @resource_name %>
13
65
  def create
14
- @<%= @singular_name %> = <%= @class_name %>.new(<%= @fields.map { |f| "#{f[:name]}: params[:#{f[:name]}]" }.join(', ') %>)
66
+ @<%= @singular_name %> = <%= @class_name %>.new(<%= @regular_fields.map { |f| "#{f[:name]}: params[:#{f[:name]}]" }.join(', ') %>)
15
67
 
16
68
  if @<%= @singular_name %>.save
17
69
  response_status :created
@@ -32,11 +84,13 @@ module <%= @module_name %>Controllers
32
84
  return error_response('<%= @class_name %> not found', :not_found) unless @<%= @singular_name %>
33
85
 
34
86
  attrs = {}
35
- <% @fields.each do |field| -%>
87
+ <% @regular_fields.each do |field| -%>
36
88
  attrs[:<%= field[:name] %>] = params[:<%= field[:name] %>] if params.key?(:<%= field[:name] %>)
37
89
  <% end -%>
38
90
 
39
- unless @<%= @singular_name %>.update(attrs)
91
+ if @<%= @singular_name %>.update(attrs)
92
+ # implicit response
93
+ else
40
94
  error_response(@<%= @singular_name %>.errors.full_messages.join(', '), :unprocessable_entity)
41
95
  end
42
96
  end
@@ -49,5 +103,6 @@ module <%= @module_name %>Controllers
49
103
  <%= @singular_name %>.destroy
50
104
  head :no_content
51
105
  end
106
+ <% end -%>
52
107
  end
53
108
  end
@@ -1,5 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class <%= @class_name %> < ApplicationRecord
4
- attr_accessor <%= @fields.map { |f| ":#{f[:name]}" }.join(', ') %>
4
+ <% @references.each do |ref| -%>
5
+ belongs_to :<%= ref[:referenced_model] %>
6
+ <% end -%>
7
+ <% if @references.any? && (@regular_fields.any? || @references.any?) -%>
8
+
9
+ <% end -%>
10
+ <% @references.each do |ref| -%>
11
+ attr_accessor :<%= ref[:referenced_model] %>_id
12
+ <% end -%>
13
+ <% @regular_fields.each do |field| -%>
14
+ attr_accessor :<%= field[:name] %>
15
+ <% end -%>
5
16
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: belt
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.13
4
+ version: 0.2.14
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stowzilla