LaravelでクエリビルダーやEloquentを使ったupdateの戻り値の違い

背景

Laravelアプリケーションでレコードをupdateするやり方が色々あり、戻り値の違いについて調べた。

結論

表にまとめた。

書き方 戻り値の型 更新対象がない時の戻り値
\DB::table(‘samples’)->where($where)->update($values) int(件数) 0
\App\Sample::where($where)->update($values) int(件数) 0
\App\Sample::where($where)->first()->update($values) bool(成否) 実行不可
\App\Sample::find($id)->update($values) bool(成否) 実行不可

詳細

それぞれのupdate呼び出し結果をvar_dumpしてみる。

<?php
// 初期データ
\DB::table('samples')->insert([ 'id' => 1, 'name' => 'alice' ]);

echo PHP_EOL;

echo "レコードと差分がない値で更新する場合" . PHP_EOL;
$exists_where = [ 'id' => 1 ];
$same_values  = [ 'name' => 'alice' ];

// クエリビルダーで更新(変更なし)
var_dump(\DB::table('samples')->where($exists_where)->update($same_values));

// Modelのwhere->updateで更新(変更なし)
var_dump(\App\Sample::where($exists_where)->update($same_values));

// Modelのwhere->first->updateで更新(変更なし) -> bool(true)
var_dump(\App\Sample::where($exists_where)->first()->update($same_values));

// Modelのfind->updateで更新(変更なし) -> bool(true)
var_dump(\App\Sample::find($exists_where['id'])->update($same_values));

echo "レコードが存在しない場合" . PHP_EOL;
$not_exists_where = [ 'id' => 999 ];

// クエリビルダーで更新(変更なし)
var_dump(\DB::table('samples')->where($not_exists_where)->update($same_values));

// Modelのwhere->updateで更新(変更なし)
var_dump(\App\Sample::where($not_exists_where)->update($same_values));

// Modelのfirstおよびfindはnullが返るので実行不可

実行すると冒頭の表の通り、int(件数)を返すものとbool(成否)を返すものがある。
何故違うかは、それぞれの実行クラスを見ると分かる。

<?php
var_dump(get_class(\DB::table('samples')->where($exists_where)));
// => string(33) "Illuminate\Database\Query\Builder"

var_dump(get_class(\App\Sample::where($exists_where)));
// => string(36) "Illuminate\Database\Eloquent\Builder"

var_dump(get_class(\App\Sample::where($exists_where)->first()));
// => string(10) "App\Sample"

var_dump(get_class(\App\Sample::find($exists_where['id'])));
// => string(10) "App\Sample"

つまりDB::table経由だろうとモデル経由だろうと、firstなりfindなりで一旦モデルを取得したかどうかで処理が異なる。
なおIlluminate\Database\Eloquent\Builderは内部でIlluminate\Database\Query\Builderに委譲している。

  • 余談1: 試していないが\App\Sample::where($where)->update($values)の場合は各種フックが走らないはず
  • 余談2: \DB::table('samples')->where($where)->first()はモデルではなくstdClassが返る

Illuminate\Database\Query\Builder

このクラスのupdateは最終的にPDOStatement::rowCountを返す。
なので常に更新件数が返る(はず)。

<?php
    /**
     * Run an SQL statement and get the number of rows affected.
     *
     * @param  string  $query
     * @param  array   $bindings
     * @return int
     */
    public function affectingStatement($query, $bindings = [])
    {
        return $this->run($query, $bindings, function ($query, $bindings) {
            if ($this->pretending()) {
                return 0;
            }

            // For update or delete statements, we want to get the number of rows affected
            // by the statement and return that back to the developer. We'll first need
            // to execute the statement and then we'll use PDO to fetch the affected.
            $statement = $this->getPdo()->prepare($query);

            $this->bindValues($statement, $this->prepareBindings($bindings));

            $statement->execute();

            return $statement->rowCount();
        });
    }

Illuminate\Database\Eloquent\Model

App\Sampleが継承しているModelのupdateは以下のようになっている。

<?php
    /**
     * Update the model in the database.
     *
     * @param  array  $attributes
     * @param  array  $options
     * @return bool
     */
    public function update(array $attributes = [], array $options = [])
    {
        if (! $this->exists) {
            return false;
        }

        return $this->fill($attributes)->save($options);
    }

saveはEloquentのフックの処理を色々したりするが、最終的には必ずboolを返す。
件数を返すことはないように見える。

なお$this->existsで見ているexistsはレコードをチェックしているわけではなく、論理上のフラグである。
モデルのコンストラクタで指定するか、insertに成功したりするとフラグが立つ。

Ruby - csvをmarkdown形式のテーブル表記にするサンプルコード

探せば色々な言語のサンプルコードがある。
気分転換に自分でも書いてみた。

整形しないで出力

require 'csv'

def print_to_table(rows)
  header   = rows[0]
  contents = rows[1..-1]

  puts "|" + header.join("|") + "|"
  puts "|" + Array.new(header.length, "---").join("|") + "|"
  contents.each do |items|
    puts "|" + items.join("|") + "|"
  end
end

# 実行
csv = <<EOS
Title,T,Tit
Text,Long Long Text,Awesome
TextText,Text,Some
EOS
rows = CSV.parse(csv)

print_to_table(rows)
|Title|T|Tit|
|---|---|---|
|Text|Long Long Text|Awesome|
|TextText|Text|Some|

整形して出力

require 'csv'

def print_to_table_pretty(rows)
  # 幅に応じて空白を後ろに追加する
  def align_cell(str, width)
    # str + (" " * (width - str.length))でもいい
    str.ljust(width, " ")
  end

  column_widths = Array.new(rows[0].length)
  # 各列の最大幅を記録する
  rows.each do |row|
    row.each_with_index do |text, i|
      current_width    = column_widths[i] || 0
      column_widths[i] = [current_width, text.length].max
    end
  end

  # 各行を列ごとの最大幅に合わせながら出力する
  rows.each_with_index do |row, row_i|
    print "|"
    row.each_with_index do |text, y|
      print " " + align_cell(text, column_widths[y]) + " |"
    end
    puts

    # ヘッダー行の後に入れる --- の行
    if row_i == 0
      print "|"
      column_widths.each do |width|
        print " " + align_cell(("-" * width), width) + " |"
      end
      puts
    end
  end
end


# 実行
csv = <<EOS
Title,T,Tit
Text,Long Long Text,Awesome
TextText,Text,Some
EOS
rows = CSV.parse(csv)

print_to_table_pretty(rows)
| Title    | T              | Tit     |
| -------- | -------------- | ------- |
| Text     | Long Long Text | Awesome |
| TextText | Text           | Some    |

phpのini_set("max_execution_time",n)とset_time_limitの違いについて調べた

背景

phpスクリプトタイムアウト上限を伸ばすには2つの方法がある。

このうちset_time_limitのドキュメントにこのような記述がある。

デフォルトの制限値は 30 秒です。 なお、php.iniでmax_execution_timeの 値が定義されている場合にはそれを用います。

これを僕は「常にmax_execution_timeが優先される」ように解釈した。調べてみた。

結論

「常にmax_execution_timeが優先される」ようなことはない。
set_time_limitini_set('max_execution_time', n)のラッパーというだけな気がする。

調査

OSはMacphpのバージョンは7.0.14。

$ php -v
PHP 7.0.14 (cli) (built: Apr  1 2017 23:41:23) ( NTS )
Copyright (c) 1997-2016 The PHP Group
Zend Engine v3.0.0, Copyright (c) 1998-2016 Zend Technologies

まずは実際に挙動を確認

<?php
// 初期値を確認
var_dump(ini_get("max_execution_time"));

// ini_setで上限値を更新
ini_set("max_execution_time", 60);
var_dump(ini_get("max_execution_time"));

// set_time_limitで上限値を更新
set_time_limit(120);
var_dump(ini_get("max_execution_time"));

コマンドライン経由だとmax_execution_timeは0になるので、ビルトインサーバーを起動してリクエストを投げることで確認した。

-- ビルトインサーバー起動
$ php -S localhost:9000


-- 別コンソールからリクエスト実行
$ curl "http://localhost:9000/sample.php"
string(2) "30"
string(2) "60"
string(3) "120"

もし「max_execution_timeが優先される」なら最後の出力は30もしくは60になるはずだが、実際はちゃんと値が更新されている。
だとすると、ドキュメントの一文は何を意味するのか。

英語の原文を見る

英語のドキュメントだとこう書かれている。

The default limit is 30 seconds or, if it exists, the max_execution_time value defined in the php.ini.

「デフォルト値は30秒だが、もしphp.iniにmax_execution_timeの定義があればそれをデフォルト値とする」という意味に読める。
言葉の意味は分かるが、そもそもset_time_limitは引数が必須だ。このデフォルト値とは何のことなのか。一応php本体のコードも読むことにした。

PHP本体のソースを読む

ざっくりとしか読んでいないので間違いはあるかもしれない。

処理的には両者に違いはほとんどない。
デフォルト値がどう関係するかといえば次に書く通り。

どちらも設定値を上書きするたびにカウンタはリセットされる

set_time_limitのドキュメントより。

この関数がコールされた場合、 タイムアウトカウンタをゼロから再スタートします。 言いかえると、タイムアウトがデフォルトの 30 秒で スクリプト実行までに 25 秒かかる場合に、 set_time_limit(20) を実行すると、スクリプトは、 タイムアウトまでに全体で 45秒 の間実行されます。

つまり、以下のコードはタイムアウトにならずに無限ループになる。

<?php
while (true) {
    set_time_limit(1);
    // ini_setの場合も同じ
    // ini_set("max_execution_time", 1);
}

なお、これを実際に試すとプロセスを直接killしないとCPU100%でずっと走り続けるので注意。

本体のソースコードを読んで「これini_setも同様にゼロから再スタートするのでは」と思って試した。
この挙動はset_time_limitだけだと勝手に思っていたが、ini_set('max_execution_time', n)でも同様だった。
ドキュメント的にもmax_execution_timeの項にはset_time_limitを参照するように書かれているわけなので正しい。

ちなみに手元のphp.iniは最初からmax_execution_timeが30秒で設定されていたが、仮に設定がなくてもphp本体が30秒をデフォルト値にしている。

要は「タイムアウト値のデフォルトはmax_execution_timeに依存する」というだけの話だと思う。

GaucheでRubyのArray#product同等の関数

結論

Gaucheではcartesian-productを使うと良い。
以下は、この関数を見落としていたことによる悪戦苦闘の記録。

背景

業務中にちょっとしたスクリプトで「複数の配列の直積」が必要になった。

例:

  • 入力: [[1, 2], [3, 4], [5]]
  • 出力: [[1, 3, 5], [1, 4, 5], [2, 3, 5], [2, 4, 5]]

2配列ぐらいなら2重ループでもいいのだけど、6配列ぐらい必要だったのでループはきつかった。

RubyのArray#product

業務中は作業速度優先で、さっさとRubyで対応した。Rubyならそのものproductというのがある。

irb(main):001:0> [1, 2].product([3, 4], [5])
=> [[1, 3, 5], [1, 4, 5], [2, 3, 5], [2, 4, 5]]

Gaucheにproductがあるのか調べる

業務後にGaucheでどう書くか調べてみた。
冒頭に書いた通り標準ライブラリに存在するのだけど、この時点では見落としていた。

まず(apropos 'product)で引っかからないので早とちりしたが、ドキュメントにちゃんと書いてあった。

Macro: apropos pattern :optional module
名前がpatternにマッチするような定義された変数のリストを表示します。 moduleにモジュールオブジェクトまたはモジュール名を与えた場合は、 そのモジュール内で定義されている変数のみが表示されます。moduleが 省略された場合は、カレントモジュールから「見える」変数が全て表示されます。

カレントモジュールから「見える」変数が全て表示されます。

cartesian-productutil.combinationsにあるので、useしないと見えなかった。
(どこにあるか分からない段階でuse出来るわけはないので、これは仕方ないが)

gosh> (use util.combinations)
gosh> (apropos 'product)
cartesian-product              (util.combinations)
cartesian-product-for-each     (util.combinations)
cartesian-product-right        (util.combinations)
cartesian-product-right-for-each (util.combinations)

とはいえDashproductで引っ掛ければcartesian-productが引っかかるので、これは単純に見落とした。

そんなわけでproduct関数がないと思い込み、練習ついでにGaucheで書いてみようと思った。

Rubyで仮実装

最初からGaucheで書けるほど慣れていないので、まずはRubyで書いてみた。
自己再帰で書いたりしたのだけど最終的にはinjectを使うようにした。

# 2要素の直積
def product_2dim(xs, ys)
  xs.inject([]) do |acc, x|
    acc + ys.map do |y|
      [x, y].flatten
    end
  end
end

# injectで畳み込み
def product(*lists)
  lists.inject([[]]) {|acc, ls|
    product_2dim(acc, ls)
  }
end

Gaucheで実装

上記のRuby実装をGaucheで置き換えた。

(define (product . lis)
  (define (append-last a b)
    (if (null? a)
        (list b)
        (append a (cons b '()))))

  (define (product-2dim xs ys)
    (fold (^(y acc)
            (append acc (map (cut append-last y <>) xs)))
          '() ys))

  (fold product-2dim '(()) lis))

期待通りには動いているように見える。でも…

ベンチマーク取ってみた

(let ((a (iota 100 1))
      (b (iota 100 1))
      (c (iota 100 1)))
  (time (product a b c)))

; (time (product a b c))
;  real 203.288
;  user 279.670
;  sys   16.460

遅い。非効率な実装なのは感じていたけど、ここまで遅いとは。
ちなみにもちろんcartesian-product使えばちゃんと早い。

(use util.combinations)
(let ((a (iota 100 1))
      (b (iota 100 1))
      (c (iota 100 1)))
  (time (cartesian-product (list a b c))))

;(time (cartesian-product (list a b c)))
; real   0.347
; user   0.330
; sys    0.020

おわりに

自分で書いたproduct実装は遅いのを抜きにしても可読性が悪い。これ絶対数日後に読んでも何がしたいか解読に時間がかかるパターン。
色々書いて読んで覚えていくしかない。

cartesian-product自体Gaucheで書かれているので、とても参考になる。

(define (cartesian-product lol)
  (if (null? lol)
      (list '())
      (let ((l (car lol))
            (rest (cartesian-product (cdr lol))))
        (append-map!
         (lambda (x)
           (map (lambda (sub-prod) (cons x sub-prod)) rest))
         l))))

Laravelにおける複文のSQLインジェクション対策

背景

LaravelアプリケーションでSQLインジェクションのテストを行っていて、わざとSQLインジェクションが起こるようなコードを書いたのに実行されなくて調べた。
(まずはSQLインジェクションが発生することを確認してから、その対応を入れて発生しなくなったことを確認しようと思っていた)

SQLインジェクションのクエリ

典型的な以下の文字列を渡した。

'; delete from admins; -- 

だが実行してみると構文エラーが発生した。

SQLSTATE[42000]: Syntax error or access violation: 1064 You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'delete from admins; --%'' at line 5 (SQL:

理由

LaravelはデフォルトでPDO::ATTR_EMULATE_PREPARESをfalseにしているため。
これがfalseだとMySQLで複文(;区切りで複数のクエリを発行すること)を許可しない。

参考:

試しにPDO::ATTR_EMULATE_PREPARESをtrueにしたら意図通りSQLインジェクションが発生した。

ということでLaravelはデフォルトで複文対策が出来ている模様。

おまけ

パターンごとにパケットキャプチャした結果。

PDO::ATTR_EMULATE_PREPARES=true + \DB::select($query, $params)(プレースホルダーを通して実行した場合)

Prepared Statementは呼ばれない。シングルクオートはエスケープされる。

where name like '%\'; delete from admins; --%'

f:id:kanno_kanno:20170518220118p:plain

PDO::ATTR_EMULATE_PREPARES=true + 文字列に直接パラメータを埋め込んで実行

華麗にインジェクションがキマる。

where name like '%'; delete from admins; --%'

f:id:kanno_kanno:20170518220635p:plain

PDO::ATTR_EMULATE_PREPARES=false + 文字列に直接パラメータを埋め込んで実行

本文に書いた通り複文を許さないので構文エラーになる。

f:id:kanno_kanno:20170518220734p:plain

PDO::ATTR_EMULATE_PREPARES=false + \DB::select($query, $params)(プレースホルダーを通して実行した場合)

基本はこれで書くはず。もしくはEloquent使うなら意識すらしないかも。
構文エラーにならずPrepared Statementとかも呼ばれて実行される。

f:id:kanno_kanno:20170518220442p:plain