From c1d9e3638baffccebd8823bd421773a5dd7b7707 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Tue, 4 Aug 2026 02:12:47 +0900 Subject: [PATCH 1/6] #354 --- ...27123600_add_tags_json_to_post_versions.rb | 279 ++++++++++++++++++ backend/db/schema.rb | 3 +- 2 files changed, 281 insertions(+), 1 deletion(-) create mode 100644 backend/db/migrate/20260727123600_add_tags_json_to_post_versions.rb diff --git a/backend/db/migrate/20260727123600_add_tags_json_to_post_versions.rb b/backend/db/migrate/20260727123600_add_tags_json_to_post_versions.rb new file mode 100644 index 0000000..d306715 --- /dev/null +++ b/backend/db/migrate/20260727123600_add_tags_json_to_post_versions.rb @@ -0,0 +1,279 @@ +class AddTagsJsonToPostVersions < ActiveRecord::Migration[8.0] + RESOLUTION_GRACE = 1.second + SECTION_LITERAL_PATTERN = /\[[^\[\]\s]*-[^\[\]\s]*\]\z/ + + class MigrationPostVersion < ActiveRecord::Base + self.table_name = 'post_versions' + end + + class MigrationTag < ActiveRecord::Base + self.table_name = 'tags' + end + + class MigrationTagName < ActiveRecord::Base + self.table_name = 'tag_names' + end + + class MigrationTagVersion < ActiveRecord::Base + self.table_name = 'tag_versions' + end + + class MigrationNicoTagVersion < ActiveRecord::Base + self.table_name = 'nico_tag_versions' + end + + def up + add_column :post_versions, :tags_json, :json, after: :tags + MigrationPostVersion.reset_column_information + + backfill_missing_initial_tag_versions! + intervals_by_name = build_intervals_by_name + + say_with_time 'Backfilling post_versions.tags_json' do + MigrationPostVersion + .where(tags_json: nil) + .find_each(batch_size: 500) do |version| + version.update_columns( + tags_json: build_tags_json(version, intervals_by_name)) + end + end + + assert_backfill_complete! + change_column_null :post_versions, :tags_json, false + end + + def down + remove_column :post_versions, :tags_json + end + + private + + def backfill_missing_initial_tag_versions! + say_with_time 'Backfilling missing initial tag versions' do + tag_rows = missing_initial_version_rows(MigrationTagVersion, nico: false) + nico_rows = missing_initial_version_rows(MigrationNicoTagVersion, nico: true) + + MigrationTagVersion.insert_all!(tag_rows) if tag_rows.any? + MigrationNicoTagVersion.insert_all!(nico_rows) if nico_rows.any? + + tag_rows.length + nico_rows.length + end + end + + def missing_initial_version_rows version_class, nico: + first_versions = + version_class + .order(:tag_id, :version_no) + .to_a + .group_by(&:tag_id) + .transform_values(&:first) + rows = [] + + MigrationTag.find_each do |tag| + next if (tag.category == 'nico') != nico + + first_version = first_versions[tag.id] + next unless first_version + next if valid_initial_version?(first_version) + + assert_inferable_initial_version!(tag, first_version) + rows << initial_version_row(tag, first_version, nico:) + end + + rows + end + + def valid_initial_version? version + version.version_no == 1 && version.event_type == 'create' + end + + def assert_inferable_initial_version! tag, version + inferable = + version.version_no == 2 && + version.event_type == 'discard' && + tag.created_at < version.created_at + return if inferable + + details = [ + "tag_id=#{ tag.id }", + "version_no=#{ version.version_no }", + "event_type=#{ version.event_type.inspect }", + "tag_created_at=#{ tag.created_at.iso8601(6) }", + "version_created_at=#{ version.created_at.iso8601(6) }"] + + raise "Cannot infer initial tag version: #{ details.join(', ') }" + end + + def initial_version_row tag, discard_version, nico: + row = { + tag_id: tag.id, + version_no: 1, + event_type: 'create', + name: discard_version.name, + created_at: tag.created_at, + created_by_user_id: nil } + + if nico + return row.merge(linked_tags: discard_version.linked_tags) + end + + row.merge( + category: discard_version.category, + aliases: discard_version.aliases, + parent_tag_ids: discard_version.parent_tag_ids, + deprecated_at: discard_version.deprecated_at) + end + + def build_intervals_by_name + intervals_by_name = Hash.new { |hash, name| hash[name] = [] } + versions_by_kind = { + tag: versions_by_tag_id(MigrationTagVersion), + nico: versions_by_tag_id(MigrationNicoTagVersion) } + current_names = current_names_by_tag_id + + MigrationTag.find_each do |tag| + nico = tag.category == 'nico' + kind = nico ? :nico : :tag + versions = versions_by_kind.fetch(kind).fetch(tag.id, []) + + intervals_for( + tag, + versions, + current_name: current_names.fetch(tag.id), + nico:).each do |interval| + name = interval.delete(:name) + intervals_by_name[name] << interval + end + end + + intervals_by_name + end + + def versions_by_tag_id version_class + version_class + .order(:tag_id, :version_no) + .to_a + .group_by(&:tag_id) + end + + def current_names_by_tag_id + MigrationTagName + .joins('INNER JOIN tags ON tags.tag_name_id = tag_names.id') + .pluck('tags.id', 'tag_names.name') + .to_h + end + + def intervals_for tag, versions, current_name:, nico: + if versions.empty? + return [{ + name: current_name, + tag_id: tag.id, + version_no: tag.version_no, + category: nico ? 'nico' : tag.category, + from: tag.created_at, + to: tag.discarded_at }] + end + + versions.each_with_index.filter_map do |version, index| + next if version.event_type == 'discard' + + { + name: version.name, + tag_id: tag.id, + version_no: version.version_no, + category: nico ? 'nico' : version.category, + from: version.created_at, + to: versions[index + 1]&.created_at || tag.discarded_at } + end + end + + def build_tags_json version, intervals_by_name + entries = version.tags.to_s.split.map do |literal| + name = tag_name_from_literal(literal) + interval = resolve_tag!(intervals_by_name.fetch(name, []), name:, version:) + + { 'id' => interval.fetch(:tag_id), + 'version_no' => interval.fetch(:version_no), + 'name' => name, + 'category' => interval.fetch(:category), + 'sections' => [] } + end + + assert_unique_tag_ids!(version, entries) + + entries.sort_by { |entry| entry.fetch('id') } + end + + def tag_name_from_literal literal + name = literal.dup + name.sub!(SECTION_LITERAL_PATTERN, '') while name.match?( + SECTION_LITERAL_PATTERN) + + if name.empty? || name.include?('[') || name.include?(']') + raise "Invalid legacy tag literal: #{ literal.inspect }" + end + + name + end + + def resolve_tag! intervals, name:, version: + time = version.created_at + candidates = intervals.select do |interval| + interval.fetch(:from) <= time && + (interval[:to].nil? || time < interval.fetch(:to)) + end + + candidates = future_candidates(intervals, time) if candidates.empty? + + return candidates.first if candidates.one? + + candidate_versions = candidates.map do |candidate| + [candidate.fetch(:tag_id), candidate.fetch(:version_no)] + end + details = [ + "post_version_id=#{ version.id }", + "post_id=#{ version.post_id }", + "name=#{ name.inspect }", + "created_at=#{ time.iso8601(6) }", + "candidates=#{ candidate_versions.inspect }"].join(', ') + + raise "Could not resolve tag snapshot: #{ details }" + end + + def future_candidates intervals, time + candidates = intervals.select do |interval| + interval.fetch(:from) > time && + interval.fetch(:from) <= time + RESOLUTION_GRACE + end + return [] if candidates.empty? + + nearest_from = candidates.map { |interval| interval.fetch(:from) }.min + + candidates.select do |interval| + interval.fetch(:from) == nearest_from + end + end + + def assert_unique_tag_ids! version, entries + duplicate_tag_ids = + entries + .map { |entry| entry.fetch('id') } + .tally + .select { |_tag_id, count| count > 1 } + .keys + return if duplicate_tag_ids.empty? + + details = [ + "post_version_id=#{ version.id }", + "duplicate_tag_ids=#{ duplicate_tag_ids.inspect }"].join(', ') + + raise "Duplicate tag IDs: #{ details }" + end + + def assert_backfill_complete! + missing_count = MigrationPostVersion.where(tags_json: nil).count + return if missing_count.zero? + + raise "#{ missing_count } post versions were not backfilled" + end +end diff --git a/backend/db/schema.rb b/backend/db/schema.rb index c515aee..d4a82c7 100644 --- a/backend/db/schema.rb +++ b/backend/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2026_07_13_000000) do +ActiveRecord::Schema[8.0].define(version: 2026_07_27_123600) do create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "name", null: false t.string "record_type", null: false @@ -349,6 +349,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_13_000000) do t.string "url", limit: 768, null: false t.string "thumbnail_base", limit: 2000 t.text "tags", null: false + t.json "tags_json", null: false t.text "parent_post_ids", null: false t.datetime "original_created_from" t.datetime "original_created_before" -- 2.34.1 From 5bb80d1a3575e7a620c9b2ab97a66c8329abccbd Mon Sep 17 00:00:00 2001 From: miteruzo Date: Wed, 5 Aug 2026 03:24:01 +0900 Subject: [PATCH 2/6] #354 --- .../controllers/post_versions_controller.rb | 77 +++++++++---------- backend/app/controllers/posts_controller.rb | 5 +- backend/app/models/post.rb | 15 ++++ backend/app/services/post_version_recorder.rb | 1 + 4 files changed, 55 insertions(+), 43 deletions(-) diff --git a/backend/app/controllers/post_versions_controller.rb b/backend/app/controllers/post_versions_controller.rb index ae3d897..a04abf2 100644 --- a/backend/app/controllers/post_versions_controller.rb +++ b/backend/app/controllers/post_versions_controller.rb @@ -23,17 +23,19 @@ class PostVersionsController < ApplicationController prev.post_id = post_versions.post_id AND prev.version_no = post_versions.version_no - 1 SQL - .select('post_versions.*', 'prev.title AS prev_title', 'prev.url AS prev_url', - 'prev.thumbnail_base AS prev_thumbnail_base', 'prev.tags AS prev_tags', + .select('post_versions.*', + 'prev.title AS prev_title', + 'prev.url AS prev_url', + 'prev.thumbnail_base AS prev_thumbnail_base', + 'prev.tags_json AS prev_tags_json', 'prev.video_ms AS prev_video_ms', 'prev.original_created_from AS prev_original_created_from', 'prev.original_created_before AS prev_original_created_before') q = q.where('post_versions.post_id = ?', post_id) if post_id if tag_name escaped = ActiveRecord::Base.sanitize_sql_like(tag_name.name) - q = q.where(("CONCAT(' ', post_versions.tags, ' ') LIKE :kw " + - "OR CONCAT(' ', prev.tags, ' ') LIKE :kw"), - kw: "% #{ escaped } %") + q = q.where(("JSON_CONTAINTS(post_version.tags_json, JSON_OBJECT('id', :tag_id)) " + + "OR JSON_CONTAINS(prev.tags_json, JSON_OBJECT('id', :tag_id))"), tag_id:) end count = q.except(:select, :order, :limit, :offset).count @@ -47,56 +49,47 @@ class PostVersionsController < ApplicationController private + def tag_literal tag + sections = tag.fetch('sections', []).map do |sec| + begin_ms = sec.fetch('begin_ms') + end_ms = sec['end_ms'] + + "[#{ Post.ms_to_time(begin_ms) }-#{ end_ms ? Post.ms_to_time(end_ms) : '' }]" + end + + "#{ tag.fetch('name') }#{ sections.join }" + end + def serialise_versions rows user_ids = rows.map(&:created_by_user_id).compact.uniq users_by_id = User.where(id: user_ids).pluck(:id, :name).to_h rows.map do |row| - cur_tags = split_tags(row.tags) - prev_tags = split_tags(row.attributes['prev_tags']) + cur_tags = split_tags(row.tags_json.map { tag_literal(_1) }) + prev_tags = split_tags(row.attributes['prev_tags_json'].map { tag_literal(_1) }) - { - post_id: row.post_id, + { post_id: row.post_id, version_no: row.version_no, event_type: row.event_type, - title: { - current: row.title, - prev: row.attributes['prev_title'] - }, - url: { - current: row.url, - prev: row.attributes['prev_url'] - }, - thumbnail: { - current: nil, - prev: nil - }, - thumbnail_base: { - current: row.thumbnail_base, - prev: row.attributes['prev_thumbnail_base'] - }, - video_ms: { - current: row.video_ms, - prev: row.attributes['prev_video_ms'] - }, + title: { current: row.title, prev: row.attributes['prev_title'] }, + url: { current: row.url, prev: row.attributes['prev_url'] }, + thumbnail: { current: nil, prev: nil }, + thumbnail_base: { current: row.thumbnail_base, + prev: row.attributes['prev_thumbnail_base'] }, + video_ms: { current: row.video_ms, prev: row.attributes['prev_video_ms'] }, tags: build_version_tags(cur_tags, prev_tags), original_created_from: { - current: row.original_created_from&.iso8601, - prev: row.attributes['prev_original_created_from']&.iso8601 - }, + current: row.original_created_from&.iso8601, + prev: row.attributes['prev_original_created_from']&.iso8601 }, original_created_before: { - current: row.original_created_before&.iso8601, - prev: row.attributes['prev_original_created_before']&.iso8601 - }, + current: row.original_created_before&.iso8601, + prev: row.attributes['prev_original_created_before']&.iso8601 }, created_at: row.created_at.iso8601, created_by_user: - if row.created_by_user_id - { - id: row.created_by_user_id, - name: users_by_id[row.created_by_user_id] - } - end - } + if row.created_by_user_id + { id: row.created_by_user_id, + name: users_by_id[row.created_by_user_id] } + end } end end diff --git a/backend/app/controllers/posts_controller.rb b/backend/app/controllers/posts_controller.rb index fb3c61a..eec228b 100644 --- a/backend/app/controllers/posts_controller.rb +++ b/backend/app/controllers/posts_controller.rb @@ -699,7 +699,10 @@ class PostsController < ApplicationController end def editable_tag_names_from_version version - version.tags.to_s.split.reject { |name| name.downcase.start_with?('nico:') }.sort + version.tags_json + .reject { _1[:name].downcase.start_with?('nico:') } + .map { tag_literal(_1) } + .sort end def post_snapshot_from_record post diff --git a/backend/app/models/post.rb b/backend/app/models/post.rb index 4f5b09d..cde0929 100644 --- a/backend/app/models/post.rb +++ b/backend/app/models/post.rb @@ -137,6 +137,21 @@ class Post < ApplicationRecord end end + def snapshot_tags_json + post_tags + .kept + .joins(tag: :tag_name) + .includes(:sections, tag: :tag_name) + .order('tag_names.name') + .map do |pt| + { id: pt.tag.id, + version_no: pt.tag.version_no, + name: pt.tag.name, + category: pt.tag.category, + sections: pt.sections.map { { begin_ms: _1.begin_ms, end_ms: _1.end_ms } } } + end + end + def self.section_literal section end_ms = section.end_ms ? Post.ms_to_time(section.end_ms) : '' diff --git a/backend/app/services/post_version_recorder.rb b/backend/app/services/post_version_recorder.rb index 0726884..873770b 100644 --- a/backend/app/services/post_version_recorder.rb +++ b/backend/app/services/post_version_recorder.rb @@ -25,6 +25,7 @@ class PostVersionRecorder < VersionRecorder thumbnail_base: @record.thumbnail_base, video_ms: @record.video_ms, tags: @record.snapshot_tag_names.join(' '), + tags_json: @record.snapshot_tags_json, parent_post_ids: @record.snapshot_parent_post_ids.join(' '), original_created_from: @record.original_created_from, original_created_before: @record.original_created_before } -- 2.34.1 From b5b5391c8e455e283d75ababf44b91919a334219 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Wed, 5 Aug 2026 12:43:33 +0900 Subject: [PATCH 3/6] #354 --- .../app/controllers/application_controller.rb | 7 ++++ .../controllers/post_versions_controller.rb | 39 ++++++------------- backend/app/controllers/posts_controller.rb | 4 +- backend/app/models/post.rb | 25 +++++++++--- 4 files changed, 39 insertions(+), 36 deletions(-) diff --git a/backend/app/controllers/application_controller.rb b/backend/app/controllers/application_controller.rb index 42adc75..95ec17a 100644 --- a/backend/app/controllers/application_controller.rb +++ b/backend/app/controllers/application_controller.rb @@ -85,4 +85,11 @@ class ApplicationController < ActionController::API base_errors: }, status: end + + def normalise_json value + return nil if value.nil? + return JSON.parse(value) if value.is_a?(String) + + value + end end diff --git a/backend/app/controllers/post_versions_controller.rb b/backend/app/controllers/post_versions_controller.rb index a04abf2..0c6f632 100644 --- a/backend/app/controllers/post_versions_controller.rb +++ b/backend/app/controllers/post_versions_controller.rb @@ -1,7 +1,7 @@ class PostVersionsController < ApplicationController def index post_id = params[:post].presence - tag_id = params[:tag].presence + tag_id = params[:tag].presence&.to_i page = (params[:page].presence || 1).to_i limit = (params[:limit].presence || 20).to_i @@ -10,12 +10,6 @@ class PostVersionsController < ApplicationController offset = (page - 1) * limit - tag_name = - if tag_id - TagName.joins(:tag).find_by(tag: { id: tag_id }) - end - return render json: { versions: [], count: 0 } if tag_id && tag_name.blank? - q = PostVersion.joins(<<~SQL.squish) LEFT JOIN post_versions prev @@ -32,10 +26,10 @@ class PostVersionsController < ApplicationController 'prev.original_created_from AS prev_original_created_from', 'prev.original_created_before AS prev_original_created_before') q = q.where('post_versions.post_id = ?', post_id) if post_id - if tag_name - escaped = ActiveRecord::Base.sanitize_sql_like(tag_name.name) - q = q.where(("JSON_CONTAINTS(post_version.tags_json, JSON_OBJECT('id', :tag_id)) " + - "OR JSON_CONTAINS(prev.tags_json, JSON_OBJECT('id', :tag_id))"), tag_id:) + if tag_id + q = q.where(("JSON_CONTAINS(post_versions.tags_json, JSON_OBJECT('id', :tag_id)) " + + "OR JSON_CONTAINS(prev.tags_json, JSON_OBJECT('id', :tag_id))"), + tag_id:) end count = q.except(:select, :order, :limit, :offset).count @@ -49,24 +43,17 @@ class PostVersionsController < ApplicationController private - def tag_literal tag - sections = tag.fetch('sections', []).map do |sec| - begin_ms = sec.fetch('begin_ms') - end_ms = sec['end_ms'] - - "[#{ Post.ms_to_time(begin_ms) }-#{ end_ms ? Post.ms_to_time(end_ms) : '' }]" - end - - "#{ tag.fetch('name') }#{ sections.join }" - end - def serialise_versions rows user_ids = rows.map(&:created_by_user_id).compact.uniq users_by_id = User.where(id: user_ids).pluck(:id, :name).to_h rows.map do |row| - cur_tags = split_tags(row.tags_json.map { tag_literal(_1) }) - prev_tags = split_tags(row.attributes['prev_tags_json'].map { tag_literal(_1) }) + cur_tags = + Array(normalise_json(row.tags_json)) + .map { Post.tag_snapshot_literal(_1) } + prev_tags = + Array(normalise_json(row.attributes['prev_tags_json'])) + .map { Post.tag_snapshot_literal(_1) } { post_id: row.post_id, version_no: row.version_no, @@ -110,8 +97,4 @@ class PostVersionsController < ApplicationController } end end - - def split_tags(tags) - tags.to_s.split(/\s+/).reject(&:blank?) - end end diff --git a/backend/app/controllers/posts_controller.rb b/backend/app/controllers/posts_controller.rb index eec228b..3f5a92c 100644 --- a/backend/app/controllers/posts_controller.rb +++ b/backend/app/controllers/posts_controller.rb @@ -700,8 +700,8 @@ class PostsController < ApplicationController def editable_tag_names_from_version version version.tags_json - .reject { _1[:name].downcase.start_with?('nico:') } - .map { tag_literal(_1) } + .reject { _1.fetch('category') == 'nico' } + .map { Post.tag_snapshot_literal(_1) } .sort end diff --git a/backend/app/models/post.rb b/backend/app/models/post.rb index cde0929..08f8fec 100644 --- a/backend/app/models/post.rb +++ b/backend/app/models/post.rb @@ -137,18 +137,31 @@ class Post < ApplicationRecord end end + def self.tag_snapshot_literal tag + sections = tag.fetch('sections', []).map do |sec| + begin_ms = sec.fetch('begin_ms') + end_ms = sec['end_ms'] + + "[#{ Post.ms_to_time(begin_ms) }-#{ end_ms ? Post.ms_to_time(end_ms) : '' }]" + end + + "#{ tag.fetch('name') }#{ sections.join }" + end + def snapshot_tags_json post_tags .kept .joins(tag: :tag_name) .includes(:sections, tag: :tag_name) - .order('tag_names.name') + .order('tags.id') .map do |pt| - { id: pt.tag.id, - version_no: pt.tag.version_no, - name: pt.tag.name, - category: pt.tag.category, - sections: pt.sections.map { { begin_ms: _1.begin_ms, end_ms: _1.end_ms } } } + { 'id' => pt.tag.id, + 'version_no' => pt.tag.version_no, + 'name' => pt.tag.name, + 'category' => pt.tag.category, + 'sections' => pt.sections.sort_by(&:begin_ms).map { + { 'begin_ms' => _1.begin_ms, 'end_ms' => _1.end_ms } + } } end end -- 2.34.1 From acd8046a77b305a622a7b519d02abae43e0fbace Mon Sep 17 00:00:00 2001 From: miteruzo Date: Thu, 6 Aug 2026 06:49:05 +0900 Subject: [PATCH 4/6] #354 --- .../controllers/post_versions_controller.rb | 4 +- ...27123600_add_tags_json_to_post_versions.rb | 46 +++++++++++++------ backend/db/schema.rb | 1 + 3 files changed, 36 insertions(+), 15 deletions(-) diff --git a/backend/app/controllers/post_versions_controller.rb b/backend/app/controllers/post_versions_controller.rb index 0c6f632..545b8e0 100644 --- a/backend/app/controllers/post_versions_controller.rb +++ b/backend/app/controllers/post_versions_controller.rb @@ -49,10 +49,10 @@ class PostVersionsController < ApplicationController rows.map do |row| cur_tags = - Array(normalise_json(row.tags_json)) + normalise_json(row.tags_json) .map { Post.tag_snapshot_literal(_1) } prev_tags = - Array(normalise_json(row.attributes['prev_tags_json'])) + (normalise_json(row.attributes['prev_tags_json']) || []) .map { Post.tag_snapshot_literal(_1) } { post_id: row.post_id, diff --git a/backend/db/migrate/20260727123600_add_tags_json_to_post_versions.rb b/backend/db/migrate/20260727123600_add_tags_json_to_post_versions.rb index d306715..7bbf3c8 100644 --- a/backend/db/migrate/20260727123600_add_tags_json_to_post_versions.rb +++ b/backend/db/migrate/20260727123600_add_tags_json_to_post_versions.rb @@ -30,19 +30,46 @@ class AddTagsJsonToPostVersions < ActiveRecord::Migration[8.0] intervals_by_name = build_intervals_by_name say_with_time 'Backfilling post_versions.tags_json' do - MigrationPostVersion - .where(tags_json: nil) - .find_each(batch_size: 500) do |version| - version.update_columns( - tags_json: build_tags_json(version, intervals_by_name)) + MigrationPostVersion.where(tags_json: nil).find_each(batch_size: 500) do |version| + version.update_columns(tags_json: build_tags_json(version, intervals_by_name)) end end - assert_backfill_complete! change_column_null :post_versions, :tags_json, false + + schema = connection.quote(JSON.generate({ + type: 'array', + items: { type: 'object', + properties: { id: { type: 'integer', minimum: 1 }, + version_no: { type: 'integer', minimum: 1 }, + name: { type: 'string', minLength: 1 }, + category: { type: 'string', enum: ['deerjikist', + 'meme', + 'character', + 'general', + 'material', + 'meta', + 'nico'] }, + sections: { type: 'array', + items: { type: 'object', + properties: { begin_ms: { type: 'integer', + minimum: 0 }, + end_ms: { type: ['integer', + 'null'], + minimum: 0 } }, + required: ['begin_ms', 'end_ms'], + additionalProperties: false } } }, + required: ['id', 'version_no', 'name', 'category', 'sections'], + additionalProperties: false } })) + + add_check_constraint :post_versions, + "JSON_SCHEMA_VALID(#{ schema }, tags_json)", + name: 'chk_post_versions_tags_json_schema' end def down + remove_check_constraint :post_versions, name: 'chk_post_versions_tags_json_schema' + remove_column :post_versions, :tags_json end @@ -269,11 +296,4 @@ class AddTagsJsonToPostVersions < ActiveRecord::Migration[8.0] raise "Duplicate tag IDs: #{ details }" end - - def assert_backfill_complete! - missing_count = MigrationPostVersion.where(tags_json: nil).count - return if missing_count.zero? - - raise "#{ missing_count } post versions were not backfilled" - end end diff --git a/backend/db/schema.rb b/backend/db/schema.rb index d4a82c7..5ebbcc5 100644 --- a/backend/db/schema.rb +++ b/backend/db/schema.rb @@ -363,6 +363,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_27_123600) do t.check_constraint "(`video_ms` is null) or (`video_ms` > 0)", name: "chk_post_versions_video_ms_positive" t.check_constraint "`event_type` in (_utf8mb4'create',_utf8mb4'update',_utf8mb4'discard',_utf8mb4'restore')", name: "post_versions_event_type_valid" t.check_constraint "`version_no` > 0", name: "post_versions_version_no_positive" + t.check_constraint "json_schema_valid(_utf8mb4'{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"id\":{\"type\":\"integer\",\"minimum\":1},\"version_no\":{\"type\":\"integer\",\"minimum\":1},\"name\":{\"type\":\"string\",\"minLength\":1},\"category\":{\"type\":\"string\",\"enum\":[\"deerjikist\",\"meme\",\"character\",\"general\",\"material\",\"meta\",\"nico\"]},\"sections\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"begin_ms\":{\"type\":\"integer\",\"minimum\":0},\"end_ms\":{\"type\":[\"integer\",\"null\"],\"minimum\":0}},\"required\":[\"begin_ms\",\"end_ms\"],\"additionalProperties\":false}}},\"required\":[\"id\",\"version_no\",\"name\",\"category\",\"sections\"],\"additionalProperties\":false}}',`tags_json`)", name: "chk_post_versions_tags_json_schema" end create_table "posts", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| -- 2.34.1 From 96b6ff32679de6b808e4ebf0c8ca712f6cf836c7 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Fri, 7 Aug 2026 01:40:24 +0900 Subject: [PATCH 5/6] #354 --- .../controllers/post_versions_controller.rb | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/backend/app/controllers/post_versions_controller.rb b/backend/app/controllers/post_versions_controller.rb index 545b8e0..f6b5223 100644 --- a/backend/app/controllers/post_versions_controller.rb +++ b/backend/app/controllers/post_versions_controller.rb @@ -50,9 +50,43 @@ class PostVersionsController < ApplicationController rows.map do |row| cur_tags = normalise_json(row.tags_json) + .sort_by { [(case _1.fetch('category') + when 'deerjikist' + 0 + when 'meme' + 1 + when 'character' + 2 + when 'general' + 3 + when 'material' + 4 + when 'meta' + 5 + else + 6 + end), + _1.fetch('name').downcase] } .map { Post.tag_snapshot_literal(_1) } prev_tags = (normalise_json(row.attributes['prev_tags_json']) || []) + .sort_by { [(case _1.fetch('category') + when 'deerjikist' + 0 + when 'meme' + 1 + when 'character' + 2 + when 'general' + 3 + when 'material' + 4 + when 'meta' + 5 + else + 6 + end), + _1.fetch('name').downcase] } .map { Post.tag_snapshot_literal(_1) } { post_id: row.post_id, -- 2.34.1 From 4cd1104213264894239117ad18f552edc45376df Mon Sep 17 00:00:00 2001 From: miteruzo Date: Fri, 7 Aug 2026 01:53:00 +0900 Subject: [PATCH 6/6] #354 --- backend/app/controllers/post_versions_controller.rb | 5 ++--- backend/spec/models/post_version_spec.rb | 1 + backend/spec/models/tag_spec.rb | 1 + backend/spec/requests/posts_spec.rb | 2 ++ backend/spec/tasks/nico_sync_spec.rb | 1 + 5 files changed, 7 insertions(+), 3 deletions(-) diff --git a/backend/app/controllers/post_versions_controller.rb b/backend/app/controllers/post_versions_controller.rb index f6b5223..e938a11 100644 --- a/backend/app/controllers/post_versions_controller.rb +++ b/backend/app/controllers/post_versions_controller.rb @@ -27,9 +27,8 @@ class PostVersionsController < ApplicationController 'prev.original_created_before AS prev_original_created_before') q = q.where('post_versions.post_id = ?', post_id) if post_id if tag_id - q = q.where(("JSON_CONTAINS(post_versions.tags_json, JSON_OBJECT('id', :tag_id)) " + - "OR JSON_CONTAINS(prev.tags_json, JSON_OBJECT('id', :tag_id))"), - tag_id:) + q = q.where("JSON_CONTAINS(post_versions.tags_json, JSON_OBJECT('id', #{ tag_id })) " + + "OR JSON_CONTAINS(prev.tags_json, JSON_OBJECT('id', #{ tag_id }))") end count = q.except(:select, :order, :limit, :offset).count diff --git a/backend/spec/models/post_version_spec.rb b/backend/spec/models/post_version_spec.rb index 8b8548a..4cc0e82 100644 --- a/backend/spec/models/post_version_spec.rb +++ b/backend/spec/models/post_version_spec.rb @@ -19,6 +19,7 @@ RSpec.describe PostVersion, type: :model do url: post_record.url, thumbnail_base: post_record.thumbnail_base, tags: post_record.snapshot_tag_names.join(' '), + tags_json: post_record.snapshot_tags_json, parent_post_ids: post_record.snapshot_parent_post_ids.join(' '), original_created_from: post_record.original_created_from, original_created_before: post_record.original_created_before, diff --git a/backend/spec/models/tag_spec.rb b/backend/spec/models/tag_spec.rb index d1b64e0..ccff0be 100644 --- a/backend/spec/models/tag_spec.rb +++ b/backend/spec/models/tag_spec.rb @@ -333,6 +333,7 @@ RSpec.describe Tag, type: :model do url: post.url, thumbnail_base: post.thumbnail_base, tags: snapshot_tags(post), + tags_json: post.snapshot_tags_json, parent_post_ids: post.snapshot_parent_post_ids.join(' '), original_created_from: post.original_created_from, original_created_before: post.original_created_before, diff --git a/backend/spec/requests/posts_spec.rb b/backend/spec/requests/posts_spec.rb index e1a0ee4..26a7c2b 100644 --- a/backend/spec/requests/posts_spec.rb +++ b/backend/spec/requests/posts_spec.rb @@ -55,6 +55,7 @@ RSpec.describe 'Posts API', type: :request do thumbnail_base: post.thumbnail_base, video_ms: post.video_ms, tags: post.snapshot_tag_names.join(' '), + tags_json: post.snapshot_tags_json, parent_post_ids: post.snapshot_parent_post_ids.join(' '), original_created_from: post.original_created_from, original_created_before: post.original_created_before, @@ -2024,6 +2025,7 @@ RSpec.describe 'Posts API', type: :request do url: post.url, thumbnail_base: post.thumbnail_base, tags: snapshot_tags(post), + tags_json: post.snapshot_tags_json, parent_post_ids: post.snapshot_parent_post_ids.join(' '), original_created_from: post.original_created_from, original_created_before: post.original_created_before, diff --git a/backend/spec/tasks/nico_sync_spec.rb b/backend/spec/tasks/nico_sync_spec.rb index 75e531f..04833e5 100644 --- a/backend/spec/tasks/nico_sync_spec.rb +++ b/backend/spec/tasks/nico_sync_spec.rb @@ -156,6 +156,7 @@ RSpec.describe 'nico:sync' do url: post.url, thumbnail_base: post.thumbnail_base, tags: snapshot_tags(post), + tags_json: post.snapshot_tags_json, parent_post_ids: post.snapshot_parent_post_ids.join(' '), original_created_from: post.original_created_from, original_created_before: post.original_created_before, -- 2.34.1