Rails Active Record Lameness
I know this is sacrilegious, but there is some serious lameness going on in ActiveRecord I’ve dealt with lately. Maybe I’m drawing outside of the Rails lines (going off the tracks?), but ActiveRecord seems to go out of its way to make things a pain in the ass.
AR::Base#sanitize_sql being a ‘protected’ method has always been a burr in people’s sides. This means you can’t call it yourself on your own piece of SQL. Presumably it is done this way so people HAVE to do it the Rails way, whether that means duplicating a bunch of code, or taking a lot more time for a one-off project, etc.
Currently I’m working a report generator for Flex, where the Flex app handles the SQL generation and passes back an XML version of the sql options like:
<query>
<name>
</name>
<sql>
<select>
<![CDATA[Date(created_at) as date]]>
</select>
<select>
<![CDATA[count(id) as total]]>
</select>
<from>
</from>
<conditions>
</conditions>
<group>
<![CDATA[date]]>
</group>
<having>
<![CDATA[date >= :start_date and date <= :end_date]]>
</having>
</sql>
</query> Now we can argue all day about the best way to do this, but the reality is that only Admin authenticated people are going to see these reports, so the fact that someone could send arbitrary sql against the database is outweighed by the Flex-ibility of being able to dynamically adjust the query values in Flex without having to create a custom server-side method for each report. It is easier in my case to let more readily available/cheaper Flex programmers handle this than more expensive Ruby coders.
Getting this accomplished in Rails led to 5 workarounds in the code:
def replace_named_bind_variables_no_quotes(statement, bind_vars) #:nodoc:
statement.gsub!(/:(\w+)/) do
match = $1.to_sym
if bind_vars.include?(match)
bind_vars[match]
else
raise ActiveRecord::PreparedStatementInvalid, "missing value for :#{match} in #{statement}"
end
end
end
def query
# from_xml puts it in something like { queries => {query => [{name, sql}, {name,sql}....] }}
queries = Hash.from_xml(params[:queries])['queries']['query']
# logger.dbg queries.inspect
# generate the report structure
report = []
queries.each { |data|
query = data['sql']
logger.dbg query.inspect
# Now we need to get around a bunch of ActiveRecord lameness....
# Presumably it is done this way to satisy someone's idea of 'how you should do things'
# rather than, 'let's help them do it, no matter how they want to get it done'
# first, AR doesn't do bind variables for anything but conditions...
replace_named_bind_variables_no_quotes(query['group'], params) if query['group']
replace_named_bind_variables_no_quotes(query['having'], params) if(query['having'])
# second, we need to join the select clauses, as :select doesn't accept an array...
query['select'] = query['select'].join(', ')
# third, AR doesn't support a separate HAVING clause, you have to attach it to GROUP BY
if query['having'] and query['group'] # you always have both...
query['group'] = query['group'] + " HAVING " + query['having']
query.delete('having')
end
# fourth, we need to intern the keys so that they pass 'inspection' by AR
interned_query = {}
query.each { |key, value|
interned_query[key.intern] = value if(value != nil) # AR doesnt like :conditions => nil either...
}
logger.dbg interned_query.inspect
# fifth, we need to use Creative instead of just ActiveRecord::Base because there is a bug/weirdness in reset_table_name
# where it can't find the abstract_class
report << [data['name'], Creative.find(:all, interned_query)]
}
# output the results xml
str = ''
xml = Builder::XmlMarkup.new(:target => str, :indent => 1)
xml.result {
report.each { |query|
xml.query {
keys = []
xml.name query[0]
xml.cols {
exemplar = query[1].first
exemplar.attributes.each { |key, value|
xml.col key
keys << key
}
}
xml.rows {
query[1].each { |row|
xml.r {
keys.each { |key|
xml.v row.attributes_before_type_cast[key]
}
}
}
}
}
}
}
render :xml => str
end
Ruby on Rails AJAX Back button support using Script.aculo.us and Prototype
So I don't really want to get into a debate on 'breaking' the back button with full javascript/AJAX based sites, instead of having pages use regular navigation and only using AJAX within the same page. Let's just assume you are here because you need to fix the back button for your Rails site after using link_to_remote throughout. There are a number of solutions out there for various javascript toolkits, but I never was able to find anything that was terribly helpful for Prototype/Scriptaculous. So I took some of the existing ones out there and adapted them to the Rails environment.
This code uses the same basic methodology as the others out there: manipulating the location bar URL so that it has a hash (#) in it with additional information about the AJAX link which creates back and forward history entries when it is changed. It uses an iframe to support this functionality in IE6 and IE7, otherwise using location.hash manipulation in Firefox, Opera, and Safari 3. A thread (PeriodicalExecuter) is created that checks for changes to the location bar and makes a new Ajax request if needed). Both single step back and forward buttons work, but using the back and forward drop downs will not. Reload/Refresh also works properly, as does Open in New Window/Tab right click and Bookmarks. It simply does normal link_to_remote processing for other browsers, such as old versions of Safari/WebKit (so no history support but the site still works).
The key difference between this method and the others however, is that instead of using some obscure mapping or number for the hash, we simply append all of the Rails controller/action/id?params information after the hash, so if you click an AJAX link http://my.domain.com/controller/action/id?param1=something your location bar will change to http://my.domain.com/#/controller/action/id?param1=something
In the Rails code, you don't always want to have a history/back button entry created for every AJAX call we make, so to differentiate we replace link_to_remote with history_remote whenever we wish to generate a browser history entry. The format is identical, it just adds an extra parameter to the request: history: true. Another key feature to note is that link_to_remote :update => 'some_div' also works with this code (when used as history_remote :update => 'some_div').
///////////////////////////////////////////////////////////
// Slain Jamison Wilde
// This source code is released into the public domain
var PrototypeBackButton = {
start: function(root, title) {
this.browser_id = 0;
this.history_clicks = 0;
this.ajax_object = null;
this.history_current_cache = "";
this.root = root; // this is the initial page we are loading.
this.title = title;
is_backable_webkit = false;
if (Prototype.Browser.WebKit) {
navigator.userAgent.match(/AppleWebKit\/([^ ]+)/)
if(parseInt(RegExp.$1) > 420) {
is_backable_webkit = true;
}
}
if (Prototype.Browser.IE) {
this.browser_id = 1;
var history = $("ie_hash_history");
var iframe = history.contentWindow.document;
iframe.open();
iframe.close();
iframe.location.hash = location.hash + location.search; // ie breaks up the hash
this.is_started = true;
if (location.hash + location.search == "" || location.hash + location.search == "#") {
new Ajax.Request(root, {asynchronous:true, history:true, evalScripts:true});
}
this.checker_thread = new PeriodicalExecuter(function() {
PrototypeBackButton.url_hash_check();
}, 0.2);
} else if (Prototype.Browser.Gecko || Prototype.Browser.Opera || is_backable_webkit == true ) {
this.browser_id = 2;
this.is_started = true;
if (location.hash == "") {
new Ajax.Request(root, {asynchronous:true, history:true, evalScripts:true});
}
this.checker_thread = new PeriodicalExecuter(function() {
PrototypeBackButton.url_hash_check();
}, 0.2);
} else {
this.is_started = false;
new Ajax.Request(root, {asynchronous:true, evalScripts:true});
}
},
// Destructor
destroy: function() {
this.stop();
},
set_root: function(root) {
this.root = root;
},
set_title: function(title) {
this.title = title;
},
stop: function() {
if (this.checker_thread) {
this.checker_thread.stop();
this.checker_thread = null;
this.is_started = false;
}
},
url_hash_check: function() {
if(this.browser_id > 0 && document.title != this.title)
// hack
document.title = this.title;
if (this.browser_id == 1) {
var history = $("ie_hash_history");
var iframe = history.contentDocument || history.contentWindow.document;
var current_hash = iframe.location.hash + iframe.location.search; // ie splits it
if(current_hash != this.history_current_cache || this.history_clicks > 0) {
this.history_clicks = 0;
location.hash = current_hash;
this.history_current_cache = current_hash;
if (this.ajax_object) {
this.ajax_object.request(current_hash.replace(/^#/, ''));
this.ajax_object = null; // dont reuse the request object
} else {
new Ajax.Request(current_hash.replace(/^#/, '')); // reloads dont have an existing object
}
}
} else if (this.browser_id == 2) {
var current_hash = location.hash;
if(current_hash != this.history_current_cache || this.history_clicks > 0) {
this.history_clicks = 0;
if (this.ajax_object) {
this.ajax_object.request(current_hash.replace(/^#/, ''));
this.ajax_object = null; // dont reuse the request object
} else {
new Ajax.Request(current_hash.replace(/^#/, '')); // reloads dont have an existing object
}
this.history_current_cache = current_hash;
}
}
},
ie_history_click: function(hash) {
var newhash = '#' + hash;
location.hash = newhash;
var history = $("ie_hash_history");
var iframe = history.contentWindow.document;
iframe.open();
iframe.close();
iframe.location.hash = newhash;
}
}
////////////////////////////////////////////////////////////You will need to modify the Prototype.js Ajax.Request.request definition to (the changed piece is simply the first 2 conditionals so you should be able to use this with updated Prototype.js versions by moving those pieces into the new version):
request: function(url) {
if (PrototypeBackButton.is_started && PrototypeBackButton.browser_id == 1 && this.options.history == true) {
PrototypeBackButton.ajax_object = this;
this.options.history = null; // prevent inf loop
PrototypeBackButton.ie_history_click(url);
PrototypeBackButton.history_clicks = 1;
} else if (PrototypeBackButton.is_started && PrototypeBackButton.browser_id == 2 && this.options.history == true) {
PrototypeBackButton.ajax_object = this;
this.options.history = null; // prevent inf loop
parent.location = '#' + url;
location.hash = '#' + url
PrototypeBackButton.history_clicks = 1;
} else {
this.url = url;
this.method = this.options.method;
var params = Object.clone(this.options.parameters);
if (!['get', 'post'].include(this.method)) {
// simulate other verbs over post
params['_method'] = this.method;
this.method = 'post';
}
this.parameters = params;
if (params = Hash.toQueryString(params)) {
// when GET, append parameters to URL
if (this.method == 'get')
this.url += (this.url.include('?') ? '&' : '?') + params;
else if (/Konqueror|Safari|KHTML/.test(navigator.userAgent))
params += '&_=';
}
try {
if (this.options.onCreate) this.options.onCreate(this.transport);
Ajax.Responders.dispatch('onCreate', this, this.transport);
this.transport.open(this.method.toUpperCase(), this.url,
this.options.asynchronous);
if (this.options.asynchronous)
setTimeout(function() { this.respondToReadyState(1) }.bind(this), 10);
this.transport.onreadystatechange = this.onStateChange.bind(this);
this.setRequestHeaders();
this.body = this.method == 'post' ? (this.options.postBody || params) : null;
this.transport.send(this.body);
/* Force Firefox to handle ready state 4 for synchronous requests */
if (!this.options.asynchronous && this.transport.overrideMimeType)
this.onStateChange();
}
catch (e) {
this.dispatchException(e);
}
}
},And here is the code for history_remote. Put this into your application_helper.rb. The code as given looks for a global variable $config['enable_back_button'] to determine if it should use the back button code. You can either define this in your environment.rb or simply remove the conditional as needed:
def history_remote(name, options = {}, html_options = {})
if $config['enable_back_button'] && $config['enable_back_button'] == true
html_options[:href] = "#" + url_for(options[:url]) # lets 'open in new tab' etc work
options[:history] = true
function = remote_function(options);
function.gsub!(/asynchronous\:true/, 'asynchronous:true, history:true')
# logger.dbg function
link_to_function(name, function, html_options)
else
link_to_remote(name, options, html_options)
end
endIn your layout/application.rhtml, you should add a call to PrototypeBackbutton.start("/controller/action", "My WebPage Title"); The title is there to deal with a bug I never got around to fixing that would keep appending the stuff after the # in the location bar to the title. We just force the title to this instead. You will also need an iframe for IE history:
<iframe id="ie_hash_history" style="display: none;"></iframe>
UPDATED: Here is a link to a full Rails 2.0 demo application with all of the pieces. Download the demo. Here is the demo runnning: Live Demo.