diff --git a/app/models/user_tracking_state.rb b/app/models/user_tracking_state.rb index 110c6bc21df..f25f68b8793 100644 --- a/app/models/user_tracking_state.rb +++ b/app/models/user_tracking_state.rb @@ -7,6 +7,8 @@ class UserTrackingState CHANNEL = "/user-tracking" + attr_accessor :user_id, :topic_id, :highest_post_number, :last_read_post_number, :created_at + MessageBus.client_filter(CHANNEL) do |user_id, message| if user_id UserTrackingState.new(User.find(user_id)).filter(message) @@ -19,23 +21,61 @@ class UserTrackingState MessageBus.publish(CHANNEL, "CHANGE", user_ids: [user_id].compact) end - def initialize(user) - @user = user - @query = TopicQuery.new(@user) + def self.treat_as_new_topic_clause + User.where("CASE + WHEN COALESCE(u.new_topic_duration_minutes, :default_duration) = :always THEN u.created_at + WHEN COALESCE(u.new_topic_duration_minutes, :default_duration) = :last_visit THEN COALESCE(u.previous_visit_at,u.created_at) + ELSE (:now::timestamp - INTERVAL '1 MINUTE' * COALESCE(u.new_topic_duration_minutes, :default_duration)) + END", + now: DateTime.now, + last_visit: User::NewTopicDuration::LAST_VISIT, + always: User::NewTopicDuration::ALWAYS, + default_duration: SiteSetting.new_topic_duration_minutes + ).where_values[0] end - def new_list - @query - .new_results(limit: false) - .select(topics: [:id, :created_at]) - .map{|t| [t.id, t.created_at]} - end + def self.report(user_ids, topic_id = nil) - def unread_list - [] - end + # Sam: this is a hairy report, in particular I need custom joins and fancy conditions + # Dropping to sql_builder so I can make sense of it. + # + # Keep in mind, we need to be able to filter on a GROUP of users, and zero in on topic + # all our existing scope work does not do this + # + # This code needs to be VERY efficient as it is triggered via the message bus and may steal + # cycles from usual requests + # + + unread = TopicQuery.unread_filter(Topic).where_values.join(" AND ") + new = TopicQuery.new_filter(Topic, "xxx").where_values.join(" AND ").gsub!("'xxx'", treat_as_new_topic_clause) + + sql = < 'private_message' AND + ((#{unread}) OR (#{new})) AND + (topics.visible OR u.admin OR u.moderator) AND + topics.deleted_at IS NULL AND + ( category_id IS NULL OR NOT c.secure OR category_id IN ( + SELECT c2.id FROM categories c2 + JOIN category_groups cg ON cg.category_id = c2.id + JOIN group_users gu ON gu.user_id = u.id AND cg.group_id = gu.group_id + WHERE c2.secure ) + ) + +SQL + + if topic_id + sql << " AND topics.id = :topic_id" + end + + SqlBuilder.new(sql) + .map_exec(UserTrackingState, user_ids: user_ids, topic_id: topic_id) - def filter(message) end end diff --git a/lib/sql_builder.rb b/lib/sql_builder.rb index f92f4549746..57ec895f8fe 100644 --- a/lib/sql_builder.rb +++ b/lib/sql_builder.rb @@ -25,7 +25,7 @@ class SqlBuilder when :select joined = "SELECT " << v.join(" , ") when :where, :where2 - joined = "WHERE " << v.join(" AND ") + joined = "WHERE " << v.map{|c| "(" << c << ")" }.join(" AND ") when :join joined = v.map{|v| "JOIN " << v }.join("\n") when :left_join @@ -55,6 +55,35 @@ class SqlBuilder ActiveRecord::Base.exec_sql(sql,@args) end end + + #weird AS reloading + unless defined? FTYPE_MAP + FTYPE_MAP = { + 23 => :value_to_integer, + 1114 => :string_to_time + } + end + + def map_exec(klass, args = {}) + results = exec(args) + + setters = results.fields.each_with_index.map do |f, index| + [(f.dup << "=").to_sym, FTYPE_MAP[results.ftype(index)]] + end + values = results.values + values.map! do |row| + mapped = klass.new + setters.each_with_index do |mapper, index| + translated = row[index] + if mapper[1] && !translated.nil? + translated = ActiveRecord::ConnectionAdapters::Column.send mapper[1], translated + end + mapped.send mapper[0], translated + end + mapped + end + end + end class ActiveRecord::Base diff --git a/lib/topic_query.rb b/lib/topic_query.rb index 17602c7d7d5..630f13b4ba2 100644 --- a/lib/topic_query.rb +++ b/lib/topic_query.rb @@ -190,17 +190,23 @@ class TopicQuery create_list(:new_in_category) {|l| l.where(category_id: category.id).by_newest.first(25)} end + def self.new_filter(list,treat_as_new_topic_start_date) + list.where("topics.created_at >= :created_at", created_at: treat_as_new_topic_start_date) + .where("tu.last_read_post_number IS NULL") + .where("COALESCE(tu.notification_level, :tracking) >= :tracking", tracking: TopicUser.notification_levels[:tracking]) + end + def new_results(list_opts={}) - default_list(list_opts) - .where("topics.created_at >= :created_at", created_at: @user.treat_as_new_topic_start_date) - .where("tu.last_read_post_number IS NULL") - .where("COALESCE(tu.notification_level, :tracking) >= :tracking", tracking: TopicUser.notification_levels[:tracking]) + TopicQuery.new_filter(default_list(list_opts),@user.treat_as_new_topic_start_date) + end + + def self.unread_filter(list) + list.where("tu.last_read_post_number < topics.highest_post_number") + .where("COALESCE(tu.notification_level, :regular) >= :tracking", regular: TopicUser.notification_levels[:regular], tracking: TopicUser.notification_levels[:tracking]) end def unread_results(list_opts={}) - default_list(list_opts) - .where("tu.last_read_post_number < topics.highest_post_number") - .where("COALESCE(tu.notification_level, :regular) >= :tracking", regular: TopicUser.notification_levels[:regular], tracking: TopicUser.notification_levels[:tracking]) + TopicQuery.unread_filter(default_list(list_opts)) end protected diff --git a/spec/components/sql_builder_spec.rb b/spec/components/sql_builder_spec.rb index baf793a6218..15cfdab6926 100644 --- a/spec/components/sql_builder_spec.rb +++ b/spec/components/sql_builder_spec.rb @@ -18,6 +18,24 @@ describe SqlBuilder do end end + describe "map" do + class SqlBuilder::TestClass + attr_accessor :int, :string, :date, :text + end + + it "correctly maps to a klass" do + rows = SqlBuilder.new("SELECT 1 AS int, 'string' AS string, CAST(NOW() at time zone 'utc' AS timestamp without time zone) AS date, 'text'::text AS text") + .map_exec(SqlBuilder::TestClass) + + rows.count.should == 1 + row = rows[0] + row.int.should == 1 + row.string.should == "string" + row.text.should == "text" + row.date.should be_within(10.seconds).of(DateTime.now) + end + end + describe "detached" do before do @builder = SqlBuilder.new("select * from (select :a A union all select :b) as X /*where*/ /*order_by*/ /*limit*/ /*offset*/") diff --git a/spec/models/user_tracking_state_spec.rb b/spec/models/user_tracking_state_spec.rb index b466a3cb706..ea8083b6388 100644 --- a/spec/models/user_tracking_state_spec.rb +++ b/spec/models/user_tracking_state_spec.rb @@ -10,25 +10,40 @@ describe UserTrackingState do Fabricate(:post) end - let(:state) do - UserTrackingState.new(user) - end - - it "correctly gets the list of new topics" do - state.new_list.should == [] - state.unread_list.should == [] + it "correctly gets the tracking state" do + report = UserTrackingState.report([user.id]) + report.length.should == 0 new_post = post - new_list = state.new_list + report = UserTrackingState.report([user.id]) - new_list.length.should == 1 - new_list[0][0].should == post.topic.id - new_list[0][1].should be_within(1.second).of(post.topic.created_at) + report.length.should == 1 + row = report[0] - state.unread_list.should == [] + row.topic_id.should == post.topic_id + row.highest_post_number.should == 1 + row.last_read_post_number.should be_nil + row.user_id.should == user.id - # read it + # lets not leak out random users + UserTrackingState.report([post.user_id]).should be_empty + + # lets not return anything if we scope on non-existing topic + UserTrackingState.report([user.id], post.topic_id + 1).should be_empty + + # when we reply the poster should have an unread row + Fabricate(:post, user: user, topic: post.topic) + + report = UserTrackingState.report([post.user_id, user.id]) + report.length.should == 1 + + row = report[0] + + row.topic_id.should == post.topic_id + row.highest_post_number.should == 2 + row.last_read_post_number.should == 1 + row.user_id.should == post.user_id end end