WebテストでActiveRecordを使いたい!

これは CAMPHOR- Advent Calendar 2020 (https://advent.camph.net/) の13日目の記事です

背景

インターンWebテストを受けていると、たまに ActiveRecord っぽいものが欲しいなーと思うことがあります。

「あなたは〇〇のシステムを作っています」系の問題で、実際の構成ならデータベースを使う部分です。

大体の場合は保存したいデータのクラスを定義して、配列や連想配列をデータベースの代わりに使うことになると思います。

しかし時間が限られている中で上記を実装していると

  • 時間がかかる
  • コードが読みづらくなる
  • バグを生む

ということが発生しやすいです。

そこであらかじめ仕組みを作っておいて、一瞬で かつ きれいなコードで 上記を実現したいと思いました。

タイトルに ActiveRecord とありますが、Ruby を使う人に限らず有効ではないかと考えています。

私は普段 Ruby on Rails を使用しているので、今回は ActiveRecord と似た記述で簡潔にかけることを目指しました。

具体例

まず簡単な例を考えてみます。

たとえば会計システムでポイントカードを扱いたいとします。ポイントカードは

プロパティ 意味
id ID '000001'
point ポイント合計 100
grade グレード(0~3) 1

のような情報を持っているとします。

その場合まずは

class PointCard
  attr_accessor :point, :grade
  def initialize(id, point, grade)
    @id = id
    @point = point
    @grade = grade
  end
end

のようなクラスを作るのではないでしょうか。

そして作成したインスタンスを保存するために

class PointCard
  @@db = {}
  attr_accessor :point, :grade
  def initialize(id, point, grade)
    @id = id
    @point = point
    @grade = grade
    @@db[id] = self # new でできた自分自身をクラス変数に保存
  end
  def self.db # PointCard.db で @@db を取り出せるようにする
    @@db
  end
end

のようにするかもしれません。

ここまで来ると

PointCard.new('000001', 0, 0) # インスタンス作成
PointCard.new('000002', 0, 0) # インスタンス作成
point_card = PointCard.db['000001'] # id が '000001' のものを取得
point_card.point += 100 # ポイント加算

のようなこともできますし、たとえばポイント付与率が grade * 0.01 だとすると

class PointCard
  ...
  def rate
    grade * 0.01
  end
end

のようなものを定義することで、1000円のものを買った際のポイント加算は

point_card.point += 1000 * point_card.rate

と書けるようになります。

ただし限られた時間の中で

「長文を読む」-> 「要件を正しく把握する」-> 「上記を実装」-> 「上記を利用して、処理全体を完成させる」

という流れを完璧にこなすのは大変で、クラス定義でミスをするとそのまま時間切れになることもあります。また「標準入力」からの値の受け渡しなどもでてきて時間を食うことがあります。

本編

そこで以下のようなコードを作成しました

class MemoRecord
  @@count = 0
  @@db = []
  def initialize(hash)
    hash.merge!({ :id => @@count += 1}) if hash[:id].nil?
    hash.each do |key, value|
      eval("@#{key} = value")
      eval("MemoRecord.attr_accessor :#{key}")
    end
  end
  def save
    @@db << self if MemoRecord.find(self.id).nil?
    true
  end
  def self.create(hash)
    (@@db << MemoRecord.new(hash))[-1]
  end
  def update(hash)
    hash.each { |key, value| eval("@#{key} = value")}
    self
  end
  def destroy
    @@db.delete_if { |obj| obj == self }
  end
  def self.find(id)
    @@db.find { |obj| obj.id == id }
  end
  def self.find_by(hash)
    hash.each do |key, value|
      return @@db.find { |obj| obj.send(key) == value }
    end
  end
  def self.where(hash)
    hash.each do |key, value|
      return @@db.find_all { |obj| obj.send(key) == value }
    end
  end
  def self.all
    @@db
  end
end

ただしこれはメンテ用で、実際に使う時には

class MemoRecord;@@c=0;@@d=[]
  def initialize(h);{id:@@c+=1}.merge(h).each{|k,v| eval("@#{k}=v;MemoRecord.attr_accessor :#{k}")};end
  def save;@@d<<self if MemoRecord.find(self.id).nil?;true;end
  def update(h);h.each{|k,v| eval("@#{k}=v")};self;end
  def destroy;@@d.delete_if{|o| o==self};end;def self.find(i);@@d.find{|o| o.id==i};end
  def self.create(h);(@@d<<MemoRecord.new(h))[-1];end;def self.all;@@d;end
  def self.find_by(h);h.each{|k,v| return @@d.find{|o| o.send(k)==v}};end
  def self.where(h);h.each{|k,v| return @@d.find_all{|o| o.send(k)==v}};end
end

くらいまで圧縮しておきます。

上記の9行をペタッと貼り付けます。そして

class PointCard < MemoRecord
end

とすることで以下のようなことができるようになります。

作成

point_card = PointCard.new(id: '000001', point: 0, grade: 0)
point_card.save

(idを指定しない場合は、作成した順番に自動的にidが付きます)

あるいは

point_card = PointCard.create(id: '000001', point: 0, grade: 0)

で作成できます

更新

point_card.update(point: 100)
point_card.update(point: 100, grade: 1)

削除

point_card.destroy

検索

idで検索

PointCard.find(id)

プロパティで検索(一致する最初の一件)

PointCard.find_by(grade: 3)

プロパティで検索(一致するすべてのデータ)

PointCard.where(grade: 3)

全件取得

PointCard.all

のようなことができます

先ほどのコードで「条件による絞り込み」や「プロパティの同時更新」などをするのは手間がかかります。

もちろんメソッドを探せば書けますが、「時間がかかる」ことや「コードがみづらい」ことを考えると

PointCard.where(...)

のようにさっと書けるほうが良さそうだと考えました。

※ 上記の実装は未完成で、まだまだ改善の余地があります。

またWebテスト上でエラー処理を書きたくないので、find(id)で合致するidがなくてもnilを返したり、1度saveをしていると2回目以降はsaveしなくて良い(@@dbに参照渡しされているので。その代わりsaveせずに捨てるというのは初回saveまでしかできない。)など、独自のルールを作っています。

あと最大の問題が残っていまして、MemoRecordを継承すると @@db も渡ってしまうので、2つ以上クラスを使いたいときは MemoRecord のコードをコピペして MemoRecord2 のようにする必要があるということです。

それでもコードがわかっていれば(ルールに従えば)簡潔に書けるので、カチッとハマる問題がでたら使えそうだと思っています。

まとめ

今回実装していて、以下のようなことを思いました。

  • 競プロではライブラリを使うことが当たり前なのに、要件を満たすコードを書く系の問題ではそういう文化が無いと感じた
  • 「なんちゃってライブラリを作る」「解答のコードのスペースを取らないように限界まで短く書く(コードゴルフ)」というのは勉強になるし、面白い

とりあえず「ActiveRecord」を知っていたら、完全に感覚で使えるところまで頑張りたいと思います。 役に立つか/立たないかは置いといて、作っていて面白かったので、みなさんもぜひ自分の好きな言語でやってみてください!それでは!

リポジトリ:

github.com