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: {}) ⇒ Array
Returns an array of values for a single column.
-
#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.
-
#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.
-
#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.
272 273 274 275 276 277 278 279 280 |
# File 'lib/app_query.rb', line 272 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)
257 258 259 |
# File 'lib/app_query.rb', line 257 def binds @binds end |
#cast ⇒ String, ... (readonly)
257 258 259 |
# File 'lib/app_query.rb', line 257 def cast @cast end |
#cte_depth ⇒ Object (readonly)
Returns the value of attribute cte_depth.
282 283 284 |
# File 'lib/app_query.rb', line 282 def cte_depth @cte_depth end |
#filename ⇒ String, ... (readonly)
257 258 259 |
# File 'lib/app_query.rb', line 257 def filename @filename end |
#name ⇒ String, ... (readonly)
257 258 259 |
# File 'lib/app_query.rb', line 257 def name @name end |
#sql ⇒ String, ... (readonly)
257 258 259 |
# File 'lib/app_query.rb', line 257 def sql @sql end |
Instance Method Details
#add_binds(**binds) ⇒ Q
Returns a new query with binds added.
722 723 724 |
# File 'lib/app_query.rb', line 722 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.
490 491 492 |
# File 'lib/app_query.rb', line 490 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.
866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 |
# File 'lib/app_query.rb', line 866 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: {}) ⇒ 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.
527 528 529 530 |
# File 'lib/app_query.rb', line 527 def column(c, s = nil, binds: {}) quoted_column = ActiveRecord::Base.connection.quote_column_name(c) with_select(s).select_all("SELECT #{quoted_column} AS column FROM :_", binds:).column("column") 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.
470 471 472 |
# File 'lib/app_query.rb', line 470 def count(s = nil, binds: {}) with_select(s).select_all("SELECT COUNT(*) c FROM :_", binds:).column("c").first end |
#cte_names ⇒ Array<String>
Returns the names of all CTEs (Common Table Expressions) in the query.
693 694 695 |
# File 'lib/app_query.rb', line 693 def cte_names tokens.filter { _1[:t] == "CTE_IDENTIFIER" }.map { _1[:v] } 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
301 302 303 |
# File 'lib/app_query.rb', line 301 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.
655 656 657 658 659 660 661 662 663 664 665 666 667 |
# File 'lib/app_query.rb', line 655 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.
564 565 566 |
# File 'lib/app_query.rb', line 564 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").
548 549 550 |
# File 'lib/app_query.rb', line 548 def ids(s = nil, binds: {}) column(:id, s, binds:) end |
#insert(binds: {}, returning: nil) ⇒ Integer, Object
Executes an INSERT query.
591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 |
# File 'lib/app_query.rb', line 591 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.
505 506 507 |
# File 'lib/app_query.rb', line 505 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.
830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 |
# File 'lib/app_query.rb', line 830 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.
808 809 810 |
# File 'lib/app_query.rb', line 808 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.
342 343 344 345 346 347 348 349 |
# File 'lib/app_query.rb', line 342 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.
904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 |
# File 'lib/app_query.rb', line 904 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.
797 798 799 |
# File 'lib/app_query.rb', line 797 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.
402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 |
# File 'lib/app_query.rb', line 402 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.
431 432 433 |
# File 'lib/app_query.rb', line 431 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.
448 449 450 |
# File 'lib/app_query.rb', line 448 def select_value(s = nil, binds: {}, cast: self.cast) select_one(s, binds:, cast:)&.values&.first end |
#to_arel ⇒ Object
284 285 286 287 288 289 290 291 |
# File 'lib/app_query.rb', line 284 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.
940 941 942 |
# File 'lib/app_query.rb', line 940 def to_s @sql end |
#tokenizer ⇒ Tokenizer
Returns the tokenizer instance for this query.
682 683 684 |
# File 'lib/app_query.rb', line 682 def tokenizer @tokenizer ||= Tokenizer.new(to_s) end |
#tokens ⇒ Array<Hash>
Returns the tokenized representation of the SQL.
675 676 677 |
# File 'lib/app_query.rb', line 675 def tokens @tokens ||= tokenizer.run end |
#update(binds: {}) ⇒ Integer
Executes an UPDATE query.
629 630 631 632 633 634 635 636 637 638 639 640 641 |
# File 'lib/app_query.rb', line 629 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.
708 709 710 |
# File 'lib/app_query.rb', line 708 def with_binds(**binds) deep_dup(binds:) end |
#with_cast(cast) ⇒ Q
Returns a new query with different cast settings.
734 735 736 |
# File 'lib/app_query.rb', line 734 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.
766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 |
# File 'lib/app_query.rb', line 766 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 new_cte = "#{current_cte} AS (\n #{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.
742 743 744 |
# File 'lib/app_query.rb', line 742 def with_sql(sql) deep_dup(sql:) end |