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 +7 -0
- data/.github/workflows/main.yml +28 -0
- data/.gitignore +15 -0
- data/.standard.yml +3 -0
- data/CHANGELOG.md +15 -0
- data/Gemfile +12 -0
- data/LICENSE.txt +21 -0
- data/README.md +47 -0
- data/Rakefile +10 -0
- data/activerecord-full_text_search.gemspec +36 -0
- data/bin/console +13 -0
- data/bin/setup +10 -0
- data/lib/active_record/full_text_search/7.2/postgresql_adapter.rb +117 -0
- data/lib/active_record/full_text_search/7.2/schema_dumper.rb +102 -0
- data/lib/active_record/full_text_search/command_recorder.rb +180 -0
- data/lib/active_record/full_text_search/schema_statements.rb +101 -0
- data/lib/active_record/full_text_search/version.rb +5 -0
- data/lib/active_record/full_text_search.rb +61 -0
- data/lib/activerecord/full_text_search.rb +1 -0
- metadata +107 -0
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
data/.standard.yml
ADDED
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
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,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,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,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: []
|