ぽよメモ

レガシーシステム考古学専攻

SQLAlchemyのリレーションにおけるメソッドやパラメータについてのメモ

SQLAlchemyはPythonのORMの中でも比較的よく検索にヒットするように思いますが,和訳済みドキュメント*1のバージョンが古く,リレーションの張り方についてどうも自分の中でごちゃごちゃしているなと思い少し調べてみました.

注意:
これはただの生物学徒が自分の興味本位で適当に本家ドキュメント*2とかを流し読みして書いた内容です.間違っていたらコメント等でお知らせください.

環境

  • Python==3.5.2
  • SQLAlchemy==1.1

基本的には公式ドキュメントのサンプルコードをお借りします.
またSessionクラスを作成しwith文で扱えるようにしておきます.

from sqlalchemy import Integer, ForeignKey, String, Column
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship

Base = declarative_base()

class User(Base):
    __tablename__ = 'user'
    id = Column(Integer, primary_key=True)
    name = Column(String)

    addresses = relationship("Address", backref="user")

class Address(Base):
    __tablename__ = 'address'
    id = Column(Integer, primary_key=True)
    email = Column(String)
    user_id = Column(Integer, ForeignKey('user.id'))

engine = create_engine('sqlite:///:memory:', pool_recycle=3600, echo=True)
Base.metadata.create_all(engine)
Sess = sessionmaker(bind=engine, expire_on_commit=False, autocommit=False)


class Session(object):

    def __init__(self):
        self.session = Sess()

    def __enter__(self):
        return self.session

    def __exit__(self, *exception):
        if exception[0] is not None:
            self.session.rollback()
        self.session.close()

relation()relationship()

公式のドキュメントを読む限り,違いは無いようです.relationship()についての説明がされた後,relation()については

A synonym for relationship().

とだけ書かれています.個人的にはどちらかに統一して欲しいところです(むしろなぜ同じ機能なのに異なるメソッド名で参照しているのだろう).

なお和訳済みドキュメントのAPIリファレンスにはこのrelationship()がどこにも載っていないようです.文中では出てくるのですが,このメソッド自体のリファレンスが存在しないようです(探し方が甘いだけでしたらすいません).なおrelation()については上記の引用と同じ事が書かれています*3.ふしぎ.

backrefback_populates

これについてはQiitaに記事がありましたので最初にご紹介します.

qiita.com

また本家ドキュメントでも詳しく扱っています.完全な情報についてはこちら*4をご覧ください.

これらは両方ともsqlalchemy.orm.relationship()(またはsqlalchemy.orm.relation())のパラメータです.和訳済みドキュメントが書かれたバージョン0.6.5の時点ではback_populatesについての言及がされていません.

これらの使い分けとしては,一つのテーブルを定義したとき,複数のリレーションを張るならback_populates,一つしか存在しないならbackrefを使うと良いようです.なおここでは簡単のためsqlalchemy.orm.backref()については一旦置いておきます.

backrefの場合

最初に記述したコードを使います.

# ...

class User(Base):
    # ...
    addresses = relationship("Address", backref="user")

class Address(Base):
    # ...
    user_id = Column(Integer, ForeignKey('user.id'))

ここではUserクラスのみにrelationship()を定義し,backref="user"というパラメータを与えています. Userクラスのインスタンス.addressAddressクラスのオブジェクトを追加したとき暗黙的に双方向のリレーションが張られるため,Addressクラスにはrelationship()が定義されていませんが.userで参照することができるようになります.
ただしこれはMany to One,もしくはOne to Many,もしくは One to Oneの場合しか使用できません.この場合はUser(One)とAddress(Many)の関係になっており,Userは複数のアドレスを持てますがAddressは一つのUserしか保持できず,Address.user.property.uselistFalseになっています.

from models import User, Address

user_data = {
    "name": "test_user"
}

address_data = {
    "owner": "test_user",
    "address": "test@example.com"
}


def test_backref():
    user = User()
    user.name = user_data["name"]
    address = Address()
    address.email = address_data["address"]

    print(user)
    print(address)
    print(user.addresses)
    print(address.user)

    user.addresses = [address]

    print(user)
    print(address)
    print(user.addresses)
    print(address.user)

if __name__ == "__main__":
    test_backref()

実行結果は以下(models.pyに上記モデルを記述しています.)

<models.User object at 0x1054bcb70>
<models.Address object at 0x1054bcc18>
[]
None
<models.User object at 0x1054bcb70>
<models.Address object at 0x1054bcc18>
[<models.Address object at 0x1054bcc18>]
<models.User object at 0x1054bcb70>

Process finished with exit code 0

back_populatesの場合

# ...

class User(Base):
     # ...
     addresses = relationship("Address", back_populates="user")

class Address(Base):
     # ...    
     user = relationship("User", back_populates="addresses")

ここでは両方にrelationship()とパラメータとしてback_populatesを定義することで明示的に双方向のリレーションを張っています.

例えばUserクラスのrelationship()を定義しなかったとすると,

sqlalchemy.exc.InvalidRequestError: Mapper 'Mapper|User|user' has no property 'addresses'

という例外を発生します.

また,Userクラスのback_populatesを指定しなかった場合,backrefのときに使用したテストと同じコードを走らせると

<models.User object at 0x1066e3fd0>
<models.Address object at 0x1066f40b8>
[]
None
<models.User object at 0x1066e3fd0>
<models.Address object at 0x1066f40b8>
[<models.Address object at 0x1066f40b8>]
None

となり,Addressインスタンスからは.userNoneとなりますがUser側からは.addressesが見えるという状態になります.

上記例におけるForeignKey

Addressの持つForeignKeyが何のために必要なのか最初分からないまま使っていたのですが,これはaddressテーブルのオブジェクトを取得したときに,同時にリレーションを張ったuserテーブル上のオブジェクトを参照するためにあるようで,記述しなかった場合以下の様な例外が発生し正しく実行できません.

sqlalchemy.exc.NoForeignKeysError: Could not determine join condition between parent/child tables on relationship User.addresses - there are no foreign keys linking these tables.  Ensure that referencing columns are associated with a ForeignKey or ForeignKeyConstraint, or specify a 'primaryjoin' expression.

また,特定の1つのテーブルに対して複数のrelationshipを持つ場合,複数のForeignKeyを設定することになりますが,この場合以下の様な例外が発生します.

# ...

class Customer(Base):
    __tablename__ = 'customer'
    id = Column(Integer, primary_key=True)
    name = Column(String)

    billing_address_id = Column(Integer, ForeignKey("address.id"))
    shipping_address_id = Column(Integer, ForeignKey("address.id"))

    billing_address = relationship("Address")
    shipping_address = relationship("Address")

class Address(Base):
    __tablename__ = 'address'
    id = Column(Integer, primary_key=True)
    street = Column(String)
    city = Column(String)
    state = Column(String)
    zip = Column(String)
sqlalchemy.exc.AmbiguousForeignKeysError: Could not determine join condition between parent/child tables on relationship Customer.billing_address - there are multiple foreign key paths linking the tables.  Specify the 'foreign_keys' argument, providing a list of those columns which should be counted as containing a foreign key reference to the parent table.

これはforeign_keysというパラメータで指定してやることで解決できます.

billing_address = relationship("Address", foreign_keys=[billing_address_id])
shipping_address = relationship("Address", foreign_keys=[shipping_address_id])

また,ForeignKeyを設定せず,自分でJOINの設定をする場合にはprimaryjoinsecondaryjoinというパラメータを使うことになるようです.詳しくは公式ドキュメント*5をご覧ください.

sqlalchemy.orm.backref()

このメソッドは,リレーションに対するさらに高度な設定を提供します.たとえばSQLAlchemyの特徴の一つである遅延読み出し(lazy),またカスケードの設定,self relationalなテーブルを作るときに用いるremote_sideなど,多くのパラメータが存在します.
「えっそれ,relationship()の引数でいいんじゃないの????」と思っていましたが,もしもrelationship()lazyなどを定義したとしても適用されるのはそのrelationshipを定義した側だけです.
backrefによって自動的に作成されたリレーションでは,このメソッドを用いて,「受け取る側」にもこの引数を伝える必要があります.

lazy

SQLAlchemyにおいて,リレーションを張った先のオブジェクトはデフォルトでは遅延して読み込まれます.

# ...

class User(Base):
    # ...
    addresses = relationship("Address", backref="user")

class Address(Base):
    # ...

class Session(object):
    # ...

if __name__ == "__main__":
    with Session() as s:
        user = User()
        addr = Address()
        user.addresses = [addr]
        user = session.query(User).filter_by(id=1).first() # Userはここで読み込まれる
        print(user.addresses) # Userに紐付いたAddressはここで読み込まれる

select, True

これがデフォルトのパラメータです.プロパティにアクセスされたときにSELECTを発行してデータを引っ張ってきます.selectの代わりにTrueを与えても同義です.
なお発行されるSQLについてはcreate_engineにおいてecho=Trueを与えたときに出力されるものをそのまま貼っています.

# ...
class User(Base):
    # ...
    addresses = relationship("Address", backref=backref("user", lazy="select"), lazy="select")

# ...

user = session.query(User).filter_by(id=1).first() # Userはここで読み込まれる
print(user.addresses) # Userに紐付いたAddressはここで読み込まれる

発行されるSQL

SELECT user.id AS user_id, user.name AS user_name 
FROM user 
WHERE user.id = ? LIMIT ? OFFSET ?

SELECT address.id AS address_id, address.email AS address_email, address.user_id AS address_user_id 
FROM address 
WHERE ? = address.user_id

immediate

これはselectと発行するSQLは全く同じで,読み出しのタイミングのみが異なります.具体的には,上記例で言うとUserが読み出されたタイミングでそれに紐付いたAddressが別のSELECTが発行されて読み出されます.

user = session.query(User).filter_by(id=1).first() # UserもAddressもここで読み込まれる
print(user.addresses)

発行されるSQL

SELECT user.id AS user_id, user.name AS user_name 
FROM user 
WHERE user.id = ? 
LIMIT ? OFFSET ?

SELECT address.id AS address_id, address.email AS address_email, address.user_id AS address_user_id 
FROM address 
WHERE ? = address.user_id

joined, False

JOINまたはLEFT OUTER JOINを用いて,親となる要素と同時にリレーション先も読み込まれます.Falseを与えても同義です.

user = session.query(User).filter_by(id=1).first() # UserもAddressもここで読み込まれる
print(user.addresses)

発行されるSQL

SELECT anon_1.user_id AS anon_1_user_id, anon_1.user_name AS anon_1_user_name, address_1.id AS address_1_id, address_1.email AS address_1_email, address_1.user_id AS address_1_user_id 
FROM (SELECT user.id AS user_id, user.name AS user_name 
FROM user 
WHERE user.id = ?
LIMIT ? OFFSET ?) AS anon_1 LEFT OUTER JOIN address AS address_1 ON anon_1.user_id = address_1.user_id

subquery

JOINを用いて親となる要素と同時にリレーション先も読み込まれます.

user = session.query(User).filter_by(id=1).first() # UserもAddressもここで読み込まれる
print(user.addresses)

発行されるSQL

SELECT address.id AS address_id, address.email AS address_email, address.user_id AS address_user_id, anon_1.user_id AS anon_1_user_id 
FROM (SELECT user.id AS user_id 
FROM user 
WHERE user.id = ?
LIMIT ? OFFSET ?) AS anon_1 JOIN address ON anon_1.user_id = address.user_id ORDER BY anon_1.user_id

dynamic

オブジェクトではなくクエリのみが構築され返されます.
また,One to OneやMany to Oneなリレーションではエラーが出ます.例えば以下の様に設定すると,

class User(Base):
    # ...
    addresses = relationship("Address", backref=backref("user", lazy="dynamic"), lazy="dynamic")

このようなエラーが出ます.

sqlalchemy.exc.InvalidRequestError: On relationship Address.user, 'dynamic' loaders cannot be used with many-to-one/one-to-one relationships and/or uselist=False.

正しくは以下の様に設定しなければいけません.

class User(Base):
    # ...
    addresses = relationship("Address", backref="user", lazy="dynamic")

要するに複数のリレーションを持ちうるrelationship()でしかdynamicは使用できません.

user = session.query(User).filter_by(id=1).first() # Userはここで読み込まれる
print(user.addresses) # Addressは読み込まれずクエリが返される
print(user.addresses.all()) # ここでクエリが発行され,Addressが読み込まれる

発行されるSQL

SELECT user.id AS user_id, user.name AS user_name 
FROM user 
WHERE user.id = ?
 LIMIT ? OFFSET ?

print(user.addresses)したときの出力結果

SELECT address.id AS address_id, address.email AS address_email, address.user_id AS address_user_id 
FROM address 
WHERE :param_1 = address.user_id

noload, None

データを追加した時点では(そのSession内では)読み取りが可能ですが,それをデータベースにcommitして再び別Sessionで取り出すと,そのリレーションを張った先のデータを取ることができなくなります.Noneを与えても同義です.

# データを追加
with Session() as s:
    user = User()
    addr = Address()
    user.addresses = [addr]
    session.add(user)
    session.commit()

# 新規でセッションを作り直して取り出す
with Session() as s:
    user = s.query(User).filter_by(id=1).first() # Userはここで読み込まれる
    print(user.addresses) # 出力結果:[]

発行されるSQL

SELECT user.id AS user_id, user.name AS user_name 
FROM user 
WHERE user.id = ?
 LIMIT ? OFFSET ?

raise, raise_on_sql

raiseまたはraise_on_sqlは1.1から追加されたパラメータで,そのリレーションに対して遅延ロードを許しません.ただし,raise_on_sqlはその遅延ロードのためにSQLを生成する必要がある場合にのみ例外を発生します(raise_on_sqlについてはその制約をくぐり抜けて値をロードする状況がわからないので誰か分かる方コメントを…).

# データを追加
with Session() as s:
    user = User()
    addr = Address()
    user.addresses = [addr]
    session.add(user)
    session.commit()

# 新規でセッションを作り直して取り出す
with Session() as s:
    user = s.query(User).filter_by(id=1).first() # Userはここで読み込まれる
    print(user.addresses) # .addressesにアクセスすると例外を送出

このとき例外を送出します.

sqlalchemy.exc.InvalidRequestError: 'User.addresses' is not available due to lazy='raise'

cascade

例えば親と子の関係があるオブジェクトで親に含まれる子のオブジェクトを削除したとき,子のテーブルからもその要素を消したいときなどがあります.デフォルトではcascade="save-update, merge"になっており,以下の様な挙動をします.

# テーブルの定義
from sqlalchemy import Integer, ForeignKey, String, Column
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship

Base = declarative_base()

class User(Base):
    # ...

class Address(Base):
    # ...

class Session(object):
    # ...

if __name__ == "__main__":
    with Session() as s:
        user = User()
        addr = Address()
        user.addresses = [addr]
        s.add(user)
        s.commit()
    with Session() as s:
        u = s.query(User).get(1)
        s.delete(u)
        users = s.query(User).all()
        addrs = s.query(Address).all()
        print(len(users))
        print(len(addrs))
        print([addr.user_id for addr in addrs])

実行結果

0 # Userは削除したため0
1 # 紐付いていたAddressは消えない
[None] # 紐付いていたAddressのuser_idはNoneになる

save-update

これはSession.add()したとき,リレーションを貼った先も同時にaddされることを示しています.これにより,一度のaddで関連するオブジェクトを全て登録することができます.

with Session() as session:
    user = User()
    addr1, addr2 = Address(), Address()
    user.addresses = [addr1, addr2]
    session.add(user)
    print(addr1 in session) # True
    print(addr2 in session) # True

この動作を無効化するにはrelationship()cascade_backref=Falseを設定します.

delete

このパラメータが設定されたとき,いわゆる「親」が削除されるときそれに紐付いた「子」も同時に削除されます.

class User(Base):
    # ...
    addresses = relationship("Address", backref="user", cascade="save-update, merge, delete")

# ...

if __name__ == "__main__":
    with Session() as s:
        user = User()
        addr = Address()
        user.addresses = [addr]
        s.add(user)
        s.commit()
    with Session() as s:
        u = s.query(User).get(1)
        s.delete(u)
        users = s.query(User).all()
        addrs = s.query(Address).all()
        print(len(users))
        print(len(addrs))

実行結果

0 # Userは削除したため0
0 # 紐付いたAddressも消える

またMany to Manyなリレーションにおいては,リレーションを張った先のオブジェクトだけでなく,セカンダリーテーブル上のものもまた削除します.

delete-orphan

親からのリレーションを解除された際にその子オブジェクトを削除します.

class User(Base):
    # ...
    addresses = relationship("Address", backref="user", cascade="save-update, merge, delete-orphan")

# ...

if __name__ == "__main__":
    with Session() as s:
        user = User()
        addr = Address()
        user.addresses = [addr]
        s.add(user)
        s.commit()
    with Session() as s:
        u = s.query(User).get(1)
        u.addresses = [] # Userのアドレスを空に
        users = s.query(User).all()
        addrs = s.query(Address).all()
        print(len(users))
        print(len(addrs))
1 # Userはそのまま
0 # リレーションが解除されたAddressは削除される

基本的にこのパラメータは子オブジェクトに一つの親を持つことしか許さないため,Many to Oneや Many to Manyなリレーションにおいては使用するべきでないとしています.もし使用する場合にはsingle_parent引数を使用するべきであるとしています*6 *7

merge

これはSession.merge()が親から子へと伝播されるべきであることを示します.merge()メソッドはSessionがオブジェクトをロードする際,元のインスタンスが持つ主キーを元にセッション内のデータと照合し,ない場合データベースへの問い合わせを行ってターゲットとなるオブジェクトを生成した後,元のインスタンスの状態をコピーする,と言ったことがリファレンスに書いてあります.和訳は適当なので参照を貼っておきます(正直に言うとよくわかっていません)*8

refresh-expire

Session.expire()によって親が期限切れにされた場合,参照されたオブジェクトにこれを伝播します.また,Session.refresh()によって期限切れに設定された後更新された場合,参照されたオブジェクトはrefreshされずexpireされるのみです.

expunge

Session.expunge()の操作によって親がセッションから削除されたとき,参照されたオブジェクトにこれを伝播します.

class User(Base):
    # ...
    addresses = relationship("Address", backref="user", cascade="save-update, merge, expunge")

# ...

if __name__ == "__main__":
    with Session() as s:
        user = User()
        addr = Address()
        user.addresses = [addr]
        s.add(user)
        print(user in s)
        print(addr in s)
        s.expunge(user) # Sessionから取り除く
        print(user in s)
        print(addr in s)
True # addしたのでTrue
True # save-updateによってTrue
False # expunge()によって除かれた
False # cascadeに従って除かれた

基本的なリレーションパターン

だいぶ長くなってしまったので,ここはもう他のサイト様にお任せします.この方のブログ記事にはかなりお世話になっています(ありがとうございます).

Python の O/Rマッパー SQLAlchemy を使ったリレーショナルマッピング基本 4... | CUBE SUGAR STORAGE

まとめ

高機能すぎて初心者にはなかなか理解できない部分もあり,かなり難しく感じますが,簡単なテーブルであれば非常に手軽に定義できるので便利に使わせてもらっています.もうちょっと使いこなせるようになりたいなぁ…

falconでAPIのテストを書く

falconとはpythonの軽量webAPIフレームワークで,手軽にちゃちゃっとパフォーマンスの良い(らしい)APIを書けます.手軽さで言うと正直Flaskでもそんな変わらないのではと今となっては思いますが,公式曰くFlaskやBottleと比べて高速に動作するそうです.

falconframework.org

環境

  • python==3.5.2
  • falcon==1.1.0
  • gunicorn==19.4.5

基本的にPyCharmで書いています.

APIを書く

テスト駆動開発という観点から見ればまずテストから書くべきなのですが,今回は都合上先にプログラムを書いていきます.
詳しいリファレンスは本家参照*1

ライブラリのインストール

$ pip install falcon gunicorn

gunicornは書いたAPIをwebから叩くために用います.

Hello Worldする

# sample.py

import falcon
import json


def create_json_resp(d: dict) -> str:
    return json.dumps(d, indent=2)


class SampleResource(object):
    @staticmethod
    def on_get(req, resp):
        resp.body = create_json_resp({
            "title": "hello world",
            "sentence": "This is a test program."
        })
        resp.status = falcon.HTTP_200 # デフォルトで200なので無くて良い

app = falcon.API()
app.add_route("/hello", SampleResource())

これで/helloGETするとレスポンスを返すAPIができました.動かしてみましょう.

$ gunicorn sample:app

これで127.0.0.1:8000/helloを叩くと設定したjsonが返ってくると思います.

日付を与えると曜日を返すAPI

helloだけでは寂しいので,タイトルそのままのAPIを書きます.

# sample.py

import falcon
from datetime import datetime

# 省略

class WeekResource(object):
    @staticmethod
    def on_get(req, resp, _date):
        week_data = ["月", "火", "水", "木", "金", "土", "日"]
        try:
            d = datetime.strptime(_date, "%Y-%m-%d")
            resp.body = create_json_resp({
                "input": _date,
                "week": week_data[d.weekday()]
            })
        except ValueError:
            resp.body = create_json_resp({
                "error": "ValueError",
                "detail": "invalid value",
                "input": _date
            })
            resp.status = falcon.HTTP_400

app = falcon.API()
app.add_route("/hello", SampleResource())
app.add_route("/week/{_date}", WeekResource())

これで127.0.0.1:8000/week/2016-12-25とかを叩くとレスポンスのweekにはと入っているはずです.

テストする

テストを書く

公式のリファレンスによるとunittest式の書き方と,pytest式の書き方ができるようです.今回はunittestで書いていきます.

from falcon import testing
import sample


class MyTest(testing.TestCase):
    def setUp(self):
        super(MyTest, self).setUp()
        # sample.pyからappを渡す
        self.app = sample.app


class TestMySample(MyTest):
    def test_hello(self):
        hello_resp = self.simulate_get("/hello")
        self.assertEqual(hello_resp.status_code, 200)
        self.assertEqual(hello_resp.json["title"], "hello world")

    def test_week(self):
        param = "2016-12-25"
        week_resp = self.simulate_get("/week/" + param)
        self.assertEqual(week_resp.status_code, 200)
        self.assertEqual(week_resp.json["input"], param)
        self.assertEqual(week_resp.json["week"], "日")

    def test_invalid_value(self):
        # 間違った形式のパラメータを与えて正しいエラーが返ってくるかを確認
        param = "20161225"
        week_resp = self.simulate_get("/week/" + param)
        self.assertEqual(week_resp.status_code, 400)
        self.assertEqual(week_resp.json["input"], param)
        self.assertEqual(week_resp.json["error"], "ValueError")

公式に書かれているように

class falcon.testing.TestCase(methodName='runTest')
Extends unittest to support WSGI functional testing.

なので,assertEqualなどはunittestのリファレンス*2を参照すると良いと思います.

simulate_get等のメソッドについてはfalconのリファレンスが参考になります*3

テストを実行する

僕はPyCharmで書いているので,このtest_sample.pyを走らせると自動的にunittestで走らせてくれるようです.
実行すると以下の様に表示されます.

f:id:pudding_info:20161228145140p:plain

成功する場合は何も表示されませんが,例えば曜日を計算するときのメソッドにはdatetime.weekday()datetime.isoweekday()の二種類があり,返ってくる値が異なります*4
このメソッドを間違って使用してしまったと仮定し,sample.pyの該当箇所を書き換えてもう一度テストを実行してみます.

f:id:pudding_info:20161228150556p:plain

このようにどこでfailしたのかを教えてくれます.

コンソールから叩く場合は以下の様にします.

$ python -m unittest test_sample.py

この場合も全てのテストに成功すると以下の様に

f:id:pudding_info:20161228150932p:plain

失敗するとこのようにエラーを吐いてくれます.

f:id:pudding_info:20161228151009p:plain

カバレッジも計測できますが,それは他の優良なサイトにお任せします*5

まとめ

webAPIはいちいち立ち上げて,自分でパラメータ打ち込んで,ブラウザとにらめっこして〜なんてしなくてもデバッグできますし,こうすべきでしょう.これまで手動でやってたなんてとても言えない.
テストケースはこれで十分なのかとか,むしろそっちの方が難しいので,せめてc0カバレッジくらいは…まあせいぜい「思ったように動いてくれる」ことを確かめられる,程度の気持ちでいた方が良さそうです.

Karabiner-Elements 0.90.64での複数デバイス間でのプロファイル切り替え

バージョン0.90.68現在,上手くいっていないようです.結局自分の自作スクリプトで切り替えさせています(安定)
実は前の時にも,自分ので走らせたジョブが動きっぱなしだった…?無いとは思いますが…混乱させる情報を掲載して申し訳ないです. いつのバージョンからか,デバイス間でのプロファイル切り替え機能はデフォルトで実装されるようになっていました.最高.好き.
というわけで以下の記事は不要となりました.多数のアクセスをしていただきありがとうございました.消しはしませんが,不要である旨を記載しておきます.

poyo.hatenablog.jp

ただまだ使い方がイマイチ分からないというか,切り替えが明示的に示されていない,というのがあり,ちょっと実験してみることに.

これは2016/11/8現在,karabiner-elementsの最新バージョン0.90.64での記事になります.近い将来なんの参考にもならなくなる可能性が高いです.

karabiner.jsonの構造

以下のような形になっていました.

{
    "profiles": [
        {
            "devices":[
                {
                     "disable_built_in_keyboard_if_exists": false,
                     "identifiers": {
                         "is_keyboard": true,
                         "is_pointing_device": false,
                         "product_id": 8209,
                         "vendor_id": 1241
                     },
                     "ignore": false,
                     "keyboard_type": 42
                 },
                 {
                     "disable_built_in_keyboard_if_exists": false,
                     "identifiers": {
                         "is_keyboard": true,
                         "is_pointing_device": false,
                         "product_id": 603,
                         "vendor_id": 1452
                     },
                     "ignore": true,
                     "keyboard_type": 42
                 },                 {
                     "disable_built_in_keyboard_if_exists": false,
                     "identifiers": {
                         "is_keyboard": true,
                         "is_pointing_device": false,
                         "product_id": 65535,
                         "vendor_id": 1452
                     },
                     "ignore": true,
                     "keyboard_type": 0
                 }
            ],
             "fn_function_keys": {
                 "f1": "vk_consumer_brightness_down",
                 "f10": "mute",
                 "f11": "volume_down",
                 "f12": "volume_up",
                 "f2": "vk_consumer_brightness_up",
                 "f3": "vk_mission_control",
                 "f4": "vk_launchpad",
                 "f5": "vk_consumer_illumination_down",
                 "f6": "vk_consumer_illumination_up",
                 "f7": "vk_consumer_previous",
                 "f8": "vk_consumer_play",
                 "f9": "vk_consumer_next"
             },
             "name": "Profile 1",
             "selected": true,
             "simple_modifications": {
                 "FROM": "TO"
             }
         },
         {
             "comment": "以下同様の構造でプロファイルとデバイスが格納"
         }
    ]
}

僕の場合,このようになっていましたが,元からあったkarabiner.jsonを新バージョンに書き直した形跡*1が見られたので,人によって異なるかもしれません.
要するに一つのプロファイルの中には

  • name: プロファイル名
  • device: デバイスの情報
  • selected: 有効(true)か無効(false)か
  • fn_function_keys: F1キー等の挙動(デフォルトではfn同時押し時の挙動)
  • simple_modifications: キーのマッピング

が含まれています.

プロファイル切り替え

デフォルト時

デフォルトだとKarabiner-Elementsのdevicesタブの中は以下のようになっていました.Keyboard Typeは"ISO","ANSI","JIS","default"という4択から選べるようです.また,Appleの内蔵キーボード/トラックパッド以外にもう一つ,共通のVendor IDを持つ名前不明のデバイスが検出されていますが,これがなんなのかはわかりませんでした.
一つ検出されているマウスのマークがついたデバイスは接続しているMX Masterです.(名前が不明になるのは少し不便.せめて名前つけれるとわかりやすいと思うんですが…)

f:id:pudding_info:20161108161609p:plain

バイスの接続状態ごとにプロファイルを分けているわけではないようで,たとえばこの状態で有効になっているプロファイルは以下のようになっていました.

"devices": [
    {
         "disable_built_in_keyboard_if_exists": false,
         "identifiers": {
             "is_keyboard": true,
             "is_pointing_device": false,
             "product_id": 65535,
             "vendor_id": 1452
          },
          "ignore": false,
          "keyboard_type": 0
      }
],
"fn_function_keys": {
    "f1": "vk_consumer_brightness_down",
    "f10": "mute",
    "f11": "volume_down",
    "f12": "volume_up",
    "f2": "vk_consumer_brightness_up",
    "f3": "vk_mission_control",
    "f4": "vk_launchpad",
    "f5": "vk_consumer_illumination_down",
    "f6": "vk_consumer_illumination_up",
    "f7": "vk_consumer_previous",
    "f8": "vk_consumer_play",
    "f9": "vk_consumer_next"
},
"name": "Default",
"selected": true,
"simple_modifications": {
    "caps_lock": "left_control"
}

karabiner.json上では,vendor_idproduct_idはそれぞれ10進数表記されています.上記のプロファイルのdevicesに表示されている"vendor_id": 1452"product_id": 65535は16進数に直すとそれぞれ0x05ac0xffffとなり,例の不明なApple製デバイスを示していることになります.予想ではdevicesの中に内蔵キーボード/トラックパッドも書き込まれるのではないかと思ったのですが,なぜかそうはならず…うーん…
その後色々やっていたら表示されるようになりました.おそらくトリガーとしてはデバイスのkarabiner-elements上での設定に何かしらの変更(Keyboard Typeを変えた,チェックを外した/つけた,など)をすると書き込まれるようです.

ちなみに内蔵キーボード/トラックパッドのKeyboard TypeをDefaultにしておくとUSとして認識されるのでJISの人はJISを選択する必要があります.

外部キーボード接続

外部キーボードを接続すると以下のように表示されました.Majestouch2 黒軸テンキーレスを接続しています.

f:id:pudding_info:20161108165242p:plain

接続した状態で,横のチェックマークを外すと,そのデバイスではキーの変更が反映されません(つまり,外部キーボードを接続した状態でプロファイルを設定し,Appleの内蔵キーボード/トラックパッドのチェックを外すと内蔵キーボードではデフォルトのままで扱うことが出来ます.).
このチェックマークはkarabiner.jsonでいうとdevicesの中にあるignoreにあたり,チェックマークを外すとtrueになります.

ただ外部キーボードを接続した/切断しただけではプロファイルの変更は反映されないようで,外部キーボードから何かしらの挙動があったとき,つまり文字を打とうとしたりするときに初めてこの変更は反映されるようです*2

複数プロファイル作成手順

まず,デフォルトの状態で(何も外部デバイスを接続していない状態で)プロファイルを作成します.このとき,必ず一度,内蔵キーボードの設定を触っておいてください(Keyboard Typeを変える,チェックマークを付けたり外したりする,など).
これをしておかないと,プロファイルのdevices内に内蔵キーボードが記述されず,プロファイルがやたら増えていったりすることになります*3

karabiner.jsonを確認してdevices内に現在の状態のデバイスが記述されていることを確認したら,外部キーボードを接続します.

  1. 適当なキーを押したり文字入力しようとするとマウス以外のデバイスに再度チェックが付きます.
  2. 外部キーボードのKeyboard TypeをJISに変えます(Defaultで良い人は外部キーボードのチェックを一度外し,再度付けます)
  3. 内蔵キーボードのチェックを外します.
  4. キーの設定をします

この手順ならば,karabiner.jsonの中には2つのプロファイルが作られ,外付けキーボード無しのプロファイルのdevicesには内臓キーボードが,外付けキーボードを挿した時のプロファイルのdevicesには内臓キーボードと外付けキーボードが,それぞれ登録された状態になっているはずです.
外付けキーボードを抜き差しして文字入力してみたりしてプロファイルが切り替わるのを確認してください.

複数プロファイル作成のポイント

  1. 用意するプロファイルのdevicesにそれぞれ全てのデバイスが組み込まれていること(適切にignoreが設定されていること)
  2. ごっちゃになってきたらkarabiner.jsonをバックアップし一旦削除してやり直す
  3. トライアンドエラー

ポイントもクソもない感じになってしまった…

JISキーボード上の表記について

JISキーボード上の表記と,karabiner-elements内での表記はわかりづらくなってしまっているので簡単なまとめを.これは2016/11/8現在の最新stableバージョン0.90.64での情報です.

JISキーボード上の表記 Karabiner-Elementsでの表記
Windowsキー left_command
Left Alt left_option
変換 PCキーボードの変換キー
無変換 PCキーボードの無変換キー
かな PCキーボード上のかなキー
applicationキー*4 application
半角/全角 grave_accent_and_tilde (`)

急いでいてきっちり確認していなかったので右側Altとか右側Ctrlとかはまだ確認取れていません…そのうち追記します.
また,ScreenLockとPause/Breakにはそれぞれディスプレイ輝度の上げ下げが割り振られていました(たぶん).
HomeやEnd,Delete等はそのまま動いていたように記憶しています.

これまでのバージョンでの不具合

僕の環境では

  1. karabiner-elementsの起動中,英数入力のときだけなぜか強制US配列になる
  2. マウス(MX Master)の設定を行っていたソフトウェア(Logicool Options)の設定をオーバーライドし,機能をデフォルトに戻す

という不具合が生じていました.バージョン0.90.64現在,この二つの不具合は

  1. キーボード配列を選択できる
  2. マウスは設定によりデフォルトで無視

という方法で解決しています.
過去のバージョンではどうにも不具合が起きていた人も,アップデートしてみることをオススメします.

まとめ

おおむねGUIのみで完結できるようになり,かなりできることの幅が広がりましたが,US配列のユーザーとしては過去の「Command空打ちで英数/かな切り替え」がまだ使えないため,Sierraへのアプデを断念している人も多いかと思います.早めに実装されるといいなぁ……

読みにくい記事になり申し訳ありません.また追加情報があれば更新していきたいです.間違ってたらコメントください…

*1:プロファイル名が継承されていました.しかし二種類あったプロファイルのうちsimple_modificationsの中身が継承されていたのは片方のみだったため,よくわかりません…完全に継承しているわけではないかも

*2:きっちりと計測したわけではなく,karabiner-elementsのGUIを見ている限りでは,ということです

*3:外部キーボードを接続→キーボードの設定は触らず,内蔵キーボードのチェックを外す→キーを設定→USBキーボードを抜く,とすると一つ新しいプロファイルが作られます.するとこのプロファイルのdevices内にはUSBキーボードは記述されておらず,次の接続時にまた設定が空の新しいプロファイルが作られました.

*4:Majestouchだと電卓?メモ帳?みたいなマークが付いているキー