Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

F2: Feed model scopes and helpers #599

Merged
merged 9 commits into from
Nov 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading