19

Partials in XML builder are proving to be non-trivial.

After some initial Google searching, I found the following to work, although it's not 100%

 xml.foo do
     xml.id(foo.id)
     xml.created_at(foo.created_at)
     xml.last_updated(foo.updated_at)
     foo.bars.each do |bar|
         xml << render(:partial => 'bar/_bar', :locals => { :bar => bar })
     end
 end

this will do the trick, except the XML output is not properly indented. the output looks something similar to:

<foo>
  <id>1</id>
  <created_at>sometime</created_at>
  <last_updated>sometime</last_updated>
<bar>
  ...
</bar>
<bar>
  ...
</bar>
</foo>

The <bar> element should align underneath the <last_updated> element, it is a child of <foo> like this:

<foo>
  <id>1</id>
  <created_at>sometime</created_at>
  <last_updated>sometime</last_updated>
  <bar>
    ...
  </bar>
  <bar>
    ...
  </bar>
</foo>

Works great if I copy the content from bar/_bar.xml.builder into the template, but then things just aren't DRY.

randombits
  • 47,058
  • 76
  • 251
  • 433

3 Answers3

32

I worked around this by passing in the builder reference as a local in the partial. No monkey patching needed. Using the original example:

xml.foo do
     xml.id(foo.id)
     xml.created_at(foo.created_at)
     xml.last_updated(foo.updated_at)
     foo.bars.each do |bar|
         render(:partial => 'bar/_bar', :locals => {:builder => xml, :bar => bar })
     end
 end

Then in your partial make sure to use the 'builder' object.

builder.bar do
  builder.id bar.id
end

Also, the above appears to only work up to Rails 4. Rails 5 and up see @srghma's comment below

Alex Soto
  • 6,167
  • 1
  • 20
  • 7
  • 2
    Nice, thanks Alex! One caveat: make sure you rename the xml variable in the locals hash. I was doing `:locals => { :xml => xml }` and that did NOT work. – ipd Nov 15 '12 at 00:43
  • @srghma please expand on your response to improve the solution. thx – Alex Soto May 07 '18 at 17:21
  • Explain: this is correct answer + I've noticied a quirk in rails - `render 'asdf', xml: xml` not work in rails 5, only `render 'asdf', builder: xml` – srghma May 07 '18 at 17:31
21

There is unfortunately not a straight-forward solution to this. When looking at the code that ActionPack will initialize the Builder object with then the indent size is hard-coded to 2 and the margin size is not set. Its a shame that there is no mechanism to override this at present.

The ideal solution here would be a fix to ActionPack to allow these options to be passed to the builder but this would require some time investment. I have 2 possible fixes for you. Both dirty you can take your pick which feels less dirty.

Modify the rendering of the partial to render to a string and then do some Regex on it. This would look like this

_bar.xml.builder

xml.bar do
  xml.id(bar.id)
  xml.name(bar.name)
   xml.created_at(bar.created_at)
   xml.last_updated(bar.updated_at)
end

foos/index.xml.builder

xml.foos do
  @foos.each do |foo|
    xml.foo do
      xml.id(foo.id)
      xml.name(foo.name)
      xml.created_at(foo.created_at)
      xml.last_updated(foo.updated_at)
      xml.bars do
        foo.bars.each do |bar|
          xml << render(:partial => 'bars/bar', 
                 :locals => { :bar => bar } ).gsub(/^/, '      ')
        end
      end
    end
  end
end

Note the gsub at the end of render line. This produces the following results

<?xml version="1.0" encoding="UTF-8"?>
<foos>
  <foo>
    <id>1</id>
    <name>Foo 1</name>
    <created_at>2010-06-11 21:54:16 UTC</created_at>
    <last_updated>2010-06-11 21:54:16 UTC</last_updated>
    <bars>
      <bar>
        <id>1</id>
        <name>Foo 1 Bar 1</name>
        <created_at>2010-06-11 21:57:29 UTC</created_at>
        <last_updated>2010-06-11 21:57:29 UTC</last_updated>
      </bar>
    </bars>
  </foo>
</foos>

That is a little hacky and definitely quite dirty but has the advantage of being contained within your code. The next solution is to monkey-patch ActionPack to get the Builder instance to work the way we want

config/initializers/builder_mods.rb

module ActionView
  module TemplateHandlers
    class BuilderOptions
      cattr_accessor :margin, :indent
    end
  end
end

module ActionView
  module TemplateHandlers
    class Builder < TemplateHandler

      def compile(template)
        "_set_controller_content_type(Mime::XML);" +
          "xml = ::Builder::XmlMarkup.new(" +
          ":indent => #{ActionView::TemplateHandlers::BuilderOptions.indent}, " +
          ":margin => #{ActionView::TemplateHandlers::BuilderOptions.margin});" +
          "self.output_buffer = xml.target!;" +
          template.source +
          ";xml.target!;"
      end
    end
  end
end

ActionView::TemplateHandlers::BuilderOptions.margin = 0
ActionView::TemplateHandlers::BuilderOptions.indent = 2

This creates a new class at Rails initialisation called BuilderOptions whose sole purpose is to host 2 values for indent and margin (although we only really need the margin value). I did try adding these variable as class variables directly to the Builder template class but that object was frozen and I couldn't change the values.

Once that class is created we patch the compile method within the TemplateHandler to use these values.

The template then looks as follows :-

xml.foos do
  @foos.each do |foo|
    xml.foo do
      xml.id(foo.id)
      xml.name(foo.name)
      xml.created_at(foo.created_at)
      xml.last_updated(foo.updated_at)
      xml.bars do
        ActionView::TemplateHandlers::BuilderOptions.margin = 3    
        foo.bars.each do |bar|
          xml << render(:partial => 'bars/bar', :locals => { :bar => bar } )
        end
        ActionView::TemplateHandlers::BuilderOptions.margin = 0
      end
    end
  end
end

The basic idea is to set the margin value to the indentation level that we are at when rendering the partial. The XML generated is identical to that shown above.

Please do not copy/paste this code in without checking it against your Rails version to ensure that they are from the same codebase. (I think the above is 2.3.5)

Steve Weet
  • 28,126
  • 11
  • 70
  • 86
  • I like the monkey patch route, although a caveat: upgrading rails could possibly break this patch or new functionality. – Joseph Yaduvanshi Jun 14 '10 at 13:28
  • Indeed I expect this would not work in Rails3 (Without looking) , that was why I commented that this was specific to 2.3.5. It's a shame there isn't a straight-forward method to hook in this behaviour as I have seen other people wanting to set indentation to 0 and this would solve their case as well. – Steve Weet Jun 14 '10 at 16:27
0

Maybe you should do:

xml.foo do
  xml.id(foo.id)
  xml.created_at(foo.created_at)
  xml.last_updated(foo.updated_at)
  xml.bars do
    foo.bars.each do |bar|
      xml.bar bar.to_xml # or "xml.bar render(:xml => bar)"
                         # or "xml.bar render(bar)" (loads bar/_bar partial)
    end
  end
end

Have a look at this link about the xml builder.

In the last alternative you could replace the inner loop with:

xml.bars render(foo.bars) # will loop over bars automatically using bar/_bar

You can probably also try:

xml << foo.to_xml(:include => :bars)

if you want to include all fields in the result.

I'm not sure about the indention of all these, so you may need to fall back to create the contents of the inner loop the same way as you do in the outer block, eg using no partial.

hurikhan77
  • 5,881
  • 3
  • 32
  • 47