activerecord-full_text_search 0.1.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 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: []