Ruby
Rails
Elixir
Phoenix

ElixirでSI開発入門 #9 Railsからのモデルの移行2(DDLをパースする)

(この記事は、「fukuoka.ex(その2) Elixir Advent Calendar 2017」の23日目、および「Data Platform Advent Calendar 2017」の5日目です)

昨日は、@piacere_ex さんの「関数型でデータサイエンス番外編:様々な日時文字列を扱えるようにする」でした

はじめに

Elixirで実際にプロダクト開発した経験からサンプルコードを交えて解説する本連載
前回に引き続き別言語のフレームワークで構築されたDBへ対してElixir+Phoenixで接続する際に必要なモデルの移行を考えます。

前回はRailsのActiveRecordとEctoのデータ型の差異を確認しました。(FItGap分析)
今回はDDLファイルを読み込んでパースする実装を考えます。

本連載の記事はこちら
|> ElixirでSI開発入門 #1 Ectoで悲観的ロック
|> ElixirでSI開発入門 #2 Ectoで楽観的ロック
|> ElixirでSI開発入門 #3 主キーが"id "じゃない既存DBへの接続
|> ElixirでSI開発入門 #4 本番パスワードを環境変数に持たせる
|> ElixirでSI開発入門 #5 Ectoで自由にSQLを書いて実行する(参照編)
|> ElixirでSI開発入門 #6 Ectoで自由にSQLを書いて実行する(更新編)
|> ElixirでSI開発入門 #7 Multiで使う関数を再利用可能な粒度に分割する
|> ElixirでSI開発入門 #8 Railsからのモデルの移行1(FitGap分析)
|> ElixirでSI開発入門 #9 Railsからのモデルの移行2(DDLをパースする)

 ★★★ お礼:各種ランキングに83回のランクインを達成しました ★★★ 

4/27から、44日間に渡り、毎日お届けしている「季節外れのfukuoka.ex Elixir Advent Calendar」「季節外れのfukuoka.ex(その2) Elixir Advent Calender」ですが、Qiitaトップページトレンドランキングに13回入賞、Elixirウィークリーランキングでは7週連続で1/2/3フィニッシュを飾り、各種ランキング通算で、トータル87回ものランクインを達成しています

みなさまの暖かい応援に励まされ、合計616件ものQiita「いいね」もいただき、fukuoka.exアドバイザーズとfukuoka.exキャストの一同、ますます季節外れのfukuoka.ex Advent Calendar、頑張っていきます!

image.png

必要なパースの要件を確認する

前回実装したモデルのDDLを確認しましょう。

CREATE TABLE `samples` (
  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
  `col_string` varchar(255) DEFAULT NULL,
  `col_int` int(11) DEFAULT NULL,
  `col_float` double DEFAULT NULL,
  `col_boolean` tinyint(1) NOT NULL DEFAULT '0',
  `col_binary` blob,
  `col_decimal` decimal(10,0) DEFAULT NULL,
  `col_map` text,
  `col_date` date DEFAULT NULL,
  `col_time` time DEFAULT NULL,
  `col_u_datetime` datetime DEFAULT NULL,
  `inserted_at` datetime NOT NULL,
  `updated_at` datetime NOT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `samples_col_int_index` (`col_int`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

非常に見慣れた構文です。
一方でモデルを生成した時に使用したmix phx.genコマンドを確認して見ましょう。

> mix phx.gen.html Samples Sample samples col_string:string col_int:integer:unique col_float:float col_boolean:boolean col_binary:binary col_decimal:decimal col_map:map col_date:date col_time:time col_u_datetime:utc_datetime

指定できる項目は限られています。

DDLファイルを読み込む
|> DDLをパースして必要な情報項目のマップを構築する
|> マップを引数にmix phx.genコマンドを生成する

という流れを考えた場合、
Mixタスクのドキュメントから見てもDDLから抽出したい項目は限られている事が分かるかと思います。

  • samples というテーブル名
  • idなどのカラム名
  • bigint(20)などのカラム型
  • samples_col_int_indexなどのユニーク制約

ActiveRecord、Ectoを使って作られたDBに関していえば
これらが抽出できれば一般的には十分と言えるかと思います。

この程度の抽出条件(抽出したい文言の出現位置、改行位置が決まっている)であれば複雑なパースロジックを実装せずとも、簡単な正規表現で抽出できそうです。
そんな時に活躍するのが、Regex.named_capturesです。

※もっとしっかりしたパーサー実装を考えたい方は下記のエントリーがオススメです。
Elixirのパーサーコンビネータライブラリ Combine入門

Regex.named_capturesで簡単なパース処理

Regex.named_capturesは、正規表現でマッチングした内容から、さらに正規表現でマッチングした部分を変数に格納してマップで返してくれる便利な関数です。

例えば

iex(1)> Regex.named_captures(~r/.*(?<target>[c][d]).*/,"abcdefg")
%{"target" => "cd"}

こうなります。
第一引数は正規表現です。 

~r/.*(?<target>[c][d]).*/

その中でも

(?<target>[c][d])

この部分は
?<変数名>変数に格納する正規表現
の形をとります。
第二引数が抽出元とする文字列です。
これをうまく使えば、テーブル名などを楽に抽出できます。

まずはiex上で試してみる

まずはDDLファイルを読み込みます

iex(1)> {:ok, ddl_body} = File.read "ddls/sample_table.ddl"
{:ok,
 "CREATE TABLE `samples` (\n  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,\n  `col_string` varchar(255) DEFAULT NULL,\n  `col_int` int(11) DEFAULT NULL,\n  `col_float` double DEFAULT NULL,\n  `col_boolean` tinyint(1) NOT NULL DEFAULT '0',\n  `col_binary` blob,\n  `col_decimal` decimal(10,0) DEFAULT NULL,\n  `col_map` text,\n  `col_date` date DEFAULT NULL,\n  `col_time` time DEFAULT NULL,\n  `col_u_datetime` datetime DEFAULT NULL,\n  `inserted_at` datetime NOT NULL,\n  `updated_at` datetime NOT NULL,\n  PRIMARY KEY (`id`),\n  UNIQUE KEY `samples_col_int_index` (`col_int`)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;"}

あとあと面倒なので改行コードを置換して削除しておきます。

iex(2)> ddl_string = Regex.replace(~R/\n|\t/,ddl_body,"")
"CREATE TABLE `samples` (  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,  `col_string` varchar(255) DEFAULT NULL,  `col_int` int(11) DEFAULT NULL,  `col_float` double DEFAULT NULL,  `col_boolean` tinyint(1) NOT NULL DEFAULT '0',  `col_binary` blob,  `col_decimal` decimal(10,0) DEFAULT NULL,  `col_map` text,  `col_date` dateDEFAULT NULL,  `col_time` time DEFAULT NULL,  `col_u_datetime` datetime DEFAULT NULL,  `inserted_at` datetime NOT NULL,  `updated_at` datetime NOT NULL,  PRIMARY KEY (`id`),  UNIQUE KEY `samples_col_int_index` (`col_int`)) ENGINE=InnoDB DEFAULT CHARSET=utf8;"

decimalのカンマも邪魔なので、.ドットに変更します。

iex(3)> ddl_string2 = Regex.replace(~R/decimal\(10,0\)/,ddl_string,"decilam(10.0)")
"CREATE TABLE `samples` (  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,  `col_string` varchar(255) DEFAULT NULL,  `col_int` int(11) DEFAULT NULL,  `col_float` double DEFAULT NULL,  `col_boolean` tinyint(1) NOT NULL DEFAULT '0',  `col_binary` blob,  `col_decimal` decilam(10.0) DEFAULT NULL,  `col_map` text,  `col_date` dateDEFAULT NULL,  `col_time` time DEFAULT NULL,  `col_u_datetime` datetime DEFAULT NULL,  `inserted_at` datetime NOT NULL,  `updated_at` datetime NOT NULL,  PRIMARY KEY (`id`),  UNIQUE KEY `samples_col_int_index` (`col_int`)) ENGINE=InnoDB DEFAULT CHARSET=utf8;"

Regex.named_capturesを使ってテーブル名とそれ以外※()以降の定義を変数に取得します。

iex(4)> ddl_map = Regex.named_captures(~r/CREATE TABLE *`(?<table_name>[a-zA-Z\d\._]*)` *\((?<columns>.*)\).*/,ddl_string2)
%{
  "columns" => "  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,  `col_string` varchar(255) DEFAULT NULL,  `col_int` int(11) DEFAULT NULL,  `col_float` double DEFAULT NULL,  `col_boolean` tinyint(1) NOT NULL DEFAULT '0',  `col_binary` blob,  `col_decimal` decilam(10.0) DEFAULT NULL,  `col_map` text,  `col_date` date DEFAULT NULL,  `col_time` time DEFAULT NULL,  `col_u_datetime` datetime DEFAULT NULL,  `inserted_at` datetime NOT NULL,  `updated_at` datetime NOT NULL,  PRIMARY KEY (`id`), UNIQUE KEY `samples_col_int_index` (`col_int`)",
  "table_name" => "samples"
}

さらにカラム定義をカンマ区切りでリスト化します。

iex(5)> col_list = String.split(ddl_map["columns"],",")
["  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT",
 "  `col_string` varchar(255) DEFAULT NULL", "  `col_int` int(11) DEFAULT NULL",
 "  `col_float` double DEFAULT NULL",
 "  `col_boolean` tinyint(1) NOT NULL DEFAULT '0'", "  `col_binary` blob",
 "  `col_decimal` decilam(10.0) DEFAULT NULL", "  `col_map` text",
 "  `col_date` date DEFAULT NULL", "  `col_time` time DEFAULT NULL",
 "  `col_u_datetime` datetime DEFAULT NULL",
 "  `inserted_at` datetime NOT NULL", "  `updated_at` datetime NOT NULL",
 "  PRIMARY KEY (`id`)", "  UNIQUE KEY `samples_col_int_index` (`col_int`)"]

カラム毎の定義をさらにパースしていきます。

iex(6)> col_string = "  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT"
"  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT"
iex(7)> col_map = Regex.named_captures(~r/`(?<name>[a-zA-Z\d\._]*)` *(?<type>[a-zA-Z\d\._\(\)]*) *(?<options>.*)/,col_string)
%{
  "name" => "id",
  "options" => "unsigned NOT NULL AUTO_INCREMENT",
  "type" => "bigint(20)"
}

NOT NULL制約などのオプションの有無は、より抽象化してbooleanでオプションが指定されているかいないかを保持する様にします。

iex(8)> ops_not_null = Regex.match?(~r/NOT NULL/,col_map["options"])
true
iex(9)> ops_auto_increment = Regex.match?(~r/AUTO_INCREMENT/,col_map["options"])
true
iex(10)> Regex.named_captures(~r/.*DEFAULT *(?<default_value>[0-9a-zA-Z\d\,_\`\']*).*/,col_map["options"])
nil

デフォルト指定があるカラムの場合はこんな感じです。

iex(11)> col_string = "  `col_boolean` tinyint(1) NOT NULL DEFAULT '0'"
"  `col_boolean` tinyint(1) NOT NULL DEFAULT '0'"
iex(12)> col_map = Regex.named_captures(~r/`(?<name>[a-zA-Z\d\._]*)` *(?<type>[a-zA-Z\d\._\(\)]*) *(?<options>.*)/,col_string)
%{
  "name" => "col_boolean",
  "options" => "NOT NULL DEFAULT '0'",
  "type" => "tinyint(1)"
}
iex(13)> Regex.named_captures(~r/.*DEFAULT *(?<default_value>[0-9a-zA-Z\d\,_\`\']*).*/,col_map["options"])
%{"default_value" => "'0'"}

これでmix genコマンドに必要な情報をだいたいパースできたかと思います。

関数を実装する

これらの結果を元に実装したDDL構造体を構築する関数が以下です。

lib/ddl/ddl.ex
defmodule Ddl do

  alias Ddl.Column
  defstruct table_name: nil, columns: [], primary_keys: [], unique_constraint: []

end
lib/ddl/column.ex
defmodule Ddl.Column do
  defstruct name: nil, type: nil, not_null: false, auto_increment: false, default: nil
end
lib/ddl_migrator_mysql.ex
defmodule DdlMigratorMysql do
alias Ddl
alias Ddl.Column

def parsing_ddl(ddl_path, ddl_file_name) do
    {:ok, ddl_body} = File.read ddl_path <> "/" <> ddl_file_name
    ddl_string = Regex.replace(~R/\n|\t/,ddl_body,"")
    ddl_string2 = Regex.replace(~R/decimal\(10,0\)/,ddl_string,"decilam(10.0)")
    ddl_map = Regex.named_captures(~r/CREATE TABLE *`(?<table_name>[a-zA-Z\d\._]*)` *\((?<columns>.*)\).*/,ddl_string2)
    table_name = List.last(String.split(ddl_map["table_name"],"."))
    plural_name = table_name

    col_list = String.split(ddl_map["columns"],",")

    ddl_struct = %Ddl{}
    |> Map.put(:table_name, table_name)
    |> parsing_col_def(col_list)
  end


  def parsing_col_def(ddl_struct, []) do
    ddl_struct
  end
  def parsing_col_def(ddl_struct, col_list) do
   [col_string | next_list] = col_list
    cond do
      Regex.match?(~r/.*PRIMARY KEY.*/,col_string) ->
        #主キー制約の場合のパース処理
        pk_map = Regex.named_captures(~r/.*\((?<columns>[a-zA-Z\d\.\,_\`]*)\)*/,col_string)
        pk_list = String.split(pk_map["columns"],",")
        ddl_struct = Map.put(ddl_struct,:primary_keys,ddl_struct.primary_keys ++ pk_list)
      Regex.match?(~r/.*UNIQUE KEY.*/,col_string) ->
        #ユニークキー制約の場合のパース処理
        unique_map = Regex.named_captures(~r/.*UNIQUE KEY *`(?<name>[a-zA-Z\d\._]*)` *\((?<columns>[a-zA-Z\d\.\,_\`]*)\)*/,col_string)
        {:unique, unique_map}
        ddl_struct = Map.put(ddl_struct,:unique_constraint,ddl_struct.unique_constraint ++ [unique_map])
      true ->
        #それ以外の通常カラムのパース処理
        col_map = Regex.named_captures(~r/`(?<name>[a-zA-Z\d\._]*)` *(?<type>[a-zA-Z\d\._\(\)]*) *(?<options>.*)/,col_string)
        ops_not_null = Regex.match?(~r/NOT NULL/,col_map["options"])
        ops_auto_increment = Regex.match?(~r/AUTO_INCREMENT/,col_map["options"])
        ops_default =
        #defaultは指定がない場合nilが返るのでwithで処理する
        with %{"default_value" => ops_default} <- Regex.named_captures(~r/.*DEFAULT *(?<default_value>[0-9a-zA-Z\d\,_\`\']*).*/,col_map["options"])
        do
          ops_default
        else
          _ -> ops_default = nil
        end

        #name: nil, type: nil, not_null: false, auto_increment: false, default: nil
        column = %Column{name: col_map["name"], type: col_map["type"], not_null: ops_not_null, auto_increment: ops_auto_increment, default: ops_default}
        #ddl_struct = Map.put(ddl_struct,:columns,columns)
        ddl_struct = Map.put(ddl_struct,:columns,ddl_struct.columns ++ [column])
    end
    parsing_col_def(ddl_struct, next_list)
  end

実行してみる

iex(227)> parsing_ddl("ddls","sample_table.ddl")
%Ddl{
  columns: [
    %Ddl.Column{
      auto_increment: true,
      default: nil,
      name: "id",
      not_null: true,
      type: "bigint(20)"
    },
    %Ddl.Column{
      auto_increment: false,
      default: "NULL",
      name: "col_string",
      not_null: false,
      type: "varchar(255)"
    },
    %Ddl.Column{
      auto_increment: false,
      default: "NULL",
      name: "col_int",
      not_null: false,
      type: "int(11)"
    },
    %Ddl.Column{
      auto_increment: false,
      default: "NULL",
      name: "col_float",
      not_null: false,
      type: "double"
    },
    %Ddl.Column{
      auto_increment: false,
      default: "'0'",
      name: "col_boolean",
      not_null: true,
      type: "tinyint(1)"
    },
    %Ddl.Column{
      auto_increment: false,
      default: nil,
      name: "col_binary",
      not_null: false,
      type: "blob"
    },
    %Ddl.Column{
      auto_increment: false,
      default: "NULL",
      name: "col_decimal",
      not_null: false,
      type: "decilam(10.0)"
    },
    %Ddl.Column{
      auto_increment: false,
      default: nil,
      name: "col_map",
      not_null: false,
      type: "text"
    },
    %Ddl.Column{
      auto_increment: false,
      default: "NULL",
      name: "col_date",
      not_null: false,
      type: "date"
    },
    %Ddl.Column{
      auto_increment: false,
      default: "NULL",
      name: "col_time",
      not_null: false,
      type: "time"
    },
    %Ddl.Column{
      auto_increment: false,
      default: "NULL",
      name: "col_u_datetime",
      not_null: false,
      type: "datetime"
    },
    %Ddl.Column{
      auto_increment: false,
      default: nil,
      name: "inserted_at",
      not_null: true,
      type: "datetime"
    },
    %Ddl.Column{
      auto_increment: false,
      default: nil,
      name: "updated_at",
      not_null: true,
      type: "datetime"
    }
  ],
  primary_keys: ["`id`"],
  table_name: "samples",
  unique_constraint: [
    %{"columns" => "`col_int`", "name" => "samples_col_int_index"}
  ]
}

まとめ

  • Regex.named_capturesは超便利

いかがだったでしょうか。
次回はElixirでSI開発入門 #8 Railsからのモデルの移行1(FitGap分析)で確認した課題2 DDLから取得できない情報の存在
について解決策を考えたいと思います。

明日は、@twinbee さんの「DataLabコンテナでTensorflexを動かそう」です

★★★ 満員御礼!Elixir MeetUpを6月末に開催します ★★★

「fukuoka.ex#11:DB/データサイエンスにコネクトするElixir」を6/22(金)19時に開催します

大人気により、一度は満席となりましたが、増枠しましたので、下記URLよりご参加ください
https://fukuokaex.connpass.com/event/87241

私もこの連載で取り上げてない、開発ネタを出す予定ですのでぜひ奮ってご応募ください。

image.png