├── .dev └── vagrant │ ├── minion │ └── salt │ ├── apt │ └── init.sls │ ├── dynamodb │ └── init.sls │ ├── rvm │ ├── .gemrc │ └── init.sls │ └── top.sls ├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── ci.yml │ ├── codeql.yml │ ├── coverage.yml │ ├── dependency-review.yml │ └── style.yml ├── .gitignore ├── .overcommit.yml ├── .rspec ├── .rubocop.yml ├── .rubocop_gemspec.yml ├── .rubocop_performance.yml ├── .rubocop_rspec.yml ├── .rubocop_thread_safety.yml ├── .rubocop_todo.yml ├── .ruby-version ├── .simplecov ├── .yardopts ├── Appraisals ├── CHANGELOG.md ├── Gemfile ├── Gemfile.lock ├── LICENSE.txt ├── README.md ├── Rakefile ├── SECURITY.md ├── Vagrantfile ├── bin ├── _dynamodblocal ├── console ├── setup ├── start_dynamodblocal └── stop_dynamodblocal ├── docker-compose.yml ├── dynamoid.gemspec ├── gemfiles ├── coverage.gemfile ├── rails_4_2.gemfile ├── rails_5_0.gemfile ├── rails_5_1.gemfile ├── rails_5_2.gemfile ├── rails_6_0.gemfile ├── rails_6_1.gemfile ├── rails_7_0.gemfile ├── rails_7_1.gemfile ├── rails_7_2.gemfile ├── rails_8_0.gemfile └── style.gemfile ├── lib ├── dynamoid.rb └── dynamoid │ ├── adapter.rb │ ├── adapter_plugin │ ├── aws_sdk_v3.rb │ └── aws_sdk_v3 │ │ ├── batch_get_item.rb │ │ ├── create_table.rb │ │ ├── execute_statement.rb │ │ ├── filter_expression_convertor.rb │ │ ├── item_updater.rb │ │ ├── middleware │ │ ├── backoff.rb │ │ ├── limit.rb │ │ └── start_key.rb │ │ ├── projection_expression_convertor.rb │ │ ├── query.rb │ │ ├── scan.rb │ │ ├── table.rb │ │ ├── transact.rb │ │ └── until_past_table_status.rb │ ├── application_time_zone.rb │ ├── associations.rb │ ├── associations │ ├── association.rb │ ├── belongs_to.rb │ ├── has_and_belongs_to_many.rb │ ├── has_many.rb │ ├── has_one.rb │ ├── many_association.rb │ └── single_association.rb │ ├── components.rb │ ├── config.rb │ ├── config │ ├── backoff_strategies │ │ ├── constant_backoff.rb │ │ └── exponential_backoff.rb │ └── options.rb │ ├── criteria.rb │ ├── criteria │ ├── chain.rb │ ├── key_fields_detector.rb │ ├── nonexistent_fields_detector.rb │ └── where_conditions.rb │ ├── dirty.rb │ ├── document.rb │ ├── dumping.rb │ ├── dynamodb_time_zone.rb │ ├── errors.rb │ ├── fields.rb │ ├── fields │ └── declare.rb │ ├── finders.rb │ ├── identity_map.rb │ ├── indexes.rb │ ├── loadable.rb │ ├── log │ └── formatter.rb │ ├── middleware │ └── identity_map.rb │ ├── persistence.rb │ ├── persistence │ ├── import.rb │ ├── inc.rb │ ├── item_updater_with_casting_and_dumping.rb │ ├── item_updater_with_dumping.rb │ ├── save.rb │ ├── update_fields.rb │ ├── update_validations.rb │ └── upsert.rb │ ├── primary_key_type_mapping.rb │ ├── railtie.rb │ ├── tasks.rb │ ├── tasks │ ├── database.rake │ └── database.rb │ ├── transaction_write.rb │ ├── transaction_write │ ├── base.rb │ ├── create.rb │ ├── delete_with_instance.rb │ ├── delete_with_primary_key.rb │ ├── destroy.rb │ ├── item_updater.rb │ ├── save.rb │ ├── update_attributes.rb │ ├── update_fields.rb │ └── upsert.rb │ ├── type_casting.rb │ ├── undumping.rb │ ├── validations.rb │ └── version.rb └── spec ├── app └── models │ ├── address.rb │ ├── bar.rb │ ├── cadillac.rb │ ├── camel_case.rb │ ├── car.rb │ ├── magazine.rb │ ├── message.rb │ ├── nuclear_submarine.rb │ ├── post.rb │ ├── sponsor.rb │ ├── subscription.rb │ ├── tweet.rb │ ├── user.rb │ └── vehicle.rb ├── dynamoid ├── adapter_plugin │ ├── aws_sdk_v3 │ │ ├── create_table_spec.rb │ │ └── until_past_table_status_spec.rb │ └── aws_sdk_v3_spec.rb ├── adapter_spec.rb ├── associations │ ├── association_spec.rb │ ├── belongs_to_spec.rb │ ├── has_and_belongs_to_many_spec.rb │ ├── has_many_spec.rb │ └── has_one_spec.rb ├── associations_spec.rb ├── before_type_cast_spec.rb ├── config │ └── backoff_strategies │ │ └── exponential_backoff_spec.rb ├── config_spec.rb ├── criteria │ └── chain_spec.rb ├── criteria_new_spec.rb ├── criteria_spec.rb ├── dirty_spec.rb ├── document_spec.rb ├── dumping_spec.rb ├── fields_spec.rb ├── finders_spec.rb ├── identity_map_spec.rb ├── indexes_spec.rb ├── loadable_spec.rb ├── log │ └── formatter │ │ └── debug_spec.rb ├── persistence_spec.rb ├── sti_spec.rb ├── tasks │ └── database_spec.rb ├── transaction_write │ ├── commit_spec.rb │ ├── create_spec.rb │ ├── delete_spec.rb │ ├── destroy_spec.rb │ ├── execute_spec.rb │ ├── save_spec.rb │ ├── update_attributes_spec.rb │ ├── update_fields_spec.rb │ └── upsert_spec.rb ├── type_casting_spec.rb └── validations_spec.rb ├── fixtures ├── dirty.rb ├── dumping.rb ├── fields.rb ├── indexes.rb └── persistence.rb ├── spec_helper.rb └── support ├── chain_helper.rb ├── clear_adapter_table_cache.rb ├── config.rb ├── delete_all_tables_in_namespace.rb ├── helpers ├── dumping_helper.rb ├── new_class_helper.rb └── persistence_helper.rb ├── log_level.rb ├── scratch_pad.rb └── unregister_declared_classes.rb /.dev/vagrant/minion: -------------------------------------------------------------------------------- 1 | # Masterless Minion Configuration File 2 | master: localhost 3 | id: development 4 | file_client: local 5 | 6 | # Where your salt state exists 7 | file_roots: 8 | base: 9 | - /vagrant/.dev/vagrant/salt 10 | -------------------------------------------------------------------------------- /.dev/vagrant/salt/apt/init.sls: -------------------------------------------------------------------------------- 1 | apt-pkgs: 2 | pkg.latest: 3 | - pkgs: 4 | - daemontools 5 | - git 6 | - openjdk-11-jre-headless 7 | - tmux 8 | - vim 9 | 10 | # JAVA_HOME 11 | /home/vagrant/.bashrc: 12 | file.append: 13 | - text: export JAVA_HOME="/usr/lib/jvm/java-11-openjdk-amd64" 14 | -------------------------------------------------------------------------------- /.dev/vagrant/salt/dynamodb/init.sls: -------------------------------------------------------------------------------- 1 | /opt/install/aws/dynamodb.tar.gz: 2 | file.managed: 3 | - source: https://s3-us-west-2.amazonaws.com/dynamodb-local/dynamodb_local_2019-02-07.tar.gz 4 | - source_hash: sha256=3281b5403d0d397959ce444b86a83b44bc521e8b40077a3c2094fa17c9eb3c43 5 | - makedirs: True 6 | 7 | /vagrant/spec/DynamoDBLocal-latest: 8 | file.directory: 9 | - name: /vagrant/spec/DynamoDBLocal-latest 10 | - user: vagrant 11 | - group: vagrant 12 | 13 | dynamodb.install: 14 | cmd.wait: 15 | - name: cd /vagrant/spec/DynamoDBLocal-latest && tar xfz /opt/install/aws/dynamodb.tar.gz 16 | - watch: 17 | - file: /opt/install/aws/dynamodb.tar.gz 18 | -------------------------------------------------------------------------------- /.dev/vagrant/salt/rvm/.gemrc: -------------------------------------------------------------------------------- 1 | gem: --no-ri --no-rdoc 2 | -------------------------------------------------------------------------------- /.dev/vagrant/salt/rvm/init.sls: -------------------------------------------------------------------------------- 1 | # https://docs.saltstack.com/en/latest/ref/states/all/salt.states.rvm.html 2 | rvm-deps: 3 | pkg.installed: 4 | - pkgs: 5 | - bash 6 | - coreutils 7 | - gzip 8 | - bzip2 9 | - gawk 10 | - sed 11 | - curl 12 | - git 13 | - subversion 14 | - gnupg2 15 | 16 | mri-deps: 17 | pkg.installed: 18 | - pkgs: 19 | - build-essential 20 | - openssl 21 | - libreadline-dev 22 | - curl 23 | - git 24 | - zlib1g 25 | - zlib1g-dev 26 | - libssl-dev 27 | - libyaml-dev 28 | - libsqlite3-0 29 | - libsqlite3-dev 30 | - sqlite3 31 | - libxml2-dev 32 | - libxslt1-dev 33 | - autoconf 34 | - libc6-dev 35 | - libncurses5-dev 36 | - automake 37 | - libtool 38 | - bison 39 | - subversion 40 | - ruby 41 | 42 | gpg-trust: 43 | cmd.run: 44 | - cwd: /home/vagrant 45 | - name: gpg2 --recv-keys 409B6B1796C275462A1703113804BB82D39DC0E3 7D2BAF1CF37B13E2069D6956105BD0E739499BDB 46 | - runas: vagrant 47 | 48 | ruby-{{ pillar['ruby']['version'] }}: 49 | rvm.installed: 50 | - name: {{ pillar['ruby']['version'] }} 51 | - default: True 52 | - user: vagrant 53 | - require: 54 | - pkg: rvm-deps 55 | - pkg: mri-deps 56 | 57 | # Disable Documentation Installation 58 | /home/vagrant/.gemrc: 59 | file.managed: 60 | - user: vagrant 61 | - group: vagrant 62 | - name: /home/vagrant/.gemrc 63 | - source: salt://rvm/.gemrc 64 | - makedirs: True 65 | 66 | # Bundler 67 | bundler.install: 68 | gem.installed: 69 | - user: vagrant 70 | - name: bundler 71 | - ruby: ruby-{{ pillar['ruby']['version'] }} 72 | - rdoc: false 73 | - ri: false 74 | 75 | bundle: 76 | cmd.run: 77 | - cwd: /vagrant 78 | - name: bundle install 79 | - runas: vagrant 80 | -------------------------------------------------------------------------------- /.dev/vagrant/salt/top.sls: -------------------------------------------------------------------------------- 1 | base: 2 | 'development': 3 | - apt 4 | - dynamodb 5 | - rvm 6 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [Dynamoid, pboling] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: dynamoid # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: rubygems/dynamoid # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 14 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: bundler 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "04:31" 8 | open-pull-requests-limit: 10 -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ "master" ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ "master" ] 20 | schedule: 21 | - cron: '29 13 * * 1' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'ruby' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Use only 'java' to analyze code written in Java, Kotlin or both 38 | # Use only 'javascript' to analyze code written in JavaScript, TypeScript or both 39 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 40 | 41 | steps: 42 | - name: Checkout repository 43 | uses: actions/checkout@v3 44 | 45 | # Initializes the CodeQL tools for scanning. 46 | - name: Initialize CodeQL 47 | uses: github/codeql-action/init@v2 48 | with: 49 | languages: ${{ matrix.language }} 50 | # If you wish to specify custom queries, you can do so here or in a config file. 51 | # By default, queries listed here will override any specified in a config file. 52 | # Prefix the list here with "+" to use these queries and those in the config file. 53 | 54 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 55 | # queries: security-extended,security-and-quality 56 | 57 | 58 | # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). 59 | # If this step fails, then you should remove it and run the build manually (see below) 60 | - name: Autobuild 61 | uses: github/codeql-action/autobuild@v2 62 | 63 | # ℹ️ Command-line programs to run using the OS shell. 64 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 65 | 66 | # If the Autobuild fails above, remove it and uncomment the following three lines. 67 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 68 | 69 | # - run: | 70 | # echo "Run, Build Application using script" 71 | # ./location_of_script_within_repo/buildscript.sh 72 | 73 | - name: Perform CodeQL Analysis 74 | uses: github/codeql-action/analyze@v2 75 | with: 76 | category: "/language:${{matrix.language}}" 77 | -------------------------------------------------------------------------------- /.github/workflows/coverage.yml: -------------------------------------------------------------------------------- 1 | name: Code Coverage 2 | 3 | env: 4 | CI_CODECOV: true 5 | COVER_ALL: true 6 | 7 | on: 8 | push: 9 | branches: 10 | - 'main' 11 | - 'master' 12 | tags: 13 | - '!*' # Do not execute on tags 14 | pull_request: 15 | branches: 16 | - '*' 17 | # Allow manually triggering the workflow. 18 | workflow_dispatch: 19 | 20 | # Cancels all previous workflow runs for the same branch that have not yet completed. 21 | concurrency: 22 | # The concurrency group contains the workflow name and the branch name. 23 | group: ${{ github.workflow }}-${{ github.ref }} 24 | cancel-in-progress: true 25 | 26 | jobs: 27 | test: 28 | name: Specs with Coverage - Ruby ${{ matrix.ruby }} ${{ matrix.name_extra || '' }} 29 | if: "!contains(github.event.commits[0].message, '[ci skip]') && !contains(github.event.commits[0].message, '[skip ci]')" 30 | strategy: 31 | fail-fast: false 32 | matrix: 33 | experimental: [false] 34 | rubygems: 35 | - latest 36 | bundler: 37 | - latest 38 | ruby: 39 | - "2.7" 40 | 41 | runs-on: ubuntu-latest 42 | env: # $BUNDLE_GEMFILE must be set at the job level, so it is set for all steps 43 | BUNDLE_GEMFILE: ${{ github.workspace }}/gemfiles/coverage.gemfile 44 | steps: 45 | - uses: amancevice/setup-code-climate@v0 46 | name: CodeClimate Install 47 | if: matrix.ruby == '2.7' && github.event_name != 'pull_request' && always() 48 | with: 49 | cc_test_reporter_id: ${{ secrets.CC_TEST_REPORTER_ID }} 50 | 51 | - name: Checkout 52 | uses: actions/checkout@v3 53 | 54 | - name: Setup Ruby & Bundle 55 | uses: ruby/setup-ruby@v1 56 | with: 57 | ruby-version: ${{ matrix.ruby }} 58 | rubygems: ${{ matrix.rubygems }} 59 | bundler: ${{ matrix.bundler }} 60 | bundler-cache: true 61 | 62 | - name: CodeClimate Pre-build Notification 63 | run: cc-test-reporter before-build 64 | if: matrix.ruby == '2.7' && github.event_name != 'pull_request' && always() 65 | continue-on-error: ${{ matrix.experimental != 'false' }} 66 | 67 | - name: Start dynamodb-local 68 | run: | 69 | docker compose up -d 70 | 71 | - name: Run RSpec tests 72 | run: | 73 | bundle exec rspec 74 | 75 | - name: Stop dynamodb-local 76 | run: | 77 | docker compose down 78 | 79 | - name: CodeClimate Post-build Notification 80 | run: cc-test-reporter after-build 81 | if: matrix.ruby == '2.7' && github.event_name != 'pull_request' && always() 82 | continue-on-error: ${{ matrix.experimental != 'false' }} 83 | 84 | - name: Code Coverage Summary Report 85 | uses: irongut/CodeCoverageSummary@v1.2.0 86 | with: 87 | filename: ./coverage/coverage.xml 88 | badge: true 89 | fail_below_min: true 90 | format: markdown 91 | hide_branch_rate: true 92 | hide_complexity: true 93 | indicators: true 94 | output: both 95 | thresholds: '90 89' 96 | continue-on-error: ${{ matrix.experimental != 'false' }} 97 | 98 | - name: Add Coverage PR Comment 99 | uses: marocchino/sticky-pull-request-comment@v2 100 | if: matrix.ruby == '2.7' && always() 101 | with: 102 | recreate: true 103 | path: code-coverage-results.md 104 | continue-on-error: ${{ matrix.experimental != 'false' }} 105 | 106 | - name: Coveralls 107 | uses: coverallsapp/github-action@master 108 | if: matrix.ruby == '2.7' && github.event_name != 'pull_request' && always() 109 | with: 110 | github-token: ${{ secrets.GITHUB_TOKEN }} 111 | continue-on-error: ${{ matrix.experimental != 'false' }} 112 | -------------------------------------------------------------------------------- /.github/workflows/dependency-review.yml: -------------------------------------------------------------------------------- 1 | # Dependency Review Action 2 | # 3 | # This Action will scan dependency manifest files that change as part of a Pull Request, surfacing known-vulnerable versions of the packages declared or updated in the PR. Once installed, if the workflow run is marked as required, PRs introducing known-vulnerable packages will be blocked from merging. 4 | # 5 | # Source repository: https://github.com/actions/dependency-review-action 6 | # Public documentation: https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/about-dependency-review#dependency-review-enforcement 7 | name: 'Dependency Review' 8 | on: [pull_request] 9 | 10 | permissions: 11 | contents: read 12 | 13 | jobs: 14 | dependency-review: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: 'Checkout Repository' 18 | uses: actions/checkout@v3 19 | - name: 'Dependency Review' 20 | uses: actions/dependency-review-action@v2 21 | -------------------------------------------------------------------------------- /.github/workflows/style.yml: -------------------------------------------------------------------------------- 1 | name: Code Style Checks 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'main' 7 | - 'master' 8 | tags: 9 | - '!*' # Do not execute on tags 10 | pull_request: 11 | branches: 12 | - '*' 13 | 14 | jobs: 15 | rubocop: 16 | name: Rubocop 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | experimental: [false] 21 | rubygems: 22 | - latest 23 | bundler: 24 | - latest 25 | ruby: 26 | - "3.4" 27 | runs-on: ubuntu-latest 28 | env: # $BUNDLE_GEMFILE must be set at the job level, so it is set for all steps 29 | BUNDLE_GEMFILE: ${{ github.workspace }}/gemfiles/style.gemfile 30 | steps: 31 | - name: Checkout 32 | uses: actions/checkout@v3 33 | - name: Setup Ruby & Bundle 34 | uses: ruby/setup-ruby@v1 35 | with: 36 | ruby-version: ${{ matrix.ruby }} 37 | rubygems: ${{ matrix.rubygems }} 38 | bundler: ${{ matrix.bundler }} 39 | bundler-cache: true 40 | - name: Run Rubocop 41 | run: bundle exec rubocop -DESP 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .project 2 | 3 | # rcov generated 4 | coverage 5 | 6 | # rdoc generated 7 | rdoc 8 | 9 | # yardoc generated 10 | .yardoc 11 | /_yardoc/ 12 | 13 | # bundler 14 | /.bundle/ 15 | 16 | # jeweler generated 17 | /pkg/ 18 | 19 | # Have editor/IDE/OS specific files you need to ignore? Consider using a global gitignore: 20 | # 21 | # * Create a file at ~/.gitignore 22 | # * Include files you want ignored 23 | # * Run: git config --global core.excludesfile ~/.gitignore 24 | # 25 | # After doing this, these files will be ignored in all your git projects, 26 | # saving you from having to 'pollute' every project you touch with them 27 | # 28 | # Not sure what to needs to be ignored for particular editors/OSes? Here's some ideas to get you started. (Remember, remove the leading # of the line) 29 | # 30 | # For MacOS: 31 | # 32 | #.DS_Store 33 | 34 | # For TextMate 35 | #*.tmproj 36 | #tmtags 37 | 38 | # For emacs: 39 | #*~ 40 | #\#* 41 | #.\#* 42 | 43 | # For vim: 44 | #*.swp 45 | 46 | # For redcar: 47 | #.redcar 48 | 49 | # For rubinius: 50 | #*.rbc 51 | 52 | # for RVM 53 | .rvmrc 54 | 55 | # For RubyMine: 56 | /.idea/ 57 | 58 | # For Ctags 59 | .gemtags 60 | .tags 61 | .tags_sorted_by_file 62 | 63 | /doc/ 64 | /spec/reports/ 65 | /tmp/ 66 | /spec/DynamoDBLocal-latest/ 67 | /vendor/ 68 | 69 | # For vagrant 70 | .vagrant 71 | 72 | # For Appraisals 73 | gemfiles/*.gemfile.lock 74 | -------------------------------------------------------------------------------- /.overcommit.yml: -------------------------------------------------------------------------------- 1 | # Use this file to configure the Overcommit hooks you wish to use. This will 2 | # extend the default configuration defined in: 3 | # https://github.com/sds/overcommit/blob/master/config/default.yml 4 | # 5 | # At the topmost level of this YAML file is a key representing type of hook 6 | # being run (e.g. pre-commit, commit-msg, etc.). Within each type you can 7 | # customize each hook, such as whether to only run it on certain files (via 8 | # `include`), whether to only display output if it fails (via `quiet`), etc. 9 | # 10 | # For a complete list of hooks, see: 11 | # https://github.com/sds/overcommit/tree/master/lib/overcommit/hook 12 | # 13 | # For a complete list of options that you can use to customize hooks, see: 14 | # https://github.com/sds/overcommit#configuration 15 | # 16 | # Uncomment the following lines to make the configuration take effect. 17 | 18 | #PreCommit: 19 | # RuboCop: 20 | # enabled: true 21 | # on_warn: fail # Treat all warnings as failures 22 | # 23 | TrailingWhitespace: 24 | enabled: true 25 | # exclude: 26 | # - '**/db/structure.sql' # Ignore trailing whitespace in generated files 27 | # 28 | #PostCheckout: 29 | # ALL: # Special hook name that customizes all hooks of this type 30 | # quiet: true # Change all post-checkout hooks to only display output on failure 31 | # 32 | # IndexTags: 33 | # enabled: true # Generate a tags file with `ctags` each time HEAD changes 34 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | # We chose not to make these changes 2 | inherit_from: 3 | - .rubocop_gemspec.yml 4 | - .rubocop_performance.yml 5 | - .rubocop_rspec.yml 6 | - .rubocop_thread_safety.yml 7 | - .rubocop_todo.yml 8 | 9 | require: 10 | - rubocop-packaging 11 | 12 | plugins: 13 | - rubocop-md 14 | - rubocop-performance 15 | - rubocop-rake 16 | - rubocop-rspec 17 | - rubocop-thread_safety 18 | 19 | # It's the lowest supported Ruby version 20 | AllCops: 21 | DisplayCopNames: true # Display the name of the failing cops 22 | TargetRubyVersion: 2.3 23 | NewCops: enable 24 | 25 | # It's a matter of taste 26 | Layout/ParameterAlignment: 27 | EnforcedStyle: with_fixed_indentation 28 | Layout/HashAlignment: 29 | Enabled: false 30 | Lint/RaiseException: 31 | Enabled: true 32 | Lint/StructNewOverride: 33 | Enabled: true 34 | Style/HashEachMethods: 35 | Enabled: true 36 | Style/HashTransformKeys: 37 | Enabled: true 38 | Style/HashTransformValues: 39 | Enabled: true 40 | Style/GuardClause: 41 | Enabled: false 42 | Style/FormatStringToken: 43 | Enabled: false 44 | Style/DoubleNegation: 45 | Enabled: false 46 | Style/IfUnlessModifier: 47 | Enabled: false 48 | Style/EachWithObject: 49 | Enabled: false 50 | Style/SafeNavigation: 51 | Enabled: false 52 | Style/BlockDelimiters: 53 | Enabled: false 54 | Layout/MultilineMethodCallIndentation: 55 | EnforcedStyle: indented 56 | Naming/VariableNumber: 57 | Enabled: false 58 | Style/MultilineBlockChain: 59 | Enabled: false 60 | Style/TrailingCommaInHashLiteral: 61 | Enabled: false 62 | Style/TrailingCommaInArrayLiteral: 63 | Enabled: false 64 | Style/TrailingCommaInArguments: 65 | Enabled: false 66 | Style/UnlessElse: 67 | Enabled: false 68 | 69 | # We aren't so brave to tackle all these issues right now 70 | Layout/LineLength: 71 | Enabled: false 72 | Metrics/BlockLength: 73 | Enabled: false 74 | Metrics/MethodLength: 75 | Enabled: false 76 | Metrics/CyclomaticComplexity: 77 | Enabled: false 78 | Metrics/AbcSize: 79 | Enabled: false 80 | Metrics/ModuleLength: 81 | Enabled: false 82 | Metrics/BlockNesting: 83 | Enabled: false 84 | Metrics/PerceivedComplexity: 85 | Enabled: false 86 | Metrics/ClassLength: 87 | Enabled: false 88 | 89 | # Minor annoying issues 90 | Lint/UselessAssignment: 91 | Enabled: false 92 | Lint/AmbiguousBlockAssociation: 93 | Enabled: false 94 | Lint/AssignmentInCondition: 95 | Enabled: false 96 | Style/Documentation: 97 | Enabled: false 98 | Style/DateTime: 99 | Enabled: false 100 | Style/MissingRespondToMissing: 101 | Enabled: false 102 | Naming/PredicatePrefix: 103 | Enabled: false 104 | Naming/PredicateMethod: 105 | Enabled: false 106 | Security/YAMLLoad: 107 | Enabled: false 108 | 109 | Lint/EmptyClass: 110 | Exclude: 111 | - README.md 112 | Lint/EmptyBlock: 113 | Exclude: 114 | - README.md 115 | 116 | -------------------------------------------------------------------------------- /.rubocop_gemspec.yml: -------------------------------------------------------------------------------- 1 | # specifying Ruby version would be a breaking change 2 | # we may consider adding `required_ruby_version` in the next major release 3 | Gemspec/RequiredRubyVersion: 4 | Enabled: false 5 | 6 | # development dependencies specified in the gemspec file are shared 7 | # by the main Gemfile and gemfiles in the gemfiles/ directory that are used on CI 8 | Gemspec/DevelopmentDependencies: 9 | Enabled: false 10 | -------------------------------------------------------------------------------- /.rubocop_performance.yml: -------------------------------------------------------------------------------- 1 | # See: https://github.com/rubocop/rubocop-performance/issues/322 2 | Performance/RegexpMatch: 3 | Enabled: false 4 | 5 | Performance/MethodObjectAsBlock: 6 | Enabled: false 7 | -------------------------------------------------------------------------------- /.rubocop_rspec.yml: -------------------------------------------------------------------------------- 1 | RSpec/SpecFilePathFormat: 2 | Enabled: false 3 | 4 | RSpec/SpecFilePathSuffix: 5 | Enabled: false 6 | 7 | RSpec/MultipleExpectations: 8 | Enabled: false 9 | 10 | RSpec/MultipleMemoizedHelpers: 11 | Enabled: false 12 | 13 | RSpec/NamedSubject: 14 | Enabled: false 15 | 16 | RSpec/ExampleLength: 17 | Enabled: false 18 | 19 | RSpec/VerifiedDoubles: 20 | Enabled: false 21 | 22 | RSpec/MessageSpies: 23 | Enabled: false 24 | 25 | RSpec/InstanceVariable: 26 | Enabled: false 27 | 28 | RSpec/NestedGroups: 29 | Enabled: false 30 | 31 | RSpec/ExpectInHook: 32 | Enabled: false 33 | 34 | RSpec/ExpectInLet: 35 | Enabled: false 36 | 37 | # NOTE: for many tests of equality `eql` works, while `be` does not, because 38 | # expected # => 101 39 | # got # => 101.0 (0.101e3) 40 | RSpec/BeEql: 41 | Enabled: false 42 | 43 | RSpec/BeEq: 44 | Enabled: false 45 | 46 | RSpec/StubbedMock: 47 | Enabled: false 48 | 49 | RSpec/IndexedLet: 50 | Enabled: false 51 | -------------------------------------------------------------------------------- /.rubocop_thread_safety.yml: -------------------------------------------------------------------------------- 1 | # It would be good to make the gem more thread safe, but at the moment it is not entirely. 2 | # TODO: Comment out the following to see code needing to be refactored for thread safety! 3 | ThreadSafety/ClassInstanceVariable: 4 | Enabled: false 5 | ThreadSafety/ClassAndModuleAttributes: 6 | Enabled: false 7 | -------------------------------------------------------------------------------- /.rubocop_todo.yml: -------------------------------------------------------------------------------- 1 | # This configuration was generated by 2 | # `rubocop --auto-gen-config` 3 | # on 2022-12-16 17:45:21 -0700 using RuboCop version 0.81.0. 4 | # The point is for the user to remove these configuration records 5 | # one by one as the offenses are removed from the code base. 6 | # Note that changes in the inspected code, or installation of new 7 | # versions of RuboCop, may require this file to be generated again. 8 | 9 | # Offense count: 1 10 | # Cop supports --auto-correct. 11 | Lint/OrderedMagicComments: 12 | Exclude: 13 | - 'lib/dynamoid/persistence.rb' 14 | 15 | # Offense count: 3 16 | # Configuration parameters: AllowComments. 17 | Lint/SuppressedException: 18 | Exclude: 19 | - 'lib/dynamoid/dirty.rb' 20 | - 'lib/dynamoid/persistence/update_fields.rb' 21 | - 'lib/dynamoid/persistence/upsert.rb' 22 | 23 | # Offense count: 1 24 | # Configuration parameters: EnforcedStyleForLeadingUnderscores. 25 | # SupportedStylesForLeadingUnderscores: disallowed, required, optional 26 | Naming/MemoizedInstanceVariableName: 27 | Exclude: 28 | - 'lib/dynamoid/dirty.rb' 29 | 30 | # Offense count: 13 31 | RSpec/AnyInstance: 32 | Exclude: 33 | - 'spec/dynamoid/adapter_plugin/aws_sdk_v3_spec.rb' 34 | - 'spec/dynamoid/adapter_spec.rb' 35 | - 'spec/dynamoid/criteria/chain_spec.rb' 36 | - 'spec/dynamoid/persistence_spec.rb' 37 | 38 | # Offense count: 125 39 | # Configuration parameters: Prefixes. 40 | # Prefixes: when, with, without 41 | RSpec/ContextWording: 42 | Enabled: false 43 | 44 | # Offense count: 2 45 | RSpec/DescribeClass: 46 | Exclude: 47 | - 'spec/dynamoid/before_type_cast_spec.rb' 48 | - 'spec/dynamoid/type_casting_spec.rb' 49 | 50 | # Offense count: 4 51 | # Configuration parameters: CustomIncludeMethods. 52 | RSpec/EmptyExampleGroup: 53 | Exclude: 54 | - 'spec/dynamoid/persistence_spec.rb' 55 | - 'spec/dynamoid/type_casting_spec.rb' 56 | 57 | # Offense count: 8 58 | RSpec/LeakyConstantDeclaration: 59 | Exclude: 60 | - 'spec/dynamoid/criteria/chain_spec.rb' 61 | - 'spec/dynamoid/indexes_spec.rb' 62 | - 'spec/dynamoid/sti_spec.rb' 63 | 64 | # Offense count: 1 65 | RSpec/LetSetup: 66 | Exclude: 67 | - 'spec/dynamoid/sti_spec.rb' 68 | 69 | # Offense count: 2 70 | RSpec/RepeatedDescription: 71 | Exclude: 72 | - 'spec/dynamoid/associations/belongs_to_spec.rb' 73 | 74 | # Offense count: 2 75 | RSpec/RepeatedExample: 76 | Exclude: 77 | - 'spec/dynamoid/associations/has_one_spec.rb' 78 | 79 | # Offense count: 6 80 | RSpec/RepeatedExampleGroupDescription: 81 | Exclude: 82 | - 'spec/dynamoid/adapter_plugin/aws_sdk_v3_spec.rb' 83 | - 'spec/dynamoid/finders_spec.rb' 84 | 85 | # Offense count: 9 86 | RSpec/SubjectStub: 87 | Exclude: 88 | - 'spec/dynamoid/adapter_spec.rb' 89 | 90 | # Offense count: 2 91 | Style/CommentedKeyword: 92 | Exclude: 93 | - 'lib/dynamoid/dirty.rb' 94 | 95 | # Offense count: 2 96 | # Cop supports --auto-correct. 97 | # Configuration parameters: EnforcedStyle, Autocorrect. 98 | # SupportedStyles: module_function, extend_self, forbidden 99 | Style/ModuleFunction: 100 | Exclude: 101 | - 'lib/dynamoid.rb' 102 | - 'lib/dynamoid/config.rb' 103 | 104 | # Offense count: 3 105 | Style/OptionalArguments: 106 | Exclude: 107 | - 'lib/dynamoid/persistence.rb' 108 | 109 | # Offense count: 1 110 | # Cop supports --auto-correct. 111 | # Configuration parameters: AllowAsExpressionSeparator. 112 | Style/Semicolon: 113 | Exclude: 114 | - 'spec/dynamoid/adapter_plugin/aws_sdk_v3_spec.rb' 115 | 116 | # Offense count: 1 117 | # Cop supports --auto-correct. 118 | # Configuration parameters: ExactNameMatch, AllowPredicates, AllowDSLWriters, IgnoreClassMethods, AllowedMethods. 119 | # AllowedMethods: to_ary, to_a, to_c, to_enum, to_h, to_hash, to_i, to_int, to_io, to_open, to_path, to_proc, to_r, to_regexp, to_str, to_s, to_sym 120 | Style/TrivialAccessors: 121 | Exclude: 122 | - 'lib/dynamoid/adapter_plugin/aws_sdk_v3.rb' 123 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.3.0 2 | -------------------------------------------------------------------------------- /.simplecov: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # To get coverage 4 | # On Local, default (HTML) output coverage is turned on with Ruby 2.6+: 5 | # bundle exec rspec spec 6 | # On Local, all output formats with Ruby 2.6+: 7 | # COVER_ALL=true bundle exec rspec spec 8 | # 9 | # On CI, all output formats, the ENV variables CI is always set, 10 | # and COVER_ALL, and CI_CODECOV, are set in the coverage.yml workflow only, 11 | # so coverage only runs in that workflow, and outputs all formats. 12 | # 13 | 14 | if RUN_COVERAGE 15 | SimpleCov.start do 16 | enable_coverage :branch 17 | primary_coverage :branch 18 | add_filter 'spec' 19 | # Why exclude version.rb? See: https://github.com/simplecov-ruby/simplecov/issues/557#issuecomment-410105995 20 | add_filter 'lib/dynamoid/version.rb' 21 | track_files '**/*.rb' 22 | 23 | if ALL_FORMATTERS 24 | command_name "#{ENV.fetch('GITHUB_WORKFLOW')} Job #{ENV.fetch('GITHUB_RUN_ID')}:#{ENV.fetch('GITHUB_RUN_NUMBER')}" 25 | else 26 | formatter SimpleCov::Formatter::HTMLFormatter 27 | end 28 | 29 | minimum_coverage(line: 90, branch: 89) 30 | end 31 | else 32 | puts "Not running coverage on #{RUBY_VERSION}-#{RUBY_ENGINE}" 33 | end 34 | -------------------------------------------------------------------------------- /.yardopts: -------------------------------------------------------------------------------- 1 | --no-private 2 | -------------------------------------------------------------------------------- /Appraisals: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | appraise 'rails-4-2' do 4 | gem 'activemodel', '~> 4.2.0' 5 | 6 | # Add bigdecimal gem to support Ruby 2.7 and above: 7 | # https://github.com/rails/rails/issues/34822 8 | 9 | # Compatibility with Ruby versions: 10 | # https://github.com/ruby/bigdecimal#which-version-should-you-select 11 | # 12 | # Actually bigdecimal 1.4.x works on all the Ruby versions till Ruby 3.0 13 | gem 'bigdecimal', '~> 1.4.0', platform: :mri 14 | end 15 | 16 | appraise 'rails-5-0' do 17 | gem 'activemodel', '~> 5.0.0' 18 | end 19 | 20 | appraise 'rails-5-1' do 21 | gem 'activemodel', '~> 5.1.0' 22 | end 23 | 24 | appraise 'rails-5-2' do 25 | gem 'activemodel', '~> 5.2.0' 26 | end 27 | 28 | appraise 'rails-6-0' do 29 | gem 'activemodel', '~> 6.0.0' 30 | 31 | # Since Ruby 3.4 these dependencies are bundled gems so should be specified explicitly. 32 | gem 'mutex_m' 33 | gem 'base64' 34 | gem 'bigdecimal' 35 | end 36 | 37 | appraise 'rails-6-1' do 38 | gem 'activemodel', '~> 6.1.0' 39 | 40 | # Since Ruby 3.4 these dependencies are bundled gems so should be specified explicitly. 41 | gem 'mutex_m' 42 | gem 'base64' 43 | gem 'bigdecimal' 44 | end 45 | 46 | appraise 'rails-7-0' do 47 | gem 'activemodel', '~> 7.0.0' 48 | 49 | # Since Ruby 3.4 these dependencies are bundled gems so should be specified explicitly. 50 | gem 'mutex_m' 51 | gem 'base64' 52 | gem 'bigdecimal' 53 | end 54 | 55 | appraise 'rails-7-1' do 56 | gem 'activemodel', '~> 7.1.0' 57 | 58 | # Since Ruby 3.4 these dependencies are bundled gems so should be specified explicitly. 59 | gem 'mutex_m' 60 | gem 'base64' 61 | gem 'bigdecimal' 62 | end 63 | 64 | appraise 'rails-7-2' do 65 | gem 'activemodel', '~> 7.2.0' 66 | 67 | # Since Ruby 3.4 these dependencies are bundled gems so should be specified explicitly. 68 | gem 'mutex_m' 69 | gem 'base64' 70 | gem 'bigdecimal' 71 | end 72 | 73 | appraise 'rails-8-0' do 74 | gem 'activemodel', '~> 8.0.0' 75 | 76 | # Since Ruby 3.4 these dependencies are bundled gems so should be specified explicitly. 77 | gem 'mutex_m' 78 | gem 'base64' 79 | gem 'bigdecimal' 80 | end 81 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # NOTE: This Gemfile is only relevant to local development. 4 | # It allows during local development: 5 | # - code coverage reports to be generated 6 | # - style linting to be run with RuboCop & extensions 7 | # All CI builds use files in gemfiles/*. 8 | source 'https://rubygems.org' 9 | 10 | # Specify your gem's dependencies in dynamoid.gemspec 11 | gemspec 12 | 13 | # Only add to the set of gems from the gemspec when running on local. 14 | # All CI jobs must use a discrete Gemfile located at gemfiles/*.gemfile. They will not use this Gemfile 15 | if ENV['CI'].nil? 16 | ruby_version = Gem::Version.new(RUBY_VERSION) 17 | minimum_version = ->(version, engine = 'ruby') { ruby_version >= Gem::Version.new(version) && engine == RUBY_ENGINE } 18 | committing = minimum_version.call('2.4') 19 | linting = minimum_version.call('2.7') 20 | coverage = minimum_version.call('2.7') 21 | 22 | platforms :mri do 23 | if committing 24 | gem 'overcommit' 25 | end 26 | if linting 27 | gem 'rubocop-md', require: false 28 | gem 'rubocop-packaging', require: false 29 | gem 'rubocop-performance', require: false 30 | gem 'rubocop-rake', require: false 31 | gem 'rubocop-rspec', require: false 32 | gem 'rubocop-thread_safety', require: false 33 | end 34 | if coverage 35 | gem 'codecov', '~> 0.6' # For CodeCov 36 | gem 'simplecov', '~> 0.21', require: false 37 | gem 'simplecov-cobertura' # XML for Jenkins 38 | gem 'simplecov-json' # For CodeClimate 39 | gem 'simplecov-lcov', '~> 0.8', require: false 40 | end 41 | end 42 | 43 | platforms :jruby do 44 | # Add `binding.pry` to your code where you want to drop to REPL 45 | gem 'pry-debugger-jruby' 46 | end 47 | 48 | platforms :ruby do 49 | gem 'pry-byebug' 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | dynamoid (3.11.0) 5 | activemodel (>= 4) 6 | aws-sdk-dynamodb (~> 1.0) 7 | concurrent-ruby (>= 1.0) 8 | 9 | GEM 10 | remote: https://rubygems.org/ 11 | specs: 12 | activemodel (7.1.3) 13 | activesupport (= 7.1.3) 14 | activesupport (7.1.3) 15 | base64 16 | bigdecimal 17 | concurrent-ruby (~> 1.0, >= 1.0.2) 18 | connection_pool (>= 2.2.5) 19 | drb 20 | i18n (>= 1.6, < 2) 21 | minitest (>= 5.1) 22 | mutex_m 23 | tzinfo (~> 2.0) 24 | appraisal (2.5.0) 25 | bundler 26 | rake 27 | thor (>= 0.14.0) 28 | ast (2.4.2) 29 | aws-eventstream (1.3.0) 30 | aws-partitions (1.946.0) 31 | aws-sdk-core (3.197.2) 32 | aws-eventstream (~> 1, >= 1.3.0) 33 | aws-partitions (~> 1, >= 1.651.0) 34 | aws-sigv4 (~> 1.8) 35 | jmespath (~> 1, >= 1.6.1) 36 | aws-sdk-dynamodb (1.113.0) 37 | aws-sdk-core (~> 3, >= 3.197.0) 38 | aws-sigv4 (~> 1.1) 39 | aws-sigv4 (1.8.0) 40 | aws-eventstream (~> 1, >= 1.0.2) 41 | base64 (0.2.0) 42 | bigdecimal (3.1.5) 43 | byebug (11.1.3) 44 | childprocess (5.0.0) 45 | codecov (0.6.0) 46 | simplecov (>= 0.15, < 0.22) 47 | coderay (1.1.3) 48 | concurrent-ruby (1.2.3) 49 | connection_pool (2.4.1) 50 | diff-lcs (1.5.0) 51 | docile (1.4.0) 52 | drb (2.2.0) 53 | ruby2_keywords 54 | i18n (1.14.1) 55 | concurrent-ruby (~> 1.0) 56 | iniparse (1.5.0) 57 | jmespath (1.6.2) 58 | json (2.9.1) 59 | language_server-protocol (3.17.0.3) 60 | method_source (1.0.0) 61 | minitest (5.21.1) 62 | mutex_m (0.2.0) 63 | overcommit (0.62.0) 64 | childprocess (>= 0.6.3, < 6) 65 | iniparse (~> 1.4) 66 | rexml (~> 3.2) 67 | parallel (1.26.3) 68 | parser (3.3.6.0) 69 | ast (~> 2.4.1) 70 | racc 71 | pry (0.14.2) 72 | coderay (~> 1.1) 73 | method_source (~> 1.0) 74 | pry-byebug (3.10.1) 75 | byebug (~> 11.0) 76 | pry (>= 0.13, < 0.15) 77 | racc (1.8.1) 78 | rainbow (3.1.1) 79 | rake (13.1.0) 80 | regexp_parser (2.10.0) 81 | rexml (3.4.0) 82 | rspec (3.12.0) 83 | rspec-core (~> 3.12.0) 84 | rspec-expectations (~> 3.12.0) 85 | rspec-mocks (~> 3.12.0) 86 | rspec-core (3.12.2) 87 | rspec-support (~> 3.12.0) 88 | rspec-expectations (3.12.3) 89 | diff-lcs (>= 1.2.0, < 2.0) 90 | rspec-support (~> 3.12.0) 91 | rspec-mocks (3.12.6) 92 | diff-lcs (>= 1.2.0, < 2.0) 93 | rspec-support (~> 3.12.0) 94 | rspec-support (3.12.1) 95 | rubocop (1.70.0) 96 | json (~> 2.3) 97 | language_server-protocol (>= 3.17.0) 98 | parallel (~> 1.10) 99 | parser (>= 3.3.0.2) 100 | rainbow (>= 2.2.2, < 4.0) 101 | regexp_parser (>= 2.9.3, < 3.0) 102 | rubocop-ast (>= 1.36.2, < 2.0) 103 | ruby-progressbar (~> 1.7) 104 | unicode-display_width (>= 2.4.0, < 4.0) 105 | rubocop-ast (1.37.0) 106 | parser (>= 3.3.1.0) 107 | rubocop-md (1.2.2) 108 | rubocop (>= 1.0) 109 | rubocop-packaging (0.5.2) 110 | rubocop (>= 1.33, < 2.0) 111 | rubocop-performance (1.20.2) 112 | rubocop (>= 1.48.1, < 2.0) 113 | rubocop-ast (>= 1.30.0, < 2.0) 114 | rubocop-rake (0.6.0) 115 | rubocop (~> 1.0) 116 | rubocop-rspec (3.0.2) 117 | rubocop (~> 1.61) 118 | rubocop-thread_safety (0.6.0) 119 | rubocop (>= 1.48.1) 120 | ruby-progressbar (1.13.0) 121 | ruby2_keywords (0.0.5) 122 | simplecov (0.21.2) 123 | docile (~> 1.1) 124 | simplecov-html (~> 0.11) 125 | simplecov_json_formatter (~> 0.1) 126 | simplecov-cobertura (2.1.0) 127 | rexml 128 | simplecov (~> 0.19) 129 | simplecov-html (0.12.3) 130 | simplecov-json (0.2.3) 131 | json 132 | simplecov 133 | simplecov-lcov (0.8.0) 134 | simplecov_json_formatter (0.1.4) 135 | thor (1.3.0) 136 | tzinfo (2.0.6) 137 | concurrent-ruby (~> 1.0) 138 | unicode-display_width (3.1.4) 139 | unicode-emoji (~> 4.0, >= 4.0.4) 140 | unicode-emoji (4.0.4) 141 | yard (0.9.34) 142 | 143 | PLATFORMS 144 | ruby 145 | x86_64-linux 146 | 147 | DEPENDENCIES 148 | appraisal 149 | bundler 150 | codecov (~> 0.6) 151 | dynamoid! 152 | overcommit 153 | pry (~> 0.14) 154 | pry-byebug 155 | pry-debugger-jruby 156 | rake (~> 13.0) 157 | rexml 158 | rspec (~> 3.12) 159 | rubocop-md 160 | rubocop-packaging 161 | rubocop-performance 162 | rubocop-rake 163 | rubocop-rspec 164 | rubocop-thread_safety 165 | simplecov (~> 0.21) 166 | simplecov-cobertura 167 | simplecov-json 168 | simplecov-lcov (~> 0.8) 169 | yard 170 | 171 | BUNDLED WITH 172 | 2.5.3 173 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2012 Josh Symonds 4 | Copyright (c) 2013 - 2022 Dynamoid, https://github.com/Dynamoid 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/gem_tasks' 4 | 5 | require 'bundler/setup' 6 | begin 7 | Bundler.setup(:default, :development) 8 | rescue Bundler::BundlerError => e 9 | warn e.message 10 | warn 'Run `bundle install` to install missing gems' 11 | exit e.status_code 12 | end 13 | load './lib/dynamoid/tasks/database.rake' if defined?(Rails) 14 | 15 | require 'rspec/core/rake_task' 16 | RSpec::Core::RakeTask.new(:spec) do |spec| 17 | spec.pattern = FileList['spec/**/*_spec.rb'] 18 | end 19 | desc 'alias test task to spec' 20 | task test: :spec 21 | 22 | ruby_version = Gem::Version.new(RUBY_VERSION) 23 | minimum_version = ->(version, engine = 'ruby') { ruby_version >= Gem::Version.new(version) && engine == RUBY_ENGINE } 24 | linting = minimum_version.call('2.7') 25 | def rubocop_task(warning) 26 | desc 'rubocop task stub' 27 | task :rubocop do 28 | warn warning 29 | end 30 | end 31 | 32 | if linting 33 | begin 34 | require 'rubocop/rake_task' 35 | RuboCop::RakeTask.new do |task| 36 | task.options = ['-DESP'] # Display the name of the failing cops 37 | end 38 | rescue LoadError 39 | rubocop_task("RuboCop is unexpectedly disabled locally for #{RUBY_ENGINE}-#{RUBY_VERSION}. Have you run bundle install?") 40 | end 41 | else 42 | rubocop_task("RuboCop is disabled locally for #{RUBY_ENGINE}-#{RUBY_VERSION}.\nIf you need it locally on #{RUBY_ENGINE}-#{RUBY_VERSION}, run BUNDLE_GEMFILE=gemfiles/style.gemfile bundle install && BUNDLE_GEMFILE=gemfiles/style.gemfile bundle exec rubocop") 43 | end 44 | 45 | require 'yard' 46 | YARD::Rake::YardocTask.new do |t| 47 | t.files = ['lib/**/*.rb', 'README.md', 'LICENSE.txt'] # optional 48 | t.options = ['-m', 'markdown'] # optional 49 | end 50 | 51 | desc 'Publish documentation to gh-pages' 52 | task :publish do 53 | Rake::Task['yard'].invoke 54 | `git add .` 55 | `git commit -m 'Regenerated documentation'` 56 | `git checkout gh-pages` 57 | `git clean -fdx` 58 | `git checkout master -- doc` 59 | `cp -R doc/* .` 60 | `git rm -rf doc/` 61 | `git add .` 62 | `git commit -m 'Regenerated documentation'` 63 | `git pull` 64 | `git push` 65 | `git checkout master` 66 | end 67 | 68 | task default: %i[test rubocop] 69 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | | Version | Supported | 6 | |---------|-----------| 7 | | 3.7.x | ✅ | 8 | | <= 3.6 | ❌ | 9 | | 2.x | ❌ | 10 | | 1.x | ❌ | 11 | | 0.x | ❌ | 12 | 13 | ## Reporting a Vulnerability 14 | 15 | Peter Boling is responsible for the security maintenance of this gem. Please find a way 16 | to [contact him directly](https://railsbling.com/contact) to report the issue. Include as much relevant information as 17 | possible. 18 | -------------------------------------------------------------------------------- /Vagrantfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Vagrant.configure('2') do |config| 4 | # Choose base box 5 | config.vm.box = 'bento/ubuntu-18.04' 6 | 7 | config.vm.provider 'virtualbox' do |vb| 8 | # Prevent clock skew when host goes to sleep while VM is running 9 | vb.customize ['guestproperty', 'set', :id, '/VirtualBox/GuestAdd/VBoxService/--timesync-set-threshold', 10_000] 10 | 11 | vb.cpus = 2 12 | vb.memory = 2048 13 | end 14 | 15 | # Defaults 16 | config.vm.provision :salt do |salt| 17 | salt.masterless = true 18 | salt.minion_config = '.dev/vagrant/minion' 19 | 20 | # Pillars 21 | salt.pillar( 22 | 'ruby' => { 23 | 'version' => '2.6.2' 24 | } 25 | ) 26 | 27 | salt.run_highstate = true 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /bin/_dynamodblocal: -------------------------------------------------------------------------------- 1 | DIST_DIR=spec/DynamoDBLocal-latest 2 | PIDFILE=dynamodb.pid 3 | LISTEN_PORT=8000 4 | LOG_DIR="logs" 5 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require 'bundler/setup' 5 | require 'dynamoid' 6 | require 'dynamoid/log/formatter' 7 | 8 | # You can add fixtures and/or initialization code here to make experimenting 9 | # with your gem easier. You can also use a different console, if you like. 10 | 11 | # (If you use this, don't forget to add pry to your Gemfile!) 12 | # require "pry" 13 | # Pry.start 14 | 15 | Dynamoid.configure do |config| 16 | # DynamoDB local version 2.0.0 and greater AWS_ACCESS_KEY_ID can contain 17 | # the only letters (A–Z, a–z) and numbers (0–9). 18 | # See https://hub.docker.com/r/amazon/dynamodb-local 19 | config.access_key = 'accesskey' 20 | config.secret_key = 'secretkey' 21 | 22 | config.region = 'us-west-2' 23 | config.endpoint = 'http://localhost:8000' 24 | config.log_formatter = Dynamoid::Log::Formatter::Compact.new 25 | end 26 | 27 | require 'irb' 28 | IRB.start 29 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | if ! brew info wget &>/dev/null; then 10 | brew install wget 11 | else 12 | echo wget is already installed 13 | fi 14 | wget http://dynamodb-local.s3-website-us-west-2.amazonaws.com/dynamodb_local_latest.zip --quiet -O spec/dynamodb_temp.zip 15 | unzip -qq spec/dynamodb_temp.zip -d spec/DynamoDBLocal-latest 16 | rm spec/dynamodb_temp.zip 17 | -------------------------------------------------------------------------------- /bin/start_dynamodblocal: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Source variables 4 | . $(dirname $0)/_dynamodblocal 5 | 6 | if [ -z $JAVA_HOME ]; then 7 | echo >&2 'ERROR: DynamoDBLocal requires JAVA_HOME to be set.' 8 | exit 1 9 | fi 10 | 11 | if [ ! -x $JAVA_HOME/bin/java ]; then 12 | echo >&2 'ERROR: JAVA_HOME is set, but I do not see the java executable there.' 13 | exit 1 14 | fi 15 | 16 | cd $DIST_DIR 17 | 18 | if [ ! -f DynamoDBLocal.jar ] || [ ! -d DynamoDBLocal_lib ]; then 19 | echo >&2 "ERROR: Could not find DynamoDBLocal files in $DIST_DIR." 20 | exit 1 21 | fi 22 | 23 | mkdir -p $LOG_DIR 24 | echo "DynamoDB Local output will save to ${DIST_DIR}/${LOG_DIR}/" 25 | hash lsof 2>/dev/null && lsof -i :$LISTEN_PORT && { echo >&2 "Something is already listening on port $LISTEN_PORT; I will not attempt to start DynamoDBLocal."; exit 1; } 26 | 27 | NOW=$(date -u +"%Y-%m-%dT%H:%M:%SZ") 28 | nohup $JAVA_HOME/bin/java -Djava.library.path=./DynamoDBLocal_lib -jar DynamoDBLocal.jar -delayTransientStatuses -port $LISTEN_PORT -inMemory 1>"${LOG_DIR}/${NOW}.out.log" 2>"${LOG_DIR}/${NOW}.err.log" & 29 | PID=$! 30 | 31 | echo 'Verifying that DynamoDBLocal actually started...' 32 | 33 | # Allow some seconds for the JDK to start and die. 34 | counter=0 35 | while [ $counter -le 5 ]; do 36 | kill -0 $PID 37 | if [ $? -ne 0 ]; then 38 | echo >&2 'ERROR: DynamoDBLocal died after we tried to start it!' 39 | exit 1 40 | else 41 | counter=$(($counter + 1)) 42 | sleep 1 43 | fi 44 | done 45 | 46 | echo "DynamoDB Local started with pid $PID listening on port $LISTEN_PORT." 47 | echo $PID > $PIDFILE 48 | -------------------------------------------------------------------------------- /bin/stop_dynamodblocal: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Source variables 4 | . $(dirname $0)/_dynamodblocal 5 | 6 | cd $DIST_DIR 7 | 8 | if [ ! -f $PIDFILE ]; then 9 | echo 'ERROR: There is no pidfile, so if DynamoDBLocal is running you will need to kill it yourself.' 10 | exit 1 11 | fi 12 | 13 | pid=$(<$PIDFILE) 14 | 15 | echo "Killing DynamoDBLocal at pid $pid..." 16 | kill $pid 17 | 18 | counter=0 19 | while [ $counter -le 5 ]; do 20 | kill -0 $pid 2>/dev/null 21 | if [ $? -ne 0 ]; then 22 | echo 'Successfully shut down DynamoDBLocal.' 23 | rm -f $PIDFILE 24 | exit 0 25 | else 26 | echo 'Still waiting for DynamoDBLocal to shut down...' 27 | counter=$(($counter + 1)) 28 | sleep 1 29 | fi 30 | done 31 | 32 | echo 'Unable to shut down DynamoDBLocal; you may need to kill it yourself.' 33 | rm -f $PIDFILE 34 | exit 1 35 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | 3 | services: 4 | dynamodb: 5 | image: amazon/dynamodb-local 6 | ports: 7 | - 8000:8000 8 | -------------------------------------------------------------------------------- /dynamoid.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | lib = File.expand_path('lib', __dir__) 4 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 5 | require 'dynamoid/version' 6 | 7 | Gem::Specification.new do |spec| 8 | spec.name = 'dynamoid' 9 | spec.version = Dynamoid::VERSION 10 | 11 | # Keep in sync with README 12 | spec.authors = [ 13 | 'Josh Symonds', 14 | 'Logan Bowers', 15 | 'Craig Heneveld', 16 | 'Anatha Kumaran', 17 | 'Jason Dew', 18 | 'Luis Arias', 19 | 'Stefan Neculai', 20 | 'Philip White', 21 | 'Peeyush Kumar', 22 | 'Sumanth Ravipati', 23 | 'Pascal Corpet', 24 | 'Brian Glusman', 25 | 'Peter Boling', 26 | 'Andrew Konchin' 27 | ] 28 | spec.email = ['andry.konchin@gmail.com', 'peter.boling@gmail.com', 'brian@stellaservice.com'] 29 | 30 | spec.description = "Dynamoid is an ORM for Amazon's DynamoDB that supports offline development, associations, querying, and everything else you'd expect from an ActiveRecord-style replacement." 31 | spec.summary = "Dynamoid is an ORM for Amazon's DynamoDB" 32 | # Ignore not committed files 33 | spec.files = Dir[ 34 | 'CHANGELOG.md', 35 | 'dynamoid.gemspec', 36 | 'lib/**/*', 37 | 'LICENSE.txt', 38 | 'README.md', 39 | 'SECURITY.md' 40 | ] 41 | spec.homepage = 'http://github.com/Dynamoid/dynamoid' 42 | spec.licenses = ['MIT'] 43 | spec.require_paths = ['lib'] 44 | 45 | spec.metadata['homepage_uri'] = spec.homepage 46 | spec.metadata['source_code_uri'] = "https://github.com/Dynamoid/dynamoid/tree/v#{spec.version}" 47 | spec.metadata['changelog_uri'] = "https://github.com/Dynamoid/dynamoid/blob/v#{spec.version}/CHANGELOG.md" 48 | spec.metadata['bug_tracker_uri'] = 'https://github.com/Dynamoid/dynamoid/issues' 49 | spec.metadata['documentation_uri'] = "https://www.rubydoc.info/gems/dynamoid/#{spec.version}" 50 | spec.metadata['funding_uri'] = 'https://opencollective.com/dynamoid' 51 | spec.metadata['wiki_uri'] = 'https://github.com/Dynamoid/dynamoid/wiki' 52 | spec.metadata['rubygems_mfa_required'] = 'true' 53 | 54 | spec.add_dependency 'activemodel', '>=4' 55 | spec.add_dependency 'aws-sdk-dynamodb', '~> 1.0' 56 | spec.add_dependency 'concurrent-ruby', '>= 1.0' 57 | 58 | spec.add_development_dependency 'appraisal' 59 | spec.add_development_dependency 'bundler' 60 | spec.add_development_dependency 'pry', '~> 0.14' 61 | spec.add_development_dependency 'rake', '~> 13.0' 62 | spec.add_development_dependency 'rexml' 63 | spec.add_development_dependency 'rspec', '~> 3.12' 64 | spec.add_development_dependency 'yard' 65 | end 66 | -------------------------------------------------------------------------------- /gemfiles/coverage.gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | # Some tests require Rails. 6 | gem 'activemodel', '~> 7.0.2' 7 | 8 | gem 'codecov', '~> 0.6', require: false # For CodeCov 9 | gem 'simplecov', '~> 0.21', require: false 10 | gem 'simplecov-cobertura', require: false # XML for Jenkins 11 | gem 'simplecov-json', require: false # For CodeClimate 12 | gem 'simplecov-lcov', '~> 0.8', require: false 13 | 14 | gemspec path: '../' 15 | -------------------------------------------------------------------------------- /gemfiles/rails_4_2.gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This file was generated by Appraisal 4 | 5 | source 'https://rubygems.org' 6 | 7 | gem 'activemodel', '~> 4.2.0' 8 | gem 'bigdecimal', '~> 1.4.0', platform: :mri 9 | gem 'pry-byebug', platforms: :ruby 10 | 11 | gemspec path: '../' 12 | -------------------------------------------------------------------------------- /gemfiles/rails_5_0.gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This file was generated by Appraisal 4 | 5 | source 'https://rubygems.org' 6 | 7 | gem 'activemodel', '~> 5.0.0' 8 | gem 'pry-byebug', platforms: :ruby 9 | 10 | gemspec path: '../' 11 | -------------------------------------------------------------------------------- /gemfiles/rails_5_1.gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This file was generated by Appraisal 4 | 5 | source 'https://rubygems.org' 6 | 7 | gem 'activemodel', '~> 5.1.0' 8 | gem 'pry-byebug', platforms: :ruby 9 | 10 | gemspec path: '../' 11 | -------------------------------------------------------------------------------- /gemfiles/rails_5_2.gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This file was generated by Appraisal 4 | 5 | source 'https://rubygems.org' 6 | 7 | gem 'activemodel', '~> 5.2.0' 8 | gem 'pry-byebug', platforms: :ruby 9 | 10 | gemspec path: '../' 11 | -------------------------------------------------------------------------------- /gemfiles/rails_6_0.gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This file was generated by Appraisal 4 | 5 | source 'https://rubygems.org' 6 | 7 | gem 'activemodel', '~> 6.0.0' 8 | gem 'base64' 9 | gem 'bigdecimal' 10 | gem 'mutex_m' 11 | gem 'pry-byebug', platforms: :ruby 12 | 13 | gemspec path: '../' 14 | -------------------------------------------------------------------------------- /gemfiles/rails_6_1.gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This file was generated by Appraisal 4 | 5 | source 'https://rubygems.org' 6 | 7 | gem 'activemodel', '~> 6.1.0' 8 | gem 'base64' 9 | gem 'bigdecimal' 10 | gem 'mutex_m' 11 | gem 'pry-byebug', platforms: :ruby 12 | 13 | gemspec path: '../' 14 | -------------------------------------------------------------------------------- /gemfiles/rails_7_0.gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This file was generated by Appraisal 4 | 5 | source 'https://rubygems.org' 6 | 7 | gem 'activemodel', '~> 7.0.2' 8 | gem 'base64' 9 | gem 'bigdecimal' 10 | gem 'mutex_m' 11 | gem 'pry-byebug', platforms: :ruby 12 | 13 | gemspec path: '../' 14 | -------------------------------------------------------------------------------- /gemfiles/rails_7_1.gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This file was generated by Appraisal 4 | 5 | source 'https://rubygems.org' 6 | 7 | gem 'activemodel', '~> 7.1.0' 8 | gem 'base64' 9 | gem 'bigdecimal' 10 | gem 'mutex_m' 11 | gem 'pry-byebug', platforms: :ruby 12 | 13 | gemspec path: '../' 14 | -------------------------------------------------------------------------------- /gemfiles/rails_7_2.gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This file was generated by Appraisal 4 | 5 | source 'https://rubygems.org' 6 | 7 | gem 'activemodel', '~> 7.2.0' 8 | gem 'base64' 9 | gem 'bigdecimal' 10 | gem 'mutex_m' 11 | gem 'pry-byebug', platforms: :ruby 12 | 13 | gemspec path: '../' 14 | -------------------------------------------------------------------------------- /gemfiles/rails_8_0.gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This file was generated by Appraisal 4 | 5 | source 'https://rubygems.org' 6 | 7 | gem 'activemodel', '~> 8.0.0' 8 | gem 'base64' 9 | gem 'bigdecimal' 10 | gem 'mutex_m' 11 | gem 'pry-byebug', platforms: :ruby 12 | 13 | gemspec path: '../' 14 | -------------------------------------------------------------------------------- /gemfiles/style.gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | gem 'rubocop-md', require: false 6 | gem 'rubocop-packaging', require: false 7 | gem 'rubocop-performance', require: false 8 | gem 'rubocop-rake', require: false 9 | gem 'rubocop-rspec', require: false 10 | gem 'rubocop-thread_safety', require: false 11 | 12 | gemspec path: '../' 13 | -------------------------------------------------------------------------------- /lib/dynamoid.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'aws-sdk-dynamodb' 4 | require 'delegate' 5 | require 'time' 6 | require 'securerandom' 7 | require 'set' 8 | require 'active_support' 9 | require 'active_support/core_ext' 10 | require 'active_support/json' 11 | require 'active_support/inflector' 12 | require 'active_support/lazy_load_hooks' 13 | require 'active_support/time_with_zone' 14 | require 'active_model' 15 | 16 | require 'dynamoid/version' 17 | require 'dynamoid/errors' 18 | require 'dynamoid/application_time_zone' 19 | require 'dynamoid/dynamodb_time_zone' 20 | require 'dynamoid/fields' 21 | require 'dynamoid/indexes' 22 | require 'dynamoid/associations' 23 | require 'dynamoid/persistence' 24 | require 'dynamoid/dumping' 25 | require 'dynamoid/undumping' 26 | require 'dynamoid/type_casting' 27 | require 'dynamoid/primary_key_type_mapping' 28 | require 'dynamoid/dirty' 29 | require 'dynamoid/validations' 30 | require 'dynamoid/criteria' 31 | require 'dynamoid/finders' 32 | require 'dynamoid/identity_map' 33 | require 'dynamoid/config' 34 | require 'dynamoid/loadable' 35 | require 'dynamoid/components' 36 | require 'dynamoid/document' 37 | require 'dynamoid/adapter' 38 | require 'dynamoid/transaction_write' 39 | 40 | require 'dynamoid/tasks/database' 41 | 42 | require 'dynamoid/middleware/identity_map' 43 | 44 | require 'dynamoid/railtie' if defined?(Rails) 45 | 46 | module Dynamoid 47 | extend self 48 | 49 | def configure 50 | block_given? ? yield(Dynamoid::Config) : Dynamoid::Config 51 | end 52 | alias config configure 53 | 54 | def logger 55 | Dynamoid::Config.logger 56 | end 57 | 58 | def included_models 59 | @included_models ||= [] 60 | end 61 | 62 | # @private 63 | def adapter 64 | @adapter ||= Adapter.new 65 | end 66 | 67 | # @private 68 | def deprecator 69 | # all the deprecated behavior will be removed in the next major version 70 | @deprecator ||= ActiveSupport::Deprecation.new('4.0', 'Dynamoid') 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /lib/dynamoid/adapter_plugin/aws_sdk_v3/batch_get_item.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dynamoid 4 | # @private 5 | module AdapterPlugin 6 | class AwsSdkV3 7 | # Documentation 8 | # https://docs.aws.amazon.com/sdkforruby/api/Aws/DynamoDB/Client.html#batch_get_item-instance_method 9 | class BatchGetItem 10 | attr_reader :client, :tables_with_ids, :options 11 | 12 | def initialize(client, tables_with_ids, options = {}) 13 | @client = client 14 | @tables_with_ids = tables_with_ids 15 | @options = options 16 | end 17 | 18 | def call 19 | results = {} 20 | 21 | tables_with_ids.each do |table, ids| 22 | if ids.blank? 23 | results[table.name] = [] 24 | next 25 | end 26 | 27 | ids = Array(ids).dup 28 | 29 | while ids.present? 30 | batch = ids.shift(Dynamoid::Config.batch_size) 31 | request = build_request(table, batch) 32 | api_response = client.batch_get_item(request) 33 | response = Response.new(api_response) 34 | 35 | if block_given? 36 | # return batch items as a result 37 | batch_results = Hash.new([].freeze) 38 | batch_results.update(response.items_grouped_by_table) 39 | 40 | yield(batch_results, response.successful_partially?) 41 | else 42 | # collect all the batches to return at the end 43 | results.update(response.items_grouped_by_table) { |_, its1, its2| its1 + its2 } 44 | end 45 | 46 | if response.successful_partially? 47 | ids += response.unprocessed_ids(table) 48 | end 49 | end 50 | end 51 | 52 | results unless block_given? 53 | end 54 | 55 | private 56 | 57 | def build_request(table, ids) 58 | ids = Array(ids) 59 | 60 | keys = if table.range_key.nil? 61 | ids.map { |hk| { table.hash_key => hk } } 62 | else 63 | ids.map { |hk, rk| { table.hash_key => hk, table.range_key => rk } } 64 | end 65 | 66 | { 67 | request_items: { 68 | table.name => { 69 | keys: keys, 70 | consistent_read: options[:consistent_read] 71 | } 72 | } 73 | } 74 | end 75 | 76 | # Helper class to work with response 77 | class Response 78 | def initialize(api_response) 79 | @api_response = api_response 80 | end 81 | 82 | def successful_partially? 83 | @api_response.unprocessed_keys.present? 84 | end 85 | 86 | def unprocessed_ids(table) 87 | # unprocessed_keys Hash contains as values instances of 88 | # Aws::DynamoDB::Types::KeysAndAttributes 89 | @api_response.unprocessed_keys[table.name].keys.map do |h| 90 | # If a table has a composite primary key then we need to return an array 91 | # of [hash key, range key]. Otherwise just return hash key's 92 | # value. 93 | if table.range_key.nil? 94 | h[table.hash_key.to_s] 95 | else 96 | [h[table.hash_key.to_s], h[table.range_key.to_s]] 97 | end 98 | end 99 | end 100 | 101 | def items_grouped_by_table 102 | # data[:responses] is a Hash[table_name -> items] 103 | @api_response.data[:responses].transform_values do |items| 104 | items.map(&method(:item_to_hash)) 105 | end 106 | end 107 | 108 | private 109 | 110 | def item_to_hash(item) 111 | item.symbolize_keys 112 | end 113 | end 114 | end 115 | end 116 | end 117 | end 118 | -------------------------------------------------------------------------------- /lib/dynamoid/adapter_plugin/aws_sdk_v3/execute_statement.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dynamoid 4 | # @private 5 | module AdapterPlugin 6 | class AwsSdkV3 7 | # Excecute a PartiQL query 8 | # 9 | # Documentation: 10 | # - https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_ExecuteStatement.html 11 | # - https://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/DynamoDB/Client.html#execute_statement-instance_method 12 | # 13 | # NOTE: For reads result may be paginated. Only pagination with NextToken 14 | # is implemented. Currently LastEvaluatedKey in response cannot be fed to 15 | # ExecuteStatement to get the next page. 16 | # 17 | # See also: 18 | # - https://repost.aws/questions/QUgNPbBYWiRoOlMsJv-XzrWg/how-to-use-last-evaluated-key-in-execute-statement-request 19 | # - https://stackoverflow.com/questions/71438439/aws-dynamodb-executestatement-pagination 20 | class ExecuteStatement 21 | attr_reader :client, :statement, :parameters, :options 22 | 23 | def initialize(client, statement, parameters, options) 24 | @client = client 25 | @statement = statement 26 | @parameters = parameters 27 | @options = options.symbolize_keys.slice(:consistent_read) 28 | end 29 | 30 | def call 31 | request = { 32 | statement: @statement, 33 | parameters: @parameters, 34 | consistent_read: @options[:consistent_read], 35 | } 36 | 37 | response = client.execute_statement(request) 38 | 39 | unless response.next_token 40 | return response_to_items(response) 41 | end 42 | 43 | Enumerator.new do |yielder| 44 | yielder.yield(response_to_items(response)) 45 | 46 | while response.next_token 47 | request[:next_token] = response.next_token 48 | response = client.execute_statement(request) 49 | yielder.yield(response_to_items(response)) 50 | end 51 | end 52 | end 53 | 54 | private 55 | 56 | def response_to_items(response) 57 | response.items.map(&:symbolize_keys) 58 | end 59 | end 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/dynamoid/adapter_plugin/aws_sdk_v3/filter_expression_convertor.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dynamoid 4 | # @private 5 | module AdapterPlugin 6 | class AwsSdkV3 7 | class FilterExpressionConvertor 8 | attr_reader :expression, :name_placeholders, :value_placeholders 9 | 10 | def initialize(conditions, name_placeholders, value_placeholders, name_placeholder_sequence, value_placeholder_sequence) 11 | @conditions = conditions 12 | @name_placeholders = name_placeholders.dup 13 | @value_placeholders = value_placeholders.dup 14 | @name_placeholder_sequence = name_placeholder_sequence 15 | @value_placeholder_sequence = value_placeholder_sequence 16 | 17 | build 18 | end 19 | 20 | private 21 | 22 | def build 23 | clauses = [] 24 | 25 | @conditions.each do |conditions| 26 | if conditions.is_a? Hash 27 | clauses << build_for_hash(conditions) unless conditions.empty? 28 | elsif conditions.is_a? Array 29 | query, placeholders = conditions 30 | clauses << build_for_string(query, placeholders) 31 | else 32 | raise ArgumentError, "expected Hash or Array but actual value is #{conditions}" 33 | end 34 | end 35 | 36 | @expression = clauses.join(' AND ') 37 | end 38 | 39 | def build_for_hash(hash) 40 | clauses = hash.map do |name, attribute_conditions| 41 | attribute_conditions.map do |operator, value| 42 | # replace attribute names with placeholders unconditionally to support 43 | # - special characters (e.g. '.', ':', and '#') and 44 | # - leading '_' 45 | # See 46 | # - https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/HowItWorks.NamingRulesDataTypes.html#HowItWorks.NamingRules 47 | # - https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Expressions.ExpressionAttributeNames.html#Expressions.ExpressionAttributeNames.AttributeNamesContainingSpecialCharacters 48 | name_placeholder = name_placeholder_for(name) 49 | 50 | case operator 51 | when :eq 52 | "#{name_placeholder} = #{value_placeholder_for(value)}" 53 | when :ne 54 | "#{name_placeholder} <> #{value_placeholder_for(value)}" 55 | when :gt 56 | "#{name_placeholder} > #{value_placeholder_for(value)}" 57 | when :lt 58 | "#{name_placeholder} < #{value_placeholder_for(value)}" 59 | when :gte 60 | "#{name_placeholder} >= #{value_placeholder_for(value)}" 61 | when :lte 62 | "#{name_placeholder} <= #{value_placeholder_for(value)}" 63 | when :between 64 | "#{name_placeholder} BETWEEN #{value_placeholder_for(value[0])} AND #{value_placeholder_for(value[1])}" 65 | when :begins_with 66 | "begins_with (#{name_placeholder}, #{value_placeholder_for(value)})" 67 | when :in 68 | list = value.map(&method(:value_placeholder_for)).join(' , ') 69 | "#{name_placeholder} IN (#{list})" 70 | when :contains 71 | "contains (#{name_placeholder}, #{value_placeholder_for(value)})" 72 | when :not_contains 73 | "NOT contains (#{name_placeholder}, #{value_placeholder_for(value)})" 74 | when :null 75 | "attribute_not_exists (#{name_placeholder})" 76 | when :not_null 77 | "attribute_exists (#{name_placeholder})" 78 | end 79 | end 80 | end.flatten 81 | 82 | if clauses.empty? 83 | nil 84 | else 85 | clauses.join(' AND ') 86 | end 87 | end 88 | 89 | def build_for_string(query, placeholders) 90 | placeholders.each do |(k, v)| 91 | k = k.to_s 92 | k = ":#{k}" unless k.start_with?(':') 93 | @value_placeholders[k] = v 94 | end 95 | 96 | "(#{query})" 97 | end 98 | 99 | def name_placeholder_for(name) 100 | placeholder = @name_placeholder_sequence.call 101 | @name_placeholders[placeholder] = name 102 | placeholder 103 | end 104 | 105 | def value_placeholder_for(value) 106 | placeholder = @value_placeholder_sequence.call 107 | @value_placeholders[placeholder] = value 108 | placeholder 109 | end 110 | end 111 | end 112 | end 113 | end 114 | -------------------------------------------------------------------------------- /lib/dynamoid/adapter_plugin/aws_sdk_v3/item_updater.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dynamoid 4 | # @private 5 | module AdapterPlugin 6 | class AwsSdkV3 7 | # Mimics behavior of the yielded object on DynamoDB's update_item API (high level). 8 | class ItemUpdater 9 | ADD = 'ADD' 10 | DELETE = 'DELETE' 11 | PUT = 'PUT' 12 | 13 | attr_reader :table, :key, :range_key 14 | 15 | def initialize(table, key, range_key = nil) 16 | @table = table 17 | @key = key 18 | @range_key = range_key 19 | @additions = {} 20 | @deletions = {} 21 | @updates = {} 22 | end 23 | 24 | # 25 | # Adds the given values to the values already stored in the corresponding columns. 26 | # The column must contain a Set or a number. 27 | # 28 | # @param [Hash] values keys of the hash are the columns to update, values 29 | # are the values to add. values must be a Set, Array, or Numeric 30 | # 31 | def add(values) 32 | @additions.merge!(sanitize_attributes(values)) 33 | end 34 | 35 | # 36 | # Removes values from the sets of the given columns 37 | # 38 | # @param [Hash|Symbol|String] values keys of the hash are the columns, values are Arrays/Sets of items 39 | # to remove 40 | # 41 | def delete(values) 42 | if values.is_a?(Hash) 43 | @deletions.merge!(sanitize_attributes(values)) 44 | else 45 | @deletions.merge!(values.to_s => nil) 46 | end 47 | end 48 | 49 | # 50 | # Replaces the values of one or more attributes 51 | # 52 | def set(values) 53 | values_sanitized = sanitize_attributes(values) 54 | 55 | if Dynamoid.config.store_attribute_with_nil_value 56 | @updates.merge!(values_sanitized) 57 | else 58 | # delete explicitly attributes if assigned nil value and configured 59 | # to not store nil values 60 | values_to_update = values_sanitized.reject { |_, v| v.nil? } 61 | values_to_delete = values_sanitized.select { |_, v| v.nil? } 62 | 63 | @updates.merge!(values_to_update) 64 | @deletions.merge!(values_to_delete) 65 | end 66 | end 67 | 68 | # 69 | # Returns an AttributeUpdates hash suitable for passing to the V2 Client API 70 | # 71 | def attribute_updates 72 | result = {} 73 | 74 | @additions.each do |k, v| 75 | result[k] = { 76 | action: ADD, 77 | value: v 78 | } 79 | end 80 | 81 | @deletions.each do |k, v| 82 | result[k] = { 83 | action: DELETE 84 | } 85 | result[k][:value] = v unless v.nil? 86 | end 87 | 88 | @updates.each do |k, v| 89 | result[k] = { 90 | action: PUT, 91 | value: v 92 | } 93 | end 94 | 95 | result 96 | end 97 | 98 | private 99 | 100 | # It's a single low level component available in a public API (with 101 | # Document#update/#update! methods). So duplicate sanitizing to some 102 | # degree. 103 | # 104 | # Keep in sync with AwsSdkV3.sanitize_item. 105 | def sanitize_attributes(attributes) 106 | # rubocop:disable Lint/DuplicateBranch 107 | attributes.transform_values do |v| 108 | if v.is_a?(Hash) 109 | v.stringify_keys 110 | elsif v.is_a?(Set) && v.empty? 111 | nil 112 | elsif v.is_a?(String) && v.empty? && Config.store_empty_string_as_nil 113 | nil 114 | else 115 | v 116 | end 117 | end 118 | # rubocop:enable Lint/DuplicateBranch 119 | end 120 | end 121 | end 122 | end 123 | end 124 | -------------------------------------------------------------------------------- /lib/dynamoid/adapter_plugin/aws_sdk_v3/middleware/backoff.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dynamoid 4 | # @private 5 | module AdapterPlugin 6 | class AwsSdkV3 7 | module Middleware 8 | class Backoff 9 | def initialize(next_chain) 10 | @next_chain = next_chain 11 | @backoff = Dynamoid.config.backoff ? Dynamoid.config.build_backoff : nil 12 | end 13 | 14 | def call(request) 15 | response = @next_chain.call(request) 16 | @backoff.call if @backoff 17 | 18 | response 19 | end 20 | end 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/dynamoid/adapter_plugin/aws_sdk_v3/middleware/limit.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dynamoid 4 | # @private 5 | module AdapterPlugin 6 | class AwsSdkV3 7 | module Middleware 8 | class Limit 9 | def initialize(next_chain, record_limit: nil, scan_limit: nil) 10 | @next_chain = next_chain 11 | 12 | @record_limit = record_limit 13 | @scan_limit = scan_limit 14 | 15 | @record_count = 0 16 | @scan_count = 0 17 | end 18 | 19 | def call(request) 20 | # Adjust the limit down if the remaining record and/or scan limit are 21 | # lower to obey limits. We can assume the difference won't be 22 | # negative due to break statements below but choose smaller limit 23 | # which is why we have 2 separate if statements. 24 | # 25 | # NOTE: Adjusting based on record_limit can cause many HTTP requests 26 | # being made. We may want to change this behavior, but it affects 27 | # filtering on data with potentially large gaps. 28 | # 29 | # Example: 30 | # User.where('created_at.gte' => 1.day.ago).record_limit(1000) 31 | # Records 1-999 User's that fit criteria 32 | # Records 1000-2000 Users's that do not fit criteria 33 | # Record 2001 fits criteria 34 | # 35 | # The underlying implementation will have 1 page for records 1-999 36 | # then will request with limit 1 for records 1000-2000 (making 1000 37 | # requests of limit 1) until hit record 2001. 38 | if request[:limit] && @record_limit && @record_limit - @record_count < request[:limit] 39 | request[:limit] = @record_limit - @record_count 40 | end 41 | if request[:limit] && @scan_limit && @scan_limit - @scan_count < request[:limit] 42 | request[:limit] = @scan_limit - @scan_count 43 | end 44 | 45 | response = @next_chain.call(request) 46 | 47 | @record_count += response.count 48 | throw :stop_pagination if @record_limit && @record_count >= @record_limit 49 | 50 | @scan_count += response.scanned_count 51 | throw :stop_pagination if @scan_limit && @scan_count >= @scan_limit 52 | 53 | response 54 | end 55 | end 56 | end 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /lib/dynamoid/adapter_plugin/aws_sdk_v3/middleware/start_key.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dynamoid 4 | # @private 5 | module AdapterPlugin 6 | class AwsSdkV3 7 | module Middleware 8 | class StartKey 9 | def initialize(next_chain) 10 | @next_chain = next_chain 11 | end 12 | 13 | def call(request) 14 | response = @next_chain.call(request) 15 | 16 | if response.last_evaluated_key 17 | request[:exclusive_start_key] = response.last_evaluated_key 18 | else 19 | throw :stop_pagination 20 | end 21 | 22 | response 23 | end 24 | end 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/dynamoid/adapter_plugin/aws_sdk_v3/projection_expression_convertor.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dynamoid 4 | # @private 5 | module AdapterPlugin 6 | class AwsSdkV3 7 | class ProjectionExpressionConvertor 8 | attr_reader :expression, :name_placeholders 9 | 10 | def initialize(names, name_placeholders, name_placeholder_sequence) 11 | @names = names 12 | @name_placeholders = name_placeholders.dup 13 | @name_placeholder_sequence = name_placeholder_sequence 14 | 15 | build 16 | end 17 | 18 | private 19 | 20 | def build 21 | return if @names.nil? || @names.empty? 22 | 23 | clauses = @names.map do |name| 24 | # replace attribute names with placeholders unconditionally to support 25 | # - special characters (e.g. '.', ':', and '#') and 26 | # - leading '_' 27 | # See 28 | # - https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/HowItWorks.NamingRulesDataTypes.html#HowItWorks.NamingRules 29 | # - https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Expressions.ExpressionAttributeNames.html#Expressions.ExpressionAttributeNames.AttributeNamesContainingSpecialCharacters 30 | placeholder = @name_placeholder_sequence.call 31 | @name_placeholders[placeholder] = name 32 | placeholder 33 | end 34 | 35 | @expression = clauses.join(' , ') 36 | end 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/dynamoid/adapter_plugin/aws_sdk_v3/query.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'middleware/backoff' 4 | require_relative 'middleware/limit' 5 | require_relative 'middleware/start_key' 6 | require_relative 'filter_expression_convertor' 7 | require_relative 'projection_expression_convertor' 8 | 9 | module Dynamoid 10 | # @private 11 | module AdapterPlugin 12 | class AwsSdkV3 13 | class Query 14 | OPTIONS_KEYS = %i[ 15 | consistent_read scan_index_forward select index_name batch_size 16 | exclusive_start_key record_limit scan_limit project 17 | ].freeze 18 | 19 | attr_reader :client, :table, :options, :conditions 20 | 21 | def initialize(client, table, key_conditions, non_key_conditions, options) 22 | @client = client 23 | @table = table 24 | 25 | @key_conditions = key_conditions 26 | @non_key_conditions = non_key_conditions 27 | @options = options.slice(*OPTIONS_KEYS) 28 | end 29 | 30 | def call 31 | request = build_request 32 | 33 | Enumerator.new do |yielder| 34 | api_call = lambda do |req| 35 | client.query(req).tap do |response| 36 | yielder << response 37 | end 38 | end 39 | 40 | middlewares = Middleware::Backoff.new( 41 | Middleware::StartKey.new( 42 | Middleware::Limit.new(api_call, record_limit: record_limit, scan_limit: scan_limit) 43 | ) 44 | ) 45 | 46 | catch :stop_pagination do 47 | loop do 48 | middlewares.call(request) 49 | end 50 | end 51 | end 52 | end 53 | 54 | private 55 | 56 | def build_request 57 | # expressions 58 | name_placeholder = +'#_a0' 59 | value_placeholder = +':_a0' 60 | 61 | name_placeholder_sequence = -> { name_placeholder.next!.dup } 62 | value_placeholder_sequence = -> { value_placeholder.next!.dup } 63 | 64 | name_placeholders = {} 65 | value_placeholders = {} 66 | 67 | # Deal with various limits and batching 68 | batch_size = options[:batch_size] 69 | limit = [record_limit, scan_limit, batch_size].compact.min 70 | 71 | # key condition expression 72 | convertor = FilterExpressionConvertor.new([@key_conditions], name_placeholders, value_placeholders, name_placeholder_sequence, value_placeholder_sequence) 73 | key_condition_expression = convertor.expression 74 | value_placeholders = convertor.value_placeholders 75 | name_placeholders = convertor.name_placeholders 76 | 77 | # filter expression 78 | convertor = FilterExpressionConvertor.new(@non_key_conditions, name_placeholders, value_placeholders, name_placeholder_sequence, value_placeholder_sequence) 79 | filter_expression = convertor.expression 80 | value_placeholders = convertor.value_placeholders 81 | name_placeholders = convertor.name_placeholders 82 | 83 | # projection expression 84 | convertor = ProjectionExpressionConvertor.new(options[:project], name_placeholders, name_placeholder_sequence) 85 | projection_expression = convertor.expression 86 | name_placeholders = convertor.name_placeholders 87 | 88 | request = options.slice( 89 | :consistent_read, 90 | :scan_index_forward, 91 | :select, 92 | :index_name, 93 | :exclusive_start_key 94 | ).compact 95 | 96 | request[:table_name] = table.name 97 | request[:limit] = limit if limit 98 | request[:key_condition_expression] = key_condition_expression if key_condition_expression.present? 99 | request[:filter_expression] = filter_expression if filter_expression.present? 100 | request[:expression_attribute_values] = value_placeholders if value_placeholders.present? 101 | request[:expression_attribute_names] = name_placeholders if name_placeholders.present? 102 | request[:projection_expression] = projection_expression if projection_expression.present? 103 | 104 | request 105 | end 106 | 107 | def record_limit 108 | options[:record_limit] 109 | end 110 | 111 | def scan_limit 112 | options[:scan_limit] 113 | end 114 | end 115 | end 116 | end 117 | end 118 | -------------------------------------------------------------------------------- /lib/dynamoid/adapter_plugin/aws_sdk_v3/scan.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'middleware/backoff' 4 | require_relative 'middleware/limit' 5 | require_relative 'middleware/start_key' 6 | require_relative 'filter_expression_convertor' 7 | require_relative 'projection_expression_convertor' 8 | 9 | module Dynamoid 10 | # @private 11 | module AdapterPlugin 12 | class AwsSdkV3 13 | class Scan 14 | attr_reader :client, :table, :conditions, :options 15 | 16 | def initialize(client, table, conditions = [], options = {}) 17 | @client = client 18 | @table = table 19 | @conditions = conditions 20 | @options = options 21 | end 22 | 23 | def call 24 | request = build_request 25 | 26 | Enumerator.new do |yielder| 27 | api_call = lambda do |req| 28 | client.scan(req).tap do |response| 29 | yielder << response 30 | end 31 | end 32 | 33 | middlewares = Middleware::Backoff.new( 34 | Middleware::StartKey.new( 35 | Middleware::Limit.new(api_call, record_limit: record_limit, scan_limit: scan_limit) 36 | ) 37 | ) 38 | 39 | catch :stop_pagination do 40 | loop do 41 | middlewares.call(request) 42 | end 43 | end 44 | end 45 | end 46 | 47 | private 48 | 49 | def build_request 50 | # expressions 51 | name_placeholder = +'#_a0' 52 | value_placeholder = +':_a0' 53 | 54 | name_placeholder_sequence = -> { name_placeholder.next!.dup } 55 | value_placeholder_sequence = -> { value_placeholder.next!.dup } 56 | 57 | name_placeholders = {} 58 | value_placeholders = {} 59 | 60 | # Deal with various limits and batching 61 | batch_size = options[:batch_size] 62 | limit = [record_limit, scan_limit, batch_size].compact.min 63 | 64 | # filter expression 65 | convertor = FilterExpressionConvertor.new(conditions, name_placeholders, value_placeholders, name_placeholder_sequence, value_placeholder_sequence) 66 | filter_expression = convertor.expression 67 | value_placeholders = convertor.value_placeholders 68 | name_placeholders = convertor.name_placeholders 69 | 70 | # projection expression 71 | convertor = ProjectionExpressionConvertor.new(options[:project], name_placeholders, name_placeholder_sequence) 72 | projection_expression = convertor.expression 73 | name_placeholders = convertor.name_placeholders 74 | 75 | request = options.slice( 76 | :consistent_read, 77 | :exclusive_start_key, 78 | :select, 79 | :index_name 80 | ).compact 81 | 82 | request[:table_name] = table.name 83 | request[:limit] = limit if limit 84 | request[:filter_expression] = filter_expression if filter_expression.present? 85 | request[:expression_attribute_values] = value_placeholders if value_placeholders.present? 86 | request[:expression_attribute_names] = name_placeholders if name_placeholders.present? 87 | request[:projection_expression] = projection_expression if projection_expression.present? 88 | 89 | request 90 | end 91 | 92 | def record_limit 93 | options[:record_limit] 94 | end 95 | 96 | def scan_limit 97 | options[:scan_limit] 98 | end 99 | end 100 | end 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /lib/dynamoid/adapter_plugin/aws_sdk_v3/table.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dynamoid 4 | # @private 5 | module AdapterPlugin 6 | class AwsSdkV3 7 | # Represents a table. Exposes data from the "DescribeTable" API call, and also 8 | # provides methods for coercing values to the proper types based on the table's schema data 9 | class Table 10 | attr_reader :schema 11 | 12 | # 13 | # @param [Hash] schema Data returns from a "DescribeTable" call 14 | # 15 | def initialize(schema) 16 | @schema = schema[:table] 17 | end 18 | 19 | def range_key 20 | @range_key ||= schema[:key_schema].find { |d| d[:key_type] == RANGE_KEY }.try(:attribute_name) 21 | end 22 | 23 | def range_type 24 | range_type ||= schema[:attribute_definitions].find do |d| 25 | d[:attribute_name] == range_key 26 | end.try(:fetch, :attribute_type, nil) 27 | end 28 | 29 | def hash_key 30 | @hash_key ||= schema[:key_schema].find { |d| d[:key_type] == HASH_KEY }.try(:attribute_name).to_sym 31 | end 32 | 33 | # 34 | # Returns the API type (e.g. "N", "S") for the given column, if the schema defines it, 35 | # nil otherwise 36 | # 37 | def col_type(col) 38 | col = col.to_s 39 | col_def = schema[:attribute_definitions].find { |d| d[:attribute_name] == col.to_s } 40 | col_def && col_def[:attribute_type] 41 | end 42 | 43 | def item_count 44 | schema[:item_count] 45 | end 46 | 47 | def name 48 | schema[:table_name] 49 | end 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/dynamoid/adapter_plugin/aws_sdk_v3/transact.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Prepare all the actions of the transaction for sending to the AWS SDK. 4 | module Dynamoid 5 | module AdapterPlugin 6 | class AwsSdkV3 7 | class Transact 8 | attr_reader :client 9 | 10 | def initialize(client) 11 | @client = client 12 | end 13 | 14 | # Perform all of the item actions in a single transaction. 15 | # 16 | # @param [Array] items of type Dynamoid::Transaction::Action or 17 | # any other object whose to_h is a transact_item hash 18 | # 19 | def transact_write_items(items) 20 | transact_items = items.map(&:to_h) 21 | params = { 22 | transact_items: transact_items, 23 | return_consumed_capacity: 'TOTAL', 24 | return_item_collection_metrics: 'SIZE' 25 | } 26 | client.transact_write_items(params) # returns this 27 | end 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/dynamoid/adapter_plugin/aws_sdk_v3/until_past_table_status.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dynamoid 4 | # @private 5 | module AdapterPlugin 6 | class AwsSdkV3 7 | class UntilPastTableStatus 8 | attr_reader :client, :table_name, :status 9 | 10 | def initialize(client, table_name, status = :creating) 11 | @client = client 12 | @table_name = table_name 13 | @status = status 14 | end 15 | 16 | def call 17 | counter = 0 18 | resp = nil 19 | begin 20 | check = { again: true } 21 | while check[:again] 22 | sleep Dynamoid::Config.sync_retry_wait_seconds 23 | resp = client.describe_table(table_name: table_name) 24 | check = check_table_status?(counter, resp, status) 25 | Dynamoid.logger.info "Checked table status for #{table_name} (check #{check.inspect})" 26 | counter += 1 27 | end 28 | # If you issue a DescribeTable request immediately after a CreateTable 29 | # request, DynamoDB might return a ResourceNotFoundException. 30 | # This is because DescribeTable uses an eventually consistent query, 31 | # and the metadata for your table might not be available at that moment. 32 | # Wait for a few seconds, and then try the DescribeTable request again. 33 | # See: http://docs.aws.amazon.com/sdkforruby/api/Aws/DynamoDB/Client.html#describe_table-instance_method 34 | rescue Aws::DynamoDB::Errors::ResourceNotFoundException => e 35 | case status 36 | when :creating 37 | if counter >= Dynamoid::Config.sync_retry_max_times 38 | Dynamoid.logger.warn "Waiting on table metadata for #{table_name} (check #{counter})" 39 | retry # start over at first line of begin, does not reset counter 40 | else 41 | Dynamoid.logger.error "Exhausted max retries (Dynamoid::Config.sync_retry_max_times) waiting on table metadata for #{table_name} (check #{counter})" 42 | raise e 43 | end 44 | else 45 | # When deleting a table, "not found" is the goal. 46 | Dynamoid.logger.info "Checked table status for #{table_name}: Not Found (check #{check.inspect})" 47 | end 48 | end 49 | end 50 | 51 | private 52 | 53 | def check_table_status?(counter, resp, expect_status) 54 | status = PARSE_TABLE_STATUS.call(resp) 55 | again = counter < Dynamoid::Config.sync_retry_max_times && 56 | status == TABLE_STATUSES[expect_status] 57 | { again: again, status: status, counter: counter } 58 | end 59 | end 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/dynamoid/application_time_zone.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dynamoid 4 | # @private 5 | module ApplicationTimeZone 6 | def self.at(value) 7 | case Dynamoid::Config.application_timezone 8 | when :utc 9 | ActiveSupport::TimeZone['UTC'].at(value).to_datetime 10 | when :local 11 | Time.at(value).to_datetime 12 | when String 13 | ActiveSupport::TimeZone[Dynamoid::Config.application_timezone].at(value).to_datetime 14 | end 15 | end 16 | 17 | def self.utc_offset 18 | case Dynamoid::Config.application_timezone 19 | when :utc 20 | 0 21 | when :local 22 | Time.now.utc_offset 23 | when String 24 | ActiveSupport::TimeZone[Dynamoid::Config.application_timezone].now.utc_offset 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/dynamoid/associations/association.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dynamoid 4 | # The base association module which all associations include. Every association has two very important components: the source and 5 | # the target. The source is the object which is calling the association information. It always has the target_ids inside of an attribute on itself. 6 | # The target is the object which is referencing by this association. 7 | # @private 8 | module Associations 9 | # @private 10 | module Association 11 | attr_accessor :name, :options, :source, :loaded 12 | 13 | # Create a new association. 14 | # 15 | # @param [Class] source the source record of the association; that is, the record that you already have 16 | # @param [Symbol] name the name of the association 17 | # @param [Hash] options optional parameters for the association 18 | # @option options [Class] :class the target class of the association; that is, the class to which the association objects belong 19 | # @option options [Symbol] :class_name the name of the target class of the association; only this or Class is necessary 20 | # @option options [Symbol] :inverse_of the name of the association on the target class 21 | # @option options [Symbol] :foreign_key the name of the field for belongs_to association 22 | # 23 | # @return [Dynamoid::Association] the actual association instance itself 24 | # 25 | # @since 0.2.0 26 | def initialize(source, name, options) 27 | @name = name 28 | @options = options 29 | @source = source 30 | @loaded = false 31 | end 32 | 33 | def loaded? 34 | @loaded 35 | end 36 | 37 | def find_target; end 38 | 39 | def target 40 | unless loaded? 41 | @target = find_target 42 | @loaded = true 43 | end 44 | 45 | @target 46 | end 47 | 48 | def reset 49 | @target = nil 50 | @loaded = false 51 | end 52 | 53 | def declaration_field_name 54 | "#{name}_ids" 55 | end 56 | 57 | def declaration_field_type 58 | :set 59 | end 60 | 61 | def disassociate_source 62 | Array(target).each do |target_entry| 63 | target_entry.send(target_association).disassociate(source.hash_key) if target_entry && target_association 64 | end 65 | end 66 | 67 | private 68 | 69 | # The target class name, either inferred through the association's name or specified in options. 70 | # 71 | # @since 0.2.0 72 | def target_class_name 73 | options[:class_name] || name.to_s.classify 74 | end 75 | 76 | # The target class, either inferred through the association's name or specified in options. 77 | # 78 | # @since 0.2.0 79 | def target_class 80 | options[:class] || target_class_name.constantize 81 | end 82 | 83 | # The target attribute: that is, the attribute on each object of the association that should reference the source. 84 | # 85 | # @since 0.2.0 86 | def target_attribute 87 | # In simple case it's equivalent to 88 | # "#{target_association}_ids".to_sym if target_association 89 | if target_association 90 | target_options = target_class.associations[target_association] 91 | assoc = Dynamoid::Associations.const_get(target_options[:type].to_s.camelcase).new(nil, target_association, target_options) 92 | assoc.send(:source_attribute) 93 | end 94 | end 95 | 96 | # The ids in the target association. 97 | # 98 | # @since 0.2.0 99 | def target_ids 100 | target.send(target_attribute) || Set.new 101 | end 102 | 103 | # The ids in the target association. 104 | # 105 | # @since 0.2.0 106 | def source_class 107 | source.class 108 | end 109 | 110 | # The source's association attribute: the name of the association with _ids afterwards, like "users_ids". 111 | # 112 | # @since 0.2.0 113 | def source_attribute 114 | declaration_field_name.to_sym 115 | end 116 | 117 | # The ids in the source association. 118 | # 119 | # @since 0.2.0 120 | def source_ids 121 | # handle case when we store scalar value instead of collection (when foreign_key option is specified) 122 | Array(source.send(source_attribute)).compact.to_set || Set.new 123 | end 124 | 125 | # Create a new instance of the target class without trying to add it to the association. This creates a base, that caller can update before setting or adding it. 126 | # 127 | # @param attributes [Hash] attribute values for the new object 128 | # 129 | # @return [Dynamoid::Document] the newly-created object 130 | # 131 | # @since 1.1.1 132 | def build(attributes = {}) 133 | target_class.build(attributes) 134 | end 135 | end 136 | end 137 | end 138 | -------------------------------------------------------------------------------- /lib/dynamoid/associations/belongs_to.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dynamoid 4 | # The belongs_to association. For belongs_to, we reference only a single target instead of multiple records; that target is the 5 | # object to which the association object is associated. 6 | module Associations 7 | # @private 8 | class BelongsTo 9 | include SingleAssociation 10 | 11 | def declaration_field_name 12 | options[:foreign_key] || "#{name}_ids" 13 | end 14 | 15 | def declaration_field_type 16 | if options[:foreign_key] 17 | target_class.attributes[target_class.hash_key][:type] 18 | else 19 | :set 20 | end 21 | end 22 | 23 | # Override default implementation 24 | # to handle case when we store id as scalar value, not as collection 25 | def associate(hash_key) 26 | target.send(target_association).disassociate(source.hash_key) if target && target_association 27 | 28 | if options[:foreign_key] 29 | source.update_attribute(source_attribute, hash_key) 30 | else 31 | source.update_attribute(source_attribute, Set[hash_key]) 32 | end 33 | end 34 | 35 | private 36 | 37 | # Find the target association, either has_many or has_one. Uses either options[:inverse_of] or the source class name and default parsing to 38 | # return the most likely name for the target association. 39 | # 40 | # @since 0.2.0 41 | def target_association 42 | name = options[:inverse_of] || source.class.to_s.underscore.pluralize.to_sym 43 | if target_class.associations.dig(name, :type) == :has_many 44 | return name 45 | end 46 | 47 | name = options[:inverse_of] || source.class.to_s.underscore.to_sym 48 | if target_class.associations.dig(name, :type) == :has_one 49 | return name # rubocop:disable Style/RedundantReturn 50 | end 51 | end 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/dynamoid/associations/has_and_belongs_to_many.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dynamoid 4 | # The has and belongs to many association. 5 | module Associations 6 | # @private 7 | class HasAndBelongsToMany 8 | include ManyAssociation 9 | 10 | private 11 | 12 | # Find the target association, always another :has_and_belongs_to_many association. Uses either options[:inverse_of] or the source class name 13 | # and default parsing to return the most likely name for the target association. 14 | # 15 | # @since 0.2.0 16 | def target_association 17 | key_name = options[:inverse_of] || source.class.to_s.pluralize.underscore.to_sym 18 | guess = target_class.associations[key_name] 19 | return nil if guess.nil? || guess[:type] != :has_and_belongs_to_many 20 | 21 | key_name 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/dynamoid/associations/has_many.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dynamoid 4 | # The has_many association. 5 | module Associations 6 | # @private 7 | class HasMany 8 | include ManyAssociation 9 | 10 | private 11 | 12 | # Find the target association, always a :belongs_to association. Uses either options[:inverse_of] or the source class name 13 | # and default parsing to return the most likely name for the target association. 14 | # 15 | # @since 0.2.0 16 | def target_association 17 | key_name = options[:inverse_of] || source.class.to_s.singularize.underscore.to_sym 18 | guess = target_class.associations[key_name] 19 | return nil if guess.nil? || guess[:type] != :belongs_to 20 | 21 | key_name 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/dynamoid/associations/has_one.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dynamoid 4 | # The HasOne association. 5 | module Associations 6 | # @private 7 | class HasOne 8 | include Association 9 | include SingleAssociation 10 | 11 | private 12 | 13 | # Find the target association, always a :belongs_to association. Uses either options[:inverse_of] or the source class name 14 | # and default parsing to return the most likely name for the target association. 15 | # 16 | # @since 0.2.0 17 | def target_association 18 | key_name = options[:inverse_of] || source.class.to_s.singularize.underscore.to_sym 19 | guess = target_class.associations[key_name] 20 | return nil if guess.nil? || guess[:type] != :belongs_to 21 | 22 | key_name 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/dynamoid/associations/single_association.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dynamoid 4 | module Associations 5 | module SingleAssociation 6 | include Association 7 | 8 | delegate :class, to: :target 9 | 10 | # @private 11 | def setter(object) 12 | if object.nil? 13 | delete 14 | return 15 | end 16 | 17 | associate(object.hash_key) 18 | self.target = object 19 | object.send(target_association).associate(source.hash_key) if target_association 20 | object 21 | end 22 | 23 | # Delete a model from the association. 24 | # 25 | # post.logo.delete # => nil 26 | # 27 | # Saves both models immediately - a source model and a target one so any 28 | # unsaved changes will be saved. Doesn't delete an associated model from 29 | # DynamoDB. 30 | def delete 31 | disassociate_source 32 | disassociate 33 | target 34 | end 35 | 36 | # Create a new instance of the target class, persist it and associate. 37 | # 38 | # post.logo.create!(hight: 50, width: 90) 39 | # 40 | # If the creation fails an exception will be raised. 41 | # 42 | # @param attributes [Hash] attributes of a model to create 43 | # @return [Dynamoid::Document] created model 44 | def create!(attributes = {}) 45 | setter(target_class.create!(attributes)) 46 | end 47 | 48 | # Create a new instance of the target class, persist it and associate. 49 | # 50 | # post.logo.create(hight: 50, width: 90) 51 | # 52 | # @param attributes [Hash] attributes of a model to create 53 | # @return [Dynamoid::Document] created model 54 | def create(attributes = {}) 55 | setter(target_class.create(attributes)) 56 | end 57 | 58 | # Is this object equal to the association's target? 59 | # 60 | # @return [Boolean] true/false 61 | # 62 | # @since 0.2.0 63 | def ==(other) 64 | target == other 65 | end 66 | 67 | if ::RUBY_VERSION < '2.7' 68 | # Delegate methods we don't find directly to the target. 69 | # 70 | # @private 71 | # @since 0.2.0 72 | def method_missing(method, *args, &block) 73 | if target.respond_to?(method) 74 | target.send(method, *args, &block) 75 | else 76 | super 77 | end 78 | end 79 | else 80 | # Delegate methods we don't find directly to the target. 81 | # 82 | # @private 83 | # @since 0.2.0 84 | def method_missing(method, *args, **kwargs, &block) 85 | if target.respond_to?(method) 86 | target.send(method, *args, **kwargs, &block) 87 | else 88 | super 89 | end 90 | end 91 | end 92 | 93 | # @private 94 | def respond_to_missing?(method_name, include_private = false) 95 | target.respond_to?(method_name, include_private) || super 96 | end 97 | 98 | # @private 99 | def nil? 100 | target.nil? 101 | end 102 | 103 | # @private 104 | def empty? 105 | # This is needed to that ActiveSupport's #blank? and #present? 106 | # methods work as expected for SingleAssociations. 107 | target.nil? 108 | end 109 | 110 | # @private 111 | def associate(hash_key) 112 | disassociate_source 113 | source.update_attribute(source_attribute, Set[hash_key]) 114 | end 115 | 116 | # @private 117 | def disassociate(_hash_key = nil) 118 | source.update_attribute(source_attribute, nil) 119 | end 120 | 121 | private 122 | 123 | # Find the target of the has_one association. 124 | # 125 | # @return [Dynamoid::Document] the found target (or nil if nothing) 126 | # 127 | # @since 0.2.0 128 | def find_target 129 | return if source_ids.empty? 130 | 131 | target_class.find(source_ids.first, raise_error: false) 132 | end 133 | 134 | def target=(object) 135 | @target = object 136 | @loaded = true 137 | end 138 | end 139 | end 140 | end 141 | -------------------------------------------------------------------------------- /lib/dynamoid/components.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dynamoid 4 | # All modules that a Document is composed of are defined in this 5 | # module, to keep the document class from getting too cluttered. 6 | # @private 7 | module Components 8 | extend ActiveSupport::Concern 9 | 10 | included do 11 | extend ActiveModel::Translation 12 | extend ActiveModel::Callbacks 13 | 14 | define_model_callbacks :create, :save, :destroy, :update 15 | define_model_callbacks :initialize, :find, :touch, only: :after 16 | define_model_callbacks :commit, :rollback, only: :after 17 | 18 | before_save :set_expires_field 19 | after_initialize :set_inheritance_field 20 | end 21 | 22 | include ActiveModel::AttributeMethods # Actually it will be inclided in Dirty module again 23 | include ActiveModel::Conversion 24 | include ActiveModel::MassAssignmentSecurity if defined?(ActiveModel::MassAssignmentSecurity) 25 | include ActiveModel::Naming 26 | include ActiveModel::Observing if defined?(ActiveModel::Observing) 27 | include ActiveModel::Serializers::JSON 28 | include ActiveModel::Serializers::Xml if defined?(ActiveModel::Serializers::Xml) 29 | include Dynamoid::Persistence 30 | include Dynamoid::Loadable 31 | # Dirty module should be included after Persistence and Loadable 32 | # because it overrides some methods declared in these modules 33 | include Dynamoid::Dirty 34 | include Dynamoid::Fields 35 | include Dynamoid::Indexes 36 | include Dynamoid::Finders 37 | include Dynamoid::Associations 38 | include Dynamoid::Criteria 39 | include Dynamoid::Validations 40 | include Dynamoid::IdentityMap 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/dynamoid/config.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'uri' 4 | require 'logger' 5 | require 'dynamoid/config/options' 6 | require 'dynamoid/config/backoff_strategies/constant_backoff' 7 | require 'dynamoid/config/backoff_strategies/exponential_backoff' 8 | 9 | module Dynamoid 10 | # Contains all the basic configuration information required for Dynamoid: both sensible defaults and required fields. 11 | # @private 12 | module Config 13 | # @since 3.3.1 14 | DEFAULT_NAMESPACE = if defined?(Rails) 15 | klass = Rails.application.class 16 | app_name = Rails::VERSION::MAJOR >= 6 ? klass.module_parent_name : klass.parent_name 17 | "dynamoid_#{app_name}_#{Rails.env}" 18 | else 19 | 'dynamoid' 20 | end 21 | 22 | extend self 23 | 24 | extend Options 25 | include ActiveModel::Observing if defined?(ActiveModel::Observing) 26 | 27 | # All the default options. 28 | option :adapter, default: 'aws_sdk_v3' 29 | option :namespace, default: DEFAULT_NAMESPACE 30 | option :access_key, default: nil 31 | option :secret_key, default: nil 32 | option :credentials, default: nil 33 | option :region, default: nil 34 | option :batch_size, default: 100 35 | option :capacity_mode, default: nil 36 | option :read_capacity, default: 100 37 | option :write_capacity, default: 20 38 | option :warn_on_scan, default: true 39 | option :endpoint, default: nil 40 | option :identity_map, default: false 41 | option :timestamps, default: true 42 | option :sync_retry_max_times, default: 60 # a bit over 2 minutes 43 | option :sync_retry_wait_seconds, default: 2 44 | option :convert_big_decimal, default: false 45 | option :store_attribute_with_nil_value, default: false # keep or ignore attribute with nil value at saving 46 | option :models_dir, default: './app/models' # perhaps you keep your dynamoid models in a different directory? 47 | option :application_timezone, default: :utc # available values - :utc, :local, time zone name like "Hawaii" 48 | option :dynamodb_timezone, default: :utc # available values - :utc, :local, time zone name like "Hawaii" 49 | option :store_datetime_as_string, default: false # store Time fields in ISO 8601 string format 50 | option :store_date_as_string, default: false # store Date fields in ISO 8601 string format 51 | option :store_empty_string_as_nil, default: true # store attribute's empty String value as null 52 | option :store_boolean_as_native, default: true 53 | option :store_binary_as_native, default: false 54 | option :backoff, default: nil # callable object to handle exceeding of table throughput limit 55 | option :backoff_strategies, default: { 56 | constant: BackoffStrategies::ConstantBackoff, 57 | exponential: BackoffStrategies::ExponentialBackoff 58 | } 59 | option :log_formatter, default: nil 60 | option :http_continue_timeout, default: nil # specify if you'd like to overwrite Aws Configure - default: 1 61 | option :http_idle_timeout, default: nil # - default: 5 62 | option :http_open_timeout, default: nil # - default: 15 63 | option :http_read_timeout, default: nil # - default: 60 64 | option :create_table_on_save, default: true 65 | 66 | # The default logger for Dynamoid: either the Rails logger or just stdout. 67 | # 68 | # @since 0.2.0 69 | def default_logger 70 | defined?(Rails) && Rails.respond_to?(:logger) ? Rails.logger : ::Logger.new($stdout) 71 | end 72 | 73 | # Returns the assigned logger instance. 74 | # 75 | # @since 0.2.0 76 | def logger 77 | @logger ||= default_logger 78 | end 79 | 80 | # If you want to, set the logger manually to any output you'd like. Or pass false or nil to disable logging entirely. 81 | # 82 | # @since 0.2.0 83 | def logger=(logger) 84 | case logger 85 | when false, nil then @logger = ::Logger.new(nil) 86 | when true then @logger = default_logger 87 | else 88 | @logger = logger if logger.respond_to?(:info) 89 | end 90 | end 91 | 92 | def build_backoff 93 | if backoff.is_a?(Hash) 94 | name = backoff.keys[0] 95 | args = backoff.values[0] 96 | 97 | backoff_strategies[name].call(args) 98 | else 99 | backoff_strategies[backoff].call 100 | end 101 | end 102 | end 103 | end 104 | -------------------------------------------------------------------------------- /lib/dynamoid/config/backoff_strategies/constant_backoff.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dynamoid 4 | module Config 5 | # @private 6 | module BackoffStrategies 7 | class ConstantBackoff 8 | def self.call(sec = 1) 9 | -> { sleep sec } 10 | end 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/dynamoid/config/backoff_strategies/exponential_backoff.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dynamoid 4 | module Config 5 | # @private 6 | module BackoffStrategies 7 | # Truncated binary exponential backoff algorithm 8 | # See https://en.wikipedia.org/wiki/Exponential_backoff 9 | class ExponentialBackoff 10 | def self.call(opts = {}) 11 | opts = { base_backoff: 0.5, ceiling: 3 }.merge(opts) 12 | base_backoff = opts[:base_backoff] 13 | ceiling = opts[:ceiling] 14 | 15 | times = 1 16 | 17 | lambda do 18 | power = [times - 1, ceiling - 1].min 19 | backoff = base_backoff * (2**power) 20 | sleep backoff 21 | 22 | times += 1 23 | end 24 | end 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/dynamoid/config/options.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Shamelessly stolen from Mongoid! 4 | module Dynamoid 5 | module Config 6 | # Encapsulates logic for setting options. 7 | # @private 8 | module Options 9 | # Get the defaults or initialize a new empty hash. 10 | # 11 | # @example Get the defaults. 12 | # options.defaults 13 | # 14 | # @return [ Hash ] The default options. 15 | # 16 | # @since 0.2.0 17 | def defaults 18 | @defaults ||= {} 19 | end 20 | 21 | # Define a configuration option with a default. 22 | # 23 | # @example Define the option. 24 | # Options.option(:persist_in_safe_mode, :default => false) 25 | # 26 | # @param [ Symbol ] name The name of the configuration option. 27 | # @param [ Hash ] options Extras for the option. 28 | # 29 | # @option options [ Object ] :default The default value. 30 | # 31 | # @since 0.2.0 32 | def option(name, options = {}) 33 | defaults[name] = settings[name] = options[:default] 34 | 35 | class_eval <<-RUBY, __FILE__, __LINE__ + 1 36 | def #{name} # def endpoint 37 | settings[#{name.inspect}] # settings["endpoint"] 38 | end # end 39 | 40 | def #{name}=(value) # def endpoint=(value) 41 | settings[#{name.inspect}] = value # settings["endpoint"] = value 42 | end # end 43 | 44 | def #{name}? # def endpoint? 45 | #{name} # endpoint 46 | end # end 47 | 48 | def reset_#{name} # def reset_endpoint 49 | settings[#{name.inspect}] = defaults[#{name.inspect}] # settings["endpoint"] = defaults["endpoint"] 50 | end # end 51 | RUBY 52 | end 53 | 54 | # Reset the configuration options to the defaults. 55 | # 56 | # @example Reset the configuration options. 57 | # config.reset 58 | # 59 | # @return [ Hash ] The defaults. 60 | # 61 | # @since 0.2.0 62 | def reset 63 | settings.replace(defaults) 64 | end 65 | 66 | # Get the settings or initialize a new empty hash. 67 | # 68 | # @example Get the settings. 69 | # options.settings 70 | # 71 | # @return [ Hash ] The setting options. 72 | # 73 | # @since 0.2.0 74 | def settings 75 | @settings ||= {} 76 | end 77 | end 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /lib/dynamoid/criteria.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'dynamoid/criteria/chain' 4 | 5 | module Dynamoid 6 | # Allows classes to be queried by where, all, first, and each and return criteria chains. 7 | module Criteria 8 | extend ActiveSupport::Concern 9 | 10 | # @private 11 | module ClassMethods 12 | %i[ 13 | where consistent all first last delete_all destroy_all each record_limit 14 | scan_limit batch start scan_index_forward find_by_pages project pluck 15 | ].each do |name| 16 | # Return a criteria chain in response to a method that will begin or end a chain. For more information, 17 | # see Dynamoid::Criteria::Chain. 18 | # 19 | # @since 0.2.0 20 | define_method(name) do |*args, &blk| 21 | # Don't use keywork arguments delegating (with **kw). It works in 22 | # different way in different Ruby versions: <= 2.6, 2.7, 3.0 and in some 23 | # future 3.x versions. Providing that there are no downstream methods 24 | # with keyword arguments in Chain. 25 | # 26 | # https://eregon.me/blog/2019/11/10/the-delegation-challenge-of-ruby27.html 27 | 28 | chain = Dynamoid::Criteria::Chain.new(self) 29 | chain.send(name, *args, &blk) 30 | end 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/dynamoid/criteria/key_fields_detector.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dynamoid 4 | module Criteria 5 | # @private 6 | class KeyFieldsDetector 7 | class Query 8 | def initialize(where_conditions) 9 | @where_conditions = where_conditions 10 | @fields_with_operator = where_conditions.keys.map(&:to_s) 11 | @fields = where_conditions.keys.map(&:to_s).map { |s| s.split('.').first } 12 | end 13 | 14 | def contain_only?(field_names) 15 | (@fields - field_names.map(&:to_s)).blank? 16 | end 17 | 18 | def contain?(field_name) 19 | @fields.include?(field_name.to_s) 20 | end 21 | 22 | def contain_with_eq_operator?(field_name) 23 | @fields_with_operator.include?(field_name.to_s) 24 | end 25 | end 26 | 27 | def initialize(where_conditions, source, forced_index_name: nil) 28 | @source = source 29 | @query = Query.new(where_conditions) 30 | @forced_index_name = forced_index_name 31 | @result = find_keys_in_query 32 | end 33 | 34 | def non_key_present? 35 | !@query.contain_only?([hash_key, range_key].compact) 36 | end 37 | 38 | def key_present? 39 | @result.present? && @query.contain_with_eq_operator?(hash_key) 40 | end 41 | 42 | def hash_key 43 | @result && @result[:hash_key] 44 | end 45 | 46 | def range_key 47 | @result && @result[:range_key] 48 | end 49 | 50 | def index_name 51 | @result && @result[:index_name] 52 | end 53 | 54 | private 55 | 56 | def find_keys_in_query 57 | return match_forced_index if @forced_index_name 58 | 59 | match_table_and_sort_key || 60 | match_local_secondary_index || 61 | match_global_secondary_index_and_sort_key || 62 | match_table || 63 | match_global_secondary_index 64 | end 65 | 66 | # Use table's default range key 67 | def match_table_and_sort_key 68 | return unless @query.contain_with_eq_operator?(@source.hash_key) 69 | return unless @source.range_key 70 | 71 | if @query.contain?(@source.range_key) 72 | { 73 | hash_key: @source.hash_key, 74 | range_key: @source.range_key 75 | } 76 | end 77 | end 78 | 79 | # See if can use any local secondary index range key 80 | # Chooses the first LSI found that can be utilized for the query 81 | def match_local_secondary_index 82 | return unless @query.contain_with_eq_operator?(@source.hash_key) 83 | 84 | lsi = @source.local_secondary_indexes.values.find do |i| 85 | @query.contain?(i.range_key) 86 | end 87 | 88 | if lsi.present? 89 | { 90 | hash_key: @source.hash_key, 91 | range_key: lsi.range_key, 92 | index_name: lsi.name, 93 | } 94 | end 95 | end 96 | 97 | # See if can use any global secondary index 98 | # Chooses the first GSI found that can be utilized for the query 99 | # GSI with range key involved into query conditions has higher priority 100 | # But only do so if projects ALL attributes otherwise we won't 101 | # get back full data 102 | def match_global_secondary_index_and_sort_key 103 | gsi = @source.global_secondary_indexes.values.find do |i| 104 | @query.contain_with_eq_operator?(i.hash_key) && i.projected_attributes == :all && 105 | @query.contain?(i.range_key) 106 | end 107 | 108 | if gsi.present? 109 | { 110 | hash_key: gsi.hash_key, 111 | range_key: gsi.range_key, 112 | index_name: gsi.name, 113 | } 114 | end 115 | end 116 | 117 | def match_table 118 | return unless @query.contain_with_eq_operator?(@source.hash_key) 119 | 120 | { 121 | hash_key: @source.hash_key, 122 | } 123 | end 124 | 125 | def match_global_secondary_index 126 | gsi = @source.global_secondary_indexes.values.find do |i| 127 | @query.contain_with_eq_operator?(i.hash_key) && i.projected_attributes == :all 128 | end 129 | 130 | if gsi.present? 131 | { 132 | hash_key: gsi.hash_key, 133 | range_key: gsi.range_key, 134 | index_name: gsi.name, 135 | } 136 | end 137 | end 138 | 139 | def match_forced_index 140 | idx = @source.find_index_by_name(@forced_index_name) 141 | 142 | { 143 | hash_key: idx.hash_key, 144 | range_key: idx.range_key, 145 | index_name: idx.name, 146 | } 147 | end 148 | end 149 | end 150 | end 151 | -------------------------------------------------------------------------------- /lib/dynamoid/criteria/nonexistent_fields_detector.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dynamoid 4 | module Criteria 5 | # @private 6 | class NonexistentFieldsDetector 7 | def initialize(conditions, source) 8 | @conditions = conditions 9 | @source = source 10 | @nonexistent_fields = nonexistent_fields 11 | end 12 | 13 | def found? 14 | @nonexistent_fields.present? 15 | end 16 | 17 | def warning_message 18 | return unless found? 19 | 20 | fields_list = @nonexistent_fields.map { |s| "`#{s}`" }.join(', ') 21 | count = @nonexistent_fields.size 22 | 23 | 'where conditions contain nonexistent ' \ 24 | "field #{'name'.pluralize(count)} #{fields_list}" 25 | end 26 | 27 | private 28 | 29 | def nonexistent_fields 30 | fields_from_conditions - fields_existent 31 | end 32 | 33 | def fields_from_conditions 34 | @conditions.keys.map { |s| s.to_s.split('.')[0].to_sym } 35 | end 36 | 37 | def fields_existent 38 | @source.attributes.keys.map(&:to_sym) 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/dynamoid/criteria/where_conditions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dynamoid 4 | module Criteria 5 | # @private 6 | class WhereConditions 7 | attr_reader :string_conditions 8 | 9 | def initialize 10 | @hash_conditions = [] 11 | @string_conditions = [] 12 | end 13 | 14 | def update_with_hash(hash) 15 | @hash_conditions << hash.symbolize_keys 16 | end 17 | 18 | def update_with_string(query, placeholders) 19 | @string_conditions << [query, placeholders] 20 | end 21 | 22 | def keys 23 | @hash_conditions.flat_map(&:keys) 24 | end 25 | 26 | def empty? 27 | @hash_conditions.empty? && @string_conditions.empty? 28 | end 29 | 30 | def [](key) 31 | hash = @hash_conditions.find { |h| h.key?(key) } 32 | hash[key] if hash 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/dynamoid/dynamodb_time_zone.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dynamoid 4 | # @private 5 | module DynamodbTimeZone 6 | def self.in_time_zone(value) 7 | case Dynamoid::Config.dynamodb_timezone 8 | when :utc 9 | value.utc.to_datetime 10 | when :local 11 | value.getlocal.to_datetime 12 | else 13 | value.in_time_zone(Dynamoid::Config.dynamodb_timezone).to_datetime 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/dynamoid/errors.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dynamoid 4 | # All the errors specific to Dynamoid. The goal is to mimic ActiveRecord. 5 | module Errors 6 | # Generic Dynamoid error 7 | class Error < StandardError; end 8 | 9 | class MissingHashKey < Error; end 10 | class MissingRangeKey < Error; end 11 | 12 | class MissingIndex < Error; end 13 | 14 | # InvalidIndex is raised when an invalid index is specified, for example if 15 | # specified key attribute(s) or projected attributes do not exist. 16 | class InvalidIndex < Error 17 | def initialize(item) 18 | if item.is_a? String 19 | super 20 | else 21 | super("Validation failed: #{item.errors.full_messages.join(', ')}") 22 | end 23 | end 24 | end 25 | 26 | class RecordNotSaved < Error 27 | attr_reader :record 28 | 29 | def initialize(record) 30 | super('Failed to save the item') 31 | @record = record 32 | end 33 | end 34 | 35 | class RecordNotDestroyed < Error 36 | attr_reader :record 37 | 38 | def initialize(record) 39 | super('Failed to destroy the item') 40 | @record = record 41 | end 42 | end 43 | 44 | # This class is intended to be private to Dynamoid. 45 | class ConditionalCheckFailedException < Error 46 | attr_reader :inner_exception 47 | 48 | def initialize(inner) 49 | super 50 | @inner_exception = inner 51 | end 52 | end 53 | 54 | class RecordNotUnique < ConditionalCheckFailedException 55 | attr_reader :original_exception 56 | 57 | def initialize(original_exception, record) 58 | super("Attempted to write record #{record} when its key already exists") 59 | @original_exception = original_exception 60 | end 61 | end 62 | 63 | class StaleObjectError < ConditionalCheckFailedException 64 | attr_reader :record, :attempted_action 65 | 66 | def initialize(record, attempted_action) 67 | super("Attempted to #{attempted_action} a stale object #{record}") 68 | @record = record 69 | @attempted_action = attempted_action 70 | end 71 | end 72 | 73 | class RecordNotFound < Error 74 | end 75 | 76 | class DocumentNotValid < Error 77 | attr_reader :document 78 | 79 | def initialize(document) 80 | super("Validation failed: #{document.errors.full_messages.join(', ')}") 81 | @document = document 82 | end 83 | end 84 | 85 | class InvalidQuery < Error; end 86 | 87 | class UnsupportedKeyType < Error; end 88 | 89 | class UnknownAttribute < Error 90 | attr_reader :model_class, :attribute_name 91 | 92 | def initialize(model_class, attribute_name) 93 | super("Attribute #{attribute_name} does not exist in #{model_class}") 94 | 95 | @model_class = model_class 96 | @attribute_name = attribute_name 97 | end 98 | end 99 | 100 | class SubclassNotFound < Error; end 101 | 102 | class Rollback < Error; end 103 | end 104 | end 105 | -------------------------------------------------------------------------------- /lib/dynamoid/fields/declare.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dynamoid 4 | module Fields 5 | # @private 6 | class Declare 7 | def initialize(source, name, type, options) 8 | @source = source 9 | @name = name.to_sym 10 | @type = type 11 | @options = options 12 | end 13 | 14 | def call 15 | # Register new field metadata 16 | @source.attributes = @source.attributes.merge( 17 | @name => { type: @type }.merge(@options) 18 | ) 19 | 20 | # Should be called before `define_attribute_methods` method because it 21 | # defines an attribute getter itself 22 | warn_about_method_overriding 23 | 24 | # Dirty API 25 | @source.define_attribute_method(@name) 26 | 27 | # Generate getters and setters as well as other helper methods 28 | generate_instance_methods 29 | 30 | # If alias name specified - generate the same instance methods 31 | if @options[:alias] 32 | generate_instance_methods_for_alias 33 | end 34 | end 35 | 36 | private 37 | 38 | def warn_about_method_overriding 39 | warn_if_method_exists(@name) 40 | warn_if_method_exists("#{@name}=") 41 | warn_if_method_exists("#{@name}?") 42 | warn_if_method_exists("#{@name}_before_type_cast?") 43 | end 44 | 45 | def generate_instance_methods 46 | # only local variable is visible in `module_eval` block 47 | name = @name 48 | 49 | @source.generated_methods.module_eval do 50 | define_method(name) { read_attribute(name) } 51 | define_method(:"#{name}?") do 52 | value = read_attribute(name) 53 | case value 54 | when true then true 55 | when false, nil then false 56 | else 57 | !value.nil? 58 | end 59 | end 60 | define_method(:"#{name}=") { |value| write_attribute(name, value) } 61 | define_method(:"#{name}_before_type_cast") { read_attribute_before_type_cast(name) } 62 | end 63 | end 64 | 65 | def generate_instance_methods_for_alias 66 | # only local variable is visible in `module_eval` block 67 | name = @name 68 | 69 | alias_name = @options[:alias].to_sym 70 | 71 | @source.generated_methods.module_eval do 72 | alias_method alias_name, name 73 | alias_method :"#{alias_name}=", :"#{name}=" 74 | alias_method :"#{alias_name}?", :"#{name}?" 75 | alias_method :"#{alias_name}_before_type_cast", :"#{name}_before_type_cast" 76 | end 77 | end 78 | 79 | def warn_if_method_exists(method) 80 | if @source.instance_methods.include?(method.to_sym) 81 | Dynamoid.logger.warn("Method #{method} generated for the field #{@name} overrides already existing method") 82 | end 83 | end 84 | end 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /lib/dynamoid/identity_map.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dynamoid 4 | module IdentityMap 5 | extend ActiveSupport::Concern 6 | 7 | def self.clear 8 | Dynamoid.included_models.each { |m| m.identity_map.clear } 9 | end 10 | 11 | module ClassMethods 12 | def identity_map 13 | @identity_map ||= {} 14 | end 15 | 16 | # @private 17 | def from_database(attrs = {}) 18 | return super if identity_map_off? 19 | 20 | key = identity_map_key(attrs) 21 | document = identity_map[key] 22 | 23 | if document.nil? 24 | document = super 25 | identity_map[key] = document 26 | else 27 | document.load(attrs) 28 | end 29 | 30 | document 31 | end 32 | 33 | # @private 34 | def find_by_id(id, options = {}) 35 | return super if identity_map_off? 36 | 37 | key = id.to_s 38 | 39 | if range_key = options[:range_key] 40 | key += "::#{range_key}" 41 | end 42 | 43 | identity_map[key] || super 44 | end 45 | 46 | # @private 47 | def identity_map_key(attrs) 48 | key = attrs[hash_key].to_s 49 | key += "::#{attrs[range_key]}" if range_key 50 | key 51 | end 52 | 53 | def identity_map_on? 54 | Dynamoid::Config.identity_map 55 | end 56 | 57 | def identity_map_off? 58 | !identity_map_on? 59 | end 60 | end 61 | 62 | def identity_map 63 | self.class.identity_map 64 | end 65 | 66 | # @private 67 | def save(*args) 68 | return super if self.class.identity_map_off? 69 | 70 | if result = super 71 | identity_map[identity_map_key] = self 72 | end 73 | result 74 | end 75 | 76 | # @private 77 | def delete 78 | return super if self.class.identity_map_off? 79 | 80 | identity_map.delete(identity_map_key) 81 | super 82 | end 83 | 84 | # @private 85 | def identity_map_key 86 | key = hash_key.to_s 87 | key += "::#{range_value}" if self.class.range_key 88 | key 89 | end 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /lib/dynamoid/loadable.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dynamoid 4 | module Loadable 5 | extend ActiveSupport::Concern 6 | 7 | def load(attrs) 8 | attrs.each do |key, value| 9 | send(:"#{key}=", value) if respond_to?(:"#{key}=") 10 | end 11 | 12 | self 13 | end 14 | alias assign_attributes load 15 | 16 | # Reload an object from the database -- if you suspect the object has changed in the data store and you need those 17 | # changes to be reflected immediately, you would call this method. This is a consistent read. 18 | # 19 | # @return [Dynamoid::Document] self 20 | # 21 | # @since 0.2.0 22 | def reload 23 | options = { consistent_read: true } 24 | 25 | if self.class.range_key 26 | options[:range_key] = range_value 27 | end 28 | 29 | self.attributes = self.class.find(hash_key, **options).attributes 30 | 31 | @associations.each_value(&:reset) 32 | @new_record = false 33 | 34 | self 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/dynamoid/log/formatter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dynamoid 4 | module Log 5 | # https://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/Log/Formatter.html 6 | # https://docs.aws.amazon.com/sdk-for-ruby/v2/api/Seahorse/Client/Response.html 7 | # https://aws.amazon.com/ru/blogs/developer/logging-requests/ 8 | module Formatter 9 | class Debug 10 | def format(response) 11 | bold = "\x1b[1m" 12 | color = "\x1b[34m" 13 | reset = "\x1b[0m" 14 | 15 | [ 16 | response.context.operation.name, 17 | "#{bold}#{color}\nRequest:\n#{reset}#{bold}", 18 | JSON.pretty_generate(JSON.parse(response.context.http_request.body.string)), 19 | "#{bold}#{color}\nResponse:\n#{reset}#{bold}", 20 | JSON.pretty_generate(JSON.parse(response.context.http_response.body.string)), 21 | reset 22 | ].join("\n") 23 | end 24 | end 25 | 26 | class Compact 27 | def format(response) 28 | bold = "\x1b[1m" 29 | reset = "\x1b[0m" 30 | 31 | [ 32 | response.context.operation.name, 33 | bold, 34 | response.context.http_request.body.string, 35 | reset 36 | ].join(' ') 37 | end 38 | end 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/dynamoid/middleware/identity_map.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dynamoid 4 | # @private 5 | module Middleware 6 | class IdentityMap 7 | def initialize(app) 8 | @app = app 9 | end 10 | 11 | def call(env) 12 | Dynamoid::IdentityMap.clear 13 | @app.call(env) 14 | ensure 15 | Dynamoid::IdentityMap.clear 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/dynamoid/persistence/import.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'securerandom' 4 | 5 | module Dynamoid 6 | module Persistence 7 | # @private 8 | class Import 9 | def self.call(model_class, array_of_attributes) 10 | new(model_class, array_of_attributes).call 11 | end 12 | 13 | def initialize(model_class, array_of_attributes) 14 | @model_class = model_class 15 | @array_of_attributes = array_of_attributes 16 | end 17 | 18 | def call 19 | models = @array_of_attributes.map(&method(:build_model)) 20 | 21 | unless Dynamoid.config.backoff 22 | import(models) 23 | else 24 | import_with_backoff(models) 25 | end 26 | 27 | models.each do |m| 28 | m.new_record = false 29 | m.clear_changes_information 30 | end 31 | models 32 | end 33 | 34 | private 35 | 36 | def build_model(attributes) 37 | attrs = attributes.symbolize_keys 38 | 39 | if @model_class.timestamps_enabled? 40 | time_now = DateTime.now.in_time_zone(Time.zone) 41 | attrs[:created_at] ||= time_now 42 | attrs[:updated_at] ||= time_now 43 | end 44 | 45 | @model_class.build(attrs).tap do |model| 46 | model.hash_key = SecureRandom.uuid if model.hash_key.blank? 47 | end 48 | end 49 | 50 | def import_with_backoff(models) 51 | backoff = nil 52 | table_name = @model_class.table_name 53 | items = array_of_dumped_attributes(models) 54 | 55 | Dynamoid.adapter.batch_write_item(table_name, items) do |has_unprocessed_items| 56 | if has_unprocessed_items 57 | backoff ||= Dynamoid.config.build_backoff 58 | backoff.call 59 | else 60 | backoff = nil 61 | end 62 | end 63 | end 64 | 65 | def import(models) 66 | Dynamoid.adapter.batch_write_item(@model_class.table_name, array_of_dumped_attributes(models)) 67 | end 68 | 69 | def array_of_dumped_attributes(models) 70 | models.map do |m| 71 | Dumping.dump_attributes(m.attributes, @model_class.attributes) 72 | end 73 | end 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /lib/dynamoid/persistence/inc.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'item_updater_with_casting_and_dumping' 4 | 5 | module Dynamoid 6 | module Persistence 7 | # @private 8 | class Inc 9 | def self.call(model_class, hash_key, range_key = nil, counters) 10 | new(model_class, hash_key, range_key, counters).call 11 | end 12 | 13 | # rubocop:disable Style/OptionalArguments 14 | def initialize(model_class, hash_key, range_key = nil, counters) 15 | @model_class = model_class 16 | @hash_key = hash_key 17 | @range_key = range_key 18 | @counters = counters 19 | end 20 | # rubocop:enable Style/OptionalArguments 21 | 22 | def call 23 | touch = @counters.delete(:touch) 24 | 25 | Dynamoid.adapter.update_item(@model_class.table_name, @hash_key, update_item_options) do |t| 26 | item_updater = ItemUpdaterWithCastingAndDumping.new(@model_class, t) 27 | 28 | @counters.each do |name, value| 29 | item_updater.add(name => value) 30 | end 31 | 32 | if touch 33 | value = DateTime.now.in_time_zone(Time.zone) 34 | 35 | timestamp_attributes_to_touch(touch).each do |name| 36 | item_updater.set(name => value) 37 | end 38 | end 39 | end 40 | end 41 | 42 | private 43 | 44 | def update_item_options 45 | if @model_class.range_key 46 | range_key_options = @model_class.attributes[@model_class.range_key] 47 | value_casted = TypeCasting.cast_field(@range_key, range_key_options) 48 | value_dumped = Dumping.dump_field(value_casted, range_key_options) 49 | { range_key: value_dumped } 50 | else 51 | {} 52 | end 53 | end 54 | 55 | def timestamp_attributes_to_touch(touch) 56 | return [] unless touch 57 | 58 | names = [] 59 | names << :updated_at if @model_class.timestamps_enabled? 60 | names += Array.wrap(touch) if touch != true 61 | names 62 | end 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/dynamoid/persistence/item_updater_with_casting_and_dumping.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dynamoid 4 | module Persistence 5 | # @private 6 | class ItemUpdaterWithCastingAndDumping 7 | def initialize(model_class, item_updater) 8 | @model_class = model_class 9 | @item_updater = item_updater 10 | end 11 | 12 | def add(attributes) 13 | @item_updater.add(cast_and_dump(attributes)) 14 | end 15 | 16 | def set(attributes) 17 | @item_updater.set(cast_and_dump(attributes)) 18 | end 19 | 20 | private 21 | 22 | def cast_and_dump(attributes) 23 | casted_and_dumped = {} 24 | 25 | attributes.each do |name, value| 26 | value_casted = TypeCasting.cast_field(value, @model_class.attributes[name]) 27 | value_dumped = Dumping.dump_field(value_casted, @model_class.attributes[name]) 28 | 29 | casted_and_dumped[name] = value_dumped 30 | end 31 | 32 | casted_and_dumped 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/dynamoid/persistence/item_updater_with_dumping.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dynamoid 4 | module Persistence 5 | # @private 6 | class ItemUpdaterWithDumping 7 | def initialize(model_class, item_updater) 8 | @model_class = model_class 9 | @item_updater = item_updater 10 | end 11 | 12 | def add(attributes) 13 | @item_updater.add(dump(attributes)) 14 | end 15 | 16 | def set(attributes) 17 | @item_updater.set(dump(attributes)) 18 | end 19 | 20 | private 21 | 22 | def dump(attributes) 23 | dumped = {} 24 | 25 | attributes.each do |name, value| 26 | dumped[name] = Dumping.dump_field(value, @model_class.attributes[name]) 27 | end 28 | 29 | dumped 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/dynamoid/persistence/save.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'item_updater_with_dumping' 4 | 5 | module Dynamoid 6 | module Persistence 7 | # @private 8 | class Save 9 | def self.call(model, **options) 10 | new(model, **options).call 11 | end 12 | 13 | def initialize(model, touch: nil) 14 | @model = model 15 | @touch = touch # touch=false means explicit disabling of updating the `updated_at` attribute 16 | end 17 | 18 | def call 19 | @model.hash_key = SecureRandom.uuid if @model.hash_key.blank? 20 | 21 | return true unless @model.changed? 22 | 23 | @model.created_at ||= DateTime.now.in_time_zone(Time.zone) if @model.class.timestamps_enabled? 24 | 25 | if @model.class.timestamps_enabled? && !@model.updated_at_changed? && !(@touch == false && @model.persisted?) 26 | @model.updated_at = DateTime.now.in_time_zone(Time.zone) 27 | end 28 | 29 | # Add an optimistic locking check if the lock_version column exists 30 | if @model.class.attributes[:lock_version] 31 | @model.lock_version = (@model.lock_version || 0) + 1 32 | end 33 | 34 | if @model.new_record? 35 | attributes_dumped = Dumping.dump_attributes(@model.attributes, @model.class.attributes) 36 | Dynamoid.adapter.write(@model.class.table_name, attributes_dumped, conditions_for_write) 37 | else 38 | attributes_to_persist = @model.attributes.slice(*@model.changed.map(&:to_sym)) 39 | 40 | Dynamoid.adapter.update_item(@model.class.table_name, @model.hash_key, options_to_update_item) do |t| 41 | item_updater = ItemUpdaterWithDumping.new(@model.class, t) 42 | 43 | attributes_to_persist.each do |name, value| 44 | item_updater.set(name => value) 45 | end 46 | end 47 | end 48 | 49 | @model.new_record = false 50 | true 51 | rescue Dynamoid::Errors::ConditionalCheckFailedException => e 52 | if @model.new_record? 53 | raise Dynamoid::Errors::RecordNotUnique.new(e, @model) 54 | else 55 | raise Dynamoid::Errors::StaleObjectError.new(@model, 'persist') 56 | end 57 | end 58 | 59 | private 60 | 61 | # Should be called after incrementing `lock_version` attribute 62 | def conditions_for_write 63 | conditions = {} 64 | 65 | # Add an 'exists' check to prevent overwriting existing records with new ones 66 | if @model.new_record? 67 | conditions[:unless_exists] = [@model.class.hash_key] 68 | if @model.range_key 69 | conditions[:unless_exists] << @model.range_key 70 | end 71 | end 72 | 73 | # Add an optimistic locking check if the lock_version column exists 74 | # Uses the original lock_version value from Dirty API 75 | # in case user changed 'lock_version' manually 76 | if @model.class.attributes[:lock_version] && @model.changes[:lock_version][0] 77 | conditions[:if] ||= {} 78 | conditions[:if][:lock_version] = @model.changes[:lock_version][0] 79 | end 80 | 81 | conditions 82 | end 83 | 84 | def options_to_update_item 85 | options = {} 86 | 87 | if @model.class.range_key 88 | value_dumped = Dumping.dump_field(@model.range_value, @model.class.attributes[@model.class.range_key]) 89 | options[:range_key] = value_dumped 90 | end 91 | 92 | conditions = {} 93 | conditions[:if] ||= {} 94 | conditions[:if][@model.class.hash_key] = @model.hash_key 95 | 96 | # Add an optimistic locking check if the lock_version column exists 97 | # Uses the original lock_version value from Dirty API 98 | # in case user changed 'lock_version' manually 99 | if @model.class.attributes[:lock_version] && @model.changes[:lock_version][0] 100 | conditions[:if] ||= {} 101 | conditions[:if][:lock_version] = @model.changes[:lock_version][0] 102 | end 103 | 104 | options[:conditions] = conditions 105 | 106 | options 107 | end 108 | end 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /lib/dynamoid/persistence/update_fields.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'item_updater_with_casting_and_dumping' 4 | 5 | module Dynamoid 6 | module Persistence 7 | # @private 8 | class UpdateFields 9 | def self.call(*args, **options) 10 | new(*args, **options).call 11 | end 12 | 13 | def initialize(model_class, partition_key:, sort_key:, attributes:, conditions:) 14 | @model_class = model_class 15 | @partition_key = partition_key 16 | @sort_key = sort_key 17 | @attributes = attributes.symbolize_keys 18 | @conditions = conditions 19 | end 20 | 21 | def call 22 | UpdateValidations.validate_attributes_exist(@model_class, @attributes) 23 | 24 | if @model_class.timestamps_enabled? 25 | @attributes[:updated_at] ||= DateTime.now.in_time_zone(Time.zone) 26 | end 27 | 28 | raw_attributes = update_item 29 | @model_class.new(undump_attributes(raw_attributes)) 30 | rescue Dynamoid::Errors::ConditionalCheckFailedException 31 | end 32 | 33 | private 34 | 35 | def update_item 36 | Dynamoid.adapter.update_item(@model_class.table_name, @partition_key, options_to_update_item) do |t| 37 | item_updater = ItemUpdaterWithCastingAndDumping.new(@model_class, t) 38 | 39 | @attributes.each do |k, v| 40 | item_updater.set(k => v) 41 | end 42 | end 43 | end 44 | 45 | def undump_attributes(attributes) 46 | Undumping.undump_attributes(attributes, @model_class.attributes) 47 | end 48 | 49 | def options_to_update_item 50 | options = {} 51 | 52 | if @model_class.range_key 53 | value_casted = TypeCasting.cast_field(@sort_key, @model_class.attributes[@model_class.range_key]) 54 | value_dumped = Dumping.dump_field(value_casted, @model_class.attributes[@model_class.range_key]) 55 | options[:range_key] = value_dumped 56 | end 57 | 58 | conditions = @conditions.deep_dup 59 | conditions[:if] ||= {} 60 | conditions[:if][@model_class.hash_key] = @partition_key 61 | options[:conditions] = conditions 62 | 63 | options 64 | end 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /lib/dynamoid/persistence/update_validations.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dynamoid 4 | module Persistence 5 | # @private 6 | module UpdateValidations 7 | def self.validate_attributes_exist(model_class, attributes) 8 | model_attributes = model_class.attributes.keys 9 | 10 | attributes.each_key do |name| 11 | unless model_attributes.include?(name) 12 | raise Dynamoid::Errors::UnknownAttribute.new(model_class, name) 13 | end 14 | end 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/dynamoid/persistence/upsert.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'item_updater_with_casting_and_dumping' 4 | 5 | module Dynamoid 6 | module Persistence 7 | # @private 8 | class Upsert 9 | def self.call(*args, **options) 10 | new(*args, **options).call 11 | end 12 | 13 | def initialize(model_class, partition_key:, sort_key:, attributes:, conditions:) 14 | @model_class = model_class 15 | @partition_key = partition_key 16 | @sort_key = sort_key 17 | @attributes = attributes.symbolize_keys 18 | @conditions = conditions 19 | end 20 | 21 | def call 22 | UpdateValidations.validate_attributes_exist(@model_class, @attributes) 23 | 24 | if @model_class.timestamps_enabled? 25 | @attributes[:updated_at] ||= DateTime.now.in_time_zone(Time.zone) 26 | end 27 | 28 | raw_attributes = update_item 29 | @model_class.new(undump_attributes(raw_attributes)) 30 | rescue Dynamoid::Errors::ConditionalCheckFailedException 31 | end 32 | 33 | private 34 | 35 | def update_item 36 | Dynamoid.adapter.update_item(@model_class.table_name, @partition_key, options_to_update_item) do |t| 37 | item_updater = ItemUpdaterWithCastingAndDumping.new(@model_class, t) 38 | 39 | @attributes.each do |k, v| 40 | item_updater.set(k => v) 41 | end 42 | end 43 | end 44 | 45 | def options_to_update_item 46 | options = {} 47 | 48 | if @model_class.range_key 49 | value_casted = TypeCasting.cast_field(@sort_key, @model_class.attributes[@model_class.range_key]) 50 | value_dumped = Dumping.dump_field(value_casted, @model_class.attributes[@model_class.range_key]) 51 | options[:range_key] = value_dumped 52 | end 53 | 54 | options[:conditions] = @conditions 55 | options 56 | end 57 | 58 | def undump_attributes(raw_attributes) 59 | Undumping.undump_attributes(raw_attributes, @model_class.attributes) 60 | end 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /lib/dynamoid/primary_key_type_mapping.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dynamoid 4 | # @private 5 | class PrimaryKeyTypeMapping 6 | def self.dynamodb_type(type, options) 7 | if type.is_a?(Class) 8 | type = type.respond_to?(:dynamoid_field_type) ? type.dynamoid_field_type : :string 9 | end 10 | 11 | case type 12 | when :string, :serialized 13 | :string 14 | when :integer, :number 15 | :number 16 | when :datetime 17 | string_format = if options[:store_as_string].nil? 18 | Dynamoid::Config.store_datetime_as_string 19 | else 20 | options[:store_as_string] 21 | end 22 | string_format ? :string : :number 23 | when :date 24 | string_format = if options[:store_as_string].nil? 25 | Dynamoid::Config.store_date_as_string 26 | else 27 | options[:store_as_string] 28 | end 29 | string_format ? :string : :number 30 | else 31 | raise Errors::UnsupportedKeyType, "#{type} cannot be used as a type of table key attribute" 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/dynamoid/railtie.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | if defined? Rails 4 | 5 | module Dynamoid 6 | # @private 7 | class Railtie < Rails::Railtie 8 | rake_tasks do 9 | Dir[File.join(File.dirname(__FILE__), 'tasks/*.rake')].each { |f| load f } 10 | end 11 | end 12 | end 13 | 14 | end 15 | -------------------------------------------------------------------------------- /lib/dynamoid/tasks.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | load 'dynamoid/tasks/database.rake' 4 | -------------------------------------------------------------------------------- /lib/dynamoid/tasks/database.rake: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'dynamoid' 4 | require 'dynamoid/tasks/database' 5 | 6 | namespace :dynamoid do 7 | desc 'Creates DynamoDB tables, one for each of your Dynamoid models - does not modify pre-existing tables' 8 | task create_tables: :environment do 9 | # Load models so Dynamoid will be able to discover tables expected. 10 | Dir[File.join(Dynamoid::Config.models_dir, '**/*.rb')].sort.each { |file| require file } 11 | if Dynamoid.included_models.any? 12 | tables = Dynamoid::Tasks::Database.create_tables 13 | result = tables[:created].map { |c| "#{c} created" } + tables[:existing].map { |e| "#{e} already exists" } 14 | result.sort.each { |r| puts r } 15 | else 16 | puts 'Dynamoid models are not loaded, or you have no Dynamoid models.' 17 | end 18 | end 19 | 20 | desc 'Tests if the DynamoDB instance can be contacted using your configuration' 21 | task ping: :environment do 22 | success = false 23 | failure_reason = nil 24 | 25 | begin 26 | Dynamoid::Tasks::Database.ping 27 | success = true 28 | rescue StandardError => e 29 | failure_reason = e.message 30 | end 31 | 32 | msg = "Connection to DynamoDB #{success ? 'OK' : 'FAILED'}" 33 | msg += if Dynamoid.config.endpoint 34 | " at local endpoint '#{Dynamoid.config.endpoint}'" 35 | else 36 | ' at remote AWS endpoint' 37 | end 38 | msg += ", reason being '#{failure_reason}'" unless success 39 | puts msg 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/dynamoid/tasks/database.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dynamoid 4 | # @private 5 | module Tasks 6 | module Database 7 | module_function 8 | 9 | # Create any new tables for the models. Existing tables are not 10 | # modified. 11 | def create_tables 12 | results = { created: [], existing: [] } 13 | # We can't quite rely on Dynamoid.included_models alone, we need to select only viable models 14 | Dynamoid.included_models.reject { |m| m.base_class.try(:name).blank? }.uniq(&:table_name).each do |model| 15 | if Dynamoid.adapter.list_tables.include? model.table_name 16 | results[:existing] << model.table_name 17 | else 18 | model.create_table(sync: true) 19 | results[:created] << model.table_name 20 | end 21 | end 22 | results 23 | end 24 | 25 | # Is the DynamoDB reachable? 26 | def ping 27 | Dynamoid.adapter.list_tables 28 | true 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/dynamoid/transaction_write/base.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dynamoid 4 | class TransactionWrite 5 | class Base 6 | # Callback called at "initialization" or "registration" an action 7 | # before changes are persisted. It's a proper place to validate 8 | # a model or run callbacks 9 | def on_registration 10 | raise 'Not implemented' 11 | end 12 | 13 | # Callback called after changes are persisted. 14 | # It's a proper place to mark changes in a model as applied. 15 | def on_commit 16 | raise 'Not implemented' 17 | end 18 | 19 | # Callback called when a transaction is rolled back. 20 | # It's a proper place to undo changes made in after_... callbacks. 21 | def on_rollback 22 | raise 'Not implemented' 23 | end 24 | 25 | # Whether some callback aborted or canceled an action 26 | def aborted? 27 | raise 'Not implemented' 28 | end 29 | 30 | # Whether there are changes to persist, e.g. updating a model with no 31 | # attribute changed is skipped. 32 | def skipped? 33 | raise 'Not implemented' 34 | end 35 | 36 | # Value returned to a user as an action result 37 | def observable_by_user_result 38 | raise 'Not implemented' 39 | end 40 | 41 | # Coresponding part of a final request body 42 | def action_request 43 | raise 'Not implemented' 44 | end 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/dynamoid/transaction_write/create.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'base' 4 | 5 | module Dynamoid 6 | class TransactionWrite 7 | class Create < Base 8 | def initialize(model_class, attributes = {}, **options, &block) 9 | super() 10 | 11 | @model = model_class.new(attributes) 12 | 13 | if block 14 | yield(@model) 15 | end 16 | 17 | @save_action = Save.new(@model, **options) 18 | end 19 | 20 | def on_registration 21 | @save_action.on_registration 22 | end 23 | 24 | def on_commit 25 | @save_action.on_commit 26 | end 27 | 28 | def on_rollback 29 | @save_action.on_rollback 30 | end 31 | 32 | def aborted? 33 | @save_action.aborted? 34 | end 35 | 36 | def skipped? 37 | false 38 | end 39 | 40 | def observable_by_user_result 41 | @model 42 | end 43 | 44 | def action_request 45 | @save_action.action_request 46 | end 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/dynamoid/transaction_write/delete_with_instance.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'base' 4 | 5 | module Dynamoid 6 | class TransactionWrite 7 | class DeleteWithInstance < Base 8 | def initialize(model) 9 | super() 10 | 11 | @model = model 12 | @model_class = model.class 13 | end 14 | 15 | def on_registration 16 | validate_model! 17 | end 18 | 19 | def on_commit 20 | @model.destroyed = true 21 | end 22 | 23 | def on_rollback; end 24 | 25 | def aborted? 26 | false 27 | end 28 | 29 | def skipped? 30 | false 31 | end 32 | 33 | def observable_by_user_result 34 | @model 35 | end 36 | 37 | def action_request 38 | key = { @model_class.hash_key => @model.hash_key } 39 | 40 | if @model_class.range_key? 41 | key[@model_class.range_key] = @model.range_value 42 | end 43 | 44 | { 45 | delete: { 46 | key: key, 47 | table_name: @model_class.table_name 48 | } 49 | } 50 | end 51 | 52 | private 53 | 54 | def validate_model! 55 | raise Dynamoid::Errors::MissingHashKey if @model.hash_key.nil? 56 | raise Dynamoid::Errors::MissingRangeKey if @model_class.range_key? && @model.range_value.nil? 57 | end 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/dynamoid/transaction_write/delete_with_primary_key.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'base' 4 | 5 | module Dynamoid 6 | class TransactionWrite 7 | class DeleteWithPrimaryKey < Base 8 | def initialize(model_class, hash_key, range_key) 9 | super() 10 | 11 | @model_class = model_class 12 | @hash_key = hash_key 13 | @range_key = range_key 14 | end 15 | 16 | def on_registration 17 | validate_primary_key! 18 | end 19 | 20 | def on_commit; end 21 | 22 | def on_rollback; end 23 | 24 | def aborted? 25 | false 26 | end 27 | 28 | def skipped? 29 | false 30 | end 31 | 32 | def observable_by_user_result 33 | nil 34 | end 35 | 36 | def action_request 37 | key = { @model_class.hash_key => @hash_key } 38 | 39 | if @model_class.range_key? 40 | key[@model_class.range_key] = @range_key 41 | end 42 | 43 | { 44 | delete: { 45 | key: key, 46 | table_name: @model_class.table_name 47 | } 48 | } 49 | end 50 | 51 | private 52 | 53 | def validate_primary_key! 54 | raise Dynamoid::Errors::MissingHashKey if @hash_key.nil? 55 | raise Dynamoid::Errors::MissingRangeKey if @model_class.range_key? && @range_key.nil? 56 | end 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /lib/dynamoid/transaction_write/destroy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'base' 4 | 5 | module Dynamoid 6 | class TransactionWrite 7 | class Destroy < Base 8 | def initialize(model, **options) 9 | super() 10 | 11 | @model = model 12 | @options = options 13 | @model_class = model.class 14 | @aborted = false 15 | end 16 | 17 | def on_registration 18 | validate_model! 19 | 20 | @aborted = true 21 | @model.run_callbacks(:destroy) do 22 | @aborted = false 23 | true 24 | end 25 | 26 | if @aborted && @options[:raise_error] 27 | raise Dynamoid::Errors::RecordNotDestroyed, @model 28 | end 29 | end 30 | 31 | def on_commit 32 | return if @aborted 33 | 34 | @model.destroyed = true 35 | @model.run_callbacks(:commit) 36 | end 37 | 38 | def on_rollback 39 | @model.run_callbacks(:rollback) 40 | end 41 | 42 | def aborted? 43 | @aborted 44 | end 45 | 46 | def skipped? 47 | false 48 | end 49 | 50 | def observable_by_user_result 51 | return false if @aborted 52 | 53 | @model 54 | end 55 | 56 | def action_request 57 | key = { @model_class.hash_key => @model.hash_key } 58 | 59 | if @model_class.range_key? 60 | key[@model_class.range_key] = @model.range_value 61 | end 62 | 63 | { 64 | delete: { 65 | key: key, 66 | table_name: @model_class.table_name 67 | } 68 | } 69 | end 70 | 71 | private 72 | 73 | def validate_model! 74 | raise Dynamoid::Errors::MissingHashKey if @model.hash_key.nil? 75 | raise Dynamoid::Errors::MissingRangeKey if @model_class.range_key? && @model.range_value.nil? 76 | end 77 | end 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /lib/dynamoid/transaction_write/item_updater.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dynamoid 4 | class TransactionWrite 5 | class ItemUpdater 6 | attr_reader :attributes_to_set, :attributes_to_add, :attributes_to_delete, :attributes_to_remove 7 | 8 | def initialize(model_class) 9 | @model_class = model_class 10 | 11 | @attributes_to_set = {} 12 | @attributes_to_add = {} 13 | @attributes_to_delete = {} 14 | @attributes_to_remove = [] 15 | end 16 | 17 | def empty? 18 | [@attributes_to_set, @attributes_to_add, @attributes_to_delete, @attributes_to_remove].all?(&:empty?) 19 | end 20 | 21 | def set(attributes) 22 | validate_attribute_names!(attributes.keys) 23 | @attributes_to_set.merge!(attributes) 24 | end 25 | 26 | # adds to array of fields for use in REMOVE update expression 27 | def remove(*names) 28 | validate_attribute_names!(names) 29 | @attributes_to_remove += names 30 | end 31 | 32 | # increments a number or adds to a set, starts at 0 or [] if it doesn't yet exist 33 | def add(attributes) 34 | validate_attribute_names!(attributes.keys) 35 | @attributes_to_add.merge!(attributes) 36 | end 37 | 38 | # deletes a value or values from a set 39 | def delete(attributes) 40 | validate_attribute_names!(attributes.keys) 41 | @attributes_to_delete.merge!(attributes) 42 | end 43 | 44 | private 45 | 46 | def validate_attribute_names!(names) 47 | names.each do |name| 48 | unless @model_class.attributes[name] 49 | raise Dynamoid::Errors::UnknownAttribute.new(@model_class, name) 50 | end 51 | end 52 | end 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/dynamoid/transaction_write/update_attributes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'base' 4 | require 'dynamoid/persistence/update_validations' 5 | 6 | module Dynamoid 7 | class TransactionWrite 8 | class UpdateAttributes < Base 9 | def initialize(model, attributes, **options) 10 | super() 11 | 12 | @model = model 13 | @model.assign_attributes(attributes) 14 | @save_action = Save.new(model, **options) 15 | end 16 | 17 | def on_registration 18 | @save_action.on_registration 19 | end 20 | 21 | def on_commit 22 | @save_action.on_commit 23 | end 24 | 25 | def on_rollback 26 | @save_action.on_rollback 27 | end 28 | 29 | def aborted? 30 | @save_action.aborted? 31 | end 32 | 33 | def skipped? 34 | @save_action.skipped? 35 | end 36 | 37 | def observable_by_user_result 38 | @save_action.observable_by_user_result 39 | end 40 | 41 | def action_request 42 | @save_action.action_request 43 | end 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/dynamoid/transaction_write/upsert.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'base' 4 | require 'dynamoid/persistence/update_validations' 5 | 6 | module Dynamoid 7 | class TransactionWrite 8 | class Upsert < Base 9 | def initialize(model_class, hash_key, range_key, attributes) 10 | super() 11 | 12 | @model_class = model_class 13 | @hash_key = hash_key 14 | @range_key = range_key 15 | @attributes = attributes 16 | end 17 | 18 | def on_registration 19 | validate_primary_key! 20 | Dynamoid::Persistence::UpdateValidations.validate_attributes_exist(@model_class, @attributes) 21 | end 22 | 23 | def on_commit; end 24 | 25 | def on_rollback; end 26 | 27 | def aborted? 28 | false 29 | end 30 | 31 | def skipped? 32 | attributes_to_assign = @attributes.except(@model_class.hash_key, @model_class.range_key) 33 | attributes_to_assign.empty? && !@model_class.timestamps_enabled? 34 | end 35 | 36 | def observable_by_user_result 37 | nil 38 | end 39 | 40 | def action_request 41 | # changed attributes to persist 42 | changes = @attributes.dup 43 | changes = add_timestamps(changes, skip_created_at: true) 44 | changes_dumped = Dynamoid::Dumping.dump_attributes(changes, @model_class.attributes) 45 | 46 | # primary key to look up an item to update 47 | key = { @model_class.hash_key => @hash_key } 48 | key[@model_class.range_key] = @range_key if @model_class.range_key? 49 | 50 | # Build UpdateExpression and keep names and values placeholders mapping 51 | # in ExpressionAttributeNames and ExpressionAttributeValues. 52 | update_expression_statements = [] 53 | expression_attribute_names = {} 54 | expression_attribute_values = {} 55 | 56 | changes_dumped.each_with_index do |(name, value), i| 57 | name_placeholder = "#_n#{i}" 58 | value_placeholder = ":_s#{i}" 59 | 60 | update_expression_statements << "#{name_placeholder} = #{value_placeholder}" 61 | expression_attribute_names[name_placeholder] = name 62 | expression_attribute_values[value_placeholder] = value 63 | end 64 | 65 | update_expression = "SET #{update_expression_statements.join(', ')}" 66 | 67 | { 68 | update: { 69 | key: key, 70 | table_name: @model_class.table_name, 71 | update_expression: update_expression, 72 | expression_attribute_names: expression_attribute_names, 73 | expression_attribute_values: expression_attribute_values 74 | } 75 | } 76 | end 77 | 78 | private 79 | 80 | def validate_primary_key! 81 | raise Dynamoid::Errors::MissingHashKey if @hash_key.nil? 82 | raise Dynamoid::Errors::MissingRangeKey if @model_class.range_key? && @range_key.nil? 83 | end 84 | 85 | def add_timestamps(attributes, skip_created_at: false) 86 | return attributes unless @model_class.timestamps_enabled? 87 | 88 | result = attributes.clone 89 | timestamp = DateTime.now.in_time_zone(Time.zone) 90 | result[:created_at] ||= timestamp unless skip_created_at 91 | result[:updated_at] ||= timestamp 92 | result 93 | end 94 | end 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /lib/dynamoid/validations.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dynamoid 4 | # Provide ActiveModel validations to Dynamoid documents. 5 | module Validations 6 | extend ActiveSupport::Concern 7 | 8 | include ActiveModel::Validations 9 | include ActiveModel::Validations::Callbacks 10 | 11 | # Override save to provide validation support. 12 | # 13 | # @private 14 | # @since 0.2.0 15 | def save(options = {}) 16 | options.reverse_merge!(validate: true) 17 | return false if options[:validate] && !valid? 18 | 19 | super 20 | end 21 | 22 | # Is this object valid? 23 | # 24 | # @since 0.2.0 25 | def valid?(context = nil) 26 | context ||= (new_record? ? :create : :update) 27 | super 28 | end 29 | 30 | # Raise an error unless this object is valid. 31 | # 32 | # @private 33 | # @since 0.2.0 34 | def save! 35 | raise Dynamoid::Errors::DocumentNotValid, self unless valid? 36 | 37 | save(validate: false) 38 | self 39 | end 40 | 41 | def update_attribute(attribute, value) 42 | write_attribute(attribute, value) 43 | save(validate: false) 44 | self 45 | end 46 | 47 | module ClassMethods 48 | # Override validates_presence_of to handle false values as present. 49 | # 50 | # @since 1.1.1 51 | def validates_presence_of(*attr_names) 52 | validates_with PresenceValidator, _merge_attributes(attr_names) 53 | end 54 | 55 | # Validates that the specified attributes are present (false or not blank). 56 | class PresenceValidator < ActiveModel::EachValidator 57 | # Validate the record for the record and value. 58 | def validate_each(record, attr_name, value) 59 | # Use keyword argument `options` because it was a Hash in Rails < 6.1 60 | # and became a keyword argument in 6.1. This way it works in both 61 | # cases. 62 | record.errors.add(attr_name, :blank, **options) if not_present?(value) 63 | end 64 | 65 | private 66 | 67 | # Check whether a value is not present. 68 | def not_present?(value) 69 | value.blank? && value != false 70 | end 71 | end 72 | end 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /lib/dynamoid/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dynamoid 4 | VERSION = '3.11.0' 5 | end 6 | -------------------------------------------------------------------------------- /spec/app/models/address.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Address 4 | include Dynamoid::Document 5 | 6 | field :city, :string, alias: :CityName 7 | field :options, :serialized 8 | field :deliverable, :boolean 9 | field :latitude, :number 10 | field :config, :raw 11 | field :registered_on, :date 12 | 13 | field :lock_version, :integer # Provides Optimistic Locking 14 | 15 | def zip_code=(_zip_code) 16 | self.city = 'Chicago' 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /spec/app/models/bar.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Bar 4 | include Dynamoid::Document 5 | 6 | table name: :bar, 7 | key: :bar_id, 8 | read_capacity: 200, 9 | write_capacity: 200 10 | 11 | range :visited_at, :datetime 12 | field :name 13 | field :visited_at, :integer 14 | 15 | validates_presence_of :name, :visited_at 16 | 17 | global_secondary_index hash_key: :name, range_key: :visited_at 18 | end 19 | -------------------------------------------------------------------------------- /spec/app/models/cadillac.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'car' 4 | 5 | class Cadillac < Car 6 | end 7 | -------------------------------------------------------------------------------- /spec/app/models/camel_case.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CamelCase 4 | include Dynamoid::Document 5 | 6 | field :color 7 | 8 | belongs_to :magazine 9 | has_many :users 10 | has_one :sponsor 11 | has_and_belongs_to_many :subscriptions 12 | 13 | before_create :doing_before_create 14 | after_create :doing_after_create 15 | before_update :doing_before_update 16 | after_update :doing_after_update 17 | 18 | private 19 | 20 | def doing_before_create 21 | true 22 | end 23 | 24 | def doing_after_create 25 | true 26 | end 27 | 28 | def doing_before_update 29 | true 30 | end 31 | 32 | def doing_after_update 33 | true 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /spec/app/models/car.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'vehicle' 4 | 5 | class Car < Vehicle 6 | field :power_locks, :boolean 7 | end 8 | -------------------------------------------------------------------------------- /spec/app/models/magazine.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Magazine 4 | include Dynamoid::Document 5 | table key: :title 6 | 7 | field :title 8 | field :size, :number 9 | 10 | has_many :subscriptions 11 | has_many :camel_cases 12 | has_one :sponsor 13 | 14 | belongs_to :owner, class_name: 'User', inverse_of: :books 15 | 16 | def publish(advertisements:, free_issue: false) 17 | result = advertisements * (free_issue ? 2 : 1) 18 | result = yield(result) if block_given? 19 | result 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /spec/app/models/message.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Message 4 | include Dynamoid::Document 5 | 6 | table name: :messages, key: :message_id, read_capacity: 200, write_capacity: 200 7 | 8 | range :time, :datetime 9 | 10 | field :text 11 | end 12 | -------------------------------------------------------------------------------- /spec/app/models/nuclear_submarine.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'vehicle' 4 | class NuclearSubmarine < Vehicle 5 | field :torpedoes, :integer 6 | end 7 | -------------------------------------------------------------------------------- /spec/app/models/post.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Post 4 | include Dynamoid::Document 5 | 6 | table name: :posts, key: :post_id, read_capacity: 200, write_capacity: 200 7 | 8 | range :posted_at, :datetime 9 | 10 | field :body 11 | field :length 12 | field :name 13 | 14 | local_secondary_index range_key: :name 15 | global_secondary_index hash_key: :name, range_key: :posted_at 16 | global_secondary_index hash_key: :length 17 | end 18 | -------------------------------------------------------------------------------- /spec/app/models/sponsor.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Sponsor 4 | include Dynamoid::Document 5 | 6 | belongs_to :magazine 7 | has_many :subscriptions 8 | 9 | belongs_to :camel_case 10 | end 11 | -------------------------------------------------------------------------------- /spec/app/models/subscription.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Subscription 4 | include Dynamoid::Document 5 | 6 | field :length, :integer 7 | 8 | belongs_to :magazine 9 | has_and_belongs_to_many :users 10 | 11 | belongs_to :customer, class_name: 'User', inverse_of: :monthly 12 | 13 | has_and_belongs_to_many :camel_cases 14 | end 15 | -------------------------------------------------------------------------------- /spec/app/models/tweet.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Tweet 4 | include Dynamoid::Document 5 | 6 | table name: :twitters, key: :tweet_id, read_capacity: 200, write_capacity: 200 7 | 8 | range :group, :string 9 | 10 | field :msg 11 | field :count, :integer 12 | field :tags, :set 13 | field :user_name 14 | end 15 | -------------------------------------------------------------------------------- /spec/app/models/user.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class User 4 | include Dynamoid::Document 5 | 6 | field :name 7 | field :email 8 | field :password 9 | field :admin, :boolean 10 | field :last_logged_in_at, :datetime 11 | 12 | field :favorite_colors, :serialized 13 | field :todo_list, :array 14 | 15 | has_and_belongs_to_many :subscriptions 16 | 17 | has_many :books, class_name: 'Magazine', inverse_of: :owner 18 | has_one :monthly, class_name: 'Subscription', inverse_of: :customer 19 | 20 | has_and_belongs_to_many :followers, class_name: 'User', inverse_of: :following 21 | has_and_belongs_to_many :following, class_name: 'User', inverse_of: :followers 22 | 23 | belongs_to :camel_case 24 | end 25 | -------------------------------------------------------------------------------- /spec/app/models/vehicle.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Vehicle 4 | include Dynamoid::Document 5 | 6 | field :type 7 | 8 | field :description 9 | end 10 | -------------------------------------------------------------------------------- /spec/dynamoid/adapter_plugin/aws_sdk_v3/until_past_table_status_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'dynamoid/adapter_plugin/aws_sdk_v3' 5 | 6 | describe Dynamoid::AdapterPlugin::AwsSdkV3::UntilPastTableStatus do 7 | describe 'call' do 8 | context 'table creation' do 9 | let(:client) { double('client') } 10 | let(:response_creating) { double('response#creating', table: creating_table) } 11 | let(:response_active) { double('response#active', table: active_table) } 12 | let(:creating_table) { double('creating_table', table_status: 'CREATING') } 13 | let(:active_table) { double('creating_table', table_status: 'ACTIVE') } 14 | 15 | it 'wait until table is created', config: { sync_retry_max_times: 60 } do 16 | expect(client).to receive(:describe_table) 17 | .with(table_name: :dogs).exactly(3).times 18 | .and_return(response_creating, response_creating, response_active) 19 | 20 | described_class.new(client, :dogs, :creating).call 21 | end 22 | 23 | it 'stops if exceeded Dynamoid.config.sync_retry_max_times attempts limit', 24 | config: { sync_retry_max_times: 5 } do 25 | expect(client).to receive(:describe_table) 26 | .exactly(6).times 27 | .and_return(*[response_creating] * 6) 28 | 29 | described_class.new(client, :dogs, :creating).call 30 | end 31 | 32 | it 'uses :sync_retry_max_times seconds to delay attempts', 33 | config: { sync_retry_wait_seconds: 2, sync_retry_max_times: 3 } do 34 | service = described_class.new(client, :dogs, :creating) 35 | allow(client).to receive(:describe_table).and_return(response_creating).exactly(4).times 36 | expect(service).to receive(:sleep).with(2).exactly(4).times 37 | 38 | service.call 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /spec/dynamoid/associations/has_and_belongs_to_many_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe Dynamoid::Associations::HasAndBelongsToMany do 6 | let(:subscription) { Subscription.create } 7 | let(:camel_case) { CamelCase.create } 8 | 9 | it 'determines equality from its records' do 10 | user = subscription.users.create 11 | 12 | expect(subscription.users.size).to eq 1 13 | expect(subscription.users).to include user 14 | end 15 | 16 | it 'determines target association correctly' do 17 | expect(subscription.users.send(:target_association)).to eq :subscriptions 18 | expect(camel_case.subscriptions.send(:target_association)).to eq :camel_cases 19 | end 20 | 21 | it 'determines target attribute' do 22 | expect(subscription.users.send(:target_attribute)).to eq :subscriptions_ids 23 | end 24 | 25 | it 'associates has_and_belongs_to_many automatically' do 26 | user = subscription.users.create 27 | 28 | expect(user.subscriptions.size).to eq 1 29 | expect(user.subscriptions).to include subscription 30 | expect(subscription.users.size).to eq 1 31 | expect(subscription.users).to include user 32 | 33 | user = User.create 34 | follower = user.followers.create 35 | expect(follower.following).to include user 36 | expect(user.followers).to include follower 37 | end 38 | 39 | it 'disassociates has_and_belongs_to_many automatically' do 40 | user = subscription.users.create 41 | 42 | subscription.users.delete(user) 43 | expect(subscription.users.size).to eq 0 44 | expect(user.subscriptions.size).to eq 0 45 | end 46 | 47 | describe 'assigning' do 48 | let(:subscription) { Subscription.create } 49 | let(:user) { User.create } 50 | 51 | it 'associates model on this side' do 52 | subscription.users << user 53 | expect(subscription.users.to_a).to eq([user]) 54 | end 55 | 56 | it 'associates model on that side' do 57 | subscription.users << user 58 | expect(user.subscriptions.to_a).to eq([subscription]) 59 | end 60 | end 61 | 62 | describe '#delete' do 63 | it 'clears association on this side' do 64 | subscription = Subscription.create 65 | user = subscription.users.create 66 | 67 | expect do 68 | subscription.users.delete(user) 69 | end.to change { subscription.users.target }.from([user]).to([]) 70 | end 71 | 72 | it 'persists changes on this side' do 73 | subscription = Subscription.create 74 | user = subscription.users.create 75 | 76 | expect do 77 | subscription.users.delete(user) 78 | end.to change { Subscription.find(subscription.id).users.target }.from([user]).to([]) 79 | end 80 | 81 | context 'has and belongs to many' do 82 | let(:subscription) { Subscription.create } 83 | let!(:user) { subscription.users.create } 84 | 85 | it 'clears association on that side' do 86 | expect do 87 | subscription.users.delete(user) 88 | end.to change { subscription.users.target }.from([user]).to([]) 89 | end 90 | 91 | it 'persists changes on that side' do 92 | expect do 93 | subscription.users.delete(user) 94 | end.to change { Subscription.find(subscription.id).users.target }.from([user]).to([]) 95 | end 96 | end 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /spec/dynamoid/associations_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe Dynamoid::Associations do 6 | let(:magazine) { Magazine.create } 7 | 8 | it 'defines a getter' do 9 | expect(magazine).to respond_to :subscriptions 10 | end 11 | 12 | it 'defines a setter' do 13 | expect(magazine).to respond_to :subscriptions= 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/dynamoid/before_type_cast_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe 'Before type cast' do 6 | describe '#attributes_before_type_cast', config: { timestamps: false } do 7 | let(:klass) do 8 | new_class do 9 | field :admin, :boolean 10 | end 11 | end 12 | 13 | it 'returns original attributes value' do 14 | obj = klass.new(admin: 0) 15 | 16 | expect(obj.attributes_before_type_cast).to eql( 17 | admin: 0, 18 | ) 19 | end 20 | 21 | it 'returns values for all the attributes even not assigned' do 22 | klass_with_many_fields = new_class do 23 | field :first_name 24 | field :last_name 25 | field :email 26 | end 27 | obj = klass_with_many_fields.new(first_name: 'John') 28 | 29 | expect(obj.attributes_before_type_cast).to eql( 30 | first_name: 'John', 31 | ) 32 | end 33 | 34 | it 'returns original default value if field has default value' do 35 | klass_with_default_value = new_class do 36 | field :activated_on, :date, default: '2018-09-27' 37 | end 38 | obj = klass_with_default_value.new 39 | 40 | expect(obj.attributes_before_type_cast).to eql( 41 | activated_on: '2018-09-27', 42 | ) 43 | end 44 | 45 | it 'returns nil if field does not have default value' do 46 | obj = klass.new 47 | 48 | expect(obj.attributes_before_type_cast).to eql({}) 49 | end 50 | 51 | it 'returns values loaded from the storage before type casting' do 52 | obj = klass.create!(admin: false) 53 | obj2 = klass.find(obj.id) 54 | 55 | expect(obj2.attributes_before_type_cast).to eql( 56 | id: obj.id, 57 | admin: false, 58 | ) 59 | end 60 | end 61 | 62 | describe '#read_attribute_before_type_cast' do 63 | let(:klass) do 64 | new_class do 65 | field :admin, :boolean 66 | end 67 | end 68 | 69 | it 'returns attribute original value' do 70 | obj = klass.new(admin: 1) 71 | 72 | expect(obj.read_attribute_before_type_cast(:admin)).to eql(1) 73 | end 74 | 75 | it 'accepts string as well as symbol argument' do 76 | obj = klass.new(admin: 1) 77 | 78 | expect(obj.read_attribute_before_type_cast('admin')).to eql(1) 79 | end 80 | 81 | it 'returns nil if there is no such attribute' do 82 | obj = klass.new 83 | 84 | expect(obj.read_attribute_before_type_cast(:first_name)).to eql(nil) 85 | end 86 | end 87 | 88 | describe '#_before_type_cast' do 89 | let(:klass) do 90 | new_class do 91 | field :first_name 92 | field :last_name 93 | field :admin, :boolean 94 | end 95 | end 96 | 97 | it 'exists for every model attribute' do 98 | obj = klass.new 99 | 100 | expect(obj).to respond_to(:id) 101 | expect(obj).to respond_to(:first_name_before_type_cast) 102 | expect(obj).to respond_to(:last_name_before_type_cast) 103 | expect(obj).to respond_to(:admin) 104 | expect(obj).to respond_to(:created_at) 105 | expect(obj).to respond_to(:updated_at) 106 | end 107 | 108 | it 'returns attribute original value' do 109 | obj = klass.new(admin: 0) 110 | 111 | expect(obj.admin_before_type_cast).to eql(0) 112 | end 113 | end 114 | end 115 | -------------------------------------------------------------------------------- /spec/dynamoid/config/backoff_strategies/exponential_backoff_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe Dynamoid::Config::BackoffStrategies::ExponentialBackoff do 6 | let(:base_backoff) { 1 } 7 | let(:ceiling) { 5 } 8 | let(:backoff) { described_class.call(base_backoff: base_backoff, ceiling: ceiling) } 9 | 10 | it 'sleeps the first time for specified base backoff time' do 11 | expect(described_class).to receive(:sleep).with(base_backoff) 12 | backoff.call 13 | end 14 | 15 | it 'sleeps for exponentialy increasing time' do 16 | seconds = [] 17 | allow(described_class).to receive(:sleep) do |s| 18 | seconds << s 19 | end 20 | 21 | backoff.call 22 | expect(seconds).to eq [base_backoff] 23 | 24 | backoff.call 25 | expect(seconds).to eq [base_backoff, base_backoff * 2] 26 | 27 | backoff.call 28 | expect(seconds).to eq [base_backoff, base_backoff * 2, base_backoff * 4] 29 | 30 | backoff.call 31 | expect(seconds).to eq [base_backoff, base_backoff * 2, base_backoff * 4, base_backoff * 8] 32 | end 33 | 34 | it 'stops to increase time after ceiling times' do 35 | seconds = [] 36 | allow(described_class).to receive(:sleep) do |s| 37 | seconds << s 38 | end 39 | 40 | 6.times { backoff.call } 41 | expect(seconds).to eq [ 42 | base_backoff, 43 | base_backoff * 2, 44 | base_backoff * 4, 45 | base_backoff * 8, 46 | base_backoff * 16, 47 | base_backoff * 16 48 | ] 49 | end 50 | 51 | it 'can be called without parameters' do 52 | backoff = nil 53 | expect do 54 | backoff = described_class.call 55 | end.not_to raise_error 56 | end 57 | 58 | it 'uses base backoff = 0.5 and ceiling = 3 by default' do 59 | backoff = described_class.call 60 | 61 | seconds = [] 62 | allow(described_class).to receive(:sleep) do |s| 63 | seconds << s 64 | end 65 | 66 | 4.times { backoff.call } 67 | expect(seconds).to eq([ 68 | 0.5, 69 | 0.5 * 2, 70 | 0.5 * 4, 71 | 0.5 * 4 72 | ]) 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /spec/dynamoid/config_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe Dynamoid::Config do 6 | describe 'credentials' do 7 | let(:credentials_new) do 8 | Aws::Credentials.new('your_access_key_id', 'your_secret_access_key') 9 | end 10 | 11 | before do 12 | @credentials_old = Dynamoid.config.credentials 13 | Dynamoid.config.credentials = credentials_new 14 | Dynamoid.adapter.connect! # clear cached client 15 | end 16 | 17 | after do 18 | Dynamoid.config.credentials = @credentials_old 19 | Dynamoid.adapter.connect! # clear cached client 20 | end 21 | 22 | it 'passes credentials to a client connection' do 23 | credentials = Dynamoid.adapter.client.config.credentials 24 | 25 | expect(credentials.access_key_id).to eq 'your_access_key_id' 26 | expect(credentials.secret_access_key).to eq 'your_secret_access_key' 27 | end 28 | end 29 | 30 | describe 'log_formatter' do 31 | let(:log_formatter) { Aws::Log::Formatter.short } 32 | let(:logger) { Logger.new(buffer) } 33 | let(:buffer) { StringIO.new } 34 | 35 | before do 36 | @log_formatter = Dynamoid.config.log_formatter 37 | @logger = Dynamoid.config.logger 38 | 39 | Dynamoid.config.log_formatter = log_formatter 40 | Dynamoid.config.logger = logger 41 | Dynamoid.adapter.connect! # clear cached client 42 | end 43 | 44 | after do 45 | Dynamoid.config.log_formatter = @log_formatter 46 | Dynamoid.config.logger = @logger 47 | Dynamoid.adapter.connect! # clear cached client 48 | end 49 | 50 | it 'changes logging format' do 51 | new_class.create_table 52 | expect(buffer.string).to match(/\[Aws::DynamoDB::Client 200 .+\] create_table \n/) 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /spec/dynamoid/identity_map_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe Dynamoid::IdentityMap do 6 | before do 7 | Dynamoid::Config.identity_map = true 8 | end 9 | 10 | after do 11 | Dynamoid::Config.identity_map = false 12 | end 13 | 14 | context 'object identity' do 15 | it 'maintains a single object' do 16 | tweet = Tweet.create(tweet_id: 'x', group: 'one') 17 | tweet1 = Tweet.where(tweet_id: 'x', group: 'one').first 18 | expect(tweet).to equal(tweet1) 19 | end 20 | end 21 | 22 | context 'cache' do 23 | it 'uses cache' do 24 | tweet = Tweet.create(tweet_id: 'x', group: 'one') 25 | expect(Dynamoid::Adapter).not_to receive(:read) 26 | tweet1 = Tweet.find_by_id('x', range_key: 'one') 27 | expect(tweet).to equal(tweet1) 28 | end 29 | 30 | it 'clears cache on delete' do 31 | tweet = Tweet.create(tweet_id: 'x', group: 'one') 32 | tweet.delete 33 | expect(Tweet.find_by_id('x', range_key: 'one')).to be_nil 34 | end 35 | end 36 | 37 | context 'clear' do 38 | it 'clears the identiy map' do 39 | Tweet.create(tweet_id: 'x', group: 'one') 40 | Tweet.create(tweet_id: 'x', group: 'two') 41 | 42 | expect(Tweet.identity_map.size).to eq(2) 43 | described_class.clear 44 | expect(Tweet.identity_map.size).to eq(0) 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /spec/dynamoid/loadable_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe Dynamoid::Loadable do 6 | describe '.reload' do 7 | let(:address) { Address.create } 8 | let(:message) { Message.create(text: 'Nice, supporting datetime range!', time: Time.now.to_datetime) } 9 | let(:tweet) { tweet = Tweet.create(tweet_id: 'x', group: 'abc') } 10 | 11 | it 'reflects persisted changes' do 12 | klass = new_class do 13 | field :city 14 | end 15 | 16 | address = klass.create(city: 'Miami') 17 | copy = klass.find(address.id) 18 | 19 | address.update_attributes(city: 'Chicago') 20 | expect(copy.reload.city).to eq 'Chicago' 21 | end 22 | 23 | it 'reads with strong consistency' do 24 | klass = new_class do 25 | field :message 26 | end 27 | 28 | tweet = klass.create 29 | 30 | expect(klass).to receive(:find).with(tweet.id, consistent_read: true).and_return(tweet) 31 | tweet.reload 32 | end 33 | 34 | it 'works with range key' do 35 | klass = new_class do 36 | field :message 37 | range :group 38 | end 39 | 40 | tweet = klass.create(group: 'tech') 41 | expect(tweet.reload.group).to eq 'tech' 42 | end 43 | 44 | it 'uses dumped value of sort key to load document' do 45 | klass = new_class do 46 | range :activated_on, :date 47 | field :name 48 | end 49 | 50 | obj = klass.create!(activated_on: Date.today, name: 'Old value') 51 | obj2 = klass.where(id: obj.id, activated_on: obj.activated_on).first 52 | obj2.update_attributes(name: 'New value') 53 | 54 | expect { obj.reload }.to change { obj.name }.from('Old value').to('New value') 55 | end 56 | 57 | # https://github.com/Dynamoid/dynamoid/issues/564 58 | it 'marks model as persisted if not saved model is already persisted and successfuly reloaded' do 59 | klass = new_class do 60 | field :message 61 | end 62 | 63 | object = klass.create(message: 'a') 64 | copy = klass.new(id: object.id) 65 | 66 | expect { copy.reload }.to change { copy.new_record? }.from(true).to(false) 67 | expect(copy.message).to eq 'a' 68 | end 69 | 70 | describe 'callbacks' do 71 | it 'runs after_initialize callback' do 72 | klass_with_callback = new_class do 73 | after_initialize { print 'run after_initialize' } 74 | end 75 | 76 | object = klass_with_callback.create! 77 | 78 | expect { object.reload }.to output('run after_initialize').to_stdout 79 | end 80 | 81 | it 'runs after_find callback' do 82 | klass_with_callback = new_class do 83 | after_find { print 'run after_find' } 84 | end 85 | 86 | object = klass_with_callback.create! 87 | 88 | expect { object.reload }.to output('run after_find').to_stdout 89 | end 90 | 91 | it 'runs callbacks in the proper order' do 92 | klass_with_callback = new_class do 93 | after_initialize { print 'run after_initialize' } 94 | after_find { print 'run after_find' } 95 | end 96 | 97 | object = klass_with_callback.create! 98 | 99 | expect do 100 | object.reload 101 | end.to output('run after_initializerun after_find').to_stdout 102 | end 103 | end 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /spec/dynamoid/log/formatter/debug_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'dynamoid/log/formatter' 5 | 6 | describe Dynamoid::Log::Formatter::Debug do 7 | describe '#format' do 8 | subject { described_class.new } 9 | 10 | let(:logger) { Logger.new(buffer) } 11 | let(:buffer) { StringIO.new } 12 | 13 | let(:request) do 14 | <<~JSON 15 | { 16 | "TableName": "dynamoid_tests_items", 17 | "KeySchema": [ 18 | { 19 | "AttributeName": "id", 20 | "KeyType": "HASH" 21 | } 22 | ], 23 | "AttributeDefinitions": [ 24 | { 25 | "AttributeName": "id", 26 | "AttributeType": "S" 27 | } 28 | ], 29 | "BillingMode": "PROVISIONED", 30 | "ProvisionedThroughput": { 31 | "ReadCapacityUnits": 100, 32 | "WriteCapacityUnits": 20 33 | } 34 | } 35 | JSON 36 | end 37 | 38 | let(:response_pattern) do 39 | Regexp.compile <<~JSON 40 | \\{ 41 | "TableDescription": \\{ 42 | "AttributeDefinitions": \\[ 43 | \\{ 44 | "AttributeName": "id", 45 | "AttributeType": "S" 46 | \\} 47 | \\], 48 | "TableName": "dynamoid_tests_items", 49 | "KeySchema": \\[ 50 | \\{ 51 | "AttributeName": "id", 52 | "KeyType": "HASH" 53 | \\} 54 | \\], 55 | "TableStatus": "ACTIVE", 56 | "CreationDateTime": .+?, 57 | "ProvisionedThroughput": \\{ 58 | "LastIncreaseDateTime": 0.0, 59 | "LastDecreaseDateTime": 0.0, 60 | "NumberOfDecreasesToday": 0, 61 | "ReadCapacityUnits": 100, 62 | "WriteCapacityUnits": 20 63 | \\}, 64 | "TableSizeBytes": 0, 65 | "ItemCount": 0, 66 | "TableArn": ".+?", 67 | "DeletionProtectionEnabled": false 68 | \\} 69 | \\} 70 | JSON 71 | end 72 | 73 | before do 74 | @log_formatter = Dynamoid.config.log_formatter 75 | @logger = Dynamoid.config.logger 76 | 77 | Dynamoid.config.log_formatter = subject 78 | Dynamoid.config.logger = logger 79 | Dynamoid.adapter.connect! # clear cached client 80 | end 81 | 82 | after do 83 | Dynamoid.config.log_formatter = @log_formatter 84 | Dynamoid.config.logger = @logger 85 | Dynamoid.adapter.connect! # clear cached client 86 | end 87 | 88 | it 'logs request and response JSON body' do 89 | new_class(table_name: 'items').create_table 90 | 91 | expect(buffer.string).to include(request) 92 | expect(buffer.string).to match(response_pattern) 93 | end 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /spec/dynamoid/tasks/database_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe Dynamoid::Tasks::Database do 6 | describe '#ping' do 7 | context 'when the database is reachable' do 8 | it 'is able to ping (connect to) DynamoDB' do 9 | expect { described_class.ping }.not_to raise_exception 10 | end 11 | end 12 | end 13 | 14 | describe '#create_tables' do 15 | before do 16 | @klass = new_class 17 | end 18 | 19 | context "when the tables don't exist yet" do 20 | it 'creates tables' do 21 | expect { 22 | described_class.create_tables 23 | }.to change { 24 | Dynamoid.adapter.list_tables.include?(@klass.table_name) 25 | }.from(false).to(true) 26 | end 27 | 28 | it 'returns created table names' do 29 | results = described_class.create_tables 30 | expect(results[:existing]).not_to include(@klass.table_name) 31 | expect(results[:created]).to include(@klass.table_name) 32 | end 33 | end 34 | 35 | context 'when the tables already exist' do 36 | it 'does not attempt to re-create the table' do 37 | @klass.create_table 38 | 39 | results = described_class.create_tables 40 | expect(results[:existing]).to include(@klass.table_name) 41 | expect(results[:created]).not_to include(@klass.table_name) 42 | end 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /spec/dynamoid/transaction_write/commit_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe Dynamoid::TransactionWrite, '#commit' do 6 | let(:klass) do 7 | new_class do 8 | field :name 9 | end 10 | end 11 | 12 | it 'persists changes' do 13 | klass.create_table 14 | transaction = described_class.new 15 | transaction.create klass 16 | transaction.create klass 17 | 18 | expect { transaction.commit }.to change(klass, :count).by(2) 19 | end 20 | 21 | describe 'callbacks' do 22 | before do 23 | ScratchPad.clear 24 | end 25 | 26 | let(:klass) do 27 | new_class do 28 | field :name 29 | 30 | after_commit { ScratchPad << 'run after_commit' } 31 | after_rollback { ScratchPad << 'run after_rollback' } 32 | end 33 | end 34 | 35 | context 'transaction succeeds' do 36 | it 'runs #after_commit callbacks for each involved model' do 37 | klass.create_table 38 | 39 | t = described_class.new 40 | t.create klass 41 | t.create klass 42 | t.commit 43 | 44 | expect(ScratchPad.recorded).to eql ['run after_commit', 'run after_commit'] 45 | end 46 | end 47 | 48 | context 'transaction fails' do 49 | before do 50 | ScratchPad.clear 51 | end 52 | 53 | it 'runs #after_rollback callbacks for each involved model' do 54 | # trigger transaction aborting by trying to create a new model with non-unique primary id 55 | existing = klass.create!(name: 'Alex') 56 | 57 | t = described_class.new 58 | t.create klass, name: 'Alex', id: existing.id 59 | t.create klass, name: 'Michael' 60 | 61 | expect { 62 | t.commit 63 | }.to raise_error(Aws::DynamoDB::Errors::TransactionCanceledException) 64 | 65 | expect(ScratchPad.recorded).to eql ['run after_rollback', 'run after_rollback'] 66 | end 67 | end 68 | 69 | context 'transaction interrupted by exception in a callback' do 70 | before do 71 | ScratchPad.clear 72 | end 73 | 74 | it 'does not run #after_rollback callbacks for each involved model' do 75 | klass_with_exception = new_class do 76 | before_create { raise 'from a callback' } 77 | end 78 | 79 | t = described_class.new 80 | t.create klass 81 | 82 | expect { 83 | t.create klass_with_exception 84 | }.to raise_error('from a callback') 85 | 86 | expect(ScratchPad.recorded).to eql [] 87 | end 88 | end 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /spec/dynamoid/validations_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe Dynamoid::Validations do 6 | let(:doc_class) do 7 | new_class 8 | end 9 | 10 | it 'validates presence of' do 11 | doc_class.field :name 12 | doc_class.validates_presence_of :name 13 | doc = doc_class.new 14 | expect(doc.save).to be_falsey 15 | expect(doc.new_record).to be_truthy 16 | doc.name = 'secret' 17 | expect(doc.save).not_to be_falsey 18 | expect(doc.errors).to be_empty 19 | end 20 | 21 | it 'validates presence of boolean field' do 22 | doc_class.field :flag, :boolean 23 | doc_class.validates_presence_of :flag 24 | doc = doc_class.new 25 | expect(doc.save).to be_falsey 26 | doc.flag = false 27 | expect(doc.save).not_to be_falsey 28 | expect(doc.errors).to be_empty 29 | end 30 | 31 | it 'raises document not found' do 32 | doc_class.field :name 33 | doc_class.validates_presence_of :name 34 | doc = doc_class.new 35 | expect { doc.save! }.to raise_error(Dynamoid::Errors::DocumentNotValid) do |error| 36 | expect(error.document).to eq doc 37 | end 38 | 39 | expect { doc_class.create! }.to raise_error(Dynamoid::Errors::DocumentNotValid) 40 | 41 | doc = doc_class.create!(name: 'test') 42 | expect(doc.errors).to be_empty 43 | end 44 | 45 | it 'does not validate when saves if `validate` option is false' do 46 | klass = new_class do 47 | field :name 48 | validates :name, presence: true 49 | end 50 | 51 | model = klass.new 52 | model.save(validate: false) 53 | expect(model).to be_persisted 54 | end 55 | 56 | it 'returns true if model is valid' do 57 | klass = new_class do 58 | field :name 59 | validates :name, presence: true 60 | end 61 | 62 | expect(klass.new(name: 'some-name').save).to eq(true) 63 | end 64 | 65 | it 'returns false if model is invalid' do 66 | klass = new_class do 67 | field :name 68 | validates :name, presence: true 69 | end 70 | 71 | expect(klass.new(name: nil).save).to eq(false) 72 | end 73 | 74 | describe 'save!' do 75 | it 'returns self' do 76 | klass = new_class 77 | 78 | model = klass.new 79 | expect(model.save!).to eq(model) 80 | end 81 | end 82 | 83 | describe '#valid?' do 84 | describe 'callbacks' do 85 | it 'runs before_validation callback' do 86 | klass_with_callback = new_class do 87 | before_validation { print 'run before_validation' } 88 | end 89 | 90 | obj = klass_with_callback.new 91 | expect { obj.valid? }.to output('run before_validation').to_stdout 92 | end 93 | 94 | it 'runs after_validation callback' do 95 | klass_with_callback = new_class do 96 | after_validation { print 'run after_validation' } 97 | end 98 | 99 | obj = klass_with_callback.new 100 | expect { obj.valid? }.to output('run after_validation').to_stdout 101 | end 102 | end 103 | end 104 | end 105 | -------------------------------------------------------------------------------- /spec/fixtures/dirty.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module DirtySpec 4 | class User 5 | attr_reader :name 6 | 7 | def initialize(name) 8 | @name = name 9 | end 10 | 11 | def dynamoid_dump 12 | @name 13 | end 14 | 15 | def self.dynamoid_load(string) 16 | new(string.to_s) 17 | end 18 | end 19 | 20 | class UserWithEquality < User 21 | def eql?(other) 22 | if ScratchPad.recorded.is_a? Array 23 | ScratchPad << ['eql?', self, other] 24 | end 25 | 26 | @name == other.name 27 | end 28 | 29 | def ==(other) 30 | if ScratchPad.recorded.is_a? Array 31 | ScratchPad << ['==', self, other] 32 | end 33 | 34 | @name == other.name 35 | end 36 | 37 | def hash 38 | @name.hash 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /spec/fixtures/dumping.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module DumpingSpecs 4 | class User 5 | attr_accessor :name 6 | 7 | def initialize(name) 8 | self.name = name 9 | end 10 | 11 | def dynamoid_dump 12 | name 13 | end 14 | 15 | def eql?(other) 16 | name == other.name 17 | end 18 | 19 | def hash 20 | name.hash 21 | end 22 | 23 | def self.dynamoid_load(string) 24 | new(string.to_s) 25 | end 26 | end 27 | 28 | # implements both #dynamoid_dump and .dynamoid_dump methods 29 | class UserWithAdapterInterface 30 | attr_accessor :name 31 | 32 | def initialize(name) 33 | self.name = name 34 | end 35 | 36 | def dynamoid_dump 37 | "#{name} (dumped with #dynamoid_dump)" 38 | end 39 | 40 | def self.dynamoid_dump(user) 41 | "#{user.name} (dumped with .dynamoid_dump)" 42 | end 43 | 44 | def self.dynamoid_load(string) 45 | new(string.to_s) 46 | end 47 | end 48 | 49 | # doesn't implement #dynamoid_dump/#dynamoid_load methods so requires an adapter 50 | class UserValue 51 | attr_accessor :name 52 | 53 | def initialize(name) 54 | self.name = name 55 | end 56 | 57 | def eql?(other) 58 | name == other.name 59 | end 60 | 61 | def hash 62 | name.hash 63 | end 64 | end 65 | 66 | class UserValueAdapter 67 | def self.dynamoid_dump(user) 68 | user.name 69 | end 70 | 71 | def self.dynamoid_load(string) 72 | UserValue.new(string.to_s) 73 | end 74 | end 75 | 76 | class UserValueToArrayAdapter 77 | def self.dynamoid_dump(user) 78 | user.name.split 79 | end 80 | 81 | def self.dynamoid_load(array) 82 | array = array.name.split if array.is_a?(UserValue) 83 | UserValue.new(array.join(' ')) 84 | end 85 | 86 | def self.dynamoid_field_type 87 | :array 88 | end 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /spec/fixtures/fields.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FieldsSpecs 4 | class User 5 | attr_accessor :name 6 | 7 | def initialize(name) 8 | self.name = name 9 | end 10 | 11 | def dynamoid_dump 12 | name 13 | end 14 | 15 | def eql?(other) 16 | name == other.name 17 | end 18 | 19 | def hash 20 | name.hash 21 | end 22 | 23 | def self.dynamoid_load(string) 24 | new(string.to_s) 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /spec/fixtures/indexes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module IndexesSpecs 4 | class CustomType 5 | def dynamoid_dump 6 | name 7 | end 8 | 9 | def self.dynamoid_load(string) 10 | new(string.to_s) 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/fixtures/persistence.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PersistenceSpec 4 | class User 5 | attr_accessor :name 6 | 7 | def initialize(name) 8 | self.name = name 9 | end 10 | 11 | def dynamoid_dump 12 | name 13 | end 14 | 15 | def eql?(other) 16 | name == other.name 17 | end 18 | 19 | def self.dynamoid_load(string) 20 | new(string.to_s) 21 | end 22 | end 23 | 24 | class UserWithAge 25 | attr_accessor :age 26 | 27 | def initialize(age) 28 | self.age = age 29 | end 30 | 31 | def dynamoid_dump 32 | age 33 | end 34 | 35 | def eql?(other) 36 | age == other.age 37 | end 38 | 39 | def self.dynamoid_load(string) 40 | new(string.to_i) 41 | end 42 | 43 | def self.dynamoid_field_type 44 | :number 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Standard Libs 4 | 5 | # Explicit requiring the "logger" library is needed for Rails 6.0-7.0 6 | # See the following for details: 7 | # - https://github.com/rails/rails/issues/54260 8 | # - https://github.com/rails/rails/pull/49372 9 | require 'logger' 10 | 11 | # Third Party Libs 12 | # https://guides.rubyonrails.org/active_support_core_extensions.html#stand-alone-active-support 13 | require 'active_support' 14 | require 'active_support/testing/time_helpers' 15 | require 'rspec' 16 | require 'pry' 17 | 18 | # Debugging 19 | DEBUG = ENV['DEBUG'] == 'true' 20 | 21 | ruby_version = Gem::Version.new(RUBY_VERSION) 22 | minimum_version = ->(version, engine = 'ruby') { ruby_version >= Gem::Version.new(version) && engine == RUBY_ENGINE } 23 | actual_version = lambda do |major, minor| 24 | actual = Gem::Version.new(ruby_version) 25 | major == actual.segments[0] && minor == actual.segments[1] && RUBY_ENGINE == 'ruby' 26 | end 27 | debugging = minimum_version.call('2.7') && DEBUG 28 | RUN_COVERAGE = minimum_version.call('2.6') && (ENV['COVER_ALL'] || ENV['CI_CODECOV'] || ENV['CI'].nil?) 29 | ALL_FORMATTERS = actual_version.call(2, 7) && (ENV['COVER_ALL'] || ENV['CI_CODECOV'] || ENV['CI']) # rubocop:disable Style/FetchEnvVar 30 | 31 | if DEBUG 32 | if debugging 33 | require 'byebug' 34 | elsif minimum_version.call('2.7', 'jruby') 35 | require 'pry-debugger-jruby' 36 | end 37 | end 38 | 39 | # Load Code Coverage as the last thing before this gem 40 | if RUN_COVERAGE 41 | require 'simplecov' # Config file `.simplecov` is run immediately when simplecov loads 42 | require 'codecov' 43 | require 'simplecov-json' 44 | require 'simplecov-lcov' 45 | require 'simplecov-cobertura' 46 | if ALL_FORMATTERS 47 | # This would override the formatter set in .simplecov, if set 48 | SimpleCov::Formatter::LcovFormatter.config do |c| 49 | c.report_with_single_file = true 50 | c.single_report_path = 'coverage/lcov.info' 51 | end 52 | 53 | SimpleCov.formatters = [ 54 | SimpleCov::Formatter::HTMLFormatter, 55 | SimpleCov::Formatter::CoberturaFormatter, # XML for Jenkins 56 | SimpleCov::Formatter::LcovFormatter, 57 | SimpleCov::Formatter::JSONFormatter, # For CodeClimate 58 | SimpleCov::Formatter::Codecov, # For CodeCov 59 | ] 60 | end 61 | end 62 | 63 | # This Gem 64 | require 'dynamoid' 65 | require 'dynamoid/log/formatter' 66 | 67 | ENV['ACCESS_KEY'] ||= 'abcd' 68 | ENV['SECRET_KEY'] ||= '1234' 69 | 70 | Aws.config.update( 71 | region: 'us-west-2', 72 | credentials: Aws::Credentials.new(ENV.fetch('ACCESS_KEY'), ENV.fetch('SECRET_KEY')) 73 | ) 74 | 75 | Dynamoid.configure do |config| 76 | config.endpoint = 'http://localhost:8000' 77 | config.namespace = 'dynamoid_tests' 78 | config.warn_on_scan = false 79 | config.sync_retry_wait_seconds = 0 80 | config.sync_retry_max_times = 3 81 | config.log_formatter = Dynamoid::Log::Formatter::Debug.new 82 | end 83 | 84 | Dynamoid.logger.level = Logger::FATAL 85 | 86 | MODELS = File.join(File.dirname(__FILE__), 'app/models') 87 | 88 | # Requires supporting files with custom matchers and macros, etc, 89 | # in ./support/ and its subdirectories. 90 | Dir["#{File.dirname(__FILE__)}/support/**/*.rb"].sort.each { |f| require f } 91 | 92 | Dir[File.join(MODELS, '*.rb')].sort.each { |file| require file } 93 | 94 | RSpec.configure do |config| 95 | config.order = :random 96 | config.raise_errors_for_deprecations! 97 | config.alias_it_should_behave_like_to :configured_with, 'configured with' 98 | 99 | config.include NewClassHelper 100 | config.include DumpingHelper 101 | config.include PersistenceHelper 102 | config.include ChainHelper 103 | config.include ActiveSupport::Testing::TimeHelpers 104 | end 105 | -------------------------------------------------------------------------------- /spec/support/chain_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ChainHelper 4 | def put_attributes(table_name, attributes) 5 | Dynamoid.adapter.put_item(table_name, attributes) 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/support/clear_adapter_table_cache.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.configure do |config| 4 | config.before do 5 | Dynamoid.adapter.clear_cache! 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/support/config.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.configure do |config| 4 | config.around :each, :config do |example| 5 | config = example.metadata[:config] 6 | config_old = {} 7 | 8 | config.each do |key, value| 9 | config_old[key] = Dynamoid::Config.send(key) 10 | Dynamoid::Config.send(:"#{key}=", value) 11 | end 12 | 13 | example.run 14 | 15 | config.each_key do |key| 16 | Dynamoid::Config.send(:"#{key}=", config_old[key]) 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /spec/support/delete_all_tables_in_namespace.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.configure do |config| 4 | config.before do 5 | unless Dynamoid.adapter.tables.empty? 6 | Dynamoid.adapter.list_tables.each do |table| 7 | Dynamoid.adapter.delete_table(table) if table =~ /^#{Dynamoid::Config.namespace}/ 8 | end 9 | Dynamoid.adapter.tables.clear 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /spec/support/helpers/dumping_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module DumpingHelper 4 | def raw_attributes(document) 5 | Dynamoid.adapter.get_item(document.class.table_name, document.id) 6 | end 7 | 8 | def reload(document) 9 | document.class.find(document.id) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/support/helpers/new_class_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Declaration DSL of partition key and sort key is weird. 4 | # So let's use helpers to simplify class declaration in specs. 5 | module NewClassHelper 6 | def new_class(options = {}, &block) 7 | table_name = options[:table_name] || :"documents_#{Time.now.to_i}_#{rand(1000)}" 8 | class_name = (options[:class_name] || table_name).to_s.classify 9 | partition_key = options[:partition_key] 10 | 11 | klass = Class.new do 12 | include Dynamoid::Document 13 | 14 | if partition_key 15 | if partition_key.is_a? Hash 16 | table name: table_name, key: partition_key[:name] 17 | if partition_key[:options] 18 | field partition_key[:name], partition_key[:type] || :string, partition_key[:options] 19 | else 20 | field partition_key[:name], partition_key[:type] || :string 21 | end 22 | else 23 | table name: table_name, key: partition_key 24 | field partition_key 25 | end 26 | else 27 | table name: table_name 28 | end 29 | 30 | @class_name = class_name 31 | @helper_options = options 32 | 33 | def self.name 34 | @class_name 35 | end 36 | end 37 | klass.class_exec(options, &block) if block 38 | klass 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /spec/support/helpers/persistence_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PersistenceHelper 4 | def raw_attribute_types(table_name) 5 | Dynamoid.adapter.adapter.send(:describe_table, table_name).schema.attribute_definitions.map do |ad| 6 | [ad.attribute_name, ad.attribute_type] 7 | end.to_h 8 | end 9 | 10 | def tables_created 11 | Dynamoid.adapter.list_tables 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/support/log_level.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.configure do |config| 4 | config.around :each, :log_level do |example| 5 | level_old = Dynamoid::Config.logger.level 6 | 7 | Dynamoid::Config.logger.level = example.metadata[:log_level] 8 | example.run 9 | Dynamoid::Config.logger.level = level_old 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/support/scratch_pad.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # It's a way to have a side effect and to check it. 4 | # 5 | # There are two use scenarios for this class. 6 | # 7 | # Scenario #1: 8 | # ScratchPad.clear 9 | # ScratchPad.record(value) 10 | # ScratchPad.recorded # => value 11 | # 12 | # Scenario #2: 13 | # ScratchPad.record [] 14 | # ScratchPad << val1 15 | # ScratchPad << val2 16 | # ScratchPad.recorded #> [val1, val2] 17 | module ScratchPad 18 | def self.clear 19 | @record = [] 20 | end 21 | 22 | def self.record(arg) 23 | @record = arg 24 | end 25 | 26 | def self.<<(arg) 27 | @record << arg 28 | end 29 | 30 | def self.recorded 31 | @record 32 | end 33 | 34 | def self.inspect 35 | "" 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /spec/support/unregister_declared_classes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.configure do |config| 4 | config.around do |example| 5 | included_models_before = Dynamoid.included_models.dup 6 | example.run 7 | Dynamoid.included_models.replace(included_models_before) 8 | end 9 | end 10 | --------------------------------------------------------------------------------