Skip to content

Commit ce6bdca

Browse files
committed
DEV: Move translation custom fields into their own tables
1 parent a43c603 commit ce6bdca

File tree

9 files changed

+257
-3
lines changed

9 files changed

+257
-3
lines changed

app/jobs/scheduled/detect_posts_language.rb

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@
33
module ::Jobs
44
class DetectPostsLanguage < ::Jobs::Scheduled
55
sidekiq_options retry: false
6-
every 5.minutes
6+
every 1.minutes
77

8-
BATCH_SIZE = 100
9-
MAX_QUEUE_SIZE = 1000
8+
BATCH_SIZE = 10
9+
MAX_QUEUE_SIZE = 100
1010

1111
def execute(args)
1212
return unless SiteSetting.translator_enabled
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# frozen_string_literal: true
2+
3+
module DiscourseTranslator
4+
class PostLocale < ActiveRecord::Base
5+
self.table_name = "discourse_translator_post_locales"
6+
7+
belongs_to :post
8+
9+
validates :post_id, presence: true, uniqueness: true
10+
validates :detected_locale, presence: true
11+
end
12+
end
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# frozen_string_literal: true
2+
3+
module DiscourseTranslator
4+
class PostTranslation < ActiveRecord::Base
5+
self.table_name = "discourse_translator_post_translations"
6+
7+
belongs_to :post
8+
9+
validates :post_id, presence: true
10+
validates :locale, presence: true
11+
validates :translation, presence: true
12+
validates :locale, uniqueness: { scope: :post_id }
13+
14+
def self.translation_for(post_id, locale)
15+
find_by(post_id: post_id, locale: locale)&.translation
16+
end
17+
end
18+
end
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# frozen_string_literal: true
2+
3+
module DiscourseTranslator
4+
class TopicLocale < ActiveRecord::Base
5+
self.table_name = "discourse_translator_topic_locales"
6+
7+
belongs_to :topic
8+
9+
validates :topic_id, presence: true, uniqueness: true
10+
validates :detected_locale, presence: true
11+
end
12+
end
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# frozen_string_literal: true
2+
3+
module DiscourseTranslator
4+
class TopicTranslation < ActiveRecord::Base
5+
self.table_name = "discourse_translator_topic_translations"
6+
7+
belongs_to :topic
8+
9+
validates :topic_id, presence: true
10+
validates :locale, presence: true
11+
validates :translation, presence: true
12+
validates :locale, uniqueness: { scope: :topic_id }
13+
14+
def self.translation_for(topic_id, locale)
15+
find_by(topic_id: topic_id, locale: locale)&.translation
16+
end
17+
end
18+
end
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# frozen_string_literal: true
2+
3+
class CreateTranslationTables < ActiveRecord::Migration[7.2]
4+
def change
5+
create_table :discourse_translator_topic_locales do |t|
6+
t.integer :topic_id, null: false
7+
t.string :detected_locale, limit: 20, null: false
8+
t.timestamps
9+
end
10+
11+
create_table :discourse_translator_topic_translations do |t|
12+
t.integer :topic_id, null: false
13+
t.string :locale, null: false
14+
t.text :translation, null: false
15+
t.timestamps
16+
end
17+
18+
create_table :discourse_translator_post_locales do |t|
19+
t.integer :post_id, null: false
20+
t.string :detected_locale, limit: 20, null: false
21+
t.timestamps
22+
end
23+
24+
create_table :discourse_translator_post_translations do |t|
25+
t.integer :post_id, null: false
26+
t.string :locale, null: false
27+
t.text :translation, null: false
28+
t.timestamps
29+
end
30+
end
31+
end
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# frozen_string_literal: true
2+
3+
class MoveTranslationsCustomFieldsToTable < ActiveRecord::Migration[7.2] # frozen_string_literal: true
4+
BATCH_SIZE = 1000
5+
6+
def up
7+
migrate_custom_fields("topic")
8+
migrate_custom_fields("post")
9+
end
10+
11+
def down
12+
execute "TRUNCATE discourse_translator_topic_locales"
13+
execute "TRUNCATE discourse_translator_topic_translations"
14+
execute "TRUNCATE discourse_translator_post_locales"
15+
execute "TRUNCATE discourse_translator_post_translations"
16+
end
17+
18+
private
19+
20+
def migrate_custom_fields(model)
21+
start_id = 0
22+
loop do
23+
last_id = DB.query_single(<<~SQL, model:, start_id:, limit: BATCH_SIZE)
24+
WITH to_insert AS (
25+
SELECT id, #{model}_id, value
26+
FROM #{model}_custom_fields
27+
WHERE name = 'post_detected_lang'
28+
AND id > :start_id
29+
ORDER BY id
30+
LIMIT :limit
31+
),
32+
do_insert AS (
33+
INSERT INTO discourse_translator_#{model}_locales (#{model}_id, detected_locale, created_at, updated_at)
34+
SELECT #{model}_id, value, NOW(), NOW()
35+
FROM to_insert
36+
),
37+
to_translate AS (
38+
SELECT id, #{model}_id, value::jsonb, created_at, updated_at
39+
FROM #{model}_custom_fields
40+
WHERE id > :start_id
41+
AND name = 'translated_text'
42+
AND value LIKE '{%}'
43+
ORDER BY id
44+
LIMIT :limit
45+
),
46+
do_translate AS (
47+
INSERT INTO discourse_translator_#{model}_translations (#{model}_id, locale, translation, created_at, updated_at)
48+
SELECT b.#{model}_id, jb.key as locale, jb.value as translation, b.created_at, b.updated_at
49+
FROM to_translate b, jsonb_each_text(b.value) jb
50+
WHERE LENGTH(jb.key) <= 20
51+
),
52+
max_value AS (SELECT COALESCE(GREATEST((SELECT MAX(id) FROM to_insert), (SELECT MAX(id) FROM to_translate) ), -1) as max_id)
53+
SELECT max_id FROM max_value
54+
SQL
55+
start_id = last_id.last
56+
break if start_id == -1
57+
end
58+
end
59+
end
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# frozen_string_literal: true
2+
3+
class CreateTranslationIndexes < ActiveRecord::Migration[7.2]
4+
disable_ddl_transaction!
5+
6+
def change
7+
add_index :discourse_translator_topic_translations,
8+
%i[topic_id locale],
9+
unique: true,
10+
algorithm: :concurrently
11+
12+
add_index :discourse_translator_post_translations,
13+
%i[post_id locale],
14+
unique: true,
15+
algorithm: :concurrently
16+
end
17+
end
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# frozen_string_literal: true
2+
3+
require_relative "../../db/migrate/20250205082401_move_translations_custom_fields_to_table"
4+
5+
module DiscourseTranslator
6+
describe MoveTranslationsCustomFieldsToTable do
7+
let!(:batch_size) { 5 }
8+
9+
before { described_class.const_set(:BATCH_SIZE, batch_size) }
10+
11+
def create_custom_fields(count)
12+
count.times do
13+
post = Fabricate(:post)
14+
topic = Fabricate(:topic)
15+
16+
post.custom_fields[DETECTED_LANG_CUSTOM_FIELD] = "pt"
17+
post.save_custom_fields
18+
19+
topic.custom_fields[DETECTED_LANG_CUSTOM_FIELD] = "es"
20+
topic.save_custom_fields
21+
22+
post.custom_fields[TRANSLATED_CUSTOM_FIELD] = {
23+
en_GB: "The Romance of the Three Kingdoms",
24+
de: "Die Romanze der Drei Königreiche",
25+
}
26+
post.save_custom_fields
27+
28+
topic.custom_fields[TRANSLATED_CUSTOM_FIELD] = {
29+
en_GB: "The Romance of the Three Kingdoms",
30+
de: "Die Romanze der Drei Königreiche",
31+
}
32+
topic.save_custom_fields
33+
end
34+
end
35+
36+
it "correctly migrates custom fields in batches" do
37+
# batch size is 5
38+
create_custom_fields(12)
39+
40+
migration = described_class.new
41+
migration.up
42+
43+
# 12 posts * 2 translations each
44+
expect(PostLocale.count).to eq(12)
45+
expect(PostTranslation.count).to eq(24)
46+
47+
# 12 topics * 2 translations each
48+
expect(TopicLocale.count).to eq(12)
49+
expect(TopicTranslation.count).to eq(24)
50+
51+
expect(PostLocale.first.detected_locale).to eq("pt")
52+
53+
expect(PostTranslation.where(post_id: Post.first.id).pluck(:locale, :translation)).to include(
54+
["en_GB", "The Romance of the Three Kingdoms"],
55+
["de", "Die Romanze der Drei Königreiche"],
56+
)
57+
58+
migration.down
59+
expect(PostLocale.count).to eq(0)
60+
expect(PostTranslation.count).to eq(0)
61+
expect(TopicLocale.count).to eq(0)
62+
expect(TopicTranslation.count).to eq(0)
63+
end
64+
65+
it "ignores invalid JSON in translated_text" do
66+
post = Fabricate(:post)
67+
post.custom_fields[TRANSLATED_CUSTOM_FIELD] = "invalid json"
68+
post.save_custom_fields(true)
69+
70+
migration = described_class.new
71+
expect { migration.up }.not_to raise_error
72+
expect(PostTranslation.count).to eq(0)
73+
end
74+
75+
it "ignores translations with locale longer than 20 chars" do
76+
post = Fabricate(:post)
77+
post.custom_fields[TRANSLATED_CUSTOM_FIELD] = { very_very_long_locale_name: "test" }
78+
post.custom_fields[DETECTED_LANG_CUSTOM_FIELD] = "very_very_long_locale_name"
79+
post.save_custom_fields(true)
80+
81+
migration = described_class.new
82+
migration.up
83+
expect(PostLocale.count).to eq(0)
84+
expect(PostTranslation.count).to eq(0)
85+
end
86+
end
87+
end

0 commit comments

Comments
 (0)