LoginSignup
5
2

More than 3 years have passed since last update.

Phoenixお気軽API開発④:DB取得+軽量APIで1件参照REST APIを実装する(TDD付)

Last updated at Posted at 2020-04-02

fukuoka.ex/kokura.exのpiacereです
ご覧いただいて、ありがとうございます :bow:

前作、軽量APIで一覧REST APIのDB取得実装をしたので、続けて、DB取得する1件参照REST APIの実装を行います

Elixir標準のインメモリ&ローカルファイルDB「Mnesia」による、DB実装サンプルにもなっていますので、Mnesiaにあまり馴染みが無いアルケミストも今回および以降のコラムをどうぞご参考ください

また、Elixir/PhoenixにおけるTDDの実践例にもなっているので、実務におけるElixir TDDをキャッチアップしたい方も、しっかりと内容に付いてきてください

逆に、今回のコード改修の内容が難しくて脱落しそうな方は、末尾にコードの最終形へのリンクを載せておきますので、ひとまずコードをコピペで作り、まずは動かして、徐々に理解していってください

本コラムの検証環境

本コラムは、以下環境で検証しています(Windowsで実施していますが、Linuxやmacでも動作する想定です)

あと下記コラムシリーズの続きとして実施するので、未実施であれば、事前に実施しておいてください(近々、ボイラープレート生成のmixコマンドとしてOSS化は考えています)

PHP的ハックを応用してNode.js Express/Go的な軽量APIをPhoenixで実現してみた
|> Express/Go的なPhoenix軽量APIの上に参照系REST APIを実装する
|> 軽量APIにて実装した一覧REST APIにDBを接続する

手順①:自動テスト用にmix test.watchを導入

そろそろ、それなりにロジックを組んでいくことになるので、TDD(Test Driven Development:テスト駆動開発)を始めましょう

まず、ファイル更新都度、テストを自動実行させるために、mix test.watchをインストールします

mix.exs
defmodule Basic.MixProject do

  defp deps do
    [
      { :mix_test_watch, "~> 1.0" },
      

一度、iexを抜けて、下記でmix test.watchをインストールし、Phoenixを起動し直します

なお、以前のmix test.watchだと、もう1枚、コンソールを開いて、TDD専用にしていましたが、現在は、ファイル保存のたびに、Phoenixを起動したコンソール上でテスト結果が走るようになっているので、コンソールは1枚で充分です

mix deps.clean --all
mix deps.get
iex -S mix phx.server

手順②:TDDしたいモジュールのdoctestを準備する

Db/DbMnesiaモジュールをdoctestでTDDするために、testフォルダ配下に、doctest用モジュール群を追加します

test/doc_test.exs
defmodule DbDocTest do
    use ExUnit.Case
    doctest Db
end

defmodule DbMnesiaDocTest do
    use ExUnit.Case
    doctest DbMnesia
end

なお、doc_test.exsに、下記のような記述を追加すれば、コントローラやビューでもdoctest TDDできますが、安易にこれらにロジックやutil的な関数を追加したくないので、書かないようにしておきます

defmodule BasicWebPageControllerDocTest do
    use ExUnit.Case
    doctest BasicWeb.PageController
end

defmodule BasicWebApiControllerDocTest do
    use ExUnit.Case
    doctest BasicWeb.ApiController
end

defmodule BasicWebLiveViewControllerDocTest do
    use ExUnit.Case
    doctest BasicWeb.LiveViewController
end

defmodule BasicWebApiViewDocTest do
    use ExUnit.Case
    doctest BasicWeb.ApiView
end

手順②:DBアクセッサモジュールに簡易where句対応を追加

where句に対応するために、DBアクセッサモジュールを修正します

1)DbMnesiaモジュールにwhere句相当の処理を追加

まず、DbMnesiaモジュールでのMnesiaの操作にて、where句相当を行うには、:mnesia.select()の第2引数に、フィルタ条件をタプルで指定する必要があります

フィルタ条件タプルは、{【演算子アトム】, 【対象列】, 【条件値】}という形式での指定が必要で、キッチリやるなら、SQL文のwhere句パースと、このタプルへの変換処理が必要ですが、ここでは、指定列と値の一致のみwhereで記述できる制約とします

なお、select()の第3引数whereの追加と合わせて、第2引数で、列選択指定も追加しましたが、今回は、where句対応を優先するため、ここでは空振りとしておき、以降で実装することとします

lib/util/db_mnesia.ex
defmodule DbMnesia do
    def select( table_name, _columns, wheres ) do
        :mnesia.start
        table_atom = table_name |> String.to_atom
        :mnesia.wait_for_tables( [ table_atom ], 1000 )
        # TODO: 列選択はそのうち
        columns = :mnesia.table_info( table_atom, :attributes ) 
            |> Enum.reduce( [], fn( item, acc ) -> acc ++ [ Atom.to_string( item ) ] end )
        columns_spec = 1..Enum.count( columns ) 
            |> Enum.reduce( { table_atom }, fn( x, acc ) -> Tuple.append( acc, :"$#{ x }" )  end )
        wheres_spec = wheres |> wheres_param |> wheres_spec( columns, columns_spec )  # <-- add here
        rows = :mnesia.transaction( fn -> 
            :mnesia.select( table_atom, [ { columns_spec, wheres_spec, [ :"$$" ] } ] ) end ) |> elem( 1 )
                                                                # ^-- modify here
        %{
            columns: columns, 
            command: :select, 
            connection_id: 0, 
            num_rows: Enum.count( rows ), 
            rows: rows
        }
    end

    # v-- add here
    @doc """
    SQL Where strings(list) to Mnesia conditons(tuple list)

    ## Examples
        iex> DbMnesia.wheres_param( [ "id = 123", "name = 'hoge'", "position = 'foo'" ] )
        [
            { :==, "id",       123    }, 
            { :==, "name",     'hoge' }, 
            { :==, "position", 'foo'  } 
        ]
    """
    def wheres_param( _wheres ) do
        [
        ]
    end

    @doc """
    Mnesia conditons columns name convert to params

    ## Examples
        iex> DbMnesia.wheres_spec( [ { :==, "id", 123 }, { :==, "name", "hoge" }, { :==, "position", "foo" } ], [ "id", "name", "age", "team", "position" ], { :members, :"$1", :"$2", :"$3", :"$4", :"$5" } )
        [
            { :==, :"$1", 123    }, 
            { :==, :"$2", 'hoge' }, 
            { :==, :"$5", 'foo'  } 
        ]
    """
    def wheres_spec( _wheres_params, _columns_spec ) do
        [
        ]
    end
    # ^-- add here
end

2)空を返し、テスト失敗を確認(TDD Red)

上記ファイルを保存すると、テストが自動で走り、期待通り、テスト失敗します(TDD Redパターン)

Running tests...
..warning: redefining module BasicWebApiViewDocTest (current version defined in memory)
  test/doc_test.exs:31

  1) doctest DbMnesia.wheres_param/1 (1) (DbMnesiaDocTest)
     test/doc_test.exs:8
     Doctest failed
     doctest:
       iex> DbMnesia.wheres_param( [ "id = 123", "name = 'hoge'", "position = 'foo'" ] )
       [
        { :==, "id",       123    },
        { :==, "name",     'hoge' },
        { :==, "position", 'foo'  }
       ]
     code:  DbMnesia.wheres_param(["id = 123", "name = 'hoge'", "position = 'foo'"]) === [
                { :==, "id",       123    },
                { :==, "name",     'hoge' },
                { :==, "position", 'foo'  }
            ]
     left:  []
     right: [{:==, "id", 123}, {:==, "name", 'hoge'}, {:==, "position", 'foo'}]
     stacktrace:
       lib/util/db_mnesia.ex:30: DbMnesia (module)

  2) doctest DbMnesia.wheres_spec/3 (2) (DbMnesiaDocTest)
     test/doc_test.exs:8
     Doctest failed
     doctest:
       iex> DbMnesia.wheres_spec( [ { :==, "id", 123 }, { :==, "name", "hoge" }, { :==, "position", "foo" } ], [ "id", "name", "age", "team", "position" ], { :members, :"$1", :"$2", :"$3", :"$4", :"$5" } )
       [
        { :==, :"$1", 123    },
        { :==, :"$2", 'hoge' },
        { :==, :"$5", 'foo'  }
       ]
     code:  DbMnesia.wheres_spec([{:==, "id", 123}, {:==, "name", 'hoge'}, {:==, "position", 'foo'}], ["id", "name", "age", "team", "position"], {:members, :"$1", :"$2", :"$3", :"$4", :"$5"}) === [
                { :==, :"$1", 123    },
                { :==, :"$2", 'hoge' },
                { :==, :"$5", 'foo'  }
            ]
     left:  []
     right: [{:==, :"$1", 123}, {:==, :"$2", 'hoge'}, {:==, :"$5", 'foo'}]
     stacktrace:
       lib/util/db_mnesia.ex:46: DbMnesia (module)

.

Finished in 0.5 seconds
2 doctests, 3 tests, 2 failures

3)期待値をリテラルで返し、テスト成功を確認(TDD Green)

期待値そのままを固定値で返すよう、ロジック側を修正します

lib/util/db_mnesia.ex
defmodule DbMnesia do

    def wheres_param( _wheres ) do
        [
            { :==, "id",       123    }, 
            { :==, "name",     "hoge" }, 
            { :==, "position", "foo"  } 
        ]
    end

    def wheres_spec( _wheres_params, _columns_spec ) do
        [
            { :==, :"$1", 123    }, 
            { :==, :"$2", 'hoge' }, 
            { :==, :"$5", 'foo'  } 
        ]
    end

保存すると、テストが自動的に走り、もちろんテスト成功します(TDD Greenパターン)

...

Finished in 0.3 seconds
2 doctests, 3 tests, 0 failures

各関数に、2つ目のテストパターンを追加すると、ロジックは固定値しか返していないため、テスト失敗します(TDD Redパターン)

lib/util/db_mnesia.ex
defmodule DbMnesia do

    @doc """
    SQL Where strings(list) to Mnesia conditons(tuple list), but different only for columns name

    ## Examples
        iex> DbMnesia.wheres_param( [ "id = 123", "name = 'hoge'", "team = 'foo'" ] )
        [
            { :==, "id",   123    }, 
            { :==, "name", "hoge" }, 
            { :==, "team", "foo"  } 
        ]
        iex> DbMnesia.wheres_param( [ "name = 'hogehoge'", "position = 'bar'" ] )
        [
            { :==, "name",     "hogehoge" }, 
            { :==, "position", "bar"  } 
        ]
    """
    def wheres_param( wheres ) do

    @doc """
    Mnesia conditons columns name convert to params

    ## Examples
        iex> DbMnesia.wheres_spec( [ { :==, "id", 123 }, { :==, "team", "foo" } ], [ "id", "name", "age", "team", "position" ], { :members, :"$1", :"$2", :"$3", :"$4", :"$5" } )
        [
            { :==, :"$1", 123    }, 
            { :==, :"$4", "foo"  } 
        ]
        iex> DbMnesia.wheres_spec( [ { :==, "name", "hoge" } ], [ "id", "name", "age", "team", "position" ], { :members, :"$1", :"$2", :"$3", :"$4", :"$5" } )
        [
            { :==, :"$2", "hoge" } 
        ]
    """
    def wheres_spec( wheres_params, columns, columns_spec ) do

4)ロジック実装し、テスト成功を確認(TDD Refactoring)

ここで、テストが通る最低限のロジックを実装します

lib/util/db_mnesia.ex
defmodule DbMnesia do

    def wheres_param( wheres ) do
        wheres
        |> Enum.map( fn where ->
            kv = String.split( where, "=" ) |> Enum.map( & String.trim( &1 ) )
            v = List.last( kv )
            raw_v = case Type.is( v ) do
                "Integer" -> v |> String.to_integer
                "Float"   -> v |> String.to_float
                _         -> v |> String.trim( "'" ) |> String.trim( "\"" )
            end
            { :==, List.first( kv ), raw_v }
        end )
    end

    def wheres_spec( wheres_params, columns, columns_spec ) do
        param_only = columns_spec |> Tuple.delete_at( 0 ) |> Tuple.to_list
        wheres_params
        |> Enum.map( fn { op, column, v } -> { op, Enum.at( param_only, Enum.find_index( columns, & &1 == column ) ), v } end )
    end

保存すると、テストが自動的に走り、テスト成功が確認できます(TDD Refactoringパターン)

...

Finished in 0.3 seconds
2 doctests, 3 tests, 0 failures

TDDは、Red → Green → Red → Refactoring、という流れで、テストとロジック実装を充実していきます

5)Dbモジュールにwhere句相当の処理を追加

Dbモジュールのquery()も、where有無でDbMnesia.select()へのパラメータ指定をスイッチするようにします ※本来は、もうちょいマトモなパーサを作った方が良いと思います

lib/util/db.ex
defmodule Db do
    def query( sql ) when sql != "" do
        # v-- modify here
        if String.match?( sql, ~r/.*where.*/ ) do
            parsed_map = Regex.named_captures( ~r/select( *)(?<columns>.*)( *)from( *)(?<tables>.*)( *)where( *)(?<wheres>.*)/, sql )
            DbMnesia.select( 
                parsed_map[ "tables" ]  |> string_to_list |> List.first, 
                parsed_map[ "columns" ] |> string_to_list, 
                parsed_map[ "wheres" ]  |> string_to_list )
        else
            parsed_map = Regex.named_captures( ~r/select( *)(?<columns>.*)( *)from( *)(?<tables>.*)/, sql )
            DbMnesia.select( 
                parsed_map[ "tables" ]  |> string_to_list |> List.first, 
                parsed_map[ "columns" ] |> string_to_list, 
                [] )
        end
        # ^-- modify here
    end

    # v-- add here
    @doc """
    String to list(item trimmed)

    ## Examples
        iex> Db.string_to_list( "id = 123, name = 'hoge'" )
        [ "id = 123", "name = 'hoge'" ]
        iex> Db.string_to_list( "team = 'foo'" )
        [ "team = 'foo'" ]
        iex> Db.string_to_list( "" )
        [ "" ]
    """
    def string_to_list( string ), do: string |> String.split( "," ) |> Enum.map( & String.trim( &1 ) )
    # ^-- add here

手順③:show.json.eex内でDBデータ取得

前回、作成した1件JSONテンプレートを、一覧JSONと共有するため、ファイル構成を変えます

まず、data.json.eexに、show.json.eexと同じ内容をコピーします

lib/basic_web/templates/api/v1/users/data.json.eex
%{
  name:     data[ "name" ], 
  age:      data[ "age" ], 
  team:     data[ "team" ], 
  position: data[ "position" ]
}

上記のwhere対応したDBアクセッサモジュールを使って、data.json.eexを1件JSONテンプレートとするshow.json.eexを作成します

lib/basic_web/templates/api/v1/users/data.json.eex
json = File.read!( "lib/basic_web/templates/api/v1/users/data.json.eex" ) 
[ data ] = Db.query( "select * from members where id = #{ params[ "id" ] }" ) |> Db.columns_rows

json |> Code.eval_string( [ params: params, data: data ] ) |> elem( 0 )

index.json.eexが参照する1件JSONテンプレートも、data.json.eexに変更します

lib/basic_web/templates/api/v1/users/index.json.eex
json = File.read!( "lib/basic_web/templates/api/v1/users/data.json.eex" ) 
datas = Db.query( "select * from members" ) |> Db.columns_rows

for data <- datas do
  json |> Code.eval_string( [ params: params, data: data ] ) |> elem( 0 )
end

手順④:動作確認

RESTクライアントで「GET http://localhost:4000/api/v1/users/1」のアクセスを行うと、以下のように、DBからデータ取得した1件がJSON返却されます
image.png

URLのidを変えると、別の1件がJSON返却されます
image.png

「GET http://localhost:4000/api/v1/users」で、一覧も確認しておきましょう
image.png

終わり

1件REST API向けに、JSONテンプレートでDBデータ取得をできるようにしました

最終的なコードは、以下にまとめてあります

Phoenix軽量APIを使ったREST APIのコード(データはMnesia DBに保持)
https://qiita.com/piacerex/items/a41176fb0c7c68523a4b

今回の内容が上手く理解できなかった方は、上記からコラムを追って、まず手元で動かすところから始めてみてください

次回は、追加・削除REST APIを実装します

p.s.このコラムが、面白かったり、役に立ったら…

image.pngimage.png にて、どうぞ応援よろしくお願いします:bow:

5
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
2