Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Refining Active Record

Sponsored · SiteGround - Reliable hosting with speed, security, and support you can count on. →
Avatar for Shugo Maeda Shugo Maeda
October 17, 2026

Refining Active Record

Avatar for Shugo Maeda

Shugo Maeda

October 17, 2026

More Decks by Shugo Maeda

Other Decks in Technology

Transcript

  1. About me ▪ Shugo Maeda ▪ CEO at NaCl ▪

    A Ruby committer ▪ An occasional Rails contributor
  2. activerecord-refinements ▪ A gem written by Akira Matsuda ▪ Provides

    a DSL for where User.where { :name == "Matz" }
  3. Warning in README And unfortunately, due to the Refinements’ spec

    change, this library does not work under Ruby 2.0.0 stable.
  4. using at the top level p 1 / 2 #=>

    0 using ExactDivision # activated only in the file p 1 / 2 #=> (1/2)
  5. using in class/module class Foo p 1 / 2 #=>

    0 using ExactDivision # activated only in the class p 1 / 2 #=> (1/2) def bar = 1 / 2 end p 1 / 2 #=> 0 p Foo.new.bar #=> (1/2)
  6. where in activerecord-refinements def where(opts = nil, *rest, &block) if

    block col, op, val = Module.new { using ActiveRecord::Refinements::WhereBlockSyntax }.module_eval &block ...
  7. activerecord-refined ▪ A fork of activerecord-refinements ▪ Reimplemented with Proc#refined

    ▪ Added a number of DSL constructs ▪ activerecord-refinements supported only ==, !=, =~, >, >=, <, and <=
  8. Proc#refined ▪ A new feature in Ruby 4.1 ▪ Returns

    a copy of the Proc with the given refinements activated
  9. Example module StringExt refine String do def shout = upcase

    + "!" end end original = ->(s) { s.shout } refined = original.refined(StringExt) p refined.call("hello") # "HELLO!" p original.call("hello") # NoMethodError
  10. where in activerecord-refined def where(opts = nil, *rest, &block) return

    super unless block super(to_arel_condition(evaluate_block(&block))) end def evaluate_block(&block) refined_block = block.refined(ActiveRecord::Refined::BlockSyntax) BlockContext.new(model).instance_exec(&refined_block) end
  11. BlockSyntax ▪ Refinements for Symbol, Integer, String, … module BlockSyntax

    refine Symbol do def ==(other) = AST::Comparison.new(self, :==, other) end end
  12. Refinements don’t leak class Post < ActiveRecord::Base def self.initial_state(author_role) #

    plain Symbol#== author_role == :admin ? "published" : "draft" end end Post.where { :state == Post.initial_state(:admin) } # WHERE "posts"."state" = 'published'
  13. BlockContext ▪ self inside the block ▪ No method_missing class

    BlockContext def avg(column) = AST::Aggregate.new(column, :average) def count(column, distinct: false) = ... end
  14. How it works Block AST Arel SQL Proc ActiveRecord::Refined::AST Arel::Nodes

    String Comparison Equality SELECT "users".* User.where { FROM "users" :name == "Matz" } WHERE "users"."name" column operator :name :== value left right "Matz" Attribute Casted "users"."name" 'Matz' = 'Matz'
  15. Design policy ▪ SQL queries as Ruby expressions ▪ Columns

    are symbols ▪ Names come from SQL or Ruby ▪ One notation, one meaning
  16. SQL queries as Ruby expressions ▪ No SQL in strings!

    ▪ Easy to read/write ▪ Automatic quoting
  17. Why not Arel directly? ▪ Not a public API ▪

    Verbose User.where(User.arel_table[:name].eq("Matz"))
  18. Columns are symbols ▪ Bare names are ambiguous ▪ Column,

    local variable, or method? where { name == "Matz" } where { :name == "Matz" } # column? variable? method? # a column
  19. Symbols are columns everywhere # Compares two columns where {

    :updated_at == :created_at } # ArgumentError if a symbol is not a column where { :status == :active } # An enum value is its string where { :status == "active" }
  20. Names come from SQL or Ruby # SQL's names by

    default Author.select { avg(:age) } # not average Author.where { # not exist? exists?(Post.where { :posts[:author_id] == :authors[:id] }) } # Ruby's names when they fit better Doc.select { :meta.dig(:author, :name).as(:author) } Author.where { :name.start_with?("A") }
  21. One notation, one meaning ▪ No overloading by SQL type

    ▪ + adds; concat concatenates ▪ & and | are AND and OR, never bitwise ▪ include? for substrings; member? for array elements
  22. Dialect class hierarchy Dialect Sqlite Postgresql sqlite3 postgresql, postgis, pglite

    MysqlCompat Oracle SqlServer oracle_enhanced sqlserver Mysql Mariadb mysql2, trilogy mysql2, trilogy
  23. Example: dig :meta.dig(:author, :name) -- SQLite "meta" -> '$.author.name' --

    PostgreSQL "meta" #> '{"author","name"}' -- MySQL JSON_EXTRACT(`meta`, '$.author.name')
  24. Example: filter count(:*).filter { :age < 50 } -- SQLite,

    PostgreSQL, and Oracle COUNT(*) FILTER (WHERE "age" < 50) -- MySQL, MariaDB, and SQL Server COUNT(CASE WHEN `age` < 50 THEN 1 END)
  25. How to add a dialect class ExampleDialect < ActiveRecord::Refined::Dialect FUNCTIONS

    = { char_length: "LEN", rand: nil }.freeze def json_path(document, _dollar_path, steps, json_value, _model) Arel::Nodes::InfixOperation.new( json_value ? :"~>" : :"~>>", document, Arel::Nodes.build_quoted(steps)) end end ActiveRecord::Refined::Dialect.register("exampledb", ExampleDialect)
  26. Performance benchmarks ▪ Query building only, no DB access ▪

    Speed: queries built per second ▪ Memory: bytes per query built ▪ Simple case: :name == "alice"
  27. Compared styles # hash User.where(name: "alice") # string User.where("name =

    ?", "alice") # Arel User.where(User.arel_table[:name].eq("alice")) # activerecord-refined User.where { :name == "alice" }
  28. Queries built per second string 40.9k Arel 40.5k activerecord-refined 36.1k

    hash 31.5k ▪ Negligible compared with a DB round trip
  29. Bytes per query built Arel 2,176 activerecord-refined 2,392 hash 2,576

    string 2,976 ▪ Proc#refined: one ISeq copy per call site
  30. A pitfall # "&" needs parentheses User.where { (:name ==

    "Matz") & (:age > 50) } # Otherwise true & ... drops the condition User.where { :active == true & (:age > 50) } # WHERE "users"."active" = TRUE ▪ && can’t be redefined