torque-postgresql 4.0.0.rc1 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/lib/generators/torque/function_generator.rb +13 -0
  3. data/lib/generators/torque/templates/function.sql.erb +4 -0
  4. data/lib/generators/torque/templates/type.sql.erb +2 -0
  5. data/lib/generators/torque/templates/view.sql.erb +3 -0
  6. data/lib/generators/torque/type_generator.rb +13 -0
  7. data/lib/generators/torque/view_generator.rb +16 -0
  8. data/lib/torque/postgresql/adapter/database_statements.rb +48 -10
  9. data/lib/torque/postgresql/adapter/schema_definitions.rb +22 -0
  10. data/lib/torque/postgresql/adapter/schema_dumper.rb +47 -1
  11. data/lib/torque/postgresql/adapter/schema_statements.rb +45 -0
  12. data/lib/torque/postgresql/arel/nodes.rb +14 -0
  13. data/lib/torque/postgresql/arel/visitors.rb +4 -0
  14. data/lib/torque/postgresql/attributes/builder/full_text_search.rb +16 -28
  15. data/lib/torque/postgresql/base.rb +2 -1
  16. data/lib/torque/postgresql/config.rb +35 -1
  17. data/lib/torque/postgresql/function.rb +33 -0
  18. data/lib/torque/postgresql/railtie.rb +26 -1
  19. data/lib/torque/postgresql/relation/auxiliary_statement.rb +7 -2
  20. data/lib/torque/postgresql/relation/buckets.rb +124 -0
  21. data/lib/torque/postgresql/relation/distinct_on.rb +7 -2
  22. data/lib/torque/postgresql/relation/inheritance.rb +18 -8
  23. data/lib/torque/postgresql/relation/join_series.rb +112 -0
  24. data/lib/torque/postgresql/relation/merger.rb +17 -3
  25. data/lib/torque/postgresql/relation.rb +18 -28
  26. data/lib/torque/postgresql/version.rb +1 -1
  27. data/lib/torque/postgresql/versioned_commands/command_migration.rb +146 -0
  28. data/lib/torque/postgresql/versioned_commands/generator.rb +57 -0
  29. data/lib/torque/postgresql/versioned_commands/migration_context.rb +83 -0
  30. data/lib/torque/postgresql/versioned_commands/migrator.rb +39 -0
  31. data/lib/torque/postgresql/versioned_commands/schema_table.rb +101 -0
  32. data/lib/torque/postgresql/versioned_commands.rb +161 -0
  33. data/spec/fixtures/migrations/20250101000001_create_users.rb +0 -0
  34. data/spec/fixtures/migrations/20250101000002_create_function_count_users_v1.sql +0 -0
  35. data/spec/fixtures/migrations/20250101000003_create_internal_users.rb +0 -0
  36. data/spec/fixtures/migrations/20250101000004_update_function_count_users_v2.sql +0 -0
  37. data/spec/fixtures/migrations/20250101000005_create_view_all_users_v1.sql +0 -0
  38. data/spec/fixtures/migrations/20250101000006_create_type_user_id_v1.sql +0 -0
  39. data/spec/fixtures/migrations/20250101000007_remove_function_count_users_v2.sql +0 -0
  40. data/spec/initialize.rb +9 -0
  41. data/spec/schema.rb +2 -4
  42. data/spec/spec_helper.rb +6 -1
  43. data/spec/tests/full_text_seach_test.rb +30 -2
  44. data/spec/tests/relation_spec.rb +229 -0
  45. data/spec/tests/schema_spec.rb +4 -1
  46. data/spec/tests/versioned_commands_spec.rb +513 -0
  47. metadata +33 -3
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Torque
4
+ module PostgreSQL
5
+ class IllegalCommandTypeError < ActiveRecord::MigrationError
6
+ def initialize(file)
7
+ super(<<~MSG.squish)
8
+ Illegal name for command file '#{file}'. Commands are more strict and require
9
+ the version, one of create, update, or remove, type of object, name
10
+ and operation version to be present in the filename.
11
+ (e.g. 20250101010101_create_function_my_function_v1.sql)
12
+ MSG
13
+ end
14
+ end
15
+
16
+ module VersionedCommands
17
+ module MigrationContext
18
+ InvalidMigrationTimestampError = ActiveRecord::InvalidMigrationTimestampError
19
+ PGAdapter = ActiveRecord::ConnectionAdapters::PostgreSQLAdapter
20
+
21
+ def migrations
22
+ return super unless running_for_pg?
23
+
24
+ commands = command_files.map do |file|
25
+ version, op, type, name, op_version, scope = parse_command_filename(file)
26
+ raise IllegalCommandTypeError.new(file) unless version
27
+ if validate_timestamp? && !valid_migration_timestamp?(version)
28
+ raise InvalidMigrationTimestampError.new(version, [op, type, name, op_version].join('_'))
29
+ end
30
+
31
+ version = version.to_i
32
+ CommandMigration.new(file, version, op, type, name, op_version.to_i, scope)
33
+ end
34
+
35
+ super.concat(commands).sort_by(&:version)
36
+ end
37
+
38
+ def migrations_status
39
+ return super unless running_for_pg?
40
+ db_list = schema_migration.normalized_versions
41
+
42
+ commands = command_files.map do |file|
43
+ version, op, type, name, op_version, scope = parse_command_filename(file)
44
+ raise IllegalCommandTypeError.new(file) unless version
45
+ if validate_timestamp? && !valid_migration_timestamp?(version)
46
+ raise InvalidMigrationTimestampError.new(version, [op, type, name, op_version].join('_'))
47
+ end
48
+
49
+ version = schema_migration.normalize_migration_number(version)
50
+ status = db_list.delete(version) ? "up" : "down"
51
+ [status, version, "#{op.capitalize} #{type.capitalize} #{name}#{scope} (v#{op_version})"]
52
+ end
53
+
54
+ (commands + super).uniq(&:second).sort_by(&:second)
55
+ end
56
+
57
+ def migration_commands
58
+ migrations.select { |m| m.is_a?(VersionedCommands::CommandMigration) }
59
+ end
60
+
61
+ private
62
+
63
+ # Checks if the current migration context is running for PostgreSQL
64
+ def running_for_pg?
65
+ connection_pool.db_config.adapter_class <= PGAdapter
66
+ end
67
+
68
+ # Get the list of all versioned command files
69
+ def command_files
70
+ paths = Array(migrations_paths)
71
+ Dir[*paths.flat_map { |path| "#{path}/**/[0-9]*_*.sql" }]
72
+ end
73
+
74
+ # Commands are more strict with the filename format
75
+ def parse_command_filename(filename)
76
+ File.basename(filename).scan(VersionedCommands.filename_regexp).first
77
+ end
78
+ end
79
+
80
+ ActiveRecord::MigrationContext.prepend(MigrationContext)
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Torque
4
+ module PostgreSQL
5
+ module VersionedCommands
6
+ module Migrator
7
+ def execute_migration_in_transaction(migration)
8
+ @versioned_command = versioned_command?(migration) && migration
9
+ super
10
+ ensure
11
+ @versioned_command = false
12
+ end
13
+
14
+ def record_version_state_after_migrating(version)
15
+ return super if (command = @versioned_command) == false
16
+
17
+ @versioned_table ||= VersionedCommands::SchemaTable.new(connection.pool)
18
+ @versioned_counter ||= @versioned_table.count
19
+
20
+ if down?
21
+ @versioned_counter -= 1
22
+ @versioned_table.delete_version(command)
23
+ @versioned_table.drop_table if @versioned_counter.zero?
24
+ else
25
+ @versioned_table.create_table if @versioned_counter.zero?
26
+ @versioned_table.create_version(command)
27
+ @versioned_counter += 1
28
+ end
29
+ end
30
+
31
+ def versioned_command?(migration)
32
+ migration.is_a?(VersionedCommands::CommandMigration)
33
+ end
34
+ end
35
+
36
+ ActiveRecord::Migrator.prepend(Migrator)
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Torque
4
+ module PostgreSQL
5
+ module VersionedCommands
6
+ class SchemaTable
7
+ attr_reader :arel_table
8
+
9
+ def initialize(pool)
10
+ @pool = pool
11
+ @arel_table = ::Arel::Table.new(table_name)
12
+ end
13
+
14
+ def create_version(command)
15
+ im = ::Arel::InsertManager.new(arel_table)
16
+ im.insert(
17
+ arel_table[primary_key] => command.version,
18
+ arel_table['type'] => command.type,
19
+ arel_table['object_name'] => command.object_name,
20
+ )
21
+
22
+ @pool.with_connection do |connection|
23
+ connection.insert(im, "#{name} Create", primary_key, command.version)
24
+ end
25
+ end
26
+
27
+ def delete_version(command)
28
+ dm = ::Arel::DeleteManager.new(arel_table)
29
+ dm.wheres = [arel_table[primary_key].eq(command.version.to_s)]
30
+
31
+ @pool.with_connection do |connection|
32
+ connection.delete(dm, "#{name} Destroy")
33
+ end
34
+ end
35
+
36
+ def primary_key
37
+ 'version'
38
+ end
39
+
40
+ def name
41
+ 'Torque::PostgreSQL::VersionedCommand'
42
+ end
43
+
44
+ def table_name
45
+ [
46
+ ActiveRecord::Base.table_name_prefix,
47
+ PostgreSQL.config.versioned_commands.table_name,
48
+ ActiveRecord::Base.table_name_suffix,
49
+ ].join
50
+ end
51
+
52
+ def create_table
53
+ @pool.with_connection do |connection|
54
+ return if connection.table_exists?(table_name)
55
+
56
+ parent = @pool.schema_migration.table_name
57
+ connection.create_table(table_name, inherits: parent) do |t|
58
+ t.string :type, null: false, index: true
59
+ t.string :object_name, null: false, index: true
60
+ end
61
+ end
62
+ end
63
+
64
+ def drop_table
65
+ @pool.with_connection do |connection|
66
+ connection.drop_table table_name, if_exists: true
67
+ end
68
+ end
69
+
70
+ def count
71
+ return 0 unless table_exists?
72
+
73
+ sm = ::Arel::SelectManager.new(arel_table)
74
+ sm.project(*FN.count(::Arel.star))
75
+
76
+ @pool.with_connection do |connection|
77
+ connection.select_value(sm, "#{self.class} Count")
78
+ end
79
+ end
80
+
81
+ def table_exists?
82
+ @pool.with_connection { |connection| connection.data_source_exists?(table_name) }
83
+ end
84
+
85
+ def versions_of(type)
86
+ return [] unless table_exists?
87
+
88
+ sm = ::Arel::SelectManager.new(arel_table)
89
+ sm.project(arel_table['object_name'], FN.count(::Arel.star).as('version'))
90
+ sm.where(arel_table['type'].eq(type.to_s))
91
+ sm.group(arel_table['object_name'])
92
+ sm.order(arel_table['object_name'].asc)
93
+
94
+ @pool.with_connection do |connection|
95
+ connection.select_rows(sm, "#{name} Load")
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'versioned_commands/command_migration'
4
+ require_relative 'versioned_commands/migration_context'
5
+ require_relative 'versioned_commands/migrator'
6
+ require_relative 'versioned_commands/schema_table'
7
+
8
+ module Torque
9
+ module PostgreSQL
10
+ # Takes advantage of Rails migrations to create other sorts of
11
+ # objects/commands that can also be versioned. Everything migrated will
12
+ # still live within Migrations borders (i.e., the schema_migrations), but
13
+ # the way they are handled and registered in the schema dumper is completely
14
+ # different
15
+ module VersionedCommands
16
+ RAILS_APP = defined?(Rails.application.paths)
17
+ NAME_MATCH = '"?((?:[_a-z0-9]+"?\."?)?[_a-z0-9]+)"?'
18
+
19
+ class << self
20
+ # Check if the type is current enabled
21
+ def valid_type?(type)
22
+ PostgreSQL.config.versioned_commands.types.include?(type.to_sym)
23
+ end
24
+
25
+ # Run the internal validations for the given type and content
26
+ def validate!(type, content, name)
27
+ method_name = :"validate_#{type}!"
28
+ return send(method_name, content, name) if valid_type?(type)
29
+ raise ArgumentError, "Unknown versioned command type: #{type}"
30
+ end
31
+
32
+ # Get the content of the command based on the type, name, and version
33
+ def fetch_command(dirs, type, name, version)
34
+ paths = Array.wrap(dirs).map { |d| "#{d}/**/*_#{type}_#{name}_v#{version}.sql" }
35
+ files = Dir[*paths]
36
+ return File.read(files.first) if files.one?
37
+
38
+ raise ArgumentError, <<~MSG.squish if files.none?
39
+ No previous version found for #{type} #{name}
40
+ of version v#{version}.
41
+ MSG
42
+
43
+ raise ArgumentError, <<~MSG.squish if files.many?
44
+ Multiple files found for #{type} #{name}
45
+ of version v#{version}.
46
+ MSG
47
+ end
48
+
49
+ # The regexp is dynamic due to the list of available types
50
+ def filename_regexp
51
+ @filename_regexp ||= begin
52
+ types = PostgreSQL.config.versioned_commands.types
53
+ Regexp.new([
54
+ "\\A([0-9]+)_",
55
+ "(create|update|remove)_",
56
+ "(#{types.join('|')})_",
57
+ "([_a-z0-9]*)",
58
+ "_v([0-9]+)",
59
+ "\\.?([_a-z0-9]*)?",
60
+ "\\.sql\\z",
61
+ ].join)
62
+ end
63
+ end
64
+
65
+ private
66
+
67
+ # Validate that the content of the command is correct
68
+ def validate_function!(content, name)
69
+ result = content.scan(Regexp.new([
70
+ '^\s*CREATE\s+(OR\s+REPLACE)?\s*',
71
+ "FUNCTION\\s+#{NAME_MATCH}",
72
+ ].join, 'mi'))
73
+
74
+ names = result.map(&:last).compact.uniq(&:downcase)
75
+ raise ArgumentError, <<~MSG.squish if names.size > 1
76
+ Multiple functions definition found.
77
+ MSG
78
+
79
+ raise ArgumentError, <<~MSG.squish unless result.all?(&:first)
80
+ 'OR REPLACE' is required for proper migration support.
81
+ MSG
82
+
83
+ fn_name = names.first.downcase.sub('.', '_')
84
+ raise ArgumentError, <<~MSG.squish if fn_name != name.downcase
85
+ Function name must match file name.
86
+ MSG
87
+ end
88
+
89
+ # Validate that the content of the command is correct
90
+ def validate_type!(content, name)
91
+ creates = content.scan(Regexp.new(['^\s*CREATE\s+TYPE\s+', NAME_MATCH].join, 'mi'))
92
+ drops = content.scan(Regexp.new([
93
+ '^\s*DROP\s+TYPE\s+(IF\s+EXISTS)?\s*',
94
+ NAME_MATCH,
95
+ ].join, 'mi'))
96
+
97
+ raise ArgumentError, <<~MSG.squish if creates.size > 1
98
+ More than one type definition found.
99
+ MSG
100
+
101
+ raise ArgumentError, <<~MSG.squish if drops.size > 1
102
+ More than one type drop found.
103
+ MSG
104
+
105
+ raise ArgumentError, <<~MSG.squish if drops.empty?
106
+ 'DROP TYPE' is required for proper migration support.
107
+ MSG
108
+
109
+ create_name = creates.first.last.downcase
110
+ raise ArgumentError, <<~MSG.squish if drops.first.last.downcase != create_name
111
+ Drop does not match create.
112
+ MSG
113
+
114
+ create_name = create_name.sub('.', '_')
115
+ raise ArgumentError, <<~MSG.squish if create_name != name.downcase
116
+ Type name must match file name.
117
+ MSG
118
+ end
119
+
120
+ # Validate that the content of the command is correct
121
+ def validate_view!(content, name)
122
+ result = content.scan(Regexp.new([
123
+ '^\s*CREATE\s+(OR\s+REPLACE)?\s*',
124
+ '((?:TEMP|TEMPORARY|MATERIALIZED)\s+)?',
125
+ '(?:RECURSIVE\s+)?',
126
+ "VIEW\\s+#{NAME_MATCH}",
127
+ ].join, 'mi'))
128
+
129
+ raise ArgumentError, <<~MSG.squish if result.empty?
130
+ Missing or invalid view definition.
131
+ MSG
132
+
133
+ raise ArgumentError, <<~MSG.squish if result.size > 1
134
+ More than one view definition found.
135
+ MSG
136
+
137
+ with_replace, opt, view_name = result.first
138
+ if opt&.strip == 'MATERIALIZED'
139
+ raise ArgumentError, <<~MSG.squish if with_replace.present?
140
+ Materialized view does not support 'OR REPLACE'.
141
+ MSG
142
+
143
+ with_drop = "DROP MATERIALIZED VIEW IF EXISTS #{view_name};"
144
+ raise ArgumentError, <<~MSG.squish unless content.include?(with_drop)
145
+ 'DROP MATERIALIZED VIEW IF EXISTS' is required for proper migration support.
146
+ MSG
147
+ else
148
+ raise ArgumentError, <<~MSG.squish if with_replace.blank?
149
+ 'OR REPLACE' is required for proper migration support.
150
+ MSG
151
+ end
152
+
153
+ view_name = view_name.downcase.sub('.', '_')
154
+ raise ArgumentError, <<~MSG.squish if view_name != name.downcase
155
+ View name must match file name.
156
+ MSG
157
+ end
158
+ end
159
+ end
160
+ end
161
+ end
data/spec/initialize.rb CHANGED
@@ -16,6 +16,10 @@ require_relative '../lib/torque/postgresql/attributes/period'
16
16
  require_relative '../lib/torque/postgresql/attributes/full_text_search'
17
17
 
18
18
  require_relative '../lib/torque/postgresql/relation/auxiliary_statement'
19
+ require_relative '../lib/torque/postgresql/relation/join_series'
20
+ require_relative '../lib/torque/postgresql/relation/buckets'
21
+
22
+ require_relative '../lib/torque/postgresql/versioned_commands'
19
23
 
20
24
  module Torque
21
25
  module PostgreSQL
@@ -27,6 +31,11 @@ module Torque
27
31
  Attributes::FullTextSearch.include_on(ActiveRecord::Base)
28
32
 
29
33
  Relation.include(Relation::AuxiliaryStatement)
34
+ Relation.include(Relation::JoinSeries)
35
+ Relation.include(Relation::Buckets)
36
+
37
+ config.versioned_commands.enabled = true
38
+ ActiveRecord::Schema::Definition.include(Adapter::Definition)
30
39
 
31
40
  ::Object.const_set('TorqueCTE', AuxiliaryStatement)
32
41
  ::Object.const_set('TorqueRecursiveCTE', AuxiliaryStatement::Recursive)
data/spec/schema.rb CHANGED
@@ -10,7 +10,7 @@
10
10
  #
11
11
  # It's strongly recommended that you check this file into your version control system.
12
12
 
13
- version = 5
13
+ version = 6
14
14
 
15
15
  return if ActiveRecord::Migrator.current_version == version
16
16
  ActiveRecord::Schema.define(version: version) do
@@ -127,6 +127,7 @@ ActiveRecord::Schema.define(version: version) do
127
127
  create_table "users", force: :cascade do |t|
128
128
  t.string "name", null: false
129
129
  t.enum "role", enum_type: :roles, default: :visitor
130
+ t.integer "age"
130
131
  t.datetime "created_at", null: false
131
132
  t.datetime "updated_at", null: false
132
133
  end
@@ -176,9 +177,6 @@ ActiveRecord::Schema.define(version: version) do
176
177
  # create_table "activity_images", force: :cascade, inherits: [:activities, :images]
177
178
 
178
179
  add_foreign_key "posts", "authors"
179
- rescue Exception => e
180
- byebug
181
- raise
182
180
  end
183
181
 
184
182
  ActiveRecord::Base.connection.schema_cache.clear!
data/spec/spec_helper.rb CHANGED
@@ -4,7 +4,12 @@ require 'factory_bot'
4
4
  require 'dotenv'
5
5
  require 'faker'
6
6
  require 'rspec'
7
- require 'byebug'
7
+
8
+ begin
9
+ require 'debug/prelude'
10
+ rescue LoadError
11
+ # No debugger available, skip
12
+ end
8
13
 
9
14
  Dotenv.load
10
15
 
@@ -215,14 +215,30 @@ RSpec.describe 'FullTextSearch' do
215
215
  expect(result.to_sql).to eql(parts)
216
216
  end
217
217
 
218
- it 'can use regular query mode' do
219
- result = Course.full_text_search('test', phrase: false)
218
+ it 'can use default query mode' do
219
+ result = Course.full_text_search('test', mode: :default)
220
220
  parts = 'SELECT "courses".* FROM "courses"'
221
221
  parts << ' WHERE "courses"."search_vector" @@'
222
222
  parts << " TO_TSQUERY('english', 'test')"
223
223
  expect(result.to_sql).to eql(parts)
224
224
  end
225
225
 
226
+ it 'can use plain query mode' do
227
+ result = Course.full_text_search('test', mode: :plain)
228
+ parts = 'SELECT "courses".* FROM "courses"'
229
+ parts << ' WHERE "courses"."search_vector" @@'
230
+ parts << " PLAINTO_TSQUERY('english', 'test')"
231
+ expect(result.to_sql).to eql(parts)
232
+ end
233
+
234
+ it 'can use web query mode' do
235
+ result = Course.full_text_search('test', mode: :web)
236
+ parts = 'SELECT "courses".* FROM "courses"'
237
+ parts << ' WHERE "courses"."search_vector" @@'
238
+ parts << " WEBSEARCH_TO_TSQUERY('english', 'test')"
239
+ expect(result.to_sql).to eql(parts)
240
+ end
241
+
226
242
  it 'can use a attribute as the language' do
227
243
  result = Course.full_text_search('test', language: :lang)
228
244
  parts = 'SELECT "courses".* FROM "courses"'
@@ -248,5 +264,17 @@ RSpec.describe 'FullTextSearch' do
248
264
  expect(binds.first.value).to eq('english')
249
265
  expect(binds.second.value).to eq('test')
250
266
  end
267
+
268
+ it 'raises an error when the language is not found' do
269
+ expect do
270
+ Course.full_text_search('test', language: '')
271
+ end.to raise_error(ArgumentError, /Unable to determine language/)
272
+ end
273
+
274
+ it 'raises an error when the mode is invalid' do
275
+ expect do
276
+ Course.full_text_search('test', mode: :invalid)
277
+ end.to raise_error(ArgumentError, /Invalid mode :invalid for full text search/)
278
+ end
251
279
  end
252
280
  end