PostgreSQL で文字列と数値があるデータを(ちょっと良く)ソートする

【環境】
CentOS 6/PostgreSQL 8.4
CentOS 7/PostgreSQL 9.2


例えば、文字列型のカラムにて、文字列と数値が混じったデータの場合にソートすると、次のようになる。

db=> SELECT * FROM sample_data ORDER BY os;
       os
-----------------
 Mac OS X 10.12
 Mac OS X 10.13
 Mac OS X 10.9
 Windows NT 10.0
 Windows NT 6.3


いい感じにソートするには、少し工夫する。

db=> SELECT * FROM sample_data 
ORDER BY
  SUBSTRING(os FROM '^[A-Za-z ]+'), 
  TO_NUMBER(SUBSTRING(os FROM '[0-9.]+$'), '9999999999');
       os
-----------------
 Mac OS X 10.9
 Mac OS X 10.12
 Mac OS X 10.13
 Windows NT 6.3
 Windows NT 10.0


考え方としては、以下の通り。

・SUBSTRING を使い、ソートするカラムの先頭から英字を抜き出して、ソートの第一条件とする。
・SUBSTRING を使い、ソートするカラムの後ろから数値とドット(小数点)を抜き出して、to_numberで適当な桁数(上記では10桁)のnumeric型にキャストして、ソートの第二条件とする。


ソートの条件をSELECTで抽出してみると、どうなっているか分かりやすい。

db=> SELECT 
  os, 
  SUBSTRING(os FROM '^[A-Za-z ]+'), 
  TO_NUMBER(SUBSTRING(os FROM '[0-9.]+$'), '9999999999') 
FROM 
  sample_data;
        os        |  substring  | to_number
------------------+-------------+-----------
 Windows NT 10.0  | Windows NT  |       100
 Windows NT 6.3   | Windows NT  |        63
 Mac OS X 10.12   | Mac OS X    |      1012
 Mac OS X 10.9    | Mac OS X    |       109
 Mac OS X 10.13   | Mac OS X    |      1013


ここに、'Mac OS X 10.12.1' というデータが入ってくると、うまくいかない(´・ω・`)

db=> SELECT * FROM sample_data
ORDER BY
  SUBSTRING(os FROM '^[A-Za-z ]+'),
  TO_NUMBER(SUBSTRING(os FROM '[0-9.]+$'), '9999999999');
        os
------------------
 Mac OS X 10.9
 Mac OS X 10.12
 Mac OS X 10.13
 Mac OS X 10.12.1
 Windows NT 6.3
 Windows NT 10.0


バージョンの区切りになっているドットの出現回数が違うのが原因。
この場合、出現回ごとに桁数を揃えて(ドットで区切って0詰めする)、ソート条件に追加すると意図したようにソートされる。

db=> SELECT * FROM sample_data
ORDER BY
  SUBSTRING(os FROM '^[A-Za-z ]+'),
  LPAD(SPLIT_PART(SUBSTRING(os FROM '[0-9.]+$'), '.', 1), 3, '0'),
  LPAD(SPLIT_PART(SUBSTRING(os FROM '[0-9.]+$'), '.', 2), 3, '0'),
  LPAD(SPLIT_PART(SUBSTRING(os FROM '[0-9.]+$'), '.', 3), 3, '0');
        os
------------------
 Mac OS X 10.9
 Mac OS X 10.12
 Mac OS X 10.12.1
 Mac OS X 10.13
 Windows NT 6.3
 Windows NT 10.0


試しに、'Mac OS X 11.0' や 'Mac OS X 9.99' という架空のデータを入れて試してみる。

db=> SELECT * FROM sample_data
ORDER BY
  substring(os from '^[A-Za-z ]+'),
  LPAD(SPLIT_PART(SUBSTRING(os FROM '[0-9.]+$'), '.', 1), 3, '0'),
  LPAD(SPLIT_PART(SUBSTRING(os FROM '[0-9.]+$'), '.', 2), 3, '0'),
  LPAD(SPLIT_PART(SUBSTRING(os FROM '[0-9.]+$'), '.', 3), 3, '0');
        os
------------------
 Mac OS X 9.99
 Mac OS X 10.9
 Mac OS X 10.12
 Mac OS X 10.12.1
 Mac OS X 10.13
 Mac OS X 11.0
 Windows NT 6.3
 Windows NT 10.0


いい感じでソートされる。

ソート条件がどうなっているか、SELECTで抽出してみると、桁毎に0詰めになっている事が確認できる。

db=> SELECT 
    os,
    LPAD(SPLIT_PART(SUBSTRING(os FROM '[0-9.]+$'), '.', 1), 3, '0'),
    LPAD(SPLIT_PART(SUBSTRING(os FROM '[0-9.]+$'), '.', 2), 3, '0'),
    LPAD(SPLIT_PART(SUBSTRING(os FROM '[0-9.]+$'), '.', 3), 3, '0')
 FROM sample_data;
        os        | lpad | lpad | lpad
------------------+------+------+------
 Windows NT 10.0  | 010  | 000  | 000
 Windows NT 6.3   | 006  | 003  | 000
 Mac OS X 10.12   | 010  | 012  | 000
 Mac OS X 10.9    | 010  | 009  | 000
 Mac OS X 10.13   | 010  | 013  | 000
 Mac OS X 10.12.1 | 010  | 012  | 001
 Mac OS X 11.0    | 011  | 000  | 000
 Mac OS X 9.99    | 009  | 099  | 000


但し、前半に文字列があり、後半に数字(ドットも可)がある、という前提で、バージョン部分が「x.y.z」というフォーマットしか判別できない。

使える場面が限られるけど、とりあえずメモ。
UserAgentから取り出したOSのソートには使えそう。


参考;
https://www.postgresql.jp/document/9.2/html/functions-string.html
https://www.postgresql.jp/document/9.2/html/functions-formatting.html