super_admin 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +216 -0
  3. data/Rakefile +30 -0
  4. data/app/assets/stylesheets/super_admin/application.css +15 -0
  5. data/app/assets/stylesheets/super_admin/tailwind.css +1 -0
  6. data/app/assets/stylesheets/super_admin/tailwind.source.css +25 -0
  7. data/app/controllers/super_admin/application_controller.rb +89 -0
  8. data/app/controllers/super_admin/associations_controller.rb +136 -0
  9. data/app/controllers/super_admin/audit_logs_controller.rb +39 -0
  10. data/app/controllers/super_admin/base_controller.rb +133 -0
  11. data/app/controllers/super_admin/dashboard_controller.rb +29 -0
  12. data/app/controllers/super_admin/exports_controller.rb +109 -0
  13. data/app/controllers/super_admin/resources_controller.rb +201 -0
  14. data/app/dashboards/super_admin/base_dashboard.rb +200 -0
  15. data/app/errors/super_admin/configuration_error.rb +6 -0
  16. data/app/helpers/super_admin/application_helper.rb +84 -0
  17. data/app/helpers/super_admin/exports_helper.rb +16 -0
  18. data/app/helpers/super_admin/resources_helper.rb +204 -0
  19. data/app/helpers/super_admin/route_helper.rb +7 -0
  20. data/app/javascript/super_admin/application.js +263 -0
  21. data/app/jobs/super_admin/application_job.rb +4 -0
  22. data/app/jobs/super_admin/generate_super_admin_csv_export_job.rb +100 -0
  23. data/app/mailers/super_admin/application_mailer.rb +6 -0
  24. data/app/models/super_admin/application_record.rb +5 -0
  25. data/app/models/super_admin/audit_log.rb +35 -0
  26. data/app/models/super_admin/csv_export.rb +67 -0
  27. data/app/services/super_admin/auditing.rb +74 -0
  28. data/app/services/super_admin/authorization.rb +113 -0
  29. data/app/services/super_admin/authorization_adapters/base_adapter.rb +100 -0
  30. data/app/services/super_admin/authorization_adapters/default_adapter.rb +77 -0
  31. data/app/services/super_admin/authorization_adapters/proc_adapter.rb +65 -0
  32. data/app/services/super_admin/authorization_adapters/pundit_adapter.rb +81 -0
  33. data/app/services/super_admin/csv_export_creator.rb +45 -0
  34. data/app/services/super_admin/dashboard_registry.rb +90 -0
  35. data/app/services/super_admin/dashboard_resolver.rb +100 -0
  36. data/app/services/super_admin/filter_builder.rb +185 -0
  37. data/app/services/super_admin/form_builder.rb +59 -0
  38. data/app/services/super_admin/form_fields/array_field.rb +35 -0
  39. data/app/services/super_admin/form_fields/association_field.rb +146 -0
  40. data/app/services/super_admin/form_fields/base_field.rb +53 -0
  41. data/app/services/super_admin/form_fields/boolean_field.rb +29 -0
  42. data/app/services/super_admin/form_fields/date_field.rb +15 -0
  43. data/app/services/super_admin/form_fields/date_time_field.rb +15 -0
  44. data/app/services/super_admin/form_fields/enum_field.rb +27 -0
  45. data/app/services/super_admin/form_fields/factory.rb +102 -0
  46. data/app/services/super_admin/form_fields/nested_field.rb +120 -0
  47. data/app/services/super_admin/form_fields/number_field.rb +29 -0
  48. data/app/services/super_admin/form_fields/text_area_field.rb +19 -0
  49. data/app/services/super_admin/model_inspector.rb +182 -0
  50. data/app/services/super_admin/queries/base_query.rb +45 -0
  51. data/app/services/super_admin/queries/filter_query.rb +188 -0
  52. data/app/services/super_admin/queries/resource_scope_query.rb +74 -0
  53. data/app/services/super_admin/queries/search_query.rb +146 -0
  54. data/app/services/super_admin/queries/sort_query.rb +41 -0
  55. data/app/services/super_admin/resource_configuration.rb +63 -0
  56. data/app/services/super_admin/resource_exporter.rb +78 -0
  57. data/app/services/super_admin/resource_query.rb +40 -0
  58. data/app/services/super_admin/resources/association_inspector.rb +112 -0
  59. data/app/services/super_admin/resources/collection_presenter.rb +63 -0
  60. data/app/services/super_admin/resources/context.rb +63 -0
  61. data/app/services/super_admin/resources/filter_params.rb +29 -0
  62. data/app/services/super_admin/resources/permitted_attributes.rb +104 -0
  63. data/app/services/super_admin/resources/value_normalizer.rb +121 -0
  64. data/app/services/super_admin/sensitive_attributes.rb +166 -0
  65. data/app/views/layouts/super_admin.html.erb +74 -0
  66. data/app/views/super_admin/audit_logs/index.html.erb +143 -0
  67. data/app/views/super_admin/dashboard/index.html.erb +79 -0
  68. data/app/views/super_admin/exports/index.html.erb +84 -0
  69. data/app/views/super_admin/exports/show.html.erb +57 -0
  70. data/app/views/super_admin/resources/_form.html.erb +42 -0
  71. data/app/views/super_admin/resources/destroy.turbo_stream.erb +17 -0
  72. data/app/views/super_admin/resources/edit.html.erb +37 -0
  73. data/app/views/super_admin/resources/index.html.erb +189 -0
  74. data/app/views/super_admin/resources/new.html.erb +31 -0
  75. data/app/views/super_admin/resources/show.html.erb +106 -0
  76. data/app/views/super_admin/shared/_breadcrumbs.html.erb +12 -0
  77. data/app/views/super_admin/shared/_custom_styles.html.erb +132 -0
  78. data/app/views/super_admin/shared/_flash.html.erb +55 -0
  79. data/app/views/super_admin/shared/_form_field.html.erb +35 -0
  80. data/app/views/super_admin/shared/_navigation.html.erb +92 -0
  81. data/app/views/super_admin/shared/_nested_fields.html.erb +59 -0
  82. data/app/views/super_admin/shared/_nested_record_fields.html.erb +45 -0
  83. data/config/importmap.rb +4 -0
  84. data/config/initializers/rack_attack.rb +134 -0
  85. data/config/initializers/super_admin.rb +117 -0
  86. data/config/locales/super_admin.en.yml +197 -0
  87. data/config/locales/super_admin.fr.yml +197 -0
  88. data/config/routes.rb +22 -0
  89. data/lib/generators/super_admin/dashboard_generator.rb +50 -0
  90. data/lib/generators/super_admin/install_generator.rb +58 -0
  91. data/lib/generators/super_admin/templates/20240101000001_create_super_admin_audit_logs.rb +24 -0
  92. data/lib/generators/super_admin/templates/20240101000002_create_super_admin_csv_exports.rb +33 -0
  93. data/lib/generators/super_admin/templates/super_admin.rb +58 -0
  94. data/lib/super_admin/dashboard_creator.rb +256 -0
  95. data/lib/super_admin/engine.rb +53 -0
  96. data/lib/super_admin/install_task.rb +96 -0
  97. data/lib/super_admin/version.rb +3 -0
  98. data/lib/super_admin.rb +7 -0
  99. data/lib/tasks/super_admin_tasks.rake +38 -0
  100. metadata +239 -0
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SuperAdmin
4
+ module FormFields
5
+ class DateField < BaseField
6
+ def render
7
+ form.date_field(attribute_name, options)
8
+ end
9
+
10
+ def type
11
+ :date
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SuperAdmin
4
+ module FormFields
5
+ class DateTimeField < BaseField
6
+ def render
7
+ form.datetime_local_field(attribute_name, options)
8
+ end
9
+
10
+ def type
11
+ :datetime
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SuperAdmin
4
+ module FormFields
5
+ class EnumField < BaseField
6
+ def render
7
+ form.select(attribute_name, enum_options, { include_blank: include_blank_option }, options.except(:required))
8
+ end
9
+
10
+ def type
11
+ :enum
12
+ end
13
+
14
+ private
15
+
16
+ def enum_options
17
+ SuperAdmin::ModelInspector.enum_values(model_class, attribute_name).keys.map do |key|
18
+ [ key.humanize, key ]
19
+ end
20
+ end
21
+
22
+ def include_blank_option
23
+ column&.null ? true : nil
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SuperAdmin
4
+ module FormFields
5
+ # Responsible for selecting the correct form field class
6
+ # based on the attribute metadata exposed by the FormBuilder.
7
+ class Factory
8
+ ASSOCIATION_SUFFIX = "_id".freeze
9
+ NESTED_SUFFIX = "_attributes".freeze
10
+
11
+ class << self
12
+ def build(builder)
13
+ new(builder).build
14
+ end
15
+ end
16
+
17
+ def initialize(builder)
18
+ @builder = builder
19
+ end
20
+
21
+ def build
22
+ field_klass.new(builder)
23
+ rescue StandardError => e
24
+ Rails.logger.error("SuperAdmin::FormFields::Factory build error: #{e.message}")
25
+ BaseField.new(builder)
26
+ end
27
+
28
+ private
29
+
30
+ attr_reader :builder
31
+
32
+ delegate :model_class, :attribute_name, :column, to: :builder
33
+
34
+ def field_klass
35
+ return NestedField if nested_attribute?
36
+ return AssociationField if association_attribute?
37
+ return EnumField if enum_attribute?
38
+ return BooleanField if boolean_attribute?
39
+ return DateTimeField if datetime_attribute?
40
+ return DateField if date_attribute?
41
+ return NumberField if number_attribute?
42
+ return ArrayField if array_attribute?
43
+ return TextAreaField if text_area_attribute?
44
+
45
+ BaseField
46
+ end
47
+
48
+ def nested_attribute?
49
+ attribute_name.to_s.end_with?(NESTED_SUFFIX)
50
+ end
51
+
52
+ def association_attribute?
53
+ association_name && model_class.reflect_on_association(association_name)
54
+ end
55
+
56
+ def association_name
57
+ return @association_name if defined?(@association_name)
58
+
59
+ name = attribute_name.to_s
60
+ @association_name = name.end_with?(ASSOCIATION_SUFFIX) ? name.delete_suffix(ASSOCIATION_SUFFIX).to_sym : nil
61
+ end
62
+
63
+ def enum_attribute?
64
+ SuperAdmin::ModelInspector.enum_values(model_class, attribute_name).present?
65
+ rescue StandardError
66
+ false
67
+ end
68
+
69
+ def boolean_attribute?
70
+ column_type?(:boolean)
71
+ end
72
+
73
+ def datetime_attribute?
74
+ column_type?(:datetime, :timestamp)
75
+ end
76
+
77
+ def date_attribute?
78
+ column_type?(:date)
79
+ end
80
+
81
+ def number_attribute?
82
+ column_type?(:integer, :float, :decimal, :bigint)
83
+ end
84
+
85
+ def text_area_attribute?
86
+ column_type?(:text)
87
+ end
88
+
89
+ def column_type?(*types)
90
+ return false unless column
91
+
92
+ types.include?(column.type)
93
+ end
94
+
95
+ def array_attribute?
96
+ return false unless column
97
+
98
+ column.respond_to?(:array) && column.array
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SuperAdmin
4
+ module FormFields
5
+ class NestedField < BaseField
6
+ MAX_DEPTH_SAFETY = 10
7
+
8
+ def render
9
+ return missing_association_warning unless reflection
10
+ return max_depth_message if max_depth_exceeded?
11
+
12
+ prepare_nested_records
13
+
14
+ view_context.render(
15
+ partial: "super_admin/shared/nested_fields",
16
+ locals: {
17
+ form: form,
18
+ parent_model_class: model_class,
19
+ association: reflection,
20
+ nested_model_class: reflection.klass,
21
+ nested_attributes: nested_editable_attributes,
22
+ nested_options: nested_options,
23
+ label: label,
24
+ current_depth: current_depth
25
+ }
26
+ )
27
+ end
28
+
29
+ def type
30
+ :nested
31
+ end
32
+
33
+ def label
34
+ return association_name.to_s.humanize unless reflection
35
+
36
+ count = reflection.collection? ? 2 : 1
37
+ reflection.klass.model_name.human(count: count)
38
+ end
39
+
40
+ def options
41
+ {}
42
+ end
43
+
44
+ private
45
+
46
+ def association_name
47
+ attribute_name.to_s.delete_suffix("_attributes")
48
+ end
49
+
50
+ def reflection
51
+ @reflection ||= model_class.reflect_on_association(association_name.to_sym)
52
+ end
53
+
54
+ def nested_options
55
+ return {} unless model_class.respond_to?(:nested_attributes_options)
56
+
57
+ model_class.nested_attributes_options[association_name.to_sym]
58
+ end
59
+
60
+ def nested_editable_attributes
61
+ attributes = SuperAdmin::DashboardResolver.form_attributes_for(reflection.klass)
62
+ attributes = SuperAdmin::ResourceConfiguration.editable_attributes(reflection.klass) if attributes.blank?
63
+
64
+ attributes = attributes.reject { |attr| attr.to_s.end_with?("_attributes") }
65
+ attributes = attributes.map { |attr| attr.to_sym }
66
+ attributes -= [ reflection.foreign_key.to_sym ] if reflection.respond_to?(:foreign_key)
67
+ attributes
68
+ end
69
+
70
+ def prepare_nested_records
71
+ parent = form.object
72
+ return unless parent
73
+ return if reflection.through_reflection
74
+
75
+ if parent.respond_to?(:association)
76
+ association_proxy = parent.association(reflection.name)
77
+ association_proxy.load_target if association_proxy.respond_to?(:load_target)
78
+ else
79
+ parent.public_send(reflection.name)
80
+ end
81
+ end
82
+
83
+ def current_depth
84
+ depth = 0
85
+ current_form = form
86
+
87
+ while current_form.respond_to?(:object) && current_form.respond_to?(:object_name) && current_form.object_name.to_s.include?("[")
88
+ depth += 1
89
+ parent = current_form.instance_variable_get(:@parent_builder)
90
+ break unless parent
91
+
92
+ current_form = parent
93
+ break if depth >= MAX_DEPTH_SAFETY
94
+ end
95
+
96
+ depth
97
+ end
98
+
99
+ def max_depth_exceeded?
100
+ current_depth >= SuperAdmin.max_nested_depth
101
+ end
102
+
103
+ def max_depth_message
104
+ view_context.content_tag(
105
+ :div,
106
+ view_context.content_tag(:p, view_context.t("super_admin.resources.nested.max_depth_exceeded", max: SuperAdmin.max_nested_depth), class: "text-sm text-amber-600"),
107
+ class: "mb-4 bg-amber-50 border border-amber-200 rounded-md p-3"
108
+ )
109
+ end
110
+
111
+ def missing_association_warning
112
+ view_context.content_tag(
113
+ :p,
114
+ view_context.t("super_admin.resources.nested.missing_association", name: association_name.humanize),
115
+ class: "text-sm text-red-600"
116
+ )
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SuperAdmin
4
+ module FormFields
5
+ class NumberField < BaseField
6
+ def render
7
+ form.number_field(attribute_name, options)
8
+ end
9
+
10
+ def type
11
+ :number
12
+ end
13
+
14
+ def options
15
+ base_options.merge(step: step_value)
16
+ end
17
+
18
+ private
19
+
20
+ def step_value
21
+ column = builder.column
22
+ return "0.01" if column&.type == :decimal && column.scale && column.scale.positive?
23
+ return "0.01" if column&.type == :float
24
+
25
+ "1"
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SuperAdmin
4
+ module FormFields
5
+ class TextAreaField < BaseField
6
+ def render
7
+ form.text_area(attribute_name, options)
8
+ end
9
+
10
+ def type
11
+ :text_area
12
+ end
13
+
14
+ def options
15
+ base_options.merge(rows: 4)
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SuperAdmin
4
+ # Service responsible for inspecting ActiveRecord models.
5
+ # Extracts information about attributes, types, and associations.
6
+ class ModelInspector
7
+ # System models to exclude from SuperAdmin interface
8
+ EXCLUDED_MODELS = %w[
9
+ ApplicationRecord
10
+ ActiveRecord::Base
11
+ ActiveRecord::SchemaMigration
12
+ ActiveRecord::InternalMetadata
13
+ ActionText::Record
14
+ ActionText::RichText
15
+ ActionText::EncryptedRichText
16
+ ActionMailbox::Record
17
+ ActionMailbox::InboundEmail
18
+ ActiveStorage::Record
19
+ ActiveStorage::Blob
20
+ ActiveStorage::Attachment
21
+ ActiveStorage::VariantRecord
22
+ SolidQueue::Job
23
+ SolidQueue::Process
24
+ SolidQueue::RecurringTask
25
+ SolidQueue::ScheduledExecution
26
+ SolidQueue::ReadyExecution
27
+ SolidQueue::ClaimedExecution
28
+ SolidQueue::FailedExecution
29
+ SolidQueue::BlockedExecution
30
+ SolidQueue::Semaphore
31
+ SolidQueue::Pause
32
+ SolidCable::Message
33
+ SolidCache::Entry
34
+ ].freeze
35
+
36
+ class << self
37
+ # Returns list of all administrable models
38
+ # @return [Array<Class>] List of model classes
39
+ def all_models
40
+ Rails.application.eager_load! unless Rails.application.config.eager_load
41
+
42
+ SuperAdmin::DashboardRegistry.instance.resource_classes
43
+ end
44
+
45
+ # Returns the list of models that could be managed by SuperAdmin, regardless of dashboard presence.
46
+ # @return [Array<Class>]
47
+ def discoverable_models
48
+ Rails.application.eager_load! unless Rails.application.config.eager_load
49
+
50
+ ActiveRecord::Base.descendants
51
+ .reject { |model| excluded_model?(model) }
52
+ .sort_by(&:name)
53
+ end
54
+
55
+ # Returns detailed information about a model
56
+ # @param model_class [Class] The model class
57
+ # @return [Hash] Model information
58
+ def inspect_model(model_class)
59
+ {
60
+ name: model_class.name,
61
+ table_name: model_class.table_name,
62
+ human_name: model_class.model_name.human,
63
+ attributes: inspect_attributes(model_class),
64
+ associations: inspect_associations(model_class),
65
+ validations: inspect_validations(model_class)
66
+ }
67
+ end
68
+
69
+ # Returns model attributes with their metadata
70
+ # @param model_class [Class] The model class
71
+ # @return [Hash] Attributes with type, null, default, etc.
72
+ def inspect_attributes(model_class)
73
+ model_class.columns.each_with_object({}) do |column, hash|
74
+ hash[column.name] = {
75
+ type: column.type,
76
+ sql_type: column.sql_type,
77
+ null: column.null,
78
+ default: column.default,
79
+ limit: column.limit,
80
+ precision: column.precision,
81
+ scale: column.scale
82
+ }
83
+ end
84
+ end
85
+
86
+ # Returns model associations
87
+ # @param model_class [Class] The model class
88
+ # @return [Hash] Associations grouped by type
89
+ def inspect_associations(model_class)
90
+ model_class.reflect_on_all_associations.each_with_object({}) do |assoc, hash|
91
+ hash[assoc.name] = {
92
+ type: assoc.macro,
93
+ class_name: assoc.class_name,
94
+ foreign_key: assoc.foreign_key,
95
+ primary_key: assoc.association_primary_key,
96
+ polymorphic: assoc.polymorphic?,
97
+ through: assoc.through_reflection&.name
98
+ }
99
+ end
100
+ end
101
+
102
+ # Returns model validations
103
+ # @param model_class [Class] The model class
104
+ # @return [Hash] Validations by attribute
105
+ def inspect_validations(model_class)
106
+ model_class.validators.each_with_object(Hash.new { |h, k| h[k] = [] }) do |validator, hash|
107
+ validator.attributes.each do |attribute|
108
+ hash[attribute] << {
109
+ kind: validator.kind,
110
+ options: validator.options.except(:class)
111
+ }
112
+ end
113
+ end
114
+ end
115
+
116
+ # Checks if an attribute is an enum
117
+ # @param model_class [Class] The model class
118
+ # @param attribute_name [String, Symbol] The attribute name
119
+ # @return [Boolean]
120
+ def enum?(model_class, attribute_name)
121
+ model_class.defined_enums.key?(attribute_name.to_s)
122
+ end
123
+
124
+ # Returns possible values for an enum
125
+ # @param model_class [Class] The model class
126
+ # @param attribute_name [String, Symbol] The attribute name
127
+ # @return [Hash, nil] Hash of enum values or nil
128
+ def enum_values(model_class, attribute_name)
129
+ model_class.defined_enums[attribute_name.to_s]
130
+ end
131
+
132
+ # Finds a model class by its name
133
+ # @param model_name [String] The model name (plural or singular)
134
+ # @return [Class, nil] The model class or nil
135
+ def find_model(model_name)
136
+ name = model_name.to_s
137
+
138
+ candidates = build_candidate_names(name)
139
+
140
+ candidates.each do |candidate|
141
+ klass = candidate.safe_constantize
142
+ return klass if valid_model_class?(klass)
143
+ end
144
+
145
+ # Fallback to registered dashboard models
146
+ normalized_name = name.singularize.camelize
147
+ all_models.find { |m| m.name == normalized_name }
148
+ end
149
+
150
+ private
151
+
152
+ def build_candidate_names(name)
153
+ variations = [ name, name.singularize, name.pluralize ].uniq
154
+
155
+ variations.flat_map do |variation|
156
+ [ variation, variation.camelize, variation.singularize.camelize ]
157
+ end.uniq
158
+ end
159
+
160
+ def valid_model_class?(klass)
161
+ klass.is_a?(Class) && klass < ActiveRecord::Base && !klass.abstract_class?
162
+ end
163
+
164
+ # Checks if a model should be excluded
165
+ # @param model [Class] The model class
166
+ # @return [Boolean]
167
+ def excluded_model?(model)
168
+ return true if model.abstract_class?
169
+ return true if EXCLUDED_MODELS.include?(model.name)
170
+ return true if model.name.start_with?("ActiveRecord::", "ActionText::", "ActionMailbox::", "ActiveStorage::", "SolidQueue::", "SolidCable::", "SolidCache::")
171
+ return true unless model.table_exists?
172
+
173
+ false
174
+ rescue StandardError => error
175
+ Rails.logger.warn(
176
+ "[SuperAdmin::ModelInspector] Failed to evaluate exclusion for #{model}: #{error.class} - #{error.message}"
177
+ )
178
+ true
179
+ end
180
+ end
181
+ end
182
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SuperAdmin
4
+ module Queries
5
+ # Abstract class for SuperAdmin query objects.
6
+ # Provides common interface and shared utilities.
7
+ class BaseQuery
8
+ attr_reader :scope, :model_class
9
+
10
+ # @param scope [ActiveRecord::Relation]
11
+ # @param model_class [Class]
12
+ def initialize(scope, model_class)
13
+ @scope = scope
14
+ @model_class = model_class
15
+ end
16
+
17
+ # Abstract method to implement in subclasses
18
+ # @return [ActiveRecord::Relation]
19
+ def call
20
+ raise NotImplementedError, "#{self.class}#call must be implemented"
21
+ end
22
+
23
+ private
24
+
25
+ # Returns the model's Arel table
26
+ # @return [Arel::Table]
27
+ def arel_table
28
+ @arel_table ||= model_class.arel_table
29
+ end
30
+
31
+ # Returns the model's columns
32
+ # @return [Array<ActiveRecord::ConnectionAdapters::Column>]
33
+ def columns
34
+ @columns ||= model_class.columns
35
+ end
36
+
37
+ # Returns columns of specified type
38
+ # @param types [Array<Symbol>] Column types (e.g., [:string, :text])
39
+ # @return [Array<ActiveRecord::ConnectionAdapters::Column>]
40
+ def columns_of_type(*types)
41
+ columns.select { |column| types.include?(column.type) }
42
+ end
43
+ end
44
+ end
45
+ end