├── .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 |
--------------------------------------------------------------------------------