Skip to content

Commit

Permalink
F2: Feed model scopes and helpers (#599)
Browse files Browse the repository at this point in the history
* Dry exceptions handling

* Test coverage

* Clean up

* Rename constant

* `Feed.stale` scope

* `Feed.ordered_by` scope

* Formatting

* Implement `Feed.stale?`

* Bump Ruby from 3.3.5 to 3.3.6
  • Loading branch information
dreikanter authored Nov 16, 2024
1 parent e173af9 commit 97c38a0
Show file tree
Hide file tree
Showing 9 changed files with 108 additions and 17 deletions.
2 changes: 1 addition & 1 deletion .ruby-version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
ruby-3.3.5
ruby-3.3.6
2 changes: 1 addition & 1 deletion .tool-versions
Original file line number Diff line number Diff line change
@@ -1 +1 @@
ruby 3.3.5
ruby 3.3.6
2 changes: 1 addition & 1 deletion Gemfile
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
source "https://rubygems.org"

ruby "3.3.5"
ruby "3.3.6"

gem "aasm", "~> 5.5"
gem "amazing_print"
Expand Down
2 changes: 1 addition & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -438,7 +438,7 @@ DEPENDENCIES
yaml-lint (~> 0.1.2)

RUBY VERSION
ruby 3.3.5p100
ruby 3.3.6p108

BUNDLED WITH
2.5.16
38 changes: 28 additions & 10 deletions app/models/feed.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
class Feed < ApplicationRecord
include AASM

MAX_LIMIT_LIMIT = 100
MAX_IMPORT_LIMIT = 100
IMPORT_LIMIT_RANGE = 0..(86400 * 7)
NAME_LENGTH_RANGE = 3..80
MAX_URL_LENGTH = 4096
Expand All @@ -14,7 +14,7 @@ class Feed < ApplicationRecord
validates :name, presence: true, length: NAME_LENGTH_RANGE, format: /\A[\w\-]+\z/
normalizes :name, with: ->(name) { name.to_s.strip.downcase }

validates :import_limit, numericality: {less_than_or_equal_to: MAX_LIMIT_LIMIT}
validates :import_limit, numericality: {less_than_or_equal_to: MAX_IMPORT_LIMIT}
validates :refresh_interval, presence: true, numericality: {greater_than_or_equal_to: 0}
validates :loader, :normalizer, :processor, presence: true, format: /\A\w+\z/
validates :url, length: {maximum: MAX_URL_LENGTH}, allow_nil: true
Expand Down Expand Up @@ -46,17 +46,33 @@ class Feed < ApplicationRecord
end
end

scope :ordered_by, ->(attribute, direction) { order(sanitize_sql_for_order("#{attribute} #{direction} NULLS LAST")) }

scope :stale, lambda {
where(refresh_interval: 0)
.or(where(refreshed_at: nil))
.or(where("age(now(), refreshed_at) > make_interval(secs => refresh_interval)"))
}

def configurable?
updated_at.blank? || configured_at.blank? || updated_at.change(usec: 0) <= configured_at.change(usec: 0)
end

# @return [true, false] true when the feed needs a refresh
def stale?
refresh_interval.zero? || refreshed_at.blank? || time_to_refresh?
end

def reference
[self.class.name.underscore, id, name].compact_blank.join("-")
end

def ensure_supported
return true if loader_class && processor_class && normalizer_class
raise FeedConfigurationError
if loader_class && processor_class && normalizer_class
true
else
raise FeedConfigurationError
end
end

def service_classes
Expand All @@ -69,8 +85,6 @@ def service_classes

def loader_class
ClassResolver.new(loader, suffix: "loader").resolve
rescue NameError
nil
end

def loader_instance
Expand All @@ -79,8 +93,6 @@ def loader_instance

def processor_class
ClassResolver.new(processor, suffix: "processor").resolve
rescue NameError
nil
end

def processor_instance
Expand All @@ -89,13 +101,19 @@ def processor_instance

def normalizer_class
ClassResolver.new(normalizer, suffix: "normalizer").resolve
rescue NameError
nil
end

private

def options_must_be_hash
errors.add(:options, :not_a_hash, message: "must be a hash") unless options.is_a?(Hash)
end

def time_to_refresh?
seconds_since_last_refresh > refresh_interval
end

def seconds_since_last_refresh
(Time.now.utc.to_i - refreshed_at.to_i).abs
end
end
2 changes: 2 additions & 0 deletions app/services/class_resolver.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,7 @@ def initialize(class_name, suffix: nil)
# @raise [NameError] if the target class is missing
def resolve
[class_name, suffix].join("_").classify.constantize
rescue NameError
nil
end
end
1 change: 0 additions & 1 deletion app/services/feed_processor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ def initialize(feeds:)
@feeds = feeds
end

# TBD: This should receive "stale enabled" feeds; test Feed model scopes
def perform
feeds.each do |feed|
Importer.new(feed).import
Expand Down
74 changes: 73 additions & 1 deletion spec/models/feed_spec.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
RSpec.describe Feed do
before { freeze_time }

describe "relations" do
subject(:feed) { build(:feed) }

Expand Down Expand Up @@ -42,7 +44,7 @@

describe "#import_limit" do
it "validates numericality" do
expect(feed).to validate_numericality_of(:import_limit).is_less_than_or_equal_to(Feed::MAX_LIMIT_LIMIT)
expect(feed).to validate_numericality_of(:import_limit).is_less_than_or_equal_to(Feed::MAX_IMPORT_LIMIT)
end
end

Expand Down Expand Up @@ -128,6 +130,58 @@
end
end

describe ".ordered_by" do
it "orders records by the specified attribute and direction" do
create(:feed, name: "bbb", refreshed_at: 2.days.ago)
create(:feed, name: "aaa", refreshed_at: 1.day.ago)
feeds = described_class.ordered_by("name", "ASC")

expect(feeds.map(&:name)).to eq(%w[aaa bbb])
end

it "puts null values last when descending" do
create(:feed, name: "bbb", refreshed_at: nil)
create(:feed, name: "aaa", refreshed_at: 1.day.ago)
feeds = described_class.ordered_by("refreshed_at", "DESC")

expect(feeds.map(&:name)).to eq(%w[aaa bbb])
end

it "puts null values last when ascending" do
create(:feed, name: "bbb", refreshed_at: nil)
create(:feed, name: "aaa", refreshed_at: 1.day.ago)
feeds = described_class.ordered_by("refreshed_at", "ASC")

expect(feeds.map(&:name)).to eq(%w[aaa bbb])
end
end

describe ".stale" do
it "includes feeds with zero refresh_interval" do
feed = create(:feed, refresh_interval: 0, refreshed_at: 1.minute.ago)

expect(described_class.stale).to eq([feed])
end

it "includes feeds with nil refreshed_at" do
feed = create(:feed, refresh_interval: 1.hour.to_i, refreshed_at: nil)

expect(described_class.stale).to eq([feed])
end

it "includes feeds that are past their refresh interval" do
feed = create(:feed, refresh_interval: 1.hour.to_i, refreshed_at: 2.hours.ago)

expect(described_class.stale).to eq([feed])
end

it "excludes fresh feeds" do
create(:feed, refresh_interval: 1.hour.to_i, refreshed_at: 30.minutes.ago)

expect(described_class.stale).to be_empty
end
end

describe "#configurable?" do
let(:arbitrary_time) { Time.current }

Expand All @@ -152,6 +206,24 @@
end
end

describe "#stale?" do
context "when refresh_interval is zero" do
it { expect(build(:feed, refresh_interval: 0)).to be_stale }
end

context "when refreshed_at is nil" do
it { expect(build(:feed, refresh_interval: 1.hour.to_i, refreshed_at: nil)).to be_stale }
end

context "when past refresh interval" do
it { expect(build(:feed, refresh_interval: 1.hour.to_i, refreshed_at: 2.hours.ago)).to be_stale }
end

context "when within refresh interval" do
it { expect(build(:feed, refresh_interval: 1.hour.to_i, refreshed_at: 30.minutes.ago)).not_to be_stale }
end
end

describe "#reference" do
it "returns expected value" do
actual = build(:feed, id: 1, name: "sample").reference
Expand Down
2 changes: 1 addition & 1 deletion spec/services/class_resolver_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@

it "raises a NameError for missing class" do
resolver = described_class.new("non_existent")
expect { resolver.resolve }.to raise_error(NameError)
expect(resolver.resolve).to be_nil
end
end
end

0 comments on commit 97c38a0

Please sign in to comment.