Alejandro Babio
Alejandro Babio

Reputation: 5229

Create and Update Model with 2 related Models

I have this models

class Book < ActiveRecord::Base
  has_many :accountings
  has_many :commodities, through: :accountings
end

class Accounting < ActiveRecord::Base
  belongs_to :book
  belongs_to :commodity
end

class Commodity < ActiveRecord::Base
  has_many :accountings
end

My book form is:

<%= form_for(book) do |f| %>
  <%= render 'shared/error_messages', object: book %>
  <%= f.label :name %>
  <%= f.text_field :name %>
  <%= f.label :commodity_ids %>
  <%= f.collection_select :commodity_ids, Commodity.all, :id, :name, {}, {multiple: true} %>
  <br />
  <%= f.submit class: 'btn btn-primary' %>
<% end %>

At the BooksController:

  def new
    @book = Book.new
  end

  def create
    @book = Book.new(book_params)
    if @book.save
      redirect_to @book
    else
      render 'new'
    end
  end

  def edit
    @book = Book.find(params[:id])
  end

  def update
    @book = Book.find(params[:id])
    if @book.update_attributes(book_params)
      redirect_to @book
    else
      render 'edit'
    end
  end

  private

    def book_params
      params.require(:book).permit(:name, commodity_ids: [])
    end

All this works fine. And Accounting records are added and deleted when commodity_ids are updated. (1)

Now I need to add a new model: Company, since Book and Commodity are shared by all companies, Accounting must belongs to company, and also not shared at all the system. And Accounting becomes:

class Accounting < ActiveRecord::Base
  belongs_to :book
  belongs_to :commodity
  belongs_to :company
end

The Company model:

class Company < ActiveRecord::Base
  has_many :accountings
end

Accounting is more than a relation between commodities and books (and companies), it represent a business model too.

The constraint here is: When a new Commodity is added to a Book, then a new Accounting must be created for each Company. (2)

I did try to relate Book with companies, through accountings. But, it does not work. Even it does not represent the business model and Book don't care about Company, I think Book is a good candidate for link the models. (3)

Now I'm thinking add a new model BookCommodity, that relate books and commodities through this model, and on save this new model generate the Accounting records needed for all companies. (4)

Before add this fifth model I want ask you if there are a better way to manage this stuff?

Edited

At Github you can find a demo_finance project, with only the code of this post. It has 4 branchs:

The key task here is add or remove commodities to a book, and get accountings updated, just like it works at version (1) (master branch).

Edit #2

I tried to do this:

class Book < ActiveRecord::Base
  has_many :accountings
  has_many :commodity_companies, through: :accountings
  has_many :commodities, through: :commodity_companies
end

but it doesn't work. On update it raises:

ActiveRecord::HasManyThroughNestedAssociationsAreReadonly
  Cannot modify association 'Book#commodities' because it goes through more than one other association.

I also try to do this:

class Book < ActiveRecord::Base
  has_many :accountings do
    def build(attributes = {}, &block)
      Company.all.each do |company|
        @association.build(attributes.merge(company_id: company.id), &block)
      end
    end
  end
  has_many :commodities, through: :accountings
end

But, this build action is not called on update books with commodity_ids=.

Upvotes: 9

Views: 795

Answers (3)

Richard_G
Richard_G

Reputation: 4820

This is a perfect application for the acts_as_tenant gem. I have this gem running in a production application and it is an excellent solution. You can basically take your original working solution and replicate it for each company within your system just by using this gem. Each company's data would be isolated to its own system within a single database.

The documentation is excellent but I would be happy to answer specific questions about the implementation. I combine this gem with Sorcery and Rolify for authentication and authorization, but that's up to you. I can have many companies on one image. In your case, your firm could easily switch to different companies but you could allow individual companies access to only their data.

It's an elegant but simple solution to such a complex issue.

Upvotes: 0

Alejandro Babio
Alejandro Babio

Reputation: 5229

Answering my own question:

I did not select this answer as the right one yet. Because, although it works, I think there could be better answers, which would deserve the bounty.

I made it work overwriting the method commodity_ids=, also is needed to add dependent: :destroy option to accountings, and scope -> {uniq} to commodities.

class Book < ActiveRecord::Base
  has_many :accountings, dependent: :destroy
  has_many :commodities, ->{ uniq }, through: :accountings

  def commodity_ids=(ids)
    self.accountings = Commodity.where(id: ids).map do |commodity|
      Company.all.map do |company|
        accountings.find_by(company_id: company.id, commodity_id: commodity.id) ||
          accountings.build(company: company, commodity: commodity)
      end
    end.flatten
  end

  def commodities=(records)
    self.commodity_ids = records.map(&:id)
  end
end

class Commodity < ActiveRecord::Base
end

class Company < ActiveRecord::Base
end

class Accounting < ActiveRecord::Base
  belongs_to :book
  belongs_to :commodity
  belongs_to :company

  def to_s
    "#{book.name} - #{company.name} - #{commodity.name}"
  end
end

Running at the console from scratch:

~/ (main) > Book.create name: 'Book 1'
=> #<Book id: 1, name: "Book 1", created_at: "2015-09-30 11:47:23", updated_at: "2015-09-30 11:47:23">
~/ (main) > Commodity.create name: 'Commodity 1'
=> #<Commodity id: 1, name: "Commodity 1", created_at: "2015-09-30 11:47:37", updated_at: "2015-09-30 11:47:37">
~/ (main) > Commodity.create name: 'Commodity 2'
=> #<Commodity id: 2, name: "Commodity 2", created_at: "2015-09-30 11:47:40", updated_at: "2015-09-30 11:47:40">
~/ (main) > Commodity.create name: 'Commodity 3'
=> #<Commodity id: 3, name: "Commodity 3", created_at: "2015-09-30 11:47:42", updated_at: "2015-09-30 11:47:42">
~/ (main) > Company.create name: 'Company 1'
=> #<Company id: 1, name: "Company 1", created_at: "2015-09-30 11:47:51", updated_at: "2015-09-30 11:47:51">
~/ (main) > Company.create name: 'Company 2'
=> #<Company id: 2, name: "Company 2", created_at: "2015-09-30 11:47:54", updated_at: "2015-09-30 11:47:54">
~/ (main) > bb = Book.first
=> #<Book id: 1, name: "Book 1", created_at: "2015-09-30 11:47:23", updated_at: "2015-09-30 11:47:23">
~/ (main) > bb.commodity_ids = ['', nil, 1, '3']
=> [
    [0] "",
    [1] nil,
    [2] 1,
    [3] "3"
]
~/ (main) > bb.save
=> true
~/ (main) > bb.reload
=> #<Book id: 1, name: "Book 1", created_at: "2015-09-30 11:47:23", updated_at: "2015-09-30 11:47:23">
~/ (main) > bb.accountings.map(&:to_s)
=> [
    [0] "Book 1 - Company 1 - Commodity 1",
    [1] "Book 1 - Company 2 - Commodity 1",
    [2] "Book 1 - Company 1 - Commodity 3",
    [3] "Book 1 - Company 2 - Commodity 3"
]
~/ (main) > bb.commodities.map(&:name)
=> [
    [0] "Commodity 1",
    [1] "Commodity 3"
]
~/ (main) > bb.commodity_ids = ['']
=> [
    [0] ""
]
~/ (main) > bb.save
=> true
~/ (main) > bb.reload
=> #<Book id: 1, name: "Book 1", created_at: "2015-09-30 11:47:23", updated_at: "2015-09-30 11:47:23">
~/ (main) > bb.commodities.map(&:name)
=> []
~/ (main) > bb.accountings.map(&:to_s)
=> []
~/ (main) > 

Upvotes: 0

akbarbin
akbarbin

Reputation: 5105

It's only a solution. After, I've learned deeply your questions. I'd say you have to add all companies in your book form as multy-select options. I knew perhaps it will make it out of your idea now but you have to follow about rails flow. You've said that you want to update commodities through accounting based on companies and then you want to update data automatically but your code don't provide to do these.

OPTION 1

This is the suggestion:

Controller

Add new strong parameter

def book_params
  params.require(:book).permit(:name, commodity_ids: [], company_ids: [])
end

Model

You need to revised some associations

class Book < ActiveRecord::Base
  has_many :accountings
  has_many :commodities, through: :accountings
  has_many :companies, through: :accountings
end

class Accounting < ActiveRecord::Base
  belongs_to :book
  belongs_to :commodity
  belongs_to :company
end

class Commodity < ActiveRecord::Base
  has_many :accountings
  has_many :companies, through: :accountings
  has_many :books, through: :accountings
end

class Company < ActiveRecord::Base
  has_many :accountings
  has_many :books through: :accountings
  has_many :commodites through: :accountings
end

View

You can select all companies as selected in order to user don't need to select options (optional)

<%= form_for(book) do |f| %>
  <%= render 'shared/error_messages', object: book %>
  <%= f.label :company_ids %>
  <%= f.collection_select :company_ids, Company.all, :id, :name, {:selected: Company.all.map(&:id)}, {multiple: true} %>
  <%= f.label :name %>
  <%= f.text_field :name %>
  <%= f.label :commodity_ids %>
  <%= f.collection_select :commodity_ids, Commodity.all, :id, :name, {}, {multiple: true} %>
  <br />
  <%= f.submit class: 'btn btn-primary' %>
<% end %>

As addition, try not to create BookCommodity to create Accounting because BookCommodity is equal to Accounting

OPTION 2

For another option if you want to not show in your form you can add in your model in before_save callback. This is only

before_save :set_companies

def set_companies
  self.company_ids = Company.all.map(&:id)
end

Conclusion:

Those are having pros and cons. In first options, user can select the company but the user need to choose the company first. Last one, the user don't choose the companies but he will get difficulties when the user want to update the company of the book or commodity. As always, I would prefer to you to use the first one way because the user will easy to edit. That's all. I hope this can help you.

Upvotes: 0

Related Questions