Skip to content

Commit f371b32

Browse files
authored
Change hashtags to preserve first-used casing (mastodon#11416)
1 parent 4cc29eb commit f371b32

6 files changed

Lines changed: 53 additions & 18 deletions

File tree

app/lib/activitypub/activity/create.rb

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -148,12 +148,9 @@ def process_tags
148148
def process_hashtag(tag)
149149
return if tag['name'].blank?
150150

151-
hashtag = tag['name'].gsub(/\A#/, '').mb_chars.downcase
152-
hashtag = Tag.where(name: hashtag).first_or_create!(name: hashtag)
153-
154-
return if @tags.include?(hashtag)
155-
156-
@tags << hashtag
151+
Tag.find_or_create_by_names(tag['name']) do |hashtag|
152+
@tags << hashtag unless @tags.include?(hashtag)
153+
end
157154
rescue ActiveRecord::RecordInvalid
158155
nil
159156
end

app/models/tag.rb

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ class Tag < ApplicationRecord
2020
HASHTAG_NAME_RE = '([[:word:]_][[:word:]_·]*[[:alpha:]_·][[:word:]_·]*[[:word:]_])|([[:word:]_]*[[:alpha:]][[:word:]_]*)'
2121
HASHTAG_RE = /(?:^|[^\/\)\w])#(#{HASHTAG_NAME_RE})/i
2222

23-
validates :name, presence: true, uniqueness: true, format: { with: /\A(#{HASHTAG_NAME_RE})\z/i }
23+
validates :name, presence: true, format: { with: /\A(#{HASHTAG_NAME_RE})\z/i }
2424

2525
scope :discoverable, -> { joins(:account_tag_stat).where(AccountTagStat.arel_table[:accounts_count].gt(0)).where(account_tag_stats: { hidden: false }).order(Arel.sql('account_tag_stats.accounts_count desc')) }
2626
scope :hidden, -> { where(account_tag_stats: { hidden: true }) }
@@ -64,22 +64,48 @@ def history
6464
end
6565

6666
class << self
67+
def find_or_create_by_names(name_or_names)
68+
Array(name_or_names).map(&method(:normalize)).uniq.map do |normalized_name|
69+
tag = matching_name(normalized_name).first || create(name: normalized_name)
70+
71+
yield tag if block_given?
72+
73+
tag
74+
end
75+
end
76+
6777
def search_for(term, limit = 5, offset = 0)
68-
pattern = sanitize_sql_like(term.strip) + '%'
78+
pattern = sanitize_sql_like(normalize(term.strip)) + '%'
6979

70-
Tag.where('lower(name) like lower(?)', pattern)
80+
Tag.where(arel_table[:name].lower.matches(pattern.downcase))
7181
.order(:name)
7282
.limit(limit)
7383
.offset(offset)
7484
end
7585

7686
def find_normalized(name)
77-
find_by(name: name.mb_chars.downcase.to_s)
87+
matching_name(name).first
7888
end
7989

8090
def find_normalized!(name)
8191
find_normalized(name) || raise(ActiveRecord::RecordNotFound)
8292
end
93+
94+
def matching_name(name_or_names)
95+
names = Array(name_or_names).map { |name| normalize(name).downcase }
96+
97+
if names.size == 1
98+
where(arel_table[:name].lower.eq(names.first))
99+
else
100+
where(arel_table[:name].lower.in(names))
101+
end
102+
end
103+
104+
private
105+
106+
def normalize(str)
107+
str.gsub(/\A#/, '').mb_chars.to_s
108+
end
83109
end
84110

85111
private

app/services/hashtag_query_service.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ def call(tag, params, account = nil, local = false)
1414

1515
private
1616

17-
def tags_for(tags)
18-
Tag.where(name: tags.map(&:downcase)) if tags.presence
17+
def tags_for(names)
18+
Tag.matching_name(names) if names.presence
1919
end
2020
end

app/services/process_hashtags_service.rb

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,7 @@ def call(status, tags = [])
55
tags = Extractor.extract_hashtags(status.text) if status.local?
66
records = []
77

8-
tags.map { |str| str.mb_chars.downcase }.uniq(&:to_s).each do |name|
9-
tag = Tag.where(name: name).first_or_create(name: name)
10-
8+
Tag.find_or_create_by_names(tags) do |tag|
119
status.tags << tag
1210
records << tag
1311

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
class AddCaseInsensitiveIndexToTags < ActiveRecord::Migration[5.2]
2+
disable_ddl_transaction!
3+
4+
def up
5+
safety_assured { execute 'CREATE UNIQUE INDEX CONCURRENTLY index_tags_on_name_lower ON tags (lower(name))' }
6+
remove_index :tags, name: 'index_tags_on_name'
7+
remove_index :tags, name: 'hashtag_search_index'
8+
end
9+
10+
def down
11+
add_index :tags, :name, unique: true, algorithm: :concurrently
12+
safety_assured { execute 'CREATE INDEX CONCURRENTLY hashtag_search_index ON tags (name text_pattern_ops)' }
13+
remove_index :tags, name: 'index_tags_on_name_lower'
14+
end
15+
end

db/schema.rb

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
#
1111
# It's strongly recommended that you check this file into your version control system.
1212

13-
ActiveRecord::Schema.define(version: 2019_07_15_164535) do
13+
ActiveRecord::Schema.define(version: 2019_07_26_175042) do
1414

1515
# These are extensions that must be enabled in order to support this database
1616
enable_extension "plpgsql"
@@ -652,8 +652,7 @@
652652
t.string "name", default: "", null: false
653653
t.datetime "created_at", null: false
654654
t.datetime "updated_at", null: false
655-
t.index "lower((name)::text) text_pattern_ops", name: "hashtag_search_index"
656-
t.index ["name"], name: "index_tags_on_name", unique: true
655+
t.index "lower((name)::text)", name: "index_tags_on_name_lower", unique: true
657656
end
658657

659658
create_table "tombstones", force: :cascade do |t|

0 commit comments

Comments
 (0)