#-- encoding: UTF-8 #-- copyright # OpenProject is a project management system. # Copyright (C) 2012-2015 the OpenProject Foundation (OPF) # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License version 3. # # OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: # Copyright (C) 2006-2013 Jean-Philippe Lang # Copyright (C) 2010-2013 the ChiliProject Team # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; either version 2 # of the License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. # # See doc/COPYRIGHT.rdoc for more details. #++ # Similar to regular Journals, but under the following circumstances journals are aggregated: # * they are in temporal proximity # * they belong to the same resource # * they were created by the same user (i.e. the same user edited the journable) # * no other user has an own journal on the same object between the aggregated ones # When a user commented (added a note) twice within a short time, the second comment will # "open" a new aggregation, since we do not want to merge comments in any way. # The term "aggregation" means the following when applied to our journaling: # * ignore/hide old journal rows (since every journal row contains a full copy of the journaled # object, dropping intermediate rows will just increase the diff of the following journal) # * in case an older row had notes, take the notes from the older row, since they shall not # be dropped class Journal::AggregatedJournal < Journal self.table_name = 'journals' class << self def default_scope # Using the roughly aggregated groups from :sql_rough_group we need to merge journals # where an entry with empty notes follows an entry containing notes, so that the notes # from the main entry are taken, while the remaining information is taken from the # more recent entry. We therefore join the rough groups with itself # _wherever a merge would be valid_. # Since the results are already pre-merged, this can only happen if Our first entry (master) # had a comment and its successor (addition) had no comment, but can be merged. # This alone would, however, leave the addition in the result set, leaving a "no change" # journal entry back. By an additional self-join towards the predecessor, we can make sure # that our own row (master) would not already have been merged by its predecessor. If it is # (that means if we can find a valid predecessor), we drop our current row, because it will # already be present (in a merged form) in the row of our predecessor. from("(#{sql_rough_group(1)}) #{table_name}") .joins("LEFT OUTER JOIN (#{sql_rough_group(2)}) addition ON #{sql_on_groups_belong_condition(table_name, 'addition')}") .joins("LEFT OUTER JOIN (#{sql_rough_group(3)}) predecessor ON #{sql_on_groups_belong_condition('predecessor', table_name)}") .where('predecessor.id IS NULL') .select("#{table_name}.journable_id, #{table_name}.journable_type, #{table_name}.user_id, #{table_name}.notes, #{table_name}.id \"notes_id\", #{table_name}.activity_type, COALESCE(addition.created_at, #{table_name}.created_at) \"created_at\", COALESCE(addition.id, #{table_name}.id) \"id\", COALESCE(addition.version, #{table_name}.version) \"version\"") end private # Provides a full SQL statement that returns journals that are aggregated on a basic level: # * a row is dropped as soon as its successor is eligible to be merged with it # * rows with a comment are never dropped (we _might_ need the comment later) # Thereby the result already has aggregation performed, but will still have too many rows: # Changes without notes after changes containing notes (even if both were performed by # the same user). Those need to be filtered out later. # To be able to self-join results of this statement, we add an additional column called # "group_number" to the result. This allows to compare a group resulting from this query with # its predecessor and successor. def sql_rough_group(uid) "SELECT predecessor.*, #{sql_group_counter(uid)} AS group_number FROM #{sql_rough_group_from_clause(uid)} LEFT OUTER JOIN journals successor ON predecessor.version + 1 = successor.version AND predecessor.journable_type = successor.journable_type AND predecessor.journable_id = successor.journable_id WHERE predecessor.user_id != successor.user_id OR (predecessor.notes != '' AND predecessor.notes IS NOT NULL) OR #{sql_beyond_aggregation_time?('predecessor', 'successor')} OR successor.id IS NULL" end # The "group_number" required in :sql_rough_group has to be generated differently depending on # the DBMS used. This method returns the appropriate statement to be used inside a SELECT to # obtain the current group number. # The :uid parameter allows to define non-conflicting variable names (for MySQL). def sql_group_counter(uid) if OpenProject::Database.mysql? group_counter = mysql_group_count_variable(uid) "(#{group_counter} := #{group_counter} + 1)" else 'row_number() OVER ()' end end # MySQL requires some initialization to be performed before being able to count the groups. # This method allows to inject further FROM sources to achieve that in a single SQL statement. # Sadly MySQL requires the whole statement to be wrapped in parenthesis, while PostgreSQL # prohibits that. def sql_rough_group_from_clause(uid) if OpenProject::Database.mysql? "(journals predecessor, (SELECT #{mysql_group_count_variable(uid)}:=0) number_initializer)" else 'journals predecessor' end end def mysql_group_count_variable(uid) "@aggregated_journal_row_counter_#{uid}" end # Similar to the WHERE statement used in :sql_rough_group. However, this condition will # match (return true) for all pairs where a merge/aggregation IS possible. def sql_on_groups_belong_condition(predecessor, successor) "#{predecessor}.group_number + 1 = #{successor}.group_number AND (NOT #{sql_beyond_aggregation_time?(predecessor, successor)} AND #{predecessor}.user_id = #{successor}.user_id AND #{successor}.journable_type = #{predecessor}.journable_type AND #{successor}.journable_id = #{predecessor}.journable_id AND NOT ((#{predecessor}.notes != '' AND #{predecessor}.notes IS NOT NULL) AND (#{successor}.notes != '' AND #{successor}.notes IS NOT NULL)))" end # Returns a SQL condition that will determine whether two entries are too far apart (temporal) # to be considered for aggregation. This takes the current instance settings for temporal # proximity into account. def sql_beyond_aggregation_time?(predecessor, successor) aggregation_time_seconds = Setting.journal_aggregation_time_minutes.to_i * 60 if OpenProject::Database.mysql? difference = "TIMESTAMPDIFF(second, #{predecessor}.created_at, #{successor}.created_at)" threshold = aggregation_time_seconds else difference = "(#{successor}.created_at - #{predecessor}.created_at)" threshold = "interval '#{aggregation_time_seconds} second'" end "(#{difference} > #{threshold})" end end def initial? predecessor.nil? end # ARs automagic addition of dynamic columns (those not present in the physical table) seems # not to work with PostgreSQL and simply return a string for unknown columns. # Thus we need to ensure manually that this column is correctly casted. def notes_id ActiveRecord::ConnectionAdapters::Column.value_to_integer(attributes['notes_id']) end end