├── .github ├── ISSUE_TEMPLATE │ ├── bug-report.yml │ ├── config.yml │ ├── documentation.yml │ └── feature-request.yml ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── ci.yml │ ├── closed-issue-message.yml │ ├── handle-stale-discussions.yml │ └── stale_issues.yml ├── .gitignore ├── .gitmodules ├── .rubocop.yml ├── .rubocop_todo.yml ├── .yardopts ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Gemfile ├── LICENSE ├── NOTICE ├── README.md ├── Rakefile ├── VERSION ├── aws-record.gemspec ├── doc-src └── templates │ └── default │ └── layout │ └── html │ ├── footer.erb │ └── layout.erb ├── features ├── batch │ ├── batch.feature │ └── step_definitions.rb ├── indexes │ ├── secondary_indexes.feature │ └── step_definitions.rb ├── inheritance │ ├── inheritance.feature │ └── step_definitions.rb ├── items │ ├── item_default_values.feature │ ├── item_updates.feature │ ├── items.feature │ └── step_definitions.rb ├── migrations │ ├── on_demand_tables.feature │ ├── step_definitions.rb │ └── tables.feature ├── searching │ ├── search.feature │ └── step_definitions.rb ├── step_definitions.rb ├── table_config │ ├── step_definitions.rb │ └── table_config.feature └── transactions │ ├── step_definitions.rb │ └── transactions.feature ├── lib ├── aws-record.rb └── aws-record │ ├── record.rb │ └── record │ ├── attribute.rb │ ├── attributes.rb │ ├── batch.rb │ ├── batch_read.rb │ ├── batch_write.rb │ ├── buildable_search.rb │ ├── client_configuration.rb │ ├── dirty_tracking.rb │ ├── errors.rb │ ├── item_collection.rb │ ├── item_data.rb │ ├── item_operations.rb │ ├── key_attributes.rb │ ├── marshalers │ ├── boolean_marshaler.rb │ ├── date_marshaler.rb │ ├── date_time_marshaler.rb │ ├── epoch_time_marshaler.rb │ ├── float_marshaler.rb │ ├── integer_marshaler.rb │ ├── list_marshaler.rb │ ├── map_marshaler.rb │ ├── numeric_set_marshaler.rb │ ├── string_marshaler.rb │ ├── string_set_marshaler.rb │ └── time_marshaler.rb │ ├── model_attributes.rb │ ├── query.rb │ ├── secondary_indexes.rb │ ├── table_config.rb │ ├── table_migration.rb │ ├── transactions.rb │ └── version.rb └── spec ├── aws-record ├── record │ ├── attribute_spec.rb │ ├── attributes_spec.rb │ ├── batch_spec.rb │ ├── client_configuration_spec.rb │ ├── dirty_tracking_spec.rb │ ├── item_collection_spec.rb │ ├── item_operations_spec.rb │ ├── marshalers │ │ ├── boolean_marshaler_spec.rb │ │ ├── date_marshaler_spec.rb │ │ ├── date_time_marshaler_spec.rb │ │ ├── epoch_time_marshaler_spec.rb │ │ ├── float_marshaler_spec.rb │ │ ├── integer_marshaler_spec.rb │ │ ├── list_marshaler_spec.rb │ │ ├── map_marshaler_spec.rb │ │ ├── numeric_set_marshaler_spec.rb │ │ ├── string_marshaler_spec.rb │ │ ├── string_set_marshaler_spec.rb │ │ └── time_marshaler_spec.rb │ ├── query_spec.rb │ ├── secondary_indexes_spec.rb │ ├── table_config_spec.rb │ ├── table_migration_spec.rb │ └── transactions_spec.rb └── record_spec.rb └── spec_helper.rb /.github/ISSUE_TEMPLATE/bug-report.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: "🐛 Bug Report" 3 | description: Report a bug 4 | title: "(short issue description)" 5 | labels: [bug, needs-triage] 6 | assignees: [] 7 | body: 8 | - type: textarea 9 | id: description 10 | attributes: 11 | label: Describe the bug 12 | description: What is the problem? A clear and concise description of the bug. 13 | validations: 14 | required: true 15 | - type: textarea 16 | id: expected 17 | attributes: 18 | label: Expected Behavior 19 | description: | 20 | What did you expect to happen? 21 | validations: 22 | required: true 23 | - type: textarea 24 | id: current 25 | attributes: 26 | label: Current Behavior 27 | description: | 28 | What actually happened? 29 | 30 | Please include full errors, uncaught exceptions, stack traces, and relevant logs. 31 | If service responses are relevant, please include wire logs. 32 | validations: 33 | required: true 34 | - type: textarea 35 | id: reproduction 36 | attributes: 37 | label: Reproduction Steps 38 | description: | 39 | Provide a self-contained, concise snippet of code that can be used to reproduce the issue. 40 | For more complex issues provide a repo with the smallest sample that reproduces the bug. 41 | 42 | Avoid including business logic or unrelated code, it makes diagnosis more difficult. 43 | The code sample should be an SSCCE. See http://sscce.org/ for details. In short, please provide a code sample that we can copy/paste, run and reproduce. 44 | validations: 45 | required: true 46 | - type: textarea 47 | id: solution 48 | attributes: 49 | label: Possible Solution 50 | description: | 51 | Suggest a fix/reason for the bug 52 | validations: 53 | required: false 54 | - type: textarea 55 | id: context 56 | attributes: 57 | label: Additional Information/Context 58 | description: | 59 | Anything else that might be relevant for troubleshooting this bug. Providing context helps us come up with a solution that is most useful in the real world. 60 | validations: 61 | required: false 62 | - type: input 63 | id: aws-sdk-ruby-record version 64 | attributes: 65 | label: Gem version used 66 | validations: 67 | required: true 68 | - type: input 69 | id: environment 70 | attributes: 71 | label: Environment details (Version of Ruby, OS environment) 72 | validations: 73 | required: true 74 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | --- 2 | blank_issues_enabled: false 3 | contact_links: 4 | - name: 💬 General Question 5 | url: https://github.com/aws/aws-sdk-ruby/discussions/categories/q-a 6 | about: Please ask and answer questions as a discussion thread -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/documentation.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: "📕 Documentation Issue" 3 | description: Report an issue in the API Reference documentation or Developer Guide 4 | title: "(short issue description)" 5 | labels: [documentation, needs-triage] 6 | assignees: [] 7 | body: 8 | - type: textarea 9 | id: description 10 | attributes: 11 | label: Describe the issue 12 | description: A clear and concise description of the issue. 13 | validations: 14 | required: true 15 | 16 | - type: textarea 17 | id: links 18 | attributes: 19 | label: Links 20 | description: | 21 | Include links to affected documentation page(s). 22 | validations: 23 | required: true 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🚀 Feature Request 3 | description: Suggest an idea for this project 4 | title: "(short issue description)" 5 | labels: [feature-request, needs-triage] 6 | assignees: [] 7 | body: 8 | - type: textarea 9 | id: description 10 | attributes: 11 | label: Describe the feature 12 | description: A clear and concise description of the feature you are proposing. 13 | validations: 14 | required: true 15 | - type: textarea 16 | id: use-case 17 | attributes: 18 | label: Use Case 19 | description: | 20 | Why do you need this feature? For example: "I'm always frustrated when..." 21 | validations: 22 | required: true 23 | - type: textarea 24 | id: solution 25 | attributes: 26 | label: Proposed Solution 27 | description: | 28 | Suggest how to implement the addition or change. Please include prototype/workaround/sketch/reference implementation. 29 | validations: 30 | required: false 31 | - type: textarea 32 | id: other 33 | attributes: 34 | label: Other Information 35 | description: | 36 | Any alternative solutions or features you considered, a more detailed explanation, stack traces, related issues, links for context, etc. 37 | validations: 38 | required: false 39 | - type: checkboxes 40 | id: ack 41 | attributes: 42 | label: Acknowledgements 43 | options: 44 | - label: I may be able to implement this feature request 45 | required: false 46 | - label: This feature might incur a breaking change 47 | required: false 48 | - type: input 49 | id: sdk-version 50 | attributes: 51 | label: aws-sdk-ruby-record version used 52 | validations: 53 | required: true 54 | - type: input 55 | id: environment 56 | attributes: 57 | label: Environment details (OS name and version, etc.) 58 | validations: 59 | required: true 60 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | *Issue #, if available:* 2 | 3 | *Description of changes:* 4 | 5 | 6 | By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license. 7 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | pull_request: 9 | branches: 10 | - main 11 | 12 | jobs: 13 | test: 14 | runs-on: ubuntu-latest 15 | strategy: 16 | fail-fast: false 17 | matrix: 18 | ruby: [2.7, '3.0', 3.1, 3.2, 3.3, 3.4, jruby-9.4, jruby-10.0] 19 | 20 | steps: 21 | - name: Setup 22 | uses: ruby/setup-ruby@v1 23 | with: 24 | ruby-version: ${{ matrix.ruby }} 25 | 26 | - uses: actions/checkout@v2 27 | 28 | - name: Install 29 | run: | 30 | bundle install --without docs 31 | 32 | - name: Test 33 | run: bundle exec rake release:test 34 | 35 | rubocop: 36 | runs-on: ubuntu-latest 37 | 38 | steps: 39 | - name: Set up Ruby 3.4 40 | uses: ruby/setup-ruby@v1 41 | with: 42 | ruby-version: 3.4 43 | 44 | - uses: actions/checkout@v2 45 | 46 | - name: Install gems 47 | run: bundle install 48 | 49 | - name: Rubocop 50 | run: bundle exec rubocop -E -S -------------------------------------------------------------------------------- /.github/workflows/closed-issue-message.yml: -------------------------------------------------------------------------------- 1 | name: Closed Issue Message 2 | on: 3 | issues: 4 | types: [closed] 5 | permissions: {} 6 | jobs: 7 | auto_comment: 8 | permissions: 9 | issues: write # to comment on issues 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: aws-actions/closed-issue-message@v1 13 | with: 14 | # These inputs are both required 15 | repo-token: "${{ secrets.GITHUB_TOKEN }}" 16 | message: | 17 | This issue is now closed. Comments on closed issues are hard for our team to see. 18 | If you need more assistance, please open a new issue that references this one. 19 | -------------------------------------------------------------------------------- /.github/workflows/handle-stale-discussions.yml: -------------------------------------------------------------------------------- 1 | name: HandleStaleDiscussions 2 | on: 3 | schedule: 4 | - cron: '0 */4 * * *' 5 | discussion_comment: 6 | types: [created] 7 | 8 | jobs: 9 | handle-stale-discussions: 10 | name: Handle stale discussions 11 | runs-on: ubuntu-latest 12 | permissions: 13 | discussions: write 14 | steps: 15 | - name: Stale discussions action 16 | uses: aws-github-ops/handle-stale-discussions@v1 17 | env: 18 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} -------------------------------------------------------------------------------- /.github/workflows/stale_issues.yml: -------------------------------------------------------------------------------- 1 | name: "Close stale issues" 2 | 3 | # Controls when the action will run. 4 | on: 5 | schedule: 6 | - cron: "0 0 * * *" 7 | 8 | permissions: {} 9 | jobs: 10 | cleanup: 11 | permissions: 12 | issues: write # to label, comment and close issues 13 | pull-requests: write # to label, comment and close pull requests 14 | 15 | runs-on: ubuntu-latest 16 | name: Stale issue job 17 | steps: 18 | - uses: aws-actions/stale-issue-cleanup@v6 19 | with: 20 | # Setting messages to an empty string will cause the automation to skip 21 | # that category 22 | ancient-issue-message: Greetings! We’re closing this issue because it has been open a long time and hasn’t been updated in a while and may not be getting the attention it deserves. We encourage you to check if this is still an issue in the latest release and if you find that this is still a problem, please feel free to comment or open a new issue. 23 | stale-issue-message: This issue has not received a response in 1 week. If you still think there is a problem, please leave a comment to avoid the issue from automatically closing. 24 | stale-pr-message: This PR has not received a response in 1 week. If you still think there is a problem, please leave a comment to avoid the PR from automatically closing. 25 | # These labels are required 26 | stale-issue-label: closing-soon 27 | exempt-issue-label: no-autoclose 28 | stale-pr-label: closing-soon 29 | exempt-pr-label: pr/needs-review 30 | response-requested-label: response-requested 31 | 32 | # Don't set closed-for-staleness label to skip closing very old issues 33 | # regardless of label 34 | closed-for-staleness-label: closed-for-staleness 35 | 36 | # Issue timing 37 | days-before-stale: 10 38 | days-before-close: 4 39 | days-before-ancient: 36500 40 | 41 | # If you don't want to mark a issue as being ancient based on a 42 | # threshold of "upvotes", you can set this here. An "upvote" is 43 | # the total number of +1, heart, hooray, and rocket reactions 44 | # on an issue. 45 | minimum-upvotes-to-exempt: 10 46 | 47 | repo-token: ${{ secrets.GITHUB_TOKEN }} 48 | # loglevel: DEBUG 49 | # Set dry-run to true to not perform label or close actions. 50 | # dry-run: true 51 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .ruby-version 2 | .yardoc/ 3 | Gemfile.lock 4 | doc 5 | coverage/ 6 | vendor/bundle 7 | .bundle 8 | .idea 9 | .DS_Store 10 | docs.zip 11 | *.gem 12 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "tasks/release"] 2 | path = tasks/release 3 | url = https://github.com/aws/aws-sdk-ruby-release-tools.git 4 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_from: .rubocop_todo.yml 2 | 3 | AllCops: 4 | NewCops: enable 5 | TargetRubyVersion: 2.7 6 | Exclude: 7 | - 'tasks/release/**/*' 8 | 9 | Gemspec/RequireMFA: 10 | Enabled: false 11 | 12 | Layout/LineLength: 13 | Max: 120 14 | 15 | Metrics/AbcSize: 16 | Enabled: false 17 | 18 | Metrics/BlockLength: 19 | Exclude: 20 | - 'features/*' 21 | - 'spec/**/*' 22 | 23 | Metrics/ClassLength: 24 | Enabled: false 25 | 26 | Metrics/MethodLength: 27 | Enabled: false 28 | 29 | Metrics/ModuleLength: 30 | Enabled: false 31 | 32 | Security/Eval: 33 | Exclude: 34 | - 'features/**/*' 35 | 36 | Style/Attr: 37 | Exclude: 38 | - 'lib/aws-record/record/attributes.rb' 39 | 40 | Style/BlockDelimiters: 41 | Exclude: 42 | - 'spec/**/*' 43 | 44 | Style/Documentation: 45 | Enabled: false 46 | 47 | Style/GlobalVars: 48 | Enabled: false 49 | 50 | Style/TrivialAccessors: 51 | Enabled: false -------------------------------------------------------------------------------- /.rubocop_todo.yml: -------------------------------------------------------------------------------- 1 | # Offense count: 3 2 | # Configuration parameters: AllowedMethods. 3 | # AllowedMethods: enums 4 | Lint/ConstantDefinitionInBlock: 5 | Exclude: 6 | - 'features/searching/step_definitions.rb' 7 | - 'features/table_config/step_definitions.rb' 8 | - 'spec/aws-record/record/batch_spec.rb' 9 | 10 | # Offense count: 3 11 | # Configuration parameters: AllowedMethods, AllowedPatterns. 12 | Metrics/CyclomaticComplexity: 13 | Max: 11 14 | 15 | # Offense count: 3 16 | # Configuration parameters: AllowedMethods, AllowedPatterns. 17 | Metrics/PerceivedComplexity: 18 | Max: 12 19 | 20 | # Offense count: 1 21 | # Configuration parameters: ExpectMatchingDefinition, CheckDefinitionPathHierarchy, CheckDefinitionPathHierarchyRoots, Regex, IgnoreExecutableScripts, AllowedAcronyms. 22 | # CheckDefinitionPathHierarchyRoots: lib, spec, test, src 23 | # AllowedAcronyms: CLI, DSL, ACL, API, ASCII, CPU, CSS, DNS, EOF, GUID, HTML, HTTP, HTTPS, ID, IP, JSON, LHS, QPS, RAM, RHS, RPC, SLA, SMTP, SQL, SSH, TCP, TLS, TTL, UDP, UI, UID, UUID, URI, URL, UTF8, VM, XML, XMPP, XSRF, XSS 24 | Naming/FileName: 25 | Exclude: 26 | - 'lib/aws-record.rb' 27 | 28 | # Offense count: 4 29 | # Configuration parameters: MinNameLength, AllowNamesEndingInNumbers, AllowedNames, ForbiddenNames. 30 | # AllowedNames: as, at, by, cc, db, id, if, in, io, ip, of, on, os, pp, to 31 | Naming/MethodParameterName: 32 | Exclude: 33 | - 'lib/aws-record/record/buildable_search.rb' 34 | - 'lib/aws-record/record/table_config.rb' 35 | 36 | # Offense count: 1 37 | Style/OpenStructUse: 38 | Exclude: 39 | - 'lib/aws-record/record/transactions.rb' 40 | -------------------------------------------------------------------------------- /.yardopts: -------------------------------------------------------------------------------- 1 | --title 'AWS SDK Ruby Record' 2 | --template-path doc-src/templates 3 | --plugin sitemap 4 | --hide-api private 5 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | ## Code of Conduct 2 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 3 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 4 | opensource-codeofconduct@amazon.com with any additional questions or comments. 5 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | Thank you for your interest in contributing to our project. Whether it's a bug report, new feature, correction, or additional 4 | documentation, we greatly value feedback and contributions from our community. 5 | 6 | Please read through this document before submitting any issues or pull requests to ensure we have all the necessary 7 | information to effectively respond to your bug report or contribution. 8 | 9 | 10 | ## Reporting Bugs/Feature Requests 11 | 12 | We welcome you to use the GitHub issue tracker to report bugs or suggest features. 13 | 14 | When filing an issue, please check [existing open](https://github.com/aws/aws-sdk-ruby-record/issues), or [recently closed](https://github.com/aws/aws-sdk-ruby-record/issues?utf8=%E2%9C%93&q=is%3Aissue%20is%3Aclosed%20), issues to make sure somebody else hasn't already 15 | reported the issue. Please try to include as much information as you can. Details like these are incredibly useful: 16 | 17 | * A reproducible test case or series of steps 18 | * The version of our code being used 19 | * Any modifications you've made relevant to the bug 20 | * Anything unusual about your environment or deployment 21 | 22 | 23 | ## Contributing via Pull Requests 24 | 25 | Contributions via pull requests are much appreciated. Before sending us a pull request, please ensure that: 26 | 27 | 1. You are working against the latest source on the *main* branch. 28 | 2. You check existing open, and recently merged, pull requests to make sure someone else hasn't addressed the problem already. 29 | 3. You open an issue to discuss any significant work - we would hate for your time to be wasted. 30 | 31 | To send us a pull request, please: 32 | 33 | 1. Fork the repository. 34 | 2. Modify the source; please focus on the specific change you are contributing. If you also reformat all the code, it will be hard for us to focus on your change. 35 | 3. Ensure local tests pass. 36 | 4. Commit to your fork using clear commit messages. 37 | 5. Send us a pull request, answering any default questions in the pull request interface. 38 | 6. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation. 39 | 40 | GitHub provides additional document on [forking a repository](https://help.github.com/articles/fork-a-repo/) and 41 | [creating a pull request](https://help.github.com/articles/creating-a-pull-request/). 42 | 43 | ### Setting Up a Development Environment 44 | 45 | You can install the development dependencies necessary to run unit tests with `bundle install`. To run integration tests, you will need to set up AWS credentials on your machine. In practice, this means a shared credential file or environment variables with your credentials. These tests may have some AWS costs associated with running them since AWS resources are created and destroyed within these tests. Integration tests are not run unless you set the environment variable `AWS_INTEGRATION`. 46 | 47 | ### General Contribution Standards 48 | 49 | * Documentation improvements are always welcome. If adding a specific code example, consider also if that example would be appropriate to add as a unit or integration test. 50 | * For bug fixes or refactors, please ensure that code coverage remains the same or improves if at all possible. Especially for bug fixes, please include the unit and/or integration tests that reproduce the failure case. 51 | * For new features, at minimum include full unit test coverage, and ideally include integrations tests for the top-line behavior. Documentation is also required. 52 | * If you need help with integration testing or with writing documentation for a feature, let us know. We're happy to review a prototype of a feature ahead of development of full documentation/integration testing. 53 | 54 | ## Finding contributions to work on 55 | 56 | Looking at the existing issues is a great way to find something to contribute on. As our projects, by default, use the default GitHub issue labels ((enhancement/bug/duplicate/help wanted/invalid/question/wontfix), looking at any ['help wanted'](https://github.com/aws/aws-sdk-ruby-record/labels/help%20wanted) issues is a great place to start. 57 | 58 | ## Code of Conduct 59 | 60 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 61 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 62 | opensource-codeofconduct@amazon.com with any additional questions or comments. 63 | 64 | ## Security issue notifications 65 | 66 | If you discover a potential security issue in this project we ask that you notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/). Please do **not** create a public github issue. 67 | 68 | ## Licensing 69 | 70 | See the [LICENSE](https://github.com/aws/aws-sdk-ruby-record/blob/main/LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution. 71 | 72 | We may ask you to sign a [Contributor License Agreement (CLA)](http://en.wikipedia.org/wiki/Contributor_License_Agreement) for larger changes. 73 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | gemspec 6 | 7 | gem 'rake', require: false 8 | 9 | group :test do 10 | gem 'cucumber' 11 | gem 'rspec' 12 | gem 'simplecov', require: false 13 | 14 | gem 'activemodel' 15 | 16 | gem 'mutex_m' if RUBY_VERSION >= '3.4' 17 | gem 'rexml' if RUBY_VERSION >= '3.0' 18 | end 19 | 20 | group :docs do 21 | gem 'yard' 22 | gem 'yard-sitemap', '~> 1.0' 23 | end 24 | 25 | group :release do 26 | gem 'octokit' 27 | end 28 | 29 | group :development do 30 | gem 'pry' 31 | gem 'rubocop' 32 | end 33 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | AWS Record 2 | Copyright 2016 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rspec/core/rake_task' 4 | 5 | $REPO_ROOT = File.dirname(__FILE__) 6 | $LOAD_PATH.unshift(File.join($REPO_ROOT, 'lib')) 7 | $VERSION = ENV['VERSION'] || File.read(File.join($REPO_ROOT, 'VERSION')).strip 8 | 9 | task 'test:coverage:clear' do 10 | sh("rm -rf #{File.join($REPO_ROOT, 'coverage')}") 11 | end 12 | 13 | desc 'run unit tests' 14 | RSpec::Core::RakeTask.new do |t| 15 | t.rspec_opts = "-I #{$REPO_ROOT}/lib -I #{$REPO_ROOT}/spec" 16 | t.pattern = "#{$REPO_ROOT}/spec" 17 | end 18 | task spec: 'test:coverage:clear' 19 | task 'test:unit' => :spec # alias old names 20 | 21 | task 'cucumber' do 22 | exec('bundle exec cucumber') 23 | end 24 | 25 | # Ensure the test:integration task behaves as it always has 26 | desc 'run integration tests' 27 | task 'test:integration' do 28 | if ENV['AWS_INTEGRATION'] 29 | Rake::Task['cucumber'].invoke 30 | else 31 | puts 'Skipping integration tests' 32 | puts 'export AWS_INTEGRATION=1 to enable integration tests' 33 | end 34 | end 35 | 36 | # Setup alias for old task names 37 | task 'test:unit' => :spec 38 | task test: %w[test:unit test:integration] 39 | task default: :test 40 | 41 | task 'release:test' => :spec 42 | 43 | Dir.glob('**/*.rake').each do |task_file| 44 | load task_file 45 | end 46 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 2.14.0 2 | -------------------------------------------------------------------------------- /aws-record.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Gem::Specification.new do |spec| 4 | spec.name = 'aws-record' 5 | spec.version = File.read(File.expand_path('VERSION', __dir__)).strip 6 | spec.authors = ['Amazon Web Services'] 7 | spec.email = ['aws-dr-rubygems@amazon.com'] 8 | spec.summary = 'AWS Record library for Amazon DynamoDB' 9 | spec.description = 'Provides an object mapping abstraction for Amazon DynamoDB.' 10 | spec.homepage = 'https://github.com/aws/aws-sdk-ruby-record' 11 | spec.license = 'Apache 2.0' 12 | 13 | spec.require_paths = ['lib'] 14 | spec.files = Dir['lib/**/*.rb', 'LICENSE', 'CHANGELOG.md', 'VERSION'] 15 | 16 | # Require 1.85.0 for user_agent_frameworks config 17 | spec.add_dependency 'aws-sdk-dynamodb', '~> 1', '>= 1.85.0' 18 | 19 | spec.required_ruby_version = '>= 2.7' 20 | end 21 | -------------------------------------------------------------------------------- /doc-src/templates/default/layout/html/footer.erb: -------------------------------------------------------------------------------- 1 | 7 | -------------------------------------------------------------------------------- /doc-src/templates/default/layout/html/layout.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | <%= erb(:headers) %> 5 | 6 | 7 | 8 | 9 | 10 | 11 | 15 | 16 |
17 | 22 | 23 |
24 | 25 | <%= yieldall %> 26 |
27 | 28 | <%= erb(:footer) %> 29 |
30 | 31 | 32 | -------------------------------------------------------------------------------- /features/batch/batch.feature: -------------------------------------------------------------------------------- 1 | # language: en 2 | 3 | @dynamodb @batch 4 | Feature: Amazon DynamoDB Batch 5 | This feature tests the ability to use the batch read and write item APIs via 6 | aws-record. To run these tests, you will need to have valid AWS credentials 7 | that are accessible with the AWS SDK for Ruby's standard credential provider 8 | chain. In practice, this means a shared credential file or environment 9 | variables with your credentials. These tests may have some AWS costs associated 10 | with running them since AWS resources are created and destroyed within 11 | these tests. 12 | 13 | Background: 14 | Given a Parent model with definition: 15 | """ 16 | set_table_name('FoodTable') 17 | integer_attr :id, hash_key: true, database_attribute_name: 'Food ID' 18 | string_attr :dish, range_key: true 19 | boolean_attr :spicy 20 | """ 21 | And a Parent model with TableConfig of: 22 | """ 23 | Aws::Record::TableConfig.define do |t| 24 | t.model_class(ParentTableModel) 25 | t.read_capacity_units(2) 26 | t.write_capacity_units(2) 27 | t.client_options(region: "us-east-1") 28 | end 29 | """ 30 | When we migrate the TableConfig 31 | Then eventually the table should exist in DynamoDB 32 | And a Child model with definition: 33 | """ 34 | set_table_name('DessertTable') 35 | boolean_attr :gluten_free 36 | """ 37 | And a Child model with TableConfig of: 38 | """ 39 | Aws::Record::TableConfig.define do |t| 40 | t.model_class(ChildTableModel) 41 | t.read_capacity_units(2) 42 | t.write_capacity_units(2) 43 | t.client_options(region: "us-east-1") 44 | end 45 | """ 46 | When we migrate the TableConfig 47 | Then eventually the table should exist in DynamoDB 48 | 49 | Scenario: Perform a batch set of writes and read 50 | When we make a batch write call with following Parent and Child model items: 51 | """ 52 | [ 53 | { "model": "Parent", "id": 1, "dish": "Papaya Salad", "spicy": true }, 54 | { "model": "Parent", "id": 2, "dish": "Hamburger", "spicy": false }, 55 | { "model": "Child", "id": 1, "dish": "Apple Pie", "spicy": false, "gluten_free": false } 56 | ] 57 | """ 58 | And we make a batch read call for the following Parent and Child model item keys: 59 | """ 60 | [ 61 | { "model": "Parent", "id": 1, "dish": "Papaya Salad" }, 62 | { "model": "Parent", "id": 2, "dish": "Hamburger" }, 63 | { "model": "Child", "id": 1, "dish": "Apple Pie" } 64 | ] 65 | """ 66 | Then we expect the batch read result to include the following items: 67 | """ 68 | [ 69 | { "id": 1, "dish": "Papaya Salad", "spicy": true }, 70 | { "id": 2, "dish": "Hamburger", "spicy": false }, 71 | { "id": 1, "dish": "Apple Pie", "spicy": false, "gluten_free": false } 72 | ] 73 | """ -------------------------------------------------------------------------------- /features/batch/step_definitions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | And(/^a (Parent|Child) model with TableConfig of:$/) do |model, code_block| 4 | case model 5 | when 'Parent' 6 | ParentTableModel = @parent # rubocop:disable Naming/ConstantName 7 | when 'Child' 8 | ChildTableModel = @model # rubocop:disable Naming/ConstantName 9 | else 10 | raise 'Model must be either a Parent or Child' 11 | end 12 | 13 | @table_config = eval(code_block) 14 | end 15 | 16 | When(/^we make a batch write call with following Parent and Child model items:$/) do |string| 17 | item_data = JSON.parse(string, symbolize_names: true) 18 | 19 | Aws::Record::Batch.write do |db| 20 | item_data.each do |item| 21 | case item[:model] 22 | when 'Parent' 23 | db.put(@parent.new(remove_model_key(item))) 24 | when 'Child' 25 | db.put(@model.new(remove_model_key(item))) 26 | else 27 | raise 'Model must be either a Parent or Child' 28 | end 29 | end 30 | end 31 | end 32 | 33 | And(/^we make a batch read call for the following Parent and Child model item keys:$/) do |string| 34 | key_batch = JSON.parse(string, symbolize_names: true) 35 | 36 | @batch_read_result = Aws::Record::Batch.read do |db| 37 | key_batch.each do |item_key| 38 | case item_key[:model] 39 | when 'Parent' 40 | db.find(@parent, remove_model_key(item_key)) 41 | when 'Child' 42 | db.find(@model, remove_model_key(item_key)) 43 | else 44 | raise 'Model must be either a Parent or Child' 45 | end 46 | end 47 | end 48 | end 49 | 50 | Then(/^we expect the batch read result to include the following items:$/) do |string| 51 | expected = JSON.parse(string, symbolize_names: true) 52 | actual = @batch_read_result.items.map(&:to_h) 53 | expect(expected.count).to eq(actual.count) 54 | expect(expected.all? { |e| actual.include?(e) }).to be_truthy 55 | end 56 | 57 | private 58 | 59 | def remove_model_key(item) 60 | item.delete(:model) 61 | item 62 | end 63 | -------------------------------------------------------------------------------- /features/indexes/secondary_indexes.feature: -------------------------------------------------------------------------------- 1 | # language: en 2 | 3 | @dynamodb @table @secondary_indexes 4 | Feature: Amazon DynamoDB Secondary Indexes 5 | This feature tests the integration of model classes which define global 6 | secondary indexes and/or local secondary indexes in DynamoDB. Indexes are 7 | defined in your Aws::Record model, and are applied with the 8 | Aws::Record::TableMigration class. To run these tests, you will need to 9 | have valid AWS credentials that are accessible with the AWS SDK for Ruby's 10 | standard credential provider chain. In practice, this means a shared 11 | credential file or environment variables with your credentials. These tests 12 | may have some AWS costs associated with running them since AWS resources are 13 | created and destroyed within these tests. 14 | 15 | Background: 16 | Given an aws-record model with definition: 17 | """ 18 | integer_attr :forum_id, hash_key: true 19 | integer_attr :post_id, range_key: true 20 | string_attr :forum_name 21 | string_attr :post_title 22 | string_attr :post_body 23 | integer_attr :author_id 24 | string_attr :author_name 25 | """ 26 | 27 | Scenario: Create a DynamoDB Table with a Local Secondary Index 28 | When we add a local secondary index to the model with parameters: 29 | """ 30 | [ 31 | "title", 32 | { 33 | "range_key": "post_title", 34 | "projection": { 35 | "projection_type": "INCLUDE", 36 | "non_key_attributes": [ 37 | "post_body" 38 | ] 39 | } 40 | } 41 | ] 42 | """ 43 | And we create a table migration for the model 44 | And we call 'create!' with parameters: 45 | """ 46 | { 47 | "provisioned_throughput": { 48 | "read_capacity_units": 1, 49 | "write_capacity_units": 1 50 | } 51 | } 52 | """ 53 | Then eventually the table should exist in DynamoDB 54 | And the table should have a local secondary index named "title" 55 | 56 | Scenario: Create a DynamoDB Table with a Global Secondary Index 57 | When we add a global secondary index to the model with parameters: 58 | """ 59 | [ 60 | "author", 61 | { 62 | "hash_key": "forum_name", 63 | "range_key": "author_name", 64 | "projection": { 65 | "projection_type": "ALL" 66 | } 67 | } 68 | ] 69 | """ 70 | And we create a table migration for the model 71 | And we call 'create!' with parameters: 72 | """ 73 | { 74 | "provisioned_throughput": { 75 | "read_capacity_units": 1, 76 | "write_capacity_units": 1 77 | }, 78 | "global_secondary_index_throughput": { 79 | "author": { 80 | "read_capacity_units": 1, 81 | "write_capacity_units": 1 82 | } 83 | } 84 | } 85 | """ 86 | Then eventually the table should exist in DynamoDB 87 | And the table should have a global secondary index named "author" 88 | -------------------------------------------------------------------------------- /features/indexes/step_definitions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | When(/^we add a local secondary index to the model with parameters:$/) do |string| 4 | name, hash = JSON.parse(string, symbolize_names: true) 5 | name = name.to_sym 6 | hash[:range_key] = hash[:range_key].to_sym 7 | @model.local_secondary_index(name, hash) 8 | end 9 | 10 | Then(/^the table should have a local secondary index named "([^"]*)"$/) do |expected| 11 | resp = @client.describe_table(table_name: @table_name) 12 | lsis = resp.table.local_secondary_indexes 13 | exists = lsis&.any? { |index| index.index_name == expected } 14 | expect(exists).to eq(true) 15 | end 16 | 17 | When(/^we add a global secondary index to the model with parameters:$/) do |string| 18 | name, hash = JSON.parse(string, symbolize_names: true) 19 | name = name.to_sym 20 | hash[:hash_key] = hash[:hash_key].to_sym 21 | hash[:range_key] = hash[:range_key].to_sym 22 | @model.global_secondary_index(name, hash) 23 | end 24 | 25 | Then(/^the table should have a global secondary index named "([^"]*)"$/) do |expected| 26 | resp = @client.describe_table(table_name: @table_name) 27 | gsis = resp.table.global_secondary_indexes 28 | exists = gsis&.any? { |index| index.index_name == expected } 29 | expect(exists).to eq(true) 30 | end 31 | -------------------------------------------------------------------------------- /features/inheritance/inheritance.feature: -------------------------------------------------------------------------------- 1 | # language: en 2 | 3 | @dynamodb @inheritance 4 | Feature: Amazon DynamoDB Inheritance 5 | This feature tests inheritance between parent class and child classes. To run 6 | these tests, you will need to have valid AWS credentials that are accessible 7 | with the AWS SDK for Ruby's standard credential provider chain. In practice, 8 | this means a shared credential file or environment variables with your credentials. 9 | These tests may have some AWS costs associated with running them since AWS resources 10 | are created and destroyed within these tests. 11 | 12 | 13 | Background: 14 | Given a Parent model with definition: 15 | """ 16 | set_table_name('Animal') 17 | integer_attr :id, hash_key: true 18 | string_attr :name, range_key: true 19 | string_attr :size 20 | list_attr :characteristics 21 | global_secondary_index( 22 | :gsi, 23 | hash_key: :id, 24 | range_key: :size, 25 | projection: { 26 | projection_type: "ALL" 27 | } 28 | ) 29 | """ 30 | 31 | Scenario: Create a Table and be able to create Items from both Child model and Parent model 32 | Given a Child model with definition: 33 | """ 34 | boolean_attr :family_friendly 35 | """ 36 | And a TableConfig of: 37 | """ 38 | Aws::Record::TableConfig.define do |t| 39 | t.model_class(TableConfigTestModel) 40 | t.read_capacity_units(2) 41 | t.write_capacity_units(2) 42 | t.global_secondary_index(:gsi) do |i| 43 | i.read_capacity_units(1) 44 | i.write_capacity_units(1) 45 | end 46 | t.client_options(region: "us-east-1") 47 | end 48 | """ 49 | When we migrate the TableConfig 50 | Then eventually the table should exist in DynamoDB 51 | And the table should have a global secondary index named "gsi" 52 | And we create a new instance of the Child model with attribute value pairs: 53 | """ 54 | [ 55 | ["id", 1], 56 | ["name", "Cheeseburger"], 57 | ["size", "Large"], 58 | ["characteristics", ["Friendly", "Curious", "Loves kisses"]], 59 | ["family_friendly", true] 60 | ] 61 | """ 62 | And we save the model instance 63 | And we call the 'find' class method with parameter data: 64 | """ 65 | { 66 | "id": 1, 67 | "name": "Cheeseburger" 68 | } 69 | """ 70 | Then we should receive an aws-record item with attribute data: 71 | """ 72 | { 73 | "id": 1, 74 | "name": "Cheeseburger", 75 | "size": "Large", 76 | "characteristics": ["Friendly", "Curious", "Loves kisses"], 77 | "family_friendly": true 78 | } 79 | """ 80 | And we create a new instance of the Parent model with attribute value pairs: 81 | """ 82 | [ 83 | ["id", 2], 84 | ["name", "Applejack"], 85 | ["size", "Medium"], 86 | ["characteristics", ["Aloof", "Dignified"]] 87 | ] 88 | """ 89 | And we save the model instance 90 | And we call the 'find' class method with parameter data: 91 | """ 92 | { 93 | "id": 2, 94 | "name": "Applejack" 95 | } 96 | """ 97 | Then we should receive an aws-record item with attribute data: 98 | """ 99 | { 100 | "id": 2, 101 | "name": "Applejack", 102 | "size": "Medium", 103 | "characteristics": ["Aloof", "Dignified"] 104 | } 105 | """ 106 | 107 | Scenario: Create a Table based on the Child Model and be able to create an item 108 | Given a Child model with definition: 109 | """ 110 | set_table_name('Cat') 111 | integer_attr :toe_beans 112 | """ 113 | And a TableConfig of: 114 | """ 115 | Aws::Record::TableConfig.define do |t| 116 | t.model_class(TableConfigTestModel) 117 | t.read_capacity_units(2) 118 | t.write_capacity_units(2) 119 | t.global_secondary_index(:gsi) do |i| 120 | i.read_capacity_units(1) 121 | i.write_capacity_units(1) 122 | end 123 | t.client_options(region: "us-east-1") 124 | end 125 | """ 126 | When we migrate the TableConfig 127 | Then eventually the table should exist in DynamoDB 128 | And we create a new instance of the Child model with attribute value pairs: 129 | """ 130 | [ 131 | ["id", 1], 132 | ["name", "Donut"], 133 | ["size", "Chonk"], 134 | ["characteristics", ["Makes good bread", "Likes snacks"]], 135 | ["toe_beans", 9] 136 | ] 137 | """ 138 | And we save the model instance 139 | And we call the 'find' class method with parameter data: 140 | """ 141 | { 142 | "id": 1, 143 | "name": "Donut" 144 | } 145 | """ 146 | Then we should receive an aws-record item with attribute data: 147 | """ 148 | { 149 | "id": 1, 150 | "name": "Donut", 151 | "size": "Chonk", 152 | "characteristics": ["Makes good bread", "Likes snacks"], 153 | "toe_beans": 9 154 | } 155 | """ -------------------------------------------------------------------------------- /features/inheritance/step_definitions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Given(/^a (Parent|Child) model with definition:$/) do |model, string| 4 | case model 5 | when 'Parent' 6 | @parent = Class.new do 7 | include(Aws::Record) 8 | end 9 | @parent.class_eval(string) 10 | @table_name = @parent.table_name 11 | when 'Child' 12 | @model = Class.new(@parent) do 13 | include(Aws::Record) 14 | end 15 | @model.class_eval(string) 16 | @table_name = @model.table_name 17 | else 18 | raise 'Model must be either a Parent or Child' 19 | end 20 | end 21 | 22 | And(/^we create a new instance of the (Parent|Child) model with attribute value pairs:$/) do |model, string| 23 | data = JSON.parse(string) 24 | case model 25 | when 'Parent' 26 | @instance = @parent.new 27 | when 'Child' 28 | @instance = @model.new 29 | else 30 | raise 'Model must be either a Parent or Child' 31 | end 32 | data.each do |row| 33 | attribute, value = row 34 | @instance.send(:"#{attribute}=", value) 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /features/items/item_default_values.feature: -------------------------------------------------------------------------------- 1 | # language: en 2 | 3 | @dynamodb @item @default_values 4 | Feature: Aws::Record Attribute Default Values 5 | 6 | Scenario: Write From Default Values 7 | Given an aws-record model with definition: 8 | """ 9 | string_attr :id, hash_key: true 10 | map_attr :map, default_value: {} 11 | """ 12 | And a TableConfig of: 13 | """ 14 | Aws::Record::TableConfig.define do |t| 15 | t.model_class(TableConfigTestModel) 16 | t.read_capacity_units(1) 17 | t.write_capacity_units(1) 18 | t.client_options(region: "us-east-1") 19 | end 20 | """ 21 | When we migrate the TableConfig 22 | And eventually the table should exist in DynamoDB 23 | And we create a new instance of the model with attribute value pairs: 24 | """ 25 | [ 26 | ["id", "1"] 27 | ] 28 | """ 29 | And we apply the following keys and values to map attribute "map": 30 | """ 31 | { "a" => "1" } 32 | """ 33 | And we save the model instance 34 | And we create a new instance of the model with attribute value pairs: 35 | """ 36 | [ 37 | ["id", "2"] 38 | ] 39 | """ 40 | And we apply the following keys and values to map attribute "map": 41 | """ 42 | { "b" => "2" } 43 | """ 44 | And we save the model instance 45 | When we call the 'find' class method with parameter data: 46 | """ 47 | { 48 | "id": "1" 49 | } 50 | """ 51 | Then the attribute "map" on the item should match: 52 | """ 53 | { "a" => "1" } 54 | """ 55 | When we call the 'find' class method with parameter data: 56 | """ 57 | { 58 | "id": "2" 59 | } 60 | """ 61 | Then the attribute "map" on the item should match: 62 | """ 63 | { "b" => "2" } 64 | """ 65 | -------------------------------------------------------------------------------- /features/items/item_updates.feature: -------------------------------------------------------------------------------- 1 | # language: en 2 | 3 | @dynamodb @item @update 4 | Feature: Amazon DynamoDB Item Updates 5 | This feature tests functionality designed to prefer update calls to put calls 6 | when possible, in order to avoid accidental overwrite of unmodeled fields. For 7 | example, this kind of feature is useful in single-table inheritance scenarios, 8 | where the same table may serve multiple models. It also provides safety checks 9 | against overwriting existing items, if unintended. To run these tests, you 10 | will need to have valid AWS credentials that are accessible with the AWS SDK 11 | for Ruby's standard credential provider chain. In practice, this means a 12 | shared credential file or environment variables with your credentials. These 13 | tests may have some AWS costs associated with running them since AWS resources 14 | are created and destroyed within these tests. 15 | 16 | Background: 17 | Given a DynamoDB table named 'shared' with data: 18 | """ 19 | [ 20 | { "attribute_name": "hk", "attribute_type": "S", "key_type": "HASH" }, 21 | { "attribute_name": "rk", "attribute_type": "S", "key_type": "RANGE" } 22 | ] 23 | """ 24 | And an item exists in the DynamoDB table with item data: 25 | """ 26 | { 27 | "hk": "sample", 28 | "rk": "sample", 29 | "x": "x", 30 | "y": "y", 31 | "z": "z" 32 | } 33 | """ 34 | 35 | Scenario: Overwriting an Existing Object With #put_item Fails Without :force 36 | Given an aws-record model with data: 37 | """ 38 | [ 39 | { "method": "string_attr", "name": "hk", "hash_key": true }, 40 | { "method": "string_attr", "name": "rk", "range_key": true }, 41 | { "method": "string_attr", "name": "x" }, 42 | { "method": "string_attr", "name": "y" }, 43 | { "method": "string_attr", "name": "z" } 44 | ] 45 | """ 46 | When we create a new instance of the model with attribute value pairs: 47 | """ 48 | [ 49 | ["hk", "sample"], 50 | ["rk", "sample"], 51 | ["x", "foo"] 52 | ] 53 | """ 54 | Then calling save should raise a conditional save exception 55 | And we call the 'find' class method with parameter data: 56 | """ 57 | { 58 | "hk": "sample", 59 | "rk": "sample" 60 | } 61 | """ 62 | And we should receive an aws-record item with attribute data: 63 | """ 64 | { 65 | "hk": "sample", 66 | "rk": "sample", 67 | "x": "x", 68 | "y": "y", 69 | "z": "z" 70 | } 71 | """ 72 | Scenario: Updating an Object Does Not Clobber Unmodeled Attributes 73 | Given an aws-record model with data: 74 | """ 75 | [ 76 | { "method": "string_attr", "name": "hk", "hash_key": true }, 77 | { "method": "string_attr", "name": "rk", "range_key": true }, 78 | { "method": "string_attr", "name": "x" } 79 | ] 80 | """ 81 | When we call the 'find' class method with parameter data: 82 | """ 83 | { 84 | "hk": "sample", 85 | "rk": "sample" 86 | } 87 | """ 88 | And we set the item attribute "x" to be "bar" 89 | And we save the model instance 90 | Then an aws-record model with data: 91 | """ 92 | [ 93 | { "method": "string_attr", "name": "hk", "hash_key": true }, 94 | { "method": "string_attr", "name": "rk", "range_key": true }, 95 | { "method": "string_attr", "name": "x" }, 96 | { "method": "string_attr", "name": "y" }, 97 | { "method": "string_attr", "name": "z" } 98 | ] 99 | """ 100 | And we call the 'find' class method with parameter data: 101 | """ 102 | { 103 | "hk": "sample", 104 | "rk": "sample" 105 | } 106 | """ 107 | Then we should receive an aws-record item with attribute data: 108 | """ 109 | { 110 | "hk": "sample", 111 | "rk": "sample", 112 | "x": "bar", 113 | "y": "y", 114 | "z": "z" 115 | } 116 | """ 117 | 118 | Scenario: Updating an Object Does Not Clobber Non-Dirty Attributes 119 | Given an aws-record model with data: 120 | """ 121 | [ 122 | { "method": "string_attr", "name": "hk", "hash_key": true }, 123 | { "method": "string_attr", "name": "rk", "range_key": true }, 124 | { "method": "string_attr", "name": "x" }, 125 | { "method": "string_attr", "name": "y" }, 126 | { "method": "string_attr", "name": "z" } 127 | ] 128 | """ 129 | When we call the 'query' class method with parameter data: 130 | """ 131 | { 132 | "key_conditions": { 133 | "hk": { 134 | "attribute_value_list": ["sample"], 135 | "comparison_operator": "EQ" 136 | } 137 | }, 138 | "projection_expression": "hk, rk, x, y" 139 | } 140 | """ 141 | And we should receive an aws-record collection with members: 142 | """ 143 | [ 144 | { 145 | "hk": "sample", 146 | "rk": "sample", 147 | "x": "x", 148 | "y": "y", 149 | "z": null 150 | } 151 | ] 152 | """ 153 | And we take the first member of the result collection 154 | And we set the item attribute "y" to be "foo" 155 | And we save the model instance 156 | And we call the 'find' class method with parameter data: 157 | """ 158 | { 159 | "hk": "sample", 160 | "rk": "sample" 161 | } 162 | """ 163 | Then we should receive an aws-record item with attribute data: 164 | """ 165 | { 166 | "hk": "sample", 167 | "rk": "sample", 168 | "x": "x", 169 | "y": "foo", 170 | "z": "z" 171 | } 172 | """ 173 | 174 | Scenario: Updating an Object with the Update Model Method 175 | Given an aws-record model with data: 176 | """ 177 | [ 178 | { "method": "string_attr", "name": "hk", "hash_key": true }, 179 | { "method": "string_attr", "name": "rk", "range_key": true }, 180 | { "method": "string_attr", "name": "x" }, 181 | { "method": "string_attr", "name": "y" }, 182 | { "method": "string_attr", "name": "z" } 183 | ] 184 | """ 185 | When we call the 'update' class method with parameter data: 186 | """ 187 | { 188 | "hk": "sample", 189 | "rk": "sample", 190 | "y": "foo", 191 | "z": "bar" 192 | } 193 | """ 194 | And we call the 'find' class method with parameter data: 195 | """ 196 | { 197 | "hk": "sample", 198 | "rk": "sample" 199 | } 200 | """ 201 | Then we should receive an aws-record item with attribute data: 202 | """ 203 | { 204 | "hk": "sample", 205 | "rk": "sample", 206 | "x": "x", 207 | "y": "foo", 208 | "z": "bar" 209 | } 210 | """ 211 | 212 | Scenario: Updating an Object for Attribute Removal 213 | Given an aws-record model with data: 214 | """ 215 | [ 216 | { "method": "string_attr", "name": "hk", "hash_key": true }, 217 | { "method": "string_attr", "name": "rk", "range_key": true }, 218 | { "method": "string_attr", "name": "x" }, 219 | { "method": "string_attr", "name": "y" }, 220 | { "method": "string_attr", "name": "z" } 221 | ] 222 | """ 223 | When we call the 'update' class method with parameter data: 224 | """ 225 | { 226 | "hk": "sample", 227 | "rk": "sample", 228 | "y": "foo", 229 | "z": null 230 | } 231 | """ 232 | Then the DynamoDB table should have exactly the following item attributes: 233 | """ 234 | { 235 | "key": { 236 | "hk": "sample", 237 | "rk": "sample" 238 | }, 239 | "item": { 240 | "hk": "sample", 241 | "rk": "sample", 242 | "x": "x", 243 | "y": "foo" 244 | } 245 | } 246 | """ 247 | -------------------------------------------------------------------------------- /features/items/items.feature: -------------------------------------------------------------------------------- 1 | # language: en 2 | 3 | @dynamodb @item 4 | Feature: Amazon DynamoDB Items 5 | This feature tests the integration of model classes that include the 6 | Aws::Record module with a DynamoDB backend. To run these tests, you will need 7 | to have valid AWS credentials that are accessible with the AWS SDK for Ruby's 8 | standard credential provider chain. In practice, this means a shared 9 | credential file or environment variables with your credentials. These tests 10 | may have some AWS costs associated with running them since AWS resources are 11 | created and destroyed within these tests. 12 | 13 | Background: 14 | Given a DynamoDB table named 'example' with data: 15 | """ 16 | [ 17 | { "attribute_name": "id", "attribute_type": "S", "key_type": "HASH" }, 18 | { "attribute_name": "rk", "attribute_type": "N", "key_type": "RANGE" } 19 | ] 20 | """ 21 | And an aws-record model with data: 22 | """ 23 | [ 24 | { "method": "string_attr", "name": "id", "hash_key": true }, 25 | { "method": "integer_attr", "name": "rk", "range_key": true }, 26 | { "method": "string_attr", "name": "body", "database_name": "content" } 27 | ] 28 | """ 29 | 30 | Scenario: Write an Item with aws-record 31 | When we create a new instance of the model with attribute value pairs: 32 | """ 33 | [ 34 | ["id", 1], 35 | ["rk", 1], 36 | ["body", "Hello!"] 37 | ] 38 | """ 39 | And we save the model instance 40 | Then the DynamoDB table should have an object with key values: 41 | """ 42 | [ 43 | ["id", "1"], 44 | ["rk", 1] 45 | ] 46 | """ 47 | 48 | Scenario: Read an Item from Amazon DynamoDB with aws-record 49 | Given an item exists in the DynamoDB table with item data: 50 | """ 51 | { 52 | "id": "2", 53 | "rk": 10, 54 | "content": "Aliased column names!" 55 | } 56 | """ 57 | When we call the 'find' class method with parameter data: 58 | """ 59 | { 60 | "id": "2", 61 | "rk": 10 62 | } 63 | """ 64 | Then we should receive an aws-record item with attribute data: 65 | """ 66 | { 67 | "id": "2", 68 | "rk": 10, 69 | "body": "Aliased column names!" 70 | } 71 | """ 72 | 73 | Scenario: Delete an Item from Amazon DynamoDB with aws-record 74 | Given an item exists in the DynamoDB table with item data: 75 | """ 76 | { 77 | "id": "3", 78 | "rk": 5, 79 | "content": "Body content." 80 | } 81 | """ 82 | When we call the 'find' class method with parameter data: 83 | """ 84 | { 85 | "id": "3", 86 | "rk": 5 87 | } 88 | """ 89 | And we call 'delete!' on the aws-record item instance 90 | Then the DynamoDB table should not have an object with key values: 91 | """ 92 | [ 93 | ["id", "3"], 94 | ["rk", 5] 95 | ] 96 | """ 97 | 98 | Scenario: Update an Item from Amazon DynamoDB with aws-record 99 | Given an item exists in the DynamoDB table with item data: 100 | """ 101 | { 102 | "id": "4", 103 | "rk": 5, 104 | "content": "Body content." 105 | } 106 | """ 107 | When we call the 'find' class method with parameter data: 108 | """ 109 | { 110 | "id": "4", 111 | "rk": 5 112 | } 113 | """ 114 | And we call 'update' on the aws-record item instance with parameter data: 115 | """ 116 | { 117 | "body": "Updated Body Content." 118 | } 119 | """ 120 | Then we should receive an aws-record item with attribute data: 121 | """ 122 | { 123 | "id": "4", 124 | "rk": 5, 125 | "body": "Updated Body Content." 126 | } 127 | """ 128 | 129 | Scenario: Increment Atomic Counter Attribute 130 | Given an aws-record model with data: 131 | """ 132 | [ 133 | { "method": "string_attr", "name": "id", "hash_key": true }, 134 | { "method": "integer_attr", "name": "rk", "range_key": true }, 135 | { "method": "atomic_counter", "name": "counter" } 136 | ] 137 | """ 138 | And an item exists in the DynamoDB table with item data: 139 | """ 140 | { 141 | "id": "5", 142 | "rk": 6, 143 | "counter": 0 144 | } 145 | """ 146 | And we call the 'find' class method with parameter data: 147 | """ 148 | { 149 | "id": "5", 150 | "rk": 6 151 | } 152 | """ 153 | When we call "increment_counter!" on aws-record item instance 154 | And we call the 'find' class method with parameter data: 155 | """ 156 | { 157 | "id": "5", 158 | "rk": 6 159 | } 160 | """ 161 | Then we should receive an aws-record item with attribute data: 162 | """ 163 | { 164 | "id": "5", 165 | "rk": 6, 166 | "counter": 1 167 | } 168 | """ 169 | When we call "increment_counter!" on aws-record item instance with an integer value of "5" 170 | And we call the 'find' class method with parameter data: 171 | """ 172 | { 173 | "id": "5", 174 | "rk": 6 175 | } 176 | """ 177 | Then we should receive an aws-record item with attribute data: 178 | """ 179 | { 180 | "id": "5", 181 | "rk": 6, 182 | "counter": 6 183 | } 184 | """ 185 | -------------------------------------------------------------------------------- /features/items/step_definitions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | When(/^we create a new instance of the model with attribute value pairs:$/) do |string| 4 | data = JSON.parse(string) 5 | @instance = @model.new 6 | data.each do |row| 7 | attribute, value = row 8 | @instance.send(:"#{attribute}=", value) 9 | end 10 | end 11 | 12 | When(/^we save the model instance$/) do 13 | @save_output = @instance.save 14 | end 15 | 16 | When(/^we call the 'find' class method with parameter data:$/) do |string| 17 | data = JSON.parse(string, symbolize_names: true) 18 | @instance = @model.find(data) 19 | end 20 | 21 | When(/^we call the 'update' class method with parameter data:$/) do |string| 22 | data = JSON.parse(string, symbolize_names: true) 23 | @model.update(data) 24 | end 25 | 26 | Then(/^we should receive an aws-record item with attribute data:$/) do |string| 27 | data = JSON.parse(string, symbolize_names: true) 28 | data.each do |key, value| 29 | expect(@instance.send(key)).to eq(value) 30 | end 31 | end 32 | 33 | When(/^we call 'delete!' on the aws-record item instance$/) do 34 | @instance.delete! 35 | end 36 | 37 | When(/^we call 'update' on the aws-record item instance with parameter data:$/) do |string| 38 | data = JSON.parse(string, symbolize_names: true) 39 | @instance.update(data) 40 | end 41 | 42 | When(/^we set the item attribute "([^"]*)" to be "([^"]*)"$/) do |attr, value| 43 | @instance.send(:"#{attr}=", value) 44 | end 45 | 46 | Then(/^calling save should raise a conditional save exception$/) do 47 | expect { @instance.save }.to raise_error( 48 | Aws::Record::Errors::ConditionalWriteFailed 49 | ) 50 | end 51 | 52 | When(/^we apply the following keys and values to map attribute "([^"]*)":$/) do |attribute, map_block| 53 | # This code will explode, probably with a NoMethodError, if you put in a 54 | # non-map attribute. It also intentionally uses mutation over assignment. 55 | value = @instance.send(:"#{attribute}") 56 | map = eval(map_block) 57 | value.merge!(map) 58 | end 59 | 60 | Then(/^the attribute "([^"]*)" on the item should match:$/) do |attribute, value_block| 61 | expected = eval(value_block) 62 | actual = @instance.send(:"#{attribute}") 63 | expect(actual).to eq(expected) 64 | end 65 | 66 | When(/^we call "([^"]*)" on aws-record item instance(?: with an integer value of "(-?\d+)")?$/) do |method, value| 67 | if value 68 | @instance.send(method, value) 69 | else 70 | @instance.send(method) 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /features/migrations/on_demand_tables.feature: -------------------------------------------------------------------------------- 1 | # language: en 2 | 3 | @dynamodb @table 4 | Feature: Amazon DynamoDB Tables with On Demand Billing 5 | This feature tests the integration of model classes that include the 6 | Aws::Record module with the Aws::Record::TableMigration class, which helps to 7 | run table change operations on DynamoDB. To run these tests, you will need to 8 | have valid AWS credentials that are accessible with the AWS SDK for Ruby's 9 | standard credential provider chain. In practice, this means a shared 10 | credential file or environment variables with your credentials. These tests 11 | may have some AWS costs associated with running them since AWS resources are 12 | created and destroyed within these tests. 13 | 14 | Scenario: Create a DynamoDB Table with on-demand billing with aws-record 15 | Given an aws-record model with data: 16 | """ 17 | [ 18 | { "method": "string_attr", "name": "id", "hash_key": true }, 19 | { "method": "integer_attr", "name": "count", "range_key": true }, 20 | { "method": "string_attr", "name": "body", "database_name": "content" } 21 | ] 22 | """ 23 | When we create a table migration for the model 24 | And we call 'create!' with parameters: 25 | """ 26 | { "billing_mode": "PAY_PER_REQUEST" } 27 | """ 28 | Then eventually the table should exist in DynamoDB 29 | And calling 'table_exists?' on the model should return "true" 30 | And calling "provisioned_throughput" on the model should return: 31 | """ 32 | { 33 | "read_capacity_units": 0, 34 | "write_capacity_units": 0 35 | } 36 | """ 37 | -------------------------------------------------------------------------------- /features/migrations/step_definitions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | When(/^we create a table migration for the model$/) do 4 | @migration = Aws::Record::TableMigration.new(@model, client: @client) 5 | end 6 | 7 | When(/^we call 'create!' with parameters:$/) do |string| 8 | data = JSON.parse(string, symbolize_names: true) 9 | @migration.create!(data) 10 | end 11 | 12 | Then(/^eventually the table should exist in DynamoDB$/) do 13 | @client.wait_until(:table_exists, table_name: @table_name) do |w| 14 | w.delay = 5 15 | w.max_attempts = 25 16 | end 17 | true 18 | end 19 | 20 | Then(/^calling 'table_exists\?' on the model should return "([^"]*)"$/) do |b| 21 | boolean = !(b == 'false' || b.nil?) 22 | expect(@model.table_exists?).to eq(boolean) 23 | end 24 | 25 | When(/^we call 'delete!' on the migration$/) do 26 | @migration.delete! 27 | end 28 | 29 | Then(/^eventually the table should not exist in DynamoDB$/) do 30 | @client.wait_until(:table_not_exists, table_name: @table_name) do |w| 31 | w.delay = 5 32 | w.max_attempts = 25 33 | end 34 | end 35 | 36 | When(/^we call 'wait_until_available' on the migration$/) do 37 | @migration.wait_until_available 38 | end 39 | 40 | When(/^we call 'update!' on the migration with parameters:$/) do |string| 41 | data = JSON.parse(string, symbolize_names: true) 42 | @migration.update!(data) 43 | # Wait until table is active again before proceeding. 44 | @client.wait_until(:table_exists, table_name: @table_name) do |w| 45 | w.delay = 5 46 | w.max_attempts = 25 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /features/migrations/tables.feature: -------------------------------------------------------------------------------- 1 | # language: en 2 | 3 | @dynamodb @table 4 | Feature: Amazon DynamoDB Tables 5 | This feature tests the integration of model classes that include the 6 | Aws::Record module with the Aws::Record::TableMigration class, which helps to 7 | run table change operations on DynamoDB. To run these tests, you will need to 8 | have valid AWS credentials that are accessible with the AWS SDK for Ruby's 9 | standard credential provider chain. In practice, this means a shared 10 | credential file or environment variables with your credentials. These tests 11 | may have some AWS costs associated with running them since AWS resources are 12 | created and destroyed within these tests. 13 | 14 | Background: 15 | Given an aws-record model with data: 16 | """ 17 | [ 18 | { "method": "string_attr", "name": "id", "hash_key": true }, 19 | { "method": "integer_attr", "name": "count", "range_key": true }, 20 | { "method": "string_attr", "name": "body", "database_name": "content" } 21 | ] 22 | """ 23 | When we create a table migration for the model 24 | And we call 'create!' with parameters: 25 | """ 26 | { 27 | "provisioned_throughput": { 28 | "read_capacity_units": 1, 29 | "write_capacity_units": 1 30 | } 31 | } 32 | """ 33 | 34 | Scenario: Create a DynamoDB Table with aws-record 35 | Then eventually the table should exist in DynamoDB 36 | And calling 'table_exists?' on the model should return "true" 37 | 38 | Scenario: Delete a DynamoDB Table After Creation 39 | When eventually the table should exist in DynamoDB 40 | And we call 'delete!' on the migration 41 | Then eventually the table should not exist in DynamoDB 42 | And calling 'table_exists?' on the model should return "false" 43 | 44 | Scenario: Provide a Migration Waiter 45 | When we call 'wait_until_available' on the migration 46 | Then calling 'table_exists?' on the model should return "true" 47 | 48 | Scenario: Update a Table After Creation 49 | When we call 'wait_until_available' on the migration 50 | And calling "provisioned_throughput" on the model should return: 51 | """ 52 | { 53 | "read_capacity_units": 1, 54 | "write_capacity_units": 1 55 | } 56 | """ 57 | And we call 'update!' on the migration with parameters: 58 | """ 59 | { 60 | "provisioned_throughput": { 61 | "read_capacity_units": 3, 62 | "write_capacity_units": 2 63 | } 64 | } 65 | """ 66 | Then calling "provisioned_throughput" on the model should return: 67 | """ 68 | { 69 | "read_capacity_units": 3, 70 | "write_capacity_units": 2 71 | } 72 | """ 73 | -------------------------------------------------------------------------------- /features/searching/search.feature: -------------------------------------------------------------------------------- 1 | # language: en 2 | 3 | @dynamodb @search 4 | Feature: Amazon DynamoDB Querying and Scanning 5 | This feature tests integration of the client #query and #scan methods with the 6 | Aws::Record abstraction. To run these tests, you will need to have valid AWS 7 | credentials that are accessible with the AWS SDK for Ruby's standard 8 | credential provider chain. In practice, this means a shared credential file or 9 | environment variables with your credentials. These tests may have some AWS 10 | costs associated with running them since AWS resources are created and 11 | destroyed within these tests. 12 | 13 | Background: 14 | Given a DynamoDB table named 'example' with data: 15 | """ 16 | [ 17 | { "attribute_name": "id", "attribute_type": "S", "key_type": "HASH" }, 18 | { "attribute_name": "count", "attribute_type": "N", "key_type": "RANGE" } 19 | ] 20 | """ 21 | And an aws-record model with data: 22 | """ 23 | [ 24 | { "method": "string_attr", "name": "id", "hash_key": true }, 25 | { "method": "integer_attr", "name": "count", "range_key": true }, 26 | { "method": "string_attr", "name": "body", "database_name": "content" } 27 | ] 28 | """ 29 | And an item exists in the DynamoDB table with item data: 30 | """ 31 | { 32 | "id": "1", 33 | "count": 5, 34 | "content": "First item." 35 | } 36 | """ 37 | And an item exists in the DynamoDB table with item data: 38 | """ 39 | { 40 | "id": "1", 41 | "count": 10, 42 | "content": "Second item." 43 | } 44 | """ 45 | And an item exists in the DynamoDB table with item data: 46 | """ 47 | { 48 | "id": "1", 49 | "count": 15, 50 | "content": "Third item." 51 | } 52 | """ 53 | And an item exists in the DynamoDB table with item data: 54 | """ 55 | { 56 | "id": "2", 57 | "count": 10, 58 | "content": "Fourth item." 59 | } 60 | """ 61 | 62 | Scenario: Run Query Directly From Aws::DynamoDB::Client#query 63 | When we call the 'query' class method with parameter data: 64 | """ 65 | { 66 | "key_conditions": { 67 | "id": { 68 | "attribute_value_list": ["1"], 69 | "comparison_operator": "EQ" 70 | }, 71 | "count": { 72 | "attribute_value_list": [7], 73 | "comparison_operator": "GT" 74 | } 75 | } 76 | } 77 | """ 78 | Then we should receive an aws-record collection with members: 79 | """ 80 | [ 81 | { 82 | "id": "1", 83 | "count": 10, 84 | "body": "Second item." 85 | }, 86 | { 87 | "id": "1", 88 | "count": 15, 89 | "body": "Third item." 90 | } 91 | ] 92 | """ 93 | 94 | Scenario: Run Scan Directly From Aws::DynamoDB::Client#scan 95 | When we call the 'scan' class method 96 | Then we should receive an aws-record collection with members: 97 | """ 98 | [ 99 | { 100 | "id": "1", 101 | "count": 5, 102 | "body": "First item." 103 | }, 104 | { 105 | "id": "1", 106 | "count": 10, 107 | "body": "Second item." 108 | }, 109 | { 110 | "id": "1", 111 | "count": 15, 112 | "body": "Third item." 113 | }, 114 | { 115 | "id": "2", 116 | "count": 10, 117 | "body": "Fourth item." 118 | } 119 | ] 120 | """ 121 | 122 | Scenario: Paginate Manually With Multiple Calls 123 | When we call the 'scan' class method with parameter data: 124 | """ 125 | { 126 | "limit": 2 127 | } 128 | """ 129 | Then we should receive an aws-record page with 2 values from members: 130 | """ 131 | [ 132 | { 133 | "id": "1", 134 | "count": 5, 135 | "body": "First item." 136 | }, 137 | { 138 | "id": "1", 139 | "count": 10, 140 | "body": "Second item." 141 | }, 142 | { 143 | "id": "1", 144 | "count": 15, 145 | "body": "Third item." 146 | }, 147 | { 148 | "id": "2", 149 | "count": 10, 150 | "body": "Fourth item." 151 | } 152 | ] 153 | """ 154 | When we call the 'scan' class method using the page's pagination token 155 | Then we should receive an aws-record page with 2 values from members: 156 | """ 157 | [ 158 | { 159 | "id": "1", 160 | "count": 5, 161 | "body": "First item." 162 | }, 163 | { 164 | "id": "1", 165 | "count": 10, 166 | "body": "Second item." 167 | }, 168 | { 169 | "id": "1", 170 | "count": 15, 171 | "body": "Third item." 172 | }, 173 | { 174 | "id": "2", 175 | "count": 10, 176 | "body": "Fourth item." 177 | } 178 | ] 179 | """ 180 | 181 | Scenario: Heterogeneous query 182 | When we run a heterogeneous query 183 | Then we should receive an aws-record collection with multiple model classes 184 | 185 | @smart_query 186 | Scenario: Build a Smart Scan 187 | When we run the following search: 188 | """ 189 | SearchTestModel.build_scan.filter_expr(':body = ?', 'Third item.').complete! 190 | """ 191 | Then we should receive an aws-record collection with members: 192 | """ 193 | [ 194 | { 195 | "id": "1", 196 | "count": 15, 197 | "body": "Third item." 198 | } 199 | ] 200 | """ 201 | 202 | @smart_query 203 | Scenario: Build a Smart Query 204 | When we run the following search: 205 | """ 206 | SearchTestModel.build_query.key_expr(':id = ? AND :count > ?', '1', 7).complete! 207 | """ 208 | Then we should receive an aws-record collection with members: 209 | """ 210 | [ 211 | { 212 | "id": "1", 213 | "count": 10, 214 | "body": "Second item." 215 | }, 216 | { 217 | "id": "1", 218 | "count": 15, 219 | "body": "Third item." 220 | } 221 | ] 222 | """ 223 | -------------------------------------------------------------------------------- /features/searching/step_definitions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | When(/^we call the 'query' class method with parameter data:$/) do |string| 4 | data = JSON.parse(string, symbolize_names: true) 5 | @collection = @model.query(data) 6 | end 7 | 8 | Then(/^we should receive an aws-record collection with members:$/) do |string| 9 | expected = JSON.parse(string, symbolize_names: true) 10 | # Ensure that we have the same number of items, and no pagination. 11 | expect(expected.size).to eq(@collection.to_a.size) 12 | # Results do not have guaranteed order, check each expected value individually 13 | @collection.each do |item| 14 | h = item.to_h 15 | expect(expected.any? { |e| h == e }).to eq(true) 16 | end 17 | end 18 | 19 | When(/^we call the 'scan' class method$/) do 20 | @collection = @model.scan 21 | end 22 | 23 | When(/^we call the 'scan' class method with parameter data:$/) do |string| 24 | data = JSON.parse(string, symbolize_names: true) 25 | @collection = @model.scan(data) 26 | end 27 | 28 | When(/^we take the first member of the result collection$/) do 29 | @instance = @collection.first 30 | end 31 | 32 | Then(/^we should receive an aws-record page with 2 values from members:$/) do |string| 33 | expected = JSON.parse(string, symbolize_names: true) 34 | page = @collection.page 35 | @last_evaluated_key = @collection.last_evaluated_key 36 | # This is definitely a hack which takes advantage of an accident in test 37 | # design. In the future, we'll need to have some sort of shared collection 38 | # state to cope with the fact that scan order is not guaranteed. 39 | page.size == 2 # rubocop:disable Lint/Void 40 | # Results do not have guaranteed order, check each expected value individually 41 | page.each do |item| 42 | h = item.to_h 43 | expect(expected.any? { |e| h == e }).to eq(true) 44 | end 45 | end 46 | 47 | When(/^we call the 'scan' class method using the page's pagination token$/) do 48 | @collection = @model.scan(exclusive_start_key: @last_evaluated_key) 49 | end 50 | 51 | When('we run the following search:') do |code| 52 | SearchTestModel = @model # rubocop:disable Naming/ConstantName 53 | @collection = eval(code) 54 | end 55 | 56 | When(/^we run a heterogeneous query$/) do 57 | @model1 = @model.dup 58 | @model2 = @model.dup 59 | scan = @model.build_scan.multi_model_filter do |raw_item_attributes| 60 | if raw_item_attributes['id'] == '1' 61 | @model1 62 | elsif raw_item_attributes['id'] == '2' 63 | @model2 64 | end 65 | end 66 | @collection = scan.complete! 67 | end 68 | 69 | Then(/^we should receive an aws-record collection with multiple model classes/) do 70 | result_classes = @collection.map(&:class) 71 | expect(result_classes).to include(@model_1, @model_2) 72 | end 73 | -------------------------------------------------------------------------------- /features/step_definitions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'securerandom' 4 | require 'aws-sdk-core' 5 | require 'aws-record' 6 | 7 | def cleanup_table 8 | log "Cleaning Up Table: #{@table_name}" 9 | @client.delete_table(table_name: @table_name) 10 | log "Cleaned up table: #{@table_name}" 11 | @table_name = nil 12 | rescue Aws::DynamoDB::Errors::ResourceNotFoundException 13 | log "Cleanup: Table #{@table_name} doesn't exist, continuing." 14 | @table_name = nil 15 | rescue Aws::DynamoDB::Errors::ResourceInUseException 16 | log 'Failed to delete table, waiting to retry.' 17 | @client.wait_until(:table_exists, table_name: @table_name) 18 | sleep(10) 19 | retry 20 | end 21 | 22 | Before do 23 | @client = Aws::DynamoDB::Client.new(region: 'us-east-1') 24 | end 25 | 26 | After('@dynamodb') do 27 | cleanup_table 28 | end 29 | 30 | Given(/^a DynamoDB table named '([^"]*)' with data:$/) do |table, string| 31 | data = JSON.parse(string) 32 | @table_name = "#{table}_#{SecureRandom.uuid}" 33 | attr_def = data.inject([]) do |acc, row| 34 | acc << { 35 | attribute_name: row['attribute_name'], 36 | attribute_type: row['attribute_type'] 37 | } 38 | end 39 | key_schema = data.inject([]) do |acc, row| 40 | acc << { 41 | attribute_name: row['attribute_name'], 42 | key_type: row['key_type'] 43 | } 44 | end 45 | @client.create_table( 46 | table_name: @table_name, 47 | attribute_definitions: attr_def, 48 | key_schema: key_schema, 49 | provisioned_throughput: { 50 | read_capacity_units: 1, 51 | write_capacity_units: 1 52 | } 53 | ) 54 | @client.wait_until(:table_exists, table_name: @table_name) do |w| 55 | w.delay = 5 56 | w.max_attempts = 25 57 | end 58 | end 59 | 60 | Then(/^the DynamoDB table should have an object with key values:$/) do |string| 61 | data = JSON.parse(string) 62 | key = {} 63 | data.each do |row| 64 | attribute, value = row 65 | key[attribute] = value 66 | end 67 | resp = @client.get_item( 68 | table_name: @table_name, 69 | key: key 70 | ) 71 | expect(resp.item).not_to eq(nil) 72 | end 73 | 74 | Given(/^an item exists in the DynamoDB table with item data:$/) do |string| 75 | data = JSON.parse(string) 76 | @client.put_item( 77 | table_name: @table_name, 78 | item: data 79 | ) 80 | end 81 | 82 | Then(/^the DynamoDB table should not have an object with key values:$/) do |string| 83 | data = JSON.parse(string) 84 | key = {} 85 | data.each do |row| 86 | attribute, value = row 87 | key[attribute] = value 88 | end 89 | resp = @client.get_item( 90 | table_name: @table_name, 91 | key: key 92 | ) 93 | expect(resp.item).to eq(nil) 94 | end 95 | 96 | Given(/^an aws-record model with data:$/) do |string| 97 | data = JSON.parse(string) 98 | @model = Class.new do 99 | include(Aws::Record) 100 | end 101 | @model.configure_client(client: @client) 102 | @table_name ||= "test_table_#{SecureRandom.uuid}" 103 | @model.set_table_name(@table_name) 104 | data.each do |row| 105 | opts = {} 106 | opts[:database_attribute_name] = row['database_name'] 107 | opts[:hash_key] = row['hash_key'] 108 | opts[:range_key] = row['range_key'] 109 | @model.send(:"#{row['method']}", row['name'].to_sym, opts) 110 | end 111 | end 112 | 113 | Then(/^calling "([^"]*)" on the model should return:$/) do |method, retval| 114 | expected = JSON.parse(retval, symbolize_names: true) 115 | expect(@model.send(method)).to eq(expected) 116 | end 117 | 118 | Given(/^an aws-record model with definition:$/) do |string| 119 | @model = Class.new do 120 | include(Aws::Record) 121 | end 122 | @table_name ||= "test_table_#{SecureRandom.uuid}" 123 | @model.set_table_name(@table_name) 124 | @model.class_eval(string) 125 | end 126 | 127 | Then(/^the DynamoDB table should have exactly the following item attributes:$/) do |string| 128 | data = JSON.parse(string) 129 | key = {} 130 | data['key'].each do |row| 131 | attribute, value = row 132 | key[attribute] = value 133 | end 134 | resp = @client.get_item( 135 | table_name: @table_name, 136 | key: key 137 | ) 138 | expect(resp.item.keys.sort).to eq(data['item'].keys.sort) 139 | data['item'].each do |k, v| 140 | expect(resp.item[k]).to eq(v) 141 | end 142 | end 143 | -------------------------------------------------------------------------------- /features/table_config/step_definitions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Given(/^a TableConfig of:$/) do |code_block| 4 | TableConfigTestModel = @model # rubocop:disable Naming/ConstantName 5 | @table_config = eval(code_block) 6 | end 7 | 8 | When(/^we migrate the TableConfig$/) do 9 | @table_config.migrate! 10 | end 11 | 12 | Then(/^the TableConfig should be compatible with the remote table$/) do 13 | expect(@table_config.compatible?).to be_truthy 14 | end 15 | 16 | Then(/^the TableConfig should be an exact match with the remote table$/) do 17 | expect(@table_config.exact_match?).to be_truthy 18 | end 19 | 20 | Then(/^the TableConfig should not be compatible with the remote table$/) do 21 | expect(@table_config.compatible?).to be_falsy 22 | end 23 | 24 | Given(/^we add a global secondary index to the model with definition:$/) do |gsi| 25 | index_name, opts = eval(gsi) 26 | @model.global_secondary_index(index_name, opts) 27 | end 28 | -------------------------------------------------------------------------------- /features/transactions/step_definitions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | When('we make a global transact_find call with parameters:') do |param_block| 4 | params = eval(param_block) 5 | @transact_get_result = Aws::Record::Transactions.transact_find(params) 6 | end 7 | 8 | When('we run the following transactional find:') do |code| 9 | @transact_get_result = eval(code) 10 | end 11 | 12 | Then('we expect a transact_find result that includes the following items:') do |result_block| 13 | tfind_result = eval(result_block) 14 | expected = tfind_result.map do |item| 15 | if item.nil? 16 | nil 17 | else 18 | item.to_h 19 | end 20 | end 21 | actual = @transact_get_result.responses.map do |item| 22 | if item.nil? 23 | nil 24 | else 25 | item.to_h 26 | end 27 | end 28 | expect(expected).to eq(actual) 29 | end 30 | 31 | When('we run the following code:') do |code| 32 | @arbitrary_code_ret = eval(code) 33 | rescue StandardError => e 34 | @arbitrary_code_exception = e 35 | end 36 | 37 | Then('we expect the code to raise an {string} exception') do |exception_class| 38 | expect(@arbitrary_code_exception.class).to eq(Kernel.const_get(exception_class)) 39 | end 40 | -------------------------------------------------------------------------------- /features/transactions/transactions.feature: -------------------------------------------------------------------------------- 1 | # language: en 2 | 3 | @dynamodb @transactions 4 | Feature: Amazon DynamoDB Transactions 5 | This feature tests the ability to use the transactional get and write item 6 | APIs via aws-record. To run these tests, you will need to have valid AWS 7 | credentials that are accessible with the AWS SDK for Ruby's standard 8 | credential provider chain. In practice, this means a shared credential file 9 | or environment variables with your credentials. These tests may have some AWS 10 | costs associated with running them since AWS resources are created and 11 | destroyed within these tests. 12 | 13 | Background: 14 | Given an aws-record model with definition: 15 | """ 16 | string_attr :uuid, hash_key: true 17 | string_attr :body 18 | string_attr :field 19 | """ 20 | And a TableConfig of: 21 | """ 22 | Aws::Record::TableConfig.define do |t| 23 | t.model_class(TableConfigTestModel) 24 | t.billing_mode("PAY_PER_REQUEST") 25 | t.client_options(region: "us-east-1") 26 | end 27 | """ 28 | When we migrate the TableConfig 29 | Then eventually the table should exist in DynamoDB 30 | And the TableConfig should be an exact match with the remote table 31 | Given an item exists in the DynamoDB table with item data: 32 | """ 33 | { 34 | "uuid": "a1", 35 | "body": "First item!", 36 | "field": "Foo" 37 | } 38 | """ 39 | And an item exists in the DynamoDB table with item data: 40 | """ 41 | { 42 | "uuid": "b2", 43 | "body": "Lorem ipsum.", 44 | "field": "Bar" 45 | } 46 | """ 47 | 48 | @transact_find @global_transact_find 49 | Scenario: Get two items in a transaction (global) 50 | When we make a global transact_find call with parameters: 51 | """ 52 | { 53 | transact_items: [ 54 | TableConfigTestModel.tfind_opts(key: { uuid: "a1"}), 55 | TableConfigTestModel.tfind_opts( 56 | key: { uuid: "b2" }, 57 | projection_expression: "#H, body", 58 | expression_attribute_names: { 59 | "#H" => "uuid" 60 | } 61 | ), 62 | ], 63 | return_consumed_capacity: "NONE" 64 | } 65 | """ 66 | Then we expect a transact_find result that includes the following items: 67 | """ 68 | [ 69 | { uuid: "a1", body: "First item!", field: "Foo" }, 70 | { uuid: "b2", body: "Lorem ipsum." }, 71 | ] 72 | """ 73 | 74 | @transact_find @global_transact_find 75 | Scenario: Get two items in a transaction plus one missing (global) 76 | When we make a global transact_find call with parameters: 77 | """ 78 | { 79 | transact_items: [ 80 | TableConfigTestModel.tfind_opts(key: {uuid: "a1"}), 81 | TableConfigTestModel.tfind_opts(key: {uuid: "nope"}), 82 | TableConfigTestModel.tfind_opts(key: {uuid: "b2"}), 83 | ] 84 | } 85 | """ 86 | Then we expect a transact_find result that includes the following items: 87 | """ 88 | [ 89 | { uuid: "a1", body: "First item!", field: "Foo" }, 90 | nil, 91 | { uuid: "b2", body: "Lorem ipsum.", field: "Bar" }, 92 | ] 93 | """ 94 | 95 | @transact_find @class_transact_find 96 | Scenario: Get two items in a transaction plus one missing (class) 97 | When we run the following transactional find: 98 | """ 99 | TableConfigTestModel.transact_find( 100 | transact_items: [ 101 | {key: {uuid: "a1"}}, 102 | {key: {uuid: "nope"}}, 103 | {key: {uuid: "b2"}}, 104 | ] 105 | ) 106 | """ 107 | Then we expect a transact_find result that includes the following items: 108 | """ 109 | [ 110 | { uuid: "a1", body: "First item!", field: "Foo" }, 111 | nil, 112 | { uuid: "b2", body: "Lorem ipsum.", field: "Bar" }, 113 | ] 114 | """ 115 | 116 | @transact_write @global_transact_write 117 | Scenario: Perform a transactional update (global) 118 | When we run the following code: 119 | """ 120 | item1 = TableConfigTestModel.find(uuid: "a1") 121 | item1.body = "Updated a1!" 122 | item2 = TableConfigTestModel.find(uuid: "b2") 123 | item3 = TableConfigTestModel.new(uuid: "c3", body: "New item!") 124 | Aws::Record::Transactions.transact_write( 125 | transact_items: [ 126 | { save: item1 }, 127 | { save: item3 }, 128 | { delete: item2 } 129 | ] 130 | ) 131 | """ 132 | Then the DynamoDB table should not have an object with key values: 133 | """ 134 | [ 135 | ["uuid", "b2"] 136 | ] 137 | """ 138 | When we call the 'find' class method with parameter data: 139 | """ 140 | { 141 | "uuid": "a1" 142 | } 143 | """ 144 | Then we should receive an aws-record item with attribute data: 145 | """ 146 | { 147 | "uuid": "a1", 148 | "body": "Updated a1!", 149 | "field": "Foo" 150 | } 151 | """ 152 | When we call the 'find' class method with parameter data: 153 | """ 154 | { 155 | "uuid": "c3" 156 | } 157 | """ 158 | Then we should receive an aws-record item with attribute data: 159 | """ 160 | { 161 | "uuid": "c3", 162 | "body": "New item!" 163 | } 164 | """ 165 | 166 | @transact_write @global_transact_write 167 | Scenario: Perform a transactional set of puts and updates (global) 168 | When we run the following code: 169 | """ 170 | item1 = TableConfigTestModel.new(uuid: "a1", body: "Replaced!") 171 | item2 = TableConfigTestModel.find(uuid: "b2") 172 | item2.body = "Updated b2!" 173 | item3 = TableConfigTestModel.new(uuid: "c3", body: "New item!") 174 | Aws::Record::Transactions.transact_write( 175 | transact_items: [ 176 | { put: item1 }, 177 | { put: item3 }, 178 | { update: item2 } 179 | ] 180 | ) 181 | """ 182 | When we call the 'find' class method with parameter data: 183 | """ 184 | { 185 | "uuid": "a1" 186 | } 187 | """ 188 | Then we should receive an aws-record item with attribute data: 189 | """ 190 | { 191 | "uuid": "a1", 192 | "body": "Replaced!" 193 | } 194 | """ 195 | When we call the 'find' class method with parameter data: 196 | """ 197 | { 198 | "uuid": "b2" 199 | } 200 | """ 201 | Then we should receive an aws-record item with attribute data: 202 | """ 203 | { 204 | "uuid": "b2", 205 | "body": "Updated b2!", 206 | "field": "Bar" 207 | } 208 | """ 209 | When we call the 'find' class method with parameter data: 210 | """ 211 | { 212 | "uuid": "c3" 213 | } 214 | """ 215 | Then we should receive an aws-record item with attribute data: 216 | """ 217 | { 218 | "uuid": "c3", 219 | "body": "New item!" 220 | } 221 | """ 222 | 223 | @transact_write @global_transact_write 224 | Scenario: Perform a transactional update with check (global) 225 | When we run the following code: 226 | """ 227 | item1 = TableConfigTestModel.find(uuid: "a1") 228 | item1.body = "Passing the check!" 229 | check_exp = TableConfigTestModel.transact_check_expression( 230 | key: { uuid: "b2" }, 231 | condition_expression: "size(#T) <= :v", 232 | expression_attribute_names: { 233 | "#T" => "body" 234 | }, 235 | expression_attribute_values: { 236 | ":v" => 1024 237 | } 238 | ) 239 | Aws::Record::Transactions.transact_write( 240 | transact_items: [ 241 | { save: item1 }, 242 | { check: check_exp } 243 | ] 244 | ) 245 | """ 246 | When we call the 'find' class method with parameter data: 247 | """ 248 | { 249 | "uuid": "a1" 250 | } 251 | """ 252 | Then we should receive an aws-record item with attribute data: 253 | """ 254 | { 255 | "uuid": "a1", 256 | "body": "Passing the check!", 257 | "field": "Foo" 258 | } 259 | """ 260 | 261 | @transact_write @global_transact_write 262 | Scenario: Perform a transactional update in error (global) 263 | When we run the following code: 264 | """ 265 | item1 = TableConfigTestModel.new(uuid: "a1", body: "Replaced!") 266 | item2 = TableConfigTestModel.new(uuid: "b2", body: "Sneaky replacement!") 267 | item3 = TableConfigTestModel.new(uuid: "c3", body: "New item!") 268 | Aws::Record::Transactions.transact_write( 269 | transact_items: [ 270 | { save: item1 }, 271 | { save: item2 }, 272 | { save: item3 } 273 | ] 274 | ) 275 | """ 276 | Then we expect the code to raise an 'Aws::DynamoDB::Errors::TransactionCanceledException' exception 277 | -------------------------------------------------------------------------------- /lib/aws-record.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'aws-sdk-dynamodb' 4 | require_relative 'aws-record/record/client_configuration' 5 | require_relative 'aws-record/record' 6 | require_relative 'aws-record/record/attribute' 7 | require_relative 'aws-record/record/attributes' 8 | require_relative 'aws-record/record/dirty_tracking' 9 | require_relative 'aws-record/record/errors' 10 | require_relative 'aws-record/record/item_collection' 11 | require_relative 'aws-record/record/item_data' 12 | require_relative 'aws-record/record/item_operations' 13 | require_relative 'aws-record/record/key_attributes' 14 | require_relative 'aws-record/record/model_attributes' 15 | require_relative 'aws-record/record/query' 16 | require_relative 'aws-record/record/secondary_indexes' 17 | require_relative 'aws-record/record/table_config' 18 | require_relative 'aws-record/record/table_migration' 19 | require_relative 'aws-record/record/version' 20 | require_relative 'aws-record/record/transactions' 21 | require_relative 'aws-record/record/buildable_search' 22 | require_relative 'aws-record/record/batch_read' 23 | require_relative 'aws-record/record/batch_write' 24 | require_relative 'aws-record/record/batch' 25 | require_relative 'aws-record/record/marshalers/string_marshaler' 26 | require_relative 'aws-record/record/marshalers/boolean_marshaler' 27 | require_relative 'aws-record/record/marshalers/integer_marshaler' 28 | require_relative 'aws-record/record/marshalers/float_marshaler' 29 | require_relative 'aws-record/record/marshalers/date_marshaler' 30 | require_relative 'aws-record/record/marshalers/date_time_marshaler' 31 | require_relative 'aws-record/record/marshalers/time_marshaler' 32 | require_relative 'aws-record/record/marshalers/epoch_time_marshaler' 33 | require_relative 'aws-record/record/marshalers/list_marshaler' 34 | require_relative 'aws-record/record/marshalers/map_marshaler' 35 | require_relative 'aws-record/record/marshalers/string_set_marshaler' 36 | require_relative 'aws-record/record/marshalers/numeric_set_marshaler' 37 | 38 | module Aws 39 | module Record 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/aws-record/record/attribute.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Aws 4 | module Record 5 | # This class provides helper methods for +Aws::Record+ attributes. These 6 | # include marshalers for type casting of item attributes, the Amazon 7 | # DynamoDB type for use in certain table and item operation calls, and the 8 | # ability to define a database name that is separate from the name used 9 | # within the model class and item instances. 10 | class Attribute 11 | attr_reader :name, :database_name, :dynamodb_type 12 | 13 | # @param [Symbol] name Name of the attribute. It should be a name that is 14 | # safe to use as a method. 15 | # @param [Hash] options 16 | # @option options [Marshaler] :marshaler The marshaler for this attribute. 17 | # So long as you provide a marshaler which implements +#type_cast+ and 18 | # +#serialize+ that consume raw values as expected, you can bring your 19 | # own marshaler type. 20 | # @option options [String] :database_attribute_name Optional attribute 21 | # used to specify a different name for database persistence than the 22 | # `name` parameter. Must be unique (you can't have overlap between 23 | # database attribute names and the names of other attributes). 24 | # @option options [String] :dynamodb_type Generally used for keys and 25 | # index attributes, one of "S", "N", "B", "BOOL", "SS", "NS", "BS", 26 | # "M", "L". Optional if this attribute will never be used for a key or 27 | # secondary index, but most convenience methods for setting attributes 28 | # will provide this. 29 | # @option options [Boolean] :persist_nil Optional attribute used to 30 | # indicate whether nil values should be persisted. If true, explicitly 31 | # set nil values will be saved to DynamoDB as a "null" type. If false, 32 | # nil values will be ignored and not persisted. By default, is false. 33 | # @option options [Object] :default_value Optional attribute used to 34 | # define a "default value" to be used if the attribute's value on an 35 | # item is nil or not set at persistence time. Additionally, lambda can 36 | # be used as a default value. 37 | def initialize(name, options = {}) 38 | @name = name 39 | @database_name = (options[:database_attribute_name] || name).to_s 40 | @dynamodb_type = options[:dynamodb_type] 41 | @marshaler = options[:marshaler] || DefaultMarshaler 42 | @persist_nil = options[:persist_nil] 43 | @default_value_or_lambda = if options.key?(:default_value) 44 | dv = options[:default_value] 45 | _is_lambda?(dv) ? dv : type_cast(dv) 46 | end 47 | end 48 | 49 | # Attempts to type cast a raw value into the attribute's type. This call 50 | # will forward the raw value to this attribute's marshaler class. 51 | # 52 | # @return [Object] the type cast object. Return type is dependent on the 53 | # marshaler used. See your attribute's marshaler class for details. 54 | def type_cast(raw_value) 55 | cast_value = @marshaler.type_cast(raw_value) 56 | cast_value = default_value if cast_value.nil? 57 | cast_value 58 | end 59 | 60 | # Attempts to serialize a raw value into the attribute's serialized 61 | # storage type. This call will forward the raw value to this attribute's 62 | # marshaler class. 63 | # 64 | # @return [Object] the serialized object. Return type is dependent on the 65 | # marshaler used. See your attribute's marshaler class for details. 66 | def serialize(raw_value) 67 | cast_value = type_cast(raw_value) 68 | cast_value = default_value if cast_value.nil? 69 | @marshaler.serialize(cast_value) 70 | end 71 | 72 | # @return [Boolean] +true+ if this attribute will actively persist nil 73 | # values, +false+ otherwise. Default: +false+ 74 | def persist_nil? 75 | @persist_nil ? true : false 76 | end 77 | 78 | # @api private 79 | def extract(dynamodb_item) 80 | dynamodb_item[@database_name] 81 | end 82 | 83 | # @api private 84 | def default_value 85 | if _is_lambda?(@default_value_or_lambda) 86 | type_cast(@default_value_or_lambda.call) 87 | else 88 | _deep_copy(@default_value_or_lambda) 89 | end 90 | end 91 | 92 | private 93 | 94 | def _deep_copy(obj) 95 | Marshal.load(Marshal.dump(obj)) 96 | end 97 | 98 | def _is_lambda?(obj) 99 | obj.respond_to?(:call) 100 | end 101 | end 102 | 103 | # This is an identity marshaler, which performs no changes for type casting 104 | # or serialization. It is generally not recommended for use. 105 | module DefaultMarshaler 106 | def self.type_cast(raw_value, _options = {}) 107 | raw_value 108 | end 109 | 110 | def self.serialize(raw_value, _options = {}) 111 | raw_value 112 | end 113 | end 114 | end 115 | end 116 | -------------------------------------------------------------------------------- /lib/aws-record/record/batch.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Aws 4 | module Record 5 | class Batch 6 | extend ClientConfiguration 7 | 8 | class << self 9 | # Provides a thin wrapper to the 10 | # {https://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/DynamoDB/Client.html#batch_write_item-instance_method 11 | # Aws::DynamoDB::Client#batch_write_item} method. Up to 25 +PutItem+ or +DeleteItem+ 12 | # operations are supported. A single request may write up to 16 MB of data, with each 13 | # item having a write limit of 400 KB. 14 | # 15 | # *Note*: this operation does not support dirty attribute handling, 16 | # nor does it enforce safe write operations (i.e. update vs new record 17 | # checks). 18 | # 19 | # This call may partially execute write operations. Failed operations 20 | # are returned as {BatchWrite.unprocessed_items unprocessed_items} (i.e. the 21 | # table fails to meet requested write capacity). Any unprocessed 22 | # items may be retried by calling {BatchWrite.execute! .execute!} 23 | # again. You can determine if the request needs to be retried by calling 24 | # the {BatchWrite.complete? .complete?} method - which returns +true+ 25 | # when all operations have been completed. 26 | # 27 | # Please see 28 | # {https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Programming.Errors.html#Programming.Errors.BatchOperations 29 | # Batch Operations and Error Handling} in the DynamoDB Developer Guide for 30 | # more details. 31 | # 32 | # @example Usage Example 33 | # class Breakfast 34 | # include Aws::Record 35 | # integer_attr :id, hash_key: true 36 | # string_attr :name, range_key: true 37 | # string_attr :body 38 | # end 39 | # 40 | # # setup 41 | # eggs = Breakfast.new(id: 1, name: "eggs").save! 42 | # waffles = Breakfast.new(id: 2, name: "waffles") 43 | # pancakes = Breakfast.new(id: 3, name: "pancakes") 44 | # 45 | # # batch operations 46 | # operation = Aws::Record::Batch.write(client: Breakfast.dynamodb_client) do |db| 47 | # db.put(waffles) 48 | # db.delete(eggs) 49 | # db.put(pancakes) 50 | # end 51 | # 52 | # # unprocessed items can be retried by calling Aws::Record::BatchWrite#execute! 53 | # operation.execute! until operation.complete? 54 | # 55 | # @param [Hash] opts the options you wish to use to create the client. 56 | # Note that if you include the option +:client+, all other options 57 | # will be ignored. See the documentation for other options in the 58 | # {https://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/DynamoDB/Client.html#initialize-instance_method 59 | # AWS SDK for Ruby}. 60 | # @option opts [Aws::DynamoDB::Client] :client allows you to pass in your 61 | # own pre-configured client. 62 | # 63 | # @return [Aws::Record::BatchWrite] An instance that contains any 64 | # unprocessed items and allows for a retry strategy. 65 | def write(opts = {}) 66 | batch = BatchWrite.new(client: _build_client(opts)) 67 | yield(batch) 68 | batch.execute! 69 | end 70 | 71 | # Provides support for the 72 | # {https://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/DynamoDB/Client.html#batch_get_item-instance_method 73 | # Aws::DynamoDB::Client#batch_get_item} for aws-record models. 74 | # 75 | # +Aws::Record::Batch+ is Enumerable and using Enumerable methods will handle 76 | # paging through all requested keys automatically. Alternatively, a lower level 77 | # interface is available. You can determine if there are any unprocessed keys by calling 78 | # {BatchRead.complete? .complete?} and any unprocessed keys can be processed by 79 | # calling {BatchRead.execute! .execute!}. You can access all processed items 80 | # through {BatchRead.items .items}. 81 | # 82 | # The +batch_get_item+ supports up to 100 operations in a single call and a single 83 | # operation can retrieve up to 16 MB of data. 84 | # 85 | # +Aws::Record::BatchRead+ can take more than 100 item keys. The first 100 requests 86 | # will be processed and the remaining requests will be stored. 87 | # When using Enumerable methods, any pending item keys will be automatically 88 | # processed and the new items will be added to +items+. 89 | # Alternately, use {BatchRead.execute! .execute!} to process any pending item keys. 90 | # 91 | # All processed operations can be accessed by {BatchRead.items items} - which is an 92 | # array of modeled items from the response. The items will be unordered since 93 | # DynamoDB does not return items in any particular order. 94 | # 95 | # If a requested item does not exist in the database, it is not returned in the response. 96 | # 97 | # If there is a returned item from the call and there's no reference model class 98 | # to be found, the item will not show up under +items+. 99 | # 100 | # @example Usage Example 101 | # class Lunch 102 | # include Aws::Record 103 | # integer_attr :id, hash_key: true 104 | # string_attr :name, range_key: true 105 | # end 106 | # 107 | # class Dessert 108 | # include Aws::Record 109 | # integer_attr :id, hash_key: true 110 | # string_attr :name, range_key: true 111 | # end 112 | # 113 | # # batch operations 114 | # operation = Aws::Record::Batch.read do |db| 115 | # db.find(Lunch, id: 1, name: 'Papaya Salad') 116 | # db.find(Lunch, id: 2, name: 'BLT Sandwich') 117 | # db.find(Dessert, id: 1, name: 'Apple Pie') 118 | # end 119 | # 120 | # # BatchRead is enumerable and handles pagination 121 | # operation.each { |item| item.id } 122 | # 123 | # # Alternatively, BatchRead provides a lower level 124 | # # interface through: execute!, complete? and items. 125 | # # Unprocessed items can be processed by calling: 126 | # operation.execute! until operation.complete? 127 | # 128 | # @param [Hash] opts the options you wish to use to create the client. 129 | # Note that if you include the option +:client+, all other options 130 | # will be ignored. See the documentation for other options in the 131 | # {https://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/DynamoDB/Client.html#initialize-instance_method 132 | # AWS SDK for Ruby}. 133 | # @option opts [Aws::DynamoDB::Client] :client allows you to pass in your 134 | # own pre-configured client. 135 | # @return [Aws::Record::BatchRead] An instance that contains modeled items 136 | # from the +BatchGetItem+ result and stores unprocessed keys to be 137 | # manually processed later. 138 | def read(opts = {}) 139 | batch = BatchRead.new(client: _build_client(opts)) 140 | yield(batch) 141 | batch.execute! 142 | batch 143 | end 144 | end 145 | end 146 | end 147 | end 148 | -------------------------------------------------------------------------------- /lib/aws-record/record/batch_read.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Aws 4 | module Record 5 | class BatchRead 6 | include Enumerable 7 | 8 | # @api private 9 | BATCH_GET_ITEM_LIMIT = 100 10 | 11 | # @param [Hash] opts 12 | # @option opts [Aws::DynamoDB::Client] client the DynamoDB SDK client. 13 | def initialize(opts = {}) 14 | @client = opts[:client] 15 | end 16 | 17 | # Append the item keys to a batch read request. 18 | # 19 | # See {Batch.read} for example usage. 20 | # @param [Aws::Record] klass a model class that includes {Aws::Record} 21 | # @param [Hash] key attribute-value pairs for the key you wish to search for. 22 | # @raise [Aws::Record::Errors::KeyMissing] if your option parameters 23 | # do not include all item keys defined in the model. 24 | # @raise [ArgumentError] if the provided item keys is a duplicate request 25 | # in the same instance. 26 | def find(klass, key = {}) 27 | unprocessed_key = format_unprocessed_key(klass, key) 28 | store_unprocessed_key(klass, unprocessed_key) 29 | store_item_class(klass, unprocessed_key) 30 | end 31 | 32 | # Perform a +batch_get_item+ request. 33 | # 34 | # This method processes the first 100 item keys and 35 | # returns an array of new modeled items. 36 | # 37 | # See {Batch.read} for example usage. 38 | # @return [Array] an array of unordered new items 39 | def execute! 40 | operation_keys = unprocessed_keys[0..BATCH_GET_ITEM_LIMIT - 1] 41 | @unprocessed_keys = unprocessed_keys[BATCH_GET_ITEM_LIMIT..] || [] 42 | 43 | operations = build_operations(operation_keys) 44 | result = @client.batch_get_item(request_items: operations) 45 | new_items = build_items(result.responses) 46 | items.concat(new_items) 47 | 48 | update_unprocessed_keys(result.unprocessed_keys) unless result.unprocessed_keys.nil? 49 | 50 | new_items 51 | end 52 | 53 | # Provides an enumeration of the results from the +batch_get_item+ request 54 | # and handles pagination. 55 | # 56 | # Any pending item keys will be automatically processed and be 57 | # added to the {#items}. 58 | # 59 | # See {Batch.read} for example usage. 60 | # @yieldparam [Aws::Record] item a modeled item 61 | # @return [Enumerable] an enumeration over the results of 62 | # +batch_get_item+ request. 63 | def each(&block) 64 | return enum_for(:each) unless block_given? 65 | 66 | @items.each(&block) 67 | 68 | until complete? 69 | new_items = execute! 70 | new_items.each(&block) 71 | end 72 | end 73 | 74 | # Indicates if all item keys have been processed. 75 | # 76 | # See {Batch.read} for example usage. 77 | # @return [Boolean] +true+ if all item keys has been processed, +false+ otherwise. 78 | def complete? 79 | unprocessed_keys.none? 80 | end 81 | 82 | # Returns an array of modeled items. The items are marshaled into classes used in {#find} method. 83 | # These items will be unordered since DynamoDB does not return items in any particular order. 84 | # 85 | # See {Batch.read} for example usage. 86 | # @return [Array] an array of modeled items from the +batch_get_item+ call. 87 | def items 88 | @items ||= [] 89 | end 90 | 91 | private 92 | 93 | def unprocessed_keys 94 | @unprocessed_keys ||= [] 95 | end 96 | 97 | def item_classes 98 | @item_classes ||= Hash.new { |h, k| h[k] = [] } 99 | end 100 | 101 | def format_unprocessed_key(klass, key) 102 | item_key = {} 103 | attributes = klass.attributes 104 | klass.keys.each_value do |attr_sym| 105 | raise Errors::KeyMissing, "Missing required key #{attr_sym} in #{key}" unless key[attr_sym] 106 | 107 | attr_name = attributes.storage_name_for(attr_sym) 108 | item_key[attr_name] = attributes.attribute_for(attr_sym) 109 | .serialize(key[attr_sym]) 110 | end 111 | item_key 112 | end 113 | 114 | def store_unprocessed_key(klass, unprocessed_key) 115 | unprocessed_keys << { keys: unprocessed_key, table_name: klass.table_name } 116 | end 117 | 118 | def store_item_class(klass, key) 119 | if item_classes.include?(klass.table_name) 120 | item_classes[klass.table_name].each do |item| 121 | if item[:keys] == key && item[:class] != klass 122 | raise ArgumentError, 'Provided item keys is a duplicate request' 123 | end 124 | end 125 | end 126 | item_classes[klass.table_name] << { keys: key, class: klass } 127 | end 128 | 129 | def build_operations(keys) 130 | operations = Hash.new { |h, k| h[k] = { keys: [] } } 131 | keys.each do |key| 132 | operations[key[:table_name]][:keys] << key[:keys] 133 | end 134 | operations 135 | end 136 | 137 | def build_items(item_responses) 138 | new_items = [] 139 | item_responses.each do |table, unprocessed_items| 140 | unprocessed_items.each do |item| 141 | item_class = find_item_class(table, item) 142 | if item_class.nil? && @client.config.logger 143 | @client.config.logger.warn( 144 | 'Unexpected response from service.' \ 145 | "Received: #{item}. Skipping above item and continuing" 146 | ) 147 | else 148 | new_items << build_item(item, item_class) 149 | end 150 | end 151 | end 152 | new_items 153 | end 154 | 155 | def update_unprocessed_keys(keys) 156 | keys.each do |table_name, table_values| 157 | table_values.keys.each do |key| # rubocop:disable Style/HashEachMethods 158 | unprocessed_keys << { keys: key, table_name: table_name } 159 | end 160 | end 161 | end 162 | 163 | def find_item_class(table, item) 164 | selected_item = item_classes[table].find { |item_info| contains_keys?(item, item_info[:keys]) } 165 | selected_item[:class] if selected_item 166 | end 167 | 168 | def contains_keys?(item, keys) 169 | item.merge(keys) == item 170 | end 171 | 172 | def build_item(item, item_class) 173 | new_item_opts = {} 174 | item.each do |db_name, value| 175 | name = item_class.attributes.db_to_attribute_name(db_name) 176 | 177 | next unless name 178 | 179 | new_item_opts[name] = value 180 | end 181 | item = item_class.new(new_item_opts) 182 | item.clean! 183 | item 184 | end 185 | end 186 | end 187 | end 188 | -------------------------------------------------------------------------------- /lib/aws-record/record/batch_write.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Aws 4 | module Record 5 | class BatchWrite 6 | # @param [Hash] opts 7 | # @option opts [Aws::DynamoDB::Client] client the DynamoDB SDK client. 8 | def initialize(opts = {}) 9 | @client = opts[:client] 10 | end 11 | 12 | # Append a +PutItem+ operation to a batch write request. 13 | # 14 | # See {Batch.write} for example usage. 15 | # 16 | # @param [Aws::Record] record a model class that includes {Aws::Record}. 17 | def put(record) 18 | table_name, params = record_put_params(record) 19 | operations[table_name] ||= [] 20 | operations[table_name] << { put_request: params } 21 | end 22 | 23 | # Append a +DeleteItem+ operation to a batch write request. 24 | # 25 | # See {Batch.write} for example usage. 26 | # @param [Aws::Record] record a model class that includes {Aws::Record}. 27 | def delete(record) 28 | table_name, params = record_delete_params(record) 29 | operations[table_name] ||= [] 30 | operations[table_name] << { delete_request: params } 31 | end 32 | 33 | # Perform a +batch_write_item+ request. 34 | # 35 | # See {Batch.write} for example usage. 36 | # @return [Aws::Record::BatchWrite] an instance that provides access to 37 | # unprocessed items and allows for retries. 38 | def execute! 39 | result = @client.batch_write_item(request_items: operations) 40 | @operations = result.unprocessed_items 41 | self 42 | end 43 | 44 | # Indicates if all items have been processed. 45 | # 46 | # See {Batch.write} for example usage. 47 | # @return [Boolean] +true+ if +unprocessed_items+ is empty, +false+ 48 | # otherwise 49 | def complete? 50 | unprocessed_items.values.none? 51 | end 52 | 53 | # Returns all +DeleteItem+ and +PutItem+ operations that have not yet been 54 | # processed successfully. 55 | # 56 | # See {Batch.write} for example usage. 57 | # @return [Hash] All operations that have not yet successfully completed. 58 | def unprocessed_items 59 | operations 60 | end 61 | 62 | private 63 | 64 | def operations 65 | @operations ||= {} 66 | end 67 | 68 | def record_delete_params(record) 69 | [record.class.table_name, { key: record.key_values }] 70 | end 71 | 72 | def record_put_params(record) 73 | [record.class.table_name, { item: record.save_values }] 74 | end 75 | end 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /lib/aws-record/record/client_configuration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Aws 4 | module Record 5 | module ClientConfiguration 6 | # Configures the Amazon DynamoDB client used by this class and all 7 | # instances of this class. 8 | # 9 | # Please note that this method is also called internally when you first 10 | # attempt to perform an operation against the remote end, if you have not 11 | # already configured a client. As such, please read and understand the 12 | # documentation in the AWS SDK for Ruby around 13 | # {http://docs.aws.amazon.com/sdk-for-ruby/v3/api/index.html#Configuration configuration} 14 | # to ensure you understand how default configuration behavior works. When 15 | # in doubt, call this method to ensure your client is configured the way 16 | # you want it to be configured. 17 | # 18 | # *Note*: {#dynamodb_client} is inherited from a parent model when 19 | # +configure_client+ is explicitly specified in the parent. 20 | # @param [Hash] opts the options you wish to use to create the client. 21 | # Note that if you include the option +:client+, all other options 22 | # will be ignored. See the documentation for other options in the 23 | # {https://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/DynamoDB/Client.html#initialize-instance_method 24 | # AWS SDK for Ruby}. 25 | # @option opts [Aws::DynamoDB::Client] :client allows you to pass in your 26 | # own pre-configured client. 27 | def configure_client(opts = {}) 28 | # rubocop:disable Style/RedundantSelf 29 | @dynamodb_client = if self.class != Module && Aws::Record.extends_record?(self) && opts.empty? && 30 | self.superclass.instance_variable_get('@dynamodb_client') 31 | self.superclass.instance_variable_get('@dynamodb_client') 32 | else 33 | _build_client(opts) 34 | end 35 | # rubocop:enable Style/RedundantSelf 36 | end 37 | 38 | # Gets the 39 | # {https://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/DynamoDB/Client.html Client} 40 | # instance that Transactions use. When called for the first time, if 41 | # {#configure_client} has not yet been called, will configure a new 42 | # client for you with default parameters. 43 | # 44 | # *Note*: +dynamodb_client+ is inherited from a parent model when 45 | # {configure_client} is explicitly specified in the parent. 46 | # 47 | # @return [Aws::DynamoDB::Client] the Amazon DynamoDB client instance. 48 | def dynamodb_client 49 | @dynamodb_client ||= configure_client 50 | end 51 | 52 | private 53 | 54 | def _build_client(opts = {}) 55 | provided_client = opts.delete(:client) 56 | client = provided_client || Aws::DynamoDB::Client.new(opts) 57 | client.config.user_agent_frameworks << 'aws-record' 58 | client 59 | end 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/aws-record/record/errors.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Aws 4 | module Record 5 | module Errors 6 | # RecordErrors relate to the persistence of items. They include both 7 | # client errors and certain validation errors. 8 | class RecordError < RuntimeError; end 9 | 10 | # Raised when a required key attribute is missing from an item when 11 | # persistence is attempted. 12 | class KeyMissing < RecordError; end 13 | 14 | # Raised when you attempt to load a record from the database, but it does 15 | # not exist there. 16 | class NotFound < RecordError; end 17 | 18 | # Raised when a conditional write fails. 19 | # Provides access to the original ConditionalCheckFailedException error 20 | # which may have item data if the return values option was used. 21 | class ConditionalWriteFailed < RecordError 22 | def initialize(message, original_error) 23 | @original_error = original_error 24 | super(message) 25 | end 26 | 27 | # @return [Aws::DynamoDB::Errors::ConditionalCheckFailedException] 28 | attr_reader :original_error 29 | end 30 | 31 | # Raised when a validation hook call to +:valid?+ fails. 32 | class ValidationError < RecordError; end 33 | 34 | # Raised when an attribute is defined that has a name collision with an 35 | # existing attribute. 36 | class NameCollision < RuntimeError; end 37 | 38 | # Raised when you attempt to create an attribute which has a name that 39 | # conflicts with reserved names (generally, defined method names). If you 40 | # see this error, you should change the attribute name in the model. If 41 | # the database uses this name, you can take advantage of the 42 | # +:database_attribute_name+ option in 43 | # {Aws::Record::Attributes::ClassMethods#attr #attr} 44 | class ReservedName < RuntimeError; end 45 | 46 | # Raised when you attempt a table migration and your model class is 47 | # invalid. 48 | class InvalidModel < RuntimeError; end 49 | 50 | # Raised when you attempt update/delete operations on a table that does 51 | # not exist. 52 | class TableDoesNotExist < RuntimeError; end 53 | 54 | class MissingRequiredConfiguration < RuntimeError; end 55 | 56 | # Raised when you attempt to combine your own condition expression with 57 | # the auto-generated condition expression from a "safe put" from saving 58 | # a new item in a transactional write operation. The path forward until 59 | # this case is supported is to use a plain "put" call, and to include 60 | # the key existance check yourself in your condition expression if you 61 | # wish to do so. 62 | class TransactionalSaveConditionCollision < RuntimeError; end 63 | 64 | # Raised when you attempt to combine your own update expression with 65 | # the update expression auto-generated from updates to an item's 66 | # attributes. The path forward until this case is supported is to 67 | # perform attribute updates yourself in your update expression if you 68 | # wish to do so. 69 | class UpdateExpressionCollision < RuntimeError; end 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /lib/aws-record/record/item_collection.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Aws 4 | module Record 5 | class ItemCollection 6 | include Enumerable 7 | 8 | def initialize(search_method, search_params, model, client) 9 | @search_method = search_method 10 | @search_params = search_params 11 | @model_filter = @search_params.delete(:model_filter) 12 | @model = model 13 | @client = client 14 | end 15 | 16 | # Provides an enumeration of the results of a query or scan operation on 17 | # your table, automatically converted into item classes. 18 | # 19 | # WARNING: This will enumerate over your entire partition in the case of 20 | # query, and over your entire table in the case of scan, save for key and 21 | # filter expressions used. This means that enumerable operations that 22 | # iterate over the full result set could make many network calls, or use a 23 | # lot of memory to build response objects. Use with caution. 24 | # 25 | # @return [Enumerable] an enumeration over the results of 26 | # your query or scan request. These results are automatically converted 27 | # into items on your behalf. 28 | def each(&block) 29 | return enum_for(:each) unless block_given? 30 | 31 | items.each_page do |page| 32 | @last_evaluated_key = page.last_evaluated_key 33 | items_array = _build_items_from_response(page.items, @model) 34 | items_array.each(&block) 35 | end 36 | end 37 | 38 | # Provides the first "page" of responses from your query operation. This 39 | # will only make a single client call, and will provide the items, if any 40 | # exist, from that response. It will not attempt to follow up on 41 | # pagination tokens, so this is not guaranteed to include all items that 42 | # match your search. 43 | # 44 | # @return [Array] an array of the record items found in the 45 | # first page of responses from the query or scan call. 46 | def page 47 | search_response = items 48 | @last_evaluated_key = search_response.last_evaluated_key 49 | _build_items_from_response(search_response.items, @model) 50 | end 51 | 52 | # Provides the pagination key most recently used by the underlying client. 53 | # This can be useful in the case where you're exposing pagination to an 54 | # outside caller, and want to be able to "resume" your scan in a new call 55 | # without starting over. 56 | # 57 | # @return [Hash] a hash representing an attribute key/value pair, suitable 58 | # for use as the +exclusive_start_key+ in another query or scan 59 | # operation. If there are no more pages in the result, will be nil. 60 | def last_evaluated_key 61 | @last_evaluated_key 62 | end 63 | 64 | # Checks if the query/scan result is completely blank. 65 | # 66 | # WARNING: This can and will query your entire partition, or scan your 67 | # entire table, if no results are found. Especially if your table is 68 | # large, use this with extreme caution. 69 | # 70 | # @return [Boolean] true if the query/scan result is empty, false 71 | # otherwise. 72 | def empty? 73 | items.each_page do |page| 74 | return false unless page.items.empty? 75 | end 76 | true 77 | end 78 | 79 | private 80 | 81 | def _build_items_from_response(items, model) 82 | ret = [] 83 | items.each do |item| 84 | model_class = @model_filter ? @model_filter.call(item) : model 85 | next unless model_class 86 | 87 | record = model_class.new 88 | data = record.instance_variable_get('@data') 89 | model_class.attributes.attributes.each do |name, attr| 90 | data.set_attribute(name, attr.extract(item)) 91 | end 92 | data.clean! 93 | data.new_record = false 94 | ret << record 95 | end 96 | ret 97 | end 98 | 99 | def items 100 | @items ||= @client.send(@search_method, @search_params) 101 | end 102 | end 103 | end 104 | end 105 | -------------------------------------------------------------------------------- /lib/aws-record/record/item_data.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Aws 4 | module Record 5 | # @api private 6 | class ItemData 7 | def initialize(model_attributes, opts) 8 | @data = {} 9 | @clean_copies = {} 10 | @dirty_flags = {} 11 | @model_attributes = model_attributes 12 | @track_mutations = opts[:track_mutations] 13 | @track_mutations = true if opts[:track_mutations].nil? 14 | @new_record = true 15 | @destroyed = false 16 | 17 | populate_default_values 18 | end 19 | attr_accessor :new_record, :destroyed 20 | 21 | def get_attribute(name) 22 | @model_attributes.attribute_for(name).type_cast(@data[name]) 23 | end 24 | 25 | def set_attribute(name, value) 26 | @data[name] = value 27 | end 28 | 29 | def new_record? 30 | @new_record 31 | end 32 | 33 | def destroyed? 34 | @destroyed 35 | end 36 | 37 | def persisted? 38 | !(new_record? || destroyed?) 39 | end 40 | 41 | def raw_value(name) 42 | @data[name] 43 | end 44 | 45 | def clean! 46 | @dirty_flags = {} 47 | @model_attributes.attributes.each_key do |name| 48 | populate_default_values 49 | value = get_attribute(name) 50 | @clean_copies[name] = if @track_mutations 51 | _deep_copy(value) 52 | else 53 | value 54 | end 55 | end 56 | end 57 | 58 | def attribute_dirty?(name) 59 | if @dirty_flags[name] 60 | true 61 | else 62 | value = get_attribute(name) 63 | value != @clean_copies[name] 64 | end 65 | end 66 | 67 | def attribute_was(name) 68 | @clean_copies[name] 69 | end 70 | 71 | def attribute_dirty!(name) 72 | @dirty_flags[name] = true 73 | end 74 | 75 | def dirty 76 | @model_attributes.attributes.keys.each_with_object([]) do |name, acc| 77 | acc << name if attribute_dirty?(name) 78 | acc 79 | end 80 | end 81 | 82 | def dirty? 83 | !dirty.empty? 84 | end 85 | 86 | def rollback_attribute!(name) 87 | if attribute_dirty?(name) 88 | @dirty_flags.delete(name) 89 | set_attribute(name, attribute_was(name)) 90 | end 91 | get_attribute(name) 92 | end 93 | 94 | def hash_copy 95 | @data.dup 96 | end 97 | 98 | def build_save_hash 99 | @data.each_with_object({}) do |name_value_pair, acc| 100 | attr_name, raw_value = name_value_pair 101 | attribute = @model_attributes.attribute_for(attr_name) 102 | if !raw_value.nil? || attribute.persist_nil? 103 | db_name = attribute.database_name 104 | acc[db_name] = attribute.serialize(raw_value) 105 | end 106 | acc 107 | end 108 | end 109 | 110 | def populate_default_values 111 | @model_attributes.attributes.each do |name, attribute| 112 | next if (default_value = attribute.default_value).nil? 113 | next unless @data[name].nil? 114 | 115 | @data[name] = default_value 116 | end 117 | end 118 | 119 | private 120 | 121 | def _deep_copy(obj) 122 | Marshal.load(Marshal.dump(obj)) 123 | end 124 | end 125 | end 126 | end 127 | -------------------------------------------------------------------------------- /lib/aws-record/record/key_attributes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Aws 4 | module Record 5 | # @api private 6 | class KeyAttributes 7 | attr_reader :keys 8 | 9 | def initialize(model_attributes) 10 | @keys = {} 11 | @model_attributes = model_attributes 12 | end 13 | 14 | def hash_key 15 | @hash_key 16 | end 17 | 18 | def hash_key_attribute 19 | @model_attributes.attribute_for(hash_key) 20 | end 21 | 22 | def range_key 23 | @range_key 24 | end 25 | 26 | def range_key_attribute 27 | @model_attributes.attribute_for(range_key) 28 | end 29 | 30 | def hash_key=(value) 31 | @keys[:hash] = value 32 | @hash_key = value 33 | end 34 | 35 | def range_key=(value) 36 | @keys[:range] = value 37 | @range_key = value 38 | end 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/aws-record/record/marshalers/boolean_marshaler.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Aws 4 | module Record 5 | module Marshalers 6 | class BooleanMarshaler 7 | def initialize(opts = {}) 8 | # pass 9 | end 10 | 11 | def type_cast(raw_value) 12 | case raw_value 13 | when nil, '' 14 | nil 15 | when false, 'false', '0', 0 16 | false 17 | else 18 | true 19 | end 20 | end 21 | 22 | def serialize(raw_value) 23 | boolean = type_cast(raw_value) 24 | case boolean 25 | when nil 26 | nil 27 | when false 28 | false 29 | when true 30 | true 31 | else 32 | msg = "expected a boolean value or nil, got #{boolean.class}" 33 | raise ArgumentError, msg 34 | end 35 | end 36 | end 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/aws-record/record/marshalers/date_marshaler.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'date' 4 | 5 | module Aws 6 | module Record 7 | module Marshalers 8 | class DateMarshaler 9 | def initialize(opts = {}) 10 | @formatter = opts[:formatter] || Iso8601Formatter 11 | end 12 | 13 | def type_cast(raw_value) 14 | case raw_value 15 | when nil, '' 16 | nil 17 | when Date 18 | raw_value 19 | when Integer 20 | Date.parse(Time.at(raw_value).to_s) # assumed timestamp 21 | else 22 | Date.parse(raw_value.to_s) # Time, DateTime or String 23 | end 24 | end 25 | 26 | def serialize(raw_value) 27 | date = type_cast(raw_value) 28 | if date.nil? 29 | nil 30 | elsif date.is_a?(Date) 31 | @formatter.format(date) 32 | else 33 | raise ArgumentError, "expected a Date value or nil, got #{date.class}" 34 | end 35 | end 36 | end 37 | 38 | module Iso8601Formatter 39 | def self.format(date) 40 | date.iso8601 41 | end 42 | end 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/aws-record/record/marshalers/date_time_marshaler.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'date' 4 | 5 | module Aws 6 | module Record 7 | module Marshalers 8 | class DateTimeMarshaler 9 | def initialize(opts = {}) 10 | @formatter = opts[:formatter] || Iso8601Formatter 11 | @use_local_time = opts[:use_local_time] ? true : false 12 | end 13 | 14 | def type_cast(raw_value) 15 | value = _format(raw_value) 16 | if !@use_local_time && value.is_a?(::DateTime) 17 | value.new_offset(0) 18 | else 19 | value 20 | end 21 | end 22 | 23 | def serialize(raw_value) 24 | datetime = type_cast(raw_value) 25 | if datetime.nil? 26 | nil 27 | elsif datetime.is_a?(::DateTime) 28 | @formatter.format(datetime) 29 | else 30 | msg = "expected a DateTime value or nil, got #{datetime.class}" 31 | raise ArgumentError, msg 32 | end 33 | end 34 | 35 | private 36 | 37 | def _format(raw_value) 38 | case raw_value 39 | when nil, '' 40 | nil 41 | when ::DateTime 42 | raw_value 43 | when Integer # timestamp 44 | ::DateTime.parse(Time.at(raw_value).to_s) 45 | else # Time, Date or String 46 | ::DateTime.parse(raw_value.to_s) 47 | end 48 | end 49 | end 50 | 51 | module Iso8601Formatter 52 | def self.format(datetime) 53 | datetime.iso8601 54 | end 55 | end 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/aws-record/record/marshalers/epoch_time_marshaler.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'time' 4 | 5 | module Aws 6 | module Record 7 | module Marshalers 8 | class EpochTimeMarshaler 9 | def initialize(opts = {}) 10 | @use_local_time = opts[:use_local_time] ? true : false 11 | end 12 | 13 | def type_cast(raw_value) 14 | value = _format(raw_value) 15 | if !@use_local_time && value.is_a?(::Time) 16 | value.utc 17 | else 18 | value 19 | end 20 | end 21 | 22 | def serialize(raw_value) 23 | time = type_cast(raw_value) 24 | if time.nil? 25 | nil 26 | elsif time.is_a?(::Time) 27 | time.to_i 28 | else 29 | msg = "expected a Time value or nil, got #{time.class}" 30 | raise ArgumentError, msg 31 | end 32 | end 33 | 34 | private 35 | 36 | def _format(raw_value) 37 | case raw_value 38 | when nil, '' 39 | nil 40 | when ::Time 41 | raw_value 42 | when Integer, BigDecimal # timestamp 43 | ::Time.at(raw_value) 44 | else # Date, DateTime, or String 45 | ::Time.parse(raw_value.to_s) 46 | end 47 | end 48 | end 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/aws-record/record/marshalers/float_marshaler.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Aws 4 | module Record 5 | module Marshalers 6 | class FloatMarshaler 7 | def initialize(opts = {}) 8 | # pass 9 | end 10 | 11 | def type_cast(raw_value) 12 | case raw_value 13 | when nil, '' 14 | nil 15 | when Float 16 | raw_value 17 | else 18 | raw_value.respond_to?(:to_f) ? raw_value.to_f : raw_value.to_s.to_f 19 | end 20 | end 21 | 22 | def serialize(raw_value) 23 | float = type_cast(raw_value) 24 | if float.nil? 25 | nil 26 | elsif float.is_a?(Float) 27 | float 28 | else 29 | msg = "expected a Float value or nil, got #{float.class}" 30 | raise ArgumentError, msg 31 | end 32 | end 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/aws-record/record/marshalers/integer_marshaler.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Aws 4 | module Record 5 | module Marshalers 6 | class IntegerMarshaler 7 | def initialize(opts = {}) 8 | # pass 9 | end 10 | 11 | def type_cast(raw_value) 12 | case raw_value 13 | when nil, '' 14 | nil 15 | when Integer 16 | raw_value 17 | else 18 | raw_value.respond_to?(:to_i) ? raw_value.to_i : raw_value.to_s.to_i 19 | end 20 | end 21 | 22 | def serialize(raw_value) 23 | integer = type_cast(raw_value) 24 | if integer.nil? 25 | nil 26 | elsif integer.is_a?(Integer) 27 | integer 28 | else 29 | msg = "expected an Integer value or nil, got #{integer.class}" 30 | raise ArgumentError, msg 31 | end 32 | end 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/aws-record/record/marshalers/list_marshaler.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Aws 4 | module Record 5 | module Marshalers 6 | class ListMarshaler 7 | def initialize(opts = {}) 8 | # pass 9 | end 10 | 11 | def type_cast(raw_value) 12 | case raw_value 13 | when nil, '' 14 | nil 15 | when Array 16 | raw_value 17 | else 18 | if raw_value.respond_to?(:to_a) 19 | raw_value.to_a 20 | else 21 | msg = "Don't know how to make #{raw_value} of type " \ 22 | "#{raw_value.class} into an array!" 23 | raise ArgumentError, msg 24 | end 25 | end 26 | end 27 | 28 | def serialize(raw_value) 29 | list = type_cast(raw_value) 30 | if list.is_a?(Array) 31 | list 32 | elsif list.nil? 33 | nil 34 | else 35 | msg = "expected an Array value or nil, got #{list.class}" 36 | raise ArgumentError, msg 37 | end 38 | end 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/aws-record/record/marshalers/map_marshaler.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Aws 4 | module Record 5 | module Marshalers 6 | class MapMarshaler 7 | def initialize(opts = {}) 8 | # pass 9 | end 10 | 11 | def type_cast(raw_value) 12 | case raw_value 13 | when nil, '' 14 | nil 15 | when Hash 16 | raw_value 17 | else 18 | if raw_value.respond_to?(:to_h) 19 | raw_value.to_h 20 | else 21 | msg = "Don't know how to make #{raw_value} of type " \ 22 | "#{raw_value.class} into a hash!" 23 | raise ArgumentError, msg 24 | end 25 | end 26 | end 27 | 28 | def serialize(raw_value) 29 | map = type_cast(raw_value) 30 | if map.is_a?(Hash) 31 | map 32 | elsif map.nil? 33 | nil 34 | else 35 | msg = "expected a Hash value or nil, got #{map.class}" 36 | raise ArgumentError, msg 37 | end 38 | end 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/aws-record/record/marshalers/numeric_set_marshaler.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Aws 4 | module Record 5 | module Marshalers 6 | def initialize(opts = {}) 7 | # pass 8 | end 9 | 10 | class NumericSetMarshaler 11 | def type_cast(raw_value) 12 | case raw_value 13 | when nil, '' 14 | Set.new 15 | when Set 16 | _as_numeric(raw_value) 17 | else 18 | if raw_value.respond_to?(:to_set) 19 | _as_numeric(raw_value.to_set) 20 | else 21 | msg = "Don't know how to make #{raw_value} of type " \ 22 | "#{raw_value.class} into a Numeric Set!" 23 | raise ArgumentError, msg 24 | end 25 | end 26 | end 27 | 28 | def serialize(raw_value) 29 | set = type_cast(raw_value) 30 | if set.is_a?(Set) 31 | if set.empty? 32 | nil 33 | else 34 | set 35 | end 36 | else 37 | msg = "expected a Set value or nil, got #{set.class}" 38 | raise ArgumentError, msg 39 | end 40 | end 41 | 42 | private 43 | 44 | def _as_numeric(set) 45 | set.collect! do |item| 46 | if item.is_a?(Numeric) 47 | item 48 | else 49 | BigDecimal(item.to_s) 50 | end 51 | end 52 | end 53 | end 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/aws-record/record/marshalers/string_marshaler.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Aws 4 | module Record 5 | module Marshalers 6 | class StringMarshaler 7 | def initialize(opts = {}) 8 | # pass 9 | end 10 | 11 | def type_cast(raw_value) 12 | case raw_value 13 | when nil 14 | nil 15 | when String 16 | raw_value 17 | else 18 | raw_value.to_s 19 | end 20 | end 21 | 22 | def serialize(raw_value) 23 | value = type_cast(raw_value) 24 | if value.is_a?(String) 25 | if value.empty? 26 | nil 27 | else 28 | value 29 | end 30 | elsif value.nil? 31 | nil 32 | else 33 | msg = "expected a String value or nil, got #{value.class}" 34 | raise ArgumentError, msg 35 | end 36 | end 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/aws-record/record/marshalers/string_set_marshaler.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Aws 4 | module Record 5 | module Marshalers 6 | class StringSetMarshaler 7 | def initialize(opts = {}) 8 | # pass 9 | end 10 | 11 | def type_cast(raw_value) 12 | case raw_value 13 | when nil, '' 14 | Set.new 15 | when Set 16 | _as_strings(raw_value) 17 | else 18 | if raw_value.respond_to?(:to_set) 19 | _as_strings(raw_value.to_set) 20 | else 21 | msg = "Don't know how to make #{raw_value} of type " \ 22 | "#{raw_value.class} into a String Set!" 23 | raise ArgumentError, msg 24 | end 25 | end 26 | end 27 | 28 | def serialize(raw_value) 29 | set = type_cast(raw_value) 30 | if set.is_a?(Set) 31 | if set.empty? 32 | nil 33 | else 34 | set 35 | end 36 | else 37 | msg = "expected a Set value or nil, got #{set.class}" 38 | raise ArgumentError, msg 39 | end 40 | end 41 | 42 | private 43 | 44 | def _as_strings(set) 45 | set.collect! do |item| 46 | if item.is_a?(String) 47 | item 48 | else 49 | item.to_s 50 | end 51 | end 52 | end 53 | end 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/aws-record/record/marshalers/time_marshaler.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'time' 4 | 5 | module Aws 6 | module Record 7 | module Marshalers 8 | class TimeMarshaler 9 | def initialize(opts = {}) 10 | @formatter = opts[:formatter] || Iso8601Formatter 11 | @use_local_time = opts[:use_local_time] ? true : false 12 | end 13 | 14 | def type_cast(raw_value) 15 | value = _format(raw_value) 16 | if !@use_local_time && value.is_a?(::Time) 17 | value.utc 18 | else 19 | value 20 | end 21 | end 22 | 23 | def serialize(raw_value) 24 | time = type_cast(raw_value) 25 | if time.nil? 26 | nil 27 | elsif time.is_a?(::Time) 28 | @formatter.format(time) 29 | else 30 | msg = "expected a Time value or nil, got #{time.class}" 31 | raise ArgumentError, msg 32 | end 33 | end 34 | 35 | private 36 | 37 | def _format(raw_value) 38 | case raw_value 39 | when nil, '' 40 | nil 41 | when ::Time 42 | raw_value 43 | when Integer # timestamp 44 | ::Time.at(raw_value) 45 | else # Date, DateTime, or String 46 | ::Time.parse(raw_value.to_s) 47 | end 48 | end 49 | end 50 | 51 | module Iso8601Formatter 52 | def self.format(time) 53 | time.iso8601 54 | end 55 | end 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/aws-record/record/model_attributes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Aws 4 | module Record 5 | # @api private 6 | class ModelAttributes 7 | attr_reader :attributes, :storage_attributes 8 | 9 | def initialize(model_class) 10 | @model_class = model_class 11 | @attributes = {} 12 | @storage_attributes = {} 13 | end 14 | 15 | def register_attribute(name, marshaler, opts) 16 | attribute = Attribute.new(name, opts.merge(marshaler: marshaler)) 17 | _new_attr_validation(name, attribute) 18 | @attributes[name] = attribute 19 | @storage_attributes[attribute.database_name] = name 20 | attribute 21 | end 22 | 23 | def register_superclass_attribute(name, attribute) 24 | _new_attr_validation(name, attribute) 25 | @attributes[name] = attribute.dup 26 | @storage_attributes[attribute.database_name] = name 27 | attribute 28 | end 29 | 30 | def attribute_for(name) 31 | @attributes[name] 32 | end 33 | 34 | def storage_name_for(name) 35 | attribute_for(name).database_name 36 | end 37 | 38 | def present?(name) 39 | attribute_for(name) ? true : false 40 | end 41 | 42 | def db_to_attribute_name(storage_name) 43 | @storage_attributes[storage_name] 44 | end 45 | 46 | private 47 | 48 | def _new_attr_validation(name, attribute) 49 | _validate_attr_name(name) 50 | _check_for_naming_collisions(name, attribute.database_name) 51 | _check_if_reserved(name) 52 | end 53 | 54 | def _validate_attr_name(name) 55 | raise ArgumentError, 'Must use symbolized :name attribute.' unless name.is_a?(Symbol) 56 | return unless @attributes[name] 57 | 58 | raise Errors::NameCollision, "Cannot overwrite existing attribute #{name}" 59 | end 60 | 61 | def _check_if_reserved(name) 62 | return unless @model_class.instance_methods.include?(name) 63 | 64 | raise Errors::ReservedName, "Cannot name an attribute #{name}, that would collide with an " \ 65 | 'existing instance method.' 66 | end 67 | 68 | def _check_for_naming_collisions(name, storage_name) 69 | if @attributes[storage_name.to_sym] 70 | raise Errors::NameCollision, "Custom storage name #{storage_name} already exists as an " \ 71 | "attribute name in #{@attributes}" 72 | elsif @storage_attributes[name.to_s] 73 | raise Errors::NameCollision, "Attribute name #{name} already exists as a custom storage " \ 74 | "name in #{@storage_attributes}" 75 | elsif @storage_attributes[storage_name] 76 | raise Errors::NameCollision, "Custom storage name #{storage_name} already in use in " \ 77 | "#{@storage_attributes}" 78 | 79 | end 80 | end 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /lib/aws-record/record/query.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Aws 4 | module Record 5 | module Query 6 | # @api private 7 | def self.included(sub_class) 8 | sub_class.extend(QueryClassMethods) 9 | end 10 | 11 | module QueryClassMethods 12 | # This method calls 13 | # {http://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/DynamoDB/Client.html#query-instance_method 14 | # Aws::DynamoDB::Client#query}, populating the +:table_name+ parameter from the model 15 | # class, and combining this with the other parameters you provide. 16 | # 17 | # @example A query with key and filter expressions: 18 | # # Example model class 19 | # class ExampleTable 20 | # include Aws::Record 21 | # string_attr :uuid, hash_key: true 22 | # integer_attr :id, range_key: true 23 | # string_attr :body 24 | # end 25 | # 26 | # query = ExampleTable.query( 27 | # key_condition_expression: "#H = :h AND #R > :r", 28 | # filter_expression: "contains(#B, :b)", 29 | # expression_attribute_names: { 30 | # "#H" => "uuid", 31 | # "#R" => "id", 32 | # "#B" => "body" 33 | # }, 34 | # expression_attribute_values: { 35 | # ":h" => "123456789uuid987654321", 36 | # ":r" => 100, 37 | # ":b" => "some substring" 38 | # } 39 | # ) 40 | # 41 | # # You can enumerate over your results. 42 | # query.each do |r| 43 | # puts "UUID: #{r.uuid}\nID: #{r.id}\nBODY: #{r.body}\n" 44 | # end 45 | # 46 | # @param [Hash] opts options to pass on to the client call to +#query+. 47 | # See the documentation above in the AWS SDK for Ruby V3. 48 | # @return [Aws::Record::ItemCollection] an enumerable collection of the 49 | # query result. 50 | def query(opts) 51 | query_opts = opts.merge(table_name: table_name) 52 | ItemCollection.new(:query, query_opts, self, dynamodb_client) 53 | end 54 | 55 | # This method calls 56 | # {http://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/DynamoDB/Client.html#scan-instance_method 57 | # Aws::DynamoDB::Client#scan}, populating the +:table_name+ parameter from the model 58 | # class, and combining this with the other parameters you provide. 59 | # 60 | # @example A scan with a filter expression: 61 | # # Example model class 62 | # class ExampleTable 63 | # include Aws::Record 64 | # string_attr :uuid, hash_key: true 65 | # integer_attr :id, range_key: true 66 | # string_attr :body 67 | # end 68 | # 69 | # scan = ExampleTable.scan( 70 | # filter_expression: "contains(#B, :b)", 71 | # expression_attribute_names: { 72 | # "#B" => "body" 73 | # }, 74 | # expression_attribute_values: { 75 | # ":b" => "some substring" 76 | # } 77 | # ) 78 | # 79 | # # You can enumerate over your results. 80 | # scan.each do |r| 81 | # puts "UUID: #{r.uuid}\nID: #{r.id}\nBODY: #{r.body}\n" 82 | # end 83 | # 84 | # @param [Hash] opts options to pass on to the client call to +#scan+. 85 | # See the documentation above in the AWS SDK for Ruby V3. 86 | # @return [Aws::Record::ItemCollection] an enumerable collection of the 87 | # scan result. 88 | def scan(opts = {}) 89 | scan_opts = opts.merge(table_name: table_name) 90 | ItemCollection.new(:scan, scan_opts, self, dynamodb_client) 91 | end 92 | 93 | # This method allows you to build a query using the {Aws::Record::BuildableSearch} DSL. 94 | # 95 | # @example Building a simple query: 96 | # # Example model class 97 | # class ExampleTable 98 | # include Aws::Record 99 | # string_attr :uuid, hash_key: true 100 | # integer_attr :id, range_key: true 101 | # string_attr :body 102 | # end 103 | # 104 | # q = ExampleTable.build_query.key_expr( 105 | # ":uuid = ? AND :id > ?", "smpl-uuid", 100 106 | # ).scan_ascending(false).complete! 107 | # q.to_a # You can use this like any other query result in aws-record 108 | def build_query 109 | BuildableSearch.new( 110 | operation: :query, 111 | model: self 112 | ) 113 | end 114 | 115 | # This method allows you to build a scan using the {Aws::Record::BuildableSearch} DSL. 116 | # 117 | # @example Building a simple scan: 118 | # # Example model class 119 | # class ExampleTable 120 | # include Aws::Record 121 | # string_attr :uuid, hash_key: true 122 | # integer_attr :id, range_key: true 123 | # string_attr :body 124 | # end 125 | # 126 | # segment_2_scan = ExampleTable.build_scan.filter_expr( 127 | # "contains(:body, ?)", 128 | # "bacon" 129 | # ).scan_ascending(false).parallel_scan( 130 | # total_segments: 5, 131 | # segment: 2 132 | # ).complete! 133 | # segment_2_scan.to_a # You can use this like any other query result in aws-record 134 | def build_scan 135 | BuildableSearch.new( 136 | operation: :scan, 137 | model: self 138 | ) 139 | end 140 | end 141 | end 142 | end 143 | end 144 | -------------------------------------------------------------------------------- /lib/aws-record/record/secondary_indexes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Aws 4 | module Record 5 | module SecondaryIndexes 6 | # @api private 7 | def self.included(sub_class) 8 | sub_class.instance_variable_set('@local_secondary_indexes', {}) 9 | sub_class.instance_variable_set('@global_secondary_indexes', {}) 10 | sub_class.extend(SecondaryIndexesClassMethods) 11 | inherit_indexes(sub_class) if Aws::Record.extends_record?(sub_class) 12 | end 13 | 14 | def self.inherit_indexes(klass) 15 | superclass_lsi = klass.superclass.instance_variable_get('@local_secondary_indexes').dup 16 | superclass_gsi = klass.superclass.instance_variable_get('@global_secondary_indexes').dup 17 | klass.instance_variable_set('@local_secondary_indexes', superclass_lsi) 18 | klass.instance_variable_set('@global_secondary_indexes', superclass_gsi) 19 | end 20 | 21 | private_class_method :inherit_indexes 22 | 23 | module SecondaryIndexesClassMethods 24 | # Creates a local secondary index for the model. Learn more about Local 25 | # Secondary Indexes in the 26 | # {http://docs.aws.amazon.com/amazondynamodb/latest/developerguide/LSI.html Amazon DynamoDB Developer Guide}. 27 | # 28 | # *Note*: {#local_secondary_indexes} is inherited from a parent model 29 | # when +local_secondary_index+ is explicitly specified in the parent. 30 | # @param [Symbol] name index name for this local secondary index 31 | # @param [Hash] opts 32 | # @option opts [Symbol] :range_key the range key used by this local 33 | # secondary index. Note that the hash key MUST be the table's hash 34 | # key, and so that value will be filled in for you. 35 | # @option opts [Hash] :projection a hash which defines which attributes 36 | # are copied from the table to the index. See shape details in the 37 | # {http://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/DynamoDB/Types/Projection.html AWS SDK for Ruby V3 docs}. 38 | def local_secondary_index(name, opts) 39 | opts[:hash_key] = hash_key 40 | _validate_required_lsi_keys(opts) 41 | local_secondary_indexes[name] = opts 42 | end 43 | 44 | # Creates a global secondary index for the model. Learn more about 45 | # Global Secondary Indexes in the 46 | # {http://docs.aws.amazon.com/amazondynamodb/latest/developerguide/GSI.html Amazon DynamoDB Developer Guide}. 47 | # 48 | # *Note*: {#global_secondary_indexes} is inherited from a parent model 49 | # when +global_secondary_index+ is explicitly specified in the parent. 50 | # @param [Symbol] name index name for this global secondary index 51 | # @param [Hash] opts 52 | # @option opts [Symbol] :hash_key the hash key used by this global 53 | # secondary index. 54 | # @option opts [Symbol] :range_key the range key used by this global 55 | # secondary index. 56 | # @option opts [Hash] :projection a hash which defines which attributes 57 | # are copied from the table to the index. See shape details in the 58 | # {http://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/DynamoDB/Types/Projection.html AWS SDK for Ruby V3 docs}. 59 | def global_secondary_index(name, opts) 60 | _validate_required_gsi_keys(opts) 61 | global_secondary_indexes[name] = opts 62 | end 63 | 64 | # Returns hash of local secondary index names to the index's attributes. 65 | # 66 | # *Note*: +local_secondary_indexes+ is inherited from a parent model when {#local_secondary_index} 67 | # is explicitly specified in the parent. 68 | # @return [Hash] hash of local secondary index names to the index's 69 | # attributes. 70 | def local_secondary_indexes 71 | @local_secondary_indexes 72 | end 73 | 74 | # Returns hash of global secondary index names to the index's attributes. 75 | # 76 | # *Note*: +global_secondary_indexes+ is inherited from a parent model when {#global_secondary_index} 77 | # is explicitly specified in the parent. 78 | # @return [Hash] hash of global secondary index names to the index's 79 | # attributes. 80 | def global_secondary_indexes 81 | @global_secondary_indexes 82 | end 83 | 84 | # @return [Hash] hash of the local secondary indexes in a form suitable 85 | # for use in a table migration. For example, any attributes which 86 | # have a unique database storage name will use that name instead. 87 | def local_secondary_indexes_for_migration 88 | _migration_format_indexes(local_secondary_indexes) 89 | end 90 | 91 | # @return [Hash] hash of the global secondary indexes in a form suitable 92 | # for use in a table migration. For example, any attributes which 93 | # have a unique database storage name will use that name instead. 94 | def global_secondary_indexes_for_migration 95 | _migration_format_indexes(global_secondary_indexes) 96 | end 97 | 98 | private 99 | 100 | def _migration_format_indexes(indexes) 101 | return nil if indexes.empty? 102 | 103 | indexes.collect do |name, opts| 104 | h = { index_name: name } 105 | h[:key_schema] = _si_key_schema(opts) 106 | hk = opts.delete(:hash_key) 107 | rk = opts.delete(:range_key) 108 | h = h.merge(opts) 109 | opts[:hash_key] = hk if hk 110 | opts[:range_key] = rk if rk 111 | h 112 | end 113 | end 114 | 115 | def _si_key_schema(opts) 116 | key_schema = [{ 117 | key_type: 'HASH', 118 | attribute_name: @attributes.storage_name_for(opts[:hash_key]) 119 | }] 120 | if opts[:range_key] 121 | key_schema << { 122 | key_type: 'RANGE', 123 | attribute_name: @attributes.storage_name_for(opts[:range_key]) 124 | } 125 | end 126 | key_schema 127 | end 128 | 129 | def _validate_required_lsi_keys(params) 130 | unless params[:hash_key] && params[:range_key] 131 | raise ArgumentError, 'Local Secondary Indexes require a hash and range key!' 132 | end 133 | 134 | _validate_attributes_exist(params[:hash_key], params[:range_key]) 135 | end 136 | 137 | def _validate_required_gsi_keys(params) 138 | raise ArgumentError, 'Global Secondary Indexes require at least a hash key!' unless params[:hash_key] 139 | 140 | if params[:range_key] 141 | _validate_attributes_exist(params[:hash_key], params[:range_key]) 142 | else 143 | _validate_attributes_exist(params[:hash_key]) 144 | end 145 | end 146 | 147 | def _validate_attributes_exist(*attr_names) 148 | missing = attr_names.reject do |attr_name| 149 | @attributes.present?(attr_name) 150 | end 151 | return if missing.empty? 152 | 153 | raise ArgumentError, "#{missing.join(', ')} not present in model attributes. " \ 154 | 'Please ensure that attributes are defined in the model ' \ 155 | 'class BEFORE defining an index on those attributes.' 156 | end 157 | end 158 | end 159 | end 160 | end 161 | -------------------------------------------------------------------------------- /lib/aws-record/record/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Aws 4 | module Record 5 | VERSION = File.read(File.expand_path('../../../VERSION', __dir__)).strip 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/aws-record/record/attribute_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | module Aws 6 | module Record 7 | describe Attribute do 8 | context 'database_attribute_name' do 9 | it 'can have a custom DB name' do 10 | a = Attribute.new(:foo, database_attribute_name: 'bar') 11 | expect(a.name).to eq(:foo) 12 | expect(a.database_name).to eq('bar') 13 | end 14 | 15 | it 'can accept a symbol as a custom DB name' do 16 | a = Attribute.new(:foo, database_attribute_name: :bar) 17 | expect(a.name).to eq(:foo) 18 | expect(a.database_name).to eq('bar') 19 | end 20 | 21 | it 'uses the attribute name by default for the DB name' do 22 | a = Attribute.new(:foo) 23 | expect(a.name).to eq(:foo) 24 | expect(a.database_name).to eq('foo') 25 | end 26 | end 27 | 28 | context 'default_value' do 29 | it 'supports lambdas' do 30 | a = Attribute.new(:foo, default_value: -> { 2 + 3 }) 31 | expect(a.default_value).to eq(5) 32 | end 33 | 34 | it 'does not type_cast lambdas' do 35 | m = Marshalers::DateTimeMarshaler.new 36 | a = Attribute.new(:foo, marshaler: m, default_value: -> { Time.now }) 37 | dv = a.instance_variable_get('@default_value_or_lambda') 38 | expect(dv.respond_to?(:call)).to eq(true) 39 | end 40 | 41 | it 'type casts result of calling a default_value lambda' do 42 | m = Marshalers::StringMarshaler.new 43 | a = Attribute.new(:foo, marshaler: m, default_value: -> { :huzzah }) 44 | expect(a.default_value).to be_a(String) 45 | end 46 | 47 | it 'uses a deep copy' do 48 | a = Attribute.new(:foo, default_value: {}) 49 | a.default_value['greeting'] = 'hi' 50 | 51 | expect(a.default_value).to eq({}) 52 | end 53 | 54 | it 'does not type_cast unset value' do 55 | m = Marshalers::StringSetMarshaler.new 56 | a = Attribute.new(:foo, marshaler: m) 57 | expect(a.default_value).to be_nil 58 | end 59 | 60 | it 'type casts nil value' do 61 | m = Marshalers::StringSetMarshaler.new 62 | a = Attribute.new(:foo, marshaler: m, default_value: nil) 63 | expect(a.default_value).to be_a(Set) 64 | end 65 | end 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /spec/aws-record/record/client_configuration_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | module Aws 6 | module Record 7 | describe 'ClientConfiguration' do 8 | context 'inheritance support for dynamodb client' do 9 | let(:parent_model) do 10 | Class.new do 11 | include(Aws::Record) 12 | end 13 | end 14 | 15 | let(:child_model) do 16 | Class.new(parent_model) do 17 | include(Aws::Record) 18 | end 19 | end 20 | 21 | let(:stub_client) { Aws::DynamoDB::Client.new(stub_responses: true) } 22 | 23 | it 'should have child model inherit dynamodb client from parent model' do 24 | parent_model.configure_client(client: stub_client) 25 | child_model.dynamodb_client 26 | expect(parent_model.dynamodb_client).to be(child_model.dynamodb_client) 27 | end 28 | 29 | it 'should have child model maintain its own dynamodb client if defined in model' do 30 | parent_model.configure_client(client: stub_client) 31 | child_model.configure_client(client: stub_client.dup) 32 | expect(child_model.dynamodb_client).not_to eql(parent_model.dynamodb_client) 33 | end 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /spec/aws-record/record/marshalers/boolean_marshaler_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | module Aws 6 | module Record 7 | module Marshalers 8 | describe BooleanMarshaler do 9 | context 'default settings' do 10 | before(:each) do 11 | @marshaler = BooleanMarshaler.new 12 | end 13 | 14 | describe 'type casting' do 15 | it 'type casts nil and empty strings as nil' do 16 | expect(@marshaler.type_cast(nil)).to be_nil 17 | expect(@marshaler.type_cast('')).to be_nil 18 | end 19 | 20 | it 'type casts false equivalents as false' do 21 | expect(@marshaler.type_cast('false')).to eq(false) 22 | expect(@marshaler.type_cast('0')).to eq(false) 23 | expect(@marshaler.type_cast(0)).to eq(false) 24 | end 25 | end 26 | 27 | describe 'serialization for storage' do 28 | it 'stores booleans as themselves' do 29 | expect(@marshaler.serialize(true)).to eq(true) 30 | end 31 | 32 | it 'attempts to type cast before storage' do 33 | expect(@marshaler.serialize(0)).to eq(false) 34 | end 35 | 36 | it 'identifies nil objects as the NULL type' do 37 | expect(@marshaler.serialize(nil)).to eq(nil) 38 | end 39 | end 40 | end 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /spec/aws-record/record/marshalers/date_marshaler_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'date' 5 | 6 | module Aws 7 | module Record 8 | module Marshalers 9 | describe DateMarshaler do 10 | context 'default settings' do 11 | before(:each) do 12 | @marshaler = DateMarshaler.new 13 | end 14 | 15 | describe 'type casting' do 16 | it 'casts nil and empty string as nil' do 17 | expect(@marshaler.type_cast(nil)).to be_nil 18 | expect(@marshaler.type_cast('')).to be_nil 19 | end 20 | 21 | it 'casts Date objects as themselves' do 22 | expected = Date.parse('2015-01-01') 23 | input = Date.parse('2015-01-01') 24 | expect(@marshaler.type_cast(input)).to eq(expected) 25 | end 26 | 27 | it 'casts timestamps to dates' do 28 | expected = Date.parse('2009-02-13') 29 | input = 1_234_567_890 30 | expect(@marshaler.type_cast(input)).to be_within(1).of(expected) 31 | end 32 | 33 | it 'casts strings to dates' do 34 | expected = Date.parse('2015-11-25') 35 | input = '2015-11-25' 36 | expect(@marshaler.type_cast(input)).to eq(expected) 37 | end 38 | end 39 | 40 | describe 'serialization for storage' do 41 | it 'serializes nil as null' do 42 | expect(@marshaler.serialize(nil)).to eq(nil) 43 | end 44 | 45 | it 'serializes dates as strings' do 46 | date = Date.parse('2015-11-25') 47 | expect(@marshaler.serialize(date)).to eq('2015-11-25') 48 | end 49 | end 50 | end 51 | 52 | context 'bring your own format' do 53 | let(:jisx0301_formatter) do 54 | Class.new do 55 | def self.format(date) 56 | date.jisx0301 57 | end 58 | end 59 | end 60 | 61 | before(:each) do 62 | @marshaler = DateMarshaler.new(formatter: jisx0301_formatter) 63 | end 64 | 65 | it 'supports custom formatting' do 66 | expected = 'H28.07.21' 67 | input = '2016-07-21' 68 | expect(@marshaler.serialize(input)).to eq(expected) 69 | end 70 | end 71 | end 72 | end 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /spec/aws-record/record/marshalers/date_time_marshaler_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'date' 5 | 6 | module Aws 7 | module Record 8 | module Marshalers 9 | describe DateTimeMarshaler do 10 | context 'default settings' do 11 | before(:each) do 12 | @marshaler = DateTimeMarshaler.new 13 | end 14 | 15 | describe 'type casting' do 16 | it 'casts nil and empty string as nil' do 17 | expect(@marshaler.type_cast(nil)).to be_nil 18 | expect(@marshaler.type_cast('')).to be_nil 19 | end 20 | 21 | it 'passes through DateTime objects' do 22 | expected = DateTime.parse('2015-11-15 17:12:56 +0700') 23 | input = DateTime.parse('2015-11-15 17:12:56 +0700') 24 | expect(@marshaler.type_cast(input)).to eq(expected) 25 | end 26 | 27 | it 'converts timestamps to DateTime' do 28 | expected = DateTime.parse('2009-02-13 23:31:30 UTC') 29 | input = 1_234_567_890 30 | expect(@marshaler.type_cast(input)).to eq(expected) 31 | end 32 | 33 | it 'converts strings to DateTime' do 34 | expected = DateTime.parse('2009-02-13 23:31:30 UTC') 35 | input = '2009-02-13 23:31:30 UTC' 36 | expect(@marshaler.type_cast(input)).to eq(expected) 37 | end 38 | 39 | it 'converts automatically to utc' do 40 | expected = DateTime.parse('2016-07-20 23:31:10 UTC') 41 | input = '2016-07-20 16:31:10 -0700' 42 | expect(@marshaler.type_cast(input)).to eq(expected) 43 | end 44 | 45 | it 'raises when unable to parse as a DateTime' do 46 | expect { 47 | @marshaler.type_cast('that time when') 48 | }.to raise_error(ArgumentError) 49 | end 50 | end 51 | 52 | describe 'serialization for storage' do 53 | it 'serializes nil as null' do 54 | expect(@marshaler.serialize(nil)).to eq(nil) 55 | end 56 | 57 | it 'serializes DateTime as a string' do 58 | dt = DateTime.parse('2009-02-13 23:31:30 UTC') 59 | expect(@marshaler.serialize(dt)).to eq( 60 | '2009-02-13T23:31:30+00:00' 61 | ) 62 | end 63 | end 64 | end 65 | 66 | context 'use local time' do 67 | before(:each) do 68 | @marshaler = DateTimeMarshaler.new(use_local_time: true) 69 | end 70 | 71 | it 'does not automatically convert to utc' do 72 | expected = DateTime.parse('2016-07-20 16:31:10 -0700') 73 | input = '2016-07-20 16:31:10 -0700' 74 | expect(@marshaler.type_cast(input)).to eq(expected) 75 | end 76 | end 77 | 78 | context 'bring your own format' do 79 | let(:jisx0301_formatter) do 80 | Class.new do 81 | def self.format(datetime) 82 | datetime.jisx0301 83 | end 84 | end 85 | end 86 | before(:each) do 87 | @marshaler = DateTimeMarshaler.new(formatter: jisx0301_formatter) 88 | end 89 | 90 | it 'supports custom formatting' do 91 | expected = 'H28.07.20T23:34:36+00:00' 92 | input = '2016-07-20T16:34:36-07:00' 93 | expect(@marshaler.serialize(input)).to eq(expected) 94 | end 95 | end 96 | end 97 | end 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /spec/aws-record/record/marshalers/epoch_time_marshaler_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'time' 5 | 6 | module Aws 7 | module Record 8 | module Marshalers 9 | describe EpochTimeMarshaler do 10 | context 'default settings' do 11 | before(:each) do 12 | @marshaler = EpochTimeMarshaler.new 13 | end 14 | 15 | describe 'type casting' do 16 | it 'casts nil and empty string as nil' do 17 | expect(@marshaler.type_cast(nil)).to be_nil 18 | expect(@marshaler.type_cast('')).to be_nil 19 | end 20 | 21 | it 'passes through Time objects' do 22 | expected = Time.at(1_531_173_732) 23 | input = Time.at(1_531_173_732) 24 | expect(@marshaler.type_cast(input)).to eq(expected) 25 | end 26 | 27 | it 'converts timestamps to Time' do 28 | expected = Time.at(1_531_173_732) 29 | input = 1_531_173_732 30 | expect(@marshaler.type_cast(input)).to eq(expected) 31 | end 32 | 33 | it 'converts BigDecimal objects to Time' do 34 | expected = Time.at(1_531_173_732) 35 | input = BigDecimal(1_531_173_732) 36 | expect(@marshaler.type_cast(input)).to eq(expected) 37 | end 38 | 39 | it 'converts DateTimes to Time' do 40 | expected = Time.parse('2009-02-13 23:31:30 UTC') 41 | input = DateTime.parse('2009-02-13 23:31:30 UTC') 42 | expect(@marshaler.type_cast(input)).to eq(expected) 43 | end 44 | 45 | it 'converts strings to Time' do 46 | expected = Time.parse('2009-02-13 23:31:30 UTC') 47 | input = '2009-02-13 23:31:30 UTC' 48 | expect(@marshaler.type_cast(input)).to eq(expected) 49 | end 50 | 51 | it 'converts automatically to utc' do 52 | expected = Time.parse('2016-07-20 23:31:10 UTC') 53 | input = '2016-07-20 16:31:10 -0700' 54 | expect(@marshaler.type_cast(input)).to eq(expected) 55 | end 56 | 57 | it 'raises when unable to parse as a Time' do 58 | expect { 59 | @marshaler.type_cast('that time when') 60 | }.to raise_error(ArgumentError) 61 | end 62 | end 63 | 64 | describe 'serialization for storage' do 65 | it 'serializes nil as null' do 66 | expect(@marshaler.serialize(nil)).to eq(nil) 67 | end 68 | 69 | it 'serializes Time in epoch seconds' do 70 | t = Time.parse('2018-07-09 22:02:12 UTC') 71 | expect(@marshaler.serialize(t)).to eq(1_531_173_732) 72 | end 73 | end 74 | end 75 | 76 | context 'use local time' do 77 | before(:each) do 78 | @marshaler = TimeMarshaler.new(use_local_time: true) 79 | end 80 | 81 | it 'does not automatically convert to utc' do 82 | expected = Time.parse('2016-07-20 16:31:10 -0700') 83 | input = '2016-07-20 16:31:10 -0700' 84 | expect(@marshaler.type_cast(input)).to eq(expected) 85 | end 86 | end 87 | end 88 | end 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /spec/aws-record/record/marshalers/float_marshaler_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | module Aws 6 | module Record 7 | module Marshalers 8 | describe FloatMarshaler do 9 | context 'default settings' do 10 | before(:each) do 11 | @marshaler = FloatMarshaler.new 12 | end 13 | 14 | describe 'type casting' do 15 | it 'casts nil and empty strings as nil' do 16 | expect(@marshaler.type_cast(nil)).to be_nil 17 | expect(@marshaler.type_cast('')).to be_nil 18 | end 19 | 20 | it 'casts stringy floats to a float' do 21 | expect(@marshaler.type_cast('5.5')).to eq(5.5) 22 | end 23 | 24 | it 'passes through float values' do 25 | expect(@marshaler.type_cast(1.2)).to eq(1.2) 26 | end 27 | 28 | it 'handles classes which do not directly serialize to floats' do 29 | indirect = Class.new do 30 | def to_s 31 | '5' 32 | end 33 | end 34 | 35 | expect(@marshaler.type_cast(indirect.new)).to eq(5.0) 36 | end 37 | end 38 | 39 | describe 'serialization for storage' do 40 | it 'serializes nil as null' do 41 | expect(@marshaler.serialize(nil)).to eq(nil) 42 | end 43 | 44 | it 'serializes floats with the numeric type' do 45 | expect(@marshaler.serialize(3.0)).to eq(3.0) 46 | end 47 | 48 | it 'raises when type_cast does not do what it is expected to do' do 49 | impossible = Class.new do 50 | def to_f 51 | 'wrong' 52 | end 53 | end 54 | 55 | expect { 56 | @marshaler.serialize(impossible.new) 57 | }.to raise_error(ArgumentError) 58 | end 59 | end 60 | end 61 | end 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /spec/aws-record/record/marshalers/integer_marshaler_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | module Aws 6 | module Record 7 | module Marshalers 8 | describe IntegerMarshaler do 9 | context 'default settings' do 10 | before(:each) do 11 | @marshaler = IntegerMarshaler.new 12 | end 13 | 14 | describe 'type casting' do 15 | it 'casts nil and empty strings as nil' do 16 | expect(@marshaler.type_cast(nil)).to be_nil 17 | expect(@marshaler.type_cast('')).to be_nil 18 | end 19 | 20 | it 'casts stringy integers to an integer' do 21 | expect(@marshaler.type_cast('5')).to eq(5) 22 | end 23 | 24 | it 'passes through integer values' do 25 | expect(@marshaler.type_cast(1)).to eq(1) 26 | end 27 | 28 | it 'type casts values that do not directly respond to to_i' do 29 | indirect = Class.new do 30 | def to_s 31 | '5' 32 | end 33 | end 34 | 35 | expect(@marshaler.type_cast(indirect.new)).to eq(5) 36 | end 37 | end 38 | 39 | describe 'serialization for storage' do 40 | it 'serializes nil as null' do 41 | expect(@marshaler.serialize(nil)).to eq(nil) 42 | end 43 | 44 | it 'serializes integers with the numeric type' do 45 | expect(@marshaler.serialize(3)).to eq(3) 46 | end 47 | 48 | it 'raises when type_cast does not return the expected type' do 49 | impossible = Class.new do 50 | def to_i 51 | 'wrong' 52 | end 53 | end 54 | 55 | expect { 56 | @marshaler.serialize(impossible.new) 57 | }.to raise_error(ArgumentError) 58 | end 59 | end 60 | end 61 | end 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /spec/aws-record/record/marshalers/list_marshaler_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | module Aws 6 | module Record 7 | module Marshalers 8 | describe ListMarshaler do 9 | context 'default settings' do 10 | before(:each) do 11 | @marshaler = ListMarshaler.new 12 | end 13 | 14 | describe 'type casting' do 15 | it 'type casts nil as nil' do 16 | expect(@marshaler.type_cast(nil)).to eq(nil) 17 | end 18 | 19 | it 'type casts an empty string as nil' do 20 | expect(@marshaler.type_cast('')).to eq(nil) 21 | end 22 | 23 | it 'type casts Arrays as themselves' do 24 | expect(@marshaler.type_cast([1, 'Two', 3])).to eq([1, 'Two', 3]) 25 | end 26 | 27 | it 'type casts enumerables as an Array' do 28 | expected = [[:a, 1], [:b, 2], [:c, 3]] 29 | input = { a: 1, b: 2, c: 3 } 30 | expect(@marshaler.type_cast(input)).to eq(expected) 31 | end 32 | 33 | it 'raises if it cannot type cast to an Array' do 34 | expect { 35 | @marshaler.type_cast(5) 36 | }.to raise_error(ArgumentError) 37 | end 38 | end 39 | 40 | describe 'serialization' do 41 | it 'serializes an array as itself' do 42 | expect(@marshaler.serialize([1, 2, 3])).to eq([1, 2, 3]) 43 | end 44 | 45 | it 'serializes nil as nil' do 46 | expect(@marshaler.serialize(nil)).to eq(nil) 47 | end 48 | end 49 | end 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /spec/aws-record/record/marshalers/map_marshaler_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | module Aws 6 | module Record 7 | module Marshalers 8 | describe MapMarshaler do 9 | context 'default settings' do 10 | before(:each) do 11 | @marshaler = MapMarshaler.new 12 | end 13 | 14 | let(:mappable) do 15 | Class.new do 16 | def to_h 17 | { a: 1, b: 'Two', c: 3.0 } 18 | end 19 | end 20 | end 21 | 22 | describe 'type casting' do 23 | it 'type casts nil as nil' do 24 | expect(@marshaler.type_cast(nil)).to eq(nil) 25 | end 26 | 27 | it 'type casts an empty string as nil' do 28 | expect(@marshaler.type_cast('')).to eq(nil) 29 | end 30 | 31 | it 'type casts Hashes as themselves' do 32 | input = { a: 1, b: 'Two', c: 3.0 } 33 | expected = { a: 1, b: 'Two', c: 3.0 } 34 | expect(@marshaler.type_cast(input)).to eq(expected) 35 | end 36 | 37 | it 'type casts classes which respond to :to_h as a Hash' do 38 | input = mappable.new 39 | expected = { a: 1, b: 'Two', c: 3.0 } 40 | expect(@marshaler.type_cast(input)).to eq(expected) 41 | end 42 | 43 | it 'raises if it cannot type cast to a Hash' do 44 | expect { 45 | @marshaler.type_cast(5) 46 | }.to raise_error(ArgumentError) 47 | end 48 | end 49 | 50 | describe 'serialization' do 51 | it 'serializes a map as itself' do 52 | input = { a: 1, b: 'Two', c: 3.0 } 53 | expected = { a: 1, b: 'Two', c: 3.0 } 54 | expect(@marshaler.serialize(input)).to eq(expected) 55 | end 56 | 57 | it 'serializes nil as nil' do 58 | expect(@marshaler.serialize(nil)).to eq(nil) 59 | end 60 | end 61 | end 62 | end 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /spec/aws-record/record/marshalers/numeric_set_marshaler_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | module Aws 6 | module Record 7 | module Marshalers 8 | describe NumericSetMarshaler do 9 | context 'default settings' do 10 | before(:each) do 11 | @marshaler = NumericSetMarshaler.new 12 | end 13 | 14 | describe '#type_cast' do 15 | it 'type casts nil as an empty set' do 16 | expect(@marshaler.type_cast(nil)).to eq(Set.new) 17 | end 18 | 19 | it 'type casts an empty string as an empty set' do 20 | expect(@marshaler.type_cast('')).to eq(Set.new) 21 | end 22 | 23 | it 'type casts numeric sets as themselves' do 24 | input = Set.new([1, 2.0, 3]) 25 | expected = Set.new([1, 2.0, 3]) 26 | expect(@marshaler.type_cast(input)).to eq(expected) 27 | end 28 | 29 | it 'type casts a list to a set on your behalf' do 30 | input = [1, 2.0, 3] 31 | expected = Set.new([1, 2.0, 3]) 32 | expect(@marshaler.type_cast(input)).to eq(expected) 33 | end 34 | 35 | it 'attempts to cast as numeric all contents of a set' do 36 | input = Set.new([1, '2.0', '3']) 37 | expected = Set.new([1, BigDecimal('2.0'), BigDecimal('3')]) 38 | expect(@marshaler.type_cast(input)).to eq(expected) 39 | end 40 | 41 | it 'raises when unable to type cast as a set' do 42 | expect { 43 | @marshaler.type_cast('fail') 44 | }.to raise_error(ArgumentError) 45 | end 46 | end 47 | 48 | describe '#serialize' do 49 | it 'serializes an empty set as nil' do 50 | expect(@marshaler.serialize(Set.new)).to eq(nil) 51 | end 52 | 53 | it 'serializes numeric sets as themselves' do 54 | input = Set.new([1, 2.0, 3]) 55 | expected = Set.new([1, 2.0, 3]) 56 | expect(@marshaler.serialize(input)).to eq(expected) 57 | end 58 | end 59 | end 60 | end 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /spec/aws-record/record/marshalers/string_marshaler_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | module Aws 6 | module Record 7 | module Marshalers 8 | describe StringMarshaler do 9 | context 'default settings' do 10 | before(:each) do 11 | @marshaler = StringMarshaler.new 12 | end 13 | 14 | describe 'type casting' do 15 | it 'type casts nil as nil' do 16 | expect(@marshaler.type_cast(nil)).to be_nil 17 | end 18 | 19 | it 'type casts an empty string as an empty string' do 20 | expect(@marshaler.type_cast('')).to eq('') 21 | end 22 | 23 | it 'type casts a string as a string' do 24 | expect(@marshaler.type_cast('Hello')).to eq('Hello') 25 | end 26 | 27 | it 'type casts other types as a string' do 28 | expect(@marshaler.type_cast(5)).to eq('5') 29 | end 30 | end 31 | 32 | describe 'serialization for storage' do 33 | it 'stores strings as themselves' do 34 | expect(@marshaler.serialize('Hello')).to eq('Hello') 35 | end 36 | 37 | it 'attempts to type cast before storage' do 38 | expect(@marshaler.serialize(5)).to eq('5') 39 | end 40 | 41 | it 'identifies nil objects as the NULL type' do 42 | expect(@marshaler.serialize(nil)).to eq(nil) 43 | end 44 | 45 | it 'always serializes empty strings as NULL' do 46 | expect(@marshaler.serialize('')).to eq(nil) 47 | end 48 | 49 | it 'raises if #type_cast failed to create a string' do 50 | impossible = Class.new do 51 | def to_s 52 | 5 53 | end 54 | end 55 | 56 | expect { 57 | @marshaler.serialize(impossible.new) 58 | }.to raise_error(ArgumentError) 59 | end 60 | end 61 | end 62 | end 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /spec/aws-record/record/marshalers/string_set_marshaler_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | module Aws 6 | module Record 7 | module Marshalers 8 | describe StringSetMarshaler do 9 | context 'default settings' do 10 | before(:each) do 11 | @marshaler = StringSetMarshaler.new 12 | end 13 | 14 | describe '#type_cast' do 15 | it 'type casts nil as an empty set' do 16 | expect(@marshaler.type_cast(nil)).to eq(Set.new) 17 | end 18 | 19 | it 'type casts an empty string as an empty set' do 20 | expect(@marshaler.type_cast('')).to eq(Set.new) 21 | end 22 | 23 | it 'type casts string sets as themselves' do 24 | input = Set.new(%w[1 2 3]) 25 | expected = Set.new(%w[1 2 3]) 26 | expect(@marshaler.type_cast(input)).to eq(expected) 27 | end 28 | 29 | it 'type casts arrays to sets for you' do 30 | input = %w[1 2 3 2] 31 | expected = Set.new(%w[1 2 3]) 32 | expect(@marshaler.type_cast(input)).to eq(expected) 33 | end 34 | 35 | it 'attempts to stringify all contents of a set' do 36 | input = Set.new([1, '2', 3]) 37 | expected = Set.new(%w[1 2 3]) 38 | expect(@marshaler.type_cast(input)).to eq(expected) 39 | end 40 | 41 | it 'raises when it does not know how to typecast to a set' do 42 | expect { 43 | @marshaler.type_cast('fail') 44 | }.to raise_error(ArgumentError) 45 | end 46 | end 47 | 48 | describe '#serialize' do 49 | it 'serializes an empty set as nil' do 50 | expect(@marshaler.serialize(Set.new)).to eq(nil) 51 | end 52 | 53 | it 'serializes string sets as themselves' do 54 | input = Set.new(%w[1 2 3]) 55 | expected = Set.new(%w[1 2 3]) 56 | expect(@marshaler.serialize(input)).to eq(expected) 57 | end 58 | end 59 | end 60 | end 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /spec/aws-record/record/marshalers/time_marshaler_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'time' 5 | 6 | module Aws 7 | module Record 8 | module Marshalers 9 | describe TimeMarshaler do 10 | context 'default settings' do 11 | before(:each) do 12 | @marshaler = TimeMarshaler.new 13 | end 14 | 15 | describe 'type casting' do 16 | it 'casts nil and empty string as nil' do 17 | expect(@marshaler.type_cast(nil)).to be_nil 18 | expect(@marshaler.type_cast('')).to be_nil 19 | end 20 | 21 | it 'passes through Time objects' do 22 | expected = Time.parse('2015-11-15 17:12:56 +0700') 23 | input = Time.parse('2015-11-15 17:12:56 +0700') 24 | expect(@marshaler.type_cast(input)).to eq(expected) 25 | end 26 | 27 | it 'converts timestamps to Time' do 28 | expected = Time.parse('2009-02-13 23:31:30 UTC') 29 | input = 1_234_567_890 30 | expect(@marshaler.type_cast(input)).to eq(expected) 31 | end 32 | 33 | it 'converts DateTimes to Time' do 34 | expected = Time.parse('2009-02-13 23:31:30 UTC') 35 | input = DateTime.parse('2009-02-13 23:31:30 UTC') 36 | expect(@marshaler.type_cast(input)).to eq(expected) 37 | end 38 | 39 | it 'converts strings to Time' do 40 | expected = Time.parse('2009-02-13 23:31:30 UTC') 41 | input = '2009-02-13 23:31:30 UTC' 42 | expect(@marshaler.type_cast(input)).to eq(expected) 43 | end 44 | 45 | it 'converts automatically to utc' do 46 | expected = Time.parse('2016-07-20 23:31:10 UTC') 47 | input = '2016-07-20 16:31:10 -0700' 48 | expect(@marshaler.type_cast(input)).to eq(expected) 49 | end 50 | 51 | it 'raises when unable to parse as a Time' do 52 | expect { 53 | @marshaler.type_cast('that time when') 54 | }.to raise_error(ArgumentError) 55 | end 56 | end 57 | 58 | describe 'serialization for storage' do 59 | it 'serializes nil as null' do 60 | expect(@marshaler.serialize(nil)).to eq(nil) 61 | end 62 | 63 | it 'serializes Time as a string' do 64 | t = Time.parse('2009-02-13 23:31:30 UTC') 65 | expect(@marshaler.serialize(t)).to eq( 66 | '2009-02-13T23:31:30Z' 67 | ) 68 | end 69 | end 70 | end 71 | 72 | context 'use local time' do 73 | before(:each) do 74 | @marshaler = TimeMarshaler.new(use_local_time: true) 75 | end 76 | 77 | it 'does not automatically convert to utc' do 78 | expected = Time.parse('2016-07-20 16:31:10 -0700') 79 | input = '2016-07-20 16:31:10 -0700' 80 | expect(@marshaler.type_cast(input)).to eq(expected) 81 | end 82 | end 83 | 84 | context 'bring your own format' do 85 | let(:rfc2822_formatter) do 86 | Class.new do 87 | def self.format(time) 88 | time.rfc2822 89 | end 90 | end 91 | end 92 | 93 | before(:each) do 94 | @marshaler = TimeMarshaler.new(formatter: rfc2822_formatter) 95 | end 96 | 97 | it 'supports custom formatting' do 98 | expected = 'Wed, 20 Jul 2016 23:34:36 -0000' 99 | input = '2016-07-20T16:34:36-07:00' 100 | expect(@marshaler.serialize(input)).to eq(expected) 101 | end 102 | end 103 | end 104 | end 105 | end 106 | end 107 | -------------------------------------------------------------------------------- /spec/aws-record/record/query_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | module Aws 6 | module Record 7 | describe 'Query' do 8 | let(:klass) do 9 | Class.new do 10 | include(Aws::Record) 11 | set_table_name('TestTable') 12 | integer_attr(:id, hash_key: true) 13 | date_attr(:date, range_key: true) 14 | string_attr(:body) 15 | global_secondary_index( 16 | :reverse, 17 | hash_key: :date, 18 | range_key: :id, 19 | projection: { 20 | projection_type: 'ALL' 21 | } 22 | ) 23 | end 24 | end 25 | 26 | let(:api_requests) { [] } 27 | 28 | let(:stub_client) do 29 | requests = api_requests 30 | client = Aws::DynamoDB::Client.new(stub_responses: true) 31 | client.handle do |context| 32 | requests << context.params 33 | @handler.call(context) 34 | end 35 | client 36 | end 37 | 38 | describe '#query' do 39 | it 'can pass on a manually constructed query to the client' do 40 | stub_client.stub_responses( 41 | :query, 42 | items: 43 | [ 44 | { 45 | 'id' => 1, 46 | 'date' => '2016-01-25', 47 | 'body' => 'Item 1' 48 | }, 49 | { 50 | 'id' => 1, 51 | 'date' => '2016-01-26', 52 | 'body' => 'Item 2' 53 | } 54 | ], 55 | count: 2, 56 | scanned_count: 2, 57 | last_evaluated_key: nil 58 | ) 59 | klass.configure_client(client: stub_client) 60 | query_opts = { 61 | key_conditions: { 62 | id: { 63 | attribute_value_list: [1], 64 | comparison_operator: 'EQ' 65 | }, 66 | date: { 67 | attribute_value_list: ['2016-01-01'], 68 | comparison_operator: 'GT' 69 | } 70 | } 71 | } 72 | klass.query(query_opts).to_a 73 | expect(api_requests).to eq( 74 | [ 75 | { 76 | table_name: 'TestTable', 77 | key_conditions: { 78 | 'id' => { 79 | attribute_value_list: [{ n: '1' }], 80 | comparison_operator: 'EQ' 81 | }, 82 | 'date' => { 83 | attribute_value_list: [{ s: '2016-01-01' }], 84 | comparison_operator: 'GT' 85 | } 86 | } 87 | } 88 | ] 89 | ) 90 | end 91 | end 92 | 93 | describe '#scan' do 94 | it 'can pass on a manually constructed scan to the client' do 95 | stub_client.stub_responses( 96 | :scan, 97 | items: 98 | [ 99 | { 100 | 'id' => 1, 101 | 'date' => '2016-01-25', 102 | 'body' => 'Item 1' 103 | }, 104 | { 105 | 'id' => 1, 106 | 'date' => '2016-01-26', 107 | 'body' => 'Item 2' 108 | } 109 | ], 110 | count: 2, 111 | scanned_count: 2, 112 | last_evaluated_key: nil 113 | ) 114 | klass.configure_client(client: stub_client) 115 | klass.scan.to_a 116 | expect(api_requests).to eq([{ table_name: 'TestTable' }]) 117 | end 118 | end 119 | 120 | describe '#build_query' do 121 | it 'accepts frozen strings as the key expression (#115)' do 122 | klass.configure_client(client: stub_client) 123 | q = klass 124 | .build_query 125 | .key_expr( 126 | ':id = ? AND begins_with(date, ?)', 127 | 'my-id', 128 | '2019-07-15' 129 | ) 130 | .scan_ascending(false) 131 | .projection_expr(':body') 132 | .limit(10) 133 | .complete! 134 | q.to_a 135 | expect(api_requests).to eq( 136 | [ 137 | { 138 | table_name: 'TestTable', 139 | key_condition_expression: '#BUILDERA = :buildera AND begins_with(date, :builderb)', 140 | projection_expression: '#BUILDERB', 141 | limit: 10, 142 | scan_index_forward: false, 143 | expression_attribute_names: { 144 | '#BUILDERA' => 'id', 145 | '#BUILDERB' => 'body' 146 | }, 147 | expression_attribute_values: { 148 | ':buildera' => { s: 'my-id' }, 149 | ':builderb' => { s: '2019-07-15' } 150 | } 151 | } 152 | ] 153 | ) 154 | end 155 | 156 | it 'can build and run a query' do 157 | klass.configure_client(client: stub_client) 158 | q = klass 159 | .build_query 160 | .on_index(:reverse) 161 | .key_expr(':date = ?', '2019-07-15') 162 | .scan_ascending(false) 163 | .projection_expr(':body') 164 | .limit(10) 165 | .complete! 166 | q.to_a 167 | expect(api_requests).to eq( 168 | [ 169 | { 170 | table_name: 'TestTable', 171 | index_name: 'reverse', 172 | key_condition_expression: '#BUILDERA = :buildera', 173 | projection_expression: '#BUILDERB', 174 | limit: 10, 175 | scan_index_forward: false, 176 | expression_attribute_names: { 177 | '#BUILDERA' => 'date', 178 | '#BUILDERB' => 'body' 179 | }, 180 | expression_attribute_values: { 181 | ':buildera' => { s: '2019-07-15' } 182 | } 183 | } 184 | ] 185 | ) 186 | end 187 | end 188 | 189 | describe '#build_scan' do 190 | it 'can build and run a scan' do 191 | klass.configure_client(client: stub_client) 192 | klass.build_scan 193 | .consistent_read(false) 194 | .filter_expr(':body = ?', 'foo') 195 | .parallel_scan(total_segments: 5, segment: 2) 196 | .exclusive_start_key(id: 5, date: '2019-01-01') 197 | .complete!.to_a 198 | expect(api_requests).to eq( 199 | [ 200 | { 201 | table_name: 'TestTable', 202 | consistent_read: false, 203 | filter_expression: '#BUILDERA = :buildera', 204 | exclusive_start_key: { 205 | 'id' => { n: '5' }, 206 | 'date' => { s: '2019-01-01' } 207 | }, 208 | segment: 2, 209 | total_segments: 5, 210 | expression_attribute_names: { 211 | '#BUILDERA' => 'body' 212 | }, 213 | expression_attribute_values: { 214 | ':buildera' => { s: 'foo' } 215 | } 216 | } 217 | ] 218 | ) 219 | end 220 | 221 | it 'does not support key expressions' do 222 | expect { 223 | klass.build_scan.key_expr(':fail = ?', true) 224 | }.to raise_error(ArgumentError) 225 | end 226 | 227 | it 'does not support ascending scan settings' do 228 | expect { 229 | klass.build_scan.scan_ascending(false) 230 | }.to raise_error(ArgumentError) 231 | end 232 | end 233 | end 234 | end 235 | end 236 | -------------------------------------------------------------------------------- /spec/aws-record/record/secondary_indexes_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | module Aws 6 | module Record 7 | describe 'SecondaryIndexes' do 8 | let(:klass) do 9 | Class.new do 10 | include(Aws::Record) 11 | set_table_name('TestTable') 12 | integer_attr(:forum_id, hash_key: true) 13 | integer_attr(:post_id, range_key: true) 14 | string_attr(:forum_name) 15 | string_attr(:post_title) 16 | integer_attr(:author_id, database_attribute_name: 'a_id') 17 | string_attr(:author_name, database_attribute_name: 'a_name') 18 | string_attr(:post_body) 19 | end 20 | end 21 | 22 | let(:api_requests) { [] } 23 | 24 | let(:stub_client) do 25 | requests = api_requests 26 | client = Aws::DynamoDB::Client.new(stub_responses: true) 27 | client.handle do |context| 28 | requests << context.params 29 | @handler.call(context) 30 | end 31 | client 32 | end 33 | 34 | context 'Local Secondary Index' do 35 | describe '#local_secondary_index' do 36 | it 'allows you to define a local secondary index on the model' do 37 | klass.local_secondary_index( 38 | :title, 39 | range_key: :post_title, 40 | projection: { 41 | projection_type: 'ALL' 42 | } 43 | ) 44 | expect(klass.local_secondary_indexes[:title]).not_to eq(nil) 45 | end 46 | 47 | it 'requires that a range key is provided' do 48 | expect { 49 | klass.local_secondary_index( 50 | :fail, 51 | projection: { projection_type: 'ALL' } 52 | ) 53 | }.to raise_error(ArgumentError) 54 | end 55 | 56 | it 'requires use of an attribute that exists in the model' do 57 | expect { 58 | klass.local_secondary_index( 59 | :fail, 60 | range_key: :missingno, 61 | projection: { projection_type: 'ALL' } 62 | ) 63 | }.to raise_error(ArgumentError) 64 | end 65 | end 66 | 67 | describe '#local_secondary_indexes_for_migration' do 68 | it 'correctly translates database names for migration' do 69 | klass.local_secondary_index( 70 | :author, 71 | range_key: :author_id, 72 | projection: { 73 | projection_type: 'ALL' 74 | } 75 | ) 76 | migration = klass.local_secondary_indexes_for_migration 77 | expect(migration.size).to eq(1) 78 | expect(migration.first).to eq( 79 | index_name: :author, 80 | key_schema: [ 81 | { key_type: 'HASH', attribute_name: 'forum_id' }, 82 | { key_type: 'RANGE', attribute_name: 'a_id' } 83 | ], 84 | projection: { projection_type: 'ALL' } 85 | ) 86 | end 87 | end 88 | end 89 | 90 | context 'Global Secondary Indexes' do 91 | describe '#global_secondary_index' do 92 | it 'allows you to define a global secondary index on the model' do 93 | klass.global_secondary_index( 94 | :author, 95 | hash_key: :forum_name, 96 | range_key: :author_name, 97 | projection: { 98 | projection_type: 'ALL' 99 | } 100 | ) 101 | expect(klass.global_secondary_indexes[:author]).not_to eq(nil) 102 | end 103 | 104 | it 'requires that a hash key is provided' do 105 | expect { 106 | klass.global_secondary_index( 107 | :fail, 108 | projection: { projection_type: 'ALL' } 109 | ) 110 | }.to raise_error(ArgumentError) 111 | end 112 | 113 | it 'requires that the hash key exists in the model' do 114 | expect { 115 | klass.global_secondary_index( 116 | :fail, 117 | hash_key: :missingno, 118 | projection: { projection_type: 'ALL' } 119 | ) 120 | }.to raise_error(ArgumentError) 121 | end 122 | 123 | it 'requires that the range key exists in the model' do 124 | expect { 125 | klass.global_secondary_index( 126 | :fail, 127 | hash_key: :forum_name, 128 | range_key: :missingno, 129 | projection: { projection_type: 'ALL' } 130 | ) 131 | }.to raise_error(ArgumentError) 132 | end 133 | end 134 | 135 | describe '#global_secondary_indexes_for_migration' do 136 | it 'correctly translates database names for migration' do 137 | klass.global_secondary_index( 138 | :author, 139 | hash_key: :forum_name, 140 | range_key: :author_name, 141 | projection: { 142 | projection_type: 'ALL' 143 | } 144 | ) 145 | migration = klass.global_secondary_indexes_for_migration 146 | expect(migration.size).to eq(1) 147 | expect(migration.first).to eq( 148 | index_name: :author, 149 | key_schema: [ 150 | { key_type: 'HASH', attribute_name: 'forum_name' }, 151 | { key_type: 'RANGE', attribute_name: 'a_name' } 152 | ], 153 | projection: { projection_type: 'ALL' } 154 | ) 155 | end 156 | end 157 | end 158 | end 159 | 160 | describe 'inheritance support' do 161 | let(:parent_model) do 162 | Class.new do 163 | include(Aws::Record) 164 | integer_attr(:id, hash_key: true) 165 | string_attr(:name, range_key: true) 166 | string_attr(:message) 167 | end 168 | end 169 | 170 | let(:child_model) do 171 | Class.new(parent_model) do 172 | include(Aws::Record) 173 | string_attr(:foo) 174 | string_attr(:bar) 175 | end 176 | end 177 | 178 | it 'should have child model inherit secondary indexes from parent model' do 179 | parent_model.local_secondary_index(:local_index, hash_key: :id, range_key: :message) 180 | parent_model.global_secondary_index(:global_index, hash_key: :name, range_key: :message) 181 | 182 | expect(child_model.local_secondary_indexes).to eq(parent_model.local_secondary_indexes) 183 | expect(child_model.global_secondary_indexes).to eq(parent_model.global_secondary_indexes) 184 | end 185 | 186 | it 'allows the child model override parent indexes' do 187 | parent_model.local_secondary_index(:local_index, hash_key: :id, range_key: :message) 188 | parent_model.global_secondary_index(:global_index, hash_key: :name, range_key: :message) 189 | child_model.local_secondary_index(:local_index, hash_key: :id, range_key: :foo) 190 | child_model.global_secondary_index(:global_index, hash_key: :bar, range_key: :foo) 191 | 192 | expect(child_model.local_secondary_indexes).to eq(local_index: { hash_key: :id, range_key: :foo }) 193 | expect(child_model.global_secondary_indexes).to eq(global_index: { hash_key: :bar, range_key: :foo }) 194 | end 195 | end 196 | end 197 | end 198 | -------------------------------------------------------------------------------- /spec/aws-record/record_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | module Aws 6 | describe 'Record' do 7 | let(:api_requests) { [] } 8 | 9 | let(:stub_client) do 10 | requests = api_requests 11 | client = Aws::DynamoDB::Client.new(stub_responses: true) 12 | client.handle do |context| 13 | requests << context.params 14 | @handler.call(context) 15 | end 16 | client 17 | end 18 | 19 | describe '#table_name' do 20 | it 'should have an implied table name from the class name' do 21 | ::UnitTestModel = Class.new do 22 | include(Aws::Record) 23 | end 24 | expect(UnitTestModel.table_name).to eq('UnitTestModel') 25 | end 26 | 27 | it 'should allow a custom table name to be specified' do 28 | expected = 'ExpectedTableName' 29 | ::UnitTestModelTwo = Class.new do 30 | include(Aws::Record) 31 | set_table_name(expected) 32 | end 33 | expect(::UnitTestModelTwo.table_name).to eq(expected) 34 | end 35 | 36 | it 'should transform outer modules for default table name' do 37 | expected = 'OuterOne_OuterTwo_ClassTableName' 38 | ::OuterOne = Module.new 39 | ::OuterOne::OuterTwo = Module.new 40 | ::OuterOne::OuterTwo::ClassTableName = Class.new do 41 | include(Aws::Record) 42 | end 43 | expect(::OuterOne::OuterTwo::ClassTableName.table_name).to eq(expected) 44 | end 45 | end 46 | 47 | describe '#provisioned_throughput' do 48 | let(:model) do 49 | Class.new do 50 | include(Aws::Record) 51 | set_table_name('TestTable') 52 | end 53 | end 54 | 55 | it 'should fetch the provisioned throughput for the table on request' do 56 | stub_client.stub_responses( 57 | :describe_table, 58 | table: { 59 | provisioned_throughput: { 60 | read_capacity_units: 25, 61 | write_capacity_units: 10 62 | } 63 | } 64 | ) 65 | model.configure_client(client: stub_client) 66 | resp = model.provisioned_throughput 67 | expect(api_requests).to eq([{ table_name: 'TestTable' }]) 68 | expect(resp).to eq( 69 | read_capacity_units: 25, 70 | write_capacity_units: 10 71 | ) 72 | end 73 | 74 | it 'should raise a TableDoesNotExist error if the table does not exist' do 75 | stub_client.stub_responses(:describe_table, 'ResourceNotFoundException') 76 | model.configure_client(client: stub_client) 77 | expect { model.provisioned_throughput }.to raise_error( 78 | Record::Errors::TableDoesNotExist 79 | ) 80 | end 81 | end 82 | 83 | describe '#table_exists' do 84 | let(:model) do 85 | Class.new do 86 | include(Aws::Record) 87 | set_table_name('TestTable') 88 | end 89 | end 90 | 91 | it 'can check if the table exists' do 92 | stub_client.stub_responses(:describe_table, table: { table_status: 'ACTIVE' }) 93 | model.configure_client(client: stub_client) 94 | expect(model.table_exists?).to eq(true) 95 | end 96 | 97 | it 'will not recognize a table as existing if it is not active' do 98 | stub_client.stub_responses(:describe_table, table: { table_status: 'CREATING' }) 99 | model.configure_client(client: stub_client) 100 | expect(model.table_exists?).to eq(false) 101 | end 102 | 103 | it 'will answer false to #table_exists? if the table does not exist in DynamoDB' do 104 | stub_client.stub_responses(:describe_table, 'ResourceNotFoundException') 105 | model.configure_client(client: stub_client) 106 | expect(model.table_exists?).to eq(false) 107 | end 108 | end 109 | 110 | describe '#track_mutations' do 111 | let(:model) do 112 | Class.new do 113 | include(Aws::Record) 114 | set_table_name('TestTable') 115 | string_attr(:uuid, hash_key: true) 116 | attr(:mt, Aws::Record::Marshalers::StringMarshaler.new) 117 | end 118 | end 119 | 120 | it 'is on by default' do 121 | expect(model.mutation_tracking_enabled?).to be_truthy 122 | end 123 | 124 | it 'can turn off mutation tracking globally for a model' do 125 | model.disable_mutation_tracking 126 | expect(model.mutation_tracking_enabled?).to be_falsy 127 | end 128 | end 129 | 130 | describe 'default_value' do 131 | let(:model) do 132 | Class.new do 133 | include(Aws::Record) 134 | set_table_name('TestTable') 135 | string_attr(:uuid, hash_key: true) 136 | map_attr(:things, default_value: {}) 137 | end 138 | end 139 | 140 | it 'uses a deep copy of the default_value' do 141 | model.new.things['foo'] = 'bar' 142 | expect(model.new.things).to eq({}) 143 | end 144 | end 145 | 146 | describe 'attribute_names' do 147 | let(:model) do 148 | Class.new do 149 | include(Aws::Record) 150 | set_table_name('TestTable') 151 | string_attr(:uuid, hash_key: true) 152 | string_attr(:other_attr) 153 | end 154 | end 155 | 156 | describe '.attribute_names' do 157 | it 'returns the attribute names' do 158 | expect(model.attribute_names).to eq(%i[uuid other_attr]) 159 | end 160 | end 161 | 162 | describe '#attribute_names' do 163 | it 'returns the attribute names' do 164 | expect(model.new.attribute_names).to eq(%i[uuid other_attr]) 165 | end 166 | end 167 | end 168 | 169 | describe 'inheritance support for table name' do 170 | let(:parent_model) do 171 | Class.new do 172 | include(Aws::Record) 173 | set_table_name('ParentTable') 174 | end 175 | end 176 | 177 | let(:child_model) do 178 | Class.new(parent_model) do 179 | include(Aws::Record) 180 | end 181 | end 182 | 183 | it 'should have child model inherit table name from parent model if it is defined in parent model' do 184 | expect(parent_model.table_name).to eq('ParentTable') 185 | expect(child_model.table_name).to eq('ParentTable') 186 | end 187 | 188 | it 'should have child model override parent table name if defined in model' do 189 | child_model.set_table_name('ChildTable') 190 | expect(parent_model.table_name).to eq('ParentTable') 191 | expect(child_model.table_name).to eq('ChildTable') 192 | end 193 | 194 | it 'should have parent and child models maintain their default table names' do 195 | ::ParentModel = Class.new do 196 | include(Aws::Record) 197 | end 198 | ::ChildModel = Class.new(ParentModel) do 199 | include(Aws::Record) 200 | end 201 | 202 | expect(ParentModel.table_name).to eq('ParentModel') 203 | expect(ChildModel.table_name).to eq('ChildModel') 204 | end 205 | end 206 | 207 | describe 'inheritance support for track mutations' do 208 | let(:parent_model) do 209 | Class.new do 210 | include(Aws::Record) 211 | integer_attr(:id, hash_key: true) 212 | end 213 | end 214 | 215 | let(:child_model) do 216 | Class.new(parent_model) do 217 | include(Aws::Record) 218 | string_attr(:foo) 219 | end 220 | end 221 | 222 | it 'should have child model inherit track mutations from parent model' do 223 | parent_model.disable_mutation_tracking 224 | expect(parent_model.mutation_tracking_enabled?).to be_falsy 225 | expect(child_model.mutation_tracking_enabled?).to be_falsy 226 | end 227 | 228 | it 'should have child model maintain its own track mutations if defined in model' do 229 | child_model.disable_mutation_tracking 230 | expect(parent_model.mutation_tracking_enabled?).to be_truthy 231 | expect(child_model.mutation_tracking_enabled?).to be_falsy 232 | end 233 | end 234 | end 235 | end 236 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'simplecov' 4 | require 'rspec' 5 | SimpleCov.start 6 | 7 | require 'aws-record' 8 | require 'active_model' 9 | --------------------------------------------------------------------------------