Search code examples
ruby-on-railsdynamicactiveadminjsonschemajsonb

activeadmin and dynamic store accessors fails on new resource


I want to generate forms for a resource that has a postgres jsonb column :data, and I want the schema for these forms to be stored in a table in the database. After a lot of research I am 90% there but my method fails in ActiveAdmin forms upon create (not update). Can anyone explain this?

Sorry for the long code snippets. This is a fairly elaborate setup but I think it would be of some interest since if this works one could build arbitrary new schemas dynamically without hard-coding.

I am following along this previous discussion with Rails 6 and ActiveAdmin 2.6.1 and ruby 2.6.5.

I want to store Json Schemas in a table SampleActionSchema that belong_to SampleAction (using the json-schema gem for validation)

class SampleActionSchema < ApplicationRecord
  validates :category, uniqueness: { case_sensitive: false }, allow_nil: false, allow_blank: true
  validate :schema_is_json_schema

  private

  def schema_is_json_schema
    metaschema = JSON::Validator.validator_for_name("draft4").metaschema
    unless JSON::Validator.validate(metaschema, schema)
       errors.add :schema, 'not a compliant json schema'
    end
  end
end
class SampleAction < ActiveRecord::Base
  belongs_to :sample

  validate :is_sample_action
  validates :name, uniqueness: { case_sensitive: false }

  after_initialize :add_field_accessors
  before_create :add_field_accessors
  before_update :add_field_accessors

  def add_store_accessor field_name
    singleton_class.class_eval {store_accessor :data, field_name.to_sym}
  end

  def add_field_accessors
    num_fields = schema_properties.try(:keys).try(:count) || 0
    schema_properties.keys.each {|field_name| add_store_accessor field_name} if num_fields > 0
  end


  def schema_properties

    schema_arr=SampleActionSchema.where(category: category)
    if schema_arr.size>0
      sc=schema_arr[0]
      if !sc.schema.empty?
        props=sc.schema["properties"]
      else
        props=[]
      end
    else
      []
    end
  end
  private

  def is_sample_action
    sa=SampleActionSchema.where(category: category)
    errors.add :category, 'not a known sample action' unless (sa.size>0)
    errors.add :base, 'incorrect json format' unless (sa.size>0) && JSON::Validator.validate(sa[0].schema, data)
  end

end

This all works correctly; For example, for a simple schema called category: "cleave", where :data looks like data: {quality: "good"}, I can create a resource as follows in the rails console:

sa=SampleAction.new(sample_id: 6, name: "test0", data: {}, category: "cleave" )
=> #<SampleAction id: nil, name: "test0", category: "cleave", data: {}, created_at: nil, updated_at: nil, sample_id: 6> 

sa.quality = "good"   => true
sa.save => true

To make this system work in AA forms, I call the normal path (new or edit)_admix_sample_action_form with params: {category: "cleave"} and then I generate permit_params dynamically:

ActiveAdmin.register SampleAction, namespace: :admix do

  permit_params do
    prms=[:name, :category, :data, :sample_id, :created_at, :updated_at]
    #the first case is creating a new record (gets parameter from admix/sample_actions/new?category="xxx"
    #the second case is updating an existing record
    #falls back to blank (no extra parameters)
    categ = @_params[:category] || (@_params[:sample_action][:category] if @_params[:sample_action]) || nil
    cat=SampleActionSchema.where(category: categ)
    if cat.size>0 && !cat[0].schema.empty?
      cat[0].schema["properties"].each do |key, value|
        prms+=[key.to_sym]
      end
    end
    prms
  end

form do |f|
    f.semantic_errors
    new=f.object.new_record?
    cat=params[:category] || f.object.category
    f.object.category=cat if cat && new
    f.object.add_field_accessors if new
    sas=SampleActionSchema.where(category: cat)
    is_schema=(sas.size>0) && !sas[0].schema.empty?
    if session[:active_sample]
      f.object.sample_id=session[:active_sample]
    end

    f.inputs "Sample Action" do
      f.input :sample_id
      f.input :name
      f.input :category
      if !is_schema
        f.input :data, as: :jsonb
      else
        f.object.schema_properties.each do |key, value|
        f.input key.to_sym, as: :string
        end
      end
    end
    f.actions
  end

Everything works fine if I am editing an existing resource (as created in the console above). The form is displayed and all the dynamic fields are updated upon submit. But when creating a new resource where e.g. :data is of the form data: {quality: "good"} I get

ActiveModel::UnknownAttributeError in Admix::SampleActionsController#create
unknown attribute 'quality' for SampleAction.

I have tried to both add_accessors in the form and to override the new command to add the accessors after initialize (these should not be needed because the ActiveRecord callback appears to do the job at the right time).

def new
  build_resource
  resource.add_field_accessors
  new!
end

Somehow when the resource is created in the AA controller, it seems impossible to get the accessors stored even though it works fine in the console. Does anyone have a strategy to initialize the resource correctly?


Solution

  • SOLUTION:

    I traced what AA was doing to figure out the minimum number of commands needed. It was necessary to add code to build_new_resource to ensure that any new resource AA built had the correct :category field, and once doing so, make the call to dynamically add the store_accessor keys to the newly built instance.

    Now users can create their own original schemas and records that use them, without any further programming! I hope others find this useful, I certainly will.

    There are a couple ugly solutions here, one is that adding the parameters to the active admin new route call is not expected by AA, but it still works. I guess this parameter could be passed in some other way, but quick and dirty does the job. The other is that I had to have the form generate a session variable to store what kind of schema was used, in order for the post-form-submission build to know, since pressing the "Create Move" button clears the params from the url.

    The operations are as follows: for a model called Move with field :data that should be dynamically serialized into fields according to the json schema tables, both admin/moves/new?category="cleave" and admin/moves/#/edit find the "cleave" schema from the schema table, and correctly create and populate a form with the serialized parameters. And, direct writes to the db

    m=Move.new(category: "cleave")          ==> true
    m.update(name: "t2", quality: "fine")   ==> true
    

    work as expected. The schema table is defined as:

    require "json-schema"
    class SampleActionSchema < ApplicationRecord
      validates :category, uniqueness: { case_sensitive: false }, allow_nil: false, allow_blank: true
      validate :schema_is_json_schema
    
      def self.schema_keys(categ)
        sas=SampleActionSchema.find_by(category: categ)
        schema_keys= sas.nil? ? [] : sas[:schema]["properties"].keys.map{|k| k.to_sym}
      end
    
      private
    
      def schema_is_json_schema
        metaschema = JSON::Validator.validator_for_name("draft4").metaschema
        unless JSON::Validator.validate(metaschema, schema)
           errors.add :schema, 'not a compliant json schema'
        end
      end
    end
    

    The Move table that employs this schema is:

    class Move < ApplicationRecord
      after_initialize :add_field_accessors
    
      def add_field_accessors
        if category!=""
          keys=SampleActionSchema.schema_keys(category)
          keys.each {|k| singleton_class.class_eval{store_accessor :data, k}}
        end
      end
    end
    

    Finally, the working controller:

    ActiveAdmin.register Move do
      permit_params do
        #choice 1 is for new records, choice 2 is for editing existing
        categ = @_params[:category] || (@_params[:move][:category] if @_params[:move]) || ""
        keys=SampleActionSchema.schema_keys(categ)
        prms = [:name, :data] + keys
      end
    
      form do |f|
        new=f.object.new_record?
        f.object.category=params[:category] if new
        if new
          session[:current_category]=params[:category]
          f.object.add_field_accessors
        else
          session[:current_category] = ""
        end
        keys=SampleActionSchema.schema_keys(f.object.category)
        f.inputs do
          f.input :name
          f.input :category
          keys.each {|k| f.input k}
        end
        f.actions
      end
    
      controller do
       def build_new_resource
        r=super
        r.assign_attributes(category: session[:current_category])
        r.add_field_accessors
        r
       end
      end
    end