コミットを比較

...
5 コミット
作成者 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
みてるぞ f8747a0f74 Merge pull request '集計機能追加 (#434)' (#441) from feature/434 into main
Reviewed-on: #441
2026-11-01 19:39:04 +09:00
17個のファイルの変更、895行の追加、218行の削除
+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
+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'
+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.
+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 = [
+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
+15 -5
ファイルの表示
@@ -354,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
+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
+41
ファイルの表示
@@ -629,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 }" }
@@ -658,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
+8 -2
ファイルの表示
@@ -221,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
+193 -23
ファイルの表示
@@ -69,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)
@@ -121,14 +123,13 @@ 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
@@ -142,7 +143,7 @@ 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!(post:, version_no: number, event_type: 'update', PostVersion.create!(post:, version_no: number, event_type: 'update',
@@ -151,27 +152,196 @@ RSpec.describe 'Counts masked pivot contracts' do
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',
@@ -187,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('********')