require 'active_record' class SampleDatatable < CustomDatatable include ActionView::Helpers::TextHelper include SamplesHelper include InputSanitizeHelper include Rails.application.routes.url_helpers include ActionView::Helpers::UrlHelper include ApplicationHelper include ActiveRecord::Sanitization::ClassMethods ASSIGNED_SORT_COL = 'assigned' SAMPLES_TABLE_DEFAULT_STATE = { 'time' => 0, 'start' => 0, 'length' => 10, 'order' => [[2, 'desc']], 'search' => { 'search' => '', 'smart' => true, 'regex' => false, 'caseInsensitive' => true }, 'columns' => [], 'assigned' => 'all', 'ColReorder' => [*0..6] } 7.times do SAMPLES_TABLE_DEFAULT_STATE['columns'] << { 'visible' => true, 'search' => { 'search' => '', 'smart' => true, 'regex' => false, 'caseInsensitive' => true } } end SAMPLES_TABLE_DEFAULT_STATE.freeze def initialize(view, team, project = nil, my_module = nil, experiment = nil, user = nil) super(view) @team = team @project = project @my_module = my_module @experiment = experiment @user = user end # Define sortable columns, so 1st column will be sorted # by attribute in sortable_columns[0] def sortable_columns sort_array = [ ASSIGNED_SORT_COL, 'Sample.name', 'SampleType.name', 'SampleGroup.name', 'Sample.created_at', 'User.full_name' ] sort_array.push(*custom_fields_sort_by) @sortable_columns = sort_array end # Define attributes on which we perform search def searchable_columns search_array = [ 'Sample.name', 'SampleType.name', 'SampleGroup.name', 'Sample.created_at', 'User.full_name' ] search_array.push(*custom_fields_sort_by) @searchable_columns ||= filter_search_array search_array end private # filters the search array by checking if the the column is visible def filter_search_array(input_array) param_index = 2 filtered_array = [] input_array.each do |col| next if dt_params.to_a[param_index].nil? params_col = columns_params.to_a.find { |v| v[1]['data'] == param_index.to_s } filtered_array.push(col) unless params_col[1]['searchable'] == 'false' param_index += 1 end filtered_array end # Get array of columns to sort by (for custom fields) def custom_fields_sort_by num_cf = CustomField.where(team_id: @team).count array = [] num_cf.times do array << 'SampleCustomField.value' end array end # Returns json of current samples (already paginated) def data records.map do |record| sample = { 'DT_RowId': record.id, '1': assigned_cell(record), '2': escape_input(record.name), '3': if record.sample_type.nil? I18n.t('samples.table.no_type') else escape_input(record.sample_type.name) end, '4': sample_group_cell(record), '5': I18n.l(record.created_at, format: :full), '6': escape_input(record.user.full_name), 'sampleInfoUrl': Rails.application.routes.url_helpers.sample_path(record.id), 'sampleEditUrl': Rails.application.routes.url_helpers.edit_sample_path(record.id), 'sampleUpdateUrl': Rails.application.routes.url_helpers.sample_path(record.id) } # Add custom attributes record.sample_custom_fields.each do |scf| sample[@cf_mappings[scf.custom_field_id]] = custom_auto_link(scf.value, simple_format: true, team: @team) end sample end end def assigned_cell(record) if @assigned_samples.include?(record) " " else " " end end def sample_group_cell(record) if record.sample_group.nil? " " \ "#{I18n.t('samples.table.no_group')}" else " " \ "#{escape_input(record.sample_group.name)}" end end # Query database for records (this will be later paginated and filtered) # after that "data" function will return json def get_raw_records samples = Sample .includes( :sample_type, :sample_group, :user, :sample_custom_fields ) .references( :sample_type, :sample_group, :user, :sample_custom_fields ) .where( team: @team ) if @my_module @assigned_samples = @my_module.samples samples = samples.joins("LEFT OUTER JOIN sample_my_modules ON (samples.id = sample_my_modules.sample_id AND (sample_my_modules.my_module_id = #{@my_module.id} OR sample_my_modules.id IS NULL))") .references(:sample_my_modules) if dt_params[:assigned] == 'assigned' samples = samples.where('"sample_my_modules"."id" > 0') end elsif @project @assigned_samples = @project.assigned_samples ids = @project.my_modules_ids if ids.blank? samples = samples.joins('LEFT OUTER JOIN sample_my_modules ON (samples.id = sample_my_modules.sample_id AND sample_my_modules.id IS NULL)') .references(:sample_my_modules) else samples = samples.joins("LEFT OUTER JOIN sample_my_modules ON (samples.id = sample_my_modules.sample_id AND (sample_my_modules.my_module_id IN (#{ids}) OR sample_my_modules.id IS NULL))") .references(:sample_my_modules) end if dt_params[:assigned] == 'assigned' samples = samples.joins('LEFT OUTER JOIN "my_modules" ON "my_modules"."id" = "sample_my_modules"."my_module_id"') .joins('LEFT OUTER JOIN "experiments" ON "experiments"."id" = "my_modules"."experiment_id"') .where('"experiments"."project_id" = ?', @project.id) .where('"my_modules"."nr_of_assigned_samples" > 0') end elsif @experiment @assigned_samples = @experiment.assigned_samples ids = @experiment.my_modules.select(:id) samples = samples.joins("LEFT OUTER JOIN sample_my_modules ON (samples.id = sample_my_modules.sample_id AND (sample_my_modules.my_module_id IN (#{ids.to_sql}) OR sample_my_modules.id IS NULL))") .references(:sample_my_modules) if dt_params[:assigned] == 'assigned' samples = samples.joins('LEFT OUTER JOIN "my_modules" ON "my_modules"."id" = "sample_my_modules"."my_module_id"') .where('"my_modules"."experiment_id" = ?', @experiment.id) .where('"my_modules"."nr_of_assigned_samples" > 0') end end # Make mappings of custom fields, so we have same id for every column i = 7 @cf_mappings = {} all_custom_fields.each do |cf| @cf_mappings[cf.id] = i.to_s i += 1 end samples end # Override default behaviour # Don't filter and paginate records when sorting by custom column - everything # is done in sort_records method - you might ask why, well if you want the # number of samples/all samples it's dependant upon sort_record query def fetch_records records = get_raw_records records = sort_records(records) if order_params.present? escape_special_chars unless sorting_by_custom_column records = filter_records(records) if dt_params[:search].present? records = paginate_records(records) unless dt_params[:length].present? && dt_params[:length] == '-1' end records end # Override default sort method if needed def sort_records(records) if sort_column(order_params) == ASSIGNED_SORT_COL # If "assigned" column is sorted if @my_module # Depending on the sort, order nulls first or # nulls last on sample_my_modules association records.order( "sample_my_modules.id NULLS #{sort_null_direction(order_params)}" ) elsif @experiment # A very elegant solution to sort assigned samples at a experiment level # grabs the ids of samples which has a modules that belongs # to this project assigned = Sample .joins('LEFT OUTER JOIN "sample_my_modules" ' \ 'ON "sample_my_modules"."sample_id" = "samples"."id"') .joins('LEFT OUTER JOIN "my_modules" ' \ 'ON "my_modules"."id" = "sample_my_modules"."my_module_id"') .where('"my_modules"."experiment_id" = ?', @experiment.id) .where('"my_modules"."nr_of_assigned_samples" > 0') .select('"samples"."id"') .distinct # grabs the ids that are not the previous one but are still # of the same team unassigned = Sample.where('"samples"."team_id" = ?', @team.id) .where('"samples"."id" NOT IN (?)', assigned) .select('"samples"."id"') .distinct # check the input param and merge the two arrays of ids if order_params['dir'] == 'asc' ids = assigned + unassigned elsif order_params['dir'] == 'desc' ids = unassigned + assigned end ids = ids.collect(&:id) # order the records by input ids order_by_index = ActiveRecord::Base.__send__( :sanitize_sql_array, ["position((',' || samples.id || ',') in ?)", ids.join(',') + ','] ) records.where(id: ids).order(order_by_index) elsif @project # A very elegant solution to sort assigned samples at a project level # grabs the ids of samples which has a modules # that belongs to this project assigned = Sample .joins('LEFT OUTER JOIN "sample_my_modules" ' \ 'ON "sample_my_modules"."sample_id" = "samples"."id"') .joins('LEFT OUTER JOIN "my_modules" ' \ 'ON "my_modules"."id" = "sample_my_modules"."my_module_id"') .joins('LEFT OUTER JOIN "experiments" ' \ 'ON "experiments"."id" = "my_modules"."experiment_id"') .where('"experiments"."project_id" = ?', @project.id) .where('"my_modules"."nr_of_assigned_samples" > 0') .select('"samples"."id"') .distinct # grabs the ids that are not the previous ones # but are still of the same team unassigned = Sample .where('"samples"."team_id" = ?', @team.id) .where('"samples"."id" NOT IN (?)', assigned) .select('"samples"."id"') .distinct # check the input param and merge the two arrays of ids if order_params['dir'] == 'asc' ids = assigned + unassigned elsif order_params['dir'] == 'desc' ids = unassigned + assigned end ids = ids.collect(&:id) # order the records by input ids order_by_index = ActiveRecord::Base.__send__( :sanitize_sql_array, ["position((',' || samples.id || ',') in ?)", ids.join(',') + ','] ) records.where(id: ids).order(order_by_index) end elsif sorting_by_custom_column # Check if have to filter samples first if dt_params[:search].present? && dt_params[:search][:value].present? # Couldn't force ActiveRecord to yield the same query as below because # Rails apparently forgets to join stuff in subqueries - # #justrailsthings conditions = build_conditions_for(dt_params[:search][:value]) filter_query = %(SELECT "samples"."id" FROM "samples" LEFT OUTER JOIN "sample_custom_fields" ON "sample_custom_fields"."sample_id" = "samples"."id" LEFT OUTER JOIN "sample_types" ON "sample_types"."id" = "samples"."sample_type_id" LEFT OUTER JOIN "sample_groups" ON "sample_groups"."id" = "samples"."sample_group_id" LEFT OUTER JOIN "users" ON "users"."id" = "samples"."user_id" WHERE "samples"."team_id" = #{@team.id} AND #{conditions.to_sql}) records = records.where("samples.id IN (#{filter_query})") end ci = sortable_displayed_columns[ order_params[:column].to_i - 1 ] cf_id = @cf_mappings.key((ci.to_i + 1).to_s) dir = sort_direction(order_params) # Because samples can have multiple sample custom fields, we first group # them by samples.id and inside that group we sort them by cf_id. Because # we sort them ASC, sorted columns will be on top. Distinct then only # takes the first row and cuts the rest of every group and voila we have # 1 row for every sample, which are not sorted yet ... records = records .select('DISTINCT ON (samples.id) *') .order("samples.id, CASE WHEN sample_custom_fields.custom_field_id = " \ "#{cf_id} THEN 1 ELSE 2 END ASC") # ... this little gem (pun intended) then takes the records query, # sorts it again and paginates it. # sq.t0_* are determined empirically and are crucial - # imagine A -> B -> C transitive relation but where A and C are the # same. Useless right? But not when you acknowledge that find_by_sql # method does some funky stuff when your query spans multiple queries - # Sample object might have id from SampleType, name from # User ... chaos ensues basically. If something changes in db this might # change. formated_date = I18n.t('time.formats.datatables_date') .gsub!(/^\"|\"?$/, '') Sample.find_by_sql( "SELECT sq.t0_r0 as id, sq.t0_r1 as name, " \ "to_char( sq.t0_r4, '#{formated_date}' ) as created_at, sq.t0_r5, " \ "sq.t0_r6 as sample_group_id ,sq.t0_r7 as sample_type_id, sq.t0_r2 " \ "as user_id, sq.custom_field_id FROM (#{records.to_sql}) " \ "as sq ORDER BY CASE WHEN sq.custom_field_id = #{cf_id} THEN 1 " \ "ELSE 2 END #{dir}, sq.value #{dir} LIMIT #{per_page} OFFSET #{offset}" ) else super(records) end end # A hack that overrides the new_search_contition method default behavior # of the ajax-datatables-rails gem now the method checks # if the column is the created_at and generate a custom SQL to parse # it back to the caller method def new_search_condition(column, value) model, column = column.split('.') model = model.constantize formated_date = I18n.t('time.formats.datatables_date') .gsub!(/^\"|\"?$/, '') if model == SampleCustomField # Find visible (searchable) custom field IDs, and only perform filter # on those custom fields searchable_cfs = columns_params.select do |_, v| v['searchable'] == 'true' && @cf_mappings.values.include?(v['data']) end cfmi = @cf_mappings.invert cf_ids = searchable_cfs.map { |_, v| cfmi[v['data']] } # Do an ILIKE on 'value', as well as make sure to only include # custom fields that have 'custom_field_id' among visible custom fields casted_column = ::Arel::Nodes::NamedFunction.new( 'CAST', [model.arel_table[column.to_sym].as(typecast)] ) casted_column = casted_column.matches("%#{value}%") casted_column = casted_column.and( model.arel_table['custom_field_id'].in(cf_ids) ) casted_column elsif column == 'created_at' casted_column = ::Arel::Nodes::NamedFunction.new( 'CAST', [Arel.sql( "to_char( samples.created_at, '#{formated_date}' ) AS VARCHAR" )] ) casted_column.matches("%#{sanitize_sql_like(value)}%") else casted_column = ::Arel::Nodes::NamedFunction.new( 'CAST', [model.arel_table[column.to_sym].as(typecast)] ) casted_column.matches("%#{sanitize_sql_like(value)}%") end end def sort_null_direction(item) val = sort_direction(item) val == 'ASC' ? 'LAST' : 'FIRST' end def inverse_sort_direction(item) val = sort_direction(item) val == 'ASC' ? 'DESC' : 'ASC' end def sorting_by_custom_column sort_column(order_params) == 'sample_custom_fields.value' end # Escapes special characters in search query def escape_special_chars if dt_params[:search].present? dt_params[:search][:value] = ActiveRecord::Base.__send__(:sanitize_sql_like, dt_params[:search][:value]) end end def new_sort_column(item) coli = item[:column].to_i - 1 model, column = sortable_columns[sortable_displayed_columns[coli].to_i] .split('.') return model if model == ASSIGNED_SORT_COL [model.constantize.table_name, column].join('.') end def generate_sortable_displayed_columns sort_order = SamplesTable.where(user: @user, team: @team) .pluck(:status) .first['ColReorder'] sort_order.shift sort_order.map! { |i| (i.to_i - 1).to_s } @sortable_displayed_columns = sort_order end end