From 0a11b04eb37f5ded714bc1c536bb808686ebf0da Mon Sep 17 00:00:00 2001 From: Martyn Loughran Date: Thu, 20 Aug 2026 00:26:59 +0100 Subject: [PATCH 01/10] Add missing spec coverage In particular this adds test coverage for: - Queries combining LIMIT and OFFSET - Queries combining DISTINCT ON with ORDER BY (including the parameterized case) --- spec/interro_spec.cr | 61 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/spec/interro_spec.cr b/spec/interro_spec.cr index 8c0565d..3311084 100644 --- a/spec/interro_spec.cr +++ b/spec/interro_spec.cr @@ -151,6 +151,10 @@ struct UserQuery < Interro::QueryBuilder(User) where name: name end + def ids + subquery select: "id" + end + def registered_after(time : Time) where { |user| user.created_at > time } end @@ -176,6 +180,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 +252,15 @@ struct UserQuery < Interro::QueryBuilder(User) order_by "levenshtein(users.name, $1)", "ASC", [name] 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 count : Int64 scalar("count(*)", as: Int64) end @@ -553,6 +570,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) @@ -705,6 +734,26 @@ 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 + describe "matching values in an array" do ids = created_users.map(&.id).first(3) @@ -875,6 +924,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 +954,12 @@ 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 "queries with WHERE EXISTS" do user = create_user(email: "included-#{UUID.random}") included = create_group From 0da3b609891f562b592b782cd44a08f7b8000593 Mon Sep 17 00:00:00 2001 From: Martyn Loughran Date: Tue, 18 Aug 2026 15:58:25 +0100 Subject: [PATCH 02/10] Refactor subquery embedding onto Subquery --- src/query_builder.cr | 39 ++++++++++++++------------------------- 1 file changed, 14 insertions(+), 25 deletions(-) diff --git a/src/query_builder.cr b/src/query_builder.cr index 7612b09..5e0e30f 100644 --- a/src/query_builder.cr +++ b/src/query_builder.cr @@ -289,6 +289,16 @@ module Interro ) end + # This subquery as the right-hand side of an IN, e.g. `id IN (SELECT ...)`. + def in_expression(column : String) : QueryExpression + if where = where_clause + where_args = where.values + else + where_args = [] of Any + end + QueryExpression.new(column, "IN", "(#{to_sql})", where_args) + end + def to_sql String.build do |sql| to_sql sql @@ -341,18 +351,8 @@ module Interro args << any new_clause = QueryExpression.new(key.to_s, "=", "ANY($#{index})", [any]) 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) + args.concat new_clause.values else args << Any.new(value) new_clause = QueryExpression.new(key.to_s, "=", "$#{index}", [Any.new(value)]) @@ -386,19 +386,8 @@ module Interro 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, - ) + new_clause = value.in_expression(key.to_s) + args.concat new_clause.values if where_clause where_clause &= new_clause From f9c5d5872b68dab3a1d8ae7f8e7aa358e004a094 Mon Sep 17 00:00:00 2001 From: Martyn Loughran Date: Thu, 20 Aug 2026 00:42:50 +0100 Subject: [PATCH 03/10] Fix renumbering of multi-digit placeholders The code was previously taking the 2nd character of the matched string, instead of the first match group. It's the same fix in lots of places; just adding one spec since this code is simplified later in the branch by removing the need to renumber entirely and refactoring parsing into one place. --- spec/interro_spec.cr | 20 ++++++++++++++++++++ src/query_builder.cr | 10 +++++----- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/spec/interro_spec.cr b/spec/interro_spec.cr index 3311084..37c830b 100644 --- a/spec/interro_spec.cr +++ b/spec/interro_spec.cr @@ -155,6 +155,14 @@ struct UserQuery < Interro::QueryBuilder(User) 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 registered_after(time : Time) where { |user| user.created_at > time } end @@ -973,6 +981,18 @@ 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 + end + it "can use arbitrary operators" do user = create_user(name: "Search User") diff --git a/src/query_builder.cr b/src/query_builder.cr index 5e0e30f..afc8e1f 100644 --- a/src/query_builder.cr +++ b/src/query_builder.cr @@ -435,11 +435,11 @@ module Interro # Translate $1, $2, ... $n to the numbers they should be. arg_count = args.size - lhs = lhs.gsub /\$(\d+)/ do |match| + lhs = lhs.gsub /\$(\d+)/ do |_, match| index = match[1].to_i "$#{arg_count + index}" end - rhs = rhs.gsub /\$(\d+)/ do |match| + rhs = rhs.gsub /\$(\d+)/ do |_, match| index = match[1].to_i "$#{arg_count + index}" end @@ -467,7 +467,7 @@ module Interro # Translate $1, $2, ... $n to the numbers they should be. arg_count = args.size - expression = expression.gsub /\$(\d+)/ do |match| + expression = expression.gsub /\$(\d+)/ do |_, match| index = match[1].to_i "$#{arg_count + index}" end @@ -527,7 +527,7 @@ module Interro # :doc: protected def order_by(expression, direction, args : Array(Interro::Value)? = nil) : self - expression = expression.gsub /\$(\d+)/ do |match| + expression = expression.gsub /\$(\d+)/ do |_, match| index = match[1].to_i "$#{self.args.size + index}" end @@ -1037,7 +1037,7 @@ module Interro lhs_arg_count = @lhs.args.size rhs = @rhs .to_sql - .gsub(/\$(\d+)/) { |match| "$#{match[1].to_i + lhs_arg_count}" } + .gsub(/\$(\d+)/) { |_, match| "$#{match[1].to_i + lhs_arg_count}" } arg_count = lhs_arg_count + @rhs.args.size From d387c11aca456542f23dc51cd524a91b8008e85f Mon Sep 17 00:00:00 2001 From: Martyn Loughran Date: Thu, 20 Aug 2026 00:43:06 +0100 Subject: [PATCH 04/10] Fix binding of LIMIT & OFFSET args in iterator and compound query Limit and offset args were not being provided in the following cases, resulting in errors like 'bind message supplies 1 parameters, but prepared statement requires 2': - the iterator form of each (e.g. `query.first(5).each`) - both sides of a CompoundQuery The abstracted `QueryBuilder#bind_args` is removed entirely later in this branch when this bug becomes impossible, so I didn't worry too much about its naming. This fix revealed that limited/offset sides of a compound query generated invalid SQL (due to missing parentheses), so that's also fixed here, and is a change which survives the later refactor. --- spec/interro_spec.cr | 18 ++++++++++++++++ src/query_builder.cr | 51 ++++++++++++++++++++------------------------ 2 files changed, 41 insertions(+), 28 deletions(-) diff --git a/spec/interro_spec.cr b/spec/interro_spec.cr index 37c830b..471d40b 100644 --- a/spec/interro_spec.cr +++ b/spec/interro_spec.cr @@ -645,6 +645,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 @@ -908,6 +917,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") } diff --git a/src/query_builder.cr b/src/query_builder.cr index afc8e1f..900aff8 100644 --- a/src/query_builder.cr +++ b/src/query_builder.cr @@ -200,20 +200,12 @@ module Interro ResultSetIterator(T).new( db: connection(CONFIG.read_db), query: to_sql, - args: args, + args: bind_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 - - connection(Interro::CONFIG.read_db).query_each to_sql, args: args do |rs| + connection(Interro::CONFIG.read_db).query_each to_sql, args: bind_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 %} }) @@ -319,7 +311,7 @@ module Interro protected def find(**params) : T? query = where(**params).limit(1) - connection(CONFIG.read_db).query_one? query.to_sql, args: query.args + [1], as: T + connection(CONFIG.read_db).query_one? query.to_sql, args: query.bind_args, as: T end # :doc: @@ -579,18 +571,7 @@ 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 = bind_args sql = String.build do |str| to_sql str do @@ -967,6 +948,19 @@ module Interro end end + # :nodoc: + # The args to bind when executing this query: the composed args plus the values for the OFFSET/LIMIT placeholders `to_sql` emits after them. + protected def bind_args : Array(Any) + args = self.args + if offset = offset_clause + args += [Any.new(offset)] + end + if limit = limit_clause + args += [Any.new(limit)] + end + args + end + private def connection(db) @transaction.try(&.connection) || db end @@ -1016,7 +1010,7 @@ module Interro end def each(& : T ->) - args = @lhs.args + @rhs.args + args = @lhs.bind_args + @rhs.bind_args if limit args << Any.new(limit) end @@ -1034,17 +1028,18 @@ module Interro def to_sql lhs = @lhs.to_sql - lhs_arg_count = @lhs.args.size + lhs_arg_count = @lhs.bind_args.size rhs = @rhs .to_sql .gsub(/\$(\d+)/) { |_, match| "$#{match[1].to_i + lhs_arg_count}" } - arg_count = lhs_arg_count + @rhs.args.size + arg_count = lhs_arg_count + @rhs.bind_args.size + # Parentheses needed so each side can have its own LIMIT/OFFSET. String.build do |str| - str << lhs + str << '(' << lhs << ')' str << ' ' << @combinator << ' ' - str << rhs + str << '(' << rhs << ')' if @limit str << " LIMIT $" << (arg_count += 1) end From 767139c7e9e503ada3c8208dd8d7e5bd7c0d886d Mon Sep 17 00:00:00 2001 From: Martyn Loughran Date: Thu, 20 Aug 2026 00:43:23 +0100 Subject: [PATCH 05/10] Fix to avoid binding ORDER BY values in any?/none? queries `QueryBuilder#none?` only renders the WHERE clause, but args could contain ORDER BY values. This bug also becomes impossible later in this branch. --- spec/interro_spec.cr | 8 ++++++++ src/query_builder.cr | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/spec/interro_spec.cr b/spec/interro_spec.cr index 471d40b..b3473c1 100644 --- a/spec/interro_spec.cr +++ b/spec/interro_spec.cr @@ -1017,6 +1017,14 @@ describe Interro do 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/src/query_builder.cr b/src/query_builder.cr index 900aff8..ff4d1b1 100644 --- a/src/query_builder.cr +++ b/src/query_builder.cr @@ -624,7 +624,7 @@ module Interro str << " LIMIT 1" end - !connection(CONFIG.read_db).query_one? sql, args: args, as: Int32 + !connection(CONFIG.read_db).query_one? sql, args: where_clause.try(&.values) || [] of Any, as: Int32 end # :doc: From a40e6c7a8f2c436fa9738510d9627679db448053 Mon Sep 17 00:00:00 2001 From: Martyn Loughran Date: Thu, 20 Aug 2026 09:21:39 +0100 Subject: [PATCH 06/10] Refactor to key OrderBy Hash by QueryExpression MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is required by the next commit, which moves placeholder numbering to render time and so needs ORDER BY keys to be expression objects rather than pre-numbered SQL text. Rendering is byte-identical, since QueryExpression#to_sql still just writes the stored text. The one nuance is that Hash key equality now compares values as well as SQL text, so two ORDER BY entries with identical text but different values no longer deduplicate — but any query that could hit that already produced a broken query. --- src/query_builder.cr | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/query_builder.cr b/src/query_builder.cr index ff4d1b1..fde1415 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`: @@ -506,7 +507,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, [] of Any)] = value } if current_order_clause = @order_by_clause order_by_clause = current_order_clause.merge(order_by_clause) @@ -523,7 +524,8 @@ module Interro index = match[1].to_i "$#{self.args.size + index}" end - order_by_clause = OrderBy{expression => direction.to_s} + values = args.try(&.map { |arg| Any.new arg }) || [] of Any + order_by_clause = OrderBy{QueryExpression.new(expression, values) => direction.to_s} if current_order_clause = @order_by_clause order_by_clause = current_order_clause.merge(order_by_clause) @@ -882,9 +884,10 @@ module Interro private def to_sql(str, &) : Nil 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.map { |expression| QueryExpression.new(expression, [] of Any) } if order_by = @order_by_clause distinct_subclause += order_by.keys end @@ -893,7 +896,7 @@ module Interro unless distinct_subclause.empty? str << "ON (" distinct_subclause.each_with_index 1 do |expression, index| - str << expression + expression.to_sql str if index < distinct_subclause.size str << ", " end @@ -922,7 +925,8 @@ module Interro if order = @order_by_clause str << " ORDER BY " order.each_with_index(1) do |(key, direction), index| - str << key << ' ' << direction.upcase + key.to_sql str + str << ' ' << direction.upcase if index < order.size str << ", " end From 7ffac822c5f2d21529e8ac9b4e53b1e99d7e000c Mon Sep 17 00:00:00 2001 From: Martyn Loughran Date: Thu, 20 Aug 2026 09:22:44 +0100 Subject: [PATCH 07/10] Fix remaining placeholder bugs by refactoring to make them impossible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two key insights, which together should make placeholder collisions impossible: 1. Changing the internal representation of `QueryExpression` to `Array(Part)` where `Part = String | Any` — raw SQL text (`String`) interleaved with the values to bind (`Any`). Raw fragments from user code (where/order_by/distinct) are parsed on init, with `$n` resolving to the fragment's own values. After that, fragments carry no numbers at all, and placeholder numbers are only assigned at the point of rendering the SQL. 2. `QueryBuilder` no longer stores an args array alongside the SQL. Keeping the two in sync by hand was the shared cause of most of the bugs fixed here — it was easy to emit a placeholder and forget its value, or bind a value the rendered clause never referenced. Now every read path builds SQL and args in one pass, and `QueryExpression#to_sql(io, args)` appends to both in one place. The specs added in interro_spec.cr describe cases that previously failed on master. The trick is `QueryExpression#to_sql(io : IO, args : Array(Any)) : Nil`; this is passed an io and args to mutate, and appends to both on consecutive lines (`args << part` & `io << '$' << args.size`), making it impossible to change one without the other. Every read path now builds SQL and args in that one pass: `each`, `to_a`, `first?` and `to_sql` via the shared `QueryBuilder#render`, with `scalar`, `none?` and `CompoundQuery` threading their own args array through the same method. That retires `bind_args` and the renumbering each call site did against the builder's args. One wrinkle falls out of numbering late: 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. That was automatic while numbers were baked into the expression; now each expression is rendered via a little `render_once` cache so they get the same placeholder ids. Behaviour notes: - A $n repeated within one fragment renders as one placeholder per reference, each bound to the same value — different SQL text, identical semantics. - A $n referencing a missing value raises ArgumentError on init, rather than surfacing as a Postgres bind error at execution. - A $n in a DISTINCT ON expression now raises, since such an expression carries no values of its own. A parameterized ORDER BY expression doesn't need repeating there to satisfy Postgres, as its key is added to the DISTINCT ON subclause anyway. So that parsing raw SQL is explicit, the constructor taking SQL text and values is gone, and internals must call `QueryExpression.parse` directly. QueryValue is refactored in the next commit to remove the remaining four-argument constructor. --- spec/interro_spec.cr | 69 ++++++++++ spec/query_expression_spec.cr | 49 +++++++- src/dynamic_query.cr | 4 - src/query_builder.cr | 228 +++++++++++++--------------------- src/query_expression.cr | 87 +++++++++++-- 5 files changed, 279 insertions(+), 158 deletions(-) diff --git a/spec/interro_spec.cr b/spec/interro_spec.cr index b3473c1..16ab974 100644 --- a/spec/interro_spec.cr +++ b/spec/interro_spec.cr @@ -163,6 +163,14 @@ struct UserQuery < Interro::QueryBuilder(User) where email: email 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 @@ -269,6 +277,11 @@ struct UserQuery < Interro::QueryBuilder(User) .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 @@ -385,6 +398,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) @@ -771,6 +788,12 @@ describe Interro do 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) @@ -986,6 +1009,42 @@ describe Interro do 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 @@ -1011,6 +1070,16 @@ describe Interro do 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") diff --git a/spec/query_expression_spec.cr b/spec/query_expression_spec.cr index fe1096c..96ba69b 100644 --- a/spec/query_expression_spec.cr +++ b/spec/query_expression_spec.cr @@ -6,14 +6,59 @@ 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)]) + rhs = QueryExpression.new("bar", "=", "$1", [Any.new(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)]) + rhs = QueryExpression.new("bar", "=", "$1", [Any.new(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", [Any.new(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", [Any.new(1), Any.new(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", [Any.new(1)]) + end + end + + it "rejects $0" do + expect_raises ArgumentError, "references $0" do + QueryExpression.parse("a = $0", [Any.new(1)]) + end + end + end + + describe "#to_sql" do + it "continues numbering across fragments rendered into one args array" do + first = QueryExpression.parse("a = $1", [Any.new(1)]) + second = QueryExpression.parse("b = $1", [Any.new(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/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 fde1415..d5ba2b4 100644 --- a/src/query_builder.cr +++ b/src/query_builder.cr @@ -173,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 @@ -198,15 +197,18 @@ module Interro end def each + sql, args = render ResultSetIterator(T).new( db: connection(CONFIG.read_db), - query: to_sql, - args: bind_args, + query: sql, + args: args, ) end def each(& : T ->) - connection(Interro::CONFIG.read_db).query_each to_sql, args: bind_args do |rs| + sql, args = render + + 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 %} }) @@ -225,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 @@ -241,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 @@ -284,35 +283,41 @@ module Interro # This subquery as the right-hand side of an IN, e.g. `id IN (SELECT ...)`. def in_expression(column : String) : QueryExpression - if where = where_clause - where_args = where.values - else - where_args = [] of Any - end - QueryExpression.new(column, "IN", "(#{to_sql})", where_args) + 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.bind_args, as: T + connection(CONFIG.read_db).query_one? sql, args: args, as: T end # :doc: @@ -334,21 +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 new_clause = value.in_expression(key.to_s) - args.concat new_clause.values 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 @@ -365,22 +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| + params.each do |key, value| new_clause = value.in_expression(key.to_s) - args.concat new_clause.values if where_clause where_clause &= new_clause @@ -396,20 +389,14 @@ 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 + # Fragments are self-contained now, so each comparison's value is $1 within its own fragment. + where_clause = yield(QueryRecord.new(table) { 1 }) if current_where_clause = @where_clause where_clause = current_where_clause & where_clause @@ -417,40 +404,12 @@ 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: @@ -458,13 +417,7 @@ module Interro # 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 @@ -472,11 +425,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 @@ -507,7 +455,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[QueryExpression.new(key.to_s, [] of Any)] = 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) @@ -520,12 +468,8 @@ module Interro # :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 values = args.try(&.map { |arg| Any.new arg }) || [] of Any - order_by_clause = OrderBy{QueryExpression.new(expression, values) => direction.to_s} + order_by_clause = OrderBy{QueryExpression.parse(expression, values) => direction.to_s} if current_order_clause = @order_by_clause order_by_clause = current_order_clause.merge(order_by_clause) @@ -533,9 +477,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 @@ -555,8 +496,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, [] of Any) }.to_a new end @@ -573,10 +516,9 @@ module Interro # :doc: protected def scalar(select expression : String, as type : U.class) : U forall U - args = bind_args - + args = [] of Any sql = String.build do |str| - to_sql str do + to_sql str, args do expression.to_s str end end @@ -608,6 +550,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 @@ -620,13 +563,14 @@ module Interro end if where = where_clause - str << " WHERE " << where.to_sql + str << " WHERE " + where.to_sql str, args end str << " LIMIT 1" end - !connection(CONFIG.read_db).query_one? sql, args: where_clause.try(&.values) || [] of Any, as: Int32 + !connection(CONFIG.read_db).query_one? sql, args: args, as: Int32 end # :doc: @@ -878,16 +822,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_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.map { |expression| QueryExpression.new(expression, [] of Any) } + distinct_subclause = distinct_expressions if order_by = @order_by_clause distinct_subclause += order_by.keys end @@ -896,7 +846,7 @@ module Interro unless distinct_subclause.empty? str << "ON (" distinct_subclause.each_with_index 1 do |expression, index| - expression.to_sql str + str << render_once.call(expression) if index < distinct_subclause.size str << ", " end @@ -919,28 +869,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| - key.to_sql str - str << ' ' << 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? @@ -953,16 +902,13 @@ module Interro end # :nodoc: - # The args to bind when executing this query: the composed args plus the values for the OFFSET/LIMIT placeholders `to_sql` emits after them. - protected def bind_args : Array(Any) - args = self.args - if offset = offset_clause - args += [Any.new(offset)] - end - if limit = limit_clause - args += [Any.new(limit)] + # 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 - args + {sql, args} end private def connection(db) @@ -1014,12 +960,9 @@ module Interro end def each(& : T ->) - args = @lhs.bind_args + @rhs.bind_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 @@ -1031,23 +974,26 @@ module Interro end def to_sql - lhs = @lhs.to_sql - lhs_arg_count = @lhs.bind_args.size - rhs = @rhs - .to_sql - .gsub(/\$(\d+)/) { |_, match| "$#{match[1].to_i + lhs_arg_count}" } + render[0] + end - arg_count = lhs_arg_count + @rhs.bind_args.size + private def render : {String, Array(Any)} + args = [] of Any # Parentheses needed so each side can have its own LIMIT/OFFSET. - String.build do |str| - str << '(' << lhs << ')' - str << ' ' << @combinator << ' ' - str << '(' << rhs << ')' - if @limit - str << " LIMIT $" << (arg_count += 1) + 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..81070ba 100644 --- a/src/query_expression.cr +++ b/src/query_expression.cr @@ -2,32 +2,97 @@ 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 + # 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) + + # Still used by QueryValue; removed in the next commit. def self.new(lhs, comparator, rhs, values : Array(Any)) - new("#{lhs} #{comparator} #{rhs}", values) + parse "#{lhs} #{comparator} #{rhs}", values + end + + 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 + + private PLACEHOLDER = /\$(\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(Any)) : self + parts = [] of Part + cursor = 0 + + fragment.scan(PLACEHOLDER) do |match| + 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 << values[index - 1] + cursor = match.end + end + + suffix = fragment[cursor..] + parts << suffix unless suffix.empty? + + new(parts) end end end From 2ebc93fde24bb55c93cffeef353d90abf53e6a89 Mon Sep 17 00:00:00 2001 From: Martyn Loughran Date: Thu, 20 Aug 2026 09:22:47 +0100 Subject: [PATCH 08/10] Refactor to remove placeholder numbering from QueryValue internals Separated from the last commit for clarity; `QueryValue` now constructs `QueryExpression` parts directly rather than parsing a numbered expression. By removing the now unused `QueryExpression.new(lhs, comparator, rhs, values)` constructor, all SQL expression parsing always goes through `QueryExpression.parse` (making it easy to spot), and is now only used for raw fragments from user code. --- spec/query_expression_spec.cr | 8 ++++---- spec/query_value_spec.cr | 2 +- src/query_builder.cr | 3 +-- src/query_expression.cr | 5 ----- src/query_record.cr | 4 ++-- src/query_value.cr | 25 ++++++++++++------------- 6 files changed, 20 insertions(+), 27 deletions(-) diff --git a/spec/query_expression_spec.cr b/spec/query_expression_spec.cr index 96ba69b..87a3ef6 100644 --- a/spec/query_expression_spec.cr +++ b/spec/query_expression_spec.cr @@ -5,14 +5,14 @@ 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", "=", "$1", [Any.new(420)]) + lhs = QueryExpression.parse("foo > $1", [Any.new(69)]) + rhs = QueryExpression.parse("bar = $1", [Any.new(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", "=", "$1", [Any.new(420)]) + lhs = QueryExpression.parse("foo > $1", [Any.new(69)]) + rhs = QueryExpression.parse("bar = $1", [Any.new(420)]) (lhs | rhs).to_sql.should eq "(foo > $1) OR (bar = $2)" 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/query_builder.cr b/src/query_builder.cr index d5ba2b4..13bd777 100644 --- a/src/query_builder.cr +++ b/src/query_builder.cr @@ -395,8 +395,7 @@ module Interro # :doc: protected def where(table = sql_table_alias, &block : QueryRecord -> QueryExpression) : self - # Fragments are self-contained now, so each comparison's value is $1 within its own fragment. - where_clause = yield(QueryRecord.new(table) { 1 }) + where_clause = yield(QueryRecord.new(table)) if current_where_clause = @where_clause where_clause = current_where_clause & where_clause diff --git a/src/query_expression.cr b/src/query_expression.cr index 81070ba..88e221c 100644 --- a/src/query_expression.cr +++ b/src/query_expression.cr @@ -9,11 +9,6 @@ module Interro # Placeholder numbers are assigned when the query is rendered in `#to_sql`. getter parts : Array(Part) - # Still used by QueryValue; removed in the next commit. - def self.new(lhs, comparator, rhs, values : Array(Any)) - parse "#{lhs} #{comparator} #{rhs}", values - end - def self.new(*parts : Part) : self array = Array(Part).new(parts.size) parts.each { |part| array << part } 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 From 4d8f85fe880070949eaaebcc33bc3a9fdec7900a Mon Sep 17 00:00:00 2001 From: Martyn Loughran Date: Thu, 20 Aug 2026 09:23:42 +0100 Subject: [PATCH 09/10] Fix renumbering of $n inside string literals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before this branch, a $n inside a SQL string literal risked being silently renumbered along with the real placeholders. Nothing failed visibly, because the renumbering only rewrote text and left the number of arguments sent to Postgres unchanged — the only damage was to the contents of the string. After the core refactor the same mistake becomes visible, which is arguably an improvement: either parsing raises, because the misread placeholder has no value to resolve to, or the value it swallows is bound to a placeholder Postgres cannot see inside the quotes, and Postgres rejects the statement. With this fix a $n inside an ordinary single-quoted literal is left alone, and ArgumentError is raised only where it should be: when a $n references a value that was not provided. Note that only ordinary single-quoted literals are recognised. A $n inside an E'' string, a dollar-quoted string, or a quoted identifier is still taken for a placeholder. Covering those means matching every form of literal Postgres accepts, which is a much larger pattern, and left till later. --- spec/interro_spec.cr | 13 +++++++++++++ spec/query_expression_spec.cr | 7 +++++++ src/query_expression.cr | 10 +++++++--- 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/spec/interro_spec.cr b/spec/interro_spec.cr index 16ab974..6a848dd 100644 --- a/spec/interro_spec.cr +++ b/spec/interro_spec.cr @@ -163,6 +163,10 @@ struct UserQuery < Interro::QueryBuilder(User) 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 @@ -1068,6 +1072,15 @@ describe Interro do 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 diff --git a/spec/query_expression_spec.cr b/spec/query_expression_spec.cr index 87a3ef6..ec0d38f 100644 --- a/spec/query_expression_spec.cr +++ b/spec/query_expression_spec.cr @@ -42,6 +42,13 @@ module Interro QueryExpression.parse("a = $0", [Any.new(1)]) end end + + it "leaves $n inside string literals alone, including '' escapes" do + expression = QueryExpression.parse("note = 'it''s $1' AND id = $1", [Any.new(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 diff --git a/src/query_expression.cr b/src/query_expression.cr index 88e221c..8737979 100644 --- a/src/query_expression.cr +++ b/src/query_expression.cr @@ -63,7 +63,8 @@ module Interro self.class.new parts end - private PLACEHOLDER = /\$(\d+)/ + # 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. @@ -71,8 +72,11 @@ module Interro parts = [] of Part cursor = 0 - fragment.scan(PLACEHOLDER) do |match| - number = match[1] + # 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") From 57b0b7031cea7d8edf90ae725d0d52ce61374b10 Mon Sep 17 00:00:00 2001 From: Martyn Loughran Date: Wed, 26 Aug 2026 15:44:16 +0100 Subject: [PATCH 10/10] Refactor to avoid need to wrap values for QueryExpression.parse Simplifies callers by avoiding the need to upcast to `Array(Any)` first, and by defaulting to empty values. Includes adding missing spec coverage for ORDER BY on a raw expression with no args. --- spec/interro_spec.cr | 10 ++++++++++ spec/query_expression_spec.cr | 22 +++++++++++----------- src/query_builder.cr | 10 +++------- src/query_expression.cr | 4 ++-- 4 files changed, 26 insertions(+), 20 deletions(-) diff --git a/spec/interro_spec.cr b/spec/interro_spec.cr index 6a848dd..c623fa5 100644 --- a/spec/interro_spec.cr +++ b/spec/interro_spec.cr @@ -272,6 +272,10 @@ 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 @@ -722,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 diff --git a/spec/query_expression_spec.cr b/spec/query_expression_spec.cr index ec0d38f..774ec01 100644 --- a/spec/query_expression_spec.cr +++ b/spec/query_expression_spec.cr @@ -5,27 +5,27 @@ require "../src/query_expression" module Interro describe QueryExpression do it "generates an AND conjunction of two expressions" do - lhs = QueryExpression.parse("foo > $1", [Any.new(69)]) - rhs = QueryExpression.parse("bar = $1", [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.parse("foo > $1", [Any.new(69)]) - rhs = QueryExpression.parse("bar = $1", [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", [Any.new(1)]) + 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", [Any.new(1), Any.new(2)]) + 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)] @@ -33,18 +33,18 @@ module Interro it "raises when a placeholder references a missing value" do expect_raises ArgumentError, "references $2" do - QueryExpression.parse("a = $2", [Any.new(1)]) + QueryExpression.parse("a = $2", [1]) end end it "rejects $0" do expect_raises ArgumentError, "references $0" do - QueryExpression.parse("a = $0", [Any.new(1)]) + 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", [Any.new(1)]) + 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)] @@ -53,8 +53,8 @@ module Interro describe "#to_sql" do it "continues numbering across fragments rendered into one args array" do - first = QueryExpression.parse("a = $1", [Any.new(1)]) - second = QueryExpression.parse("b = $1", [Any.new(2)]) + first = QueryExpression.parse("a = $1", [1]) + second = QueryExpression.parse("b = $1", [2]) args = [] of Any sql = String.build do |str| diff --git a/src/query_builder.cr b/src/query_builder.cr index 13bd777..a92f83f 100644 --- a/src/query_builder.cr +++ b/src/query_builder.cr @@ -413,9 +413,6 @@ module Interro # :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) } - where_clause = Interro::QueryExpression.parse(expression, values) if current_where_clause = @where_clause @@ -466,9 +463,8 @@ module Interro end # :doc: - protected def order_by(expression, direction, args : Array(Interro::Value)? = nil) : self - values = args.try(&.map { |arg| Any.new arg }) || [] of Any - order_by_clause = OrderBy{QueryExpression.parse(expression, values) => 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) @@ -498,7 +494,7 @@ module Interro # 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.map { |expression| QueryExpression.parse(expression, [] of Any) }.to_a + new.distinct = expressions.map { |expression| QueryExpression.parse(expression) }.to_a new end diff --git a/src/query_expression.cr b/src/query_expression.cr index 8737979..e004b09 100644 --- a/src/query_expression.cr +++ b/src/query_expression.cr @@ -68,7 +68,7 @@ module Interro # 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(Any)) : self + def self.parse(fragment : String, values : Array(Value) = [] of Value) : self parts = [] of Part cursor = 0 @@ -84,7 +84,7 @@ module Interro prefix = fragment[cursor...match.begin] parts << prefix unless prefix.empty? - parts << values[index - 1] + parts << Any.new(values[index - 1]) cursor = match.end end