fukuoka.ex/kokura.exのpiacereです
ご覧いただいて、ありがとうございます
前作、軽量APIで一覧REST APIのDB取得実装をしたので、続けて、DB取得する1件参照REST APIの実装を行います
Elixir標準のインメモリ&ローカルファイルDB「Mnesia」による、DB実装サンプルにもなっていますので、Mnesiaにあまり馴染みが無いアルケミストも今回および以降のコラムをどうぞご参考ください
また、Elixir/PhoenixにおけるTDDの実践例にもなっているので、実務におけるElixir TDDをキャッチアップしたい方も、しっかりと内容に付いてきてください
逆に、今回のコード改修の内容が難しくて脱落しそうな方は、末尾にコードの最終形へのリンクを載せておきますので、ひとまずコードをコピペで作り、まずは動かして、徐々に理解していってください
本コラムの検証環境
本コラムは、以下環境で検証しています(Windowsで実施していますが、Linuxやmacでも動作する想定です)
- Windows 10
- Elixir 1.10.1 ※最新版のインストール手順はコチラ
- Phoenix 1.4.15 ※最新版のインストール手順はコチラ
- Node.js 12.14.0
あと下記コラムシリーズの続きとして実施するので、未実施であれば、事前に実施しておいてください(近々、ボイラープレート生成の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をインストールします
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用モジュール群を追加します
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句対応を優先するため、ここでは空振りとしておき、以降で実装することとします
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)
期待値そのままを固定値で返すよう、ロジック側を修正します
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パターン)
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)
ここで、テストが通る最低限のロジックを実装します
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()へのパラメータ指定をスイッチするようにします ※本来は、もうちょいマトモなパーサを作った方が良いと思います
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と同じ内容をコピーします
%{
name: data[ "name" ],
age: data[ "age" ],
team: data[ "team" ],
position: data[ "position" ]
}
上記のwhere対応したDBアクセッサモジュールを使って、data.json.eexを1件JSONテンプレートとするshow.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に変更します
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返却されます
「GET http://localhost:4000/api/v1/users」
で、一覧も確認しておきましょう
終わり
1件REST API向けに、JSONテンプレートでDBデータ取得をできるようにしました
最終的なコードは、以下にまとめてあります
Phoenix軽量APIを使ったREST APIのコード(データはMnesia DBに保持)
https://qiita.com/piacerex/items/a41176fb0c7c68523a4b
今回の内容が上手く理解できなかった方は、上記からコラムを追って、まず手元で動かすところから始めてみてください