activerecord-full_text_search 0.1.0 → 0.3.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a0e395c41810e9e42ae30e3f2aca5527fe80666fd52dcfdd9913b3501b6e97f0
4
- data.tar.gz: dab379e41ed0fe39a5f90e3dc42b99337d5dbf441e6e8b1b10eae93ab6f202c9
3
+ metadata.gz: 5bccc63fac75881da6efbc80e1281588f0091d6e227d11fd8335623366f720dd
4
+ data.tar.gz: 36440ad1275656da894a59ad1afb8f908e4fafb2070232f302161a2994a756b8
5
5
  SHA512:
6
- metadata.gz: 51fc2b15b2016a7030c4fbd63c0cf0065453c1cff0d4a50007d8870e556cf8e4098008f7077a76b85c41124d865cda4e4fda3d9f0acdc38d8dbb4b7c951f32ba
7
- data.tar.gz: e97e619209f362b9412ad7df74b0511f3f99b557dd1085cd8b7508ae4ee369b74e2e8f5a7ea2c4c8191ab3253ecd4432ee8c25b0c2f5d14d295c344b206198bf
6
+ metadata.gz: 6d75a6e9c06fd2e609ebf27b4a041fff217b1f3207cbabab342da4011a06c7293bc84888483b063c9618b86fd6eb86af6ad2e91f760807a15a10450246657758
7
+ data.tar.gz: 7ab9fbaef688c03932c72661e60f74e9b4593bf5388f0b5a8727386a4408e16fcaf750f1603b609a67dce2f8b54314bad81f13b634de020e2cd6fe504ba5afec
data/CHANGELOG.md CHANGED
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.0] - 2026-06-02
11
+
12
+ - Add Rails 8.0 & 8.1 as known versions
13
+ - Sort functions and triggers
14
+
15
+ ## [0.2.0] - 2024-12-03
16
+
17
+ - Add basic support for TRIGGERS
18
+
10
19
  ## [0.1.0] - 2024-11-28
11
20
 
12
21
  ### Added
data/README.md CHANGED
@@ -10,6 +10,8 @@ The gem permits to use these commands:
10
10
 
11
11
  - `create_function`
12
12
  - `drop_function`
13
+ - `create_trigger`
14
+ - `drop_trigger`
13
15
  - `create_text_search_template`
14
16
  - `rename_text_search_template`
15
17
  - `drop_text_search_template`
@@ -31,6 +33,10 @@ The gem permits to use these commands:
31
33
 
32
34
  - Add tests
33
35
  - Enhance (and extract?) functions support
36
+ - Enhance (and extract?) triggers support
37
+ - Support triggers with parameterized functions
38
+ - Support `UPDATE OF columns` in triggers
39
+ - Support `TRUNCATE` in triggers
34
40
  - Check recorder
35
41
  - Manage schema (`public` is hardcoded)
36
42
 
@@ -15,15 +15,44 @@ module ActiveRecord
15
15
  def functions
16
16
  # List of functions in the current schema with their argument types, return type, language, immutability, and body.
17
17
  # List only functions that don't depend on extensions.
18
+ # Functions are ordered by dependency depth (via pg_depend) so that dependencies come first.
18
19
  res = exec_query(<<-SQL.strip_heredoc, "SCHEMA")
20
+ WITH RECURSIVE
21
+ func_deps AS (
22
+ SELECT p.oid AS func_oid, dep_p.oid AS dep_func_oid
23
+ FROM pg_catalog.pg_proc p
24
+ JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace
25
+ JOIN pg_catalog.pg_depend fd ON fd.objid = p.oid AND fd.deptype = 'n'
26
+ JOIN pg_catalog.pg_proc dep_p ON dep_p.oid = fd.refobjid
27
+ JOIN pg_catalog.pg_namespace dep_n ON dep_n.oid = dep_p.pronamespace
28
+ WHERE n.nspname = ANY (current_schemas(false))
29
+ AND dep_n.nspname = ANY (current_schemas(false))
30
+ AND p.oid != dep_p.oid
31
+ ),
32
+ levels(oid, level) AS (
33
+ SELECT p.oid, 0
34
+ FROM pg_catalog.pg_proc p
35
+ JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace
36
+ WHERE n.nspname = ANY (current_schemas(false))
37
+ AND p.oid NOT IN (SELECT func_oid FROM func_deps)
38
+ UNION ALL
39
+ SELECT fd.func_oid, l.level + 1
40
+ FROM func_deps fd
41
+ JOIN levels l ON l.oid = fd.dep_func_oid
42
+ ),
43
+ max_levels AS (
44
+ SELECT oid, MAX(level) AS level FROM levels GROUP BY oid
45
+ )
19
46
  SELECT proname, pg_catalog.pg_get_function_arguments(p.oid) AS argtypes, pg_catalog.pg_get_function_result(p.oid) AS rettype, lanname, provolatile, prosrc
20
47
  FROM pg_catalog.pg_proc p
21
- JOIN pg_catalog.pg_namespace n ON n.oid = pronamespace
22
- JOIN pg_catalog.pg_language l ON l.oid = prolang
48
+ JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace
49
+ JOIN pg_catalog.pg_language l ON l.oid = p.prolang
23
50
  LEFT JOIN pg_catalog.pg_depend d ON d.objid = p.oid AND d.deptype = 'e'
24
51
  LEFT JOIN pg_catalog.pg_extension e ON e.oid = d.refobjid
52
+ JOIN max_levels ml ON ml.oid = p.oid
25
53
  WHERE n.nspname = ANY (current_schemas(false))
26
- AND e.extname IS NULL;
54
+ AND e.extname IS NULL
55
+ ORDER BY ml.level, proname;
27
56
  SQL
28
57
 
29
58
  res.rows.each_with_object({}) do |(name, args, ret, lang, vol, src), memo|
@@ -31,6 +60,49 @@ module ActiveRecord
31
60
  end
32
61
  end
33
62
 
63
+ # See https://github.com/postgres/postgres/blob/master/src/include/commands/trigger.h
64
+ # and https://stackoverflow.com/questions/23634550/meanings-of-bits-in-trigger-type-field-tgtype-of-postgres-pg-trigger
65
+ def triggers
66
+ # List of triggers in the current schema with name, table name, function, timing, op, for each, condition, deferrable, initially_deferred.
67
+ res = exec_query(<<-SQL.strip_heredoc, "SCHEMA")
68
+ SELECT tgname, c.relname, proname,
69
+ COALESCE(
70
+ CASE WHEN (tgtype::int::bit(7) & b'0000010')::int = 0 THEN NULL ELSE 'before' END,
71
+ CASE WHEN (tgtype::int::bit(7) & b'0000010')::int = 0 THEN 'after' ELSE NULL END,
72
+ CASE WHEN (tgtype::int::bit(7) & b'1000000')::int = 0 THEN NULL ELSE 'instead_of' END,
73
+ ''
74
+ ) as tg_timing,
75
+ (CASE WHEN (tgtype::int::bit(7) & b'0000100')::int = 0 THEN '' ELSE ' insert' END)
76
+ || (CASE WHEN (tgtype::int::bit(7) & b'0001000')::int = 0 THEN '' ELSE ' delete' END)
77
+ || (CASE WHEN (tgtype::int::bit(7) & b'0010000')::int = 0 THEN '' ELSE ' update' END)
78
+ -- || (CASE WHEN (tgtype::int::bit(7) & b'0100000')::int = 0 THEN '' ELSE ' truncate' END)
79
+ AS tg_ops,
80
+ CASE WHEN (tgtype::int::bit(7) & b'0000001')::int = 0 THEN 'statement' ELSE 'row' END as tg_foreach,
81
+ pg_get_triggerdef(t.oid, true) AS tg_definition,
82
+ tgdeferrable,
83
+ tginitdeferred
84
+ FROM pg_catalog.pg_trigger t
85
+ JOIN pg_catalog.pg_class c ON c.oid = tgrelid
86
+ JOIN pg_catalog.pg_proc f ON f.oid = t.tgfoid
87
+ JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
88
+ LEFT JOIN pg_catalog.pg_depend d ON d.objid = t.oid AND d.deptype = 'e'
89
+ LEFT JOIN pg_catalog.pg_extension e ON e.oid = d.refobjid
90
+ WHERE n.nspname = ANY (current_schemas(false))
91
+ AND tgisinternal = FALSE
92
+ AND e.extname IS NULL
93
+ ORDER BY c.relname, tgname;
94
+ SQL
95
+
96
+ res.rows.each_with_object({}) do |(name, table, function, timing, ops, for_each, definition, deferrable, initially_deferred), memo|
97
+ attributes = {table: table, function: function, for_each: for_each.to_sym}
98
+ condition = extract_trigger_condition(definition)
99
+ attributes[:when] = condition if condition.present?
100
+ attributes[timing.to_sym] = ops.strip.split(/\s+/).map(&:to_sym)
101
+ attributes[:deferrable] = initially_deferred ? :initially_deferred : true if deferrable
102
+ memo[name] = attributes
103
+ end
104
+ end
105
+
34
106
  def text_search_parsers
35
107
  res = exec_query(<<-SQL.strip_heredoc, "SCHEMA")
36
108
  SELECT prsname, prsstart::VARCHAR, prstoken::VARCHAR, prsend::VARCHAR, prsheadline::VARCHAR, prslextype::VARCHAR
@@ -109,6 +181,12 @@ module ActiveRecord
109
181
 
110
182
  private
111
183
 
184
+ def extract_trigger_condition(definition)
185
+ return unless definition
186
+
187
+ definition[/\bWHEN\s*\((.*)\)\s+EXECUTE\s+FUNCTION\b/m, 1]
188
+ end
189
+
112
190
  def options_to_hash(text)
113
191
  text.split(/\s*,\s*/).map { |s| s.strip.split(/\s+=\s+/) }.to_h.transform_values { |v| v[1..-2] }.transform_keys(&:to_sym)
114
192
  end
@@ -17,6 +17,11 @@ module ActiveRecord
17
17
  text_search_configurations(stream)
18
18
  end
19
19
 
20
+ def tables(stream)
21
+ super
22
+ triggers(stream)
23
+ end
24
+
20
25
  def functions(stream)
21
26
  return unless (functions = @connection.functions).any?
22
27
 
@@ -33,6 +38,22 @@ module ActiveRecord
33
38
  stream.puts
34
39
  end
35
40
 
41
+ def triggers(stream)
42
+ return unless (triggers = @connection.triggers).any?
43
+
44
+ stream.puts
45
+ stream.puts " # These are triggers that must be created in order to support this database"
46
+
47
+ triggers.each do |name, definition|
48
+ definition[:name] = name if include_trigger_name_in_dump?(definition)
49
+ table = definition.delete(:table)
50
+ function = definition.delete(:function)
51
+ stream.puts %( create_trigger #{table.inspect}, #{function.inspect}, #{hash_to_string(definition)})
52
+ end
53
+
54
+ # stream.puts
55
+ end
56
+
36
57
  def text_search_parsers(stream)
37
58
  return unless (parsers = @connection.text_search_parsers).any?
38
59
 
@@ -88,6 +109,10 @@ module ActiveRecord
88
109
 
89
110
  private
90
111
 
112
+ def include_trigger_name_in_dump?(trigger_definition)
113
+ trigger_definition[:name] !=~ /\Atg_rails_/
114
+ end
115
+
91
116
  def hash_to_string(hash)
92
117
  hash.map { |k, v| "#{k}: #{v.inspect}" }.join(", ")
93
118
  end
@@ -30,6 +30,14 @@ module ActiveRecord
30
30
  record(:drop_function, args, &block)
31
31
  end
32
32
 
33
+ def create_trigger(*args, &block)
34
+ record(:create_trigger, args, &block)
35
+ end
36
+
37
+ def drop_trigger(*args, &block)
38
+ record(:drop_trigger, args, &block)
39
+ end
40
+
33
41
  def create_text_search_configuration(*args, &block)
34
42
  record(:create_text_search_configuration, args, &block)
35
43
  end
@@ -108,6 +116,14 @@ module ActiveRecord
108
116
  [:create_function, args]
109
117
  end
110
118
 
119
+ def invert_create_trigger(args)
120
+ [:drop_trigger, args]
121
+ end
122
+
123
+ def invert_drop_trigger(args)
124
+ [:create_trigger, args]
125
+ end
126
+
111
127
  def invert_create_text_search_configuration(args)
112
128
  [:drop_text_search_configuration, args]
113
129
  end
@@ -6,7 +6,10 @@ module ActiveRecord
6
6
  end
7
7
 
8
8
  module SchemaStatements
9
- def create_function(name_with_args, as:, volatility: :volatile, language: "sql", returns: "void", replace: true)
9
+ def create_function(name_with_args, as:, volatility: :volatile, language: nil, returns: :void, replace: true)
10
+ name_with_args = "#{name_with_args}()" unless name_with_args.to_s.include?("(")
11
+ language = "plpgsql" if returns == :trigger
12
+ language ||= "sql"
10
13
  execute(<<-SQL)
11
14
  CREATE #{"OR REPLACE" if replace} FUNCTION #{name_with_args}
12
15
  RETURNS #{returns}
@@ -18,10 +21,60 @@ module ActiveRecord
18
21
  SQL
19
22
  end
20
23
 
21
- def drop_function(name_with_args, if_exists: false, cascade: false)
24
+ def drop_function(name_with_args, options = {})
25
+ if_exists = options[:if_exists]
26
+ cascade = options[:cascade]
22
27
  execute "DROP FUNCTION #{"IF EXISTS" if if_exists} #{name_with_args} #{"CASCADE" if cascade}"
23
28
  end
24
29
 
30
+ def create_trigger(table, function, options = {})
31
+ raise ArgumentError, "function name is invalid" unless /\A\w+\z/.match?(function.to_s)
32
+ raise ArgumentError, "Must specify one and only one of the options :before, :after, or :instead_of" unless %i[before after instead_of].select { |t| options.key?(t) }.count == 1
33
+ raise ArgumentError, "for_each must be :row or :statement" if options[:for_each] && !%i[row statement].include?(options[:for_each])
34
+
35
+ timing = %i[before after instead_of].find { |t| options.key?(t) }
36
+ operations = options[timing] || raise(ArgumentError, "Must specify operations for #{timing} trigger")
37
+ operations.detect { |op| %i[insert update delete].exclude?(op) } && raise(ArgumentError, "Invalid operation for trigger: #{operations.inspect}")
38
+ for_each = "FOR EACH #{options[:for_each].to_s.upcase}" if options[:for_each]
39
+ if options[:deferrable] == :initially_deferred
40
+ deferrability = "DEFERRABLE INITIALLY DEFERRED"
41
+ elsif options[:deferrable] == :initially_immediate
42
+ deferrability = "DEFERRABLE INITIALLY IMMEDIATE"
43
+ elsif options[:deferrable] == true
44
+ deferrability = "DEFERRABLE"
45
+ elsif options[:deferrable] == false
46
+ deferrability = "NOT DEFERRABLE"
47
+ elsif options[:deferrable]
48
+ raise ArgumentError, "Invalid value for :deferrable"
49
+ end
50
+ condition = options[:when] ? "WHEN (#{options[:when]})" : ""
51
+ operations = [operations].flatten.map do |event|
52
+ if %i[insert update delete].include?(event)
53
+ event.to_s.upcase
54
+ # elsif event.is_a?(Hash)
55
+ # raise ArgumentError, "Key must be :update" unless event.keys.size == 1 && event.keys.first == :update
56
+ # "UPDATE OF #{[event[:update]].flatten.map { |c| quote_column_name(c) }.join(", ")}"
57
+ else
58
+ raise ArgumentError, "Unsupported event: #{event.inspect}"
59
+ end
60
+ end
61
+ name = options[:name] || default_trigger_name(table, function, timing, operations)
62
+
63
+ execute "CREATE TRIGGER #{name} #{timing.to_s.upcase} #{operations.join(" OR ")} ON #{table} #{for_each} #{deferrability} #{condition} EXECUTE FUNCTION #{function}()"
64
+ end
65
+
66
+ def drop_trigger(table, function, options = {})
67
+ if_exists = options[:if_exists]
68
+ cascade = options[:cascade]
69
+ if options.keys.intersect?(%i[before after instead_of])
70
+ raise ArgumentError, "Must specify only one of the options :before, :after, or :instead_of" unless %i[before after instead_of].select { |t| options.key?(t) }.count == 1
71
+ timing = %i[before after instead_of].find { |t| options.key?(t) }
72
+ operations = options[timing] || raise(ArgumentError, "Must specify operations for #{timing} trigger")
73
+ end
74
+ name = options[:name] || default_trigger_name(table, function, timing, operations)
75
+ execute "DROP TRIGGER #{"IF EXISTS" if if_exists} #{name} ON #{table} #{"CASCADE" if cascade}"
76
+ end
77
+
25
78
  def create_text_search_template(name, lexize:, init: nil)
26
79
  options = {init: init, lexize: lexize}.compact
27
80
  execute("CREATE TEXT SEARCH TEMPLATE public.#{name} (#{options.map { |k, v| "#{k.upcase} = #{v}" }.join(", ")})")
@@ -96,6 +149,19 @@ module ActiveRecord
96
149
  def drop_text_search_configuration(name, if_exists: false, cascade: :restrict)
97
150
  execute "DROP TEXT SEARCH CONFIGURATION #{"IF EXISTS" if if_exists} public.#{name} #{"CASCADE" if cascade == :cascade}"
98
151
  end
152
+
153
+ def max_trigger_name_size
154
+ 62
155
+ end
156
+
157
+ private
158
+
159
+ # Copied from ActiveRecord::ConnectionAdapters::Abstract::SchemaStatements#foreign_key_name
160
+ def default_trigger_name(table, function, timing, operations)
161
+ identifier = "#{table}_#{function}_#{timing}_#{operations.sort.join('_')}_tg".underscore
162
+ hashed_identifier = OpenSSL::Digest::SHA256.hexdigest(identifier).first(10)
163
+ "tg_rails_#{hashed_identifier}"
164
+ end
99
165
  end
100
166
  end
101
167
  end
@@ -1,5 +1,5 @@
1
1
  module ActiveRecord
2
2
  module FullTextSearch
3
- VERSION = "0.1.0"
3
+ VERSION = "0.3.0"
4
4
  end
5
5
  end
@@ -3,7 +3,15 @@ require "active_support/lazy_load_hooks"
3
3
 
4
4
  module ActiveRecord
5
5
  module FullTextSearch
6
- KNOWN_VERSIONS = %w[7.2].map { |v| Gem::Version.new(v) }.freeze
6
+ KNOWN_VERSIONS = %w[7.2 8.0 8.1].map { |v| Gem::Version.new(v) }.freeze
7
+
8
+ # Maps a supported version to the directory containing its implementation.
9
+ # Versions that share an implementation point to the same directory.
10
+ VERSION_DIRECTORIES = {
11
+ Gem::Version.new("7.2") => "7.2",
12
+ Gem::Version.new("8.0") => "7.2",
13
+ Gem::Version.new("8.1") => "7.2",
14
+ }.freeze
7
15
 
8
16
  class << self
9
17
  attr_reader :enabled_version
@@ -39,7 +47,8 @@ module ActiveRecord
39
47
  require "active_record/full_text_search/command_recorder"
40
48
  require "active_record/full_text_search/schema_statements"
41
49
 
42
- Dir[File.join(__dir__, "full_text_search", enabled_version.to_s, "*.rb")].each { |file| require file }
50
+ directory = VERSION_DIRECTORIES.fetch(enabled_version, enabled_version.to_s)
51
+ Dir[File.join(__dir__, "full_text_search", directory, "*.rb")].each { |file| require file }
43
52
  monkeypatches.keys.each { |patch| monkeypatches.delete(patch).call }
44
53
  end
45
54
 
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: activerecord-full_text_search
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Codeur SAS
8
- autorequire:
9
8
  bindir: exe
10
9
  cert_chain: []
11
- date: 2024-11-29 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: pg
@@ -85,7 +84,6 @@ metadata:
85
84
  changelog_uri: https://github.com/codeur/activerecord-full_text_search/blob/master/CHANGELOG.md
86
85
  pgp_keys_uri: https://keybase.io/codeur/pgp_keys.asc
87
86
  signatures_uri: https://keybase.pub/codeur/gems/
88
- post_install_message:
89
87
  rdoc_options: []
90
88
  require_paths:
91
89
  - lib
@@ -100,8 +98,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
100
98
  - !ruby/object:Gem::Version
101
99
  version: '0'
102
100
  requirements: []
103
- rubygems_version: 3.3.26
104
- signing_key:
101
+ rubygems_version: 3.6.9
105
102
  specification_version: 4
106
103
  summary: Integrate PostgreSQL's FTS configs with Rails
107
104
  test_files: []