Динамическое построение запросов в рельсах

Im пытается реплицировать стиль списка поиска crunchbase, используя ruby ​​on rails. У меня есть набор фильтров, который выглядит примерно так:

[
   {
      "id":"0",
      "className":"Company",
      "field":"name",
      "operator":"starts with",
      "val":"a"
   },
   {
      "id":"1",
      "className":"Company",
      "field":"hq_city",
      "operator":"equals",
      "val":"Karachi"
   },
   {
      "id":"2",
      "className":"Category",
      "field":"name",
      "operator":"does not include",
      "val":"ECommerce"
   }
]

Я отправляю эту строку json в свой рубиновый контроллер, где я реализовал эту логику:

filters = params[:q]
table_names = {}
filters.each do |filter|
    filter = filters[filter]
    className = filter["className"]
    fieldName = filter["field"]
    operator = filter["operator"]
    val = filter["val"]
    if table_names[className].blank? 
        table_names[className] = []
    end
    table_names[className].push({
        fieldName: fieldName,
        operator: operator,
        val: val
    })
end

table_names.each do |k, v|
    i = 0
    where_string = ''
    val_hash = {}
    v.each do |field|
        if i > 0
            where_string += ' AND '
        end
        where_string += "#{field[:fieldName]} = :#{field[:fieldName]}"
        val_hash[field[:fieldName].to_sym] = field[:val]
        i += 1
    end
    className = k.constantize
    puts className.where(where_string, val_hash)
end

Что я делаю, я петлю над массивом json и создаю хэш с ключами как имена таблиц, а значения - это массив с именем столбца, оператором и значением для применения этого оператора. Поэтому после создания тэга table_names у меня было бы что-то подобное:

{
   'Company':[
      {
         fieldName:'name',
         operator:'starts with',
         val:'a'
      },
      {
         fieldName:'hq_city',
         operator:'equals',
         val:'karachi'
      }
   ],
   'Category':[
      {
         fieldName:'name',
         operator:'does not include',
         val:'ECommerce'
      }
   ]
}

Теперь я перебираю хэш таблицы table_names и создаю запрос where с использованием синтаксиса Model.where("column_name = :column_name", {column_name: 'abcd'}).

Итак, я бы создал два запроса:

SELECT "companies".* FROM "companies" WHERE (name = 'a' AND hq_city = 'b')
SELECT "categories".* FROM "categories" WHERE (name = 'c')

Теперь у меня есть две проблемы:

1. Операторы:

У меня есть много операторов, которые можно применить к столбцу типа "начинается с", "заканчивается", "равно", "не равно", "включает", "не включает", "больше", 'меньше, чем'. Я предполагаю, что лучшим способом было бы сделать случай переключения на операторе и использовать соответствующий символ при построении строки where. Например, если оператор "начинает с", я бы сделал что-то вроде where_string += "#{field[:fieldName]} like %:#{field[:fieldName]}", а также для других.

Итак, этот подход правильный, и этот тип подстановочного синтаксиса разрешен в этом типе .where?

2. Более 1 таблицы

Как вы видели, мой подход создает 2 запроса для более чем 2 таблиц. Мне не нужны 2 запроса, мне нужно, чтобы имя категории было в том же запросе, где категория принадлежит компании.

Теперь, что я хочу сделать, мне нужно создать такой запрос:

Company.joins(:categories).where("name = :name and hq_city = :hq_city and categories.name = :categories[name]", {name: 'a', hq_city: 'Karachi', categories: {name: 'ECommerce'}})

Но это не так. Поиск может стать очень сложным. Например:

Компания имеет много FundingRound. FundingRound может иметь много инвестиций и инвестиций, которые могут иметь много IndividualInvestor. Поэтому я могу выбрать создать фильтр, например:

{
  "id":"0",
  "className":"IndividualInvestor",
  "field":"first_name",
  "operator":"starts with",
  "val":"za"
} 

Мой подход создаст такой запрос:

SELECT "individual_investors".* FROM "individual_investors" WHERE (first_name like %za%)

Этот запрос неверен. Я хочу обратиться к отдельным инвесторам с просьбами об инвестициях в раунд финансирования компании. Который много соединяет таблицы.

Подход, который я использовал, применим к одной модели и не может решить проблему, о которой я говорил выше.

Как я могу решить эту проблему?

Ответ 1

Вы можете создать SQL-запрос на основе хеша. Наиболее общий подход - это raw SQL, который может быть выполнен ActiveRecord.

Вот какой код концепции, который должен дать вам правильную идею:

query_select = "select * from "
query_where = ""
tables = [] # for selecting from all tables
hash.each do |table, values|
  table_name = table.constantize.table_name
  tables << table_name
  values.each do |q|
    query_where += " AND " unless query_string.empty?
    query_where += "'#{ActiveRecord::Base.connection.quote(table_name)}'."
    query_where += "'#{ActiveRecord::Base.connection.quote(q[fieldName)}'"
    if q[:operator] == "starts with" # this should be done with an appropriate method
      query_where += " LIKE '#{ActiveRecord::Base.connection.quote(q[val)}%'"
    end
  end
end
query_tables = tables.join(", ")
raw_query = query_select + query_tables + " where " + query_where 
result = ActiveRecord::Base.connection.execute(raw_query)
result.to_h # not required, but raw results are probably easier to handle as a hash

Что это делает:

  • query_select указывает, какую информацию вы хотите получить в результате
  • query_where создает все условия поиска и выводит входные данные для предотвращения инъекций SQL
  • query_tables - список всех таблиц, которые нужно искать
  • table_name = table.constantize.table_name предоставит вам имя таблицы SQL, используемое моделью
  • raw_query - это фактический объединенный запрос sql из частей выше
  • ActiveRecord::Base.connection.execute(raw_query) выполняет sql в базе данных

Обязательно помещайте любые введенные пользователем данные в кавычки и удаляйте их должным образом, чтобы предотвратить инъекции SQL.

В вашем примере созданный запрос будет выглядеть следующим образом:

select * from companies, categories where 'companies'.'name' LIKE 'a%' AND 'companies'.'hq_city' = 'karachi' AND 'categories'.'name' NOT LIKE '%ECommerce%'

В этом подходе может потребоваться дополнительная логика для объединения связанных таблиц. В вашем случае, если company и category имеют ассоциацию, вы должны добавить что-то вроде этого в query_where

"AND 'company'.'category_id' = 'categories'.'id'"

Легкий подход:. Вы можете создать хэш для всех пар моделей/таблиц, которые можно запросить и сохранить соответствующее условие соединения. Этот хэш не должен быть слишком сложным даже для проекта среднего размера.

Жесткий подход: Это можно сделать автоматически, если у вас есть has_many, has_one и belongs_to, правильно определенные в ваших моделях. Вы можете получить ассоциации модели, используя reflect_on_all_associations. Внедрите алгоритм Breath-First-Search или Depth-First Search и начните с любой модели и найдите соответствующие ассоциации с другими моделями с вашего json-входа. Начните новую работу BFS/DFS до тех пор, пока не будут отображены невидимые модели с входа json слева. Из найденной информации вы можете получить все условия соединения, а затем добавить их как выражения в предложении where подсистемы raw sql, как описано выше. Еще более сложным, но также выполнимым будет чтение базы данных schema и использование аналогичного подхода, как определено здесь, путем поиска foreign keys.

Использование ассоциаций: Если все они связаны с has_many/has_one, вы можете обрабатывать соединения с помощью ActiveRecord с помощью метода joins с inject на "наиболее значимая" модель:

base_model = "Company".constantize
assocations = [:categories]  # and so on
result = assocations.inject(base_model) { |model, assoc| model.joins(assoc) }.where(query_where)

Что это делает:

  • он передает base_model в качестве начального ввода в Enumerable.inject, который будет повторно называть input.send(: join,: assoc) ( для моего примера это сделало бы Company.send(:joins, :categories), что эквивалентно `Company.categories
  • в объединенном соединении, он выполняет условия where (построенные, как описано выше)

Отказ от ответственности Точный синтаксис, который вам нужен, может отличаться в зависимости от используемой вами реализации SQL.

Ответ 2

Полностью раздутая строка SQL является проблемой безопасности, поскольку она предоставляет вашему приложению атаку SQL-инъекций. Если вы можете обойти это, вполне можно выполнить эти конкатенации запросов, если вы сделаете их совместимыми с вашей БД (да, это решение специфичен для БД).

Кроме того, вы можете сделать какое-то поле, которое отмечает некоторые запросы как объединенные, как я уже упоминал в комментарии, у вас будет какая-то переменная, чтобы пометить нужную таблицу как результат запроса, например:

[
  {
    "id":"1",
    "className":"Category",
    "field":"name",
    "operator":"does not include",
    "val":"ECommerce",
    "queryModel":"Company"
  }
]

Что при обработке запроса вы использовали бы для вывода результата этого запроса как queryModel вместо className, в тех случаях className будет использоваться только для соединения с условиями таблицы.

Ответ 3

Я бы предложил изменить данные JSON. Прямо сейчас вы отправляете только имя модели без контекста, было бы проще, если бы ваша модель имела бы контекст.

В вашем примере данные должны выглядеть как

data = [
  {
    id: '0',
    className: 'Company',
    relation: 'Company',
    field: 'name',
    operator: 'starts with',
    val: 'a'
  },
  {
    id: '1',
    className: 'Category',
    relation: 'Company.categories',
    field: 'name',
    operator: 'equals',
    val: '12'
  },  
  {
    id: '3',
    className: 'IndividualInvestor',
    relation:     'Company.founding_rounds.investments.individual_investors',
    field: 'name',
    operator: 'equals',
    val: '12'
  }
]

И вы отправляете этот data в QueryBuilder

query = QueryBuilder.new(data) results = query.find_records

Примечание: find_records возвращает массив хэшей на model, на котором вы выполняете запрос.

Например, он вернет [{Company: [....]]

class QueryBuilder
  def initialize(data)
    @data = prepare_data(data)
  end

  def find_records
    queries = @data.group_by {|e| e[:model]}
    queries.map do |k, v|
      q = v.map do |f|
        {
          field: "#{f[:table_name]}.#{f[:field]} #{read_operator(f[:operator])} ?",
          value: value_based_on_operator(f[:val], f[:operator])
        }
      end

      db_query = q.map {|e| e[:field]}.join(" AND ")
      values = q.map {|e| e[:value]}

      {"#{k}": k.constantize.joins(join_hash(v)).where(db_query, *values)}
    end
  end

  private

  def join_hash(array_of_relations)
    hash = {}
    array_of_relations.each do |f|
      hash.merge!(array_to_hash(f[:joins]))
    end
    hash.map do |k, v|
      if v.nil?
        k
      else
        {"#{k}": v}
      end
    end
  end

  def read_operator(operator)
    case operator
    when 'equals'
      '='
    when 'starts with'
      'LIKE'
    end
  end

  def value_based_on_operator(value, operator)
    case operator
    when 'equals'
      value
    when 'starts with'
      "%#{value}"
    end
  end

  def prepare_data(data)
    data.each do |record|
      record.tap do |f|
        f[:model] = f[:relation].split('.')[0]
        f[:joins] = f[:relation].split('.').drop(1)
        f[:table_name] = f[:className].constantize.table_name
      end
    end
  end

  def array_to_hash(array)
    if array.length < 1
      {}
    elsif array.length == 1
      {"#{array[0]}": nil}
    elsif array.length == 2
      {"#{array[0]}": array[1]}
    else
      {"#{array[0]}": array_to_hash(array.drop(1))}
    end
  end
end

Ответ 4

Я чувствую, что вы слишком усложняете ситуацию, имея один контроллер для всего. Я бы создал контроллер для каждой модели или сущности, которые вы хотели бы показать, а затем реализовать фильтры, как вы сказали.

Реализация динамического где и порядок не очень сложна, но если, как вы сказали, вам нужно иметь также логику для реализации некоторых объединений, вы не только усложняете решение (потому что вам придется обновлять этот контроллер каждый раз, когда вы добавляете новую модель, сущность или изменяете основную логику), но вы также позволяете людям начать играть с вашими данными.

Я не очень хорошо знаком с Rails, поэтому, к сожалению, я не могу дать вам какой-либо конкретный cde, кроме того, что ваш подход кажется мне хорошим. Я бы взорвал его на несколько контроллеров.