yucatio@システムエンジニア

趣味で作ったものいろいろ

正規表現間違い探しクイズ その1

正規表現間違い探しクイズシリーズです。

正規表現のバグは単体テストを書いている場合でも発見しづらいものです。 そのためレビューの時には注意深く見るようにしています。そんな中見つけた間違いのうち印象的だったものを紹介します。

問題編

仕様

ユーザモデルはニックネームを持ちます。ニックネームは以下の条件を満たします。

  • 入力必須
  • 32文字以下
  • 使用できる文字列は半角英数字と#$%&()-._

ソースコード

ソースコードです。 今回はRuby on Railsで実装します。

class User < ApplicationRecord
  validates :nickname, presence: true, length: { maximum: 32 },
                       format: { with: /\A[0-9a-zA-Z#$%&()-._]*\z/,
                                 message: 'には半角英数字と#$%&()-._のみ使用できます。' }
end

さて、上記ソースコード正規表現(\A[0-9a-zA-Z#$%&()-._]*\z)には明らかな間違いがあります。どのような間違いでしょうか。また、どのように修正するべきでしょうか。(ちなみに正規表現のはじめの\Aは文字列の先頭、\zは文字列の末尾を指します。それぞれ^$と似ていますが、違いは参考のリンクを参照してください)

テスト

以下のテストはパスしています。

require 'rails_helper'

RSpec.describe User, type: :model do
  describe '#nickname' do
    subject { user.errors[:nickname] }

    let(:user) { User.new(params) }
    let(:params) { {nickname: nickname} }

    before do
      user.valid?
    end

    context '許可される文字の場合' do
      context '英大文字小文字数字#$%&()-._' do
        let(:nickname) { 'ARZatz809(_#-$%.&)' }
        it { is_expected.to be_blank }
      end
    end

    context '許可されない文字の場合' do
      context '許可されていない文字"?"と"""が含まれている場合' do
        let(:nickname) { '?baerg"' }
        it { is_expected.to include('には半角英数字と#$%&()-._のみ使用できます。') }
      end
    end
  end
end

RSpec知らない人だと何書いてあるかわからないかと思いますが、重要な部分は、入力がARZatz809(_#-$%.&)だとエラーが出なくて(be_blank)、入力が?baerg"だとには半角英数字と#$%&()-._のみ使用できます。というエラーが出るという部分です。

解答編

少し考えてから解答編を見てください

よいですか?

間違いの部分

正規表現可視化ツールRegulexで確認してみましょう。

Regulex:JavaScript Regular Expression Visualizer

f:id:yucatio:20190103170420p:plain
regulex

分かりましたか?

正規表現\A[0-9a-zA-Z#$%&()-._]*\zの中の)-.の部分が)-.という意味でなく、)から.という意味になっています。 ASCIIコード表を見てみると、).の間には、*+,-の記号が含まれていることが分かります。

16進 ASCII
09 )
0A *
0B +
0C ,
0D -
0E .

よって、[0-9a-zA-Z#$%&()-._]の文字の意味は、半角英数字のいずれかまたは#$%&()*+,-._という意味になってしまい、仕様を満たしません。

テストを追加してみましょう。

require 'rails_helper'

RSpec.describe User, type: :model do
  describe '#nickname' do
    subject {user.errors[:nickname]}

    # 略

    context '許可されない文字の場合' do
      # 略

      # 追加ここから
      context '許可されていない文字"*"が含まれている場合' do
        let(:nickname) {'***abc***'}
        it { is_expected.to include('には半角英数字と#$%&()-._のみ使用できます。') }
      end
      # 追加ここまで
    end
  end
end

これを実行すると、以下の理由でテストが失敗します。

expected [] to include "には半角英数字と\#$%&()-._のみ使用できます。"

エラーが空配列になっていますね。

どう直すか

正規表現[]で囲まれた中でハイフン-をそのまま(メタ文字でない)ハイフンとして認識させるには、

  • ハイフンを[]の先頭に置く
  • \を使用してハイフンをエスケープする
  • ハイフンを[]の最後に置く

の3つの方法があります。

ハイフンを[]の先頭に置くのは個人的にはおすすめです。この方法で正規表現を書き直すと\A[-0-9a-zA-Z#$%&()._]*\zとなります。ハイフンが文字集合に含まれることがわかりやすくて良いと思います。

ハイフンを\を使用してエスケープする方法も良いでしょう。この方法で正規表現を書き直すと\A[0-9a-zA-Z#$%&()\-._]*\zとなります。個人的にはバックスラッシュがノイズのように感じて好きではないですが、記号が書かれている位置がまとまっているため、こちらを好む人もいるでしょう。

ハイフンを[]の最後に置く方法はおすすめしません。この方法で正規表現を書き直すと\A[0-9a-zA-Z#$%&()._-]*\zとなります。おすすめしない理由は、将来的に入力できる文字が増えたときにバグが発生する可能性が高いからです。例えばこれをリリースした数年後に、新たに~も入力値に許可したいといった場合に、\A[0-9a-zA-Z#$%&()._-~]*\zといった正規表現を書きがちだからです。今回はテストがあるのでこのような場合にバグを発見できますが、毎回テストが書かれているわけでもありませんし、そもそも変に書き換えたときにうまくテストが落ちるとも限りません。正規表現単体テストを書くのは難しいので。

あとがき

今回のクイズは実際にあった事例をもとにしています。はじめにこの正規表現を見たときには実は間違いを見逃していました。危ない。他の部分のコードが未熟だったので、正規表現も再度見直したら間違いに気づいた次第です。

落ちるケースを見つけるのも難しいのも正規表現の怖さだと思いました。

ところでRubyMineは文字は緑、範囲を示すハイフンは黒で表示していて、細かい配慮がありうれしいです。

f:id:yucatio:20190103171431p:plain

参考