belt 0.2.12 → 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: 10e92ad2ab3fe79f48f07cca6495ce8b9ccdad09755c7a6517e651deadcc118d
4
- data.tar.gz: 6cd8a8ee7ad1023fb949183122c3aca465c216cf91d175e8f9395e8f413ad880
3
+ metadata.gz: 45b24007aa3cc00eb76d188f6958cf3db681c0a5365aff6419183064b36af8c8
4
+ data.tar.gz: 9dc9e908e5c46be7ec2ee2b835fd08be862de554a6c1ccafe04c9003d5245274
5
5
  SHA512:
6
- metadata.gz: cb7d5dacc2b90e0ae7c4dd9853d76ea42ef8d6e324d30edd91c5fbe6d934affafd2c3bee6fb752960d9c8462a59663244ae448797ab7e57de7ad05c50afa627f
7
- data.tar.gz: fd7f4ff9af68c36b34c02c2f4af2c42649f0e9494ac192eaaefeae3111e0da81ced4091b88510b82ac183fefaadfd0677e4d34f270e16a7dcf389b54259e8c52
6
+ metadata.gz: 017ed10dec340386b86ed3f398225c53764f70845c3938c878bfa9033c0b81d53fb34797aca786258097a692fcaf7a7a72bbf22b8ba0a8e111982bd2d847b18a
7
+ data.tar.gz: ad9e69adcda08161b2992b3899822931c7144cba29ea5d3ef7e12d175c13d8572d9d0de50491959e2280cd3fb2509e814d85d2e9ed36ddf6964fff431f3b4d20
data/CHANGELOG.md CHANGED
@@ -31,7 +31,17 @@ Previously, contracts were only accessible as a side-effect of `belt routes -f j
31
31
  - Module template updated: `source` points to `config/routes.rb`
32
32
  - All scaffold/generator help text and templates reference new filenames
33
33
 
34
- ## Unreleased
34
+ ## 0.2.13
35
+
36
+ ### Template cleanup
37
+
38
+ - **Model template**: single-line `attr_accessor` instead of one per attribute; removed redundant `to_h` (ActiveItem::Base already provides it)
39
+ - **Controller template**: uses implicit response pattern (instance variable assigns) instead of explicit `success_response` calls; `response_status :created` for create actions, `head :no_content` for destroy
40
+ - **Gemfile template**: removed `activeitem` and `lambda_loadout` (already belt gem dependencies)
41
+ - **gitignore template**: excludes `lambda/lib/routes/` (generated artifact from `belt routes`)
42
+ - **Removed `require 'activeitem'`** from api.rb, environment.rb, and application_record.rb templates (belt requires it transitively)
43
+ - **Removed `ActiveItem.configure` boilerplate** from api.rb and environment.rb templates (activeitem 0.0.13+ defaults `table_prefix` and `environment` from ENV vars)
44
+ - Bumped activeitem dependency to `>= 0.0.13`
35
45
 
36
46
  ## 0.2.11
37
47
 
@@ -59,6 +59,7 @@ module Belt
59
59
 
60
60
  install_dependencies(dest_dir)
61
61
  setup_frontend_infra_for_existing_environments
62
+ generate_views_for_existing_resources
62
63
 
63
64
  return if @quiet || !@announce
64
65
 
@@ -104,6 +105,28 @@ module Belt
104
105
  puts " create #{frontend_tf}" unless @quiet
105
106
  end
106
107
 
108
+ def generate_views_for_existing_resources
109
+ routes_file = find_routes_file_path
110
+ return unless routes_file && File.exist?(routes_file)
111
+
112
+ resources = extract_resources_from_routes(routes_file)
113
+ return if resources.empty?
114
+
115
+ puts "\n Detected existing resources: #{resources.join(', ')}" unless @quiet
116
+ puts ' Generating views...' unless @quiet
117
+
118
+ require_relative 'views_command'
119
+ resources.each do |resource_name|
120
+ fields = ViewsCommand.read_schema_fields(resource_name)
121
+ ViewsCommand.new(resource_name, fields, force: true).generate
122
+ end
123
+ end
124
+
125
+ def extract_resources_from_routes(routes_file)
126
+ content = File.read(routes_file)
127
+ content.scan(/resources\s+:(\w+)/).flatten.uniq
128
+ end
129
+
107
130
  def copy_template(src_dir, dest_dir)
108
131
  Dir.glob("#{src_dir}/**/*", File::FNM_DOTMATCH).each do |src|
109
132
  next if File.directory?(src)
@@ -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
 
@@ -478,18 +571,48 @@ module Belt
478
571
  puts " update #{schema_file}"
479
572
  end
480
573
 
481
- # Map generator field types to OpenAPI schema DSL types
574
+ # Map generator field types to schema DSL types.
575
+ # Preserves :text, :date, :datetime so views can render appropriate form inputs.
482
576
  def schema_type_for(field_type)
483
577
  case field_type.to_s
484
- when 'text' then 'string'
578
+ when 'text' then 'text'
485
579
  when 'integer' then 'integer'
486
580
  when 'float' then 'number'
487
581
  when 'boolean' then 'boolean'
488
- when 'date', 'datetime' then 'string'
582
+ when 'date' then 'date'
583
+ when 'datetime' then 'datetime'
584
+ when 'references' then 'string'
489
585
  else 'string'
490
586
  end
491
587
  end
492
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
+
493
616
  def sync_tables
494
617
  Belt::CLI::TablesCommand.sync_all_environments
495
618
  end
@@ -505,7 +628,7 @@ module Belt
505
628
  return unless Dir.exist?('frontend/src')
506
629
  return if @skip_views
507
630
 
508
- Belt::CLI::ViewsCommand.new(@name, @fields).generate
631
+ Belt::CLI::ViewsCommand.new(@name, @fields, force: @force).generate
509
632
  end
510
633
  end
511
634
  end