Viết SQL trong module một cách mạch lạc, gọn gàng
Bài đăng này đã không được cập nhật trong 9 năm
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__
và 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.
All rights reserved