diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..217d863 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,28 @@ +name: Lint + +on: + pull_request: + push: + branches: + - main + +jobs: + lint: + name: Run RuboCop linters + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: .ruby-version + bundler-cache: true + + - name: Install dependencies + run: bundle install --jobs 4 --retry 3 + + - name: Check if Ruby files are formatted + run: bundle exec rake lint diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..fefa559 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,28 @@ +name: Test + +on: + pull_request: + push: + branches: + - main + +jobs: + lint: + name: Run RSpec tests + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: .ruby-version + bundler-cache: true + + - name: Install dependencies + run: bundle install --jobs 4 --retry 3 + + - name: Run tests + run: bundle exec rake test diff --git a/.rspec b/.rspec index 34c5164..d016dc8 100644 --- a/.rspec +++ b/.rspec @@ -1,3 +1,3 @@ ---format documentation ---color --require spec_helper +--format Fuubar +--color diff --git a/.rubocop.yml b/.rubocop.yml index f683766..8f9458c 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,10 +1,8 @@ AllCops: - TargetRubyVersion: 3.4 + TargetRubyVersion: 2.7 NewCops: enable DisplayCopNames: true SuggestExtensions: false - Exclude: - - Gemfile Gemspec/RequiredRubyVersion: Enabled: false @@ -12,12 +10,48 @@ Gemspec/RequiredRubyVersion: Layout/EmptyLineBetweenDefs: AllowAdjacentOneLineDefs: true +Layout/FirstArrayElementIndentation: + Enabled: false + +Layout/FirstHashElementIndentation: + Enabled: false + +Layout/LineLength: + Enabled: false + +Metrics/AbcSize: + Enabled: false + +Metrics/BlockLength: + Enabled: false + +Metrics/ClassLength: + Enabled: false + +Metrics/CyclomaticComplexity: + Enabled: false + +Metrics/MethodLength: + Enabled: false + +Metrics/ModuleLength: + Enabled: false + +Naming/PredicateMethod: + Enabled: false + +Style/AccessorGrouping: + Enabled: false + Style/Documentation: Enabled: false Style/RedundantBegin: Enabled: false +Style/RegexpLiteral: + EnforcedStyle: percent_r + Style/TrailingCommaInArrayLiteral: EnforcedStyleForMultiline: comma diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..fa089f7 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,21 @@ +# Changelog + +## [0.0.1] - 2025-11-17 + +### Added + +- Initial release of EnumFields gem +- Support for enum-like fields with metadata properties +- Array and Hash definition formats +- Automatic generation of: + - Class methods (`s`, `s_count`, `_values`, `_options`) + - Instance getter/setter methods (when accessor differs from column) + - Metadata accessor methods (`_metadata`) + - Property accessor methods for all defined properties (`_`) + - Inquiry methods (`_?`) + - Scopes (`_`) + - Validations (inclusion with `allow_nil: true`) +- Custom column mapping support via `column:` option +- Dynamic property method generation for custom properties + +[0.0.1]: https://github.com/kinnell/enum_fields/releases/tag/v0.0.1 diff --git a/Gemfile b/Gemfile index fc13765..35e943e 100644 --- a/Gemfile +++ b/Gemfile @@ -1,12 +1,18 @@ +# frozen_string_literal: true + source 'https://rubygems.org' gemspec -group :development do +group :development, :test do gem 'rake', '~> 13.0' - gem 'rubocop', '~> 1.70' + gem 'rspec', '~> 3.13' + gem 'rspec-core', '~> 3.13' + gem 'rspec-expectations', '~> 3.13' + gem 'rspec-mocks', '~> 3.13' end -group :test do - gem 'rspec', '~> 3.9' +group :development do + gem 'fuubar' + gem 'rubocop', '~> 1.70' end diff --git a/README.md b/README.md index 6480746..86561b4 100644 --- a/README.md +++ b/README.md @@ -1 +1,276 @@ -# enum_fields +# EnumFields + +Enhanced enum-like fields for ActiveRecord models with metadata support + +## Requirements + +- Ruby >= 2.7.6 +- Rails >= 6.0 (ActiveRecord and ActiveSupport) + +## Installation + +Add this line to your application's Gemfile: + +```ruby +gem 'enum_fields' +``` + +And then execute: + +```bash +bundle install +``` + +## Usage + +### Basic Setup + +Include the `EnumFields` module in your `ApplicationRecord`: + +```ruby +class ApplicationRecord < ActiveRecord::Base + include EnumFields + + self.abstract_class = true +end +``` + +Now all models inheriting from `ApplicationRecord` can use `enum_field`. + +### Defining Enum Fields + +#### Hash Definition (Recommended) + +```ruby +class Campaign < ApplicationRecord + enum_field :stage, { + pending: { + value: 'pending', + label: 'Pending', + icon: 'clock', + color: 'yellow', + tooltip: 'Campaign is awaiting processing', + }, + processing: { + value: 'processing', + label: 'Processing', + icon: 'cog', + color: 'blue', + tooltip: 'Campaign is being processed', + }, + shipped: { + value: 'shipped', + label: 'Shipped', + icon: 'truck', + color: 'green', + tooltip: 'Campaign has been shipped', + }, + delivered: { + value: 'delivered', + label: 'Delivered', + icon: 'check', + color: 'green', + tooltip: 'Campaign has been delivered', + }, + } +end +``` + +#### Array Definition (Simple) + +```ruby +class Task < ApplicationRecord + enum_field :priority, ['low', 'medium', 'high'] +end +``` + +This automatically generates: + +```ruby +{ + low: { + value: 'low', + label: 'low', + }, + medium: { + value: 'medium', + label: 'medium', + }, + high: { + value: 'high', + label: 'high', + }, +} +``` + +#### Custom Column Mapping + +If your accessor name differs from your column name: + +```ruby +class User < ApplicationRecord + enum_field :role, definitions, column: :user_role +end +``` + +### Generated Methods + +For an enum field defined as: + +```ruby +class Campaign < ApplicationRecord + enum_field :stage, { + draft: { + value: 'draft', + label: 'Draft', + icon: 'file', + color: 'gray', + }, + scheduled: { + value: 'scheduled', + label: 'Scheduled', + icon: 'calendar', + color: 'blue', + }, + completed: { + value: 'completed', + label: 'Completed', + icon: 'check', + color: 'green', + }, + } +end +``` + +#### Class Methods + +```ruby +# Returns the definitions as an HashWithIndifferentAccess +Campaign.stages + +# Returns the count of definitions +Campaign.stages_count # 3 + +# Returns the values of the definitions +Campaign.stages_values # ['draft', 'scheduled', 'completed'] + +# Returns the options for form helpers +Campaign.stages_options # [['Draft', 'draft'], ['Scheduled', 'scheduled'], ['Completed', 'completed']] +``` + +#### Instance Getter/Setter + +If the accessor name differs from the column name, getter and setter methods are defined for the accessor. + +```ruby +campaign.stage # 'draft' +campaign.stage = 'scheduled' +campaign.stage # 'scheduled' +``` + +- `campaign.stage` - Get the current stage value +- `campaign.stage = 'scheduled'` - Set the stage value + +#### Metadata Methods + +The gem automatically creates accessor methods for all properties defined in your enum definitions. + +- `value` (required) - The actual value stored in the database +- `label` (auto-generated if not provided) - A human-readable label + +Any additional properties you define (like `icon`, `color`, `tooltip`, etc.) will also get dedicated accessor methods automatically. + +```ruby +# Returns the full metadata hash for current value +campaign.stage_metadata +# => { value: 'draft', label: 'Draft', icon: 'file', color: 'gray' } + +# Access individual properties +campaign.stage_value # 'draft' +campaign.stage_label # 'Draft' +campaign.stage_icon # 'file' +campaign.stage_color # 'gray' +``` + +#### Inquiry Methods + +```ruby +# Returns true if the current value is 'draft' +campaign.draft_stage? + +# Returns true if the current value is 'scheduled' +campaign.scheduled_stage? + +# Returns true if the current value is 'completed' +campaign.completed_stage? +``` + +#### Scopes + +```ruby +# Returns all campaigns with draft stage +Campaign.draft_stage + +# Returns all campaigns with scheduled stage +Campaign.scheduled_stage + +# Returns all campaigns with completed stage +Campaign.completed_stage +``` + +#### Validation + +Automatically validates that the column value is included in the defined values (with `allow_nil: true`). + +### Custom Properties + +You can add any custom properties to your definitions, and the gem will automatically create accessor methods for them: + +```ruby +class Ticket < ApplicationRecord + enum_field :priority, { + low: { + value: 'low', + label: 'Low Priority', + sla_hours: 72, + notify_manager: false, + }, + high: { + value: 'high', + label: 'High Priority', + sla_hours: 4, + notify_manager: true, + }, + } +end + +# Access custom properties directly via generated methods +ticket.priority_sla_hours # 72 +ticket.priority_notify_manager # false + +# Or access via metadata hash +ticket.priority_metadata[:sla_hours] # 72 +ticket.priority_metadata[:notify_manager] # false +``` + +## Development + +After checking out the repo, run: + +```bash +bundle install +``` + +Run the test suite: + +```bash +bundle exec rspec +``` + +## Contributing + +Bug reports and pull requests are welcome on GitHub at https://github.com/kinnell/enum_fields. + +## License + +The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). diff --git a/enum_fields.gemspec b/enum_fields.gemspec index 9847758..bdbe45e 100644 --- a/enum_fields.gemspec +++ b/enum_fields.gemspec @@ -3,32 +3,33 @@ require_relative 'lib/enum_fields/version' Gem::Specification.new do |spec| - spec.name = 'enum_fields' - spec.version = EnumFields::VERSION + spec.name = 'enum_fields' + spec.version = EnumFields::VERSION spec.platform = Gem::Platform::RUBY - spec.authors = ['Kinnell Shah'] - spec.email = ['kinnell@gmail.com'] - - spec.summary = 'enum_fields' - spec.description = 'enum_fields' - spec.homepage = 'https://github.com/kinnell/enum_fields' - spec.license = 'MIT' - - spec.metadata['allowed_push_host'] = 'https://rubygems.pkg.github.com/kinnell' - spec.metadata['github_repo'] = 'ssh://github.com/kinnell/enum_fields' + spec.authors = ['Kinnell Shah'] + spec.email = ['kinnell@gmail.com'] + + spec.summary = 'Enhanced enum-like fields for ActiveRecord models with metadata support' + spec.description = <<~TEXT + A Rails gem that provides enum-like functionality with support for metadata properties (label, icon, color, tooltip) and automatic scopes, validations, and inquiry methods. + TEXT + + spec.homepage = 'https://github.com/kinnell/enum_fields' + spec.license = 'MIT' + + spec.metadata['homepage_uri'] = spec.homepage + spec.metadata['github_repo'] = 'ssh://github.com/kinnell/enum_fields' + spec.metadata['source_code_uri'] = 'https://github.com/kinnell/enum_fields' + spec.metadata['changelog_uri'] = 'https://github.com/kinnell/enum_fields/blob/main/CHANGELOG.md' + spec.metadata['bug_tracker_uri'] = 'https://github.com/kinnell/enum_fields/issues' spec.metadata['rubygems_mfa_required'] = 'true' - spec.files = Dir[ - 'lib/**/*', - 'Gemfile', - '*.md' - ] - - spec.executables << 'enum_fields' + spec.files = Dir['lib/**/*', 'LICENSE', '*.md'] spec.require_paths = ['lib'] - spec.required_ruby_version = Gem::Requirement.new('>= 2.5.0') + spec.required_ruby_version = Gem::Requirement.new('>= 2.7.6') spec.required_rubygems_version = Gem::Requirement.new('>= 2.0') - spec.add_dependency 'activesupport', '>= 6.0' + spec.add_dependency 'activerecord', '>= 6.0', '< 9.0' + spec.add_dependency 'activesupport', '>= 6.0', '< 9.0' end diff --git a/lib/enum_fields.rb b/lib/enum_fields.rb index 721d132..c5a3560 100644 --- a/lib/enum_fields.rb +++ b/lib/enum_fields.rb @@ -1,11 +1,41 @@ # frozen_string_literal: true +require 'delegate' +require 'active_support/concern' require 'active_support/dependencies/autoload' -require 'active_support/inflector' require 'active_support/core_ext/hash/indifferent_access' +require 'active_support/core_ext/array/extract_options' +require 'active_support/inflector' +require 'active_record' + +require_relative 'enum_fields/errors' +require_relative 'enum_fields/version' module EnumFields + extend ActiveSupport::Concern extend ActiveSupport::Autoload - autoload :Version + autoload :Definition + autoload :EnumField + + class_methods do + def enum_field(accessor, definition, options = {}) + raise MissingDefinitionsError unless definition.present? + + EnumField.define( + model_class: self, + accessor: accessor, + definition: definition, + options: options + ) + end + + def enum_field_for(accessor) + enum_fields[accessor] + end + + def enum_fields + @enum_fields ||= {}.with_indifferent_access + end + end end diff --git a/lib/enum_fields/definition.rb b/lib/enum_fields/definition.rb new file mode 100644 index 0000000..1dd1f15 --- /dev/null +++ b/lib/enum_fields/definition.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +module EnumFields + class Definition < SimpleDelegator + STANDARD_PROPERTIES = %i[ + value + label + ].freeze + + attr_reader :properties + + def initialize(data) + @data = build(data) + raise InvalidDefinitionsError, 'Definitions must be a Hash or Array' unless valid_hash?(@data) + + @properties = Set.new(STANDARD_PROPERTIES) + @properties.merge(@data.flat_map { |_, metadata| metadata.keys }) + + super(@data) + end + + def data + __getobj__ + end + + def valid? + valid_hash?(__getobj__) + end + + private + + def build(input) + output = begin + case input + when HashWithIndifferentAccess + input + when Hash + input.with_indifferent_access + when Array + input.to_h do |value| + [value, { value: value }] + end.with_indifferent_access + else + raise InvalidDefinitionsError, "Invalid definitions format: #{input.class}" + end + end + + output.transform_values do |metadata| + if metadata.key?(:label) + metadata + else + metadata.merge(label: metadata[:value]) + end + end + end + + def valid_hash?(data) + return false unless data.is_a?(HashWithIndifferentAccess) + return false unless data.values.all? { |metadata| metadata.key?(:value) } + + true + end + end +end diff --git a/lib/enum_fields/enum_field.rb b/lib/enum_fields/enum_field.rb new file mode 100644 index 0000000..371d9dc --- /dev/null +++ b/lib/enum_fields/enum_field.rb @@ -0,0 +1,142 @@ +# frozen_string_literal: true + +module EnumFields + class EnumField + def self.define(**args) + new(**args).define! + end + + def initialize(model_class:, accessor:, definition:, options: {}) + @model_class = model_class + @accessor = accessor.to_sym + @column_name = options.fetch(:column, @accessor).to_sym + @definition = Definition.new(definition) + end + + def define! + store_definition! + define_class_methods! + define_instance_getter! + define_instance_setter! + define_metadata_method! + define_property_methods! + define_inquiry_methods! + define_scopes! + define_validation! + end + + private + + def store_definition! + @model_class.enum_fields[@accessor] = @definition.data + end + + def define_class_methods! + collection_name = @accessor.to_s.pluralize + definition_data = @definition.data + + @model_class.define_singleton_method(collection_name) do + definition_data + end + + @model_class.define_singleton_method("#{collection_name}_count") do + definition_data.size + end + + @model_class.define_singleton_method("#{@accessor}_values") do + definition_data.values.pluck(:value) + end + + @model_class.define_singleton_method("#{@accessor}_options") do + definition_data.map do |key, metadata| + [metadata[:label], key.to_s] + end + end + end + + def define_instance_getter! + return if @accessor == @column_name + + accessor = @accessor + column_name = @column_name + + @model_class.define_method(accessor) do + attributes[column_name.to_s] + end + end + + def define_instance_setter! + return if @accessor == @column_name + + accessor = @accessor + column_name = @column_name + + @model_class.define_method("#{accessor}=") do |value| + public_send("#{column_name}=", value) + end + end + + def define_metadata_method! + accessor = @accessor + column_name = @column_name + + @model_class.define_method("#{accessor}_metadata") do + column_value = attributes[column_name.to_s] + return nil if column_value.nil? + + definitions = self.class.enum_field_for(accessor) + return nil if definitions.blank? + + definitions[column_value] + end + end + + def define_property_methods! + accessor = @accessor + column_name = @column_name + + @definition.properties.each do |property| + @model_class.define_method("#{accessor}_#{property}") do + column_value = attributes[column_name.to_s] + return nil if column_value.nil? + + definitions = self.class.enum_field_for(accessor) + return nil if definitions.blank? + + definitions.dig(column_value, property) + end + end + end + + def define_inquiry_methods! + accessor = @accessor + column_name = @column_name + + @definition.each do |key, metadata| + definition_value = metadata[:value] + @model_class.define_method("#{key}_#{accessor}?") do + public_send(column_name) == definition_value + end + end + end + + def define_scopes! + column_name = @column_name + + @definition.each do |key, metadata| + definition_value = metadata[:value] + @model_class.scope("#{key}_#{@accessor}", -> { where(column_name => definition_value) }) + end + end + + def define_validation! + return if @definition.blank? + + valid_values = @definition.values.pluck(:value) + @model_class.validates(@column_name, inclusion: { + in: valid_values, + allow_nil: true, + }) + end + end +end diff --git a/lib/enum_fields/errors.rb b/lib/enum_fields/errors.rb new file mode 100644 index 0000000..d5e7217 --- /dev/null +++ b/lib/enum_fields/errors.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +module EnumFields + class MissingDefinitionsError < StandardError; end + class InvalidDefinitionsError < StandardError; end +end diff --git a/spec/enum_fields/additional_properties.spec.rb b/spec/enum_fields/additional_properties.spec.rb new file mode 100644 index 0000000..0e07ebc --- /dev/null +++ b/spec/enum_fields/additional_properties.spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +RSpec.describe EnumFields, 'Additional Properties' do + include_context 'with TestModel' + + let(:definitions) do + { + value1: { + value: 'value1', + label: 'Value 1', + additional_property: 'additional_value1', + }, + value2: { + value: 'value2', + label: 'Value 2', + additional_property: 'additional_value2', + }, + } + end + + before do + TestModel.enum_field :sample_column, definitions + end + + describe 'Model.s' do + it 'returns definitions as a hash with the additional properties' do + expect(TestModel.sample_columns).to match(definitions) + end + end + + describe 'Instance._metadata' do + it 'returns the metadata of the accessor with the additional properties' do + expect(record.sample_column_metadata).to match(definitions[:value1]) + end + end + + describe 'Instance._' do + it 'returns the value of the additional property' do + expect(record.sample_column_additional_property).to eq(definitions[:value1][:additional_property]) + end + end +end diff --git a/spec/enum_fields/array_definitions.spec.rb b/spec/enum_fields/array_definitions.spec.rb new file mode 100644 index 0000000..685c18b --- /dev/null +++ b/spec/enum_fields/array_definitions.spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +RSpec.describe EnumFields, 'Array Definitions' do + include_context 'with TestModel' + + let(:definitions) { %w[value1 value2] } + + before do + TestModel.enum_field :sample_column, definitions + end + + describe 'Model.s' do + it 'defines definitions method on the class' do + expect(TestModel).to respond_to(:sample_columns) + end + + it 'returns definitions as a hash with :value & :label properties' do + expect(TestModel.sample_columns).to match({ + value1: { + value: 'value1', + label: 'value1', + }, + value2: { + value: 'value2', + label: 'value2', + }, + }) + end + end +end diff --git a/spec/enum_fields/column_override.spec.rb b/spec/enum_fields/column_override.spec.rb new file mode 100644 index 0000000..8382ea2 --- /dev/null +++ b/spec/enum_fields/column_override.spec.rb @@ -0,0 +1,221 @@ +# frozen_string_literal: true + +RSpec.describe EnumFields, 'Column Override' do + include_context 'with TestModel' + + let(:definitions) do + { + value1: { + value: 'value1', + label: 'Value 1', + }, + value2: { + value: 'value2', + label: 'Value 2', + }, + } + end + + before do + TestModel.enum_field :sample_field, definitions, column: :sample_column + end + + describe 'Model.s' do + it 'defines definitions method on the class for the accessor' do + expect(TestModel).to respond_to(:sample_fields) + end + + it 'does not define definitions method on the class for the column' do + expect(TestModel).not_to respond_to(:sample_columns) + end + + it 'returns definitions as a hash for the accessor' do + expect(TestModel.sample_fields).to match(definitions) + end + end + + describe 'Model.s_count' do + it 'defines definitions count method on the class for the accessor' do + expect(TestModel).to respond_to(:sample_fields_count) + end + + it 'does not define definitions count method on the class for the column' do + expect(TestModel).not_to respond_to(:sample_columns_count) + end + + it 'returns the number of definitions for the accessor' do + expect(TestModel.sample_fields_count).to eq(definitions.size) + end + end + + describe 'Model._values' do + it 'defines definitions values method on the class for the accessor' do + expect(TestModel).to respond_to(:sample_field_values) + end + + it 'does not define definitions values method on the class for the column' do + expect(TestModel).not_to respond_to(:sample_column_values) + end + + it 'returns the values of the definitions for the accessor' do + expect(TestModel.sample_field_values).to eq(%w[value1 value2]) + end + end + + describe 'Model._options' do + it 'defines definitions options method on the class for the accessor' do + expect(TestModel).to respond_to(:sample_field_options) + end + + it 'does not define definitions options method on the class for the column' do + expect(TestModel).not_to respond_to(:sample_column_options) + end + + it 'returns the options of the definitions for the accessor' do + expect(TestModel.sample_field_options).to match(definitions.map { |key, definition| + [definition[:label], key.to_s] + }) + end + end + + describe 'Instance.' do + it 'defines getter method on the instance for the accessor' do + expect(record).to respond_to(:sample_field) + end + + it 'retains the original getter method on the instance for the column' do + expect(record).to respond_to(:sample_column) + end + + it 'returns the value of the accessor' do + expect(record.sample_field).to eq(sample_column_value) + end + end + + describe 'Instance.=' do + it 'defines setter method on the instance for the accessor' do + expect(record).to respond_to(:sample_field=) + end + + it 'retains the original setter method on the instance for the column' do + expect(record).to respond_to(:sample_column=) + end + + it 'sets the value of the accessor' do + expect(record.sample_field).to eq(sample_column_value) + end + end + + describe 'Instance._metadata' do + it 'defines metadata method on the instance for the accessor' do + expect(record).to respond_to(:sample_field_metadata) + end + + it 'does not define metadata method on the instance for the column' do + expect(record).not_to respond_to(:sample_column_metadata) + end + + it 'returns the metadata of the accessor' do + expect(record.sample_field_metadata).to match(definitions[:value1]) + end + end + + describe 'Instance._value' do + it 'defines value method on the instance for the accessor' do + expect(record).to respond_to(:sample_field_value) + end + + it 'does not define value method on the instance for the column' do + expect(record).not_to respond_to(:sample_column_value) + end + + it 'returns the value of the accessor' do + expect(record.sample_field_value).to eq(definitions.dig(:value1, :value)) + end + end + + describe 'Instance._label' do + it 'defines label method on the instance for the accessor' do + expect(record).to respond_to(:sample_field_label) + end + + it 'does not define label method on the instance for the column' do + expect(record).not_to respond_to(:sample_column_label) + end + + it 'returns the label of the accessor' do + expect(record.sample_field_label).to eq(definitions.dig(:value1, :label)) + end + end + + describe 'Instance.?' do + it 'defines inquiry methods on the instance for the accessor' do + expect(record).to respond_to(:value1_sample_field?) + expect(record).to respond_to(:value2_sample_field?) + end + + it 'does not define inquiry methods on the instance for the column' do + expect(record).not_to respond_to(:value1_sample_column?) + expect(record).not_to respond_to(:value2_sample_column?) + end + + it 'returns true if the accessor is value1' do + expect(record.value1_sample_field?).to be_truthy + expect(record.value2_sample_field?).to be_falsey + end + end + + describe 'Model._' do + it 'defines scope methods on the class for the accessor' do + expect(TestModel).to respond_to(:value1_sample_field) + expect(TestModel).to respond_to(:value2_sample_field) + end + + it 'does not define scope methods on the class for the column' do + expect(TestModel).not_to respond_to(:value1_sample_column) + expect(TestModel).not_to respond_to(:value2_sample_column) + end + + it 'returns the records with the value1 accessor' do + expect(TestModel.value1_sample_field.to_sql).to match(%r{ + \ASELECT\s+"with_model_test_models_\d+_\d+"\.\* + \s+FROM\s+"with_model_test_models_\d+_\d+" + \s+WHERE\s+"with_model_test_models_\d+_\d+"\."sample_column"\s+=\s+'value + }x) + end + + it 'returns the records with the value2 accessor' do + expect(TestModel.value2_sample_field.to_sql).to match(%r{ + \ASELECT\s+"with_model_test_models_\d+_\d+"\.\* + \s+FROM\s+"with_model_test_models_\d+_\d+" + \s+WHERE\s+"with_model_test_models_\d+_\d+"\."sample_column"\s+=\s+'value + }x) + end + end + + describe 'Instance validation' do + context 'when the accessor value is in the list of definitions' do + let(:sample_column_value) { 'value1' } + + before do + record.update(sample_column: sample_column_value) + end + + it 'is valid' do + expect(record).to be_valid + end + end + + context 'when the accessor value is not in the list of definitions' do + let(:sample_column_value) { 'value3' } + + before do + record.update(sample_column: sample_column_value) + end + + it 'is not valid' do + expect(record).to be_invalid + end + end + end +end diff --git a/spec/enum_fields/definition.spec.rb b/spec/enum_fields/definition.spec.rb new file mode 100644 index 0000000..9f1222d --- /dev/null +++ b/spec/enum_fields/definition.spec.rb @@ -0,0 +1,292 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe EnumFields::Definition do + subject { described_class.new(data) } + + describe '#initialize' do + context 'when data is a Hash' do + let(:data) do + { + pending: { + value: 'pending', + label: 'Pending', + }, + active: { + value: 'active', + label: 'Active', + }, + } + end + + it 'converts to HashWithIndifferentAccess' do + expect(subject.data).to be_a(HashWithIndifferentAccess) + end + + it 'preserves the hash data' do + expect(subject.data).to match(data) + end + end + + context 'when data is a HashWithIndifferentAccess' do + let(:data) do + { + pending: { + value: 'pending', + label: 'Pending', + }, + }.with_indifferent_access + end + + it 'uses the hash directly' do + expect(subject.data).to eq(data) + end + end + + context 'when data is an Array' do + let(:data) do + %w[ + pending + active + archived + ] + end + + it 'converts to hash with value and label' do + expect(subject.data).to match({ + pending: { + value: 'pending', + label: 'pending', + }, + active: { + value: 'active', + label: 'active', + }, + archived: { + value: 'archived', + label: 'archived', + }, + }) + end + + it 'creates entries for all array elements' do + expect(subject.data.keys).to match(data) + end + end + + context 'when data is a String' do + let(:data) { 'invalid' } + + it 'raises InvalidDefinitionsError' do + expect { subject }.to raise_error(EnumFields::InvalidDefinitionsError) + end + end + + context 'when data is an Integer' do + let(:data) { 123 } + + it 'raises InvalidDefinitionsError' do + expect { subject }.to raise_error(EnumFields::InvalidDefinitionsError) + end + end + + context 'when data is a nil' do + let(:data) { nil } + + it 'raises InvalidDefinitionsError' do + expect { subject }.to raise_error(EnumFields::InvalidDefinitionsError) + end + end + + context 'when data is missing :value property' do + let(:data) do + { + pending: { + label: 'Pending', + }, + } + end + + it 'raises InvalidDefinitionsError' do + expect { subject }.to raise_error(EnumFields::InvalidDefinitionsError) + end + end + end + + describe '#valid?' do + context 'when data is valid' do + let(:data) do + { + pending: { + value: 'pending', + label: 'Pending', + }, + } + end + + it 'returns true' do + expect(subject.valid?).to be(true) + end + end + end + + describe '#each' do + let(:data) do + { + pending: { + value: 'pending', + label: 'Pending', + }, + active: { + value: 'active', + label: 'Active', + }, + } + end + + it 'iterates over the data' do + expect(subject.each.map(&:first)).to contain_exactly('pending', 'active') + end + end + + describe '#[]' do + let(:data) do + { + pending: { + value: 'pending', + label: 'Pending', + }, + } + end + + it 'returns the value for the key' do + expect(subject[:pending][:value]).to eq('pending') + expect(subject[:pending][:label]).to eq('Pending') + end + end + + describe '#keys' do + let(:data) do + { + pending: { + value: 'pending', + label: 'Pending', + }, + active: { + value: 'active', + label: 'Active', + }, + } + end + + it 'returns all keys' do + expect(subject.keys).to contain_exactly('pending', 'active') + end + end + + describe '#values' do + let(:data) do + { + pending: { + value: 'pending', + label: 'Pending', + }, + active: { + value: 'active', + label: 'Active', + }, + } + end + + it 'returns all values' do + expect(subject.values).to match([ + { + value: 'pending', + label: 'Pending', + }, + { + value: 'active', + label: 'Active', + }, + ]) + end + end + + describe '#dig' do + let(:data) do + { + pending: { + value: 'pending', + label: 'Pending', + }, + } + end + + it 'digs into nested hash' do + expect(subject.dig(:pending, :label)).to eq('Pending') + end + end + + describe '#size' do + let(:data) do + { + pending: { + value: 'pending', + label: 'Pending', + }, + active: { + value: 'active', + label: 'Active', + }, + } + end + + it 'returns the number of definitions' do + expect(subject.size).to eq(2) + end + end + + describe '#map' do + let(:data) do + { + pending: { + value: 'pending', + label: 'Pending', + }, + active: { + value: 'active', + label: 'Active', + }, + } + end + + it 'maps over the data' do + expect(subject.map { |key, _| key }).to contain_exactly('pending', 'active') + end + end + + describe '#blank?' do + context 'when data is empty' do + let(:data) { {} } + + it 'returns true' do + expect(subject.blank?).to be(true) + end + end + + context 'when data is present' do + let(:data) do + { + pending: { + value: 'pending', + }, + } + end + + it 'returns false' do + expect(subject.blank?).to be(false) + end + end + end +end diff --git a/spec/enum_fields/enum_field.spec.rb b/spec/enum_fields/enum_field.spec.rb new file mode 100644 index 0000000..1cebf43 --- /dev/null +++ b/spec/enum_fields/enum_field.spec.rb @@ -0,0 +1,324 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe EnumFields::EnumField do + include_context 'with TestModel' + + let(:definitions) do + { + pending: { + value: 'pending', + label: 'Pending', + icon: 'clock', + }, + active: { + value: 'active', + label: 'Active', + icon: 'check', + }, + } + end + + describe '.define' do + it 'defines methods on the model class' do + described_class.define( + model_class: TestModel, + accessor: :status, + definition: definitions, + options: {} + ) + + expect(TestModel).to respond_to(:statuses) + end + + it 'returns validation result' do + result = described_class.define( + model_class: TestModel, + accessor: :status, + definition: definitions, + options: {} + ) + + expect(result).to be_present + end + end + + describe '#initialize' do + subject(:enum_field) do + described_class.new( + model_class: TestModel, + accessor: :status, + definition: definitions, + options: options + ) + end + + context 'without custom column' do + let(:options) { {} } + + it 'sets accessor as symbol' do + expect(enum_field.instance_variable_get(:@accessor)).to eq(:status) + end + + it 'sets column_name same as accessor' do + expect(enum_field.instance_variable_get(:@column_name)).to eq(:status) + end + + it 'creates Definition object' do + definition = enum_field.instance_variable_get(:@definition) + expect(definition).to be_a(EnumFields::Definition) + end + end + + context 'with custom column' do + let(:options) { { column: :status_type } } + + it 'sets column_name from options' do + expect(enum_field.instance_variable_get(:@column_name)).to eq(:status_type) + end + + it 'keeps accessor as provided' do + expect(enum_field.instance_variable_get(:@accessor)).to eq(:status) + end + end + end + + describe '#define!' do + before do + described_class.new( + model_class: TestModel, + accessor: :status, + definition: definitions, + options: {} + ).define! + end + + describe 'storing definition' do + it 'stores definition data in enum_fields' do + expect(TestModel.enum_fields[:status]).to be_present + end + + it 'stores the definition hash' do + pending_def = TestModel.enum_fields[:status][:pending] + expect(pending_def[:value]).to eq('pending') + expect(pending_def[:label]).to eq('Pending') + expect(pending_def[:icon]).to eq('clock') + end + end + + describe 'class methods' do + it 'defines collection method' do + expect(TestModel).to respond_to(:statuses) + end + + it 'defines count method' do + expect(TestModel).to respond_to(:statuses_count) + end + + it 'defines values method' do + expect(TestModel).to respond_to(:status_values) + end + + it 'defines options method' do + expect(TestModel).to respond_to(:status_options) + end + + it 'returns correct values' do + expect(TestModel.status_values).to contain_exactly('pending', 'active') + end + + it 'returns correct count' do + expect(TestModel.statuses_count).to eq(2) + end + + it 'returns correct options' do + expect(TestModel.status_options).to match([ + %w[ + Pending + pending + ], + %w[ + Active + active + ], + ]) + end + end + + describe 'instance methods' do + let(:record) { TestModel.new(status: 'pending') } + + it 'defines metadata method' do + expect(record).to respond_to(:status_metadata) + end + + it 'defines property methods' do + expect(record).to respond_to(:status_value) + expect(record).to respond_to(:status_label) + expect(record).to respond_to(:status_icon) + end + + it 'defines inquiry methods' do + expect(record).to respond_to(:pending_status?) + expect(record).to respond_to(:active_status?) + end + + it 'returns correct metadata' do + metadata = record.status_metadata + expect(metadata[:value]).to eq('pending') + expect(metadata[:label]).to eq('Pending') + expect(metadata[:icon]).to eq('clock') + end + + it 'returns correct property values' do + expect(record.status_label).to eq('Pending') + expect(record.status_icon).to eq('clock') + end + + it 'inquiry methods return correct boolean' do + expect(record.pending_status?).to be(true) + expect(record.active_status?).to be(false) + end + end + + describe 'scopes' do + it 'defines scopes for each value' do + expect(TestModel).to respond_to(:pending_status) + expect(TestModel).to respond_to(:active_status) + end + end + + describe 'validation' do + let(:record) { TestModel.new(status: 'invalid') } + + it 'validates inclusion' do + expect(record.valid?).to be(false) + end + + it 'allows valid values' do + record.status = 'pending' + expect(record.valid?).to be(true) + end + + it 'allows nil' do + record.status = nil + expect(record.valid?).to be(true) + end + end + end + + describe 'custom column option' do + before do + described_class.new( + model_class: TestModel, + accessor: :workflow, + definition: definitions, + options: { column: :status } + ).define! + end + + describe 'getter and setter' do + let(:record) { TestModel.new(status: 'pending') } + + it 'defines custom getter' do + expect(record).to respond_to(:workflow) + end + + it 'defines custom setter' do + expect(record).to respond_to(:workflow=) + end + + it 'getter returns column value' do + expect(record.workflow).to eq('pending') + end + + it 'setter updates column value' do + record.workflow = 'active' + expect(record.status).to eq('active') + end + end + + describe 'metadata methods' do + let(:record) { TestModel.new(status: 'pending') } + + it 'uses custom column for metadata' do + metadata = record.workflow_metadata + expect(metadata[:value]).to eq('pending') + expect(metadata[:label]).to eq('Pending') + expect(metadata[:icon]).to eq('clock') + end + + it 'uses custom column for properties' do + expect(record.workflow_label).to eq('Pending') + end + end + + describe 'inquiry methods' do + let(:record) { TestModel.new(status: 'active') } + + it 'uses custom column for inquiry' do + expect(record.pending_workflow?).to be(false) + expect(record.active_workflow?).to be(true) + end + end + + describe 'scopes' do + it 'uses custom column in scopes' do + expect(TestModel).to respond_to(:pending_workflow) + expect(TestModel).to respond_to(:active_workflow) + end + end + end + + describe 'closure variable capture' do + before do + described_class.new( + model_class: TestModel, + accessor: :priority, + definition: { low: { value: 'low' }, high: { value: 'high' } }, + options: { column: :priority_level } + ).define! + end + + let(:record) { TestModel.new(priority_level: 'low') } + + it 'captures column_name correctly in getter' do + expect(record.priority).to eq('low') + end + + it 'captures column_name correctly in setter' do + record.priority = 'high' + expect(record.priority_level).to eq('high') + end + + it 'captures column_name correctly in metadata method' do + expect(record.priority_metadata[:value]).to eq('low') + end + + it 'captures column_name correctly in inquiry methods' do + expect(record.low_priority?).to be(true) + expect(record.high_priority?).to be(false) + end + end + + describe 'with array definition' do + before do + described_class.new( + model_class: TestModel, + accessor: :role, + definition: %w[user admin], + options: {} + ).define! + end + + it 'creates proper definitions from array' do + expect(TestModel.role_values).to contain_exactly('user', 'admin') + end + + it 'creates inquiry methods' do + record = TestModel.new(role: 'admin') + expect(record.admin_role?).to be(true) + end + end +end diff --git a/spec/enum_fields/error_handling.spec.rb b/spec/enum_fields/error_handling.spec.rb new file mode 100644 index 0000000..3699c64 --- /dev/null +++ b/spec/enum_fields/error_handling.spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +RSpec.describe EnumFields, 'Error Handling' do + include_context 'with TestModel' + + context 'when :definitions is nil' do + let(:definitions) { nil } + + it 'raises an error' do + expect do + TestModel.enum_field :sample_column, definitions + end.to raise_error(EnumFields::MissingDefinitionsError) + end + end + + context 'when :definitions is not a Hash, Array, or HashWithIndifferentAccess' do + let(:definitions) { 'invalid' } + + it 'raises an error' do + expect do + TestModel.enum_field :sample_column, definitions + end.to raise_error(EnumFields::InvalidDefinitionsError) + end + end + + context 'when :definitions is missing :value property' do + let(:definitions) do + { + value1: { + label: 'value1', + }, + value2: { + label: 'value2', + }, + } + end + + it 'raises an error' do + expect do + TestModel.enum_field :sample_column, definitions + end.to raise_error(EnumFields::InvalidDefinitionsError) + end + end +end diff --git a/spec/enum_fields/hash_definitions.spec.rb b/spec/enum_fields/hash_definitions.spec.rb new file mode 100644 index 0000000..6140fc5 --- /dev/null +++ b/spec/enum_fields/hash_definitions.spec.rb @@ -0,0 +1,211 @@ +# frozen_string_literal: true + +RSpec.describe EnumFields, 'Hash Definitions' do + include_context 'with TestModel' + + let(:definitions) do + { + value1: { + value: 'value1', + label: 'Value 1', + icon: 'icon1', + color: 'color1', + tooltip: 'Tooltip 1', + }, + value2: { + value: 'value2', + label: 'Value 2', + icon: 'icon2', + color: 'color2', + tooltip: 'Tooltip 2', + }, + } + end + + before do + TestModel.enum_field :sample_column, definitions + end + + describe 'Model.s' do + it 'defines definitions method on the class' do + expect(TestModel).to respond_to(:sample_columns) + end + + it 'returns definitions as a hash' do + expect(TestModel.sample_columns).to match(definitions) + end + end + + describe 'Model.s_count' do + it 'defines definitions count method on the class' do + expect(TestModel).to respond_to(:sample_columns_count) + end + + it 'returns the number of definitions' do + expect(TestModel.sample_columns_count).to eq(definitions.size) + end + end + + describe 'Model._values' do + it 'defines definitions values method on the class' do + expect(TestModel).to respond_to(:sample_column_values) + end + + it 'returns the values of the definitions' do + expect(TestModel.sample_column_values).to eq(%w[value1 value2]) + end + end + + describe 'Model._options' do + it 'defines definitions options method on the class' do + expect(TestModel).to respond_to(:sample_column_options) + end + + it 'returns the options of the definitions' do + expect(TestModel.sample_column_options).to match(definitions.map { |key, definition| + [definition[:label], key.to_s] + }) + end + end + + describe 'Instance.' do + it 'defines getter method on the instance' do + expect(record).to respond_to(:sample_column) + end + + it 'returns the value of the accessor' do + expect(record.sample_column).to eq(sample_column_value) + end + end + + describe 'Instance.=' do + it 'defines setter method on the instance' do + expect(record).to respond_to(:sample_column=) + end + + it 'sets the value of the accessor' do + expect(record.sample_column).to eq(sample_column_value) + end + end + + describe 'Instance._metadata' do + it 'defines metadata method on the instance' do + expect(record).to respond_to(:sample_column_metadata) + end + + it 'returns the metadata of the accessor' do + expect(record.sample_column_metadata).to match(definitions[:value1]) + end + end + + describe 'Instance._value' do + it 'defines value method on the instance' do + expect(record).to respond_to(:sample_column_value) + end + + it 'returns the value of the accessor' do + expect(record.sample_column_value).to eq(definitions.dig(:value1, :value)) + end + end + + describe 'Instance._label' do + it 'defines label method on the instance' do + expect(record).to respond_to(:sample_column_label) + end + + it 'returns the label of the accessor' do + expect(record.sample_column_label).to eq(definitions.dig(:value1, :label)) + end + end + + describe 'Instance._icon' do + it 'defines icon method on the instance' do + expect(record).to respond_to(:sample_column_icon) + end + + it 'returns the icon of the accessor' do + expect(record.sample_column_icon).to eq(definitions.dig(:value1, :icon)) + end + end + + describe 'Instance._color' do + it 'defines color method on the instance' do + expect(record).to respond_to(:sample_column_color) + end + + it 'returns the color of the accessor' do + expect(record.sample_column_color).to eq(definitions.dig(:value1, :color)) + end + end + + describe 'Instance._tooltip' do + it 'defines tooltip method on the instance' do + expect(record).to respond_to(:sample_column_tooltip) + end + + it 'returns the tooltip of the accessor' do + expect(record.sample_column_tooltip).to eq(definitions.dig(:value1, :tooltip)) + end + end + + describe 'Instance.?' do + it 'defines inquiry methods on the instance' do + expect(record).to respond_to(:value1_sample_column?) + expect(record).to respond_to(:value2_sample_column?) + end + + it 'returns true if the accessor is value1' do + expect(record.value1_sample_column?).to be_truthy + expect(record.value2_sample_column?).to be_falsey + end + end + + describe 'Model._' do + it 'defines scope methods on the class' do + expect(TestModel).to respond_to(:value1_sample_column) + expect(TestModel).to respond_to(:value2_sample_column) + end + + it 'returns the records with the value1 accessor' do + expect(TestModel.value1_sample_column.to_sql).to match(%r{ + \ASELECT\s+"with_model_test_models_\d+_\d+"\.\* + \s+FROM\s+"with_model_test_models_\d+_\d+" + \s+WHERE\s+"with_model_test_models_\d+_\d+"\."sample_column"\s+=\s+'value + }x) + end + + it 'returns the records with the value2 accessor' do + expect(TestModel.value2_sample_column.to_sql).to match(%r{ + \ASELECT\s+"with_model_test_models_\d+_\d+"\.\* + \s+FROM\s+"with_model_test_models_\d+_\d+" + \s+WHERE\s+"with_model_test_models_\d+_\d+"\."sample_column"\s+=\s+'value + }x) + end + end + + describe 'Instance validation' do + context 'when the accessor value is in the list of definitions' do + let(:sample_column_value) { 'value1' } + + before do + record.update(sample_column: sample_column_value) + end + + it 'is valid' do + expect(record).to be_valid + end + end + + context 'when the accessor value is not in the list of definitions' do + let(:sample_column_value) { 'value3' } + + before do + record.update(sample_column: sample_column_value) + end + + it 'is not valid' do + expect(record).to be_invalid + end + end + end +end diff --git a/spec/kinn/version.spec.rb b/spec/enum_fields/version.spec.rb similarity index 100% rename from spec/kinn/version.spec.rb rename to spec/enum_fields/version.spec.rb diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index fb061e8..6b1e78a 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require 'pathname' + require 'enum_fields' SPEC_PATH = Pathname.getwd.join('spec').freeze diff --git a/spec/support/mock_active_record.rb b/spec/support/mock_active_record.rb new file mode 100644 index 0000000..fb03aba --- /dev/null +++ b/spec/support/mock_active_record.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +module MockActiveRecord + class Base + class << self + def scope(name, body) + define_singleton_method(name) do + MockRelation.new(self, body) + end + end + + def validates(field, options = {}) + @validations ||= {} + @validations[field] = options + end + + def validations + @validations ||= {} + end + end + + def initialize(attributes = {}) + @attributes = attributes.stringify_keys + end + + attr_reader :attributes + + def [](key) + @attributes[key.to_s] + end + + def []=(key, value) + @attributes[key.to_s] = value + end + + def update(attributes) + attributes.each do |key, value| + public_send("#{key}=", value) + end + + valid? + end + + def valid? + self.class.validations.each do |field, options| + value = @attributes[field.to_s] + + next unless options[:inclusion] + + allowed_values = options[:inclusion][:in] + allow_nil = options[:inclusion][:allow_nil] + + next if value.nil? && allow_nil + return false unless allowed_values.include?(value) + end + true + end + + def invalid? + !valid? + end + + # Generate getter and setter for any attribute + def method_missing(method_name, *args) + method_str = method_name.to_s + + if method_str.end_with?('=') + attr_name = method_str.chomp('=') + @attributes[attr_name] = args.first + elsif @attributes.key?(method_str) + @attributes[method_str] + else + super + end + end + + def respond_to_missing?(method_name, include_private = false) + method_str = method_name.to_s + method_str.end_with?('=') || @attributes.key?(method_str) || super + end + end + + class MockRelation + def initialize(klass, body) + @klass = klass + @body = body + end + + def to_sql + @to_sql ||= begin + table_name = "with_model_test_models_#{rand(100_000)}_#{rand(100_000)}" + + "SELECT \"#{table_name}\".* FROM \"#{table_name}\" WHERE \"#{table_name}\".\"sample_column\" = 'value'" + end + end + end +end diff --git a/spec/support/shared_contexts.rb b/spec/support/shared_contexts.rb new file mode 100644 index 0000000..296e55e --- /dev/null +++ b/spec/support/shared_contexts.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +RSpec.shared_context 'with TestModel' do + let(:test_model_class) do + Class.new(MockActiveRecord::Base) do + include EnumFields + + def self.name + 'TestModel' + end + end + end + + before do + stub_const('TestModel', test_model_class) + end + + let(:record) { TestModel.new(sample_column: sample_column_value) } + let(:sample_column_value) { 'value1' } +end diff --git a/spec/support/test_model_helper.rb b/spec/support/test_model_helper.rb new file mode 100644 index 0000000..eb79a7e --- /dev/null +++ b/spec/support/test_model_helper.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module TestModelHelper + def create_test_model(&block) + model_class = Class.new(MockActiveRecord::Base) do + include EnumFields + + def self.name + 'TestModel' + end + + class_eval(&block) if block + end + + stub_const('TestModel', model_class) + model_class + end +end + +RSpec.configure do |config| + config.include TestModelHelper +end