stretchy-model 0.3.0 → 0.5.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 +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
         |