rider-kick 0.0.13 → 0.0.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.
Files changed (88) 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 +235 -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/repositories_contract_spec.rb +95 -22
  13. data/lib/generators/rider_kick/scaffold_generator.rb +377 -62
  14. data/lib/generators/rider_kick/scaffold_generator_builder_uploaders_spec.rb +119 -14
  15. data/lib/generators/rider_kick/scaffold_generator_conditional_filtering_spec.rb +820 -0
  16. data/lib/generators/rider_kick/scaffold_generator_contracts_spec.rb +37 -10
  17. data/lib/generators/rider_kick/scaffold_generator_contracts_with_scope_spec.rb +40 -11
  18. data/lib/generators/rider_kick/scaffold_generator_engine_spec.rb +221 -0
  19. data/lib/generators/rider_kick/scaffold_generator_idempotent_spec.rb +38 -13
  20. data/lib/generators/rider_kick/scaffold_generator_list_spec_format_spec.rb +153 -0
  21. data/lib/generators/rider_kick/scaffold_generator_rspec_spec.rb +347 -0
  22. data/lib/generators/rider_kick/scaffold_generator_success_spec.rb +31 -12
  23. data/lib/generators/rider_kick/scaffold_generator_with_scope_spec.rb +32 -11
  24. data/lib/generators/rider_kick/structure_generator.rb +154 -43
  25. data/lib/generators/rider_kick/structure_generator_comprehensive_spec.rb +598 -0
  26. data/lib/generators/rider_kick/structure_generator_engine_spec.rb +279 -0
  27. data/lib/generators/rider_kick/structure_generator_spec.rb +3 -3
  28. data/lib/generators/rider_kick/structure_generator_success_spec.rb +33 -5
  29. data/lib/generators/rider_kick/structure_generator_unit_spec.rb +2202 -0
  30. data/lib/generators/rider_kick/templates/.rubocop.yml +5 -4
  31. data/lib/generators/rider_kick/templates/config/initializers/version.rb.tt +1 -1
  32. data/lib/generators/rider_kick/templates/db/migrate/20220613145533_init_database.rb +1 -1
  33. data/lib/generators/rider_kick/templates/db/structures/example.yaml.tt +140 -66
  34. data/lib/generators/rider_kick/templates/domains/core/builders/builder.rb.tt +36 -10
  35. data/lib/generators/rider_kick/templates/domains/core/builders/builder_spec.rb.tt +219 -0
  36. data/lib/generators/rider_kick/templates/domains/core/builders/error.rb.tt +2 -2
  37. data/lib/generators/rider_kick/templates/domains/core/builders/pagination.rb.tt +2 -2
  38. data/lib/generators/rider_kick/templates/domains/core/entities/entity.rb.tt +32 -14
  39. data/lib/generators/rider_kick/templates/domains/core/entities/error.rb.tt +1 -1
  40. data/lib/generators/rider_kick/templates/domains/core/entities/pagination.rb.tt +1 -1
  41. data/lib/generators/rider_kick/templates/domains/core/repositories/abstract_repository.rb.tt +4 -4
  42. data/lib/generators/rider_kick/templates/domains/core/repositories/create.rb.tt +2 -2
  43. data/lib/generators/rider_kick/templates/domains/core/repositories/create_spec.rb.tt +78 -0
  44. data/lib/generators/rider_kick/templates/domains/core/repositories/destroy.rb.tt +2 -2
  45. data/lib/generators/rider_kick/templates/domains/core/repositories/destroy_spec.rb.tt +88 -0
  46. data/lib/generators/rider_kick/templates/domains/core/repositories/fetch_by_id.rb.tt +3 -3
  47. data/lib/generators/rider_kick/templates/domains/core/repositories/fetch_by_id_spec.rb.tt +62 -0
  48. data/lib/generators/rider_kick/templates/domains/core/repositories/list.rb.tt +13 -8
  49. data/lib/generators/rider_kick/templates/domains/core/repositories/list_spec.rb.tt +190 -0
  50. data/lib/generators/rider_kick/templates/domains/core/repositories/update.rb.tt +4 -4
  51. data/lib/generators/rider_kick/templates/domains/core/repositories/update_spec.rb.tt +119 -0
  52. data/lib/generators/rider_kick/templates/domains/core/use_cases/contract/default.rb.tt +1 -1
  53. data/lib/generators/rider_kick/templates/domains/core/use_cases/contract/pagination.rb.tt +1 -1
  54. data/lib/generators/rider_kick/templates/domains/core/use_cases/create.rb.tt +3 -7
  55. data/lib/generators/rider_kick/templates/domains/core/use_cases/create_spec.rb.tt +71 -0
  56. data/lib/generators/rider_kick/templates/domains/core/use_cases/destroy.rb.tt +3 -7
  57. data/lib/generators/rider_kick/templates/domains/core/use_cases/destroy_spec.rb.tt +62 -0
  58. data/lib/generators/rider_kick/templates/domains/core/use_cases/fetch_by_id.rb.tt +3 -7
  59. data/lib/generators/rider_kick/templates/domains/core/use_cases/fetch_by_id_spec.rb.tt +62 -0
  60. data/lib/generators/rider_kick/templates/domains/core/use_cases/get_version.rb.tt +2 -2
  61. data/lib/generators/rider_kick/templates/domains/core/use_cases/list.rb.tt +3 -7
  62. data/lib/generators/rider_kick/templates/domains/core/use_cases/list_spec.rb.tt +64 -0
  63. data/lib/generators/rider_kick/templates/domains/core/use_cases/update.rb.tt +3 -7
  64. data/lib/generators/rider_kick/templates/domains/core/use_cases/update_spec.rb.tt +73 -0
  65. data/lib/generators/rider_kick/templates/domains/core/utils/abstract_utils.rb.tt +3 -3
  66. data/lib/generators/rider_kick/templates/domains/core/utils/request_methods.rb.tt +1 -1
  67. data/lib/generators/rider_kick/templates/env.development +1 -1
  68. data/lib/generators/rider_kick/templates/env.production +1 -1
  69. data/lib/generators/rider_kick/templates/env.test +1 -1
  70. data/lib/generators/rider_kick/templates/models/{application_record.rb → application_record.rb.tt} +3 -1
  71. data/lib/generators/rider_kick/templates/models/model_spec.rb.tt +68 -0
  72. data/lib/generators/rider_kick/templates/spec/factories/.gitkeep +19 -0
  73. data/lib/generators/rider_kick/templates/spec/factories/factory.rb.tt +8 -0
  74. data/lib/generators/rider_kick/templates/spec/rails_helper.rb +2 -0
  75. data/lib/generators/rider_kick/templates/spec/support/class_stubber.rb +148 -0
  76. data/lib/generators/rider_kick/templates/spec/support/factory_bot.rb +34 -0
  77. data/lib/generators/rider_kick/templates/spec/support/faker.rb +61 -0
  78. data/lib/rider-kick.rb +8 -6
  79. data/lib/rider_kick/builders/abstract_active_record_entity_builder_spec.rb +644 -0
  80. data/lib/rider_kick/configuration.rb +238 -0
  81. data/lib/rider_kick/configuration_engine_spec.rb +377 -0
  82. data/lib/rider_kick/entities/failure_details.rb +1 -1
  83. data/lib/rider_kick/entities/failure_details_spec.rb +1 -1
  84. data/lib/rider_kick/matchers/use_case_result.rb +1 -1
  85. data/lib/rider_kick/use_cases/abstract_use_case.rb +1 -1
  86. data/lib/rider_kick/version.rb +1 -1
  87. metadata +129 -8
  88. 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,267 @@ 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")
124
+ setup_structure_config
125
+ setup_model_variables
126
+ setup_contract_variables
127
+ setup_entity_variables
128
+ setup_repository_variables
129
+ end
130
+
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)
34
144
  @structure = Hashie::Mash.new(config)
145
+ end
35
146
 
36
- # Mengambil detail konfigurasi
37
- model_name = @structure.model
147
+ def setup_model_variables
148
+ model_name = @structure.model
149
+ validate_model_exists!(model_name.camelize)
150
+ @model_class = model_name.camelize.constantize
151
+
152
+ @variable_subject = model_name.split('::').last.underscore.downcase
153
+ @subject_class = @variable_subject.camelize
154
+ @fields = contract_fields
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
38
178
  resource_name = @structure.resource_name.singularize.underscore.downcase
39
179
  entity = @structure.entity || {}
40
180
 
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 || []
53
-
54
- @variable_subject = model_name.split('::').last.underscore.downcase
55
181
  @scope_path = resource_name.pluralize.underscore.downcase
56
182
  @scope_class = @scope_path.camelize
57
183
  @scope_subject = @scope_path.singularize
58
- @model_class = model_name.camelize.constantize
59
- @subject_class = @variable_subject.camelize
60
- @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
- }
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_class = @route_scope_path.camelize
204
+
205
+ # Baca actor_id dari structure.yaml jika ada, jika tidak generate dari actor
206
+ @actor_id = if @structure.actor_id.present?
207
+ @structure.actor_id.to_s
208
+ elsif @actor.present?
209
+ "#{@actor.to_s.downcase}_id"
210
+ end
211
+
212
+ # Set flag untuk setiap action apakah resource_owner_id atau actor_id ada di contract
213
+ # Ini digunakan di template repository untuk conditional logic
214
+ @has_resource_owner_id_in_list_contract = field_exists_in_contract?(@resource_owner_id, 'list')
215
+ @has_resource_owner_id_in_fetch_by_id_contract = field_exists_in_contract?(@resource_owner_id, 'fetch_by_id')
216
+ @has_resource_owner_id_in_create_contract = field_exists_in_contract?(@resource_owner_id, 'create')
217
+ @has_resource_owner_id_in_update_contract = field_exists_in_contract?(@resource_owner_id, 'update')
218
+ @has_resource_owner_id_in_destroy_contract = field_exists_in_contract?(@resource_owner_id, 'destroy')
219
+
220
+ @has_actor_id_in_list_contract = field_exists_in_contract?(@actor_id, 'list')
221
+ @has_actor_id_in_fetch_by_id_contract = field_exists_in_contract?(@actor_id, 'fetch_by_id')
222
+ @has_actor_id_in_create_contract = field_exists_in_contract?(@actor_id, 'create')
223
+ @has_actor_id_in_update_contract = field_exists_in_contract?(@actor_id, 'update')
224
+ @has_actor_id_in_destroy_contract = field_exists_in_contract?(@actor_id, 'destroy')
225
+ end
226
+
227
+ # Check apakah field (actor_id atau resource_owner_id) ada di contract untuk action tertentu
228
+ def field_exists_in_contract?(field_name, action)
229
+ return false if field_name.blank?
230
+
231
+ # Get contract untuk action tertentu
232
+ contract = case action.to_s
233
+ when 'list'
234
+ @contract_list
235
+ when 'fetch', 'fetch_by_id'
236
+ @contract_fetch_by_id
237
+ when 'create'
238
+ @contract_create
239
+ when 'update'
240
+ @contract_update
241
+ when 'destroy'
242
+ @contract_destroy
243
+ else
244
+ []
245
+ end
246
+ contract ||= []
247
+ # Check apakah contract string mengandung field name
248
+ # Pattern: "required(:field_name)" atau "optional(:field_name)"
249
+ # Contoh: "required(:account_id).filled(:string)" -> match untuk "account_id"
250
+ # Pattern lebih robust: cari :field_name atau (:field_name) di dalam string
251
+ return false if contract.empty?
252
+
253
+ contract.any? do |contract_line|
254
+ contract_str = contract_line.to_s.strip
255
+ # Match pattern: required(:field_name) atau optional(:field_name) atau :field_name
256
+ # Pattern: cari :field_name atau (:field_name) di dalam string
257
+ # Escaped field_name untuk handle special characters
258
+ escaped_field = Regexp.escape(field_name.to_s)
259
+ contract_str.match?(/[:\(]#{escaped_field}[\):]/)
260
+ end
261
+ end
262
+
263
+ # Helper method untuk template: check apakah resource_owner_id ada di contract untuk action tertentu
264
+ def has_resource_owner_id_in_contract?(action)
265
+ case action.to_s
266
+ when 'list'
267
+ @has_resource_owner_id_in_list_contract
268
+ when 'fetch', 'fetch_by_id'
269
+ @has_resource_owner_id_in_fetch_by_id_contract
270
+ when 'create'
271
+ @has_resource_owner_id_in_create_contract
272
+ when 'update'
273
+ @has_resource_owner_id_in_update_contract
274
+ when 'destroy'
275
+ @has_resource_owner_id_in_destroy_contract
276
+ else
277
+ false
278
+ end
279
+ end
280
+
281
+ # Helper method untuk template: check apakah actor_id ada di contract untuk action tertentu
282
+ def has_actor_id_in_contract?(action)
283
+ case action.to_s
284
+ when 'list'
285
+ @has_actor_id_in_list_contract
286
+ when 'fetch', 'fetch_by_id'
287
+ @has_actor_id_in_fetch_by_id_contract
288
+ when 'create'
289
+ @has_actor_id_in_create_contract
290
+ when 'update'
291
+ @has_actor_id_in_update_contract
292
+ when 'destroy'
293
+ @has_actor_id_in_destroy_contract
294
+ else
295
+ false
296
+ end
87
297
  end
88
298
 
89
299
  def is_singular?(str)
@@ -92,11 +302,49 @@ module RiderKick
92
302
 
93
303
  def set_uploader_in_model
94
304
  @uploaders.each do |uploader|
95
- method_strategy = 'has_many_attached'
96
- if is_singular?(uploader)
97
- method_strategy = 'has_one_attached'
305
+ method_strategy = uploader.type == 'single' ? 'has_one_attached' : 'has_many_attached'
306
+ uploader_name = uploader.name
307
+ model_path = model_file_path(@model_class, @variable_subject)
308
+
309
+ unless File.exist?(model_path)
310
+ say "Skip attaching #{uploader_name}: model file not found: #{model_path}", :yellow
311
+ next
98
312
  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"
313
+
314
+ content = File.read(model_path)
315
+
316
+ # More robust check: look for has_one_attached/has_many_attached with the uploader name
317
+ # Pattern matches: has_one_attached :name or has_one_attached :name, dependent: ...
318
+ attachment_pattern = /#{Regexp.escape(method_strategy)}\s+:#{Regexp.escape(uploader_name)}\b/
319
+
320
+ if content.match?(attachment_pattern)
321
+ say "Skip attaching #{uploader_name}: already present in #{model_path}", :blue
322
+ next
323
+ end
324
+
325
+ line_to_insert = " #{method_strategy} :#{uploader_name}, dependent: :purge\n"
326
+ class_anchor_regex = /class #{Regexp.escape(@model_class.to_s)} < ApplicationRecord[^\n]*\n/
327
+
328
+ inject_into_file model_path, line_to_insert, after: class_anchor_regex
329
+ end
330
+ end
331
+
332
+ def model_file_path(model_class, variable_subject)
333
+ # Extract namespace dari model class
334
+ # Models::User -> namespace setelah Models adalah []
335
+ # Models::EngineName::User -> namespace setelah Models adalah [EngineName]
336
+ full_namespace = model_class.to_s.deconstantize
337
+ namespace_parts = full_namespace.split('::').reject(&:empty?)
338
+
339
+ # Jika model_class mengandung Models::EngineName::User, maka path ke engine
340
+ # Jika model_class Models::User, maka path ke main app
341
+ if namespace_parts.length > 1 && namespace_parts.first == 'Models'
342
+ # Engine: Models::EngineName::User -> app/models/<engine_name>/<model>.rb
343
+ engine_name_part = namespace_parts[1].underscore
344
+ File.join('app/models', engine_name_part, "#{variable_subject}.rb")
345
+ else
346
+ # Main app: Models::User -> app/models/models/<model>.rb
347
+ File.join(RiderKick.configuration.models_path, "#{variable_subject}.rb")
100
348
  end
101
349
  end
102
350
 
@@ -107,22 +355,50 @@ module RiderKick
107
355
  @use_case_class = use_case_filename.camelize
108
356
  @repository_class = repository_filename.camelize
109
357
 
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")
358
+ # Generate code files
359
+ template "domains/core/use_cases/#{action + suffix}.rb.tt", File.join(RiderKick.configuration.domains_path, 'use_cases', @route_scope_path.to_s, @scope_path.to_s, "#{use_case_filename}.rb")
360
+ template "domains/core/repositories/#{action + suffix}.rb.tt", File.join(RiderKick.configuration.domains_path, 'repositories', @scope_path.to_s, "#{repository_filename}.rb")
361
+
362
+ # Generate spec files
363
+ generate_use_case_spec(action, suffix, use_case_filename)
364
+ generate_repository_spec(action, suffix, repository_filename)
112
365
  end
113
366
 
114
367
  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")
368
+ template 'domains/core/builders/builder.rb.tt', File.join(RiderKick.configuration.domains_path, 'builders', "#{@variable_subject}.rb")
369
+ template 'domains/core/entities/entity.rb.tt', File.join(RiderKick.configuration.domains_path, 'entities', "#{@variable_subject}.rb")
117
370
  end
118
371
 
119
372
  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)
373
+ @model_class.columns.reject { |c| ['id', 'created_at', 'updated_at', 'type'].include?(c.name.to_s) }
374
+ .map { |c| c.name.to_s }
375
+ end
376
+
377
+ # --- AWAL BLOK MODIFIKASI: (PERBAIKAN KEGAGALAN #1) ---
378
+ # Menambahkan helper-helper ini dari structure_generator
379
+ def columns_meta
380
+ @model_class.columns.map do |c|
381
+ {
382
+ name: c.name.to_s,
383
+ type: c.type,
384
+ sql_type: (c.respond_to?(:sql_type) ? c.sql_type : nil),
385
+ null: (c.respond_to?(:null) ? c.null : nil),
386
+ default: (c.respond_to?(:default) ? c.default : nil),
387
+ precision: (c.respond_to?(:precision) ? c.precision : nil),
388
+ scale: (c.respond_to?(:scale) ? c.scale : nil),
389
+ limit: (c.respond_to?(:limit) ? c.limit : nil)
390
+ }
391
+ end
392
+ end
393
+
394
+ def get_column_meta(field)
395
+ @columns_meta_hash[field.to_s] || {}
122
396
  end
397
+ # --- AKHIR BLOK MODIFIKASI ---
123
398
 
124
399
  def get_column_type(field)
125
- @uploaders.include?(field) ? 'upload' : @model_class.columns_hash[field.to_s].type
400
+ is_uploader = @uploaders.any? { |up| up.name == field.to_s }
401
+ is_uploader ? 'upload' : @model_class.columns_hash[field.to_s].type
126
402
  end
127
403
 
128
404
  def root_path_app
@@ -136,5 +412,44 @@ module RiderKick
136
412
  def build_repository_filename(action, suffix = '')
137
413
  "#{action}_#{@variable_subject}#{suffix}"
138
414
  end
415
+
416
+ def generate_use_case_spec(action, suffix, use_case_filename)
417
+ template_name = "domains/core/use_cases/#{action + suffix}_spec.rb.tt"
418
+ spec_path = File.join(RiderKick.configuration.domains_path, 'use_cases', @route_scope_path.to_s, @scope_path.to_s, "#{use_case_filename}_spec.rb")
419
+
420
+ if File.exist?(File.join(self.class.source_root, template_name))
421
+ template template_name, spec_path
422
+ else
423
+ say "Warning: Spec template not found: #{template_name}", :yellow
424
+ end
425
+ end
426
+
427
+ def generate_repository_spec(action, suffix, repository_filename)
428
+ template_name = "domains/core/repositories/#{action + suffix}_spec.rb.tt"
429
+ spec_path = File.join(RiderKick.configuration.domains_path, 'repositories', @scope_path.to_s, "#{repository_filename}_spec.rb")
430
+
431
+ if File.exist?(File.join(self.class.source_root, template_name))
432
+ template template_name, spec_path
433
+ else
434
+ say "Warning: Spec template not found: #{template_name}", :yellow
435
+ end
436
+ end
437
+
438
+ def generate_spec_files
439
+ # Generate builder spec (covers entity validation too)
440
+ builder_spec_path = File.join(RiderKick.configuration.domains_path, 'builders', "#{@variable_subject}_spec.rb")
441
+ template 'domains/core/builders/builder_spec.rb.tt', builder_spec_path
442
+
443
+ # Generate model spec
444
+ generate_model_spec
445
+ end
446
+
447
+ def generate_model_spec
448
+ model_spec_path = model_file_path(@model_class, @variable_subject).gsub('.rb', '_spec.rb')
449
+ # Place spec alongside the model file (same directory)
450
+ # model_spec_path sudah berisi path lengkap ke app/models/.../_spec.rb
451
+
452
+ template 'models/model_spec.rb.tt', model_spec_path
453
+ end
139
454
  end
140
455
  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