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

Crafting elegant code with Ruby DSLs - Euruko 2023

Sponsored · Your Podcast. Everywhere. Effortlessly. Share. Educate. Inspire. Entertain. You do you. We'll handle the rest.
Avatar for Tom de Bruijn Tom de Bruijn
September 23, 2023

Crafting elegant code with Ruby DSLs - Euruko 2023

Given at Euruko 2023 in Vilnius, Lithuania on 23th of September 2023.

- https://tomdebruijn.com/
- https://twitter.com/tombruijn
- https://mastodon.social/@tombruijn

Avatar for Tom de Bruijn

Tom de Bruijn

September 23, 2023
Tweet

More Decks by Tom de Bruijn

Other Decks in Programming

Transcript

  1. / ~130 I love Ruby I can write elegant code

    The developer in me is happy Doesn't get in my way I can do cool metaprogramming I can be dangerous with Ruby 2
  2. / ~130 module DiagnoseReportPresenter class Config include Validations::ReportValidator SUPPORTED_LOG_LEVELS =

    ["error", "warn", "info", "debug", "trace"].freeze attr_reader :key, :label def initialize(report) @report = report @key = "config" @label = "Configuration" end def options @options ||= begin is_new_style = report[key].key?("options") option_keys = if is_new_style report.dig(key, "options") || {} else report.dig(key) || {} end clean_up_internal_config_keys!(option_keys) definitions = config_definitions keys = definitions.keys | option_keys.keys keys.map do |option| ConfigOption.new(option, report, definitions.fetch(option, {})) end end end def config_definitions # rubocop:disable return @definitions if @definitions d = {} d["active"] = { :label => "Active", :required => true, :highlight => true, :validate => proc do |option| next if option.value == true option.validations << [:error, "AppSignal is not configured as active."] end } d["name"] = { :label => "App name", :highlight => true, :required => true } if report.elixir? d["otp_app"] = { :label => "OTP app name", :highlight => true, :required => true, :since => { :elixir => "2.0.0" } } end d["log_level"] = { :label => "Log level", :since => { :ruby => "3.0.16", :elixir => "2.2.8", :nodejs => "2.2.6" }, :validate => proc do |option, _report| if option.value? && !SUPPORTED_LOG_LEVELS.include? (option.value) option.validations << [ :error, "Unknown log level. Supported levels: #{SUPPORTED_LOG_LEVELS.join(", ")}" ] end end } # Repeat for every config option end end end 8
  3. / ~130 d["active"] = { :label => "App active", :required

    => true, :highlight => true, :validate => proc do |option| next if option.value == true option.validations << [:error, "not configured as active."] end } 9
  4. / ~130 window = Engine::Window.new window.add( Engine::Text.new("My game name")) button

    = Engine::Button.new("Start game") event = Engine::Events::OnClicked .new do |state| state.windows["menu"].navigate end button.add_event(event) window.add(button) window.open window { title "My game name" button do text "Start game" on_clicked do state.windows["menu"].navigate end end }.open API DSL 23
  5. / ~130 # config/application.rb class Application < Rails::Application config.load_defaults 7.0

    config.time_zone = "UTC" config.app.custom_option = "oh hi!" Easier configuration 24
  6. / ~130 class CreateProducts < ActiveRecord::Migration[7.0] def change create_table :products

    do |t| t.string :name t.text :description t.timestamps end end end Automate writing code 26
  7. / ~130 case ARGV.first when "hello" puts "Hello world" when

    "bye" puts "Goodbye world" end # ruby tasks.rb hello task :hello do puts "Hello world" end task :bye do puts "Goodbye world" end # rake hello Make high churn code more maintainable 27
  8. / ~130 RSpec.describe Post do context "with comments" do let(:comment)

    { Comment.new(:body => "hi!") } let(:post) { Post.new(:comments => [comment]) } it "has comments" do expect(post).to have_comments # ... 32
  9. / ~130 d["active"] = { :label => "App active", :required

    => true, :highlight => true, :validate => proc do |option| next if option.value == true option.validations << [:error, "not active"] end } 35
  10. / ~130 attribute :active do label "App active" required message:

    "is not active" highlight true 
 check do |attr| attr.add_error "custom error" end end 37
  11. / ~130 class ConfigReport attr_accessor :active, :name end report =

    ConfigReport.new report.active = true report.name = "My app" 40
  12. / ~130 class ConfigReport attr_reader :attributes def initialize @attributes =

    {} end def active=(value) @attributes[:active] = value end def name=(value) @attributes[:name] = value end # Repeated for a 100 options 42
  13. / ~130 def filter_parameters(value) @attributes[:filter_parameters] = value end def hostname(value)

    @attributes[:hostname] = value end def ignore_errors(value) @attributes[:ignore_errors] = value end def running_in_container(value) @attributes[:running_in_container] = value end def active=(value) @attributes[:active] = value end def name=(value) @attributes[:name] = value end def filter_parameters(value) @attributes[:filter_parameters] = value end def hostname(value) @attributes[:hostname] = value def active=(value) @attributes[:active] = value end def name=(value) @attributes[:name] = value end def filter_parameters(value) @attributes[:filter_parameters] end def hostname(value) @attributes[:hostname] = value end def ignore_errors(value) @attributes[:ignore_errors] = va end def running_in_container(value) @attributes[:running_in_containe end def active=(value) @attributes[:active] = value end def name=(value) @attributes[:name] = value active] = value ) name] = value meters(value) filter_parameters] = value lue) hostname] = value rs(value) ignore_errors] = value container(value) running_in_container] = value ue) active] = value ) name] = value New problem: Methods for every attribute 43
  14. / ~130 class ConfigReport def self.attribute(name, metadata = {}) #

    ... end attribute :active, :label => "App active" 45
  15. / ~130 class ConfigReport def self.attribute(name, metadata = {}) define_method

    name do @attributes[name] end # ... end attribute :active, :label => "App active" 46 define_method name do @attributes[name] end
  16. / ~130 class ConfigReport def self.attribute(name, metadata = {}) define_method

    name do @attributes[name] end define_method "#{name}=" do |value| @attributes[name] = { :value => value }.merge(config) end end attribute :active, :label => "App active" 48 [:value] define_method "#{name}=" do |value| @attributes[name] = { :value => value }.merge(metadata) end
  17. / ~130 report = ConfigReport.new report.active = true report.active #

    true report.attributes { :active => { :value=>true, :label=>"App active" } } 49
  18. / ~130 [1, 2, 3].each do |i| puts i end

    We use blocks all the time 51
  19. / ~130 class ConfigReport def self.attribute(name, &block) attr = AttributeDSL.new

    attr.instance_eval(&block) # ... 59 &block attr.instance_eval(&block)
  20. / ~130 attribute :active do check do |attr| if attr.value

    == false end end 64 check do |attr| if attr.value == false attr.add_error "Some error" end end
  21. / ~130 class AttributeDSL def self.check(&block) @checks << block end

    # ... @checks.each do |check| check.call(attribute) end 65
  22. / ~130 class ConfigReport < Report attribute :active do required

    true end class LanguageReport < Report attribute :otp_version do package :elixir end 71
  23. / ~130 class LabelPlugin < Plugin metadata :label class ConfigReport

    < Report plugin LabelPlugin attribute :name do label "App version" end 79
  24. / ~130 class RequiredPlugin < Plugin metadata :required check do

    |attr, required:| next unless required
 next if attr.value attr.add_error "value not set" end end 80
  25. / ~130 module Metadata def self.define(*metadata) mod = Module.new metadata.each

    do |option| mod.define_method option do |value| @metadata[option] = value end end mod end 89 mod = Module.new mod.define_method option do |value| @metadata[option] = value end mod
  26. / ~130 class MyAttribute extend Metadata.define(:label, :required) module "AnonymousModule" def

    label(value) @metadata[:label] = value end def required(value) @metadata[:required] = value end end 90
  27. / ~130 module "AnonymousModule" def label(value) @metadata[:label] = value end

    def required(value) @metadata[:required] = value end end class MyAttribute extend "AnonymousModule" end 91
  28. / ~130 module "AnonymousModule" # ... end class MyAttribute def

    self.label(value) @metadata[:label] = value end
 
 def self.required(value) @metadata[:required] = value end end 92
  29. / ~130 Advantage #1: 
 Easy to reuse everywhere 93

    extend Metadata.define(:label, :required)
  30. / ~130 Advantage #2: 
 No de fi nition logic

    in the class itself 94 No define method on the Attribute class
  31. / ~130 class Report def self.plugin(*plugs) Attribute.plugin(*plugs) end class ConfigReport

    < Report plugin LabelPlugin class LanguageReport < Report plugin PackagePlugin 97
  32. / ~130 class ConfigReport < Report plugin LabelPlugin attribute :active

    do label "App active" end end class LanguageReport < Report attribute :otp_version do label "OTP version" # Doesn't error end end 98
  33. / ~130 101 class Report def self.attribute(name, &block) definition =

    Class.new(Attribute) definition.plugin(*plugins) definition.instance_eval(&block)
  34. / ~130 104 class Report def self.attribute(name, &block) definition =

    Class.new(Attribute) definition.plugin(*plugins) definition.instance_eval(&block)
  35. / ~130 class Attribute def self.plugin(*plugs) plugs.each do |plug| extend

    plug.class_dsl include plug.instance_dsl end end 105
  36. / ~130 class Plugin def self.class_dsl mod = Module.new #

    ... mod # Return the module end 106
  37. / ~130 class Plugin def self.class_dsl mod = Module.new metadata.each_key

    do |option| mod.define_method option do |value| @metadata[option] = value end end mod end 107 mod = Module.new mod.define_method option do |value| @metadata[option] = value end mod
  38. / ~130 class ConfigReport < Report plugin LabelPlugin, RequiredPlugin attribute

    :active do label "App active" required :message => "App is not active!" end end class LanguageReport < Report plugin PackagePlugin attribute :otp_version do package :elixir => "1.2.3" end end 109
  39. / ~130 class ConfigReport < Report plugin LabelPlugin, RequiredPlugin attribute

    :active do label "App active" required :message => "App is not active!" end end class LanguageReport < Report plugin PackagePlugin attribute :otp_version do package :elixir => "1.2.3" end end 110 class ConfigReport < Report plugin LabelPlugin, RequiredPlugin 
 
 
 
 class LanguageReport < Report plugin PackagePlugin
  40. / ~130 class ConfigReport < Report plugin LabelPlugin, RequiredPlugin attribute

    :active do label "App active" required :message => "App is not active!" end end class LanguageReport < Report plugin PackagePlugin attribute :otp_version do package :elixir => "1.2.3" end end 111 attribute :active do label "App active" required :message => "App is not active!" end attribute :otp_version do package :elixir => "1.2.3" end
  41. / ~130 I love Ruby more I can write elegant

    code Write my own DSLs I can build cool things with metaprogramming 117
  42. / ~130 d["active"] = { :label => "App active", :required

    => true, :highlight => true, :validate => proc do |option| next if option.value == true option.validations << [:error, "not configured as active."] end } 124
  43. / ~130 class ConfigReport < Report plugin LabelPlugin, RequiredPlugin attribute

    :options do label "Config options" attribute :active do # nested attributes! label "App active" required :message => "App is not active!" end end end 125
  44. / ~130 Our toolbox • accessors • de fi ne_method

    • Ruby blocks • yield • .instance_eval • .call • DSL classes • Modules • Share behavior between classes • Module builder pattern • Dynamic Class and Module creation • Plugins 126