Skip to content

Commit

Permalink
Extend Vets::Model with secondary Common::Base functionality (#19828)
Browse files Browse the repository at this point in the history
* add comment

* Add filterable to Vets::Model (#19869)

* add filterable option to vets::attributes

* minor refactoring and add filterable_params

* linting

* remove class instance variables

* Add pagination info to Vets::Model (#19876)

* add pagination info to vets::model

* actually include the module

* refactor tests

* load pagination on vets::model

* convert pagination mixin to concern

* fix broken spec

* Add sortability to Vets::Model (#19827)

* add sortability to vets::model

* linting

* fix loading file

* treat sortable like a concern

* Add dirty module to Vets::Model (#19884)

* add dirty to vets::model

* model cleanup

* linting

* convert dirty mixin to concern

* update initializer

* linting
  • Loading branch information
stevenjcumming authored Dec 23, 2024
1 parent 01d25d0 commit 21f9a23
Show file tree
Hide file tree
Showing 9 changed files with 465 additions and 8 deletions.
18 changes: 17 additions & 1 deletion lib/vets/attributes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@ def attributes
def attribute(name, klass, **options)
default = options[:default]
array = options[:array] || false
filterable = options[:filterable] || false

attributes[name] = { type: klass, default:, array: }
attributes[name] = { type: klass, default:, array:, filterable: }

define_getter(name, default)
define_setter(name, klass, array)
Expand All @@ -28,6 +29,21 @@ def attribute_set
ancestors.select { |klass| klass.respond_to?(:attributes) }.flat_map { |klass| klass.attributes.keys }.uniq
end

# Lists the attributes that are filterable
def filterable_attributes
attributes.select { |_, options| options[:filterable] }.keys
end

# Creates a param hash for filterable
def filterable_params
attributes.each_with_object({}) do |attribute, hash|
name = attribute.first
options = attribute.second

hash[name.to_s] = options[:filterable] if options[:filterable]
end.with_indifferent_access
end

private

def define_getter(name, default)
Expand Down
7 changes: 7 additions & 0 deletions lib/vets/model.rb
Original file line number Diff line number Diff line change
@@ -1,18 +1,25 @@
# frozen_string_literal: true

require 'vets/attributes'
require 'vets/model/dirty'
require 'vets/model/sortable'
require 'vets/model/pagination'

# This will be moved after virtus is removed
module Bool; end
class TrueClass; include Bool; end
class FalseClass; include Bool; end

# This will be a replacement for Common::Base
module Vets
module Model
extend ActiveSupport::Concern
include ActiveModel::Model
include ActiveModel::Serializers::JSON
include Vets::Attributes
include Vets::Model::Dirty
include Vets::Model::Sortable
include Vets::Model::Pagination

included do
extend ActiveModel::Naming
Expand Down
36 changes: 36 additions & 0 deletions lib/vets/model/dirty.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# frozen_string_literal: true

# Intended to only be used with Vets::Model
# inspired by ActiveModel::Dirty

module Vets
module Model
module Dirty
extend ActiveSupport::Concern

included do
attr_reader :original_attributes
end

def initialize(*, **)
super(*, **) if defined?(super)
@original_attributes = attribute_values.dup
end

def changed?
changes.any?
end

def changed
changes.keys
end

def changes
attribute_values.each_with_object({}) do |(key, current_value), result|
original_value = @original_attributes[key]
result[key] = [original_value, current_value] if original_value != current_value
end
end
end
end
end
52 changes: 52 additions & 0 deletions lib/vets/model/pagination.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# frozen_string_literal: true

#
# Pagination allows Vets::Model models to set pagination info
# for that model class.
#
# class User
# include Vets::Model
#
# attr_accessor :name, :age
#
# set_pagination per_page: 21, max_per_page: 41
#
# ...
# end
#
# User.per_page
# => 21
#
# User.max_per_page
# => 41
#

module Vets
module Model
module Pagination
extend ActiveSupport::Concern

DEFAULT_PER_PAGE = 10
DEFAULT_MAX_PER_PAGE = 100

class_methods do
# rubocop:disable ThreadSafety/ClassInstanceVariable
def set_pagination(per_page:, max_per_page:)
@per_page = per_page
@max_per_page = max_per_page
end
private :set_pagination

# Provide default values if set_pagination has not been called
def per_page
@per_page || DEFAULT_PER_PAGE
end

def max_per_page
@max_per_page || DEFAULT_MAX_PER_PAGE
end
# rubocop:enable ThreadSafety/ClassInstanceVariable
end
end
end
end
69 changes: 69 additions & 0 deletions lib/vets/model/sortable.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# frozen_string_literal: true

#
# Sortable allows Vets::Model models to specify a default sort attribute and direction
# for use with `#sort`.
#
# class User
# include Vets::Model
#
# attr_accessor :name, :age
#
# default_sort_by name: :asc
#
# ...
# end
#
# [user1, user3, user4, user2].sort
#=> [user1, user2, user3, user4]
#

module Vets
module Model
module Sortable
include Comparable
extend ActiveSupport::Concern

class_methods do
# sets the default sorting criteria
# required for use with Array#sort
# rubocop:disable ThreadSafety/ClassInstanceVariable
def default_sort_by(sort_criteria)
if sort_criteria.size != 1
raise ArgumentError, 'Only one attribute and direction can be provided in default_sort_by'
end

_, direction = sort_criteria.first
raise ArgumentError, 'Direction must be either :asc or :desc' unless %i[asc desc].include?(direction)

@default_sort_criteria = sort_criteria
end

def default_sort_criteria
@default_sort_criteria ||= {}
end
# rubocop:enable ThreadSafety/ClassInstanceVariable
end

def <=>(other)
return 0 unless self.class.default_sort_criteria.any?

attribute = self.class.default_sort_criteria.keys.first
direction = self.class.default_sort_criteria[attribute] || :asc

# Validate if the attribute value is comparable
raise ArgumentError, "Attribute '#{attribute}' is not comparable." unless comparable?(attribute)

comparison_result = public_send(attribute) <=> other.public_send(attribute)
direction == :desc ? -comparison_result : comparison_result
end

private

def comparable?(attribute)
value = public_send(attribute)
value.is_a?(Comparable)
end
end
end
end
30 changes: 23 additions & 7 deletions spec/lib/vets/attributes_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,10 @@ class DummyModel < DummyParentModel
include Vets::Attributes

attribute :name, String, default: 'Unknown'
attribute :age, Integer, array: false
attribute :age, Integer, array: false, filterable: %w[eq lteq gteq]
attribute :tags, String, array: true
attribute :categories, FakeCategory, array: true
attribute :created_at, DateTime, default: :current_time
attribute :created_at, DateTime, default: :current_time, filterable: %w[eq not_eq]

def current_time
DateTime.new(2024, 9, 25, 10, 30, 0)
Expand Down Expand Up @@ -62,11 +62,11 @@ def current_time
describe '.attributes' do
it 'returns a hash of the attribute definitions' do
expected_attributes = {
name: { type: String, default: 'Unknown', array: false },
age: { type: Integer, default: nil, array: false },
tags: { type: String, default: nil, array: true },
categories: { type: FakeCategory, default: nil, array: true },
created_at: { type: DateTime, default: :current_time, array: false }
name: { type: String, default: 'Unknown', array: false, filterable: false },
age: { type: Integer, default: nil, array: false, filterable: %w[eq lteq gteq] },
tags: { type: String, default: nil, array: true, filterable: false },
categories: { type: FakeCategory, default: nil, array: true, filterable: false },
created_at: { type: DateTime, default: :current_time, array: false, filterable: %w[eq not_eq] }
}
expect(DummyModel.attributes).to eq(expected_attributes)
end
Expand All @@ -82,4 +82,20 @@ def current_time
expect(DummyModel.attribute_set).to include(:updated_at)
end
end

describe '.filterable_attributes' do
it 'returns an of the attribute with the filterable option' do
expect(DummyModel.filterable_attributes).to eq(%i[age created_at])
end
end

describe '.filterable_params' do
it 'returns a hash of the attribute with the filterable option for param filter' do
filterable_params = {
'age' => %w[eq lteq gteq],
'created_at' => %w[eq not_eq]
}
expect(DummyModel.filterable_params).to eq(filterable_params)
end
end
end
79 changes: 79 additions & 0 deletions spec/lib/vets/model/dirty_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# frozen_string_literal: true

require 'rails_helper'
require 'vets/model'
require 'vets/model/dirty'

RSpec.describe Vets::Model::Dirty do
let(:user_class) do
Class.new do
include Vets::Model

attr_accessor :name, :email

def self.attribute_set
%i[name email]
end
end
end

let(:user) { user_class.new(name: 'Alice', email: '[email protected]') }

describe '#changed?' do
it 'returns false when no changes have been made' do
expect(user.changed?).to eq(false)
end

it 'returns true when an attribute has been changed' do
user.name = 'Bob'
expect(user.changed?).to eq(true)
end

it 'returns false when changes are reverted back to original values' do
user.name = 'Bob'
user.name = 'Alice'
expect(user.changed?).to eq(false)
end
end

describe '#changed' do
it 'returns an empty array when no changes have been made' do
expect(user.changed).to eq([])
end

it 'returns a list of changed attributes when changes have been made' do
user.name = 'Bob'
expect(user.changed).to eq(['name'])
end

it 'returns a list of all changed attributes after multiple changes' do
user.name = 'Bob'
user.email = '[email protected]'
expect(user.changed).to match_array(%w[name email])
end
end

describe '#changes' do
it 'returns an empty hash when no changes have been made' do
expect(user.changes).to eq({})
end

it 'returns the changes with the original and current values when an attribute has been changed' do
user.name = 'Bob'
expect(user.changes).to eq({ 'name' => %w[Alice Bob] })
end

it 'returns changes for multiple attributes' do
user.name = 'Bob'
user.email = '[email protected]'
expect(user.changes).to include('name' => %w[Alice Bob])
expect(user.changes).to include('email' => ['[email protected]', '[email protected]'])
end

it 'returns an empty hash if changes are reverted' do
user.name = 'Bob'
user.name = 'Alice'
expect(user.changes).to eq({})
end
end
end
Loading

0 comments on commit 21f9a23

Please sign in to comment.