+6

Arel - SQL manager for Ruby

Tài liệu: The definitive guide to Arel, the SQL manager for Ruby

Arel là thư viện mà nhiều rails developer sử dụng hàng ngày nhưng có thể thậm chí không biết đến sự tồn tại của nó. Tên của thư viện này chỉ xuất hiện khi mọi thứ khác fail có ý nghĩa gì?

Nó có ý nghĩa là cung cấp cho các frameworks cách xây dựng và biểu diễn các truy vấn SQL(sql queries). Nó không phải là thư viện mà bạn có thể muốn sử dụng trực tiếp. Arel là một khối xây dựng cơ bản trên các framework xây dựng các API riêng có nhiều phù hợp cho người dùng cuối.

Một trong số các framework đó là ActiveRecord(AR), một ORM mặc định trong Rails. Nhiệm vụ của ActiveRecord là kết nối tới database, xác định các mối quan hệ giữa các model, cung cấp một giao diện truy vấn đẹp và tất cả các thứ khác chúng ta cần.

# ActiveRecord
User.first.comments.where(created_at: 2.days.ago..Time.current).limit(5)

Đằng sau nó, ActiveRecord sử dụng Arel để xây dựng các query và cuồi cùng gọi tới nó để lấy ra SQL cuối trước khi chuyển nó tới database của bạn chọn.

Vậy làm thế nào mà Arel thực hiện xây dựng các query một cách linh hoạt? đó là bằng cách xây dựng một AST. Arel thao tác bên trong trên các nút AST(AST nodes)- bạn chỉnh sửa query thông qua việc gọi phương thức và Arel sẽ chỉnh sửa hay tạo nút phù hợp cho cây.

Sự biểu diễn này giữ hai thuộc tính quan trọng. Đầu tiên là composability. Bằng composable bạn đã tăng tốc độ xây dựng truy vấn lặp, xây dựng từng phần, và thậm chí kết nối một vài các query cùng nhau. Nhiều phần của API có thể không xuất hiện hay ít nhất xử lý rất khó khăn mà không có thuộc tính này.

# ActiveRecord

bob = User.where(email: "bob@test.com").where(active: true)
# => SELECT "users".* FROM "users" WHERE "users"."email" = 'bob@test.com' AND "users"."active" = 't'

details = User.select(:id, :email, :first_name).order(id: :desc)
# => SELECT "users"."id", "users"."email", "users"."first_name" FROM "users" ORDER BY "users"."id" DESC

bob.merge(details).first
# => SELECT "users"."id", "users"."email", "users"."first_name" FROM "users"
#    WHERE "users"."email" = 'bob@test.com' AND "users"."active" = 't'
#    ORDER BY "users"."id" DESC LIMIT 1

Một thuộc tính quan trọng tương tự khác đó là hoàn toàn bỏ qua những gì ở bên ngoài. Arel không quan tâm những gì xảy ra với kết quả. Nó có thể kết thúc việc chuyển đổi thành một SQL query hay thành một định dạng hoàn toàn khác. Sự thật, Arel có thể chuyển đồi query thành định dạng Graphviz’s dot và bạn có thể tạo các sơ đồ ngoài nó.

Từ trước tới giờ chúng ta chỉ nhìn thấy ActiveRecord's query interface, phần xây dựng trên top của Arel. Để hiểu được mặt bên dưới và bắt đầu làm việc với Arel hãy làm theo các chỉ dẫn sau. Script sau sẽ download phiên bản chính xác của các thư viện và bỏ bạn bên trong một Pry REPL instance(chạy bundle console nếu bạn rời REPL và muốn quay trở lại). Nó luôn luôn là một ý tưởng tốt để kiểm tra tất cả các script trước khi bạn chạy chúng.

cd /tmp
mkdir arel_playground && cd arel_playground

wget http://jpospisil.com/arel_setup.sh
# or
curl -L -o arel_setup.sh http://jpospisil.com/arel_setup.sh

bash ./arel_setup.sh

Diving in with SelectManager

Bắt đầu bằng việc xây dựng một select query để lấy ra tất cả các users. Đầu tiên, chúng ta cần tạo một đối tượng biểu diễn bản thân cái table. Chú ý rằng tên bảng là bất kỳ, nó không cần phải tồn tại ở bất kỳ nơi nào.

users = Arel::Table.new(:users)

Arel::Table có nhiều phương thức xử lýxem ở đây mà có nhiệm vụ cho việc delegate các hàm gọi sâu trong hệ thống. Phương thức được quan tâm nhất bây giờ là phương thức project. Tên của nó đến từ relational algebra nhưng đảm bảo nó chỉ là một select đơn giản.

select_manager = users.project(Arel.star)

Chú ý sử dụng của Arel.star, một phương thức tiện lợi cho ký tự *. Những gì chúng ta có là một instance của Arel::SelectManager, một sự kết hợp của select query. Bây giờ chúng ta có thể lấy ra kết quả SQL từ select_manager.

select_manager.to_sql
# => NoMethodError: undefined method `connection' for nil:NilClass

Và nó không làm việc. Đó là bởi vì chúng ta không xác định bất kỳ một chi tiết database nào và Arel không có cách nào để biết những gì mà database chúng ta muốn phát sinh query. Các database có thể khác nhau về cú pháp, capabilities và thậm chị là character escaping. Hãy làm một kết nối ActiveRecord database và thử lại.

ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")

users          = Arel::Table.new(:users, ActiveRecord::Base)
select_manager = users.project(Arel.star)

select_manager.to_sql
# => SELECT * FROM "users"

Chú ý chúng ta đã truyền ActiveRecord::Base tới hàm tạo của Arel::Table . Chúng ta cũng có thể thiết lập một biến gobal thông qua Arel::Table.engine=. Với tất cả thứ đó đặt trong một nơi, cuối cùng chúng ta có được một SQL query.

Thứ đáng quan tâm nhất ở đây là sự cộng tác giữa Arel và ActiveRecord. Arel là một kỹ thuật độc lập từ ActiveRecord nhưng nó cần lấy các chi tiết database từ một nơi nào đó và hiện thời nó sử dụng ActiveRecord. Đặc biệt nữa, Arel yêu cầu API của ActiveRecord. Thậm chí là một sự cái đặt ActiveRecord giả, FakeRecord, được sử dụng để chạy Arel's tests. Trước đây bạn cần chạy MySQL server.

Getting picky Truy vấn tất cả các thông tin của các users là tốt nhưng nó quá cụ thể. Chúng ta chỉ muốn lựa chọn ids và names của users. Cái key abstraction Arel cung cấp để làm việc với các thuộc tính(tên các cột) là Arel::Attribute.

Arel::Attribute thể hiện một tên cột bất kỳ. Cách dễ dàng để lấy ra một Arel::Attribute trong một table là sử dụng phương thức Arel::Table#[]. Sau đó chúng ta có thể sử dụng kết quả này truyền vào trong phương thức project.

select_manager = users.project(users[:id], users[:name])
select_manager.to_sql
# => SELECT "users"."id", "users"."name" FROM "users

Bởi vậy bạn nên chú ý, class được include với một gói các modules mà thêm nhiều function. Module đầu tiên Arel::Expression, thêm các aggregate functions.

select_manager = users.project(users[:comments_count].average)
select_manager.to_sql
# => SELECT AVG("users"."comments_count") AS avg_id FROM "users"

Kết quả của các aggregate functions là giữ các biến với các tên được hardcoded(trong trường hợp này là avg_id). May mắn là cóArel::AliasPredication để giải quyết trường hợp này.

select_manager = users.project(users[:vip].as("status"), users[:vip].count.as("count")).group("vip")
select_manager.to_sql
# => SELECT "users"."vip" AS status, COUNT("users"."vip") AS count FROM "users"  GROUP BY vip

Module Arel::Math đã cài đặt các toán tử toán phổ biến bởi vậy chúng ta có thể sử dụng chúng trực tiếp trên các thuộc tính như thể chúng ta đang làm việc với các giá trị.

select_manager = users.project((users[:stared_comments_count] / users[:comments_count]).as("ratio"))
select_manager.to_sql
# => SELECT "users"."stared_comments_count" / "users"."comments_count" AS ratio FROM "users"

Extending our index finger Các select query mà trả về dữ liệu từ toàn bộ table là hoàn toàn hiếm. Bạn luôn luôn muốn có nhiều control hoàn toàn tốt. Hãy nhìn cách mà Arel xử lý các trường hợp. Bắt đầu từ Arel::Attribute. Đặc biệt hơn là Arel::Predications, nó là một trong các included modules của nó. Hãy trông code bạn có thể nhìn thấy nhiều phương thức xử lý.

select_manager = users.project(Arel.star).where(users[:id].eq(23).or(users[:id].eq(42)))
select_manager = users.project(Arel.star).where(users[:id].eq_any([23, 42]))
select_manager.to_sql
# => SELECT * FROM "users"  WHERE ("users"."id" = 23 OR "users"."id" = 42)

Cho nhiều query phức tạp, nó luôn luôn là tốt nhất để xây dựng các phần riêng biệt và cuối cùng kết nối chúng lại cùng nhau.

admins_vips    = users[:admin].eq(true).or(users[:vip].eq(true))
with_karma     = users[:karma].gteq(5000).and(users[:hellbanned].eq(false))

select_manager = users.project(users[:id]).where(admins_vips.or(with_karma)).order(users[:id].desc)
select_manager.to_sql
# => SELECT COUNT("users"."id") FROM "users" WHERE (("users"."admin" = 't' OR "users"."vip" = 't') 
#      OR "users"."karma" >= 5000 AND "users"."hellbanned" = 'f')
#    ORDER BY "users"."id" DESC

The more the merrier Tiếp theo, xét đến câu lệnh join. Arel exposes joins trực tiếp từ Arel::SelectManager. Giống như mong đợi, Arel hỗ trợ các loại join là INNER JOIN, và LEFT, RIGHT, FULL OUTER JOIN.

comments       = Arel::Table.new(:comments, ActiveRecord::Base)

select_manager = users.project(Arel.star).join(comments).on(users[:id].eq(comments[:user_id]))
select_manager.to_sql
# => SELECT * FROM "users" INNER JOIN "comments" ON "users"."id" = "comments"."user_id"

Để tạo joins, chúng ta cần truyền tường minh hai đối số tới phương thức join

select_manager = users.project(Arel.star).join(comments, Arel::Nodes::OuterJoin).
  on(users[:id].eq(comments[:user_id])).
  having(comments[:id].count.lteq(16)).
  take(15)

select_manager.to_sql
# => SELECT * FROM "users" LEFT OUTER JOIN "comments" ON "users"."id" = "comments"."user_id" 
#    HAVING COUNT("comments"."id") <= 16 LIMIT 15

There’s always more Arel hỗ trợ thậm chí cho cả các tính năng nâng cao nhỏ giống như là câu lệnh WITH hay các hàm WINDOW. Hãy thử làm lại ví dụ 7.8.1 SELECT in WITH từ PostgreSQL. query là hoàn toàn phức tạp, nó bao gồm 2 câu lệnh WITH và một ít subquries. Đầu tiên hãy tập trung vào các mệnh đề WITH, regional_salestop_regions.

orders          = Arel::Table.new(:orders, ActiveRecord::Base)
reg_sales       = Arel::Table.new(:regional_sales, ActiveRecord::Base)
top_regions     = Arel::Table.new(:top_regions, ActiveRecord::Base)

reg_sales_query = orders.project(orders[:region], orders[:amount].sum.as("total_sales")).
                    group(orders[:region])
reg_sales_as    = Arel::Nodes::As.new(reg_sales, reg_sales_query)

Không có điều gì chúng ta chưa biết ở đây. Chỉ có một ngoại lệ là thí dụ cụ thể củaArel::Nodes::As.

top_regions_subquery = reg_sales.project(Arel.sql("SUM(total_sales) / 10"))
top_regions_query    = reg_sales.project(reg_sales[:region]).
                        where(reg_sales[:total_sales].gt(top_regions_subquery))
top_regions_as       = Arel::Nodes::As.new(top_regions, top_regions_query)

Sử dụng Arel.sql không phải là một ý tưởng tuy nhiên giống như phần trước, ở đó không có cách để sử dụng toán tử toán trên kết quả của hàm gọi sum

attributes = [orders[:region], orders[:product], orders[:quantity].as("product_units"),
               orders[:amount].as("product_sales")]

res = orders.project(*attributes).where(orders[:region].in(top_regions.project(top_regions[:region]))).
        with([reg_sales_as, top_regions_as]).group(orders[:region], orders[:product])

res.to_sql

Với tất cả những gì ở trên chúng ta có một query cuối cùng. Nếu chúng ta nhìn từng phần riêng rẽ, chúng rất đơn giản. Tuy nhiên code này là dài hơn so với giải pháp pure SQL.

SelectManager is not the only one Từ trước cho đến bây giờ, tất cả những gì chúng ta làm là viết select quries thông qua SelectManager, nhưng Arel có support các thao tác khác rất tốt. Hãy lướt qua một chút về deleting. Ở đó có hai cách bạn có thể tạo một delete query. Cách thứ nhất là dùng Arel::DeleteManager

delete_manager = Arel::DeleteManager.new(ActiveRecord::Base)
delete_manager.from(users).where(users[:id].eq_any([4, 8]))
delete_manager.to_sql
# => DELETE FROM "users" WHERE ("users"."id" = 4 OR "users"."id" = 8)

Một cách khác nữa, mặc dù nó dường như không được tán thành đó là tạo một câu lệnh delete từ một câu lệnh select bởi gọi compile_delete.

select_manager = users.project(users[:id], users[:name]).where(users[:banned].eq(true))
select_manager.to_sql
# => SELECT "users"."id", "users"."name" FROM "users"  WHERE "users"."banned = 't'

delete_manager = select_manager.compile_delete
delete_manager.to_sql
# => DELETE FROM "users" WHERE "users"."banned" = 't'

Trình quản lý cho các thao tác còn lại, InsertManagerUpdateManager làm việc theo kiểu tương tự.

insert_manager = Arel::InsertManager.new(ActiveRecord::Base)
insert_manager.insert([[users[:name], "Bob"], [users[:admin], true]])
insert_manager.to_sql
# => INSERT INTO "users" ("name", "admin) VALUES ('Bob', 't')

Chú ý rằng Arel::InsertManager có thể nhận ra tên của table chúng ta chèn tự động qua việc sử dụng Arel::Attribute. Nếu chúng ta sử dụng string literals thay thế, chúng ta phải xác định tên bảng thông qua phương thức into.

update_manager = Arel::UpdateManager.new(ActiveRecord::Base)
update_manager.table(users).where(users[:id].eq(42))
update_manager.set([[users[:name], "Bob"], [users[:admin], true]])
update_manager.to_sql
# => UPDATE "users" SET "name" = 'Bob', "admin" = 't' WHERE "users"."id" = 42

The story of .to_sql Trong toàn bộ bài viết này chúng ta có gọi .to_sql trong hầu hết ví dụ nhưng không bao giờ thực sự nói về cách nó làm việc. Bởi vậy bây giờ chúng ta bắt đầu đề cập đến nó. Arel thể hiện tất cả các query giống như là các node trong một abstract syntax tree. Trình quản lý tạo và chỉnh sửa các cây. Tất nhiên một số thứ sau đó lấy ra cây kết quả và xử lý nó để output. Arel sử dụng các loại visitor khác nhau để hoàn thành điều này(xem Visitor pattern).

Thực chất visitor pattern abstracts là làm thế nào để các node của một AST được xử lý từ các node bản thân chúng. Các node giống nhau có thể thiết lập các visitor khác nhau và có các kết quả khác nhau. Đây chính xác là những gì Arel cần để phát sinh ra tất cả các loại của định dạng ouput.

Sự cài đặt của Arel của visitor pattern là thú vị. Nó sử dụng một variation called Extrinsic Visitor. Variation tạo ra sự thuận lợi lớn cho hành vi động của Ruby và thông tin sẵn có ở thời điểm chạy. Thay vì bắt buộc các node cài đặt phương thức accept, visitor sẽ gọi accept trên nó với node gốc là đối số. Nó sau đó kiểm tra node để tìm ra kiểu của nó và tìm phương thức visit thích hợp. Để tạo một phần dispatch nhanh, code nên sử dụng một hash table cho mục địch cache.

{ 
  Arel::Visitors::SQLite => {
    Arel::Nodes::SelectStatement => "visit_Arel_Nodes_SelectStatement",
    Arel::Nodes::SqlLiteral      => "visit_Arel_Nodes_SqlLiteral",
    Arel::Nodes::Or              => "visit_Arel_Nodes_Or",
    Arel::Attributes::Attribute  => "visit_Arel_Attributes_Attribute",
    Arel::Nodes::InnerJoin       => "visit_Arel_Nodes_InnerJoin",
    Arel::Nodes::Having          => "visit_Arel_Nodes_Having",
    Arel::Nodes::Limit           => "visit_Arel_Nodes_Limit"
    Fixnum                       => "visit_Fixnum",
  }
}

Nếu chúng ta nhìn vào visitors directory, chúng ta có thể nhìn thấy một vài visitor mà Arel mặc định. Một số chúng tương ứng trực tiếp với một database riêng, một số chỉ được sử dụng bên trong và một số chỉ được sử dụng từ AR. Chú ý rằng tất cả các visitors liên quan tới database kế thừa từ to_sql visitor mà làm những việc quan trọng nhất và particular database visitor chỉ xử lý chi tiết khác nhau của database cụ thể. H tạo một select manager và lấy ra SQL query bên ngoài nó mà không cần phương thức to_sql.

select_manager = users.project(Arel.star)
select_manager.to_sql
# => SELECT * FROM "users"

sqlite_visitor = Arel::Visitors::SQLite.new(ActiveRecord::Base.connection)
collector      = Arel::Collectors::SQLString.new
collector      = sqlite_visitor.accept(select_manager.ast, collector)
collector.value
# => SELECT * FROM "users"

Một collector là một đối tượng mà lấy ra các kết quả giống như là chúng đến từ visitor. Trong ví dụ cụ thể này, collector có thể là một String riêng của Ruby và chúng ta lấy ra kết quả giốn nhau(mà không cần gọi value cuối cùng của course). Nếu chúng ta nhìn vào source code thực của to_sql, chúng ta có thể nhìn thấy nó loại ra giống như nó lấy trực tiếp từ visitor từ kết nối.

Hãy xét một visitor nữa, Arel::Visitors::Dot. visitor phát sinh định dạng Graphviz’s Dot và chúng ta có thể sử dụng nó để tạo sơ đồ ngoài AST. Đề làm mọi thứ dễ dàng, ở đó có phương thức to_dot chúng ta có thể sử dụng. Chúng ta lấy ra output và lưu nó vào file.

File.write("arel.dot", select_manager.to_dot)

Trên command line, chúng ta sử dụng dot để convert kết quả thành ảnh.

dot arel.dot -T png -o arel.png

Back to upper levels Chúng ta có tất cả khả năng để tùy ý sử dụng tầng Arel nhưng làm thế nào chúng ta có thể sử nó cùng với ActiveRecord? Chúng ta có thể dễ dàng lấy Arel::Table ở dưới trực tiếp từ model với <Table>.arel_table. Thậm chí những gì còn tốt hơn là chúng ta có thể lấy AST từ các query của ActiveRecord và thao tác nó. Một cảnh báo là làm việc với đối tượng Arel bên dưới không phải là một officially supported và các thứ có thể thay đổi giữa các lần phát hành phiên bản mới mà không thông báo.

Đầu tiên, chúng ta cần một vài các bảng và các đối tượng ActiveRecord tương ứng để làm việc. Hãy bắt đầu lại với users và comments.

class User < ActiveRecord::Base
  connection.create_table table_name, force: true do |t|
    t.string :name, null: false
    t.integer :karma, null: false, default: 0
    t.boolean :vip, null: false, default: false
    t.timestamps
  end

  create! [{name: "Alice", karma: 999, vip: true}, {name: "Bob", karma: 1000}, {name: "Charlie"}]

  has_many :comments, dependent: :delete_all
end

class Comment < ActiveRecord::Base
  connection.create_table table_name, force: true do |t|
    t.text :text, null: false
    t.integer :points, null: false, default: 0
    t.references :user
    t.timestamps
  end

  belongs_to :user
end

Giống như đã đề cập ở phần trước, chúng ta có thể lấy đối tượng Arel::Table bằng cách gọi arel_table trên model. Một khi chúng ta làm điều này, chúng ta có thể sử dụng các phương thức tương tự như chúng ta đã sử dụng từ trược tới giờ .

u = User.arel_table
User.where(u[:karma].gteq(1000).or(u[:vip].eq(true))).to_a
# => [#<User id: 1, name: "Alice"...>, #<User id: 2, name: "Bob"...>]

Ờ đây chúng ta truyền một nút Arel(Arel::Nodes::Grouping) trực tiếp tớiwhere` của AR. Không cần phải chuyển đổi bất kỳ thứ gì giống như AR biết làm cách nào với các đối tượng. Hãy làm tiếp sử dụng một AR query bên trong một Arel one.

User.first.comments.create! text: "Sample text!", points: 1001

c             = Comment.arel_table
popular_users = User.select(:id).order(karma: :desc).limit(5)
comments      = c.project(Arel.star).where(c[:points].gt(1000).and(c[:user_id].in(popular_users.ast)))

Comment.find_by_sql(comments.to_sql)

Để thực thi các Arel query, đầu tiên chúng ta cần lấy ra SQL của Arel và sau đó truyền nó cho find_by_sql. Chú ý rằng chúng ta đã gọi ast trên popular_users trước khi truyền nó tới in của Arel. Đó là bởi vì popular_users là một instance của ActiveRecord::Relation và chúng ta cần lấy Arel AST bên dưới.

Tiến trình này xảy ra một lần khi bạn cần đưa ra một query mà kết quả trong bản ghi không cần thiết trả về. Trong trường hợp này, chúng ta sử dụng kết nối trực tiếp và gọi execute với SQL là đối số.

ActiveRecord::Base.connection.execute(c.where(c[:id].eq(1)).compile_delete.to_sql)

Một vấn đề là bạn phải chạy vào trong khi sử dụng ActiveRecord 4.1.x là gọi to_sql phải trả về một SQL query với các tham số gắn kết thay vì các giá trị thực. Vấn đề này đã được giải quyết trên master branch hiện thời và sẽ là một phần của phát hành kế tiếp. Trở lại vấn đề này bây giờ chúng ta phải sử dụng unprepared_statement

# ActiveRecord 4.1.x

sql = User.first.comments.to_sql
# => SELECT "comments".* FROM "comments"  WHERE "comments"."user_id" = ?

sql = User.connection.unprepared_statement { 
  User.first.comments.to_sql 
}
# => SELECT "comments".* FROM "comments"  WHERE "comments"."user_id" = 1

Khối code unprepared_statement được đánh giá với một visitor mà mixes trong Arel::Visitors::BindVisitor, mà ngay lập tức giái quyết các tham số bind.

Real world Tổng quát lại, chúng ta tự hỏi là làm thế nào để sử dụng những điều từ đầu đến giờ trong một ứng dụng thực mà code có thể giữ được và không trở nên lộn xộn? một trong các cách làm là tạo một lớp mà sẽ thể hiện query của chúng ta. Hãy nhìn ví dụ đơn giản dưới đây

class PrivilegedUsersQuery
  attr_reader :relation

  def initialize(relation = User.all)
    @relation = relation
  end

  def find_each(&block)
    relation.where(privileged_users).find_each(&block)
  end

  private

  def privileged_users
    with_high_karma.or with_vip
  end

  def with_high_karma
    table[:karma].gt(1000)
  end

  def with_vip
    table[:vip].eq(true)
  end

  def table
    User.arel_table
  end
end

Chúng ta có đầy đủ lợi thế để xây dựng lặp lại các truy vấn và để một phương thức tới mỗi phần hay tương tự, bất cứ thứ gì cảm thấy là cách tiếp cận tốt nhất cho trường hợp cụ thể.

PrivilegedUsersQuery.new.find_each do |user| 
 # ...
end

The end Arel là một tool tuyệt vời để xây dựng các abstractions ở trên và có một trợ giúp mạnh mẽ cho các abstractions fail để cung cấp các chức năng bạn cần. Bây giờ bạn biết tất cả mọi thứ, biết sử dụng Arel có hiệu quả và quan trọng nhất bạn biết nơi để tìm kiếm cho các câu trả lời khi xây dựng một query phức tạp hay khi các thứ đi sai. Làm ơn cho chúng tôi biết nếu bạn tìm thấy một lỗi của bất kỳ loại nào hay có những đề xuất khác.


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí