activerecord-full_text_search 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: a0e395c41810e9e42ae30e3f2aca5527fe80666fd52dcfdd9913b3501b6e97f0
4
+ data.tar.gz: dab379e41ed0fe39a5f90e3dc42b99337d5dbf441e6e8b1b10eae93ab6f202c9
5
+ SHA512:
6
+ metadata.gz: 51fc2b15b2016a7030c4fbd63c0cf0065453c1cff0d4a50007d8870e556cf8e4098008f7077a76b85c41124d865cda4e4fda3d9f0acdc38d8dbb4b7c951f32ba
7
+ data.tar.gz: e97e619209f362b9412ad7df74b0511f3f99b557dd1085cd8b7508ae4ee369b74e2e8f5a7ea2c4c8191ab3253ecd4432ee8c25b0c2f5d14d295c344b206198bf
@@ -0,0 +1,28 @@
1
+ name: Ruby
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+
8
+ pull_request:
9
+
10
+ jobs:
11
+ build:
12
+ runs-on: ubuntu-latest
13
+ name: Ruby ${{ matrix.ruby }}
14
+ strategy:
15
+ matrix:
16
+ ruby:
17
+ - "3.1.3"
18
+
19
+ steps:
20
+ - name: Checkout code
21
+ uses: actions/checkout@v4
22
+ - name: Set up Ruby
23
+ uses: ruby/setup-ruby@v1
24
+ with:
25
+ ruby-version: ${{ matrix.ruby }}
26
+ bundler-cache: true
27
+ - name: Run the default task
28
+ run: bundle exec rake
data/.gitignore ADDED
@@ -0,0 +1,15 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /Gemfile.lock
4
+ /_yardoc/
5
+ /coverage/
6
+ /doc/
7
+ /pkg/
8
+ /spec/reports/
9
+ /tmp/
10
+ *.vim
11
+ .envrc
12
+ .DS_Store
13
+
14
+ # rspec failure tracking
15
+ .rspec_status
data/.standard.yml ADDED
@@ -0,0 +1,3 @@
1
+ # For available configuration options, see:
2
+ # https://github.com/standardrb/standard
3
+ ruby_version: 3.0
data/CHANGELOG.md ADDED
@@ -0,0 +1,15 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
6
+ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0] - 2024-11-28
11
+
12
+ ### Added
13
+
14
+ - Support for TEXT SEARCH queries.
15
+ - Basic support for FUNCTIONS
data/Gemfile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ # Specify your gem's dependencies in activerecord-full_text_search.gemspec
6
+ gemspec
7
+
8
+ gem "rake", "~> 13.0"
9
+
10
+ gem "minitest", "~> 5.16"
11
+
12
+ gem "standard", "~> 1.3"
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2024 Codeur SAS
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,47 @@
1
+ # ActiveRecord::FullTextSearch
2
+
3
+ This gem adds support for TEXT SEARCH commands in a Rails (>= 7.2) app using PostgreSQL.
4
+
5
+ It is largely built using the gem [activerecord-pg_enum](https://github.com/alassek/activerecord-pg_enum). Thanks!
6
+
7
+ ## Usage
8
+
9
+ The gem permits to use these commands:
10
+
11
+ - `create_function`
12
+ - `drop_function`
13
+ - `create_text_search_template`
14
+ - `rename_text_search_template`
15
+ - `drop_text_search_template`
16
+ - `create_text_search_parser`
17
+ - `rename_text_search_parser`
18
+ - `drop_text_search_parser`
19
+ - `create_text_search_dictionary`
20
+ - `rename_text_search_dictionary`
21
+ - `drop_text_search_dictionary`
22
+ - `create_text_search_configuration`
23
+ - `rename_text_search_configuration`
24
+ - `drop_text_search_configuration`
25
+ - `add_text_search_configuration_mapping`
26
+ - `change_text_search_configuration_mapping`
27
+ - `replace_text_search_configuration_mapping`
28
+ - `drop_text_search_configuration_mapping`
29
+
30
+ ## TODO
31
+
32
+ - Add tests
33
+ - Enhance (and extract?) functions support
34
+ - Check recorder
35
+ - Manage schema (`public` is hardcoded)
36
+
37
+ ## Contributing
38
+
39
+ Bug reports and pull requests are welcome on GitHub at https://github.com/codeur/activerecord-full_text_search. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/codeur/activerecord-full_text_search/blob/main/CODE_OF_CONDUCT.md).
40
+
41
+ ## License
42
+
43
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
44
+
45
+ ## Code of Conduct
46
+
47
+ Everyone interacting in the ActiveRecord::FullTextSearch project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/codeur/activerecord-full_text_search/blob/main/CODE_OF_CONDUCT.md).
data/Rakefile ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "minitest/test_task"
5
+
6
+ Minitest::TestTask.create
7
+
8
+ require "standard/rake"
9
+
10
+ task default: %i[test standard]
@@ -0,0 +1,36 @@
1
+ lib = File.expand_path("../lib", __FILE__)
2
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
3
+ require "active_record/full_text_search/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "activerecord-full_text_search"
7
+ spec.version = ActiveRecord::FullTextSearch::VERSION
8
+ spec.authors = ["Codeur SAS"]
9
+ spec.email = ["dev@codeur.com"]
10
+
11
+ spec.summary = "Integrate PostgreSQL's FTS configs with Rails"
12
+ spec.description = "Integrate PostgreSQL's Full Text Search configurations with Rails"
13
+ spec.homepage = "https://github.com/codeur/activerecord-full_text_search"
14
+ spec.license = "MIT"
15
+
16
+ spec.metadata = {
17
+ "bug_tracker_uri" => "https://github.com/codeur/activerecord-full_text_search/issues",
18
+ "changelog_uri" => "https://github.com/codeur/activerecord-full_text_search/blob/master/CHANGELOG.md",
19
+ "pgp_keys_uri" => "https://keybase.io/codeur/pgp_keys.asc",
20
+ "signatures_uri" => "https://keybase.pub/codeur/gems/"
21
+ }
22
+
23
+ spec.files = `git ls-files -z`.split("\x0").reject do |f|
24
+ f.match(%r{^(test|spec|features)/})
25
+ end
26
+
27
+ spec.required_ruby_version = ">= 2.2.2"
28
+
29
+ spec.bindir = "exe"
30
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
31
+ spec.require_paths = ["lib"]
32
+
33
+ spec.add_dependency "pg"
34
+ spec.add_dependency "activerecord", ">= 7.2.0"
35
+ spec.add_dependency "activesupport"
36
+ end
data/bin/console ADDED
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+ require "activerecord/full_text_search"
6
+
7
+ # You can add fixtures and/or initialization code here to make experimenting
8
+ # with your gem easier. You can also use a different console, if you like.
9
+
10
+ ActiveSupport.run_load_hooks(:active_record, ActiveRecord::Base)
11
+
12
+ require "irb"
13
+ IRB.start(__FILE__)
data/bin/setup ADDED
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ # gem install appraisal
7
+ # appraisal bundle install
8
+ bundle install
9
+
10
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,117 @@
1
+ module ActiveRecord
2
+ module FullTextSearch
3
+ register :postgresql_adapter do
4
+ require "active_record/connection_adapters/postgresql_adapter"
5
+ ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.include ::ActiveRecord::FullTextSearch::PostgreSQLAdapter
6
+ end
7
+
8
+ module PostgreSQLAdapter
9
+ VOLATILITIES = {
10
+ "i" => :immutable,
11
+ "s" => :stable,
12
+ "v" => :volatile
13
+ }.freeze
14
+
15
+ def functions
16
+ # List of functions in the current schema with their argument types, return type, language, immutability, and body.
17
+ # List only functions that don't depend on extensions.
18
+ res = exec_query(<<-SQL.strip_heredoc, "SCHEMA")
19
+ 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
+ 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
23
+ LEFT JOIN pg_catalog.pg_depend d ON d.objid = p.oid AND d.deptype = 'e'
24
+ LEFT JOIN pg_catalog.pg_extension e ON e.oid = d.refobjid
25
+ WHERE n.nspname = ANY (current_schemas(false))
26
+ AND e.extname IS NULL;
27
+ SQL
28
+
29
+ res.rows.each_with_object({}) do |(name, args, ret, lang, vol, src), memo|
30
+ memo[name] = {arguments: args, returns: ret, language: lang, volatility: VOLATILITIES[vol], source: src}
31
+ end
32
+ end
33
+
34
+ def text_search_parsers
35
+ res = exec_query(<<-SQL.strip_heredoc, "SCHEMA")
36
+ SELECT prsname, prsstart::VARCHAR, prstoken::VARCHAR, prsend::VARCHAR, prsheadline::VARCHAR, prslextype::VARCHAR
37
+ FROM pg_catalog.pg_ts_parser
38
+ LEFT JOIN pg_catalog.pg_depend AS d ON d.objid = pg_ts_parser.oid AND d.deptype = 'e'
39
+ LEFT JOIN pg_catalog.pg_extension AS e ON e.oid = d.refobjid
40
+ WHERE prsnamespace = (SELECT oid FROM pg_catalog.pg_namespace WHERE nspname = ANY (current_schemas(false)))
41
+ AND e.extname IS NULL;
42
+ SQL
43
+
44
+ res.rows.each_with_object({}) do |(name, start, token, finish, headline, lextype), memo|
45
+ memo[name] = {start: start, token: token, finish: finish, headline: headline, lextype: lextype}
46
+ end
47
+ end
48
+
49
+ def text_search_templates
50
+ res = exec_query(<<-SQL.strip_heredoc, "SCHEMA")
51
+ SELECT tmplname, tmplinit::VARCHAR, tmpllexize::VARCHAR
52
+ FROM pg_catalog.pg_ts_template
53
+ LEFT JOIN pg_catalog.pg_depend AS d ON d.objid = pg_ts_template.oid AND d.deptype = 'e'
54
+ LEFT JOIN pg_catalog.pg_extension AS e ON e.oid = d.refobjid
55
+ WHERE tmplnamespace = (SELECT oid FROM pg_catalog.pg_namespace WHERE nspname = ANY (current_schemas(false)))
56
+ AND tmplnamespace NOT IN (SELECT extnamespace FROM pg_extension)
57
+ AND e.extname IS NULL;
58
+ SQL
59
+
60
+ res.rows.each_with_object({}) { |(name, init, lexize), memo| memo[name] = {init: init, lexize: lexize} }
61
+ end
62
+
63
+ def text_search_dictionaries
64
+ res = exec_query(<<-SQL.strip_heredoc, "SCHEMA")
65
+ SELECT dictname, tns.nspname || '.' || tmplname, dictinitoption
66
+ FROM pg_catalog.pg_ts_dict
67
+ LEFT JOIN pg_catalog.pg_ts_template AS t ON dicttemplate = t.oid
68
+ LEFT JOIN pg_catalog.pg_namespace AS tns ON t.tmplnamespace = tns.oid
69
+ LEFT JOIN pg_catalog.pg_depend AS d ON d.objid = dicttemplate AND d.deptype = 'e'
70
+ LEFT JOIN pg_catalog.pg_extension AS e ON e.oid = d.refobjid
71
+ WHERE dictnamespace = (SELECT oid FROM pg_catalog.pg_namespace WHERE nspname = ANY (current_schemas(false)))
72
+ AND e.extname IS NULL;
73
+ SQL
74
+
75
+ res.rows.each_with_object({}) { |(name, template, init), memo| memo[name] = options_to_hash(init).reverse_merge(template: template) }.sort_by { |k, _| k.to_s }.sort_by { |_, v| v[:dictionary].nil? ? 0 : 1 }
76
+ end
77
+
78
+ def text_search_configurations
79
+ res = exec_query(<<-SQL.strip_heredoc, "SCHEMA")
80
+ SELECT cfg.oid, cfgname, cfgparser, prsname
81
+ FROM pg_catalog.pg_ts_config AS cfg
82
+ LEFT JOIN pg_catalog.pg_ts_parser ON cfgparser = pg_ts_parser.oid
83
+ LEFT JOIN pg_catalog.pg_depend AS d ON d.objid = cfgparser AND d.deptype = 'e'
84
+ LEFT JOIN pg_catalog.pg_extension AS e ON e.oid = d.refobjid
85
+ WHERE cfgnamespace = (SELECT oid FROM pg_catalog.pg_namespace WHERE nspname = ANY (current_schemas(false)))
86
+ AND e.extname IS NULL;
87
+ SQL
88
+
89
+ res.rows.each_with_object({}) do |(oid, name, parser_oid, parser_name), memo|
90
+ maps = exec_query(<<-SQL.strip_heredoc, "SCHEMA")
91
+ SELECT t.alias AS "token", dictname AS "dict"
92
+ FROM pg_catalog.pg_ts_config_map
93
+ JOIN (SELECT * FROM ts_token_type(#{parser_oid})) AS t ON maptokentype = t.tokid
94
+ JOIN pg_catalog.pg_ts_dict ON mapdict = pg_ts_dict.oid
95
+ WHERE mapcfg = #{oid}
96
+ ORDER BY mapseqno;
97
+ SQL
98
+ maps = maps.rows.each_with_object({}) { |(token, dict), memo|
99
+ memo[token] ||= []
100
+ memo[token] << dict
101
+ }
102
+ maps = maps.each_with_object({}) { |(k, v), memo|
103
+ memo[v] ||= []
104
+ memo[v] << k
105
+ }
106
+ memo[name] = {parser: parser_name, maps: maps}
107
+ end
108
+ end
109
+
110
+ private
111
+
112
+ def options_to_hash(text)
113
+ text.split(/\s*,\s*/).map { |s| s.strip.split(/\s+=\s+/) }.to_h.transform_values { |v| v[1..-2] }.transform_keys(&:to_sym)
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,102 @@
1
+ module ActiveRecord
2
+ module FullTextSearch
3
+ register :schema_dumper do
4
+ require "active_record/connection_adapters/postgresql/schema_dumper"
5
+ ActiveRecord::ConnectionAdapters::PostgreSQL::SchemaDumper.prepend SchemaDumper
6
+ end
7
+
8
+ module SchemaDumper
9
+ private
10
+
11
+ def extensions(stream)
12
+ super
13
+ functions(stream)
14
+ text_search_parsers(stream)
15
+ text_search_templates(stream)
16
+ text_search_dictionaries(stream)
17
+ text_search_configurations(stream)
18
+ end
19
+
20
+ def functions(stream)
21
+ return unless (functions = @connection.functions).any?
22
+
23
+ stream.puts " # These are functions that must be created in order to support this database"
24
+
25
+ functions.each do |name, definition|
26
+ source = definition.delete(:source)
27
+ arguments = definition.delete(:arguments)
28
+ stream.puts %{ create_function "#{name}(#{arguments})", #{hash_to_string(definition)}, as: <<~SQL}
29
+ stream.puts source.strip.gsub(/^/, " ").gsub(/\s+$/, "")
30
+ stream.puts " SQL"
31
+ end
32
+
33
+ stream.puts
34
+ end
35
+
36
+ def text_search_parsers(stream)
37
+ return unless (parsers = @connection.text_search_parsers).any?
38
+
39
+ stream.puts " # These are full-text search parsers that must be created in order to support this database"
40
+
41
+ parsers.each do |name, definition|
42
+ stream.puts %( create_text_search_parser "#{name}", #{hash_to_string(definition)})
43
+ end
44
+
45
+ stream.puts
46
+ end
47
+
48
+ def text_search_templates(stream)
49
+ return unless (templates = @connection.text_search_templates).any?
50
+
51
+ stream.puts " # These are full-text search templates that must be created in order to support this database"
52
+
53
+ templates.each do |name, definition|
54
+ stream.puts %( create_text_search_template "#{name}", #{hash_to_string(definition)})
55
+ end
56
+ stream.puts
57
+ end
58
+
59
+ def text_search_dictionaries(stream)
60
+ return unless (dictionaries = @connection.text_search_dictionaries).any?
61
+
62
+ stream.puts " # These are full-text search dictionaries that must be created in order to support this database"
63
+
64
+ dictionaries.each do |name, definition|
65
+ stream.puts %( create_text_search_dictionary "#{name}", #{hash_to_string(definition)})
66
+ end
67
+
68
+ stream.puts
69
+ end
70
+
71
+ def text_search_configurations(stream)
72
+ return unless (configurations = @connection.text_search_configurations).any?
73
+
74
+ stream.puts " # These are full-text search configurations that must be created in order to support this database"
75
+
76
+ configurations.each do |name, definition|
77
+ if definition[:parser] == "default"
78
+ stream.puts %( create_text_search_configuration "#{name}")
79
+ else
80
+ stream.puts %( create_text_search_configuration "#{name}", parser: "#{definition[:parser]}")
81
+ end
82
+ definition[:maps].each do |dicts, tokens|
83
+ stream.puts %( add_text_search_configuration_mapping "#{name}", #{array_to_string(tokens)}, #{array_to_string(dicts)})
84
+ end
85
+ end
86
+ stream.puts
87
+ end
88
+
89
+ private
90
+
91
+ def hash_to_string(hash)
92
+ hash.map { |k, v| "#{k}: #{v.inspect}" }.join(", ")
93
+ end
94
+
95
+ def array_to_string(array)
96
+ return array.inspect if array.detect { |v| !v.is_a?(String) || v =~ /\s+/ }
97
+
98
+ "%w[#{array.join(" ")}]"
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,180 @@
1
+ module ActiveRecord
2
+ module FullTextSearch
3
+ register :command_recorder do
4
+ require "active_record/migration/command_recorder"
5
+ ActiveRecord::Migration::CommandRecorder.include CommandRecorder
6
+ end
7
+
8
+ # ActiveRecord::Migration::CommandRecorder is a class used by reversible migrations.
9
+ # It captures the forward migration commands and translates them into their inverse
10
+ # by way of some simple metaprogramming.
11
+ #
12
+ # The Migrator class uses CommandRecorder during the reverse migration instead of
13
+ # the connection object. Forward migration calls are translated to their inverse
14
+ # where possible, and then forwarded to the connetion. Irreversible migrations
15
+ # raise an exception.
16
+ #
17
+ # Known schema statement methods are metaprogrammed into an inverse method like so:
18
+ #
19
+ # create_table => invert_create_table
20
+ #
21
+ # which returns:
22
+ #
23
+ # [:drop_table, args.first]
24
+ module CommandRecorder
25
+ def create_function(*args, &block)
26
+ record(:create_function, args, &block)
27
+ end
28
+
29
+ def drop_function(*args, &block)
30
+ record(:drop_function, args, &block)
31
+ end
32
+
33
+ def create_text_search_configuration(*args, &block)
34
+ record(:create_text_search_configuration, args, &block)
35
+ end
36
+
37
+ def drop_text_search_configuration(*args, &block)
38
+ record(:drop_text_search_configuration, args, &block)
39
+ end
40
+
41
+ def rename_text_search_configuration(*args, &block)
42
+ record(:rename_text_search_configuration, args, &block)
43
+ end
44
+
45
+ def add_text_search_configuration_mappingping(*args, &block)
46
+ record(:add_text_search_configuration_mappingping, args, &block)
47
+ end
48
+
49
+ def change_text_search_configuration_mapping(*args, &block)
50
+ record(:change_text_search_configuration_mapping, args, &block)
51
+ end
52
+
53
+ def replace_text_search_configuration_dictionary(*args, &block)
54
+ record(:replace_text_search_configuration_dictionary, args, &block)
55
+ end
56
+
57
+ def drop_text_search_configuration_mapping(*args, &block)
58
+ record(:drop_text_search_configuration_mapping, args, &block)
59
+ end
60
+
61
+ def create_text_search_dictionary(*args, &block)
62
+ record(:create_text_search_dictionary, args, &block)
63
+ end
64
+
65
+ def drop_text_search_dictionary(*args, &block)
66
+ record(:drop_text_search_dictionary, args, &block)
67
+ end
68
+
69
+ def rename_text_search_dictionary(*args, &block)
70
+ record(:rename_text_search_dictionary, args, &block)
71
+ end
72
+
73
+ def change_text_search_dictionary_option(*args, &block)
74
+ record(:change_text_search_dictionary_option, args, &block)
75
+ end
76
+
77
+ def create_text_search_parser(*args, &block)
78
+ record(:create_text_search_parser, args, &block)
79
+ end
80
+
81
+ def drop_text_search_parser(*args, &block)
82
+ record(:drop_text_search_parser, args, &block)
83
+ end
84
+
85
+ def rename_text_search_parser(*args, &block)
86
+ record(:rename_text_search_parser, args, &block)
87
+ end
88
+
89
+ def create_text_search_template(*args, &block)
90
+ record(:create_text_search_template, args, &block)
91
+ end
92
+
93
+ def drop_text_search_template(*args, &block)
94
+ record(:drop_text_search_template, args, &block)
95
+ end
96
+
97
+ def rename_text_search_template(*args, &block)
98
+ record(:rename_text_search_template, args, &block)
99
+ end
100
+
101
+ private
102
+
103
+ def invert_create_function(args)
104
+ [:drop_function, args]
105
+ end
106
+
107
+ def invert_drop_function(args)
108
+ [:create_function, args]
109
+ end
110
+
111
+ def invert_create_text_search_configuration(args)
112
+ [:drop_text_search_configuration, args]
113
+ end
114
+
115
+ def invert_drop_text_search_configuration(args)
116
+ [:create_text_search_configuration, args]
117
+ end
118
+
119
+ def invert_rename_text_search_configuration(args)
120
+ [:rename_text_search_configuration, [args.last[:to], to: args.first]]
121
+ end
122
+
123
+ def invert_add_text_search_configuration_mappingping(args)
124
+ [:drop_text_search_configuration_mapping, args]
125
+ end
126
+
127
+ def invert_change_text_search_configuration_mapping(args)
128
+ [:change_text_search_configuration_mapping, args]
129
+ end
130
+
131
+ def invert_replace_text_search_configuration_dictionary(args)
132
+ [:replace_text_search_configuration_dictionary, args.values_at(:to, :from)]
133
+ end
134
+
135
+ def invert_drop_text_search_configuration_mapping(args)
136
+ [:add_text_search_configuration_mappingping, args]
137
+ end
138
+
139
+ def invert_create_text_search_dictionary(args)
140
+ [:drop_text_search_dictionary, args]
141
+ end
142
+
143
+ def invert_drop_text_search_dictionary(args)
144
+ [:create_text_search_dictionary, args]
145
+ end
146
+
147
+ def invert_rename_text_search_dictionary(args)
148
+ [:rename_text_search_dictionary, [args.last[:to], to: args.first]]
149
+ end
150
+
151
+ def invert_change_text_search_dictionary_option(args)
152
+ [:change_text_search_dictionary_option, args.values_at(:option, :default)]
153
+ end
154
+
155
+ def invert_create_text_search_parser(args)
156
+ [:drop_text_search_parser, args]
157
+ end
158
+
159
+ def invert_drop_text_search_parser(args)
160
+ [:create_text_search_parser, args]
161
+ end
162
+
163
+ def invert_rename_text_search_parser(args)
164
+ [:rename_text_search_parser, [args.last[:to], to: args.first]]
165
+ end
166
+
167
+ def invert_create_text_search_template(args)
168
+ [:drop_text_search_template, args]
169
+ end
170
+
171
+ def invert_drop_text_search_template(args)
172
+ [:create_text_search_template, args]
173
+ end
174
+
175
+ def invert_rename_text_search_template(args)
176
+ [:rename_text_search_template, [args.last[:to], to: args.first]]
177
+ end
178
+ end
179
+ end
180
+ end
@@ -0,0 +1,101 @@
1
+ module ActiveRecord
2
+ module FullTextSearch
3
+ register :schema_statements do
4
+ require "active_record/connection_adapters/postgresql_adapter"
5
+ ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.include SchemaStatements
6
+ end
7
+
8
+ module SchemaStatements
9
+ def create_function(name_with_args, as:, volatility: :volatile, language: "sql", returns: "void", replace: true)
10
+ execute(<<-SQL)
11
+ CREATE #{"OR REPLACE" if replace} FUNCTION #{name_with_args}
12
+ RETURNS #{returns}
13
+ AS $$
14
+ #{as}
15
+ $$
16
+ LANGUAGE #{language}
17
+ #{volatility.to_s.upcase}
18
+ SQL
19
+ end
20
+
21
+ def drop_function(name_with_args, if_exists: false, cascade: false)
22
+ execute "DROP FUNCTION #{"IF EXISTS" if if_exists} #{name_with_args} #{"CASCADE" if cascade}"
23
+ end
24
+
25
+ def create_text_search_template(name, lexize:, init: nil)
26
+ options = {init: init, lexize: lexize}.compact
27
+ execute("CREATE TEXT SEARCH TEMPLATE public.#{name} (#{options.map { |k, v| "#{k.upcase} = #{v}" }.join(", ")})")
28
+ end
29
+
30
+ def rename_text_search_template(name, to)
31
+ execute "ALTER TEXT SEARCH TEMPLATE public.#{name} RENAME TO #{to}"
32
+ end
33
+
34
+ def drop_text_search_template(name, if_exists: false, cascade: :restrict)
35
+ execute "DROP TEXT SEARCH TEMPLATE #{"IF EXISTS" if if_exists} public.#{name} #{"CASCADE" if cascade == :cascade}"
36
+ end
37
+
38
+ def create_text_search_dictionary(name, options = {})
39
+ raise ArgumentError, "Must specify :template" unless (template = options.delete(:template))
40
+ execute("CREATE TEXT SEARCH DICTIONARY public.#{name} (TEMPLATE = #{template}#{options.map { |k, v| ", #{k} = '#{v}'" }.join})")
41
+ end
42
+
43
+ def rename_text_search_dictionary(name, to)
44
+ execute("ALTER TEXT SEARCH DICTIONARY public.#{name} RENAME TO #{to}")
45
+ end
46
+
47
+ def change_text_search_dictionary_option(name, option, value = :default)
48
+ execute("ALTER TEXT SEARCH DICTIONARY public.#{name} SET #{option} #{value if value != :default}")
49
+ end
50
+
51
+ def drop_text_search_dictionary(name, if_exists: false, cascade: :restrict)
52
+ execute "DROP TEXT SEARCH DICTIONARY #{"IF EXISTS" if if_exists} public.#{name} #{"CASCADE" if cascade == :cascade}"
53
+ end
54
+
55
+ def create_text_search_parser(name, start:, gettoken:, end:, lextypes:, headline: nil)
56
+ options = {start: start, gettoken: gettoken, end: binding.local_variable_get(:end), lextypes: lextypes, headline: headline}.compact
57
+ execute("CREATE TEXT SEARCH PARSER public.#{name} (#{options.map { |k, v| "#{k.upcase} = #{v}" }.join(", ")})")
58
+ end
59
+
60
+ def rename_text_search_parser(name, to)
61
+ execute "ALTER TEXT SEARCH PARSER public.#{name} RENAME TO #{to}"
62
+ end
63
+
64
+ def drop_text_search_parser(name, if_exists: false, cascade: :restrict)
65
+ execute "DROP TEXT SEARCH PARSER #{"IF EXISTS" if if_exists} public.#{name} #{"CASCADE" if cascade == :cascade}"
66
+ end
67
+
68
+ def create_text_search_configuration(name, parser: nil, copy: nil)
69
+ if copy
70
+ execute("CREATE TEXT SEARCH CONFIGURATION public.#{name} (COPY = #{copy})")
71
+ else
72
+ execute("CREATE TEXT SEARCH CONFIGURATION public.#{name} (PARSER = '#{parser || "default"}')")
73
+ end
74
+ end
75
+
76
+ def rename_text_search_configuration(name, to)
77
+ execute "ALTER TEXT SEARCH CONFIGURATION public.#{name} RENAME TO #{to}"
78
+ end
79
+
80
+ def add_text_search_configuration_mapping(name, token_types, dictionaries)
81
+ execute "ALTER TEXT SEARCH CONFIGURATION public.#{name} ADD MAPPING FOR #{token_types.join(", ")} WITH #{dictionaries.join(", ")}"
82
+ end
83
+
84
+ def change_text_search_configuration_mapping(name, token_types, dictionaries)
85
+ execute "ALTER TEXT SEARCH CONFIGURATION public.#{name} ALTER MAPPING FOR #{token_types.join(", ")} WITH #{dictionaries.join(", ")}"
86
+ end
87
+
88
+ def replace_text_search_configuration_dictionary(name, from:, to:)
89
+ execute "ALTER TEXT SEARCH CONFIGURATION public.#{name} ALTER MAPPING REPLACE #{from} WITH #{to}"
90
+ end
91
+
92
+ def drop_text_search_configuration_mapping(name, token_types, if_exists: false)
93
+ execute "ALTER TEXT SEARCH CONFIGURATION public.#{name} DROP MAPPING #{"IF EXISTS" if if_exists} FOR #{token_types.join(", ")}"
94
+ end
95
+
96
+ def drop_text_search_configuration(name, if_exists: false, cascade: :restrict)
97
+ execute "DROP TEXT SEARCH CONFIGURATION #{"IF EXISTS" if if_exists} public.#{name} #{"CASCADE" if cascade == :cascade}"
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,5 @@
1
+ module ActiveRecord
2
+ module FullTextSearch
3
+ VERSION = "0.1.0"
4
+ end
5
+ end
@@ -0,0 +1,61 @@
1
+ require "active_record"
2
+ require "active_support/lazy_load_hooks"
3
+
4
+ module ActiveRecord
5
+ module FullTextSearch
6
+ KNOWN_VERSIONS = %w[7.2].map { |v| Gem::Version.new(v) }.freeze
7
+
8
+ class << self
9
+ attr_reader :enabled_version
10
+
11
+ def install(version)
12
+ @enabled_version = approximate_version(version)
13
+
14
+ # Don't immediately fail if we don't yet support the current version.
15
+ # There's at least a chance it could work.
16
+ if !KNOWN_VERSIONS.include?(enabled_version) && enabled_version > KNOWN_VERSIONS.last
17
+ @enabled_version = KNOWN_VERSIONS.last
18
+ warn "[FullTextSearch] Current ActiveRecord version unsupported! Falling back to: #{enabled_version}"
19
+ end
20
+
21
+ initialize!
22
+ end
23
+
24
+ def register(patch, &block)
25
+ monkeypatches[patch] = block
26
+ end
27
+
28
+ def detected_version
29
+ approximate_version Gem.loaded_specs["activerecord"].version
30
+ end
31
+
32
+ private
33
+
34
+ def monkeypatches
35
+ @patches ||= {}
36
+ end
37
+
38
+ def initialize!
39
+ require "active_record/full_text_search/command_recorder"
40
+ require "active_record/full_text_search/schema_statements"
41
+
42
+ Dir[File.join(__dir__, "full_text_search", enabled_version.to_s, "*.rb")].each { |file| require file }
43
+ monkeypatches.keys.each { |patch| monkeypatches.delete(patch).call }
44
+ end
45
+
46
+ def approximate_version(version)
47
+ segments = version.respond_to?(:canonical_segments) ? version.canonical_segments.dup : version.segments
48
+
49
+ segments.pop while segments.any? { |s| String === s }
50
+ segments.pop while segments.size > 2
51
+ segments.push(0) while segments.size < 2
52
+
53
+ Gem::Version.new segments.join(".")
54
+ end
55
+ end
56
+ end
57
+ end
58
+
59
+ ActiveSupport.on_load(:active_record) do
60
+ ActiveRecord::FullTextSearch.install Gem.loaded_specs["activerecord"].version
61
+ end
@@ -0,0 +1 @@
1
+ require "active_record/full_text_search"
metadata ADDED
@@ -0,0 +1,107 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: activerecord-full_text_search
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Codeur SAS
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2024-11-29 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: pg
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: activerecord
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: 7.2.0
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: 7.2.0
41
+ - !ruby/object:Gem::Dependency
42
+ name: activesupport
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ description: Integrate PostgreSQL's Full Text Search configurations with Rails
56
+ email:
57
+ - dev@codeur.com
58
+ executables: []
59
+ extensions: []
60
+ extra_rdoc_files: []
61
+ files:
62
+ - ".github/workflows/main.yml"
63
+ - ".gitignore"
64
+ - ".standard.yml"
65
+ - CHANGELOG.md
66
+ - Gemfile
67
+ - LICENSE.txt
68
+ - README.md
69
+ - Rakefile
70
+ - activerecord-full_text_search.gemspec
71
+ - bin/console
72
+ - bin/setup
73
+ - lib/active_record/full_text_search.rb
74
+ - lib/active_record/full_text_search/7.2/postgresql_adapter.rb
75
+ - lib/active_record/full_text_search/7.2/schema_dumper.rb
76
+ - lib/active_record/full_text_search/command_recorder.rb
77
+ - lib/active_record/full_text_search/schema_statements.rb
78
+ - lib/active_record/full_text_search/version.rb
79
+ - lib/activerecord/full_text_search.rb
80
+ homepage: https://github.com/codeur/activerecord-full_text_search
81
+ licenses:
82
+ - MIT
83
+ metadata:
84
+ bug_tracker_uri: https://github.com/codeur/activerecord-full_text_search/issues
85
+ changelog_uri: https://github.com/codeur/activerecord-full_text_search/blob/master/CHANGELOG.md
86
+ pgp_keys_uri: https://keybase.io/codeur/pgp_keys.asc
87
+ signatures_uri: https://keybase.pub/codeur/gems/
88
+ post_install_message:
89
+ rdoc_options: []
90
+ require_paths:
91
+ - lib
92
+ required_ruby_version: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: 2.2.2
97
+ required_rubygems_version: !ruby/object:Gem::Requirement
98
+ requirements:
99
+ - - ">="
100
+ - !ruby/object:Gem::Version
101
+ version: '0'
102
+ requirements: []
103
+ rubygems_version: 3.3.26
104
+ signing_key:
105
+ specification_version: 4
106
+ summary: Integrate PostgreSQL's FTS configs with Rails
107
+ test_files: []