6

Sto tentando di progettare un sistema di risultati in Ruby on Rails e ho avuto un problema con il mio design/codice.RoR Achievement System - Polymorphic Association & Design Issues

Il tentativo di utilizzare associazioni polimorfiche:

class Achievement < ActiveRecord::Base 
    belongs_to :achievable, :polymorphic => true 
end 

class WeightAchievement < ActiveRecord::Base 
    has_one :achievement, :as => :achievable 
end 

Migrazioni:

class CreateAchievements < ActiveRecord::Migration 
... #code 
    create_table :achievements do |t| 
     t.string :name 
     t.text :description 
     t.references :achievable, :polymorphic => true 

     t.timestamps 
    end 

    create_table :weight_achievements do |t| 
     t.integer :weight_required 
     t.references :exercises, :null => false 

     t.timestamps 
    end 
... #code 
end 

Poi, quando provo questa seguente test di unità e getta, non riesce perché si dice che il risultato è nullo.

test "parent achievement exists" do 
    weightAchievement = WeightAchievement.find(1) 
    achievement = weightAchievement.achievement 

    assert_not_nil achievement 
    assert_equal 500, weightAchievement.weight_required 
    assert_equal achievement.name, "Brick House Baby!" 
    assert_equal achievement.description, "Squat 500 lbs" 
    end 

E i miei infissi: achievements.yml ...

BrickHouse: 
id: 1 
name: Brick House 
description: Squat 500 lbs 
achievable: BrickHouseCriteria (WeightAchievement) 

weight_achievements.ym ...

BrickHouseCriteria: 
    id: 1 
    weight_required: 500 
    exercises_id: 1 

Anche se, non posso ottenere questo per funzionare, forse nel grande schema delle cose, è un pessimo problema di design. Quello che sto tentando di fare è avere un'unica tabella con tutti i risultati e le loro informazioni di base (nome e descrizione). Usando quella tabella e le associazioni polimorfiche, voglio collegarmi ad altre tabelle che conterranno i criteri per completare quel risultato, ad es. la tabella WeightAchievement avrà il peso richiesto e l'esercizio id. Quindi, il progresso di un utente verrà memorizzato in un modello UserProgress, in cui si collega all'effettivo Obiettivo (al contrario di WeightAchievement).

Il motivo per cui ho bisogno dei criteri in tabelle separate è perché i criteri varieranno selvaggiamente tra i diversi tipi di risultati e verranno aggiunti dinamicamente in seguito, motivo per cui non sto creando un modello separato per ogni risultato.

Questo ha senso? Dovrei semplicemente unire la tabella Achievement con il tipo specifico di raggiungimento come WeightAchievement (quindi la tabella è name, description, weight_required, exercise_id), quindi quando un utente interroga i risultati, nel mio codice cerco semplicemente tutti i risultati? (ad esempio, WeightAchievement, EnduranceAchievement, RepAchievement, ecc.)

risposta

13

Il modo in cui i sistemi di rendimento funzionano generalmente è che ci sono un gran numero di vari risultati che possono essere attivati, e c'è una serie di trigger che possono essere usati per testare se no un risultato dovrebbe essere attivato.

L'utilizzo di un'associazione polimorfica è probabilmente una cattiva idea perché caricare tutti i risultati da eseguire e testarli tutti potrebbe finire per essere un esercizio complicato. C'è anche il fatto che dovrai capire come esprimere le condizioni di successo o di fallimento in una sorta di tabella, ma in molti casi potresti finire con una definizione che non si mappa in modo così preciso. Potresti finire con sessanta diversi tavoli per rappresentare tutti i diversi tipi di trigger e sembra un incubo da mantenere.

Un approccio alternativo sarebbe definire i risultati in termini di nome, valore e così via e disporre di una tabella costante che funge da archivio di chiavi/valori.

Ecco una migrazione del campione:

create_table :achievements do |t| 
    t.string :name 
    t.integer :points 
    t.text :proc 
end 

create_table :trigger_constants do |t| 
    t.string :key 
    t.integer :val 
end 

create_table :user_achievements do |t| 
    t.integer :user_id 
    t.integer :achievement_id 
end 

La colonna achievements.proc contiene il codice Ruby valutare per determinare se il risultato deve essere attivato o meno.In genere questo viene caricato in, avvolto, e finisce come un metodo di utilità si può chiamare:

class Achievement < ActiveRecord::Base 
    def proc 
    @proc ||= eval("Proc.new { |user| #{read_attribute(:proc)} }") 
    rescue 
    nil # You might want to raise here, rescue in ApplicationController 
    end 

    def triggered_for_user?(user) 
    # Double-negation returns true/false only, not nil 
    proc and !!proc.call(user) 
    rescue 
    nil # You might want to raise here, rescue in ApplicationController 
    end 
end 

La classe TriggerConstant definisce vari parametri che è possibile modificare:

class TriggerConstant < ActiveRecord::Base 
    def self.[](key) 
    # Make a direct SQL call here to avoid the overhead of a model 
    # that will be immediately discarded anyway. You can use 
    # ActiveSupport::Memoizable.memoize to cache this if desired. 
    connection.select_value(sanitize_sql(["SELECT val FROM `#{table_name}` WHERE key=?", key.to_s ])) 
    end 
end 

Avere il codice grezzo Ruby in vostra DB significa che è più facile adattare le regole al volo senza dover ridistribuire l'applicazione, ma ciò potrebbe rendere più difficile il test.

Un campione proc potrebbe essere simile:

user.max_weight_lifted > TriggerConstant[:brickhouse_weight_required] 

Se si vuole semplificare le regole, si potrebbe creare qualcosa che si espande in $brickhouse_weight_requiredTriggerConstant[:brickhouse_weight_required] automaticamente. Ciò lo renderebbe più leggibile da persone non tecniche.

Per evitare di inserire il codice nel DB, che alcune persone potrebbero trovare di cattivo gusto, è necessario definire queste procedure in modo indipendente in alcuni file di procedura di massa e passare i vari parametri di ottimizzazione con una sorta di definizione . Questo approccio sarà simile:

module TriggerConditions 
    def max_weight_lifted(user, options) 
    user.max_weight_lifted > options[:weight_required] 
    end 
end 

Regolare il tavolo Achievement in modo che memorizza le informazioni su quali opzioni di passare in:

create_table :achievements do |t| 
    t.string :name 
    t.integer :points 
    t.string :trigger_type 
    t.text :trigger_options 
end 

In questo caso trigger_options è una tabella di mappatura che viene memorizzato serializzato. Un esempio potrebbe essere:

{ :weight_required => :brickhouse_weight_required } 

Combinando questo si ottiene un po 'semplificata, meno eval felice esito:

class Achievement < ActiveRecord::Base 
    serialize :trigger_options 

    # Import the conditions which are defined in a separate module 
    # to avoid cluttering up this file. 
    include TriggerConditions 

    def triggered_for_user?(user) 
    # Convert the options into actual values by converting 
    # the values into the equivalent values from `TriggerConstant` 
    options = trigger_options.inject({ }) do |h, (k, v)| 
     h[k] = TriggerConstant[v] 
     h 
    end 

    # Return the result of the evaluation with these options 
    !!send(trigger_type, user, options) 
    rescue 
    nil # You might want to raise here, rescue in ApplicationController 
    end 
end 

Troverete spesso necessario strobo attraverso un mucchio di Achievement record per vedere se Sono stati raggiunti a meno che non si disponga di una tabella di mappatura in grado di definire, in termini allentati, quale tipo di record vengono testati dai trigger. Un'attuazione più robusta di questo sistema ti permetterebbe di definire classi specifiche da osservare per ogni Obiettivo, ma questo approccio di base dovrebbe almeno servire da base.

+1

Grazie - essenzialmente ciò che stavo cercando ma non riuscivo a capirmi. – MunkiPhD