Kreative Assoziationen

Beziehungen und Eheprobleme

Michael Schürig

Viele Beziehungen

Viele Assoziationen zwischen Person und Film
  • Jede Beziehung wird durch eine eigene Verknüpfungstabelle modelliert.
  • Für jede neue Beziehung müssen Code und Datenbank geändert werden.
  • Schwierige Fragen:
    • Wer ist an diesem Film beteiligt?
    • An welchen Filmen ist diese Person beteiligt?

Die eine Beziehung: von der Rolle

Eine Assoziation mit Rollentyp zwischen Person und Film
  • Ternäre Verknüpfung: Person, Film, Typ
  • Manche Queries werden schwieriger:
    • Wer war Regisseur dieses Films?
  • Andere werden leicher:
    • Wer war an diesem Film beteiligt?

Symmetrie in der Ehe

Ich bin mit dir verheiratet
Du bist mit mir verheiratet

  • Beispiel für eine symmetrische Beziehung.
  • Serielle Monogamie: Die Assoziation benötigt Informationen über den Zeitraum.

Es geht auch ganz anders...

Gay marriage: the database engineering perspective

Schauspielerei

class Movie < ActiveRecord::Base
  has_and_belongs_to_many :actors, :class_name => 'Person'
  # ... für Regie, Kamera, Schnitt, Makeup, ...
end
Nein, so nicht.
  • Directors Guild of America: erlaubt nur einen Regisseur Wikipedia/DGA.

Rollen!

class Movie < ActiveRecord::Base
  has_many :roles
  has_many :participants, :through => :roles,
    :source => :person
end

class Role < ActiveRecord::Base
  validates_presence_of :role_type
  belongs_to :person
  belongs_to :movie
end

Aber wie bekomme ich (nur) eine Rolle?

movie.participants.find(:all, :conditions => ...)
Das tut ja weh.

Erweiterte Assoziationen

class Movie < ActiveRecord::Base
  has_many :roles do
    def as_actor
      self.scoped(
        :joins => 'CROSS JOIN roles',
        :conditions => { :roles => { :role_type => "actor" } }
      )
    end
    def as_director
      ...
    end
  end

  has_many :participants, :through => :roles,
      :source => :person do
    ...
  end
end
  • Wer bin ich?
    self
    AssociationProxy, verhält sich fast wie das Zielobject.
    proxy_owner
    Returns the object the association is part of.
    proxy_reflection
    Returns the reflection object that describes the association.
    proxy_target
    Returns the associated object for belongs_to and has_one, or the collection of associated objects for has_many and has_and_belongs_to_many.

Wiederhole ich mich?

JA!

Dynamisch weniger reden...

class Movie < ActiveRecord::Base
  has_many :roles do
    def as(role_type)
      self.scoped(
        :joins => 'CROSS JOIN roles',
        :conditions => { :roles => { :role_type => role_type }}
      )
    end
  end
end

...und trotzdem alles sagen

class Movie < ActiveRecord::Base
  has_many :roles do
    def as(role_type)
      ...
    end

    [:actor, :director].each do |role_type|
      define_method("as_#{role_type}") do
        as(role_type)
      end
    end
  end
end
  • Wo kommen denn die Typen her?

Rollen, Schauspieler, Regisseure

Da war noch was:
class Movie < ActiveRecord::Base
  has_many :roles do
    ...
  end
  has_many :participants, :through => :roles,
      :source => :person do
    def as(role_type)
      ...
    end
    [:actor, :director].each do |role_type|
      define_method ...
    end
  end
end
Muss das sein?

Dürrezeit

Nein.
class Movie < ActiveRecord::Base
  has_many :roles, :extend => RoleTypeExtension
  has_many :participants, :through => :roles,
    :source => :person,
    :extend => RoleTypeExtension
end
module RoleTypeExtension
  def as(role_type)
    ...
  end
  [:actor, :director].each do |role_type|
    define_method ...
  end
end
  • Assoziationen können auch um Module erweitert werden.
  • Umso praktischer, wenn die Erweiterungen an mehreren Stellen passen.

Serendipity

class Person < ActiveRecord::Base
  has_many :roles, :extend => RoleTypeExtension
  has_many :movies, :through => :roles,
    :extend => RoleTypeExtension
end

Beschränkte Gruppen

class Person < ActiveRecord::Base
  named_scope :actors, {
    :joins =>
      'INNER JOIN roles ON roles.person_id = people.id',
    :conditions => { :roles => { :role_type => 'actor' }
  }
end
  • Wir können nun die Schauspieler, Regisseure zu einemFilm finden. Wie finden wir alle?
  • Ein Schritt zurück, es geht auch ohne Assoziationen
  • Aber wir drohen, uns wieder zu wiederholen.

Dynamische Gruppen

class Person < ActiveRecord::Base
  ['actor', 'director'] do |role_type|
    named_scope role_type.pluralize,
      {
        :joins =>
          'INNER JOIN roles ON roles.person_id = people.id',
        :conditions => { :roles => { :role_type => role_type }
      }
    end
  end
end

Oops, we did it again

['actor', 'director'] do |role_type|

Jedes Ding an seinem Platz

class Role < ActiveRecord::Base
  ROLE_TYPES = %w(actor director).freeze
  def self.each_role_type(&block)
    ROLE_TYPES.each(&block)
  end
end

class Person < ActiveRecord::Base
  Role.each_role_type do |role_type|
    named_scope role_type.pluralize,
      ...

Nur zur Ansicht?

movie.participants.add(... ? ...)

movie.participants.remove(... ? ...)
  • Bisher können wir nur durch bestehende Verknüpfungen wühlen.
  • Neue erzeugen oder alte zerstören können wir nicht

Geben und Nehmen (1)

class Movie < ActiveRecord::Base
  has_many :roles, :extend => RoleTypeExtension
  has_many :participants, :through => :roles,
    :source => :person, :extend => ParticipantsExtension

  module ParticipantsExtension
    include RoleTypeExtension
    ...
  end
end

module RoleTypeExtension
  ...
end

Geben und Nehmen (2)

module ParticipantsExtension
  include RoleTypeExtension
  def add(role_type, person)
    proxy_owner.roles.build(
      :person    => person,
      :role_type => role_type)
  end
  def remove(role_type, person)
    role = Role.find(:first,
      :joins => :roles,
      :conditions => {
        :person_id => person,
        :movie_id  => proxy_owner,
        :role_type => role_type })
    proxy_owner.roles.delete(role)
  end
end
  • Tückisch: Änderungen sind nicht unmittelbar an den assoziierten Objekten sichtbar!

Besuch im Kontrollraum

class MoviesController < ApplicationController
  def create
    @movie = Movie.new(params[:movie])
    respond_to do |format|
      ...
    end
  end

  def update
    @movie = Movie.find(params[:id])
    @movie.update_attributes(params[:movie])
    respond_to do |format|
      ...
    end
  end
end
  • Wenn wir einen neuen Film anlegen, dann wollen wir natürlich gleichzeitig Schauspieler und Regisseur zuordnen. Ebenso wenn wir einen Film aktualisieren.
  • "Beteiligung" an einem Film ist keine eigenständige Ressource.
  • Filme und die daran hängenden Beteiligten bilden ein Aggregat mit dem Film als Wurzel.

Immer zu Diensten

Was der Client schickt:
{
  "id" => 4217,
  "movie" => {
    "title"        => "Tramping Along the Rails",
    "actor_ids"    => ["1", "2", "3", "5", "8"],
    "director_ids" => ["13"]
  }
}

Komplizierter Kunde

Ein Auftrag mit Sonderwünschen:
{
  "id" => 4217,
  "movie" => {
    "title"     => "Tramping Along the Rails",
    "actors"    => [
      { "person_id" => "1", "credited_as" => "Will Shatter", 
        "character" => "Captain Krak" }
      { "person_id" => "7", "credited_as" => "Lemur Nerode",
        "character" => "Mr Conehead" }
    ],
    ...
  }
}

Waren wir (ich) zu schlau?

Bahnarbeiter

Wir überreden den Kunden, uns die Daten in dieser Form zu liefern
{
  "id" => 4217,
  "movie" => {
    "title"            => "Tramping Along the Rails",
    "roles_attributes" => [
      { "id" => "1", "credited_as" => "Will Shatter", 
        "character" => "Captain Krak", "role_type" => "actor" }
      { "id" => "13", "_delete" => "1" }
    ],
    ...
  }
}

Arbeitserleichterung

Dann nimmt Rails uns die weitere Arbeit ab.
class Movie < ActiveRecord::Base
  has_many :roles, :extend => RoleTypeExtension
  accepts_nested_attributes_for :roles, :allow_destroy => true
end

Pause

Ehevertrag

Asymmetrie

"Ach, wir sind verheiratet?"
class Person < ActiveRecord::Base
  has_many :marriages
  has_many :spouses, :through => :marriages, :source => :spouse
end

class Marriage < ActiveRecord::Base
  belongs_to :person
  belongs_to :spouse, :class_name => 'Person'
end

ich.marriages.create(:spouse => du)
du.spouses == ?
  • Unbrauchbar für Assoziationen und Queries.

Polygamie

class Person < ActiveRecord::Base
  has_and_belongs_to_many :marriages
  def spouses
    marriages.map(&:people).flatten - [self]
  end
end

class Marriage < ActiveRecord::Base
  has_and_belongs_to_many :people
end

ich.marriages.create(:spouse => du)
ich.spouses.include?(du) # => true
du.spouses.include?(ich) # => true
du.marriages.create(:spouse => no3)
...
  • Problem: Wie wird die richtige Anzahl der Ehepartner garantiert?
  • Möglichkeit: Locking (optimistisch/pessimistisch) des Marriage-Objekts.
  • Kompliziert: has_many :spouses, :through => :marriages nicht möglich

Inkonsistenz

class Person < ActiveRecord::Base
  has_many :marriages
  has_many :spouses, :through => :marriages, :source => :spouse
end

class Marriage < ActiveRecord::Base
  validates_presence_of :start_date
  belongs_to :person
  belongs_to :spouse, :class_name => 'Person'
  after_create  { |m| Marriage.create(:person => m.spouse,
                                      :spouse => m.person) }
  after_destroy { |m| Marriage.delete_all(:conditions => ...) }
end
  • Redundanz macht Inkonsistenz möglich.

Spieglein, Spieglein

create_table :marriages_internal do |t|
  t.belongs_to :person1, :null => false
  t.belongs_to :person2, :null => false
end
add_index :marriages_internal, [:person1_id, :person2_id],
  :unique => true
create_view :marriages,
  %{SELECT id, person1_id, person2_id FROM marriages_internal
    UNION
    SELECT id, person2_id, person1_id FROM marriages_internal
  } do |v|
  v.column :id
  v.column :person_id
  v.column :spouse_id
end
http://github.com/aeden/rails_sql_views
  • Fehlende Attribute: start_date, end_date, lock_version.

Mit Klasse

class Marriage < ActiveRecord::Base
  belongs_to :person
  belongs_to :spouse, :class_name => 'Person'
  validates_presence_of :person, :spouse
end

class Person < ActiveRecord::Base
  has_one :marriage, :conditions => 'end_date IS NULL'
  has_one :spouse, :through => :marriage
end
  • Die Tabelle (View) marriages enthält Zeilen mit doppelten ids, aber ActiveRecord merkt davon nichts.
  • validates_presence_of: ja, das ist (inzwischen) richtig so. Früher mußte der Foreign Key angegeben werden, heute ist (auch?) die Assoziation selbst korrekt.

Regeln für eine gute Beziehung

CREATE RULE
  • Regeln per execute in der Migration ausführen.
  • MySQL kann nicht mit UNION in Updatable Views umgehen.
  • Statement Trigger mit BEFORE/AFTER genügen nicht.

Regeln (1): Hochzeit

CREATE RULE marriages_ins AS ON INSERT TO marriages DO INSTEAD
INSERT INTO marriages_internal (person1_id, person2_id,
                                start_date, end_date)
VALUES (LEAST(NEW.person_id, NEW.spouse_id), 
        GREATEST(NEW.person_id, NEW.spouse_id))
RETURNING id, person1_id, person2_id, start_date, end_date;
  • IDs der Ehepartner in kanonischer Reihenfolge: person1_id < person2_id.
  • RETURNING: Sicht auf die eingefügte Zeile; ActiveRecord nimmt davon nur id.
  • Reduziert: lock_version fehlt.

Regeln (2): Scheidung

CREATE RULE marriages_upd AS ON UPDATE TO marriages DO INSTEAD
UPDATE marriages_internal
SET start_date   = NEW.start_date,
    end_date     = NEW.end_date
WHERE (id = OLD.id);

Regeln (3): Vergessen

CREATE RULE marriages_del AS ON DELETE TO marriages DO INSTEAD
DELETE FROM marriages_internal
WHERE (id = OLD.id);

Drum prüfe...

class Marriage < ActiveRecord::Base
  def before_validation
    self.start_date ||= Date.today
  end

  def validate_on_create
    errors.add_to_base("...") if person == spouse
  end

  def validate
    if end_date && end_date < start_date
      errors.add(:end_date, "...")
    end
    validate_unmarried(person, :person_id)
    validate_unmarried(spouse, :spouse_id)
  end
  ...

und prüfe...

  ...
  def period
    today = Date.today
    ((start_date || today)..(end_date || today))
  end

  def overlaps?(other_period)
    period.overlaps?(other_period)
  end

  def validate_unmarried(person, attribute)
    others = person.marriages.during(period) - [self]
    unless others.empty?
      errors.add(attribute, "Is already married at that time.")
    end
  end
end

Mit Hilfe

class Person
  has_many :marriages do
    def during(dates)
      self.select { |marriage| marriage.overlaps?(dates) }
    end
  end
end
  • Kein DB-Zugriff mit #exists?. Die marriage-Assoziation wird früher oder später ohnehin geladen.

Heiratsschwindel

Prozess1Prozess2
Marriage.create(
:person => p1,
:spouse => p2)
Marriage.create(
:person => p1,
:spouse => p3)
validate
validate
save
save
  • TOCTTOU
  • Transaktionen allein helfen nicht!

Serialisierte Monogamie (pessimistisch)

So:
class Marriage < ActiveRecord::Base
  def before_validation
    ...
    Person.find(:all, 
      :conditions => { :id => [person, spouse].compact },
      :lock => true)
  end
end
    
Nicht:
  def before_validation
    ...
    person.try(:lock!)
    spouse.try(:lock!) # There be deadlocks
  end
  • #compact, weil person/spouse nil sein können.
  • Einzeln #lock! genügen nicht: Gefahr eines Deadlocks.
  • Die Personen wirken als Mutex für ihre angehängten Ehen.
  • ActiveRecord#lock!

Serialisierte Monogamie (optimistisch)

class Marriage < ActiveRecord::Base
  belongs_to :person, :touch => true
  belongs_to :spouse, :class_name => 'Person', :touch => true
end

Pause

Brad - Jennifer - Angelina

date = movie.release_date
movie.participants.select do |brad|
  jennifer = brad.marriages.ended_before(date).last.spouse
  angelina = brad.marriages.started_after(date).first.spouse
  jennifer && angelina && movie.participants.include?(angelina)
end
  • Lustig, aber ineffizient.
  • Für Ernstfälle besser in SQL schreiben.

Archiwurfentierung?

War das jetzt

Architektur: Wo — Controller

  • evtl. mit Hilfe von (Rack) Middleware

Architektur: Wo — Model

Design: Was

  • Objekte und ihre Rollen

Implementierung: Wie

Das war's

Mehr über mich und von mir:

http://www.schuerig.de/michael

Zum Nachschauen:
http://www.schuerig.de/michael/pres/kreative-assoziationen/