rider-kick 0.0.13 → 0.0.15

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 (91) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +629 -25
  3. data/lib/generators/rider_kick/USAGE +2 -0
  4. data/lib/generators/rider_kick/base_generator.rb +190 -0
  5. data/lib/generators/rider_kick/clean_arch_generator.rb +236 -45
  6. data/lib/generators/rider_kick/clean_arch_generator_engine_spec.rb +359 -0
  7. data/lib/generators/rider_kick/clean_arch_generator_factory_bot_spec.rb +131 -0
  8. data/lib/generators/rider_kick/entity_type_mapping_spec.rb +22 -13
  9. data/lib/generators/rider_kick/errors.rb +42 -0
  10. data/lib/generators/rider_kick/factory_generator.rb +238 -0
  11. data/lib/generators/rider_kick/factory_generator_spec.rb +175 -0
  12. data/lib/generators/rider_kick/init_generator.rb +15 -0
  13. data/lib/generators/rider_kick/repositories_contract_spec.rb +95 -22
  14. data/lib/generators/rider_kick/scaffold_generator.rb +381 -64
  15. data/lib/generators/rider_kick/scaffold_generator_builder_uploaders_spec.rb +119 -14
  16. data/lib/generators/rider_kick/scaffold_generator_conditional_filtering_spec.rb +820 -0
  17. data/lib/generators/rider_kick/scaffold_generator_contracts_spec.rb +37 -10
  18. data/lib/generators/rider_kick/scaffold_generator_contracts_with_scope_spec.rb +42 -14
  19. data/lib/generators/rider_kick/scaffold_generator_engine_spec.rb +221 -0
  20. data/lib/generators/rider_kick/scaffold_generator_idempotent_spec.rb +38 -13
  21. data/lib/generators/rider_kick/scaffold_generator_list_spec_format_spec.rb +153 -0
  22. data/lib/generators/rider_kick/scaffold_generator_rspec_spec.rb +347 -0
  23. data/lib/generators/rider_kick/scaffold_generator_success_spec.rb +31 -12
  24. data/lib/generators/rider_kick/scaffold_generator_with_scope_spec.rb +33 -12
  25. data/lib/generators/rider_kick/structure_generator.rb +156 -43
  26. data/lib/generators/rider_kick/structure_generator_comprehensive_spec.rb +598 -0
  27. data/lib/generators/rider_kick/structure_generator_engine_spec.rb +279 -0
  28. data/lib/generators/rider_kick/structure_generator_spec.rb +3 -3
  29. data/lib/generators/rider_kick/structure_generator_success_spec.rb +33 -5
  30. data/lib/generators/rider_kick/structure_generator_unit_spec.rb +2202 -0
  31. data/lib/generators/rider_kick/templates/.rubocop.yml +5 -4
  32. data/lib/generators/rider_kick/templates/config/initializers/rider_kick.rb.tt +29 -0
  33. data/lib/generators/rider_kick/templates/config/initializers/version.rb.tt +1 -1
  34. data/lib/generators/rider_kick/templates/db/migrate/20220613145533_init_database.rb +1 -1
  35. data/lib/generators/rider_kick/templates/db/structures/example.yaml.tt +142 -66
  36. data/lib/generators/rider_kick/templates/domains/core/builders/builder.rb.tt +36 -10
  37. data/lib/generators/rider_kick/templates/domains/core/builders/builder_spec.rb.tt +219 -0
  38. data/lib/generators/rider_kick/templates/domains/core/builders/error.rb.tt +2 -2
  39. data/lib/generators/rider_kick/templates/domains/core/builders/pagination.rb.tt +2 -2
  40. data/lib/generators/rider_kick/templates/domains/core/entities/entity.rb.tt +32 -14
  41. data/lib/generators/rider_kick/templates/domains/core/entities/error.rb.tt +1 -1
  42. data/lib/generators/rider_kick/templates/domains/core/entities/pagination.rb.tt +1 -1
  43. data/lib/generators/rider_kick/templates/domains/core/repositories/abstract_repository.rb.tt +4 -4
  44. data/lib/generators/rider_kick/templates/domains/core/repositories/create.rb.tt +2 -2
  45. data/lib/generators/rider_kick/templates/domains/core/repositories/create_spec.rb.tt +78 -0
  46. data/lib/generators/rider_kick/templates/domains/core/repositories/destroy.rb.tt +2 -2
  47. data/lib/generators/rider_kick/templates/domains/core/repositories/destroy_spec.rb.tt +88 -0
  48. data/lib/generators/rider_kick/templates/domains/core/repositories/fetch_by_id.rb.tt +3 -3
  49. data/lib/generators/rider_kick/templates/domains/core/repositories/fetch_by_id_spec.rb.tt +62 -0
  50. data/lib/generators/rider_kick/templates/domains/core/repositories/list.rb.tt +13 -8
  51. data/lib/generators/rider_kick/templates/domains/core/repositories/list_spec.rb.tt +190 -0
  52. data/lib/generators/rider_kick/templates/domains/core/repositories/update.rb.tt +4 -4
  53. data/lib/generators/rider_kick/templates/domains/core/repositories/update_spec.rb.tt +119 -0
  54. data/lib/generators/rider_kick/templates/domains/core/use_cases/contract/default.rb.tt +1 -1
  55. data/lib/generators/rider_kick/templates/domains/core/use_cases/contract/pagination.rb.tt +1 -1
  56. data/lib/generators/rider_kick/templates/domains/core/use_cases/create.rb.tt +3 -7
  57. data/lib/generators/rider_kick/templates/domains/core/use_cases/create_spec.rb.tt +71 -0
  58. data/lib/generators/rider_kick/templates/domains/core/use_cases/destroy.rb.tt +3 -7
  59. data/lib/generators/rider_kick/templates/domains/core/use_cases/destroy_spec.rb.tt +62 -0
  60. data/lib/generators/rider_kick/templates/domains/core/use_cases/fetch_by_id.rb.tt +3 -7
  61. data/lib/generators/rider_kick/templates/domains/core/use_cases/fetch_by_id_spec.rb.tt +62 -0
  62. data/lib/generators/rider_kick/templates/domains/core/use_cases/get_version.rb.tt +2 -2
  63. data/lib/generators/rider_kick/templates/domains/core/use_cases/list.rb.tt +3 -7
  64. data/lib/generators/rider_kick/templates/domains/core/use_cases/list_spec.rb.tt +64 -0
  65. data/lib/generators/rider_kick/templates/domains/core/use_cases/update.rb.tt +3 -7
  66. data/lib/generators/rider_kick/templates/domains/core/use_cases/update_spec.rb.tt +73 -0
  67. data/lib/generators/rider_kick/templates/domains/core/utils/abstract_utils.rb.tt +3 -3
  68. data/lib/generators/rider_kick/templates/domains/core/utils/request_methods.rb.tt +1 -1
  69. data/lib/generators/rider_kick/templates/env.development +1 -1
  70. data/lib/generators/rider_kick/templates/env.production +1 -1
  71. data/lib/generators/rider_kick/templates/env.test +1 -1
  72. data/lib/generators/rider_kick/templates/models/{application_record.rb → application_record.rb.tt} +3 -1
  73. data/lib/generators/rider_kick/templates/models/model_spec.rb.tt +68 -0
  74. data/lib/generators/rider_kick/templates/spec/factories/.gitkeep +19 -0
  75. data/lib/generators/rider_kick/templates/spec/factories/factory.rb.tt +8 -0
  76. data/lib/generators/rider_kick/templates/spec/rails_helper.rb +3 -0
  77. data/lib/generators/rider_kick/templates/spec/support/class_stubber.rb +148 -0
  78. data/lib/generators/rider_kick/templates/spec/support/factory_bot.rb +34 -0
  79. data/lib/generators/rider_kick/templates/spec/support/faker.rb +61 -0
  80. data/lib/generators/rider_kick/templates/spec/support/use_case_stubber.rb +14 -0
  81. data/lib/rider-kick.rb +8 -6
  82. data/lib/rider_kick/builders/abstract_active_record_entity_builder_spec.rb +644 -0
  83. data/lib/rider_kick/configuration.rb +258 -0
  84. data/lib/rider_kick/configuration_engine_spec.rb +377 -0
  85. data/lib/rider_kick/entities/failure_details.rb +1 -1
  86. data/lib/rider_kick/entities/failure_details_spec.rb +1 -1
  87. data/lib/rider_kick/matchers/use_case_result.rb +1 -1
  88. data/lib/rider_kick/use_cases/abstract_use_case.rb +1 -1
  89. data/lib/rider_kick/version.rb +1 -1
  90. metadata +132 -8
  91. data/CHANGELOG.md +0 -5
@@ -1,14 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+ require 'active_support/inflector'
5
+ require 'active_support/core_ext/object/blank'
6
+ require 'active_support/core_ext/enumerable'
7
+ require 'hashie'
1
8
  require 'yaml'
9
+ require_relative 'base_generator'
10
+ require_relative '../../rider-kick'
11
+
2
12
  module RiderKick
3
- class ScaffoldGenerator < Rails::Generators::Base
13
+ class ScaffoldGenerator < BaseGenerator
4
14
  source_root File.expand_path('templates', __dir__)
5
15
 
6
16
  argument :arg_structure, type: :string, default: '', banner: ''
7
- argument :arg_scope, type: :hash, default: '', banner: 'scope:dashboard'
17
+ argument :arg_scope, type: :hash, default: {}, banner: 'scope:dashboard'
18
+ class_option :engine, type: :string, default: nil, desc: 'Specify engine name (e.g., Core, Admin)'
19
+ class_option :domain, type: :string, default: '', desc: 'Specify domain scope (e.g., core/, admin/, api/v1/)'
8
20
 
9
21
  def generate_use_case
22
+ configure_engine
10
23
  validation!
11
24
  setup_variables
25
+ validate_repository_filters! # ← validate filter fields exist (after setup_variables)
26
+ validate_entity_fields! # ← validate entity db_attributes exist (after setup_variables)
12
27
 
13
28
  generate_files('create')
14
29
  generate_files('update')
@@ -18,72 +33,268 @@ module RiderKick
18
33
 
19
34
  set_uploader_in_model
20
35
  copy_builder_and_entity_files
36
+ generate_spec_files
21
37
  end
22
38
 
23
39
  private
24
40
 
41
+ def domain_class_name
42
+ # Convert domain scope to class name
43
+ # Engine: "<Engine>" for ApplicationRecord, "<Engine>::<Domain>" for other classes
44
+ # Main app: "" for ApplicationRecord, "<AppName>" for root domain, "<Domain>" for scoped domain
45
+ scope = RiderKick.configuration.domain_scope.chomp('/')
46
+
47
+ if RiderKick.configuration.engine_name.present?
48
+ # Engine context: domain_scope always starts with engine name
49
+ engine_prefix = RiderKick.configuration.engine_name.camelize
50
+ engine_underscored = RiderKick.configuration.engine_name.underscore
51
+
52
+ if scope == engine_underscored
53
+ # Default engine domain: engines/my_engine/app/domains/my_engine/
54
+ engine_prefix
55
+ else
56
+ # Engine with sub-domain: engines/my_engine/app/domains/my_engine/admin/
57
+ # Remove engine prefix from scope and create namespace
58
+ sub_scope = scope.sub(/^#{engine_underscored}\//, '')
59
+ if sub_scope.empty?
60
+ engine_prefix
61
+ else
62
+ "#{engine_prefix}::#{sub_scope.split('/').map(&:camelize).join('::')}"
63
+ end
64
+ end
65
+ elsif scope.empty?
66
+ # Main app context
67
+ # Root domain in main app: use application name
68
+ begin
69
+ Rails.application&.class&.module_parent_name || 'MyApp'
70
+ rescue
71
+ 'MyApp' # Fallback for test environment
72
+ end
73
+ else
74
+ # Scoped domain in main app: use domain name only
75
+ scope.split('/').map(&:camelize).join('::')
76
+ end
77
+ end
78
+
25
79
  def validation!
26
- unless Dir.exist?('app/domains')
27
- say 'Error must create clean arch structure first!'
28
- raise Thor::Error, 'run: bin/rails generate rider_kick:clean_arch --setup'
80
+ validate_domains_path!
81
+ end
82
+
83
+ def validate_repository_filters!
84
+ return if @repository_list_filters.empty?
85
+
86
+ @repository_list_filters.each do |filter_hash|
87
+ # Filter hash format: "{ field: 'name', type: 'search' }"
88
+ # Kita perlu extract field name dari string ini
89
+ match = filter_hash.match(/field: '([^']+)'/)
90
+ next unless match
91
+
92
+ field_name = match[1]
93
+
94
+ unless @model_class.column_names.include?(field_name)
95
+ raise ValidationError.new(
96
+ "Repository filter error: Field '#{field_name}' tidak ditemukan di model #{@model_class}",
97
+ structure_file: "#{arg_structure}_structure.yaml",
98
+ field_name: field_name,
99
+ model_class: @model_class.to_s,
100
+ available_columns: @model_class.column_names
101
+ )
102
+ end
103
+ end
104
+ end
105
+
106
+ def validate_entity_fields!
107
+ return if @entity_db_fields.empty?
108
+
109
+ missing_fields = @entity_db_fields - @model_class.column_names
110
+
111
+ if missing_fields.any?
112
+ raise ValidationError.new(
113
+ "Entity configuration error: Field(s) tidak ditemukan di model #{@model_class}",
114
+ structure_file: "#{arg_structure}_structure.yaml",
115
+ missing_fields: missing_fields,
116
+ model_class: @model_class.to_s,
117
+ available_columns: @model_class.column_names,
118
+ suggestion: "Update section 'entity.db_attributes' di YAML file"
119
+ )
29
120
  end
30
121
  end
31
122
 
32
123
  def setup_variables
33
- config = YAML.load_file("db/structures/#{arg_structure}_structure.yaml")
34
- @structure = Hashie::Mash.new(config)
124
+ setup_structure_config
125
+ setup_model_variables
126
+ setup_contract_variables
127
+ setup_entity_variables
128
+ setup_repository_variables
129
+ end
35
130
 
36
- # Mengambil detail konfigurasi
37
- model_name = @structure.model
38
- resource_name = @structure.resource_name.singularize.underscore.downcase
39
- entity = @structure.entity || {}
131
+ def setup_structure_config
132
+ # Determine structure file path based on engine configuration
133
+ structure_path = if RiderKick.configuration.engine_name.present?
134
+ # For engines, read structure file from engine's db/structures directory
135
+ engine_name = RiderKick.configuration.engine_name.downcase
136
+ "engines/#{engine_name}/db/structures/#{arg_structure}_structure.yaml"
137
+ else
138
+ # For main app, read from host's db/structures directory
139
+ "db/structures/#{arg_structure}_structure.yaml"
140
+ end
141
+
142
+ validate_yaml_format!(structure_path)
143
+ config = YAML.load_file(structure_path)
144
+ @structure = Hashie::Mash.new(config)
145
+ end
40
146
 
41
- @actor = @structure.actor
42
- @resource_owner_id = @structure.resource_owner_id
43
- @uploaders = @structure.uploaders || []
44
- @search_able = @structure.search_able || []
45
- @services = @structure.domains || {}
46
- @contract_list = @services.action_list.use_case.contract || []
47
- @contract_fetch_by_id = @services.action_fetch_by_id.use_case.contract || []
48
- @contract_create = @services.action_create.use_case.contract || []
49
- @contract_update = @services.action_update.use_case.contract || []
50
- @contract_destroy = @services.action_destroy.use_case.contract || []
51
- @skipped_fields = entity.skipped_fields || []
52
- @custom_fields = entity.custom_fields || []
147
+ def setup_model_variables
148
+ model_name = @structure.model
149
+ validate_model_exists!(model_name.camelize)
150
+ @model_class = model_name.camelize.constantize
53
151
 
54
152
  @variable_subject = model_name.split('::').last.underscore.downcase
55
- @scope_path = resource_name.pluralize.underscore.downcase
56
- @scope_class = @scope_path.camelize
57
- @scope_subject = @scope_path.singularize
58
- @model_class = model_name.camelize.constantize
59
153
  @subject_class = @variable_subject.camelize
60
154
  @fields = contract_fields
61
- @route_scope_path = arg_scope['scope'].to_s.downcase rescue ''
62
- @route_scope_class = @route_scope_path.camelize rescue ''
63
-
64
- @type_mapping = {
65
- 'uuid' => ':string',
66
- 'string' => ':string',
67
- 'text' => ':string',
68
- 'integer' => ':integer',
69
- 'boolean' => ':bool',
70
- 'float' => ':float',
71
- 'decimal' => ':float',
72
- 'date' => ':date',
73
- 'upload' => 'Types::File',
74
- 'datetime' => ':string'
75
- }
76
- @entity_type_mapping = {
77
- 'uuid' => 'Types::Strict::String',
78
- 'string' => 'Types::Strict::String',
79
- 'text' => 'Types::Strict::String',
80
- 'integer' => 'Types::Strict::Integer',
81
- 'boolean' => 'Types::Strict::Bool',
82
- 'float' => 'Types::Strict::Float',
83
- 'decimal' => 'Types::Strict::Decimal',
84
- 'date' => 'Types::Strict::Date',
85
- 'datetime' => 'Types::Strict::Time'
86
- }
155
+
156
+ # Column metadata
157
+ @columns_meta = columns_meta
158
+ @columns_meta_hash = @columns_meta.index_by { |c| c[:name] }
159
+
160
+ # Type mappings
161
+ @type_mapping = RiderKick.configuration.type_mapping
162
+ @entity_type_mapping = RiderKick.configuration.entity_type_mapping
163
+ end
164
+
165
+ def setup_contract_variables
166
+ @services = @structure.domains || {}
167
+
168
+ # Membaca kontrak dinamis (dari Peningkatan #1)
169
+ # Pastikan selalu return array, bahkan jika nil
170
+ @contract_list = Array(@services.action_list&.use_case&.contract).compact
171
+ @contract_fetch_by_id = Array(@services.action_fetch_by_id&.use_case&.contract).compact
172
+ @contract_create = Array(@services.action_create&.use_case&.contract).compact
173
+ @contract_update = Array(@services.action_update&.use_case&.contract).compact
174
+ @contract_destroy = Array(@services.action_destroy&.use_case&.contract).compact
175
+ end
176
+
177
+ def setup_entity_variables
178
+ resource_name = @structure.resource_name.singularize.underscore.downcase
179
+ entity = @structure.entity || {}
180
+
181
+ @scope_path = resource_name.pluralize.underscore.downcase
182
+ @scope_class = @scope_path.camelize
183
+ @scope_subject = @scope_path.singularize
184
+
185
+ # Membaca definisi uploader baru (array of hashes)
186
+ @uploaders = (@structure.uploaders || []).map { |up| Hashie::Mash.new(up) }
187
+
188
+ # Membaca atribut DB eksplisit (array string)
189
+ @entity_db_fields = entity.respond_to?(:db_attributes) ? entity.db_attributes || [] : []
190
+ end
191
+
192
+ def setup_repository_variables
193
+ @actor = @structure.actor
194
+ @resource_owner_id = @structure.resource_owner_id
195
+ @resource_owner = @structure.resource_owner
196
+ @search_able = @structure.search_able
197
+
198
+ # Membaca DSL filter repositori baru (Peningkatan #2)
199
+ @repository_list_filters = @services.action_list&.repository&.filters || []
200
+
201
+ # Route scope
202
+ # @route_scope_path = arg_scope.fetch('scope', '').to_s.downcase
203
+ @route_scope_path = @structure.domain.to_s.downcase
204
+ @route_scope_class = @route_scope_path.camelize
205
+
206
+ # Baca actor_id dari structure.yaml jika ada, jika tidak generate dari actor
207
+ @actor_id = if @structure.actor_id.present?
208
+ @structure.actor_id.to_s
209
+ elsif @actor.present?
210
+ "#{@actor.to_s.downcase}_id"
211
+ end
212
+
213
+ # Set flag untuk setiap action apakah resource_owner_id atau actor_id ada di contract
214
+ # Ini digunakan di template repository untuk conditional logic
215
+ @has_resource_owner_id_in_list_contract = field_exists_in_contract?(@resource_owner_id, 'list')
216
+ @has_resource_owner_id_in_fetch_by_id_contract = field_exists_in_contract?(@resource_owner_id, 'fetch_by_id')
217
+ @has_resource_owner_id_in_create_contract = field_exists_in_contract?(@resource_owner_id, 'create')
218
+ @has_resource_owner_id_in_update_contract = field_exists_in_contract?(@resource_owner_id, 'update')
219
+ @has_resource_owner_id_in_destroy_contract = field_exists_in_contract?(@resource_owner_id, 'destroy')
220
+
221
+ @has_actor_id_in_list_contract = field_exists_in_contract?(@actor_id, 'list')
222
+ @has_actor_id_in_fetch_by_id_contract = field_exists_in_contract?(@actor_id, 'fetch_by_id')
223
+ @has_actor_id_in_create_contract = field_exists_in_contract?(@actor_id, 'create')
224
+ @has_actor_id_in_update_contract = field_exists_in_contract?(@actor_id, 'update')
225
+ @has_actor_id_in_destroy_contract = field_exists_in_contract?(@actor_id, 'destroy')
226
+ end
227
+
228
+ # Check apakah field (actor_id atau resource_owner_id) ada di contract untuk action tertentu
229
+ def field_exists_in_contract?(field_name, action)
230
+ return false if field_name.blank?
231
+
232
+ # Get contract untuk action tertentu
233
+ contract = case action.to_s
234
+ when 'list'
235
+ @contract_list
236
+ when 'fetch', 'fetch_by_id'
237
+ @contract_fetch_by_id
238
+ when 'create'
239
+ @contract_create
240
+ when 'update'
241
+ @contract_update
242
+ when 'destroy'
243
+ @contract_destroy
244
+ else
245
+ []
246
+ end
247
+ contract ||= []
248
+ # Check apakah contract string mengandung field name
249
+ # Pattern: "required(:field_name)" atau "optional(:field_name)"
250
+ # Contoh: "required(:account_id).filled(:string)" -> match untuk "account_id"
251
+ # Pattern lebih robust: cari :field_name atau (:field_name) di dalam string
252
+ return false if contract.empty?
253
+
254
+ contract.any? do |contract_line|
255
+ contract_str = contract_line.to_s.strip
256
+ # Match pattern: required(:field_name) atau optional(:field_name) atau :field_name
257
+ # Pattern: cari :field_name atau (:field_name) di dalam string
258
+ # Escaped field_name untuk handle special characters
259
+ escaped_field = Regexp.escape(field_name.to_s)
260
+ contract_str.match?(/[:\(]#{escaped_field}[\):]/)
261
+ end
262
+ end
263
+
264
+ # Helper method untuk template: check apakah resource_owner_id ada di contract untuk action tertentu
265
+ def has_resource_owner_id_in_contract?(action)
266
+ case action.to_s
267
+ when 'list'
268
+ @has_resource_owner_id_in_list_contract
269
+ when 'fetch', 'fetch_by_id'
270
+ @has_resource_owner_id_in_fetch_by_id_contract
271
+ when 'create'
272
+ @has_resource_owner_id_in_create_contract
273
+ when 'update'
274
+ @has_resource_owner_id_in_update_contract
275
+ when 'destroy'
276
+ @has_resource_owner_id_in_destroy_contract
277
+ else
278
+ false
279
+ end
280
+ end
281
+
282
+ # Helper method untuk template: check apakah actor_id ada di contract untuk action tertentu
283
+ def has_actor_id_in_contract?(action)
284
+ case action.to_s
285
+ when 'list'
286
+ @has_actor_id_in_list_contract
287
+ when 'fetch', 'fetch_by_id'
288
+ @has_actor_id_in_fetch_by_id_contract
289
+ when 'create'
290
+ @has_actor_id_in_create_contract
291
+ when 'update'
292
+ @has_actor_id_in_update_contract
293
+ when 'destroy'
294
+ @has_actor_id_in_destroy_contract
295
+ else
296
+ false
297
+ end
87
298
  end
88
299
 
89
300
  def is_singular?(str)
@@ -92,11 +303,49 @@ module RiderKick
92
303
 
93
304
  def set_uploader_in_model
94
305
  @uploaders.each do |uploader|
95
- method_strategy = 'has_many_attached'
96
- if is_singular?(uploader)
97
- method_strategy = 'has_one_attached'
306
+ method_strategy = uploader.type == 'single' ? 'has_one_attached' : 'has_many_attached'
307
+ uploader_name = uploader.name
308
+ model_path = model_file_path(@model_class, @variable_subject)
309
+
310
+ unless File.exist?(model_path)
311
+ say "Skip attaching #{uploader_name}: model file not found: #{model_path}", :yellow
312
+ next
313
+ end
314
+
315
+ content = File.read(model_path)
316
+
317
+ # More robust check: look for has_one_attached/has_many_attached with the uploader name
318
+ # Pattern matches: has_one_attached :name or has_one_attached :name, dependent: ...
319
+ attachment_pattern = /#{Regexp.escape(method_strategy)}\s+:#{Regexp.escape(uploader_name)}\b/
320
+
321
+ if content.match?(attachment_pattern)
322
+ say "Skip attaching #{uploader_name}: already present in #{model_path}", :blue
323
+ next
98
324
  end
99
- inject_into_file File.join("#{root_path_app}/models", @model_class.to_s.split('::').first.downcase.to_s, "#{@variable_subject}.rb"), " #{method_strategy} :#{uploader}, dependent: :purge\n", after: "class #{@model_class} < ApplicationRecord\n"
325
+
326
+ line_to_insert = " #{method_strategy} :#{uploader_name}, dependent: :purge\n"
327
+ class_anchor_regex = /class #{Regexp.escape(@model_class.to_s)} < ApplicationRecord[^\n]*\n/
328
+
329
+ inject_into_file model_path, line_to_insert, after: class_anchor_regex
330
+ end
331
+ end
332
+
333
+ def model_file_path(model_class, variable_subject)
334
+ # Extract namespace dari model class
335
+ # Models::User -> namespace setelah Models adalah []
336
+ # Models::EngineName::User -> namespace setelah Models adalah [EngineName]
337
+ full_namespace = model_class.to_s.deconstantize
338
+ namespace_parts = full_namespace.split('::').reject(&:empty?)
339
+
340
+ # Jika model_class mengandung Models::EngineName::User, maka path ke engine
341
+ # Jika model_class Models::User, maka path ke main app
342
+ if namespace_parts.length > 1 && namespace_parts.first == 'Models'
343
+ # Engine: Models::EngineName::User -> app/models/<engine_name>/<model>.rb
344
+ engine_name_part = namespace_parts[1].underscore
345
+ File.join('app/models', engine_name_part, "#{variable_subject}.rb")
346
+ else
347
+ # Main app: Models::User -> app/models/models/<model>.rb
348
+ File.join(RiderKick.configuration.models_path, "#{variable_subject}.rb")
100
349
  end
101
350
  end
102
351
 
@@ -107,22 +356,51 @@ module RiderKick
107
356
  @use_case_class = use_case_filename.camelize
108
357
  @repository_class = repository_filename.camelize
109
358
 
110
- template "domains/core/use_cases/#{action + suffix}.rb.tt", File.join("#{root_path_app}/domains/core/use_cases/", @route_scope_path.to_s, @scope_path.to_s, "#{use_case_filename}.rb")
111
- template "domains/core/repositories/#{action + suffix}.rb.tt", File.join("#{root_path_app}/domains/core/repositories/#{@scope_path}", "#{repository_filename}.rb")
359
+ # Generate code files
360
+ template "domains/core/use_cases/#{action + suffix}.rb.tt", File.join(RiderKick.configuration.domains_path, RiderKick.configuration.use_cases_dir, @route_scope_path.to_s, @scope_path.to_s, "#{use_case_filename}.rb")
361
+ template "domains/core/repositories/#{action + suffix}.rb.tt", File.join(RiderKick.configuration.domains_path, RiderKick.configuration.repositories_dir, @scope_path.to_s, "#{repository_filename}.rb")
362
+
363
+ # Generate spec files
364
+ generate_use_case_spec(action, suffix, use_case_filename)
365
+ generate_repository_spec(action, suffix, repository_filename)
112
366
  end
113
367
 
114
368
  def copy_builder_and_entity_files
115
- template 'domains/core/builders/builder.rb.tt', File.join("#{root_path_app}/domains/core/builders", "#{@variable_subject}.rb")
116
- template 'domains/core/entities/entity.rb.tt', File.join("#{root_path_app}/domains/core/entities", "#{@variable_subject}.rb")
369
+ template 'domains/core/builders/builder.rb.tt', File.join(RiderKick.configuration.domains_path, RiderKick.configuration.builders_dir, "#{@variable_subject}.rb")
370
+ template 'domains/core/entities/entity.rb.tt', File.join(RiderKick.configuration.domains_path, RiderKick.configuration.entities_dir, "#{@variable_subject}.rb")
117
371
  end
118
372
 
119
373
  def contract_fields
120
- skip_contract_fields = @skipped_fields.map(&:strip).uniq
121
- @model_class.columns.reject { |column| skip_contract_fields.include?(column.name.to_s) }.map(&:name).map(&:to_s)
374
+ @model_class.columns.reject { |c| ['id', 'created_at', 'updated_at', 'type'].include?(c.name.to_s) }
375
+ .map { |c| c.name.to_s }
376
+ end
377
+
378
+ # --- AWAL BLOK MODIFIKASI: (PERBAIKAN KEGAGALAN #1) ---
379
+ # Menambahkan helper-helper ini dari structure_generator
380
+ def columns_meta
381
+ @model_class.columns.map do |c|
382
+ {
383
+ name: c.name.to_s,
384
+ type: c.type,
385
+ sql_type: (c.respond_to?(:sql_type) ? c.sql_type : nil),
386
+ null: (c.respond_to?(:null) ? c.null : nil),
387
+ default: (c.respond_to?(:default) ? c.default : nil),
388
+ precision: (c.respond_to?(:precision) ? c.precision : nil),
389
+ scale: (c.respond_to?(:scale) ? c.scale : nil),
390
+ limit: (c.respond_to?(:limit) ? c.limit : nil)
391
+ }
392
+ end
122
393
  end
123
394
 
395
+ def get_column_meta(field)
396
+ @columns_meta_hash[field.to_s] || {}
397
+ end
398
+
399
+ # --- AKHIR BLOK MODIFIKASI ---
400
+
124
401
  def get_column_type(field)
125
- @uploaders.include?(field) ? 'upload' : @model_class.columns_hash[field.to_s].type
402
+ is_uploader = @uploaders.any? { |up| up.name == field.to_s }
403
+ is_uploader ? 'upload' : @model_class.columns_hash[field.to_s].type
126
404
  end
127
405
 
128
406
  def root_path_app
@@ -136,5 +414,44 @@ module RiderKick
136
414
  def build_repository_filename(action, suffix = '')
137
415
  "#{action}_#{@variable_subject}#{suffix}"
138
416
  end
417
+
418
+ def generate_use_case_spec(action, suffix, use_case_filename)
419
+ template_name = "domains/core/use_cases/#{action + suffix}_spec.rb.tt"
420
+ spec_path = File.join(RiderKick.configuration.domains_path, RiderKick.configuration.use_cases_dir, @route_scope_path.to_s, @scope_path.to_s, "#{use_case_filename}_spec.rb")
421
+
422
+ if File.exist?(File.join(self.class.source_root, template_name))
423
+ template template_name, spec_path
424
+ else
425
+ say "Warning: Spec template not found: #{template_name}", :yellow
426
+ end
427
+ end
428
+
429
+ def generate_repository_spec(action, suffix, repository_filename)
430
+ template_name = "domains/core/repositories/#{action + suffix}_spec.rb.tt"
431
+ spec_path = File.join(RiderKick.configuration.domains_path, RiderKick.configuration.repositories_dir, @scope_path.to_s, "#{repository_filename}_spec.rb")
432
+
433
+ if File.exist?(File.join(self.class.source_root, template_name))
434
+ template template_name, spec_path
435
+ else
436
+ say "Warning: Spec template not found: #{template_name}", :yellow
437
+ end
438
+ end
439
+
440
+ def generate_spec_files
441
+ # Generate builder spec (covers entity validation too)
442
+ builder_spec_path = File.join(RiderKick.configuration.domains_path, RiderKick.configuration.builders_dir, "#{@variable_subject}_spec.rb")
443
+ template 'domains/core/builders/builder_spec.rb.tt', builder_spec_path
444
+
445
+ # Generate model spec
446
+ generate_model_spec
447
+ end
448
+
449
+ def generate_model_spec
450
+ model_spec_path = model_file_path(@model_class, @variable_subject).gsub('.rb', '_spec.rb')
451
+ # Place spec alongside the model file (same directory)
452
+ # model_spec_path sudah berisi path lengkap ke app/models/.../_spec.rb
453
+
454
+ template 'models/model_spec.rb.tt', model_spec_path
455
+ end
139
456
  end
140
457
  end
@@ -3,7 +3,6 @@
3
3
  require 'rails/generators'
4
4
  require 'tmpdir'
5
5
  require 'active_support/inflector'
6
- require 'ostruct'
7
6
  require 'fileutils'
8
7
  require 'generators/rider_kick/scaffold_generator'
9
8
 
@@ -13,13 +12,13 @@ RSpec.describe 'rider_kick:scaffold builder (uploaders)' do
13
12
  it 'menulis mapping uploader: single (has_one) & multiple (has_many) ke builder' do
14
13
  Dir.mktmpdir do |dir|
15
14
  Dir.chdir(dir) do
16
- FileUtils.mkdir_p(%w[
17
- app/domains/core/use_cases
18
- app/domains/core/repositories
19
- app/domains/core/builders
20
- app/domains/core/entities
21
- app/models/models
22
- db/structures
15
+ FileUtils.mkdir_p([
16
+ RiderKick.configuration.domains_path + '/core/use_cases',
17
+ RiderKick.configuration.domains_path + '/core/repositories',
18
+ RiderKick.configuration.domains_path + '/core/builders',
19
+ RiderKick.configuration.domains_path + '/core/entities',
20
+ 'app/models/models',
21
+ 'db/structures'
23
22
  ])
24
23
 
25
24
  File.write('app/models/models/user.rb', "class Models::User < ApplicationRecord; end\n")
@@ -29,7 +28,9 @@ RSpec.describe 'rider_kick:scaffold builder (uploaders)' do
29
28
  model: Models::User
30
29
  resource_name: users
31
30
  actor: owner
32
- uploaders: [avatar, images]
31
+ resource_owner_id: account_id
32
+ resource_owner: account
33
+ uploaders: [{ name: 'avatar', type: 'single' }, { name: 'images', type: 'multiple' }]
33
34
  search_able: []
34
35
  domains:
35
36
  action_list: { use_case: { contract: [] } }
@@ -37,17 +38,121 @@ RSpec.describe 'rider_kick:scaffold builder (uploaders)' do
37
38
  action_create: { use_case: { contract: [] } }
38
39
  action_update: { use_case: { contract: [] } }
39
40
  action_destroy: { use_case: { contract: [] } }
40
- entity: { skipped_fields: [id, created_at, updated_at] }
41
+ entity: { db_attributes: [id, created_at, updated_at] }
41
42
  YAML
42
43
 
43
44
  klass.new(['users']).generate_use_case
44
45
 
45
- builder = File.read('app/domains/core/builders/user.rb')
46
+ builder = File.read(RiderKick.configuration.domains_path + '/builders/user.rb')
46
47
  # singular -> satu URL string
47
- expect(builder).to include('avatar: (Rails.application.routes.url_helpers.polymorphic_url(params.avatar)')
48
+ expect(builder).to include('def with_avatar_url(model)')
49
+ expect(builder).to include('model.avatar.url')
48
50
  # plural -> array dengan helper build_assets
49
- expect(builder).to include('images: build_assets(params.images)')
50
- expect(builder).to include('def build_assets(assets)')
51
+ expect(builder).to include('def with_images_urls(model)')
52
+ expect(builder).to include('model.images.map')
53
+ expect(builder).to include('attachment.url')
54
+ end
55
+ end
56
+ end
57
+
58
+ it 'tidak menambahkan duplikasi has_one_attached atau has_many_attached jika sudah ada' do
59
+ Dir.mktmpdir do |dir|
60
+ Dir.chdir(dir) do
61
+ FileUtils.mkdir_p([
62
+ RiderKick.configuration.domains_path + '/core/use_cases',
63
+ RiderKick.configuration.domains_path + '/core/repositories',
64
+ RiderKick.configuration.domains_path + '/core/builders',
65
+ RiderKick.configuration.domains_path + '/core/entities',
66
+ 'app/models/models',
67
+ 'db/structures'
68
+ ])
69
+
70
+ # Model dengan attachment yang sudah ada
71
+ File.write('app/models/models/user.rb', <<~RUBY)
72
+ class Models::User < ApplicationRecord
73
+ has_one_attached :avatar, dependent: :purge
74
+ has_many_attached :images, dependent: :purge
75
+ end
76
+ RUBY
77
+
78
+ File.write('db/structures/users_structure.yaml', <<~YAML)
79
+ model: Models::User
80
+ resource_name: users
81
+ actor: owner
82
+ resource_owner_id: account_id
83
+ resource_owner: account
84
+ uploaders: [{ name: 'avatar', type: 'single' }, { name: 'images', type: 'multiple' }]
85
+ search_able: []
86
+ domains:
87
+ action_list: { use_case: { contract: [] } }
88
+ action_fetch_by_id: { use_case: { contract: [] } }
89
+ action_create: { use_case: { contract: [] } }
90
+ action_update: { use_case: { contract: [] } }
91
+ action_destroy: { use_case: { contract: [] } }
92
+ entity: { db_attributes: [id, created_at, updated_at] }
93
+ YAML
94
+
95
+ klass.new(['users']).generate_use_case
96
+
97
+ model_content = File.read('app/models/models/user.rb')
98
+
99
+ # Pastikan tidak ada duplikasi
100
+ avatar_count = model_content.scan(/has_one_attached\s+:avatar\b/).count
101
+ images_count = model_content.scan(/has_many_attached\s+:images\b/).count
102
+
103
+ expect(avatar_count).to eq(1), "Expected has_one_attached :avatar to appear only once, but found #{avatar_count} times"
104
+ expect(images_count).to eq(1), "Expected has_many_attached :images to appear only once, but found #{images_count} times"
105
+ end
106
+ end
107
+ end
108
+
109
+ it 'tetap mendeteksi attachment yang sudah ada dengan format berbeda' do
110
+ Dir.mktmpdir do |dir|
111
+ Dir.chdir(dir) do
112
+ FileUtils.mkdir_p([
113
+ RiderKick.configuration.domains_path + '/core/use_cases',
114
+ RiderKick.configuration.domains_path + '/core/repositories',
115
+ RiderKick.configuration.domains_path + '/core/builders',
116
+ RiderKick.configuration.domains_path + '/core/entities',
117
+ 'app/models/models',
118
+ 'db/structures'
119
+ ])
120
+
121
+ # Model dengan attachment yang sudah ada dengan format berbeda (tanpa dependent)
122
+ File.write('app/models/models/user.rb', <<~RUBY)
123
+ class Models::User < ApplicationRecord
124
+ has_one_attached :avatar
125
+ has_many_attached :images
126
+ end
127
+ RUBY
128
+
129
+ File.write('db/structures/users_structure.yaml', <<~YAML)
130
+ model: Models::User
131
+ resource_name: users
132
+ actor: owner
133
+ resource_owner_id: account_id
134
+ resource_owner: account
135
+ uploaders: [{ name: 'avatar', type: 'single' }, { name: 'images', type: 'multiple' }]
136
+ search_able: []
137
+ domains:
138
+ action_list: { use_case: { contract: [] } }
139
+ action_fetch_by_id: { use_case: { contract: [] } }
140
+ action_create: { use_case: { contract: [] } }
141
+ action_update: { use_case: { contract: [] } }
142
+ action_destroy: { use_case: { contract: [] } }
143
+ entity: { db_attributes: [id, created_at, updated_at] }
144
+ YAML
145
+
146
+ klass.new(['users']).generate_use_case
147
+
148
+ model_content = File.read('app/models/models/user.rb')
149
+
150
+ # Pastikan tidak ada duplikasi meskipun format berbeda
151
+ avatar_count = model_content.scan(/has_one_attached\s+:avatar\b/).count
152
+ images_count = model_content.scan(/has_many_attached\s+:images\b/).count
153
+
154
+ expect(avatar_count).to eq(1), "Expected has_one_attached :avatar to appear only once, but found #{avatar_count} times"
155
+ expect(images_count).to eq(1), "Expected has_many_attached :images to appear only once, but found #{images_count} times"
51
156
  end
52
157
  end
53
158
  end