From 4b21c2d4c6124d61b0822b00610dd3f580c03e42 Mon Sep 17 00:00:00 2001 From: Carlos Silva Date: Thu, 31 Mar 2022 15:09:49 -0300 Subject: [PATCH 01/29] Replace travis for Circle CI --- .circleci/config.yml | 42 ++++++++++++++++++++++++++++++++++++++++++ .travis.yml | 41 ----------------------------------------- 2 files changed, 42 insertions(+), 41 deletions(-) create mode 100644 .circleci/config.yml delete mode 100644 .travis.yml diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 0000000..29bc718 --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,42 @@ +version: 2.1 +orbs: + ruby: circleci/ruby@1.4.0 + +jobs: + test: + parameters: + ruby-version: + type: string + bundle-version: + type: string + + docker: + - image: cimg/ruby:<< parameters.ruby-version >> + - image: circleci/postgres:12.9 + environment: + POSTGRES_USER: torque + POSTGRES_PASSWORD: torque + POSTGRES_DB: torque_postgresql + + steps: + - checkout + - run: ruby --version + - run: + command: 'bundle install --gemfile gemfiles/<< parameters.bundle-version >>' + name: Install Bundle + - run: + command: 'bundle exec --gemfile gemfiles/<< parameters.bundle-version >> rspec' + name: Run Tests + +references: + matrix_build: &matrix_build + test: + matrix: + parameters: + ruby-version: ['2.6', '2.7', '3.0'] + bundle-version: ['Gemfile.rails-6.0', 'Gemfile.rails-6.1'] + +workflows: + commit: + jobs: + - <<: *matrix_build diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 9fd68f9..0000000 --- a/.travis.yml +++ /dev/null @@ -1,41 +0,0 @@ -os: linux -arch: - - arm64 - - ppc64le - - s390x - -language: ruby -rvm: - - 2.7.2 - - 3.0.1 - - 3.1.0 - -gemfile: - - gemfiles/Gemfile.rails-6.0 - - gemfiles/Gemfile.rails-6.1 - - gemfiles/Gemfile.rails-7.0 - -cache: - bundler: true - -env: - global: - - CC_TEST_REPORTER_ID=d21c4c9d0d7ba6a27368b8e25edad911eb1daa03202e69fe2bc2e42a3ed21de3 - -before_script: - - curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter - - chmod +x ./cc-test-reporter - - ./cc-test-reporter before-build - - psql -c 'DROP DATABASE IF EXISTS torque_postgresql_test;' -U travis -p 5433 - - psql -c 'CREATE DATABASE torque_postgresql_test;' -U travis -p 5433 - - bundle exec rake dump - -after_script: - - ./cc-test-reporter after-build --exit-code $TRAVIS_TEST_RESULT - -addons: - postgresql: '12' - apt: - packages: - - postgresql-12 - - postgresql-client-12 From dbdc4cde66c6782c13005ea140254eced0391eb5 Mon Sep 17 00:00:00 2001 From: Carlos Silva Date: Thu, 31 Mar 2022 15:14:33 -0300 Subject: [PATCH 02/29] Fix database connection on tests --- .circleci/config.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 29bc718..f640737 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -14,8 +14,7 @@ jobs: - image: cimg/ruby:<< parameters.ruby-version >> - image: circleci/postgres:12.9 environment: - POSTGRES_USER: torque - POSTGRES_PASSWORD: torque + POSTGRES_USER: postgres POSTGRES_DB: torque_postgresql steps: @@ -27,6 +26,8 @@ jobs: - run: command: 'bundle exec --gemfile gemfiles/<< parameters.bundle-version >> rspec' name: Run Tests + environment: + DATABASE_URL: 'postgresql://postgres@localhost/torque_postgresql' references: matrix_build: &matrix_build From 47869c7e018479d2152a60e426d9d1ab985d462b Mon Sep 17 00:00:00 2001 From: Carlos Silva Date: Thu, 31 Mar 2022 15:20:58 -0300 Subject: [PATCH 03/29] Fixes for testing --- .circleci/config.yml | 6 +++++- torque_postgresql.gemspec | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index f640737..a3ce161 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -15,6 +15,7 @@ jobs: - image: circleci/postgres:12.9 environment: POSTGRES_USER: postgres + POSTGRES_PASSWORD: torque POSTGRES_DB: torque_postgresql steps: @@ -23,11 +24,14 @@ jobs: - run: command: 'bundle install --gemfile gemfiles/<< parameters.bundle-version >>' name: Install Bundle + - run: + command: dockerize -wait tcp://localhost:5432 -timeout 1m + name: Wait for DB - run: command: 'bundle exec --gemfile gemfiles/<< parameters.bundle-version >> rspec' name: Run Tests environment: - DATABASE_URL: 'postgresql://postgres@localhost/torque_postgresql' + DATABASE_URL: 'postgresql://postgres:torque@localhost/torque_postgresql' references: matrix_build: &matrix_build diff --git a/torque_postgresql.gemspec b/torque_postgresql.gemspec index 0dec2e3..984b8f5 100644 --- a/torque_postgresql.gemspec +++ b/torque_postgresql.gemspec @@ -19,7 +19,7 @@ Gem::Specification.new do |s| s.files = Dir['MIT-LICENSE', 'README.rdoc', 'lib/**/*', 'Rakefile'] s.test_files = Dir['spec/**/*'] - s.required_ruby_version = '>= 2.7.2' + s.required_ruby_version = '>= 2.6' s.required_rubygems_version = '>= 1.8.11' s.add_dependency 'rails', '>= 6.0' From 123ab8f6d6c68b6951eebc16ed0f70b8d7d7891d Mon Sep 17 00:00:00 2001 From: Carlos Silva Date: Thu, 31 Mar 2022 15:57:03 -0300 Subject: [PATCH 04/29] Fix tests --- README.md | 2 +- .../belongs_to_many_association.rb | 4 +- spec/schema.rb | 293 +++++++++--------- 3 files changed, 149 insertions(+), 150 deletions(-) diff --git a/README.md b/README.md index 8315dd5..d72527c 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Torque PostgreSQL -[![Build Status](https://travis-ci.com/crashtech/torque-postgresql.svg?branch=master)](https://travis-ci.com/crashtech/torque-postgresql) +[![CircleCI](https://circleci.com/gh/crashtech/torque-postgresql/tree/master_v2.svg?style=svg)](https://circleci.com/gh/crashtech/torque-postgresql/tree/master_v2) [![Code Climate](https://codeclimate.com/github/crashtech/torque-postgresql/badges/gpa.svg)](https://codeclimate.com/github/crashtech/torque-postgresql) [![Gem Version](https://badge.fury.io/rb/torque-postgresql.svg)](https://badge.fury.io/rb/torque-postgresql) diff --git a/lib/torque/postgresql/associations/belongs_to_many_association.rb b/lib/torque/postgresql/associations/belongs_to_many_association.rb index 9931c57..a9c8975 100644 --- a/lib/torque/postgresql/associations/belongs_to_many_association.rb +++ b/lib/torque/postgresql/associations/belongs_to_many_association.rb @@ -53,8 +53,8 @@ def include?(record) def load_target if stale_target? || find_target? - @target = merge_target_lists(find_target, stale_target = target) - @target += stale_target.select(&:persisted?) if PostgreSQL::AR615 + new_records = PostgreSQL::AR615 ? target.extract!(&:persisted?) : [] + @target = merge_target_lists((find_target || []) + new_records, target) end loaded! diff --git a/spec/schema.rb b/spec/schema.rb index 9340a38..3817e01 100644 --- a/spec/schema.rb +++ b/spec/schema.rb @@ -10,151 +10,150 @@ # # It's strongly recommended that you check this file into your version control system. -begin - version = 73 - - raise SystemExit if ActiveRecord::Migrator.current_version == version - ActiveRecord::Schema.define(version: version) do - self.verbose = false - - # These are extensions that must be enabled in order to support this database - enable_extension "pgcrypto" - enable_extension "plpgsql" - - # These are user-defined types used on this database - create_enum "content_status", ["created", "draft", "published", "archived"], force: :cascade - create_enum "specialties", ["books", "movies", "plays"], force: :cascade - create_enum "roles", ["visitor", "assistant", "manager", "admin"], force: :cascade - create_enum "conflicts", ["valid", "invalid", "untrusted"], force: :cascade - create_enum "types", ["A", "B", "C", "D"], force: :cascade - - create_table "geometries", force: :cascade do |t| - t.point "point" - t.line "line" - t.lseg "lseg" - t.box "box" - t.path "closed_path" - t.path "open_path" - t.polygon "polygon" - t.circle "circle" - end - - create_table "time_keepers", force: :cascade do |t| - t.daterange "available" - t.tsrange "period" - t.tstzrange "tzperiod" - t.interval "th" - end - - create_table "tags", force: :cascade do |t| - t.string "name" - end - - create_table "videos", force: :cascade do |t| - t.bigint "tag_ids", array: true - t.string "title" - t.string "url" - t.enum "type", subtype: :types - t.enum "conflicts", subtype: :conflicts, array: true - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - end - - create_table "authors", force: :cascade do |t| - t.string "name" - t.string "type" - t.enum "specialty", subtype: :specialties - end - - create_table "texts", force: :cascade do |t| - t.integer "user_id" - t.string "content" - t.enum "conflict", subtype: :conflicts - end - - create_table "comments", force: :cascade do |t| - t.integer "user_id", null: false - t.integer "comment_id" - t.integer "video_id" - t.text "content", null: false - t.string "kind" - t.index ["user_id"], name: "index_comments_on_user_id", using: :btree - t.index ["comment_id"], name: "index_comments_on_comment_id", using: :btree - end - - create_table "courses", force: :cascade do |t| - t.string "title", null: false - t.interval "duration" - t.enum "types", subtype: :types, array: true - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - end - - create_table "images", force: :cascade, id: false do |t| - t.string "file" - end - - create_table "posts", force: :cascade do |t| - t.integer "author_id" - t.integer "activity_id" - t.string "title" - t.text "content" - t.enum "status", subtype: :content_status - t.index ["author_id"], name: "index_posts_on_author_id", using: :btree - end - - create_table "items", force: :cascade do |t| - t.string "name" - t.bigint "tag_ids", array: true, default: "{1}" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - end - - create_table "users", force: :cascade do |t| - t.string "name", null: false - t.enum "role", subtype: :roles, default: :visitor - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - end - - create_table "activities", force: :cascade do |t| - t.integer "author_id" - t.string "title" - t.boolean "active" - t.enum "kind", subtype: :types - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - end - - create_table "questions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "title" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - end - - create_table "activity_books", force: :cascade, inherits: :activities do |t| - t.text "description" - t.string "url" - t.boolean "activated" - end - - create_table "activity_posts", force: :cascade, inherits: [:activities, :images] do |t| - t.integer "post_id" - t.string "url" - t.integer "activated" - end - - create_table "activity_post_samples", force: :cascade, inherits: :activity_posts - - create_table "question_selects", force: :cascade, inherits: :questions do |t| - t.string "options", array: true - end - - # create_table "activity_blanks", force: :cascade, inherits: :activities - - # create_table "activity_images", force: :cascade, inherits: [:activities, :images] - - add_foreign_key "posts", "authors" - end -rescue SystemExit +version = 77 + +return if ActiveRecord::Migrator.current_version == version +ActiveRecord::Schema.define(version: version) do + self.verbose = false + + # These are extensions that must be enabled in order to support this database + enable_extension "pgcrypto" + enable_extension "plpgsql" + + # These are user-defined types used on this database + create_enum "content_status", ["created", "draft", "published", "archived"], force: :cascade + create_enum "specialties", ["books", "movies", "plays"], force: :cascade + create_enum "roles", ["visitor", "assistant", "manager", "admin"], force: :cascade + create_enum "conflicts", ["valid", "invalid", "untrusted"], force: :cascade + create_enum "types", ["A", "B", "C", "D"], force: :cascade + + create_table "geometries", force: :cascade do |t| + t.point "point" + t.line "line" + t.lseg "lseg" + t.box "box" + t.path "closed_path" + t.path "open_path" + t.polygon "polygon" + t.circle "circle" + end + + create_table "time_keepers", force: :cascade do |t| + t.daterange "available" + t.tsrange "period" + t.tstzrange "tzperiod" + t.interval "th" + end + + create_table "tags", force: :cascade do |t| + t.string "name" + end + + create_table "videos", force: :cascade do |t| + t.bigint "tag_ids", array: true + t.string "title" + t.string "url" + t.enum "type", subtype: :types + t.enum "conflicts", subtype: :conflicts, array: true + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + + create_table "authors", force: :cascade do |t| + t.string "name" + t.string "type" + t.enum "specialty", subtype: :specialties + end + + create_table "texts", force: :cascade do |t| + t.integer "user_id" + t.string "content" + t.enum "conflict", subtype: :conflicts + end + + create_table "comments", force: :cascade do |t| + t.integer "user_id", null: false + t.integer "comment_id" + t.integer "video_id" + t.text "content", null: false + t.string "kind" + t.index ["user_id"], name: "index_comments_on_user_id", using: :btree + t.index ["comment_id"], name: "index_comments_on_comment_id", using: :btree + end + + create_table "courses", force: :cascade do |t| + t.string "title", null: false + t.interval "duration" + t.enum "types", subtype: :types, array: true + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + + create_table "images", force: :cascade, id: false do |t| + t.string "file" + end + + create_table "posts", force: :cascade do |t| + t.integer "author_id" + t.integer "activity_id" + t.string "title" + t.text "content" + t.enum "status", subtype: :content_status + t.index ["author_id"], name: "index_posts_on_author_id", using: :btree + end + + create_table "items", force: :cascade do |t| + t.string "name" + t.bigint "tag_ids", array: true, default: "{1}" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + + create_table "users", force: :cascade do |t| + t.string "name", null: false + t.enum "role", subtype: :roles, default: :visitor + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + + create_table "activities", force: :cascade do |t| + t.integer "author_id" + t.string "title" + t.boolean "active" + t.enum "kind", subtype: :types + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + + create_table "questions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "title" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + + create_table "activity_books", force: :cascade, inherits: :activities do |t| + t.text "description" + t.string "url" + t.boolean "activated" + end + + create_table "activity_posts", force: :cascade, inherits: [:activities, :images] do |t| + t.integer "post_id" + t.string "url" + t.integer "activated" + end + + create_table "activity_post_samples", force: :cascade, inherits: :activity_posts + + create_table "question_selects", force: :cascade, inherits: :questions do |t| + t.string "options", array: true + end + + # create_table "activity_blanks", force: :cascade, inherits: :activities + + # create_table "activity_images", force: :cascade, inherits: [:activities, :images] + + add_foreign_key "posts", "authors" end + +ActiveRecord::Base.connection.schema_cache.clear! From 8b88f251c1bb432d47577a1c2776f6dd37c19105 Mon Sep 17 00:00:00 2001 From: Carlos Silva Date: Sat, 9 Apr 2022 21:23:58 -0300 Subject: [PATCH 05/29] Add adaptations that prepare migrations for Rails 7 --- .../postgresql/adapter/schema_definitions.rb | 10 ++++----- .../postgresql/adapter/schema_dumper.rb | 11 +++++----- lib/torque/postgresql/version.rb | 2 +- spec/schema.rb | 21 ++++++++++--------- spec/spec_helper.rb | 4 ---- spec/tests/arel_spec.rb | 21 ++++++++++++++----- spec/tests/enum_set_spec.rb | 6 +++--- spec/tests/enum_spec.rb | 12 ++++++++--- 8 files changed, 51 insertions(+), 36 deletions(-) diff --git a/lib/torque/postgresql/adapter/schema_definitions.rb b/lib/torque/postgresql/adapter/schema_definitions.rb index 2a6726d..d7b2240 100644 --- a/lib/torque/postgresql/adapter/schema_definitions.rb +++ b/lib/torque/postgresql/adapter/schema_definitions.rb @@ -12,16 +12,16 @@ def interval(*args, **options) args.each { |name| column(name, :interval, **options) } end - # Creates a column with an enum type, needing to specify the subtype, + # Creates a column with an enum type, needing to specify the enum_type, # which is basically the name of the type defined prior creating the # column def enum(*args, **options) - subtype = options.delete(:subtype) - args.each { |name| column(name, (subtype || name), **options) } + enum_type = [options.delete(:subtype), options.delete(:enum_type)].compact.first + args.each { |name| column(name, (enum_type || name), **options) } end # Creates a column with an enum array type, needing to specify the - # subtype, which is basically the name of the type defined prior + # enum_type, which is basically the name of the type defined prior # creating the column def enum_set(*args, **options) super(*args, **options.merge(array: true)) @@ -47,7 +47,7 @@ def initialize(*args, **options) if ActiveRecord::ConnectionAdapters::PostgreSQL.const_defined?('ColumnDefinition') module ColumnDefinition - attr_accessor :subtype + attr_accessor :subtype, :enum_type end ActiveRecord::ConnectionAdapters::PostgreSQL::ColumnDefinition.include ColumnDefinition diff --git a/lib/torque/postgresql/adapter/schema_dumper.rb b/lib/torque/postgresql/adapter/schema_dumper.rb index dc73ab8..7495e51 100644 --- a/lib/torque/postgresql/adapter/schema_dumper.rb +++ b/lib/torque/postgresql/adapter/schema_dumper.rb @@ -22,12 +22,12 @@ def schema_type(column) column.type == :enum_set ? :enum : super end - # Adds +:subtype+ option to the default set + # Adds +:enum_type+ option to the default set def prepare_column_options(column) spec = super - if subtype = schema_subtype(column) - spec[:subtype] = subtype + if enum_type = schema_enum_type(column) + spec[:enum_type] = enum_type end spec @@ -35,7 +35,7 @@ def prepare_column_options(column) private - def schema_subtype(column) + def schema_enum_type(column) column.sql_type.to_sym.inspect if column.type == :enum || column.type == :enum_set end @@ -89,7 +89,8 @@ def user_defined_types(stream) types = @connection.user_defined_types('e') return unless types.any? - stream.puts " # These are user-defined types used on this database" + stream.puts " # Custom types defined in this database." + stream.puts " # Note that some types may not work with other database engines. Be careful if changing database." types.sort_by(&:first).each { |(name, type)| send(type.to_sym, name, stream) } stream.puts rescue => e diff --git a/lib/torque/postgresql/version.rb b/lib/torque/postgresql/version.rb index c42b3e9..02b92b3 100644 --- a/lib/torque/postgresql/version.rb +++ b/lib/torque/postgresql/version.rb @@ -2,6 +2,6 @@ module Torque module PostgreSQL - VERSION = '2.2.3' + VERSION = '2.2.4' end end diff --git a/spec/schema.rb b/spec/schema.rb index 3817e01..64802f2 100644 --- a/spec/schema.rb +++ b/spec/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -version = 77 +version = 2 return if ActiveRecord::Migrator.current_version == version ActiveRecord::Schema.define(version: version) do @@ -20,7 +20,8 @@ enable_extension "pgcrypto" enable_extension "plpgsql" - # These are user-defined types used on this database + # Custom types defined in this database. + # Note that some types may not work with other database engines. Be careful if changing database. create_enum "content_status", ["created", "draft", "published", "archived"], force: :cascade create_enum "specialties", ["books", "movies", "plays"], force: :cascade create_enum "roles", ["visitor", "assistant", "manager", "admin"], force: :cascade @@ -53,8 +54,8 @@ t.bigint "tag_ids", array: true t.string "title" t.string "url" - t.enum "type", subtype: :types - t.enum "conflicts", subtype: :conflicts, array: true + t.enum "type", enum_type: :types + t.enum "conflicts", enum_type: :conflicts, array: true t.datetime "created_at", null: false t.datetime "updated_at", null: false end @@ -62,13 +63,13 @@ create_table "authors", force: :cascade do |t| t.string "name" t.string "type" - t.enum "specialty", subtype: :specialties + t.enum "specialty", enum_type: :specialties end create_table "texts", force: :cascade do |t| t.integer "user_id" t.string "content" - t.enum "conflict", subtype: :conflicts + t.enum "conflict", enum_type: :conflicts end create_table "comments", force: :cascade do |t| @@ -84,7 +85,7 @@ create_table "courses", force: :cascade do |t| t.string "title", null: false t.interval "duration" - t.enum "types", subtype: :types, array: true + t.enum "types", enum_type: :types, array: true t.datetime "created_at", null: false t.datetime "updated_at", null: false end @@ -98,7 +99,7 @@ t.integer "activity_id" t.string "title" t.text "content" - t.enum "status", subtype: :content_status + t.enum "status", enum_type: :content_status t.index ["author_id"], name: "index_posts_on_author_id", using: :btree end @@ -111,7 +112,7 @@ create_table "users", force: :cascade do |t| t.string "name", null: false - t.enum "role", subtype: :roles, default: :visitor + t.enum "role", enum_type: :roles, default: :visitor t.datetime "created_at", null: false t.datetime "updated_at", null: false end @@ -120,7 +121,7 @@ t.integer "author_id" t.string "title" t.boolean "active" - t.enum "kind", subtype: :types + t.enum "kind", enum_type: :types t.datetime "created_at", null: false t.datetime "updated_at", null: false end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index bab9017..2b6488c 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -46,10 +46,6 @@ DatabaseCleaner.strategy = :transaction end - config.before(:each, js: true) do - DatabaseCleaner.strategy = :truncation - end - config.before(:each) do DatabaseCleaner.start end diff --git a/spec/tests/arel_spec.rb b/spec/tests/arel_spec.rb index cf0a693..13d7840 100644 --- a/spec/tests/arel_spec.rb +++ b/spec/tests/arel_spec.rb @@ -68,26 +68,37 @@ it 'works properly when column is an array' do expect { connection.add_column(:authors, :tag_ids, :bigint, array: true, default: []) }.not_to raise_error - expect(Author.columns_hash['tag_ids'].default).to eq([]) + expect(Author.new.tag_ids).to eq([]) end - it 'works with an array with enum values' do + it 'works with an array with enum values for a new enum' do + value = ['a', 'b'] + + expect do + connection.create_enum(:samples, %i[a b c d]) + connection.add_column(:authors, :samples, :samples, array: true, default: value) + end.not_to raise_error + + expect(Author.new.samples).to eq(value) + end + + it 'works with an array with enum values for an existing enum' do value = ['visitor', 'assistant'] expect { connection.add_column(:authors, :roles, :roles, array: true, default: value) }.not_to raise_error - expect(Author.columns_hash['roles'].default).to eq(value) + expect(Author.new.roles).to eq(value) end it 'works with multi dimentional array' do value = [['1', '2'], ['3', '4']] expect { connection.add_column(:authors, :tag_ids, :string, array: true, default: value) }.not_to raise_error - expect(Author.columns_hash['tag_ids'].default).to eq(value) + expect(Author.new.tag_ids).to eq(value) end it 'works with change column default value' do value = ['2', '3'] connection.add_column(:authors, :tag_ids, :string, array: true) expect { connection.change_column_default(:authors, :tag_ids, { from: nil, to: value }) }.not_to raise_error - expect(Author.columns_hash['tag_ids'].default).to eq(value) + expect(Author.new.tag_ids).to eq(value) end end diff --git a/spec/tests/enum_set_spec.rb b/spec/tests/enum_set_spec.rb index bc5bf4b..e68ba7e 100644 --- a/spec/tests/enum_set_spec.rb +++ b/spec/tests/enum_set_spec.rb @@ -29,7 +29,7 @@ def decorate(model, field, options = {}) subject { table_definition.new(connection, 'articles') } it 'can be defined as an array' do - subject.enum(:content_status, array: true) + subject.enum(:content_status, array: true, enum_type: :content_status) expect(subject['content_status'].name).to be_eql('content_status') expect(subject['content_status'].type).to be_eql(:content_status) @@ -44,14 +44,14 @@ def decorate(model, field, options = {}) context 'on schema' do it 'can be used on tables' do dump_io = StringIO.new - checker = /t\.enum +"conflicts", +array: true, +subtype: :conflicts/ + checker = /t\.enum +"conflicts", +array: true, +enum_type: :conflicts/ ActiveRecord::SchemaDumper.dump(connection, dump_io) expect(dump_io.string).to match checker end xit 'can have a default value as an array of symbols' do dump_io = StringIO.new - checker = /t\.enum +"types", +default: \[:A, :B\], +array: true, +subtype: :types/ + checker = /t\.enum +"types", +default: \[:A, :B\], +array: true, +enum_type: :types/ ActiveRecord::SchemaDumper.dump(connection, dump_io) expect(dump_io.string).to match checker end diff --git a/spec/tests/enum_spec.rb b/spec/tests/enum_spec.rb index 3455e9f..7c76147 100644 --- a/spec/tests/enum_spec.rb +++ b/spec/tests/enum_spec.rb @@ -99,13 +99,19 @@ def decorate(model, field, options = {}) end it 'can be used in a multiple form' do - subject.enum('foo', 'bar', 'baz', subtype: :content_status) + subject.enum('foo', 'bar', 'baz', enum_type: :content_status) expect(subject['foo'].type).to be_eql(:content_status) expect(subject['bar'].type).to be_eql(:content_status) expect(subject['baz'].type).to be_eql(:content_status) end it 'can have custom type' do + subject.enum('foo', enum_type: :content_status) + expect(subject['foo'].name).to be_eql('foo') + expect(subject['foo'].type).to be_eql(:content_status) + end + + it 'can use the deprecated subtype option' do subject.enum('foo', subtype: :content_status) expect(subject['foo'].name).to be_eql('foo') expect(subject['foo'].type).to be_eql(:content_status) @@ -143,13 +149,13 @@ def decorate(model, field, options = {}) it 'can be used on tables too' do dump_io = StringIO.new ActiveRecord::SchemaDumper.dump(connection, dump_io) - expect(dump_io.string).to match /t\.enum +"status", +subtype: :content_status/ + expect(dump_io.string).to match /t\.enum +"status", +enum_type: :content_status/ end it 'can have a default value as symbol' do dump_io = StringIO.new ActiveRecord::SchemaDumper.dump(connection, dump_io) - expect(dump_io.string).to match /t\.enum +"role", +default: :visitor, +subtype: :roles/ + expect(dump_io.string).to match /t\.enum +"role", +default: :visitor, +enum_type: :roles/ end end From 08fddd92afab31ae956ce1c6b7c9bde6c9fda825 Mon Sep 17 00:00:00 2001 From: Carlos Silva Date: Mon, 11 Apr 2022 03:55:13 -0300 Subject: [PATCH 06/29] Add support to any kind of type in array associations --- .../reflection/abstract_reflection.rb | 52 +++++-------------- spec/spec_helper.rb | 1 + spec/tests/belongs_to_many_spec.rb | 46 ++++++++++++++++ spec/tests/has_many_spec.rb | 44 ++++++++++++++++ 4 files changed, 104 insertions(+), 39 deletions(-) diff --git a/lib/torque/postgresql/reflection/abstract_reflection.rb b/lib/torque/postgresql/reflection/abstract_reflection.rb index b78f946..d6fe9e9 100644 --- a/lib/torque/postgresql/reflection/abstract_reflection.rb +++ b/lib/torque/postgresql/reflection/abstract_reflection.rb @@ -6,9 +6,6 @@ module Reflection module AbstractReflection AREL_ATTR = ::Arel::Attributes::Attribute - ARR_NO_CAST = 'bigint' - ARR_CAST = 'bigint[]' - # Check if the foreign key actually exists def connected_through_array? false @@ -40,34 +37,29 @@ def build_join_constraint(table, foreign_table) result end - # Build the id constraint checking if both types are perfect matching + # Build the id constraint checking if both types are perfect matching. + # The klass attribute (left side) will always be a column attribute def build_id_constraint(klass_attr, source_attr) return klass_attr.eq(source_attr) unless connected_through_array? # Klass and key are associated with the reflection Class klass_type = klass.columns_hash[join_keys.key.to_s] - # active_record and foreign_key are associated with the source Class - source_type = active_record.columns_hash[join_keys.foreign_key.to_s] - # If both are attributes but the left side is not an array, and the - # right side is, use the ANY operation - any_operation = arel_array_to_any(klass_attr, source_attr, klass_type, source_type) - return klass_attr.eq(any_operation) if any_operation + # Apply an ANY operation which checks if the single value on the left + # side exists in the array on the right side + if source_attr.is_a?(AREL_ATTR) + any_value = [klass_attr, source_attr] + any_value.reverse! if klass_type.try(:array?) + return any_value.shift.eq(::Arel::Nodes::NamedFunction.new('ANY', any_value)) + end # If the left side is not an array, just use the IN condition return klass_attr.in(source_attr) unless klass_type.try(:array) - # Decide if should apply a cast to ensure same type comparision - should_cast = klass_type.type.eql?(:integer) && source_type.type.eql?(:integer) - should_cast &= !klass_type.sql_type.eql?(source_type.sql_type) - should_cast |= !(klass_attr.is_a?(AREL_ATTR) && source_attr.is_a?(AREL_ATTR)) - - # Apply necessary transformations to values - klass_attr = cast_constraint_to_array(klass_type, klass_attr, should_cast) - source_attr = cast_constraint_to_array(source_type, source_attr, should_cast) - - # Return the overlap condition - klass_attr.overlaps(source_attr) + # Build the overlap condition (array && array) ensuring that the right + # side has the same type as the left side + source_attr = ::Arel::Nodes.build_quoted(Array.wrap(source_attr)) + klass_attr.overlaps(source_attr.cast(klass_type.sql_type_metadata.sql_type)) end if PostgreSQL::AR610 @@ -85,24 +77,6 @@ def build_id_constraint_between(table, foreign_table) build_id_constraint(klass_attr, source_attr) end - - # Prepare a value for an array constraint overlap condition - def cast_constraint_to_array(type, value, should_cast) - base_ready = type.try(:array) && value.is_a?(AREL_ATTR) - return value if base_ready && (type.sql_type.eql?(ARR_NO_CAST) || !should_cast) - - value = ::Arel::Nodes.build_quoted(Array.wrap(value)) unless base_ready - value = value.cast(ARR_CAST) if should_cast - value - end - - # Check if it's possible to turn both attributes into an ANY condition - def arel_array_to_any(klass_attr, source_attr, klass_type, source_type) - return unless !klass_type.try(:array) && source_type.try(:array) && - klass_attr.is_a?(AREL_ATTR) && source_attr.is_a?(AREL_ATTR) - - ::Arel::Nodes::NamedFunction.new('ANY', [source_attr]) - end end ::ActiveRecord::Reflection::AbstractReflection.prepend(AbstractReflection) diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 2b6488c..1a6616b 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -39,6 +39,7 @@ # Handles acton before rspec initialize config.before(:suite) do + ActiveSupport::Deprecation.silenced = true DatabaseCleaner.clean_with(:truncation) end diff --git a/spec/tests/belongs_to_many_spec.rb b/spec/tests/belongs_to_many_spec.rb index 40d121a..e90f2d8 100644 --- a/spec/tests/belongs_to_many_spec.rb +++ b/spec/tests/belongs_to_many_spec.rb @@ -392,4 +392,50 @@ end end end + + context 'using uuid' do + let(:connection) { ActiveRecord::Base.connection } + let(:game) { Class.new(ActiveRecord::Base) } + let(:player) { Class.new(ActiveRecord::Base) } + let(:other) { player.create } + + # TODO: Set as a shred example + before do + connection.create_table(:players, id: :uuid) { |t| t.string :name } + connection.create_table(:games, id: :uuid) { |t| t.uuid :player_ids, array: true } + + game.table_name = 'games' + player.table_name = 'players' + game.belongs_to_many :players, anonymous_class: player, inverse_of: false + end + + subject { game.create } + + it 'loads associated records' do + subject.update(player_ids: [other.id]) + expect(subject.players.to_sql).to be_eql(<<-SQL.squish) + SELECT "players".* FROM "players" WHERE "players"."id" IN ('#{other.id}') + SQL + + expect(subject.players.load).to be_a(ActiveRecord::Associations::CollectionProxy) + expect(subject.players.to_a).to be_eql([other]) + end + + it 'can preload records' do + records = 5.times.map { player.create } + subject.players.concat(records) + + entries = game.all.includes(:players).load + + expect(entries.size).to be_eql(1) + expect(entries.first.players).to be_loaded + expect(entries.first.players.size).to be_eql(5) + end + + it 'can joins records' do + query = game.all.joins(:players) + expect(query.to_sql).to match(/INNER JOIN "players"/) + expect { query.load }.not_to raise_error + end + end end diff --git a/spec/tests/has_many_spec.rb b/spec/tests/has_many_spec.rb index 5309470..ad63403 100644 --- a/spec/tests/has_many_spec.rb +++ b/spec/tests/has_many_spec.rb @@ -411,4 +411,48 @@ expect { query.load }.not_to raise_error end end + + context 'using uuid' do + let(:connection) { ActiveRecord::Base.connection } + let(:game) { Class.new(ActiveRecord::Base) } + let(:player) { Class.new(ActiveRecord::Base) } + + # TODO: Set as a shred example + before do + connection.create_table(:players, id: :uuid) { |t| t.string :name } + connection.create_table(:games, id: :uuid) { |t| t.uuid :player_ids, array: true } + + game.table_name = 'games' + player.table_name = 'players' + player.has_many :games, array: true, anonymous_class: game, + inverse_of: false, foreign_key: :player_ids + end + + subject { player.create } + + it 'loads associated records' do + expect(subject.games.to_sql).to match(Regexp.new(<<-SQL.squish)) + SELECT "games"\\.\\* FROM "games" + WHERE \\(?"games"\\."player_ids" && ARRAY\\['#{subject.id}'\\]::uuid\\[\\]\\)? + SQL + + expect(subject.games.load).to be_a(ActiveRecord::Associations::CollectionProxy) + expect(subject.games.to_a).to be_eql([]) + end + + it 'can preload records' do + 5.times { game.create(player_ids: [subject.id]) } + entries = player.all.includes(:games).load + + expect(entries.size).to be_eql(1) + expect(entries.first.games).to be_loaded + expect(entries.first.games.size).to be_eql(5) + end + + it 'can joins records' do + query = player.all.joins(:games) + expect(query.to_sql).to match(/INNER JOIN "games"/) + expect { query.load }.not_to raise_error + end + end end From e5732b188b3ae257cc00860ee41cce3cee98cbcd Mon Sep 17 00:00:00 2001 From: Carlos Silva Date: Mon, 11 Apr 2022 04:05:19 -0300 Subject: [PATCH 07/29] Fix for Rails 6.0 --- spec/tests/belongs_to_many_spec.rb | 5 ++++- spec/tests/has_many_spec.rb | 6 ++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/spec/tests/belongs_to_many_spec.rb b/spec/tests/belongs_to_many_spec.rb index e90f2d8..d78acdf 100644 --- a/spec/tests/belongs_to_many_spec.rb +++ b/spec/tests/belongs_to_many_spec.rb @@ -404,9 +404,12 @@ connection.create_table(:players, id: :uuid) { |t| t.string :name } connection.create_table(:games, id: :uuid) { |t| t.uuid :player_ids, array: true } + options = { anonymous_class: player, foreign_key: :player_ids } + options[:inverse_of] = false if Torque::PostgreSQL::AR610 + game.table_name = 'games' player.table_name = 'players' - game.belongs_to_many :players, anonymous_class: player, inverse_of: false + game.belongs_to_many :players, **options end subject { game.create } diff --git a/spec/tests/has_many_spec.rb b/spec/tests/has_many_spec.rb index ad63403..3990d43 100644 --- a/spec/tests/has_many_spec.rb +++ b/spec/tests/has_many_spec.rb @@ -422,10 +422,12 @@ connection.create_table(:players, id: :uuid) { |t| t.string :name } connection.create_table(:games, id: :uuid) { |t| t.uuid :player_ids, array: true } + options = { anonymous_class: game, foreign_key: :player_ids } + options[:inverse_of] = false if Torque::PostgreSQL::AR610 + game.table_name = 'games' player.table_name = 'players' - player.has_many :games, array: true, anonymous_class: game, - inverse_of: false, foreign_key: :player_ids + player.has_many :games, array: true, **options end subject { player.create } From 9cf28b7291faffb0d89d20f19f735a12ea713254 Mon Sep 17 00:00:00 2001 From: Carlos Silva Date: Sat, 24 Dec 2022 21:30:44 -0300 Subject: [PATCH 08/29] Update Circle CI --- .circleci/config.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index a3ce161..0d98d1d 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -12,7 +12,7 @@ jobs: docker: - image: cimg/ruby:<< parameters.ruby-version >> - - image: circleci/postgres:12.9 + - image: circleci/postgres:14.6 environment: POSTGRES_USER: postgres POSTGRES_PASSWORD: torque @@ -38,7 +38,7 @@ references: test: matrix: parameters: - ruby-version: ['2.6', '2.7', '3.0'] + ruby-version: ['2.6', '2.7', '3.0', '3.1'] bundle-version: ['Gemfile.rails-6.0', 'Gemfile.rails-6.1'] workflows: From d301cc1642358d4f7b50437b0d86158e858b1666 Mon Sep 17 00:00:00 2001 From: Carlos Silva Date: Sat, 24 Dec 2022 21:44:24 -0300 Subject: [PATCH 09/29] Change PostgreSQL version on CircleCI --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 0d98d1d..4228a36 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -12,7 +12,7 @@ jobs: docker: - image: cimg/ruby:<< parameters.ruby-version >> - - image: circleci/postgres:14.6 + - image: circleci/postgres:14.5 environment: POSTGRES_USER: postgres POSTGRES_PASSWORD: torque From 845427cd3134f5dc5efbd13301074474da8907a2 Mon Sep 17 00:00:00 2001 From: Carlos Silva Date: Sat, 24 Dec 2022 21:58:58 -0300 Subject: [PATCH 10/29] Fix PostgreSQL image --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 4228a36..c809354 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -12,7 +12,7 @@ jobs: docker: - image: cimg/ruby:<< parameters.ruby-version >> - - image: circleci/postgres:14.5 + - image: cimg/postgres:14.6 environment: POSTGRES_USER: postgres POSTGRES_PASSWORD: torque From f16d01051eb7a3acaede298179ddd7b63da2e2c4 Mon Sep 17 00:00:00 2001 From: Carlos Silva Date: Sat, 24 Dec 2022 22:00:39 -0300 Subject: [PATCH 11/29] Remove incorrect rails gemfile versions --- gemfiles/Gemfile.rails-5.0 | 12 ------------ gemfiles/Gemfile.rails-5.1 | 12 ------------ gemfiles/Gemfile.rails-5.2 | 7 ------- gemfiles/Gemfile.rails-7.0 | 7 ------- 4 files changed, 38 deletions(-) delete mode 100644 gemfiles/Gemfile.rails-5.0 delete mode 100644 gemfiles/Gemfile.rails-5.1 delete mode 100644 gemfiles/Gemfile.rails-5.2 delete mode 100644 gemfiles/Gemfile.rails-7.0 diff --git a/gemfiles/Gemfile.rails-5.0 b/gemfiles/Gemfile.rails-5.0 deleted file mode 100644 index 193809c..0000000 --- a/gemfiles/Gemfile.rails-5.0 +++ /dev/null @@ -1,12 +0,0 @@ -source 'https://rubygems.org/' - -gem 'rails', '~> 5.0.0' -gem 'pg', '0.18' - -# Extra needed gems -gem "rspec" -gem "faker" -gem "dotenv" -gem "byebug" -gem "factory_bot" -gem "database_cleaner" diff --git a/gemfiles/Gemfile.rails-5.1 b/gemfiles/Gemfile.rails-5.1 deleted file mode 100644 index 924baa4..0000000 --- a/gemfiles/Gemfile.rails-5.1 +++ /dev/null @@ -1,12 +0,0 @@ -source 'https://rubygems.org/' - -gem 'rails', '~> 5.1.6' -gem 'pg', '0.18' - -# Extra needed gems -gem "rspec" -gem "faker" -gem "dotenv" -gem "byebug" -gem "factory_bot" -gem "database_cleaner" diff --git a/gemfiles/Gemfile.rails-5.2 b/gemfiles/Gemfile.rails-5.2 deleted file mode 100644 index fa578dd..0000000 --- a/gemfiles/Gemfile.rails-5.2 +++ /dev/null @@ -1,7 +0,0 @@ -source 'https://rubygems.org/' - -gem 'rails', '~> 5.2.4', '>= 5.2.4.1' -gem 'pg', '~> 1.1.3' -gem "byebug" - -gemspec path: "../" diff --git a/gemfiles/Gemfile.rails-7.0 b/gemfiles/Gemfile.rails-7.0 deleted file mode 100644 index c978afe..0000000 --- a/gemfiles/Gemfile.rails-7.0 +++ /dev/null @@ -1,7 +0,0 @@ -source 'https://rubygems.org/' - -gem 'rails', '~> 7.0' -gem 'pg', '~> 1.2.3' -gem "byebug" - -gemspec path: "../" From 608dabcb151fb82432378d094bd3d4e9c9708672 Mon Sep 17 00:00:00 2001 From: Carlos Silva Date: Sat, 24 Dec 2022 22:20:31 -0300 Subject: [PATCH 12/29] Adjust gemspec --- torque_postgresql.gemspec | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/torque_postgresql.gemspec b/torque_postgresql.gemspec index 984b8f5..d6c8784 100644 --- a/torque_postgresql.gemspec +++ b/torque_postgresql.gemspec @@ -7,22 +7,30 @@ require 'torque/postgresql/version' Gem::Specification.new do |s| s.name = 'torque-postgresql' s.version = Torque::PostgreSQL::VERSION + s.date = Date.today.to_s s.authors = ['Carlos Silva'] - s.email = ['carlinhus.fsilva@gmail.com'] + s.email = ['me@carlosfsilva.com'] s.homepage = 'https://github.com/crashtech/torque-postgresql' s.summary = 'ActiveRecord extension to access PostgreSQL advanced resources' s.description = 'Add support to complex resources of PostgreSQL, like data types, array associations, and auxiliary statements (CTE)' s.license = 'MIT' + s.metadata = { + # 'homepage_uri' => 'https://torque.carlosfsilva.com/postgresql', + "source_code_uri" => 'https://github.com/crashtech/torque-postgresql', + 'bug_tracker_uri' => 'https://github.com/crashtech/torque-postgresql/issues', + # 'changelog_uri' => 'https://github.com/crashtech/torque-postgresql/blob/master/CHANGELOG.md', + } s.require_paths = ['lib'] - s.files = Dir['MIT-LICENSE', 'README.rdoc', 'lib/**/*', 'Rakefile'] - s.test_files = Dir['spec/**/*'] + s.files = Dir['MIT-LICENSE', 'README.rdoc', 'lib/**/*', 'Rakefile'] + s.test_files = Dir['spec/**/*'] + s.rdoc_options = ['--title', 'Torque PostgreSQL'] - s.required_ruby_version = '>= 2.6' + s.required_ruby_version = '>= 2.7.2' s.required_rubygems_version = '>= 1.8.11' - s.add_dependency 'rails', '>= 6.0' + s.add_dependency 'rails', '>= 7.0' s.add_dependency 'pg', '>= 1.2' s.add_development_dependency 'rake', '~> 12.3', '>= 12.3.3' From 9ea62cc63e21b6924c7e8c859584e5a3f36d21d5 Mon Sep 17 00:00:00 2001 From: Carlos Silva Date: Sat, 24 Dec 2022 22:22:29 -0300 Subject: [PATCH 13/29] Fix gemspec --- torque_postgresql.gemspec | 1 + 1 file changed, 1 insertion(+) diff --git a/torque_postgresql.gemspec b/torque_postgresql.gemspec index d6c8784..a897b04 100644 --- a/torque_postgresql.gemspec +++ b/torque_postgresql.gemspec @@ -2,6 +2,7 @@ $:.push File.expand_path('../lib', __FILE__) # Maintain your gem's version: require 'torque/postgresql/version' +require 'date' # Describe your gem and declare its dependencies: Gem::Specification.new do |s| From 00068a0d6fc63f951aae6434b3d4871eba7c9073 Mon Sep 17 00:00:00 2001 From: Carlos Silva Date: Sat, 24 Dec 2022 22:35:49 -0300 Subject: [PATCH 14/29] Fix rails versions --- Gemfile | 2 +- torque_postgresql.gemspec | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile b/Gemfile index e69d51f..2938e33 100644 --- a/Gemfile +++ b/Gemfile @@ -5,7 +5,7 @@ source 'https://rubygems.org/' # development dependencies will be added by default to the :development group. gemspec -gem 'rails', '>= 6.0' +gem 'rails', '>= 6.0', '< 7.0' gem 'pg', '>= 1.2' # Declare any dependencies that are still in development here instead of in diff --git a/torque_postgresql.gemspec b/torque_postgresql.gemspec index a897b04..b92899f 100644 --- a/torque_postgresql.gemspec +++ b/torque_postgresql.gemspec @@ -31,7 +31,7 @@ Gem::Specification.new do |s| s.required_ruby_version = '>= 2.7.2' s.required_rubygems_version = '>= 1.8.11' - s.add_dependency 'rails', '>= 7.0' + s.add_dependency 'rails', '>= 6.0', '< 7.0' s.add_dependency 'pg', '>= 1.2' s.add_development_dependency 'rake', '~> 12.3', '>= 12.3.3' From f68ab1e8b13cbfcf84da600566a26d1c973a7251 Mon Sep 17 00:00:00 2001 From: Carlos Silva Date: Sat, 24 Dec 2022 22:37:35 -0300 Subject: [PATCH 15/29] Remove ruby 2.6 from tests --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index c809354..cb3266f 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -38,7 +38,7 @@ references: test: matrix: parameters: - ruby-version: ['2.6', '2.7', '3.0', '3.1'] + ruby-version: ['2.7', '3.0', '3.1'] bundle-version: ['Gemfile.rails-6.0', 'Gemfile.rails-6.1'] workflows: From 9ddc46b50e5a6560ebed7eb25bc8d759e1b06265 Mon Sep 17 00:00:00 2001 From: Carlos Silva Date: Sat, 24 Dec 2022 22:46:59 -0300 Subject: [PATCH 16/29] Brink back ruby 2.6 version support --- .circleci/config.yml | 2 +- torque_postgresql.gemspec | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index cb3266f..c809354 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -38,7 +38,7 @@ references: test: matrix: parameters: - ruby-version: ['2.7', '3.0', '3.1'] + ruby-version: ['2.6', '2.7', '3.0', '3.1'] bundle-version: ['Gemfile.rails-6.0', 'Gemfile.rails-6.1'] workflows: diff --git a/torque_postgresql.gemspec b/torque_postgresql.gemspec index b92899f..dc4b589 100644 --- a/torque_postgresql.gemspec +++ b/torque_postgresql.gemspec @@ -28,7 +28,7 @@ Gem::Specification.new do |s| s.test_files = Dir['spec/**/*'] s.rdoc_options = ['--title', 'Torque PostgreSQL'] - s.required_ruby_version = '>= 2.7.2' + s.required_ruby_version = '>= 2.6' s.required_rubygems_version = '>= 1.8.11' s.add_dependency 'rails', '>= 6.0', '< 7.0' From f60859b343673d0424206f12393b5d3c66f02687 Mon Sep 17 00:00:00 2001 From: Carlos Silva Date: Sun, 25 Dec 2022 06:30:04 -0300 Subject: [PATCH 17/29] Add support for multiple schemas --- lib/torque/postgresql.rb | 3 +- lib/torque/postgresql/adapter.rb | 4 +- .../postgresql/adapter/database_statements.rb | 68 ++++++++++++-- .../postgresql/adapter/schema_dumper.rb | 21 ++++- .../postgresql/adapter/schema_statements.rb | 40 ++++++++ lib/torque/postgresql/base.rb | 36 +++++--- lib/torque/postgresql/config.rb | 13 +++ .../postgresql/migration/command_recorder.rb | 14 ++- lib/torque/postgresql/schema_cache.rb | 6 ++ lib/torque/postgresql/table_name.rb | 41 +++++++++ lib/torque/postgresql/version.rb | 2 +- lib/torque/range.rb | 2 + spec/models/internal/user.rb | 5 + spec/schema.rb | 12 ++- spec/spec_helper.rb | 2 +- spec/tests/enum_set_spec.rb | 13 +-- spec/tests/schema_spec.rb | 92 +++++++++++++++++++ spec/tests/table_inheritance_spec.rb | 26 +++--- 18 files changed, 349 insertions(+), 51 deletions(-) create mode 100644 lib/torque/postgresql/table_name.rb create mode 100644 spec/models/internal/user.rb create mode 100644 spec/tests/schema_spec.rb diff --git a/lib/torque/postgresql.rb b/lib/torque/postgresql.rb index 2ee6f53..193a68a 100644 --- a/lib/torque/postgresql.rb +++ b/lib/torque/postgresql.rb @@ -20,12 +20,13 @@ require 'torque/postgresql/attributes' require 'torque/postgresql/autosave_association' require 'torque/postgresql/auxiliary_statement' -require 'torque/postgresql/base' require 'torque/postgresql/inheritance' +require 'torque/postgresql/base'# Needs to be after inheritance require 'torque/postgresql/insert_all' require 'torque/postgresql/migration' require 'torque/postgresql/relation' require 'torque/postgresql/reflection' require 'torque/postgresql/schema_cache' +require 'torque/postgresql/table_name' require 'torque/postgresql/railtie' if defined?(Rails) diff --git a/lib/torque/postgresql/adapter.rb b/lib/torque/postgresql/adapter.rb index a885892..6b82f17 100644 --- a/lib/torque/postgresql/adapter.rb +++ b/lib/torque/postgresql/adapter.rb @@ -31,9 +31,9 @@ def version ) end - # Add `inherits` to the list of extracted table options + # Add `inherits` and `schema` to the list of extracted table options def extract_table_options!(options) - super.merge(options.extract!(:inherits)) + super.merge(options.extract!(:inherits, :schema)) end # Allow filtered bulk insert by adding the where clause. This method is diff --git a/lib/torque/postgresql/adapter/database_statements.rb b/lib/torque/postgresql/adapter/database_statements.rb index 9b1be86..93f6042 100644 --- a/lib/torque/postgresql/adapter/database_statements.rb +++ b/lib/torque/postgresql/adapter/database_statements.rb @@ -12,6 +12,26 @@ def dump_mode! @_dump_mode = !!!@_dump_mode end + # List of schemas blocked by the application in the current connection + def schemas_blacklist + @schemas_blacklist ||= Torque::PostgreSQL.config.schemas.blacklist + + (@config.dig(:schemas, 'blacklist') || []) + end + + # List of schemas used by the application in the current connection + def schemas_whitelist + @schemas_whitelist ||= Torque::PostgreSQL.config.schemas.whitelist + + (@config.dig(:schemas, 'whitelist') || []) + end + + # A list of schemas on the search path sanitized + def schemas_search_path_sanitized + @schemas_search_path_sanitized ||= begin + db_user = @config[:username] || ENV['USER'] || ENV['USERNAME'] + schema_search_path.split(',').map { |item| item.strip.sub('"$user"', db_user) } + end + end + # Check if a given type is valid. def valid_type?(type) super || extended_types.include?(type) @@ -22,6 +42,17 @@ def extended_types EXTENDED_DATABASE_TYPES end + # Checks if a given schema exists in the database. If +filtered+ is + # given as false, then it will check regardless of whitelist and + # blacklist + def schema_exists?(name, filtered: true) + return user_defined_schemas.include?(name.to_s) if filtered + + query_value(<<-SQL) == 1 + SELECT 1 FROM pg_catalog.pg_namespace WHERE nspname = '#{name}' + SQL + end + # Returns true if type exists. def type_exists?(name) user_defined_types.key? name.to_s @@ -115,18 +146,41 @@ def user_defined_types(*categories) # Get the list of inherited tables associated with their parent tables def inherited_tables tables = query(<<-SQL, 'SCHEMA') - SELECT child.relname AS table_name, - array_agg(parent.relname) AS inheritances + SELECT inhrelid::regclass AS table_name, + inhparent::regclass AS inheritances FROM pg_inherits JOIN pg_class parent ON pg_inherits.inhparent = parent.oid JOIN pg_class child ON pg_inherits.inhrelid = child.oid - GROUP BY child.relname, pg_inherits.inhrelid - ORDER BY pg_inherits.inhrelid + ORDER BY inhrelid SQL - tables.map do |(table, refs)| - [table, PG::TextDecoder::Array.new.decode(refs)] - end.to_h + tables.each_with_object({}) do |(child, parent), result| + (result[child] ||= []) << parent + end + end + + # Get the list of schemas that were created by the user + def user_defined_schemas + query_values(user_defined_schemas_sql, 'SCHEMA') + end + + # Build the query for allowed schemas + def user_defined_schemas_sql + conditions = [] + conditions << <<-SQL if schemas_blacklist.any? + nspname NOT LIKE ANY (ARRAY['#{schemas_blacklist.join("', '")}']) + SQL + + conditions << <<-SQL if schemas_whitelist.any? + nspname LIKE ANY (ARRAY['#{schemas_whitelist.join("', '")}']) + SQL + + <<-SQL.squish + SELECT nspname + FROM pg_catalog.pg_namespace + WHERE 1=1 AND #{conditions.join(' AND ')} + ORDER BY oid + SQL end # Get the list of columns, and their definition, but only from the diff --git a/lib/torque/postgresql/adapter/schema_dumper.rb b/lib/torque/postgresql/adapter/schema_dumper.rb index 7495e51..8db3aae 100644 --- a/lib/torque/postgresql/adapter/schema_dumper.rb +++ b/lib/torque/postgresql/adapter/schema_dumper.rb @@ -14,6 +14,7 @@ def dump(stream) # :nodoc: def extensions(stream) # :nodoc: super + user_defined_schemas(stream) user_defined_types(stream) end @@ -41,7 +42,9 @@ def schema_enum_type(column) def tables(stream) # :nodoc: inherited_tables = @connection.inherited_tables - sorted_tables = @connection.tables.sort - @connection.views + sorted_tables = (@connection.tables - @connection.views).sort_by do |table_name| + table_name.split(/(?:public)?\./).reverse + end stream.puts " # These are the common tables" (sorted_tables - inherited_tables.keys).each do |table_name| @@ -58,7 +61,7 @@ def tables(stream) # :nodoc: # Add the inherits setting sub_stream.rewind - inherits.map!(&:to_sym) + inherits.map! { |parent| parent.to_s.sub(/\Apublic\./, '') } inherits = inherits.first if inherits.size === 1 inherits = ", inherits: #{inherits.inspect} do |t|" table_dump = sub_stream.read.gsub(/ do \|t\|$/, inherits) @@ -84,6 +87,20 @@ def tables(stream) # :nodoc: triggers(stream) if defined?(::Fx::SchemaDumper::Trigger) end + # Make sure to remove the schema from the table name + def remove_prefix_and_suffix(table) + super(table.sub(/\A[a-z0-9_]*\./, '')) + end + + # Dump user defined schemas + def user_defined_schemas(stream) + return if (list = (@connection.user_defined_schemas - ['public'])).empty? + + stream.puts " # Custom schemas defined in this database." + list.each { |name| stream.puts " create_schema \"#{name}\", force: :cascade" } + stream.puts + end + # Dump user defined types like enum def user_defined_types(stream) types = @connection.user_defined_types('e') diff --git a/lib/torque/postgresql/adapter/schema_statements.rb b/lib/torque/postgresql/adapter/schema_statements.rb index 368c0e2..80aa905 100644 --- a/lib/torque/postgresql/adapter/schema_statements.rb +++ b/lib/torque/postgresql/adapter/schema_statements.rb @@ -7,6 +7,21 @@ module SchemaStatements TableDefinition = ActiveRecord::ConnectionAdapters::PostgreSQL::TableDefinition + # Create a new schema + def create_schema(name, options = {}) + drop_schema(name, options) if options[:force] + + check = 'IF NOT EXISTS' if options.fetch(:check, true) + execute("CREATE SCHEMA #{check} #{quote_schema_name(name.to_s)}") + end + + # Drop an existing schema + def drop_schema(name, options = {}) + force = options.fetch(:force, '').upcase + check = 'IF EXISTS' if options.fetch(:check, true) + execute("DROP SCHEMA #{check} #{quote_schema_name(name.to_s)} #{force}") + end + # Drops a type. def drop_type(name, options = {}) force = options.fetch(:force, '').upcase @@ -79,12 +94,37 @@ def enum_values(name) # Rewrite the method that creates tables to easily accept extra options def create_table(table_name, **options, &block) + table_name = "#{options[:schema]}.#{table_name}" if options[:schema].present? + options[:id] = false if options[:inherits].present? && options[:primary_key].blank? && options[:id].blank? super table_name, **options, &block end + # Add the schema option when extracting table options + def table_options(table_name) + parts = table_name.split('.').reverse + return super unless parts.size == 2 && parts[1] != 'public' + + (super || {}).merge(schema: parts[1]) + end + + # When dumping the schema we need to add all schemas, not only those + # active for the current +schema_search_path+ + def quoted_scope(name = nil, type: nil) + return super unless name.nil? + + super.merge(schema: "ANY ('{#{user_defined_schemas.join(',')}}')") + end + + # Fix the query to include the schema on tables names when dumping + def data_source_sql(name = nil, type: nil) + return super unless name.nil? + + super.sub('SELECT c.relname FROM', "SELECT n.nspname || '.' || c.relname FROM") + end + private def quote_enum_values(name, values, options) diff --git a/lib/torque/postgresql/base.rb b/lib/torque/postgresql/base.rb index 4a5f698..516777e 100644 --- a/lib/torque/postgresql/base.rb +++ b/lib/torque/postgresql/base.rb @@ -5,15 +5,27 @@ module PostgreSQL module Base extend ActiveSupport::Concern + ## + # :singleton-method: schema + # :call-seq: schema + # + # The schema to which the table belongs to. + included do mattr_accessor :belongs_to_many_required_by_default, instance_accessor: false + class_attribute :schema, instance_writer: false end module ClassMethods delegate :distinct_on, :with, :itself_only, :cast_records, to: :all - # Wenever it's inherited, add a new list of auxiliary statements - # It also adds an auxiliary statement to load inherited records' relname + # Make sure that table name is an instance of TableName class + def reset_table_name + self.table_name = TableName.new(self, super) + end + + # Whenever the base model is inherited, add a list of auxiliary + # statements like the one that loads inherited records' relname def inherited(subclass) super @@ -24,6 +36,11 @@ def inherited(subclass) # Define helper methods to return the class of the given records subclass.auxiliary_statement record_class do |cte| + ActiveSupport::Deprecation.warn(<<~MSG.squish) + Inheritance does not use this auxiliary statement and it can be removed. + You can replace it with `model.select_extra_values << 'tableoid::regclass'`. + MSG + pg_class = ::Arel::Table.new('pg_class') arel_query = ::Arel::SelectManager.new(pg_class) arel_query.project(pg_class['oid'], pg_class['relname'].as(record_class.to_s)) @@ -36,18 +53,11 @@ def inherited(subclass) # Define the dynamic attribute that returns the same information as # the one provided by the auxiliary statement subclass.dynamic_attribute(record_class) do - next self.class.table_name unless self.class.physically_inheritances? - - pg_class = ::Arel::Table.new('pg_class') - source = ::Arel::Table.new(subclass.table_name, as: 'source') - quoted_id = ::Arel::Nodes::Quoted.new(id) - - query = ::Arel::SelectManager.new(pg_class) - query.join(source).on(pg_class['oid'].eq(source['tableoid'])) - query.where(source[subclass.primary_key].eq(quoted_id)) - query.project(pg_class['relname']) + klass = self.class + next klass.table_name unless klass.physically_inheritances? - self.class.connection.select_value(query) + query = klass.unscoped.where(subclass.primary_key => id) + query.pluck(klass.arel_table['tableoid'].cast('regclass')).first end end diff --git a/lib/torque/postgresql/config.rb b/lib/torque/postgresql/config.rb index 557381c..b3aff9f 100644 --- a/lib/torque/postgresql/config.rb +++ b/lib/torque/postgresql/config.rb @@ -40,6 +40,19 @@ def config.irregular_models=(hash) end.to_h end + # Configure multiple schemas + config.nested(:schemas) do |schemas| + + # Defines a list of LIKE-based schemas to not consider for a multiple + # schema database + schemas.blacklist = %w[information_schema pg_%] + + # Defines a list of LIKE-based schemas to consider for a multiple schema + # database + schemas.whitelist = %w[public] + + end + # Configure associations features config.nested(:associations) do |assoc| diff --git a/lib/torque/postgresql/migration/command_recorder.rb b/lib/torque/postgresql/migration/command_recorder.rb index e6bf830..b9513ed 100644 --- a/lib/torque/postgresql/migration/command_recorder.rb +++ b/lib/torque/postgresql/migration/command_recorder.rb @@ -5,16 +5,26 @@ module PostgreSQL module Migration module CommandRecorder - # Records the rename operation for types. + # Records the rename operation for types def rename_type(*args, &block) record(:rename_type, args, &block) end - # Inverts the type name. + # Inverts the type rename operation def invert_rename_type(args) [:rename_type, args.reverse] end + # Records the creation of a schema + def create_schema(*args, &block) + record(:create_schema, args, &block) + end + + # Inverts the creation of a schema + def invert_create_schema(args) + [:drop_schema, [args.first]] + end + # Records the creation of the enum to be reverted. def create_enum(*args, &block) record(:create_enum, args, &block) diff --git a/lib/torque/postgresql/schema_cache.rb b/lib/torque/postgresql/schema_cache.rb index 67a785b..ba412f4 100644 --- a/lib/torque/postgresql/schema_cache.rb +++ b/lib/torque/postgresql/schema_cache.rb @@ -117,6 +117,12 @@ def lookup_model(table_name, scoped_class = '') scopes = scoped_class.scan(/(?:::)?[A-Z][a-z]+/) scopes.unshift('Object::') + # Check if the table name comes with a schema + if table_name.include?('.') + schema, table_name = table_name.split('.') + scopes.insert(1, schema.camelize) if schema != 'public' + end + # Consider the maximum namespaced possible model name max_name = table_name.tr('_', '/').camelize.split(/(::)/) max_name[-1] = max_name[-1].singularize diff --git a/lib/torque/postgresql/table_name.rb b/lib/torque/postgresql/table_name.rb new file mode 100644 index 0000000..2bb27f3 --- /dev/null +++ b/lib/torque/postgresql/table_name.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Torque + module PostgreSQL + class TableName < Delegator + def initialize(klass, table_name) + @klass = klass + @table_name = table_name + end + + def schema + return @schema if defined?(@schema) + + @schema = ([@klass] + @klass.module_parents[0..-2]).find do |klass| + next unless klass.respond_to?(:schema) + break klass.schema + end + end + + def to_s + schema.nil? ? @table_name : "#{schema}.#{@table_name}" + end + + alias __getobj__ to_s + + def ==(other) + other.to_s =~ /("?#{schema | search_path_schemes.join('|')}"?\.)?"?#{@table_name}"?/ + end + + def __setobj__(value) + @table_name = value + end + + private + + def search_path_schemes + klass.connection.schemas_search_path_sanitized + end + end + end +end diff --git a/lib/torque/postgresql/version.rb b/lib/torque/postgresql/version.rb index 02b92b3..364dde0 100644 --- a/lib/torque/postgresql/version.rb +++ b/lib/torque/postgresql/version.rb @@ -2,6 +2,6 @@ module Torque module PostgreSQL - VERSION = '2.2.4' + VERSION = '2.3.0' end end diff --git a/lib/torque/range.rb b/lib/torque/range.rb index 7e38bfe..45c1a37 100644 --- a/lib/torque/range.rb +++ b/lib/torque/range.rb @@ -1,6 +1,7 @@ module Torque module Range def intersection(other) + ActiveSupport::Deprecation.warn('Range extensions will be removed in future versions') raise ArgumentError, 'value must be a Range' unless other.kind_of?(Range) new_min = self.cover?(other.min) ? other.min : other.cover?(min) ? min : nil @@ -11,6 +12,7 @@ def intersection(other) alias_method :&, :intersection def union(other) + ActiveSupport::Deprecation.warn('Range extensions will be removed in future versions') raise ArgumentError, 'value must be a Range' unless other.kind_of?(Range) ([min, other.min].min)..([max, other.max].max) diff --git a/spec/models/internal/user.rb b/spec/models/internal/user.rb new file mode 100644 index 0000000..984e5d1 --- /dev/null +++ b/spec/models/internal/user.rb @@ -0,0 +1,5 @@ +module Internal + class User < ActiveRecord::Base + self.schema = 'internal' + end +end diff --git a/spec/schema.rb b/spec/schema.rb index 64802f2..d6bcd32 100644 --- a/spec/schema.rb +++ b/spec/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -version = 2 +version = 1 return if ActiveRecord::Migrator.current_version == version ActiveRecord::Schema.define(version: version) do @@ -20,6 +20,9 @@ enable_extension "pgcrypto" enable_extension "plpgsql" + # Custom schemas used in this database. + create_schema "internal", force: :cascade + # Custom types defined in this database. # Note that some types may not work with other database engines. Be careful if changing database. create_enum "content_status", ["created", "draft", "published", "archived"], force: :cascade @@ -117,6 +120,13 @@ t.datetime "updated_at", null: false end + create_table "users", schema: "internal", force: :cascade do |t| + t.string "email" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["email"], name: "index_internal_users_on_email", unique: true + end + create_table "activities", force: :cascade do |t| t.integer "author_id" t.string "title" diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 1a6616b..e83f6c2 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -22,7 +22,7 @@ end load File.join('schema.rb') -Dir.glob(File.join('spec', '{models,factories,mocks}', '*.rb')) do |file| +Dir.glob(File.join('spec', '{models,factories,mocks}', '**', '*.rb')) do |file| require file[5..-4] end diff --git a/spec/tests/enum_set_spec.rb b/spec/tests/enum_set_spec.rb index e68ba7e..6e20334 100644 --- a/spec/tests/enum_set_spec.rb +++ b/spec/tests/enum_set_spec.rb @@ -42,18 +42,19 @@ def decorate(model, field, options = {}) end context 'on schema' do + let(:dump_result) do + ActiveRecord::SchemaDumper.dump(connection, (dump_result = StringIO.new)) + dump_result.string + end + it 'can be used on tables' do - dump_io = StringIO.new checker = /t\.enum +"conflicts", +array: true, +enum_type: :conflicts/ - ActiveRecord::SchemaDumper.dump(connection, dump_io) - expect(dump_io.string).to match checker + expect(dump_result).to match checker end xit 'can have a default value as an array of symbols' do - dump_io = StringIO.new checker = /t\.enum +"types", +default: \[:A, :B\], +array: true, +enum_type: :types/ - ActiveRecord::SchemaDumper.dump(connection, dump_io) - expect(dump_io.string).to match checker + expect(dump_result).to match checker end end diff --git a/spec/tests/schema_spec.rb b/spec/tests/schema_spec.rb new file mode 100644 index 0000000..bf13171 --- /dev/null +++ b/spec/tests/schema_spec.rb @@ -0,0 +1,92 @@ +require 'spec_helper' + +RSpec.describe 'Schema' do + let(:connection) { ActiveRecord::Base.connection } + + before do + connection.instance_variable_set(:@schmeas_blacklist, nil) + connection.instance_variable_set(:@schmeas_whitelist, nil) + end + + context 'on migration' do + it 'can check for existance' do + expect(connection.schema_exists?(:information_schema)).to be_falsey + expect(connection.schema_exists?(:information_schema, filtered: false)).to be_truthy + end + + it 'can be created' do + expect(connection.schema_exists?(:legacy, filtered: false)).to be_falsey + connection.create_schema(:legacy) + expect(connection.schema_exists?(:legacy, filtered: false)).to be_truthy + end + + it 'can be deleted' do + expect(connection.schema_exists?(:legacy, filtered: false)).to be_falsey + + connection.create_schema(:legacy) + expect(connection.schema_exists?(:legacy, filtered: false)).to be_truthy + + connection.drop_schema(:legacy) + expect(connection.schema_exists?(:legacy, filtered: false)).to be_falsey + end + + it 'works with whitelist' do + expect(connection.schema_exists?(:legacy)).to be_falsey + connection.create_schema(:legacy) + + expect(connection.schema_exists?(:legacy)).to be_falsey + expect(connection.schema_exists?(:legacy, filtered: false)).to be_truthy + + connection.schemas_whitelist.push('legacy') + expect(connection.schema_exists?(:legacy)).to be_truthy + end + end + + context 'on schema' do + let(:dump_result) do + ActiveRecord::SchemaDumper.dump(connection, (dump_result = StringIO.new)) + dump_result.string + end + + it 'does not add when there is no extra schemas' do + connection.drop_schema(:internal, force: :cascade) + expect(dump_result).not_to match /Custom schemas defined in this database/ + end + + it 'does not include tables from blacklisted schemas' do + connection.schemas_blacklist.push('internal') + expect(dump_result).not_to match /create_table \"users\",.*schema: +"internal"/ + end + + context 'with internal schema whitelisted' do + before { connection.schemas_whitelist.push('internal') } + + it 'dumps the schemas' do + expect(dump_result).to match /create_schema \"internal\"/ + end + + it 'shows the internal users table in the connection tables list' do + expect(connection.tables).to include('internal.users') + end + + it 'dumps tables on whitelisted schemas' do + expect(dump_result).to match /create_table \"users\",.*schema: +"internal"/ + end + end + end + + context 'on relation' do + let(:model) { Internal::User } + + it 'adds the schema to the query' do + expect(model.all.to_sql).to match(/FROM "internal"."users"/) + end + + it 'can load the schema from the module' do + allow(Internal).to receive(:schema).and_return('internal') + allow(model).to receive(:schema).and_return(nil) + + expect(model.all.to_sql).to match(/FROM "internal"."users"/) + end + end +end diff --git a/spec/tests/table_inheritance_spec.rb b/spec/tests/table_inheritance_spec.rb index 268505f..2dd7a06 100644 --- a/spec/tests/table_inheritance_spec.rb +++ b/spec/tests/table_inheritance_spec.rb @@ -73,37 +73,33 @@ end context 'on schema' do - it 'dumps single inheritance with body' do - dump_io = StringIO.new - ActiveRecord::SchemaDumper.dump(connection, dump_io) + let(:dump_result) do + ActiveRecord::SchemaDumper.dump(connection, (dump_result = StringIO.new)) + dump_result.string + end + it 'dumps single inheritance with body' do parts = '"activity_books"' parts << ', id: false' parts << ', force: :cascade' - parts << ', inherits: :activities' - expect(dump_io.string).to match(/create_table #{parts} do /) + parts << ', inherits: "activities"' + expect(dump_result).to match(/create_table #{parts} do /) end it 'dumps single inheritance without body' do - dump_io = StringIO.new - ActiveRecord::SchemaDumper.dump(connection, dump_io) - parts = '"activity_post_samples"' parts << ', id: false' parts << ', force: :cascade' - parts << ', inherits: :activity_posts' - expect(dump_io.string).to match(/create_table #{parts}(?! do \|t\|)/) + parts << ', inherits: "activity_posts"' + expect(dump_result).to match(/create_table #{parts}(?! do \|t\|)/) end it 'dumps multiple inheritance' do - dump_io = StringIO.new - ActiveRecord::SchemaDumper.dump(connection, dump_io) - parts = '"activity_posts"' parts << ', id: false' parts << ', force: :cascade' - parts << ', inherits: (\[:images, :activities\]|\[:activities, :images\])' - expect(dump_io.string).to match(/create_table #{parts}/) + parts << ', inherits: (\["images", "activities"\]|\["activities", "images"\])' + expect(dump_result).to match(/create_table #{parts}/) end end From 55f0fa7adb9fca4a24d593f999ee3065b2e1ec76 Mon Sep 17 00:00:00 2001 From: Carlos Silva Date: Sun, 25 Dec 2022 07:44:57 -0300 Subject: [PATCH 18/29] Update README --- README.md | 19 ++++++++++--------- README.rdoc | 17 +++++++++++++++++ 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index d72527c..01bd51c 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,9 @@ * [TODO](https://github.com/crashtech/torque-postgresql/wiki/TODO) # Description -`torque-postgresql` is a plugin that enhances Ruby on Rails enabling easy access to existing PostgreSQL advanced resources, such as data types and queries statements. Its features are designed to be as similar to Rails architecture and they work as smoothly as possible. +`torque-postgresql` is a plugin that enhances Ruby on Rails enabling easy access to existing PostgreSQL advanced resources, such as data types and query statements. Its features are designed to be similar to Rails architecture and work as smoothly as possible. -100% plug-and-play, with optional configurations, so that can be adapted to your project's design pattern. +Fully compatible with `schema.rb` and 100% plug-and-play, with optional configurations, so that it can be adapted to your project's design pattern. # Installation @@ -48,25 +48,26 @@ These are the currently available features: ## Data types +* [Box](https://github.com/crashtech/torque-postgresql/wiki/Box) +* [Circle](https://github.com/crashtech/torque-postgresql/wiki/Circle) +* [Date/Time Range](https://github.com/crashtech/torque-postgresql/wiki/Date-Time-Range) * [Enum](https://github.com/crashtech/torque-postgresql/wiki/Enum) * [EnumSet](https://github.com/crashtech/torque-postgresql/wiki/Enum-Set) * [Interval](https://github.com/crashtech/torque-postgresql/wiki/Interval) -* [Date/Time Range](https://github.com/crashtech/torque-postgresql/wiki/Date-Time-Range) -* [Box](https://github.com/crashtech/torque-postgresql/wiki/Box) -* [Circle](https://github.com/crashtech/torque-postgresql/wiki/Circle) * [Line](https://github.com/crashtech/torque-postgresql/wiki/Line) * [Segment](https://github.com/crashtech/torque-postgresql/wiki/Segment) ## Querying * [Arel](https://github.com/crashtech/torque-postgresql/wiki/Arel) -* [Has Many](https://github.com/crashtech/torque-postgresql/wiki/Has-Many) +* [Auxiliary Statements](https://github.com/crashtech/torque-postgresql/wiki/Auxiliary-Statements) * [Belongs to Many](https://github.com/crashtech/torque-postgresql/wiki/Belongs-to-Many) -* [Dynamic Attributes](https://github.com/crashtech/torque-postgresql/wiki/Dynamic-Attributes) * [Distinct On](https://github.com/crashtech/torque-postgresql/wiki/Distinct-On) -* [Insert All](https://github.com/crashtech/torque-postgresql/wiki/Insert-All) -* [Auxiliary Statements](https://github.com/crashtech/torque-postgresql/wiki/Auxiliary-Statements) +* [Dynamic Attributes](https://github.com/crashtech/torque-postgresql/wiki/Dynamic-Attributes) +* [Has Many](https://github.com/crashtech/torque-postgresql/wiki/Has-Many) * [Inherited Tables](https://github.com/crashtech/torque-postgresql/wiki/Inherited-Tables) +* [Insert All](https://github.com/crashtech/torque-postgresql/wiki/Insert-All) +* [Multiple Schemas](https://github.com/crashtech/torque-postgresql/wiki/Multiple-Schemas) # How to Contribute diff --git a/README.rdoc b/README.rdoc index f852e77..753f8c8 100644 --- a/README.rdoc +++ b/README.rdoc @@ -128,6 +128,23 @@ reconfigured on the model, and then can be used during querying process. {Learn more}[link:classes/Torque/PostgreSQL/AuxiliaryStatement.html] +* Multiple Schemas + +Allows models and modules to have a schema associated with them, so that +developers can better organize their tables into schemas and build features in +a way that the database can better represent how they are separated. + + create_schema "internal", force: :cascade + + module Internal + class User < ActiveRecord::Base + self.schema = 'internal' + end + end + + Internal::User.all + + {Learn more}[link:classes/Torque/PostgreSQL/Adapter/DatabaseStatements.html] == Download and installation From a1c3cab649459199cbe7f894f473a28d16f305ea Mon Sep 17 00:00:00 2001 From: Carlos Silva Date: Sun, 25 Dec 2022 07:46:11 -0300 Subject: [PATCH 19/29] Update README --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 01bd51c..fe2f7df 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,6 @@ Fully compatible with `schema.rb` and 100% plug-and-play, with optional configur To install torque-postgresql you need to add the following to your Gemfile: ```ruby -gem 'torque-postgresql', '~> 1.1' # For Rails < 6.0 gem 'torque-postgresql', '~> 2.0' # For Rails >= 6.0 < 6.1 gem 'torque-postgresql', '~> 2.0.4' # For Rails >= 6.1 ``` From 1fa840b763265ba2f5d2f708c99755b2c6b31af8 Mon Sep 17 00:00:00 2001 From: Carlos Silva Date: Fri, 30 Dec 2022 02:50:36 -0300 Subject: [PATCH 20/29] Finish implementation of recursive auxiliary statments --- lib/torque/postgresql/auxiliary_statement.rb | 77 ++-- .../auxiliary_statement/recursive.rb | 149 +++++++ .../auxiliary_statement/settings.rb | 97 ++++- lib/torque/postgresql/base.rb | 10 + lib/torque/postgresql/config.rb | 4 + lib/torque/postgresql/inheritance.rb | 4 +- lib/torque/postgresql/railtie.rb | 6 +- .../relation/auxiliary_statement.rb | 43 +- lib/torque/postgresql/version.rb | 2 +- spec/models/category.rb | 2 + spec/schema.rb | 8 +- spec/tests/auxiliary_statement_spec.rb | 409 ++++++++++++++++-- 12 files changed, 696 insertions(+), 115 deletions(-) create mode 100644 lib/torque/postgresql/auxiliary_statement/recursive.rb create mode 100644 spec/models/category.rb diff --git a/lib/torque/postgresql/auxiliary_statement.rb b/lib/torque/postgresql/auxiliary_statement.rb index f590e10..49eb268 100644 --- a/lib/torque/postgresql/auxiliary_statement.rb +++ b/lib/torque/postgresql/auxiliary_statement.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require_relative 'auxiliary_statement/settings' +require_relative 'auxiliary_statement/recursive' module Torque module PostgreSQL @@ -8,17 +9,20 @@ class AuxiliaryStatement TABLE_COLUMN_AS_STRING = /\A(?:"?(\w+)"?\.)?"?(\w+)"?\z/.freeze class << self - attr_reader :config + attr_reader :config, :table_name # Find or create the class that will handle statement def lookup(name, base) const = name.to_s.camelize << '_' << self.name.demodulize return base.const_get(const, false) if base.const_defined?(const, false) - base.const_set(const, Class.new(AuxiliaryStatement)) + + base.const_set(const, Class.new(self)).tap do |klass| + klass.instance_variable_set(:@table_name, name.to_s) + end end # Create a new instance of an auxiliary statement - def instantiate(statement, base, options = nil) + def instantiate(statement, base, **options) klass = while base < ActiveRecord::Base list = base.auxiliary_statements_list break list[statement] if list.present? && list.key?(statement) @@ -26,15 +30,15 @@ def instantiate(statement, base, options = nil) base = base.superclass end - return klass.new(options) unless klass.nil? + return klass.new(**options) unless klass.nil? raise ArgumentError, <<-MSG.squish There's no '#{statement}' auxiliary statement defined for #{base.class.name}. MSG end # Fast access to statement build - def build(statement, base, options = nil, bound_attributes = [], join_sources = []) - klass = instantiate(statement, base, options) + def build(statement, base, bound_attributes = [], join_sources = [], **options) + klass = instantiate(statement, base, **options) result = klass.build(base) bound_attributes.concat(klass.bound_attributes) @@ -56,7 +60,7 @@ def arel_query?(obj) # A way to create auxiliary statements outside of models configurations, # being able to use on extensions def create(table_or_settings, &block) - klass = Class.new(AuxiliaryStatement) + klass = Class.new(self) if block_given? klass.instance_variable_set(:@table_name, table_or_settings) @@ -89,7 +93,8 @@ def configurator(config) def configure(base, instance) return @config unless @config.respond_to?(:call) - settings = Settings.new(base, instance) + recursive = self < AuxiliaryStatement::Recursive + settings = Settings.new(base, instance, recursive) settings.instance_exec(settings, &@config) settings end @@ -98,11 +103,6 @@ def configure(base, instance) def table @table ||= ::Arel::Table.new(table_name) end - - # Get the name of the table of the configurated statement - def table_name - @table_name ||= self.name.demodulize.split('_').first.underscore - end end delegate :config, :table, :table_name, :relation, :configure, :relation_query?, @@ -111,15 +111,14 @@ def table_name attr_reader :bound_attributes, :join_sources # Start a new auxiliary statement giving extra options - def initialize(*args) - options = args.extract_options! + def initialize(*, **options) args_key = Torque::PostgreSQL.config.auxiliary_statement.send_arguments_key @join = options.fetch(:join, {}) @args = options.fetch(args_key, {}) @where = options.fetch(:where, {}) @select = options.fetch(:select, {}) - @join_type = options.fetch(:join_type, nil) + @join_type = options[:join_type] @bound_attributes = [] @join_sources = [] @@ -131,7 +130,7 @@ def build(base) @join_sources.clear # Prepare all the data for the statement - prepare(base) + prepare(base, configure(base, self)) # Add the join condition to the list @join_sources << build_join(base) @@ -142,8 +141,7 @@ def build(base) private # Setup the statement using the class configuration - def prepare(base) - settings = configure(base, self) + def prepare(base, settings) requires = Array.wrap(settings.requires).flatten.compact @dependencies = ensure_dependencies(requires, base).flatten.compact @@ -151,14 +149,13 @@ def prepare(base) @query = settings.query # Call a proc to get the real query - if @query.methods.include?(:call) + if @query.respond_to?(:call) call_args = @query.try(:arity) === 0 ? [] : [OpenStruct.new(@args)] @query = @query.call(*call_args) @args = [] end - # Manually set the query table when it's not an relation query - @query_table = settings.query_table unless relation_query?(@query) + # Merge select attributes provided on the instance creation @select = settings.attributes.merge(@select) if settings.attributes.present? # Merge join settings @@ -168,7 +165,7 @@ def prepare(base) @association = settings.through.to_s elsif relation_query?(@query) @association = base.reflections.find do |name, reflection| - break name if @query.klass.eql? reflection.klass + break name if @query.klass.eql?(reflection.klass) end end end @@ -234,15 +231,6 @@ def build_join(base) as a query object on #{self.class.name}. MSG - # Expose join columns - if relation_query?(@query) - query_table = @query.arel_table - conditions.children.each do |item| - @query.select_values += [query_table[item.left.name]] \ - if item.left.relation.eql?(table) - end - end - # Build the join based on the join type arel_join.new(table, table.create_on(conditions)) end @@ -263,21 +251,31 @@ def arel_join # Mount the list of selected attributes def expose_columns(base, query_table = nil) + # Add the columns necessary for the join + list = @join_sources.each_with_object(@select) do |join, hash| + join.right.expr.children.each do |item| + hash[item.left.name] = nil if item.left.relation.eql?(table) + end + end + # Add select columns to the query and get exposed columns - @select.map do |left, right| - base.select_extra_values += [table[right.to_s]] - project(left, query_table).as(right.to_s) if query_table + list.filter_map do |left, right| + base.select_extra_values += [table[right.to_s]] unless right.nil? + next unless query_table + + col = project(left, query_table) + right.nil? ? col : col.as(right.to_s) end end # Ensure that all the dependencies are loaded in the base relation def ensure_dependencies(list, base) with_options = list.extract_options!.to_a - (list + with_options).map do |dependent, options| - dependent_klass = base.model.auxiliary_statements_list[dependent] + (list + with_options).map do |name, options| + dependent_klass = base.model.auxiliary_statements_list[name] raise ArgumentError, <<-MSG.squish if dependent_klass.nil? - The '#{dependent}' auxiliary statement dependency can't found on + The '#{name}' auxiliary statement dependency can't found on #{self.class.name}. MSG @@ -285,7 +283,8 @@ def ensure_dependencies(list, base) cte.is_a?(dependent_klass) end - AuxiliaryStatement.build(dependent, base, options, bound_attributes, join_sources) + options ||= {} + AuxiliaryStatement.build(name, base, bound_attributes, join_sources, **options) end end diff --git a/lib/torque/postgresql/auxiliary_statement/recursive.rb b/lib/torque/postgresql/auxiliary_statement/recursive.rb new file mode 100644 index 0000000..141878f --- /dev/null +++ b/lib/torque/postgresql/auxiliary_statement/recursive.rb @@ -0,0 +1,149 @@ +# frozen_string_literal: true + +module Torque + module PostgreSQL + class AuxiliaryStatement + class Recursive < AuxiliaryStatement + # Setup any additional option in the recursive mode + def initialize(*, **options) + super + + @connect = options[:connect]&.to_a&.first + @union_all = options[:union_all] + @sub_query = options[:sub_query] + + if options.key?(:with_depth) + @depth = options[:with_depth].values_at(:name, :start, :as) + @depth[0] ||= 'depth' + end + + if options.key?(:with_path) + @path = options[:with_path].values_at(:name, :source, :as) + @path[0] ||= 'path' + end + end + + private + + # Build the string or arel query + def build_query(base) + # Expose columns and get the list of the ones for select + columns = expose_columns(base, @query.try(:arel_table)) + sub_columns = columns.dup + type = @union_all.present? ? 'all' : '' + + # Build any extra columns that are dynamic and from the recursion + extra_columns(base, columns, sub_columns) + + # Prepare the query depending on its type + if @query.is_a?(String) && @sub_query.is_a?(String) + args = @args.each_with_object({}) { |h, (k, v)| h[k] = base.connection.quote(v) } + ::Arel.sql("(#{@query} UNION #{type.upcase} #{@sub_query})" % args) + elsif relation_query?(@query) + @query = @query.where(@where) if @where.present? + @bound_attributes.concat(@query.send(:bound_attributes)) + + if relation_query?(@sub_query) + @bound_attributes.concat(@sub_query.send(:bound_attributes)) + + sub_query = @sub_query.select(*sub_columns).arel + sub_query.from([@sub_query.arel_table, table]) + else + sub_query = ::Arel.sql(@sub_query) + end + + @query.select(*columns).arel.union(type, sub_query) + else + raise ArgumentError, <<-MSG.squish + Only String and ActiveRecord::Base objects are accepted as query and sub query + objects, #{@query.class.name} given for #{self.class.name}. + MSG + end + end + + # Setup the statement using the class configuration + def prepare(base, settings) + super + + prepare_sub_query(base, settings) + end + + # Make sure that both parts of the union are ready + def prepare_sub_query(base, settings) + @union_all = settings.union_all if @union_all.nil? + @sub_query ||= settings.sub_query + @depth ||= settings.depth + @path ||= settings.path + + # Collect the connection + @connect ||= settings.connect || begin + key = base.primary_key + [key.to_sym, :"parent_#{key}"] unless key.nil? + end + + raise ArgumentError, <<-MSG.squish if @sub_query.nil? && @query.is_a?(String) + Unable to generate sub query from a string query. Please provide a `sub_query` + property on the "#{table_name}" settings. + MSG + + if @sub_query.nil? + raise ArgumentError, <<-MSG.squish if @connect.blank? + Unable to generate sub query without setting up a proper way to connect it + with the main query. Please provide a `connect` property on the "#{table_name}" + settings. + MSG + + left, right = @connect.map(&:to_s) + condition = @query.arel_table[right].eq(table[left]) + + if @query.where_values_hash.key?(right) + @sub_query = @query.unscope(where: right.to_sym).where(condition) + else + @sub_query = @query.where(condition) + @query = @query.where(right => nil) + end + elsif @sub_query.respond_to?(:call) + # Call a proc to get the real sub query + call_args = @sub_query.try(:arity) === 0 ? [] : [OpenStruct.new(@args)] + @sub_query = @sub_query.call(*call_args) + end + end + + # Add depth and path if they were defined in settings + def extra_columns(base, columns, sub_columns) + return if @query.is_a?(String) || @sub_query.is_a?(String) + + # Add the connect attribute to the query + if defined?(@connect) + columns.unshift(@query.arel_table[@connect[0]]) + sub_columns.unshift(@sub_query.arel_table[@connect[0]]) + end + + # Build a column to represent the depth of the recursion + if @depth.present? + name, start, as = @depth + col = table[name] + base.select_extra_values += [col.as(as)] unless as.nil? + + columns << ::Arel.sql(start.to_s).as(name) + sub_columns << (col + ::Arel.sql('1')).as(name) + end + + # Build a column to represent the path of the record access + if @path.present? + name, source, as = @path + source = @query.arel_table[source || @connect[0]] + + col = table[name] + base.select_extra_values += [col.as(as)] unless as.nil? + parts = [col, source.cast(:varchar)] + + columns << ::Arel.array([source]).cast(:varchar, true).as(name) + sub_columns << ::Arel::Nodes::NamedFunction.new('array_append', parts).as(name) + end + end + + end + end + end +end diff --git a/lib/torque/postgresql/auxiliary_statement/settings.rb b/lib/torque/postgresql/auxiliary_statement/settings.rb index ed10cbb..bf2fd7c 100644 --- a/lib/torque/postgresql/auxiliary_statement/settings.rb +++ b/lib/torque/postgresql/auxiliary_statement/settings.rb @@ -4,9 +4,9 @@ module Torque module PostgreSQL class AuxiliaryStatement class Settings < Collector.new(:attributes, :join, :join_type, :query, :requires, - :polymorphic, :through) + :polymorphic, :through, :union_all, :connect) - attr_reader :base, :source + attr_reader :base, :source, :depth, :path alias_method :select, :attributes alias_method :cte, :source @@ -14,9 +14,10 @@ class Settings < Collector.new(:attributes, :join, :join_type, :query, :requires delegate :table, :table_name, to: :@source delegate :sql, to: ::Arel - def initialize(base, source) + def initialize(base, source, recursive = false) @base = base @source = source + @recursive = recursive end def base_name @@ -27,6 +28,39 @@ def base_table @base.arel_table end + + def recursive? + @recursive + end + + def depth? + defined?(@depth) + end + + def path? + defined?(@path) + end + + # Add an attribute to the result showing the depth of each iteration + def with_depth(name = 'depth', start: 0, as: nil) + @depth = [name.to_s, start, as&.to_s] if recursive? + end + + # Add an attribute to the result showing the path of each record + def with_path(name = 'path', source: nil, as: nil) + @path = [name.to_s, source&.to_s, as&.to_s] if recursive? + end + + # Set recursive operation to use union all + def union_all! + @union_all = true if recursive? + end + + # Add both depth and path to the result + def with_depth_and_path + with_depth && with_path + end + # Get the arel version of the table set on the query def query_table raise StandardError, 'The query is not defined yet' if query.nil? @@ -41,36 +75,55 @@ def col(name) alias column col - # There are two ways of setting the query: + # There are three ways of setting the query: # - A simple relation based on a Model # - A Arel-based select manager - # - A string or a proc that requires the table name as first argument + # - A string or a proc def query(value = nil, command = nil) return @query if value.nil? - return @query = value if relation_query?(value) - if value.is_a?(::Arel::SelectManager) - @query = value - @query_table = value.source.left.name - return - end + @query = sanitize_query(value, command) + end - valid_type = command.respond_to?(:call) || command.is_a?(String) + # Same as query, but for the second part of the union for recursive cte + def sub_query(value = nil, command = nil) + return unless recursive? + return @sub_query if value.nil? - raise ArgumentError, <<-MSG.squish if command.nil? - To use proc or string as query, you need to provide the table name - as the first argument - MSG + @sub_query = sanitize_query(value, command) + end + + # Assume `parent_` as the other part if provided a Symbol or String + def connect(value = nil) + return @connect if value.nil? - raise ArgumentError, <<-MSG.squish unless valid_type - Only relation, string and proc are valid object types for query, - #{command.inspect} given. - MSG + value = [value.to_sym, :"parent_#{value}"] \ + if value.is_a?(String) || value.is_a?(Symbol) + value = value.to_a.first if value.is_a?(Hash) - @query = command - @query_table = ::Arel::Table.new(value) + @connect = value end + alias connect= connect + + private + + # Get the query and table from the params + def sanitize_query(value, command = nil) + return value if relation_query?(value) + return value if value.is_a?(::Arel::SelectManager) + + command = value if command.nil? # For compatibility purposes + valid_type = command.respond_to?(:call) || command.is_a?(String) + + raise ArgumentError, <<-MSG.squish unless valid_type + Only relation, string and proc are valid object types for query, + #{command.inspect} given. + MSG + + command + end + end end end diff --git a/lib/torque/postgresql/base.rb b/lib/torque/postgresql/base.rb index 516777e..7bf0773 100644 --- a/lib/torque/postgresql/base.rb +++ b/lib/torque/postgresql/base.rb @@ -309,6 +309,16 @@ def auxiliary_statement(table, &block) klass.configurator(block) end alias cte auxiliary_statement + + # Creates a new recursive auxiliary statement (CTE) under the base + # Very similar to the regular auxiliary statement, but with two-part + # query where one is executed first and the second recursively + def recursive_auxiliary_statement(table, &block) + klass = AuxiliaryStatement::Recursive.lookup(table, self) + auxiliary_statements_list[table.to_sym] = klass + klass.configurator(block) + end + alias recursive_cte recursive_auxiliary_statement end end diff --git a/lib/torque/postgresql/config.rb b/lib/torque/postgresql/config.rb index b3aff9f..27e4bd1 100644 --- a/lib/torque/postgresql/config.rb +++ b/lib/torque/postgresql/config.rb @@ -73,6 +73,10 @@ def config.irregular_models=(hash) # auxiliary statement in order to perform detached CTEs cte.exposed_class = 'TorqueCTE' + # Estipulate a class name (which may contain namespace) that expose the + # recursive auxiliary statement in order to perform detached CTEs + cte.exposed_recursive_class = 'TorqueRecursiveCTE' + end # Configure ENUM features diff --git a/lib/torque/postgresql/inheritance.rb b/lib/torque/postgresql/inheritance.rb index 8c49296..fa6f262 100644 --- a/lib/torque/postgresql/inheritance.rb +++ b/lib/torque/postgresql/inheritance.rb @@ -55,7 +55,9 @@ def inheritance_mergeable_attributes # Check if the model's table depends on any inheritance def physically_inherited? - @physically_inherited ||= connection.schema_cache.dependencies( + return @physically_inherited if defined?(@physically_inherited) + + @physically_inherited = connection.schema_cache.dependencies( defined?(@table_name) ? @table_name : decorated_table_name, ).present? rescue ActiveRecord::ConnectionNotEstablished diff --git a/lib/torque/postgresql/railtie.rb b/lib/torque/postgresql/railtie.rb index 91d19df..b5c2319 100644 --- a/lib/torque/postgresql/railtie.rb +++ b/lib/torque/postgresql/railtie.rb @@ -30,11 +30,15 @@ class Railtie < Rails::Railtie # :nodoc: Torque::PostgreSQL::Attributes::Enum.lookup(name).sample end - # Define the exposed constant for auxiliary statements + # Define the exposed constant for both types of auxiliary statements if torque_config.auxiliary_statement.exposed_class.present? *ns, name = torque_config.auxiliary_statement.exposed_class.split('::') base = ns.present? ? Object.const_get(ns.join('::')) : Object base.const_set(name, Torque::PostgreSQL::AuxiliaryStatement) + + *ns, name = torque_config.auxiliary_statement.exposed_recursive_class.split('::') + base = ns.present? ? Object.const_get(ns.join('::')) : Object + base.const_set(name, Torque::PostgreSQL::AuxiliaryStatement::Recursive) end end end diff --git a/lib/torque/postgresql/relation/auxiliary_statement.rb b/lib/torque/postgresql/relation/auxiliary_statement.rb index ea2f7ea..509ccf6 100644 --- a/lib/torque/postgresql/relation/auxiliary_statement.rb +++ b/lib/torque/postgresql/relation/auxiliary_statement.rb @@ -10,22 +10,14 @@ def auxiliary_statements_values; get_value(:auxiliary_statements); end # :nodoc: def auxiliary_statements_values=(value); set_value(:auxiliary_statements, value); end - # Set use of an auxiliary statement already configurated on the model - def with(*args) - spawn.with!(*args) + # Set use of an auxiliary statement + def with(*args, **settings) + spawn.with!(*args, **settings) end # Like #with, but modifies relation in place. - def with!(*args) - options = args.extract_options! - args.each do |table| - instance = table.is_a?(Class) && table < PostgreSQL::AuxiliaryStatement \ - ? table.new(options) \ - : PostgreSQL::AuxiliaryStatement.instantiate(table, self, options) - - self.auxiliary_statements_values += [instance] - end - + def with!(*args, **settings) + instantiate_auxiliary_statements(*args, **settings) self end @@ -47,8 +39,23 @@ def bound_attributes # Hook arel build to add the distinct on clause def build_arel(*) arel = super - subqueries = build_auxiliary_statements(arel) - subqueries.nil? ? arel : arel.with(subqueries) + type = auxiliary_statement_type + sub_queries = build_auxiliary_statements(arel) + sub_queries.nil? ? arel : arel.with(*type, *sub_queries) + end + + # Instantiate one or more auxiliary statements for the given +klass+ + def instantiate_auxiliary_statements(*args, **options) + klass = PostgreSQL::AuxiliaryStatement + klass = klass::Recursive if options.delete(:recursive).present? + + self.auxiliary_statements_values += args.map do |table| + if table.is_a?(Class) && table < klass + table.new(**options) + else + klass.instantiate(table, self, **options) + end + end end # Build all necessary data for auxiliary statements @@ -59,6 +66,12 @@ def build_auxiliary_statements(arel) end end + # Return recursive if any auxiliary statement is recursive + def auxiliary_statement_type + klass = PostgreSQL::AuxiliaryStatement::Recursive + :recursive if auxiliary_statements_values.any?(klass) + end + # Throw an error showing that an auxiliary statement of the given # table name isn't defined def auxiliary_statement_error(name) diff --git a/lib/torque/postgresql/version.rb b/lib/torque/postgresql/version.rb index 364dde0..a47f9da 100644 --- a/lib/torque/postgresql/version.rb +++ b/lib/torque/postgresql/version.rb @@ -2,6 +2,6 @@ module Torque module PostgreSQL - VERSION = '2.3.0' + VERSION = '2.4.0' end end diff --git a/spec/models/category.rb b/spec/models/category.rb new file mode 100644 index 0000000..910a009 --- /dev/null +++ b/spec/models/category.rb @@ -0,0 +1,2 @@ +class Category < ActiveRecord::Base +end diff --git a/spec/schema.rb b/spec/schema.rb index d6bcd32..128881c 100644 --- a/spec/schema.rb +++ b/spec/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -version = 1 +version = 2 return if ActiveRecord::Migrator.current_version == version ActiveRecord::Schema.define(version: version) do @@ -69,6 +69,11 @@ t.enum "specialty", enum_type: :specialties end + create_table "categories", force: :cascade do |t| + t.integer "parent_id" + t.string "title" + end + create_table "texts", force: :cascade do |t| t.integer "user_id" t.string "content" @@ -86,6 +91,7 @@ end create_table "courses", force: :cascade do |t| + t.integer "category_id" t.string "title", null: false t.interval "duration" t.enum "types", enum_type: :types, array: true diff --git a/spec/tests/auxiliary_statement_spec.rb b/spec/tests/auxiliary_statement_spec.rb index 674c87b..cccd28a 100644 --- a/spec/tests/auxiliary_statement_spec.rb +++ b/spec/tests/auxiliary_statement_spec.rb @@ -21,7 +21,7 @@ end result = 'WITH "comments" AS' - result << ' (SELECT "comments"."user_id", "comments"."content" AS comment_content FROM "comments")' + result << ' (SELECT "comments"."content" AS comment_content, "comments"."user_id" FROM "comments")' result << ' SELECT "users".*, "comments"."comment_content" FROM "users"' result << ' INNER JOIN "comments" ON "comments"."user_id" = "users"."id"' expect(subject.with(:comments).arel.to_sql).to eql(result) @@ -34,7 +34,7 @@ end result = 'WITH "comments" AS (SELECT DISTINCT ON ( "comments"."user_id" )' - result << ' "comments"."user_id", "comments"."content" AS last_comment' + result << ' "comments"."content" AS last_comment, "comments"."user_id"' result << ' FROM "comments" ORDER BY "comments"."user_id" ASC,' result << ' "comments"."id" DESC) SELECT "users".*,' result << ' "comments"."last_comment" FROM "users" INNER JOIN "comments"' @@ -49,7 +49,7 @@ end result = 'WITH "comments" AS' - result << ' (SELECT "comments"."user_id", "comments"."content" AS comment_content, "comments"."slug" AS comment_slug FROM "comments")' + result << ' (SELECT "comments"."content" AS comment_content, "comments"."slug" AS comment_slug, "comments"."user_id" FROM "comments")' result << ' SELECT "users".*, "comments"."comment_content", "comments"."comment_slug" FROM "users"' result << ' INNER JOIN "comments" ON "comments"."user_id" = "users"."id"' expect(subject.with(:comments, select: {slug: :comment_slug}).arel.to_sql).to eql(result) @@ -62,7 +62,7 @@ end result = 'WITH "comments" AS' - result << ' (SELECT "comments"."user_id", "comments"."active", "comments"."content" AS comment_content FROM "comments")' + result << ' (SELECT "comments"."content" AS comment_content, "comments"."user_id", "comments"."active" FROM "comments")' result << ' SELECT "users".*, "comments"."comment_content" FROM "users"' result << ' INNER JOIN "comments" ON "comments"."user_id" = "users"."id" AND "comments"."active" = "users"."active"' expect(subject.with(:comments, join: {active: :active}).arel.to_sql).to eql(result) @@ -75,7 +75,7 @@ end result = 'WITH "comments" AS' - result << ' (SELECT "comments"."user_id", "comments"."content" AS comment_content' + result << ' (SELECT "comments"."content" AS comment_content, "comments"."user_id"' result << ' FROM "comments" WHERE "comments"."active" = $1)' result << ' SELECT "users".*, "comments"."comment_content" FROM "users"' result << ' INNER JOIN "comments" ON "comments"."user_id" = "users"."id"' @@ -91,7 +91,7 @@ query = subject.where(id: 2).with(:comments) result = 'WITH "comments" AS' - result << ' (SELECT "comments"."user_id", "comments"."content" AS comment_content FROM "comments"' + result << ' (SELECT "comments"."content" AS comment_content, "comments"."user_id" FROM "comments"' result << ' WHERE "comments"."id" = $1)' result << ' SELECT "users".*, "comments"."comment_content" FROM "users"' result << ' INNER JOIN "comments" ON "comments"."user_id" = "users"."id"' @@ -108,7 +108,7 @@ end result = 'WITH "comments" AS' - result << ' (SELECT "comments"."user_id", MAX(id) AS comment_id FROM "comments")' + result << ' (SELECT MAX(id) AS comment_id, "comments"."user_id" FROM "comments")' result << ' SELECT "users".*, "comments"."comment_id" FROM "users"' result << ' INNER JOIN "comments" ON "comments"."user_id" = "users"."id"' expect(subject.with(:comments).arel.to_sql).to eql(result) @@ -121,7 +121,7 @@ end result = 'WITH "comments" AS' - result << ' (SELECT "comments"."user_id", ROW_NUMBER() OVER (PARTITION BY ORDER BY "comments"."id") AS comment_id FROM "comments")' + result << ' (SELECT ROW_NUMBER() OVER (PARTITION BY ORDER BY "comments"."id") AS comment_id, "comments"."user_id" FROM "comments")' result << ' SELECT "users".*, "comments"."comment_id" FROM "users"' result << ' INNER JOIN "comments" ON "comments"."user_id" = "users"."id"' expect(subject.with(:comments).arel.to_sql).to eql(result) @@ -134,7 +134,7 @@ end result = 'WITH "comments" AS' - result << ' (SELECT "comments"."user_id", MIN("comments"."id") AS comment_id FROM "comments")' + result << ' (SELECT MIN("comments"."id") AS comment_id, "comments"."user_id" FROM "comments")' result << ' SELECT "users".*, "comments"."comment_id" FROM "users"' result << ' INNER JOIN "comments" ON "comments"."user_id" = "users"."id"' expect(subject.with(:comments).arel.to_sql).to eql(result) @@ -147,8 +147,8 @@ cte.join name: :id, 'a.col' => :col end - result = 'WITH "comments" AS (SELECT "comments"."id", "comments"."col",' - result << ' "comments"."content" AS comment_content FROM "comments") SELECT "users".*,' + result = 'WITH "comments" AS (SELECT "comments"."content" AS comment_content,' + result << ' "comments"."id", "comments"."col" FROM "comments") SELECT "users".*,' result << ' "comments"."comment_content" FROM "users" INNER JOIN "comments"' result << ' ON "comments"."id" = "users"."name" AND "comments"."col" = "a"."col"' expect(subject.with(:comments).arel.to_sql).to eql(result) @@ -161,8 +161,8 @@ cte.join_type :left end - result = 'WITH "comments" AS (SELECT "comments"."user_id",' - result << ' "comments"."content" AS comment_content FROM "comments") SELECT "users".*,' + result = 'WITH "comments" AS (SELECT "comments"."content" AS comment_content,' + result << ' "comments"."user_id" FROM "comments") SELECT "users".*,' result << ' "comments"."comment_content" FROM "users" LEFT OUTER JOIN "comments"' result << ' ON "comments"."user_id" = "users"."id"' expect(subject.with(:comments).arel.to_sql).to eql(result) @@ -177,7 +177,7 @@ end result = 'WITH "comments" AS' - result << ' (SELECT "comments"."a_user_id", "comments"."content" AS sample_content FROM "comments")' + result << ' (SELECT "comments"."content" AS sample_content, "comments"."a_user_id" FROM "comments")' result << ' SELECT "users".*, "comments"."sample_content" FROM "users"' result << ' INNER JOIN "comments" ON "comments"."a_user_id" = "users"."id"' expect(subject.with(:comments).arel.to_sql).to eql(result) @@ -198,8 +198,8 @@ query = subject.where(id: 3).with(:comments2) result = 'WITH ' - result << '"comments1" AS (SELECT "comments"."user_id", "comments"."content" AS comment_content1 FROM "comments" WHERE "comments"."id" = $1), ' - result << '"comments2" AS (SELECT "comments"."user_id", "comments"."content" AS comment_content2 FROM "comments" WHERE "comments"."id" = $2)' + result << '"comments1" AS (SELECT "comments"."content" AS comment_content1, "comments"."user_id" FROM "comments" WHERE "comments"."id" = $1), ' + result << '"comments2" AS (SELECT "comments"."content" AS comment_content2, "comments"."user_id" FROM "comments" WHERE "comments"."id" = $2)' result << ' SELECT "users".*, "comments1"."comment_content1", "comments2"."comment_content2" FROM "users"' result << ' INNER JOIN "comments1" ON "comments1"."user_id" = "users"."id"' result << ' INNER JOIN "comments2" ON "comments2"."user_id" = "users"."id"' @@ -225,8 +225,8 @@ it 'can requires another statement as dependency' do result = 'WITH ' - result << '"comments1" AS (SELECT "comments"."user_id", "comments"."content" AS comment_content1 FROM "comments"), ' - result << '"comments2" AS (SELECT "comments"."user_id", "comments"."content" AS comment_content2 FROM "comments")' + result << '"comments1" AS (SELECT "comments"."content" AS comment_content1, "comments"."user_id" FROM "comments"), ' + result << '"comments2" AS (SELECT "comments"."content" AS comment_content2, "comments"."user_id" FROM "comments")' result << ' SELECT "users".*, "comments1"."comment_content1", "comments2"."comment_content2" FROM "users"' result << ' INNER JOIN "comments1" ON "comments1"."user_id" = "users"."id"' result << ' INNER JOIN "comments2" ON "comments2"."user_id" = "users"."id"' @@ -235,8 +235,8 @@ it 'can uses already already set dependent' do result = 'WITH ' - result << '"comments1" AS (SELECT "comments"."user_id", "comments"."content" AS comment_content1 FROM "comments"), ' - result << '"comments2" AS (SELECT "comments"."user_id", "comments"."content" AS comment_content2 FROM "comments")' + result << '"comments1" AS (SELECT "comments"."content" AS comment_content1, "comments"."user_id" FROM "comments"), ' + result << '"comments2" AS (SELECT "comments"."content" AS comment_content2, "comments"."user_id" FROM "comments")' result << ' SELECT "users".*, "comments1"."comment_content1", "comments2"."comment_content2" FROM "users"' result << ' INNER JOIN "comments1" ON "comments1"."user_id" = "users"."id"' result << ' INNER JOIN "comments2" ON "comments2"."user_id" = "users"."id"' @@ -289,14 +289,14 @@ expect{ subject.with(:comments).arel.to_sql }.to raise_error(ArgumentError, /join columns/) end - it 'raises an error when not given the table name as first argument' do + it 'not raises an error when not given the table name as first argument' do klass.send(:auxiliary_statement, :comments) do |cte| cte.query 'SELECT * FROM comments' cte.attributes content: :comment cte.join id: :user_id end - expect{ subject.with(:comments).arel.to_sql }.to raise_error(ArgumentError, /table name/) + expect{ subject.with(:comments).arel.to_sql }.not_to raise_error end end @@ -309,7 +309,7 @@ end result = 'WITH "comments" AS' - result << ' (SELECT "comments"."user_id", "comments"."content" AS comment FROM "comments")' + result << ' (SELECT "comments"."content" AS comment, "comments"."user_id" FROM "comments")' result << ' SELECT "users".*, "comments"."comment" FROM "users"' result << ' INNER JOIN "comments" ON "comments"."user_id" = "users"."id"' expect(subject.with(:comments).arel.to_sql).to eql(result) @@ -352,7 +352,7 @@ query = subject.with(:comments, args: {id: 1}) result = 'WITH "comments" AS' - result << ' (SELECT "comments"."user_id", "comments"."content" AS comment' + result << ' (SELECT "comments"."content" AS comment, "comments"."user_id"' result << ' FROM "comments" WHERE "comments"."id" = $1)' result << ' SELECT "users".*, "comments"."comment" FROM "users"' result << ' INNER JOIN "comments" ON "comments"."user_id" = "users"."id"' @@ -370,14 +370,14 @@ expect{ subject.with(:comments).arel.to_sql }.to raise_error(ArgumentError, /join columns/) end - it 'raises an error when not given the table name as first argument' do + it 'not raises an error when not given the table name as first argument' do klass.send(:auxiliary_statement, :comments) do |cte| cte.query -> { Comment.all } cte.attributes content: :comment cte.join id: :user_id end - expect{ subject.with(:comments).arel.to_sql }.to raise_error(ArgumentError, /table name/) + expect{ subject.with(:comments).arel.to_sql }.not_to raise_error end it 'raises an error when the result of the proc is an invalid type' do @@ -403,7 +403,7 @@ end result = 'WITH "authors" AS' - result << ' (SELECT "authors"."id", "authors"."name" AS author_name FROM "authors")' + result << ' (SELECT "authors"."name" AS author_name, "authors"."id" FROM "authors")' result << ' SELECT "activity_books".*, "authors"."author_name" FROM "activity_books"' result << ' INNER JOIN "authors" ON "authors"."id" = "activity_books"."author_id"' expect(subject.with(:authors).arel.to_sql).to eql(result) @@ -423,7 +423,7 @@ end result = 'WITH "authors" AS' - result << ' (SELECT "authors"."id", "authors"."type" AS author_type FROM "authors")' + result << ' (SELECT "authors"."type" AS author_type, "authors"."id" FROM "authors")' result << ' SELECT "activity_books".*, "authors"."author_type" FROM "activity_books"' result << ' INNER JOIN "authors" ON "authors"."id" = "activity_books"."author_id"' expect(subject.with(:authors).arel.to_sql).to eql(result) @@ -434,6 +434,233 @@ end end + context 'recursive' do + let(:klass) { Course } + + it 'correctly build a recursive cte' do + klass.send(:recursive_auxiliary_statement, :all_categories) do |cte| + cte.query Category.all + cte.join id: :parent_id + end + + result = 'WITH RECURSIVE "all_categories" AS (' + result << ' SELECT "categories"."id", "categories"."parent_id"' + result << ' FROM "categories"' + result << ' WHERE "categories"."parent_id" IS NULL' + result << ' UNION' + result << ' SELECT "categories"."id", "categories"."parent_id"' + result << ' FROM "categories", "all_categories"' + result << ' WHERE "categories"."parent_id" = "all_categories"."id"' + result << ' ) SELECT "courses".* FROM "courses" INNER JOIN "all_categories"' + result << ' ON "all_categories"."parent_id" = "courses"."id"' + expect(subject.with(:all_categories).arel.to_sql).to eql(result) + end + + it 'allows connect to be set to something different using a single value' do + klass.send(:recursive_auxiliary_statement, :all_categories) do |cte| + cte.query Category.all + cte.join id: :parent_id + cte.connect :name + end + + result = 'WITH RECURSIVE "all_categories" AS (' + result << ' SELECT "categories"."name", "categories"."parent_id"' + result << ' FROM "categories"' + result << ' WHERE "categories"."parent_name" IS NULL' + result << ' UNION' + result << ' SELECT "categories"."name", "categories"."parent_id"' + result << ' FROM "categories", "all_categories"' + result << ' WHERE "categories"."parent_name" = "all_categories"."name"' + result << ' ) SELECT "courses".* FROM "courses" INNER JOIN "all_categories"' + result << ' ON "all_categories"."parent_id" = "courses"."id"' + expect(subject.with(:all_categories).arel.to_sql).to eql(result) + end + + it 'allows a complete different set of connect' do + klass.send(:recursive_auxiliary_statement, :all_categories) do |cte| + cte.query Category.all + cte.join id: :parent_id + cte.connect left: :right + end + + result = 'WITH RECURSIVE "all_categories" AS (' + result << ' SELECT "categories"."left", "categories"."parent_id"' + result << ' FROM "categories"' + result << ' WHERE "categories"."right" IS NULL' + result << ' UNION' + result << ' SELECT "categories"."left", "categories"."parent_id"' + result << ' FROM "categories", "all_categories"' + result << ' WHERE "categories"."right" = "all_categories"."left"' + result << ' ) SELECT "courses".* FROM "courses" INNER JOIN "all_categories"' + result << ' ON "all_categories"."parent_id" = "courses"."id"' + expect(subject.with(:all_categories).arel.to_sql).to eql(result) + end + + it 'allows using an union all' do + klass.send(:recursive_auxiliary_statement, :all_categories) do |cte| + cte.query Category.all + cte.join id: :parent_id + cte.union_all! + end + + result = 'WITH RECURSIVE "all_categories" AS (' + result << ' SELECT "categories"."id", "categories"."parent_id"' + result << ' FROM "categories"' + result << ' WHERE "categories"."parent_id" IS NULL' + result << ' UNION ALL' + result << ' SELECT "categories"."id", "categories"."parent_id"' + result << ' FROM "categories", "all_categories"' + result << ' WHERE "categories"."parent_id" = "all_categories"."id"' + result << ' ) SELECT "courses".* FROM "courses" INNER JOIN "all_categories"' + result << ' ON "all_categories"."parent_id" = "courses"."id"' + expect(subject.with(:all_categories).arel.to_sql).to eql(result) + end + + it 'allows having a complete different initiator' do + klass.send(:recursive_auxiliary_statement, :all_categories) do |cte| + cte.query Category.where(parent_id: 5) + cte.join id: :parent_id + end + + result = 'WITH RECURSIVE "all_categories" AS (' + result << ' SELECT "categories"."id", "categories"."parent_id"' + result << ' FROM "categories"' + result << ' WHERE "categories"."parent_id" = $1' + result << ' UNION' + result << ' SELECT "categories"."id", "categories"."parent_id"' + result << ' FROM "categories", "all_categories"' + result << ' WHERE "categories"."parent_id" = "all_categories"."id"' + result << ' ) SELECT "courses".* FROM "courses" INNER JOIN "all_categories"' + result << ' ON "all_categories"."parent_id" = "courses"."id"' + expect(subject.with(:all_categories).arel.to_sql).to eql(result) + end + + it 'can process the depth of the query' do + klass.send(:recursive_auxiliary_statement, :all_categories) do |cte| + cte.query Category.all + cte.join id: :parent_id + cte.with_depth + end + + result = 'WITH RECURSIVE "all_categories" AS (' + result << ' SELECT "categories"."id", "categories"."parent_id", 0 AS depth' + result << ' FROM "categories"' + result << ' WHERE "categories"."parent_id" IS NULL' + result << ' UNION' + result << ' SELECT "categories"."id", "categories"."parent_id", ("all_categories"."depth" + 1) AS depth' + result << ' FROM "categories", "all_categories"' + result << ' WHERE "categories"."parent_id" = "all_categories"."id"' + result << ' ) SELECT "courses".* FROM "courses" INNER JOIN "all_categories"' + result << ' ON "all_categories"."parent_id" = "courses"."id"' + expect(subject.with(:all_categories).arel.to_sql).to eql(result) + end + + it 'can process and expose the depth of the query' do + klass.send(:recursive_auxiliary_statement, :all_categories) do |cte| + cte.query Category.all + cte.join id: :parent_id + cte.with_depth 'd', start: 10, as: :category_depth + end + + result = 'WITH RECURSIVE "all_categories" AS (' + result << ' SELECT "categories"."id", "categories"."parent_id", 10 AS d' + result << ' FROM "categories"' + result << ' WHERE "categories"."parent_id" IS NULL' + result << ' UNION' + result << ' SELECT "categories"."id", "categories"."parent_id", ("all_categories"."d" + 1) AS d' + result << ' FROM "categories", "all_categories"' + result << ' WHERE "categories"."parent_id" = "all_categories"."id"' + result << ' ) SELECT "courses".*, "all_categories"."d" AS category_depth FROM "courses" INNER JOIN "all_categories"' + result << ' ON "all_categories"."parent_id" = "courses"."id"' + expect(subject.with(:all_categories).arel.to_sql).to eql(result) + end + + it 'can process the path of the query' do + klass.send(:recursive_auxiliary_statement, :all_categories) do |cte| + cte.query Category.all + cte.join id: :parent_id + cte.with_path + end + + result = 'WITH RECURSIVE "all_categories" AS (' + result << ' SELECT "categories"."id", "categories"."parent_id", ARRAY["categories"."id"]::varchar[] AS path' + result << ' FROM "categories"' + result << ' WHERE "categories"."parent_id" IS NULL' + result << ' UNION' + result << ' SELECT "categories"."id", "categories"."parent_id", array_append("all_categories"."path", "categories"."id"::varchar) AS path' + result << ' FROM "categories", "all_categories"' + result << ' WHERE "categories"."parent_id" = "all_categories"."id"' + result << ' ) SELECT "courses".* FROM "courses" INNER JOIN "all_categories"' + result << ' ON "all_categories"."parent_id" = "courses"."id"' + expect(subject.with(:all_categories).arel.to_sql).to eql(result) + end + + it 'can process and expose the path of the query' do + klass.send(:recursive_auxiliary_statement, :all_categories) do |cte| + cte.query Category.all + cte.join id: :parent_id + cte.with_path 'p', source: :name, as: :category_path + end + + result = 'WITH RECURSIVE "all_categories" AS (' + result << ' SELECT "categories"."id", "categories"."parent_id", ARRAY["categories"."name"]::varchar[] AS p' + result << ' FROM "categories"' + result << ' WHERE "categories"."parent_id" IS NULL' + result << ' UNION' + result << ' SELECT "categories"."id", "categories"."parent_id", array_append("all_categories"."p", "categories"."name"::varchar) AS p' + result << ' FROM "categories", "all_categories"' + result << ' WHERE "categories"."parent_id" = "all_categories"."id"' + result << ' ) SELECT "courses".*, "all_categories"."p" AS category_path FROM "courses" INNER JOIN "all_categories"' + result << ' ON "all_categories"."parent_id" = "courses"."id"' + expect(subject.with(:all_categories).arel.to_sql).to eql(result) + end + + it 'works with string queries' do + klass.send(:recursive_auxiliary_statement, :all_categories) do |cte| + cte.query 'SELECT * FROM categories WHERE a IS NULL' + cte.sub_query 'SELECT * FROM categories, all_categories WHERE all_categories.a = b' + cte.join id: :parent_id + end + + result = 'WITH RECURSIVE "all_categories" AS (' + result << 'SELECT * FROM categories WHERE a IS NULL' + result << ' UNION ' + result << ' SELECT * FROM categories, all_categories WHERE all_categories.a = b' + result << ') SELECT "courses".* FROM "courses" INNER JOIN "all_categories"' + result << ' ON "all_categories"."parent_id" = "courses"."id"' + expect(subject.with(:all_categories).arel.to_sql).to eql(result) + end + + it 'raises an error when query is a string and there is no sub query' do + klass.send(:recursive_auxiliary_statement, :all_categories) do |cte| + cte.query 'SELECT * FROM categories WHERE a IS NULL' + cte.join id: :parent_id + end + + expect{ subject.with(:all_categories).arel.to_sql }.to raise_error(ArgumentError, /generate sub query/) + end + + it 'raises an error when sub query has an invalid type' do + klass.send(:recursive_auxiliary_statement, :all_categories) do |cte| + cte.query 'SELECT * FROM categories WHERE a IS NULL' + cte.sub_query -> { 1 } + cte.join id: :parent_id + end + + expect{ subject.with(:all_categories).arel.to_sql }.to raise_error(ArgumentError, /query and sub query objects/) + end + + it 'raises an error when connect can be resolved automatically' do + allow(klass).to receive(:primary_key).and_return(nil) + klass.send(:recursive_auxiliary_statement, :all_categories) do |cte| + cte.query Category.all + cte.join id: :parent_id + end + + expect{ subject.with(:all_categories).arel.to_sql }.to raise_error(ArgumentError, /setting up a proper way to connect/) + end + end + it 'works with count and does not add extra columns' do klass.send(:auxiliary_statement, :comments) do |cte| cte.query Comment.all @@ -441,7 +668,7 @@ end result = 'WITH "comments" AS' - result << ' (SELECT "comments"."user_id", "comments"."content" AS comment_content FROM "comments")' + result << ' (SELECT "comments"."content" AS comment_content, "comments"."user_id" FROM "comments")' result << ' SELECT COUNT(*) FROM "users"' result << ' INNER JOIN "comments" ON "comments"."user_id" = "users"."id"' @@ -456,7 +683,7 @@ end result = 'WITH "comments" AS' - result << ' (SELECT "comments"."user_id", "comments"."id" AS value FROM "comments")' + result << ' (SELECT "comments"."id" AS value, "comments"."user_id" FROM "comments")' result << ' SELECT SUM("comments"."value") FROM "users"' result << ' INNER JOIN "comments" ON "comments"."user_id" = "users"."id"' @@ -472,7 +699,7 @@ expect{ subject.with(:comments).arel.to_sql }.to raise_error(ArgumentError, /object types/) end - it 'raises an error when traying to use a statement that is not defined' do + it 'raises an error when trying to use a statement that is not defined' do expect{ subject.with(:does_not_exist).arel.to_sql }.to raise_error(ArgumentError) end @@ -495,7 +722,12 @@ expect(subject.protected_methods).to include(:auxiliary_statement) end - it 'allows configurate new auxiliary statements' do + it 'has the recursive configuration' do + expect(subject.protected_methods).to include(:recursive_cte) + expect(subject.protected_methods).to include(:recursive_auxiliary_statement) + end + + it 'allows configure new auxiliary statements' do subject.send(:auxiliary_statement, :cte1) expect(subject.auxiliary_statements_list).to include(:cte1) expect(subject.const_defined?('Cte1_AuxiliaryStatement')).to be_truthy @@ -527,7 +759,7 @@ query = subject.with(sample, select: {content: :comment_content}).arel.to_sql result = 'WITH "comment" AS' - result << ' (SELECT "comments"."user_id", "comments"."content" AS comment_content FROM "comments")' + result << ' (SELECT "comments"."content" AS comment_content, "comments"."user_id" FROM "comments")' result << ' SELECT "users".*, "comment"."comment_content" FROM "users"' result << ' INNER JOIN "comment" ON "comment"."user_id" = "users"."id"' expect(query).to eql(result) @@ -538,7 +770,7 @@ query = subject.with(sample).arel.to_sql result = 'WITH "comment" AS' - result << ' (SELECT "comments"."user_id", "comments"."content" AS comment_content FROM "comments")' + result << ' (SELECT "comments"."content" AS comment_content, "comments"."user_id" FROM "comments")' result << ' SELECT "users".*, "comment"."comment_content" FROM "users"' result << ' INNER JOIN "comment" ON "comment"."user_id" = "users"."id"' expect(query).to eql(result) @@ -551,13 +783,120 @@ end result = 'WITH "all_comments" AS' - result << ' (SELECT "comments"."user_id", "comments"."content" AS comment_content FROM "comments")' + result << ' (SELECT "comments"."content" AS comment_content, "comments"."user_id" FROM "comments")' result << ' SELECT "users".*, "all_comments"."comment_content" FROM "users"' result << ' INNER JOIN "all_comments" ON "all_comments"."user_id" = "users"."id"' query = subject.with(sample).arel.to_sql expect(query).to eql(result) end + + context 'recursive' do + let(:klass) { Torque::PostgreSQL::AuxiliaryStatement::Recursive } + subject { Course } + + it 'has the external method available' do + expect(klass).to respond_to(:create) + end + + it 'accepts simple recursive auxiliary statement definition' do + settings = { join: { id: :parent_id } } + query = subject.with(klass.create(Category.all), **settings).arel.to_sql + + result = 'WITH RECURSIVE "category" AS (' + result << ' SELECT "categories"."id", "categories"."parent_id"' + result << ' FROM "categories"' + result << ' WHERE "categories"."parent_id" IS NULL' + result << ' UNION' + result << ' SELECT "categories"."id", "categories"."parent_id"' + result << ' FROM "categories", "category"' + result << ' WHERE "categories"."parent_id" = "category"."id"' + result << ' ) SELECT "courses".* FROM "courses" INNER JOIN "category"' + result << ' ON "category"."parent_id" = "courses"."id"' + expect(query).to eql(result) + end + + it 'accepts a connect option' do + settings = { join: { id: :parent_id }, connect: { a: :b } } + query = subject.with(klass.create(Category.all), **settings).arel.to_sql + + result = 'WITH RECURSIVE "category" AS (' + result << ' SELECT "categories"."a", "categories"."parent_id"' + result << ' FROM "categories"' + result << ' WHERE "categories"."b" IS NULL' + result << ' UNION' + result << ' SELECT "categories"."a", "categories"."parent_id"' + result << ' FROM "categories", "category"' + result << ' WHERE "categories"."b" = "category"."a"' + result << ' ) SELECT "courses".* FROM "courses" INNER JOIN "category"' + result << ' ON "category"."parent_id" = "courses"."id"' + expect(query).to eql(result) + end + + it 'accepts an union all option' do + settings = { join: { id: :parent_id }, union_all: true } + query = subject.with(klass.create(Category.all), **settings).arel.to_sql + + result = 'WITH RECURSIVE "category" AS (' + result << ' SELECT "categories"."id", "categories"."parent_id"' + result << ' FROM "categories"' + result << ' WHERE "categories"."parent_id" IS NULL' + result << ' UNION ALL' + result << ' SELECT "categories"."id", "categories"."parent_id"' + result << ' FROM "categories", "category"' + result << ' WHERE "categories"."parent_id" = "category"."id"' + result << ' ) SELECT "courses".* FROM "courses" INNER JOIN "category"' + result << ' ON "category"."parent_id" = "courses"."id"' + expect(query).to eql(result) + end + + it 'accepts a sub query option' do + settings = { join: { id: :parent_id }, sub_query: Category.where(active: true) } + query = subject.with(klass.create(Category.all), **settings).arel.to_sql + + result = 'WITH RECURSIVE "category" AS (' + result << ' SELECT "categories"."id", "categories"."parent_id" FROM "categories"' + result << ' UNION' + result << ' SELECT "categories"."id", "categories"."parent_id" FROM "categories", "category" WHERE "categories"."active" = $1' + result << ' ) SELECT "courses".* FROM "courses" INNER JOIN "category"' + result << ' ON "category"."parent_id" = "courses"."id"' + expect(query).to eql(result) + end + + it 'accepts a depth option' do + settings = { join: { id: :parent_id }, with_depth: { name: 'a', start: 5, as: 'b' } } + query = subject.with(klass.create(Category.all), **settings).arel.to_sql + + result = 'WITH RECURSIVE "category" AS (' + result << ' SELECT "categories"."id", "categories"."parent_id", 5 AS a' + result << ' FROM "categories"' + result << ' WHERE "categories"."parent_id" IS NULL' + result << ' UNION' + result << ' SELECT "categories"."id", "categories"."parent_id", ("category"."a" + 1) AS a' + result << ' FROM "categories", "category"' + result << ' WHERE "categories"."parent_id" = "category"."id"' + result << ' ) SELECT "courses".*, "category"."a" AS b FROM "courses" INNER JOIN "category"' + result << ' ON "category"."parent_id" = "courses"."id"' + expect(query).to eql(result) + end + + it 'accepts a path option' do + settings = { join: { id: :parent_id }, with_path: { name: 'a', source: 'b', as: 'c' } } + query = subject.with(klass.create(Category.all), **settings).arel.to_sql + + result = 'WITH RECURSIVE "category" AS (' + result << ' SELECT "categories"."id", "categories"."parent_id", ARRAY["categories"."b"]::varchar[] AS a' + result << ' FROM "categories"' + result << ' WHERE "categories"."parent_id" IS NULL' + result << ' UNION' + result << ' SELECT "categories"."id", "categories"."parent_id", array_append("category"."a", "categories"."b"::varchar) AS a' + result << ' FROM "categories", "category"' + result << ' WHERE "categories"."parent_id" = "category"."id"' + result << ' ) SELECT "courses".*, "category"."a" AS c FROM "courses" INNER JOIN "category"' + result << ' ON "category"."parent_id" = "courses"."id"' + expect(query).to eql(result) + end + end end context 'on settings' do From 8ee00b736c5f21ecf6c70167675b91a95af8df84 Mon Sep 17 00:00:00 2001 From: Carlos Silva Date: Fri, 30 Dec 2022 03:38:59 -0300 Subject: [PATCH 21/29] Add fix for Ruby 2.6 --- lib/torque/postgresql/auxiliary_statement.rb | 4 ++-- lib/torque/postgresql/config.rb | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/torque/postgresql/auxiliary_statement.rb b/lib/torque/postgresql/auxiliary_statement.rb index 49eb268..30167a3 100644 --- a/lib/torque/postgresql/auxiliary_statement.rb +++ b/lib/torque/postgresql/auxiliary_statement.rb @@ -259,13 +259,13 @@ def expose_columns(base, query_table = nil) end # Add select columns to the query and get exposed columns - list.filter_map do |left, right| + list.map do |left, right| base.select_extra_values += [table[right.to_s]] unless right.nil? next unless query_table col = project(left, query_table) right.nil? ? col : col.as(right.to_s) - end + end.compact end # Ensure that all the dependencies are loaded in the base relation diff --git a/lib/torque/postgresql/config.rb b/lib/torque/postgresql/config.rb index 27e4bd1..24a27a3 100644 --- a/lib/torque/postgresql/config.rb +++ b/lib/torque/postgresql/config.rb @@ -69,11 +69,11 @@ def config.irregular_models=(hash) # arguments to format string or send on a proc cte.send_arguments_key = :args - # Estipulate a class name (which may contain namespace) that expose the + # Estipulate a class name (which may contain namespace) that exposes the # auxiliary statement in order to perform detached CTEs cte.exposed_class = 'TorqueCTE' - # Estipulate a class name (which may contain namespace) that expose the + # Estipulate a class name (which may contain namespace) that exposes the # recursive auxiliary statement in order to perform detached CTEs cte.exposed_recursive_class = 'TorqueRecursiveCTE' From 6cb88a77f972de2d8ffa5a50859ba508ab364914 Mon Sep 17 00:00:00 2001 From: Carlos Silva Date: Thu, 5 Jan 2023 15:03:56 -0300 Subject: [PATCH 22/29] Update column definition query --- .../postgresql/adapter/database_statements.rb | 27 +++++++++---------- lib/torque/postgresql/version.rb | 2 +- 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/lib/torque/postgresql/adapter/database_statements.rb b/lib/torque/postgresql/adapter/database_statements.rb index 93f6042..309261f 100644 --- a/lib/torque/postgresql/adapter/database_statements.rb +++ b/lib/torque/postgresql/adapter/database_statements.rb @@ -185,21 +185,20 @@ def user_defined_schemas_sql # Get the list of columns, and their definition, but only from the # actual table, does not include columns that comes from inherited table - def column_definitions(table_name) # :nodoc: - local_condition = 'AND a.attislocal IS TRUE' if @_dump_mode + def column_definitions(table_name) + local = 'AND a.attislocal' if @_dump_mode + query(<<-SQL, 'SCHEMA') - SELECT a.attname, format_type(a.atttypid, a.atttypmod), - pg_get_expr(d.adbin, d.adrelid), a.attnotnull, a.atttypid, a.atttypmod, - (SELECT c.collname FROM pg_collation c, pg_type t - WHERE c.oid = a.attcollation AND t.oid = a.atttypid AND a.attcollation <> t.typcollation), - col_description(a.attrelid, a.attnum) AS comment - FROM pg_attribute a - LEFT JOIN pg_attrdef d ON a.attrelid = d.adrelid AND a.attnum = d.adnum - WHERE a.attrelid = '#{quote_table_name(table_name)}'::regclass - AND a.attnum > 0 - AND a.attisdropped IS FALSE - #{local_condition} - ORDER BY a.attnum + SELECT a.attname, format_type(a.atttypid, a.atttypmod), + pg_get_expr(d.adbin, d.adrelid), a.attnotnull, a.atttypid, a.atttypmod, + c.collname, col_description(a.attrelid, a.attnum) AS comment + FROM pg_attribute a + LEFT JOIN pg_attrdef d ON a.attrelid = d.adrelid AND a.attnum = d.adnum + LEFT JOIN pg_type t ON a.atttypid = t.oid + LEFT JOIN pg_collation c ON a.attcollation = c.oid AND a.attcollation <> t.typcollation + WHERE a.attrelid = #{quote(quote_table_name(table_name))}::regclass + AND a.attnum > 0 AND NOT a.attisdropped #{local} + ORDER BY a.attnum SQL end diff --git a/lib/torque/postgresql/version.rb b/lib/torque/postgresql/version.rb index a47f9da..80e7d92 100644 --- a/lib/torque/postgresql/version.rb +++ b/lib/torque/postgresql/version.rb @@ -2,6 +2,6 @@ module Torque module PostgreSQL - VERSION = '2.4.0' + VERSION = '2.4.1' end end From d2bef2290b93b047dade6e2b4a70fd8ee1b4ac37 Mon Sep 17 00:00:00 2001 From: Carlos Silva Date: Fri, 6 Jan 2023 11:57:02 -0300 Subject: [PATCH 23/29] Fix sequecen name generation to remove schema from the table name --- lib/torque/postgresql/adapter/schema_statements.rb | 5 +++++ spec/tests/schema_spec.rb | 9 +++++++++ 2 files changed, 14 insertions(+) diff --git a/lib/torque/postgresql/adapter/schema_statements.rb b/lib/torque/postgresql/adapter/schema_statements.rb index 80aa905..b0610df 100644 --- a/lib/torque/postgresql/adapter/schema_statements.rb +++ b/lib/torque/postgresql/adapter/schema_statements.rb @@ -127,6 +127,11 @@ def data_source_sql(name = nil, type: nil) private + # Remove the schema from the sequence name + def sequence_name_from_parts(table_name, column_name, suffix) + super(table_name.split('.').last, column_name, suffix) + end + def quote_enum_values(name, values, options) prefix = options[:prefix] prefix = name if prefix === true diff --git a/spec/tests/schema_spec.rb b/spec/tests/schema_spec.rb index bf13171..3bf9b0a 100644 --- a/spec/tests/schema_spec.rb +++ b/spec/tests/schema_spec.rb @@ -73,6 +73,15 @@ expect(dump_result).to match /create_table \"users\",.*schema: +"internal"/ end end + + it 'does not affect serial ids' do + connection.create_table(:primary_keys, id: :serial) do |t| + t.string :title + end + + parts = '"primary_keys", id: :serial, force: :cascade' + expect(dump_result).to match(/create_table #{parts} do /) + end end context 'on relation' do From 936c7702111accb8fa365e2bef42195dc458669b Mon Sep 17 00:00:00 2001 From: Carlos Silva Date: Thu, 19 Jan 2023 17:16:49 -0300 Subject: [PATCH 24/29] Fix #78, add better tests for multiple schemas, and allow change type to receive schema --- .../postgresql/adapter/schema_statements.rb | 16 +++++++-- lib/torque/postgresql/table_name.rb | 4 +-- lib/torque/postgresql/version.rb | 2 +- spec/tests/schema_spec.rb | 33 +++++++++++++++++++ 4 files changed, 50 insertions(+), 5 deletions(-) diff --git a/lib/torque/postgresql/adapter/schema_statements.rb b/lib/torque/postgresql/adapter/schema_statements.rb index b0610df..96ebe5f 100644 --- a/lib/torque/postgresql/adapter/schema_statements.rb +++ b/lib/torque/postgresql/adapter/schema_statements.rb @@ -33,9 +33,9 @@ def drop_type(name, options = {}) end # Renames a type. - def rename_type(type_name, new_name) + def rename_type(type_name, new_name, options = {}) execute <<-SQL.squish - ALTER TYPE #{quote_type_name(type_name)} + ALTER TYPE #{quote_type_name(type_name, options[:schema])} RENAME TO #{Quoting::Name.new(nil, new_name.to_s).quoted} SQL end @@ -102,6 +102,18 @@ def create_table(table_name, **options, &block) super table_name, **options, &block end + # Simply add the schema to the table name when changing a table + def change_table(table_name, **options) + table_name = "#{options[:schema]}.#{table_name}" if options[:schema].present? + super table_name, **options + end + + # Simply add the schema to the table name when dropping a table + def drop_table(table_name, **options) + table_name = "#{options[:schema]}.#{table_name}" if options[:schema].present? + super table_name, **options + end + # Add the schema option when extracting table options def table_options(table_name) parts = table_name.split('.').reverse diff --git a/lib/torque/postgresql/table_name.rb b/lib/torque/postgresql/table_name.rb index 2bb27f3..0a4e4e8 100644 --- a/lib/torque/postgresql/table_name.rb +++ b/lib/torque/postgresql/table_name.rb @@ -12,8 +12,8 @@ def schema return @schema if defined?(@schema) @schema = ([@klass] + @klass.module_parents[0..-2]).find do |klass| - next unless klass.respond_to?(:schema) - break klass.schema + next unless klass.respond_to?(:schema) && !(value = klass.schema).nil? + break value end end diff --git a/lib/torque/postgresql/version.rb b/lib/torque/postgresql/version.rb index 80e7d92..77c9b97 100644 --- a/lib/torque/postgresql/version.rb +++ b/lib/torque/postgresql/version.rb @@ -2,6 +2,6 @@ module Torque module PostgreSQL - VERSION = '2.4.1' + VERSION = '2.4.2' end end diff --git a/spec/tests/schema_spec.rb b/spec/tests/schema_spec.rb index 3bf9b0a..bab6454 100644 --- a/spec/tests/schema_spec.rb +++ b/spec/tests/schema_spec.rb @@ -40,6 +40,26 @@ connection.schemas_whitelist.push('legacy') expect(connection.schema_exists?(:legacy)).to be_truthy end + + context 'reverting' do + let(:migration) { ActiveRecord::Migration::Current.new('Testing') } + + before { connection.create_schema(:legacy) } + + it 'reverts the creation of a schema' do + expect(connection.schema_exists?(:legacy, filtered: false)).to be_truthy + migration.revert { migration.connection.create_schema(:legacy) } + expect(connection.schema_exists?(:legacy, filtered: false)).to be_falsey + end + + it 'reverts the creation of a table' do + connection.create_table(:users, schema: :legacy) { |t| t.string(:name) } + + expect(connection.table_exists?('legacy.users')).to be_truthy + migration.revert { migration.connection.create_table(:users, schema: :legacy) } + expect(connection.table_exists?('legacy.users')).to be_falsey + end + end end context 'on schema' do @@ -86,8 +106,11 @@ context 'on relation' do let(:model) { Internal::User } + let(:table_name) { Torque::PostgreSQL::TableName.new(model, 'users') } it 'adds the schema to the query' do + model.reset_table_name + expect(table_name.to_s).to eq('internal.users') expect(model.all.to_sql).to match(/FROM "internal"."users"/) end @@ -95,7 +118,17 @@ allow(Internal).to receive(:schema).and_return('internal') allow(model).to receive(:schema).and_return(nil) + model.reset_table_name + expect(table_name.to_s).to eq('internal.users') expect(model.all.to_sql).to match(/FROM "internal"."users"/) end + + it 'does not change anything if the model has not configured a schema' do + allow(model).to receive(:schema).and_return(nil) + + model.reset_table_name + expect(table_name.to_s).to eq('users') + expect(model.all.to_sql).to match(/FROM "users"/) + end end end From b0c7e62c1b4e051a6bd7de956f1e61371185e663 Mon Sep 17 00:00:00 2001 From: Carlos Silva Date: Wed, 13 Mar 2024 12:52:05 -0300 Subject: [PATCH 25/29] Fix for belongs_to_many association with custom keys --- .../belongs_to_many_association.rb | 4 +- lib/torque/postgresql/version.rb | 2 +- spec/schema.rb | 18 ++++---- spec/tests/belongs_to_many_spec.rb | 41 +++++++++++++++++++ spec/tests/distinct_on_spec.rb | 2 +- spec/tests/relation_spec.rb | 2 +- 6 files changed, 55 insertions(+), 14 deletions(-) diff --git a/lib/torque/postgresql/associations/belongs_to_many_association.rb b/lib/torque/postgresql/associations/belongs_to_many_association.rb index a9c8975..c2ab11d 100644 --- a/lib/torque/postgresql/associations/belongs_to_many_association.rb +++ b/lib/torque/postgresql/associations/belongs_to_many_association.rb @@ -12,9 +12,9 @@ class BelongsToManyAssociation < ::ActiveRecord::Associations::CollectionAssocia ## CUSTOM def ids_reader if loaded? - target.pluck(reflection.association_primary_key) + target.pluck(reflection.active_record_primary_key) elsif !target.empty? - load_target.pluck(reflection.association_primary_key) + load_target.pluck(reflection.active_record_primary_key) else stale_state || column_default_value end diff --git a/lib/torque/postgresql/version.rb b/lib/torque/postgresql/version.rb index 77c9b97..dbe3fb9 100644 --- a/lib/torque/postgresql/version.rb +++ b/lib/torque/postgresql/version.rb @@ -2,6 +2,6 @@ module Torque module PostgreSQL - VERSION = '2.4.2' + VERSION = '2.4.3' end end diff --git a/spec/schema.rb b/spec/schema.rb index 128881c..d1b390b 100644 --- a/spec/schema.rb +++ b/spec/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -version = 2 +version = 3 return if ActiveRecord::Migrator.current_version == version ActiveRecord::Schema.define(version: version) do @@ -77,14 +77,14 @@ create_table "texts", force: :cascade do |t| t.integer "user_id" t.string "content" - t.enum "conflict", enum_type: :conflicts + t.enum "conflict", enum_type: :conflicts end create_table "comments", force: :cascade do |t| - t.integer "user_id", null: false + t.integer "user_id", null: false t.integer "comment_id" t.integer "video_id" - t.text "content", null: false + t.text "content", null: false t.string "kind" t.index ["user_id"], name: "index_comments_on_user_id", using: :btree t.index ["comment_id"], name: "index_comments_on_comment_id", using: :btree @@ -92,7 +92,7 @@ create_table "courses", force: :cascade do |t| t.integer "category_id" - t.string "title", null: false + t.string "title", null: false t.interval "duration" t.enum "types", enum_type: :types, array: true t.datetime "created_at", null: false @@ -108,7 +108,7 @@ t.integer "activity_id" t.string "title" t.text "content" - t.enum "status", enum_type: :content_status + t.enum "status", enum_type: :content_status t.index ["author_id"], name: "index_posts_on_author_id", using: :btree end @@ -120,8 +120,8 @@ end create_table "users", force: :cascade do |t| - t.string "name", null: false - t.enum "role", enum_type: :roles, default: :visitor + t.string "name", null: false + t.enum "role", enum_type: :roles, default: :visitor t.datetime "created_at", null: false t.datetime "updated_at", null: false end @@ -137,7 +137,7 @@ t.integer "author_id" t.string "title" t.boolean "active" - t.enum "kind", enum_type: :types + t.enum "kind", enum_type: :types t.datetime "created_at", null: false t.datetime "updated_at", null: false end diff --git a/spec/tests/belongs_to_many_spec.rb b/spec/tests/belongs_to_many_spec.rb index d78acdf..8ddecbf 100644 --- a/spec/tests/belongs_to_many_spec.rb +++ b/spec/tests/belongs_to_many_spec.rb @@ -441,4 +441,45 @@ expect { query.load }.not_to raise_error end end + + context 'using custom keys' do + let(:connection) { ActiveRecord::Base.connection } + let(:post) { Post } + let(:tag) { Tag } + let(:tags) { %w[a b c].map { |id| create(:tag, friendly_id: id) } } + + subject { create(:post) } + + before do + connection.add_column(:tags, :friendly_id, :string) + connection.add_column(:posts, :friendly_tag_ids, :string, array: true) + post.belongs_to_many(:tags, foreign_key: :friendly_tag_ids, primary_key: :friendly_id) + post.reset_column_information + tag.reset_column_information + end + + after do + tag.reset_column_information + post.reset_column_information + post._reflections.delete(:tags) + end + + it 'loads associated records' do + subject.update(friendly_tag_ids: tags.pluck(:friendly_id)) + + expect(subject.tags.to_sql).to be_eql(<<-SQL.squish) + SELECT "tags".* FROM "tags" WHERE "tags"."friendly_id" IN ('a', 'b', 'c') + SQL + + expect(subject.tags.load).to be_a(ActiveRecord::Associations::CollectionProxy) + expect(subject.tags.to_a).to be_eql(tags) + end + + it 'can properly assign tags' do + expect(subject.friendly_tag_ids).to be_blank + + subject.tags = tags + expect(subject.friendly_tag_ids).to be_eql(%w[a b c]) + end + end end diff --git a/spec/tests/distinct_on_spec.rb b/spec/tests/distinct_on_spec.rb index b55eed2..7300606 100644 --- a/spec/tests/distinct_on_spec.rb +++ b/spec/tests/distinct_on_spec.rb @@ -40,7 +40,7 @@ end it 'raises with invalid relation' do - expect { subject.distinct_on(tags: :name).to_sql }.to \ + expect { subject.distinct_on(supervisors: :name).to_sql }.to \ raise_error(ArgumentError, /Relation for/) end diff --git a/spec/tests/relation_spec.rb b/spec/tests/relation_spec.rb index 9a99b55..a4c803a 100644 --- a/spec/tests/relation_spec.rb +++ b/spec/tests/relation_spec.rb @@ -44,7 +44,7 @@ def attribute(relation, name) end it 'raises on relation not present' do - check = [tags: :name] + check = [supervisors: :name] expect{ subject.call(check) }.to raise_error(ArgumentError, /Relation for/) end From 9a2166e869579ed622baf76add748d8528c6fdeb Mon Sep 17 00:00:00 2001 From: Carlos Silva Date: Wed, 13 Mar 2024 12:57:15 -0300 Subject: [PATCH 26/29] Fix tests for older factory_bot --- gemfiles/Gemfile.rails-6.1 | 1 + torque_postgresql.gemspec | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/gemfiles/Gemfile.rails-6.1 b/gemfiles/Gemfile.rails-6.1 index 091117b..9bf87d8 100644 --- a/gemfiles/Gemfile.rails-6.1 +++ b/gemfiles/Gemfile.rails-6.1 @@ -3,5 +3,6 @@ source 'https://rubygems.org/' gem 'rails', '~> 6.1.0' gem 'pg', '~> 1.2.3' gem "byebug" +gem 'date', '>= 3.3.4' gemspec path: "../" diff --git a/torque_postgresql.gemspec b/torque_postgresql.gemspec index dc4b589..82d8f18 100644 --- a/torque_postgresql.gemspec +++ b/torque_postgresql.gemspec @@ -39,6 +39,6 @@ Gem::Specification.new do |s| s.add_development_dependency 'dotenv', '~> 2.1', '>= 2.1.1' s.add_development_dependency 'rspec', '~> 3.5', '>= 3.5.0' - s.add_development_dependency 'factory_bot', '~> 6.2', '>= 6.2.1' + s.add_development_dependency 'factory_bot', '~> 6.2', '>= 6.2.1', '< 6.4.6' s.add_development_dependency 'faker', '~> 2.20' end From 0f2ea6926c7f906d2ac6ffaee52f0fb7947e9ffe Mon Sep 17 00:00:00 2001 From: Carlos Silva Date: Wed, 13 Mar 2024 13:02:11 -0300 Subject: [PATCH 27/29] Fix tests for older factory_bot --- torque_postgresql.gemspec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/torque_postgresql.gemspec b/torque_postgresql.gemspec index 82d8f18..17a5895 100644 --- a/torque_postgresql.gemspec +++ b/torque_postgresql.gemspec @@ -39,6 +39,6 @@ Gem::Specification.new do |s| s.add_development_dependency 'dotenv', '~> 2.1', '>= 2.1.1' s.add_development_dependency 'rspec', '~> 3.5', '>= 3.5.0' - s.add_development_dependency 'factory_bot', '~> 6.2', '>= 6.2.1', '< 6.4.6' + s.add_development_dependency 'factory_bot', '~> 6.2', '>= 6.2.1', '< 6.4' s.add_development_dependency 'faker', '~> 2.20' end From f40f0853c7eb54f1bfd335ff1953286d7cb98261 Mon Sep 17 00:00:00 2001 From: Carlos Silva Date: Thu, 21 Mar 2024 23:07:08 -0300 Subject: [PATCH 28/29] Fix problem with setting up reflection with symbol values --- .../postgresql/reflection/belongs_to_many_reflection.rb | 4 ++-- lib/torque/postgresql/version.rb | 2 +- spec/tests/belongs_to_many_spec.rb | 8 ++++++++ 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/lib/torque/postgresql/reflection/belongs_to_many_reflection.rb b/lib/torque/postgresql/reflection/belongs_to_many_reflection.rb index 5fe59b1..ad65e69 100644 --- a/lib/torque/postgresql/reflection/belongs_to_many_reflection.rb +++ b/lib/torque/postgresql/reflection/belongs_to_many_reflection.rb @@ -25,7 +25,7 @@ def association_class end def foreign_key - @foreign_key ||= options[:foreign_key] || derive_foreign_key.freeze + @foreign_key ||= options[:foreign_key]&.to_s || derive_foreign_key.freeze end def association_foreign_key @@ -33,7 +33,7 @@ def association_foreign_key end def active_record_primary_key - @active_record_primary_key ||= options[:primary_key] || derive_primary_key + @active_record_primary_key ||= options[:primary_key]&.to_s || derive_primary_key end def join_primary_key(*) diff --git a/lib/torque/postgresql/version.rb b/lib/torque/postgresql/version.rb index dbe3fb9..309f276 100644 --- a/lib/torque/postgresql/version.rb +++ b/lib/torque/postgresql/version.rb @@ -2,6 +2,6 @@ module Torque module PostgreSQL - VERSION = '2.4.3' + VERSION = '2.4.4' end end diff --git a/spec/tests/belongs_to_many_spec.rb b/spec/tests/belongs_to_many_spec.rb index 8ddecbf..da02ca1 100644 --- a/spec/tests/belongs_to_many_spec.rb +++ b/spec/tests/belongs_to_many_spec.rb @@ -20,6 +20,14 @@ model.belongs_to_many(:tests) end + + it 'allows setting up foreign key and primary_key as symbol' do + model.belongs_to_many(:tests, foreign_key: :test_ids, primary_key: :test_id) + + reflection = model._reflections['tests'] + expect(reflection.foreign_key).to be_eql('test_ids') + expect(reflection.active_record_primary_key).to be_eql('test_id') + end end context 'on association' do From ce8058743b8252b1df2258121b06e69a0b4f29e4 Mon Sep 17 00:00:00 2001 From: Carlos Silva Date: Mon, 14 Oct 2024 12:54:13 -0300 Subject: [PATCH 29/29] Fix #96 (Array Quoting) by using correct arel accessor (v2) (#98) --- lib/torque/postgresql/arel/visitors.rb | 2 +- lib/torque/postgresql/version.rb | 2 +- spec/schema.rb | 3 ++- spec/tests/arel_spec.rb | 6 ++++++ 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/lib/torque/postgresql/arel/visitors.rb b/lib/torque/postgresql/arel/visitors.rb index fdf4e10..b591c3c 100644 --- a/lib/torque/postgresql/arel/visitors.rb +++ b/lib/torque/postgresql/arel/visitors.rb @@ -25,7 +25,7 @@ def visit_Arel_Nodes_Quoted(o, collector) # Allow quoted arrays to get here def visit_Arel_Nodes_Casted(o, collector) - value = o.respond_to?(:val) ? o.val : o.value + value = PostgreSQL::AR610 ? o.value_for_database : o.val return super unless value.is_a?(::Enumerable) quote_array(value, collector) end diff --git a/lib/torque/postgresql/version.rb b/lib/torque/postgresql/version.rb index 309f276..e732a03 100644 --- a/lib/torque/postgresql/version.rb +++ b/lib/torque/postgresql/version.rb @@ -2,6 +2,6 @@ module Torque module PostgreSQL - VERSION = '2.4.4' + VERSION = '2.4.5' end end diff --git a/spec/schema.rb b/spec/schema.rb index d1b390b..e8f8d19 100644 --- a/spec/schema.rb +++ b/spec/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -version = 3 +version = 4 return if ActiveRecord::Migrator.current_version == version ActiveRecord::Schema.define(version: version) do @@ -59,6 +59,7 @@ t.string "url" t.enum "type", enum_type: :types t.enum "conflicts", enum_type: :conflicts, array: true + t.jsonb "metadata" t.datetime "created_at", null: false t.datetime "updated_at", null: false end diff --git a/spec/tests/arel_spec.rb b/spec/tests/arel_spec.rb index 13d7840..fb45dc8 100644 --- a/spec/tests/arel_spec.rb +++ b/spec/tests/arel_spec.rb @@ -64,6 +64,12 @@ it 'does not break jsonb' do expect { connection.add_column(:authors, :profile, :jsonb, default: []) }.not_to raise_error expect(Author.columns_hash['profile'].default).to eq('[]') + + condition = Author.arel_table['profile'].is_distinct_from([]) + result = Torque::PostgreSQL::AR610 ? "'[]'" : "ARRAY[]" + expect(Author.where(condition).to_sql).to eq(<<~SQL.squish) + SELECT "authors".* FROM "authors" WHERE "authors"."profile" IS DISTINCT FROM #{result} + SQL end it 'works properly when column is an array' do