Viết SQL trong module một cách mạch lạc, gọn gàng

Viết SQL trong module một cách mạch lạc, gọn gàng

Động cơ

ActiveRecord thật là tuyệt vời đúng không? Không chỉ hầu hết mọi việc chúng ta có thể chạy mà không cần viết SQL ra mà hơn nữa chúng ta có thể sử dụng chuỗi phương thức để làm điều mình muốn một cách gọn gàng.

Mặt khác, nếu chú ý một chút khi chạy chương trình thì tỷ suất phát sinh vấn đề về performance cũng sẽ ít đi.

Tất nhiên, cũng có những ngoại lệ là trường hợp chúng ta bắt buộc phải viết SQL. Trong trường hợp đó, kể cả chúng ta viết code của Ruby có đẹp thế nào đi chăng nữa thì một ngôn ngữ hoàn toàn khác như SQL mà vào code thì khả năng đọc được sẽ giảm.

Như vậy tôi đã nghĩ rằng, trong trường hợp đó thì mình có thể làm như thế nào.

Here document

Đầu tiên, phương pháp mà tôi nghĩ đến là dùng here document. (SQL dưới chỉ là ví dụ đơn thuần thôi. Viết một SQL như thế này thì quả thật thất lễ với ActiveRecord.)

class User < ActiveRecord::Base
  class << self
    def created_on_today
      sql = <<-EOS
      SELECT * FROM users
        WHERE created_at >= '%s' AND created_at <= '%s';
      EOS
      ActiveRecord::Base.connection.select sql % [
        Time.now.beginning_of_day.utc.to_s(:db),
          Time.now.end_of_day.utc.to_s(:db)]
    end
  end
end

Cũng không tồi đúng không? Tuy nhiên, trong code của Ruby lại trộn thêm cả SQL nên rất khó để nhìn ra một cách nhanh chóng cho đến đâu là SQL. Hơn nữa, lại có sự thụt lề không cần thiết đối với SQL nên trông không đẹp lắm nữa. Suy nghĩ về hai điều này thì dưới đây thế nào?

class User < ActiveRecord::Base
  class << self
    def created_on_today
      sql = <<-EOS
SELECT * FROM users WHERE created_at >= '%s' AND created_at <= '%s';
      EOS
      ActiveRecord::Base.connection.select sql % [
        Time.now.beginning_of_day.utc.to_s(:db),
          Time.now.end_of_day.utc.to_s(:db)]
    end
  end
end

Vì cố tình gỡ bỏ sự lùi đầu dòng, rồi chỉ cần một lần thoáng mắt qua ta cũng có thể biết đâu là SQL rồi. Rồi SQL viết ra cũng không có sự lùi dòng không cần thiêt nữa.

Nhưng, tại đây chỉ một dòng chúng ta làm dài hơn nên toàn thể code cũng không được đẹp.

File ngoài

Đã vậy thì có vẻ tốt hơn nếu chúng ta cắt chỉ dòng SQL ra ngoài file Ruby. Trong trường hợp này thì sử dụng ** RailsConfig ** là nhiều. Ví dụ như dưới đây chúng ta sử dụng RailsConfig và có thể chạy chương trình như dưới đây.

sql:
  users:
    created_on_today:
      SELECT * FROM users WHERE created_at >= '%s' AND created_at <= '%s';
class User < ActiveRecord::Base
  class << self
    def created_on_today
      ActiveRecord::Base.connection.select sql.created_on_today % [
        Time.now.beginning_of_day.utc.to_s(:db),
          Time.now.end_of_day.utc.to_s(:db)]
    end
    private
    def sql
      Settings.sql.users
    end
  end
end

Như vậy chúng ta đã có thể loại SQL ra khỏi model. Hơn nữa, sau này kể cả SQL có nhiều hơn thì chúng ta cũng có thể gọi nó một cách bình thường ra trong model. Tuy nhiên, khi xác nhận sự thực thi của model thì chúng ta cũng phải xác nhận sự thực thi của một file khác nữa.

_END_ và DATA

Tại đây trong khi chạy model tôi muốn phân loại Ruby và SQL xong rồi lưu hỗn hợp cả hai thứ này. Không hay dùng lắm nên có thể mọi người (ít nhất là những người xung quanh tôi) đã quên mất chúng ta có thể dùng __END__DATA để ghi bằng ngôn ngữ ngoài Ruby trong file Ruby.

Về chi tiết có trong mục document công thức http://docs.ruby-lang.org/ja/2.1.0/method/Object/c/DATA.html, nhưng nếu xác nhận ví dụ dưới đây,

print DATA.gets # => 故人西辞黄鶴楼
print DATA.gets # => 烟花三月下揚州
print DATA.gets # => 孤帆遠影碧空尽
print DATA.gets # => 唯見長江天際流
DATA.gets       # => nil

__END__
故人西辞黄鶴楼
烟花三月下揚州
孤帆遠影碧空尽
唯見長江天際流

thì sẽ có động tác xảy ra như vậy.

Trông có vẻ sử dụng được nên tôi nhanh chóng viết thử.

class User < ActiveRecord::Base
  class << self
    def created_on_today
      sql = DATA.gets
      ActiveRecord::Base.connection.select sql % [
        Time.now.beginning_of_day.utc.to_s(:db),
          Time.now.end_of_day.utc.to_s(:db)]
    end
  end
end
__END__
SELECT * FROM users WHERE created_at >= '%s' AND created_at <= '%s';

Um, trong cùng một file thì cả Ruby và SQL đã được viết một cách gọn gàng và dễ hiểu. Như thế này thì về khả năng đọc trong code cũng không vấn đề gì.

Tuy nhiên, nếu là DATA.get thì thứ tự viết SQL bị cố định nên hơi bất tiện, vậy thì chúng ta thử sử dụng YAML cũng giống như lúc dùng RailsConfig như trên xem sao.

class User < ActiveRecord::Base
  class << self
    def created_on_today
      ActiveRecord::Base.connection.select sql["created_on_today"] % [
        Time.now.beginning_of_day.utc.to_s(:db),
          Time.now.end_of_day.utc.to_s(:db)]
    end
    private
    def sql
      YAML.load(DATA)
    end
  end
end
__END__
created_on_today:
  SELECT * FROM users WHERE created_at >= '%s' AND created_at <= '%s';

Như thế này thì khi viết thêm SQL nhiều như thế nào thì chúng ta cũng có thể nhìn thấy và dễ hiểu đó là SQL nào đúng không?

Không chạy

Trên thực tế thì ví dụ trên sẽ không chạy.

Không phải vì DATA nằm bên dưới __END__ của file khởi động process (có nghĩa là phần trong $0) mà file đang tham chiếu DATA nằm dưới __END__ sẽ được đọc.

Tại đây chúng ta thử tác chiến dùng _FLIE_ thay cho DATA để chương trình đọc phần dưới _END_ của chương trình đang chạy.

Sử dụng __FILE__ đọc nội dung

Lấy phần lõi của file , rồi trong đó nếu có dòng chỉ có __END__ thôi thì lấy cuối của dùng đó và pass với tư cách là YAML. Kết quả sau khi pass ta thêm prefix là SQL_ rồi đăng lên như một hằng số.

class User < ActiveRecord::Base
  self_content = File.read __FILE__
  data = self_content.split(/^__END__$/).last if self_content =~ /^__END__$/
  data.present? && (YAML.load(data).each do |name, string|
    const_set "sql_#{name}".upcase, string
  end)

  class << self
    def created_on_today
      ActiveRecord::Base.connection.select SQL_CREATED_ON_TODAY % [
        Time.now.beginning_of_day.utc.to_s(:db),
          Time.now.end_of_day.utc.to_s(:db)]
    end
  end
end
__END__
created_on_today:
  SELECT * FROM users WHERE created_at >= '%s' AND created_at <= '%s';

Ta giản lược xử lý dựa theo quy định là về cơ bản thì __END__ không ghi 2 lần thì ta giản lược đi.

Thực tế, nếu ta cho chạy code này thì chúng ta có thể xác nhận được rằng nó sẽ chạy được như mong muốn. Vì SQL được định nghĩa trở thành hằng số nên khi class được load thì sau khi được định nghĩa một lần thì chúng ta có thể gọi nó ra mà không cần có overhead.

Vì chương trình đã chạy như mong muốn nên thật là tốt nếu có thể dùng cái này vào những module khác. Khi viết Ruby thì ta không thể không ý thức đến DRY. Chúng ta thử làm nó thành module thôi.

Module hóa

Tôi muốn cách định nghĩa SQL này thì ít nhất cũng thống nhất rồi sử dụng trong ứng dụng Rails.

Model User chúng ta viết SQL sau __END__ nhưng thật là không tốt chút nào khi nói rằng nó đang được viết trong Here document trong model khác.

Vì vậy nên ta cắt bộ phận định nghĩa SQL riêng ra như một module, và chia sẻ với tất cả các model khác. Nói đến sự share xử lý thì tất nhiên sẽ là sự xuất hiện của ActiveSupport::Concern.

class User < ActiveRecord::Base
  include SqlDefiner
  class << self
    def created_on_today
      ActiveRecord::Base.connection.select SQL_CREATED_ON_TODAY % [
        Time.now.beginning_of_day.utc.to_s(:db),
          Time.now.end_of_day.utc.to_s(:db)]
    end
  end
end
__END__
created_on_today:
  SELECT * FROM users WHERE created_at >= '%s' AND created_at <= '%s';

Chúng ta cắt bộ phận xử lý muốn chia sẻ ra ngoài, rồi included. Về phần gọi ra thì chúng ta làm như sau.

module SqlDefiner
  extend ActiveSupport::Concern
  included do
    self_content = File.read __FILE__
    data = self_content.split(/^__END__$/).last if self_content =~ /^__END__$/
    data.present? && (YAML.load(data).each do |name, string|
      const_set "sql_#{name}".upcase, string
    end)
  end
end

extend ActiveSupport::Concern rồi đưa xử lý gọi ra cho included dưới dạng block. Làm như vậy thì module này sẽ chạy block class đã included.

Lại không chạy

Module này nếu ==require== từ initializers thì có vẻ sẽ chạy nhưng thực tế nó sẽ không chạy.

Vì khi chỉ __FILE__thì nếu như ví dụ trên thì ta mong muốn là app/models/user.rb nhưng nó lại thành `lib/sql_definer.rb'.

Vậy thì chỉ cần viết tất cả SQL vào lib/sql_definer.rb là được chứ gì?

Không không, như vậy những cố gắng từ trước đến nay của chúng ta sẽ trở nên vô nghĩa. Chúng ta suy nghĩ phương pháp khác.

Việc gọi method included

Block của lệnh included chắc chắn gọi module bao gồm trong class. Có nghĩa là trong block nếu có thể đặc định class đã gọi mình thì chắc chắn có thể lấy được nội dung được ghi dưới __END__ của class.

Lệnh caller

Trong block của included của lib/sql_definer.rb lúc trước, chúng ta thử gọi caller và xác nhận thứ tự gọi ra đó.

["/Users/norifumi/.rbenv/versions/2.1.5/lib/ruby/gems/2.1.0/gems/activesupport-4.1.5/lib/active_support/concern.rb:120:in `class_eval'", "/Users/norifumi/.rbenv/versions/2.1.5/lib/ruby/gems/2.1.0/gems/activesupport-4.1.5/lib/active_support/concern.rb:120:in `append_features'", "/Users/norifumi/RailsApps/sampleapp/app/models/user.rb:2:in `include'", ...

Việc này có thể khác nhau dựa vào từng version của ActiveSupport nhưng chúng ta có thể xác nhận một điều là app/models/user.rb đã vào ở dòng thứ 3.

Sử dụng cái này, thử viết lại __FILE__ của lúc nãy.

module SqlDefiner
  extend ActiveSupport::Concern
  included do
    self_content = File.read caller[2].split(":").first
    data = self_content.split(/^__END__$/).last if self_content =~ /^__END__$/
    data.present? && (YAML.load(data).each do |name, string|
      const_set "sql_#{name}".upcase, string
    end)
  end
end

Không phải tham chiếu ở __FILE__ mà chúng ta đã tham chiếu nội dung của file bằng cách lấy tên file đó trong kết quả thứ 3 của lệnh caller.

Như vậy thì nó sẽ hoạt đông. Không chỉ app/models/user.rb mà kể cả include ở một model khác thì chương trình cũng sẽ chạy như mong đợi.

Sau đó, nếu tập hợp file vào gem, cố định version của ActiveSupport, qua dependency thì việc chạy chương trình có vẻ như sẽ không có vấn đề gì xảy ra.

Không muốn phụ thuộc vào sự chạy của library khác

Tuy nhiên, như trên tôi vẫn có linh cảm chưa hoàn thành.

Tôi nghĩ rằng như trên thì vẫn hoàn toàn có thể đáp ứng được tính thực dụng. Khi thay đổi version của ActiveSupport cũng như thay đổi chính version của Rails, trong trường hợp này thì việc chạy hay xác nhận chương trình lại là một sự thay đổi lớn cần thiết.

Tôi chỉ hơi phân vân về sự thực hiện chương trình này. Nói rằng nếu thay đổi thứ tự xử lý trong ActiveSupport thì việc chương trình không chạy nữa là chắc chắn, thì ta có khai báo dependency thì nó cũng không trở thành một thứ dễ chịu được.

Ý kiến sử dụng lệnh caller nghe có vẻ hay. vì nó có thể lấy được file trong lúc gọi và có vẻ không còn cách nào nữa.

Vấn đề là chúng ta đang sử dụng cái đó trong included, một lệnh mà ActiveSupport::Concern cung cấp. Nếu vậy thì không phải tốt nếu bắt gọi lệnh trực tiếp từ phía model đang include sao?

Tham khảo phương pháp mở rộng chức năng gọi là thể acts_as, và chạy thử như dưới đây.

class User < ActiveRecord::Base
  acts_as_sql_definer
  class << self
    def created_on_today
      ActiveRecord::Base.connection.select SQL_CREATED_ON_TODAY % [
        Time.now.beginning_of_day.utc.to_s(:db),
          Time.now.end_of_day.utc.to_s(:db)]
    end
  end
end
__END__
created_on_today:
  SELECT * FROM users WHERE created_at >= '%s' AND created_at <= '%s';

Điểm nhấn chính là từ model sử dụng lệnh này bắt gọi ra sử lý bao gồm caller sử dụng Class Macro(Method)

module ActsAsSqlDefiner
  extend ActiveSupport::Concern
  module ClassMethods
    def acts_as_sql_definer
      self_content = File.read caller.first.split(":").first
      data = self_content.split(/^__END__$/).last if self_content =~ /^__END__$/
      data.present? && (YAML.load(data).each do |name, string|
        const_set "sql_#{name}".upcase, string
      end)
    end
  end
end
ActiveRecord::Base.send :include, ActsAsSqlDefiner

Như vậy thì chắc chắn chúng ta có thể cài đặt nguồn gọi vào caller.first. Sau đó thì xử lý không thay đổi, chúng ta load nó bằng Initializer.

require "acts_as_sql_definer.rb"

Nếu hoàn thành được đến đây thì giống như vậy chỉ còn viết SQL vào đuôi cuối file Ruby model mình muốn định nghĩa,

class Product < ActiveRecord::Base
  acts_as_sql_definer
  #...
__END__
select_all:
  SELECT * FROM products;

Như trên gọi Class Macro ra.

Model không cần chức năng này thì nếu không gọi acts_as_sql_definer thì xử lý sẽ không được chạy.

Cuối cùng thì cũng có thể hoàn thành một cách tót đẹp.

Chúng ta có thể thực hiện được suy nghĩa muốn viết code một cách đẹp hơn ngay lập tức, Meta programming của Ruby quả thật là vui đúng không?

Làm thành gem rồi cho chạy erb trước khi pass bằng YAML, chúng ta còn có rất nhiều điều có thể làm nhưng tại đây, mục tiêu nêu ra ban đầu đã hoàn thành.