Вы можете легко сделать это с помощью левых соединений.
Я покажу вам, как получить наиболее активный код, похожий на запись, начиная с необработанного SQL, исправляя его на лету и все больше и больше повышая уровень абстракции.Возможно, это поможет вам в будущем в решении некоторых других подобных задач.
Давайте начнем с самого простого SQL, который может работать (на самом деле это не так).
SELECT `projects`.`*`,
`tasks`.`*`,
`todo_items`.`*`
FROM `projects`
LEFT OUTER JOIN `tasks`
ON `tasks`.`project_id` = `projects`.`id
LEFT OUTER JOIN `todo_items`
ON `todo_items`.`task_id` = `tasks`.`id
AND NOT `todo_items`.`done`
WHERE `todo_items`.`id` IS NULL
;
Это присоединится ктри таблицы, при указанных условиях.Если вам нужен простой способ понять объединения, вы можете представить, что результатом являются все комбинации строк из трех таблиц при условии, что выполнены условия в ON
частях.
Фундаментальный фактLEFT присоединяется к тому, что всякий раз, когда нет совпадения для какой-либо строки слева, БД обеспечивает получение результата с NULL справа.
Забывая о конкретных столбцах, вы можете представить, что этот запрос будетreturn:
(p1, NULL, NULL) -- project with no tasks
(p2, t2_1, NULL) -- project with three tasks, first is complete
(p2, t2_2, todo2_2_1) -- this task has one pending todo
(p2, t2_3, todo2_3_1) -- this task has two pending todos
(p2, t2_3, todo2_3_2)
(p3, t3_1, NULL) -- project with two complete tasks
(p3, t3_2, NULL)
...
Здесь есть причина для использования AND NOT todo_items.done
в предложении ON
.Всякий раз, когда задание только завершило TODO, оно будет отображаться с NULL
todo.Когда у задачи есть какое-то неполное TODO, оно появится вместе со своими данными.С другой стороны, если у задачи t2_2
есть какой-либо завершенный todo_item, он не будет возвращен.
Теперь исходный запрос не выполняется, потому что p2
, у которого есть одна завершенная задача, будет возвращено, несмотря на наличиееще одна задача, которая не завершена.
Но есть хорошая функция SQL, которую вы можете использовать для проверки ненулевых значений:
SELECT `projects`.`*`,
COUNT(`todo_items`.`id`) AS `pending_todo_count`
FROM `projects`
LEFT OUTER JOIN `tasks`
ON `tasks`.`project_id` = `projects`.`id
LEFT OUTER JOIN `todo_items`
ON `todo_items`.`task_id` = `tasks`.`id
AND NOT `todo_items`.`done`
GROUP BY `projects`.`*`
;
С приведенными выше данными это вернет что-токак
(p1, 0) -- project with no tasks
(p2, 3) -- project with three tasks, first is complete
(p3, 0) -- project with two complete tasks
...
Теперь мы пропускаем те, у которых есть неполные TODO
SELECT `projects`.`*`
FROM `projects`
LEFT OUTER JOIN `tasks`
ON `tasks`.`project_id` = `projects`.`id
LEFT OUTER JOIN `todo_items`
ON `todo_items`.`task_id` = `tasks`.`id
AND NOT `todo_items`.`done`
GROUP BY `projects`.`*`
HAVING COUNT(`todo_items`.`id`) = 0
;
Вы можете видеть, что мы удалили столбец в SELECT и добавили условие для группировки.Этот запрос, наконец, прав, он вернет
(p1)
(p3)
Так что теперь нам нужен код ruby для него, если вы в Rails 5, вам повезло, потому что левые объединения поддерживаются напрямую, если вы определитеассоциация для 'pending_todo_items' в Task:
class Task
has_many :pending_todo_items, -> { where(done: false) }, class_name: 'TodoItem'
end
Project.
left_joins(tasks: :pending_todo_items).
group(Project.arel_table[:id]).
having(TodoItem.arel_table[:id].count.eq(0))
Аргументы для group
и having
взяты из Arel, который является лишь одним уровнем абстракции ниже ActiveRecord, чтобы избежать использования жестко закодированных строк, таких как
Project.
left_joins(tasks: :pending_todo_items).
group('projects.id').
having('COUNT(todo_items.id) = 0')
, который сломается, как только изменится отображение таблицы <-> имени модели.
Если вы находитесь в Rails 4 или менее, вы должны написать левое соединение вручную или черезArel:
Project.
joins('LEFT OUTER JOIN .........').
group('projects.id').
having('COUNT(todo_items.id) = 0')
ОБНОВЛЕНИЕ
Вы также можете использовать NULLIF
для преобразования столбца done
(1/0) в pending
column (NULL / notnull), таким образом, вы можете избежать особых ассоциаций в области (хотя я определенно вижу, что они могут быть полезны):
SELECT `projects`.`*`
FROM `projects`
LEFT OUTER JOIN `tasks`
ON `tasks`.`project_id` = `projects`.`id
LEFT OUTER JOIN `todo_items`
ON `todo_items`.`task_id` = `tasks`.`id
GROUP BY `projects`.`*`
HAVING COUNT(NULLIF(`todo_items`.`done`, 1)) = 0
;
Выражение NULLIF(
todo_items .
done , 1)
возвращает NULL
для готовых изделий и 0
(исходное значение) длянаходящиеся на рассмотрении элементы.
В ActiveRecord для простоты использования вам придется исправлять предсказания:
module Arel::Predications
def null_if(other)
Arel::Nodes::NamedFunction.new('NULLIF', [self, other])
end
end
Project.
left_joins(tasks: :todo_items).
group(Project.arel_table[:id]).
having(TodoItem.arel_table[:done].null_if(true).count.eq(0))