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 '名前'
ここまでやったが。。。
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"
結論
一度、DataMapper::Collection#union(|, +)を利用すると、それ以降クエリチェインは(クエリが壊れるため)利用できない。
クエリチェインは利用できないが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
退職にあたり、様々な方にご迷惑、ご心配ををおかけし、大変申し訳ありませんでした。
振り返って
思い出話なのでとりとめのない感じですが。
新卒入社して約5年、主に受託でのWebシステムの開発、保守を行ってきました。様々な技術や言語*2が使われたシステムの仕事に携わる中で、一つの技術に偏らない普遍的なことについて学ばせてもらったと思います。
タイムインターメディアはシステムインテグレータ(SIer)です。Web系との対比で語られるSIerは残念な感じがすごいですが、そういうSIerとは異なり技術が好きなエンジニアがたくさんいる会社です。 仕事の仕方やコードについて社内で相談できる先輩、後輩が何人もいたはとても良かったです。
技術偏重でプロジェクトマネジメントは、、という事もあったみたいですが、僕は先輩、後輩に恵まれたせいか、大変な事になったことはあまりなかったです*3。
むしろ、初めて自分が技術のリーダーとして携わった案件は大炎上して関係各所に大迷惑をかけてしまいました。ごめんなさい。学ぶことも多い案件で、その時の経験は今の糧になっていると思います。
外から全く見えない面白い点は偶数月に開催する社内パーティー*4でしょうか。会議室で出前のピザや寿司、ビールを飲むささやかな会ですが、普段仕事ではかかわりがない人たちとの交流できる楽しい場です*5。何年もこの行事を続けているのはとても良い文化だなぁと思いました。
僕個人が会社に対して何か残した事というのはあまり無いですが、先輩、後輩の協力で次の事が実現出来たのは良かったです。
- 新卒採用面接に一緒に働くであろう若手エンジニアが出席するようになった事
- 最近エンジニアが話題にしているトピックを社長に教える会を5回くらい開いた事*6
- 会社でBitbucket契約して自由に使えるようになった事
退職の理由
一番大きな理由は「受託開発以外のソフトウェア開発をしたい」です。SIerで出来ること、出来ないことがあるので。
これから
今後についてですが、実は4月はまだちょっとだけタイムインターメディアさんにいます。 その後についてはいろいろ調整中です。
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