Commit 38b8ae64 authored by Yorick Peterse's avatar Yorick Peterse

Clean up ActiveRecord code in TodoService

This refactors the TodoService class according to our code reuse
guidelines. The resulting code is a wee bit more verbose, but it allows
us to decouple the column names from the input, resulting in fewer
changes being necessary when we change the schema.

One particular noteworthy line in TodoService is the following:

    todos_ids = todos.update_state(state)

Technically this is a violation of the guidelines, because
`update_state` is a class method, which services are not supposed to use
(safe for a few allowed ones). I decided to keep this, since there is no
alternative. `update_state` doesn't produce a relation so it doesn't
belong in a Finder, and we can't move it to another Service either. As
such I opted to just use the method directly.

Cases like this may happen more frequently, at which point we should
update our documentation with some sort of recommendation. For now, I
want to refrain from doing so until we have a few more examples.
parent 4c1dc310
# frozen_string_literal: true
# Finder for retrieving the pending todos of a user, optionally filtered using
# various fields.
#
# While this finder is a bit more verbose compared to use
# `where(params.slice(...))`, it allows us to decouple the input parameters from
# the actual column names. For example, if we ever decide to use separate
# columns for target types (e.g. `issue_id`, `merge_request_id`, etc), we no
# longer need to change _everything_ that uses this finder. Instead, we just
# change the various `by_*` methods in this finder, without having to touch
# everything that uses it.
class PendingTodosFinder
attr_reader :current_user, :params
# current_user - The user to retrieve the todos for.
# params - A Hash containing columns and values to use for filtering todos.
def initialize(current_user, params = {})
@current_user = current_user
@params = params
end
def execute
todos = current_user.todos.pending
todos = by_project(todos)
todos = by_target_id(todos)
todos = by_target_type(todos)
todos = by_commit_id(todos)
todos
end
def by_project(todos)
if (id = params[:project_id])
todos.for_project(id)
else
todos
end
end
def by_target_id(todos)
if (id = params[:target_id])
todos.for_target(id)
else
todos
end
end
def by_target_type(todos)
if (type = params[:target_type])
todos.for_type(type)
else
todos
end
end
def by_commit_id(todos)
if (id = params[:commit_id])
todos.for_commit(id)
else
todos
end
end
end
...@@ -47,6 +47,13 @@ class TodosFinder ...@@ -47,6 +47,13 @@ class TodosFinder
sort(items) sort(items)
end end
# Returns `true` if the current user has any todos for the given target.
#
# target - The value of the `target_type` column, such as `Issue`.
def any_for_target?(target)
current_user.todos.any_for_target?(target)
end
private private
def action_id? def action_id?
......
# frozen_string_literal: true
# Finder that given a target (e.g. an issue) finds all the users that have
# pending todos for said target.
class UsersWithPendingTodosFinder
attr_reader :target
# target - The target, such as an Issue or MergeRequest.
def initialize(target)
@target = target
end
def execute
User.for_todos(target.todos.pending)
end
end
...@@ -45,6 +45,8 @@ class Todo < ActiveRecord::Base ...@@ -45,6 +45,8 @@ class Todo < ActiveRecord::Base
scope :for_project, -> (project) { where(project: project) } scope :for_project, -> (project) { where(project: project) }
scope :for_group, -> (group) { where(group: group) } scope :for_group, -> (group) { where(group: group) }
scope :for_type, -> (type) { where(target_type: type) } scope :for_type, -> (type) { where(target_type: type) }
scope :for_target, -> (id) { where(target_id: id) }
scope :for_commit, -> (id) { where(commit_id: id) }
state_machine :state, initial: :pending do state_machine :state, initial: :pending do
event :done do event :done do
...@@ -72,6 +74,28 @@ class Todo < ActiveRecord::Base ...@@ -72,6 +74,28 @@ class Todo < ActiveRecord::Base
]) ])
end end
# Returns `true` if the current user has any todos for the given target.
#
# target - The value of the `target_type` column, such as `Issue`.
def any_for_target?(target)
exists?(target: target)
end
# Updates the state of a relation of todos to the new state.
#
# new_state - The new state of the todos.
#
# Returns an `Array` containing the IDs of the updated todos.
def update_state(new_state)
# Only update those that are not really on that state
base = where.not(state: new_state).except(:order)
ids = base.pluck(:id)
base.update_all(state: new_state)
ids
end
# Priority sorting isn't displayed in the dropdown, because we don't show # Priority sorting isn't displayed in the dropdown, because we don't show
# milestones, but still show something if the user has a URL with that # milestones, but still show something if the user has a URL with that
# selected. # selected.
......
...@@ -265,6 +265,7 @@ class User < ActiveRecord::Base ...@@ -265,6 +265,7 @@ class User < ActiveRecord::Base
scope :order_oldest_sign_in, -> { reorder(Gitlab::Database.nulls_last_order('current_sign_in_at', 'ASC')) } scope :order_oldest_sign_in, -> { reorder(Gitlab::Database.nulls_last_order('current_sign_in_at', 'ASC')) }
scope :confirmed, -> { where.not(confirmed_at: nil) } scope :confirmed, -> { where.not(confirmed_at: nil) }
scope :by_username, -> (usernames) { iwhere(username: usernames) } scope :by_username, -> (usernames) { iwhere(username: usernames) }
scope :for_todos, -> (todos) { where(id: todos.select(:user_id)) }
# Limits the users to those that have TODOs, optionally in the given state. # Limits the users to those that have TODOs, optionally in the given state.
# #
...@@ -1366,6 +1367,10 @@ class User < ActiveRecord::Base ...@@ -1366,6 +1367,10 @@ class User < ActiveRecord::Base
!consented_usage_stats? && 7.days.ago > self.created_at && !has_current_license? && User.single_user? !consented_usage_stats? && 7.days.ago > self.created_at && !has_current_license? && User.single_user?
end end
def todos_limited_to(ids)
todos.where(id: ids)
end
# @deprecated # @deprecated
alias_method :owned_or_masters_groups, :owned_or_maintainers_groups alias_method :owned_or_masters_groups, :owned_or_maintainers_groups
......
...@@ -41,15 +41,13 @@ class TodoService ...@@ -41,15 +41,13 @@ class TodoService
# collects the todo users before the todos themselves are deleted, then # collects the todo users before the todos themselves are deleted, then
# updates the todo counts for those users. # updates the todo counts for those users.
# #
# rubocop: disable CodeReuse/ActiveRecord
def destroy_target(target) def destroy_target(target)
todo_users = User.where(id: target.todos.pending.select(:user_id)).to_a todo_users = UsersWithPendingTodosFinder.new(target).execute.to_a
yield target yield target
todo_users.each(&:update_todos_count_cache) todo_users.each(&:update_todos_count_cache)
end end
# rubocop: enable CodeReuse/ActiveRecord
# When we reassign an issue we should: # When we reassign an issue we should:
# #
...@@ -200,30 +198,23 @@ class TodoService ...@@ -200,30 +198,23 @@ class TodoService
create_todos(current_user, attributes) create_todos(current_user, attributes)
end end
# rubocop: disable CodeReuse/ActiveRecord
def todo_exist?(issuable, current_user) def todo_exist?(issuable, current_user)
TodosFinder.new(current_user).execute.exists?(target: issuable) TodosFinder.new(current_user).any_for_target?(issuable)
end end
# rubocop: enable CodeReuse/ActiveRecord
private private
# rubocop: disable CodeReuse/ActiveRecord
def todos_by_ids(ids, current_user) def todos_by_ids(ids, current_user)
current_user.todos.where(id: Array(ids)) current_user.todos_limited_to(Array(ids))
end end
# rubocop: enable CodeReuse/ActiveRecord
# rubocop: disable CodeReuse/ActiveRecord
def update_todos_state(todos, current_user, state) def update_todos_state(todos, current_user, state)
# Only update those that are not really on that state todos_ids = todos.update_state(state)
todos = todos.where.not(state: state)
todos_ids = todos.pluck(:id)
todos.unscope(:order).update_all(state: state)
current_user.update_todos_count_cache current_user.update_todos_count_cache
todos_ids todos_ids
end end
# rubocop: enable CodeReuse/ActiveRecord
def create_todos(users, attributes) def create_todos(users, attributes)
Array(users).map do |user| Array(users).map do |user|
...@@ -348,10 +339,7 @@ class TodoService ...@@ -348,10 +339,7 @@ class TodoService
end end
end end
# rubocop: disable CodeReuse/ActiveRecord
def pending_todos(user, criteria = {}) def pending_todos(user, criteria = {})
valid_keys = [:project_id, :target_id, :target_type, :commit_id] PendingTodosFinder.new(user, criteria).execute
user.todos.pending.where(criteria.slice(*valid_keys))
end end
# rubocop: enable CodeReuse/ActiveRecord
end end
# frozen_string_literal: true
require 'spec_helper'
describe PendingTodosFinder do
let(:user) { create(:user) }
describe '#execute' do
it 'returns only pending todos' do
create(:todo, :done, user: user)
todo = create(:todo, :pending, user: user)
todos = described_class.new(user).execute
expect(todos).to eq([todo])
end
it 'supports retrieving of todos for a specific project' do
project1 = create(:project)
project2 = create(:project)
create(:todo, :pending, user: user, project: project2)
todo = create(:todo, :pending, user: user, project: project1)
todos = described_class.new(user, project_id: project1.id).execute
expect(todos).to eq([todo])
end
it 'supports retrieving of todos for a specific todo target' do
issue = create(:issue)
note = create(:note)
todo = create(:todo, :pending, user: user, target: issue)
create(:todo, :pending, user: user, target: note)
todos = described_class.new(user, target_id: issue.id).execute
expect(todos).to eq([todo])
end
it 'supports retrieving of todos for a specific target type' do
issue = create(:issue)
note = create(:note)
todo = create(:todo, :pending, user: user, target: issue)
create(:todo, :pending, user: user, target: note)
todos = described_class.new(user, target_type: issue.class).execute
expect(todos).to eq([todo])
end
it 'supports retrieving of todos for a specific commit ID' do
create(:todo, :pending, user: user, commit_id: '456')
todo = create(:todo, :pending, user: user, commit_id: '123')
todos = described_class.new(user, commit_id: '123').execute
expect(todos).to eq([todo])
end
end
end
...@@ -109,4 +109,20 @@ describe TodosFinder do ...@@ -109,4 +109,20 @@ describe TodosFinder do
end end
end end
end end
describe '#any_for_target?' do
it 'returns true if there are any todos for the given target' do
todo = create(:todo, :pending)
finder = described_class.new(todo.user)
expect(finder.any_for_target?(todo.target)).to eq(true)
end
it 'returns false if there are no todos for the given target' do
issue = create(:issue)
finder = described_class.new(issue.author)
expect(finder.any_for_target?(issue)).to eq(false)
end
end
end end
# frozen_string_literal: true
require 'spec_helper'
describe UsersWithPendingTodosFinder do
describe '#execute' do
it 'returns the users for all pending todos of a target' do
issue = create(:issue)
note = create(:note)
todo = create(:todo, :pending, target: issue)
create(:todo, :pending, target: note)
users = described_class.new(issue).execute
expect(users).to eq([todo.user])
end
end
end
...@@ -230,6 +230,26 @@ describe Todo do ...@@ -230,6 +230,26 @@ describe Todo do
end end
end end
describe '.for_target' do
it 'returns the todos for a given target' do
todo = create(:todo, target: create(:issue))
create(:todo, target: create(:merge_request))
expect(described_class.for_target(todo.target)).to eq([todo])
end
end
describe '.for_commit' do
it 'returns the todos for a commit ID' do
todo = create(:todo, commit_id: '123')
create(:todo, commit_id: '456')
expect(described_class.for_commit('123')).to eq([todo])
end
end
describe '.for_group_and_descendants' do describe '.for_group_and_descendants' do
it 'returns the todos for a group and its descendants' do it 'returns the todos for a group and its descendants' do
parent_group = create(:group) parent_group = create(:group)
...@@ -237,9 +257,45 @@ describe Todo do ...@@ -237,9 +257,45 @@ describe Todo do
todo1 = create(:todo, group: parent_group) todo1 = create(:todo, group: parent_group)
todo2 = create(:todo, group: child_group) todo2 = create(:todo, group: child_group)
todos = described_class.for_group_and_descendants(parent_group)
expect(todos).to include(todo1)
# Nested groups only work on PostgreSQL, so on MySQL todo2 won't be
# present.
expect(todos).to include(todo2) if Gitlab::Database.postgresql?
end
end
describe '.any_for_target?' do
it 'returns true if there are todos for a given target' do
todo = create(:todo)
expect(described_class.any_for_target?(todo.target)).to eq(true)
end
it 'returns false if there are no todos for a given target' do
issue = create(:issue)
expect(described_class.any_for_target?(issue)).to eq(false)
end
end
describe '.update_state' do
it 'updates the state of todos' do
todo = create(:todo, :pending)
ids = described_class.update_state(:done)
todo.reload
expect(ids).to eq([todo.id])
expect(todo.state).to eq('done')
end
it 'does not update todos that already have the given state' do
create(:todo, :pending)
expect(described_class.for_group_and_descendants(parent_group)) expect(described_class.update_state(:pending)).to be_empty
.to include(todo1, todo2)
end end
end end
end end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment