コミットを比較

..
4 コミット
作成者 SHA1 メッセージ 日付
みてるぞ b9d7c4fef5 #444 2026-11-08 01:56:45 +09:00
みてるぞ 10b3717886 Merge remote-tracking branch 'origin/main' into feature/444 2026-11-07 20:06:40 +09:00
みてるぞ 8d00a6545e API の処理に時間がかかりすぎる問題 (#442) (#449)
Reviewed-on: #449
Co-authored-by: miteruzo <miteruzo@naver.com>
2026-11-07 19:01:20 +09:00
みてるぞ 9e803d473a #444 2026-11-07 15:50:00 +09:00
57個のファイルの変更、1030行の追加、1067行の削除
+2
ファイルの表示
@@ -71,3 +71,5 @@ gem "rspec-rails", "~> 8.0", :groups => [:development, :test]
gem 'aws-sdk-s3', require: false gem 'aws-sdk-s3', require: false
gem 'rails-i18n', '~> 8.0.0' gem 'rails-i18n', '~> 8.0.0'
gem "json", "~> 2.19.9"
+142 -134
ファイルの表示
@@ -1,29 +1,29 @@
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
actioncable (8.0.2) actioncable (8.0.5.1)
actionpack (= 8.0.2) actionpack (= 8.0.5.1)
activesupport (= 8.0.2) activesupport (= 8.0.5.1)
nio4r (~> 2.0) nio4r (~> 2.0)
websocket-driver (>= 0.6.1) websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6) zeitwerk (~> 2.6)
actionmailbox (8.0.2) actionmailbox (8.0.5.1)
actionpack (= 8.0.2) actionpack (= 8.0.5.1)
activejob (= 8.0.2) activejob (= 8.0.5.1)
activerecord (= 8.0.2) activerecord (= 8.0.5.1)
activestorage (= 8.0.2) activestorage (= 8.0.5.1)
activesupport (= 8.0.2) activesupport (= 8.0.5.1)
mail (>= 2.8.0) mail (>= 2.8.0)
actionmailer (8.0.2) actionmailer (8.0.5.1)
actionpack (= 8.0.2) actionpack (= 8.0.5.1)
actionview (= 8.0.2) actionview (= 8.0.5.1)
activejob (= 8.0.2) activejob (= 8.0.5.1)
activesupport (= 8.0.2) activesupport (= 8.0.5.1)
mail (>= 2.8.0) mail (>= 2.8.0)
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
actionpack (8.0.2) actionpack (8.0.5.1)
actionview (= 8.0.2) actionview (= 8.0.5.1)
activesupport (= 8.0.2) activesupport (= 8.0.5.1)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
rack (>= 2.2.4) rack (>= 2.2.4)
rack-session (>= 1.0.1) rack-session (>= 1.0.1)
@@ -31,35 +31,35 @@ GEM
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6) rails-html-sanitizer (~> 1.6)
useragent (~> 0.16) useragent (~> 0.16)
actiontext (8.0.2) actiontext (8.0.5.1)
actionpack (= 8.0.2) actionpack (= 8.0.5.1)
activerecord (= 8.0.2) activerecord (= 8.0.5.1)
activestorage (= 8.0.2) activestorage (= 8.0.5.1)
activesupport (= 8.0.2) activesupport (= 8.0.5.1)
globalid (>= 0.6.0) globalid (>= 0.6.0)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
actionview (8.0.2) actionview (8.0.5.1)
activesupport (= 8.0.2) activesupport (= 8.0.5.1)
builder (~> 3.1) builder (~> 3.1)
erubi (~> 1.11) erubi (~> 1.11)
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6) rails-html-sanitizer (~> 1.6)
activejob (8.0.2) activejob (8.0.5.1)
activesupport (= 8.0.2) activesupport (= 8.0.5.1)
globalid (>= 0.3.6) globalid (>= 0.3.6)
activemodel (8.0.2) activemodel (8.0.5.1)
activesupport (= 8.0.2) activesupport (= 8.0.5.1)
activerecord (8.0.2) activerecord (8.0.5.1)
activemodel (= 8.0.2) activemodel (= 8.0.5.1)
activesupport (= 8.0.2) activesupport (= 8.0.5.1)
timeout (>= 0.4.0) timeout (>= 0.4.0)
activestorage (8.0.2) activestorage (8.0.5.1)
actionpack (= 8.0.2) actionpack (= 8.0.5.1)
activejob (= 8.0.2) activejob (= 8.0.5.1)
activerecord (= 8.0.2) activerecord (= 8.0.5.1)
activesupport (= 8.0.2) activesupport (= 8.0.5.1)
marcel (~> 1.0) marcel (~> 1.0)
activesupport (8.0.2) activesupport (8.0.5.1)
base64 base64
benchmark (>= 0.3) benchmark (>= 0.3)
bigdecimal bigdecimal
@@ -92,22 +92,22 @@ GEM
aws-sigv4 (~> 1.5) aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1) aws-sigv4 (1.12.1)
aws-eventstream (~> 1, >= 1.0.2) aws-eventstream (~> 1, >= 1.0.2)
base64 (0.2.0) base64 (0.3.0)
bcrypt_pbkdf (1.1.1) bcrypt_pbkdf (1.1.1)
bcrypt_pbkdf (1.1.1-arm64-darwin) bcrypt_pbkdf (1.1.1-arm64-darwin)
bcrypt_pbkdf (1.1.1-x86_64-darwin) bcrypt_pbkdf (1.1.1-x86_64-darwin)
benchmark (0.4.0) benchmark (0.5.0)
bigdecimal (3.1.9) bigdecimal (4.1.3)
bootsnap (1.18.6) bootsnap (1.18.6)
msgpack (~> 1.2) msgpack (~> 1.2)
brakeman (7.0.2) brakeman (7.0.2)
racc racc
builder (3.3.0) builder (3.3.0)
chronic (0.10.2) chronic (0.10.2)
concurrent-ruby (1.3.5) concurrent-ruby (1.3.8)
connection_pool (2.5.3) connection_pool (3.0.2)
crass (1.0.6) crass (1.0.7)
date (3.4.1) date (3.6.0)
diff-lcs (1.6.2) diff-lcs (1.6.2)
discard (1.4.0) discard (1.4.0)
activerecord (>= 4.2, < 9.0) activerecord (>= 4.2, < 9.0)
@@ -115,8 +115,9 @@ GEM
dotenv-rails (3.1.8) dotenv-rails (3.1.8)
dotenv (= 3.1.8) dotenv (= 3.1.8)
railties (>= 6.1) railties (>= 6.1)
drb (2.2.1) drb (2.2.3)
ed25519 (1.4.0) ed25519 (1.4.0)
erb (6.0.7)
erubi (1.13.1) erubi (1.13.1)
factory_bot (6.5.6) factory_bot (6.5.6)
activesupport (>= 6.1.0) activesupport (>= 6.1.0)
@@ -134,7 +135,7 @@ GEM
gemojione (4.3.3) gemojione (4.3.3)
json json
github-markup (4.0.2) github-markup (4.0.2)
globalid (1.2.1) globalid (1.4.0)
activesupport (>= 6.1) activesupport (>= 6.1)
gollum (6.1.0) gollum (6.1.0)
gemojione (~> 4.1) gemojione (~> 4.1)
@@ -166,19 +167,20 @@ GEM
gollum-rugged_adapter (3.0) gollum-rugged_adapter (3.0)
mime-types (~> 3.4) mime-types (~> 3.4)
rugged (~> 1.5) rugged (~> 1.5)
i18n (1.14.7) i18n (1.15.2)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
image_processing (1.14.0) image_processing (1.14.0)
mini_magick (>= 4.9.5, < 6) mini_magick (>= 4.9.5, < 6)
ruby-vips (>= 2.0.17, < 3) ruby-vips (>= 2.0.17, < 3)
io-console (0.8.0) io-console (0.9.4)
irb (1.15.2) irb (1.18.0)
pp (>= 0.6.0) pp (>= 0.6.0)
prism (>= 1.3.0)
rdoc (>= 4.0.0) rdoc (>= 4.0.0)
reline (>= 0.4.2) reline (>= 0.4.2)
jmespath (1.6.2) jmespath (1.6.2)
json (2.12.0) json (2.19.9)
jwt (2.10.1) jwt (3.3.0)
base64 base64
kamal (2.6.1) kamal (2.6.1)
activesupport (>= 7.0) activesupport (>= 7.0)
@@ -198,15 +200,16 @@ GEM
language_server-protocol (3.17.0.5) language_server-protocol (3.17.0.5)
lint_roller (1.1.0) lint_roller (1.1.0)
logger (1.7.0) logger (1.7.0)
loofah (2.24.1) loofah (2.25.2)
crass (~> 1.0.2) crass (~> 1.0.2)
nokogiri (>= 1.12.0) nokogiri (>= 1.12.0)
mail (2.8.1) mail (2.9.1)
logger
mini_mime (>= 0.1.1) mini_mime (>= 0.1.1)
net-imap net-imap
net-pop net-pop
net-smtp net-smtp
marcel (1.0.4) marcel (1.2.1)
mime-types (3.7.0) mime-types (3.7.0)
logger logger
mime-types-data (~> 3.2025, >= 3.2025.0507) mime-types-data (~> 3.2025, >= 3.2025.0507)
@@ -215,45 +218,46 @@ GEM
benchmark benchmark
logger logger
mini_mime (1.1.5) mini_mime (1.1.5)
minitest (5.25.5) minitest (6.0.6)
msgpack (1.8.0) drb (~> 2.0)
multi_json (1.15.0) prism (~> 1.5)
msgpack (1.8.5)
multi_json (1.21.2)
mustache (1.1.1) mustache (1.1.1)
mustache-sinatra (2.0.0) mustache-sinatra (2.0.0)
mustache (~> 1.0) mustache (~> 1.0)
mustermann (3.0.3) mustermann (3.1.1)
ruby2_keywords (~> 0.0.1)
mysql2 (0.5.6) mysql2 (0.5.6)
net-imap (0.5.8) net-imap (0.6.8)
date date
net-protocol net-protocol
net-pop (0.1.2) net-pop (0.1.2)
net-protocol net-protocol
net-protocol (0.2.2) net-protocol (0.4.0)
timeout timeout
net-scp (4.1.0) net-scp (4.1.0)
net-ssh (>= 2.6.5, < 8.0.0) net-ssh (>= 2.6.5, < 8.0.0)
net-sftp (4.0.0) net-sftp (4.0.0)
net-ssh (>= 5.0.0, < 8.0.0) net-ssh (>= 5.0.0, < 8.0.0)
net-smtp (0.5.1) net-smtp (0.5.2)
net-protocol net-protocol
net-ssh (7.3.0) net-ssh (7.3.0)
nio4r (2.7.4) nio4r (2.7.5)
nokogiri (1.18.8-aarch64-linux-gnu) nokogiri (1.19.4-aarch64-linux-gnu)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.8-aarch64-linux-musl) nokogiri (1.19.4-aarch64-linux-musl)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.8-arm-linux-gnu) nokogiri (1.19.4-arm-linux-gnu)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.8-arm-linux-musl) nokogiri (1.19.4-arm-linux-musl)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.8-arm64-darwin) nokogiri (1.19.4-arm64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.8-x86_64-darwin) nokogiri (1.19.4-x86_64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.8-x86_64-linux-gnu) nokogiri (1.19.4-x86_64-linux-gnu)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.8-x86_64-linux-musl) nokogiri (1.19.4-x86_64-linux-musl)
racc (~> 1.4) racc (~> 1.4)
octicons (19.15.2) octicons (19.15.2)
ostruct (0.6.1) ostruct (0.6.1)
@@ -261,70 +265,73 @@ GEM
parser (3.3.8.0) parser (3.3.8.0)
ast (~> 2.4.1) ast (~> 2.4.1)
racc racc
pp (0.6.2) pp (0.6.4)
prettyprint prettyprint
prettyprint (0.2.0) prettyprint (0.2.1)
prism (1.4.0) prism (1.9.0)
psych (5.2.6) psych (5.5.0)
date date
stringio stringio
puma (6.6.0) puma (6.6.0)
nio4r (~> 2.0) nio4r (~> 2.0)
racc (1.8.1) racc (1.8.1)
rack (3.1.14) rack (3.2.7)
rack-cors (2.0.2) rack-cors (2.0.2)
rack (>= 2.0.0) rack (>= 2.0.0)
rack-protection (4.1.1) rack-protection (4.2.1)
base64 (>= 0.1.0) base64 (>= 0.1.0)
logger (>= 1.6.0) logger (>= 1.6.0)
rack (>= 3.0.0, < 4) rack (>= 3.0.0, < 4)
rack-session (2.1.1) rack-session (2.1.2)
base64 (>= 0.1.0) base64 (>= 0.1.0)
rack (>= 3.0.0) rack (>= 3.0.0)
rack-test (2.2.0) rack-test (2.3.0)
rack (>= 1.3) rack (>= 1.3)
rackup (2.2.1) rackup (2.3.1)
rack (>= 3) rack (>= 3)
rails (8.0.2) rails (8.0.5.1)
actioncable (= 8.0.2) actioncable (= 8.0.5.1)
actionmailbox (= 8.0.2) actionmailbox (= 8.0.5.1)
actionmailer (= 8.0.2) actionmailer (= 8.0.5.1)
actionpack (= 8.0.2) actionpack (= 8.0.5.1)
actiontext (= 8.0.2) actiontext (= 8.0.5.1)
actionview (= 8.0.2) actionview (= 8.0.5.1)
activejob (= 8.0.2) activejob (= 8.0.5.1)
activemodel (= 8.0.2) activemodel (= 8.0.5.1)
activerecord (= 8.0.2) activerecord (= 8.0.5.1)
activestorage (= 8.0.2) activestorage (= 8.0.5.1)
activesupport (= 8.0.2) activesupport (= 8.0.5.1)
bundler (>= 1.15.0) bundler (>= 1.15.0)
railties (= 8.0.2) railties (= 8.0.5.1)
rails-dom-testing (2.2.0) rails-dom-testing (2.3.0)
activesupport (>= 5.0.0) activesupport (>= 5.0.0)
minitest minitest
nokogiri (>= 1.6) nokogiri (>= 1.6)
rails-html-sanitizer (1.6.2) rails-html-sanitizer (1.7.1)
loofah (~> 2.21) loofah (~> 2.25, >= 2.25.2)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
rails-i18n (8.0.2) rails-i18n (8.0.2)
i18n (>= 0.7, < 2) i18n (>= 0.7, < 2)
railties (>= 8.0.0, < 9) railties (>= 8.0.0, < 9)
railties (8.0.2) railties (8.0.5.1)
actionpack (= 8.0.2) actionpack (= 8.0.5.1)
activesupport (= 8.0.2) activesupport (= 8.0.5.1)
irb (~> 1.13) irb (~> 1.13)
rackup (>= 1.0.0) rackup (>= 1.0.0)
rake (>= 12.2) rake (>= 12.2)
thor (~> 1.0, >= 1.2.2) thor (~> 1.0, >= 1.2.2)
tsort (>= 0.2)
zeitwerk (~> 2.6) zeitwerk (~> 2.6)
rainbow (3.1.1) rainbow (3.1.1)
rake (13.2.1) rake (13.4.2)
rdoc (6.13.1) rdoc (6.17.0)
erb
psych (>= 4.0.0) psych (>= 4.0.0)
regexp_parser (2.10.0) tsort
reline (0.6.1) regexp_parser (2.13.1)
reline (0.7.0)
io-console (~> 0.5) io-console (~> 0.5)
rexml (3.4.1) rexml (3.4.4)
rouge (3.30.0) rouge (3.30.0)
rspec-core (3.13.6) rspec-core (3.13.6)
rspec-support (~> 3.13.0) rspec-support (~> 3.13.0)
@@ -345,20 +352,20 @@ GEM
rspec-support (3.13.6) rspec-support (3.13.6)
rss (0.3.1) rss (0.3.1)
rexml rexml
rubocop (1.75.6) rubocop (1.92.0)
json (~> 2.3) json (>= 2.3)
language_server-protocol (~> 3.17.0.2) language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0) lint_roller (~> 1.1.0)
parallel (~> 1.10) parallel (>= 1.10)
parser (>= 3.3.0.2) parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0) rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.9.3, < 3.0) regexp_parser (>= 2.11.3, < 3.0)
rubocop-ast (>= 1.44.0, < 2.0) rubocop-ast (>= 1.49.0, < 2.0)
ruby-progressbar (~> 1.7) ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0) unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.44.1) rubocop-ast (1.50.0)
parser (>= 3.3.7.2) parser (>= 3.3.7.2)
prism (~> 1.4) prism (~> 1.7)
rubocop-performance (1.25.0) rubocop-performance (1.25.0)
lint_roller (~> 1.1) lint_roller (~> 1.1)
rubocop (>= 1.75.0, < 2.0) rubocop (>= 1.75.0, < 2.0)
@@ -377,24 +384,23 @@ GEM
ruby-vips (2.2.3) ruby-vips (2.2.3)
ffi (~> 1.12) ffi (~> 1.12)
logger logger
ruby2_keywords (0.0.5)
rugged (1.9.0) rugged (1.9.0)
securerandom (0.4.1) securerandom (0.4.1)
sinatra (4.1.1) sinatra (4.2.1)
logger (>= 1.6.0) logger (>= 1.6.0)
mustermann (~> 3.0) mustermann (~> 3.0)
rack (>= 3.0.0, < 4) rack (>= 3.0.0, < 4)
rack-protection (= 4.1.1) rack-protection (= 4.2.1)
rack-session (>= 2.0.0, < 3) rack-session (>= 2.0.0, < 3)
tilt (~> 2.0) tilt (~> 2.0)
sinatra-contrib (4.1.1) sinatra-contrib (4.2.1)
multi_json (>= 0.0.2) multi_json (>= 0.0.2)
mustermann (~> 3.0) mustermann (~> 3.0)
rack-protection (= 4.1.1) rack-protection (= 4.2.1)
sinatra (= 4.1.1) sinatra (= 4.2.1)
tilt (~> 2.0) tilt (~> 2.0)
sprockets (4.2.2) sprockets (4.4.1)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.1)
logger logger
rack (>= 2.2.4, < 4) rack (>= 2.2.4, < 4)
sprockets-helpers (1.4.0) sprockets-helpers (1.4.0)
@@ -403,14 +409,14 @@ GEM
actionpack (>= 6.1) actionpack (>= 6.1)
activesupport (>= 6.1) activesupport (>= 6.1)
sprockets (>= 3.0.0) sprockets (>= 3.0.0)
sqlite3 (2.6.0-aarch64-linux-gnu) sqlite3 (2.10.1-aarch64-linux-gnu)
sqlite3 (2.6.0-aarch64-linux-musl) sqlite3 (2.10.1-aarch64-linux-musl)
sqlite3 (2.6.0-arm-linux-gnu) sqlite3 (2.10.1-arm-linux-gnu)
sqlite3 (2.6.0-arm-linux-musl) sqlite3 (2.10.1-arm-linux-musl)
sqlite3 (2.6.0-arm64-darwin) sqlite3 (2.10.1-arm64-darwin)
sqlite3 (2.6.0-x86_64-darwin) sqlite3 (2.10.1-x86_64-darwin)
sqlite3 (2.6.0-x86_64-linux-gnu) sqlite3 (2.10.1-x86_64-linux-gnu)
sqlite3 (2.6.0-x86_64-linux-musl) sqlite3 (2.10.1-x86_64-linux-musl)
sshkit (1.24.0) sshkit (1.24.0)
base64 base64
logger logger
@@ -418,18 +424,19 @@ GEM
net-sftp (>= 2.1.2) net-sftp (>= 2.1.2)
net-ssh (>= 2.8.0) net-ssh (>= 2.8.0)
ostruct ostruct
stringio (3.1.7) stringio (3.2.0)
therubyrhino (2.1.2) therubyrhino (2.1.2)
therubyrhino_jar (>= 1.7.4, < 1.7.9) therubyrhino_jar (>= 1.7.4, < 1.7.9)
therubyrhino_jar (1.7.8) therubyrhino_jar (1.7.8)
thor (1.3.2) thor (1.5.0)
thruster (0.1.13) thruster (0.1.13)
thruster (0.1.13-aarch64-linux) thruster (0.1.13-aarch64-linux)
thruster (0.1.13-arm64-darwin) thruster (0.1.13-arm64-darwin)
thruster (0.1.13-x86_64-darwin) thruster (0.1.13-x86_64-darwin)
thruster (0.1.13-x86_64-linux) thruster (0.1.13-x86_64-linux)
tilt (2.6.0) tilt (2.9.0)
timeout (0.4.3) timeout (0.6.1)
tsort (0.2.0)
twitter-text (1.14.7) twitter-text (1.14.7)
unf (~> 0.1.0) unf (~> 0.1.0)
tzinfo (2.0.6) tzinfo (2.0.6)
@@ -440,16 +447,16 @@ GEM
unicode-display_width (3.1.4) unicode-display_width (3.1.4)
unicode-emoji (~> 4.0, >= 4.0.4) unicode-emoji (~> 4.0, >= 4.0.4)
unicode-emoji (4.0.4) unicode-emoji (4.0.4)
uri (1.0.3) uri (1.1.2)
useragent (0.16.11) useragent (0.16.11)
webrick (1.9.1) webrick (1.9.1)
websocket-driver (0.7.7) websocket-driver (0.8.2)
base64 base64
websocket-extensions (>= 0.1.0) websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5) websocket-extensions (0.1.5)
whenever (1.0.0) whenever (1.0.0)
chronic (>= 0.6.3) chronic (>= 0.6.3)
zeitwerk (2.7.2) zeitwerk (2.8.3)
PLATFORMS PLATFORMS
aarch64-linux aarch64-linux
@@ -473,6 +480,7 @@ DEPENDENCIES
factory_bot_rails factory_bot_rails
gollum gollum
image_processing (~> 1.14) image_processing (~> 1.14)
json (~> 2.19.9)
jwt jwt
kamal kamal
mysql2 (~> 0.5.6) mysql2 (~> 0.5.6)
+6 -2
ファイルの表示
@@ -14,8 +14,9 @@ class PostVersionsController < ApplicationController
offset = (page - 1) * limit offset = (page - 1) * limit
q = HistoryAccess.visible(version_scope) q = HistoryAccess.visible(PostVersion.all)
q = q.where('post_versions.post_id = ?', post_id) if post_id q = q.where('post_versions.post_id = ?', post_id) if post_id
q = q.with_previous_version if tag_id || external_tag_id
if external_tag_id || (tag_id && !(Tag.exists?(id: tag_id))) if external_tag_id || (tag_id && !(Tag.exists?(id: tag_id)))
q = q.where('JSON_CONTAINS(post_versions.tags_json,' + q = q.where('JSON_CONTAINS(post_versions.tags_json,' +
"JSON_OBJECT('external_tag_id', #{ external_tag_id || tag_id })) " + "JSON_OBJECT('external_tag_id', #{ external_tag_id || tag_id })) " +
@@ -28,9 +29,12 @@ class PostVersionsController < ApplicationController
count = q.except(:select, :order, :limit, :offset).count count = q.except(:select, :order, :limit, :offset).count
versions = q.order(Arel.sql('post_versions.created_at DESC, post_versions.id DESC')) ids = q.order(Arel.sql('post_versions.created_at DESC, post_versions.id DESC'))
.limit(limit) .limit(limit)
.offset(offset) .offset(offset)
.pluck('post_versions.id')
versions = version_scope.merge(q).where(id: ids)
.order(Arel.sql('post_versions.created_at DESC, post_versions.id DESC'))
render json: { versions: serialise_versions(versions, locale.language_code), count:, population_count: HistoryAccess.population_count(PostVersion) } render json: { versions: serialise_versions(versions, locale.language_code), count:, population_count: HistoryAccess.population_count(PostVersion) }
end end
+25 -14
ファイルの表示
@@ -37,20 +37,10 @@ class PostsController < ApplicationController
offset = (page - 1) * limit offset = (page - 1) * limit
pt_max_sql = needs_history_join = order[0] == 'updated_at' || updated_between.any?(&:present?)
HistoryAccess.visible(PostVersion.all)
.select('post_id, MAX(created_at) AS max_updated_at')
.group('post_id')
.to_sql
updated_at_all_sql =
'GREATEST(posts.updated_at,' +
'COALESCE(pt_max.max_updated_at, posts.updated_at))'
q = q =
filtered_posts(locale) filtered_posts(locale)
.joins("LEFT JOIN (#{ pt_max_sql }) pt_max ON pt_max.post_id = posts.id")
.reselect('posts.*', Arel.sql("#{ updated_at_all_sql } AS updated_at_all"))
.preload(:external_tags, :uploaded_user, :parents, :children, .preload(:external_tags, :uploaded_user, :parents, :children,
post_tags: [:sections, { tag: [:deerjikists, :materials, post_tags: [:sections, { tag: [:deerjikists, :materials,
:tag_names, :tag_descriptions] }]) :tag_names, :tag_descriptions] }])
@@ -66,12 +56,25 @@ class PostsController < ApplicationController
end end
q = q.where('posts.created_at >= ?', created_between[0]) if created_between[0] q = q.where('posts.created_at >= ?', created_between[0]) if created_between[0]
q = q.where('posts.created_at <= ?', created_between[1]) if created_between[1] q = q.where('posts.created_at <= ?', created_between[1]) if created_between[1]
count_scope = q
if needs_history_join
history_times = HistoryAccess.visible(PostVersion.all)
.select('post_id, MAX(created_at) AS latest_at').group(:post_id)
q = q.joins("LEFT JOIN (#{ history_times.to_sql }) history_times " +
'ON history_times.post_id = posts.id')
updated_at_all_sql = 'GREATEST(posts.updated_at, ' +
'COALESCE(history_times.latest_at, posts.updated_at))'
end
if updated_between[0] if updated_between[0]
q = q.where("#{ updated_at_all_sql } >= ?", updated_between[0]) q = q.where("#{ updated_at_all_sql } >= ?", updated_between[0])
end end
if updated_between[1] if updated_between[1]
q = q.where("#{ updated_at_all_sql } <= ?", updated_between[1]) q = q.where("#{ updated_at_all_sql } <= ?", updated_between[1])
end end
count_scope = q if updated_between.any?(&:present?)
if needs_history_join
q = q.select('posts.*', "#{ updated_at_all_sql } AS updated_at")
end
sort_sql = sort_sql =
case order[0] case order[0]
@@ -88,12 +91,20 @@ class PostsController < ApplicationController
.limit(limit) .limit(limit)
.offset(offset) .offset(offset)
.to_a .to_a
latest_version_times =
if needs_history_join
{ }
else
HistoryAccess.visible(PostVersion.where(post_id: posts.map(&:id)))
.group(:post_id).maximum(:created_at)
end
q = q.except(:select, :order) count_scope = count_scope.except(:select, :order)
render json: { posts: posts.map { |post| render json: { posts: posts.map { |post|
updated_at = [post.updated_at, latest_version_times[post.id]].compact.max
PostRepr.base(post, language_code: locale.language_code) PostRepr.base(post, language_code: locale.language_code)
.merge(updated_at: post.updated_at_all).tap do |json| .merge('updated_at' => updated_at).tap do |json|
json['thumbnail'] = json['thumbnail'] =
if post.thumbnail.attached? if post.thumbnail.attached?
rails_storage_proxy_url(post.thumbnail, only_path: false) rails_storage_proxy_url(post.thumbnail, only_path: false)
@@ -101,7 +112,7 @@ class PostsController < ApplicationController
nil nil
end end
end end
}, count: q.group_values.present? ? q.count.size : q.count } }, count: count_scope.group_values.present? ? count_scope.count.size : count_scope.count }
end end
def random def random
-9
ファイルの表示
@@ -1,9 +0,0 @@
class Event < ApplicationRecord
has_many :post_versions
has_many :tag_versions
has_many :material_versions
has_many :tag_description_versions
has_many :tag_group_versions
belongs_to :created_by_user, class_name: 'User', optional: true
end
+15 -10
ファイルの表示
@@ -4,7 +4,7 @@ require 'digest'
class HistoryAccess < ActiveSupport::CurrentAttributes class HistoryAccess < ActiveSupport::CurrentAttributes
attribute :user attribute :user
class Forbidden < StandardError; end class Forbidden < StandardError; end
ARTIFACT_POLICY = 'counts-masked-summary-1'.freeze ARTIFACT_POLICY = 'counts-private-predicates-4'.freeze
KEYS = { 'post_versions' => ['post_id'], 'tag_versions' => ['tag_id'], KEYS = { 'post_versions' => ['post_id'], 'tag_versions' => ['tag_id'],
'nico_tag_versions' => ['tag_id'], 'nico_tag_versions' => ['tag_id'],
@@ -59,20 +59,21 @@ class HistoryAccess < ActiveSupport::CurrentAttributes
end end
def self.json_reference(table, key, document) def self.json_reference(table, key, document)
"NOT EXISTS (SELECT 1 FROM JSON_TABLE(COALESCE(#{ document }, JSON_ARRAY()), " + # A scalar lookup avoids MySQL's semijoin rewrite losing the JSON row correlation.
"(SELECT 1 FROM JSON_TABLE(COALESCE(#{ document }, JSON_ARRAY()), " +
"'$[*]' COLUMNS (reference_id BIGINT PATH '$' NULL ON EMPTY)) reference_entry " + "'$[*]' COLUMNS (reference_id BIGINT PATH '$' NULL ON EMPTY)) reference_entry " +
"JOIN (#{ discarded_keys(table, key) }) blocked " + "JOIN (#{ discarded_keys(table, key) }) blocked " +
"ON reference_entry.reference_id = blocked.#{ key })" "ON reference_entry.reference_id = blocked.#{ key } LIMIT 1) IS NULL"
end end
def self.list_reference(table, key, document) def self.list_reference(table, key, document)
# Preserve FIND_IN_SET's exact token semantics, including empty tokens. # Preserve FIND_IN_SET's exact token semantics, including empty tokens.
array = "CONCAT('[', REPLACE(JSON_QUOTE(REPLACE(COALESCE(#{ document }, ''), " + array = "CONCAT('[', REPLACE(JSON_QUOTE(REPLACE(COALESCE(#{ document }, ''), " +
"' ', ',')), ',', '\",\"'), ']')" "' ', ',')), ',', '\",\"'), ']')"
"NOT EXISTS (SELECT 1 FROM JSON_TABLE(#{ array }, '$[*]' " + "(SELECT 1 FROM JSON_TABLE(#{ array }, '$[*]' " +
"COLUMNS (reference_id VARCHAR(255) PATH '$')) reference_entry " + "COLUMNS (reference_id VARCHAR(255) PATH '$')) reference_entry " +
"JOIN (#{ discarded_keys(table, key) }) blocked " + "JOIN (#{ discarded_keys(table, key) }) blocked " +
"ON reference_entry.reference_id = CAST(blocked.#{ key } AS CHAR))" "ON reference_entry.reference_id = CAST(blocked.#{ key } AS CHAR) LIMIT 1) IS NULL"
end end
def self.snapshot_reference(model, row, external: false) def self.snapshot_reference(model, row, external: false)
@@ -91,8 +92,8 @@ class HistoryAccess < ActiveSupport::CurrentAttributes
match += ' AND reference_version.version_no = tag_reference.version_no' match += ' AND reference_version.version_no = tag_reference.version_no'
latest = '' latest = ''
end end
"NOT EXISTS (SELECT 1 FROM #{ source } JOIN #{ table } reference_version ON #{ match } " + "(SELECT 1 FROM #{ source } JOIN #{ table } reference_version ON #{ match } " +
"WHERE NOT (#{ sql(model, 'reference_version') }) #{ latest })" "WHERE NOT (#{ sql(model, 'reference_version') }) #{ latest } LIMIT 1) IS NULL"
end end
def self.reference_sql(table, row) def self.reference_sql(table, row)
@@ -109,11 +110,15 @@ class HistoryAccess < ActiveSupport::CurrentAttributes
# conservatively; never expose a private linked name through an external tag. # conservatively; never expose a private linked name through an external tag.
names = "JSON_MERGE_PRESERVE(JSON_ARRAY(private_tag.name), " + names = "JSON_MERGE_PRESERVE(JSON_ARRAY(private_tag.name), " +
"COALESCE(JSON_EXTRACT(private_tag.names, '$**.name'), JSON_ARRAY()))" "COALESCE(JSON_EXTRACT(private_tag.names, '$**.name'), JSON_ARRAY()))"
["NOT EXISTS (SELECT 1 FROM tag_versions private_tag " + # DISTINCT prevents merging this independent name set into the correlated scan.
# Keep byte-distinct names even when the column collation considers them equal.
private_names = 'SELECT DISTINCT private_name.name, ' +
'CAST(private_name.name AS BINARY) AS name_bytes FROM tag_versions private_tag ' +
"JOIN (#{ discarded_keys('tag_versions', 'tag_id') }) blocked " + "JOIN (#{ discarded_keys('tag_versions', 'tag_id') }) blocked " +
"ON blocked.tag_id = private_tag.tag_id " + 'ON blocked.tag_id = private_tag.tag_id ' +
"JOIN JSON_TABLE(#{ names }, '$[*]' COLUMNS (name TEXT PATH '$')) private_name " + "JOIN JSON_TABLE(#{ names }, '$[*]' COLUMNS (name TEXT PATH '$')) private_name " +
"WHERE private_name.name <> '' AND " + "WHERE private_name.name <> ''"
["NOT EXISTS (SELECT 1 FROM (#{ private_names }) private_name WHERE " +
"LOCATE(CONCAT(' ', private_name.name, ' '), " + "LOCATE(CONCAT(' ', private_name.name, ' '), " +
"CONCAT(' ', COALESCE(#{ row }.linked_tags, ''), ' ')) > 0)"] "CONCAT(' ', COALESCE(#{ row }.linked_tags, ''), ' ')) > 0)"]
when 'tag_versions' when 'tag_versions'
+3 -1
ファイルの表示
@@ -11,5 +11,7 @@ class MaterialVersion < ApplicationRecord
belongs_to :parent, class_name: 'Material', optional: true belongs_to :parent, class_name: 'Material', optional: true
belongs_to :updated_by_user, class_name: 'User', optional: true belongs_to :updated_by_user, class_name: 'User', optional: true
def export_paths_hash = export_paths_json || { } def export_paths_hash
export_paths_json || {}
end
end end
-2
ファイルの表示
@@ -19,8 +19,6 @@ module VersionRecord
belongs_to :created_by_user, class_name: 'User', optional: true belongs_to :created_by_user, class_name: 'User', optional: true
belongs_to :event
enum :event_type, event_type_map, prefix: true, validate: true enum :event_type, event_type_map, prefix: true, validate: true
validates :version_no, presence: true, numericality: { only_integer: true, greater_than: 0 } validates :version_no, presence: true, numericality: { only_integer: true, greater_than: 0 }
+56 -13
ファイルの表示
@@ -1,12 +1,41 @@
class CountsAxes class CountsAxes
MAX_AXES = 8 MAX_AXES = 8
MASKED_TEXT = '********'.freeze
MASKED_VALUE = '__counts_private__'.freeze MASKED_VALUE = '__counts_private__'.freeze
PUBLIC_FIELDS = ['created_at', 'event_type'].freeze PUBLIC_FIELDS = ['resource_id', 'version_no', 'event_type', 'user',
'created_at'].freeze
class TooManyAxes < ArgumentError; end class TooManyAxes < ArgumentError; end
class PrivateCondition < ArgumentError; end class PrivateCondition < ArgumentError; end
GRAINS = ['year', 'quarter', 'month', 'week', 'day', 'hour'].freeze GRAINS = ['year', 'quarter', 'month', 'week', 'day', 'hour'].freeze
def self.private_field?(field)
!PUBLIC_FIELDS.include?(field) && field != 'user.id'
end
def self.visible_ids_sql(definition)
"SELECT counts_visible.id FROM #{ definition.table } counts_visible WHERE " +
HistoryAccess.sql(definition.model, 'counts_visible')
end
def self.visibility_sql(definition, field)
return 'TRUE' if HistoryAccess.admin?
source = visible_ids_sql(definition)
visible = "v.id IN (#{ source })"
return visible unless field == 'changed_field'
"(#{ visible }) AND (previous.id IS NULL OR previous.id IN (#{ source }))"
end
def self.masked_sql(expression, definition, field)
return expression if HistoryAccess.admin? || !private_field?(field)
visible = visibility_sql(definition, field)
quoted = ApplicationRecord.connection.quote(MASKED_VALUE)
"CASE WHEN #{ visible } THEN #{ expression } ELSE #{ quoted } END"
end
def self.time_expression(expression, grain, timezone) def self.time_expression(expression, grain, timezone)
raise ArgumentError unless GRAINS.include?(grain) raise ArgumentError unless GRAINS.include?(grain)
@@ -60,13 +89,9 @@ class CountsAxes
@definition.fields[field] == :datetime ? self.class.time_expression( @definition.fields[field] == :datetime ? self.class.time_expression(
expression, axis.fetch('grain', 'month'), @population.timezone) : expression expression, axis.fetch('grain', 'month'), @population.timezone) : expression
end end
if !HistoryAccess.admin? && !PUBLIC_FIELDS.include?(field) if !HistoryAccess.admin? && self.class.private_field?(field)
visible = HistoryAccess.sql(@definition.model, 'v') expression = self.class.masked_sql(
if field == 'changed_field' "CAST((#{ expression }) AS CHAR)", @definition, field)
visible = "(#{ visible }) AND (#{ HistoryAccess.sql(@definition.model, 'previous') })"
end
expression = "CASE WHEN #{ visible } " +
"THEN CAST((#{ expression }) AS CHAR) ELSE '#{ MASKED_VALUE }' END"
end end
expression expression
end end
@@ -77,10 +102,12 @@ class CountsAxes
selected = indexes.map { "#{ expressions[_1] } AS a#{ _1 }" } selected = indexes.map { "#{ expressions[_1] } AS a#{ _1 }" }
identities = ['v.id'] identities = ['v.id']
if rows.any? { _1['field'] == 'tags' } if rows.any? { _1['field'] == 'tags' }
# Raw identities stay inside COUNT; masked tag occurrences remain additive. identities << self.class.masked_sql(
identities << "COALESCE(CONCAT('internal:', tag_entry.tag_id), " + "COALESCE(CONCAT('internal:', tag_entry.tag_id), " +
"CONCAT('external:', tag_entry.external_tag_id))" "CONCAT('external:', tag_entry.external_tag_id))", @definition, 'tags')
identities << 'changed.field_key' if rows.any? { _1['field'] == 'changed_field' } if rows.any? { _1['field'] == 'changed_field' }
identities << self.class.masked_sql('changed.field_key', @definition, 'changed_field')
end
end end
members = identities.each_index.map { "counts_member_#{ _1 }" } members = identities.each_index.map { "counts_member_#{ _1 }" }
selected += identities.each_with_index.map { |expression, index| selected += identities.each_with_index.map { |expression, index|
@@ -98,8 +125,17 @@ class CountsAxes
def joins(fields = all.map { _1['field'] }) def joins(fields = all.map { _1['field'] })
result = '' result = ''
if fields.include?('tags') if fields.include?('tags')
document = 'CAST(v.tags_json AS JSON)'
unless HistoryAccess.admin?
visible = self.class.visible_ids_sql(@definition)
result += " LEFT JOIN (#{ visible }) counts_tags_visible " +
'ON counts_tags_visible.id = v.id '
document = "CASE WHEN counts_tags_visible.id IS NOT NULL " +
"THEN #{ document } ELSE JSON_ARRAY(" +
"JSON_OBJECT('tag_id', 0)) END"
end
result += <<~SQL.squish result += <<~SQL.squish
JOIN JSON_TABLE(CAST(v.tags_json AS JSON), '$[*]' COLUMNS ( JOIN JSON_TABLE(#{ document }, '$[*]' COLUMNS (
tag_id BIGINT PATH '$.tag_id' NULL ON EMPTY, tag_id BIGINT PATH '$.tag_id' NULL ON EMPTY,
external_tag_id BIGINT PATH '$.external_tag_id' NULL ON EMPTY)) tag_entry external_tag_id BIGINT PATH '$.external_tag_id' NULL ON EMPTY)) tag_entry
@@ -112,6 +148,13 @@ class CountsAxes
condition = @definition.changes.map do |field| condition = @definition.changes.map do |field|
"(changed.field_key = '#{ field }' AND #{ @population.changed_sql(field) })" "(changed.field_key = '#{ field }' AND #{ @population.changed_sql(field) })"
end.join(' OR ') end.join(' OR ')
unless HistoryAccess.admin?
visible = self.class.visibility_sql(@definition, 'changed_field')
marker = ApplicationRecord.connection.quote(MASKED_VALUE)
fields += " UNION ALL SELECT #{ marker } AS field_key"
condition = "((#{ visible }) AND (#{ condition })) OR " +
"(NOT (#{ visible }) AND changed.field_key = #{ marker })"
end
result += " JOIN (#{ fields }) changed ON (#{ condition })" result += " JOIN (#{ fields }) changed ON (#{ condition })"
end end
result result
+41 -2
ファイルの表示
@@ -82,14 +82,32 @@ class CountsCondition
def apply(scope, entry) def apply(scope, entry)
raise ArgumentError unless entry.is_a?(Hash) raise ArgumentError unless entry.is_a?(Hash)
if entry['operator'] == 'axis_eq' if entry['operator'] == 'axis_eq'
raise ArgumentError unless (entry.keys - ['field', 'operator', 'grain', 'value']).empty? raise ArgumentError unless (entry.keys - ['field', 'operator', 'grain', 'value']).empty?
value = entry['value'] value = entry['value']
unless value.nil? || value.is_a?(String) || value.is_a?(Numeric) || [true, false].include?(value) unless (value.nil? || value.is_a?(String) || value.is_a?(Numeric) ||
[true, false].include?(value))
raise ArgumentError raise ArgumentError
end end
raise ArgumentError if value.is_a?(String) && value.bytesize > 8192 raise ArgumentError if value.is_a?(String) && value.bytesize > 8192
if (!HistoryAccess.admin? && CountsAxes.private_field?(entry['field']) &&
(value == CountsAxes::MASKED_VALUE ||
(value == CountsAxes::MASKED_TEXT &&
(@definition.fields[entry['field']] != :string ||
['tags', 'changed_field'].include?(entry['field'])))))
field = entry.fetch('field')
raise ArgumentError unless @definition.axes(@population.history).include?(field)
if entry.key?('grain')
unless (@definition.fields[field] == :datetime &&
CountsAxes::GRAINS.include?(entry['grain']))
raise ArgumentError
end
end
return scope.none
end
scope = @population.condition_scope(scope, entry.fetch('field'))
return @population.axis_condition(scope, entry) return @population.axis_condition(scope, entry)
end end
allowed = ['field', 'operator', 'value', 'end', 'blank', 'input', 'values'] allowed = ['field', 'operator', 'value', 'end', 'blank', 'input', 'values']
@@ -111,6 +129,13 @@ class CountsCondition
source, expression = source_for(field[:key]) source, expression = source_for(field[:key])
type = field[:type] type = field[:type]
# Preserve typed input compatibility; display markers are never real typed values.
if (!HistoryAccess.admin? && CountsAxes.private_field?(field[:key]) &&
entry['value'] == CountsAxes::MASKED_TEXT && type != :string && !entry['blank'])
return scope.none unless entry['operator'] == 'ne'
return @population.condition_scope(scope, field[:key])
end
blank = blank_sql(expression, type) blank = blank_sql(expression, type)
if entry['blank'] if entry['blank']
sql = if source && field[:collection] sql = if source && field[:collection]
@@ -119,6 +144,7 @@ class CountsCondition
"NOT EXISTS (SELECT 1 FROM #{ source } WHERE NOT (#{ blank }))" "NOT EXISTS (SELECT 1 FROM #{ source } WHERE NOT (#{ blank }))"
else blank else blank
end end
scope = @population.condition_scope(scope, field[:key])
return scope.where(Arel.sql(sql)) return scope.where(Arel.sql(sql))
end end
if ['in', 'not_in', 'any', 'all', 'none'].include?(entry['operator']) if ['in', 'not_in', 'any', 'all', 'none'].include?(entry['operator'])
@@ -131,6 +157,7 @@ class CountsCondition
validate_enum!(field[:key], parsed.first) if type == :enum validate_enum!(field[:key], parsed.first) if type == :enum
operator = entry['operator'] operator = entry['operator']
if type == :interval if type == :interval
scope = @population.condition_scope(scope, field[:key])
return interval(scope, source, expression, parsed) return interval(scope, source, expression, parsed)
end end
clauses = [] clauses = []
@@ -158,6 +185,7 @@ class CountsCondition
elsif operator == 'ne' elsif operator == 'ne'
predicate = "(NOT (#{ predicate }) OR #{ blank })" predicate = "(NOT (#{ predicate }) OR #{ blank })"
end end
scope = @population.condition_scope(scope, field[:key])
scope.where(predicate, *binds) scope.where(predicate, *binds)
end end
@@ -168,6 +196,13 @@ class CountsCondition
values = entry['values'] values = entry['values']
raise ArgumentError unless values.is_a?(Array) && values.length <= 100 raise ArgumentError unless values.is_a?(Array) && values.length <= 100
if (!HistoryAccess.admin? && CountsAxes.private_field?(field[:key]) &&
values.include?(CountsAxes::MASKED_TEXT) &&
(field[:type] != :string || field[:set_type] == 'tag_reference'))
return scope.none if entry['operator'] == 'all'
values = values - [CountsAxes::MASKED_TEXT]
end
raise ArgumentError if [entry['value'], entry['end']].any? { !_1.nil? && _1 != '' } raise ArgumentError if [entry['value'], entry['end']].any? { !_1.nil? && _1 != '' }
parsed = values.map do |value| parsed = values.map do |value|
@@ -191,7 +226,10 @@ class CountsCondition
operator = entry['operator'] operator = entry['operator']
if parsed.empty? if parsed.empty?
return ['in', 'any'].include?(operator) ? scope.none : scope return scope.none if ['in', 'any'].include?(operator)
return scope if entry['values'].empty?
return @population.condition_scope(scope, field[:key])
end end
source, expression = source_for(field[:key]) source, expression = source_for(field[:key])
if field[:set_type] == 'tag_reference' if field[:set_type] == 'tag_reference'
@@ -212,6 +250,7 @@ class CountsCondition
predicate = source ? "NOT #{ predicate }" : predicate = source ? "NOT #{ predicate }" :
"(NOT #{ predicate } OR #{ blank_sql(expression, field[:type]) })" "(NOT #{ predicate } OR #{ blank_sql(expression, field[:type]) })"
end end
scope = @population.condition_scope(scope, field[:key])
scope.where(predicate, *parsed) scope.where(predicate, *parsed)
end end
+8
ファイルの表示
@@ -70,6 +70,13 @@ class CountsPopulation
@authorisation_flg ? HistoryAccess.sql(model, row) : 'TRUE' @authorisation_flg ? HistoryAccess.sql(model, row) : 'TRUE'
end end
# Each predicate keeps its own visibility restriction, including OR branches.
def condition_scope(scope, field)
return scope if HistoryAccess.admin? || !CountsAxes.private_field?(field)
scope.where(Arel.sql(CountsAxes.visibility_sql(definition, field)))
end
def axis_condition(scope, entry) def axis_condition(scope, entry)
raise CountsAxes::PrivateCondition if entry['value'] == CountsAxes::MASKED_VALUE raise CountsAxes::PrivateCondition if entry['value'] == CountsAxes::MASKED_VALUE
@@ -209,6 +216,7 @@ class CountsPopulation
def apply_tags def apply_tags
raise ArgumentError unless definition.resource == 'posts' raise ArgumentError unless definition.resource == 'posts'
@scope = condition_scope(@scope, 'tags')
if params['tag'].present? if params['tag'].present?
@scope = @scope.where(snapshot_membership( @scope = @scope.where(snapshot_membership(
"#{ params.fetch('kind', 'internal') }:#{ params['tag'] }")) "#{ params.fetch('kind', 'internal') }:#{ params['tag'] }"))
+1 -1
ファイルの表示
@@ -253,7 +253,7 @@ class CountsTableExport
end end
def summary_value(value, index, page) def summary_value(value, index, page)
return '********' if value == CountsAxes::MASKED_VALUE return CountsAxes::MASKED_TEXT if value == CountsAxes::MASKED_VALUE
field = @query.axes.all.fetch(index).fetch('field') field = @query.axes.all.fetch(index).fetch('field')
return @labels.fetch('bot', '') if field == 'user' && value.nil? return @labels.fetch('bot', '') if field == 'user' && value.nil?
+2 -10
ファイルの表示
@@ -220,19 +220,11 @@ class CountsTableQuery
private private
# Public timeline predicates may include hidden records. Private predicates must # Private predicates require visibility independently of their operator.
# never become a count oracle, even when the selected axes themselves are public. # Ordering/measure state must not select a server-only masked coordinate.
def validate_summary_conditions! def validate_summary_conditions!
return if mode != 'summary' || HistoryAccess.admin? return if mode != 'summary' || HistoryAccess.admin?
fields = QueryState.records(@params, 'conditions') + QueryState.records(@params, 'drill')
private_fields = fields.any? { !CountsAxes::PUBLIC_FIELDS.include?(_1['field']) }
keys = ['tags', 'tag', 'kind', 'resource_id', 'user_id']
private_parameters = keys.any? do |key|
@params.any? { |name, value| (name == key || name.start_with?("#{ key }.")) && value != '' }
end
raise CountsAxes::PrivateCondition if private_fields || private_parameters
['column_order', 'measures'].each do |key| ['column_order', 'measures'].each do |key|
# Coordinates are compared only to already masked expressions, never raw # Coordinates are compared only to already masked expressions, never raw
# values. Public coordinates remain sortable; a guessed private ID cannot match. # values. Public coordinates remain sortable; a guessed private ID cannot match.
+5 -8
ファイルの表示
@@ -1,23 +1,20 @@
class MaterialVersionRecorder < VersionRecorder class MaterialVersionRecorder < VersionRecorder
EVENT_TYPES = ['create', 'update', 'discard', 'restore'].freeze EVENT_TYPES = ['create', 'update', 'discard', 'restore'].freeze
def self.record! material:, event_type:, event:, created_by_user:, file_snapshot: nil def self.record! material:, event_type:, created_by_user:, file_snapshot: nil
new(material:, event_type:, event:, created_by_user:, file_snapshot:).record! new(material:, event_type:, created_by_user:, file_snapshot:).record!
end end
def initialize material:, event_type:, event:, created_by_user:, file_snapshot: nil def initialize material:, event_type:, created_by_user:, file_snapshot: nil
@file_snapshot = file_snapshot @file_snapshot = file_snapshot
super(record: material, event_type:, event:, created_by_user:) super(record: material, event_type:, created_by_user:)
end end
def self.ensure_snapshot! material, created_by_user: def self.ensure_snapshot! material, created_by_user:
return if material.material_versions.exists? return if material.material_versions.exists?
event = Event.create!(event_type: 'materials.create', record!(material:, event_type: :create,
created_by_user:,
created_at: material.created_at)
record!(material:, event_type: :create, event:,
created_by_user: material.created_by_user || created_by_user) created_by_user: material.created_by_user || created_by_user)
end end
+5 -8
ファイルの表示
@@ -1,19 +1,16 @@
class PostVersionRecorder < VersionRecorder class PostVersionRecorder < VersionRecorder
def self.record! post:, event_type:, event:, created_by_user: def self.record! post:, event_type:, created_by_user:
new(post:, event_type:, event:, created_by_user:).record! new(post:, event_type:, created_by_user:).record!
end end
def initialize post:, event_type:, event:, created_by_user: def initialize post:, event_type:, created_by_user:
super(record: post, event_type:, event:, created_by_user:) super(record: post, event_type:, created_by_user:)
end end
def self.ensure_snapshot! post, created_by_user: def self.ensure_snapshot! post, created_by_user:
return if post.post_versions.exists? return if post.post_versions.exists?
event = Event.create!(event_type: 'posts.create', record!(post:, event_type: :create, created_by_user:)
created_by_user:,
created_at: post.created_at)
record!(post:, event_type: :create, event:, created_by_user:)
end end
private private
+4 -4
ファイルの表示
@@ -1,13 +1,13 @@
class TagVersionRecorder < VersionRecorder class TagVersionRecorder < VersionRecorder
def self.record! tag:, event_type:, event:, created_by_user:, **_options def self.record! tag:, event_type:, created_by_user:, **_options
Tag.transaction do Tag.transaction do
SharedResourceIdAllocator.lock_space!(Tag, 'tags') SharedResourceIdAllocator.lock_space!(Tag, 'tags')
new(tag:, event_type:, event:, created_by_user:).record! new(tag:, event_type:, created_by_user:).record!
end end
end end
def initialize tag:, event_type:, event:, created_by_user: def initialize tag:, event_type:, created_by_user:
super(record: tag, event_type:, event:, created_by_user:) super(record: tag, event_type:, created_by_user:)
end end
private private
+3 -5
ファイルの表示
@@ -1,10 +1,9 @@
class VersionRecorder class VersionRecorder
EVENT_TYPES = ['create', 'update', 'discard', 'restore'].freeze EVENT_TYPES = ['create', 'update', 'discard', 'restore'].freeze
def initialize record:, event_type:, event:, created_by_user: def initialize record:, event_type:, created_by_user:
@record = record @record = record
@event_type = event_type.to_s @event_type = event_type.to_s
@event = event
@created_by_user = created_by_user @created_by_user = created_by_user
validate_event_type! validate_event_type!
@@ -43,9 +42,8 @@ class VersionRecorder
def base_attributes latest def base_attributes latest
{ version_no: (latest&.version_no || 0) + 1, { version_no: (latest&.version_no || 0) + 1,
event_type: @event_type, event_type: @event_type,
event: @event, created_at: Time.current,
created_by_user: @created_by_user, created_by_user: @created_by_user }
created_at: Time.current }
end end
def update_record_version_no! version_no def update_record_version_no! version_no
-19
ファイルの表示
@@ -1,19 +0,0 @@
class CreateEvents < ActiveRecord::Migration[8.0]
def change
create_table :events do |t|
t.string :event_type, null: false, index: true
t.text :reason
t.references :reverted_from_event, foreign_key: { to_table: :events }
t.json :metadata
t.binary :ip_address, limit: 16, index: true
t.references :created_by_user, index: true, foreign_key: false
t.datetime :created_at, null: false, index: true
end
add_reference :post_versions, :event, foreign_key: true, after: :original_created_before
add_reference :tag_versions, :event, foreign_key: true, after: :deprecated_at
add_reference :material_versions, :event, foreign_key: true
add_reference :tag_description_versions, :event, foreign_key: true, after: :source_version_no
add_reference :tag_group_versions, :event, foreign_key: true, after: :placements
end
end
-207
ファイルの表示
@@ -1,207 +0,0 @@
class BackfillEventsFromExistingVersions < ActiveRecord::Migration[8.0]
class MigrationEvent < ActiveRecord::Base
self.table_name = 'events'
end
class MigrationPostVersion < ActiveRecord::Base
self.table_name = 'post_versions'
end
class MigrationTagVersion < ActiveRecord::Base
self.table_name = 'tag_versions'
end
class MigrationMaterialVersion < ActiveRecord::Base
self.table_name = 'material_versions'
end
class MigrationTagDescriptionVersion < ActiveRecord::Base
self.table_name = 'tag_description_versions'
end
class MigrationTagGroupVersion < ActiveRecord::Base
self.table_name = 'tag_group_versions'
end
class MigrationLocale < ActiveRecord::Base
self.table_name = 'locales'
end
def up
# create → posts.create
# update → posts.update
raise if MigrationPostVersion.where.not(event_type: ['create', 'update']).exists?
# create → materials.create
# update → materials.update
if MigrationMaterialVersion.where.not(event_type: ['create', 'update']).exists?
raise
end
# create → posts.create / posts.update / materials.create / materials.update / unknown
# update → tags.update / locales.create
# discard → unknown
if MigrationTagVersion.where.not(event_type: ['create', 'update', 'discard']).exists?
raise
end
# create → tag_descriptions.create
# update → tag_descriptions.update
if MigrationTagDescriptionVersion.where.not(event_type: ['create', 'update']).exists?
raise
end
# create → tag_groups.create
# update → tag_groups.update
if MigrationTagGroupVersion.where.not(event_type: ['create', 'update']).exists?
raise
end
# post_versions の Backfill
MigrationPostVersion.find_each do |pv|
ev = MigrationEvent.create!(event_type: "posts.#{ pv.event_type }",
reason: nil,
reverted_from_event_id: nil,
metadata: nil,
ip_address: nil,
created_by_user_id: pv.created_by_user_id,
created_at: pv.created_at)
pv.update_columns(event_id: ev.id)
end
# material_versions の Backfill
MigrationMaterialVersion.find_each do |m|
ev = MigrationEvent.create!(event_type: "materials.#{ m.event_type }",
reason: nil,
reverted_from_event_id: nil,
metadata: nil,
ip_address: nil,
created_by_user_id: m.created_by_user_id,
created_at: m.created_at)
m.update_columns(event_id: ev.id)
end
# ロカール追加に伴ふ該当語言のタグ名称追加異動のみ分離
ids_cause_of_locale_creation = []
MigrationLocale.where('created_at > ?', MigrationLocale.minimum(:created_at)).find_each do |l|
ev = MigrationEvent.create!(event_type: 'locales.create',
reason: nil,
reverted_from_event_id: nil,
metadata: nil,
ip_address: nil,
created_by_user_id: nil,
created_at: l.created_at)
MigrationTagVersion
.where('created_at > ?', l.created_at)
.where(event_type: 'update', event_id: nil)
.find_each do |t|
next unless t.names.key?(l.language_code)
prev = MigrationTagVersion.find_by(tag_id: t.tag_id, version_no: t.version_no - 1)
next if prev.nil? || prev.names.key?(l.language_code)
t.update_columns(event_id: ev.id)
ids_cause_of_locale_creation << t.id
end
ev.destroy! unless MigrationTagVersion.where(event_id: ev.id).exists?
end
# tag_versions の Backfill
MigrationTagVersion.where.not(id: ids_cause_of_locale_creation).find_each do |t|
ev =
case t.event_type
when 'create'
post =
MigrationPostVersion
.where('JSON_CONTAINS(tags_json, ?) <> 0', { tag_id: t.tag_id }.to_json)
.find_by(created_at: MigrationPostVersion
.where('JSON_CONTAINS(tags_json, ?) <> 0',
{ tag_id: t.tag_id }.to_json)
.minimum(:created_at))
material =
MigrationMaterialVersion
.where(tag_id: t.tag_id)
.find_by(created_at: MigrationMaterialVersion
.where(tag_id: t.tag_id)
.minimum(:created_at))
if post
if material
if post.created_at < material.created_at
MigrationEvent.find(post.event_id)
else
MigrationEvent.find(material.event_id)
end
else
MigrationEvent.find(post.event_id)
end
else
if material
MigrationEvent.find(material.event_id)
else
MigrationEvent.create!(event_type: 'unknown',
reason: nil,
reverted_from_event_id: nil,
metadata: nil,
ip_address: nil,
created_by_user_id: t.created_by_user_id,
created_at: t.created_at)
end
end
when 'update'
MigrationEvent.create!(event_type: 'tags.update',
reason: nil,
reverted_from_event_id: nil,
metadata: nil,
ip_address: nil,
created_by_user_id: t.created_by_user_id,
created_at: t.created_at)
when 'discard'
MigrationEvent.create!(event_type: 'unknown',
reason: nil,
reverted_from_event_id: nil,
metadata: nil,
ip_address: nil,
created_by_user_id: t.created_by_user_id,
created_at: t.created_at)
end
t.update_columns(event_id: ev.id)
end
# tag_description_versions の Backfill
MigrationTagDescriptionVersion.find_each do |td|
ev = MigrationEvent.create!(event_type: "tag_descriptions.#{ td.event_type }",
reason: nil,
reverted_from_event_id: nil,
metadata: nil,
ip_address: nil,
created_by_user_id: td.created_by_user_id,
created_at: td.created_at)
td.update_columns(event_id: ev.id)
end
# tag_group_versions の Backfill
MigrationTagGroupVersion.find_each do |tg|
ev = MigrationEvent.create!(event_type: "tag_groups.#{ tg.event_type }",
reason: nil,
reverted_from_event_id: nil,
metadata: nil,
ip_address: nil,
created_by_user_id: tg.created_by_user_id,
created_at: tg.created_at)
tg.update_columns(event_id: ev.id)
end
change_column_null :post_versions, :event_id, false
change_column_null :tag_versions, :event_id, false
change_column_null :material_versions, :event_id, false
change_column_null :tag_description_versions, :event_id, false
change_column_null :tag_group_versions, :event_id, false
end
def down
raise ActiveRecord::IrreversibleMigration, '戻せません.'
end
end
生成ファイル
+1 -32
ファイルの表示
@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2026_11_02_010000) do ActiveRecord::Schema[8.0].define(version: 2026_10_15_010000) do
create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "name", null: false t.string "name", null: false
t.string "record_type", null: false t.string "record_type", null: false
@@ -48,21 +48,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_11_02_010000) do
t.index ["tag_id"], name: "index_deerjikists_on_tag_id" t.index ["tag_id"], name: "index_deerjikists_on_tag_id"
end end
create_table "events", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "event_type", null: false
t.text "reason"
t.bigint "reverted_from_event_id"
t.json "metadata"
t.binary "ip_address", limit: 16
t.bigint "created_by_user_id"
t.datetime "created_at", null: false
t.index ["created_at"], name: "index_events_on_created_at"
t.index ["created_by_user_id"], name: "index_events_on_created_by_user_id"
t.index ["event_type"], name: "index_events_on_event_type"
t.index ["ip_address"], name: "index_events_on_ip_address"
t.index ["reverted_from_event_id"], name: "index_events_on_reverted_from_event_id"
end
create_table "external_tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "external_tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "platform", limit: 16, null: false t.string "platform", limit: 16, null: false
t.string "name", null: false t.string "name", null: false
@@ -265,10 +250,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_11_02_010000) do
t.string "source_path" t.string "source_path"
t.string "source_file_id" t.string "source_file_id"
t.string "normalized_source_key" t.string "normalized_source_key"
t.bigint "event_id", null: false
t.index ["created_by_user_id"], name: "index_material_versions_on_created_by_user_id" t.index ["created_by_user_id"], name: "index_material_versions_on_created_by_user_id"
t.index ["discarded_at"], name: "index_material_versions_on_discarded_at" t.index ["discarded_at"], name: "index_material_versions_on_discarded_at"
t.index ["event_id"], name: "index_material_versions_on_event_id"
t.index ["file_blob_id"], name: "index_material_versions_on_file_blob_id" t.index ["file_blob_id"], name: "index_material_versions_on_file_blob_id"
t.index ["material_id", "version_no"], name: "index_material_versions_on_material_id_and_version_no", unique: true t.index ["material_id", "version_no"], name: "index_material_versions_on_material_id_and_version_no", unique: true
t.index ["material_id"], name: "index_material_versions_on_material_id" t.index ["material_id"], name: "index_material_versions_on_material_id"
@@ -397,12 +380,10 @@ ActiveRecord::Schema[8.0].define(version: 2026_11_02_010000) do
t.text "parent_post_ids", null: false t.text "parent_post_ids", null: false
t.datetime "original_created_from" t.datetime "original_created_from"
t.datetime "original_created_before" t.datetime "original_created_before"
t.bigint "event_id", null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.bigint "created_by_user_id" t.bigint "created_by_user_id"
t.integer "video_ms" t.integer "video_ms"
t.index ["created_by_user_id"], name: "index_post_versions_on_created_by_user_id" t.index ["created_by_user_id"], name: "index_post_versions_on_created_by_user_id"
t.index ["event_id"], name: "index_post_versions_on_event_id"
t.index ["post_id", "version_no"], name: "index_post_versions_on_post_id_and_version_no", unique: true t.index ["post_id", "version_no"], name: "index_post_versions_on_post_id_and_version_no", unique: true
t.index ["post_id"], name: "index_post_versions_on_post_id" t.index ["post_id"], name: "index_post_versions_on_post_id"
t.index ["video_ms", "post_id"], name: "idx_post_versions_video_ms_post_id" t.index ["video_ms", "post_id"], name: "idx_post_versions_video_ms_post_id"
@@ -467,11 +448,9 @@ ActiveRecord::Schema[8.0].define(version: 2026_11_02_010000) do
t.text "body", null: false t.text "body", null: false
t.string "source_language_code", limit: 16 t.string "source_language_code", limit: 16
t.integer "source_version_no" t.integer "source_version_no"
t.bigint "event_id", null: false
t.bigint "created_by_user_id" t.bigint "created_by_user_id"
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.index ["created_by_user_id"], name: "index_tag_description_versions_on_created_by_user_id" t.index ["created_by_user_id"], name: "index_tag_description_versions_on_created_by_user_id"
t.index ["event_id"], name: "index_tag_description_versions_on_event_id"
t.index ["tag_id", "language_code", "version_no"], name: "idx_on_tag_id_language_code_version_no_ced77e57e6", unique: true t.index ["tag_id", "language_code", "version_no"], name: "idx_on_tag_id_language_code_version_no_ced77e57e6", unique: true
t.index ["tag_id", "source_language_code", "source_version_no"], name: "fk_rails_4f13094dbc" t.index ["tag_id", "source_language_code", "source_version_no"], name: "fk_rails_4f13094dbc"
t.index ["tag_id"], name: "index_tag_description_versions_on_tag_id" t.index ["tag_id"], name: "index_tag_description_versions_on_tag_id"
@@ -554,11 +533,9 @@ ActiveRecord::Schema[8.0].define(version: 2026_11_02_010000) do
t.text "description" t.text "description"
t.json "members", null: false t.json "members", null: false
t.json "placements", null: false t.json "placements", null: false
t.bigint "event_id", null: false
t.bigint "created_by_user_id" t.bigint "created_by_user_id"
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.index ["created_by_user_id"], name: "index_tag_group_versions_on_created_by_user_id" t.index ["created_by_user_id"], name: "index_tag_group_versions_on_created_by_user_id"
t.index ["event_id"], name: "index_tag_group_versions_on_event_id"
t.index ["tag_group_id", "version_no"], name: "index_tag_group_versions_on_tag_group_id_and_version_no", unique: true t.index ["tag_group_id", "version_no"], name: "index_tag_group_versions_on_tag_group_id_and_version_no", unique: true
t.index ["tag_group_id"], name: "index_tag_group_versions_on_tag_group_id" t.index ["tag_group_id"], name: "index_tag_group_versions_on_tag_group_id"
t.check_constraint "`version_no` > 0", name: "chk_tag_group_versions_version_no_positive" t.check_constraint "`version_no` > 0", name: "chk_tag_group_versions_version_no_positive"
@@ -634,13 +611,11 @@ ActiveRecord::Schema[8.0].define(version: 2026_11_02_010000) do
t.text "aliases", null: false t.text "aliases", null: false
t.text "parent_tag_ids", null: false t.text "parent_tag_ids", null: false
t.datetime "deprecated_at" t.datetime "deprecated_at"
t.bigint "event_id", null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.bigint "created_by_user_id" t.bigint "created_by_user_id"
t.json "names", null: false t.json "names", null: false
t.index ["created_at"], name: "index_tag_versions_on_created_at" t.index ["created_at"], name: "index_tag_versions_on_created_at"
t.index ["created_by_user_id", "created_at"], name: "index_tag_versions_on_created_by_user_id_and_created_at", order: { created_at: :desc } t.index ["created_by_user_id", "created_at"], name: "index_tag_versions_on_created_by_user_id_and_created_at", order: { created_at: :desc }
t.index ["event_id"], name: "index_tag_versions_on_event_id"
t.index ["tag_id", "created_at"], name: "index_tag_versions_on_tag_id_and_created_at", order: { created_at: :desc } t.index ["tag_id", "created_at"], name: "index_tag_versions_on_tag_id_and_created_at", order: { created_at: :desc }
t.index ["tag_id", "version_no"], name: "index_tag_versions_on_tag_id_and_version_no", unique: true t.index ["tag_id", "version_no"], name: "index_tag_versions_on_tag_id_and_version_no", unique: true
t.check_constraint "`version_no` > 0", name: "tag_versions_version_no_positive" t.check_constraint "`version_no` > 0", name: "tag_versions_version_no_positive"
@@ -796,7 +771,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_11_02_010000) do
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "events", "events", column: "reverted_from_event_id"
add_foreign_key "gekanator_ai_runs", "gekanator_question_suggestions" add_foreign_key "gekanator_ai_runs", "gekanator_question_suggestions"
add_foreign_key "gekanator_games", "posts", column: "correct_post_id" add_foreign_key "gekanator_games", "posts", column: "correct_post_id"
add_foreign_key "gekanator_games", "posts", column: "guessed_post_id" add_foreign_key "gekanator_games", "posts", column: "guessed_post_id"
@@ -818,7 +792,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_11_02_010000) do
add_foreign_key "material_sync_sources", "users", column: "created_by_user_id" add_foreign_key "material_sync_sources", "users", column: "created_by_user_id"
add_foreign_key "material_sync_sources", "users", column: "updated_by_user_id" add_foreign_key "material_sync_sources", "users", column: "updated_by_user_id"
add_foreign_key "material_sync_suppressions", "users", column: "created_by_user_id" add_foreign_key "material_sync_suppressions", "users", column: "created_by_user_id"
add_foreign_key "material_versions", "events"
add_foreign_key "material_versions", "materials" add_foreign_key "material_versions", "materials"
add_foreign_key "material_versions", "materials", column: "parent_id" add_foreign_key "material_versions", "materials", column: "parent_id"
add_foreign_key "material_versions", "users", column: "created_by_user_id" add_foreign_key "material_versions", "users", column: "created_by_user_id"
@@ -840,12 +813,10 @@ ActiveRecord::Schema[8.0].define(version: 2026_11_02_010000) do
add_foreign_key "post_tags", "posts" add_foreign_key "post_tags", "posts"
add_foreign_key "post_tags", "tags" add_foreign_key "post_tags", "tags"
add_foreign_key "post_tags", "users", column: "created_user_id" add_foreign_key "post_tags", "users", column: "created_user_id"
add_foreign_key "post_versions", "events"
add_foreign_key "post_versions", "posts" add_foreign_key "post_versions", "posts"
add_foreign_key "post_versions", "users", column: "created_by_user_id" add_foreign_key "post_versions", "users", column: "created_by_user_id"
add_foreign_key "posts", "users", column: "uploaded_user_id" add_foreign_key "posts", "users", column: "uploaded_user_id"
add_foreign_key "settings", "users" add_foreign_key "settings", "users"
add_foreign_key "tag_description_versions", "events"
add_foreign_key "tag_description_versions", "tag_description_versions", column: ["tag_id", "source_language_code", "source_version_no"], primary_key: ["tag_id", "language_code", "version_no"] add_foreign_key "tag_description_versions", "tag_description_versions", column: ["tag_id", "source_language_code", "source_version_no"], primary_key: ["tag_id", "language_code", "version_no"]
add_foreign_key "tag_descriptions", "languages", column: "language_code", primary_key: "code" add_foreign_key "tag_descriptions", "languages", column: "language_code", primary_key: "code"
add_foreign_key "tag_descriptions", "tag_description_versions", column: ["tag_id", "source_language_code", "source_version_no"], primary_key: ["tag_id", "language_code", "version_no"] add_foreign_key "tag_descriptions", "tag_description_versions", column: ["tag_id", "source_language_code", "source_version_no"], primary_key: ["tag_id", "language_code", "version_no"]
@@ -864,7 +835,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_11_02_010000) do
add_foreign_key "tag_group_tags", "tag_groups" add_foreign_key "tag_group_tags", "tag_groups"
add_foreign_key "tag_group_tags", "tags" add_foreign_key "tag_group_tags", "tags"
add_foreign_key "tag_group_tags", "users", column: "created_by_user_id" add_foreign_key "tag_group_tags", "users", column: "created_by_user_id"
add_foreign_key "tag_group_versions", "events"
add_foreign_key "tag_groups", "users", column: "created_by_user_id" add_foreign_key "tag_groups", "users", column: "created_by_user_id"
add_foreign_key "tag_groups", "users", column: "updated_by_user_id" add_foreign_key "tag_groups", "users", column: "updated_by_user_id"
add_foreign_key "tag_implications", "tags" add_foreign_key "tag_implications", "tags"
@@ -874,7 +844,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_11_02_010000) do
add_foreign_key "tag_names", "tags" add_foreign_key "tag_names", "tags"
add_foreign_key "tag_similarities", "tags" add_foreign_key "tag_similarities", "tags"
add_foreign_key "tag_similarities", "tags", column: "target_tag_id" add_foreign_key "tag_similarities", "tags", column: "target_tag_id"
add_foreign_key "tag_versions", "events"
add_foreign_key "tag_versions", "users", column: "created_by_user_id" add_foreign_key "tag_versions", "users", column: "created_by_user_id"
add_foreign_key "theatre_comments", "theatres" add_foreign_key "theatre_comments", "theatres"
add_foreign_key "theatre_comments", "users" add_foreign_key "theatre_comments", "users"
+15 -2
ファイルの表示
@@ -9,8 +9,21 @@
# end # end
ApplicationRecord.transaction do ApplicationRecord.transaction do
Language.find_or_create_by!(code: 'ain') { _1.name = 'ain' } Language.find_or_create_by!(code: 'ja') { _1.name = '日本語' }
Script.find_or_create_by!(code: 'Kana') { _1.name = 'Kana' } Script.find_or_create_by!(code: 'Jpan') { _1.name = '漢字、仮名文字およびラテン文字' }
Region.find_or_create_by!(code: 392) do
_1.alpha_code = 'JP'
_1.name = '日本国'
end
Locale.find_or_create_by!(code: 'ja') do
_1.language_code = 'ja'
_1.script_code = 'Jpan'
_1.region_code = 392
_1.name = '日本語'
end
Language.find_or_create_by!(code: 'ain') { _1.name = 'ヤウンモシルン アイヌ イタㇰ' }
Script.find_or_create_by!(code: 'Kana') { _1.name = 'カナモジ オヨビ ラテンモジ' }
end end
post_url_sanitisation_rules = [ post_url_sanitisation_rules = [
-1
ファイルの表示
@@ -102,7 +102,6 @@ RSpec.describe 'Tag group persistence constraints' do
6.times { nodes = [{ 'tag_id' => tag.id, 'children' => nodes }] } 6.times { nodes = [{ 'tag_id' => tag.id, 'children' => nodes }] }
reject_statement do reject_statement do
insert_group_row('tag_group_versions', { insert_group_row('tag_group_versions', {
event_id: create_event!(event_type: 'unknown').id,
tag_group_id: group_id, version_no: 2, event_type: 'update', name: '制約確認', tag_group_id: group_id, version_no: 2, event_type: 'update', name: '制約確認',
members: [{ 'tag_id' => tag.id, 'version_no' => tag.version_no }].to_json, members: [{ 'tag_id' => tag.id, 'version_no' => tag.version_no }].to_json,
placements: nodes.to_json, created_at: Time.current }) placements: nodes.to_json, created_at: Time.current })
-1
ファイルの表示
@@ -10,7 +10,6 @@ RSpec.describe 'TagVersion names database constraint' do
def insert_snapshot!(snapshot) def insert_snapshot!(snapshot)
TagVersion.insert_all!([{ tag_id: tag.id, version_no: 1, event_type: 'create', TagVersion.insert_all!([{ tag_id: tag.id, version_no: 1, event_type: 'create',
event_id: create_event!(event_type: 'unknown').id,
name: 'snapshot_name', category: 'general', name: 'snapshot_name', category: 'general',
aliases: '', parent_tag_ids: '', names: snapshot, aliases: '', parent_tag_ids: '', names: snapshot,
created_at: Time.current }]) created_at: Time.current }])
+7 -21
ファイルの表示
@@ -9,9 +9,7 @@ RSpec.describe HistoryAccess do
locale locale
rows = ['ja', 'ain'].flat_map do |language| rows = ['ja', 'ain'].flat_map do |language|
['create', 'discard', 'restore'].each_with_index.map do |event, index| ['create', 'discard', 'restore'].each_with_index.map do |event, index|
TagDescriptionVersion.create!( TagDescriptionVersion.create!(tag_id: tag.id, language_code: language,
event_id: create_event!(event_type: 'unknown').id,
tag_id: tag.id, language_code: language,
version_no: index + 1, event_type: language == 'ain' ? 'update' : event, version_no: index + 1, event_type: language == 'ain' ? 'update' : event,
body: "#{ language } #{ index }", created_at: Time.utc(2024)) body: "#{ language } #{ index }", created_at: Time.utc(2024))
end end
@@ -30,9 +28,7 @@ RSpec.describe HistoryAccess do
public_metadata = described_class.set(user: nil) { described_class.authority } public_metadata = described_class.set(user: nil) { described_class.authority }
admin = create(:user, :admin) admin = create(:user, :admin)
admin_metadata = described_class.set(user: admin) { described_class.authority } admin_metadata = described_class.set(user: admin) { described_class.authority }
TagVersion.create!( TagVersion.create!(tag:, version_no: 1, event_type: 'discard', name: 'private',
event_id: create_event!(event_type: 'unknown').id,
tag:, version_no: 1, event_type: 'discard', name: 'private',
category: 'general', category: 'general',
names: { ja: { name: 'private', script_code: 'Jpan', names: { ja: { name: 'private', script_code: 'Jpan',
auto_generated: false, aliases: [] } }, auto_generated: false, aliases: [] } },
@@ -56,9 +52,7 @@ RSpec.describe HistoryAccess do
blob = ActiveStorage::Blob.create_before_direct_upload!(filename: 'private.png', blob = ActiveStorage::Blob.create_before_direct_upload!(filename: 'private.png',
byte_size: 1, checksum: Base64.strict_encode64('checksum'), content_type: 'image/png') byte_size: 1, checksum: Base64.strict_encode64('checksum'), content_type: 'image/png')
ActiveStorage::Attachment.create!(name: 'thumbnail', record: post, blob:) ActiveStorage::Attachment.create!(name: 'thumbnail', record: post, blob:)
PostVersion.create!( PostVersion.create!(post:, version_no: 1, event_type: 'discard', url: post.url,
event_id: create_event!(event_type: 'unknown').id,
post:, version_no: 1, event_type: 'discard', url: post.url,
title: 'private', tags: '', tags_json: [], parent_post_ids: '', created_at: Time.utc(2024)) title: 'private', tags: '', tags_json: [], parent_post_ids: '', created_at: Time.utc(2024))
variant = ActiveStorage::VariantRecord.create!(blob:, variation_digest: 'spec-variation') variant = ActiveStorage::VariantRecord.create!(blob:, variation_digest: 'spec-variation')
derived = ActiveStorage::Blob.create_before_direct_upload!(filename: 'derived.png', derived = ActiveStorage::Blob.create_before_direct_upload!(filename: 'derived.png',
@@ -84,9 +78,7 @@ RSpec.describe 'Counts artifact policy and external references' do
it 'does not expose a private linked tag name through an external history snapshot' do it 'does not expose a private linked tag name through an external history snapshot' do
tag = create(:tag, primary_name: 'private-linked-name') tag = create(:tag, primary_name: 'private-linked-name')
TagVersion.create!( TagVersion.create!(tag:, version_no: 1, event_type: 'discard', name: 'private-linked-name',
event_id: create_event!(event_type: 'unknown').id,
tag:, version_no: 1, event_type: 'discard', name: 'private-linked-name',
category: 'general', aliases: '', parent_tag_ids: '', created_at: Time.utc(2024), category: 'general', aliases: '', parent_tag_ids: '', created_at: Time.utc(2024),
names: { ja: { name: 'private-linked-name', script_code: 'Jpan', names: { ja: { name: 'private-linked-name', script_code: 'Jpan',
auto_generated: false, aliases: [] } }) auto_generated: false, aliases: [] } })
@@ -95,9 +87,7 @@ RSpec.describe 'Counts artifact policy and external references' do
name: 'nico:public-external', linked_tags: 'private-linked-name', name: 'nico:public-external', linked_tags: 'private-linked-name',
created_at: Time.utc(2024)) created_at: Time.utc(2024))
post = create(:post) post = create(:post)
snapshot = PostVersion.create!( snapshot = PostVersion.create!(post:, version_no: 1, event_type: 'create',
event_id: create_event!(event_type: 'unknown').id,
post:, version_no: 1, event_type: 'create',
title: 'external reference', url: post.url, tags: '', title: 'external reference', url: post.url, tags: '',
tags_json: [{ external_tag_id: external.id }], parent_post_ids: '', tags_json: [{ external_tag_id: external.id }], parent_post_ids: '',
created_at: Time.utc(2024, 2)) created_at: Time.utc(2024, 2))
@@ -122,17 +112,13 @@ RSpec.describe 'Counts artifact policy and external references' do
child = create(:tag, primary_name: 'referencing child') child = create(:tag, primary_name: 'referencing child')
[[parent, 'discard', '', 'private parent'], [[parent, 'discard', '', 'private parent'],
[child, 'create', parent.id.to_s, 'referencing child']].each do |tag, event, parents, name| [child, 'create', parent.id.to_s, 'referencing child']].each do |tag, event, parents, name|
TagVersion.create!( TagVersion.create!(tag:, version_no: 1, event_type: event, name:,
event_id: create_event!(event_type: 'unknown').id,
tag:, version_no: 1, event_type: event, name:,
category: 'general', aliases: '', parent_tag_ids: parents, created_at: Time.utc(2023), category: 'general', aliases: '', parent_tag_ids: parents, created_at: Time.utc(2023),
names: { ja: { name:, script_code: 'Jpan', names: { ja: { name:, script_code: 'Jpan',
auto_generated: false, aliases: [] } }) auto_generated: false, aliases: [] } })
end end
post = create(:post) post = create(:post)
row = PostVersion.create!( row = PostVersion.create!(post:, version_no: 1, event_type: 'create', title: 'snapshot',
event_id: create_event!(event_type: 'unknown').id,
post:, version_no: 1, event_type: 'create', title: 'snapshot',
url: post.url, tags: '', parent_post_ids: '', url: post.url, tags: '', parent_post_ids: '',
tags_json: [{ tag_id: child.id, version_no: 1, name: 'referencing child', sections: [] }], tags_json: [{ tag_id: child.id, version_no: 1, name: 'referencing child', sections: [] }],
created_at: Time.utc(2024)) created_at: Time.utc(2024))
+1 -24
ファイルの表示
@@ -23,22 +23,9 @@ RSpec.describe 'Locale activation', type: :model do
.and_raise('backfill failed') .and_raise('backfill failed')
end end
it 'groups all tag histories from locale creation into one event' do
previous_ids = TagVersion.pluck(:id)
expect { build_locale.save! }.to change { event_rows.length }.by(1)
event = event_rows.last
expect(event).to include('event_type' => 'locales.create', 'created_by_user_id' => nil)
versions = TagVersion.where.not(id: previous_ids)
expect(versions.pluck(:tag_id)).to include(first.id, second.id)
expect(versions.pluck(:event_id).uniq).to eq([event.fetch('id')])
end
it 'rolls back a newly created locale and the preceding generated names' do it 'rolls back a newly created locale and the preceding generated names' do
locale = build_locale locale = build_locale
fail_second_generation! fail_second_generation!
events = event_rows
expect { locale.save! }.to raise_error('backfill failed') expect { locale.save! }.to raise_error('backfill failed')
@@ -46,7 +33,6 @@ RSpec.describe 'Locale activation', type: :model do
expect(generated_names).to be_empty expect(generated_names).to be_empty
expect(first.reload.name('ja')).to eq('activation_first') expect(first.reload.name('ja')).to eq('activation_first')
expect(second.reload.name('ja')).to eq('activation_second') expect(second.reload.name('ja')).to eq('activation_second')
expect(event_rows).to eq(events)
end end
it 'does not backfill while the locale is inactive' do it 'does not backfill while the locale is inactive' do
@@ -59,14 +45,7 @@ RSpec.describe 'Locale activation', type: :model do
locale = build_locale(deprecated_at: Time.current) locale = build_locale(deprecated_at: Time.current)
locale.save! locale.save!
previous_ids = TagVersion.pluck(:id) locale.update!(deprecated_at: nil)
expect { locale.update!(deprecated_at: nil) }.to change { event_rows.length }.by(1)
event = event_rows.last
expect(event).to include('event_type' => 'locales.restore', 'created_by_user_id' => nil)
versions = TagVersion.where.not(id: previous_ids)
expect(versions.pluck(:tag_id)).to include(first.id, second.id)
expect(versions.pluck(:event_id).uniq).to eq([event.fetch('id')])
expect(generated_names.pluck(:tag_id)).to contain_exactly(first.id, second.id) expect(generated_names.pluck(:tag_id)).to contain_exactly(first.id, second.id)
expect(generated_names).to all(have_attributes( expect(generated_names).to all(have_attributes(
@@ -78,13 +57,11 @@ RSpec.describe 'Locale activation', type: :model do
locale.save! locale.save!
previous_deprecated_at = locale.reload.deprecated_at previous_deprecated_at = locale.reload.deprecated_at
fail_second_generation! fail_second_generation!
events = event_rows
expect { locale.update!(deprecated_at: nil) }.to raise_error('backfill failed') expect { locale.update!(deprecated_at: nil) }.to raise_error('backfill failed')
expect(locale.reload.deprecated_at).to eq(previous_deprecated_at) expect(locale.reload.deprecated_at).to eq(previous_deprecated_at)
expect(generated_names).to be_empty expect(generated_names).to be_empty
expect(event_rows).to eq(events)
end end
it 'preserves a human primary through deactivation and repeated activation' do it 'preserves a human primary through deactivation and repeated activation' do
+2 -2
ファイルの表示
@@ -12,7 +12,6 @@ RSpec.describe PostVersion, type: :model do
let!(:post_version) do let!(:post_version) do
PostVersion.create!( PostVersion.create!(
event_id: create_event!(event_type: 'unknown').id,
post: post_record, post: post_record,
version_no: 1, version_no: 1,
event_type: 'create', event_type: 'create',
@@ -25,7 +24,8 @@ RSpec.describe PostVersion, type: :model do
original_created_from: post_record.original_created_from, original_created_from: post_record.original_created_from,
original_created_before: post_record.original_created_before, original_created_before: post_record.original_created_before,
created_at: Time.current, created_at: Time.current,
created_by_user: nil) created_by_user: nil
)
end end
it 'is read only after create' do it 'is read only after create' do
-27
ファイルの表示
@@ -34,31 +34,6 @@ RSpec.describe TagNameSanitisationRule, type: :model do
described_class.create!(priority: 10, source_pattern: 'foo_', replacement: 'foo') described_class.create!(priority: 10, source_pattern: 'foo_', replacement: 'foo')
end end
it 'groups renames, merges and affected post histories into one event' do
target = create(:tag, primary_name: 'foobar')
source = create(:tag, primary_name: 'temporary_source')
renamed = create(:tag, primary_name: 'temporary_rename')
source.tag_name('ja').update_columns(name: 'foo_bar')
renamed.tag_name('ja').update_columns(name: 'foo_baz')
post = create(:post)
PostTag.create!(post:, tag: source)
previous_tag_ids = TagVersion.pluck(:id)
previous_post_ids = PostVersion.pluck(:id)
expect { described_class.apply! }.to change { event_rows.length }.by(1)
event = event_rows.last
expect(event).to include(
'event_type' => 'tag_name_sanitisation_rules.apply', 'created_by_user_id' => nil)
tag_versions = TagVersion.where.not(id: previous_tag_ids)
expect(tag_versions.pluck(:tag_id).uniq).to contain_exactly(target.id, source.id, renamed.id)
expect(tag_versions.pluck(:event_type)).to include('discard', 'update')
expect(tag_versions.pluck(:event_id).uniq).to eq([event.fetch('id')])
post_versions = PostVersion.where.not(id: previous_post_ids)
expect(post_versions.pluck(:post_id).uniq).to eq([post.id])
expect(post_versions.pluck(:event_id).uniq).to eq([event.fetch('id')])
end
[:primary, :alias].each do |source_kind| [:primary, :alias].each do |source_kind|
it "keeps the primary identity when a #{ source_kind } collides within its Tag" do it "keeps the primary identity when a #{ source_kind } collides within its Tag" do
tag = create(:tag, primary_name: source_kind == :primary ? 'temporary' : 'foobar') tag = create(:tag, primary_name: source_kind == :primary ? 'temporary' : 'foobar')
@@ -108,7 +83,6 @@ RSpec.describe TagNameSanitisationRule, type: :model do
PostTag.create!(post:, tag: source) PostTag.create!(post:, tag: source)
models = [Tag, TagName, TagVersion, TagDescription, TagDescriptionVersion, PostTag] models = [Tag, TagName, TagVersion, TagDescription, TagDescriptionVersion, PostTag]
snapshots = models.map { |model| model.order(*Array(model.primary_key)).map(&:attributes) } snapshots = models.map { |model| model.order(*Array(model.primary_key)).map(&:attributes) }
events = event_rows
expect { described_class.apply! }.to raise_error(ActiveRecord::RecordInvalid) expect { described_class.apply! }.to raise_error(ActiveRecord::RecordInvalid)
@@ -116,7 +90,6 @@ RSpec.describe TagNameSanitisationRule, type: :model do
model.order(*Array(model.primary_key)).map(&:attributes) model.order(*Array(model.primary_key)).map(&:attributes)
end end
expect(after_state).to eq(snapshots) expect(after_state).to eq(snapshots)
expect(event_rows).to eq(events)
expect(post.reload.tags).to contain_exactly(source) expect(post.reload.tags).to contain_exactly(source)
expect(target.reload.name('ja')).to eq('foobar') expect(target.reload.name('ja')).to eq('foobar')
end end
+2 -2
ファイルの表示
@@ -665,7 +665,6 @@ RSpec.describe Tag, type: :model do
def create_post_version_for!(post, version_no: 1, event_type: 'create', created_by_user: nil) def create_post_version_for!(post, version_no: 1, event_type: 'create', created_by_user: nil)
PostVersion.create!( PostVersion.create!(
event_id: create_event!(event_type: 'unknown').id,
post: post, post: post,
version_no: version_no, version_no: version_no,
event_type: event_type, event_type: event_type,
@@ -678,7 +677,8 @@ RSpec.describe Tag, type: :model do
original_created_from: post.original_created_from, original_created_from: post.original_created_from,
original_created_before: post.original_created_before, original_created_before: post.original_created_before,
created_at: Time.current, created_at: Time.current,
created_by_user: created_by_user) created_by_user: created_by_user
)
end end
context 'when post versions are enabled' do context 'when post versions are enabled' do
-2
ファイルの表示
@@ -6,7 +6,6 @@ RSpec.describe VersionRecord, type: :model do
it 'makes TagVersion read only after create' do it 'makes TagVersion read only after create' do
version = TagVersion.create!( version = TagVersion.create!(
event_id: create_event!(event_type: 'unknown').id,
tag: tag, tag: tag,
version_no: 1, version_no: 1,
event_type: 'create', event_type: 'create',
@@ -26,7 +25,6 @@ RSpec.describe VersionRecord, type: :model do
it 'prevents TagVersion destroy' do it 'prevents TagVersion destroy' do
version = TagVersion.create!( version = TagVersion.create!(
event_id: create_event!(event_type: 'unknown').id,
tag: tag, tag: tag,
version_no: 1, version_no: 1,
event_type: 'create', event_type: 'create',
-1
ファイルの表示
@@ -44,7 +44,6 @@ RSpec.configure do |config|
end end
config.include TestRecords config.include TestRecords
config.include EventRecords
config.include RakeTaskHelper config.include RakeTaskHelper
# FactoryBot の create / create_list を使へるやぅにする # FactoryBot の create / create_list を使へるやぅにする
+1 -3
ファイルの表示
@@ -84,9 +84,7 @@ RSpec.describe 'Counts native GET acknowledgement', type: :request do
expect(response).to have_http_status(:not_found) expect(response).to have_http_status(:not_found)
job, file, attempt = ready_job('json') job, file, attempt = ready_job('json')
record = create(:post) record = create(:post)
PostVersion.create!( PostVersion.create!(post: record, version_no: 1, event_type: 'discard', title: 'private',
event_id: create_event!(event_type: 'unknown').id,
post: record, version_no: 1, event_type: 'discard', title: 'private',
url: record.url, tags: '', tags_json: [], parent_post_ids: '', created_at: Time.utc(2024)) url: record.url, tags: '', tags_json: [], parent_post_ids: '', created_at: Time.utc(2024))
get "/counts/export/files/#{ file }", params: { job:, attempt: } get "/counts/export/files/#{ file }", params: { job:, attempt: }
expect(response).to have_http_status(:not_found) expect(response).to have_http_status(:not_found)
+1 -3
ファイルの表示
@@ -8,9 +8,7 @@ RSpec.describe 'History boundaries shared with Counts', type: :request do
before do before do
['create', 'discard'].each_with_index do |event, index| ['create', 'discard'].each_with_index do |event, index|
TagDescriptionVersion.create!( TagDescriptionVersion.create!(tag_id: tag.id, language_code: 'ja',
event_id: create_event!(event_type: 'unknown').id,
tag_id: tag.id, language_code: 'ja',
version_no: index + 1, event_type: event, body: 'private body', version_no: index + 1, event_type: event, body: 'private body',
created_at: Time.utc(2024)) created_at: Time.utc(2024))
end end
+196
ファイルの表示
@@ -0,0 +1,196 @@
require 'rails_helper'
RSpec.describe 'Counts masked search', type: :request do
let!(:tag) { create(:tag, primary_name: '秘密のタグ') }
let(:actor) { create(:user, :member) }
let(:admin) { create(:user, :admin) }
let(:base) do
{ 'resource' => 'tags', 'definition' => 'history', 'mode' => 'summary',
'locale' => 'ja', 'execution_at' => '2025-01-01T00:00:00Z',
'timezone' => 'UTC', 'rows' => '', 'cols' => '', 'sort' => '' }
end
before do
[1, 2].each do |number|
TagVersion.create!(tag:, version_no: number,
event_type: number == 1 ? 'create' : 'discard', name: '秘密のタグ',
category: 'general', aliases: '秘密の別名', parent_tag_ids: '',
names: { ja: { name: '秘密のタグ', script_code: 'Jpan', auto_generated: false,
aliases: [{ name: '秘密の別名', script_code: 'Jpan' }] } },
created_by_user: actor, created_at: Time.utc(2023, number))
end
end
def query(extra = {})
get '/counts/query', params: base.merge(extra)
expect(response).to have_http_status(:ok), extra.inspect
response.parsed_body
end
def predicate(field, operator, value, index = 0)
prefix = "conditions.#{ index }"
result = { 'conditions' => '', "#{ prefix }.field" => field,
"#{ prefix }.operator" => operator }
if ['in', 'any', 'all', 'none', 'not_in'].include?(operator)
result.merge!("#{ prefix }.values" => '1', 'condition_values' => '',
"condition_values.#{ index }.condition" => index.to_s,
"condition_values.#{ index }.value_type" => 'string',
"condition_values.#{ index }.value" => value)
else
result["#{ prefix }.value"] = value
end
result
end
it 'rejects every name and alias predicate on private history' do
['name', 'name_ja', 'names.name', 'aliases.name', 'names.aliases.name'].each do |field|
secret = field.include?('aliases') ? '秘密の別名' : '秘密のタグ'
multiple = !['name', 'name_ja'].include?(field)
operators = multiple ? ['any', 'all', 'none'] : ['in', 'not_in']
['eq', 'ne', 'like', *operators].each do |operator|
[secret, '********', '%', ''].each do |value|
expected = (value == '' && ['eq', 'ne', 'like'].include?(operator)) ? 2 : 0
expect(query(predicate(field, operator, value))['count']).to eq(expected)
end
end
blank = predicate(field, 'eq', '').merge('conditions.0.blank' => '1')
expect(query(blank)['count']).to eq(0)
end
end
it 'ignores missing and empty scalar values unless blank is checked' do
['name', 'name_ja', 'names.name', 'aliases.name'].each do |field|
['eq', 'ne', 'like'].each do |operator|
condition = predicate(field, operator, '')
[condition, condition.except('conditions.0.value')].each do |empty|
expect(query(empty)['count']).to eq(2)
expect(query(empty.merge('conditions.0.blank' => '0'))['count']).to eq(2)
expect(query(empty.merge('conditions.0.blank' => '1'))['count']).to eq(0)
end
end
end
end
it 'keeps details totals unchanged for inactive private predicates' do
settings = predicate('name', 'ne', '').merge('mode' => 'details')
result = query(settings)
expect(result['count']).to eq(0)
expect(result['versions']).to be_empty
expect(result['output_count']).to eq(2)
active = query(settings.merge('conditions.0.blank' => '1'))
expect(active['output_count']).to eq(0)
end
it 'ignores missing sets but preserves explicit empty set semantics' do
{ 'name' => ['in', 'not_in'], 'aliases.name' => ['any', 'all', 'none'] }.each do
|field, operators|
operators.each do |operator|
condition = { 'conditions' => '', 'conditions.0.field' => field,
'conditions.0.operator' => operator }
expect(query(condition)['count']).to eq(2)
empty = condition.merge('conditions.0.values' => '1', 'condition_values' => '')
expected = ['in', 'any'].include?(operator) ? 0 : 2
expect(query(empty)['count']).to eq(expected)
expect(query(condition.merge('conditions.0.blank' => '1'))['count']).to eq(0)
# An explicit empty string element is a real value, not an unspecified set.
expect(query(predicate(field, operator, ''))['count']).to eq(0)
end
end
end
it 'ignores empty private predicates regardless of their position' do
first = predicate('name', 'ne', '')
.merge(predicate('resource_id', 'eq', tag.id.to_s, 1))
last = predicate('resource_id', 'eq', tag.id.to_s)
.merge(predicate('aliases.name', 'like', '', 1))
expect(query(first)['count']).to eq(2)
expect(query(last)['count']).to eq(2)
active = predicate('name', 'ne', 'unrelated')
.merge(predicate('aliases.name', 'like', '', 1))
expect(query(active)['count']).to eq(0)
end
it 'searches public literal markers and treats axis predicates as explicit' do
public_tag = create(:tag, primary_name: '********')
TagVersion.create!(tag: public_tag, version_no: 1, event_type: 'create',
name: '********', category: 'general', aliases: '', parent_tag_ids: '',
names: { ja: { name: '********', script_code: 'Jpan', auto_generated: false,
aliases: [] } }, created_at: Time.utc(2023))
expect(query['count']).to eq(3)
['eq', 'ne', 'like', 'in', 'not_in'].each do |operator|
expected = ['ne', 'not_in'].include?(operator) ? 0 : 1
expect(query(predicate('name', operator, '********'))['count']).to eq(expected)
end
blank = predicate('aliases.name', 'eq', '').merge('conditions.0.blank' => '1')
expect(query(blank)['count']).to eq(1)
['********', '', nil].each do |value|
condition = { 'conditions' => '', 'conditions.0.field' => 'name',
'conditions.0.operator' => 'axis_eq',
'conditions.0.value_type' => value.nil? ? 'null' : 'string' }
condition['conditions.0.value'] = value unless value.nil?
expected = value == '********' ? 1 : 0
expect(query(condition)['count']).to eq(expected)
drill = { 'drill' => '', 'drill.0.field' => 'name',
'drill.0.value_type' => value.nil? ? 'null' : 'string' }
drill['drill.0.value'] = value unless value.nil?
expect(query(drill)['count']).to eq(expected)
end
end
it 'keeps public identity and timeline predicates searchable' do
expect(query['count']).to eq(2)
values = { 'resource_id' => tag.id.to_s, 'version_no' => '2',
'event_type' => 'discard', 'user' => actor.id.to_s,
'created_at' => '2023-02-01T00:00:00Z' }
values.each do |field, value|
expected = ['resource_id', 'user'].include?(field) ? 2 : 1
expect(query(predicate(field, 'eq', value))['count']).to eq(expected)
end
condition = predicate('name', 'ne', '秘密のタグ')
.merge(predicate('event_type', 'eq', 'discard', 1))
expect(query(condition)['count']).to eq(0)
expect(query(predicate('category', 'eq', '********'))['count']).to eq(0)
expect(query(predicate('category', 'eq', 'general'))['count']).to eq(0)
end
it 'keeps public predicates available after combining multiple conditions' do
condition = predicate('resource_id', 'eq', tag.id.to_s)
.merge(predicate('event_type', 'eq', 'discard', 1))
expect(query(condition)['count']).to eq(1)
condition.merge!(predicate('name', 'ne', 'unrelated', 2))
expect(query(condition)['count']).to eq(0)
end
it 'keeps real name searches for administrators and excludes private details' do
condition = predicate('name', 'eq', '秘密のタグ')
details = query(condition.merge('mode' => 'details'))
expect(details['versions']).to be_empty
expect(details['output_count']).to eq(0)
masked = query(predicate('name', 'eq', '********').merge('mode' => 'details'))
expect(masked['versions']).to be_empty
expect(masked['output_count']).to eq(0)
sign_in_as(admin)
expect(query(condition)['count']).to eq(2)
expect(query(condition.merge('mode' => 'details'))['versions'].length).to eq(2)
end
it 'rejects private names reached through a post tag snapshot' do
post = create(:post)
PostVersion.create!(post:, version_no: 1, event_type: 'create', title: 'post',
url: post.url, tags: '', parent_post_ids: '',
tags_json: [{ tag_id: tag.id, version_no: 1, name: '秘密のタグ',
category: 'general', sections: [] }],
created_at: Time.utc(2024))
['tags.name', 'tags.names.name', 'tags.name_ja'].each do |field|
['eq', 'ne', 'like', 'any', 'all', 'none'].each do |operator|
['秘密のタグ', '********', '%'].each do |value|
settings = predicate(field, operator, value).merge('resource' => 'posts')
expect(query(settings)['count']).to eq(0)
end
end
blank = predicate(field, 'eq', '').merge('resource' => 'posts',
'conditions.0.blank' => '1')
expect(query(blank)['count']).to eq(0)
end
end
end
+2 -6
ファイルの表示
@@ -10,16 +10,12 @@ RSpec.describe 'Legacy Counts API', type: :request do
category: 'general', sections: [] } category: 'general', sections: [] }
[1, 2, 3].each do |number| [1, 2, 3].each do |number|
time = Time.utc(2024, number == 3 ? 2 : 1, number, 0, 0) time = Time.utc(2024, number == 3 ? 2 : 1, number, 0, 0)
PostVersion.create!( PostVersion.create!(post:, version_no: number,
event_id: create_event!(event_type: 'unknown').id,
post:, version_no: number,
event_type: number == 1 ? 'create' : 'update', event_type: number == 1 ? 'create' : 'update',
tags: '', url: post.url, title: "Snapshot #{ number }", parent_post_ids: '', tags: '', url: post.url, title: "Snapshot #{ number }", parent_post_ids: '',
tags_json: number == 1 ? [snapshot] : [], tags_json: number == 1 ? [snapshot] : [],
created_by_user: number == 3 ? nil : actor, created_at: time) created_by_user: number == 3 ? nil : actor, created_at: time)
TagVersion.create!( TagVersion.create!(tag:, version_no: number,
event_id: create_event!(event_type: 'unknown').id,
tag:, version_no: number,
event_type: number == 1 ? 'create' : 'update', event_type: number == 1 ? 'create' : 'update',
name: 'counts tag', category: 'general', aliases: '', parent_tag_ids: '', name: 'counts tag', category: 'general', aliases: '', parent_tag_ids: '',
names: { ja: { name: 'counts tag', script_code: 'Jpan', names: { ja: { name: 'counts tag', script_code: 'Jpan',
+24 -32
ファイルの表示
@@ -14,9 +14,7 @@ RSpec.describe 'Counts table API', type: :request do
end end
before do before do
TagVersion.create!( TagVersion.create!(tag:, version_no: 1, event_type: 'create', name: 'alpha',
event_id: create_event!(event_type: 'unknown').id,
tag:, version_no: 1, event_type: 'create', name: 'alpha',
category: 'general', aliases: '', parent_tag_ids: '', category: 'general', aliases: '', parent_tag_ids: '',
names: { ja: { name: 'alpha', script_code: 'Jpan', auto_generated: false, aliases: [] } }, names: { ja: { name: 'alpha', script_code: 'Jpan', auto_generated: false, aliases: [] } },
created_at: Time.utc(2023)) created_at: Time.utc(2023))
@@ -26,9 +24,7 @@ RSpec.describe 'Counts table API', type: :request do
{ tag_id: tag.id, version_no: 1, name: 'alpha', category: 'general', sections: [] }, { tag_id: tag.id, version_no: 1, name: 'alpha', category: 'general', sections: [] },
{ external_tag_id: external.id }] { external_tag_id: external.id }]
[1, 2, 3].each do |number| [1, 2, 3].each do |number|
PostVersion.create!( PostVersion.create!(post: record, version_no: number,
event_id: create_event!(event_type: 'unknown').id,
post: record, version_no: number,
event_type: number == 1 ? 'create' : 'update', url: record.url, event_type: number == 1 ? 'create' : 'update', url: record.url,
title: "snapshot #{ number }", tags: '', parent_post_ids: '', title: "snapshot #{ number }", tags: '', parent_post_ids: '',
tags_json: snapshots.first(number == 1 ? 2 : (number == 2 ? 1 : 0)), tags_json: snapshots.first(number == 1 ? 2 : (number == 2 ? 1 : 0)),
@@ -138,9 +134,7 @@ RSpec.describe 'Counts table API', type: :request do
end end
it 'checks the current latest flag beyond the execution timestamp' do it 'checks the current latest flag beyond the execution timestamp' do
PostVersion.create!( PostVersion.create!(post: record, version_no: 4, event_type: 'update',
event_id: create_event!(event_type: 'unknown').id,
post: record, version_no: 4, event_type: 'update',
url: record.url, title: 'after execution', tags: '', tags_json: [], url: record.url, title: 'after execution', tags: '', tags_json: [],
parent_post_ids: '', created_at: Time.utc(2025, 2, 1)) parent_post_ids: '', created_at: Time.utc(2025, 2, 1))
result = query result = query
@@ -194,16 +188,12 @@ RSpec.describe 'Counts table API', type: :request do
tag.tag_names.find_by!(language_code: 'ja', primary_flg: true) tag.tag_names.find_by!(language_code: 'ja', primary_flg: true)
.update!(name: 'renamed-alpha') .update!(name: 'renamed-alpha')
create(:tag_name, :alias, tag:, name: 'alpha-alias') create(:tag_name, :alias, tag:, name: 'alpha-alias')
TagVersion.create!( TagVersion.create!(tag:, version_no: 2, event_type: 'update', name: 'renamed-alpha',
event_id: create_event!(event_type: 'unknown').id,
tag:, version_no: 2, event_type: 'update', name: 'renamed-alpha',
category: 'general', aliases: 'alpha-alias', parent_tag_ids: '', category: 'general', aliases: 'alpha-alias', parent_tag_ids: '',
names: { ja: { name: 'renamed-alpha', script_code: 'Jpan', auto_generated: false, names: { ja: { name: 'renamed-alpha', script_code: 'Jpan', auto_generated: false,
aliases: [{ name: 'alpha-alias', script_code: 'Jpan' }] } }, aliases: [{ name: 'alpha-alias', script_code: 'Jpan' }] } },
created_at: Time.utc(2024, 2, 1)) created_at: Time.utc(2024, 2, 1))
PostVersion.create!( PostVersion.create!(post: record, version_no: 4, event_type: 'update',
event_id: create_event!(event_type: 'unknown').id,
post: record, version_no: 4, event_type: 'update',
url: record.url, title: 'after rename', tags: '', parent_post_ids: '', url: record.url, title: 'after rename', tags: '', parent_post_ids: '',
tags_json: [{ tag_id: tag.id, version_no: 2, name: 'renamed-alpha', tags_json: [{ tag_id: tag.id, version_no: 2, name: 'renamed-alpha',
category: 'general', sections: [] }], category: 'general', sections: [] }],
@@ -267,9 +257,7 @@ RSpec.describe 'Counts table API', type: :request do
it 'reports column overflow separately from malformed input' do it 'reports column overflow separately from malformed input' do
101.times do |index| 101.times do |index|
PostVersion.create!( PostVersion.create!(post: record, version_no: index + 4,
event_id: create_event!(event_type: 'unknown').id,
post: record, version_no: index + 4,
event_type: 'update', url: record.url, title: "column #{ index }", event_type: 'update', url: record.url, title: "column #{ index }",
tags: '', tags_json: [], parent_post_ids: '', created_at: Time.utc(2024, 3)) tags: '', tags_json: [], parent_post_ids: '', created_at: Time.utc(2024, 3))
end end
@@ -313,9 +301,7 @@ RSpec.describe 'Counts table API', type: :request do
sign_in_as(create(:user, role)) if role sign_in_as(create(:user, role)) if role
# Equal timestamps deliberately require version ordering, not a date comparison. # Equal timestamps deliberately require version ordering, not a date comparison.
['discard', 'restore', 'discard', 'restore'].each_with_index do |event, index| ['discard', 'restore', 'discard', 'restore'].each_with_index do |event, index|
PostVersion.create!( PostVersion.create!(post: record, version_no: index + 4, event_type: event,
event_id: create_event!(event_type: 'unknown').id,
post: record, version_no: index + 4, event_type: event,
url: record.url, title: event == 'restore' ? 'restored' : 'private snapshot', url: record.url, title: event == 'restore' ? 'restored' : 'private snapshot',
tags: '', tags_json: [], parent_post_ids: '', created_at: Time.utc(2024, 3)) tags: '', tags_json: [], parent_post_ids: '', created_at: Time.utc(2024, 3))
end end
@@ -343,9 +329,7 @@ RSpec.describe 'Counts table API', type: :request do
end end
it 'does not resolve private condition names even when the result set is empty' do it 'does not resolve private condition names even when the result set is empty' do
TagVersion.create!( TagVersion.create!(tag:, version_no: 2, event_type: 'discard', name: 'secret tag',
event_id: create_event!(event_type: 'unknown').id,
tag:, version_no: 2, event_type: 'discard', name: 'secret tag',
category: 'general', aliases: '', parent_tag_ids: '', category: 'general', aliases: '', parent_tag_ids: '',
names: { ja: { name: 'secret tag', script_code: 'Jpan', auto_generated: false, aliases: [] } }, names: { ja: { name: 'secret tag', script_code: 'Jpan', auto_generated: false, aliases: [] } },
created_at: Time.utc(2024, 2)) created_at: Time.utc(2024, 2))
@@ -361,9 +345,7 @@ RSpec.describe 'Counts table API', type: :request do
end end
end end
it 'includes private history in the public timeline without an exclusion count' do it 'includes private history in the public timeline without an exclusion count' do
PostVersion.create!( PostVersion.create!(post: record, version_no: 4, event_type: 'discard', url: record.url,
event_id: create_event!(event_type: 'unknown').id,
post: record, version_no: 4, event_type: 'discard', url: record.url,
title: 'hidden title', tags: '', tags_json: [], parent_post_ids: '', title: 'hidden title', tags: '', tags_json: [], parent_post_ids: '',
created_at: Time.utc(2024, 3)) created_at: Time.utc(2024, 3))
result = query(mode: 'summary', rows: 'created_at:month', cols: 'event_type') result = query(mode: 'summary', rows: 'created_at:month', cols: 'event_type')
@@ -372,15 +354,25 @@ RSpec.describe 'Counts table API', type: :request do
expect(result['cells'].sum { _1['count'] }).to eq(4) expect(result['cells'].sum { _1['count'] }).to eq(4)
expect(response.body).not_to include('hidden title') expect(response.body).not_to include('hidden title')
masked = query(mode: 'summary', rows: 'resource_id', cols: '') masked = query(mode: 'summary', rows: 'resource_id', cols: '')
expect(masked['cells'].map { _1['values'] }).to eq([[CountsAxes::MASKED_VALUE]]) expect(masked['cells'].map { _1['values'] }).to eq([[record.id]])
expect(masked['cells'].first['count']).to eq(4) expect(masked['cells'].first['count']).to eq(4)
expect(query['versions']).to be_empty expect(query['versions']).to be_empty
end end
it 'rejects private summary predicates instead of exposing a count oracle' do it 'allows public identity predicates without exposing private snapshot fields' do
get '/counts/query', params: base.merge(mode: 'summary', resource_id: record.id.to_s) PostVersion.create!(post: record, version_no: 4, event_type: 'discard',
expect(response).to have_http_status(:forbidden) url: record.url, title: 'hidden title', tags: '', tags_json: [],
expect(json).to eq('error' => 'private_condition') parent_post_ids: '', created_at: Time.utc(2024, 3))
get '/counts/query', params: base.merge(mode: 'summary',
resource_id: record.id.to_s, rows: 'resource_id', cols: 'title')
expect(response).to have_http_status(:ok)
expect(json['count']).to eq(4)
expect(json['cells'].sum { _1['count'] }).to eq(4)
expect(json['cells'].map { _1['values'].last })
.to include(CountsAxes::MASKED_VALUE)
expect(response.body).not_to include('hidden title')
end end
it 'reports the combined axis limit without replacing it with a generic 400' do it 'reports the combined axis limit without replacing it with a generic 400' do
+1 -40
ファイルの表示
@@ -314,7 +314,6 @@ RSpec.describe 'Materials API', type: :request do
.and change(Tag, :count).by(1) .and change(Tag, :count).by(1)
.and change(TagName, :count).by(1) .and change(TagName, :count).by(1)
.and change(MaterialVersion, :count).by(1) .and change(MaterialVersion, :count).by(1)
.and change { event_rows.length }.by(1)
expect(response).to have_http_status(:created) expect(response).to have_http_status(:created)
@@ -326,12 +325,6 @@ RSpec.describe 'Materials API', type: :request do
expect(material.file.attached?).to be(true) expect(material.file.attached?).to be(true)
expect(material.version_no).to eq(1) expect(material.version_no).to eq(1)
expect(material.material_versions.first.event_type).to eq('create') expect(material.material_versions.first.event_type).to eq('create')
event = event_rows.last
expect(event).to include(
'event_type' => 'materials.create',
'created_by_user_id' => member_user.id)
expect(material.material_versions.first.event_id).to eq(event.fetch('id'))
expect(material.tag.tag_versions.pluck(:event_id)).to eq([event.fetch('id')])
expect(material.material_export_items.first.export_path).to eq('伊地知ニジカ/created.png') expect(material.material_export_items.first.export_path).to eq('伊地知ニジカ/created.png')
expect(material.material_versions.first.export_paths_json).to eq( expect(material.material_versions.first.export_paths_json).to eq(
'legacy_drive' => '伊地知ニジカ/created.png' 'legacy_drive' => '伊地知ニジカ/created.png'
@@ -516,7 +509,6 @@ RSpec.describe 'Materials API', type: :request do
export_paths: { legacy_drive: '伊地知ニジカ/updated.jpg' } export_paths: { legacy_drive: '伊地知ニジカ/updated.jpg' }
} }
end.to change(MaterialVersion, :count).by(2) end.to change(MaterialVersion, :count).by(2)
.and change { event_rows.length }.by(1)
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
@@ -525,12 +517,6 @@ RSpec.describe 'Materials API', type: :request do
expect(material.tag.category).to eq('material') expect(material.tag.category).to eq('material')
expect(material.url).to eq('https://example.com/updated-source') expect(material.url).to eq('https://example.com/updated-source')
expect(material.updated_by_user).to eq(member_user) expect(material.updated_by_user).to eq(member_user)
event = event_rows.last
expect(event).to include(
'event_type' => 'materials.update',
'created_by_user_id' => member_user.id)
expect(material.material_versions.pluck(:event_id).uniq).to eq([event.fetch('id')])
expect(material.tag.tag_versions.pluck(:event_id)).to eq([event.fetch('id')])
expect(material.file.attached?).to be(true) expect(material.file.attached?).to be(true)
expect(material.file.blob.id).not_to eq(old_blob_id) expect(material.file.blob.id).not_to eq(old_blob_id)
expect(ActiveStorage::Blob.where(id: old_blob_id).exists?).to be(true) expect(ActiveStorage::Blob.where(id: old_blob_id).exists?).to be(true)
@@ -646,7 +632,6 @@ RSpec.describe 'Materials API', type: :request do
MaterialVersionRecorder.record!(material:, event_type: :create, MaterialVersionRecorder.record!(material:, event_type: :create,
created_by_user: member_user) created_by_user: member_user)
TagVersioning.ensure_snapshot!(tag, created_by_user: member_user) TagVersioning.ensure_snapshot!(tag, created_by_user: member_user)
events = event_rows
expect do expect do
put "/materials/#{ material.id }", params: { put "/materials/#{ material.id }", params: {
@@ -658,7 +643,6 @@ RSpec.describe 'Materials API', type: :request do
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(material.reload.version_no).to eq(1) expect(material.reload.version_no).to eq(1)
expect(event_rows).to eq(events)
end end
it 'records update version when only export_path changes' do it 'records update version when only export_path changes' do
@@ -823,12 +807,6 @@ RSpec.describe 'Materials API', type: :request do
it 'creates source suppression as member' do it 'creates source suppression as member' do
sign_in_as(member_user) sign_in_as(member_user)
materials = ['a', 'b'].map do |name|
Material.create!(
source_kind: 'google_drive_file', source_file_id: "blocked-#{ name }",
source_path: "伊地知ニジカ/危険素材/#{ name }.png",
url: "https://example.com/blocked-#{ name }", created_by_user: member_user)
end
expect do expect do
post '/materials/suppressions', params: { post '/materials/suppressions', params: {
@@ -837,7 +815,6 @@ RSpec.describe 'Materials API', type: :request do
reason: 'copyright_high_risk' reason: 'copyright_high_risk'
} }
end.to change(MaterialSyncSuppression, :count).by(1) end.to change(MaterialSyncSuppression, :count).by(1)
.and change { event_rows.length }.by(1)
expect(response).to have_http_status(:created) expect(response).to have_http_status(:created)
expect(json).to include( expect(json).to include(
@@ -845,14 +822,6 @@ RSpec.describe 'Materials API', type: :request do
'drive_path' => '伊地知ニジカ/危険素材', 'drive_path' => '伊地知ニジカ/危険素材',
'normalized_source_key' => 'google_drive_path_prefix:伊地知ニジカ/危険素材' 'normalized_source_key' => 'google_drive_path_prefix:伊地知ニジカ/危険素材'
) )
event = event_rows.last
expect(event).to include(
'event_type' => 'materials.block', 'created_by_user_id' => member_user.id)
expect(materials.map { _1.reload.discarded? }).to eq([true, true])
versions = MaterialVersion.where(material_id: materials.map(&:id))
expect(versions.where(event_type: 'discard').pluck(:material_id))
.to match_array(materials.map(&:id))
expect(versions.pluck(:event_id).uniq).to eq([event.fetch('id')])
end end
end end
@@ -891,19 +860,11 @@ RSpec.describe 'Materials API', type: :request do
end end
it 'discards the material and returns 204' do it 'discards the material and returns 204' do
expect { delete "/materials/#{ material.id }" } delete "/materials/#{ material.id }"
.to change { event_rows.length }.by(1)
expect(response).to have_http_status(:no_content) expect(response).to have_http_status(:no_content)
expect(Material.find_by(id: material.id)).to be_nil expect(Material.find_by(id: material.id)).to be_nil
expect(Material.with_discarded.find(material.id)).to be_discarded expect(Material.with_discarded.find(material.id)).to be_discarded
event = event_rows.last
expect(event).to include(
'event_type' => 'materials.destroy',
'created_by_user_id' => member_user.id)
versions = MaterialVersion.where(material_id: material.id)
expect(versions.order(:version_no).last.event_type).to eq('discard')
expect(versions.pluck(:event_id).uniq).to eq([event.fetch('id')])
end end
end end
end end
-5
ファイルの表示
@@ -177,7 +177,6 @@ RSpec.describe 'NicoTags', type: :request do
} }
}.to change(TagVersion, :count).by(2) }.to change(TagVersion, :count).by(2)
.and change(NicoTagVersion, :count).by(1) .and change(NicoTagVersion, :count).by(1)
.and change { event_rows.length }.by(1)
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
@@ -187,10 +186,6 @@ RSpec.describe 'NicoTags', type: :request do
linked_versions = TagVersion.where(tag: [linked_a, linked_b]).order(:tag_id) linked_versions = TagVersion.where(tag: [linked_a, linked_b]).order(:tag_id)
expect(linked_versions.map(&:event_type)).to eq(['create', 'create']) expect(linked_versions.map(&:event_type)).to eq(['create', 'create'])
expect(linked_versions.map(&:created_by_user_id)).to all(eq(admin.id)) expect(linked_versions.map(&:created_by_user_id)).to all(eq(admin.id))
event = event_rows.last
expect(event).to include(
'event_type' => 'tags.update', 'created_by_user_id' => admin.id)
expect(linked_versions.pluck(:event_id).uniq).to eq([event.fetch('id')])
versions = nico_tag.reload.nico_tag_versions.order(:version_no) versions = nico_tag.reload.nico_tag_versions.order(:version_no)
expect(versions.map(&:event_type)).to eq(['create', 'update']) expect(versions.map(&:event_type)).to eq(['create', 'update'])
+129
ファイルの表示
@@ -0,0 +1,129 @@
require 'rails_helper'
RSpec.describe 'Post history privacy', type: :request do
let(:origin) { Time.utc(2020, 1, 1) }
let!(:post_record) do
create(:post, title: 'current', created_at: origin, updated_at: origin)
end
let!(:other_post) do
create(:post, created_at: origin, updated_at: origin + 3.days + 1.hour)
end
let!(:tag) { create(:tag, primary_name: 'private-history-tag') }
let!(:external) { create(:external_tag, name: 'external-history-tag') }
before do
['create', 'discard'].each_with_index do |event, index|
TagVersion.create!(tag:, version_no: index + 1, event_type: event,
name: 'private-history-tag', category: 'general', aliases: '', parent_tag_ids: '',
names: { ja: { name: 'private-history-tag', script_code: 'Jpan',
auto_generated: false, aliases: [] } },
created_at: origin + index.hours)
end
NicoTagVersion.create!(external_tag: external, version_no: 1, event_type: 'create',
name: 'nico:external-history-tag', linked_tags: 'private-history-tag',
created_at: origin)
['create', 'discard', 'restore', 'update'].each_with_index do |event, index|
snapshots =
if index < 2
[{ tag_id: tag.id, version_no: 1, name: 'private-history-tag', sections: [] }]
elsif index == 3
[{ external_tag_id: external.id }]
else
[]
end
PostVersion.create!(post: post_record, version_no: index + 1, event_type: event,
title: "version #{ index + 1 }", url: post_record.url, tags: '',
tags_json: snapshots, parent_post_ids: '', created_at: origin + (index + 1).days)
end
end
it 'evaluates each reference condition against the restored snapshot alone' do
HistoryAccess.set(user: nil) do
restored = PostVersion.where(post_id: post_record.id, version_no: 3)
HistoryAccess.reference_sql('post_versions', 'post_versions').each do |condition|
expect(restored.where(Arel.sql(condition)).pluck(:version_no)).to eq([3])
end
expect(HistoryAccess.visible(PostVersion.where(post_id: post_record.id))
.pluck(:version_no)).to eq([3])
end
end
it 'keeps every version through a later discard private after another restore' do
['discard', 'restore'].each_with_index do |event, index|
PostVersion.create!(post: post_record, version_no: index + 5, event_type: event,
title: 'later snapshot', url: post_record.url, tags: '', tags_json: [],
parent_post_ids: '', created_at: origin + (index + 5).days)
end
get '/posts/versions', params: { post: post_record.id }
expect(response).to have_http_status(:ok)
expect(response.parsed_body.fetch('versions').pluck('version_no')).to eq([6])
expect(response.parsed_body.fetch('count')).to eq(1)
expect(response.parsed_body.fetch('versions').first.fetch('title').fetch('prev')).to be_nil
end
['anonymous', 'member', 'admin'].each do |role|
context "as #{ role }" do
before do
sign_in_as(create(:user, role.to_sym)) unless role == 'anonymous'
end
it 'uses only authorised history timestamps for display' do
get '/posts'
expect(response).to have_http_status(:ok)
row = response.parsed_body.fetch('posts').find { _1.fetch('id') == post_record.id }
expected = origin + (role == 'admin' ? 4 : 3).days
expect(Time.zone.parse(row.fetch('updated_at'))).to eq(expected)
end
it 'uses the same visibility for date bounds and count' do
get '/posts', params: { updated_from: (origin + 4.days).iso8601,
updated_to: (origin + 4.days).iso8601 }
expect(response).to have_http_status(:ok)
ids = response.parsed_body.fetch('posts').pluck('id')
expect(ids).to eq(role == 'admin' ? [post_record.id] : [])
expect(response.parsed_body.fetch('count')).to eq(ids.length)
end
it 'sorts using authorised timestamps in both directions' do
['asc', 'desc'].each do |direction|
get '/posts', params: { order: "updated_at:#{ direction }" }
expect(response).to have_http_status(:ok)
expected = role == 'admin' ? [other_post.id, post_record.id] :
[post_record.id, other_post.id]
expected.reverse! if direction == 'desc'
expect(response.parsed_body.fetch('posts').pluck('id')).to eq(expected)
end
end
it 'applies authorised timestamp ordering before pagination' do
get '/posts', params: { order: 'updated_at:desc', limit: 1 }
expect(response).to have_http_status(:ok)
expected = role == 'admin' ? post_record.id : other_post.id
expect(response.parsed_body.fetch('posts').pluck('id')).to eq([expected])
expect(response.parsed_body.fetch('count')).to eq(2)
end
it 'counts only visible versions and masks a private previous version' do
get '/posts/versions', params: { post: post_record.id }
expect(response).to have_http_status(:ok)
body = response.parsed_body
expected = role == 'admin' ? [4, 3, 2, 1] : [3]
expect(body.fetch('count')).to eq(expected.length)
expect(body.fetch('versions').pluck('version_no')).to eq(expected)
restored = body.fetch('versions').find { _1.fetch('version_no') == 3 }
expect(restored.fetch('title').fetch('prev')).to eq(role == 'admin' ? 'version 2' : nil)
end
it 'uses only an authorised previous version for tag filtering' do
get '/posts/versions', params: { post: post_record.id, tag: tag.id }
expect(response).to have_http_status(:ok)
expected = role == 'admin' ? [3, 2, 1] : []
expect(response.parsed_body.fetch('versions').pluck('version_no')).to eq(expected)
expect(response.parsed_body.fetch('count')).to eq(expected.length)
end
end
end
end
+45 -27
ファイルの表示
@@ -50,8 +50,6 @@ RSpec.describe 'Posts API', type: :request do
post:, post:,
version_no: 1, version_no: 1,
event_type: 'create', event_type: 'create',
event_id: create_event!(
event_type: 'posts.create', created_by_user: post.uploaded_user).id,
title: post.title, title: post.title,
url: post.url, url: post.url,
thumbnail_base: post.thumbnail_base, thumbnail_base: post.thumbnail_base,
@@ -631,6 +629,37 @@ RSpec.describe 'Posts API', type: :request do
create(:post_tag, post: plain_post, tag:, created_at: t3) create(:post_tag, post: plain_post, tag:, created_at: t3)
end end
it 'displays history time after pagination without joining history to posts or count' do
queries = []
subscriber = ->(event) { queries << event.payload.fetch(:sql) }
ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') do
get '/posts', params: { url: '/version-time/', order: 'url:asc', limit: 1 }
end
expect(response).to have_http_status(:ok)
expect(json.fetch('posts').pluck('id')).to eq([history_post.id])
expect(Time.zone.parse(json.fetch('posts').first.fetch('updated_at'))).to eq(t2)
expect(json.fetch('count')).to eq(3)
post_queries = queries.select { _1.include?('FROM `posts`') }
expect(post_queries).not_to be_empty
expect(post_queries.grep(/post_versions/)).to be_empty
end
it 'omits the history aggregate from count when only sorting by update time' do
queries = []
subscriber = ->(event) { queries << event.payload.fetch(:sql) }
ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') do
get '/posts', params: { url: '/version-time/', order: 'updated_at:desc', limit: 1 }
end
expect(response).to have_http_status(:ok)
expect(json.fetch('posts').pluck('id')).to eq([newer_post.id])
expect(json.fetch('count')).to eq(3)
count_queries = queries.select { _1.include?('FROM `posts`') && _1.match?(/COUNT\(/i) }
expect(count_queries).not_to be_empty
expect(count_queries.grep(/post_versions/)).to be_empty
end
['asc', 'desc'].each do |direction| ['asc', 'desc'].each do |direction|
it "sorts by the later of post update and latest version time (#{ direction })" do it "sorts by the later of post update and latest version time (#{ direction })" do
get '/posts', params: { url: '/version-time/', order: "updated_at:#{ direction }" } get '/posts', params: { url: '/version-time/', order: "updated_at:#{ direction }" }
@@ -660,6 +689,16 @@ RSpec.describe 'Posts API', type: :request do
expect(json.fetch('posts').map { |item| item.fetch('id') }).to eq([history_post.id]) expect(json.fetch('posts').map { |item| item.fetch('id') }).to eq([history_post.id])
expect(json.fetch('count')).to eq(1) expect(json.fetch('count')).to eq(1)
end end
it 'counts every timestamp match before pagination' do
get '/posts', params: { url: '/version-time/', order: 'updated_at:asc',
updated_from: t1.iso8601, updated_to: t3.iso8601,
limit: 1, page: 2 }
expect(response).to have_http_status(:ok)
expect(json.fetch('posts').pluck('id')).to eq([history_post.id])
expect(json.fetch('count')).to eq(3)
end
end end
context 'when original_created_from/original_created_to are provided' do context 'when original_created_from/original_created_to are provided' do
@@ -1029,7 +1068,7 @@ RSpec.describe 'Posts API', type: :request do
it 'dry-runs without persisting posts or new tags' do it 'dry-runs without persisting posts or new tags' do
sign_in_as(member) sign_in_as(member)
counts = [Post.count, Tag.count, TagName.count, event_rows.length] counts = [Post.count, Tag.count, TagName.count]
post '/posts?dry=1', params: post_write_params( post '/posts?dry=1', params: post_write_params(
title: 'dry-run post', title: 'dry-run post',
@@ -1045,7 +1084,7 @@ RSpec.describe 'Posts API', type: :request do
[{ 'name' => 'new_dry_run_tag', [{ 'name' => 'new_dry_run_tag',
'category' => 'character', 'category' => 'character',
'section_literals' => [] }]) 'section_literals' => [] }])
expect([Post.count, Tag.count, TagName.count, event_rows.length]).to eq(counts) expect([Post.count, Tag.count, TagName.count]).to eq(counts)
end end
it 'dry-runs an existing URL without validating its upload' do it 'dry-runs an existing URL without validating its upload' do
@@ -2220,7 +2259,6 @@ RSpec.describe 'Posts API', type: :request do
def create_post_version! post, version_no:, event_type:, created_by_user:, created_at: def create_post_version! post, version_no:, event_type:, created_by_user:, created_at:
PostVersion.create!( PostVersion.create!(
event_id: create_event!(event_type: 'unknown').id,
post:, post:,
version_no:, version_no:,
event_type:, event_type:,
@@ -2233,7 +2271,8 @@ RSpec.describe 'Posts API', type: :request do
original_created_from: post.original_created_from, original_created_from: post.original_created_from,
original_created_before: post.original_created_before, original_created_before: post.original_created_before,
created_at:, created_at:,
created_by_user:) created_by_user:
)
end end
let!(:v1) do let!(:v1) do
@@ -2561,7 +2600,6 @@ RSpec.describe 'Posts API', type: :request do
tags: 'spec_tag', tags: 'spec_tag',
thumbnail: dummy_upload) thumbnail: dummy_upload)
end.to change(PostVersion, :count).by(1) end.to change(PostVersion, :count).by(1)
.and change { event_rows.length }.by(1)
expect(response).to have_http_status(:created) expect(response).to have_http_status(:created)
@@ -2573,13 +2611,6 @@ RSpec.describe 'Posts API', type: :request do
expect(version.url).to eq('https://example.com/versioned-post') expect(version.url).to eq('https://example.com/versioned-post')
expect(version.created_by_user_id).to eq(member.id) expect(version.created_by_user_id).to eq(member.id)
expect(version.tags).to eq(snapshot_tags(created_post)) expect(version.tags).to eq(snapshot_tags(created_post))
event = event_rows.last
expect(event).to include(
'event_type' => 'posts.create', 'created_by_user_id' => member.id)
expect(version.event_id).to eq(event.fetch('id'))
tag_versions = TagVersion.where(tag_id: created_post.tags.select(:id))
expect(tag_versions).not_to be_empty
expect(tag_versions.pluck(:event_id).uniq).to eq([event.fetch('id')])
end end
it 'creates next version on PUT /posts/:id when snapshot changes' do it 'creates next version on PUT /posts/:id when snapshot changes' do
@@ -2595,7 +2626,6 @@ RSpec.describe 'Posts API', type: :request do
title: 'updated title', title: 'updated title',
tags: 'spec_tag_2') tags: 'spec_tag_2')
end.to change(PostVersion, :count).by(1) end.to change(PostVersion, :count).by(1)
.and change { event_rows.length }.by(1)
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
@@ -2605,12 +2635,6 @@ RSpec.describe 'Posts API', type: :request do
expect(version.title).to eq('updated title') expect(version.title).to eq('updated title')
expect(version.created_by_user_id).to eq(member.id) expect(version.created_by_user_id).to eq(member.id)
expect(version.tags).to eq(snapshot_tags(post_record.reload)) expect(version.tags).to eq(snapshot_tags(post_record.reload))
event = event_rows.last
expect(event).to include(
'event_type' => 'posts.update', 'created_by_user_id' => member.id)
expect(version.event_id).to eq(event.fetch('id'))
tag_version = TagVersion.find_by!(tag_id: tag_name2.reload.tag_id, version_no: 1)
expect(tag_version.event_id).to eq(event.fetch('id'))
end end
it 'does not create a new version on PUT /posts/:id when snapshot is unchanged' do it 'does not create a new version on PUT /posts/:id when snapshot is unchanged' do
@@ -2619,7 +2643,6 @@ RSpec.describe 'Posts API', type: :request do
PostTag.create!(post: post_record, tag: Tag.no_deerjikist) PostTag.create!(post: post_record, tag: Tag.no_deerjikist)
base_version = create_post_version_for!(post_record.reload) base_version = create_post_version_for!(post_record.reload)
events = event_rows
expect { expect {
put "/posts/#{post_record.id}", params: post_write_params( put "/posts/#{post_record.id}", params: post_write_params(
base_version_no: base_version.version_no, base_version_no: base_version.version_no,
@@ -2633,12 +2656,10 @@ RSpec.describe 'Posts API', type: :request do
expect(version.version_no).to eq(1) expect(version.version_no).to eq(1)
expect(version.event_type).to eq('create') expect(version.event_type).to eq('create')
expect(version.tags).to eq(snapshot_tags(post_record)) expect(version.tags).to eq(snapshot_tags(post_record))
expect(event_rows).to eq(events)
end end
it 'does not create a version when POST /posts is invalid' do it 'does not create a version when POST /posts is invalid' do
sign_in_as(member) sign_in_as(member)
events = event_rows
expect do expect do
post '/posts', params: post_write_params( post '/posts', params: post_write_params(
@@ -2649,13 +2670,11 @@ RSpec.describe 'Posts API', type: :request do
end.not_to change(PostVersion, :count) end.not_to change(PostVersion, :count)
expect(response).to have_http_status(:unprocessable_entity) expect(response).to have_http_status(:unprocessable_entity)
expect(event_rows).to eq(events)
end end
it 'does not create a version when PUT /posts/:id is invalid' do it 'does not create a version when PUT /posts/:id is invalid' do
sign_in_as(member) sign_in_as(member)
base_version = create_post_version_for!(post_record) base_version = create_post_version_for!(post_record)
events = event_rows
expect do expect do
put "/posts/#{post_record.id}", params: post_write_params( put "/posts/#{post_record.id}", params: post_write_params(
@@ -2667,7 +2686,6 @@ RSpec.describe 'Posts API', type: :request do
end.not_to change(PostVersion, :count) end.not_to change(PostVersion, :count)
expect(response).to have_http_status(:unprocessable_entity) expect(response).to have_http_status(:unprocessable_entity)
expect(event_rows).to eq(events)
end end
it 'rejects second-bearing original created timestamps on POST /posts' do it 'rejects second-bearing original created timestamps on POST /posts' do
+1 -3
ファイルの表示
@@ -119,9 +119,7 @@ RSpec.describe 'Sitemap entries API', type: :request do
# Legacy empty bodies can exist even though the write service rejects them. # Legacy empty bodies can exist even though the write service rejects them.
empty = TagDescription.create!(tag: empty_tag, language_code: 'ain', empty = TagDescription.create!(tag: empty_tag, language_code: 'ain',
body: '', version_no: 1) body: '', version_no: 1)
TagDescriptionVersion.create!( TagDescriptionVersion.create!(tag_id: empty_tag.id, language_code: 'ain',
event_id: create_event!(event_type: 'unknown').id,
tag_id: empty_tag.id, language_code: 'ain',
body: '', version_no: 1, event_type: 'create', body: '', version_no: 1, event_type: 'create',
created_by_user_id: user.id, created_at: Time.current) created_by_user_id: user.id, created_at: Time.current)
-10
ファイルの表示
@@ -53,13 +53,8 @@ RSpec.describe "TagChildren", type: :request do
expect { do_request } expect { do_request }
.to change { parent.reload.children.ids.include?(child.id) } .to change { parent.reload.children.ids.include?(child.id) }
.from(false).to(true) .from(false).to(true)
.and change { event_rows.length }.by(1)
expect(response).to have_http_status(:no_content) expect(response).to have_http_status(:no_content)
event = event_rows.last
expect(event).to include(
'event_type' => 'tags.update', 'created_by_user_id' => admin.id)
expect(child.tag_versions.pluck(:event_id).uniq).to eq([event.fetch('id')])
end end
it 'returns 422 and does not create relation when the new link makes a cycle' do it 'returns 422 and does not create relation when the new link makes a cycle' do
@@ -137,7 +132,6 @@ RSpec.describe "TagChildren", type: :request do
expect { expect {
do_request do_request
}.to change(TagVersion, :count).by(2) }.to change(TagVersion, :count).by(2)
.and change { event_rows.length }.by(1)
expect(response).to have_http_status(:no_content) expect(response).to have_http_status(:no_content)
@@ -146,10 +140,6 @@ RSpec.describe "TagChildren", type: :request do
expect(versions.first.parent_tag_ids.split).to include(parent.id.to_s) expect(versions.first.parent_tag_ids.split).to include(parent.id.to_s)
expect(versions.second.parent_tag_ids).to eq('') expect(versions.second.parent_tag_ids).to eq('')
expect(versions.second.created_by_user_id).to eq(admin.id) expect(versions.second.created_by_user_id).to eq(admin.id)
event = event_rows.last
expect(event).to include(
'event_type' => 'tags.update', 'created_by_user_id' => admin.id)
expect(versions.pluck(:event_id).uniq).to eq([event.fetch('id')])
end end
end end
-2
ファイルの表示
@@ -8,7 +8,6 @@ RSpec.describe 'Tag description Wiki compatibility', type: :request do
let!(:versions) do let!(:versions) do
['A', 'A', 'new-version'].each_with_index.map do |body, index| ['A', 'A', 'new-version'].each_with_index.map do |body, index|
TagDescriptionVersion.create!( TagDescriptionVersion.create!(
event_id: create_event!(event_type: 'unknown').id,
tag_id: tag.id, language_code: 'ja', version_no: index + 1, tag_id: tag.id, language_code: 'ja', version_no: index + 1,
event_type: index.zero? ? 'create' : 'update', body:, event_type: index.zero? ? 'create' : 'update', body:,
created_by_user_id: user.id, created_at: Time.current) created_by_user_id: user.id, created_at: Time.current)
@@ -74,7 +73,6 @@ RSpec.describe 'Tag description Wiki compatibility', type: :request do
let!(:other_tag) { create(:tag, primary_name: 'other_description_contract') } let!(:other_tag) { create(:tag, primary_name: 'other_description_contract') }
let!(:other_version) do let!(:other_version) do
TagDescriptionVersion.create!( TagDescriptionVersion.create!(
event_id: create_event!(event_type: 'unknown').id,
tag_id: other_tag.id, language_code: 'ja', version_no: 1, tag_id: other_tag.id, language_code: 'ja', version_no: 1,
event_type: 'create', body: 'other tag history', event_type: 'create', body: 'other tag history',
created_by_user_id: user.id, created_at: Time.current) created_by_user_id: user.id, created_at: Time.current)
+1 -13
ファイルの表示
@@ -13,7 +13,7 @@ RSpec.describe 'Tag description native API', type: :request do
def stored_state def stored_state
[TagDescription, TagDescriptionVersion, TagName, TagVersion].map do |model| [TagDescription, TagDescriptionVersion, TagName, TagVersion].map do |model|
model.order(:id).map(&:attributes) model.order(:id).map(&:attributes)
end + [event_rows] end
end end
def fail_save model def fail_save model
@@ -91,8 +91,6 @@ RSpec.describe 'Tag description native API', type: :request do
let!(:version) do let!(:version) do
TagDescriptionVersion.create!( TagDescriptionVersion.create!(
tag_id: tag.id, language_code: 'ja', body: 'native version', version_no: 1, tag_id: tag.id, language_code: 'ja', body: 'native version', version_no: 1,
event_id: create_event!(
event_type: 'tag_descriptions.create', created_by_user: author).id,
event_type: 'create', created_by_user_id: author.id, created_at: Time.current) event_type: 'create', created_by_user_id: author.id, created_at: Time.current)
end end
let(:name_endpoint) { "/tags/name/#{ tag_name.name }/description" } let(:name_endpoint) { "/tags/name/#{ tag_name.name }/description" }
@@ -187,7 +185,6 @@ RSpec.describe 'Tag description native API', type: :request do
expect { native_write(body: 'created natively') } expect { native_write(body: 'created natively') }
.to change(TagDescription, :count).by(1) .to change(TagDescription, :count).by(1)
.and change(TagDescriptionVersion, :count).by(1) .and change(TagDescriptionVersion, :count).by(1)
.and change { event_rows.length }.by(1)
expect(response).to have_http_status(:created) expect(response).to have_http_status(:created)
description = TagDescription.find_by!(tag_id: tag.id, language_code: 'ja') description = TagDescription.find_by!(tag_id: tag.id, language_code: 'ja')
expect_current(description) expect_current(description)
@@ -195,10 +192,6 @@ RSpec.describe 'Tag description native API', type: :request do
expect(version).to have_attributes( expect(version).to have_attributes(
body: 'created natively', version_no: 1, event_type: 'create', body: 'created natively', version_no: 1, event_type: 'create',
created_by_user_id: editor.id) created_by_user_id: editor.id)
event = event_rows.last
expect(event).to include(
'event_type' => 'tag_descriptions.create', 'created_by_user_id' => editor.id)
expect(version.event_id).to eq(event.fetch('id'))
get "/wiki/#{ description.id }" get "/wiki/#{ description.id }"
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json).to include('body' => description.body, 'revision_id' => version.id) expect(json).to include('body' => description.body, 'revision_id' => version.id)
@@ -230,7 +223,6 @@ RSpec.describe 'Tag description native API', type: :request do
expect(description.version_no).to eq(1) expect(description.version_no).to eq(1)
expect { native_write(body: 'after') } expect { native_write(body: 'after') }
.to change(TagDescriptionVersion, :count).by(1) .to change(TagDescriptionVersion, :count).by(1)
.and change { event_rows.length }.by(1)
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(description.reload).to have_attributes( expect(description.reload).to have_attributes(
body: 'after', version_no: 2, body: 'after', version_no: 2,
@@ -242,10 +234,6 @@ RSpec.describe 'Tag description native API', type: :request do
expect(version).to have_attributes( expect(version).to have_attributes(
tag_id: tag.id, language_code: 'ja', version_no: 2, event_type: 'update', tag_id: tag.id, language_code: 'ja', version_no: 2, event_type: 'update',
body: 'after', created_by_user_id: editor.id) body: 'after', created_by_user_id: editor.id)
event = event_rows.last
expect(event).to include(
'event_type' => 'tag_descriptions.update', 'created_by_user_id' => editor.id)
expect(version.event_id).to eq(event.fetch('id'))
expect(revision.body).to eq(version.body) expect(revision.body).to eq(version.body)
expect(description_versions(page).order(:version_no).last.body).to eq(version.body) expect(description_versions(page).order(:version_no).last.body).to eq(version.body)
get "/wiki/#{ description.id }" get "/wiki/#{ description.id }"
+1 -5
ファイルの表示
@@ -121,14 +121,10 @@ RSpec.describe 'Tag group native API', type: :request do
[:create, :update].each do |operation| [:create, :update].each do |operation|
it "#{ operation }s structured data and keeps unplaced membership" do it "#{ operation }s structured data and keeps unplaced membership" do
previous = group_version(group_id, 1) previous = group_version(group_id, 1)
expect { native_write(operation) }.to change { event_rows.length }.by(1) native_write(operation)
expect(response).to have_http_status(operation == :create ? :created : :ok) expect(response).to have_http_status(operation == :create ? :created : :ok)
id = operation == :create ? json.fetch('id') : group_id id = operation == :create ? json.fetch('id') : group_id
number = operation == :create ? 1 : 2 number = operation == :create ? 1 : 2
event = event_rows.last
event_type = operation == :create ? 'tag_groups.create' : 'tag_groups.update'
expect(event).to include('event_type' => event_type, 'created_by_user_id' => user.id)
expect(group_version(id, number).fetch('event_id')).to eq(event.fetch('id'))
expect_group_snapshot( expect_group_snapshot(
id, number:, members: snapshot_members, placements:, description: '全体の説明') id, number:, members: snapshot_members, placements:, description: '全体の説明')
expect(group_version(group_id, 1)).to eq(previous) expect(group_version(group_id, 1)).to eq(previous)
-1
ファイルの表示
@@ -30,7 +30,6 @@ RSpec.describe 'TagVersions API', type: :request do
) )
version = version =
TagVersion.create!( TagVersion.create!(
event_id: create_event!(event_type: 'unknown').id,
tag: tag, tag: tag,
version_no: version_no, version_no: version_no,
event_type: event_type, event_type: event_type,
-10
ファイルの表示
@@ -705,7 +705,6 @@ RSpec.describe 'Tags API', type: :request do
expect { expect {
patch "/tags/#{tag.id}", params: { name: 'new_tag_name', category: 'meme' } patch "/tags/#{tag.id}", params: { name: 'new_tag_name', category: 'meme' }
}.to change(TagVersion, :count).by(2) }.to change(TagVersion, :count).by(2)
.and change { event_rows.length }.by(1)
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
@@ -720,10 +719,6 @@ RSpec.describe 'Tags API', type: :request do
expect(version_primary_name(versions.second, 'ja')).to eq('new_tag_name') expect(version_primary_name(versions.second, 'ja')).to eq('new_tag_name')
expect(versions.second.category).to eq('meme') expect(versions.second.category).to eq('meme')
expect(versions.second.created_by_user_id).to eq(member_user.id) expect(versions.second.created_by_user_id).to eq(member_user.id)
event = event_rows.last
expect(event).to include(
'event_type' => 'tags.update', 'created_by_user_id' => member_user.id)
expect(versions.pluck(:event_id).uniq).to eq([event.fetch('id')])
end end
it 'updates deprecated state and records it in tag versions' do it 'updates deprecated state and records it in tag versions' do
@@ -1280,7 +1275,6 @@ RSpec.describe 'Tags API', type: :request do
deprecated: '0', deprecated: '0',
} }
}.to change(TagVersion, :count).by(2) }.to change(TagVersion, :count).by(2)
.and change { event_rows.length }.by(1)
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
@@ -1296,10 +1290,6 @@ RSpec.describe 'Tags API', type: :request do
expect(versions.second.category).to eq('meta') expect(versions.second.category).to eq('meta')
expect(version_alias_names(versions.second, 'ja')).to include('spec_tag') expect(version_alias_names(versions.second, 'ja')).to include('spec_tag')
expect(versions.second.created_by_user_id).to eq(member_user.id) expect(versions.second.created_by_user_id).to eq(member_user.id)
event = event_rows.last
expect(event).to include(
'event_type' => 'tags.update', 'created_by_user_id' => member_user.id)
expect(versions.pluck(:event_id).uniq).to eq([event.fetch('id')])
end end
it '同じ tag の旧 alias へ戻しても rename できる' do it '同じ tag の旧 alias へ戻しても rename できる' do
+9 -5
ファイルの表示
@@ -143,9 +143,7 @@ RSpec.describe 'Counts export contracts' do
expect(CountsExportDownload.find(token, 'owner')).not_to be_nil expect(CountsExportDownload.find(token, 'owner')).not_to be_nil
expect(CountsExportDownload.find(token, 'other')).to be_nil expect(CountsExportDownload.find(token, 'other')).to be_nil
post = create(:post) post = create(:post)
PostVersion.create!( PostVersion.create!(post:, version_no: 1, event_type: 'discard', url: post.url,
event_id: create_event!(event_type: 'unknown').id,
post:, version_no: 1, event_type: 'discard', url: post.url,
title: 'private', tags: '', tags_json: [], parent_post_ids: '', created_at: Time.utc(2024)) title: 'private', tags: '', tags_json: [], parent_post_ids: '', created_at: Time.utc(2024))
expect(CountsExportDownload.find(token, 'owner')).to be_nil expect(CountsExportDownload.find(token, 'owner')).to be_nil
end end
@@ -223,9 +221,15 @@ RSpec.describe CountsExportDownload::ResponseBody do
body = described_class.new(source) { |state| states << state; true } body = described_class.new(source) { |state| states << state; true }
response = ActionDispatch::Response.new response = ActionDispatch::Response.new
response.body = body response.body = body
deferred = response.stream.to_ary
_, _, rack_body = response.to_a
expect(states).to eq([]) expect(states).to eq([])
expect(deferred.to_a.join).to eq(payload)
chunks = []
rack_body.each { |chunk| chunks << chunk }
expect(chunks.join).to eq(payload)
expect(states.count('started')).to eq(1) expect(states.count('started')).to eq(1)
end end
end end
+200 -44
ファイルの表示
@@ -13,9 +13,7 @@ RSpec.describe 'Counts masked pivot contracts' do
let!(:tags) do let!(:tags) do
['A', 'B', 'C'].map do |name| ['A', 'B', 'C'].map do |name|
tag = create(:tag, primary_name: name) tag = create(:tag, primary_name: name)
TagVersion.create!( TagVersion.create!(tag:, version_no: 1, event_type: 'create', name:,
event_id: create_event!(event_type: 'unknown').id,
tag:, version_no: 1, event_type: 'create', name:,
category: 'general', aliases: '', parent_tag_ids: '', category: 'general', aliases: '', parent_tag_ids: '',
names: { ja: { name:, script_code: 'Jpan', auto_generated: false, aliases: [] } }, names: { ja: { name:, script_code: 'Jpan', auto_generated: false, aliases: [] } },
created_at: Time.utc(2023)) created_at: Time.utc(2023))
@@ -27,16 +25,12 @@ RSpec.describe 'Counts masked pivot contracts' do
[[0, 1], [0], [1, 2]].each_with_index do |indexes, index| [[0, 1], [0], [1, 2]].each_with_index do |indexes, index|
post = create(:post) post = create(:post)
snapshot = indexes.map { { tag_id: tags[_1].id, version_no: 1, sections: [] } } snapshot = indexes.map { { tag_id: tags[_1].id, version_no: 1, sections: [] } }
PostVersion.create!( PostVersion.create!(post:, version_no: 1, event_type: 'create',
event_id: create_event!(event_type: 'unknown').id,
post:, version_no: 1, event_type: 'create',
title: "hidden-title-#{ index }", url: post.url, tags: '', tags_json: snapshot, title: "hidden-title-#{ index }", url: post.url, tags: '', tags_json: snapshot,
parent_post_ids: '', created_by_user: member, created_at: Time.utc(2024, 1)) parent_post_ids: '', created_by_user: member, created_at: Time.utc(2024, 1))
next if index == 2 next if index == 2
PostVersion.create!( PostVersion.create!(post:, version_no: 2, event_type: 'discard',
event_id: create_event!(event_type: 'unknown').id,
post:, version_no: 2, event_type: 'discard',
title: "private-discard-#{ index }", url: post.url, tags: '', tags_json: [], title: "private-discard-#{ index }", url: post.url, tags: '', tags_json: [],
parent_post_ids: '', created_by_user: member, created_at: Time.utc(2024, 2)) parent_post_ids: '', created_by_user: member, created_at: Time.utc(2024, 2))
end end
@@ -75,27 +69,29 @@ RSpec.describe 'Counts masked pivot contracts' do
end end
end end
it 'preserves five tag occurrences after merging private tags and across pages' do it 'counts one opaque collection per private version across pages' do
[nil, guest, member, admin].each do |user| [nil, guest, member, admin].each do |user|
page = result(user) page = result(user)
expect(grand(page)).to eq(5) expected = user == admin ? 5 : 6
expect(page[:cells].sum { _1[:count] }).to eq(5) expect(grand(page)).to eq(expected)
expect(page[:cells].sum { _1[:count] }).to eq(expected)
expect(page).not_to have_key(:excluded_count) expect(page).not_to have_key(:excluded_count)
paged = result(user, 'limit' => '1') paged = result(user, 'limit' => '1')
expect(grand(paged)).to eq(5) expect(grand(paged)).to eq(expected)
expect(result(user, 'limit' => '1', 'page' => '2')[:group_count]) expect(result(user, 'limit' => '1', 'page' => '2')[:group_count])
.to eq(page[:group_count]) .to eq(page[:group_count])
end end
public_page = result(nil) public_page = result(nil)
hidden = public_page[:cells].select { _1[:values].first == CountsAxes::MASKED_VALUE } hidden = public_page[:cells].select { _1[:values].first == CountsAxes::MASKED_VALUE }
expect(hidden.sum { _1[:count] }).to eq(3) expect(hidden.sum { _1[:count] }).to eq(4)
expect(public_page[:axis_labels]['a0']).not_to have_key("internal:#{ tags.first.id }") expect(public_page[:axis_labels]['a0']).not_to have_key("internal:#{ tags.first.id }")
expect(public_page.to_json).not_to include('hidden-title', 'private-discard') expect(public_page.to_json).not_to include('hidden-title', 'private-discard')
end end
it 'retains distinct membership when tags are not a row axis' do it 'retains distinct membership when tags are not a row axis' do
[nil, admin].each do |user| [nil, admin].each do |user|
expect(grand(result(user, 'rows' => 'event_type', 'cols' => 'tags'))).to eq(3) expect(grand(result(user, 'rows' => 'event_type', 'cols' => 'tags')))
.to eq(user == admin ? 3 : 5)
expect(result(user, 'rows' => '', 'cols' => '')[:cells].sole[:count]).to eq(5) expect(result(user, 'rows' => '', 'cols' => '')[:cells].sole[:count]).to eq(5)
details = result(user, 'mode' => 'details', 'rows' => '', 'cols' => '') details = result(user, 'mode' => 'details', 'rows' => '', 'cols' => '')
expect(details[:count]).to eq(user == admin ? 5 : 1) expect(details[:count]).to eq(user == admin ? 5 : 1)
@@ -104,16 +100,12 @@ RSpec.describe 'Counts masked pivot contracts' do
it 'uses changed-record membership for totals and hides changes to private predecessors' do it 'uses changed-record membership for totals and hides changes to private predecessors' do
post = PostVersion.order(:id).last.post post = PostVersion.order(:id).last.post
PostVersion.create!( PostVersion.create!(post:, version_no: 2, event_type: 'update', title: 'changed-public-title',
event_id: create_event!(event_type: 'unknown').id,
post:, version_no: 2, event_type: 'update', title: 'changed-public-title',
url: post.url, tags: '', url: post.url, tags: '',
tags_json: [{ tag_id: tags.last.id, version_no: 1, sections: [] }], tags_json: [{ tag_id: tags.last.id, version_no: 1, sections: [] }],
parent_post_ids: '', created_at: Time.utc(2024, 3)) parent_post_ids: '', created_at: Time.utc(2024, 3))
restored = PostVersion.order(:id).first.post restored = PostVersion.order(:id).first.post
PostVersion.create!( PostVersion.create!(post: restored, version_no: 3, event_type: 'restore',
event_id: create_event!(event_type: 'unknown').id,
post: restored, version_no: 3, event_type: 'restore',
title: 'restored-public-title', url: restored.url, tags: '', title: 'restored-public-title', url: restored.url, tags: '',
tags_json: [{ tag_id: tags.first.id, version_no: 1, sections: [] }], parent_post_ids: '', tags_json: [{ tag_id: tags.first.id, version_no: 1, sections: [] }], parent_post_ids: '',
created_at: Time.utc(2024, 4)) created_at: Time.utc(2024, 4))
@@ -131,22 +123,19 @@ RSpec.describe 'Counts masked pivot contracts' do
it 'counts distinct changed records when tags are not additive row tuples' do it 'counts distinct changed records when tags are not additive row tuples' do
[nil, admin].each do |user| [nil, admin].each do |user|
page = result(user, 'rows' => 'changed_field', 'cols' => 'event_type') page = result(user, 'rows' => 'changed_field', 'cols' => 'event_type')
expect(grand(page)).to eq(2) expect(grand(page)).to eq(user == admin ? 2 : 4)
if user.nil? if user.nil?
expect(page[:cells].map { _1[:values].first }.uniq).to eq([CountsAxes::MASKED_VALUE]) expect(page[:cells].map { _1[:values].first }.uniq).to eq([CountsAxes::MASKED_VALUE])
end end
# Changed discard snapshots have no tags: no full cell contributes here. # Private empty collections contribute the same opaque tuple as populated ones.
empty = result(user, 'rows' => 'changed_field', 'cols' => 'tags') combined = result(user, 'rows' => 'changed_field', 'cols' => 'tags')
expect(empty[:cells]).to be_empty expect(grand(combined)).to eq(user == admin ? 0 : 4)
expect(grand(empty)).to eq(0)
end end
end end
it 'keeps NULL as a public data value rather than treating it as a rollup' do it 'keeps NULL as a public data value rather than treating it as a rollup' do
post = create(:post) post = create(:post)
PostVersion.create!( PostVersion.create!(post:, version_no: 1, event_type: 'create', title: 'public bot',
event_id: create_event!(event_type: 'unknown').id,
post:, version_no: 1, event_type: 'create', title: 'public bot',
url: post.url, tags: '', tags_json: [], parent_post_ids: '', created_at: Time.utc(2024, 5)) url: post.url, tags: '', tags_json: [], parent_post_ids: '', created_at: Time.utc(2024, 5))
page = result(nil, 'rows' => 'user', 'cols' => 'event_type') page = result(nil, 'rows' => 'user', 'cols' => 'event_type')
expect(page[:cells].select { _1[:values].first.nil? }.sum { _1[:count] }).to eq(1) expect(page[:cells].select { _1[:values].first.nil? }.sum { _1[:count] }).to eq(1)
@@ -154,38 +143,205 @@ RSpec.describe 'Counts masked pivot contracts' do
expect(page[:row_headers]).to include(hash_including(values: [nil], kind: 'data')) expect(page[:row_headers]).to include(hash_including(values: [nil], kind: 'data'))
end end
it 'sorts public numeric values numerically alongside a masked group' do it 'sorts public version numbers numerically including formerly hidden records' do
post = PostVersion.order(:id).last.post post = PostVersion.order(:id).last.post
[2, 10].each do |number| [2, 10].each do |number|
PostVersion.create!( PostVersion.create!(post:, version_no: number, event_type: 'update',
event_id: create_event!(event_type: 'unknown').id,
post:, version_no: number, event_type: 'update',
title: 'public numeric sort', url: post.url, tags: '', tags_json: [], title: 'public numeric sort', url: post.url, tags: '', tags_json: [],
parent_post_ids: '', created_at: Time.utc(2024, 5)) parent_post_ids: '', created_at: Time.utc(2024, 5))
end end
page = result(nil, 'rows' => 'version_no', 'cols' => '') page = result(nil, 'rows' => 'version_no', 'cols' => '')
values = page[:row_headers].select { _1[:kind] == 'data' }.map { _1[:values].first } values = page[:row_headers].select { _1[:kind] == 'data' }.map { _1[:values].first }
expect(values.reject { _1 == CountsAxes::MASKED_VALUE }.map(&:to_i)).to eq([1, 2, 10]) expect(values.map(&:to_i)).to eq([1, 2, 10])
expect(values.last).to eq(CountsAxes::MASKED_VALUE)
end end
it 'rejects private predicates and supplied measure coordinates before SQL labels escape' do it 'keeps public identity predicates and excludes private attributes' do
probes = [{ 'resource_id' => PostVersion.first.post_id.to_s }, hidden_id = PostVersion.first.post_id.to_s
{ 'tags' => 'A' }, base = { 'rows' => '', 'cols' => '' }
{ 'conditions' => '', 'conditions.0.field' => 'title', 'conditions.0.operator' => 'eq', condition = lambda do |field, operator, value|
'conditions.0.value' => 'hidden-title-0' }, { 'conditions' => '', 'conditions.0.field' => field,
{ 'drill' => '', 'drill.0.field' => 'tags', 'drill.0.value_type' => 'string', 'conditions.0.operator' => operator, 'conditions.0.value' => value }
'drill.0.value' => "internal:#{ tags.first.id }" }, end
expect(result(nil, base.merge('resource_id' => hidden_id))[:cells].sole[:count]).to eq(2)
expect(result(nil, base.merge(condition.call('version_no', 'eq', '2')))[:cells]
.sole[:count]).to eq(2)
expect(result(nil, base.merge(condition.call('user', 'eq', member.id.to_s)))[:cells]
.sole[:count]).to eq(5)
expect(result(nil, base.merge(condition.call('title', 'eq', 'hidden-title-0')))[:cells]
.sole[:count]).to eq(0)
expect(result(nil, base.merge(condition.call('title', 'ne', 'hidden-title-0')))[:cells]
.sole[:count]).to eq(1)
expect(result(nil, base.merge(condition.call('title', 'eq', '********')))[:cells]
.sole[:count]).to eq(0)
expect(result(nil, base.merge(condition.call('video_ms', 'eq', '********')))[:cells]
.sole[:count]).to eq(0)
expect(result(nil, base.merge(condition.call('title', 'like', '%title%')))[:cells]
.sole[:count]).to eq(1)
expect(result(nil, base.merge(condition.call('title', 'like', '%')))[:cells]
.sole[:count]).to eq(1)
expect(result(nil, base.merge('tags' => 'C'))[:cells].sole[:count]).to eq(1)
expect(result(nil, base.merge('tags' => 'not:C'))[:cells].sole[:count]).to eq(0)
end
it 'retains public OR branches while rejecting private predicates on hidden rows' do
HistoryAccess.set(user: nil) do
population = CountsPopulation.new(params.merge('rows' => '', 'cols' => ''),
authorisation_flg: false)
condition = CountsCondition.new(population)
public_scope = condition.apply(population.scope,
{ 'field' => 'event_type', 'operator' => 'eq', 'value' => 'discard' })
private_scope = condition.apply(population.scope,
{ 'field' => 'title', 'operator' => 'ne', 'value' => 'unrelated' })
masked_scope = condition.apply(population.scope,
{ 'field' => 'title', 'operator' => 'eq', 'value' => '********' })
expect(public_scope.except(:select).count).to eq(2)
expect(public_scope.or(private_scope).except(:select).count).to eq(3)
expect(private_scope.or(public_scope).except(:select).count).to eq(3)
expect(public_scope.or(masked_scope).except(:select).count).to eq(2)
expect(public_scope.merge(private_scope).except(:select).count).to eq(0)
end
end
it 'excludes private collections from positive, negative, AND and OR tag searches' do
settings = { 'rows' => '', 'cols' => '' }
{ 'C' => 1, 'not:C' => 0, 'not:unknown' => 1, '********' => 0,
'not:********' => 1, 'not:C C' => 0, 'not:C type:or C' => 1 }.each do |value, count|
expect(result(nil, settings.merge('tags' => value))[:cells].sole[:count]).to eq(count)
end
end
it 'ignores empty numeric and interval bounds without restricting private history' do
settings = { 'rows' => '', 'cols' => '', 'conditions' => '' }
{ 'video_ms' => ['eq', 'ne', 'gt', 'ge', 'lt', 'le', 'range'],
'tags.sections' => ['range'] }.each do |field, operators|
operators.each do |operator|
condition = settings.merge('conditions.0.field' => field,
'conditions.0.operator' => operator)
empty = condition.merge('conditions.0.value' => '')
expect(result(nil, condition)[:cells].sole[:count]).to eq(5)
expect(result(nil, empty)[:cells].sole[:count]).to eq(5)
expect(result(nil, empty.merge('conditions.0.blank' => '0'))[:cells]
.sole[:count]).to eq(5)
if operator == 'range'
empty['conditions.0.end'] = ''
expect(result(nil, empty)[:cells].sole[:count]).to eq(5)
['conditions.0.value', 'conditions.0.end'].each do |key|
active = empty.merge(key => '100')
expect(result(nil, active)[:cells].sole[:count]).to eq(0)
end
end
end
end
end
it 'keeps typed marker sets active after incompatible marker values are removed' do
settings = { 'rows' => '', 'cols' => '', 'conditions' => '',
'conditions.0.field' => 'video_ms', 'conditions.0.values' => '1',
'condition_values' => '', 'condition_values.0.condition' => '0',
'condition_values.0.value_type' => 'string',
'condition_values.0.value' => '********' }
['in', 'not_in'].each do |operator|
expected = operator == 'in' ? 0 : 1
condition = settings.merge('conditions.0.operator' => operator)
expect(result(nil, condition)[:cells].sole[:count]).to eq(expected)
end
end
it 'restricts active numeric comparisons and preserves empty selection semantics' do
settings = { 'rows' => '', 'cols' => '', 'conditions' => '',
'conditions.0.field' => 'video_ms' }
['eq', 'ne', 'gt', 'ge', 'lt', 'le', 'range'].each do |operator|
condition = settings.merge('conditions.0.operator' => operator,
'conditions.0.value' => '0')
condition['conditions.0.end'] = '100' if operator == 'range'
# The one public row is NULL; private NULLs and real values never match.
expected = operator == 'ne' ? 1 : 0
expect(result(nil, condition)[:cells].sole[:count]).to eq(expected)
end
['in', 'not_in'].each do |operator|
condition = settings.merge('conditions.0.operator' => operator,
'conditions.0.values' => '1', 'condition_values' => '')
expected = operator == 'not_in' ? 5 : 0
expect(result(nil, condition)[:cells].sole[:count]).to eq(expected)
end
blank = settings.merge('conditions.0.operator' => 'eq', 'conditions.0.blank' => '1')
expect(result(nil, blank)[:cells].sole[:count]).to eq(1)
end
it 'preserves empty tag sets and restricts active blank collection predicates' do
settings = { 'rows' => '', 'cols' => '', 'conditions' => '',
'conditions.0.field' => 'tags' }
['any', 'all', 'none'].each do |operator|
condition = settings.merge('conditions.0.operator' => operator,
'conditions.0.values' => '1', 'condition_values' => '')
expected = operator == 'any' ? 0 : 5
expect(result(nil, condition)[:cells].sole[:count]).to eq(expected)
end
blank = settings.merge('conditions.0.operator' => 'eq', 'conditions.0.blank' => '1')
expect(result(nil, blank)[:cells].sole[:count]).to eq(0)
end
it 'rejects server-only coordinates in column order' do
probes = [
{ 'cols' => 'tags', 'column_order' => '', 'column_order.0.value_type' => 'string', { 'cols' => 'tags', 'column_order' => '', 'column_order.0.value_type' => 'string',
'column_order.0.value' => CountsAxes::MASKED_VALUE }] 'column_order.0.value' => CountsAxes::MASKED_VALUE }]
HistoryAccess.set(user: nil) do HistoryAccess.set(user: nil) do
probes.each do |probe| probes.each do |probe|
expect { CountsTableQuery.new(params.merge(probe)) } expect { CountsTableQuery.new(params.merge(probe)).call }
.to raise_error(CountsAxes::PrivateCondition) .to raise_error(CountsAxes::PrivateCondition)
end end
end end
end end
it 'keeps private collection and change counts independent of stored elements' do
settings = [
{ 'rows' => 'tags', 'cols' => 'changed_field' },
{ 'rows' => 'changed_field tags', 'cols' => '' }]
original = settings.map { result(nil, _1) }
snapshot = tags.map do |tag|
{ tag_id: tag.id, version_no: 1, sections: [] }
end
hidden_posts = PostVersion.where(event_type: 'discard').pluck(:post_id)
PostVersion.where(post_id: hidden_posts).update_all(
title: 'same hidden value', tags_json: snapshot)
expect(settings.map { result(nil, _1) }).to eq(original)
end
it 'excludes private tag and change coordinates from drill conditions' do
['tags', 'changed_field'].each do |field|
settings = { 'rows' => field, 'cols' => '' }
page = result(nil, settings)
cell = page[:cells].find { _1[:values] == [CountsAxes::MASKED_VALUE] }
expect(cell[:count]).to eq(4)
[CountsAxes::MASKED_VALUE, CountsAxes::MASKED_TEXT].each do |value|
drill = { 'drill' => '', 'drill.0.field' => field,
'drill.0.value_type' => 'string', 'drill.0.value' => value }
expect(result(nil, settings.merge(drill))[:cells]).to be_empty
details = result(nil, settings.merge(drill).merge('mode' => 'details'))
expect(details[:versions]).to be_empty
condition = { 'conditions' => '', 'conditions.0.field' => field,
'conditions.0.operator' => 'axis_eq',
'conditions.0.value_type' => 'string', 'conditions.0.value' => value }
expect(result(nil, settings.merge(condition))[:cells]).to be_empty
end
end
end
it 'rejects real private tag and change coordinates for non-admin users' do
[nil, guest, member].each do |user|
{ 'tags' => "internal:#{ tags.first.id }", 'changed_field' => 'title' }.each do
|field, value|
settings = { 'rows' => '', 'cols' => '', 'drill' => '',
'drill.0.field' => field, 'drill.0.value_type' => 'string',
'drill.0.value' => value }
expect(result(user, settings)[:cells].sole[:count]).to eq(0)
expect(result(admin, settings)[:cells].sole[:count]).to be > 0
end
end
end
it 'keeps public column sorting usable while guessed private coordinates match nothing' do it 'keeps public column sorting usable while guessed private coordinates match nothing' do
first = result(nil, 'rows' => 'event_type', 'cols' => 'tags') first = result(nil, 'rows' => 'event_type', 'cols' => 'tags')
probe = result(nil, 'rows' => 'event_type', 'cols' => 'tags', probe = result(nil, 'rows' => 'event_type', 'cols' => 'tags',
@@ -201,7 +357,7 @@ RSpec.describe 'Counts masked pivot contracts' do
HistoryAccess.set(user: nil) do HistoryAccess.set(user: nil) do
payload = params.merge('format' => 'json') payload = params.merge('format' => 'json')
json = JSON.parse(CountsTableExport.new(payload).body.to_a.join) json = JSON.parse(CountsTableExport.new(payload).body.to_a.join)
expect(json['data'].sum { _1['kind'] == 'data' ? _1['count'] : 0 }).to eq(5) expect(json['data'].sum { _1['kind'] == 'data' ? _1['count'] : 0 }).to eq(6)
expect(json.to_json).not_to include('hidden-title', 'private-discard') expect(json.to_json).not_to include('hidden-title', 'private-discard')
csv = CountsTableExport.new(payload.merge('format' => 'csv')).body.to_a.join csv = CountsTableExport.new(payload.merge('format' => 'csv')).body.to_a.join
expect(csv).to include('********') expect(csv).to include('********')
-2
ファイルの表示
@@ -56,7 +56,6 @@ RSpec.describe MaterialSyncImporter do
profile: 'legacy_drive', profile: 'legacy_drive',
export_path: '素材/a.png', export_path: '素材/a.png',
created_by_user: user) created_by_user: user)
events = event_rows
result = result =
described_class.import!( described_class.import!(
@@ -74,7 +73,6 @@ RSpec.describe MaterialSyncImporter do
expect(result.action).to eq(:unchanged) expect(result.action).to eq(:unchanged)
expect(material.reload.tag).to eq(tag) expect(material.reload.tag).to eq(tag)
expect(material.url).to eq('https://example.com/human') expect(material.url).to eq('https://example.com/human')
expect(event_rows).to eq(events)
end end
it 'suppresses a Google Drive candidate by relative path prefix' do it 'suppresses a Google Drive candidate by relative path prefix' do
-41
ファイルの表示
@@ -14,47 +14,6 @@ RSpec.describe MaterialSyncRunner do
let(:drive_client) { instance_double(GoogleDrive::ApiClient) } let(:drive_client) { instance_double(GoogleDrive::ApiClient) }
describe '#sync!' do describe '#sync!' do
it 'groups created and updated materials across sync sources into one event' do
existing = Material.create!(
source_kind: 'google_drive_file', source_file_id: 'existing-file',
source_path: 'old.png', url: 'https://example.com/existing',
created_by_user: user, updated_by_user: user)
source
MaterialSyncSource.create!(
name: 'Second Drive source', source_kind: 'google_drive_path',
source_file_id: 'folder-456', profile: 'legacy_drive',
created_by_user: user, updated_by_user: user)
allow(GoogleDrive::ApiClient).to receive(:new).and_return(drive_client)
allow(drive_client).to receive(:each_material_file_under_folder) do |folder_id, &block|
id = folder_id == 'folder-123' ? 'existing-file' : 'new-file'
block.call(id:, name: "#{ id }.png", mime_type: 'image/png',
relative_path: "素材/#{ id }.png", sha256_checksum: nil,
web_view_link: "https://drive.google.com/file/d/#{ id }/view")
end
allow(drive_client).to receive(:download_to_tempfile) do |id, **|
Tempfile.new(['sync-event', '.png']).tap do |file|
file.binmode
file.write(id)
file.rewind
end
end
allow(MaterialThumbnailGenerator).to receive(:generate!)
previous_ids = MaterialVersion.pluck(:id)
result = nil
expect { result = described_class.sync_enabled! }
.to change { event_rows.length }.by(1)
expect(result).to have_attributes(imported: 1, updated: 1, failed: 0)
event = event_rows.last
expect(event.fetch('event_type')).to eq('materials.sync')
created = Material.find_by!(source_file_id: 'new-file')
versions = MaterialVersion.where.not(id: previous_ids)
expect(versions.pluck(:material_id).uniq).to contain_exactly(existing.id, created.id)
expect(versions.pluck(:event_type)).to include('create', 'update')
expect(versions.pluck(:event_id).uniq).to eq([event.fetch('id')])
end
it 'imports google drive path candidates as they are yielded' do it 'imports google drive path candidates as they are yielded' do
first_entry = { id: 'file-1', first_entry = { id: 'file-1',
name: 'a.png', name: 'a.png',
-83
ファイルの表示
@@ -88,86 +88,3 @@ RSpec.describe PostBulkCreator do
expect(creator).to have_received(:create!).with(locale).once expect(creator).to have_received(:create!).with(locale).once
end end
end end
RSpec.describe 'PostBulkCreator event grouping', type: :model do
self.use_transactional_tests = false
before do
expect(Rails.env.test?).to be(true)
expect(ApplicationRecord.connection.open_transactions).to eq(0)
@prefix = "bulk_event_#{ SecureRandom.hex(8) }"
@actor = create(:user, :member)
allow(PostCreatePreflight).to receive(:new) do |attributes:, **|
specs = [{ name: attributes.fetch(:title), category: :general }]
instance_double(PostCreatePreflight, run: attributes.merge(
existing_post_id: nil, snapshot_tag_specs: specs, post_tag_specs: specs,
normalised_parent_post_ids: [], tag_sections: { }, video_ms: nil))
end
end
after do
if @actor
posts = Post.where(uploaded_user: @actor)
PostTagSection.where(post_id: posts.select(:id)).delete_all
PostTag.where(post_id: posts.select(:id)).delete_all
PostVersion.where(post_id: posts.select(:id)).delete_all
posts.delete_all
tag_ids = TagName.where(name: ["#{ @prefix }_1", "#{ @prefix }_2"]).pluck(:tag_id)
TagVersion.where(tag_id: tag_ids).delete_all
TagName.where(tag_id: tag_ids).delete_all
Tag.where(id: tag_ids).delete_all
EventRecords::EventRow.where(created_by_user_id: @actor.id).delete_all
@actor.delete
end
end
it 'shares one creation event between real workers and their tag histories' do
posts = [1, 2].map do |number|
{ title: "#{ @prefix }_#{ number }",
url: "https://example.com/#{ @prefix }/#{ number }" }
end
result = nil
expect {
result = PostBulkCreator.new(actor: @actor, posts:, thumbnails: { })
.run(Locale.nipponese)
}.to change { event_rows.length }.by(1)
expect(result.fetch(:results).map { _1.fetch(:status) }).to eq(['created', 'created'])
event = event_rows.last
expect(event).to include(
'event_type' => 'posts.create', 'created_by_user_id' => @actor.id)
versions = PostVersion.where(created_by_user_id: @actor.id)
expect(versions.count).to eq(2)
expect(versions.pluck(:event_id).uniq).to eq([event.fetch('id')])
tag_versions = TagVersion.where(created_by_user_id: @actor.id)
expect(tag_versions.count).to eq(2)
expect(tag_versions.pluck(:event_id).uniq).to eq([event.fetch('id')])
end
it 'does not leave an event when every row fails validation' do
preflight = instance_double(PostCreatePreflight)
allow(PostCreatePreflight).to receive(:new).and_return(preflight)
allow(preflight).to receive(:run).and_raise(
PostCreatePreflight::ValidationFailed.new(fields: { url: ['invalid'] }))
events = event_rows
result = PostBulkCreator.new(
actor: @actor, posts: [{ title: 'invalid' }], thumbnails: { }).run(Locale.nipponese)
expect(result.fetch(:results).map { _1.fetch(:status) }).to eq(['failed'])
expect(event_rows).to eq(events)
end
it 'does not leave an event when every row is skipped' do
preflight = instance_double(PostCreatePreflight, run: { existing_post_id: 123 })
allow(PostCreatePreflight).to receive(:new).and_return(preflight)
events = event_rows
result = PostBulkCreator.new(
actor: @actor, posts: [{ title: 'existing' }], thumbnails: { }).run(Locale.nipponese)
expect(result.fetch(:results).map { _1.fetch(:status) }).to eq(['skipped'])
expect(event_rows).to eq(events)
end
end
-10
ファイルの表示
@@ -16,7 +16,6 @@ RSpec.describe 'Shared resource IDs across MySQL connections', type: :model do
@tag = create(:tag, primary_name: "#{ @prefix }_owner") @tag = create(:tag, primary_name: "#{ @prefix }_owner")
@group = TagGroup.create!(name: "#{ @prefix }_owner", version_no: 1) @group = TagGroup.create!(name: "#{ @prefix }_owner", version_no: 1)
@tag_ids = Queue.new @tag_ids = Queue.new
@event_ids = Queue.new
@threads = [] @threads = []
@release = Queue.new @release = Queue.new
end end
@@ -43,9 +42,6 @@ RSpec.describe 'Shared resource IDs across MySQL connections', type: :model do
ids << @tag.id if @tag ids << @tag.id if @tag
TagName.where(tag_id: ids).delete_all TagName.where(tag_id: ids).delete_all
Tag.where(id: ids).delete_all Tag.where(id: ids).delete_all
event_ids = []
event_ids << @event_ids.pop until @event_ids.empty? if @event_ids
EventRecords::EventRow.where(id: event_ids).delete_all
end end
end end
@@ -76,17 +72,11 @@ RSpec.describe 'Shared resource IDs across MySQL connections', type: :model do
when :description when :description
TagDescription.create!(tag: @tag, language_code: 'ja', body: '', version_no: 1) TagDescription.create!(tag: @tag, language_code: 'ja', body: '', version_no: 1)
when :description_version when :description_version
event = create_event!(event_type: 'unknown')
@event_ids << event.id
TagDescriptionVersion.create!( TagDescriptionVersion.create!(
event_id: event.id,
tag_id: @tag.id, language_code: 'ja', body: 'body', version_no: 1, tag_id: @tag.id, language_code: 'ja', body: 'body', version_no: 1,
event_type: 'create', created_at: Time.current) event_type: 'create', created_at: Time.current)
when :group_version when :group_version
event = create_event!(event_type: 'unknown')
@event_ids << event.id
TagGroupVersion.create!( TagGroupVersion.create!(
event_id: event.id,
tag_group: @group, name: @group.name, version_no: 1, event_type: 'create', tag_group: @group, name: @group.name, version_no: 1, event_type: 'create',
members: [], placements: [], created_at: Time.current) members: [], placements: [], created_at: Time.current)
when :group when :group
-3
ファイルの表示
@@ -15,12 +15,10 @@ RSpec.describe SharedResourceIdAllocator do
TagGroup.new(id:, name: "group #{ number }", version_no: number) TagGroup.new(id:, name: "group #{ number }", version_no: number)
when 'tag_description_versions' when 'tag_description_versions'
TagDescriptionVersion.new( TagDescriptionVersion.new(
event_id: create_event!(event_type: 'unknown').id,
id:, tag_id: tag.id, language_code: 'ja', body: 'body', version_no: number, id:, tag_id: tag.id, language_code: 'ja', body: 'body', version_no: number,
event_type: 'create', created_at: Time.current) event_type: 'create', created_at: Time.current)
when 'tag_group_versions' when 'tag_group_versions'
TagGroupVersion.new( TagGroupVersion.new(
event_id: create_event!(event_type: 'unknown').id,
id:, tag_group_id: group.id, name: group.name, version_no: number, id:, tag_group_id: group.id, name: group.name, version_no: number,
event_type: 'create', members: [], placements: [], created_at: Time.current) event_type: 'create', members: [], placements: [], created_at: Time.current)
end end
@@ -84,7 +82,6 @@ RSpec.describe SharedResourceIdAllocator do
it 'reserves a Tag ID referenced only by remaining description history' do it 'reserves a Tag ID referenced only by remaining description history' do
high = [Tag.maximum(:id).to_i, ExternalTag.maximum(:id).to_i].max + 1000 high = [Tag.maximum(:id).to_i, ExternalTag.maximum(:id).to_i].max + 1000
TagDescriptionVersion.create!( TagDescriptionVersion.create!(
event_id: create_event!(event_type: 'unknown').id,
tag_id: high, language_code: 'ja', version_no: 1, body: 'history', tag_id: high, language_code: 'ja', version_no: 1, body: 'history',
event_type: 'create', created_at: Time.current) event_type: 'create', created_at: Time.current)
expect(create(:tag).id).to be > high expect(create(:tag).id).to be > high
-35
ファイルの表示
@@ -42,41 +42,6 @@ RSpec.describe Youtube::Sync do
end end
describe '#sync!' do describe '#sync!' do
it 'groups created and updated post histories into one sync event' do
allow(PostVersionRecorder).to receive(:record!).and_call_original
allow(PostVersionRecorder).to receive(:ensure_snapshot!).and_call_original
existing = create(:post, url: 'https://youtu.be/video-1', title: 'old title')
allow(sync).to receive(:query_terms).and_return([])
allow(sync).to receive(:playlist_ids).and_return(['PL123'])
allow(client).to receive(:playlist_items).with(playlist_id: 'PL123', page_token: nil)
.and_return('items' => [
{ 'contentDetails' => { 'videoId' => 'video-1' } },
{ 'contentDetails' => { 'videoId' => 'video-2' } }])
allow(client).to receive(:videos).with(['video-1', 'video-2'])
.and_return('items' => [
youtube_video_item(id: 'video-1', title: 'updated', channel_id: 'UC_NONE'),
youtube_video_item(id: 'video-2', title: 'created', channel_id: 'UC_NONE')])
previous_post_ids = PostVersion.pluck(:id)
previous_tag_ids = TagVersion.pluck(:id)
expect { sync.sync! }.to change { event_rows.length }.by(1)
event = event_rows.last
expect(event).to include('event_type' => 'posts.sync', 'created_by_user_id' => nil)
versions = PostVersion.where.not(id: previous_post_ids)
created = Post.find_by!(url: 'https://www.youtube.com/watch?v=video-2')
expect(versions.pluck(:post_id).uniq).to contain_exactly(existing.id, created.id)
expect(versions.pluck(:event_type)).to include('create', 'update')
expect(versions.pluck(:event_id).uniq).to eq([event.fetch('id')])
tag_versions = TagVersion.where.not(id: previous_tag_ids)
expect(tag_versions).not_to be_empty
expect(tag_versions.pluck(:event_id).uniq).to eq([event.fetch('id')])
events = event_rows
expect { sync.sync! }.not_to change(PostVersion, :count)
expect(event_rows).to eq(events)
end
it 'returns without fetching video details when no video ids are discovered' do it 'returns without fetching video details when no video ids are discovered' do
allow(sync).to receive(:query_terms).and_return([]) allow(sync).to receive(:query_terms).and_return([])
allow(sync).to receive(:playlist_ids).and_return([]) allow(sync).to receive(:playlist_ids).and_return([])
-15
ファイルの表示
@@ -1,15 +0,0 @@
module EventRecords
# Read the database contract without requiring an application Event model.
class EventRow < ActiveRecord::Base
self.table_name = 'events'
end
def event_rows
EventRow.order(:id).map(&:attributes)
end
def create_event! event_type:, created_by_user: nil
EventRow.create!(event_type:, created_by_user_id: created_by_user&.id,
created_at: Time.current)
end
end
+1 -4
ファイルの表示
@@ -3,13 +3,11 @@ require 'cgi'
# SQL fixtures use the migration contract without inventing application models. # SQL fixtures use the migration contract without inventing application models.
# Include explicitly; these helpers must not affect unrelated examples. # Include explicitly; these helpers must not affect unrelated examples.
module TagGroupRecords module TagGroupRecords
include EventRecords
GROUP_TABLES = [ GROUP_TABLES = [
'tag_groups', 'tag_group_tags', 'tag_group_groups', 'tag_groups', 'tag_group_tags', 'tag_group_groups',
'tag_group_placements', 'tag_group_versions'].freeze 'tag_group_placements', 'tag_group_versions'].freeze
WRITE_TABLES = GROUP_TABLES + [ WRITE_TABLES = GROUP_TABLES + [
'events', 'tag_names', 'tag_description_versions', 'tag_names', 'tag_description_versions',
'tag_descriptions'] 'tag_descriptions']
def group_connection = ApplicationRecord.connection def group_connection = ApplicationRecord.connection
@@ -116,7 +114,6 @@ module TagGroupRecords
group_id = insert_group_row('tag_groups', attributes) group_id = insert_group_row('tag_groups', attributes)
insert_group_row('tag_group_versions', { insert_group_row('tag_group_versions', {
tag_group_id: group_id, version_no: 1, event_type: 'create', tag_group_id: group_id, version_no: 1, event_type: 'create',
event_id: create_event!(event_type: 'tag_groups.create', created_by_user: user).id,
name:, description:, members: members.to_json, placements: placements.to_json, name:, description:, members: members.to_json, placements: placements.to_json,
created_by_user_id: user.id, created_at: now }) created_by_user_id: user.id, created_at: now })
members.each do |member| members.each do |member|
+2 -31
ファイルの表示
@@ -174,7 +174,6 @@ RSpec.describe 'nico:sync' do
def create_post_version_for!(post, version_no: 1, event_type: 'create', created_by_user: nil) def create_post_version_for!(post, version_no: 1, event_type: 'create', created_by_user: nil)
PostVersion.create!( PostVersion.create!(
event_id: create_event!(event_type: 'unknown').id,
post: post, post: post,
version_no: version_no, version_no: version_no,
event_type: event_type, event_type: event_type,
@@ -187,7 +186,8 @@ RSpec.describe 'nico:sync' do
original_created_from: post.original_created_from, original_created_from: post.original_created_from,
original_created_before: post.original_created_before, original_created_before: post.original_created_before,
created_at: Time.current, created_at: Time.current,
created_by_user: created_by_user) created_by_user: created_by_user
)
end end
it '新規 post 作成時に version 1 を作る' do it '新規 post 作成時に version 1 を作る' do
@@ -209,7 +209,6 @@ RSpec.describe 'nico:sync' do
expect { expect {
run_rake_task('nico:sync') run_rake_task('nico:sync')
}.to change(PostVersion, :count).by(1) }.to change(PostVersion, :count).by(1)
.and change { event_rows.length }.by(1)
post = Post.find_by!(url: 'https://www.nicovideo.jp/watch/sm9') post = Post.find_by!(url: 'https://www.nicovideo.jp/watch/sm9')
version = post.post_versions.order(:version_no).last version = post.post_versions.order(:version_no).last
@@ -218,32 +217,6 @@ RSpec.describe 'nico:sync' do
expect(version.event_type).to eq('create') expect(version.event_type).to eq('create')
expect(version.created_by_user).to be_nil expect(version.created_by_user).to be_nil
expect(version.tags).to eq(snapshot_tags(post.reload)) expect(version.tags).to eq(snapshot_tags(post.reload))
event = event_rows.last
expect(event).to include('event_type' => 'posts.sync', 'created_by_user_id' => nil)
expect(version.event_id).to eq(event.fetch('id'))
end
it 'groups created and updated post histories into one sync event' do
existing = create(:post, title: 'old', url: 'https://www.nicovideo.jp/watch/sm9')
stub_python([
{ 'code' => 'sm9', 'title' => 'updated', 'tags' => [] },
{ 'code' => 'sm10', 'title' => 'created', 'tags' => [] }])
allow(URI).to receive(:open).and_return(StringIO.new('<html></html>'))
previous_post_ids = PostVersion.pluck(:id)
previous_tag_ids = TagVersion.pluck(:id)
expect { run_rake_task('nico:sync') }.to change { event_rows.length }.by(1)
event = event_rows.last
expect(event).to include('event_type' => 'posts.sync', 'created_by_user_id' => nil)
created = Post.find_by!(url: 'https://www.nicovideo.jp/watch/sm10')
versions = PostVersion.where.not(id: previous_post_ids)
expect(versions.pluck(:post_id).uniq).to contain_exactly(existing.id, created.id)
expect(versions.pluck(:event_type)).to include('create', 'update')
expect(versions.pluck(:event_id).uniq).to eq([event.fetch('id')])
tag_versions = TagVersion.where.not(id: previous_tag_ids)
expect(tag_versions).not_to be_empty
expect(tag_versions.pluck(:event_id).uniq).to eq([event.fetch('id')])
end end
it '既存 post の内容または tags が変わったとき update version を作る' do it '既存 post の内容または tags が変わったとき update version を作る' do
@@ -309,7 +282,6 @@ RSpec.describe 'nico:sync' do
}]) }])
allow(URI).to receive(:open).and_return(StringIO.new('<html></html>')) allow(URI).to receive(:open).and_return(StringIO.new('<html></html>'))
events = event_rows
expect { expect {
run_rake_task('nico:sync') run_rake_task('nico:sync')
@@ -319,7 +291,6 @@ RSpec.describe 'nico:sync' do
expect(version.version_no).to eq(1) expect(version.version_no).to eq(1)
expect(version.event_type).to eq('create') expect(version.event_type).to eq('create')
expect(version.tags).to eq(snapshot_tags(post.reload)) expect(version.tags).to eq(snapshot_tags(post.reload))
expect(event_rows).to eq(events)
end end
it '新規 nico tag に nico tag version を作る' do it '新規 nico tag に nico tag version を作る' do