プログラマーのための YAML 入門 (実践編)

書いた人: kwatch

はじめに

YAML とは、データシリアライゼーション用のフォーマットです。読み書きのしやすさを考慮して設計されたため、構造化されたデータを表現するためによく用いられます。「構造化されたデータを表現する」という点では XML と似ていますが、XML と比べて読みやすい、書きやすい、わかりやすいという特徴があります。

YAML ではデータを次の 3 つの組み合わせで表現します。特別なデータ構造を必要としないので、XML と比べてわかりやすいです。

  • 配列 (シーケンス)
  • ハッシュ (マッピング)
  • 数値、文字列、日付など (スカラー)

初級編では YAML の書き方と XML との比較について説明し、中級編では YAML 用ライブラリである Syck について説明しました。 今回の実践編では、YAML を使って実用的なツールを作成してみます。

目次

概要

実践編である今回は、YAML を使った実用的なツールを作成します。

作成するツールは、テーブル定義ファイルを読み込み、create table 文を自動生成するツールです。今流行りの Ruby on Rails ではデータベースからテーブル情報を自動的に読み込んでくれますが、テーブルを作成する機能はありません。そこで、テーブルを作成するツールを題材にしてみました。

テーブル定義ファイルは YAML で記述します。それを Ruby スクリプトで読み込んで加工し、eRuby のテンプレートで出力します。つまり、ツールは次のような構成になっています。

テーブル定義ファイル (YAML ファイル)
テーブル定義を記述したファイルです。YAML で記述します。
メインプログラム (Ruby スクリプト)
テーブル定義を読み込み、加工します。具体的には値のチェックや、デフォルト値の設定を行います。
出力用テンプレート (eRuby スクリプト)
加工したデータを出力するためのテンプレートです。テンプレートを切り替えることで、SQL だけではなく、様々な形式に出力できます。

このような構成にする利点は次のとおりです。

  • データの再利用が容易です。最終的な出力 (今回なら SQL 文) を直接記述すると、それ以外の用途に再利用するのが困難になります。これを、元データから出力を得るようにすることで、元データの再利用が容易になります。
  • 出力形式の変更が容易です。テンプレートを切り替えることで、1 つのデータファイルから様々な出力が得られます。
  • データの記述が容易になります。データは YAML のような書きやすい形式で記述でき、またメインプログラムでデフォルト値を設定できるのでデータを記述するときはデフォルト値を省略できます。
  • データが一元管理できます。複数の出力形式が必要なとき、それらの間でデータの整合性をとる必要があります。定義ファイルから自動生成するようにすれば、データの整合性に悩む必要はありません。
  • 必要なデータだけを選択できます。テンプレート側で、必要な情報だけを出力するようにすれば、用途に応じて出力するデータを選択できます。逆に言えば、出力に必要のない情報もデータファイルに含めておくことができます。

この「YAML + Ruby + eRuby」という組み合わせは非常に強力です。もっと広まってもいいと思うのですが、あまり知られていません。これはきっと、この組み合わせに名前がないないせいだと思い、ここでは勝手に「DMT パターン」と呼ぶことにします。DMT とは、

  • Data file or Definition file
  • Manipulator or Main program
  • Template file

の頭文字を表します。MVC パターンの一種と考えられなくもないですが、MVC パターンが主にインタラクティブな用途を前提にしているのに対し、DMT パターンはバッチ処理によるファイル出力を前提としているので、別物ととらえたほうがいいでしょう。

なお「YAML + Ruby + eRuby」のかわりに「XML + XSLT」を使っている方もいると思います。両者の比較はセクション「XML + XSLT との比較」で行います。

SQL 生成ツールの解説

作成するツールは以下のような構成になっています。

datafile.yaml
テーブル定義が書かれたデータファイルです。DMT パターン の D にあたるもので、YAML 形式で記述します。
creatable.rb
データを読み込んで加工する Ruby プログラムです。DMT パターン の M にあたります。
ddl-mysql.eruby
MySQL 用の create table 文を出力する eRuby テンプレートです。DMT パターン の T にあたります。
ddl-postgresql.eruby
PostgreSQL 用の create table 文を出力する eRuby テンプレートです。DMT パターン の T にあたります。

まずはじめに、最終的にどんな出力を得たいのかを示します。そのあとで、それを得るためのデータファイルやメインプログラムや出力用テンプレートを説明します。

最終出力物 (SQL スクリプト)

最終的には、MySQL 用と PostgreSQL 用の SQL スクリプトを出力します。この SQL スクリプトでは、テーブルを作成するための create table 文が生成されます。

MySQL と PostgreSQL では、create table 文の書き方に以下のような違いがあります。これらを意識して、それぞれの create table 文を生成します。

項目 MySQL PostgreSQL
予約語のエスケープ `...` "..."
自動インクリメント auto_increment 制約 serial 型
列挙型 使用できる 使用できない
外部参照キー 指定できない 指定できる

MySQL 用の SQL スクリプトは、例えば次のようになります。このあとの PostgreSQL 用 SQL スクリプトと比較してみてください。

ddl-mysql.sql - 出力ファイル (MySQL 用)

----------------------------------------------------------------------
-- DDL for MySQL
--   generated at Sun Oct 02 02:49:28 JST 2005
----------------------------------------------------------------------

-- グループマスタテーブル
create table groups (
   id                   integer              auto_increment primary key,
   name                 varchar(63)          not null,
   `desc`               varchar(255)         ,
   created_on           timestamp            not null,
   updated_on           timestamp            not null
);

-- ユーザマスタテーブル
create table users (
   id                   integer              auto_increment primary key,
   name                 varchar(63)          not null,
   `desc`               varchar(255)         ,
   email                varchar(63)          not null,
   group_id             integer              not null,  -- references groups.id
   age                  integer              ,
   gender               enum('M', 'F')       ,
   created_on           timestamp            not null,
   updated_on           timestamp            not null
);

PostgreSQL 用の SQL スクリプトは、例えば次のようになります。上の MySQL 用 SQL スクリプトと比較してみてください。

ddl-postgresql.sql - 出力ファイル (PostgreSQL 用)

----------------------------------------------------------------------
-- DDL for PostgreSQL
--   generated at Sun Oct 02 02:49:28 JST 2005
----------------------------------------------------------------------

-- グループマスタテーブル
create table groups (
   id                   serial               primary key,
   name                 varchar(63)          not null,
   "desc"               varchar(255)         ,
   created_on           timestamp            not null,
   updated_on           timestamp            not null
);

-- ユーザマスタテーブル
create table users (
   id                   serial               primary key,
   name                 varchar(63)          not null,
   "desc"               varchar(255)         ,
   email                varchar(63)          not null,
   group_id             integer              not null references groups(id),
   age                  integer              ,
   gender               varchar(1)           ,  -- M,F
   created_on           timestamp            not null,
   updated_on           timestamp            not null
);

データベースごとの相違点は、出力用テンプレートを切り替えることで対応できます。アプリケーションを MySQL から PostgreSQL に切り替える (あるいはその逆) といった場合にも、柔軟に対応できます。

出力ファイルのイメージは掴めたでしょうか。以降では、この出力ファイルを得るためのデータファイルやメインプログラムや出力用テンプレートを説明します。

データファイル (YAML ファイル)

元データとなるデータファイル「datafile.yaml」には、テーブル定義を記述します。

重要なのは次の点です。

  • テーブル定義に先立ち、カラムの名前と属性のデフォルト値を設定する。
  • テーブル定義でカラム名を指定すると、そのデフォルト値が設定される (上書きもできる)。
  • 外部参照キーは YAML のアンカーとエイリアスで表現する。

これにより、テーブル定義においてカラムの属性指定をかなり省略することができます。またカラムの説明など、出力には必要のない情報も含めることができます。

また、データファイルはなるべく汎用的に使えるようにしておきましょう。例えばデータ型で「varchar2」などと指定してしまうと、特定の RDBMS でしか使用できなくなります。できれば「string」や「text」のように抽象的に指定しておき、出力用テンプレートのほうで変換するのがよいでしょう。

なおデータファイル自体の詳しい解説は省略します (見ればわかりますよね?)。

datafile.yaml - データファイル

##
## テーブル定義ファイル
##

## カラムのデフォルト値
defaults:
  columns:
    - name:       id
      desc:       ID
      type:       integer
      primary-key: yes
      serial:     yes
  
    - name:       name
      desc:       名前
      type:       string
      not-null:   yes
      width:      63
  
    - name:       desc
      desc:       摘要
      type:       string
  
    - name:       created_on
      desc:       作成時刻
      type:       timestamp
      not-null:   yes
  
    - name:       updated_on
      desc:       更新時刻
      type:       timestamp
      not-null:   yes

### テーブル定義
tables:
  - name:         groups
    class:        Group
    desc:         グループマスターテーブル
    columns:
      - &groups-id
        name:     id
        desc:     グループID
      - name:     name
      - name:     desc
      - name:     created_on
      - name:     updated_on

  - name:         users
    class:        User
    desc:         ユーザマスターテーブル
    columns:
      - &users-id
        name:     id
        desc:     ユーザID
      - name:     name
      - name:     desc
      - name:     email
        desc:     メールアドレス
        type:     string
        width:    63
        not-null: yes
      - name:     group_id
        desc:     所属するグループのID
        ref:      *groups-id
        not-null: yes
      - name:     age
        desc:     年齢
        type:     integer
      - name:     gender
        desc:     性別
        type:     string
        width:    1
        enum:
          - M
          - F
      - name:     created_on
      - name:     updated_on

なおここでは、外部参照キーの指定に YAML のアンカーとエイリアスを使いました。しかし YAML のアンカーとエイリアスでは、後方参照ができません。つまり、エイリアスではすでに定義されてあるアンカーしか参照できません (これは YAML を 1 パスで構文解析するための意図的な仕様です)。これを避けるためには、外部参照キーを例えば「テーブル名.カラム名」のように指定できるようにするとよいでしょう。

メインプログラム (Ruby プログラム)

続いて、メインプログラム「creatable.rb」です。この中では 2 つのクラスがあります。

  • MainProgram クラス ‥‥ ずばり、メインプログラムを表すクラスです。具体的には次のことを行います。
    • コマンドオプションの解析
    • テーブル定義ファイル (データファイル) の読み込み
    • Manipulator オブジェクトによるデータの操作
    • 出力用テンプレートの適用
  • Manipulator クラス ‥‥ 読み込んだデータを操作するクラスです。具体的には次のことを行います。
    • データの検証
      • カラム名やテーブル名が抜けてないか
      • カラム名やテーブル名が重複していないか
    • デフォルト値の設定
      • テーブルのカラムに対し、デフォルトのカラム属性値を設定
    • 必要な情報の追加
      • カラムからテーブルへのリンクを追加
      • 外部キーで参照しているカラムのデータ型とデータ幅をコピー

メインプログラムは思ったより長くなってしまいました。大事なところを強調しておきますので、他の部分はざっと目を通すだけで結構です。

creatable.rb - メインプログラム

#!/usr/bin/env ruby

##
## creatable - テーブル定義を読み込んで加工し、テンプレートで出力する
##

require 'yaml'
require 'erb'


##
## メインプログラムを表すクラス
##
## 使い方:
##  main = MainProgram.new()
##  output = main.execute(ARGV)
##  print output if output
##
class MainProgram

  def execute(argv=ARGV)
    # コマンドオプションの解析
    options = _parse_options(ARGV)
    return usage() if options[:help]
    raise "テンプレートが指定されていません。" unless options[:template]

    # データファイルを読み込む。タブ文字は空白に展開する。
    s = ''
    while line = gets()
      s << line.gsub(/([^\t]{8})|([^\t]*)\t/n){[$+].pack("A8")}
    end
    doc = YAML.load(s)

    # 読み込んだデータを加工する
    manipulator = Manipulator.new(doc)
    manipulator.manipulate()

    # テンプレートを読み込んで出力を生成する
    s = File.read(options[:template])
    trim_mode = '>'      # '%>' で終わる行では改行を出力しない
    erb = ERB.new(s, $SAFE, trim_mode)
    context = { 'tables' => doc['tables'] }
    output = _eval_erb(erb, context)
    return output
  end

  private

  ## テンプレートを適用する。
  def _eval_erb(__erb, context)
    # このように ERB#result() だけを実行するメソッドを用意すると、
    # 必要な変数 (この場合なら context) だけをテンプレートに渡し、
    # 不必要なローカル変数は渡さなくてすむようになる。
    return __erb.result(binding())
  end

  ## ヘルプメッセージ
  def usage()
     s = ''
     s << "Usage: ruby creatable.rb [-h] -f template datafile.yaml [...]\n"
     s << "  -h          : ヘルプ\n"
     s << "  -f template : テンプレートのファイル名\n"
     return s
  end

  ## コマンドオプションを解析する
  def _parse_options(argv)
    options = {}
    while argv[0] && argv[0][0] == ?-
      opt = argv.shift
      case opt
      when '-h'        # ヘルプ
        options[:help] = true
      when '-f'        # テンプレート名
        arg = argv.shift
        raise "-f: テンプレート名を指定してください。" unless arg
        options[:template] = arg
      else
        raise "#{opt}: コマンドオプションが間違ってます。"
      end
    end
    return options
  end

end


##
## 定義ファイルから読み込んだデータをチェックし、加工するクラス
##
## 使い方:
##   yaml = YAML.load(file)
##   manipulator = Manipulator.new()
##   manipulator.manipulate(yaml)
##
class Manipulator

  def initialize(doc)
    @defaults = doc['defaults'] || {}
    @tables   = doc['tables']   || []
  end

  ## 定義ファイルから読み込んだデータを操作する
  def manipulate()
    
    # 「カラム名→デフォルトカラム定義」の Hash を作成する
    default_columns = {}
    @defaults['columns'].each do |column|
      colname = column['name']
      raise "カラム名がありません。" unless colname
      raise "#{colname}: カラム名が重複しています。" if default_columns[colname]
      default_columns[colname] = column
    end if @defaults['columns']

    # テーブルのカラムをチェックし値を設定する
    tablenames = {}
    @tables.each do |table|
      tblname = table['name']
      raise "テーブル名がありません。" unless tblname
      raise "#{tblname}: テーブル名が重複しています。" if tablenames[tblname]
      tablenames[tblname] = true
      colnames = {}
      table['columns'].each do |column|
        colname = column['name']
        raise "#{tblname}: カラム名がありません。" unless colname
        raise "#{tblname}.#{colname}: カラム名が重複しています。" if colnames[colname]
        colnames[colname] = true
        # カラムのデフォルト値を設定
        default_column = default_columns[colname]
        default_column.each do |key, val|
          column[key] = val unless column.key?(key)
        end if default_column
        # カラムからテーブルへのリンクを設定
        column['table'] = table
        # 外部キーで参照しているカラムの、データ型とカラム幅をコピーする
        if (ref_column = column['ref']) != nil
          column['type']    = ref_column['type']
          column['width'] ||= ref_column['width']  if ref_column.key?('width')
        end
      end if table['columns']
    end
  end

end


## メインプログラムを実行
main = MainProgram.new
output = main.execute(ARGV)
print output if output

出力用テンプレート (eRuby スクリプト)

最後に、MySQL 用と PostgreSQL 用の出力テンプレートです。eRuby で記述します。

テンプレートでは、以下のことを行います。

  1. メインプログラムからテーブル定義データを受け取る
  2. MySQL や PostgreSQL の予約語をエスケープする
  3. create table 文を出力する
  4. 型名を MySQL や PostgreSQL の型に変換する
  5. enum や not-null や primary-key の制約をつける
  6. テーブルのカラム定義を出力する

MySQL 用のテンプレート「ddl-mysql.eruby」は次のようになります。予約語が多いですが、気にしないでください。

ddl-mysql.eruby - 出力用テンプレート

<%
   ##
   ## MySQL用のDDL(create table文)を生成するテンプレート
   ##

   ## メインプログラムからデータを受け取る  ・・・(1)
   tables     = context['tables']

   ## MySQL予約語
   keywords = <<-END
     add all alter analyze and as asc asensitive
     before between bigint binary blob both by
     call cascade case change char character check collate column
     condition connection constraint continue convert create cross
     current_date current_time current_timestamp current_user cursor
     database databases day_hour day_microsecond day_minute day_second
     dec decimal declare default delayed delete desc describe
     deterministic distinct distinctrow div double drop dual
     each else elseif enclosed escaped exists exit explain
     false fetch float for force foreign from fulltext
     goto grant group
     having high_priority hour_microsecond hour_minute hour_second
     if ignore in index infile inner inout insensitive insert
     int integer interval into is iterate
     join
     key keys kill
     leading leave left like limit lines load localtime
     localtimestamp lock long longblob longtext loop low_priority
     match mediumblob mediumint mediumtext middleint
     minute_microsecond minute_second mod modifies
     natural not no_write_to_binlog null numeric
     on optimize option optionally or order out outer outfile
     precision primary procedure purge
     read reads real references regexp release rename repeat
     replace require restrict return revoke right rlike
     schema schemas second_microsecond select sensitive
     separator set show smallint soname spatial specific sql
     sqlexception sqlstate sqlwarning sql_big_result
     sql_calc_found_rows sql_small_result ssl starting straight_join
     table terminated then tinyblob tinyint tinytext to
     trailing trigger true
     undo union unique unlock unsigned update usage use using
     utc_date utc_time utc_timestamp
     values varbinary varchar varcharacter varying
     when where while with write
     xor
     year_month
     zerofill
   END
   @keywords = {}
   keywords.split(/\s+/).each { |word| @keywords[word] = true }

   ## 予約語をエスケープする関数   ・・・(2)
   def self._(word)
     return @keywords[word.downcase] ? "`#{word}`" : word
   end

 %>
----------------------------------------------------------------------
-- DDL for MySQL
--   generated at <%= Time.now.to_s %>

----------------------------------------------------------------------
<% ## create table 文を出力   ・・・(3) %>
<% tables.each do |table| %>

-- <%= table['desc'] %>

create table <%= _(table['name']) %> (
<%
     n = table['columns'].length
     table['columns'].each_with_index do |column, i|
       flag_last_loop = n == i + 1   # ループの最後かどうか

       ## カラムの名前と型
       name  = column['name']
       type  = column['type']
       width = column['width']

       ## 型情報    ・・・(4)
       case type
       when 'char'      ;  type = 'tinyint'
       when 'short'     ;  type = 'mediumint'
       when 'int'       ;  type = 'integer'
       when 'inteter'   ;
       when 'str'       ;  type = 'varchar'; width ||= 255
       when 'string'    ;  type = 'varchar'; width ||= 255
       when 'text'      ;
       when 'float'     ;
       when 'double'    ;
       when 'bool'      ;  type = 'boolean'
       when 'boolean'   ;
       when 'date'      ;
       when 'timestamp' ;
       when 'money'     ;  type = 'decimal'
       end
       type += "(#{width})" if width

       ## enum が指定されているときは型を enum() にする  ・・・(5)
       if column['enum']
         type  = "enum(" + column['enum'].collect{|v| "'#{v}'"}.join(", ") + ")"
         width = nil
       end

       ## 制約条件   ・・・(5)
       flag_notnull = column['not-null'] && !column['serial'] && !column['primary-key']
       constraints = []
       constraints << 'auto_increment' if column['serial']
       constraints << 'not null'       if flag_notnull
       constraints << 'primary key'    if column['primary-key']
       constraints << 'unique'         if column['unique']

       ## カラム定義を出力   ・・・(6)
       name_part  = '%-20s' % _(name)
       type_part  = '%-20s' % type
       const_part = constraints.join(' ')
       comma   = flag_last_loop ? '' : ','
       ref     = column['ref']
       comment = ref ? "  -- references #{ref['table']['name']}.#{ref['name']}" : ""
 %>
   <%= name_part %> <%= type_part %> <%= const_part %><%= comma %><%= comment %>

<%
     end
 %>
<%   ## 主キーが複合キーの場合 %>
<%   if table['primary-keys'] %>
<%     pkeystr = table['primary-keys'].collect{|pkey| _(pkey)}.join(', ') %>
   , primary key (<%= pkeystr %>)
<%   end %>
);
<% end %>

eRuby テンプレートの中で新しいメソッドが必要な場合は、特異メソッドとして定義すると他のプログラムへの影響を少なくできます。 この例では、エスケープするメソッドを特異メソッドとして定義しています。 なお特異メソッドは、今回の例だと MainProgram インスタンスオブジェクトの特異メソッドとして定義されます。

続いて、PostgreSQL 用のテンプレート「ddl-postgresql.eruby」です。やってることは MySQL 用とあまり変わりません。

ddl-postgresql.eruby - 出力用テンプレート

<%
   ##
   ## PostgreSQL用のDDL(create table文)を生成するテンプレート
   ##

   ## メインプログラムからデータを受け取る  ・・・(1)
   tables     = context['tables']

   ## PostgreSQL予約語
   keywords = <<-END
     abort admin all analyse analyze and any as asc
     between binary bit both
     case cast char character check cluster coalesce
     collate column constraint copy cross current_date
     current_time current_timestamp current_user
     dec decimal default deferrable desc distinct do
     else end except exists explain extend extract
     false float for foreign from full
     global group
     having
     ilike in initially inner inout intersect into is isnull
     join
     leading leftlike limit listen local lock
     move
     natural nchar new not notnull null nullif numeric
     off offset old on only or order out outer overlaps
     position precision primary public
     references reset right
     select session_user setof showsome substring
     table then to trailing transaction trim true
     union unique user using
     vacuum varchar verbose
     when where
   END
   @keywords = {}
   keywords.split(/\s+/).each { |word| @keywords[word] = true }

   ## 予約語をエスケープする関数   ・・・(2)
   def self._(word)
     return @keywords[word] ? "\"#{word}\"" : word
   end

 %>
----------------------------------------------------------------------
-- DDL for PostgreSQL
--   generated at <%= Time.now.to_s %>

----------------------------------------------------------------------
<% ## create table 文を出力   ・・・(3) %>
<% tables.each do |table| %>

-- <%= table['desc'] %>

create table <%= _(table['name']) %> (
<%
     n = table['columns'].length
     table['columns'].each_with_index do |column, i|
       flag_last_loop = n == i + 1   # ループの最後かどうか

       ## カラムの名前と型
       name  = column['name']
       type  = column['type']
       width = column['width']

       ## 型情報    ・・・(4)
       case type
       when 'char'      ;
       when 'short'     ;  type = 'smallint'
       when 'int'       ;  type = 'integer'
       when 'inteter'   ;
       when 'str'       ;  type = 'varchar' ; width ||= 255
       when 'string'    ;  type = 'varchar' ; width ||= 255
       when 'text'      ;
       when 'float'     ;  type = 'real'
       when 'double'    ;  type = 'double precision'
       when 'bool'      ;  type = 'boolean'
       when 'boolean'   ;
       when 'date'      ;
       when 'timestamp' ;
       when 'money'     ;  type = 'decimal'
       end
       type += "(#{width})" if width

       ## serial 型の桁数が大きい場合は bigserial を使う  ・・・(5)
       if column['serial']
          type = width && width >= 10 ? 'bigserial' : 'serial'
       end

       ## 制約条件   ・・・(5)
       flag_notnull = column['not-null'] && !column['serial'] && !column['primary-key']
       constraints = []
       constraints << 'not null'    if flag_notnull
       constraints << 'primary key' if column['primary-key']
       constraints << 'unique'      if column['unique']
       if (ref = column['ref']) != nil
         constraints << "references #{ref['table']['name']}(#{ref['name']})"
       end

       ## カラム定義を出力   ・・・(6)
       name_part = '%-20s' % _(name)
       type_part = '%-20s' % type
       const_part = constraints.join(' ')
       comma   = flag_last_loop ? '' : ','
       comment = column['enum'] ? "  -- #{column['enum'].join(',')}" : ""

 %>
   <%= name_part %> <%= type_part %> <%= const_part %><%= comma %><%= comment %>

<%
     end
 %>
<%   ## 主キーが複合キーの場合 %>
<%   if table['primary-keys'] %>
<%     pkeystr = table['primary-keys'].collect{|pkey| _(pkey)}.join(', ') %>
   , primary key (<%= pkeystr %>)
<%   end %>
);
<% end %>

なお、2 つのテンプレートファイルが似ていることから、ひとつにまとめられるのでは?と思う方もいるでしょう。もちろんそうしたいところなのですが、筆者の経験からいうと、まとめたほうが逆に保守しにくいテンプレートとなりました。理想的だが複雑なものより、次善策だが単純なもののほうが、結局は保守しやすいというのが筆者なりの結論です。ただこれはあくまで筆者の私見なので、まとめられると思えばそうしていただいて結構です。

実行例

これらを実行するには、次のようにしてください。

 $ ruby creatable.rb -f ddl-mysql.eruby      datafile.yaml > ddl-mysql.sql
 $ ruby creatable.rb -f ddl-postgresql.eruby datafile.yaml > ddl-postgresql.sql

SQL スクリプトが生成されることを確認してください。

SQL 生成ツールの拡張

今回作成したツールは、SQL 文を生成する以外にも活用できます。例として、Java の DTO (Data Transfer Object) クラスを生成するようにツールを拡張してみましょう。

Java の DTO クラスを生成する場合、次のような拡張が必要です。

  • テーブルごとに別のファイルを出力する
    SQL 文の例では、すべてのテーブル定義からひとつの SQL スクリプトを生成していました。Java の DTO クラスを生成する場合は、テーブルごとに別の Java ファイルを生成する必要があります。ここでは、テーブルごとにファイルを出力するように、以下の拡張を行います。
    • コマンドラインオプション「-m」を追加します。「-m」が指定されたときは、テーブル定義ごとにテンプレートを適用します。
    • 出力するファイル名はテンプレートの中で決定し、メインプログラムがそれを受け取ります。
  • コマンドラインからテンプレートに引数を渡す
    Java では、クラスに対してパッケージ名を指定できます。このパッケージ名をテンプレートに埋め込んでしまうと、再利用性が低くなります。データファイルに書いてもいいのですが、今回は次のような拡張を行うことにします。
    • コマンドラインからテンプレートに対して「--package=my」のようなオプションを指定できるようにします。これをテンプレートプロパティと呼ぶことにします。
    • テンプレートでは、コマンドラインで指定されたテンプレートプロパティを受け取れるようにします。
  • ファイルの出力先となるディレクトリを指定する
    Java ではパッケージ名に沿ったディレクトリにソースコードを置く必要があります。パッケージ名をコマンドラインから指定できるようにするので、ディレクトリもコマンドラインから指定できるようにしてみましょう。
    • 出力先ディレクトリを指定するコマンドラインオプション「-d directory」を追加します。
  • テンプレートファイルを検索するパスを指定する
    テンプレートファイルは必ずしもカレントディレクトリに置けるわけではありません。テンプレートファイルが置いてあるディレクトリを指定できるように、次のような拡張を行います。
    • 環境変数「$CREATABLE_PATH」を参照してテンプレートファイルを検索するようにします。

メインプログラム「creatable.rb」

メインプログラムでは、クラス「MainProgram」に対して次のような拡張を行います。

  • コマンドラインオプション「-m」を追加し、それが指定されたときはテーブル定義ごとにテンプレートを適用します。
  • また「-m」が指定されたときは、テーブル定義ごとに別のファイルに出力します。
  • コマンドラインからテンプレートプロパティを指定できるようにし、それをテンプレートに渡します。
  • 出力先ディレクトリを指定するコマンドラインオプション「-d directory」を追加します。
  • テンプレートを検索するときに、環境変数「$CREATABLE_PATH」を参照します。

なおクラス「Manipulator」は変更する必要はありません。

以下に、変更点がわかるようにしたメインプログラムを示します。

creatable.rb - メインプログラム

#!/usr/bin/env ruby

##
## creatable - テーブル定義を読み込んで加工し、テンプレートで出力する
##

require 'yaml'
require 'erb'


##
## メインプログラムを表すクラス
##
## 使い方:
##  main = MainProgram.new()
##  output = main.execute(ARGV)
##  print output if output
##
class MainProgram

  def execute(argv=ARGV)
    # コマンドオプションの解析
    options, properties = _parse_options(ARGV)
    return usage() if options[:help]
    raise "テンプレートが指定されていません。" unless options[:template]

    # データファイルを読み込む。タブ文字は空白に展開する。
    s = ''
    while line = gets()
      s << line.gsub(/([^\t]{8})|([^\t]*)\t/n){[$+].pack("A8")}
    end
    doc = YAML.load(s)

    # 読み込んだデータを加工する
    manipulator = Manipulator.new(doc)
    manipulator.manipulate()

    # テンプレートを検索する
    t = options[:template]
    if test(?f, t)
      template = t
    elsif ENV['CREATABLE_PATH']
      path_list = ENV['CREATABLE_PATH'].split(File::PATH_SEPARATOR)
      template = path_list.find { |path| test(?f, "#{path}/#{t}") }
    end
    raise "'#{t}': テンプレートが見つかりません。" unless template
    
    # テンプレートを読み込んで出力を生成する
    s = File.read(template)
    trim_mode = '>'        # '%>' で終わる行では改行を出力しない
    erb = ERB.new(s, $SAFE, trim_mode)
    if options[:multiple]  # 複数ファイルへ出力
      doc['tables'].each do |table|
        context = { 'table' => table, 'properties' => properties }
        output = _eval_erb(erb, context)
        filename = context[:output_filename]   # 出力ファイル名
        filename = options[:directory] + "/" + filename if options[:directory]
        File.open(filename, 'w') do |f|
          f.write(output)
          $stderr.puts "generated: #{filename}"
        end if filename
      end
      output = nil
    else                   # 標準出力へ出力
      context = { 'tables' => doc['tables'], 'properties' => properties }
      output = _eval_erb(erb, context)
    end
    return output
  end

  private

  ## テンプレートを適用する。
  def _eval_erb(__erb, context)
    # このようにERB#result()だけを実行するメソッドを用意すると、
    # 必要な変数(この場合ならcontext)だけをテンプレートに渡し、
    # 不必要なローカル変数は渡さなくてすむようになる。
    return __erb.result(binding())
  end

  ## ヘルプメッセージ
  def usage()
     s = ''
     s << "Usage: ruby creatable.rb [-h] [-m] -f template datafile.yaml [...]\n"
     s << "  -h          : ヘルプ\n"
     s << "  -m          : multiple output file\n"
     s << "  -f template : テンプレートのファイル名\n"
     return s
  end

  ## コマンドオプションおよびテンプレートプロパティを解析する
  def _parse_options(argv)
    options = {}
    properties = {}
    while argv[0] && argv[0][0] == ?-
      opt = argv.shift
      if opt =~ /^--(.*)/
        # テンプレートプロパティ
        param_str = $1
        if param_str =~ /\A([-\w]+)=(.*)/
          key, value = $1, $2
        else
          key, value = param_str, true
        end
        properties[key] = value
      else
        # コマンドオプション
        case opt
        when '-h'      # ヘルプ
          options[:help] = true
        when '-f'      # テンプレート名
          arg = argv.shift
          raise "-f: テンプレート名を指定してください。" unless arg
          options[:template] = arg
        when '-m'   # テーブルごとの出力ファイル
          options[:multiple] = true
        when '-d'   # 出力先ディレクトリ
          arg = argv.shift
          raise "-d: ディレクトリ名を指定してください。" unless arg
          options[:directory] = arg
        else
          raise "#{opt}: コマンドオプションが間違ってます。"
        end
      end
    end
    return options, properties
  end

end


##
## 定義ファイルから読み込んだデータをチェックし、加工するクラス
##
## 使い方:
##   doc = YAML.load(file)
##   manipulator = Manipulator.new()
##   manipulator.manipulate(doc)
##
class Manipulator

  def initialize(doc)
    @defaults = doc['defaults'] || {}
    @tables   = doc['tables']   || []
  end

  ## 定義ファイルから読み込んだデータを操作する
  def manipulate()
    
    # 「カラム名→カラム定義」の Hash を作成する
    default_columns = {}
    @defaults['columns'].each do |column|
      colname = column['name']
      raise "カラム名がありません。" unless colname
      raise "#{colname}: カラム名が重複しています。" if default_columns[colname]
      default_columns[colname] = column
    end if @defaults['columns']

    # テーブルのカラムをチェックし値を設定する
    tablenames = {}
    @tables.each do |table|
      tblname = table['name']
      raise "テーブル名がありません。" unless tblname
      raise "#{tblname}: テーブル名が重複しています。" if tablenames[tblname]
      tablenames[tblname] = true
      colnames = {}
      table['columns'].each do |column|
        colname = column['name']
        raise "#{tblname}: カラム名がありません。" unless colname
        raise "#{tblname}.#{colname}: カラム名が重複しています。" if colnames[colname]
        colnames[colname] = true
        # カラムのデフォルト値を設定
        default_column = default_columns[colname]
        default_column.each do |key, val|
          column[key] = val unless column.key?(key)
        end if default_column
        # カラムからテーブルへのリンクを設定
        column['table'] = table
        # 外部キーで参照しているカラムの、データ型とカラム幅をコピーする
        if (ref_column = column['ref']) != nil
          column['type']    = ref_column['type']
          column['width'] ||= ref_column['width']  if ref_column.key?('width')
        end
      end if table['columns']
    end
  end

end


## メインプログラムを実行
main = MainProgram.new
output = main.execute(ARGV)
print output if output

出力用テンプレート「dto-java.eruby」

続いて、Java の DTO 用クラスを生成するためのテンプレート「dto-java.eruby」を説明します。 ポイントは次のとおりです。

  1. テーブルごとに適用されるため、すべてのテーブル定義を受け取るのではなく、個別のテーブル定義を受け取っています。
  2. テンプレートプロパティを受け取っています。
  3. テンプレートプロパティを参照してパッケージ名や親クラス名を決定しています。
  4. 出力ファイル名をテンプレート側で指定しています。これをメインプログラムが読み取ります。

dto-java.eruby - 出力用テンプレート

<%
   ##
   ## Java用DTO(Data Transfer Object)クラスを生成するテンプレート
   ##
   ## テンプレートプロパティ:
   ##   package   - パッケージ名
   ##   parent    - 親クラス名
   ##

   ## コンテキスト
   table      = context['table']         # ・・・(1)
   properties = context['properties']    # ・・・(2)
   raise "コマンドオプション '-m' を指定してください。" unless table

   unless defined?(KEYWORDS)

     ## Javaの予約語
     keywords = <<-END
       abstract assert boolean break byte case catch char class const
       continue default do double else enum extends final finally float
       for goto if implements import instanceof int interface long
       native new package private protected public return short
       static strictfp super switch synchronized this throw throws
       transient try void volatile while
     END
     @keywords = {}
     keywords.each { |word| @keywords[word] = true }

     ## 予約語をエスケープする関数
     def self._(word)
       return @keywords[word] ? "_#{word}" : word
     end

     ## 文字列 "aaa_bbb_ccc" を "AaaBbbCcc" に変換する関数
     def camel_case(name, flag_all=true)
       s = ''
       name.split('_').each_with_index do |word, i|
         s << (!flag_all && i == 0 ? word.downcase : word.capitalize)
       end
       return s
     end

   end


   ## クラス定義情報
   klass = {
     :name       => table['class']   || camel_case(table['name']),
     :package    => table['package'] || properties['package'],    # ・・・(3)
     :parent     => table['parent']  || properties['parent'],     # ・・・(3)
     :desc       => table['desc'],
   }

   ## 出力ファイル名
   context[:output_filename] = klass[:name] + ".java"    # ・・・(4)

   ## インスタンス変数
   variables = []
   ref_variables = []
   table['columns'].each do |column|
     type = column['class']
     unless type
       type   = column['type']
       width  = column['width']
       case type
       when 'char'            ;  type = (!width || width == 1) ? 'char' : 'String'
       when 'short'           ;
       when 'int', 'integer'  ;  type = 'int'
       when 'str', 'string'   ;  type = width == 1 ? 'char' : 'String'
       when 'text'            ;  type = width == 1 ? 'char' : 'String'
       when 'float'           ;
       when 'double'          ;
       when 'bool', 'boolean' ;  type = 'boolean'
       when 'date'            ;  type = 'java.util.Date'  # or java.util.Calendar
       when 'timestamp'       ;  type = 'java.util.Date'  # or java.util.Calendar
       when 'money'           ;  type = 'double'
       end
     end
     var = {
       :name  => column['name'],
       :type  => type,
       :desc  => column['desc'],
     }
     variables << var
     ## 外部参照キー
     if (ref = column['ref']) != nil
       var = {
         :name => column['ref-name'] || column['name'].sub(/_id$/, ''),
         :type => ref['table']['class'],
         :desc => ref['table']['desc']
       }
       ref_variables << var
     end
   end
   variables.concat(ref_variables)


   ## ファイルの出力を開始

 %>
/*
 * DTO class for Java
 *   generated at <%= Time.now.to_s %>

 */
<% if klass[:package] %>
package <%= klass[:package] %>;
<% end %>

/**
 * <%= klass[:desc] %>

 */
<% extends = klass[:parent] ? ' extends ' + klass[:parent] : '' %>
public class <%= klass[:name] %><% extends %> implements java.io.Serializable {

<% ## インスタンス変数 %>
<% variables.each do |var|  %>
<%   vartype = '%-12s' % var[:type] %>
<%   varname = '%-12s' % (var[:name]+';') %>
    private  <%= vartype %> <%= _(varname) %>  /* <%= var[:desc] %> */
<% end %>

<% ## getter/setter %>
<% variables.each do |var| %>
<%   vname  = var[:name] %>
<%   vtype  = var[:type] %>
<%   getter = "#{vtype == 'boolean' ? 'is' : 'get'}#{camel_case(vname)}" %>
<%   setter = "set#{camel_case(vname)}" %>
    public <%= vtype %> <%= getter %>() { return <%= _(vname) %> }
    public void <%=setter%>(<%=vtype%> <%=_(vname)%>) { this.<%=_(vname)%> = <%=_(vname)%>; }
<% end %>

}

実行例

このテンプレートを使うには、コマンドラインオプション「-m」とテンプレートプロパティ「--package='my'」をつけます (パッケージ名は任意です)。

実行例

 $ ruby creatable.rb -m -f dto-java.eruby --package='my' datafile.yaml
 generated: Group.java
 generated: User.java

作成された Java クラスは次のようになります。

Group.java - 自動生成された DTO クラス

/*
 * DTO class for Java
 *   generated at Sun Oct 02 02:49:29 JST 2005
 */
package my;

/**
 * グループマスターテーブル
 */
public class Group implements java.io.Serializable {

    private  int          id;           /* グループID */
    private  String       name;         /* 名前 */
    private  String       desc;         /* 摘要 */
    private  java.util.Date created_on;   /* 作成時刻 */
    private  java.util.Date updated_on;   /* 更新時刻 */

    public int getId() { return id }
    public void setId(int id) { this.id = id; }
    public String getName() { return name }
    public void setName(String name) { this.name = name; }
    public String getDesc() { return desc }
    public void setDesc(String desc) { this.desc = desc; }
    public java.util.Date getCreatedOn() { return created_on }
    public void setCreatedOn(java.util.Date created_on) { this.created_on = created_on; }
    public java.util.Date getUpdatedOn() { return updated_on }
    public void setUpdatedOn(java.util.Date updated_on) { this.updated_on = updated_on; }

}

User.java では外部参照キーもインスタンス変数として追加されていることがわかります。

User.java - 自動生成された DTO クラス

/*
 * DTO class for Java
 *   generated at Sun Oct 02 02:49:29 JST 2005
 */
package my;

/**
 * ユーザマスターテーブル
 */
public class User implements java.io.Serializable {

    private  int          id;           /* ユーザID */
    private  String       name;         /* 名前 */
    private  String       desc;         /* 摘要 */
    private  String       email;        /* メールアドレス */
    private  int          group_id;     /* 所属するグループのID */
    private  int          age;          /* 年齢 */
    private  char         gender;       /* 性別 */
    private  java.util.Date created_on;   /* 作成時刻 */
    private  java.util.Date updated_on;   /* 更新時刻 */
    private  Group        group;        /* グループマスターテーブル */

    public int getId() { return id }
    public void setId(int id) { this.id = id; }
    public String getName() { return name }
    public void setName(String name) { this.name = name; }
    public String getDesc() { return desc }
    public void setDesc(String desc) { this.desc = desc; }
    public String getEmail() { return email }
    public void setEmail(String email) { this.email = email; }
    public int getGroupId() { return group_id }
    public void setGroupId(int group_id) { this.group_id = group_id; }
    public int getAge() { return age }
    public void setAge(int age) { this.age = age; }
    public char getGender() { return gender }
    public void setGender(char gender) { this.gender = gender; }
    public java.util.Date getCreatedOn() { return created_on }
    public void setCreatedOn(java.util.Date created_on) { this.created_on = created_on; }
    public java.util.Date getUpdatedOn() { return updated_on }
    public void setUpdatedOn(java.util.Date updated_on) { this.updated_on = updated_on; }
    public Group getGroup() { return group }
    public void setGroup(Group group) { this.group = group; }

}

「XML + XSLT」との比較

本稿では「YAML + Ruby + eRuby」という組み合わせを使いました。しかし世の中では、「XML + XSLT」という組み合わせもよく見かけます。 そこで、両者の比較を行ってみましょう。

役割分担について

役割分担の違いについては、次の表のようになります。XSLT がデータの「加工や操作」と「出力」の両方を行っていることがわかります。

役割 YAML+Ruby+eRuby XML+XSLT
元データ YAML XML
加工・操作 Ruby XSLT
出力 eRuby

「データの加工・操作までは同じで、出力だけが違う」という場合、「YAML + Ruby + eRuby」だと eRuby のテンプレートだけを切り替えれば済みます。 しかし「XML + XSLT」の場合は、XSLT を切り替えると「加工」までも切り替えることになります。XSLT で「加工」部分と「出力」部分とを切り分けるには、明示的に別ファイルにしておき、<xsl:include> や <xsl:import> を使う必要があります。

また「YAML + Ruby + eRuby」の場合、元データは必ずしも YAML でなくてもよく、XML や CSV やタブ区切りのファイルも使用できます。その違いを Ruby スクリプトが吸収すればよいだけです。しかし「XML + XSLT」の場合、元データとしては XML しか利用できません。

データ記述のしやすさについて

データの記述は、素の状態では YAML のほうが書きやすく読みやすいです。ただし XML は入力支援機能をもつ専用エディタを使えば、書きやすくなります。

また構造化されたデータを記述するという点では YAML も XML も同じですが、どちらかというと YAML は設定ファイルのような定型的なデータに向き、XML は文章のような非定型的なデータに向きます。

なお既存のデータがすでに XML で蓄積されているという場合も多いでしょう。そのような場合は、無理して YAML にする必要はありません。また他人とデータ交換をする必要がある場合は、今のところ XML のほうが無難です。

加工のしやすさについて

XSLT はそれ自体が XML なので、簡潔な記述ができません。また汎用言語ではないので、Ruby と比べるとどうしても能力的に劣ります。 簡単な加工や操作であれば XSLT でもよいですが、本格的な加工や操作が必要になれば、Ruby のほうが圧倒的に有利でしょう。

出力について

XSLT はまるでパズルのような、たいへんわかりにくい記述になります。再帰的な構造をもつような非定型的な出力をする場合は XSLT が向くのだと思いますが、今回のような定型的な出力であれば、明らかに eRuby のほうがわかりやすく記述できます。

ただし、XSLT はブラウザのサポートが期待できます。つまりブラウザが XSLT を解釈する場合、XML と XSLT を用意しておけば、出力ファイルを生成する手間が省けます。これは用途によってはアドバンテージとなることもあるでしょう。

おわりに

今回は YAML を使ったアプリケーションの例として、create table 文を自動生成するツールを作成しました。また応用例として Java クラスを生成するように拡張しました。

ツールの構成は次のとおりです (この構成を勝手に「DMT パターン」と呼んでいます)。

データファイル
YAML で記述します。各種出力の元ネタとなります。
メインプログラム
データファイルを読み込み、加工や操作を行う Ruby スクリプトです。具体的には値のチェック、デフォルト値の設定、値の追加などを行います。
出力用テンプレート
データを出力するための eRuby スクリプトです。テンプレートを切り替えることで、さまざまな形式で出力できます。

このような構成にすると次のような利点があります。

  • データの再利用が容易です。
  • 出力形式の変更が容易です。
  • データの記述が容易になります。
  • データが一元管理できます。
  • 必要なデータだけを選択して出力できます。

また「XML + XSLT」と比べると、データの記述がしやすい、加工や操作が強力でわかりやすい、などの利点があります。

今回紹介した「YAML + Ruby + eRuby」という組み合わせは大変強力です。応用範囲も広いので、ぜひ試してみてください。

さて、3 回で終了する予定だった本連載ですが、書きたいネタがあるので延長させてもらいました。次回は YAML 用のスキーマについて説明します。

著者について

名前:kwatch。三流プログラマー。今の高校生は平成生まれと聞いてがく然とした昭和ウン年世代。そのうち 21 世紀生まれがやってくるのかと思うと、今から鬱。最近のお気に入りは「いわみて」。