在 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%'
这种方法可能需要额外的逻辑来连接相关的表。
在您的情况下,如果 company
和 category
有关联,则必须将类似这样的内容添加到 query_where
"AND 'company'.'category_id' = 'categories'.'id'"
简单的方法:您可以为所有可以查询的模型/表对创建一个哈希,并在那里存储适当的连接条件。即使对于中型项目,这个 Hash 也不应该太复杂。
困难的方法:如果您在模型中正确定义了has_many
、has_one
和belongs_to
,这可以自动完成。您可以使用reflect_on_all_associations 获取模型的关联。实现Breath-First-Search
或Depth-First Search
算法并从任何模型开始,并从您的 json 输入中搜索与其他模型的匹配关联。开始新的 BFS/DFS 运行,直到没有来自 json 输入的未访问模型。从找到的信息中,您可以导出所有连接条件,然后将它们作为表达式添加到原始 sql 方法的where
子句中,如上所述。更复杂但也可行的是读取数据库schema
,并使用此处定义的类似方法,查找foreign keys
。
使用关联:如果它们都与has_many
/has_one
相关联,您可以使用joins
方法与inject
在ActiveRecord
上处理连接像这样的“最重要”模型:
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 中使用 Q() 动态构建复杂查询 [关闭]