モンモンブログ

技術的な話など

(jQuery) select 要素の選択結果で別の select 要素の選択肢を絞り込む jQuery プラグイン "Select Narrowing Plugin"

ある select の選択結果で、別の select の選択肢を絞り込む jQuery プラグインを作りました。
こういうの、「Hierselect」(hierarchy + select, 階層select)っていうらしいです。

ググると同じ目的のライブラリはいくつか見つかるけども、

サーバサイドの言語に依存してたり、階層の数が限定されてたり、単純な階層関係しか定義できなかったり、ちょっと不便。
なので新しく作りました。

このプラグインのウリ↓

  • サーバサイドの実装なしに、JS と HTML だけで動作します。サーバサイドが PHP だろうが Ruby だろうが Java だろうが関係なく動きます。素敵。
  • 単純な「親→子」だけでなく、「親→子→孫→ひ孫…」と、何階層でも連鎖させられます。国→エリア→都市、とか。
  • 「親→子1&子2」のように、1つの親 select に複数の子 select を持たせられます。
  • 「親1&親2→子」のように、複数の親 select の選択結果により子 select の選択肢を絞り込んだり出来ます。例えば select1 で色を、select2 で形をそれぞれ選択して、select3 の選択肢を絞り込んだりとか。
  • これらを組み合わせて、いくらでも複雑な階層関係を表現出来ます。出来るはず。あまり複雑な階層関係で実験したことないですけど。。。

プラグインの使い方ですが、

HTML をこんな風に書いて、

<!-- 親select:食品カテゴリ -->
<select id="category">
    <option value="">-- Food Category --</option>
    <option value="meat">Meat</option>
    <option value="vegetable">Vegetable</option>
    <option value="fruit">Fruit</option>
</select>
<!-- 子select:食品 -->
<select id="food">
    <option value="">-- Food --</option>
    <option value="beef" data-category="meat">Beef</option>
    <option value="pork" data-category="meat">Pork</option>
    <option value="chicken" data-category="meat">Chicken</option>
    <option value="lettuce" data-category="vegetable">Lettuce</option>
    <option value="carrot" data-category="vegetable">Carrot</option>
    <option value="tomato" data-category="vegetable">Tomato</option>
    <option value="apple" data-category="fruit">Apple</option>
    <option value="banana" data-category="fruit">Banana</option>
    <option value="melon" data-category="fruit">Melon</option>
</select>

jQuery ライブラリをこんな風に呼ぶだけです。

<script type="text/javascript">
$(function () {
    $("#category").narrows("#food");    // #category は #food を絞り込む
});
</script>

親 select でどの option を選択したら子 select でどの option が選択肢になるのか?ていうのは子 select のデータ属性で定義します。上の例でいうと、

    <option value="beef" data-category="meat">Beef</option>

データ属性 data-category="meat" により「id="category" な親 select で value="meat" が選択された場合にこの option を表示」って表現しています。

詳細は github の README に書いたので見てね。

jQuery Select Narrowing Plugin

あとサンプルはこちら。

sample.html

あっ。そういえば Windows で動作確認してないです_(:3」∠)_ ちょっとまっててよん。 Windows でも動きました。IE6、結構古めのFirefox(笑。バージョン忘れました)で動作確認。

(追記)いろんな OS のいろんなブラウザにスクリーンショット取って貰えるサービス Browsershotssample.htmlスクリーンショットをまとめて取って、Jasmine のテストが通ってるか(画面下の緑のバーが出ているか)ざっくり眺めて確認しましたが、大体のブラウザで動作してるみたいでした。(画面下まで写ってなくてテスト結果が見えないのはありましたが少なくともテスト失敗を表す赤いバーは1つも見えませんでした。)Jasmine + Browsershots ってすごい便利!

(php) 日付を1日ずつインクリメントして出力

php

2013-01-01, 2013-01-02, ..., 2013-12-31

って具合に、日付をインクリメントしながら文字列として出力したいような場合 DateTime クラス + DateInterval クラス による実装と、
date 関数 + strtotime 関数 による実装と、
2通り考えられるかなーと思います。
どっちのが速いんだ? って思ったので実験してみました。

結論から言っちゃうと date 関数 + strtotime 関数 のが歴然と速かった。3倍〜4倍くらい速かった。まあそんなもんかもね。。。

実験コード

<?php
// 実験回数
$times = 1000;

// DateTime + DateInterval で実験
print "*** DateTime + DateInterval ***\n";
$time1_msec = ceil(microtime(true)*1000);
for ($i = $times; $i > 0; $i--) {
    $start_at = new DateTime('2013-01-01');    // 開始日
    $end_at = new DateTime('2013-12-31');      // 終了日
    $_1day = new DateInterval('P1D');          // 「1日」を表す DateInterval 型インスタンス
    $date = $start_at;
    while ((int)($date->diff($end_at)->format('%R%a')) >= 0) {
        $date_string = $date->format('Y-m-d');
        //print "$date_string\n";
        $date->add($_1day);     // 1日インクリメント
    }
}
$time2_msec = ceil(microtime(true)*1000);
printf("%d msec\n", ($time2_msec - $time1_msec));

// date + strtotime で実験
print "*** date + strtotime ***\n";
$time1_msec = ceil(microtime(true)*1000);
for ($i = $times; $i > 0; $i--) {
    $start_at_string = '2013-01-01';    // 開始日
    $end_at_string = '2013-12-31';      // 終了日
    $start_at_unixtime = strtotime($start_at_string);
    $end_at_unixtime = strtotime($end_at_string);
    $date_unixtime = $start_at_unixtime;
    while ($date_unixtime <= $end_at_unixtime) {
        $date_string = date('Y-m-d', $date_unixtime);
        //print "$date_string\n";
        $date_unixtime += 86400;    // 1日インクリメント
    }
}
$time2_msec = ceil(microtime(true)*1000);
printf("%d msec\n", ($time2_msec - $time1_msec));

実行結果。3回叩いてこんな感じ。

$ php date-iteration.php
*** DateTime + DateInterval ***
3353 msec
*** date + strtotime ***
949 msec

$ php date-iteration.php
*** DateTime + DateInterval ***
4058 msec
*** date + strtotime ***
1113 msec

$ php php-date-iteration.php
*** DateTime + DateInterval ***
3371 msec
*** date + strtotime ***
962 msec

いじょ

(symfony1.4) フォームクラスの生成で Fatal error: Call to a member function setTableName() とかなんとか

symfony1.4 で、モデルのコンストラクタのオーバーライドをトチったら厄介なエラーに悩まされたのでメモ。

"Customer" モデルクラスのコンストラクタをこんな風に書きました。parent::__construct を呼ぶのを忘れてます。いっぱしの phper なら鼻で笑っちゃうような初歩的なミスですけど。

$ vim lib/model/doctrine/Customer.class.php
class Customer extends BaseCustomer
{
    // バグコード
    public function __construct()
    {
        $this->ehehe = sfConfig::get('app_ehehe');
    }

    // 以下略
}

そしたらフォームクラスの自動生成で意味不明なエラーが出るようになりました。
↓のように doctrine:build でモデル&フォームを自動生成しようとすると、モデルの生成は成功するけど、続くフォームの生成で Fatal error で失敗。

$ ./symfony doctrine:build --model --forms
>> doctrine  generating model classes  ←モデル生成
>> file+     /var/folders/wj/fp8svh9j25l0yz4lx61p84cm0000gn/T/doctrine_schema_59077.yml
>> tokens    /var/www/ehehe/lib/model/doctrine/base/BaseCustomer.class.php
>> autoload  Resetting application autoloaders
>> file-     /var/www/ehehe/cache/ehehe/dev/config/config_autoload.yml.php

>> doctrine  generating form classes  ←フォーム生成
PHP Fatal error:  Call to a member function setTableName() on a non-object in /var/www/symfony-1.4.18/lib/plugins/sfDoctrinePlugin/lib/vendor/doctrine/Doctrine/Record/Abstract.php on line 140

Fatal error: Call to a member function setTableName() on a non-object in /var/www/symfony-1.4.18/lib/plugins/sfDoctrinePlugin/lib/vendor/doctrine/Doctrine/Record/Abstract.php on line 140
PHP Fatal error:  Call to a member function evictAll() on a non-object in /var/www/symfony-1.4.18/lib/plugins/sfDoctrinePlugin/lib/vendor/doctrine/Doctrine/Connection.php on line 1239

Fatal error: Call to a member function evictAll() on a non-object in /var/www/symfony-1.4.18/lib/plugins/sfDoctrinePlugin/lib/vendor/doctrine/Doctrine/Connection.php on line 1239

エラーを見ても何が何やら分かんないです。で結局、モデルクラスのコンストラクタが原因だったと。

正しくはこう。

$ vim lib/model/doctrine/Customer.class.php
class Customer extends BaseCustomer
{
    // 正しいコード
    public function __construct($table = null, $isNewEntry = false)
    {
        $this->ehehe = sfConfig::get('app_ehehe');
        parent::__construct($table, $isNewEntry);
    }

    // 以下略
}

こんなアホなミスする奴はグーグル先生も見つけらんなかったから僕がきっとパイオニアだよ。同じミスするアホの役に立つことを願って書き残します。そこのお前だよおいアホ m9(^Д^)プギャー

じゃね。

(php) いろんな形式の電話番号を統一形式に変換

いろんな形式の電話番号の文字列を、000-0000-0000 のような統一された形式に変換したい時ありますよね。こんな風に。

090 1234 5678 → 090-1234-5678
+81 90-1234-5678 → 090-1234-5678
+81 (90) 1234 5678 → 090-1234-5678

ユーザー入力の電話番号でDB検索かける時とかに必要んなります。

phpで関数を書きました。

function canonicalized_phonenumber ($phone) {
    // (, ) を取り除く
    $phone = preg_replace("/[()]/", ' ', trim($phone));
    // 国番号以外の番号をハイフンで繋ぐ
    if (preg_match("/^(\+[0-9]{2}[ -]+)?([0-9]+)[ -]+([0-9]+)[ -]+([0-9]+)$/", $phone, $m)) {
        if ('0' != $m[2][0]) $m[2] = '0'.$m[2];
        return "$m[2]-$m[3]-$m[4]";
    } else {
        return null;
    }
}

テスト。

assert(canonicalized_phonenumber('090-1234-5678') == '090-1234-5678');
assert(canonicalized_phonenumber('090 1234 5678') == '090-1234-5678');
assert(canonicalized_phonenumber('+81 90-1234-5678') == '090-1234-5678');
assert(canonicalized_phonenumber('+81 090-1234-5678') == '090-1234-5678');
assert(canonicalized_phonenumber('+81 (90) 1234 5678') == '090-1234-5678');
assert(canonicalized_phonenumber('0120 (123) 4567') == '0120-123-4567');
assert(canonicalized_phonenumber('  0120-123    4567 ') == '0120-123-4567');

いじょ。

(Symfony2/Doctrine2) Entity の OneToMany のアノテーションを自動生成させる

Symfony2 で、Entity の OneToMany アノテーションを自動生成させる方法についてです。Doctrineのライブラリに手を加えて実現します。
対象バージョンは Doctrine2.3.2。
じゃーいくよ。

まず基本から。Symfony2 での Entity の生成の仕方はこう。

  1. DBのスキーマ情報をXMLに落とす(このXML作っても無視されるみたいだけど…)

    app/console doctrine:mapping:convert xml src/Monmon/HmmBundle/Resources/config/doctrine/metadata/orm --from-database --force

  2. Entity を生成。DBのカラムに対応するフィールドのみ。

    app/console doctrine:mapping:import MonmonHmmBundle annotation

  3. Entity の中身のフィールドだけ見て、getter / setter をセットする。

    app/console doctrine:generate:entities MonmonHmmBundle

外部キーが張ってあるテーブルについては、参照先のエンティティを指すフィールドも作ってくれます。 ただし FROM テーブルから TO テーブルの参照(ManyToOne)だけ。逆の参照(OneToMany)はなし。

例。テーブル構造がこんなだとすると、

CREATE TABLE `category` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `name` varchar(128) NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

CREATE TABLE `product` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `category_id` int(11) NOT NULL,
  `name` varchar(128) NOT NULL,
  `price` int(11) NOT NULL,
  PRIMARY KEY (`id`),
  KEY `category_id` (`category_id`),
  CONSTRAINT `product_ibfk_1` FOREIGN KEY (`category_id`) REFERENCES `category` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

Entityはこんな感じ。Product の方に $category フィールドが出来てるのに注目。

// src/Monmon/HmmBundle/Entity/Category.php
/**
 * Category
 *
 * @ORM\Table(name="category")
 * @ORM\Entity
 */
class Category
{
    /**
     * @var integer
     *
     * @ORM\Column(name="id", type="integer", nullable=false)
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="IDENTITY")
     */
    private $id;

    /**
     * @var string
     *
     * @ORM\Column(name="name", type="string", length=128, nullable=false)
     */
    private $name;

    // 以下略
}

// src/Monmon/HmmBundle/Entity/Product.php

/**
 * Product
 *
 * @ORM\Table(name="product")
 * @ORM\Entity
 */
class Product
{
    /**
     * @var integer
     *
     * @ORM\Column(name="id", type="integer", nullable=false)
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="IDENTITY")
     */
    private $id;

    /**
     * @var string
     *
     * @ORM\Column(name="name", type="string", length=128, nullable=false)
     */
    private $name;

    /**
     * @var integer
     *
     * @ORM\Column(name="price", type="integer", nullable=false)
     */
    private $price;

    /**
     * @var \Category
     *
     * @ORM\ManyToOne(targetEntity="Category")        // ←「ManyToOne」アノテーションで表現
     * @ORM\JoinColumns({
     *   @ORM\JoinColumn(name="category_id", referencedColumnName="id")
     * })
     */
    private $category;                  // ←Category Entity への参照ができてる!

    // 以下略
}

でもこれだけだと、こういうことはできるけど、

{{ product.category.name }}

これが出来ないよね。

{% for product in category.products %}
  {{ product.name }}<br>
{% endfor %}

でもこれやりたい場面ってしょっちゅうあるじゃん。

なんでこれ自動生成してくれないんだー?
$products フィールド作って ManyToOne アノテーションつけといてくれよー。
どこかにやり方はないのかー?
って探してたら、ありました。

php - Symfony2 Doctrine2 - generate One-To-Many annotation from existing database by doctrine:mapping:import - Stack Overflow

Doctrine のライブラリそのものを書き換えてしまいます。抵抗ある人は回れ右。修正するファイルは↓こちら。

vendor/doctrine/orm/lib/Doctrine/ORM/Mapping/Driver/DatabaseDriver.php

ですが、これにはちょっとしたバグがありました。
あるテーブルから1つのテーブルに対し2つ以上の外部キーを張ってると、「フィールドが重複してんぞー」ってエラー吐かれて生成出来ません。
それってどんな場合かって?たとえばこんな。

CREATE TABLE `city` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `name` varchar(128) NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

CREATE TABLE `distance` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `city1_id` int(11) NOT NULL,
  `city2_id` int(11) NOT NULL,
  `price` int(11) NOT NULL,
  PRIMARY KEY (`id`),
  KEY `city1_id` (`city1_id`),
  KEY `city2_id` (`city2_id`),
  CONSTRAINT `distance_ibfk_1` FOREIGN KEY (`city1_id`) REFERENCES `city` (`id`),
  CONSTRAINT `distance_ibfk_2` FOREIGN KEY (`city2_id`) REFERENCES `city` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

都市間の距離を表す distance テーブルに city テーブルへの参照が2つあるため、 City エンティティに同名の $distance フィールドを2つ載せようとして失敗します。

$ app/console doctrine:mapping:import MonmonHmmBundle annotation


  [Doctrine\ORM\Mapping\MappingException]
  Property "distance" in "City" was already declared, but it must be declared only once

こういう場合ってどうするのが正しいんでしょうねー?
$distances1, $distances2 って連番にする?
僕はめんどくさかったので「同じテーブルから複数外部キーが張られてる場合は OneToMany 参照は持たせない!」てことにしちゃいました。

パッチはこちら。 https://github.com/monmonmon/autogen-onetomany-annotations

symfony2 のプロジェクトトップでパッチをあててね。

$ patch -p0 < patch.txt
patching file vendor/doctrine/orm/lib/Doctrine/ORM/Mapping/Driver/DatabaseDriver.php

で、Entityを再生成してみると、
てってれー。Category に $product フィールドが追加されました。

/**
 * Category
 *
 * @ORM\Table(name="category")
 * @ORM\Entity
 */
class Category
{
    /**
     * @var integer
     *
     * @ORM\Column(name="id", type="integer", nullable=false)
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="IDENTITY")
     */
    private $id;

    /**
     * @var string
     *
     * @ORM\Column(name="name", type="string", length=128, nullable=false)
     */
    private $name;

    /**
     * @var \Doctrine\Common\Collections\Collection
     *
     * @ORM\OneToMany(targetEntity="Product", mappedBy="category")
     */
    private $product;    // ( ´ ▽ ` )ノ

    /**
     * Constructor
     */
    public function __construct()
    {
        // コンストラクタも追加されます
        $this->product = new \Doctrine\Common\Collections\ArrayCollection();
    }

    // 以下略

複数形 $products にしたいとこですけど。あとで直すかも。

じゃね。