Skip to content

Commit 55c1eb4

Browse files
authored
DEV: Move solved custom fields into a table (#342)
Related: - #309 - #341 Requires: - discourse/discourse#31954 This commit converts all use of post and topic custom fields into a dedicated table: - migration for copying custom field into table - swap app usage of custom fields to table This commit does not attempt to fix issues or optimise, and does not delete old data from custom fields _yet_.
1 parent e82c6ae commit 55c1eb4

20 files changed

+273
-289
lines changed

.discourse-compatibility

+1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
< 3.5.0.beta2-dev: e82c6ae1ca38ccebb34669148f8de93a3028906e
12
< 3.5.0.beta1-dev: 5450a5ef4e2ae35185320fc6af9678621026e148
23
< 3.4.0.beta4-dev: 3f724bf3114cc7877fa757bc8035f13a7390c739
34
< 3.4.0.beta2-dev: 1bbdfd8f5681171dc3f0e9ea93cd56997dc7938a
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# frozen_string_literal: true
2+
3+
module DiscourseSolved
4+
class SolvedTopic < ActiveRecord::Base
5+
self.table_name = "discourse_solved_solved_topics"
6+
7+
belongs_to :topic, class_name: "Topic"
8+
belongs_to :answer_post, class_name: "Post", foreign_key: "answer_post_id"
9+
belongs_to :accepter, class_name: "User", foreign_key: "accepter_user_id"
10+
belongs_to :topic_timer
11+
12+
validates :topic_id, presence: true
13+
validates :answer_post_id, presence: true
14+
validates :accepter_user_id, presence: true
15+
end
16+
end
17+
18+
# == Schema Information
19+
#
20+
# Table name: discourse_solved_solved_topics
21+
#
22+
# id :bigint not null, primary key
23+
# topic_id :integer not null
24+
# answer_post_id :integer not null
25+
# accepter_user_id :integer not null
26+
# topic_timer_id :integer
27+
# created_at :datetime not null
28+
# updated_at :datetime not null
29+
#
30+
# Indexes
31+
#
32+
# index_discourse_solved_solved_topics_on_answer_post_id (answer_post_id) UNIQUE
33+
# index_discourse_solved_solved_topics_on_topic_id (topic_id) UNIQUE
34+
#

app/serializers/concerns/discourse_solved/topic_answer_mixin.rb

+1-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ def self.included(klass)
77
end
88

99
def has_accepted_answer
10-
object.custom_fields[::DiscourseSolved::ACCEPTED_ANSWER_POST_ID_CUSTOM_FIELD].present?
10+
object&.solved.present?
1111
end
1212

1313
def include_has_accepted_answer?

db/fixtures/001_badges.rb

+11-13
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,13 @@
33
first_solution_query = <<~SQL
44
SELECT post_id, user_id, created_at AS granted_at
55
FROM (
6-
SELECT p.id AS post_id, p.user_id, pcf.created_at,
7-
ROW_NUMBER() OVER (PARTITION BY p.user_id ORDER BY pcf.created_at) AS row_number
8-
FROM post_custom_fields pcf
9-
JOIN badge_posts p ON pcf.post_id = p.id
10-
JOIN topics t ON p.topic_id = t.id
11-
WHERE pcf.name = 'is_accepted_answer'
12-
AND p.user_id <> t.user_id -- ignore topics solved by OP
13-
AND (:backfill OR p.id IN (:post_ids))
6+
SELECT p.id AS post_id, p.user_id, dsst.created_at,
7+
ROW_NUMBER() OVER (PARTITION BY p.user_id ORDER BY dsst.created_at) AS row_number
8+
FROM discourse_solved_solved_topics dsst
9+
JOIN badge_posts p ON dsst.answer_post_id = p.id
10+
JOIN topics t ON p.topic_id = t.id
11+
WHERE p.user_id <> t.user_id -- ignore topics solved by OP
12+
AND (:backfill OR p.id IN (:post_ids))
1413
) x
1514
WHERE row_number = 1
1615
SQL
@@ -32,12 +31,11 @@
3231

3332
def solved_query_with_count(min_count)
3433
<<~SQL
35-
SELECT p.user_id, MAX(pcf.created_at) AS granted_at
36-
FROM post_custom_fields pcf
37-
JOIN badge_posts p ON pcf.post_id = p.id
34+
SELECT p.user_id, MAX(dsst.created_at) AS granted_at
35+
FROM discourse_solved_solved_topics dsst
36+
JOIN badge_posts p ON dsst.answer_post_id = p.id
3837
JOIN topics t ON p.topic_id = t.id
39-
WHERE pcf.name = 'is_accepted_answer'
40-
AND p.user_id <> t.user_id -- ignore topics solved by OP
38+
WHERE p.user_id <> t.user_id -- ignore topics solved by OP
4139
AND (:backfill OR p.id IN (:post_ids))
4240
GROUP BY p.user_id
4341
HAVING COUNT(*) >= #{min_count}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# frozen_string_literal: true
2+
#
3+
class CreateDiscourseSolvedSolvedTopics < ActiveRecord::Migration[7.2]
4+
def change
5+
create_table :discourse_solved_solved_topics do |t|
6+
t.integer :topic_id, null: false
7+
t.integer :answer_post_id, null: false
8+
t.integer :accepter_user_id, null: false
9+
t.integer :topic_timer_id
10+
t.timestamps
11+
end
12+
end
13+
end
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# frozen_string_literal: true
2+
#
3+
class CopySolvedTopicCustomFieldToDiscourseSolvedSolvedTopics < ActiveRecord::Migration[7.2]
4+
disable_ddl_transaction!
5+
6+
BATCH_SIZE = 5000
7+
8+
def up
9+
last_id = 0
10+
loop do
11+
rows = DB.query(<<~SQL, last_id: last_id, batch_size: BATCH_SIZE)
12+
INSERT INTO discourse_solved_solved_topics (
13+
topic_id,
14+
answer_post_id,
15+
topic_timer_id,
16+
accepter_user_id,
17+
created_at,
18+
updated_at
19+
)
20+
SELECT DISTINCT ON (tc.topic_id)
21+
tc.topic_id,
22+
CAST(tc.value AS INTEGER),
23+
CAST(tc2.value AS INTEGER),
24+
COALESCE(ua.acting_user_id, -1),
25+
tc.created_at,
26+
tc.updated_at
27+
FROM topic_custom_fields tc
28+
LEFT JOIN topic_custom_fields tc2
29+
ON tc2.topic_id = tc.topic_id
30+
AND tc2.name = 'solved_auto_close_topic_timer_id'
31+
LEFT JOIN user_actions ua
32+
ON ua.target_topic_id = tc.topic_id
33+
AND ua.action_type = 15
34+
WHERE tc.name = 'accepted_answer_post_id'
35+
AND tc.id > :last_id
36+
ORDER BY tc.topic_id, ua.created_at DESC
37+
LIMIT :batch_size
38+
SQL
39+
40+
break if rows.length == 0
41+
last_id += BATCH_SIZE
42+
end
43+
end
44+
45+
def down
46+
raise ActiveRecord::IrreversibleMigration
47+
end
48+
end
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# frozen_string_literal: true
2+
#
3+
class AddIndexForDiscourseSolvedTopics < ActiveRecord::Migration[7.2]
4+
disable_ddl_transaction!
5+
6+
def change
7+
remove_index :discourse_solved_solved_topics,
8+
:topic_id,
9+
algorithm: :concurrently,
10+
unique: true,
11+
if_exists: true
12+
remove_index :discourse_solved_solved_topics,
13+
:answer_post_id,
14+
algorithm: :concurrently,
15+
unique: true,
16+
if_exists: true
17+
18+
add_index :discourse_solved_solved_topics, :topic_id, unique: true, algorithm: :concurrently
19+
add_index :discourse_solved_solved_topics,
20+
:answer_post_id,
21+
unique: true,
22+
algorithm: :concurrently
23+
end
24+
end

lib/discourse_assign/entry_point.rb

+4-6
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,13 @@
22

33
module DiscourseAssign
44
class EntryPoint
5+
# TODO: These four plugin api usages should ideally be in the assign plugin, not the solved plugin.
6+
# They have been moved here from plugin.rb as part of the custom fields migration.
7+
58
def self.inject(plugin)
69
plugin.register_modifier(:assigns_reminder_assigned_topics_query) do |query|
710
next query if !SiteSetting.ignore_solved_topics_in_assigned_reminder
8-
query.where.not(
9-
id:
10-
TopicCustomField.where(
11-
name: ::DiscourseSolved::ACCEPTED_ANSWER_POST_ID_CUSTOM_FIELD,
12-
).pluck(:topic_id),
13-
)
11+
query.where.not(id: DiscourseSolved::SolvedTopic.select(:topic_id))
1412
end
1513

1614
plugin.register_modifier(:assigned_count_for_user_query) do |query, user|

lib/discourse_dev/discourse_solved.rb

+4-4
Original file line numberDiff line numberDiff line change
@@ -11,24 +11,24 @@ def self.populate(plugin)
1111

1212
solved_category =
1313
DiscourseDev::Record.random(
14-
Category.where(
14+
::Category.where(
1515
read_restricted: false,
1616
id: records.pluck(:id),
1717
parent_category_id: nil,
1818
),
1919
)
20-
CategoryCustomField.create!(
20+
::CategoryCustomField.create!(
2121
category_id: solved_category.id,
2222
name: ::DiscourseSolved::ENABLE_ACCEPTED_ANSWERS_CUSTOM_FIELD,
2323
value: "true",
2424
)
2525
puts "discourse-solved enabled on category '#{solved_category.name}' (#{solved_category.id})."
2626
elsif type == :topic
27-
topics = Topic.where(id: records.pluck(:id))
27+
topics = ::Topic.where(id: records.pluck(:id))
2828

2929
unless SiteSetting.allow_solved_on_all_topics
3030
solved_category_id =
31-
CategoryCustomField
31+
::CategoryCustomField
3232
.where(name: ::DiscourseSolved::ENABLE_ACCEPTED_ANSWERS_CUSTOM_FIELD, value: "true")
3333
.first
3434
.category_id

lib/discourse_solved/before_head_close.rb

+1-4
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,7 @@ def html
4040
},
4141
}
4242

43-
if accepted_answer =
44-
Post.find_by(
45-
id: topic.custom_fields[::DiscourseSolved::ACCEPTED_ANSWER_POST_ID_CUSTOM_FIELD],
46-
)
43+
if accepted_answer = topic.solved&.answer_post
4744
question_json["answerCount"] = 1
4845
question_json[:acceptedAnswer] = {
4946
"@type" => "Answer",

lib/discourse_solved/register_filters.rb

+21-29
Original file line numberDiff line numberDiff line change
@@ -4,46 +4,38 @@ module DiscourseSolved
44
class RegisterFilters
55
def self.register(plugin)
66
solved_callback = ->(scope) do
7-
sql = <<~SQL
8-
topics.id IN (
9-
SELECT topic_id
10-
FROM topic_custom_fields
11-
WHERE name = '#{::DiscourseSolved::ACCEPTED_ANSWER_POST_ID_CUSTOM_FIELD}'
12-
AND value IS NOT NULL
13-
)
14-
SQL
15-
16-
scope.where(sql).where("topics.archetype <> ?", Archetype.private_message)
7+
scope.joins(
8+
"INNER JOIN discourse_solved_solved_topics ON discourse_solved_solved_topics.topic_id = topics.id",
9+
).where("topics.archetype <> ?", Archetype.private_message)
1710
end
11+
1812
unsolved_callback = ->(scope) do
19-
scope = scope.where <<~SQL
13+
scope = scope.where(<<~SQL)
2014
topics.id NOT IN (
2115
SELECT topic_id
22-
FROM topic_custom_fields
23-
WHERE name = '#{::DiscourseSolved::ACCEPTED_ANSWER_POST_ID_CUSTOM_FIELD}'
24-
AND value IS NOT NULL
16+
FROM discourse_solved_solved_topics
2517
)
2618
SQL
2719

2820
if !SiteSetting.allow_solved_on_all_topics
2921
tag_ids = Tag.where(name: SiteSetting.enable_solved_tags.split("|")).pluck(:id)
3022

3123
scope = scope.where <<~SQL, tag_ids
32-
topics.id IN (
33-
SELECT t.id
34-
FROM topics t
35-
JOIN category_custom_fields cc
36-
ON t.category_id = cc.category_id
37-
AND cc.name = '#{::DiscourseSolved::ENABLE_ACCEPTED_ANSWERS_CUSTOM_FIELD}'
38-
AND cc.value = 'true'
39-
)
40-
OR
41-
topics.id IN (
42-
SELECT topic_id
43-
FROM topic_tags
44-
WHERE tag_id IN (?)
45-
)
46-
SQL
24+
topics.id IN (
25+
SELECT t.id
26+
FROM topics t
27+
JOIN category_custom_fields cc
28+
ON t.category_id = cc.category_id
29+
AND cc.name = '#{::DiscourseSolved::ENABLE_ACCEPTED_ANSWERS_CUSTOM_FIELD}'
30+
AND cc.value = 'true'
31+
)
32+
OR
33+
topics.id IN (
34+
SELECT topic_id
35+
FROM topic_tags
36+
WHERE tag_id IN (?)
37+
)
38+
SQL
4739
end
4840

4941
scope.where("topics.archetype <> ?", Archetype.private_message)
+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# frozen_string_literal: true
2+
3+
module DiscourseSolved::TopicExtension
4+
extend ActiveSupport::Concern
5+
6+
prepended { has_one :solved, class_name: "DiscourseSolved::SolvedTopic", dependent: :destroy }
7+
end

lib/discourse_solved/topic_view_serializer_extension.rb

+1-7
Original file line numberDiff line numberDiff line change
@@ -43,12 +43,6 @@ def accepted_answer_post_info
4343
end
4444

4545
def accepted_answer_post_id
46-
id = object.topic.custom_fields[::DiscourseSolved::ACCEPTED_ANSWER_POST_ID_CUSTOM_FIELD]
47-
# a bit messy but race conditions can give us an array here, avoid
48-
begin
49-
id && id.to_i
50-
rescue StandardError
51-
nil
52-
end
46+
object.topic.solved&.answer_post_id
5347
end
5448
end

lib/discourse_solved/user_summary_extension.rb

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,6 @@ module DiscourseSolved::UserSummaryExtension
44
extend ActiveSupport::Concern
55

66
def solved_count
7-
UserAction.where(user: @user).where(action_type: UserAction::SOLVED).count
7+
DiscourseSolved::SolvedTopic.where(accepter: @user).count
88
end
99
end

0 commit comments

Comments
 (0)