Несколько возможностей метапрограммирования в Ruby на примере ORM «для бедных»

в 2:52, , рубрики: metaprogramming, mysql2, ruby, метки: , ,

Введение

Продолжаем тему. В данной статье задействуем несколько приемов метапрограммирования. Для наглядности напишем простую версию ORM (наподобие ActiveRecord).
Уверен, опытные разработчики Ruby не раз встречали различные приемы метапрограммирования изучая исходники gem'ов или стандартной библиотеки Ruby. ActiveRecord бесспорно использует все возможности Ruby, превращая использование сложного ORM в простой и удобный процесс.

В нашем примере реализуем простой базовый класс для всех «моделей» — очень упрощенный аналог ActiveRecord::Base, который будет предоставлять следующие возможности:
1) Новая модель добавляется наследованием от базового класса
2) Таблица именуются в базе по имени класса модели, к примеру: class Pet -> table pet; class Person -> table person (для упрощения)
3) Вставка/сохранение объекта в базе данных
4) Возможность поиска объекта по id и выборка всех объектов
5) Модель имеет атрибуты, соответствующие колонкам в таблице, а так же access-методы для данных атрибутов (для упрощения поддержка строковых и числовых значений)
6) Модель работает напрямую с адаптером mysql

Шаг 1: настройка адаптера mysql2

В качестве адаптера используется gem mysql2. Поэтому убедитесь, что данный пакет у вас установлен: gem install mysql2.
В конструктор передаем информацию для подключения к БД. Обычно эти данные хранятся в yml-конфигах, но для простоты используем значения прямо в коде. Далее пересоздаем таблицу pet в базе данных:

require 'mysql2'
client = Mysql2::Client.new(:host => "localhost", :username => "root", :password => "password", :database => "ar_sample")
results = client.query('DROP TABLE if exists pet')
results = client.query('CREATE TABLE pet (id INT(11) NOT NULL AUTO_INCREMENT PRIMARY KEY,  name CHAR(30), owner_name CHAR(20), age SMALLINT(6));')

По классике поле id используется как первичный ключ, у всех моделей, созданных на базе нашего класса. Остальные поля — можно создавать по своему желанию. В частности Pet имеет имя, возраст и имя своего хозяина.

Шаг 2: подготовительные работы (+расширение типов)

Для обращения к mysql наш класс будет формировать sql запрос и исполнять его через адаптер. При обновлении или вставке новых данных в тексте sql-запроса будут присутствовать значения атрибутов, поэтому для удобства объявим в классах String и Numeric методы to_sql, которые будут форматировать данные для вставки в sql-запрос (число вставляется как есть, строка окружается кавычками:

class String
  def to_sql; ""#{self.to_s}""; end
end

class Numeric
  def to_sql; self.to_s; end
end

Мы задействовали одну из интересных возможностей Ruby — расширение типа. Более того, это не единственный в Ruby способ сделать это, вот еще пример:

String.class_eval do
  define_method(:to_sql) { ""#{self.to_s}"" }
end

Шаг 3: основа базового класса (+подмешивание)

Код, приведенный ниже описывает открытый интерфейс базового класса. Ничего не обычного, кроме того, что методы модуля ClassMethods расширяю определение класса (точнее расширяют метакласс класса Base), после чего они доступны для использования через класс Base.find(....)

module Model
  module ClassMethods
    attr_reader :connection #подключение к бд, адаптер mysql
    # возвращает имя таблицы
    def table_name
    end

	# выборка всех объектов из БД
    def all
    end

	# поиск объекта по имени
    def find(search_id)
    end
  end
  
  # Базовый класс, расширяется модулем ClassMethods для добавления методов класса
  class Base
    extend(ClassMethods)
	# get-метод для id
    attr_reader :id

	# инициализация объекта
    def initialize()
    end

	# проверяет, является ли объект новой записью (т.е. не сохранялся в БД)
    def new_record?
    end

	# вставляет запись в таблицу, в случае, если это новый объект,
	# либо обновляет данные, в случае, если объект не новый
    def save
    end
  end
end

В данном примере использование extend, скорее изощрение, ведь можно было бы написать так:

module Model
  class Base
    class << self
      def table_name
      end
      def all
      end
      def find(search_id)
      end
    end
    attr_reader :id
    def initialize()
    end
    def new_record?
    end
    def save
    end
  end
end

Но безусловный плюс в использовании модуля и extend в том, то данный модулем возможно расширить другие класса, либо включить модуль в другой модуль или класс (здесь имеется ввиду включение include).

Шаг 4: не примечательный код без метапрограммирования

Реализуем по порядку незатейливые методы, оставив «вкусненькое» на потом.
1) Метод table_name возвращает имя таблицы в БД, реализуем очень просто — возвращаем имя класса. Данный метод используется при формировании sql-запроса.

    def table_name
      self.name.downcase
    end

2) Определим приватный метод materialize, который будет создавать объект из полученного в результате запроса хеша. Он очень простой: устанав

    private
    def materialize(hash_data)
      model_instance = self.new
      model_instance.each do |k, v|
       model_instance.instance_variable_set("@#{k}", v)
      end
      model_instance
    end

3) Методы all, find делают select-запросы и возвращают «материализованные» объекты

    def all
      connection.query("select * from #{table_name}").collect {|row| materialize(row) }
    end

    def find(search_id)
      results = connection.query("select * from #{table_name} where id = #{search_id}").to_a
      results.size > 0 ? materialize(results.first) : nil
    end

Шаг 5: определение методов доступа к атрибутам объекта (define_method)

Значение каждого атрибута (поля из таблицы) храниться в одноименной внутренней переменной объекта. Для доступа к этим данным, динамически определяем access-методы. При установке значения определенного атрибута, объект так же будет фиксировать имя измененного атрибута (это понадобиться для обновления). Все это действие будет происходить в методе setup, в котором мы получаем информацию о всех колонках в таблице, и на основании этих данных создаем одноименные методы доступа:

  module ClassMethods
    def setup(mysql)
      @connection = mysql

      custom_field_names = connection.query("SHOW COLUMNS FROM #{table_name};").collect{|row| row["Field"] } - ["id"]
      custom_field_names.each do |field_name|         
        define_method(field_name) do 
          instance_variable_get("@#{field_name}")
        end
        define_method("#{field_name}=") do |new_value|
          old_value = instance_variable_get("@#{field_name}") 
          instance_variable_set("@#{field_name}", new_value)
          @changed_attributes << field_name if old_value != new_value && !@changed_attributes.include?(field_name)
        end
      end
    end
  end

Замечу, что в приведенном выше примере методы доступа создаются заранее, но есть подход, позволяющий определять их по необходимости, он основывается на использовании method_missing (метода, вызывающегося в случае, если у объекта не найден метод).

Шаг 6: определение методов экземпляра (save)

Последнее, что осталось сделать — определить метод для сохранения данных, конструктор для инициализации переменных:

  class Base
    extend(ClassMethods)
    attr_reader :id

    def initialize()
      @changed_attributes = []
    end

    def new_record?
      @id.nil?
    end

    def save
      return true if @changed_attributes.size == 0
      if (new_record?)
        self.class.connection.query("INSERT INTO #{self.class.table_name} (#{@changed_attributes.sort.join(", ")}) VALUES (#{@changed_attributes.sort.collect{|a| "#{instance_variable_get("@#{a}").to_sql}" }.join(", ")})")
        @id = self.class.connection.last_id
      else
        query = "UPDATE #{self.class.table_name} set #{@changed_attributes.sort.collect{|a| "#{a} = #{instance_variable_get("@#{a}").to_sql}"}.join(", ")} where id = #{@id};"
        r = self.class.connection.query(query)
      end
      @changed_attributes = []
    end
  end

Здесь, как видите, ничего примечательного. Это не идеальный, но вполне работающий код, ниже пример работы с ним:

class Pet < Model::Base
end

Pet.setup(client)

p = Pet.new
p.name = "Bobik"
p.owner_name = "Dmitry"
p.age = 10
p.save

pp = Pet.find(1)
pp.name = "Sharik"
pp.save

Заключение

В несколько нехитрых шагов, и чуть более 30 строк кода на Ruby мы написали не идеальный, но работающий код собственной ORM. Одной из главных причин его простоты, компактности и «своеобразного изящества» безусловно является использование техники метопрограммирования, которая является очень сильной стороной Ruby.
Пример носит показательный характер и не предлагается к рассмотрению как рабочая библиотека (ввиду многих недоработок).

Полный исходный текст статьи вы можете посмотреть по этой ссылке: gist.github.com/dsalahutdinov/5dabd8a45992207b0c53

Автор: dsalahutdinov

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js