DataMapperとMySQLの組み合わせでDDLにコメントを含める

dm-core-1.2.0の話です。

DDLにコメントを含めたい!という要望があったので。

モンキーパッチ

# dm-core-1.2.0/lib/dm-core/property.rb
# commentを定義可能にする
module DataMapper
  class Property
    accept_options :comment
    attr_reader :comment
  end
end

# dm-migrations-1.2.0/lib/dm-migrations/adapters/dm-mysql-adapter.rb
# dm-mysql-adapter-1.2.0/lib/dm-mysql-adapter/adapter.rb
# create_table、propertyの定義部分にコメントを差し込む
# auto_migration、migrationのための対応
module DataMapper
  module Adapters
    class MysqlAdapter < DataObjectsAdapter
      module CommentExtensions
        def create_table_statement(connection, model, properties)
          if model.respond_to?(:table_comment)
            "#{super} COMMENT = '#{model.table_comment}'"
          else
            super
          end
        end
        def property_schema_hash(property)
          schema = super
          schema[:comment] = property.options[:comment]
          schema
        end
        def property_schema_statement(connection, schema)
          statement = super
          statement << " COMMENT '#{schema[:comment]}'" if schema[:comment]
          statement
        end
      end

      include CommentExtensions
    end
  end
end

# dm-migrations-1.2.0/lib/dm-migrations/sql/mysql.rb
# create_table、propertyの定義部分にコメントを差し込む
# migrationのための対応
module SQL
  module Mysql
    def table_options(opts)
      opt_engine    = opts[:storage_engine] || storage_engine
      opt_char_set  = opts[:character_set] || character_set
      opt_collation = opts[:collation] || collation
      opt_comment = opts[:comment] || ''

      " ENGINE = #{opt_engine} CHARACTER SET #{opt_char_set} COLLATE #{opt_collation} COMMENT='#{opt_comment}'"
    end

    def property_schema_statement(connection, schema)
      if supports_serial? && schema[:serial]
        statement = "#{schema[:quote_column_name]} SERIAL PRIMARY KEY"
        statement << " COMMENT '#{schema[:comment]}'" if schema[:comment]
        statement
      else
        super
      end
    end
  end
end

auto_migrateの場合

auto_migrateの場合、モデルに次のような定義行う。propertyの部分にコードとしてコメントがあるのはわかりやすい。テーブル名の定義はちょっとださい。

# -*- coding: utf-8 -*-
class User
  include DataMapper::Resource

  def self.table_comment; 'ユーザー'; end
  property :id, Serial, comment: 'ユーザーID'
  property :name, String, comment: 'ユーザー名'
end

# CREATE TABLE `users` (
#   `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT 'ユーザーID',
#   `name` varchar(50) DEFAULT NULL COMMENT 'ユーザー名',
#   PRIMARY KEY (`id`),
#   UNIQUE KEY `id` (`id`)
# ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='ユーザー'

migrationの場合

db/migrate以下にマイグレーションスクリプトを書く場合は、次のように定義する。

# -*- coding: utf-8 -*-
migration 1, :create_users do

  up do
    create_table :users, comment: 'ユーザー' do
      column :id, Integer, serial: true, comment: 'ユーザーID'
      column :name, String, comment: 'ユーザー名'
    end
  end

  down do
    drop_table :users
  end

end

# -*- coding: utf-8 -*-
migration 2, :modify_name_comment do

  up do
    modify_table :users do
      change_column :name, String, comment: '名前'
    end
  end

  down do
    modify_table :users do
      change_column :name, String, comment: 'ユーザー名'
    end
  end

end

# migration 1
# CREATE TABLE `users` (
#   `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT 'ユーザーID',
#   `name` varchar(50) DEFAULT NULL COMMENT 'ユーザー名',
#   PRIMARY KEY (`id`),
#   UNIQUE KEY `id` (`id`)
# ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='ユーザー'

# migration 2
# ALTER TABLE `users` MODIFY COLUMN `name` VARCHAR(50) COMMENT '名前'

ここまでやったが。。。

  • dm-migrations-1.2.0のrename_columnはmysqlでは動かない。master(リリースされていない1.3.x系)では修正されている。
  • 結局、マイグレーションスクリプトのSQLを書いてる。(実際はauto_migrateした結果をコピペ)
  • gemが細かく分割されているため、コードを追うのが大変だった。。。
  • 1.3.0系のリリースや、dm-railsのrails4対応ってあるのかなぁ。。

DataMapperでinに空の配列が渡された時になんとか空のコレクションを返す

dm-core-1.2.0の話です。

DataMapperはinの条件に配列を渡すと次のように良い感じで検索してくれる。

class User
  include DataMapper::Resource
  
  property :id, Serial
  property :name, String
end

User.all(id: [1, 2, 3])
# SELECT "id", "name"
# FROM "users"
# WHERE "id" IN (1, 2, 3)
# ORDER BY "id"

User.all(id: [])
# => [] # 不正なクエリのためDataMapperがSQLを発行せず[]を返す
User.all(id: []).query.conditions.valid?
# => false

しかし、調子に乗ると、、、

しかし、調子に乗ってDataMapper::Collection.union(|)を利用すると、次の用にクエリが壊れてしまう。

User.all(id: [1, 2])
# SELECT "id", "name"
# FROM "users"
# WHERE "id" IN (1, 2)
# ORDER BY "id"

User.all(id: []).all(name: 'hoge')
# => []
User.all(id: []).all(name: 'hoge').query.conditions.valid?
# => false

(User.all(id: [1, 2]) | User.all(id: []).all(name: 'hoge'))
# SELECT "id", "name"
# FROM "users"
# WHERE ("id" IN (1, 2) OR "name" = 'hoge')
# ORDER BY "id"
# orの条件がおかしい。

# 期待するSQLは次のはずなのに、、
# SELECT "id", "name"
# FROM "users"
# WHERE "id" IN (1, 2)
# ORDER BY "id

(User.all(id: [1, 2]) & User.all(id: []).all(name: 'hoge'))
# => []
(User.all(id: [1, 2]) & User.all(id: []).all(name: 'hoge')).query.conditions.valid?
# => false
# & だとこの不具合は発生しない。。。

ちょっとやんちゃすると壊れてしまうのは困る。

回避策

この回避方法はやっつけ仕事である。 空の配列が渡された時にまずマッチしないであろう値['NOT_EXISTS']に書き換えて回避する。

module DataMapper
  class Query
    module Conditions
      class InclusionComparison < AbstractComparison
        # inの検索のときに空の配列を渡された場合、現実的にマッチしない条件に置き換える
        def initialize(subject, value)
          @subject      = subject
          @loaded_value = typecast(value)
          if @loaded_value.is_a?(Enumerable) and @loaded_value.empty?
            @loaded_value = typecast(['NOT_EXISTS'])
          end
          @dumped_value = dump
        end
      end
    end
  end
end

(User.all(id: [1, 2]) | User.all(id: []).all(name: 'hoge'))
# SELECT "id", "name"
# FROM "users"
# WHERE ("id" IN (1, 2) OR ("id" IN ('NOT_EXISTS') AND "name" = 'hoge'))
# ORDER BY "id"

とりあえず、意図した結果が取得できるSQLになった。

まとめ

だれか、もっと良い解決方法教えてください、、、

DataMapperのクエリチェインが使えない(場合の)話

dm-core-1.2.0の話です。

DataMapperのクエリチェイン(正式名称しらない。。)は特定の条件下(DataMapper::Collection#union(|, +))で利用できない理由と、回避策を調べた。 長いです。

class User
  include DataMapper::Resource
  
  property :id, Serial
  property :name, String
  property :active, Boolean
  
  def self.active
    all(active: true)
  end

  def self.name_start_with(prefix)
    all(:name.like => "#{prefix}%")
  end
end

# DataMapperはこんなことができる(クエリチェイン)
User.active.name_start_with('a')
# SELECT "id", "name", "active"
# FROM "users"
# WHERE ("active" = 't' AND "name" LIKE 'a%')
# ORDER BY "id"

# orを使いたい場合はこんな感じ
a = (User.name_start_with('a') | User.name_start_with('b'))
# SELECT "id", "name", "active"
# FROM "users"
# WHERE ("name" LIKE 'a%' OR "name" LIKE 'b%')
# ORDER BY "id"
# likeの条件は維持される

# が、調子に乗ると、、
b = (User.name_start_with('a') | User.name_start_with('b')).active
# SELECT "id", "name", "active"
# FROM "users"
# WHERE "active" = 't'
# ORDER BY "id"
# likeの条件が落ちる!!

c = (User.name_start_with('a') | User.name_start_with('b')).all(active: true)
# SELECT "id", "name", "active"
# FROM "users"
# WHERE (("name" LIKE 'a%' OR "name" LIKE 'b%') AND "active" = 't')
# ORDER BY "id"
# likeの条件は維持される

d = (User.name_start_with('a').active | User.name_start_with('a').active)
# SELECT "id", "name", "active"
# FROM "users"
# WHERE (("name" LIKE 'a%' OR "name" LIKE 'b%') AND "active" = 't')
# ORDER BY "id"
# likeの条件は維持される

actives = User.active
e = (actives.name_start_with('a') | actives.name_start_with('b'))
# SELECT "id", "name", "active"
# FROM "users"
# WHERE (("active" = 't' AND "name" LIKE 'a%') OR
#        ("active" = 't' AND "name" LIKE 'b%'))
# ORDER BY "id"
# likeの条件は維持される

なんで?

まずactiveを呼び出す処理を確認する。 activeはUserに定義しているのでDataMapper::Collectionには存在しない。DataMapper::Collection#method_missingでModelクラスに処理を移譲している。

    def method_missing(method, *args, &block)
      relationships = self.relationships

      if model.respond_to?(method)
        delegate_to_model(method, *args, &block)
      elsif relationship = relationships[method] || relationships[DataMapper::Inflector.singularize(method.to_s).to_sym]
        delegate_to_relationship(relationship, *args)
      else
        super
      end
    end

    def delegate_to_model(method, *args, &block)
      model = self.model
      model.send(:with_scope, query) do
        model.send(method, *args, &block)
      end
    end

DataMapper::Model#with_scopeはDataMapper::Model::Scopeに定義されていて、実装は次の通り。

      def with_scope(query)
        options = if query.kind_of?(Hash)
          query
        else
          query.options
        end

        # merge the current scope with the passed in query
        with_exclusive_scope(self.query.merge(options)) { |*block_args| yield(*block_args) }
      end

with_scopeに渡されるqueryは次の結果である。

(User.name_start_with('a') | User.name_start_with('b')).query

このため、クエリチェインを利用した場合、次のquery.optionsしか利用されない。

(User.name_start_with('a') | User.name_start_with('b')).query.options

次に(a),(b),(c)のケースについてDataMapper::Collectionのqueryとquery.optionsの中身を確認してみる。

a = (User.name_start_with('a') | User.name_start_with('b')).query
# => #<DataMapper::Query
# @repository=:default
# @model=User
# @fields=[
#   #<DataMapper::Property::Serial @model=User @name=:id>,
#   #<DataMapper::Property::String @model=User @name=:name>,
#   #<DataMapper::Property::Boolean @model=User @name=:active>
# ]
# @links=[]
# @conditions=(name LIKE "a%" OR name LIKE "b%")
# @order=[
#   #<DataMapper::Query::Direction @target=#<DataMapper::Property::Serial @model=User @name=:id> @operator=:asc>
# ]
# @limit=nil
# @offset=0
# @reload=false
# @unique=false
# >
a = (User.name_start_with('a') | User.name_start_with('b')).query.options
# => {:fields=>
#  [#<DataMapper::Property::Serial @model=User @name=:id>,
#   #<DataMapper::Property::String @model=User @name=:name>,
#   #<DataMapper::Property::Boolean @model=User @name=:active>],
# :order=>
#  [#<DataMapper::Query::Direction @target=#<DataMapper::Property::Serial @model=User @name=:id> @operator=:asc>],
# :unique=>false,
# :add_reversed=>false,
# :reload=>false}

b = (User.name_start_with('a') | User.name_start_with('b')).active.query
# => #<DataMapper::Query
# @repository=:default
# @model=User
# @fields=[
#   #<DataMapper::Property::Serial @model=User @name=:id>,
#   #<DataMapper::Property::String @model=User @name=:name>,
#   #<DataMapper::Property::Boolean @model=User @name=:active>
# ]
# @links=[]
# @conditions=(active = true)
# @order=[
#   #<DataMapper::Query::Direction @target=#<DataMapper::Property::Serial @model=User @name=:id> @operator=:asc>
# ]
# @limit=nil
# @offset=0
# @reload=false
# @unique=false
# >
b = (User.name_start_with('a') | User.name_start_with('b')).active.query.options
# {:fields=>
#   [#<DataMapper::Property::Serial @model=User @name=:id>,
#    #<DataMapper::Property::String @model=User @name=:name>,
#    #<DataMapper::Property::Boolean @model=User @name=:active>],
#  :order=>
#   [#<DataMapper::Query::Direction @target=#<DataMapper::Property::Serial @model=User @name=:id> @operator=:asc>],
#  :unique=>false,
#  :add_reversed=>false,
#  :reload=>false,
#  :active=>true}

c = (User.name_start_with('a') | User.name_start_with('b')).all(active: true).query
# => #<DataMapper::Query
# @repository=:default
# @model=User
# @fields=[
#   #<DataMapper::Property::Serial @model=User @name=:id>,
#   #<DataMapper::Property::String @model=User @name=:name>,
#   #<DataMapper::Property::Boolean @model=User @name=:active>
# ]
# @links=[]
# @conditions=((name LIKE "a%" OR name LIKE "b%") AND active = true)
# @order=[
#   #<DataMapper::Query::Direction @target=#<DataMapper::Property::Serial @model=User @name=:id> @operator=:asc>
# ]
# @limit=nil
# @offset=0
# @reload=false
# @unique=false
# >
c = (User.name_start_with('a') | User.name_start_with('b')).all(active: true).query.options
# {:fields=>
#   [#<DataMapper::Property::Serial @model=User @name=:id>,
#    #<DataMapper::Property::String @model=User @name=:name>,
#    #<DataMapper::Property::Boolean @model=User @name=:active>],
#  :order=>
#   [#<DataMapper::Query::Direction @target=#<DataMapper::Property::Serial @model=User @name=:id> @operator=:asc>],
#  :unique=>false,
#  :add_reversed=>false,
#  :reload=>false,
#  :active=>true

(b)と(c)で等価なqueryになるべきだが、@conditionsの中身が異なることがわかる。 また、(a)、(b)、(c)のどのquery.optionsにもorの条件が含まれないことがわかる。

という事で、query.optionsにorの条件が含まれないため、orを含むクエリの場合、条件が壊れてしまうことがわかった。

回避策

クエリチェイン使わなければ、意図したSQLになる。

((User.name_start_with('a') | User.name_start_with('b')) & User.active)
# SELECT "id", "name", "active"
# FROM "users"
# WHERE (("name" LIKE 'a%' OR "name" LIKE 'b%') AND "active" = 't')
# ORDER BY "id"

結論

  1. 一度、DataMapper::Collection#union(|, +)を利用すると、それ以降クエリチェインは(クエリが壊れるため)利用できない。

  2. クエリチェインは利用できないがDataMapper::Collection#intersection(&)を利用することで回避できる。

# これは使えない。
(User.name_start_with('a') | User.name_start_with('b')).active

# こっちはOK
((User.name_start_with('a') | User.name_start_with('b')) & User.active)

# これもOK (allは使える)
(User.name_start_with('a') | User.name_start_with('b')).all(active: true)

# allに渡す条件次第ではこれも使える(冗長な書き方)
(User.name_start_with('a') | User.name_start_with('b')).all(User.active.query.options)

DataMapperでinner joinする

dm-core-1.2.0の話です。

DataMapperでjoinする話が全然検索に引っかからないので。メモ的に

class User
  include DataMapper::Resource

  property :id, Serial
  property :name, String

  has 1, :profile, 'UserProfile'
end

class UserProfile
  include DataMapper::Resource

  property :id, Serial
  property :address, String

  belongs_to :user
end

# 関連として定義したUser.profileを条件にわたす
# ユーザープロフィールが存在するもののみ取得
User.all(User.profile.id.not => nil)
# SELECT "users"."id", "users"."name"
# FROM "users" INNER JOIN "user_profiles" ON "users"."id" = "user_profiles"."user_id"
# WHERE NOT("user_profiles"."id" IS NULL)
# GROUP BY "users"."id", "users"."name"
# ORDER BY "users"."id"

# ユーザープロフィールで住所が東京から始まるものだけ取得
User.all(User.profile.address.like => '東京%')
# SELECT "users"."id", "users"."name"
# FROM "users" INNER JOIN "user_profiles" ON "users"."id" = "user_profiles"."user_id"
# WHERE "user_profiles"."address" LIKE '東京%'
# GROUP BY "users"."id", "users"."name"
# ORDER BY "users"."id"

# User.profileとUser.profile.addressはDataMapper::Query::Pathオブジェクト
pp User.profile.address
# #<DataMapper::Query::Path:0x007ff21d040b48
#  @model=UserProfile,
#  @property=nil,
#  @relationships=nil,
#  @repository_name=:default>

異なるテーブルをjoinする場合は問題ないが、自分自身をjoinする場合、条件の混同が発生してしまう。

class Category
  include DataMapper::Resource
  property :id,         Serial
  property :name,       String
  property :parent_id,  Integer

  belongs_to :parent_category, 'Category',
    parent_key: [:id],
    child_key: [:parent_id]
end

Category.all(Category.parent_category.name => "parent", :name => "child")
# SELECT "categories"."id", "categories"."name", "categories"."parent_id"
# FROM "categories" INNER JOIN "categories" "categories_1" ON "categories"."parent_id" = "categories_1"."id"
# WHERE ("categories"."name" = 'parent' AND "categories"."name" = 'child')
# GROUP BY "categories"."id", "categories"."name", "categories"."parent_id"
# ORDER BY "categories"."id"

# joinしたテーブルの条件とjoinされるテーブルの条件が混同されている
# WHERE ("categories"."name" = 'parent' AND "categories"."name" = 'child')

調べてみたけど、解決方法を見つけられなかった。次のようにconditionsを利用して混同を避けたりしてる。

# exists を使ってjoinの代わりとする。
Category.all(
  name: "child",
  conditions: [
    "exists (select id from categories as C where C.id = categories.id and name = ?)",
    'parent'
  ])
# SELECT "id", "name", "parent_id"
# FROM "categories"
# WHERE ("name" = 'child' AND (exists (select id from categories as C where C.id = categories.id and name = 'parent')))
# ORDER BY "id"

DBがレガシーで次のように:fieldを指定している場合、その定義を再利用すると良いかもしれない。

class Category
  property :name, String, field: 'CAT_NAME'
end

Category.properties[:name].field
# => 'CAT_NAME'

<<SQL
select #{Category.properties[:id].field}
from #{Category.storage_name} as C
where C.#{Category.properties[:id].field} = categories.#{Category.id}
  and #{Category.properties[:name].field} = ?
SQL
# select id
# from categories as C
# where C.id = categories.id
#   and CAT_NAME = ?

レガシーは辛い。

退職しました

新卒入社してから約5年間お世話になった株式会社タイムインターメディアを退職しました。 正確には今月末までですが。*1

退職にあたり、様々な方にご迷惑、ご心配ををおかけし、大変申し訳ありませんでした。

振り返って

思い出話なのでとりとめのない感じですが。

HaskellSchemeの仕事は一度も無かったです。

新卒入社して約5年、主に受託でのWebシステムの開発、保守を行ってきました。様々な技術や言語*2が使われたシステムの仕事に携わる中で、一つの技術に偏らない普遍的なことについて学ばせてもらったと思います。

タイムインターメディアはシステムインテグレータ(SIer)です。Web系との対比で語られるSIerは残念な感じがすごいですが、そういうSIerとは異なり技術が好きなエンジニアがたくさんいる会社です。 仕事の仕方やコードについて社内で相談できる先輩、後輩が何人もいたはとても良かったです。

技術偏重でプロジェクトマネジメントは、、という事もあったみたいですが、僕は先輩、後輩に恵まれたせいか、大変な事になったことはあまりなかったです*3

むしろ、初めて自分が技術のリーダーとして携わった案件は大炎上して関係各所に大迷惑をかけてしまいました。ごめんなさい。学ぶことも多い案件で、その時の経験は今の糧になっていると思います。

外から全く見えない面白い点は偶数月に開催する社内パーティー*4でしょうか。会議室で出前のピザや寿司、ビールを飲むささやかな会ですが、普段仕事ではかかわりがない人たちとの交流できる楽しい場です*5。何年もこの行事を続けているのはとても良い文化だなぁと思いました。

僕個人が会社に対して何か残した事というのはあまり無いですが、先輩、後輩の協力で次の事が実現出来たのは良かったです。

  • 新卒採用面接に一緒に働くであろう若手エンジニアが出席するようになった事
  • 最近エンジニアが話題にしているトピックを社長に教える会を5回くらい開いた事*6
  • 会社でBitbucket契約して自由に使えるようになった事

退職の理由

一番大きな理由は「受託開発以外のソフトウェア開発をしたい」です。SIerで出来ること、出来ないことがあるので。

これから

今後についてですが、実は4月はまだちょっとだけタイムインターメディアさんにいます。 その後についてはいろいろ調整中です。

*1:社内向けに送信した退職のご挨拶メールから来た人はこんにちは。

*2:と言っても、PHPRubyJavaくらいですが

*3:自分が体験していないことについては多くは語らないです。

*4:4月は花見、8月は暑気払い、12月は忘年会で、社外でやります。

*5:ピープルウェアのスパゲッティーディナーっぽいですね

*6:最初は月一開催だったが、、、引き継ぎ先募集中

TokyoMercurial#7 を開催しました。 #TokyoMercurial #mercurialjp

2013/03/23(土)にTokyoMercurial#7を開催しました。

Mercurial Wikiの適当なページの翻訳を行うと考えていたのですが、実際はrhodecode-1.6.0devのメッセージの翻訳を行なっていました。betaブランチでだいたい93%->95%になりました。ただ、次のことからまだPull Requestは出していません。

rhodecode/i18n/rhodecode.potが更新されていない問題

rhodecodeはメッセージリソースの抽出結果であるpot*1ファイルがコミットされています。このファイルは次のコマンド*2で自動生成しているファイルです。

python setup.py extract_messages

自動生成したファイルはコミットしないのが定石だろう、、と思いきや、それはプロジェクト次第らしく、Mercurial本体はコミットされていませんが、TortoiseSVNなどではpotファイルがコミットされていました*3

メッセージ変更後に頻繁に更新されているのであれば良いのですが、もう、数ヶ月更新が無いのも事実です。ということで、頻繁に更新する気がないなら、自動生成するファイルなのでバージョン管理下から外してしまえば?というPull Request*4を出しました。 (説明が適当なので、あれですが)

この結果次第ですが、しばらくほっといてから、1.6.0リリースに間に合うように翻訳結果のPull Requestを出したいと思います。

次回

次回は5月あたりの開催を考えています。Python mini Hack-a-thon (#pyhack) とブッキングしないように気をつけたいですね。

pyramidとsqlalchemyのドキュメントをDash.app用に変換してみた

sphinxベースだったらなんでもOKの様ですね。メモ的に。

Pyramid-ja

$ git clone https://github.com/pylonsproject-jp/pyramid
$ cd pyramid
$ virtualenv env
$ source env/bin/activate
$ python setup.py docs
$ pip install doc2dash
$ git submodule update --init
$ cd docs
$ make html
$ doc2dash -n Pyramid-ja -i _themes/pyramid-ja/static/pyramid.png -a -d ~/Library/Application\ Support/Dash/DocSets/Pyramid-ja _build/html

SQLAlchemy

$ hg clone http://hg.sqlalchemy.org/sqlalchemy
$ cd sqlalchemy
$ hg up rel_0_8_0
$ virtualenv env
$ source env/bin/activate
$ python setup.py install
$ pip install sphinx doc2dash
$ cd doc/build
$ pip install -r requirements.txt
$ make html
$ doc2dash -n SQLAlchemy -i sqla_arch_small.png -a -d ~/Library/Application\ Support/Dash/DocSets/SQLAlchemy output/html

成果物

参考