diff --git a/spec/interro_spec.cr b/spec/interro_spec.cr index 8c0565d..c623fa5 100644 --- a/spec/interro_spec.cr +++ b/spec/interro_spec.cr @@ -151,6 +151,30 @@ struct UserQuery < Interro::QueryBuilder(User) where name: name end + def ids + subquery select: "id" + end + + def with_name_in_ten(names : Array(String)) + where "name IN ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", names + end + + def with_email(email : String) + where email: email + end + + def named_with_literal_dollar + where "name = 'it''s $7'" + end + + def with_id_in(subquery : Interro::QueryBuilder::Subquery) + where id: subquery + end + + def with_id_in_and_named(subquery : Interro::QueryBuilder::Subquery, name : String) + where id: subquery, name: name + end + def registered_after(time : Time) where { |user| user.created_at > time } end @@ -176,6 +200,10 @@ struct UserQuery < Interro::QueryBuilder(User) limit count end + def offset_by(count : Int32) + offset count + end + def change_name(user : User, name : String) self .where(id: user.id) @@ -244,6 +272,24 @@ struct UserQuery < Interro::QueryBuilder(User) order_by "levenshtein(users.name, $1)", "ASC", [name] end + def by_reversed_name + order_by "reverse(users.name)", "ASC" + end + + def distinct_names_in_order + distinct(on: "users.name").order_by("users.name": :asc) + end + + def distinct_by_name_similarity(term : String) + distinct(on: "users.name") + .order_by("levenshtein(users.name, $1)", "ASC", [term]) + end + + def distinct_by_placeholder + # This is not allowed and raises ArgumentError. + distinct(on: "levenshtein(users.name, $1)") + end + def count : Int64 scalar("count(*)", as: Int64) end @@ -360,6 +406,10 @@ struct GroupMembershipQuery < Interro::QueryBuilder(GroupMembership) insert user_id: user.id, group_id: group.id end end + + def user_ids + subquery select: "user_id" + end end struct TaskQuery < Interro::QueryBuilder(Task) @@ -553,6 +603,18 @@ describe Interro do users.should eq created_users[2...7].reverse end + it "can limit and offset a query" do + ordered = query + .registered_before(created_users[7].created_at) + .in_reverse_chronological_order + + ordered.at_most(5).to_a.should eq created_users[2...7].reverse + + paged = ordered.at_most(5).offset_by(1) + paged.to_sql.should end_with %{OFFSET $2 LIMIT $3} + paged.to_a.should eq created_users[1...6].reverse + end + it "can build a query with a compound where clause" do users = query .registered_before_with_compound_where_clause(created_users[7].created_at) @@ -608,6 +670,15 @@ describe Interro do users.should eq created_users[8..9].reverse end + it "binds the limit when iterating" do + name = "Lazily Limited #{UUID.random}" + 3.times { create_user(name: name) } + + users = query.with_name(name).at_most(2).each.to_a + + users.size.should eq 2 + end + it "uses concurrency-safe iterators" do iterator = UserQuery.new.each @@ -655,6 +726,12 @@ describe Interro do results.first.should eq created_users.first end + it "can perform ORDER BY on a raw expression with no args" do + results = query.by_reversed_name.to_a.select { |user| created_users.includes? user } + + results.should eq created_users.sort_by(&.name.reverse) + end + it "can be used to return all values" do current_size = GroupQuery.new.size @@ -705,6 +782,32 @@ describe Interro do TaskQuery.new.for(user).size.should eq 2 end + it "can combine DISTINCT ON with ORDER BY" do + create_user(name: "Distinct #{UUID.random}") + + matching = query.distinct_names_in_order + + matching.to_sql.should contain "DISTINCT ON (users.name, users.name)" + matching.to_a.size.should be > 0 + end + + it "can combine DISTINCT ON with a parameterized ORDER BY" do + user = create_user(name: "Similar #{UUID.random}") + + matching = query.distinct_by_name_similarity(user.name) + + # Note that both sites reference the same placeholder, which is required by Postgres. + matching.to_sql.should contain "DISTINCT ON (users.name, levenshtein(users.name, $1))" + matching.to_sql.should end_with "ORDER BY levenshtein(users.name, $1) ASC" + matching.to_a.map(&.id).should contain user.id + end + + it "rejects a placeholder in a DISTINCT ON expression" do + expect_raises ArgumentError, "references $1" do + query.distinct_by_placeholder + end + end + describe "matching values in an array" do ids = created_users.map(&.id).first(3) @@ -851,6 +954,15 @@ describe Interro do query.with_id(not_deleted.id).should contain not_deleted end + it "binds a limit applied to one side of a compound query" do + 2.times { create_user(name: "Side LHS") } + 2.times { create_user(name: "Side RHS") } + + users = (query.with_name("Side LHS").at_most(1) | query.with_name("Side RHS")).to_a + + users.size.should eq 3 + end + it "can run UNION queries" do lhs = Array.new(3) { create_user(name: "LHS") } rhs = Array.new(3) { create_user(name: "RHS") } @@ -875,6 +987,12 @@ describe Interro do users.should_not contain only_lhs end + it "renders compound queries to SQL" do + compound = query.with_name("LHS") | query.with_name("RHS") + + compound.to_sql.should contain "UNION" + end + it "can limit compound queries" do 2.times { create_user(name: "Limit LHS") } 2.times { create_user(name: "Limit RHS") } @@ -899,6 +1017,48 @@ describe Interro do users.should_not contain excluded end + it "renders a subquery on its own" do + subquery = UserQuery.new.with_name("Someone").ids + + subquery.to_sql.should eq %{SELECT id FROM users WHERE name = $1} + end + + it "numbers a subquery's placeholders after the outer query's args" do + carol = create_user(name: "Carol") + create_user(name: "Carol") + + subquery = UserQuery.new.with_email(carol.email).with_name("Carol").ids + matching = query.with_email(carol.email).with_id_in_and_named(subquery, "Carol") + + matching.to_sql.should end_with %{WHERE (email = $1) AND ((id IN (SELECT id FROM users WHERE (email = $2) AND (name = $3))) AND (name = $4))} + matching.to_a.map(&.id).should eq [carol.id] + end + + it "numbers a where_exists subquery's placeholders after the outer query's args" do + heidi = create_user(name: "Heidi #{UUID.random}") + group = create_group + GroupMembershipQuery.new.create(user: heidi, group: group) + + membership = GroupMembershipQuery.new.for(group_id: group.id).user_ids + matching = query.with_name(heidi.name).where_exists(id: membership) + + matching.to_sql.should end_with %{WHERE (name = $1) AND (id IN (SELECT user_id FROM group_memberships WHERE group_id = $2))} + matching.to_a.map(&.id).should eq [heidi.id] + end + + it "numbers a subquery from $1 even when its builder holds order_by args" do + grace = create_user(name: "Grace") + + subquery = UserQuery.new + .by_name_similarity_to("Grace") + .with_email(grace.email) + .ids + matching = query.with_id_in(subquery) + + matching.to_sql.should end_with %{WHERE id IN (SELECT id FROM users WHERE email = $1)} + matching.to_a.map(&.id).should eq [grace.id] + end + it "queries with WHERE EXISTS" do user = create_user(email: "included-#{UUID.random}") included = create_group @@ -912,12 +1072,51 @@ describe Interro do end end + describe "composing raw where fragments" do + it "renumbers multi-digit placeholders" do + target = create_user(name: "MultiDigit 0") + names = Array.new(10) { |i| "MultiDigit #{i}" } + + matching = query.with_email(target.email).with_name_in_ten(names) + + matching.to_sql.should end_with %{WHERE (email = $1) AND (name IN ($2, $3, $4, $5, $6, $7, $8, $9, $10, $11))} + matching.to_a.should eq [target] + end + + it "leaves $n inside string literals alone" do + judy = create_user(name: "it's $7") + + matching = query.with_email(judy.email).named_with_literal_dollar + + matching.to_sql.should end_with %{WHERE (email = $1) AND (name = 'it''s $7')} + matching.to_a.map(&.id).should eq [judy.id] + end + end + + it "numbers a merged query's placeholders after the receiving query's args" do + erin = create_user(name: "Erin #{UUID.random}") + + matching = query.with_email(erin.email) + .merge(UserQuery.new.with_name(erin.name).by_name_similarity_to(erin.name)) + + matching.to_sql.should end_with %{WHERE (email = $1) AND (name = $2) ORDER BY levenshtein(users.name, $3) ASC} + matching.to_a.map(&.id).should eq [erin.id] + end + it "can use arbitrary operators" do user = create_user(name: "Search User") UserQuery.new.search("search").should contain user end + it "generates a correct any? query when the order by includes placeholder arguments" do + user = create_user + + ordered = UserQuery.new.by_name_similarity_to(user.name) + ordered.to_sql.should end_with %{ORDER BY levenshtein(users.name, $1) ASC} + ordered.any?.should eq true + end + it "can check whether any records match" do user = create_user matching = UserQuery.new.with_id(user.id) diff --git a/spec/query_expression_spec.cr b/spec/query_expression_spec.cr index fe1096c..774ec01 100644 --- a/spec/query_expression_spec.cr +++ b/spec/query_expression_spec.cr @@ -5,15 +5,67 @@ require "../src/query_expression" module Interro describe QueryExpression do it "generates an AND conjunction of two expressions" do - lhs = QueryExpression.new("foo", ">", "$1", [Any.new(69)]) - rhs = QueryExpression.new("bar", "=", "$2", [Any.new(420)]) + lhs = QueryExpression.parse("foo > $1", [69]) + rhs = QueryExpression.parse("bar = $1", [420]) (lhs & rhs).to_sql.should eq "(foo > $1) AND (bar = $2)" end it "generates an OR conjunction of two expressions" do - lhs = QueryExpression.new("foo", ">", "$1", [Any.new(69)]) - rhs = QueryExpression.new("bar", "=", "$2", [Any.new(420)]) + lhs = QueryExpression.parse("foo > $1", [69]) + rhs = QueryExpression.parse("bar = $1", [420]) (lhs | rhs).to_sql.should eq "(foo > $1) OR (bar = $2)" end + + describe ".parse" do + it "binds a repeated placeholder once per reference" do + expression = QueryExpression.parse("a = $1 OR b = $1", [1]) + + expression.to_sql.should eq "a = $1 OR b = $2" + expression.values.should eq [Any.new(1), Any.new(1)] + end + + it "binds values in reference order when placeholders appear out of order" do + expression = QueryExpression.parse("b = $2 AND a = $1", [1, 2]) + + expression.to_sql.should eq "b = $1 AND a = $2" + expression.values.should eq [Any.new(2), Any.new(1)] + end + + it "raises when a placeholder references a missing value" do + expect_raises ArgumentError, "references $2" do + QueryExpression.parse("a = $2", [1]) + end + end + + it "rejects $0" do + expect_raises ArgumentError, "references $0" do + QueryExpression.parse("a = $0", [1]) + end + end + + it "leaves $n inside string literals alone, including '' escapes" do + expression = QueryExpression.parse("note = 'it''s $1' AND id = $1", [1]) + + expression.to_sql.should eq "note = 'it''s $1' AND id = $1" + expression.values.should eq [Any.new(1)] + end + end + + describe "#to_sql" do + it "continues numbering across fragments rendered into one args array" do + first = QueryExpression.parse("a = $1", [1]) + second = QueryExpression.parse("b = $1", [2]) + args = [] of Any + + sql = String.build do |str| + first.to_sql str, args + str << " AND " + second.to_sql str, args + end + + sql.should eq "a = $1 AND b = $2" + args.should eq [Any.new(1), Any.new(2)] + end + end end end diff --git a/spec/query_value_spec.cr b/spec/query_value_spec.cr index fa35f3b..b2796a0 100644 --- a/spec/query_value_spec.cr +++ b/spec/query_value_spec.cr @@ -4,7 +4,7 @@ require "../src/query_value" module Interro describe QueryValue do - value = QueryValue.new("my_value", 1) + value = QueryValue.new("my_value") it "checks equal" do (value == 42).to_sql.should eq "my_value = $1" diff --git a/src/dynamic_query.cr b/src/dynamic_query.cr index 14c40ef..3fc72e2 100644 --- a/src/dynamic_query.cr +++ b/src/dynamic_query.cr @@ -26,7 +26,6 @@ module Interro order_by: @order_by_clause, offset: @offset_clause, limit: @limit_clause, - args: @args, transaction: transaction, delegate: delegate, ) @@ -47,7 +46,6 @@ module Interro order_by @order_by_clause, offset @offset_clause, limit @limit_clause, - @args, @transaction, @delegate : U, ) @@ -72,7 +70,6 @@ module Interro @delegate.order_by_clause = @order_by_clause @delegate.offset_clause = @offset_clause @delegate.limit_clause = @limit_clause - @delegate.args = @args @delegate.transaction = @transaction %new_query = @delegate.{{call}} case %new_query @@ -86,7 +83,6 @@ module Interro order_by: %new_query.order_by_clause, offset: %new_query.offset_clause, limit: %new_query.limit_clause, - args: %new_query.args, transaction: %new_query.transaction, delegate: @delegate, ) diff --git a/src/query_builder.cr b/src/query_builder.cr index 7612b09..a92f83f 100644 --- a/src/query_builder.cr +++ b/src/query_builder.cr @@ -10,7 +10,8 @@ require "./delete_operation" require "./transaction" module Interro - alias OrderBy = Hash(String, String) + # QueryExpression => direction + alias OrderBy = Hash(QueryExpression, String) # Defining `QueryBuilder` objects is a way to create composable queries. For # example, if you have the following `Model` and `QueryBuilder`: @@ -172,14 +173,13 @@ module Interro self[transaction_owner.transaction] end - protected property? distinct : Array(String)? = nil + protected property? distinct : Array(QueryExpression)? = nil protected property join_clause : Array(JoinClause) { [] of JoinClause } protected property where_clause : QueryExpression? protected property order_by_clause : OrderBy? protected property limit_clause : Int32? = nil protected property offset_clause : Int32? = nil protected property transaction : Transaction? = nil - protected property args : Array(Any) { Array(Any).new } protected property? for_update = false protected property? skip_locked = false @@ -197,23 +197,18 @@ module Interro end def each + sql, args = render ResultSetIterator(T).new( db: connection(CONFIG.read_db), - query: to_sql, + query: sql, args: args, ) end def each(& : T ->) - args = self.args - if offset = offset_clause - args += [offset] of Interro::Value - end - if limit = limit_clause - args += [limit] of Interro::Value - end + sql, args = render - connection(Interro::CONFIG.read_db).query_each to_sql, args: args do |rs| + connection(Interro::CONFIG.read_db).query_each sql, args: args do |rs| {% begin %} {% if T < Tuple %} yield({ {% for type, index in T.type_vars %} rs.read({{type}}) {% if index < T.type_vars.size - 1 %},{% end %} {% end %} }) @@ -232,7 +227,6 @@ module Interro else new.where_clause ||= other.where_clause end - new.args += other.args if (my_order = new.order_by_clause) && (their_order = other.order_by_clause) new.order_by_clause = my_order.merge(their_order) else @@ -248,9 +242,7 @@ module Interro end def to_sql : String - String.build do |str| - to_sql str - end + render[0] end def |(other : self) : CompoundQuery @@ -289,27 +281,43 @@ module Interro ) end + # This subquery as the right-hand side of an IN, e.g. `id IN (SELECT ...)`. + def in_expression(column : String) : QueryExpression + parts = [] of QueryExpression::Part + parts << "#{column} IN (" + parts.concat to_parts + parts << ")" + QueryExpression.new(parts) + end + + # Renders the subquery on its own, numbering arguments from `$1`. def to_sql String.build do |sql| to_sql sql end end + # :ditto: def to_sql(io : IO) : Nil - io << "SELECT " << select_clause - io << " FROM " << relation + QueryExpression.new(to_parts).to_sql io + end + + private def to_parts : Array(QueryExpression::Part) + parts = [] of QueryExpression::Part + parts << "SELECT #{select_clause} FROM #{relation}" if where = where_clause - io << " WHERE " - where.to_sql io + parts << " WHERE " + parts.concat where.parts end + parts end end # :doc: protected def find(**params) : T? - query = where(**params).limit(1) + sql, args = where(**params).limit(1).render - connection(CONFIG.read_db).query_one? query.to_sql, args: query.args + [1], as: T + connection(CONFIG.read_db).query_one? sql, args: args, as: T end # :doc: @@ -331,31 +339,16 @@ module Interro # :doc: protected def where(**params : Value | Any | Array | Subquery) : self where_clause = nil - args = Array(Any).new(initial_capacity: params.size) - params.each_with_index(self.args.size + 1) do |key, value, index| + params.each do |key, value| case value when Nil - new_clause = QueryExpression.new(key.to_s, "IS", "NULL", [] of Any) + new_clause = QueryExpression.new("#{key} IS NULL") when Array - any = Any.new(value) - args << any - new_clause = QueryExpression.new(key.to_s, "=", "ANY($#{index})", [any]) + new_clause = QueryExpression.new("#{key} = ANY(", Any.new(value), ")") when Subquery - if where = value.where_clause - where_args = where.values - else - where_args = [] of Any - end - args.concat where_args - new_clause = QueryExpression.new( - key.to_s, - "IN", - "(#{value.to_sql})", - where_args, - ) + new_clause = value.in_expression(key.to_s) else - args << Any.new(value) - new_clause = QueryExpression.new(key.to_s, "=", "$#{index}", [Any.new(value)]) + new_clause = QueryExpression.new("#{key} = ", Any.new(value)) end if where_clause @@ -372,33 +365,15 @@ module Interro new = dup if where_clause new.where_clause = where_clause - if self.args.any? - new.args = self.args + args - else # If the current array is empty, we don't need to concatenate - new.args = args - end end new end def where_exists(**params : Subquery) : self where_clause = nil - args = Array(Any).new(initial_capacity: params.size) - - params.each_with_index(self.args.size + 1) do |key, value, index| - if where = value.where_clause - where_args = where.values - args.concat where_args - else - where_args = [] of Any - end - new_clause = QueryExpression.new( - key.to_s, - "IN", - "(#{value.to_sql})", - where_args, - ) + params.each do |key, value| + new_clause = value.in_expression(key.to_s) if where_clause where_clause &= new_clause @@ -414,20 +389,13 @@ module Interro new = dup if where_clause new.where_clause = where_clause - if self.args.any? - new.args = self.args + args - else # If the current array is empty, we don't need to concatenate - new.args = args - end end new end # :doc: protected def where(table = sql_table_alias, &block : QueryRecord -> QueryExpression) : self - index = args.size - where_clause = yield(QueryRecord.new(table) { index += 1 }) - values = where_clause.values + where_clause = yield(QueryRecord.new(table)) if current_where_clause = @where_clause where_clause = current_where_clause & where_clause @@ -435,54 +403,17 @@ module Interro new = dup new.where_clause = where_clause - new.args = args + values new end # :doc: protected def where(lhs : String, comparator : String, rhs : String, values : Array(Value) = [] of Value) : self - # Must upcast all values in the array to Interro::Value objects - values = values.map { |value| Any.new(value) } - - # Translate $1, $2, ... $n to the numbers they should be. - arg_count = args.size - lhs = lhs.gsub /\$(\d+)/ do |match| - index = match[1].to_i - "$#{arg_count + index}" - end - rhs = rhs.gsub /\$(\d+)/ do |match| - index = match[1].to_i - "$#{arg_count + index}" - end - - where_clause = Interro::QueryExpression.new(lhs, comparator, rhs, values) - - if current_where_clause = @where_clause - where_clause = current_where_clause & where_clause - end - - new = dup - new.where_clause = where_clause - if args.any? - new.args = args + values - else # If the current array is empty, we don't need to concatenate - new.args = values - end - new + where "#{lhs} #{comparator} #{rhs}", values end # :doc: protected def where(expression : String, values : Array(Value) = [] of Value) : self - # Must upcast all values in the array to Interro::Value objects - values = values.map { |value| Any.new(value) } - - # Translate $1, $2, ... $n to the numbers they should be. - arg_count = args.size - expression = expression.gsub /\$(\d+)/ do |match| - index = match[1].to_i - "$#{arg_count + index}" - end - where_clause = Interro::QueryExpression.new(expression, values) + where_clause = Interro::QueryExpression.parse(expression, values) if current_where_clause = @where_clause where_clause = current_where_clause & where_clause @@ -490,11 +421,6 @@ module Interro new = dup new.where_clause = where_clause - if args.any? - new.args = args + values - else # If the current array is empty, we don't need to concatenate - new.args = values - end new end @@ -525,7 +451,7 @@ module Interro # :doc: protected def order_by(**params : String) : self order_by_clause = OrderBy.new(initial_capacity: params.size) - params.each { |key, value| order_by_clause[key.to_s] = value } + params.each { |key, value| order_by_clause[QueryExpression.new(key.to_s)] = value } if current_order_clause = @order_by_clause order_by_clause = current_order_clause.merge(order_by_clause) @@ -537,12 +463,8 @@ module Interro end # :doc: - protected def order_by(expression, direction, args : Array(Interro::Value)? = nil) : self - expression = expression.gsub /\$(\d+)/ do |match| - index = match[1].to_i - "$#{self.args.size + index}" - end - order_by_clause = OrderBy{expression => direction.to_s} + protected def order_by(expression, direction, args : Array(Interro::Value) = [] of Value) : self + order_by_clause = OrderBy{QueryExpression.parse(expression, args) => direction.to_s} if current_order_clause = @order_by_clause order_by_clause = current_order_clause.merge(order_by_clause) @@ -550,9 +472,6 @@ module Interro new = dup new.order_by_clause = order_by_clause - if args - new.args += args.map { |arg| Any.new arg } - end new end @@ -572,8 +491,10 @@ module Interro # :doc: protected def distinct(on expressions : Enumerable(String)) : self + # A distinct expression is raw SQL with no values of its own, so a $n in one cannot resolve to anything and `parse` rejects it. + # An ORDER BY expression that carries values does not need repeating here: its key is added to the subclause when the query is rendered, and Postgres is satisfied as long as every ORDER BY expression appears there. new = dup - new.distinct = expressions.to_a + new.distinct = expressions.map { |expression| QueryExpression.parse(expression) }.to_a new end @@ -590,21 +511,9 @@ module Interro # :doc: protected def scalar(select expression : String, as type : U.class) : U forall U - if args = @args - args = args.map { |arg| Any.new arg } - else - args = [] of Interro::Any - end - - if offset = offset_clause - args << Any.new offset - end - if limit = limit_clause - args << Any.new limit - end - + args = [] of Any sql = String.build do |str| - to_sql str do + to_sql str, args do expression.to_s str end end @@ -636,6 +545,7 @@ module Interro end def none? : Bool + args = [] of Any sql = String.build do |str| str << "SELECT 1 AS one" str << " FROM " << sql_table_name @@ -648,7 +558,8 @@ module Interro end if where = where_clause - str << " WHERE " << where.to_sql + str << " WHERE " + where.to_sql str, args end str << " LIMIT 1" @@ -906,15 +817,22 @@ module Interro end # :doc: - protected def to_sql(io) : Nil - to_sql(io) { select_columns io } + protected def to_sql(io, args : Array(Any)) : Nil + to_sql(io, args) { select_columns io } end - private def to_sql(str, &) : Nil + private def to_sql(str, args : Array(Any), &) : Nil + # An expression appearing in both the DISTINCT ON subclause and ORDER BY has to render identically in both, placeholder numbers included, or Postgres rejects the statement — so each expression is rendered once and the result reused. + rendered = {} of QueryExpression => String + render_once = ->(expression : QueryExpression) do + rendered[expression] ||= String.build { |sql| expression.to_sql sql, args } + end + str << "SELECT " - if distinct_subclause = self.distinct? + if distinct_expressions = self.distinct? # If you provide DISTINCT and an ORDER BY, the ORDER BY clause must also # appear in the DISTINCT subclause. + distinct_subclause = distinct_expressions if order_by = @order_by_clause distinct_subclause += order_by.keys end @@ -923,7 +841,7 @@ module Interro unless distinct_subclause.empty? str << "ON (" distinct_subclause.each_with_index 1 do |expression, index| - str << expression + str << render_once.call(expression) if index < distinct_subclause.size str << ", " end @@ -946,27 +864,27 @@ module Interro if where = @where_clause str << " WHERE " - where.to_sql str + where.to_sql str, args end if order = @order_by_clause str << " ORDER BY " - order.each_with_index(1) do |(key, direction), index| - str << key << ' ' << direction.upcase + order.each_with_index(1) do |(expression, direction), index| + str << render_once.call(expression) << ' ' << direction.upcase if index < order.size str << ", " end end end - placeholder = args.size - if offset = @offset_clause - str << " OFFSET $" << (placeholder += 1) + args << Any.new(offset) + str << " OFFSET $" << args.size end if limit = @limit_clause - str << " LIMIT $" << (placeholder += 1) + args << Any.new(limit) + str << " LIMIT $" << args.size end if for_update? @@ -978,6 +896,16 @@ module Interro end end + # :nodoc: + # Renders the query and the args to bind together, in one pass: each value is appended to the args array as it is emitted, so its placeholder number is simply its position in that array. + protected def render : {String, Array(Any)} + args = [] of Any + sql = String.build do |str| + to_sql str, args + end + {sql, args} + end + private def connection(db) @transaction.try(&.connection) || db end @@ -1027,12 +955,9 @@ module Interro end def each(& : T ->) - args = @lhs.args + @rhs.args - if limit - args << Any.new(limit) - end + sql, args = render - @connection.query_each to_sql, args: args do |rs| + @connection.query_each sql, args: args do |rs| yield T.new(rs) end end @@ -1044,22 +969,26 @@ module Interro end def to_sql - lhs = @lhs.to_sql - lhs_arg_count = @lhs.args.size - rhs = @rhs - .to_sql - .gsub(/\$(\d+)/) { |match| "$#{match[1].to_i + lhs_arg_count}" } - - arg_count = lhs_arg_count + @rhs.args.size - - String.build do |str| - str << lhs - str << ' ' << @combinator << ' ' - str << rhs - if @limit - str << " LIMIT $" << (arg_count += 1) + render[0] + end + + private def render : {String, Array(Any)} + args = [] of Any + + # Parentheses needed so each side can have its own LIMIT/OFFSET. + sql = String.build do |str| + str << '(' + @lhs.to_sql str, args + str << ") " << @combinator << " (" + @rhs.to_sql str, args + str << ')' + if limit = @limit + args << Any.new(limit) + str << " LIMIT $" << args.size end end + + {sql, args} end end end diff --git a/src/query_expression.cr b/src/query_expression.cr index e8c1939..e004b09 100644 --- a/src/query_expression.cr +++ b/src/query_expression.cr @@ -2,32 +2,96 @@ require "./types" # for Interro::Value module Interro struct QueryExpression - getter expression : String - getter values : Array(Any) + # Each part is either raw SQL text (`String`) or a value to bind (`Any`). + alias Part = String | Any - def self.new(lhs, comparator, rhs, values : Array(Any)) - new("#{lhs} #{comparator} #{rhs}", values) + # The expression is just its parts: SQL text interleaved with the values to bind. + # Placeholder numbers are assigned when the query is rendered in `#to_sql`. + getter parts : Array(Part) + + def self.new(*parts : Part) : self + array = Array(Part).new(parts.size) + parts.each { |part| array << part } + new array + end + + def initialize(@parts) end - def initialize(@expression, @values) + # The values this fragment binds, in the order `to_sql` renders them. + def values : Array(Any) + @parts.compact_map(&.as?(Any)) end def &(other : self) : self - values = @values + other.values - self.class.new("(#{to_sql})", "AND", "(#{other.to_sql})", values) + combine "AND", other end def |(other : self) : self - values = @values + other.values - self.class.new("(#{to_sql})", "OR", "(#{other.to_sql})", values) + combine "OR", other + end + + def to_sql(io : IO, args : Array(Any)) : Nil + @parts.each do |part| + case part + in String + io << part + in Any + args << part + io << '$' << args.size + end + end end - def to_sql(io) - io << expression + # Render this expression, numbering placeholders from `$1`. + # Intended for inspecting an expression; one that is about to executed should use `to_sql(io, args)` so that placeholders are numbered relative to the built args array. + def to_sql(io : IO) : Nil + to_sql io, [] of Any end def to_sql - expression + String.build { |str| to_sql str } + end + + private def combine(operator : String, other : self) : self + parts = Array(Part).new(@parts.size + other.parts.size + 3) + parts << "(" + parts.concat @parts + parts << ") #{operator} (" + parts.concat other.parts + parts << ")" + self.class.new parts + end + + # Matches either a $n placeholder, capturing n, or a whole single-quoted SQL string literal ('' being an escaped quote), with no capture. + private PLACEHOLDER_OR_LITERAL = /\$(\d+)|'[^']*(?:''[^']*)*'/ + + # Parses a raw SQL fragment, resolving each `$n` placeholder to the value it references: `$1` is `values[0]`, and so on. + # Raises `ArgumentError` if a placeholder references no value. + def self.parse(fragment : String, values : Array(Value) = [] of Value) : self + parts = [] of Part + cursor = 0 + + # String literals are matched so that a $n inside one cannot match as a placeholder. + fragment.scan(PLACEHOLDER_OR_LITERAL) do |match| + # A literal match has no capture. + next unless number = match[1]? + + index = number.to_i + unless index.in?(1..values.size) + raise ArgumentError.new("SQL fragment #{fragment.inspect} references $#{number}, but only #{values.size} values were provided") + end + + prefix = fragment[cursor...match.begin] + parts << prefix unless prefix.empty? + parts << Any.new(values[index - 1]) + cursor = match.end + end + + suffix = fragment[cursor..] + parts << suffix unless suffix.empty? + + new(parts) end end end diff --git a/src/query_record.cr b/src/query_record.cr index 9b0c532..eee7296 100644 --- a/src/query_record.cr +++ b/src/query_record.cr @@ -2,11 +2,11 @@ require "./query_value" module Interro struct QueryRecord - def initialize(@relation : String, &@block : -> Int32) + def initialize(@relation : String) end macro method_missing(call) - QueryValue.new("#{@relation}.{{call.id}}", @block.call) + QueryValue.new("#{@relation}.{{call.id}}") end end end diff --git a/src/query_value.cr b/src/query_value.cr index de86453..b0ee7c2 100644 --- a/src/query_value.cr +++ b/src/query_value.cr @@ -4,41 +4,40 @@ require "./query_expression" module Interro struct QueryValue getter value : String - getter index : Int32 - def initialize(@value, @index) + def initialize(@value) end def ==(other : Value) - QueryExpression.new(value, "=", "$#{index}", [Any.new(other)]) + QueryExpression.new("#{value} = ", Any.new(other)) end def ==(other : Nil) - QueryExpression.new(value, "IS", "NULL", [] of Any) + QueryExpression.new("#{value} IS NULL") end def <=(other : Value) - QueryExpression.new(value, "<=", "$#{index}", [Any.new(other)]) + QueryExpression.new("#{value} <= ", Any.new(other)) end def >=(other : Value) - QueryExpression.new(value, ">=", "$#{index}", [Any.new(other)]) + QueryExpression.new("#{value} >= ", Any.new(other)) end def <(other : Value) - QueryExpression.new(value, "<", "$#{index}", [Any.new(other)]) + QueryExpression.new("#{value} < ", Any.new(other)) end def >(other : Value) - QueryExpression.new(value, ">", "$#{index}", [Any.new(other)]) + QueryExpression.new("#{value} > ", Any.new(other)) end def !=(other : Value) - QueryExpression.new(value, "!=", "$#{index}", [Any.new(other)]) + QueryExpression.new("#{value} != ", Any.new(other)) end def !=(other : Nil) - QueryExpression.new(value, "IS NOT", "NULL", [] of Any) + QueryExpression.new("#{value} IS NOT NULL") end def in?(array : Enumerable(Value)) @@ -46,7 +45,7 @@ module Interro end def in?(array : Enumerable(Any)) - QueryExpression.new(value, "=", "ANY($#{index})", [Any.new(array)]) + QueryExpression.new("#{value} = ANY(", Any.new(array), ")") end def not_in?(array : Enumerable(Value)) @@ -54,13 +53,13 @@ module Interro end def not_in?(array : Enumerable(Any)) - QueryExpression.new(value, "!=", "ALL($#{index})", [Any.new(array)]) + QueryExpression.new("#{value} != ALL(", Any.new(array), ")") end {% for operator in %w[& | ^] %} # Bitwise operator def {{operator.id}}(other : Value) - QueryExpression.new(value, {{operator}}, "$#{index}", [Any.new(other)]) + QueryExpression.new("#{value} {{operator.id}} ", Any.new(other)) end {% end %} end