数字の読み方（42→よんじゅうに）を PostgreSQL で求める

![PostgreSQL-13.2](https://img.shields.io/badge/PostgreSQL-13.2-brightgreen)

# キッカケ

数年前に社内の勉強会で出題されたらしい問題で「数字の読み方を表示するプログラムを作る」というのがありました。
※ らしいと言っていますが、私は参加しなかったので。資料だけ拝借しました。へ(￣o￣へ)ｽｯ

それで、この問題を見たとき、初めは簡単そうな問題だと思ったのです。

1234 → せんにひゃくさんじゅうよん

こんな問題、ただ桁ごとに分割して switch 文とかで繰り返して読み方に変換していくだけなのでは、と。
ただ、思いのほか上手くいかず。

例えばコレとか。

610 → ろっぴゃくじゅう

おそらく、あまり考えずにプログラムを実装してしまうと、

610 → ろくひゃくいちじゅう

になるパターンです。
「こ、これは、思いのほか、いい問題な気がする。。」
と考え改めまして、実装してみることにしました。

# 数字の正しい読み方って何？

我々が普段何気なく読んでいる数字ですが、実はその読み方にはちょっとした例外が潜んでいます。

既に記載した `610` もそうですが、他にも `800` も `はちひゃく` ではなく、 `はっぴゃく` と読み、
`300` に限っては `さんびゃく` とひゃくが濁音に変換されています。

他にも、`9` は `きゅう` とも読みますが、 `く` とも読めます。

これは思い付くだけ挙げていくのではちょっとスマートではなさそうです。
（ニホンゴハムズカシイネ）

果たして、こういう例外的な読み方も含めてきちんと整理されている資料は存在するのか。
ちょっと調べてみたのですが、なんと存在してました。

[現代日本語の数詞と助数詞 ― 形態の整理と実態調査 ―](http://repo.nara-u.ac.jp/modules/xoonips/download.php/AN00181569-19900300-1015.pdf?file_id=3658)

どうやら、こういう疑問は言語学の形態論で研究されているジャンルらしく、
論文の 1.1 ~ 1.2 で解答が得られそうです。

では、こちらの論文を元に今回プログラムの仕様となるべく読み方の早見表を作ってみます。

|前項 \ 後項|じゅう|ひゃく|せん|まん|おく|ちょう|
|:-:|:-:|:-:|:-:|:-:|:-:|:-:|
|いち|じゅう|ひゃく|せん|まん|いちおく|__いっ__ちょう|
|に|にじゅう|にひゃく|にせん|にまん|におく|にちょう|
|さん|さんじゅう|さん__びゃく__|さん__ぜん__|さんまん|さんおく|さんちょう|
|よん|よんじゅう|よんひゃく|よんせん|よんまん|よんおく|よんちょう|
|ご|ごじゅう|ごひゃく|ごせん|ごまん|ごおく|ごちょう|
|ろく|ろくじゅう|__ろっぴゃく__|ろくせん|ろくまん|ろくおく|ろくちょう|
|なな|ななじゅう|ななひゃく|ななせん|ななまん|ななおく|ななちょう|
|はち|はちじゅう|__はっぴゃく__|__はっ__せん|はちまん|はちおく|__はっ__ちょう|
|きゅう|きゅうじゅう|きゅうひゃく|きゅうせん|きゅうまん|きゅうおく|きゅうちょう|
|じゅう|-|-|-|じゅうまん|じゅうおく|__じゅっ__ちょう|
|ひゃく|-|-|-|ひゃくまん|ひゃくおく|ひゃくちょう|
|せん|-|-|-|せんまん|せんおく|せんちょう|

※ 一桁目が 9 の場合は「く」と読めるパターンもありますが、処理を簡単にするために一律「きゅう」としています。
※ 7 の場合は「しち」とも読めますが、聴覚上紛らわしいので「なな」とします。
※ 一桁目と二桁目が 4 の場合は「し」とも読めますが、こちらも処理を簡単にするために一律「よん」としています。
※ 千の位が 1 の場合、「いっせん」「いっせんまん」「いっせんおく」「いっせんちょう」と言ったりする事例もありますが、
　 それぞれ「せん」「せんまん」「せんおく」「せんちょう」と読んでも誤りではないため、一律「いっせん」ではなく「せん」とします。

では、数字の読み方の仕様が整理できましたので、プログラムで実装していきます。

タイトルにもありますが、こういうのは普通は Python とかで実装例を示すものですが、
こういう問題を見てしまうと、どうしても SQL で実装してみたくなってしまう性なので、ご了承ください。

# ストアド（PL/pgSQL）での実装例

後で、 SQL の実装例も記載しますが、先にストアド（以下、PL/pgSQL）の実装を載せておきます。
PL/pgSQL であれば、その辺の手続き型言語とそんなに処理の流れはあまり変わらないです。

PL/pgSQL 未経験者でも前節の表通りに実装されているのが、なんとなくわかると思います。

```sql
CREATE OR REPLACE FUNCTION numerical_to_reading(numerical_value bigint) RETURNS varchar AS $$
  DECLARE
    numerical_char CONSTANT varchar := abs(numerical_value)::varchar;
    numerical_length CONSTANT integer := char_length(numerical_char);
    current_digit integer;
    current_val integer;
    ret varchar := '';
  BEGIN
    IF numerical_value = 0 THEN
      -- 一桁の０の場合のみ「ぜろ」が読まれるため、すぐに RETURN で返す
      RETURN 'ぜろ';
    ELSIF numerical_value < 0 THEN
      -- マイナス符号の読み方を定義
      ret := ret || 'まいなす';
    END IF;

    -- 整数部の読み方を求める
    FOR i IN 1 .. numerical_length LOOP
      current_digit := numerical_length - i + 1;
      current_val := substring(numerical_char FROM i FOR 1)::integer;

      -- 数字の読み方を逐次解釈する
      CASE current_val
        WHEN 1 THEN
          IF current_digit % 13 = 0 THEN
            -- 1兆（いっちょう）の読み方を定義
            ret := ret || 'いっ';
          ELSIF current_digit % 4 = 1 THEN
            ret := ret || 'いち';
          ELSE
            -- 十、百、千の場合は1自体は読まない
            NULL;
          END IF;
        WHEN 2 THEN
          ret := ret || 'に';
        WHEN 3 THEN
          ret := ret || 'さん';
        WHEN 4 THEN
          ret := ret || 'よん';
        WHEN 5 THEN
          ret := ret || 'ご';
        WHEN 6 THEN
          IF current_digit % 4 = 3 THEN
            -- 6百（ろっぴゃく）の読み方を定義
            ret := ret || 'ろっ';
          ELSE
            ret := ret || 'ろく';
          END IF;
        WHEN 7 THEN
          ret := ret || 'なな';
        WHEN 8 then
          IF current_digit % 13 = 0 OR current_digit % 4 IN (3, 0) THEN
            -- 8兆（はっちょう）、8百（はっぴゃく）、8千（はっせん）の読み方を定義
            ret := ret || 'はっ';
          ELSE
            ret := ret || 'はち';
          END IF;
        WHEN 9 THEN
          ret := ret || 'きゅう';
        ELSE
          NULL;
      END CASE;

      -- 1桁ごとの読み方を定義（一、十、百、千）
      IF current_val = 0 THEN
        NULL;
      ELSE
        CASE current_digit % 4
          WHEN 2 then
            IF current_digit = 14 AND substring(numerical_char FROM i + 1 FOR 1) = '0' then
              -- 10兆（じゅっちょう）の読み方を定義
              ret := ret || 'じゅっ';
            ELSE
              ret := ret || 'じゅう';
            END if;
          WHEN 3 THEN
            IF current_val = 3 THEN
              -- 3百（さんびゃく）の読み方を定義
              ret := ret || 'びゃく';
            ELSIF current_val IN (6, 8) THEN
              -- 6百（ろっぴゃく）、8百（はっぴゃく）の読み方を定義
              ret := ret || 'ぴゃく';
            ELSE
              ret := ret || 'ひゃく';
            END IF;
          WHEN 0 THEN
            IF current_val = 3 THEN
              -- 3千（さんぜん）の読み方を定義
              ret := ret || 'ぜん';
            ELSE
              ret := ret || 'せん';
            END IF;
          ELSE
            NULL;
        END CASE;
      END IF;

      -- 4桁ごとにつく単位の読み方を定義（万、億、兆）
      IF current_digit % 4 = 1 THEN
        IF substring(numerical_char FROM i - 3 FOR 4)::integer = 0 THEN
          NULL;
        else
          CASE current_digit
            WHEN 5 THEN
              ret := ret || 'まん';
            WHEN 9 THEN
              ret := ret || 'おく';
            WHEN 13 THEN
              ret := ret || 'ちょう';
            ELSE
              -- 1の位は何も読まない
              NULL;
          END CASE;
        END IF;
      END IF;
    END LOOP;
    RETURN ret;
  END
$$ LANGUAGE plpgsql
   IMMUTABLE
   RETURNS NULL ON NULL INPUT;
```

では、 PL/pgSQL で実装した関数を何パターン化の数字で呼び出し、
期待している読み方が取得できていることを確認していきます。

```sql
WITH
  parameterized_tests(args, expected) AS (
    VALUES
      (1234567890123456, 'せんにひゃくさんじゅうよんちょうごせんろっぴゃくななじゅうはちおくきゅうせんじゅうにまんさんぜんよんひゃくごじゅうろく'),
      (2345678901234567, 'にせんさんびゃくよんじゅうごちょうろくせんななひゃくはちじゅうきゅうおくひゃくにじゅうさんまんよんせんごひゃくろくじゅうなな'),
      (3456789012345678, 'さんぜんよんひゃくごじゅうろくちょうななせんはっぴゃくきゅうじゅうおくせんにひゃくさんじゅうよんまんごせんろっぴゃくななじゅうはち'),
      (4567890123456789, 'よんせんごひゃくろくじゅうななちょうはっせんきゅうひゃくいちおくにせんさんびゃくよんじゅうごまんろくせんななひゃくはちじゅうきゅう'),
      (5678901234567890, 'ごせんろっぴゃくななじゅうはっちょうきゅうせんじゅうにおくさんぜんよんひゃくごじゅうろくまんななせんはっぴゃくきゅうじゅう'),
      (6789012345678901, 'ろくせんななひゃくはちじゅうきゅうちょうひゃくにじゅうさんおくよんせんごひゃくろくじゅうななまんはっせんきゅうひゃくいち'),
      (7890123456789012, 'ななせんはっぴゃくきゅうじゅっちょうせんにひゃくさんじゅうよんおくごせんろっぴゃくななじゅうはちまんきゅうせんじゅうに'),
      (8901234567890123, 'はっせんきゅうひゃくいっちょうにせんさんびゃくよんじゅうごおくろくせんななひゃくはちじゅうきゅうまんひゃくにじゅうさん'),
      (9012345678901234, 'きゅうせんじゅうにちょうさんぜんよんひゃくごじゅうろくおくななせんはっぴゃくきゅうじゅうまんせんにひゃくさんじゅうよん'),
      ( 123456789012345, 'ひゃくにじゅうさんちょうよんせんごひゃくろくじゅうななおくはっせんきゅうひゃくいちまんにせんさんびゃくよんじゅうご'),
      (               0, 'ぜろ'),
      (              -1, 'まいなすいち'),
      (  10000000000000, 'じゅっちょう')
  )
SELECT args, CASE WHEN numerical_to_reading(args) = expected THEN 'Passed' ELSE 'Failed' END
  FROM parameterized_tests
;
```

上の結果が全て Passed になっていれば OK です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/114728/aa171828-3a24-d9f6-b2e5-69ca32a1b2e5.png)


# SQL での実装例

次に先ほど PL/pgSQL で実装した処理を SQL で実装しなおしていきます。

```sql
CREATE OR REPLACE FUNCTION numerical_to_reading(numerical_value bigint) RETURNS varchar AS $$
  -- 数字の読み方を出力する。
  WITH RECURSIVE
    -- 数字の読み方を定義
    general_numeral_readings(numeral, reading) AS (
      VALUES
        (0, ''),
        (1, 'いち'), (2, 'に'), (3, 'さん'),
        (4, 'よん'), (5, 'ご'), (6, 'ろく'),
        (7, 'なな'), (8, 'はち'), (9, 'きゅう')
    ),
    -- 1桁ごとの読み方を定義（一、十、百、千）
    general_digit_readings_for_every_digit(digit, reading) AS (
      VALUES
        (1, ''),                     -- 一の位の数については呼称なし。
        (2, 'じゅう'), (3, 'ひゃく'), (4, 'せん')
    ),
    -- 4桁区切りの桁ごとの読み方を定義（万、億、兆、...）
    general_digit_readings_for_every_four_digit(digit, reading) AS (
      VALUES
        (5, 'まん'), (9, 'おく'), (13, 'ちょう')
    ),
    -- 例外的な数字の読み方
    specific_numeral_readings_for_every_digit(digit, numeral, reading) AS (
      VALUES
        (2, 1, ''), (3, 1, ''), (4, 1, ''),  -- 十、百、千（いっせん）の読み方を定義
        (3, 6, 'ろっ'),                          -- 6百（ろっぴゃく）の読み方を定義
        (3, 8, 'はっ'), (4, 8, 'はっ')            -- 8百（はっせん）、8千（はっせん）の読み方を定義
    ),
    -- 例外的な桁の読み方
    specific_digit_readings_for_every_digit(digit, numeral, reading) AS (
      VALUES
        (2, 0, ''), (3, 0, ''), (4, 0, ''), -- 0の場合は十百千は読まない
        (3, 3, 'びゃく'), (4, 3, 'ぜん'),  -- 3百（さんびゃく）、3千（さんぜん）の読み方を定義
        (3, 6, 'ぴゃく'), (3, 8, 'ぴゃく')  -- 6百（ろっぴゃく）、8百（はっぴゃく）の読み方を定義
    ),
    -- 例外的な数字の読み方（万億兆）
    specific_numeral_readings_for_every_four_digit(digit, numeral, reading) AS (
      VALUES
        (13, 1, 'いっ'), (13, 8, 'はっ')
    ),
    -- 引数に指定された数字を桁ごとに分割する。
    digit_numeral_mapping(digit, numeral) AS (
      SELECT
          1,
          substring(
            abs(numerical_value)::varchar from char_length(abs(numerical_value)::varchar) for 1
          )::integer
       UNION ALL
      SELECT
          parts.digit + 1,
          substring(
            abs(numerical_value)::varchar from char_length(abs(numerical_value)::varchar) - (parts.digit) for 1
          )::integer
        FROM digit_numeral_mapping AS parts
       WHERE parts.digit < char_length(abs(numerical_value)::varchar)
    ),
    -- 他の桁の数字に依存する例外的な読み方を定義
    other_digit_readings_for_every_digit(digit, reading) AS (
      SELECT 14, 'じゅっ'
       WHERE EXISTS (SELECT 'X' FROM digit_numeral_mapping WHERE digit = 13 AND numeral = 0)
    ),
    -- 他の桁の数字に依存する例外的な読み方を定義（万億兆）
    other_digit_readings_for_every_four_digit(digit, numeral, reading) AS (
      SELECT 9, 0, ''
       WHERE 3 = (SELECT COUNT(1) FROM digit_numeral_mapping WHERE digit IN (10, 11, 12) AND numeral = 0)
       UNION ALL
      SELECT 5, 0, ''
       WHERE 3 = (SELECT COUNT(1) FROM digit_numeral_mapping WHERE digit IN (6, 7, 8) AND numeral = 0)
    ),
    -- 桁ごとに数字の読み方を出力する。
    readings_per_digit(digit, reading) AS (
      SELECT
          parts.digit,
                     coalesce(s_num_efd.reading, s_num_ed.reading, g_num.reading)   -- 0~9までの数字
                  || coalesce(o_dgt_ed.reading, s_dgt_ed.reading, g_dgt_ed.reading) -- 十百千の位
                  || coalesce(o_dgt_efd.reading, g_dgt_efd.reading, '')             -- 万、億の位
        FROM digit_numeral_mapping AS parts
     NATURAL JOIN general_numeral_readings AS g_num
        JOIN general_digit_readings_for_every_digit AS g_dgt_ed
          ON g_dgt_ed.digit % 4 = parts.digit % 4
        LEFT JOIN general_digit_readings_for_every_four_digit AS g_dgt_efd
          ON g_dgt_efd.digit = parts.digit
        LEFT JOIN specific_numeral_readings_for_every_digit AS s_num_ed
          ON s_num_ed.digit % 4 = parts.digit % 4
         AND s_num_ed.numeral = parts.numeral
        LEFT JOIN specific_digit_readings_for_every_digit AS s_dgt_ed
          ON s_dgt_ed.digit % 4 = parts.digit % 4
         AND s_dgt_ed.numeral = parts.numeral
        LEFT JOIN specific_numeral_readings_for_every_four_digit AS s_num_efd
          ON s_num_efd.digit = parts.digit
         AND s_num_efd.numeral = parts.numeral
        LEFT JOIN other_digit_readings_for_every_digit AS o_dgt_ed
          ON o_dgt_ed.digit = parts.digit
        LEFT JOIN other_digit_readings_for_every_four_digit AS o_dgt_efd
          ON o_dgt_efd.digit = parts.digit
         AND o_dgt_efd.numeral = parts.numeral
       ORDER BY parts.digit DESC
    )
  -- 数字の読み方を出力
  SELECT
      CASE
        WHEN numerical_value = 0 THEN 'ぜろ'
        WHEN numerical_value < 0 THEN 'まいなす'
        ELSE ''
      END || array_to_string(array_agg(reading ORDER BY digit DESC), '') AS reading
    FROM readings_per_digit
  ;
$$ LANGUAGE SQL
   IMMUTABLE
   RETURNS NULL ON NULL INPUT;
```

PL/pgSQL の時に確認した方法と同じ方法で実装に問題がないか確認できます。
書き方は前回のを参照してください。


# さいごに

過去にも SQL で今回のようなアルゴリズム問題を解いた投稿をしたので、リンクだけ貼ります。

[SQLだけで√nの小数部100桁まで算出する（nは自然数）](https://qiita.com/nimzo6689/items/5f9e15c7c7e950e7d60a)