Rails – Sebastians Blog https://sgaul.de Neues aus den Softwareminen Fri, 30 Dec 2022 22:39:00 +0000 de-DE hourly 1 https://wordpress.org/?v=6.1.7 https://sgaul.de/wp-content/uploads/2019/02/cropped-sgaul-2-1-32x32.jpg Rails – Sebastians Blog https://sgaul.de 32 32 Pagy-Gem für Paginierung https://sgaul.de/2022/12/30/pagy-gem-fuer-paginierung/ Fri, 30 Dec 2022 22:39:00 +0000 https://sgaul.de/?p=2970 Pagy-Gem für Paginierung weiterlesen]]> Seit Jahren verwende ich privat wie beruflich will_paginate. Da es stets gute Dienste leistete habe ich dies auch nie hinterfragt und bin nur durch die mangelnde Weiterentwicklung und Limitation-Warnung auf pagy gestoßen. Dieses punktet nicht mit Bescheidenheit, sondern mit den folgenden Aussagen:

~ 40x Faster!
~ 36x Lighter!
~ 35x Simpler!
~ 1,410x More Efficient!

Der Ansatz ist im Vergleich zu will_paginate etwas anders. So fällt auf, dass die erzeugten Active-Record-Relations nicht erweitert werden, sondern die eigentliche Collection und Paginierungs-Metadaten getrennt werden. Mein Interesse ist geweckt.

Pagy-Gem installieren:

bundle add pagy
# Installing pagy 6.0.0

An zentraler Stelle für Controller includen…

class ApplicationController < ActionController::Base
  include Pagy::Backend
end

Und als Helper verfügbar machen.

module ApplicationHelper
  include Pagy::Frontend
end

Inititalizer config/initializers/pagy.rb anlegen um z.B. die Standard-Seitengröße festzulegen, Bootstrap-basierte Helper zu aktivieren und die Sprache festzulegen (Vorlage mit allen Einstellungsmöglichkeiten).

# Instance variables
# See https://ddnexus.github.io/pagy/docs/api/pagy#instance-variables
Pagy::DEFAULT[:page]   = 1                                  # default
Pagy::DEFAULT[:items]  = 10                                 # default
Pagy::DEFAULT[:outset] = 0                                  # default

# Bootstrap extra: Add nav, nav_js and combo_nav_js helpers and templates for Bootstrap pagination
# See https://ddnexus.github.io/pagy/docs/extras/bootstrap
require 'pagy/extras/bootstrap'

# I18n
Pagy::I18n.load(locale: 'de')

# When you are done setting your own default freeze it, so it will not get changed accidentally
Pagy::DEFAULT.freeze

Im Controller dann die entsprechende Methode verwenden, die ein Array mit zwei Werten zurückgibt.

  # GET /users
  def index
    @pagy, @users = pagy(User.all)
  end

Im passenden View dann den die Liste der User und den Paginator anzeigen. Leider ist die Rückgabe von Pagy nicht html_safe und muss entsprechend eingefügt werden (im Beispiel HAML).

= render @users
!= pagy_bootstrap_nav(@pagy)
Ergebnis mit ein bisschen Nacharbeit…

Dies ist natürlich nur ein erster Ansatz. Das Gem kommt mit vielen nativen Erweiterungen und hat vor allem im Bereich Performance so einiges zu bieten. So ließe sich z.B. ein Endless-Scroller ohne aufwendige Eintragszählung umsetzen oder eine Suche mit Searchkick/Elasticsearch realisieren.

]]>
JSON-Warnungen in Rails-Projekten https://sgaul.de/2020/02/21/json-warnungen-in-rails-projekten/ Fri, 21 Feb 2020 09:56:59 +0000 https://sgaul.de/?p=2954 Immer wieder tauchen bei mir folgende Warnungen auf:

.../lib/ruby/2.5.0/json/version.rb:4: warning: already initialized constant JSON::VERSION
.../gems/json-2.3.0/lib/json/version.rb:4: warning: previous definition of VERSION was here
.../lib/ruby/2.5.0/json/version.rb:5: warning: already initialized constant JSON::VERSION_ARRAY
.../gems/json-2.3.0/lib/json/version.rb:5: warning: previous definition of VERSION_ARRAY was here

Eine schnelle (vermutlich nicht dauerhafte) Lösung ist bundle clean:

bundle clean --force
bundle

]]>
Vollständige Dependent-Einstellungen in Rails-Models testen https://sgaul.de/2019/03/04/vollstaendige-dependent-einstellungen-in-rails-models-testen/ Mon, 04 Mar 2019 15:38:13 +0000 https://sgaul.de/?p=2933 Vollständige Dependent-Einstellungen in Rails-Models testen weiterlesen]]> Ein selten, aber leider regelmäßig wiederkehrendes Problem sind Fremdschlüsselbeziehungen beim Löschen. In Rails muss auf Seite des Schlüsselziels definiert werden, ob ein Fremdschlüssel auf null gesetzt werden darf oder ob das ganze Model gelöscht werden muss. Vergisst man diese Konfiguration wirft die Datenbank beim Löschen einen Fehler. Um dies zu vermeiden möchte ich alle Fremdschlüssel, die auf eine Model-Tabelle zeigen, überprüfen, ob sie entsprechend konfiguriert sind. Hierfür verwende ich ein shared Example in Rspec.

Fremdschlüssel finden

Die Schlüssel für das beschriebene Model lassen sich in zumindest mit Postgres folgendermaßen auflisten:

def foreign_keys(to_table)
  ActiveRecord::Base.connection.tables.map do |table_name|
    ActiveRecord::Base.connection.foreign_keys(table_name).select{ |index| index.to_table == to_table }
  end.flatten
end

Konfigurierte Assoziationen finden

Die Assoziationen eines Models finden wir mit folgendem Ansatz. Wir ignorieren Assoziationen mit Scopes, da hier nicht ohne weiteres festgestellt werden kann, ob diese alle möglichen Verbindungen abdecken. Auch beschränken wir uns auf Has-many- und Has-one-Beziehungen, da diese zumindest aktuell die einzig relevanten sind. Sollte hier etwas fehlen ist dies auch nicht weiter tragisch, da der Test im Zweifel lieber zu viel als zu wenig bemängeln soll.

def safe_associations(target_class)
  associations =
    target_class.reflect_on_all_associations(:has_many) +
    target_class.reflect_on_all_associations(:has_one)
  associations.select{ |assoc| assoc.options[:dependent].in?(%i{destroy nullify}) && assoc.scope.nil? }
end

Shared Example für verdächtige Keys

Nun können die gültigen Konfigurationen von den Fremdschlüsseln abziehen. Nur wenn die Differenz leer ist, sind alle Schlüssel konfiguriert. Da wir bei Scopes etwas übervorsichtig sind, erlauben wir aber auch eine ignore-Option, um bestimmte Fremdschlüssel zu ignorieren:

shared_examples 'a dependent model' do  |options = {}|
  it 'has a dependent option for has_* associations' do
    foreign_keys = foreign_keys(described_class.table_name)
    associations = safe_associations(described_class)
    ignore = options[:ignore] || []

    suspicious_keys = 
      foreign_keys.map{ |key| "#{key.from_table}.#{key.column}" } -
      associations.map{ |assoc| "#{assoc.klass.table_name}.#{assoc.foreign_key}" } -
      ignore

    expect(suspicious_keys).to be_empty
  end

  # ...
end

Nun noch die Fehlermeldungen modifizieren und etwas Doku ergänzen und schon kann das Example in Model-Specs eingebunden werden:

describe BookWithChapters do
  it_behaves_like 'a dependent model'
end
]]>
Verwischte Font-Awesome-Icons in Phantom JS https://sgaul.de/2016/07/07/verwischte-font-awesome-icons-in-phantom-js/ Thu, 07 Jul 2016 15:48:15 +0000 https://sgaul.de/?p=2874 Verwischte Font-Awesome-Icons in Phantom JS weiterlesen]]> Wir reichern unsere auf Capybara und Phantom JS basierenden Feature-Tests gerne mit Screenshots an. Hierbei kam es regelmäßig zu einem Problem mit Font Awesome, wodurch der automatische Abgleich fehl schlug. Bei einzelnen Specs tritt das Problem nicht auf, erst wenn mehrere Tests mit unterschiedlichen Seiten abgelichtet werden zeigte sich folgendes Phänomen:

font-awesome-phantomjs

Alle Icons, wie hier das Such-Icon rechts, wurden wie links zu sehen verzerrt. Den Lösungsansatz brachte dieser Eintrag auf Stackoverflow: Ist die Schriftart nativ auf dem System vorhanden, tritt die Verzerrung nicht mehr auf. Unter Ubuntu ist das schnell gemacht:

sudo apt-get install fonts-font-awesome
sudo fc-cache -f -v
]]>
Factory-Girl-Daten im Development-Modus https://sgaul.de/2016/03/30/factory-girl-daten-im-development-modus/ Wed, 30 Mar 2016 12:20:55 +0000 https://sgaul.de/?p=2867 Testdaten aus Factory-Girl lassen sich in einer Rails-Konsole auch für den Development-Modus (und entsprechend jeden anderen Modus) bereitstellen:

require 'factory_girl'; FactoryGirl.find_definitions
FactoryGirl.create :my_model
]]>
Rails-Konfiguration in Engine auslagern https://sgaul.de/2016/01/30/rails-konfiguration-in-engine-auslagern/ Sat, 30 Jan 2016 09:01:17 +0000 https://sgaul.de/?p=2854 Rails-Konfiguration in Engine auslagern weiterlesen]]> Für die meisten Rails-Projekte verwende ich die gleichen Gems mit ähnlichen Konfigurationen. Um den Projektstart und den Update-Prozess zu vereinheitlichen, möchte ich eine Engine, die diese Abhängigkeiten und Konfigurationen übernimmt.

Für den einfacheren Einstieg erzeuge ich innerhalb einer bestehenden Rails-App eine neue Engine:

rails plugin new m3 --full

Diese wird zu Testzwecken im Gemfile des Elternprojekts verlinkt:

gem 'm3', path: 'm3'

Gem-Abhängigkeiten im Gemspec-File

Im Gemspec-File m3.gemspec steht bereits eine Rails-Abhängigkeit, die ich um die gewünschten Gems erweitere:

s.add_dependency "rails", "~> 4.2.4"
s.add_dependency "simple_form", "~> 3.2.0"
s.add_dependency "draper", "~> 2.1.0"
s.add_dependency "cancancan", "~> 1.13.1"

Damit diese beim Start der Elternapplikation zur Verfügung stehen, müssen diese vom Gem geladen werden. Dies sollte in der Engine-Datei gemacht werden:

# <gem>/lib/<gem>/engine.rb

require 'cancan'
require 'draper'
require 'simple_form'

module M3
  class Engine < ::Rails::Engine
    # ...

Die Elternapplikation kann nun wie gewohnt auf die Gems zugreifen.

Initializer

Erweiterte Konfigurationen für Simple-Form werden in zugehörigen Initializern festgelegt, die man ebenfalls einfach in der Engine anlegen kann:

# <gem>/config/initializers/simple_form.rb

SimpleForm.setup do |config|
  # ...
# <gem>/config/initializers/simple_form_bootstrap.rb

SimpleForm.setup do |config|
 # ...

Controller-Erweiterungen

Grundlegende Controller-Konfigurationen habe ich in einen Application-Controller mit Namespace gepackt:

# <gem>/app/controllers/m3/application_controller.rb

class M3::ApplicationController < ActionController::Base
  # ...

Hier kann man auch wie gewohnt Concerns verwenden, diese werden ebenfalls automatisch gefunden.

Die fertigen Konfigurationen können dann vom Application-Controller der Elternapplikation verwendet werden:

# <app>/app/controllers/application_controller.rb

class ApplicationController < M3::ApplicationController
  # ...

Module und Klassen aus lib bereitstellen

Auch in einer Engine landet unter lib so einiges, was nicht vom Autoloading abgedeckt wird. Diese könnten wie Gem-Abhängigkeiten explizit in der Engine-Datei geladen werden. Diese Lösung ist jedoch ein unschöner Workaround und sei hier nur für den Notfall aufgeführt.

# <gem>/lib/<gem>/engine.rb
# Workaround
require_dependency File.expand_path("../../../app/models/m3", __FILE__)
require_dependency File.expand_path("../../../lib/m3/router", __FILE__)

Wie man sieht lade ich hier auch den Namespace M3, da Autoloading an dieser Stelle noch nicht funktioniert und die Lib-Klasse Routes entweder aufgrund des fehlenden Namespaces knallt oder diesen als leeres Modul anlegt. Letzteres kann zu einem schwer zu findenden Fehler führen, da die Modul-Datei im Verzeichnis app dann gar nicht mehr geladen wird.

Wie angedeutet ist dieser Ansatz wenig elegant. Die Require-Statements sollten besser zu einem Zeitpunkt ausgeführt werden, an dem Autoloading bereits zur Verfügung steht. Dann passiert vieles automatisch und übrige Requires können ohne File-Path-Expansions notiert werden:

# <gem>/lib/m3/router.rb

require_dependency 'm3'

class M3::Router
  # ...
# <gem>/config/routes.rb

require_dependency 'm3/router'

Rails.application.routes.draw do
  M3::Router.draw_routes(self)
end
]]>
Match API output with YAML fixture https://sgaul.de/2015/10/12/match-api-output-with-yaml-fixture/ Mon, 12 Oct 2015 13:23:50 +0000 https://sgaul.de/?p=2834 Match API output with YAML fixture weiterlesen]]> A nice and simple approach to test you JSON APIs:

# spec/features/api/user_spec.rb

require 'rails_helper'

RSpec.feature 'User', type: :feature do
  it 'lists users' do
    user = create(:user, :as_admin)
    visit api_user_path(user, format: :json)
    expect(page).to match_yaml_fixture('api/user')
  end
end

This checks if the page source matches the data given in the fixture file. To make it more readable we format the expected JSON as YAML:

# spec/fixtures/api/user.yaml

---
user:
- id: 1
  first_name: Max
  last_name: Power
  role:
    id: 1
    name: Admin

Add match_yaml_fixture to RSpec

Drop this in your support directory:

require 'rspec/expectations'

module FixtureHelper
  def read_fixture(*path_elements)
    IO.read(Rails.root.join("spec", "fixtures", *path_elements))
  end
end

RSpec::Matchers.define :match_yaml_fixture do |expected|
  match do |actual|
    expected = prepare_expected(expected)
    actual = prepare_actual(actual)
    actual.strip == read_fixture(expected).strip
  end
  failure_message do |actual|
    expected = prepare_expected(expected)
    actual = prepare_actual(actual)
    "expected the following to match fixture file #{expected}:\n\n#{actual}"
  end

  private

  def prepare_expected(expected)
    expected += '.yaml' unless expected.end_with?('.yaml')
    expected
  end

  def prepare_actual(actual)
    actual = JSON.parse(actual.source) if actual.is_a?(Capybara::Session)
    actual = actual.to_yaml if actual.is_a?(Hash)
    actual.to_s
  end
end

Then include the helper in your spec_helper.rb or rails_helper.rb file:

# spec/rails_helper.rb

require File.expand_path('../support/fixture_helper', __FILE__)
RSpec.configure do |config|
  config.include FixtureHelper
end
]]>
Overwriting and Overriding with define_method https://sgaul.de/2015/09/29/overriding-vs-overwriting/ Tue, 29 Sep 2015 15:06:38 +0000 https://sgaul.de/?p=2827 Overwriting and Overriding with define_method weiterlesen]]> Recently we stumbled upon this inheritance issue, which seemed very weird at the first:

class A
  def talk
    'A'
  end
end

class B < A
  def self.define_talk
    define_method :talk do
      super() << 'B'
    end
  end
end

class C < B
  define_talk
 
  def talk
    super << 'C'
  end
end

> C.new.talk
 => "AC"

The talk addition from class B doesn’t appear, even though define_talk is triggered by Class C. C’s super call seems to ignore its direct parent B.

Overriding vs Overwriting

The reason for that is the delayed execution of define_talk, which adds the talk method to C rather than B. The previous definition of C is equivalent to this:

class C < B
  # calling define_talk is equivalent to
  def talk
    super << 'B'
  end
 
  def talk
    super << 'C'
  end
end

The second definition of talk doesn’t override the first one, it overwrites it: The first definition is completely gone and therefore no longer available as super.

Overriding with Anonymous Modules

To fix this we need to ensure that B defines the method on B or another untouched inheritance layer. This layer can be an anonymous module created and included by the define_talk method:

class B < A
  def self.define_talk
    mod = Module.new do
      define_method :talk do
        super() << 'B'
      end
    end
    include mod
  end
end

Now C’s definition of talk only overrides the one of the anonymous module, which is therefore still available as super.

> C.new.talk
 => "ABC"
]]>
ActiveRecord: Klasse einer Model-Instanz ändern https://sgaul.de/2015/09/26/activerecord-klasse-einer-model-instanz-aendern/ Sat, 26 Sep 2015 11:42:18 +0000 https://sgaul.de/?p=2815 ActiveRecord: Klasse einer Model-Instanz ändern weiterlesen]]> ActiveRecord erlaubt das direkte Ändern der Klasse einer Model-Instanz mittels #becomes. Dies kann zum Beispiel hilfreich sein, wenn man für einen Spezialfall weitere Funktionen oder Validierungen zu einem Model hinzufügen will:

> company = Company.last
> company.persisted?
 => true
> company.valid?
 => true 
> company = company.becomes(RestrictedCompany)
> company.is_a?(RestrictedCompany)
 => true 
> company.is_a?(Company)
 => true 
> company.persisted?
 => true
> company.valid?
 => false

Ähnliches ließe sich zwar auch mit bedingten Validierungen erreichen, würde aber ab einem gewissen Umfang schlicht nicht mehr skalieren.

Ein weiterer, oft gesehener Ansatz wäre RestrictedCompany.find(company.id). Dieser ist jedoch von der Datenbank abhängig und somit vermutlich weniger performant. Zudem benötigt er wirklich persistierte Instanzen. Dies ist vor allem nervig, wenn man in Tests gerne Stubbing nutzt.

Zum Speichern der Klassenänderung kann man #becomes! verwenden. Dies passt auch die STI-Vererbungsspalte (in aller Regel type) an.

]]>
Action-Mailer-Previews im Spec-Verzeichnis https://sgaul.de/2015/09/25/action-mailer-previews-im-spec-verzeichnis/ Fri, 25 Sep 2015 15:02:06 +0000 https://sgaul.de/?p=2813 Action-Mailer-Previews im Spec-Verzeichnis weiterlesen]]> Die Mailer-Previews in Rails 4 sind ein Segen. Wer Rspec statt der üblichen Testsuite verwendet kann das Verzeichnis von test in spec ändern, so dass alle Testdateien zusammen bleiben:

# config/application.rb

# ...
config.action_mailer.preview_path = "#{Rails.root}/spec/mailers/previews"

Nach einem Serverneustart sollten die Previews gefunden werden:

# spec/mailers/previews/user_mailer_preview.rb

class UserMailerPreview < ActionMailer::Preview
  def send_invitation
    UserMailer.send_invitation(Invitation.first!)
  end
end

Die Vorschau kann man dann unter den üblichen URLs betrachten, z.B.:

http://localhost:3000/rails/mailers/user_mailer/send_invitation
]]>