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

Crafting elegant code with Ruby DSLs - Euruko 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

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