Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 0 additions & 12 deletions .github/workflows/rubocop.yml

This file was deleted.

34 changes: 0 additions & 34 deletions .github/workflows/test-with-mysql.yml

This file was deleted.

18 changes: 3 additions & 15 deletions .github/workflows/test-with-postgresql.yml
Original file line number Diff line number Diff line change
@@ -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: >-
Expand Down
33 changes: 0 additions & 33 deletions .github/workflows/test-with-sqlite.yml

This file was deleted.

5 changes: 1 addition & 4 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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/
Expand Down
11 changes: 4 additions & 7 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
```

Expand Down
10 changes: 5 additions & 5 deletions activerecord-cte.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 2 additions & 2 deletions bin/test
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
17 changes: 1 addition & 16 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 23 additions & 1 deletion lib/activerecord/cte/core_ext.rb
Original file line number Diff line number Diff line change
@@ -1,35 +1,47 @@
# 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) }
relation.with!(*other_values) if other_values.any?
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
Expand All @@ -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)
Expand All @@ -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}"
Expand All @@ -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)
Expand Down
Loading