在 Rails 中动态构建查询

Posted

技术标签:

【中文标题】在 Rails 中动态构建查询【英文标题】:Building queries dynamically in rails 【发布时间】:2018-04-09 16:15:29 【问题描述】:

我正在尝试使用 ruby​​ on rails 复制 crunchbase 的搜索列表样式。 我有一组看起来像这样的过滤器:

[
   
      "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 字符串发送到我已实现此逻辑的 ruby​​ 控制器:

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 哈希并使用 Model.where("column_name = :column_name", column_name: 'abcd') 语法创建 where 查询。

所以我会生成两个查询:

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

我现在有两个问题:

1.运营商:

我有许多可以应用于列的运算符,例如“开始于”、“结束于”、“等于”、“不等于”、“包括”、“不包括”、“大于”、 '少于'。我猜最好的方法是在操作符上做一个 switch case,并在构建 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 可以有很多 Investment,Investment 可以有很多 IndividualInvestor。所以我可以选择创建一个过滤器,如:


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

我的方法会创建一个这样的查询:

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

这个查询是错误的。我想咨询一下公司本轮融资投资的个人投资者。这是很多连接表。

我使用的方法适用于单个模型,无法解决我上面所说的问题。

我该如何解决这个问题?

【问题讨论】:

所以搜索总是在Company上下文中? 当查询在关联模型的上下文中时,为什么不在 JSON 中包含类似 queryModel 的内容? @ErvalhouS 不,不会的 嗯,queryModel 会做什么? 它将表示您将用于搜索相应类的模型,即:Category 查询与 queryModel: 'Company' 将连接它在公司查询中搜索类别,而不是创建结果对于类别,它将处理具有这些类别匹配器的公司的结果。 【参考方案1】:

您可以根据哈希创建 SQL 查询。最通用的方法是原始 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%'

这种方法可能需要额外的逻辑来连接相关的表。 在您的情况下,如果 companycategory 有关联,则必须将类似这样的内容添加到 query_where

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

简单的方法:您可以为所有可以查询的模型/表对创建一个哈希,并在那里存储适当的连接条件。即使对于中型项目,这个 Hash 也不应该太复杂。

困难的方法:如果您在模型中正确定义了has_manyhas_onebelongs_to,这可以自动完成。您可以使用reflect_on_all_associations 获取模型的关联。实现Breath-First-SearchDepth-First Search 算法并从任何模型开始,并从您的 json 输入中搜索与其他模型的匹配关联。开始新的 BFS/DFS 运行,直到没有来自 json 输入的未访问模型。从找到的信息中,您可以导出所有连接条件,然后将它们作为表达式添加到原始 sql 方法的where 子句中,如上所述。更复杂但也可行的是读取数据库schema,并使用此处定义的类似方法,查找foreign keys

使用关联:如果它们都与has_many/has_one相关联,您可以使用joins方法与injectActiveRecord上处理连接像这样的“最重要”模型:

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(:joins, :assoc)(对于我的示例,这将执行 Company.send(:joins, :categories),这相当于 `Company.categories 在组合连接上,它执行 where 条件(如上所述构造)

免责声明您需要的确切语法可能因您使用的 SQL 实现而异。

【讨论】:

【参考方案2】:

完整的 SQL 字符串是一个安全问题,因为它会将您的应用程序暴露给 SQL 注入攻击。如果您可以解决此问题,则完全可以进行这些查询连接,只要您使它们与您的 DB (yes, this solution is DB specific) 兼容。

除此之外,您可以创建一些将某些查询标记为已连接的字段,正如我在评论中提到的那样,您将有一些变量来将所需的表标记为查询的输出,例如:

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

在处理查询时,您将使用此查询的结果输出为queryModel 而不是className,在这些情况下className 将仅用于连接表条件。

【讨论】:

某些模型与 queryModel 没有直接关系 例如,我需要公司的所有个人投资者。公司有很多投资,投资有很多个人投资者。你如何建议我加入这些表格? 有什么方法可以找到两个模型之间的关联吗? 听起来不错。公司也有很多创始人,创始人属于Person。我将如何将其应用于此? 所以我可以做类似company.joins(:founders).where(founders: first_name: 'abcd')【参考方案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

【讨论】:

没有必要将关系添加到输入中,他可能不想或无法将这些细节添加到 json 输入中。如我的回答中所述,您可以通过使用带有“reflect_on_all_associations”的 BFS 或 DFS 来导出所有关联。【参考方案4】:

我觉得你用一个控制器来处理所有事情会让事情变得过于复杂。我将为您要显示的每个模型或实体创建一个控制器,然后像您说的那样实现过滤器。

实现动态 where 和 order by 并不是很难,但如果如您所说,您还需要实现一些连接的逻辑,那么您不仅会使解决方案复杂化(因为您必须保持此控制器更新每次添加新模型、实体或更改基本逻辑时),但您也可以让人们开始使用您的数据。

我对 Rails 不是很熟悉,所以很遗憾我不能给你任何具体的 cde,只能说你的方法对我来说似乎没问题。我会把它分解成多个控制器。

【讨论】:

以上是关于在 Rails 中动态构建查询的主要内容,如果未能解决你的问题,请参考以下文章

为了在 Django 中过滤数据,为多列构建动态查询

在 Where 子句中使用 case 构建动态查询

Mysql基于逻辑在存储过程中动态构建查询字符串

在 Django 中使用 Q() 动态构建复杂查询 [关闭]

使用 Q-Objects 在 Django 中动态构建复杂查询

来自结构化对象的 Typeorm 动态查询构建器