stretchy-model 0.3.0 → 0.5.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.rspec +1 -1
- data/README.md +40 -94
- data/Rakefile +92 -0
- data/lib/stretchy/associations.rb +155 -15
- data/lib/stretchy/attributes/transformers/keyword_transformer.rb +85 -0
- data/lib/stretchy/attributes/type/array.rb +15 -0
- data/lib/stretchy/attributes/type/hash.rb +17 -0
- data/lib/stretchy/attributes/type/keyword.rb +11 -0
- data/lib/stretchy/attributes/type/text.rb +12 -0
- data/lib/stretchy/attributes.rb +30 -0
- data/lib/stretchy/common.rb +2 -3
- data/lib/stretchy/delegation/gateway_delegation.rb +7 -1
- data/lib/stretchy/model/serialization.rb +1 -0
- data/lib/stretchy/querying.rb +6 -5
- data/lib/stretchy/record.rb +8 -9
- data/lib/stretchy/relation.rb +11 -17
- data/lib/stretchy/relations/aggregation_methods.rb +758 -0
- data/lib/stretchy/relations/finder_methods.rb +21 -3
- data/lib/stretchy/relations/merger.rb +11 -7
- data/lib/stretchy/relations/query_builder.rb +48 -11
- data/lib/stretchy/relations/query_methods.rb +76 -38
- data/lib/stretchy/shared_scopes.rb +1 -1
- data/lib/stretchy/utils.rb +21 -0
- data/lib/stretchy/version.rb +1 -3
- data/lib/stretchy.rb +21 -11
- data/lib/stretchy_model.rb +9 -0
- metadata +39 -5
- data/lib/active_model/type/array.rb +0 -13
- data/lib/active_model/type/hash.rb +0 -15
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: dd18b424c18bda352233d72af113e9bbc944277e03a02cfb950780f0fab9a405
|
4
|
+
data.tar.gz: 6eb86522e7bc91012cc4c743d0a9fccc54c7e75b14ff4d6f4fca5bdb9fa31626
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 0fd2a0fb25439d79c799f43f43bb01df7c4dfe0ce9eb577d445b6d415eb9c38ecdc0f45c73d8255d2e079ff502e943f6491b874e3bb9c41fc9c853efed84d252
|
7
|
+
data.tar.gz: 8ea4cfa584ec825ac30aa4aa5dea88b04c709cbf7cc75089e28cd1e850318f150f1f68ead89bc571535739aa8ff8db777102d5dd05173087d4af499bac8d8bbb
|
data/.rspec
CHANGED
@@ -1 +1 @@
|
|
1
|
-
--require spec_helper
|
1
|
+
--require spec_helper
|
data/README.md
CHANGED
@@ -1,6 +1,5 @@
|
|
1
1
|
stretchy-model
|
2
2
|
===
|
3
|
-
|
4
3
|
<p>
|
5
4
|
<a href="https://stretchy.io/" target="_blank"><img src="./stretchy.logo.png" alt="Gum Image" width="450" /></a>
|
6
5
|
<br><br>
|
@@ -9,114 +8,42 @@ stretchy-model
|
|
9
8
|
|
10
9
|
</p>
|
11
10
|
|
12
|
-
Stretchy provides Elasticsearch models in a Rails environment with an integrated ActiveRecord-like interface and features.
|
13
11
|
|
14
12
|
## Features
|
15
13
|
Stretchy simplifies the process of querying, aggregating, and managing Elasticsearch-backed models, allowing Rails developers to work with search indices as comfortably as they would with traditional Rails models.
|
16
14
|
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
attribute :flagged, :boolean, default: false
|
25
|
-
attribute :author, :hash
|
26
|
-
attribute :tags, :array, default: []
|
27
|
-
|
28
|
-
end
|
29
|
-
```
|
30
|
-
>[!NOTE]
|
31
|
-
>`created_at`, `:updated_at` and `:id` are automatically added to all `Stretchy::Records`
|
32
|
-
|
33
|
-
|
34
|
-
## Query
|
35
|
-
```ruby
|
36
|
-
Post.where('author.name': "Jadzia", flagged: true).first
|
37
|
-
#=> <Post id: aW02w3092, title: "Fun Cats", body: "...", flagged: true,
|
38
|
-
# author: {name: "Jadzia", age: 20}, tags: ["cat", "amusing"]>
|
39
|
-
```
|
40
|
-
|
41
|
-
## Aggregations
|
42
|
-
```ruby
|
43
|
-
|
44
|
-
result = Post.filter(:range, 'author.age': {gte: 18})
|
45
|
-
.aggregation(:post_frequency, date_histogram: {
|
46
|
-
field: :created_at,
|
47
|
-
calender_interval: :month
|
48
|
-
})
|
15
|
+
* Model fully back by Elasticsearch/Opensearch
|
16
|
+
* Chain queries, scopes and aggregations
|
17
|
+
* Reduce Elasticsearch query complexity
|
18
|
+
* Support for time-based indices and aliases
|
19
|
+
* Associations to both ActiveRecord models and Stretchy::Record
|
20
|
+
* Bulk Operations made easy
|
21
|
+
* Validations, custom attributes, and more...
|
49
22
|
|
50
|
-
|
51
|
-
#=> {buckets: [{key_as_string: "2024-01-01", doc_count: 20}, ...]}
|
52
|
-
```
|
53
|
-
|
54
|
-
## Scopes
|
55
|
-
|
56
|
-
```ruby
|
57
|
-
class Post < Stretchy::Record
|
58
|
-
# ...attributes
|
59
|
-
|
60
|
-
# Scopes
|
61
|
-
scope :flagged, -> { where(flagged: true) }
|
62
|
-
scope :top_links, lambda do |size=10, url=".com"|
|
63
|
-
aggregation(:links,
|
64
|
-
terms: {
|
65
|
-
field: :links,
|
66
|
-
size: size,
|
67
|
-
include: ".*#{url}.*"
|
68
|
-
})
|
69
|
-
end
|
70
|
-
end
|
71
|
-
|
72
|
-
# Returns flagged posts and includes the top 10 'youtube.com'
|
73
|
-
# links in results.aggregations.links
|
74
|
-
result = Post.flagged.top_links(10, "youtube.com")
|
75
|
-
|
76
|
-
```
|
23
|
+
Follow the guides to learn more about:
|
77
24
|
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
Model.bulk(records_as_bulk_operations)
|
83
|
-
```
|
84
|
-
|
85
|
-
#### Bulk helper
|
86
|
-
Generates structure for the bulk operation
|
87
|
-
```ruby
|
88
|
-
record.to_bulk # default to_bulk(:index)
|
89
|
-
record.to_bulk(:delete)
|
90
|
-
record.to_bulk(:update)
|
91
|
-
```
|
25
|
+
* [Models](https://theablefew.github.io/stretchy/#/guides/models?id=models)
|
26
|
+
* [Querying](https://theablefew.github.io/stretchy/#/guides/querying?id=querying)
|
27
|
+
* [Aggregations](https://theablefew.github.io/stretchy/#/guides/aggregations?id=aggregations)
|
28
|
+
* [Scopes](https://theablefew.github.io/stretchy/#/guides/scopes?id=scopes)
|
92
29
|
|
93
|
-
#### In batches
|
94
|
-
Run bulk operations in batches specified by `size`
|
95
|
-
```ruby
|
96
|
-
Model.bulk_in_batches(records, size: 100) do |batch|
|
97
|
-
batch.map! { |record| Model.new(record).to_bulk }
|
98
|
-
end
|
99
|
-
```
|
100
30
|
|
31
|
+
[Read the Documentation](https://theablefew.github.io/stretchy/#/) or walk through of a simple [Data Analysis](https://theablefew.github.io/stretchy/#/examples/data_analysis?id=data-analysis) example.
|
101
32
|
|
102
|
-
## Instrumentation
|
103
|
-
```ruby
|
104
|
-
Blanket.first
|
105
|
-
```
|
106
33
|
|
107
|
-
```sh
|
108
|
-
Blanket (6.322ms) curl -X GET 'http://localhost:9200/blankets/_search?size=1' -d '{"sort":{"date":"desc"}}'
|
109
|
-
```
|
110
34
|
|
111
35
|
## Installation
|
112
36
|
|
113
37
|
Install the gem and add to the application's Gemfile by executing:
|
114
38
|
|
115
|
-
|
39
|
+
```sh
|
40
|
+
bundle add stretchy-model
|
41
|
+
```
|
116
42
|
|
117
43
|
If bundler is not being used to manage dependencies, install the gem by executing:
|
118
|
-
|
119
|
-
|
44
|
+
```sh
|
45
|
+
gem install stretchy-model
|
46
|
+
```
|
120
47
|
|
121
48
|
<details>
|
122
49
|
<summary>Rails Configuration</summary>
|
@@ -131,6 +58,12 @@ rails credentials:edit
|
|
131
58
|
```yaml
|
132
59
|
elasticsearch:
|
133
60
|
url: localhost:9200
|
61
|
+
|
62
|
+
# or opensearch
|
63
|
+
# opensearch:
|
64
|
+
# host: https://localhost:9200
|
65
|
+
# user: admin
|
66
|
+
# password: admin
|
134
67
|
```
|
135
68
|
|
136
69
|
#### Create an initializer
|
@@ -149,11 +82,24 @@ end
|
|
149
82
|
After checking out the repo, run `bin/setup` to install dependencies. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
150
83
|
|
151
84
|
>[!TIP]
|
152
|
-
>This library is built on top of the excellent [elasticsearch-persistence](https://github.com/elastic/elasticsearch-rails/tree/main/elasticsearch-persistence) gem.
|
153
|
-
>
|
154
85
|
> Full documentation on [Elasticsearch Query DSL and Aggregation options](https://github.com/elastic/elasticsearch-rails/tree/main/elasticsearch-persistence)
|
155
86
|
|
156
87
|
## Testing
|
88
|
+
<details>
|
89
|
+
<summary>Act</summary>
|
90
|
+
|
91
|
+
Run github action workflow locally
|
92
|
+
|
93
|
+
```sh
|
94
|
+
brew install act --HEAD
|
95
|
+
```
|
96
|
+
|
97
|
+
```sh
|
98
|
+
act -P ubuntu-latest=ghcr.io/catthehacker/ubuntu:runner-latest
|
99
|
+
```
|
100
|
+
|
101
|
+
</details>
|
102
|
+
|
157
103
|
<details>
|
158
104
|
<summary>Elasticsearch</summary>
|
159
105
|
|
data/Rakefile
CHANGED
@@ -2,3 +2,95 @@
|
|
2
2
|
|
3
3
|
require "bundler/gem_tasks"
|
4
4
|
task default: %i[]
|
5
|
+
|
6
|
+
require 'octokit'
|
7
|
+
require 'versionomy'
|
8
|
+
require 'rainbow'
|
9
|
+
|
10
|
+
def determine_current_version
|
11
|
+
# Load current version
|
12
|
+
load 'lib/stretchy/version.rb'
|
13
|
+
current_version = Versionomy.parse(Stretchy::VERSION)
|
14
|
+
end
|
15
|
+
|
16
|
+
def determine_new_version(version)
|
17
|
+
# Load current version
|
18
|
+
current_version = determine_current_version
|
19
|
+
|
20
|
+
# Determine new version
|
21
|
+
case version.to_sym
|
22
|
+
when :major
|
23
|
+
current_version.bump(:major)
|
24
|
+
when :minor
|
25
|
+
current_version.bump(:minor)
|
26
|
+
when :patch
|
27
|
+
current_version.bump(:tiny)
|
28
|
+
else
|
29
|
+
version =~ /\Av?\d+\.\d+\.\d+\z/ ? Versionomy.parse(version).to_s.gsub(/v/,'') : current_version
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
def create_release_branch(new_version, base_branch)
|
34
|
+
system("git stash save 'Changes before creating release branch'")
|
35
|
+
system("git fetch origin #{base_branch}")
|
36
|
+
branch_name = "release/v#{new_version}"
|
37
|
+
system("git checkout -b #{branch_name} #{base_branch}")
|
38
|
+
branch_name
|
39
|
+
end
|
40
|
+
|
41
|
+
def update_version_file(new_version)
|
42
|
+
# Update lib/stretchy/version.rb
|
43
|
+
File.open('lib/stretchy/version.rb', 'w') do |file|
|
44
|
+
file.puts "module Stretchy\n VERSION = '#{new_version}'\nend"
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
def commit_and_push_changes(new_version, branch_name)
|
49
|
+
system("git add lib/stretchy/version.rb")
|
50
|
+
system("git commit -m 'Bump version to v#{new_version}'")
|
51
|
+
system("git tag v#{new_version}")
|
52
|
+
system("git push origin #{branch_name} --tags -f")
|
53
|
+
end
|
54
|
+
|
55
|
+
def create_pull_request(new_version, base_branch, branch_name)
|
56
|
+
# Create a pull request
|
57
|
+
client = Octokit::Client.new(access_token: ENV['GH_TOKEN'])
|
58
|
+
client.create_pull_request('theablefew/stretchy', base_branch, branch_name, "Release v#{new_version}")
|
59
|
+
end
|
60
|
+
|
61
|
+
namespace :publish do
|
62
|
+
desc "Create a release"
|
63
|
+
task :release, [:version, :base_branch] do |t, args|
|
64
|
+
args.with_defaults(version: :patch, base_branch: 'main')
|
65
|
+
version = args[:version]
|
66
|
+
base_branch = args[:base_branch]
|
67
|
+
|
68
|
+
old_version = determine_current_version
|
69
|
+
new_version = determine_new_version(version)
|
70
|
+
puts Rainbow("Bumping version from #{old_version} to #{new_version}").green
|
71
|
+
branch_name = create_release_branch(new_version, base_branch)
|
72
|
+
begin
|
73
|
+
update_version_file(new_version)
|
74
|
+
commit_and_push_changes(new_version, branch_name)
|
75
|
+
create_pull_request(new_version, base_branch, branch_name)
|
76
|
+
rescue => e
|
77
|
+
puts "Error: #{e.message}"
|
78
|
+
puts "Rolling back changes"
|
79
|
+
system("git tag -d v#{new_version}")
|
80
|
+
system("git checkout #{base_branch}")
|
81
|
+
system("git branch -D #{branch_name}")
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
task :major do
|
86
|
+
Rake::Task['publish:release'].invoke('major')
|
87
|
+
end
|
88
|
+
|
89
|
+
task :minor do
|
90
|
+
Rake::Task['publish:release'].invoke('minor')
|
91
|
+
end
|
92
|
+
|
93
|
+
task :patch do
|
94
|
+
Rake::Task['publish:release'].invoke('patch')
|
95
|
+
end
|
96
|
+
end
|
@@ -3,7 +3,11 @@ module Stretchy
|
|
3
3
|
extend ActiveSupport::Concern
|
4
4
|
|
5
5
|
def save!
|
6
|
+
if valid?
|
6
7
|
self.save
|
8
|
+
else
|
9
|
+
raise "Record is invalid"
|
10
|
+
end
|
7
11
|
end
|
8
12
|
|
9
13
|
# Required for Elasticsearch < 7
|
@@ -34,7 +38,7 @@ module Stretchy
|
|
34
38
|
end
|
35
39
|
|
36
40
|
def association_reflection(association)
|
37
|
-
|
41
|
+
Stretchy::Relation.new @@_associations[association], (dirty[association.to_sym] || [])
|
38
42
|
end
|
39
43
|
|
40
44
|
def _destroy=(bool)
|
@@ -48,6 +52,7 @@ module Stretchy
|
|
48
52
|
def save_associations
|
49
53
|
@_after_save_objects.each_pair do |association, collection|
|
50
54
|
collection.each do |instance|
|
55
|
+
# TODO: bulk update
|
51
56
|
instance.send("#{@@_association_options[association.to_sym][:foreign_key]}=", self.id)
|
52
57
|
instance.save
|
53
58
|
end
|
@@ -59,59 +64,194 @@ module Stretchy
|
|
59
64
|
@@_associations ||= {}
|
60
65
|
@@_association_options ||= {}
|
61
66
|
|
67
|
+
# The belongs_to method is used to set up a one-to-one connection with another model.
|
68
|
+
# This indicates that this model has exactly one instance of another model.
|
69
|
+
# For example, if your application includes authors and books, and each book can be assigned exactly one author,
|
70
|
+
# you'd declare the book model to belong to the author model.
|
71
|
+
#
|
72
|
+
# association:: [Symbol] the name of the association
|
73
|
+
# options:: [Hash] a hash to set up options for the association
|
74
|
+
# :foreign_key - the foreign key used for the association. Defaults to "#{association}_id"
|
75
|
+
# :primary_key - the primary key used for the association. Defaults to "id"
|
76
|
+
# :class_name - the name of the associated object's class. Defaults to the name of the association
|
77
|
+
#
|
78
|
+
# Example:
|
79
|
+
# belongs_to :author
|
80
|
+
#
|
81
|
+
# This creates a book.author method that returns the author of the book.
|
82
|
+
# It also creates an author= method that allows you to assign the author of the book.
|
83
|
+
#
|
62
84
|
def belongs_to(association, options = {})
|
63
85
|
@@_association_options[association] = {
|
64
86
|
foreign_key: "#{association}_id",
|
65
87
|
primary_key: "id",
|
66
88
|
class_name: association
|
67
|
-
}.
|
89
|
+
}.merge(options)
|
68
90
|
|
69
91
|
klass = @@_association_options[association][:class_name].to_s.singularize.classify.constantize
|
70
92
|
@@_associations[association] = klass
|
71
93
|
|
72
94
|
define_method(association.to_sym) do
|
73
|
-
|
95
|
+
instance_variable_get("@#{association}") ||
|
96
|
+
klass.where(_id: self.send(@@_association_options[association][:foreign_key].to_sym)).first
|
74
97
|
end
|
75
98
|
|
76
99
|
define_method("#{association}=".to_sym) do |val|
|
77
100
|
options = @@_association_options[association]
|
78
|
-
|
101
|
+
self.send("#{options[:foreign_key]}=", val.send(options[:primary_key]))
|
102
|
+
instance_variable_set("@#{association}", val)
|
103
|
+
end
|
104
|
+
|
105
|
+
define_method("build_#{association}") do |*args|
|
106
|
+
associated_object = klass.new(*args)
|
107
|
+
instance_variable_set("@#{association}", associated_object)
|
108
|
+
associated_object
|
109
|
+
end
|
110
|
+
|
111
|
+
before_save do
|
112
|
+
associated_object = instance_variable_get("@#{association}")
|
113
|
+
if associated_object && associated_object.new_record?
|
114
|
+
if associated_object.save!
|
115
|
+
self.send("#{@@_association_options[association][:foreign_key]}=", associated_object.id)
|
116
|
+
end
|
117
|
+
end
|
79
118
|
end
|
80
119
|
end
|
81
120
|
|
82
|
-
def has_one(association, class_name: nil, foreign_key: nil, dependent: :destroy)
|
83
121
|
|
84
|
-
|
85
|
-
|
122
|
+
|
123
|
+
|
124
|
+
|
125
|
+
|
126
|
+
|
127
|
+
|
128
|
+
|
129
|
+
|
130
|
+
# The has_one method is used to set up a one-to-one connection with another model.
|
131
|
+
# This indicates that this model contains the foreign key.
|
132
|
+
#
|
133
|
+
# association:: [Symbol] The name of the association.
|
134
|
+
# options:: [Hash] A hash to set up options for the association.
|
135
|
+
# :class_name - The name of the associated model. If not provided, it's derived from +association+.
|
136
|
+
# :foreign_key - The name of the foreign key on the associated model. If not provided, it's derived from the name of this model.
|
137
|
+
# :dependent - If set to +:destroy+, the associated object will be destroyed when this object is destroyed. This is the default behavior.
|
138
|
+
# :primary_key - The name of the primary key on the associated model. If not provided, it's assumed to be +id+.
|
139
|
+
#
|
140
|
+
#
|
141
|
+
# Example:
|
142
|
+
# has_one :profile
|
143
|
+
#
|
144
|
+
# This creates a user.profile method that returns the profile of the user.
|
145
|
+
# It also creates a profile= method that allows you to assign the profile of the user.
|
146
|
+
#
|
147
|
+
def has_one(association, options = {})
|
148
|
+
|
149
|
+
@@_association_options[association] = {
|
150
|
+
foreign_key: "#{self.name.underscore}_id",
|
151
|
+
primary_key: "id",
|
152
|
+
class_name: association
|
153
|
+
}.merge(options)
|
154
|
+
|
155
|
+
klass = @@_association_options[association][:class_name].to_s.singularize.classify.constantize
|
86
156
|
@@_associations[association] = klass
|
87
157
|
|
158
|
+
foreign_key = @@_association_options[association][:foreign_key]
|
159
|
+
|
88
160
|
define_method(association.to_sym) do
|
89
|
-
|
161
|
+
instance_variable_get("@#{association}") ||
|
162
|
+
klass.where("#{foreign_key}": self.id).first
|
163
|
+
end
|
164
|
+
|
165
|
+
define_method("#{association}=".to_sym) do |val|
|
166
|
+
instance_variable_set("@#{association}", val)
|
167
|
+
save!
|
168
|
+
end
|
169
|
+
|
170
|
+
before_save do
|
171
|
+
associated_object = instance_variable_get("@#{association}")
|
172
|
+
if associated_object
|
173
|
+
associated_object.send("#{foreign_key}=", self.id)
|
174
|
+
associated_object.save!
|
175
|
+
end
|
90
176
|
end
|
91
177
|
end
|
92
178
|
|
93
|
-
def has_many(association, klass, options = {})
|
94
|
-
@@_associations[association] = klass
|
95
179
|
|
96
|
-
opt_fk = options.delete(:foreign_key)
|
97
|
-
foreign_key = opt_fk ? opt_fk : "#{self.name.split("::").last.tableize.singularize}_id"
|
98
180
|
|
99
|
-
|
181
|
+
|
182
|
+
|
183
|
+
|
184
|
+
|
185
|
+
|
186
|
+
|
187
|
+
# The has_many method is used to set up a one-to-many connection with another model.
|
188
|
+
# This indicates that this model can be matched with zero or more instances of another model.
|
189
|
+
# For example, if your application includes authors and books, and each author can have many books,
|
190
|
+
# you'd declare the author model to have many books.
|
191
|
+
#
|
192
|
+
# association:: [Symbol] the name of the association
|
193
|
+
# options:: [Hash] a hash to set up options for the association
|
194
|
+
# :foreign_key - the foreign key used for the association. Defaults to "#{self.name.downcase}_id"
|
195
|
+
# :primary_key - the primary key used for the association. Defaults to "id"
|
196
|
+
# :class_name - the name of the associated object's class. Defaults to the name of the association
|
197
|
+
# :dependent - if set to :destroy, the associated object will be destroyed when this object is destroyed. This is the default behavior.
|
198
|
+
#
|
199
|
+
#
|
200
|
+
# Example:
|
201
|
+
# has_many :books
|
202
|
+
#
|
203
|
+
# This creates an author.books method that returns a collection of books for the author.
|
204
|
+
# It also creates a books= method that allows you to assign the books for the author.
|
205
|
+
#
|
206
|
+
def has_many(association, options = {})
|
207
|
+
@@_association_options[association] = {
|
208
|
+
foreign_key: "#{self.name.underscore}_id",
|
209
|
+
primary_key: "id",
|
210
|
+
class_name: association.to_s.singularize.to_sym
|
211
|
+
}.merge(options)
|
212
|
+
|
213
|
+
klass = @@_association_options[association][:class_name].to_s.classify.constantize
|
214
|
+
foreign_key = @@_association_options[association][:foreign_key]
|
215
|
+
primary_key = @@_association_options[association][:primary_key]
|
216
|
+
@@_associations[association] = klass
|
100
217
|
|
101
218
|
define_method(association.to_sym) do
|
102
219
|
args = {}
|
103
|
-
args[
|
220
|
+
args["_#{primary_key}"] = self.send("#{association.to_s.singularize}_ids")
|
104
221
|
self.new_record? ? association_reflection(association) : klass.where(args)
|
105
222
|
end
|
106
223
|
|
224
|
+
define_method("#{association.to_s.singularize}_ids") do
|
225
|
+
instance_variable_get("@#{association.to_s.singularize}_ids".to_sym)
|
226
|
+
end
|
227
|
+
|
228
|
+
define_method("#{association.to_s.singularize}_ids=") do |val|
|
229
|
+
instance_variable_set("@#{association.to_s.singularize}_ids".to_sym, val)
|
230
|
+
end
|
231
|
+
|
232
|
+
define_method("#{association}=".to_sym) do |val|
|
233
|
+
val.each { |v| after_save_objects(v.attributes, association)}
|
234
|
+
self.send("#{association.to_s.singularize}_ids=", val.map(&:id))
|
235
|
+
dirty
|
236
|
+
end
|
237
|
+
|
107
238
|
define_method("build_#{association}".to_sym) do |*args|
|
108
239
|
opts = {}
|
109
240
|
opts[foreign_key] = self.id
|
110
241
|
args.first.merge! opts
|
111
242
|
klass.new *args
|
112
243
|
end
|
244
|
+
|
245
|
+
after_save do
|
246
|
+
save_associations
|
247
|
+
end
|
113
248
|
end
|
114
249
|
|
250
|
+
|
251
|
+
|
252
|
+
|
253
|
+
|
254
|
+
|
115
255
|
def validates_associated(*attr_names)
|
116
256
|
validates_with AssociatedValidator, _merge_attributes(attr_names)
|
117
257
|
end
|
@@ -131,7 +271,7 @@ module Stretchy
|
|
131
271
|
end
|
132
272
|
|
133
273
|
def reflect_on_association(association)
|
134
|
-
|
274
|
+
Stretchy::Relation.new @@_associations[association]
|
135
275
|
end
|
136
276
|
|
137
277
|
def update_all(records, **attributes)
|
@@ -0,0 +1,85 @@
|
|
1
|
+
module Stretchy
|
2
|
+
module Attributes
|
3
|
+
module Transformers
|
4
|
+
class KeywordTransformer
|
5
|
+
|
6
|
+
KEYWORD_AGGREGATION_KEYS = [:terms, :rare_terms, :significant_terms, :cardinality, :string_stats]
|
7
|
+
|
8
|
+
attr_reader :attribute_types
|
9
|
+
|
10
|
+
def initialize(attribute_types)
|
11
|
+
@attribute_types = attribute_types
|
12
|
+
end
|
13
|
+
|
14
|
+
def cast_value_keys
|
15
|
+
values.transform_values do |value|
|
16
|
+
case value
|
17
|
+
when Array
|
18
|
+
value.map { |item| transform_keys_for_item(item) }
|
19
|
+
when Hash
|
20
|
+
transform_keys_for_item(value)
|
21
|
+
else
|
22
|
+
value
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
def keyword?(arg)
|
28
|
+
attr = @attribute_types[arg.to_s]
|
29
|
+
return false unless attr
|
30
|
+
attr.is_a?(Stretchy::Attributes::Type::Keyword)
|
31
|
+
end
|
32
|
+
|
33
|
+
def protected?(arg)
|
34
|
+
return false if arg.nil?
|
35
|
+
Stretchy::Relations::AggregationMethods::AGGREGATION_METHODS.include?(arg.to_sym)
|
36
|
+
end
|
37
|
+
|
38
|
+
def transform(item, *ignore)
|
39
|
+
item.each_with_object({}) do |(k, v), new_item|
|
40
|
+
if ignore && ignore.include?(k)
|
41
|
+
new_item[k] = v
|
42
|
+
next
|
43
|
+
end
|
44
|
+
new_key = (!protected?(k) && keyword?(k)) ? "#{k}.keyword" : k
|
45
|
+
|
46
|
+
new_value = v
|
47
|
+
|
48
|
+
if new_value.is_a?(Hash)
|
49
|
+
new_value = transform(new_value)
|
50
|
+
elsif new_value.is_a?(Array)
|
51
|
+
new_value = new_value.map { |i| i.is_a?(Hash) ? transform(i) : i }
|
52
|
+
elsif new_value.is_a?(String) || new_value.is_a?(Symbol)
|
53
|
+
new_value = "#{new_value}.keyword" if keyword?(new_value)
|
54
|
+
end
|
55
|
+
|
56
|
+
new_item[new_key] = new_value
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
# If terms are used, we assume that the field is a keyword field
|
61
|
+
# and append .keyword to the field name
|
62
|
+
# {terms: {field: 'gender'}}
|
63
|
+
# or nested aggs
|
64
|
+
# {terms: {field: 'gender'}, aggs: {name: {terms: {field: 'position.name'}}}}
|
65
|
+
# should be converted to
|
66
|
+
# {terms: {field: 'gender.keyword'}, aggs: {name: {terms: {field: 'position.name.keyword'}}}}
|
67
|
+
# {date_histogram: {field: 'created_at', interval: 'day'}}
|
68
|
+
# TODO: There may be cases where we don't want to add .keyword to the field and there should be a way to override this
|
69
|
+
def assume_keyword_field(args={}, parent_match=false)
|
70
|
+
if args.is_a?(Hash)
|
71
|
+
args.each do |k, v|
|
72
|
+
if v.is_a?(Hash)
|
73
|
+
assume_keyword_field(v, KEYWORD_AGGREGATION_FIELDS.include?(k))
|
74
|
+
else
|
75
|
+
next unless v.is_a?(String) || v.is_a?(Symbol)
|
76
|
+
args[k] = ([:field, :fields].include?(k.to_sym) && v !~ /\.keyword$/ && parent_match) ? "#{v}.keyword" : v.to_s
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|