WebDesign Dackel

CakePHPのHashクラスをJavaScriptに移植した「cake-hash.js」を作ってみた

CakePHPのHashクラスをJavaScriptに移植した「cake-hash.js」を作ってみた

Hatena0
Google+0
Pocket0
Feedly0

最近フロントエンドが楽しくて、なかなかPHPを書いていませんでした。
ふと、以前にCakePHPで作ったWebアプリを見なおしてみると、Hashクラス便利だったなぁ〜と思いました。

どんな感じの実装になってるのか、ソースを読んでみるとJavaScriptにも簡単に持ってこれそうだったので、全てのコードをES6で書いて移植してみました。

IE9以上、その他はモダンブラウザ、Node上で動作します。IE8以下は無かったこととします。

概要

そもそもCakePHPHashクラスをご存知無い方のために。

CakePHPでは、モデルから取得するデータ、その他設定データなどが連想配列で構造化されています。そうすると、どんどんデータが複雑な構造になりがちです。

そして、そんな複雑化したデータを簡単に管理する為に、Hashクラスが活躍しています。
(上記は2.x系での知識で、3.x系からはエンティティが出来たので多少楽になったかもです…!)

ちなみに、最近のフロントエンドでも同じで、どんどんデータ構造が複雑化してきているような気がします。


移植版の「cake-hash.js」でも、本家とほとんど同じ使い方ができます。
以下は簡単なサンプルです。

import CakeHash from "cake-hash"

let users = [
  {id: 1, name: "mark"},
  {id: 2, name: "jane"},
  {id: 3, name: "sally"},
  {id: 4, name: "jose"}
];

let result = CakeHash.extract(users, "{n}.id");
console.log(result); // [1, 2, 3, 4]

{n}の様な特殊なパス構文もそのまま使用できます。

パス構文について

上記で少し触れましたが、柔軟なパスの指定が可能となっています。
以下の構文は各メソッドで使用できます。

式の種類

以下の様な式を使用できます。

  • {n} – 数値キーを意味します。
  • {s} – 文字列キーを意味します。
  • Foo – 完全に同じ値だった場合のみ一致します。

属性値での絞り込み

式に加えて、属性値での絞り込みが行えます。

  • [id] – 記述されたキーと一致する要素に絞り込みます。
  • [id=2]id2となっている要素に絞り込みます。
  • [id!=2]id2ではない要素に絞り込みます。
  • [id>2]id2より大きい要素に絞り込みます。
  • [id>=2]id2以上の要素に絞り込みます。
  • [id<2]id2より小さい値に絞り込みます。
  • [id<=2]id2以下の要素に絞り込みます。
  • [text=/.../] – 正規表現...とマッチする値をもった要素に絞り込みます。

参考:Hash — CakePHP Cookbook 2.x ドキュメント

セパレータ文字のエスケープについて

本家CakePHPHashクラスと同様に、cake-hash.jsでも配列やオブジェクトを掘っていくのにドットシンタックスを使用します。

キーの中にドットが含まれている場合でも、ガンガン掘り進められるようにするためには、ドットをバックスラッシュ(\)でエスケープします。

const data = {
  "index.html": {
    css: {
      "style.css": "* {box-sizing: border-box}"
    }
  }
};

let result = CakeHash.get(data, "index\\.html.css.style\\.css");
console.log(result); // * {box-sizing: border-box}

この点は本家に無いオリジナルな挙動になっています。

インストール

npmからのインストールに対応しています。

$ npm install cake-hash

リポジトリから直接ファイルを持ってきて、<script>で読み込んでも使えます。

<script src="./cake-hash.min.js"></script>

リポジトリは以下です。

tsuyoshiwada/cake-hash

使い方

JavaScriptでは、既にUnderscore.jslodashの様な強力なライブラリがあるので、そちらで代替できるものは移植していません。

サポートしているメソッドの一覧です。

  • get
  • extract
  • insert
  • remove
  • combine
  • check
  • flatten
  • expand
  • map
  • reduce

以下、メソッド毎の使い方をちょいちょい省きながらご紹介です!
READMEにも書いていますが念のため。

get(data, path, [defaultValue = null])

data : array | object
path : string
defaultValue : mixed
return : mixed

get()は後述のextract()のシンプル版です。{n}{s}の様なマッチャをサポートしませんが、その分早く要素へアクセスできます。

let users = [
  {id: 1, name: "mark"},
  {id: 2, name: "jane"},
  {id: 3, name: "sally"},
  {id: 4, name: "jose"}
];

let result = CakeHash.get(users, "2.name");
console.log(result); // "sally"

result = CakeHash.get(users, "hoge.fuga", "default!!");
console.log(result); // default!!

extract(data, path)

data : array | object
path : string
return: mixed

extract()は全てのパス構文とマッチャをサポートします。複雑なデータの取得に有用です!サンプルは最初に書いたものと同じものです。

let users = [
  {id: 1, name: "mark"},
  {id: 2, name: "jane"},
  {id: 3, name: "sally"},
  {id: 4, name: "jose"}
];

let result = CakeHash.extract(users, "{n}.id");
console.log(result); // [1, 2, 3, 4]

insert(data, path, [value = null])

data : array | object
path : string
value : mixed
return: mixed

datapathの定義に沿って配列(又はオブジェクト)に挿入します。

let data = {
  pages: {name: "page"}
};

let result = CakeHash.insert(data, "files", {name: "file"});
console.log(result);
/*
{
  pages: {name: "page"},
  files: {name: "file"}
}
*/

{n}{s}などののパス構文を使用することで、複数の箇所に値を挿入できます。

users = CakeHash.insert(users, "{n}.new", "value");

以下のようにして、属性値を使った絞り込みも可能です。

let data = [
  {up: true, item: {id: 1, title: "first"}},
  {item: {id: 2, title: "second"}},
  {item: {id: 3, title: "third"}},
  {up: true, item: {id: 4, title: "fourth"}},
  {item: {id: 5, title: "fifth"}}
];

let result = CakeHash.insert(data, "{n}[up].item[id=4].new", 9);
console.log(result);
/*
[
  {up: true, item: {id: 1, title: "first"}},
  {item: {id: 2, title: "second"}},
  {item: {id: 3, title: "third"}},
  {up: true, item: {id: 4, title: "fourth", new: 9}},
  {item: {id: 5, title: "fifth"}}
]
*/

remove(data, path)

data : array | object
path : string
return: mixed

配列、またはオブジェクトの中から、pathに一致する要素を削除します。

let data = {
  pages: {name: "page"},
  files: {name: "file"}
};

let result = CakeHash.remove(data, "files");
console.log(result);
/*
{
  pages: {name: "page"}
}
*/

パス構文やマッチャの指定も可能です。

let data = [
  {clear: true, item: {id: 1, title: "first"}},
  {item: {id: 2, title: "second"}},
  {item: {id: 3, title: "third"}},
  {clear: true, item: {id: 4, title: "fourth"}},
  {item: {id: 5, title: "fifth"}}
];

let result = CakeHash.remove(data, "{n}[clear].item[id=4]");
console.log(result);
/*
[
  {clear: true, item: {id: 1, title: "first"}},
  {item: {id: 2, title: "second"}},
  {item: {id: 3, title: "third"}},
  {clear: true},
  {item: {id: 5, title: "fifth"}}
]
*/

combine(data, keyPath, [valuePath = null, groupPath = null])

data : array | object
keyPath : string
valuePath : string
groupPath : string
return: array | object

keyPathのパスをキー、valuePathのパスを値として使い、配列、又はオブジェクトを作ります。
groupPathが指定された場合は、そのパスに従って生成したものをグループ化します。

本家のHashクラスでは、値のフォーマットができますが、cake-hash.jsでは未対応です。

let data = [
  {
    user: {
      id: 2,
      group_id: 1,
      data: {
        user: "mariano.iglesias",
        name: "Mariano Iglesias"
      }
    }
  },
  {
    user: {
      id: 14,
      group_id: 2,
      data: {
        user: "phpnut",
        name: "Larry E. Masters"
      }
    }
  }
];

result = CakeHash.combine(data, "{n}.user.id", "{n}.user.data.name");
console.log(result);
/*
[2: "Mariano Iglesias", 14: "Larry E. Masters"]
*/

result = CakeHash.combine(data, "{n}.user.id", "{n}.user.data.name", "{n}.user.group_id");
console.log(result);
/*
[
  1: {
    2: "Mariano Iglesias"
  },
  2: {
    14: "Larry E. Masters"
  }
]
*/

check(data, path)

data : array | object
path : string
return: boolean

指定したパスがセットされているかチェックします。

let data = {
  "My Index 1": {
    first: {
      second: {
        third: {
          fourth: "Heavy. Nesting."
        }
      }
    }
  }
};

result = CakeHash.check(data, "My Index 1.first.second");
console.log(result); // true

result = CakeHash.check(data, "My Index 1.first.second.third");
console.log(result); // true

result = CakeHash.check(data, "My Index 1.first.second.third.fourth");
console.log(result); // true

result = CakeHash.check(data, "My Index 1.first.seconds.third.fourth");
console.log(result); // false
// 分かりづらいですが、"seconds"にしています!

flatten(data, separator = “.”)

data : array | object
separator : string
return: array | object

多次元配列、オブジェクトを一次元なフラットな構造にします。

let data = [
  {
    post: {id: 1, title: "First Post"},
    author: {id: 1, user: "Kyle"}
  },
  {
    post: {id: 2, title: "Second Post"},
    author: {id: 3, user: "Crystal"}
  }
];

let result = CakeHash.flatten(data);
console.log(result);
/*
{
  "0.post.id"    : 1,
  "0.post.title" : "First Post",
  "0.author.id"  : 1,
  "0.author.user": "Kyle",
  "1.post.id"    : 2,
  "1.post.title" : "Second Post",
  "1.author.id"  : 3,
  "1.author.user": "Crystal"
}
*/

expand(data, separator = “.”)

data : array | object
separator : string
return: array | object

flatten()でフラットにした構造を展開します。

let data = {
  "0.post.id"    : 1,
  "0.post.title" : "First Post",
  "0.author.id"  : 1,
  "0.author.user": "Kyle",
  "1.post.id"    : 2,
  "1.post.title" : "Second Post",
  "1.author.id"  : 3,
  "1.author.user": "Crystal"
};

let result = CakeHash.expand(data);
console.log(result);
/*
[
  {
    post: {id: 1, title: "First Post"},
    author: {id: 1, user: "Kyle"}
  },
  {
    post: {id: 2, title: "Second Post"},
    author: {id: 3, user: "Crystal"}
  }
]
*/

map(data, path, callback)

data : array | object
path : string
callback : function
return: array | object

指定したパスで返ってくる要素に対して、コールバックを適用して新しい配列を作ります。

let data = [
  {user: {id: 1, name: "Adam"}},
  {user: {id: 2, name: "Clyde"}},
  {user: {id: 3, name: "Cyril"}},
  {user: {id: 4, name: "Thomas"}},
  {user: {id: 5, name: "William"}}
];

let result = CakeHash.map(data, "{n}.user.id", (id) => id * 2);
console.log(result); // [2, 4, 6, 8, 10]

reduce(data, path, callback)

data : array | object
path : string
callback : function
return: array | object

指定したパスで返ってくる要素で、隣り合う2つの要素に対して同時にコールバックを適用して、単一の値にします。

let data = [
  {user: {id: 1, name: "Adam"}},
  {user: {id: 2, name: "Clyde"}},
  {user: {id: 3, name: "Cyril"}},
  {user: {id: 4, name: "Thomas"}},
  {user: {id: 5, name: "William"}}
];

let result = CakeHash.reduce(data, "{n}.user.id", (one, two) => one + two);
console.log(result); // 15

おわりに

あまり本筋には関係ありませんが、初めてちゃんとテストを書いて、レッドな状態から実装して、グリーンに持っていくみたいなTDDな感じで開発を進められました!

power-assertさまさまでした。

また、ES6で書いてブラウザ、Node両対応にするためにrollupを使ってみました。
browserifyでバンドルしたファイルに比べて、すごいクリーンなコードが吐かれて感動しました。
こちらの使い方は今度記事に書いてみようと思います。


バグや機能についてのツッコミなどありましたら教えて下さいませ。。