diff --git a/.github/workflows/rubocop.yml b/.github/workflows/rubocop.yml deleted file mode 100644 index 60b1571..0000000 --- a/.github/workflows/rubocop.yml +++ /dev/null @@ -1,12 +0,0 @@ -name: Rubocop -on: [pull_request] -jobs: - Rubocop: - runs-on: ubuntu-latest - steps: - - name: Check out repository code - uses: actions/checkout@v2 - - name: Setup Ruby - uses: ruby/setup-ruby@v1 - - run: bundle install - - run: bundle exec rubocop diff --git a/.github/workflows/test-with-mysql.yml b/.github/workflows/test-with-mysql.yml deleted file mode 100644 index 26ac584..0000000 --- a/.github/workflows/test-with-mysql.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: MySql -on: [pull_request] -jobs: - Test-With-Mysql: - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - active_record: [6.1.7.2, 6.0.6, 5.2.8.1] - ruby: ['3.0', 3.1, 3.2] - exclude: - - active_record: 5.2.8.1 - ruby: '3.0' - - active_record: 5.2.8.1 - ruby: 3.1 - - active_record: 5.2.8.1 - ruby: 3.2 - env: - ACTIVE_RECORD_VERSION: ${{ matrix.active_record }} - DATABASE_ADAPTER: mysql - INSTALL_MYSQL_GEM: true - RAILS_ENV: test - steps: - - name: Check out repository code - uses: actions/checkout@v2 - - name: Start mysql - run: sudo service mysql start - - name: Set up Ruby - uses: ruby/setup-ruby@v1 - with: - ruby-version: ${{ matrix.ruby }} - bundler-cache: true # runs 'bundle install' and caches installed gems automatically - - name: Run tests - run: bundle exec rake test diff --git a/.github/workflows/test-with-postgresql.yml b/.github/workflows/test-with-postgresql.yml index a6deaf3..f504a8b 100644 --- a/.github/workflows/test-with-postgresql.yml +++ b/.github/workflows/test-with-postgresql.yml @@ -1,29 +1,17 @@ name: PostgreSQL -on: [pull_request] +on: [push] jobs: Test-With-PostgreSQL: runs-on: ubuntu-latest - container: ruby:${{ matrix.ruby }} + container: ruby:3.2 strategy: fail-fast: false - matrix: - active_record: [6.1.7.2, 6.0.6, 5.2.8.1] - ruby: ['3.0', 3.1, 3.2] - exclude: - - active_record: 5.2.8.1 - ruby: '3.0' - - active_record: 5.2.8.1 - ruby: 3.1 - - active_record: 5.2.8.1 - ruby: 3.2 env: - ACTIVE_RECORD_VERSION: ${{ matrix.active_record }} DATABASE_ADAPTER: postgresql - INSTALL_PG_GEM: true RAILS_ENV: test services: postgres: - image: postgres + image: postgres:14 env: POSTGRES_PASSWORD: postgres options: >- diff --git a/.github/workflows/test-with-sqlite.yml b/.github/workflows/test-with-sqlite.yml deleted file mode 100644 index 61f2ddb..0000000 --- a/.github/workflows/test-with-sqlite.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: SQLite -on: [pull_request] -jobs: - Test-With-SQLite: - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - active_record: [6.1.7.2, 6.0.6, 5.2.8.1] - # Due to https://github.com/actions/runner/issues/849, we have to use quotes for '3.0' - ruby: ['3.0', 3.1, 3.2] - exclude: - - active_record: 5.2.8.1 - ruby: '3.0' - - active_record: 5.2.8.1 - ruby: 3.1 - - active_record: 5.2.8.1 - ruby: 3.2 - - env: - ACTIVE_RECORD_VERSION: ${{ matrix.active_record }} - DATABASE_ADAPTER: sqlite3 - RAILS_ENV: test - steps: - - name: Check out repository code - uses: actions/checkout@v2 - - name: Set up Ruby - uses: ruby/setup-ruby@v1 - with: - ruby-version: ${{ matrix.ruby }} - bundler-cache: true # runs 'bundle install' and caches installed gems automatically - - name: Run tests - run: bundle exec rake test diff --git a/Dockerfile b/Dockerfile index d5e4e20..7842689 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,13 +1,10 @@ -FROM ruby:3.1 +FROM ruby:3.2 ENV APP_HOME /activerecord_cte RUN mkdir $APP_HOME WORKDIR $APP_HOME ENV RAILS_ENV test -ENV INSTALL_MYSQL_GEM true -ENV INSTALL_PG_GEM true -ENV MYSQL_HOST mysql # Cache the bundle install COPY Gemfile* $APP_HOME/ diff --git a/Gemfile b/Gemfile index 60a3e31..53f71d9 100644 --- a/Gemfile +++ b/Gemfile @@ -5,11 +5,8 @@ source "https://rubygems.org" # Specify your gem's dependencies in activerecord-cte.gemspec gemspec -ACTIVE_RECORD_VERSION = ENV.fetch("ACTIVE_RECORD_VERSION", "6.1.7.2") +gem "pg" -gem "activerecord", ACTIVE_RECORD_VERSION - -gem "mysql2" if ENV["INSTALL_MYSQL_GEM"] -gem "pg" if ENV["INSTALL_PG_GEM"] - -gem "sqlite3", "1.7.3" +group :development, :test do + gem "activerecord", "6.1.7.9" +end diff --git a/README.md b/README.md index dbf6f9b..569d8eb 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,32 @@ Post.with("posts_with_tags AS (SELECT * FROM posts WHERE tags_count > 0)") # SELECT * FROM posts ``` +#### Enhanced String CTE Parsing + +This gem includes robust string CTE parsing that handles various table name formats and provides detailed error messages. It supports: + +- **Quoted table names**: `` `table_name` ``, `"table_name"` +- **Unquoted table names**: `table_name`, `user_posts`, `table_2023` +- **Case-insensitive AS keyword**: `AS`, `as`, `As` +- **Complex SQL expressions**: Nested parentheses, subqueries, etc. +- **Comprehensive validation**: Balanced parentheses, empty components, malformed syntax + +```ruby +# All of these work: +Post.with("`quoted_table` AS (SELECT * FROM posts)") +Post.with('"double_quoted" AS (SELECT * FROM posts)') +Post.with("users_with_posts AS (SELECT * FROM posts WHERE id IN (SELECT post_id FROM comments))") +Post.with("popular_posts as (SELECT * FROM posts WHERE views > 1000)") # lowercase 'as' +``` + +If there's a syntax error, you'll get helpful error messages: +- `"CTE string cannot be empty"` +- `"CTE string must contain 'AS' keyword. Expected 'table_name AS (SELECT ...)' but got: ..."` +- `"CTE expression must be enclosed in parentheses. Expected 'table_name AS (SELECT ...)' but got: ..."` +- `"Unbalanced parentheses in CTE expression: ..."` + +This parsing capability provides a workaround for Rails 6.1+ where string CTE support was broken (see [Rails PR #42563](https://github.com/rails/rails/pull/42563) which was rejected). The implementation is fully documented in `lib/activerecord/cte/string_cte_parser.rb`. + ### Arel Nodes If you already have `Arel::Node::As` node you can just pass it as is @@ -214,6 +240,9 @@ bundle exec rubocop To run the tests using SQLite adapter and latest version on Rails run ``` +POSTGRES_USER={your_pg_user} \ +POSTGRES_PASSWORD={your_pg_password} \ +POSTGRES_HOST=localhost \ bundle exec rake test ``` diff --git a/activerecord-cte.gemspec b/activerecord-cte.gemspec index 1a2bf74..e5171b3 100644 --- a/activerecord-cte.gemspec +++ b/activerecord-cte.gemspec @@ -34,10 +34,10 @@ Gem::Specification.new do |spec| spec.add_development_dependency "bundler", "~> 2.0" spec.add_development_dependency "minitest", "~> 5.0" + spec.add_development_dependency "pg", "~> 1.5.6" spec.add_development_dependency "rake", "~> 13.0.1" - spec.add_development_dependency "rubocop", "~> 1.17.0" - spec.add_development_dependency "rubocop-minitest", "~> 0.13.0" - spec.add_development_dependency "rubocop-performance", "~> 1.11.3" - spec.add_development_dependency "rubocop-rake", "~> 0.5.1" - spec.add_development_dependency "sqlite3" + spec.add_development_dependency "rubocop", "~> 1.53.0" + spec.add_development_dependency "rubocop-minitest", "~> 0.30.0" + spec.add_development_dependency "rubocop-performance", "~> 1.19.0" + spec.add_development_dependency "rubocop-rake", "~> 0.6.0" end diff --git a/bin/test b/bin/test index f948314..bc78a3f 100755 --- a/bin/test +++ b/bin/test @@ -4,8 +4,8 @@ require "English" require "yaml" -active_record_versions = %w[6.1.7.2 6.0.6] -database_adapters = %w[mysql postgresql sqlite3] +active_record_versions = %w[6.1.7.9] +database_adapters = %w[postgresql] class Matrix def initialize(active_record_versions, database_adapters) diff --git a/docker-compose.yml b/docker-compose.yml index 94457e6..8537f5d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,27 +4,12 @@ services: lib: build: . links: - - mysql - postgres volumes: - ".:/activerecord_cte" - mysql: - image: mysql:8.0 - command: mysqld --default-authentication-plugin=mysql_native_password --skip-mysqlx - restart: always - environment: - MYSQL_DATABASE: activerecord_cte_test - MYSQL_USER: root - MYSQL_PASSWORD: root - MYSQL_ROOT_PASSWORD: root - ports: - - 3306:3306 - expose: - - 3306 - postgres: - image: postgres:12 + image: postgres:14 restart: always environment: POSTGRES_DB: activerecord_cte_test diff --git a/lib/activerecord/cte/core_ext.rb b/lib/activerecord/cte/core_ext.rb index c6cae4f..a0dd9a6 100644 --- a/lib/activerecord/cte/core_ext.rb +++ b/lib/activerecord/cte/core_ext.rb @@ -1,19 +1,27 @@ # frozen_string_literal: true +require "activerecord/cte/string_cte_parser" + module ActiveRecord + # --------------------------------------------------------------------------- module Querying delegate :with, to: :all end + # --------------------------------------------------------------------------- module WithMerger + # --------------------------------------------------------------------------- def merge super merge_withs relation end + # --------------------------------------------------------------------------- private + # --------------------------------------------------------------------------- + # --------------------------------------------------------------------------- def merge_withs relation.recursive_with = true if other.recursive_with? other_values = other.with_values.reject { |value| relation.with_values.include?(value) } @@ -21,15 +29,19 @@ def merge_withs end end + # --------------------------------------------------------------------------- class Relation + # --------------------------------------------------------------------------- class Merger prepend WithMerger end + # --------------------------------------------------------------------------- def with(opts, *rest) spawn.with!(opts, *rest) end + # --------------------------------------------------------------------------- def with!(opts, *rest) if opts == :recursive self.recursive_with = true @@ -40,40 +52,48 @@ def with!(opts, *rest) self end + # --------------------------------------------------------------------------- def with_values @values[:with] || [] end + # --------------------------------------------------------------------------- def with_values=(values) raise ImmutableRelation if @loaded @values[:with] = values end + # --------------------------------------------------------------------------- def recursive_with? @values[:recursive_with] end + # --------------------------------------------------------------------------- def recursive_with=(value) raise ImmutableRelation if @loaded @values[:recursive_with] = value end + # --------------------------------------------------------------------------- private + # --------------------------------------------------------------------------- + # --------------------------------------------------------------------------- def build_arel(*args) arel = super build_with(arel) if @values[:with] arel end + # --------------------------------------------------------------------------- def build_with(arel) # rubocop:disable Metrics/MethodLength, Metrics/CyclomaticComplexity return if with_values.empty? with_statements = with_values.map do |with_value| case with_value - when String then Arel::Nodes::SqlLiteral.new(with_value) + when String then Activerecord::Cte::StringCteParser.parse(with_value) when Arel::Nodes::As then with_value when Hash then build_with_value_from_hash(with_value) when Array then build_with_value_from_array(with_value) @@ -85,6 +105,7 @@ def build_with(arel) # rubocop:disable Metrics/MethodLength, Metrics/CyclomaticC recursive_with? ? arel.with(:recursive, with_statements) : arel.with(with_statements) end + # --------------------------------------------------------------------------- def build_with_value_from_array(array) unless array.map(&:class).uniq == [Arel::Nodes::As] raise ArgumentError, "Unsupported argument type: #{array} #{array.class}" @@ -93,6 +114,7 @@ def build_with_value_from_array(array) array end + # --------------------------------------------------------------------------- def build_with_value_from_hash(hash) # rubocop:disable Metrics/MethodLength hash.map do |name, value| table = Arel::Table.new(name) diff --git a/lib/activerecord/cte/string_cte_parser.rb b/lib/activerecord/cte/string_cte_parser.rb new file mode 100644 index 0000000..72d6e84 --- /dev/null +++ b/lib/activerecord/cte/string_cte_parser.rb @@ -0,0 +1,209 @@ +# frozen_string_literal: true + +module Activerecord + # --------------------------------------------------------------------------- + module Cte + # --------------------------------------------------------------------------- + # WORKAROUND: String CTE Parsing + # + # This module exists to handle a limitation in how Arel processes CTE (Common Table Expression) strings. + # When a string is passed to the `with()` method, Arel expects an `Arel::Nodes::As` node structure, + # but raw strings are converted to `Arel::Nodes::SqlLiteral` which doesn't have the table name + # information that Arel's CTE visitor (`collect_ctes`) requires. + # + # The CTE visitor calls `quote_table_name` on what it expects to be a table name, but gets `nil` + # from SqlLiteral nodes, causing the "no implicit conversion of nil into String" error. + # + # This workaround manually parses the string to extract: + # 1. The table name (with support for quoted identifiers) + # 2. The SQL expression + # And constructs a proper `Arel::Nodes::As` node that Arel can process correctly. + # + # WHEN CAN THIS BE REMOVED? + # This workaround can be removed when: + # 1. Arel's CTE visitor is updated to handle SqlLiteral nodes properly, OR + # 2. ActiveRecord provides built-in string CTE parsing, OR + # 3. We decide to remove support for String CTE definitions (breaking change) + # + # CURRENT STATUS (Rails 6.1.7.9 + Ruby 3.2): + # - Rails 6.0: String CTEs worked (through undocumented Arel behavior) + # - Rails 6.1: String CTE support BROKE - this is where our current issue originates + # - Rails 7.1 (October 2023): Added basic .with() CTE support but ONLY for Hash/Arel nodes + # - PR #37944: https://github.com/rails/rails/pull/37944 (merged July 2022) + # - Does NOT include string CTE support - strings still cause the same SqlLiteral issue + # - Rails 7.2 (August 2024): Added .with_recursive() support but still no string support + # - Only supports: Post.with_recursive(name: [base_query, recursive_query]) + # - Strings still converted to SqlLiteral causing nil table name issues + # + # RAILS CORE TEAM DECISION: + # - PR #42563 (June 2021): Attempted to fix string CTE support in Rails 6.1+ + # - https://github.com/rails/rails/pull/42563 + # - Was CLOSED/REJECTED in November 2021 (marked as stale) + # - Rails core team chose NOT to restore string CTE functionality + # - This suggests string CTEs are considered unsupported/deprecated behavior + # + # UPGRADE PATH: + # Rails 6.0 → 6.1+: String CTEs BROKE and will NOT be fixed by Rails core team + # Rails 6.1.7.9 → 7.1: This workaround STILL NEEDED for string CTEs + # Rails 7.1 → 7.2: This workaround STILL NEEDED for string CTEs + # Rails 7.3+: String CTE support unlikely to be added (based on rejected PR #42563) + # + # REFERENCES: + # - Rails 7.1 CTE docs: https://guides.rubyonrails.org/active_record_querying.html#common-table-expressions + # - Original Rails CTE PR: https://github.com/rails/rails/pull/37944 + # - Rejected string CTE fix PR: https://github.com/rails/rails/pull/42563 + # - Rails 7.2 recursive support: https://edgeguides.rubyonrails.org/7_2_release_notes.html + # + # Until Rails officially supports string CTEs, this ensures string CTEs work as users + # expect while maintaining compatibility with the existing Arel infrastructure. + module StringCteParser + # CTE String Parsing Constants + # These constants break down the complex regex for better readability and debugging + + # Matches table names enclosed in backticks: `table_name` + # Examples that MATCH: + # `users` → captures "users" + # `user_posts` → captures "user_posts" + # `complex table name` → captures "complex table name" + # Examples that DON'T match: + # users (no backticks) + # `users (missing closing backtick) + BACKTICK_QUOTED_TABLE = /`([^`]+)`/.freeze + + # Matches table names enclosed in double quotes: "table_name" + # Examples that MATCH: + # "users" → captures "users" + # "user_posts" → captures "user_posts" + # "complex table name" → captures "complex table name" + # Examples that DON'T match: + # users (no quotes) + # "users (missing closing quote) + DOUBLE_QUOTED_TABLE = /"([^"]+)"/.freeze + + # Matches unquoted table names: must start with letter/underscore, can contain letters/numbers/underscores + # Examples that MATCH: + # users → captures "users" + # user_posts → captures "user_posts" + # _private_table → captures "_private_table" + # table123 → captures "table123" + # User_Posts_2023 → captures "User_Posts_2023" + # Examples that DON'T match: + # 123users (starts with number) + # user-posts (contains hyphen) + # user.posts (contains dot) + # "users" (has quotes - handled by other patterns) + UNQUOTED_TABLE = /([a-zA-Z_][a-zA-Z0-9_]*)/.freeze + + # Combines all table name patterns with non-capturing group + # Examples that MATCH: + # `users` → captures "users" from backtick group + # "user_posts" → captures "user_posts" from quote group + # popular_posts → captures "popular_posts" from unquoted group + # Note: Only one capture group will have a value, the others will be nil + TABLE_NAME_PATTERN = /(?:#{BACKTICK_QUOTED_TABLE}|#{DOUBLE_QUOTED_TABLE}|#{UNQUOTED_TABLE})/.freeze + + # Matches the AS keyword (case insensitive) + # Examples that MATCH: + # AS, as, As, aS (any case combination) + # Examples that DON'T match: + # A S (space in between) + # ASS (too many letters) + AS_KEYWORD = /AS/i.freeze + + # Matches the SQL expression inside parentheses (greedy match for everything inside, including newlines) + # Using [\s\S] to match any character including newlines (equivalent to . with DOTALL flag) + # Examples that MATCH: + # (SELECT * FROM posts) → captures "SELECT * FROM posts" + # (SELECT id, name FROM users WHERE active = true) → captures "SELECT id, name FROM users WHERE active = true" + # (SELECT * FROM posts WHERE views > (SELECT AVG(views) FROM posts)) → captures "SELECT * FROM posts WHERE views > (SELECT AVG(views) FROM posts)" + # (SELECT *\n FROM posts\n WHERE views > 100) → captures "SELECT *\n FROM posts\n WHERE views > 100" + # Examples that DON'T match: + # SELECT * FROM posts (no parentheses) + # (SELECT * FROM posts (missing closing paren) + # SELECT * FROM posts) (missing opening paren) + EXPRESSION_PATTERN = /\(([\s\S]+)\)/.freeze + + # Complete CTE string pattern: optional whitespace + table_name + whitespace + AS + whitespace + (expression) + optional whitespace + # Uses multiline mode to handle strings with newlines + # Examples that MATCH: + # "popular_posts AS (SELECT * FROM posts WHERE views_count > 100)" + # " `user stats` AS (SELECT COUNT(*) FROM users) " + # '"complex_table" as (SELECT * FROM posts)' + # "table_2023 AS (SELECT id FROM posts WHERE created_at > '2023-01-01')" + # "multiline_cte AS (\n SELECT *\n FROM posts\n WHERE active = true\n)" + # Examples that DON'T match: + # "popular_posts (SELECT * FROM posts)" (missing AS) + # "popular_posts AS SELECT * FROM posts" (missing parentheses) + # "AS (SELECT * FROM posts)" (missing table name) + # "123_table AS (SELECT * FROM posts)" (invalid table name) + CTE_STRING_PATTERN = /\A\s*#{TABLE_NAME_PATTERN}\s+#{AS_KEYWORD}\s+#{EXPRESSION_PATTERN}\s*\z/im.freeze + + # --------------------------------------------------------------------------- + # Main parsing method that converts a CTE string into an Arel::Nodes::As node + # that Arel's CTE visitor can process correctly + def self.parse(string) + # Match against our comprehensive CTE pattern + match = string.match(CTE_STRING_PATTERN) + + unless match + # Provide more specific error messages for common mistakes + if string.strip.empty? + raise ArgumentError, "CTE string cannot be empty" + elsif !string.match(/\sAS\s/i) + raise ArgumentError, + "CTE string must contain 'AS' keyword. Expected 'table_name AS (SELECT ...)' but got: #{string}" + elsif !string.include?("(") || !string.include?(")") + raise ArgumentError, + "CTE expression must be enclosed in parentheses. Expected 'table_name AS (SELECT ...)' but got: #{string}" + else + raise ArgumentError, "Invalid CTE string format. Expected 'table_name AS (SELECT ...)' but got: #{string}" + end + end + + # Extract table name from whichever group matched (backtick, double-quote, or unquoted) + # Regexp.last_match(1) = backtick group, (2) = double-quote group, (3) = unquoted group + # The expression is always in the last group (4) + table_name = Regexp.last_match(1) || Regexp.last_match(2) || Regexp.last_match(3) + expression = Regexp.last_match(4) + + # Validation: Ensure we extracted meaningful values + validate_cte_components(table_name, expression) + + # Validate SQL structure + validate_expression_syntax(expression) + + # Build the proper Arel structure that the CTE visitor expects + table = Arel::Table.new(table_name.to_sym) + Arel::Nodes::As.new(table, Arel::Nodes::SqlLiteral.new("(#{expression})")) + end + + # --------------------------------------------------------------------------- + # Validates that the extracted CTE components are not empty or whitespace-only + def self.validate_cte_components(table_name, expression) + raise ArgumentError, "Empty table name in CTE string" if table_name.nil? || table_name.strip.empty? + + raise ArgumentError, "Empty expression in CTE string" if expression.nil? || expression.strip.empty? + end + + # --------------------------------------------------------------------------- + # Validates basic SQL syntax - primarily checking for balanced parentheses + # This catches common copy-paste errors and malformed SQL + def self.validate_expression_syntax(expression) + # Check for balanced parentheses to catch malformed SQL early + paren_count = 0 + expression.each_char do |char| + case char + when "(" + paren_count += 1 + when ")" + paren_count -= 1 + # If we have more closing than opening parens, fail immediately + break if paren_count.negative? + end + end + + raise ArgumentError, "Unbalanced parentheses in CTE expression: #{expression}" unless paren_count.zero? + end + end + end +end diff --git a/lib/activerecord/cte/version.rb b/lib/activerecord/cte/version.rb index d9a448b..90e2e70 100644 --- a/lib/activerecord/cte/version.rb +++ b/lib/activerecord/cte/version.rb @@ -2,6 +2,6 @@ module Activerecord module Cte - VERSION = "0.4.0" + VERSION = "0.5.0" end end diff --git a/test/activerecord/cte_test.rb b/test/activerecord/cte_test.rb index 0ee05c0..2cfc627 100644 --- a/test/activerecord/cte_test.rb +++ b/test/activerecord/cte_test.rb @@ -15,9 +15,6 @@ def test_with_when_hash_is_passed_as_an_argument end def test_with_when_string_is_passed_as_an_argument - # Guard can be removed when new version that includes https://github.com/rails/rails/pull/42563 is released and configured in test matrix - return if ActiveRecord.version == Gem::Version.create("6.1.7.2") - popular_posts = Post.where("views_count > 100") popular_posts_from_cte = Post.with("popular_posts AS (SELECT * FROM posts WHERE views_count > 100)").from("popular_posts AS posts") assert popular_posts.any? @@ -242,4 +239,157 @@ def test_delete_all_works_as_expected Post.with(most_popular: Post.where("views_count >= 100")).delete_all assert_equal 0, Post.count end + + def test_string_cte_with_quoted_table_names + # Test with backticks + popular_posts = Post.where("views_count > 100") + popular_posts_from_cte = Post.with("`popular_posts` AS (SELECT * FROM posts WHERE views_count > 100)").from("popular_posts AS posts") + assert popular_posts.any? + assert_equal popular_posts.to_a, popular_posts_from_cte + + # Test with double quotes + popular_posts_from_cte2 = Post.with('"popular_posts" AS (SELECT * FROM posts WHERE views_count > 100)').from("popular_posts AS posts") + assert_equal popular_posts.to_a, popular_posts_from_cte2 + end + + def test_string_cte_with_complex_sql_and_nested_parentheses + # Test with nested parentheses and complex SQL + complex_cte = "complex_posts AS (SELECT * FROM posts WHERE views_count > (SELECT AVG(views_count) FROM posts) AND language IN ('en', 'de'))" + posts_from_complex_cte = Post.with(complex_cte).from("complex_posts AS posts") + + # Should execute without errors + assert_nothing_raised { posts_from_complex_cte.load } + end + + def test_string_cte_case_insensitive_as_keyword + # Test case variations of AS keyword + popular_posts = Post.where("views_count > 100") + + # lowercase 'as' + popular_posts_from_cte1 = Post.with("popular_posts as (SELECT * FROM posts WHERE views_count > 100)").from("popular_posts AS posts") + assert_equal popular_posts.to_a, popular_posts_from_cte1 + + # mixed case 'As' + popular_posts_from_cte2 = Post.with("popular_posts As (SELECT * FROM posts WHERE views_count > 100)").from("popular_posts AS posts") + assert_equal popular_posts.to_a, popular_posts_from_cte2 + + # uppercase 'AS' + popular_posts_from_cte3 = Post.with("popular_posts AS (SELECT * FROM posts WHERE views_count > 100)").from("popular_posts AS posts") + assert_equal popular_posts.to_a, popular_posts_from_cte3 + end + + def test_string_cte_with_whitespace_variations + popular_posts = Post.where("views_count > 100") + + # Extra whitespace + cte_with_spaces = " popular_posts AS ( SELECT * FROM posts WHERE views_count > 100 ) " + popular_posts_from_cte = Post.with(cte_with_spaces).from("popular_posts AS posts") + assert_equal popular_posts.to_a, popular_posts_from_cte + end + + def test_string_cte_error_handling + # Test invalid formats + assert_raise(ArgumentError, "Should reject CTE without AS keyword") do + Post.with("popular_posts (SELECT * FROM posts)").load + end + + assert_raise(ArgumentError, "Should reject CTE without parentheses") do + Post.with("popular_posts AS SELECT * FROM posts").load + end + + assert_raise(ArgumentError, "Should reject CTE with unbalanced parentheses") do + Post.with("popular_posts AS (SELECT * FROM posts WHERE views_count > (100").load + end + + assert_raise(ArgumentError, "Should reject CTE with unbalanced parentheses") do + Post.with("popular_posts AS (SELECT * FROM posts WHERE views_count > 100))").load + end + + assert_raise(ArgumentError, "Should reject CTE with empty table name") do + Post.with(" AS (SELECT * FROM posts)").load + end + + assert_raise(ArgumentError, "Should reject CTE with empty expression") do + Post.with("popular_posts AS ()").load + end + + assert_raise(ArgumentError, "Should reject CTE with whitespace-only expression") do + Post.with("popular_posts AS ( )").load + end + end + + def test_string_cte_with_underscores_and_numbers + # Test table names with underscores and numbers + cte_string = "popular_posts_2023 AS (SELECT * FROM posts WHERE views_count > 100)" + popular_posts_from_cte = Post.with(cte_string).from("popular_posts_2023 AS posts") + + popular_posts = Post.where("views_count > 100") + assert_equal popular_posts.to_a, popular_posts_from_cte + end + + def test_string_cte_with_multiline_expressions + # Test multiline CTE expressions with complex formatting + multiline_cte = <<~SQL.strip + filtered_tracker_issue_extras AS ( + SELECT tie.scheduled_in_external_sprint_ids, + tie.tracker_project_issue_id + FROM tracker_issue_extras tie + JOIN repo_issues ri + ON ri.tracker_project_issue_id = tie.tracker_project_issue_id + WHERE ri.primary_committer_id = ANY(ARRAY[1]::bigint[]) + AND ri.repo_id = ANY(ARRAY[2, 3, 1]::bigint[]) + ) + SQL + + # This should parse successfully without raising an error + assert_nothing_raised do + Post.with(multiline_cte).to_sql + end + + # Test with newlines in different positions + cte_with_newlines = "popular_posts AS (\n SELECT *\n FROM posts\n WHERE views_count > 100\n)" + assert_nothing_raised do + Post.with(cte_with_newlines).to_sql + end + + # Test with complex nested subqueries and multiline formatting + complex_multiline_cte = <<~SQL.strip + complex_analysis AS ( + SELECT + p.id, + p.title, + (SELECT COUNT(*) + FROM comments c + WHERE c.post_id = p.id + AND c.created_at > '2023-01-01') as recent_comments, + CASE + WHEN p.views_count > 1000 THEN 'popular' + WHEN p.views_count > 100 THEN 'moderate' + ELSE 'low' + END as popularity + FROM posts p + WHERE p.published_at IS NOT NULL + ) + SQL + + assert_nothing_raised do + Post.with(complex_multiline_cte).to_sql + end + end + + def test_string_cte_with_user_provided_multiline_example + # Test the specific multiline example provided by the user + user_multiline_cte = "filtered_tracker_issue_extras AS (\n SELECT tie.scheduled_in_external_sprint_ids,\n tie.tracker_project_issue_id\n FROM tracker_issue_extras tie\n JOIN repo_issues ri\n ON ri.tracker_project_issue_id = tie.tracker_project_issue_id\n WHERE ri.primary_committer_id = ANY(ARRAY[[1]]::bigint[])\n AND ri.repo_id = ANY(ARRAY[[2, 3, 1]]::bigint[])\n)\n" + + # This should parse successfully without raising an error + result = Post.with(user_multiline_cte).to_sql + + # The table name gets quoted by PostgreSQL, so check for quoted version + assert result.include?("WITH \"filtered_tracker_issue_extras\" AS"), "Should include WITH clause with user's table name (quoted)" + assert result.include?("tie.scheduled_in_external_sprint_ids"), "Should include specific column from user's example" + assert result.include?("tracker_issue_extras tie"), "Should include table alias from user's example" + assert result.include?("JOIN repo_issues ri"), "Should include JOIN clause from user's example" + assert result.include?("ARRAY[[1]]::bigint[]"), "Should preserve complex array syntax" + assert result.include?("ARRAY[[2, 3, 1]]::bigint[]"), "Should preserve complex array with multiple values" + end end diff --git a/test/database.yml b/test/database.yml index b67f6e8..77d8d87 100644 --- a/test/database.yml +++ b/test/database.yml @@ -1,19 +1,7 @@ -mysql: - adapter: mysql2 - database: activerecord_cte_test - username: root - password: root - host: <%= ENV.fetch("MYSQL_HOST", "localhost") %> - port: 3306 - postgresql: adapter: postgresql encoding: unicode database: activerecord_cte_test - username: postgres - password: postgres - host: postgres - -sqlite3: - adapter: sqlite3 - database: tmp/activerecord_cte_test.db + username: <%= ENV.fetch("POSTGRES_USER", "postgres") %> + password: <%= ENV.fetch("POSTGRES_PASSWORD", "postgres") %> + host: <%= ENV.fetch("POSTGRES_HOST", "postgres") %> diff --git a/test/test_helper.rb b/test/test_helper.rb index ff88b3e..109db6b 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -4,6 +4,7 @@ ENV["RAILS_ENV"] = "test" +require "logger" require "active_record" require "active_record/fixtures" require "active_support/test_case" @@ -19,11 +20,11 @@ Warning[:deprecated] = false end -adapter = ENV.fetch("DATABASE_ADAPTER", "sqlite3") +adapter = "postgresql" db_config = YAML.safe_load(ERB.new(File.read("test/database.yml")).result)[adapter] ActiveRecord::Base.configurations = { "test" => db_config } # Key must be string for older AR versions -ActiveRecord::Tasks::DatabaseTasks.create(db_config) if %w[postgresql mysql].include?(adapter) +ActiveRecord::Tasks::DatabaseTasks.create(db_config) if %w[postgresql].include?(adapter) ActiveRecord::Base.establish_connection(:test) ActiveSupport.on_load(:active_support_test_case) do