[metaprogramowanie] przypisanie walidacji do obiektu zamiast klasy

Witam
Czy jest w ogóle coś takiego możliwe ? Pola dla obiektu definiowane są dynamicznie, dlatego chciałbym przypisać inne walidacje dla danego obiektu, teraz mam taki kod i niestety po zapisie jednego obiektu drugi nie chce się zapisać właśnie z powodu walidacji ?

[code=ruby]module SerializeExtensions
def self.included(base)
base.before_validation :add_validations
end

def add_validations
self.class.class_eval do
validates_with SerializedPresenceValidator, fields: fields_not_blank
end
end
end

class SerializedPresenceValidator < ActiveModel::Validator
def validate(record)
options[:fields].select {|f| record.send(f).blank? }.each do |field|
record.errors[field] << “can’t be blank”
end
end
end[/code]
Próbowałem self.class.instance_eval, niestety nie pomogło. Przy każdej próbie zapisu walidacje przypisywane są wielokrotnie, jak można zrobić żeby były tylko raz?
Co ciekawe jeśli zmienie coś w walidatorze to przechodzi, pewnie wtedy klasa jest ładowana ponownie i poprzednie walidacje sie resetują.

http://railscasts.com/episodes/62-hacking-activerecord/

Why oh God why?

Generalnie rozwiązania są dwa(a nawet trzy)
a) Dziedziczenie jak człowiek (nie mogę uwierzyć że te pola są zupełnie losowe)
b) nadpisanie metody #validate zdaje się i prowadzenie walidacji ręcznie
c) Dodanie :if do validatora np.

module SerializeExtensions def self.included(base) fields_not_blank.each do |field| validate field, :presence => {:if => lambda{ respond_to?(field) }} end end end
Pisane z głowy mogą być błędy ale generalnei nei wiem czemu dodawać nowy validator jak jest już jeden presence.

  1. rozumiem, że dane do pól trzymasz zserializowane w 1 kolumnie?
  2. wrzuć kod odpowiedzialiny za dynamiczne tworzenie pól dla obiektu

A i dokładnie to co Świstak napisał - cięzko mi wyobrazić sobie sytuację kiedy sam :if nie wystarczy …

Cel jest taki że użytkownik może budować własne formularze za pomocą edytora w javascripcie. Dane z edytora zapisywane są jako JSON w jednej kolumnie modelu form.
Gdy ktoś wypełni formularz rekord odpowiedzi zapisuje dane także jako JSON, wcześniej tez kopiuje sobie do kolumny form_structure strukturę pól z modelu form. Moim celem było zasymulowanie żeby Response traktował pola które są w data tak jak normalne atrybuty, czyli odczyt i zapis artrybutu, validacja itp, no i z grubsza się udało poza tymi nachodzącymi na siebie walidacjami.
Nie moge użyć tutaj dziedziczenia bo typów formularzy mogą być setki, każdy inne pola.
Oto kod modułu, kontrolera oraz modelu:

[code=ruby]module SerializeExtensions

def self.included(base)
base.after_initialize :collect_fields, :add_accessors, unless: lambda {|r| r.form_structure.blank? }
base.before_validation :add_validations, unless: lambda {|r| r.form_structure.blank? }
end

private

def collect_fields
@fields = JSON.parse(form_structure)
end

def add_accessors
serialized_attr_accessor :data, @fields.collect {|e| e[‘name’]}
end

def add_validations
fields_not_blank = @fields.select {|f| f[‘required’] == ‘checked’}.collect {|f| f[‘name’]}

unless fields_not_blank.empty?
  self.class.instance_eval do
    validates_with SerializedPresenceValidator, fields: fields_not_blank
  end
end

end

def serialized_attr_accessor(serialized, accessors)
accessors.each do |k|
self.class.instance_eval do
define_method("#{k}") do
self[serialized] && self[serialized][k]
end

    define_method("#{k}=") do |value|
      self[serialized] ||= {}
      self[serialized][k] = value
    end
  end
end

end

end

class ResponsesController < ApplicationController

def create
@response = @form.responses.build(data: params[:response])
@response.user = current_user

if @response.save
  redirect_to forms_path
else
  render :new
end

end

end

class Response < ActiveRecord::Base

serialize :data, JSON

attr_accessible :data, :form_structure
belongs_to :form
belongs_to :user

after_initialize :attach_current_form_structure

validate :data, :form_id, :form_structure, :user_id, presence: true

include SerializeExtensions

private

def attach_current_form_structure
self.form_structure = self.form.structure if self.form
end

end[/code]

Nie jestem pewien, czy dobrze wszystko rozumiem, ale wydaje mi się, że tak naprawdę potrzebujesz tylko dodać zwykły walidator, który będzie przechodził po wszystkich dynamicznych polach i sprawdzał, czy są poprawnie wypełnione. Nie widzę tutaj potrzeby przypisywania osobnych metod walidacji każdemu obiektowi.

[code=ruby]class Model
validate :check_dynamic_fields

def check_dynamic_fields
dynamic_fields.each do |df|
if self.send(df.to_sym).blank?
errors.add(df.to_sym, “can’t be blank”)
end
end
end
end[/code]
No i o czywiście te metody dynamiczne należy wcześniej zdefiniować, np. tak jak to robisz, w after_initialize

Dzięki wielkie ! Proste i skuteczne rozwiązanie, dorzuciłem to do modułu.
Jeszcze będe musiał sprawdzić czy moje dynamiczne dodawanie atrybutów działa na produkcji, gdzie klasy są cachowane. Chodzi o to żeby wszystkie obiekty tej klasy nie otrzymywały atrybutów innych obiektów, ale tylko te swoje. Do wyboru jest tutaj self.class.instance_eval, self.class.class_eval lub rozwiazanie z punktu 6 tutaj http://codeblog.dhananjaynene.com/2010/01/dynamically-adding-methods-with-metaprogramming-ruby-and-python/ i do niego zaczynam sie skłaniać.

@Artut79 jakiej bazy używasz? Bo jak postgres to mógłbyś użyć hstore.

@slawosz: wiem, nadawał by się idealnie, jednak w tym projekcie mam narzuconą już baze
Mam teraz problem z factory, jak utworzyć att_accessory dla tych dynamicznych atrybutów obiektu ? Najlepiej takie żeby nie były przypisane do klasy (bo wtedy każdy nowy obiekt będzie je miał niepotrzebnie), tylko do obiektu.
Poza tym wygląda na to że podczas próby utworzenia factory dla modelu Response after_initialize nie jest wcale odpalane.

@Artur79 nie możesz zmockować? Co konkretnie chcesz testować?

to są akurat testy funkcjonalne, więc wygodnie by było mieć factory dla Response, obecnie kod (troche uproszczony żeby przykład był czytelniejszy, pól jest więcej) wygląda tak:

[code=ruby] factory :form do
sequence(:name) {|n| “My Form#{n}” }
user
structure ‘[{“cssClass”:“text_field”,“name”:“name”,“required”:“checked”,“values”:“Name”},{“cssClass”:“select”,“name”:“animal”,“required”:“checked”,“title”:“Animal”,“values”:{“2”:{“value”:“dog”,“baseline”:“undefined”},“3”:{“value”:“cat”,“baseline”:“checked”},“4”:{“value”:“hamster”,“baseline”:“undefined”}}}]’
end

factory :response do
user
form
form_structure ‘[{“cssClass”:“text_field”,“name”:“name”,“required”:“checked”,“values”:“Name”},{“cssClass”:“select”,“name”:“animal”,“required”:“checked”,“title”:“Animal”,“values”:{“2”:{“value”:“dog”,“baseline”:“undefined”},“3”:{“value”:“cat”,“baseline”:“checked”},“4”:{“value”:“hamster”,“baseline”:“undefined”}}}]’
sequence(:data) {|n| {name: “Responder #{n}”, animal: “cat” }}
end[/code]
Próba utworzenia obiektu za pomocą factory girl daje błąd

[quote]Failure/Error: @response = create :response, form: @form, user: @user
ActiveRecord::UnknownAttributeError:
unknown attribute: name[/quote]
Czyli, jak wspomniałem wyżej, podejrzewam że FactoryGirl nie odpala after initialize, gdzie te atrybuty są dodawane do obiektu.

Chciałbym dodatkowo dodać do mojego modułu dynamiczne załączanie obrazków z paperclipa, jednak mam problem z dodaniem has_attached_file, próbowałem czegoś takiego

[code=ruby] def self.included(base)
base.after_initialize :set_file_upload_params
end

def set_file_upload_params
self.class.send(:attr_accessible, :photo)
self.class.send(:has_attached_file, :photo, styles: { orignal: “200x200>”, medium: “73x73>”, thumb: “48x48>” }, url: “/files/:id/:attachment/:style/:basename.:extension”)
end[/code]
i dostaje: unknown attribute: photo

Może w ogóle podejście jest złe. Chodzi o to że nie wiem jak będzie się nazywało pole typu file upload, to dopiero użytkownik zdefiniuje tworząc formularz, takich pól może być też kilka, każde o innej nazwie. Po wyświetleniu formularza i kliknięciu zapisu, chciałbym zapisać plik w systemie plików, najlepiej za pomocą Paperclipa a kolumny z danymi pliku (image_file_name itd…) zamiast tworzyć za pomocą migracji dla Paperclipa umieścić w tej kolumnie gdzie mam inne zserializowane pola.