Class: AppQuery::Q
- Inherits:
-
Object
- Object
- AppQuery::Q
- Defined in:
- lib/app_query.rb
Overview
Query object for building, rendering, and executing SQL queries.
Q wraps a SQL string (optionally with ERB templating) and provides methods for query execution, CTE manipulation, and result handling.
Method Groups
- Rendering — Process ERB templates to produce executable SQL.
- Query Execution — Execute queries against the database. These methods
wrap the equivalent
ActiveRecord::Base.connectionmethods (select_all,insert,update,delete). - Query Introspection — Inspect and analyze the structure of the query.
- Query Transformation — Create modified copies of the query. All transformation methods are immutable—they return a new Q instance and leave the original unchanged.
- CTE Manipulation — Add, replace, or reorder Common Table Expressions (CTEs). Like transformation methods, these return a new Q instance.
Instance Attribute Summary collapse
- #binds ⇒ String, ... readonly
- #cast ⇒ String, ... readonly
-
#cte_depth ⇒ Object
readonly
Returns the value of attribute cte_depth.
- #filename ⇒ String, ... readonly
- #name ⇒ String, ... readonly
- #sql ⇒ String, ... readonly
Rendering collapse
-
#render(vars = {}) ⇒ Q
Renders the ERB template with the given variables.
Query Execution collapse
-
#any?(s = nil, binds: {}) ⇒ Boolean
Returns whether any rows exist in the query result.
-
#column(c, s = nil, binds: {}, unique: false) ⇒ Array
Returns an array of values for a single column.
-
#columns(s = nil, binds: {}) ⇒ Array<String>
Returns the column names from the query without fetching any rows.
-
#copy_to(s = nil, format: :csv, header: true, delimiter: nil, dest: nil, binds: {}) ⇒ String, ...
Executes COPY TO STDOUT for efficient data export.
-
#count(s = nil, binds: {}) ⇒ Integer
Returns the count of rows from the query.
-
#delete(binds: {}) ⇒ Integer
Executes a DELETE query.
-
#entries ⇒ Array<Hash>
Executes the query and returns results as an Array of Hashes.
-
#ids(s = nil, binds: {}) ⇒ Array
Returns an array of id values from the query.
-
#insert(binds: {}, returning: nil) ⇒ Integer, Object
Executes an INSERT query.
-
#none?(s = nil, binds: {}) ⇒ Boolean
Returns whether no rows exist in the query result.
-
#select_all(s = nil, binds: {}, cast: self.cast) ⇒ Result
Executes the query and returns all matching rows.
-
#select_one(s = nil, binds: {}, cast: self.cast) ⇒ Hash?
(also: #first)
Executes the query and returns the first row.
-
#select_value(s = nil, binds: {}, cast: self.cast) ⇒ Object?
Executes the query and returns the first value of the first row.
-
#take(n, s = nil, binds: {}, cast: self.cast) ⇒ Array<Hash>
(also: #limit)
Executes the query and returns the first n rows.
-
#update(binds: {}) ⇒ Integer
Executes an UPDATE query.
Query Introspection collapse
-
#cte_names ⇒ Array<String>
Returns the names of all CTEs (Common Table Expressions) in the query.
-
#recursive? ⇒ Boolean
Checks if the query uses RECURSIVE CTEs.
-
#select ⇒ String?
Returns the SELECT clause of the query.
-
#tokenizer ⇒ Tokenizer
Returns the tokenizer instance for this query.
-
#tokens ⇒ Array<Hash>
Returns the tokenized representation of the SQL.
Query Transformation collapse
-
#add_binds(**binds) ⇒ Q
Returns a new query with binds added.
-
#with_binds(**binds) ⇒ Q
(also: #replace_binds)
Returns a new query with different bind parameters.
-
#with_cast(cast) ⇒ Q
Returns a new query with different cast settings.
-
#with_select(sql) ⇒ Q
Returns a new query with a modified SELECT statement.
-
#with_sql(sql) ⇒ Q
Returns a new query with different SQL.
CTE Manipulation collapse
-
#append_cte(cte) ⇒ Q
Appends a CTE to the end of the WITH clause.
-
#cte(name) ⇒ Q
Returns a new query focused on the specified CTE.
-
#prepend_cte(cte) ⇒ Q
Prepends a CTE to the beginning of the WITH clause.
-
#replace_cte(cte) ⇒ Q
Replaces an existing CTE with a new definition.
Instance Method Summary collapse
- #deep_dup(sql: self.sql, name: self.name, filename: self.filename, binds: self.binds.dup, cast: self.cast, cte_depth: self.cte_depth) ⇒ Object
-
#initialize(sql, name: nil, filename: nil, binds: {}, cast: true, cte_depth: 0) ⇒ Q
constructor
Creates a new query object.
- #to_arel ⇒ Object
-
#to_s ⇒ String
Returns the SQL string.
Constructor Details
#initialize(sql, name: nil, filename: nil, binds: {}, cast: true, cte_depth: 0) ⇒ Q
Creates a new query object.
292 293 294 295 296 297 298 299 300 |
# File 'lib/app_query.rb', line 292 def initialize(sql, name: nil, filename: nil, binds: {}, cast: true, cte_depth: 0) @sql = sql @name = name @filename = filename @binds = binds @cast = cast @cte_depth = cte_depth @binds = binds_with_defaults(sql, binds) end |
Instance Attribute Details
#binds ⇒ String, ... (readonly)
277 278 279 |
# File 'lib/app_query.rb', line 277 def binds @binds end |
#cast ⇒ String, ... (readonly)
277 278 279 |
# File 'lib/app_query.rb', line 277 def cast @cast end |
#cte_depth ⇒ Object (readonly)
Returns the value of attribute cte_depth.
302 303 304 |
# File 'lib/app_query.rb', line 302 def cte_depth @cte_depth end |
#filename ⇒ String, ... (readonly)
277 278 279 |
# File 'lib/app_query.rb', line 277 def filename @filename end |
#name ⇒ String, ... (readonly)
277 278 279 |
# File 'lib/app_query.rb', line 277 def name @name end |
#sql ⇒ String, ... (readonly)
277 278 279 |
# File 'lib/app_query.rb', line 277 def sql @sql end |
Instance Method Details
#add_binds(**binds) ⇒ Q
Returns a new query with binds added.
881 882 883 |
# File 'lib/app_query.rb', line 881 def add_binds(**binds) deep_dup(binds: self.binds.merge(binds)) end |
#any?(s = nil, binds: {}) ⇒ Boolean
Returns whether any rows exist in the query result.
Uses EXISTS which stops at the first matching row, making it more
efficient than count > 0 for large result sets.
528 529 530 |
# File 'lib/app_query.rb', line 528 def any?(s = nil, binds: {}) with_select(s).select_all("SELECT EXISTS(SELECT 1 FROM :_) e", binds:).column("e").first end |
#append_cte(cte) ⇒ Q
Appends a CTE to the end of the WITH clause.
If the query has no CTEs, wraps it with WITH. If the query already has CTEs, adds the new CTE at the end.
1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 |
# File 'lib/app_query.rb', line 1050 def append_cte(cte) # early raise when cte is not valid sql add_recursive, to_append = Tokenizer.tokenize(cte, state: :lex_append_cte).then do |tokens| [!recursive? && tokens.find { _1[:t] == "RECURSIVE" }, tokens.reject { _1[:t] == "RECURSIVE" }] end if cte_names.none? with_sql("WITH #{cte}\n#{self}") else nof_ctes = cte_names.size with_sql(tokens.map do |token| nof_ctes -= 1 if token[:t] == "CTE_SELECT" if nof_ctes.zero? nof_ctes -= 1 token[:v] + to_append.map { _1[:v] }.join elsif token[:t] == "WITH" && add_recursive token[:v] + add_recursive[:v] else token[:v] end end.join) end end |
#column(c, s = nil, binds: {}, unique: false) ⇒ Array
Returns an array of values for a single column.
Wraps the query in a CTE and selects only the specified column, which is
more efficient than fetching all columns via select_all.column(name).
The column name is safely quoted, making this method safe for user input.
570 571 572 573 |
# File 'lib/app_query.rb', line 570 def column(c, s = nil, binds: {}, unique: false) quoted_column = ActiveRecord::Base.connection.quote_column_name(c) with_select(s).select_all("SELECT #{unique ? "DISTINCT" : ""} #{quoted_column} AS column FROM :_", binds:).column("column") end |
#columns(s = nil, binds: {}) ⇒ Array<String>
Returns the column names from the query without fetching any rows.
Uses LIMIT 0 to get column metadata efficiently.
590 591 592 |
# File 'lib/app_query.rb', line 590 def columns(s = nil, binds: {}) with_select(s).select_all("SELECT * FROM :_ LIMIT 0", binds:).columns end |
#copy_to(s = nil, format: :csv, header: true, delimiter: nil, dest: nil, binds: {}) ⇒ String, ...
Executes COPY TO STDOUT for efficient data export.
PostgreSQL-only. Uses raw connection for streaming. Raises an error when used with SQLite or other non-PostgreSQL adapters.
765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 |
# File 'lib/app_query.rb', line 765 def copy_to(s = nil, format: :csv, header: true, delimiter: nil, dest: nil, binds: {}) raw_conn = ActiveRecord::Base.connection.raw_connection unless raw_conn.respond_to?(:copy_data) raise Error, "copy_to requires PostgreSQL (current adapter does not support COPY)" end allowed_formats = %i[csv text binary] unless allowed_formats.include?(format) raise ArgumentError, "Invalid format: #{format.inspect}. Allowed: #{allowed_formats.join(", ")}" end delimiters = {tab: "\t", comma: ",", pipe: "|", semicolon: ";"} if delimiter if !delimiters.key?(delimiter) raise ArgumentError, "Invalid delimiter: #{delimiter.inspect}. Allowed: #{delimiters.keys.join(", ")}" elsif format == :binary raise ArgumentError, "Delimiter not allowed for format :binary" end end add_binds(**binds).with_select(s).render({}).then do |aq| = ["FORMAT #{format.to_s.upcase}"] << "HEADER" if header && format == :csv << "DELIMITER E'#{delimiters[delimiter]}'" if delimiter inner_sql = ActiveRecord::Base.sanitize_sql_array([aq.to_s, aq.binds]) copy_sql = "COPY (#{inner_sql}) TO STDOUT WITH (#{.join(", ")})" case dest when NilClass output = +"" raw_conn.copy_data(copy_sql) do while (row = raw_conn.get_copy_data) output << row end end # pg returns ASCII-8BIT, but CSV/text is UTF-8; binary stays as-is (format == :binary) ? output : output.force_encoding(Encoding::UTF_8) when String bytes = 0 File.open(dest, "wb") do |f| raw_conn.copy_data(copy_sql) do while (row = raw_conn.get_copy_data) bytes += f.write(row) end end end bytes else raw_conn.copy_data(copy_sql) do while (row = raw_conn.get_copy_data) dest.write(row) end end nil end end end |
#count(s = nil, binds: {}) ⇒ Integer
Returns the count of rows from the query.
Wraps the query in a CTE and selects only the count, which is more
efficient than fetching all rows via select_all.count.
508 509 510 |
# File 'lib/app_query.rb', line 508 def count(s = nil, binds: {}) with_select(s).select_all("SELECT COUNT(*) c FROM :_", binds:).column("c").first end |
#cte(name) ⇒ Q
Returns a new query focused on the specified CTE.
Wraps the query to select from the named CTE, allowing you to inspect or test individual CTEs in isolation.
989 990 991 992 993 994 995 996 |
# File 'lib/app_query.rb', line 989 def cte(name) name = name.to_s unless cte_names.include?(name) raise ArgumentError, "Unknown CTE #{name.inspect}. Available: #{cte_names.inspect}" end quoted = ActiveRecord::Base.connection.quote_table_name(name) with_select("SELECT * FROM #{quoted}") end |
#cte_names ⇒ Array<String>
Returns the names of all CTEs (Common Table Expressions) in the query.
852 853 854 |
# File 'lib/app_query.rb', line 852 def cte_names tokens.filter { _1[:t] == "CTE_IDENTIFIER" }.map { _1[:v].delete_prefix('"').delete_suffix('"') } end |
#deep_dup(sql: self.sql, name: self.name, filename: self.filename, binds: self.binds.dup, cast: self.cast, cte_depth: self.cte_depth) ⇒ Object
321 322 323 |
# File 'lib/app_query.rb', line 321 def deep_dup(sql: self.sql, name: self.name, filename: self.filename, binds: self.binds.dup, cast: self.cast, cte_depth: self.cte_depth) self.class.new(sql, name:, filename:, binds:, cast:, cte_depth:) end |
#delete(binds: {}) ⇒ Integer
Executes a DELETE query.
712 713 714 715 716 717 718 719 720 721 722 723 724 |
# File 'lib/app_query.rb', line 712 def delete(binds: {}) with_binds(**binds).render({}).then do |aq| sql = if ActiveRecord::VERSION::STRING.to_f >= 7.1 aq.to_arel else ActiveRecord::Base.sanitize_sql_array([aq.to_s, **aq.binds]) end ActiveRecord::Base.connection.delete(sql, name) end rescue NameError => e raise e unless e.instance_of?(NameError) raise UnrenderedQueryError, "Query is ERB. Use #render before deleting." end |
#entries ⇒ Array<Hash>
Executes the query and returns results as an Array of Hashes.
Shorthand for select_all(...).entries. Accepts the same arguments as
#select_all.
626 627 628 |
# File 'lib/app_query.rb', line 626 def entries(...) select_all(...).entries end |
#ids(s = nil, binds: {}) ⇒ Array
Returns an array of id values from the query.
Convenience method equivalent to column(:id). More efficient than
fetching all columns via select_all.column("id").
610 611 612 |
# File 'lib/app_query.rb', line 610 def ids(s = nil, binds: {}) column(:id, s, binds:) end |
#insert(binds: {}, returning: nil) ⇒ Integer, Object
Executes an INSERT query.
648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 |
# File 'lib/app_query.rb', line 648 def insert(binds: {}, returning: nil) # ActiveRecord::Base.connection.insert(sql, name, _pk = nil, _id_value = nil, _sequence_name = nil, binds, returning: nil) if returning && ActiveRecord::VERSION::STRING.to_f < 7.1 raise ArgumentError, "The 'returning' option requires Rails 7.1+. Current version: #{ActiveRecord::VERSION::STRING}" end with_binds(**binds).render({}).then do |aq| sql = if ActiveRecord::VERSION::STRING.to_f >= 7.1 aq.to_arel else ActiveRecord::Base.sanitize_sql_array([aq.to_s, **aq.binds]) end if ActiveRecord::VERSION::STRING.to_f >= 7.1 ActiveRecord::Base.connection.insert(sql, name, returning:) else ActiveRecord::Base.connection.insert(sql, name) end end rescue NameError => e # Prevent any subclasses, e.g. NoMethodError raise e unless e.instance_of?(NameError) raise UnrenderedQueryError, "Query is ERB. Use #render before select-ing." end |
#none?(s = nil, binds: {}) ⇒ Boolean
Returns whether no rows exist in the query result.
Inverse of #any?. Uses EXISTS for efficiency.
543 544 545 |
# File 'lib/app_query.rb', line 543 def none?(s = nil, binds: {}) !any?(s, binds:) end |
#prepend_cte(cte) ⇒ Q
Prepends a CTE to the beginning of the WITH clause.
If the query has no CTEs, wraps it with WITH. If the query already has CTEs, adds the new CTE at the beginning.
1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 |
# File 'lib/app_query.rb', line 1014 def prepend_cte(cte) # early raise when cte is not valid sql to_append = Tokenizer.tokenize(cte, state: :lex_prepend_cte).then do |tokens| recursive? ? tokens.reject { _1[:t] == "RECURSIVE" } : tokens end if cte_names.none? with_sql("WITH #{cte}\n#{self}") else split_at_type = recursive? ? "RECURSIVE" : "WITH" with_sql(tokens.map do |token| if token[:t] == split_at_type token[:v] + to_append.map { _1[:v] }.join else token[:v] end end.join) end end |
#recursive? ⇒ Boolean
Checks if the query uses RECURSIVE CTEs.
968 969 970 |
# File 'lib/app_query.rb', line 968 def recursive? !!tokens.find { _1[:t] == "RECURSIVE" } end |
#render(vars = {}) ⇒ Q
Renders the ERB template with the given variables.
Processes ERB tags in the SQL and collects any bind parameters created by helpers like RenderHelpers#bind and RenderHelpers#values.
362 363 364 365 366 367 368 369 |
# File 'lib/app_query.rb', line 362 def render(vars = {}) vars ||= {} helper = render_helper(vars) sql = to_erb.result(helper.get_binding) collected = helper.collected_binds with_sql(sql).add_binds(**collected) end |
#replace_cte(cte) ⇒ Q
Replaces an existing CTE with a new definition.
1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 |
# File 'lib/app_query.rb', line 1088 def replace_cte(cte) add_recursive, to_append = Tokenizer.tokenize(cte, state: :lex_recursive_cte).then do |tokens| [!recursive? && tokens.find { _1[:t] == "RECURSIVE" }, tokens.reject { _1[:t] == "RECURSIVE" }] end cte_name = to_append.find { _1[:t] == "CTE_IDENTIFIER" }&.[](:v) unless cte_names.include?(cte_name) raise ArgumentError, "Unknown cte #{cte_name.inspect}. Options: #{cte_names}." end cte_ix = cte_names.index(cte_name) return self unless cte_ix cte_found = false with_sql(tokens.map do |token| if cte_found ||= token[:t] == "CTE_IDENTIFIER" && token[:v] == cte_name unless (cte_found = (token[:t] != "CTE_SELECT")) next to_append.map { _1[:v] }.join end next elsif token[:t] == "WITH" && add_recursive token[:v] + add_recursive[:v] else token[:v] end end.join) end |
#select ⇒ String?
Returns the SELECT clause of the query.
957 958 959 |
# File 'lib/app_query.rb', line 957 def select tokens.find { _1[:t] == "SELECT" }&.[](:v) end |
#select_all(s = nil, binds: {}, cast: self.cast) ⇒ Result
Executes the query and returns all matching rows.
422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 |
# File 'lib/app_query.rb', line 422 def select_all(s = nil, binds: {}, cast: self.cast) add_binds(**binds).with_select(s).render({}).then do |aq| sql = if ActiveRecord::VERSION::STRING.to_f >= 7.1 aq.to_arel else ActiveRecord::Base.sanitize_sql_array([aq.to_s, aq.binds]) end ActiveRecord::Base.connection.select_all(sql, aq.name).then do |result| Result.from_ar_result(result, cast) end end rescue NameError => e # Prevent any subclasses, e.g. NoMethodError raise e unless e.instance_of?(NameError) raise UnrenderedQueryError, "Query is ERB. Use #render before select-ing." end |
#select_one(s = nil, binds: {}, cast: self.cast) ⇒ Hash? Also known as: first
Executes the query and returns the first row.
451 452 453 |
# File 'lib/app_query.rb', line 451 def select_one(s = nil, binds: {}, cast: self.cast) with_select(s).select_all("SELECT * FROM :_ LIMIT 1", binds:, cast:).first end |
#select_value(s = nil, binds: {}, cast: self.cast) ⇒ Object?
Executes the query and returns the first value of the first row.
486 487 488 |
# File 'lib/app_query.rb', line 486 def select_value(s = nil, binds: {}, cast: self.cast) select_one(s, binds:, cast:)&.values&.first end |
#take(n, s = nil, binds: {}, cast: self.cast) ⇒ Array<Hash> Also known as: limit
Executes the query and returns the first n rows.
469 470 471 |
# File 'lib/app_query.rb', line 469 def take(n, s = nil, binds: {}, cast: self.cast) with_select(s).select_all("SELECT * FROM :_ LIMIT #{n.to_i}", binds:, cast:).entries end |
#to_arel ⇒ Object
304 305 306 307 308 309 310 311 |
# File 'lib/app_query.rb', line 304 def to_arel if binds.presence Arel::Nodes::BoundSqlLiteral.new sql, [], binds else # TODO: add retryable? available from >=7.1 Arel::Nodes::SqlLiteral.new(sql) end end |
#to_s ⇒ String
Returns the SQL string.
1124 1125 1126 |
# File 'lib/app_query.rb', line 1124 def to_s @sql end |
#tokenizer ⇒ Tokenizer
Returns the tokenizer instance for this query.
837 838 839 |
# File 'lib/app_query.rb', line 837 def tokenizer @tokenizer ||= Tokenizer.new(to_s) end |
#tokens ⇒ Array<Hash>
Returns the tokenized representation of the SQL.
830 831 832 |
# File 'lib/app_query.rb', line 830 def tokens @tokens ||= tokenizer.run end |
#update(binds: {}) ⇒ Integer
Executes an UPDATE query.
686 687 688 689 690 691 692 693 694 695 696 697 698 |
# File 'lib/app_query.rb', line 686 def update(binds: {}) with_binds(**binds).render({}).then do |aq| sql = if ActiveRecord::VERSION::STRING.to_f >= 7.1 aq.to_arel else ActiveRecord::Base.sanitize_sql_array([aq.to_s, **aq.binds]) end ActiveRecord::Base.connection.update(sql, name) end rescue NameError => e raise e unless e.instance_of?(NameError) raise UnrenderedQueryError, "Query is ERB. Use #render before updating." end |
#with_binds(**binds) ⇒ Q Also known as: replace_binds
Returns a new query with different bind parameters.
867 868 869 |
# File 'lib/app_query.rb', line 867 def with_binds(**binds) deep_dup(binds:) end |
#with_cast(cast) ⇒ Q
Returns a new query with different cast settings.
893 894 895 |
# File 'lib/app_query.rb', line 893 def with_cast(cast) deep_dup(cast:) end |
#with_select(sql) ⇒ Q
Returns a new query with a modified SELECT statement.
Wraps the current SELECT in a numbered CTE and applies the new SELECT.
CTEs are named _, _1, _2, etc. Use :_ in the new SELECT to
reference the previous result.
925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 |
# File 'lib/app_query.rb', line 925 def with_select(sql) return self if sql.nil? # First CTE is "_", then "_1", "_2", etc. current_cte = (cte_depth == 0) ? "_" : "_#{cte_depth}" # Replace :_ with the current CTE name processed_sql = sql.gsub(/:_\b/, current_cte) # Wrap current SELECT in numbered CTE (indent all lines, strip trailing whitespace) indented_select = select.rstrip.gsub("\n", "\n ") new_cte = "#{current_cte} AS (\n #{indented_select}\n)" append_cte(new_cte).then do |q| # Replace the SELECT token with processed_sql and increment depth new_sql = q.tokens.each_with_object([]) do |token, acc| v = (token[:t] == "SELECT") ? processed_sql : token[:v] acc << v end.join q.deep_dup(sql: new_sql, cte_depth: cte_depth + 1) end end |
#with_sql(sql) ⇒ Q
Returns a new query with different SQL.
901 902 903 |
# File 'lib/app_query.rb', line 901 def with_sql(sql) deep_dup(sql:) end |