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
-
#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.
-
#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.
227 228 229 230 231 232 233 234 235 |
# File 'lib/app_query.rb', line 227 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)
212 213 214 |
# File 'lib/app_query.rb', line 212 def binds @binds end |
#cast ⇒ String, ... (readonly)
212 213 214 |
# File 'lib/app_query.rb', line 212 def cast @cast end |
#cte_depth ⇒ Object (readonly)
Returns the value of attribute cte_depth.
237 238 239 |
# File 'lib/app_query.rb', line 237 def cte_depth @cte_depth end |
#filename ⇒ String, ... (readonly)
212 213 214 |
# File 'lib/app_query.rb', line 212 def filename @filename end |
#name ⇒ String, ... (readonly)
212 213 214 |
# File 'lib/app_query.rb', line 212 def name @name end |
#sql ⇒ String, ... (readonly)
212 213 214 |
# File 'lib/app_query.rb', line 212 def sql @sql end |
Instance Method Details
#add_binds(**binds) ⇒ Q
Returns a new query with binds added.
640 641 642 |
# File 'lib/app_query.rb', line 640 def add_binds(**binds) deep_dup(binds: self.binds.merge(binds)) 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.
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 |
# File 'lib/app_query.rb', line 784 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.
445 446 447 448 |
# File 'lib/app_query.rb', line 445 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.
423 424 425 |
# File 'lib/app_query.rb', line 423 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.
611 612 613 |
# File 'lib/app_query.rb', line 611 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
256 257 258 |
# File 'lib/app_query.rb', line 256 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.
573 574 575 576 577 578 579 580 581 582 583 584 585 |
# File 'lib/app_query.rb', line 573 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.
482 483 484 |
# File 'lib/app_query.rb', line 482 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").
466 467 468 |
# File 'lib/app_query.rb', line 466 def ids(s = nil, binds: {}) column(:id, s, binds:) end |
#insert(binds: {}, returning: nil) ⇒ Integer, Object
Executes an INSERT query.
509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 |
# File 'lib/app_query.rb', line 509 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 |
#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.
748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 |
# File 'lib/app_query.rb', line 748 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.
726 727 728 |
# File 'lib/app_query.rb', line 726 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.
297 298 299 300 301 302 303 304 |
# File 'lib/app_query.rb', line 297 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.
822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 |
# File 'lib/app_query.rb', line 822 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.
715 716 717 |
# File 'lib/app_query.rb', line 715 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.
TODO: have aliases for common casts: select_all(cast: => :date)
355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 |
# File 'lib/app_query.rb', line 355 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.
384 385 386 |
# File 'lib/app_query.rb', line 384 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.
401 402 403 |
# File 'lib/app_query.rb', line 401 def select_value(s = nil, binds: {}, cast: self.cast) select_one(s, binds:, cast:)&.values&.first end |
#to_arel ⇒ Object
239 240 241 242 243 244 245 246 |
# File 'lib/app_query.rb', line 239 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.
858 859 860 |
# File 'lib/app_query.rb', line 858 def to_s @sql end |
#tokenizer ⇒ Tokenizer
Returns the tokenizer instance for this query.
600 601 602 |
# File 'lib/app_query.rb', line 600 def tokenizer @tokenizer ||= Tokenizer.new(to_s) end |
#tokens ⇒ Array<Hash>
Returns the tokenized representation of the SQL.
593 594 595 |
# File 'lib/app_query.rb', line 593 def tokens @tokens ||= tokenizer.run end |
#update(binds: {}) ⇒ Integer
Executes an UPDATE query.
547 548 549 550 551 552 553 554 555 556 557 558 559 |
# File 'lib/app_query.rb', line 547 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.
626 627 628 |
# File 'lib/app_query.rb', line 626 def with_binds(**binds) deep_dup(binds:) end |
#with_cast(cast) ⇒ Q
Returns a new query with different cast settings.
652 653 654 |
# File 'lib/app_query.rb', line 652 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.
684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 |
# File 'lib/app_query.rb', line 684 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.
660 661 662 |
# File 'lib/app_query.rb', line 660 def with_sql(sql) deep_dup(sql:) end |