├── .devtools └── templates │ ├── changelog.erb │ └── release.erb ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug-report.md │ └── config.yml ├── SUPPORT.md └── workflows │ ├── ci.yml │ ├── docsite.yml │ ├── rubocop.yml │ └── sync_configs.yml ├── .gitignore ├── .repobot.yml ├── .rspec ├── .rubocop.yml ├── .yardopts ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Gemfile ├── Gemfile.devtools ├── LICENSE ├── README.md ├── Rakefile ├── benchmarks ├── basic.rb ├── constrained.rb ├── profile_instantiation.rb └── setup.rb ├── bin ├── .gitkeep ├── console └── setup ├── changelog.yml ├── docsite └── source │ ├── index.html.md │ ├── nested-structs.html.md │ └── recipes.html.md ├── dry-struct.gemspec ├── lib ├── dry-struct.rb └── dry │ ├── struct.rb │ └── struct │ ├── class_interface.rb │ ├── compiler.rb │ ├── constructor.rb │ ├── errors.rb │ ├── extensions.rb │ ├── extensions │ ├── pretty_print.rb │ └── super_diff.rb │ ├── hashify.rb │ ├── printer.rb │ ├── struct_builder.rb │ ├── sum.rb │ ├── value.rb │ └── version.rb ├── log └── .gitkeep ├── project.yml └── spec ├── extensions ├── pretty_print_spec.rb └── super_diff_spec.rb ├── integration ├── array_spec.rb ├── attribute_dsl │ ├── abstract_struct_spec.rb │ ├── definition_spec.rb │ ├── nested_array_spec.rb │ └── nested_struct_spec.rb ├── attributes_from_spec.rb ├── compile_spec.rb ├── constructor_spec.rb ├── dry_spec.rb ├── pattern_matching_spec.rb ├── struct_spec.rb ├── sum_spec.rb └── value_spec.rb ├── shared ├── struct.rb └── user_type.rb ├── spec_helper.rb ├── support ├── coverage.rb ├── rspec_options.rb └── warnings.rb └── unit └── struct_spec.rb /.devtools/templates/changelog.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | <% releases.each_with_index do |r, idx| %> 5 | ## <%= r.version %> <%= r.date %> 6 | 7 | <% if r.summary %> 8 | <%= r.summary %> 9 | 10 | <% end %> 11 | 12 | <% if r.added? %> 13 | ### Added 14 | 15 | <% r.added.each do |log| %> 16 | - <%= log %> 17 | <% end %> 18 | 19 | <% end %> 20 | <% if r.fixed? %> 21 | ### Fixed 22 | 23 | <% r.fixed.each do |log| %> 24 | - <%= log %> 25 | <% end %> 26 | 27 | <% end %> 28 | <% if r.changed? %> 29 | ### Changed 30 | 31 | <% r.changed.each do |log| %> 32 | - <%= log %> 33 | <% end %> 34 | <% end %> 35 | <% curr_ver = r.date ? "v#{r.version}" : 'master' %> 36 | <% prev_rel = releases[idx + 1] %> 37 | <% if prev_rel %> 38 | <% ver_range = "v#{prev_rel.version}...#{curr_ver}" %> 39 | 40 | [Compare <%=ver_range%>](https://github.com/dry-rb/<%= project.name %>/compare/<%=ver_range%>) 41 | <% end %> 42 | 43 | <% end %> 44 | -------------------------------------------------------------------------------- /.devtools/templates/release.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | <% if latest_release.summary %> 5 | <%= latest_release.summary %> 6 | 7 | <% end %> 8 | 9 | <% if latest_release.added? %> 10 | ### Added 11 | 12 | <% latest_release.added.each do |log| %> 13 | - <%= log %> 14 | <% end %> 15 | 16 | <% end %> 17 | <% if latest_release.fixed? %> 18 | ### Fixed 19 | 20 | <% latest_release.fixed.each do |log| %> 21 | - <%= log %> 22 | <% end %> 23 | 24 | <% end %> 25 | <% if latest_release.changed? %> 26 | ### Changed 27 | 28 | <% latest_release.changed.each do |log| %> 29 | - <%= log %> 30 | <% end %> 31 | <% end %> 32 | <% if previous_release %> 33 | <% ver_range = "v#{previous_release.version}...v#{latest_release.version}" %> 34 | 35 | [Compare <%=ver_range%>](https://github.com/dry-rb/<%= project.name %>/compare/<%=ver_range%>) 36 | <% end %> 37 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: hanami 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "\U0001F41B Bug report" 3 | about: See CONTRIBUTING.md for more information 4 | title: '' 5 | labels: bug, help wanted 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Describe the bug 11 | 12 | A clear and concise description of what the bug is. 13 | 14 | ## To Reproduce 15 | 16 | Provide detailed steps to reproduce, **an executable script would be best**. 17 | 18 | ## Expected behavior 19 | 20 | A clear and concise description of what you expected to happen. 21 | 22 | ## My environment 23 | 24 | - Affects my production application: **YES/NO** 25 | - Ruby version: ... 26 | - OS: ... 27 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Community Support 4 | url: https://discourse.dry-rb.org 5 | about: Please ask and answer questions here. 6 | -------------------------------------------------------------------------------- /.github/SUPPORT.md: -------------------------------------------------------------------------------- 1 | ## Support 2 | 3 | If you need help with any of the dry-rb libraries, feel free to ask questions on our [discussion forum](https://discourse.dry-rb.org/). This is the best place to seek help. Make sure to search for a potential solution in past threads before posting your question. Thanks! :heart: 4 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # This file is synced from dry-rb/template-gem repo 2 | name: CI 3 | 4 | on: 5 | push: 6 | paths: 7 | - ".github/workflows/ci.yml" 8 | - "lib/**" 9 | - "*.gemspec" 10 | - "spec/**" 11 | - "Rakefile" 12 | - "Gemfile" 13 | - "Gemfile.devtools" 14 | - ".rubocop.yml" 15 | - "project.yml" 16 | pull_request: 17 | branches: 18 | - main 19 | create: 20 | 21 | jobs: 22 | tests: 23 | runs-on: ubuntu-latest 24 | name: Tests 25 | strategy: 26 | fail-fast: false 27 | matrix: 28 | ruby: 29 | - "3.4" 30 | - "3.3" 31 | # ensure 3.3.0 passes 32 | # see https://github.com/dry-rb/dry-types/issues/478 33 | - "3.3.0" 34 | - "3.2" 35 | - "3.1" 36 | include: 37 | - ruby: "3.4" 38 | coverage: "true" 39 | env: 40 | COVERAGE: ${{matrix.coverage}} 41 | COVERAGE_TOKEN: ${{secrets.CODACY_PROJECT_TOKEN}} 42 | steps: 43 | - name: Checkout 44 | uses: actions/checkout@v3 45 | - name: Install package dependencies 46 | run: "[ -e $APT_DEPS ] || sudo apt-get install -y --no-install-recommends $APT_DEPS" 47 | - name: Set up Ruby 48 | uses: ruby/setup-ruby@v1 49 | with: 50 | ruby-version: ${{matrix.ruby}} 51 | bundler-cache: true 52 | - name: Run all tests 53 | run: bundle exec rake 54 | release: 55 | runs-on: ubuntu-latest 56 | if: contains(github.ref, 'tags') && github.event_name == 'create' 57 | needs: tests 58 | env: 59 | GITHUB_LOGIN: dry-bot 60 | GITHUB_TOKEN: ${{secrets.GH_PAT}} 61 | steps: 62 | - uses: actions/checkout@v3 63 | - name: Install package dependencies 64 | run: "[ -e $APT_DEPS ] || sudo apt-get install -y --no-install-recommends $APT_DEPS" 65 | - name: Set up Ruby 66 | uses: ruby/setup-ruby@v1 67 | with: 68 | ruby-version: 3.4 69 | - name: Install dependencies 70 | run: gem install ossy --no-document 71 | - name: Trigger release workflow 72 | run: | 73 | tag=$(echo $GITHUB_REF | cut -d / -f 3) 74 | ossy gh w dry-rb/devtools release --payload "{\"tag\":\"$tag\",\"sha\":\"${{github.sha}}\",\"tag_creator\":\"$GITHUB_ACTOR\",\"repo\":\"$GITHUB_REPOSITORY\",\"repo_name\":\"${{github.event.repository.name}}\"}" 75 | -------------------------------------------------------------------------------- /.github/workflows/docsite.yml: -------------------------------------------------------------------------------- 1 | # This file is synced from dry-rb/template-gem repo 2 | 3 | name: docsite 4 | 5 | on: 6 | push: 7 | paths: 8 | - docsite/** 9 | - .github/workflows/docsite.yml 10 | branches: 11 | - main 12 | - release-** 13 | tags: 14 | 15 | jobs: 16 | update-docs: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v3 20 | with: 21 | fetch-depth: 0 22 | - run: | 23 | git fetch --no-tags --prune --depth=1 origin +refs/heads/*:refs/remotes/origin/* 24 | - name: Set up Ruby 25 | uses: ruby/setup-ruby@v1 26 | with: 27 | ruby-version: "3.0.5" 28 | - name: Set up git user 29 | run: | 30 | git config --local user.email "dry-bot@dry-rb.org" 31 | git config --local user.name "dry-bot" 32 | - name: Install dependencies 33 | run: gem install ossy --no-document 34 | - name: Update release branches 35 | run: | 36 | branches=`git log --format=%B -n 1 $GITHUB_SHA | grep "docsite:release-" || echo "nothing"` 37 | 38 | if [[ ! $branches -eq "nothing" ]]; then 39 | for b in $branches 40 | do 41 | name=`echo $b | ruby -e 'puts gets[/:(.+)/, 1].gsub(/\s+/, "")'` 42 | 43 | echo "merging $GITHUB_SHA to $name" 44 | 45 | git checkout -b $name --track origin/$name 46 | 47 | echo `git log -n 1` 48 | 49 | git cherry-pick $GITHUB_SHA -m 1 50 | done 51 | 52 | git push --all "https://dry-bot:${{secrets.GH_PAT}}@github.com/$GITHUB_REPOSITORY.git" 53 | 54 | git checkout main 55 | else 56 | echo "no need to update branches" 57 | fi 58 | - name: Trigger dry-rb.org deploy 59 | env: 60 | GITHUB_LOGIN: dry-bot 61 | GITHUB_TOKEN: ${{secrets.GH_PAT}} 62 | run: ossy github workflow dry-rb/dry-rb.org ci 63 | -------------------------------------------------------------------------------- /.github/workflows/rubocop.yml: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This file is synced from dry-rb/template-gem repo 4 | 5 | name: RuboCop 6 | 7 | on: [push, pull_request] 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | env: 16 | BUNDLE_ONLY: tools 17 | 18 | steps: 19 | - uses: actions/checkout@v3 20 | 21 | - name: Set up Ruby 3.2 22 | uses: ruby/setup-ruby@v1 23 | with: 24 | ruby-version: 3.2 25 | bundler-cache: true 26 | 27 | - name: Run RuboCop 28 | run: bundle exec rubocop --parallel 29 | -------------------------------------------------------------------------------- /.github/workflows/sync_configs.yml: -------------------------------------------------------------------------------- 1 | # This file is synced from dry-rb/template-gem repo 2 | 3 | name: sync 4 | 5 | on: 6 | repository_dispatch: 7 | push: 8 | branches: 9 | - "main" 10 | 11 | jobs: 12 | main: 13 | runs-on: ubuntu-latest 14 | if: (github.event_name == 'repository_dispatch' && github.event.action == 'sync_configs') || github.event_name != 'repository_dispatch' 15 | env: 16 | GITHUB_LOGIN: dry-bot 17 | GITHUB_TOKEN: ${{ secrets.GH_PAT }} 18 | steps: 19 | - name: Checkout ${{github.repository}} 20 | uses: actions/checkout@v3 21 | - name: Checkout devtools 22 | uses: actions/checkout@v3 23 | with: 24 | repository: dry-rb/devtools 25 | path: tmp/devtools 26 | - name: Setup git user 27 | run: | 28 | git config --local user.email "dry-bot@dry-rb.org" 29 | git config --local user.name "dry-bot" 30 | - name: Set up Ruby 31 | uses: ruby/setup-ruby@v1 32 | with: 33 | ruby-version: "3.1" 34 | - name: Install dependencies 35 | run: gem install ossy --no-document 36 | - name: Update changelog.yml from commit 37 | run: tmp/devtools/bin/update-changelog-from-commit $GITHUB_SHA 38 | - name: Compile CHANGELOG.md 39 | run: tmp/devtools/bin/compile-changelog 40 | - name: Commit 41 | run: | 42 | git add -A 43 | git commit -m "[devtools] sync" || echo "nothing to commit" 44 | - name: Push changes 45 | run: | 46 | git pull --rebase origin main 47 | git push https://dry-bot:${{secrets.GH_PAT}}@github.com/${{github.repository}}.git HEAD:main 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | *.log 11 | 12 | .DS_Store 13 | .vscode 14 | .ruby-version 15 | .ruby-gemset 16 | CLAUDE.md 17 | -------------------------------------------------------------------------------- /.repobot.yml: -------------------------------------------------------------------------------- 1 | ########################################################### 2 | # DO NOT EDIT THIS FILE 3 | # 4 | # This is a config synced from dry-rb/template-gem repo 5 | ########################################################### 6 | 7 | sources: 8 | - repo: dry-rb/template-gem 9 | sync: 10 | - ".repobot.yml.erb" 11 | - ".devtools/templates/*.sync:${{dir}}/${{name}}" 12 | - ".github/**/*.*" 13 | - ".rspec" 14 | - ".rubocop.yml" 15 | - "gemspec.erb:dry-struct.gemspec" 16 | - "spec/support/*" 17 | - "CODE_OF_CONDUCT.md" 18 | - "CONTRIBUTING.md" 19 | - "LICENSE.erb" 20 | - "README.md.erb" 21 | - "Gemfile.devtools" 22 | - repo: repobot-app/workflows 23 | sync: 24 | - ".github/workflows/*.yml" 25 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --require spec_helper 3 | --order random 4 | --warnings 5 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | # This is a config synced from dry-rb/template-gem repo 2 | 3 | AllCops: 4 | TargetRubyVersion: 3.1 5 | NewCops: enable 6 | SuggestExtensions: false 7 | Exclude: 8 | - "**/vendor/**/*" # For GitHub Actions, see rubocop/rubocop#9832 9 | - benchmarks/*.rb 10 | - spec/support/coverage.rb 11 | - spec/support/warnings.rb 12 | - spec/support/rspec_options.rb 13 | - Gemfile.devtools 14 | - "*.gemspec" 15 | 16 | Layout/SpaceAroundMethodCallOperator: 17 | Enabled: false 18 | 19 | Layout/SpaceInLambdaLiteral: 20 | Enabled: false 21 | 22 | Layout/MultilineMethodCallIndentation: 23 | Enabled: true 24 | EnforcedStyle: indented 25 | 26 | Layout/FirstArrayElementIndentation: 27 | EnforcedStyle: consistent 28 | 29 | Layout/SpaceInsideHashLiteralBraces: 30 | Enabled: true 31 | EnforcedStyle: no_space 32 | EnforcedStyleForEmptyBraces: no_space 33 | 34 | Layout/LineLength: 35 | Max: 100 36 | Exclude: 37 | - "spec/**/*.rb" 38 | 39 | Lint/AmbiguousBlockAssociation: 40 | Enabled: true 41 | # because 'expect { foo }.to change { bar }' is fine 42 | Exclude: 43 | - "spec/**/*.rb" 44 | 45 | Lint/BooleanSymbol: 46 | Enabled: false 47 | 48 | Lint/ConstantDefinitionInBlock: 49 | Exclude: 50 | - "spec/**/*.rb" 51 | 52 | Lint/RaiseException: 53 | Enabled: false 54 | 55 | Lint/StructNewOverride: 56 | Enabled: false 57 | 58 | Lint/SuppressedException: 59 | Exclude: 60 | - "spec/spec_helper.rb" 61 | 62 | Lint/LiteralAsCondition: 63 | Exclude: 64 | - "spec/**/*.rb" 65 | 66 | Naming/PredicateName: 67 | Enabled: false 68 | 69 | Naming/FileName: 70 | Exclude: 71 | - "lib/*-*.rb" 72 | 73 | Naming/MethodName: 74 | Enabled: false 75 | 76 | Naming/MethodParameterName: 77 | Enabled: false 78 | 79 | Naming/MemoizedInstanceVariableName: 80 | Enabled: false 81 | 82 | Metrics/MethodLength: 83 | Enabled: false 84 | 85 | Metrics/ClassLength: 86 | Enabled: false 87 | 88 | Metrics/BlockLength: 89 | Enabled: false 90 | 91 | Metrics/AbcSize: 92 | Max: 25 93 | 94 | Metrics/CyclomaticComplexity: 95 | Enabled: true 96 | Max: 12 97 | 98 | Style/ExponentialNotation: 99 | Enabled: false 100 | 101 | Style/HashEachMethods: 102 | Enabled: false 103 | 104 | Style/HashTransformKeys: 105 | Enabled: false 106 | 107 | Style/HashTransformValues: 108 | Enabled: false 109 | 110 | Style/AccessModifierDeclarations: 111 | Enabled: false 112 | 113 | Style/Alias: 114 | Enabled: true 115 | EnforcedStyle: prefer_alias_method 116 | 117 | Style/AsciiComments: 118 | Enabled: false 119 | 120 | Style/BlockDelimiters: 121 | Enabled: false 122 | 123 | Style/ClassAndModuleChildren: 124 | Exclude: 125 | - "spec/**/*.rb" 126 | 127 | Style/ConditionalAssignment: 128 | Enabled: false 129 | 130 | Style/DateTime: 131 | Enabled: false 132 | 133 | Style/Documentation: 134 | Enabled: false 135 | 136 | Style/EachWithObject: 137 | Enabled: false 138 | 139 | Style/FormatString: 140 | Enabled: false 141 | 142 | Style/FormatStringToken: 143 | Enabled: false 144 | 145 | Style/GuardClause: 146 | Enabled: false 147 | 148 | Style/IfUnlessModifier: 149 | Enabled: false 150 | 151 | Style/Lambda: 152 | Enabled: false 153 | 154 | Style/LambdaCall: 155 | Enabled: false 156 | 157 | Style/ParallelAssignment: 158 | Enabled: false 159 | 160 | Style/RaiseArgs: 161 | Enabled: false 162 | 163 | Style/StabbyLambdaParentheses: 164 | Enabled: false 165 | 166 | Style/StringLiterals: 167 | Enabled: true 168 | EnforcedStyle: double_quotes 169 | ConsistentQuotesInMultiline: false 170 | 171 | Style/StringLiteralsInInterpolation: 172 | Enabled: true 173 | EnforcedStyle: double_quotes 174 | 175 | Style/SymbolArray: 176 | Exclude: 177 | - "spec/**/*.rb" 178 | 179 | Style/TrailingUnderscoreVariable: 180 | Enabled: false 181 | 182 | Style/MultipleComparison: 183 | Enabled: false 184 | 185 | Style/Next: 186 | Enabled: false 187 | 188 | Style/AccessorGrouping: 189 | Enabled: false 190 | 191 | Style/EmptyLiteral: 192 | Enabled: false 193 | 194 | Style/Semicolon: 195 | Exclude: 196 | - "spec/**/*.rb" 197 | 198 | Style/HashAsLastArrayItem: 199 | Exclude: 200 | - "spec/**/*.rb" 201 | 202 | Style/CaseEquality: 203 | Exclude: 204 | - "lib/dry/monads/**/*.rb" 205 | - "lib/dry/struct/**/*.rb" 206 | - "lib/dry/types/**/*.rb" 207 | - "spec/**/*.rb" 208 | 209 | Style/ExplicitBlockArgument: 210 | Exclude: 211 | - "lib/dry/types/**/*.rb" 212 | 213 | Style/CombinableLoops: 214 | Enabled: false 215 | 216 | Style/EmptyElse: 217 | Enabled: false 218 | 219 | Style/DoubleNegation: 220 | Enabled: false 221 | 222 | Style/MultilineBlockChain: 223 | Enabled: false 224 | 225 | Style/NumberedParametersLimit: 226 | Max: 2 227 | 228 | Lint/UnusedBlockArgument: 229 | Exclude: 230 | - "spec/**/*.rb" 231 | 232 | Lint/Debugger: 233 | Exclude: 234 | - "bin/console" 235 | 236 | Lint/BinaryOperatorWithIdenticalOperands: 237 | Exclude: 238 | - "spec/**/*.rb" 239 | 240 | Metrics/ParameterLists: 241 | Exclude: 242 | - "spec/**/*.rb" 243 | 244 | Lint/EmptyBlock: 245 | Exclude: 246 | - "spec/**/*.rb" 247 | 248 | Lint/EmptyFile: 249 | Exclude: 250 | - "spec/**/*.rb" 251 | 252 | Lint/UselessMethodDefinition: 253 | Exclude: 254 | - "spec/**/*.rb" 255 | 256 | Lint/SelfAssignment: 257 | Enabled: false 258 | 259 | Lint/EmptyClass: 260 | Enabled: false 261 | 262 | Naming/ConstantName: 263 | Exclude: 264 | - "spec/**/*.rb" 265 | 266 | Naming/VariableNumber: 267 | Exclude: 268 | - "spec/**/*.rb" 269 | 270 | Naming/BinaryOperatorParameterName: 271 | Enabled: false 272 | -------------------------------------------------------------------------------- /.yardopts: -------------------------------------------------------------------------------- 1 | --title 'dry-struct' 2 | --markup markdown 3 | --readme README.md 4 | lib/**/*.rb 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## 1.8.0 2025-03-09 4 | 5 | 6 | ### Added 7 | 8 | - Added super_diff extension for improved struct diffing in RSpec tests (@flash-gordon in #197) 9 | 10 | Add this to your Gemfile: 11 | ```ruby 12 | gem 'super_diff', group: :test 13 | ``` 14 | 15 | Then activate the extension in your spec_helper: 16 | ```ruby 17 | Dry::Struct.load_extensions(:super_diff) 18 | ``` 19 | 20 | Now this 21 | 22 | ```ruby 23 | expected: # 24 | got: # 25 | 26 | (compared using eql?) 27 | 28 | Diff: 29 | @@ -1 +1 @@ 30 | -# 31 | +# 32 | ``` 33 | 34 | will become this: 35 | 36 | ```ruby 37 | expected: # 38 | got: # 39 | 40 | (compared using eql?) 41 | 42 | # 47 | ``` 48 | 49 | 50 | [Compare v1.7.1...v1.8.0](https://github.com/dry-rb/dry-struct/compare/v1.7.1...v1.8.0) 51 | 52 | ## 1.7.1 2025-01-31 53 | 54 | 55 | ### Fixed 56 | 57 | - Syntax errors on 3.3.0 (@flash-gordon, see https://github.com/dry-rb/dry-types/issues/478) 58 | 59 | 60 | [Compare v1.7.0...v1.7.1](https://github.com/dry-rb/dry-struct/compare/v1.7.0...v1.7.1) 61 | 62 | ## 1.7.0 2025-01-06 63 | 64 | 65 | ### Fixed 66 | 67 | - Fixed coercion errors for structs (issue #192 via #193) (@flash-gordon) 68 | - Invalid method names are now allowed as struct attributes (issue #169 via #195) (@flash-gordon) 69 | 70 | ### Changed 71 | 72 | - Missing attribute error now includes the name of the class (issue #170 via #191) (@phillipoertel + @cllns) 73 | - 3.1 is now the minimum Ruby version (@flash-gordon) 74 | - `Dry::Struct::Error` is now a subclass of `Dry::Types::CoercionError` (in #193) (@flash-gordon) 75 | - `Dry::Struct#[]` now returns `nil` if an optional attribute is not set. This is consistent with calling accessor methods for optional attributes. (issue #171 via #194) (@ivleonov + @flash-gordon) 76 | 77 | [Compare v1.6.0...v1.7.0](https://github.com/dry-rb/dry-struct/compare/v1.6.0...v1.7.0) 78 | 79 | ## 1.6.0 2022-11-04 80 | 81 | 82 | ### Changed 83 | 84 | - This version uses dry-core 1.0 (@flash-gordon + @solnic) 85 | 86 | [Compare v1.5.2...v1.6.0](https://github.com/dry-rb/dry-struct/compare/v1.5.2...v1.6.0) 87 | 88 | ## 1.5.2 2022-10-19 89 | 90 | 91 | ### Fixed 92 | 93 | - Coercion failures keep the original error instead of just having a string (@flash-gordon + @newx) 94 | 95 | 96 | [Compare v1.5.1...v1.5.2](https://github.com/dry-rb/dry-struct/compare/v1.5.1...v1.5.2) 97 | 98 | ## 1.5.1 2022-10-17 99 | 100 | 101 | ### Fixed 102 | 103 | - Fixed issues with auto-loading `Extensions` module (issue #183 fixed via #184) (@solnic) 104 | 105 | 106 | [Compare v1.5.0...v1.5.1](https://github.com/dry-rb/dry-struct/compare/v1.5.0...v1.5.1) 107 | 108 | ## 1.5.0 2022-10-15 109 | 110 | 111 | ### Changed 112 | 113 | - Use zeitwerk for auto-loading (@flash-gordon) 114 | 115 | [Compare v1.4.0...v1.5.0](https://github.com/dry-rb/dry-struct/compare/v1.4.0...v1.5.0) 116 | 117 | ## 1.4.0 2021-01-21 118 | 119 | 120 | ### Added 121 | 122 | - Support for wrapping constructors and fallbacks, see release notes for dry-types 1.5.0 (@flash-gordon) 123 | - Improvements of the attribute DSL, now it's possible to use optional structs as a base class (@flash-gordon) 124 | ```ruby 125 | class User < Dry::Struct 126 | attribute :name, Types::String 127 | attribute :address, Dry::Struct.optional do 128 | attribute :city, Types::String 129 | end 130 | end 131 | 132 | User.new(name: "John", address: nil) # => # 133 | ``` 134 | 135 | 136 | [Compare v1.3.0...v1.4.0](https://github.com/dry-rb/dry-struct/compare/v1.3.0...v1.4.0) 137 | 138 | ## 1.3.0 2020-02-10 139 | 140 | 141 | ### Added 142 | 143 | - Nested structures will reuse type and key transformations from the enclosing struct (@flash-gordon) 144 | 145 | ```ruby 146 | class User < Dry::Struct 147 | transform_keys(&:to_sym) 148 | 149 | attribute :name, Types::String 150 | attribute :address do 151 | # this struct will inherit transform_keys(&:to_sym) 152 | attribute :city, Types::String 153 | end 154 | 155 | # nested struct will _not_ transform keys because a parent 156 | # struct is given 157 | attribute :contacts, Dry::Struct do 158 | attribute :email, Types::String 159 | end 160 | end 161 | ``` 162 | - `Dry::Struct::Constructor` finally acts like a fully-featured type (@flash-gordon) 163 | - `Dry::Struct.abstract` declares a struct class as abstract. An abstract class is used as a default superclass for nested structs (@flash-gordon) 164 | - `Dry::Struct.to_ast` and struct compiler (@flash-gordon) 165 | - Struct composition with `Dry::Struct.attributes_from`. It's more flexible than inheritance (@waiting-for-dev + @flash-gordon) 166 | 167 | ```ruby 168 | class Address < Dry::Struct 169 | attribute :city, Types::String 170 | attribute :zipcode, Types::String 171 | end 172 | 173 | class Buyer < Dry::Struct 174 | attribute :name, Types::String 175 | attributes_from Address 176 | end 177 | 178 | class Seller < Dry::Struct 179 | attribute :name, Types::String 180 | attribute :email, Types::String 181 | attributes_from Address 182 | end 183 | ``` 184 | 185 | ### Changed 186 | 187 | - [internal] metadata is now stored inside schema (@flash-gordon) 188 | 189 | [Compare v1.2.0...v1.3.0](https://github.com/dry-rb/dry-struct/compare/v1.2.0...v1.3.0) 190 | 191 | ## 1.2.0 2019-12-20 192 | 193 | 194 | ### Changed 195 | 196 | - `Dry::Struct::Value` is deprecated. `Dry::Struct` instances were never meant to be mutable, we have no support for this. The only difference between `Dry::Struct` and `Dry::Struct::Value` is that the latter is deeply frozen. Freezing objects slows the code down and gives you very little benefit in return. If you have a use case for `Value`, it won't be hard to roll your own solution using [ice_nine](https://github.com/dkubb/ice_nine) (flash-gordon) 197 | - In the thread of the previous change, structs now use immutable equalizer. This means `Struct#hash` memoizes its value after the first invocation. Depending on the case, this may speed up your code significantly (flash-gordon) 198 | 199 | [Compare v1.1.1...v1.2.0](https://github.com/dry-rb/dry-struct/compare/v1.1.1...v1.2.0) 200 | 201 | ## 1.1.1 2019-10-13 202 | 203 | 204 | ### Changed 205 | 206 | - Pattern matching syntax is simplified with `deconstruct_keys` (k-tsj) 207 | 208 | ```ruby 209 | User = Dry.Struct(name: 'string', email: 'string') 210 | 211 | user = User.new(name: 'John Doe', email: 'john@acme.org') 212 | 213 | case user 214 | in User(name: 'John Doe', email:) 215 | puts email 216 | else 217 | puts 'Not John' 218 | end 219 | ``` 220 | 221 | See more examples in the [specs](https://github.com/dry-rb/dry-struct/blob/8112772eb08d22ff2cd3e6997514d79a9b124968/spec/dry/struct/pattern_matching_spec.rb). 222 | 223 | [Compare v1.1.0...v1.1.1](https://github.com/dry-rb/dry-struct/compare/v1.1.0...v1.1.1) 224 | 225 | ## 1.1.0 2019-10-07 226 | 227 | 228 | ### Added 229 | 230 | - Experimental support for pattern matching :tada: (flash-gordon) 231 | 232 | 233 | [Compare v1.0.0...v1.1.0](https://github.com/dry-rb/dry-struct/compare/v1.0.0...v1.1.0) 234 | 235 | ## 1.0.0 2019-04-23 236 | 237 | 238 | ### Added 239 | 240 | - `Struct.call` now accepts an optional block that will be called on failed coercion. This behavior is consistent with dry-types 1.0. Note that `.new` doesn't take a block (flash-gordon) 241 | ```ruby 242 | User = Dry::Struct(name: 'string') 243 | User.(1) { :oh_no } 244 | # => :oh_no 245 | ``` 246 | 247 | ### Changed 248 | 249 | - `valid?` and `===` behave differently, `===` works the same way `Class#===` does and `valid?` checks if the value _can be_ coerced to the struct (flash-gordon) 250 | 251 | [Compare v0.7.0...v1.0.0](https://github.com/dry-rb/dry-struct/compare/v0.7.0...v1.0.0) 252 | 253 | ## 0.7.0 2019-03-22 254 | 255 | 256 | ### Changed 257 | 258 | - [BREAKING] `Struct.input` was renamed `Struct.schema`, hence `Struct.schema` returns an instance of `Dry::Types::Hash::Schema` rather than a `Hash`. Schemas are also implementing `Enumerable` but they iterate over key types. 259 | New API: 260 | ```ruby 261 | User.schema.each do |key| 262 | puts "Key name: #{ key.name }" 263 | puts "Key type: #{ key.type }" 264 | end 265 | ``` 266 | To get a type by its name use `.key`: 267 | ```ruby 268 | User.schema.key(:id) # => # 269 | ``` 270 | - [BREAKING] `transform_types` now passes one argument to the block, an instance of the `Key` type. Combined with the new API from dry-types it simplifies declaring omittable keys: 271 | ```ruby 272 | class StructWithOptionalKeys < Dry::Struct 273 | transform_types { |key| key.required(false) } 274 | # or simply 275 | transform_types(&:omittable) 276 | end 277 | ``` 278 | - `Dry::Stuct#new` is now more efficient for partial updates (flash-gordon) 279 | - Ruby 2.3 is EOL and not officially supported. It may work but we don't test it. 280 | 281 | [Compare v0.6.0...v0.7.0](https://github.com/dry-rb/dry-struct/compare/v0.6.0...v0.7.0) 282 | 283 | ## 0.6.0 2018-10-24 284 | 285 | 286 | ### Added 287 | 288 | - `Struct.attribute?` is an easy way to define omittable attributes (flash-gordon): 289 | 290 | ```ruby 291 | class User < Dry::Struct 292 | attribute :name, Types::Strict::String 293 | attribute? :email, Types::Strict::String 294 | end 295 | # User.new(name: 'John') # => # 296 | ``` 297 | 298 | ### Fixed 299 | 300 | - `Struct#to_h` recursively converts hash values to hashes, this was done to be consistent with current behavior for arrays (oeoeaio + ZimbiX) 301 | 302 | ### Changed 303 | 304 | - [BREAKING] `Struct.attribute?` in the old sense is deprecated, use `has_attribute?` as a replacement 305 | 306 | [Compare v0.5.1...v0.6.0](https://github.com/dry-rb/dry-struct/compare/v0.5.1...v0.6.0) 307 | 308 | ## 0.5.1 2018-08-11 309 | 310 | 311 | ### Added 312 | 313 | - Pretty print extension (ojab) 314 | ```ruby 315 | Dry::Struct.load_extensions(:pretty_print) 316 | PP.pp(user) 317 | #> 321 | ``` 322 | 323 | ### Fixed 324 | 325 | - Constant resolution is now restricted to the current module when structs are automatically defined using the block syntax. This shouldn't break any existing code (piktur) 326 | 327 | 328 | [Compare v0.5.0...v0.5.1](https://github.com/dry-rb/dry-struct/compare/v0.5.0...v0.5.1) 329 | 330 | ## 0.5.0 2018-05-03 331 | 332 | 333 | ### Added 334 | 335 | - `Dry::Struct.transform_types` accepts a block which is yielded on every type to add. Since types are `dry-types`' objects that come with a robust DSL it's rather simple to restore the behavior of `constructor_type`. See https://github.com/dry-rb/dry-struct/pull/64 for details (flash-gordon) 336 | 337 | Example: evaluate defaults on `nil` values 338 | 339 | ```ruby 340 | class User < Dry::Struct 341 | transform_types do |type| 342 | type.constructor { |value| value.nil? ? Undefined : value } 343 | end 344 | end 345 | ``` 346 | - `Data::Struct.transform_keys` accepts a block/proc that transforms keys of input hashes. The most obvious usage is simbolization but arbitrary transformations are allowed (flash-gordon) 347 | - `Dry.Struct` builds a struct by a hash of attribute names and types (citizen428) 348 | 349 | ```ruby 350 | User = Dry::Struct(name: 'strict.string') do 351 | attribute :email, 'strict.string' 352 | end 353 | ``` 354 | - Support for `Struct.meta`, note that `.meta` returns a _new class_ (flash-gordon) 355 | 356 | ```ruby 357 | class User < Dry::Struct 358 | attribute :name, Dry::Types['strict.string'] 359 | end 360 | 361 | UserWithMeta = User.meta(foo: :bar) 362 | 363 | User.new(name: 'Jade').class == UserWithMeta.new(name: 'Jade').class # => false 364 | ``` 365 | - `Struct.attribute` yields a block with definition for nested structs. It defines a nested constant for the new struct and supports arrays (AMHOL + flash-gordon) 366 | 367 | ```ruby 368 | class User < Dry::Struct 369 | attribute :name, Types::Strict::String 370 | attribute :address do 371 | attribute :country, Types::Strict::String 372 | attribute :city, Types::Strict::String 373 | end 374 | attribute :accounts, Types::Strict::Array do 375 | attribute :currency, Types::Strict::String 376 | attribute :balance, Types::Strict::Decimal 377 | end 378 | end 379 | 380 | # ^This automatically defines User::Address and User::Account 381 | ``` 382 | 383 | ### Fixed 384 | 385 | - Adding a new attribute invalidates `attribute_names` (flash-gordon) 386 | - Struct classes track subclasses and define attributes in them, now it doesn't matter whether you define attributes first and _then_ subclass or vice versa. Note this can lead to memory leaks in Rails environment when struct classes are reloaded (flash-gordon) 387 | 388 | 389 | [Compare v0.4.0...v0.5.0](https://github.com/dry-rb/dry-struct/compare/v0.4.0...v0.5.0) 390 | 391 | ## 0.4.0 2017-11-04 392 | 393 | 394 | ### Fixed 395 | 396 | - `Struct#new` doesn't call `.to_hash` recursively (flash-gordon) 397 | 398 | ### Changed 399 | 400 | - Attribute readers don't override existing instance methods (solnic) 401 | - `Struct#new` uses raw attributes instead of method calls, this makes the behavior consistent with the change above (flash-gordon) 402 | - `constructor_type` now actively rejects `:weak` and `:symbolized` values (GustavoCaso) 403 | 404 | [Compare v0.3.1...v0.4.0](https://github.com/dry-rb/dry-struct/compare/v0.3.1...v0.4.0) 405 | 406 | ## 0.3.1 2017-06-30 407 | 408 | 409 | ### Added 410 | 411 | - `Struct.constructor` that makes dry-struct more aligned with dry-types; now you can have a struct with a custom constructor that will be called _before_ calling the `new` method (v-kolesnikov) 412 | - `Struct.attribute?` and `Struct.attribute_names` for introspecting struct attributes (flash-gordon) 413 | - `Struct#__new__` is a safe-to-use-in-gems alias for `Struct#new` (flash-gordon) 414 | 415 | 416 | [Compare v0.3.0...v0.3.1](https://github.com/dry-rb/dry-struct/compare/v0.3.0...v0.3.1) 417 | 418 | ## 0.3.0 2017-05-05 419 | 420 | 421 | ### Added 422 | 423 | - `Dry::Struct#new` method to return new instance with applied changeset (Kukunin) 424 | 425 | ### Fixed 426 | 427 | - `.[]` and `.call` does not coerce subclass to superclass anymore (Kukunin) 428 | - Raise ArgumentError when attribute type is a string and no value provided is for `new` (GustavoCaso) 429 | 430 | ### Changed 431 | 432 | - `.new` without arguments doesn't use nil as an input for non-default types anymore (flash-gordon) 433 | 434 | [Compare v0.2.1...v0.3.0](https://github.com/dry-rb/dry-struct/compare/v0.2.1...v0.3.0) 435 | 436 | ## 0.2.1 2017-02-27 437 | 438 | 439 | ### Fixed 440 | 441 | - Fixed `Dry::Struct::Value` which appeared to be broken in the last release (flash-gordon) 442 | 443 | 444 | [Compare v0.2.0...v0.2.1](https://github.com/dry-rb/dry-struct/compare/v0.2.0...v0.2.1) 445 | 446 | ## 0.2.0 2016-02-26 447 | 448 | 449 | ### Changed 450 | 451 | - Struct attributes can be overridden in a subclass (flash-gordon) 452 | 453 | [Compare v0.1.1...v0.2.0](https://github.com/dry-rb/dry-struct/compare/v0.1.1...v0.2.0) 454 | 455 | ## 0.1.1 2016-11-13 456 | 457 | 458 | ### Fixed 459 | 460 | - Make `Dry::Struct` act as a constrained type. This fixes the behavior of sum types containing structs (flash-gordon) 461 | 462 | 463 | [Compare v0.1.0...v0.1.1](https://github.com/dry-rb/dry-struct/compare/v0.1.0...v0.1.1) 464 | 465 | ## 0.1.0 2016-09-21 466 | 467 | 468 | ### Added 469 | 470 | - `:strict_with_defaults` constructor type (backus) 471 | 472 | ### Changed 473 | 474 | - [BREAKING] `:strict` was renamed to `:permissive` as it ignores missing keys (backus) 475 | - [BREAKING] `:strict` now raises on unexpected keys (backus) 476 | - Structs no longer auto-register themselves in the types container as they implement `Type` interface and we don't have to wrap them in `Type::Definition` (flash-gordon) 477 | 478 | [Compare v0.0.1...v0.1.0](https://github.com/dry-rb/dry-struct/compare/v0.0.1...v0.1.0) 479 | 480 | ## 0.0.1 2016-07-17 481 | 482 | Initial release of code imported from dry-types 483 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, caste, color, religion, or sexual 10 | identity and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the overall 26 | community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or advances of 31 | any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email address, 35 | without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | team at dry-rb.org. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series of 86 | actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or permanent 93 | ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within the 113 | community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.1, available at 119 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 120 | 121 | Community Impact Guidelines were inspired by 122 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 123 | 124 | For answers to common questions about this code of conduct, see the FAQ at 125 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at 126 | [https://www.contributor-covenant.org/translations][translations]. 127 | 128 | [homepage]: https://www.contributor-covenant.org 129 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 130 | [Mozilla CoC]: https://github.com/mozilla/diversity 131 | [FAQ]: https://www.contributor-covenant.org/faq 132 | [translations]: https://www.contributor-covenant.org/translations 133 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Issue Guidelines 2 | 3 | ## Reporting bugs 4 | 5 | If you found a bug, report an issue and describe what's the expected behavior versus what actually happens. If the bug causes a crash, attach a full backtrace. If possible, a reproduction script showing the problem is highly appreciated. 6 | 7 | ## Reporting feature requests 8 | 9 | Report a feature request **only after discussing it first on [discourse.dry-rb.org](https://discourse.dry-rb.org)** where it was accepted. Please provide a concise description of the feature. 10 | 11 | ## Reporting questions, support requests, ideas, concerns etc. 12 | 13 | **PLEASE DON'T** - use [discourse.dry-rb.org](https://discourse.dry-rb.org) instead. 14 | 15 | # Pull Request Guidelines 16 | 17 | A Pull Request will only be accepted if it addresses a specific issue that was reported previously, or fixes typos, mistakes in documentation etc. 18 | 19 | Other requirements: 20 | 21 | 1) Do not open a pull request if you can't provide tests along with it. If you have problems writing tests, ask for help in the related issue. 22 | 2) Follow the style conventions of the surrounding code. In most cases, this is standard ruby style. 23 | 3) Add API documentation if it's a new feature 24 | 4) Update API documentation if it changes an existing feature 25 | 5) Bonus points for sending a PR which updates user documentation in the `docsite` directory 26 | 27 | # Asking for help 28 | 29 | If these guidelines aren't helpful, and you're stuck, please post a message on [discourse.dry-rb.org](https://discourse.dry-rb.org). 30 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | eval_gemfile "Gemfile.devtools" 6 | 7 | gemspec 8 | 9 | group :test do 10 | gem "dry-monads", github: "dry-rb/dry-monads" 11 | gem "super_diff" 12 | end 13 | 14 | group :benchmarks do 15 | gem "activerecord" 16 | gem "attrio" 17 | gem "benchmark-ips" 18 | gem "fast_attributes" 19 | # gem "hotch", platform: :mri 20 | gem "sqlite3", platform: :mri 21 | gem "virtus" 22 | end 23 | -------------------------------------------------------------------------------- /Gemfile.devtools: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This file is synced from dry-rb/template-gem repo 4 | 5 | gem "rake", ">= 12.3.3" 6 | 7 | group :test do 8 | gem "simplecov", require: false, platforms: :ruby 9 | gem "simplecov-cobertura", require: false, platforms: :ruby 10 | gem "rexml", require: false 11 | gem "rspec" 12 | 13 | gem "warning" 14 | end 15 | 16 | group :tools do 17 | gem "rubocop", "~> 1.69.2" 18 | gem "byebug" 19 | gem "yard" 20 | 21 | if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("3.4.0") 22 | gem "debug" 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015-2023 dry-rb team 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | [gem]: https://rubygems.org/gems/dry-struct 3 | [actions]: https://github.com/dry-rb/dry-struct/actions 4 | 5 | # dry-struct [![Gem Version](https://badge.fury.io/rb/dry-struct.svg)][gem] [![CI Status](https://github.com/dry-rb/dry-struct/workflows/CI/badge.svg)][actions] 6 | 7 | ## Links 8 | 9 | * [User documentation](https://dry-rb.org/gems/dry-struct) 10 | * [API documentation](http://rubydoc.info/gems/dry-struct) 11 | * [Forum](https://discourse.dry-rb.org) 12 | 13 | ## Supported Ruby versions 14 | 15 | This library officially supports the following Ruby versions: 16 | 17 | * MRI `>= 3.1` 18 | * jruby `>= 9.4` (not tested on CI) 19 | 20 | ## License 21 | 22 | See `LICENSE` file. 23 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | require "rspec/core/rake_task" 5 | 6 | RSpec::Core::RakeTask.new(:spec) 7 | 8 | task default: :spec 9 | 10 | require "yard" 11 | require "yard/rake/yardoc_task" 12 | YARD::Rake::YardocTask.new(:doc) 13 | -------------------------------------------------------------------------------- /benchmarks/basic.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "dry/struct" 4 | require "virtus" 5 | require "fast_attributes" 6 | require "attrio" 7 | require "ostruct" 8 | 9 | require "benchmark/ips" 10 | 11 | class VirtusUser 12 | include Virtus.model 13 | 14 | attribute :name, String 15 | attribute :age, Integer 16 | end 17 | 18 | class FastUser 19 | extend FastAttributes 20 | 21 | define_attributes initialize: true, attributes: true do 22 | attribute :name, String 23 | attribute :age, Integer 24 | end 25 | end 26 | 27 | class AttrioUser 28 | include Attrio 29 | 30 | define_attributes do 31 | attr :name, String 32 | attr :age, Integer 33 | end 34 | 35 | def initialize(attributes = {}) 36 | self.attributes = attributes 37 | end 38 | 39 | def attributes=(attributes = {}) 40 | attributes.each do |attr, value| 41 | send("#{attr}=", value) if respond_to?("#{attr}=") 42 | end 43 | end 44 | end 45 | 46 | class DryStructUser < Dry::Struct 47 | attributes(name: "strict.string", age: "params.integer") 48 | end 49 | 50 | puts DryStructUser.new(name: "Jane", age: "21").inspect 51 | 52 | Benchmark.ips do |x| 53 | x.report("virtus") { VirtusUser.new(name: "Jane", age: "21") } 54 | x.report("fast_attributes") { FastUser.new(name: "Jane", age: "21") } 55 | x.report("attrio") { AttrioUser.new(name: "Jane", age: "21") } 56 | x.report("dry-struct") { DryStructUser.new(name: "Jane", age: "21") } 57 | 58 | x.compare! 59 | end 60 | -------------------------------------------------------------------------------- /benchmarks/constrained.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "dry/struct" 4 | 5 | require "active_record" 6 | require "benchmark/ips" 7 | 8 | ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:") 9 | 10 | ActiveRecord::Schema.define do 11 | create_table :users do |table| 12 | table.column :name, :string 13 | table.column :age, :integer 14 | end 15 | end 16 | 17 | class ARUser < ActiveRecord::Base 18 | self.table_name = :users 19 | end 20 | 21 | module Types 22 | include Dry.Types 23 | end 24 | 25 | class DryStructUser < Dry::Struct 26 | attribute :id, Types::Params::Integer 27 | attribute :name, Types::Strict::String.constrained(size: 3..64) 28 | attribute :age, Types::Params::Integer.constrained(gt: 18) 29 | end 30 | 31 | puts ARUser.new(id: 1, name: "Jane", age: "21").inspect 32 | puts DryStructUser.new(id: 1, name: "Jane", age: "21").inspect 33 | 34 | Benchmark.ips do |x| 35 | x.report("active record") { ARUser.new(id: 1, name: "Jane", age: "21") } 36 | x.report("dry-struct") { DryStructUser.new(id: 1, name: "Jane", age: "21") } 37 | 38 | x.compare! 39 | end 40 | -------------------------------------------------------------------------------- /benchmarks/profile_instantiation.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "setup" 4 | 5 | ATTR_NAMES = %i[attr0 attr1 attr2 attr3 attr4 attr5 attr6 attr7 attr8 attr9].freeze 6 | 7 | class Integers < Dry::Struct 8 | ATTR_NAMES.each do |name| 9 | attribute? name, "coercible.integer" 10 | end 11 | end 12 | 13 | integers = {attr0: 0, attr1: 1, attr2: 2, attr3: 3, attr4: 4, attr5: 5, attr6: 6, attr7: 7, 14 | attr8: 8, attr9: 9} 15 | 16 | require "pry-byebug" 17 | 18 | profile do 19 | 1_000_000.times do 20 | Integers.new(integers) 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /benchmarks/setup.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "benchmark/ips" 4 | require "hotch" 5 | ENV["HOTCH_VIEWER"] ||= "open" 6 | 7 | require "dry-struct" 8 | 9 | def profile(&block) 10 | Hotch(filter: "Dry", &block) 11 | end 12 | -------------------------------------------------------------------------------- /bin/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dry-rb/dry-struct/9fcdbcc1d9a582a18bf3740914152610d9dd79fa/bin/.gitkeep -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "bundler/setup" 5 | require "dry/struct" 6 | 7 | require "irb" 8 | 9 | module Types 10 | include Dry.Types() 11 | end 12 | 13 | binding.irb 14 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | 5 | bundle install 6 | 7 | # Do any other automated setup that you need to do here 8 | -------------------------------------------------------------------------------- /changelog.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - version: 1.8.0 3 | date: 2025-03-09 4 | added: 5 | - |- 6 | Added super_diff extension for improved struct diffing in RSpec tests (@flash-gordon in #197) 7 | 8 | Add this to your Gemfile: 9 | ```ruby 10 | gem 'super_diff', group: :test 11 | ``` 12 | 13 | Then activate the extension in your spec_helper: 14 | ```ruby 15 | Dry::Struct.load_extensions(:super_diff) 16 | ``` 17 | 18 | Now this 19 | 20 | ```ruby 21 | expected: # 22 | got: # 23 | 24 | (compared using eql?) 25 | 26 | Diff: 27 | @@ -1 +1 @@ 28 | -# 29 | +# 30 | ``` 31 | 32 | will become this: 33 | 34 | ```ruby 35 | expected: # 36 | got: # 37 | 38 | (compared using eql?) 39 | 40 | # 45 | ``` 46 | 47 | - version: 1.7.1 48 | date: 2025-01-31 49 | fixed: 50 | - "Syntax errors on 3.3.0 (@flash-gordon, see https://github.com/dry-rb/dry-types/issues/478)" 51 | - version: 1.7.0 52 | summary: 53 | date: 2025-01-06 54 | fixed: 55 | - 'Fixed coercion errors for structs (issue #192 via #193) 56 | (@flash-gordon)' 57 | - "Invalid method names are now allowed as struct attributes (issue #169 via #195) (@flash-gordon)" 58 | changed: 59 | - 'Missing attribute error now includes the name of the class (issue #170 via #191) 60 | (@phillipoertel + @cllns)' 61 | - '3.1 is now the minimum Ruby version (@flash-gordon)' 62 | - '`Dry::Struct::Error` is now a subclass of `Dry::Types::CoercionError` (in #193) 63 | (@flash-gordon)' 64 | - '`Dry::Struct#[]` now returns `nil` if an optional attribute is not set. 65 | This is consistent with calling accessor methods for optional attributes. 66 | (issue #171 via #194) (@ivleonov + @flash-gordon)' 67 | - version: 1.6.0 68 | date: 2022-11-04 69 | changed: 70 | - This version uses dry-core 1.0 (@flash-gordon + @solnic) 71 | - version: 1.5.2 72 | date: '2022-10-19' 73 | fixed: 74 | - Coercion failures keep the original error instead of just having a string (@flash-gordon 75 | + @newx) 76 | - version: 1.5.1 77 | date: '2022-10-17' 78 | fixed: 79 | - 'Fixed issues with auto-loading `Extensions` module (issue #183 fixed via #184) 80 | (@solnic)' 81 | - version: 1.5.0 82 | date: '2022-10-15' 83 | added: 84 | changed: 85 | - Use zeitwerk for auto-loading (@flash-gordon) 86 | - version: 1.4.0 87 | date: '2021-01-21' 88 | added: 89 | - Support for wrapping constructors and fallbacks, see release notes for dry-types 90 | 1.5.0 (@flash-gordon) 91 | - |- 92 | Improvements of the attribute DSL, now it's possible to use optional structs as a base class (@flash-gordon) 93 | ```ruby 94 | class User < Dry::Struct 95 | attribute :name, Types::String 96 | attribute :address, Dry::Struct.optional do 97 | attribute :city, Types::String 98 | end 99 | end 100 | 101 | User.new(name: "John", address: nil) # => # 102 | ``` 103 | - version: 1.3.0 104 | date: '2020-02-10' 105 | added: 106 | - |- 107 | Nested structures will reuse type and key transformations from the enclosing struct (@flash-gordon) 108 | 109 | ```ruby 110 | class User < Dry::Struct 111 | transform_keys(&:to_sym) 112 | 113 | attribute :name, Types::String 114 | attribute :address do 115 | # this struct will inherit transform_keys(&:to_sym) 116 | attribute :city, Types::String 117 | end 118 | 119 | # nested struct will _not_ transform keys because a parent 120 | # struct is given 121 | attribute :contacts, Dry::Struct do 122 | attribute :email, Types::String 123 | end 124 | end 125 | ``` 126 | - "`Dry::Struct::Constructor` finally acts like a fully-featured type (@flash-gordon)" 127 | - "`Dry::Struct.abstract` declares a struct class as abstract. An abstract class 128 | is used as a default superclass for nested structs (@flash-gordon)" 129 | - "`Dry::Struct.to_ast` and struct compiler (@flash-gordon)" 130 | - |- 131 | Struct composition with `Dry::Struct.attributes_from`. It's more flexible than inheritance (@waiting-for-dev + @flash-gordon) 132 | 133 | ```ruby 134 | class Address < Dry::Struct 135 | attribute :city, Types::String 136 | attribute :zipcode, Types::String 137 | end 138 | 139 | class Buyer < Dry::Struct 140 | attribute :name, Types::String 141 | attributes_from Address 142 | end 143 | 144 | class Seller < Dry::Struct 145 | attribute :name, Types::String 146 | attribute :email, Types::String 147 | attributes_from Address 148 | end 149 | ``` 150 | changed: 151 | - "[internal] metadata is now stored inside schema (@flash-gordon)" 152 | - version: 1.2.0 153 | date: '2019-12-20' 154 | changed: 155 | - "`Dry::Struct::Value` is deprecated. `Dry::Struct` instances were never meant 156 | to be mutable, we have no support for this. The only difference between `Dry::Struct` 157 | and `Dry::Struct::Value` is that the latter is deeply frozen. Freezing objects 158 | slows the code down and gives you very little benefit in return. If you have a 159 | use case for `Value`, it won't be hard to roll your own solution using [ice_nine](https://github.com/dkubb/ice_nine) 160 | (flash-gordon)" 161 | - In the thread of the previous change, structs now use immutable equalizer. This 162 | means `Struct#hash` memoizes its value after the first invocation. Depending on 163 | the case, this may speed up your code significantly (flash-gordon) 164 | - version: 1.1.1 165 | date: '2019-10-13' 166 | changed: 167 | - |- 168 | Pattern matching syntax is simplified with `deconstruct_keys` (k-tsj) 169 | 170 | ```ruby 171 | User = Dry.Struct(name: 'string', email: 'string') 172 | 173 | user = User.new(name: 'John Doe', email: 'john@acme.org') 174 | 175 | case user 176 | in User(name: 'John Doe', email:) 177 | puts email 178 | else 179 | puts 'Not John' 180 | end 181 | ``` 182 | 183 | See more examples in the [specs](https://github.com/dry-rb/dry-struct/blob/8112772eb08d22ff2cd3e6997514d79a9b124968/spec/dry/struct/pattern_matching_spec.rb). 184 | - version: 1.1.0 185 | date: '2019-10-07' 186 | added: 187 | - 'Experimental support for pattern matching :tada: (flash-gordon)' 188 | - version: 1.0.0 189 | date: '2019-04-23' 190 | changed: 191 | - "`valid?` and `===` behave differently, `===` works the same way `Class#===` does 192 | and `valid?` checks if the value _can be_ coerced to the struct (flash-gordon)" 193 | added: 194 | - |- 195 | `Struct.call` now accepts an optional block that will be called on failed coercion. This behavior is consistent with dry-types 1.0. Note that `.new` doesn't take a block (flash-gordon) 196 | ```ruby 197 | User = Dry::Struct(name: 'string') 198 | User.(1) { :oh_no } 199 | # => :oh_no 200 | ``` 201 | - version: 0.7.0 202 | date: '2019-03-22' 203 | changed: 204 | - |- 205 | [BREAKING] `Struct.input` was renamed `Struct.schema`, hence `Struct.schema` returns an instance of `Dry::Types::Hash::Schema` rather than a `Hash`. Schemas are also implementing `Enumerable` but they iterate over key types. 206 | New API: 207 | ```ruby 208 | User.schema.each do |key| 209 | puts "Key name: #{ key.name }" 210 | puts "Key type: #{ key.type }" 211 | end 212 | ``` 213 | To get a type by its name use `.key`: 214 | ```ruby 215 | User.schema.key(:id) # => # 216 | ``` 217 | - |- 218 | [BREAKING] `transform_types` now passes one argument to the block, an instance of the `Key` type. Combined with the new API from dry-types it simplifies declaring omittable keys: 219 | ```ruby 220 | class StructWithOptionalKeys < Dry::Struct 221 | transform_types { |key| key.required(false) } 222 | # or simply 223 | transform_types(&:omittable) 224 | end 225 | ``` 226 | - "`Dry::Stuct#new` is now more efficient for partial updates (flash-gordon)" 227 | - Ruby 2.3 is EOL and not officially supported. It may work but we don't test it. 228 | - version: 0.6.0 229 | date: '2018-10-24' 230 | changed: 231 | - "[BREAKING] `Struct.attribute?` in the old sense is deprecated, use `has_attribute?` 232 | as a replacement" 233 | added: 234 | - |- 235 | `Struct.attribute?` is an easy way to define omittable attributes (flash-gordon): 236 | 237 | ```ruby 238 | class User < Dry::Struct 239 | attribute :name, Types::Strict::String 240 | attribute? :email, Types::Strict::String 241 | end 242 | # User.new(name: 'John') # => # 243 | ``` 244 | fixed: 245 | - "`Struct#to_h` recursively converts hash values to hashes, this was done to be 246 | consistent with current behavior for arrays (oeoeaio + ZimbiX)" 247 | - version: 0.5.1 248 | date: '2018-08-11' 249 | fixed: 250 | - Constant resolution is now restricted to the current module when structs are automatically 251 | defined using the block syntax. This shouldn't break any existing code (piktur) 252 | added: 253 | - |- 254 | Pretty print extension (ojab) 255 | ```ruby 256 | Dry::Struct.load_extensions(:pretty_print) 257 | PP.pp(user) 258 | #> 262 | ``` 263 | - version: 0.5.0 264 | date: '2018-05-03' 265 | added: 266 | - |- 267 | `Dry::Struct.transform_types` accepts a block which is yielded on every type to add. Since types are `dry-types`' objects that come with a robust DSL it's rather simple to restore the behavior of `constructor_type`. See https://github.com/dry-rb/dry-struct/pull/64 for details (flash-gordon) 268 | 269 | Example: evaluate defaults on `nil` values 270 | 271 | ```ruby 272 | class User < Dry::Struct 273 | transform_types do |type| 274 | type.constructor { |value| value.nil? ? Undefined : value } 275 | end 276 | end 277 | ``` 278 | - "`Data::Struct.transform_keys` accepts a block/proc that transforms keys of input 279 | hashes. The most obvious usage is simbolization but arbitrary transformations 280 | are allowed (flash-gordon)" 281 | - |- 282 | `Dry.Struct` builds a struct by a hash of attribute names and types (citizen428) 283 | 284 | ```ruby 285 | User = Dry::Struct(name: 'strict.string') do 286 | attribute :email, 'strict.string' 287 | end 288 | ``` 289 | - |- 290 | Support for `Struct.meta`, note that `.meta` returns a _new class_ (flash-gordon) 291 | 292 | ```ruby 293 | class User < Dry::Struct 294 | attribute :name, Dry::Types['strict.string'] 295 | end 296 | 297 | UserWithMeta = User.meta(foo: :bar) 298 | 299 | User.new(name: 'Jade').class == UserWithMeta.new(name: 'Jade').class # => false 300 | ``` 301 | - |- 302 | `Struct.attribute` yields a block with definition for nested structs. It defines a nested constant for the new struct and supports arrays (AMHOL + flash-gordon) 303 | 304 | ```ruby 305 | class User < Dry::Struct 306 | attribute :name, Types::Strict::String 307 | attribute :address do 308 | attribute :country, Types::Strict::String 309 | attribute :city, Types::Strict::String 310 | end 311 | attribute :accounts, Types::Strict::Array do 312 | attribute :currency, Types::Strict::String 313 | attribute :balance, Types::Strict::Decimal 314 | end 315 | end 316 | 317 | # ^This automatically defines User::Address and User::Account 318 | ``` 319 | fixed: 320 | - Adding a new attribute invalidates `attribute_names` (flash-gordon) 321 | - Struct classes track subclasses and define attributes in them, now it doesn't 322 | matter whether you define attributes first and _then_ subclass or vice versa. 323 | Note this can lead to memory leaks in Rails environment when struct classes are 324 | reloaded (flash-gordon) 325 | - version: 0.4.0 326 | date: '2017-11-04' 327 | changed: 328 | - Attribute readers don't override existing instance methods (solnic) 329 | - "`Struct#new` uses raw attributes instead of method calls, this makes the behavior 330 | consistent with the change above (flash-gordon)" 331 | - "`constructor_type` now actively rejects `:weak` and `:symbolized` values (GustavoCaso)" 332 | fixed: 333 | - "`Struct#new` doesn't call `.to_hash` recursively (flash-gordon)" 334 | - version: 0.3.1 335 | date: '2017-06-30' 336 | added: 337 | - "`Struct.constructor` that makes dry-struct more aligned with dry-types; now you 338 | can have a struct with a custom constructor that will be called _before_ calling 339 | the `new` method (v-kolesnikov)" 340 | - "`Struct.attribute?` and `Struct.attribute_names` for introspecting struct attributes 341 | (flash-gordon)" 342 | - "`Struct#__new__` is a safe-to-use-in-gems alias for `Struct#new` (flash-gordon)" 343 | - version: 0.3.0 344 | date: '2017-05-05' 345 | added: 346 | - "`Dry::Struct#new` method to return new instance with applied changeset (Kukunin)" 347 | fixed: 348 | - "`.[]` and `.call` does not coerce subclass to superclass anymore (Kukunin)" 349 | - Raise ArgumentError when attribute type is a string and no value provided is for 350 | `new` (GustavoCaso) 351 | changed: 352 | - "`.new` without arguments doesn't use nil as an input for non-default types anymore 353 | (flash-gordon)" 354 | - version: 0.2.1 355 | date: '2017-02-27' 356 | fixed: 357 | - Fixed `Dry::Struct::Value` which appeared to be broken in the last release (flash-gordon) 358 | - version: 0.2.0 359 | date: '2016-02-26' 360 | changed: 361 | - Struct attributes can be overridden in a subclass (flash-gordon) 362 | - version: 0.1.1 363 | date: '2016-11-13' 364 | fixed: 365 | - Make `Dry::Struct` act as a constrained type. This fixes the behavior of sum types 366 | containing structs (flash-gordon) 367 | - version: 0.1.0 368 | date: '2016-09-21' 369 | added: 370 | - "`:strict_with_defaults` constructor type (backus)" 371 | changed: 372 | - "[BREAKING] `:strict` was renamed to `:permissive` as it ignores missing keys 373 | (backus)" 374 | - "[BREAKING] `:strict` now raises on unexpected keys (backus)" 375 | - Structs no longer auto-register themselves in the types container as they implement 376 | `Type` interface and we don't have to wrap them in `Type::Definition` (flash-gordon) 377 | - version: 0.0.1 378 | date: '2016-07-17' 379 | summary: Initial release of code imported from dry-types 380 | -------------------------------------------------------------------------------- /docsite/source/index.html.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Introduction 3 | layout: gem-single 4 | type: gem 5 | name: dry-struct 6 | sections: 7 | - nested-structs 8 | - recipes 9 | --- 10 | 11 | `dry-struct` is a gem built on top of `dry-types` which provides virtus-like DSL for defining typed struct classes. 12 | 13 | ### Basic Usage 14 | 15 | You can define struct objects which will have readers for specified attributes using a simple dsl: 16 | 17 | ``` ruby 18 | require 'dry-struct' 19 | 20 | module Types 21 | include Dry.Types() 22 | end 23 | 24 | class User < Dry::Struct 25 | attribute :name, Types::String.optional 26 | attribute :age, Types::Coercible::Integer 27 | end 28 | 29 | user = User.new(name: nil, age: '21') 30 | 31 | user.name # nil 32 | user.age # 21 33 | 34 | user = User.new(name: 'Jane', age: '21') 35 | 36 | user.name # => "Jane" 37 | user.age # => 21 38 | ``` 39 | 40 | Note: An `optional` type means that the value can be nil, not the key in the hash can be skipped. 41 | 42 | ### Value 43 | 44 | :warning: `Dry::Struct::Value` is deprecated in 1.2.0. Structs are already meant to be immutable, freezing them doesn't add any value (no pun intended) beyond a bad example of defensive programming. 45 | 46 | You can define value objects which will behave like structs but will be *deeply frozen*: 47 | 48 | ``` ruby 49 | class Location < Dry::Struct::Value 50 | attribute :lat, Types::Float 51 | attribute :lng, Types::Float 52 | end 53 | 54 | loc1 = Location.new(lat: 1.23, lng: 4.56) 55 | loc2 = Location.new(lat: 1.23, lng: 4.56) 56 | 57 | loc1.frozen? # true 58 | loc2.frozen? # true 59 | 60 | loc1 == loc2 61 | # true 62 | ``` 63 | 64 | ### Hash Schemas 65 | 66 | `Dry::Struct` out of the box uses [hash schemas](/gems/dry-types/1.0/hash-schemas) from `dry-types` for processing input hashes. `with_type_transform` and `with_key_transform` are exposed as `transform_types` and `transform_keys`: 67 | 68 | ```ruby 69 | class User < Dry::Struct 70 | transform_keys(&:to_sym) 71 | 72 | attribute :name, Types::String.optional 73 | attribute :age, Types::Coercible::Integer 74 | end 75 | 76 | User.new('name' => 'Jane', 'age' => '21') 77 | # => # 78 | ``` 79 | 80 | This plays nicely with inheritance, you can define a base struct for symbolizing input and then reuse it: 81 | 82 | ```ruby 83 | class SymbolizeStruct < Dry::Struct 84 | transform_keys(&:to_sym) 85 | end 86 | 87 | class User < SymbolizeStruct 88 | attribute :name, Types::String.optional 89 | attribute :age, Types::Coercible::Integer 90 | end 91 | ``` 92 | 93 | ### Validating data with dry-struct 94 | 95 | Please don't. Structs are meant to work with valid input, it cannot generate error messages good enough for displaying them for a user etc. Use [`dry-validation`](/gems/dry-validation) for validating incoming data and then pass its output to structs. 96 | 97 | ### Differences between dry-struct and virtus 98 | 99 | `dry-struct` look somewhat similar to Virtus but there are few significant differences: 100 | 101 | * Structs don't provide attribute writers and are meant to be used as "data objects" exclusively 102 | * Handling of attribute values is provided by standalone type objects from `dry-types`, which gives you way more powerful features 103 | * Handling of attribute hashes is provided by standalone hash schemas from `dry-types`, which means there are different types of constructors in `dry-struct` 104 | * Structs are not designed as swiss-army knives, specific constructor types are used depending on the use case 105 | * Struct classes quack like `dry-types`, which means you can use them in hash schemas, as array members or sum them 106 | -------------------------------------------------------------------------------- /docsite/source/nested-structs.html.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Nested Structs 3 | layout: gem-single 4 | name: dry-struct 5 | --- 6 | 7 | The DSL allows to define nested structs by passing a block to `attribute`: 8 | 9 | ```ruby 10 | class User < Dry::Struct 11 | attribute :name, Types::String 12 | attribute :address do 13 | attribute :city, Types::String 14 | attribute :street, Types::String 15 | end 16 | end 17 | 18 | User.new(name: 'Jane', address: { city: 'London', street: 'Oxford' }) 19 | # => #> 20 | 21 | # constants for nested structs are automatically defined 22 | User::Address 23 | # => User::Address 24 | ``` 25 | 26 | By default, new struct classes uses `Dry::Struct` as a base class (`Dry::Struct::Value` for values). You can explicitly pass a different class: 27 | 28 | ```ruby 29 | class User < Dry::Struct 30 | attribute :address, MyStruct do 31 | # ... 32 | end 33 | end 34 | ``` 35 | 36 | It is even possible to define an array of struct: 37 | 38 | ```ruby 39 | class User < Dry::Struct 40 | attribute :addresses, Types::Array do 41 | attribute :city, Types::String 42 | attribute :street, Types::String 43 | end 44 | end 45 | 46 | # constants are still there! 47 | User::Address 48 | # => User::Address 49 | ``` 50 | -------------------------------------------------------------------------------- /docsite/source/recipes.html.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Recipes 3 | layout: gem-single 4 | name: dry-struct 5 | --- 6 | 7 | ### Symbolize input keys 8 | 9 | ```ruby 10 | require 'dry-struct' 11 | 12 | module Types 13 | include Dry.Types() 14 | end 15 | 16 | class User < Dry::Struct 17 | transform_keys(&:to_sym) 18 | 19 | attribute :name, Types::String 20 | end 21 | 22 | User.new('name' => 'Jane') 23 | # => # 24 | ``` 25 | 26 | ### Tolerance to extra keys 27 | 28 | Structs ignore extra keys by default. This can be changed by replacing the constructor. 29 | 30 | ```ruby 31 | class User < Dry::Struct 32 | # This does the trick 33 | schema schema.strict 34 | 35 | attribute :name, Types::String 36 | end 37 | 38 | User.new(name: 'Jane', age: 21) 39 | # => Dry::Struct::Error ([User.new] unexpected keys [:age] in Hash input) 40 | ``` 41 | 42 | ### Tolerance to missing keys 43 | 44 | You can mark certain keys as optional by calling `attribute?`. 45 | 46 | ```ruby 47 | class User < Dry::Struct 48 | attribute :name, Types::String 49 | attribute? :age, Types::Integer 50 | end 51 | 52 | user = User.new(name: 'Jane') 53 | # => # 54 | user.age 55 | # => nil 56 | ``` 57 | 58 | In the example above `nil` violates the type constraint so be careful with `attribute?`. 59 | 60 | ### Default values 61 | 62 | Instead of violating constraints you can assign default values to attributes: 63 | 64 | ```ruby 65 | class User < Dry::Struct 66 | attribute :name, Types::String 67 | attribute :age, Types::Integer.default(18) 68 | end 69 | 70 | User.new(name: 'Jane') 71 | # => # 72 | ``` 73 | 74 | ### Resolving default values on `nil` 75 | 76 | `nil` as a value isn't replaced with a default value for default types. You may use `transform_types` to turn all types into constructors which map `nil` to `Dry::Types::Undefined` which in order triggers default values. 77 | 78 | ```ruby 79 | class User < Dry::Struct 80 | transform_types do |type| 81 | if type.default? 82 | type.constructor do |value| 83 | value.nil? ? Dry::Types::Undefined : value 84 | end 85 | else 86 | type 87 | end 88 | end 89 | 90 | attribute :name, Types::String 91 | attribute :age, Types::Integer.default(18) 92 | end 93 | 94 | User.new(name: 'Jane') 95 | # => # 96 | User.new(name: 'Jane', age: nil) 97 | # => # 98 | ``` 99 | 100 | ### Creating a custom struct class 101 | 102 | You can combine examples from this page to create a custom-purposed base struct class and the reuse it your application or gem 103 | 104 | ```ruby 105 | class MyStruct < Dry::Struct 106 | # throw an error when unknown keys provided 107 | schema schema.strict 108 | 109 | # convert string keys to symbols 110 | transform_keys(&:to_sym) 111 | 112 | # resolve default types on nil 113 | transform_types do |type| 114 | if type.default? 115 | type.constructor do |value| 116 | value.nil? ? Dry::Types::Undefined : value 117 | end 118 | else 119 | type 120 | end 121 | end 122 | end 123 | ``` 124 | 125 | ### Set default value for a nested hash 126 | 127 | ```ruby 128 | class Foo < Dry::Struct 129 | attribute :bar do 130 | attribute :nested, Types::Integer 131 | end 132 | end 133 | ``` 134 | 135 | ```ruby 136 | class Foo < Dry::Struct 137 | class Bar < Dry::Struct 138 | attribute :nested, Types::Integer 139 | end 140 | 141 | attribute :bar, Bar.default { Bar.new(nested: 1) } 142 | end 143 | ``` 144 | 145 | ### Composing structs 146 | 147 | You can compose other struct attributes as if they 148 | had been defined in place. 149 | 150 | ```ruby 151 | class Address < Dry::Struct 152 | attribute :city, Types::String 153 | attribute :country, Types::String 154 | end 155 | 156 | class User < Dry::Struct 157 | attribute :name, Types::String 158 | attributes_from Address 159 | end 160 | 161 | User.new(name: 'Quispe', city: 'La Paz', country: 'Bolivia') 162 | ``` 163 | 164 | Composition can happen within a nested attribute: 165 | 166 | ```ruby 167 | class User < Dry::Struct 168 | attribute :name, Types::String 169 | attribute :address do 170 | attributes_from Address 171 | end 172 | end 173 | ``` 174 | -------------------------------------------------------------------------------- /dry-struct.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # this file is synced from dry-rb/template-gem project 4 | 5 | lib = File.expand_path("lib", __dir__) 6 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 7 | require "dry/struct/version" 8 | 9 | Gem::Specification.new do |spec| 10 | spec.name = "dry-struct" 11 | spec.authors = ["Piotr Solnica"] 12 | spec.email = ["piotr.solnica@gmail.com"] 13 | spec.license = "MIT" 14 | spec.version = Dry::Struct::VERSION.dup 15 | 16 | spec.summary = "Typed structs and value objects" 17 | spec.description = spec.summary 18 | spec.homepage = "https://dry-rb.org/gems/dry-struct" 19 | spec.files = Dir["CHANGELOG.md", "LICENSE", "README.md", "dry-struct.gemspec", "lib/**/*"] 20 | spec.bindir = "bin" 21 | spec.executables = [] 22 | spec.require_paths = ["lib"] 23 | 24 | spec.metadata["allowed_push_host"] = "https://rubygems.org" 25 | spec.metadata["changelog_uri"] = "https://github.com/dry-rb/dry-struct/blob/main/CHANGELOG.md" 26 | spec.metadata["source_code_uri"] = "https://github.com/dry-rb/dry-struct" 27 | spec.metadata["bug_tracker_uri"] = "https://github.com/dry-rb/dry-struct/issues" 28 | spec.metadata["rubygems_mfa_required"] = "true" 29 | 30 | spec.required_ruby_version = ">= 3.1.0" 31 | 32 | # to update dependencies edit project.yml 33 | spec.add_dependency "dry-core", "~> 1.1" 34 | spec.add_dependency "dry-types", "~> 1.8", ">= 1.8.2" 35 | spec.add_dependency "ice_nine", "~> 0.11" 36 | spec.add_dependency "zeitwerk", "~> 2.6" 37 | end 38 | -------------------------------------------------------------------------------- /lib/dry-struct.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "dry/struct" 4 | -------------------------------------------------------------------------------- /lib/dry/struct.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "weakref" 4 | 5 | require "dry/core" 6 | require "dry/types" 7 | 8 | require "dry/struct/class_interface" 9 | require "dry/struct/errors" 10 | require "dry/struct/version" 11 | 12 | module Dry 13 | # Constructor method for easily creating a {Dry::Struct}. 14 | # @return [Dry::Struct] 15 | # @example 16 | # require 'dry-struct' 17 | # 18 | # module Types 19 | # include Dry.Types() 20 | # end 21 | # 22 | # Person = Dry.Struct(name: Types::String, age: Types::Integer) 23 | # matz = Person.new(name: "Matz", age: 52) 24 | # matz.name #=> "Matz" 25 | # matz.age #=> 52 26 | # 27 | # Test = Dry.Struct(expected: Types::String) { schema(schema.strict) } 28 | # Test[expected: "foo", unexpected: "bar"] 29 | # #=> Dry::Struct::Error: [Test.new] unexpected keys [:unexpected] in Hash input 30 | def self.Struct(attributes = Dry::Core::Constants::EMPTY_HASH, &block) 31 | Class.new(Dry::Struct) do 32 | attributes.each { |a, type| attribute a, type } 33 | module_eval(&block) if block_given? 34 | end 35 | end 36 | 37 | # Typed {Struct} with virtus-like DSL for defining schema. 38 | # 39 | # ### Differences between dry-struct and virtus 40 | # 41 | # {Struct} look somewhat similar to [Virtus][] but there are few significant differences: 42 | # 43 | # * {Struct}s don't provide attribute writers and are meant to be used 44 | # as "data objects" exclusively. 45 | # * Handling of attribute values is provided by standalone type objects from 46 | # [`dry-types`][]. 47 | # * Handling of attribute hashes is provided by standalone hash schemas from 48 | # [`dry-types`][]. 49 | # * Struct classes quack like [`dry-types`][], which means you can use them 50 | # in hash schemas, as array members or sum them 51 | # 52 | # {Struct} class can specify a constructor type, which uses [hash schemas][] 53 | # to handle attributes in `.new` method. 54 | # 55 | # [`dry-types`]: https://github.com/dry-rb/dry-types 56 | # [Virtus]: https://github.com/solnic/virtus 57 | # [hash schemas]: http://dry-rb.org/gems/dry-types/hash-schemas 58 | # 59 | # @example 60 | # require 'dry-struct' 61 | # 62 | # module Types 63 | # include Dry.Types() 64 | # end 65 | # 66 | # class Book < Dry::Struct 67 | # attribute :title, Types::String 68 | # attribute :subtitle, Types::String.optional 69 | # end 70 | # 71 | # rom_n_roda = Book.new( 72 | # title: 'Web Development with ROM and Roda', 73 | # subtitle: nil 74 | # ) 75 | # rom_n_roda.title #=> 'Web Development with ROM and Roda' 76 | # rom_n_roda.subtitle #=> nil 77 | # 78 | # refactoring = Book.new( 79 | # title: 'Refactoring', 80 | # subtitle: 'Improving the Design of Existing Code' 81 | # ) 82 | # refactoring.title #=> 'Refactoring' 83 | # refactoring.subtitle #=> 'Improving the Design of Existing Code' 84 | class Struct 85 | extend Core::Extensions 86 | include Core::Constants 87 | extend ClassInterface 88 | extend Core::Deprecations[:"dry-struct"] 89 | 90 | class << self 91 | # override `Dry::Types::Builder#prepend` 92 | define_method(:prepend, ::Module.method(:prepend)) 93 | 94 | def loader 95 | @loader ||= ::Zeitwerk::Loader.new.tap do |loader| 96 | root = ::File.expand_path("..", __dir__) 97 | loader.tag = "dry-struct" 98 | loader.inflector = ::Zeitwerk::GemInflector.new("#{root}/dry-struct.rb") 99 | loader.push_dir(root) 100 | loader.ignore( 101 | "#{root}/dry-struct.rb", 102 | "#{root}/dry/struct/{class_interface,errors,extensions,printer,value,version}.rb", 103 | "#{root}/dry/struct/extensions" 104 | ) 105 | end 106 | end 107 | end 108 | 109 | loader.setup 110 | 111 | include ::Dry::Equalizer(:__attributes__, inspect: false, immutable: true) 112 | 113 | # {Dry::Types::Hash::Schema} subclass with specific behaviour defined for 114 | # @return [Dry::Types::Hash::Schema] 115 | defines :schema 116 | schema Types["coercible.hash"].schema(EMPTY_HASH) 117 | 118 | defines :abstract_class 119 | abstract 120 | 121 | # @!attribute [Hash{Symbol => Object}] attributes 122 | attr_reader :attributes 123 | alias_method :__attributes__, :attributes 124 | 125 | # @param [Hash, #each] attributes 126 | def initialize(attributes) 127 | @attributes = attributes 128 | end 129 | 130 | # Retrieves value of previously defined attribute by its' `name` 131 | # 132 | # @param [String] name 133 | # @return [Object] 134 | # 135 | # @example 136 | # class Book < Dry::Struct 137 | # attribute :title, Types::String 138 | # attribute :subtitle, Types::String.optional 139 | # end 140 | # 141 | # rom_n_roda = Book.new( 142 | # title: 'Web Development with ROM and Roda', 143 | # subtitle: nil 144 | # ) 145 | # rom_n_roda[:title] #=> 'Web Development with ROM and Roda' 146 | # rom_n_roda[:subtitle] #=> nil 147 | def [](name) 148 | @attributes.fetch(name) do 149 | if self.class.attribute_names.include?(name) 150 | nil 151 | else 152 | raise MissingAttributeError.new(attribute: name, klass: self.class) 153 | end 154 | end 155 | end 156 | 157 | # Converts the {Dry::Struct} to a hash with keys representing 158 | # each attribute (as symbols) and their corresponding values 159 | # 160 | # @return [Hash{Symbol => Object}] 161 | # 162 | # @example 163 | # class Book < Dry::Struct 164 | # attribute :title, Types::String 165 | # attribute :subtitle, Types::String.optional 166 | # end 167 | # 168 | # rom_n_roda = Book.new( 169 | # title: 'Web Development with ROM and Roda', 170 | # subtitle: nil 171 | # ) 172 | # rom_n_roda.to_hash 173 | # #=> {title: 'Web Development with ROM and Roda', subtitle: nil} 174 | def to_h 175 | self.class.schema.each_with_object({}) do |key, result| 176 | result[key.name] = Hashify[self[key.name]] if attributes.key?(key.name) 177 | end 178 | end 179 | # TODO: remove in 2.0 180 | alias_method :to_hash, :to_h 181 | 182 | # Create a copy of {Dry::Struct} with overriden attributes 183 | # 184 | # @param [Hash{Symbol => Object}] changeset 185 | # 186 | # @return [Struct] 187 | # 188 | # @example 189 | # class Book < Dry::Struct 190 | # attribute :title, Types::String 191 | # attribute :subtitle, Types::String.optional 192 | # end 193 | # 194 | # rom_n_roda = Book.new( 195 | # title: 'Web Development with ROM and Roda', 196 | # subtitle: '2nd edition' 197 | # ) 198 | # #=> # 199 | # 200 | # rom_n_roda.new(subtitle: '3rd edition') 201 | # #=> # 202 | def new(changeset) 203 | new_attributes = self.class.schema.apply( 204 | changeset, 205 | skip_missing: true, 206 | resolve_defaults: false 207 | ) 208 | self.class.load(__attributes__.merge(new_attributes)) 209 | rescue Types::SchemaError, Types::MissingKeyError, Types::UnknownKeysError => e 210 | raise Error, "[#{self}.new] #{e}" 211 | end 212 | alias_method :__new__, :new 213 | 214 | # @return [String] 215 | def inspect 216 | klass = self.class 217 | attrs = klass.attribute_names.map { |key| " #{key}=#{@attributes[key].inspect}" }.join 218 | "#<#{klass.name || klass.inspect}#{attrs}>" 219 | end 220 | 221 | # Pattern matching support 222 | # 223 | # @api private 224 | def deconstruct_keys(_keys) = attributes 225 | end 226 | end 227 | 228 | require "dry/struct/extensions" 229 | require "dry/struct/printer" 230 | require "dry/struct/value" 231 | -------------------------------------------------------------------------------- /lib/dry/struct/class_interface.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "weakref" 4 | 5 | module Dry 6 | class Struct 7 | # Class-level interface of {Struct} and {Value} 8 | module ClassInterface # rubocop:disable Metrics/ModuleLength 9 | include Core::ClassAttributes 10 | 11 | include Types::Type 12 | include Types::Builder 13 | 14 | # Adds an attribute for this {Struct} with given `name` and `type` 15 | # and modifies {.schema} accordingly. 16 | # 17 | # @param [Symbol] name name of the defined attribute 18 | # @param [Dry::Types::Type, nil] type or superclass of nested type 19 | # @return [Dry::Struct] 20 | # @yield 21 | # If a block is given, it will be evaluated in the context of 22 | # a new struct class, and set as a nested type for the given 23 | # attribute. A class with a matching name will also be defined for 24 | # the nested type. 25 | # @raise [RepeatedAttributeError] when trying to define attribute with the 26 | # same name as previously defined one 27 | # 28 | # @example with nested structs 29 | # class Language < Dry::Struct 30 | # attribute :name, Types::String 31 | # attribute :details, Dry::Struct do 32 | # attribute :type, Types::String 33 | # end 34 | # end 35 | # 36 | # Language.schema # new lines for readability 37 | # # => # rule=[type?(String)]> 40 | # details: Language::Details 41 | # }> fn=Kernel.Hash>]> 42 | # 43 | # ruby = Language.new(name: 'Ruby', details: { type: 'OO' }) 44 | # ruby.name #=> 'Ruby' 45 | # ruby.details #=> # 46 | # ruby.details.type #=> 'OO' 47 | # 48 | # @example with a nested array of structs 49 | # class Language < Dry::Struct 50 | # attribute :name, Types::String 51 | # attribute :versions, Types::Array.of(Types::String) 52 | # attribute :celebrities, Types::Array.of(Dry::Struct) do 53 | # attribute :name, Types::String 54 | # attribute :pseudonym, Types::String 55 | # end 56 | # end 57 | # 58 | # Language.schema # new lines for readability 59 | # => # rule=[type?(String)]> 61 | # versions: Constrained< 62 | # Array rule=[type?(String)]> 63 | # > rule=[type?(Array)]> 64 | # celebrities: Constrained rule=[type?(Array)]> 65 | # }> fn=Kernel.Hash>]> 66 | # 67 | # ruby = Language.new( 68 | # name: 'Ruby', 69 | # versions: %w(1.8.7 1.9.8 2.0.1), 70 | # celebrities: [ 71 | # { name: 'Yukihiro Matsumoto', pseudonym: 'Matz' }, 72 | # { name: 'Aaron Patterson', pseudonym: 'tenderlove' } 73 | # ] 74 | # ) 75 | # ruby.name #=> 'Ruby' 76 | # ruby.versions #=> ['1.8.7', '1.9.8', '2.0.1'] 77 | # ruby.celebrities 78 | # #=> [ 79 | # #, 80 | # # 81 | # ] 82 | # ruby.celebrities[0].name #=> 'Yukihiro Matsumoto' 83 | # ruby.celebrities[0].pseudonym #=> 'Matz' 84 | # ruby.celebrities[1].name #=> 'Aaron Patterson' 85 | # ruby.celebrities[1].pseudonym #=> 'tenderlove' 86 | def attribute(name, type = Undefined, &) 87 | attributes(name => build_type(name, type, &)) 88 | end 89 | 90 | # Add atributes from another struct 91 | # 92 | # @example 93 | # class Address < Dry::Struct 94 | # attribute :city, Types::String 95 | # attribute :country, Types::String 96 | # end 97 | # 98 | # class User < Dry::Struct 99 | # attribute :name, Types::String 100 | # attributes_from Address 101 | # end 102 | # 103 | # User.new(name: 'Quispe', city: 'La Paz', country: 'Bolivia') 104 | # 105 | # @example with nested structs 106 | # class User < Dry::Struct 107 | # attribute :name, Types::String 108 | # attribute :address do 109 | # attributes_from Address 110 | # end 111 | # end 112 | # 113 | # @param struct [Dry::Struct] 114 | def attributes_from(struct) 115 | extracted_schema = struct.schema.keys.to_h do |key| 116 | if key.required? 117 | [key.name, key.type] 118 | else 119 | [:"#{key.name}?", key.type] 120 | end 121 | end 122 | attributes(extracted_schema) 123 | end 124 | 125 | # Adds an omittable (key is not required on initialization) attribute for this {Struct} 126 | # 127 | # @example 128 | # class User < Dry::Struct 129 | # attribute :name, Types::String 130 | # attribute? :email, Types::String 131 | # end 132 | # 133 | # User.new(name: 'John') # => # 134 | # 135 | # @param [Symbol] name name of the defined attribute 136 | # @param [Dry::Types::Type, nil] type or superclass of nested type 137 | # @return [Dry::Struct] 138 | # 139 | def attribute?(*args, &) 140 | if args.size == 1 && !block_given? 141 | Core::Deprecations.warn( 142 | "Dry::Struct.attribute? is deprecated for checking attribute presence, " \ 143 | "use has_attribute? instead", 144 | tag: :"dry-struct" 145 | ) 146 | 147 | has_attribute?(args[0]) 148 | else 149 | name, * = args 150 | 151 | attribute(:"#{name}?", build_type(*args, &)) 152 | end 153 | end 154 | 155 | # @param [Hash{Symbol => Dry::Types::Type}] new_schema 156 | # @return [Dry::Struct] 157 | # @raise [RepeatedAttributeError] when trying to define attribute with the 158 | # same name as previously defined one 159 | # @see #attribute 160 | # @example 161 | # class Book < Dry::Struct 162 | # attributes( 163 | # title: Types::String, 164 | # author: Types::String 165 | # ) 166 | # end 167 | # 168 | # Book.schema 169 | # # => # rule=[type?(String)]> 171 | # # author: Constrained rule=[type?(String)]> 172 | # # }> fn=Kernel.Hash>]> 173 | def attributes(new_schema) 174 | keys = new_schema.keys.map { |k| k.to_s.chomp("?").to_sym } 175 | check_schema_duplication(keys) 176 | 177 | schema schema.schema(new_schema) 178 | 179 | define_accessors(keys) 180 | 181 | @attribute_names = nil 182 | 183 | subclasses.each do |d| 184 | inherited_attrs = new_schema.reject { |k, _| d.has_attribute?(k.to_s.chomp("?").to_sym) } 185 | d.attributes(inherited_attrs) 186 | end 187 | 188 | self 189 | end 190 | 191 | # Add an arbitrary transformation for new attribute types. 192 | # 193 | # @param [#call,nil] proc 194 | # @param [#call,nil] block 195 | # @example 196 | # class Book < Dry::Struct 197 | # transform_types { |t| t.meta(struct: :Book) } 198 | # 199 | # attribute :title, Types::String 200 | # end 201 | # 202 | # Book.schema.key(:title).meta # => { struct: :Book } 203 | # 204 | def transform_types(proc = nil, &block) 205 | schema schema.with_type_transform(proc || block) 206 | end 207 | 208 | # Add an arbitrary transformation for input hash keys. 209 | # 210 | # @param [#call,nil] proc 211 | # @param [#call,nil] block 212 | # @example 213 | # class Book < Dry::Struct 214 | # transform_keys(&:to_sym) 215 | # 216 | # attribute :title, Types::String 217 | # end 218 | # 219 | # Book.new('title' => "The Old Man and the Sea") 220 | # # => # 221 | def transform_keys(proc = nil, &block) 222 | schema schema.with_key_transform(proc || block) 223 | end 224 | 225 | # @param [Hash{Symbol => Dry::Types::Type, Dry::Struct}] new_keys 226 | # @raise [RepeatedAttributeError] when trying to define attribute with the 227 | # same name as previously defined one 228 | def check_schema_duplication(new_keys) 229 | overlapping_keys = new_keys & (attribute_names - superclass.attribute_names) 230 | 231 | if overlapping_keys.any? 232 | raise RepeatedAttributeError, overlapping_keys.first 233 | end 234 | end 235 | private :check_schema_duplication 236 | 237 | # @param [Hash{Symbol => Object},Dry::Struct] attributes 238 | # @raise [Struct::Error] if the given attributes don't conform {#schema} 239 | def new(attributes = default_attributes, safe = false, &) # rubocop:disable Style/OptionalBooleanParameter 240 | if attributes.is_a?(Struct) 241 | if equal?(attributes.class) 242 | attributes 243 | else 244 | # This implicit coercion is arguable but makes sense overall 245 | # in cases there you pass child struct to the base struct constructor 246 | # User.new(super_user) 247 | # 248 | # We may deprecate this behavior in future forcing people to be explicit 249 | new(attributes.to_h, safe, &) 250 | end 251 | elsif safe 252 | load(schema.call_safe(attributes) { |output = attributes| return yield output }) 253 | else 254 | load(schema.call_unsafe(attributes)) 255 | end 256 | rescue Types::CoercionError => e 257 | raise Error, "[#{self}.new] #{e}", e.backtrace 258 | end 259 | 260 | # @api private 261 | def call_safe(input, &) 262 | if input.is_a?(self) 263 | input 264 | else 265 | new(input, true, &) 266 | end 267 | end 268 | 269 | # @api private 270 | def call_unsafe(input) 271 | if input.is_a?(self) 272 | input 273 | else 274 | new(input) 275 | end 276 | end 277 | 278 | # @api private 279 | def load(attributes) 280 | struct = allocate 281 | struct.__send__(:initialize, attributes) 282 | struct 283 | end 284 | 285 | # @param [#call,nil] constructor 286 | # @param [#call,nil] block 287 | # @return [Dry::Struct::Constructor] 288 | def constructor(constructor = nil, **, &block) 289 | Constructor[self, fn: constructor || block] 290 | end 291 | 292 | # @param [Hash{Symbol => Object},Dry::Struct] input 293 | # @yieldparam [Dry::Types::Result::Failure] failure 294 | # @yieldreturn [Dry::Types::Result] 295 | # @return [Dry::Types::Result] 296 | def try(input) 297 | success(self[input]) 298 | rescue Error => e 299 | failure_result = failure(input, e) 300 | block_given? ? yield(failure_result) : failure_result 301 | end 302 | 303 | # @param [Hash{Symbol => Object},Dry::Struct] input 304 | # @return [Dry::Types::Result] 305 | # @private 306 | def try_struct(input) 307 | if input.is_a?(self) 308 | input 309 | else 310 | yield 311 | end 312 | end 313 | 314 | # @param [({Symbol => Object})] args 315 | # @return [Dry::Types::Result::Success] 316 | def success(*args) = result(Types::Result::Success, *args) 317 | 318 | # @param [({Symbol => Object})] args 319 | # @return [Dry::Types::Result::Failure] 320 | def failure(*args) = result(Types::Result::Failure, *args) 321 | 322 | # @param [Class] klass 323 | # @param [({Symbol => Object})] args 324 | def result(klass, *args) = klass.new(*args) 325 | 326 | # @return [false] 327 | def default? = false 328 | 329 | # @param [Object, Dry::Struct] other 330 | # @return [Boolean] 331 | def ===(other) = other.is_a?(self) 332 | alias_method :primitive?, :=== 333 | 334 | # @return [true] 335 | def constrained? = true 336 | 337 | # @return [self] 338 | def primitive = self 339 | 340 | # @return [false] 341 | def optional? = false 342 | 343 | # @return [Proc] 344 | def to_proc 345 | @to_proc ||= proc { |input| call(input) } 346 | end 347 | 348 | # Checks if this {Struct} has the given attribute 349 | # 350 | # @param [Symbol] key Attribute name 351 | # @return [Boolean] 352 | def has_attribute?(key) = schema.key?(key) 353 | 354 | # Gets the list of attribute names 355 | # 356 | # @return [Array] 357 | def attribute_names 358 | @attribute_names ||= schema.map(&:name) 359 | end 360 | 361 | # @return [{Symbol => Object}] 362 | def meta(meta = Undefined) 363 | if meta.equal?(Undefined) 364 | schema.meta 365 | elsif meta.empty? 366 | self 367 | else 368 | ::Class.new(self) do 369 | schema schema.meta(meta) unless meta.empty? 370 | end 371 | end 372 | end 373 | 374 | # Build a sum type 375 | # @param [Dry::Types::Type] type 376 | # @return [Dry::Types::Sum] 377 | def |(type) 378 | if type.is_a?(::Class) && type <= Struct 379 | Sum.new(self, type) 380 | else 381 | super 382 | end 383 | end 384 | 385 | # Make the struct abstract. This class will be used as a default 386 | # parent class for nested structs 387 | def abstract 388 | abstract_class self 389 | end 390 | 391 | # Dump to the AST 392 | # 393 | # @return [Array] 394 | # 395 | # @api public 396 | def to_ast(meta: true) 397 | [:struct, [::WeakRef.new(self), schema.to_ast(meta: meta)]] 398 | end 399 | 400 | # Stores an object for building nested struct classes 401 | # @return [StructBuilder] 402 | def struct_builder 403 | @struct_builder ||= StructBuilder.new(self).freeze 404 | end 405 | private :struct_builder 406 | 407 | # Retrieves default attributes from defined {.schema}. 408 | # Used in a {Struct} constructor if no attributes provided to {.new} 409 | # 410 | # @return [Hash{Symbol => Object}] 411 | def default_attributes(default_schema = schema) 412 | default_schema.each_with_object({}) do |key, result| 413 | result[key.name] = default_attributes(key.schema) if struct?(key.type) 414 | end 415 | end 416 | private :default_attributes 417 | 418 | # Checks if the given type is a Dry::Struct 419 | # 420 | # @param [Dry::Types::Type] type 421 | # @return [Boolean] 422 | def struct?(type) 423 | type.is_a?(::Class) && type <= Struct 424 | end 425 | private :struct? 426 | 427 | # Constructs a type 428 | # 429 | # @return [Dry::Types::Type, Dry::Struct] 430 | def build_type(name, type = Undefined, &) 431 | type_object = 432 | if type.is_a?(::String) 433 | Types[type] 434 | elsif !block_given? && Undefined.equal?(type) 435 | raise( 436 | ::ArgumentError, 437 | "you must supply a type or a block to `Dry::Struct.attribute`" 438 | ) 439 | else 440 | type 441 | end 442 | 443 | if block_given? 444 | struct_builder.(name, type_object, &) 445 | else 446 | type_object 447 | end 448 | end 449 | private :build_type 450 | 451 | # @api private 452 | def define_accessors(keys) 453 | (keys - instance_methods).each do |key| 454 | if valid_method_name?(key) 455 | class_eval(<<-RUBY, __FILE__, __LINE__ + 1) 456 | def #{key} # def email 457 | @attributes[#{key.inspect}] # @attributes[:email] 458 | end # end 459 | RUBY 460 | else 461 | define_method(key) { @attributes[key] } 462 | end 463 | end 464 | end 465 | private :define_accessors 466 | 467 | # @api private 468 | private def valid_method_name?(key) = key.to_s.match?(/\A[a-zA-Z_]\w*\z/) 469 | end 470 | end 471 | end 472 | -------------------------------------------------------------------------------- /lib/dry/struct/compiler.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dry 4 | class Struct 5 | class Compiler < Types::Compiler 6 | def visit_struct(node) 7 | struct, _ = node 8 | 9 | struct.__getobj__ 10 | rescue ::WeakRef::RefError 11 | if struct.weakref_alive? 12 | raise 13 | else 14 | raise RecycledStructError 15 | end 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/dry/struct/constructor.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dry 4 | class Struct 5 | class Constructor < Types::Constructor 6 | alias_method :primitive, :type 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/dry/struct/errors.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dry 4 | class Struct 5 | # Raised when given input doesn't conform schema and constructor type 6 | Error = Class.new(::Dry::Types::CoercionError) 7 | 8 | # Raised when defining duplicate attributes 9 | class RepeatedAttributeError < ::ArgumentError 10 | # @param [Symbol] key 11 | # attribute name that is the same as previously defined one 12 | def initialize(key) 13 | super("Attribute :#{key} has already been defined") 14 | end 15 | end 16 | 17 | # Raised when a struct doesn't have an attribute 18 | class MissingAttributeError < ::KeyError 19 | def initialize(attribute:, klass:) 20 | super("Missing attribute: #{attribute.inspect} on #{klass}") 21 | end 22 | end 23 | 24 | # When struct class stored in ast was garbage collected because no alive objects exists 25 | # This shouldn't happen in a working application 26 | class RecycledStructError < ::RuntimeError 27 | def initialize 28 | super("Reference to struct class was garbage collected") 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/dry/struct/extensions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Dry::Struct.register_extension(:pretty_print) do 4 | require "dry/struct/extensions/pretty_print" 5 | end 6 | 7 | Dry::Struct.register_extension(:super_diff) do 8 | require "dry/struct/extensions/super_diff" 9 | end 10 | -------------------------------------------------------------------------------- /lib/dry/struct/extensions/pretty_print.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "pp" # rubocop:disable Lint/RedundantRequireStatement 4 | 5 | module Dry 6 | class Struct 7 | def pretty_print(pp) 8 | klass = self.class 9 | pp.group(1, "#<#{klass.name || klass.inspect}", ">") do 10 | pp.seplist(@attributes.keys, proc { pp.text "," }) do |column_name| 11 | column_value = @attributes[column_name] 12 | pp.breakable " " 13 | pp.group(1) do 14 | pp.text column_name.to_s 15 | pp.text "=" 16 | pp.pp column_value 17 | end 18 | end 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/dry/struct/extensions/super_diff.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "super_diff" 4 | require "super_diff/rspec" 5 | 6 | module Dry 7 | class Struct 8 | def attributes_for_super_diff = attributes 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/dry/struct/hashify.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dry 4 | class Struct 5 | # Helper for {Struct#to_hash} implementation 6 | module Hashify 7 | # Converts value to hash recursively 8 | # @param [#to_hash, #map, Object] value 9 | # @return [Hash, Array] 10 | def self.[](value) 11 | if value.is_a?(Struct) 12 | value.to_h.transform_values { self[_1] } 13 | elsif value.respond_to?(:to_hash) 14 | value.to_hash.transform_values { self[_1] } 15 | elsif value.respond_to?(:to_ary) 16 | value.to_ary.map { self[_1] } 17 | else 18 | value 19 | end 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/dry/struct/printer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "dry/types/printer" 4 | 5 | module Dry 6 | module Types 7 | # @api private 8 | class Printer 9 | MAPPING[Struct::Sum] = :visit_struct_sum 10 | MAPPING[Struct::Constructor] = :visit_struct_constructor 11 | 12 | def visit_struct_sum(sum) 13 | visit_sum_constructors(sum) do |constructors| 14 | visit_options(EMPTY_HASH, sum.meta) do |opts| 15 | yield "Struct::Sum<#{constructors}#{opts}>" 16 | end 17 | end 18 | end 19 | 20 | alias_method :visit_struct_constructor, :visit_constructor 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/dry/struct/struct_builder.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dry 4 | class Struct 5 | # @private 6 | class StructBuilder < Compiler 7 | attr_reader :struct 8 | 9 | def initialize(struct) 10 | super(Types) 11 | @struct = struct 12 | end 13 | 14 | # @param [Symbol|String] attr_name the name of the nested type 15 | # @param [Dry::Struct,Dry::Types::Type::Array,Undefined] type the superclass 16 | # of the nested struct 17 | # @yield the body of the nested struct 18 | def call(attr_name, type, &block) 19 | const_name = const_name(type, attr_name) 20 | check_name(const_name) 21 | 22 | builder = self 23 | parent = parent(type) 24 | 25 | new_type = ::Class.new(Undefined.default(parent, struct.abstract_class)) do 26 | if Undefined.equal?(parent) 27 | schema builder.struct.schema.clear 28 | end 29 | 30 | class_exec(&block) 31 | end 32 | 33 | struct.const_set(const_name, new_type) 34 | 35 | if array?(type) 36 | type.of(new_type) 37 | elsif optional?(type) 38 | new_type.optional 39 | else 40 | new_type 41 | end 42 | end 43 | 44 | private 45 | 46 | def type?(type) = type.is_a?(Types::Type) 47 | 48 | def array?(type) 49 | type?(type) && !type.optional? && type.primitive.equal?(::Array) 50 | end 51 | 52 | def optional?(type) = type?(type) && type.optional? 53 | 54 | def parent(type) 55 | if array?(type) 56 | visit(type.to_ast) 57 | elsif optional?(type) 58 | type.right 59 | else 60 | type 61 | end 62 | end 63 | 64 | def const_name(type, attr_name) 65 | snake_name = 66 | if array?(type) 67 | Core::Inflector.singularize(attr_name) 68 | else 69 | attr_name 70 | end 71 | 72 | Core::Inflector.camelize(snake_name) 73 | end 74 | 75 | def check_name(name) 76 | if struct.const_defined?(name, false) 77 | raise( 78 | Error, 79 | "Can't create nested attribute - `#{struct}::#{name}` already defined" 80 | ) 81 | end 82 | end 83 | 84 | def visit_constrained(node) 85 | definition, * = node 86 | visit(definition) 87 | end 88 | 89 | def visit_array(node) 90 | member, * = node 91 | visit(member) 92 | end 93 | 94 | def visit_nominal(*) = Undefined 95 | 96 | def visit_constructor(node) 97 | definition, * = node 98 | visit(definition) 99 | end 100 | end 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /lib/dry/struct/sum.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dry 4 | class Struct 5 | # A sum type of two or more structs 6 | # As opposed to Dry::Types::Sum::Constrained 7 | # this type tries no to coerce data first. 8 | class Sum < Dry::Types::Sum::Constrained 9 | def call(input) 10 | left.try_struct(input) do 11 | right.try_struct(input) { super } 12 | end 13 | end 14 | 15 | # @param [Hash{Symbol => Object},Dry::Struct] input 16 | # @yieldparam [Dry::Types::Result::Failure] failure 17 | # @yieldreturn [Dry::Types::Result] 18 | # @return [Dry::Types::Result] 19 | def try(input) 20 | if input.is_a?(Struct) 21 | ::Dry::Types::Result::Success.new(try_struct(input) { return super }) 22 | else 23 | super 24 | end 25 | end 26 | 27 | # Build a new sum type 28 | # @param [Dry::Types::Type] type 29 | # @return [Dry::Types::Sum] 30 | def |(type) 31 | if (type.is_a?(::Class) && type <= Struct) || type.is_a?(Sum) 32 | Sum.new(self, type) 33 | else 34 | super 35 | end 36 | end 37 | 38 | # @return [boolean] 39 | def ===(value) = left === value || right === value 40 | 41 | protected 42 | 43 | # @private 44 | def try_struct(input, &block) 45 | left.try_struct(input) do 46 | right.try_struct(input, &block) 47 | end 48 | end 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/dry/struct/value.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "ice_nine" 4 | 5 | module Dry 6 | class Struct 7 | extend Core::Deprecations[:"dry-struct"] 8 | 9 | # {Value} objects behave like {Struct}s but *deeply frozen* 10 | # using [`ice_nine`](https://github.com/dkubb/ice_nine) 11 | # 12 | # @example 13 | # class Location < Dry::Struct::Value 14 | # attribute :lat, Types::Float 15 | # attribute :lng, Types::Float 16 | # end 17 | # 18 | # loc1 = Location.new(lat: 1.23, lng: 4.56) 19 | # loc2 = Location.new(lat: 1.23, lng: 4.56) 20 | # 21 | # loc1.frozen? #=> true 22 | # loc2.frozen? #=> true 23 | # loc1 == loc2 #=> true 24 | # 25 | # @see https://github.com/dkubb/ice_nine 26 | class Value < self 27 | abstract 28 | 29 | # @param (see ClassInterface#new) 30 | # @return [Value] 31 | # @see https://github.com/dkubb/ice_nine 32 | def self.new(*) = ::IceNine.deep_freeze(super) 33 | end 34 | 35 | deprecate_constant :Value 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/dry/struct/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dry 4 | class Struct 5 | # @private 6 | VERSION = "1.8.0" 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /log/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dry-rb/dry-struct/9fcdbcc1d9a582a18bf3740914152610d9dd79fa/log/.gitkeep -------------------------------------------------------------------------------- /project.yml: -------------------------------------------------------------------------------- 1 | name: dry-struct 2 | codacy_id: 961f5c776f1d49218b2cede3745e059c 3 | gemspec: 4 | authors: ["Piotr Solnica"] 5 | email: ["piotr.solnica@gmail.com"] 6 | summary: "Typed structs and value objects" 7 | runtime_dependencies: 8 | - [zeitwerk, "~> 2.6"] 9 | - [dry-core, "~> 1.1"] 10 | - [dry-types, "~> 1.8", ">= 1.8.2"] 11 | - [ice_nine, "~> 0.11"] 12 | -------------------------------------------------------------------------------- /spec/extensions/pretty_print_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Dry::Struct do 4 | describe "#pretty_inspect" do 5 | include_context "user type" 6 | 7 | subject(:pretty_inspect) { user.pretty_inspect } 8 | 9 | before { Dry::Struct.load_extensions(:pretty_print) } 10 | 11 | context "with Test::User" do 12 | let(:user) do 13 | user_type[ 14 | name: "Jane", age: 21, 15 | address: {city: "NYC", zipcode: "123"} 16 | ] 17 | end 18 | 19 | it do 20 | is_expected.to eql <<~PRETTY_INSPECT 21 | #> 25 | PRETTY_INSPECT 26 | end 27 | end 28 | 29 | context "with Test::SuperUSer" do 30 | let(:user) do 31 | root_type[ 32 | name: :Mike, age: 43, root: false, 33 | address: {city: "Atlantis", zipcode: 456} 34 | ] 35 | end 36 | 37 | it do 38 | is_expected.to eql <<~PRETTY_INSPECT 39 | #> 44 | PRETTY_INSPECT 45 | end 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /spec/extensions/super_diff_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "tempfile" 4 | require "spec_helper" 5 | 6 | RSpec.describe Dry::Struct do 7 | let(:output_start_marker) do 8 | /(expected:)|(Expected )/ 9 | end 10 | 11 | let(:output_end_marker) do 12 | /#{output_start_marker.source}|Finished/ 13 | end 14 | 15 | def run_spec(code) 16 | temp_spec = Tempfile.new(["failing_spec", ".rb"]) 17 | temp_spec.write(<<~RUBY) 18 | require "dry/struct" 19 | 20 | RSpec.describe "A failing example" do 21 | before(:all) do 22 | Dry::Struct.load_extensions(:super_diff) 23 | end 24 | 25 | #{code} 26 | end 27 | RUBY 28 | temp_spec.close 29 | 30 | process_output(`rspec #{temp_spec.path}`, temp_spec.path) 31 | end 32 | 33 | def process_output(output, path) 34 | uncolored = output.gsub(/\e\[([;\d]+)?m/, "") 35 | # cut out significant lines 36 | lines = extract_diff(uncolored, path) 37 | prefix = lines.filter_map { |line| 38 | line.match(/^\A(\s+)/).to_s unless line.strip.empty? 39 | }.min 40 | processed_lines = lines.map { |line| line.gsub(prefix, "") } 41 | remove_banner(processed_lines).join.gsub("\n\n\n", "\n\n").gsub(/\n\n\z/, "\n") 42 | end 43 | 44 | # remove this part from the output: 45 | # 46 | # Diff: 47 | # 48 | # ┌ (Key) ──────────────────────────┐ 49 | # │ ‹-› in expected, not in actual │ 50 | # │ ‹+› in actual, not in expected │ 51 | # │ ‹ › in both expected and actual │ 52 | # └─────────────────────────────────┘ 53 | # 54 | def remove_banner(lines) 55 | before_banner = lines.take_while { |line| !line.start_with?("Diff:") } 56 | after_banner = lines.drop_while { |line| 57 | !line.include?("└") 58 | }.drop(1) 59 | before_banner + after_banner 60 | end 61 | 62 | def extract_diff(output, path) 63 | output.lines.drop_while { |line| 64 | !line[output_start_marker] 65 | }.take_while.with_index { |line, idx| 66 | idx.zero? || !(line.include?(path) || line[output_start_marker]) 67 | } 68 | end 69 | 70 | it "produces a nice diff" do 71 | output = run_spec(<<~RUBY) 72 | let(:user_type) do 73 | module Test 74 | class User < Dry::Struct 75 | attribute :name, 'string' 76 | attribute :age, 'integer' 77 | end 78 | end 79 | 80 | Test::User 81 | end 82 | 83 | let(:user) do 84 | user_type[name: "Jane", age: 21] 85 | end 86 | 87 | let(:other_user) do 88 | user_type[name: "Jane", age: 22] 89 | end 90 | 91 | example "failing" do 92 | expect(user).to eql(other_user) 93 | end 94 | RUBY 95 | 96 | expect(output).to eql(<<~DIFF) 97 | expected: # 98 | got: # 99 | 100 | (compared using eql?) 101 | 102 | # 107 | DIFF 108 | end 109 | end 110 | -------------------------------------------------------------------------------- /spec/integration/array_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Dry::Types::Array do 4 | before do 5 | module Test 6 | class Street < Dry::Struct 7 | attribute :street_name, "string" 8 | end 9 | 10 | class City < Dry::Struct 11 | attribute :city_name, "string" 12 | end 13 | 14 | CityOrStreet = City | Street 15 | end 16 | end 17 | 18 | describe "#try" do 19 | context "simple struct" do 20 | subject(:array) { Dry::Types["array"].of(Test::Street) } 21 | it "returns success for valid array" do 22 | expect(array.try([{street_name: "Oxford"}, {street_name: "London"}])).to be_success 23 | end 24 | 25 | it "returns failure for invalid array" do 26 | expect(array.try([{name: "Oxford"}, {name: 123}])).to be_failure 27 | expect(array.try([{}])).to be_failure 28 | end 29 | end 30 | 31 | context "sum struct" do 32 | subject(:array) { Dry::Types["array"].of(Test::CityOrStreet) } 33 | 34 | it "returns success for valid array" do 35 | expect(array.try([{city_name: "London"}, {street_name: "Oxford"}])).to be_success 36 | expect(array.try([Test::Street.new(street_name: "Oxford")])).to be_success 37 | end 38 | 39 | it "returns failure for invalid array" do 40 | expect(array.try([{city_name: "London"}, {street_name: 123}])).to be_failure 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /spec/integration/attribute_dsl/abstract_struct_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Dry::Struct, method: ".abstract" do 4 | before do 5 | class Test::Abstract < Dry::Struct 6 | abstract 7 | 8 | transform_keys(&:to_sym) 9 | 10 | def key?(key) 11 | attributes.key?(key) 12 | end 13 | end 14 | end 15 | 16 | it "is reused as a base class in descendants" do 17 | class Test::User < Test::Abstract 18 | attribute :name, "string" 19 | 20 | attribute :address do 21 | attribute :city, "string" 22 | end 23 | end 24 | 25 | user = Test::User.("name" => "John", "address" => {"city" => "Mexico"}) 26 | 27 | expect(user.to_h).to eql( 28 | name: "John", 29 | address: {city: "Mexico"} 30 | ) 31 | expect(Test::User::Address).to be < Test::Abstract 32 | expect(user.address.key?(:city)).to be(true) 33 | expect(user.address.key?(:street)).to be(false) 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /spec/integration/attribute_dsl/definition_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Dry::Struct, method: ".attribute" do 4 | include_context "user type" 5 | 6 | def assert_valid_struct(user) 7 | expect(user.name).to eql("Jane") 8 | expect(user.age).to be(21) 9 | expect(user.address.city).to eql("NYC") 10 | expect(user.address.zipcode).to eql("123") 11 | end 12 | 13 | context "when given a pre-defined nested type" do 14 | it "defines attributes for the constructor" do 15 | user = user_type[ 16 | name: :Jane, age: "21", address: {city: "NYC", zipcode: 123} 17 | ] 18 | 19 | assert_valid_struct(user) 20 | end 21 | end 22 | 23 | context "when given a block-style nested type" do 24 | context "when the nested type is not already defined" do 25 | context "with no superclass type" do 26 | let(:user_type) do 27 | Class.new(Dry::Struct) do 28 | attribute :name, "coercible.string" 29 | attribute :age, "coercible.integer" 30 | attribute :address do 31 | attribute :city, "strict.string" 32 | attribute :zipcode, "coercible.string" 33 | end 34 | end 35 | end 36 | 37 | it "defines attributes for the constructor" do 38 | user = user_type[ 39 | name: :Jane, age: "21", address: {city: "NYC", zipcode: 123} 40 | ] 41 | 42 | assert_valid_struct(user) 43 | end 44 | 45 | it "defines a nested type" do 46 | expect { user_type.const_get("Address") }.to_not raise_error 47 | end 48 | end 49 | 50 | context "with a superclass type" do 51 | let(:user_type) do 52 | Class.new(Dry::Struct) do 53 | attribute :name, "coercible.string" 54 | attribute :age, "coercible.integer" 55 | attribute :address, Test::BaseAddress do 56 | attribute :city, "strict.string" 57 | attribute :zipcode, "coercible.string" 58 | end 59 | end 60 | end 61 | 62 | it "defines attributes for the constructor" do 63 | user = user_type[ 64 | name: :Jane, 65 | age: "21", 66 | address: { 67 | street: "123 Fake Street", 68 | city: "NYC", 69 | zipcode: 123 70 | } 71 | ] 72 | 73 | assert_valid_struct(user) 74 | expect(user.address.street).to eq("123 Fake Street") 75 | end 76 | 77 | it "defines a nested type" do 78 | expect { user_type.const_get("Address") }.to_not raise_error 79 | end 80 | 81 | context "optional struct" do 82 | let(:user_type) do 83 | Class.new(Dry::Struct) do 84 | attribute :name, "coercible.string" 85 | attribute :address, Dry::Struct.optional do 86 | attribute :city, "string" 87 | end 88 | end 89 | end 90 | 91 | it "accepts nil as input" do 92 | expect { user_type[name: :Jane, address: nil] }.to_not raise_error 93 | end 94 | end 95 | end 96 | end 97 | 98 | context "when the nested type is not already defined" do 99 | before do 100 | module Test 101 | module AlreadyDefined 102 | class User < Dry::Struct 103 | class Address 104 | end 105 | end 106 | end 107 | end 108 | end 109 | 110 | it "raises a Dry::Struct::Error" do 111 | expect { 112 | Test::AlreadyDefined::User.attribute(:address) {} 113 | }.to raise_error(Dry::Struct::Error) 114 | end 115 | end 116 | end 117 | 118 | it "ignores unknown keys" do 119 | user = user_type[ 120 | name: :Jane, age: "21", address: {city: "NYC", zipcode: 123}, invalid: "foo" 121 | ] 122 | 123 | assert_valid_struct(user) 124 | end 125 | 126 | it "merges attributes from the parent struct" do 127 | user = root_type[ 128 | name: :Jane, age: "21", root: true, address: {city: "NYC", zipcode: 123} 129 | ] 130 | 131 | assert_valid_struct(user) 132 | 133 | expect(user.root).to be(true) 134 | end 135 | 136 | context "when no nested attribute block given" do 137 | it "raises error when type is missing" do 138 | expect { 139 | class Test::Foo < Dry::Struct 140 | attribute :bar 141 | end 142 | }.to raise_error(ArgumentError) 143 | end 144 | end 145 | 146 | context "when nested attribute block given" do 147 | it "does not raise error when type is missing" do 148 | expect { 149 | class Test::Foo < Dry::Struct 150 | attribute :bar do 151 | attribute :foo, "strict.string" 152 | end 153 | end 154 | }.to_not raise_error 155 | end 156 | end 157 | 158 | it "raises error when attribute is defined twice" do 159 | expect { 160 | class Test::Foo < Dry::Struct 161 | attribute :bar, "strict.string" 162 | attribute :bar, "strict.string" 163 | end 164 | }.to raise_error( 165 | Dry::Struct::RepeatedAttributeError, 166 | "Attribute :bar has already been defined" 167 | ) 168 | end 169 | 170 | it "allows to redefine attributes in a subclass" do 171 | expect { 172 | class Test::Foo < Dry::Struct 173 | attribute :bar, "strict.string" 174 | end 175 | 176 | class Test::Bar < Test::Foo 177 | attribute :bar, "strict.integer" 178 | end 179 | }.not_to raise_error 180 | end 181 | 182 | it "can be chained" do 183 | class Test::Foo < Dry::Struct 184 | end 185 | 186 | Test::Foo 187 | .attribute(:foo, "strict.string") 188 | .attribute(:bar, "strict.integer") 189 | 190 | foo = Test::Foo.new(foo: "foo", bar: 123) 191 | 192 | expect(foo.foo).to eql("foo") 193 | expect(foo.bar).to eql(123) 194 | end 195 | 196 | it "doesn't define readers if methods are present" do 197 | class Test::Foo < Dry::Struct 198 | def age 199 | "#{@attributes[:age]} years old" 200 | end 201 | end 202 | 203 | Test::Foo 204 | .attribute(:age, "strict.integer") 205 | 206 | struct = Test::Foo.new(age: 18) 207 | expect(struct.age).to eql("18 years old") 208 | end 209 | 210 | context "attribute?" do 211 | it "defines omittable keys" do 212 | class Test::Foo < Dry::Struct 213 | attribute :foo, "strict.string" 214 | attribute? :bar, "strict.string" 215 | end 216 | 217 | struct = Test::Foo.new(foo: "value") 218 | expect(struct.foo).to eql("value") 219 | expect(struct.attributes).not_to have_key(:bar) 220 | expect(Test::Foo.has_attribute?(:bar)).to be true 221 | 222 | struct = Test::Foo.new(foo: "value", bar: "another value") 223 | expect(struct.bar).to eql("another value") 224 | end 225 | 226 | it "defines omittable structs" do 227 | class Test::Foo < Dry::Struct 228 | attribute :foo, "string" 229 | attribute? :nested do 230 | attribute :bar, "string" 231 | end 232 | end 233 | 234 | struct = Test::Foo.new(foo: "value") 235 | expect(struct.foo).to eql("value") 236 | expect(struct.attributes).not_to have_key(:nested) 237 | expect(Test::Foo.has_attribute?(:nested)).to be true 238 | end 239 | end 240 | end 241 | -------------------------------------------------------------------------------- /spec/integration/attribute_dsl/nested_array_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Dry::Struct, method: ".attribute" do 4 | let(:user_type) { Test::User } 5 | 6 | before do 7 | module Test 8 | class Role < Dry::Struct 9 | attribute :id, "strict.integer" 10 | attribute :name, "strict.string" 11 | end 12 | 13 | class User < Dry::Struct 14 | end 15 | end 16 | end 17 | 18 | context "when given a block-style nested type" do 19 | context "when the nested type is not already defined" do 20 | context "with no superclass type" do 21 | before do 22 | module Test 23 | User.attribute(:permissions, Dry::Types["strict.array"]) do 24 | attribute :id, "strict.integer" 25 | attribute :name, "strict.string" 26 | end 27 | end 28 | end 29 | 30 | it "defines attributes for the constructor" do 31 | user = user_type[ 32 | permissions: [{id: 1, name: "all"}, {id: 2, name: "edit_users"}] 33 | ] 34 | 35 | expect(user.permissions.length).to be(2) 36 | expect(user.permissions[0].id).to be(1) 37 | expect(user.permissions[0].name).to eql("all") 38 | expect(user.permissions[1].id).to be(2) 39 | expect(user.permissions[1].name).to eql("edit_users") 40 | end 41 | 42 | it "defines a nested type" do 43 | expect { user_type.const_get("Permission") }.to_not raise_error 44 | end 45 | end 46 | 47 | context "with a superclass type" do 48 | %w[array strict.array coercible.array].each do |array_type| 49 | context "using #{array_type}" do 50 | before do 51 | module Test 52 | class BasePermission < Dry::Struct 53 | attribute :id, "strict.integer" 54 | end 55 | end 56 | 57 | Test::User.attribute(:permissions, Dry::Types[array_type].of(Test:: BasePermission)) do 58 | attribute :name, "strict.string" 59 | end 60 | end 61 | 62 | it "uses the given array type" do 63 | expect(user_type.schema.key(:permissions).type) 64 | .to eql(Dry::Types[array_type].of(Test::User::Permission)) 65 | end 66 | 67 | it "defines attributes for the constructor" do 68 | user = user_type[ 69 | permissions: [{id: 1, name: "all"}, {id: 2, name: "edit_users"}] 70 | ] 71 | 72 | expect(user.permissions.length).to be(2) 73 | expect(user.permissions[0].id).to be(1) 74 | expect(user.permissions[0].name).to eql("all") 75 | expect(user.permissions[1].id).to be(2) 76 | expect(user.permissions[1].name).to eql("edit_users") 77 | end 78 | 79 | it "defines a nested type" do 80 | expect { user_type.const_get("Permission") }.to_not raise_error 81 | end 82 | end 83 | end 84 | end 85 | 86 | context "with a named type" do 87 | before do 88 | module Test 89 | User.attribute(:permissions, "strict.array") do 90 | attribute :id, "strict.integer" 91 | attribute :name, "strict.string" 92 | end 93 | end 94 | end 95 | 96 | it "uses the given array type" do 97 | expect(user_type.schema.key(:permissions).type) 98 | .to eql(Dry::Types["strict.array"].of(Test::User::Permission)) 99 | end 100 | end 101 | end 102 | 103 | context "when the nested type is already defined" do 104 | before do 105 | module Test 106 | class User < Dry::Struct 107 | class Role < Dry::Struct 108 | end 109 | end 110 | end 111 | end 112 | 113 | it "raises a Dry::Struct::Error" do 114 | expect { 115 | Test::User.attribute(:roles, Dry::Types["strict.array"]) {} 116 | }.to raise_error(Dry::Struct::Error) 117 | end 118 | end 119 | end 120 | 121 | context "when no nested attribute block given" do 122 | it "raises error when type is missing" do 123 | expect { 124 | class Test::Foo < Dry::Struct 125 | attribute :bar 126 | end 127 | }.to raise_error(ArgumentError) 128 | end 129 | end 130 | end 131 | -------------------------------------------------------------------------------- /spec/integration/attribute_dsl/nested_struct_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Dry::Struct, method: ".attribute" do 4 | include_context "user type" 5 | 6 | def assert_valid_struct(user) 7 | expect(user.name).to eql("Jane") 8 | expect(user.age).to be(21) 9 | expect(user.address.city).to eql("NYC") 10 | expect(user.address.zipcode).to eql("123") 11 | end 12 | 13 | context "when given a pre-defined nested type" do 14 | it "defines attributes for the constructor" do 15 | user = user_type[ 16 | name: :Jane, age: "21", address: {city: "NYC", zipcode: 123} 17 | ] 18 | 19 | assert_valid_struct(user) 20 | end 21 | end 22 | 23 | context "when given a block-style nested type" do 24 | context "when the nested type is already defined" do 25 | context "with no superclass type" do 26 | let(:user_type) do 27 | Class.new(Dry::Struct) do 28 | attribute :name, "coercible.string" 29 | attribute :age, "coercible.integer" 30 | attribute :address do 31 | attribute :city, "strict.string" 32 | attribute :zipcode, "coercible.string" 33 | end 34 | end 35 | end 36 | 37 | it "defines attributes for the constructor" do 38 | user = user_type[ 39 | name: :Jane, age: "21", address: {city: "NYC", zipcode: 123} 40 | ] 41 | 42 | assert_valid_struct(user) 43 | end 44 | 45 | it "defines a nested type" do 46 | expect { user_type.const_get("Address") }.to_not raise_error 47 | end 48 | end 49 | 50 | context "with a superclass type" do 51 | let(:user_type) do 52 | Class.new(Dry::Struct) do 53 | attribute :name, "coercible.string" 54 | attribute :age, "coercible.integer" 55 | attribute :address, Test::BaseAddress do 56 | attribute :city, "string" 57 | attribute :zipcode, "coercible.string" 58 | end 59 | end 60 | end 61 | 62 | it "defines attributes for the constructor" do 63 | user = user_type[ 64 | name: :Jane, age: "21", address: { 65 | street: "123 Fake Street", 66 | city: "NYC", 67 | zipcode: 123 68 | } 69 | ] 70 | 71 | assert_valid_struct(user) 72 | expect(user.address.street).to eq("123 Fake Street") 73 | end 74 | 75 | it "defines a nested type" do 76 | expect { user_type.const_get("Address") }.to_not raise_error 77 | end 78 | end 79 | end 80 | 81 | context "when the nested type is not defined" do 82 | let(:struct) { Class.new(Dry::Struct) } 83 | 84 | it "should check constant existence within class scope only" do 85 | expect { struct.attribute(:test) { attribute(:abc, "string") } }.not_to raise_error 86 | end 87 | end 88 | 89 | context "when the nested type is already defined" do 90 | before do 91 | module Test 92 | module AlreadyDefined 93 | class User < Dry::Struct 94 | class Address 95 | end 96 | end 97 | end 98 | end 99 | end 100 | 101 | it "raises a Dry::Struct::Error" do 102 | expect { 103 | Test::AlreadyDefined::User.attribute(:address) {} 104 | }.to raise_error(Dry::Struct::Error) 105 | end 106 | end 107 | end 108 | 109 | context "when no nested attribute block given" do 110 | it "raises error when type is missing" do 111 | expect { 112 | class Test::Foo < Dry::Struct 113 | attribute :bar 114 | end 115 | }.to raise_error(ArgumentError) 116 | end 117 | end 118 | 119 | context "when nested attribute block given" do 120 | it "does not raise error when type is missing" do 121 | expect { 122 | class Test::Foo < Dry::Struct 123 | attribute :bar do 124 | attribute :foo, "strict.string" 125 | end 126 | end 127 | }.to_not raise_error 128 | end 129 | end 130 | 131 | it "ignores unknown keys" do 132 | user = user_type[ 133 | name: :Jane, age: "21", address: {city: "NYC", zipcode: 123}, invalid: "foo" 134 | ] 135 | 136 | assert_valid_struct(user) 137 | end 138 | 139 | it "merges attributes from the parent struct" do 140 | user = root_type[ 141 | name: :Jane, age: "21", root: true, address: {city: "NYC", zipcode: 123} 142 | ] 143 | 144 | assert_valid_struct(user) 145 | 146 | expect(user.root).to be(true) 147 | end 148 | 149 | it "raises error when attribute is defined twice" do 150 | expect { 151 | class Test::Foo < Dry::Struct 152 | attribute :bar, "strict.string" 153 | attribute :bar, "strict.string" 154 | end 155 | }.to raise_error( 156 | Dry::Struct::RepeatedAttributeError, 157 | "Attribute :bar has already been defined" 158 | ) 159 | end 160 | 161 | it "allows to redefine attributes in a subclass" do 162 | expect { 163 | class Test::Foo < Dry::Struct 164 | attribute :bar, "strict.string" 165 | end 166 | 167 | class Test::Bar < Test::Foo 168 | attribute :bar, "strict.integer" 169 | end 170 | }.not_to raise_error 171 | end 172 | 173 | it "can be chained" do 174 | class Test::Foo < Dry::Struct 175 | end 176 | 177 | Test::Foo 178 | .attribute(:foo, "strict.string") 179 | .attribute(:bar, "strict.integer") 180 | 181 | foo = Test::Foo.new(foo: "foo", bar: 123) 182 | 183 | expect(foo.foo).to eql("foo") 184 | expect(foo.bar).to eql(123) 185 | end 186 | 187 | it "doesn't define readers if methods are present" do 188 | class Test::Foo < Dry::Struct 189 | def age 190 | "#{@attributes[:age]} years old" 191 | end 192 | end 193 | 194 | Test::Foo 195 | .attribute(:age, "strict.integer") 196 | 197 | struct = Test::Foo.new(age: 18) 198 | expect(struct.age).to eql("18 years old") 199 | end 200 | 201 | context "keeping transformations" do 202 | it "works for simple structs" do 203 | class Test::Foo < Dry::Struct 204 | transform_types(&:optional) 205 | transform_keys(&:to_sym) 206 | 207 | attribute :address do 208 | attribute :city, "string" 209 | end 210 | end 211 | 212 | struct = Test::Foo.new("address" => {"city" => "London"}) 213 | 214 | expect(struct.to_h).to eql(address: {city: "London"}) 215 | 216 | struct = Test::Foo.new("address" => {"city" => nil}) 217 | 218 | expect(struct.to_h).to eql(address: {city: nil}) 219 | end 220 | 221 | it "works for arrays" do 222 | class Test::Foo < Dry::Struct 223 | transform_types(&:optional) 224 | transform_keys(&:to_sym) 225 | 226 | attribute :address, "array" do 227 | attribute :city, "string" 228 | end 229 | end 230 | 231 | struct = Test::Foo.new("address" => ["city" => "London"]) 232 | 233 | expect(struct.to_h).to eql(address: [city: "London"]) 234 | 235 | struct = Test::Foo.new("address" => ["city" => nil]) 236 | 237 | expect(struct.to_h).to eql(address: [city: nil]) 238 | end 239 | 240 | example "explicit structs cancel transformations" do 241 | class Test::Foo < Dry::Struct 242 | transform_types(&:optional) 243 | transform_keys(&:to_sym) 244 | 245 | attribute :address, Dry::Struct do 246 | attribute :city, "string" 247 | end 248 | end 249 | 250 | expect(Test::Foo.valid?("address" => {"city" => "London"})).to be(false) 251 | expect(Test::Foo.valid?("address" => {city: nil})).to be(false) 252 | expect(Test::Foo.valid?("address" => {city: "London"})).to be(true) 253 | end 254 | 255 | example "non-polymorphic array types cancel transformations" do 256 | class Test::Foo < Dry::Struct 257 | transform_types(&:optional) 258 | transform_keys(&:to_sym) 259 | 260 | attribute :address, Dry::Types["array"].of(Dry::Struct) do 261 | attribute :city, "string" 262 | end 263 | end 264 | 265 | expect(Test::Foo.valid?("address" => ["city" => "London"])).to be(false) 266 | expect(Test::Foo.valid?("address" => [city: nil])).to be(false) 267 | expect(Test::Foo.valid?("address" => [city: "London"])).to be(true) 268 | end 269 | end 270 | end 271 | -------------------------------------------------------------------------------- /spec/integration/attributes_from_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe "Dry::Struct.attributes_from" do 4 | it "composes attributes at place" do 5 | module Test 6 | class Address < Dry::Struct 7 | attribute :city, "string" 8 | attribute :zipcode, "coercible.string" 9 | end 10 | 11 | class User < Dry::Struct 12 | attribute :name, "coercible.string" 13 | attributes_from Address 14 | attribute :age, "coercible.integer" 15 | end 16 | end 17 | 18 | expect(Test::User.attribute_names).to eql( 19 | [:name, :city, :zipcode, :age] 20 | ) 21 | end 22 | 23 | it "composes within a nested attribute" do 24 | module Test 25 | class Address < Dry::Struct 26 | attribute :city, "string" 27 | attribute :zipcode, "coercible.string" 28 | end 29 | 30 | class User < Dry::Struct 31 | attribute :address do 32 | attributes_from Address 33 | end 34 | end 35 | end 36 | 37 | expect(Test::User.schema.key(:address).attribute_names).to eql( 38 | [:city, :zipcode] 39 | ) 40 | end 41 | 42 | it "composes a nested attribute" do 43 | module Test 44 | class Address < Dry::Struct 45 | attribute :address do 46 | attribute :city, "string" 47 | attribute :zipcode, "coercible.string" 48 | end 49 | end 50 | 51 | class User < Dry::Struct 52 | attributes_from Address 53 | end 54 | end 55 | 56 | expect(Test::User.schema.key(:address).attribute_names).to eql( 57 | [:city, :zipcode] 58 | ) 59 | end 60 | 61 | context "behavior" do 62 | before do 63 | module Test 64 | class Address < Dry::Struct 65 | attribute :address do 66 | attribute :city, "string" 67 | attribute :zipcode, "coercible.string" 68 | end 69 | end 70 | 71 | class User < Dry::Struct 72 | attributes_from Address 73 | end 74 | end 75 | end 76 | 77 | let(:user) { Test::User.new(address: {city: "NYC", zipcode: 123}) } 78 | 79 | it "adds accessors" do 80 | expect(user.address.city).to eql("NYC") 81 | end 82 | 83 | it "resets attribute names" do 84 | expect(Test::User.attribute_names).to eql(%i[address]) 85 | end 86 | 87 | context "when attribute name is not a valid method name" do 88 | before do 89 | module Test 90 | class InvalidName < Dry::Struct 91 | attribute :"123", "string" 92 | attribute :":", "string" 93 | attribute :"with space", "string" 94 | attribute :"with-dash", "string" 95 | end 96 | end 97 | end 98 | 99 | it "adds an accessor" do 100 | odd_struct = Test::InvalidName.new( 101 | "123": "John", 102 | ":": "Jane", 103 | "with space": "Doe", 104 | "with-dash": "Smith" 105 | ) 106 | expect(odd_struct.public_send(:"123")).to eql("John") 107 | expect(odd_struct.public_send(:":")).to eql("Jane") 108 | expect(odd_struct.public_send("with space")).to eql("Doe") 109 | expect(odd_struct.public_send("with-dash")).to eql("Smith") 110 | end 111 | end 112 | 113 | context "inheritance" do 114 | before do 115 | class Test::Person < Dry::Struct 116 | end 117 | 118 | class Test::Citizen < Test::Person 119 | end 120 | 121 | Test::Person.attributes_from(Test::Address) 122 | end 123 | 124 | let(:citizen) { Test::Citizen.new(user.to_h) } 125 | 126 | it "adds attributes to subclasses" do 127 | expect(citizen.address.city).to eql("NYC") 128 | end 129 | end 130 | 131 | context "omittable keys" do 132 | before do 133 | module Test 134 | class Address 135 | attribute? :country, "string" 136 | end 137 | 138 | class Person < Dry::Struct 139 | attributes_from Address 140 | end 141 | end 142 | end 143 | 144 | let(:person_without_country) { Test::Person.new(user.to_h) } 145 | 146 | let(:person_with_country) do 147 | Test::Person.new( 148 | country: "uk", 149 | address: { 150 | city: "London", 151 | zipcode: 234 152 | } 153 | ) 154 | end 155 | 156 | it "adds omittable keys" do 157 | expect(person_without_country.country).to be_nil 158 | expect(person_with_country.country).to eql("uk") 159 | end 160 | end 161 | end 162 | end 163 | -------------------------------------------------------------------------------- /spec/integration/compile_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Dry::Struct::Compiler do 4 | subject(:compiler) { described_class.new(Dry::Types) } 5 | 6 | let(:address) do 7 | Dry.Struct(street: "string", city?: "optional.string") 8 | end 9 | 10 | it "compiles struct back to the same class" do 11 | expect(compiler.(address.to_ast)).to be(address) 12 | end 13 | 14 | unless defined?(JRUBY_VERSION) 15 | it "raises an error when the original struct was reclaimed" do 16 | collected = nil 17 | 18 | (1..100).each do |pow| 19 | asts = Array.new(10**pow) { Dry.Struct(street: "string").to_ast } 20 | 21 | 10.times do 22 | GC.start 23 | GC.start 24 | break if (collected = asts.find { |ast| !ast[1][0].weakref_alive? }) 25 | end 26 | break unless collected.nil? 27 | end 28 | 29 | expect(collected).not_to be_nil 30 | expect { compiler.(collected) }.to raise_error(Dry::Struct::RecycledStructError) 31 | end 32 | end 33 | 34 | context "struct constructor" do 35 | let(:address) { super().constructor(:itself.to_proc) } 36 | 37 | specify do 38 | expect(compiler.(address.to_ast)).to eql(address) 39 | end 40 | end 41 | 42 | context "optional struct" do 43 | let(:address) { super().optional } 44 | 45 | specify do 46 | expect(compiler.(address.to_ast)).to eql(address) 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /spec/integration/constructor_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Dry::Struct::Constructor do 4 | include_context "user type" 5 | 6 | subject(:type) { Test::User.constructor(-> x { x }) } 7 | 8 | it_behaves_like Dry::Types::Nominal do 9 | end 10 | 11 | it "adds meta" do 12 | expect(type.meta(foo: :bar).meta).to eql(foo: :bar) 13 | end 14 | 15 | it "has .type equal to .primitive" do 16 | expect(type.type).to be(type.primitive) 17 | end 18 | 19 | describe "#optional" do 20 | let(:type) { super().optional } 21 | 22 | it "builds an optional type" do 23 | expect(type).to be_optional 24 | expect(type.(nil)).to be(nil) 25 | end 26 | end 27 | 28 | describe "#prepend" do 29 | let(:type) do 30 | super().prepend { |x| x.to_h { |k, v| [k.to_sym, v] } } 31 | end 32 | 33 | specify do 34 | user = type.( 35 | "name" => "John", 36 | "age" => 20, 37 | "address" => {city: "London", zipcode: 123_123} 38 | ) 39 | expect(user).to be_a(Test::User) 40 | end 41 | end 42 | 43 | context "wrapping constructors" do 44 | defaults = { 45 | age: 18, 46 | name: "John Doe" 47 | } 48 | 49 | subject(:type) do 50 | Test::User.constructor do |input, type| 51 | type.(input) { type.(defaults.merge(input)) } 52 | end 53 | end 54 | 55 | it "makes a seconds try with default values added" do 56 | expect(type.(address: {city: "London", zipcode: 123_123})).to be_a(Test::User) 57 | end 58 | 59 | it "has .type equal to .primitive" do 60 | expect(type.type).to be(type.primitive) 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /spec/integration/dry_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Dry do 4 | describe ".Struct" do 5 | it "returns a struct" do 6 | struct_klass = Dry.Struct(name: "strict.string") 7 | 8 | struct = struct_klass.new(name: "Test") 9 | expect(struct.attributes).to eql(name: "Test") 10 | end 11 | 12 | context "initializer block" do 13 | before do 14 | module Test 15 | Library = Dry.Struct do 16 | schema schema.strict 17 | 18 | attribute :library, "string" 19 | attribute :language, "string" 20 | 21 | def qualified 22 | "#{language}/#{library}" 23 | end 24 | end 25 | end 26 | end 27 | 28 | it "sets the correct constructor type" do 29 | expect { 30 | Test::Library.new(library: "dry-rb") 31 | }.to raise_error( 32 | Dry::Struct::Error, 33 | "[Test::Library.new] :language is missing in Hash input" 34 | ) 35 | end 36 | 37 | it "sets the correct attributes" do 38 | attributes = {library: "dry-struct", language: "Ruby"} 39 | expect(Test::Library.new(attributes).to_h).to eql(attributes) 40 | end 41 | 42 | it "can define methods within block" do 43 | attributes = {library: "dry-struct", language: "Ruby"} 44 | expect(Test::Library.new(attributes).qualified).to eql("Ruby/dry-struct") 45 | end 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /spec/integration/pattern_matching_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "dry/monads" 4 | 5 | RSpec.describe "pattern matching" do 6 | let(:struct) do 7 | Dry.Struct( 8 | first_name: "string", 9 | last_name: "string", 10 | address: Dry.Struct( 11 | city: "string", 12 | street: "string" 13 | ).tap { stub_const("Address", _1) } 14 | ).tap { stub_const("User", _1) } 15 | end 16 | 17 | let(:john) do 18 | struct.( 19 | first_name: "John", 20 | last_name: "Doe", 21 | address: { 22 | city: "Barcelona", 23 | street: "Carrer de Mallorca" 24 | } 25 | ) 26 | end 27 | 28 | let(:jack) { john.new(first_name: "Jack") } 29 | 30 | let(:boris) do 31 | struct.( 32 | first_name: "Boris", 33 | last_name: "Johnson", 34 | address: { 35 | city: "London", 36 | street: "Downing street" 37 | } 38 | ) 39 | end 40 | 41 | let(:alice) { john.new(first_name: "Alice") } 42 | 43 | let(:carol) { john.new(first_name: "Carol") } 44 | 45 | context "pattern matching" do 46 | def match(user) 47 | case user 48 | in User(first_name: "Jack") 49 | "It's Jack" 50 | in User(first_name: "Alice" | "Carol") 51 | "Alice or Carol" 52 | in User(first_name:, last_name: "Doe") 53 | "DOE, #{first_name.upcase}" 54 | in User(first_name:, address: Address(street: "Downing street")) 55 | "PM is #{first_name}" 56 | end 57 | end 58 | 59 | specify do 60 | expect(match(john)).to eql("DOE, JOHN") 61 | expect(match(jack)).to eql("It's Jack") 62 | expect(match(boris)).to eql("PM is Boris") 63 | expect(match(alice)).to eql("Alice or Carol") 64 | expect(match(carol)).to eql("Alice or Carol") 65 | end 66 | 67 | example "collecting name" do 68 | case john 69 | in User(address: _, **name) 70 | expect(name).to eql(first_name: "John", last_name: "Doe") 71 | end 72 | end 73 | 74 | example "multiple structs" do 75 | case john 76 | in User(first_name: "John" | "Jack") 77 | "John or Jack" 78 | end 79 | end 80 | end 81 | 82 | context "using with monads" do 83 | include Dry::Monads[:result, :maybe] 84 | 85 | let(:matching_context) do 86 | module Test 87 | class Operation 88 | include Dry::Monads[:result] 89 | 90 | def call(result) 91 | case result 92 | in Success(User(first_name:)) 93 | "Name is #{first_name}" 94 | in Failure[:not_found] 95 | "Wasn't found" 96 | in Failure[error] 97 | "Error: #{error.inspect}, no meta given" 98 | in Failure[error, meta] 99 | "Error: #{error.inspect}, meta: #{meta.inspect}" 100 | end 101 | end 102 | end 103 | end 104 | Test::Operation 105 | end 106 | 107 | def match(result) 108 | matching_context.new.(result) 109 | end 110 | 111 | it "matches results" do 112 | expect(match(Success(john))).to eql("Name is John") 113 | expect(match(Success(boris))).to eql("Name is Boris") 114 | expect(match(Failure([:not_found]))).to eql("Wasn't found") 115 | expect(match(Failure([:not_valid]))).to eql("Error: :not_valid, no meta given") 116 | 117 | if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("3.4.0") 118 | expect(match(Failure([:not_valid, name: "Too short"]))).to eql( 119 | 'Error: :not_valid, meta: {name: "Too short"}' 120 | ) 121 | else 122 | expect(match(Failure([:not_valid, name: "Too short"]))).to eql( 123 | 'Error: :not_valid, meta: {:name=>"Too short"}' 124 | ) 125 | end 126 | end 127 | end 128 | end 129 | -------------------------------------------------------------------------------- /spec/integration/struct_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Dry::Struct do 4 | include_context "user type" 5 | 6 | it_behaves_like Dry::Struct do 7 | subject(:type) { root_type } 8 | end 9 | 10 | shared_examples_for "typical constructor" do 11 | it "raises StructError when attribute constructor failed" do 12 | expect { 13 | construct_user(name: :Jane, age: "21", address: nil) 14 | }.to raise_error( 15 | Dry::Struct::Error, 16 | /\[Test::Address.new\] :city is missing in Hash input/ 17 | ) 18 | end 19 | 20 | it "passes through values when they are structs already" do 21 | address = Test::Address.new(city: "NYC", zipcode: "312") 22 | user = construct_user(name: "Jane", age: 21, address: address) 23 | 24 | expect(user.address).to be(address) 25 | end 26 | 27 | it "returns itself when an argument is an instance of given class" do 28 | user = user_type[ 29 | name: :Jane, age: "21", address: {city: "NYC", zipcode: 123} 30 | ] 31 | 32 | expect(construct_user(user)).to be_equal(user) 33 | end 34 | 35 | it "creates an empty struct when called without arguments" do 36 | class Test::Empty < Dry::Struct 37 | @constructor = Dry::Types["strict.hash"].schema(schema).strict 38 | end 39 | 40 | expect { Test::Empty.new }.to_not raise_error 41 | end 42 | end 43 | 44 | describe ".new" do 45 | def construct_user(attributes) 46 | user_type.new(attributes) 47 | end 48 | 49 | it_behaves_like "typical constructor" 50 | 51 | it "returns new object when an argument is an instance of subclass" do 52 | user = root_type[ 53 | name: :Jane, age: "21", root: true, address: {city: "NYC", zipcode: 123} 54 | ] 55 | 56 | expect(construct_user(user)).to be_instance_of(user_type) 57 | end 58 | 59 | it "supports safe call when a struct is given" do 60 | subtype = Class.new(root_type) { attribute :age, "string" } 61 | user = subtype[ 62 | name: :Jane, age: "twenty-one", root: true, address: {city: "NYC", zipcode: 123} 63 | ] 64 | 65 | expect(root_type.new(user, true) { :fallback }).to be(:fallback) 66 | end 67 | 68 | context "with default" do 69 | it "resolves missing values with defaults" do 70 | struct = Class.new(Dry::Struct) do 71 | attribute :name, Dry::Types["strict.string"].default("Jane") 72 | attribute :admin, Dry::Types["strict.bool"].default(true) 73 | end 74 | 75 | expect(struct.new.to_h) 76 | .to eql(name: "Jane", admin: true) 77 | end 78 | 79 | it "doesn't tolerate missing required keys" do 80 | struct = Class.new(Dry::Struct) do 81 | attribute :name, Dry::Types["strict.string"].default("Jane") 82 | attribute :age, Dry::Types["strict.integer"] 83 | end 84 | 85 | expect { struct.new }.to raise_error(Dry::Struct::Error, /:age is missing in Hash input/) 86 | end 87 | 88 | it "resolves missing values for nested attributes" do 89 | struct = Class.new(Dry::Struct) do 90 | attribute :kid do 91 | attribute :age, Dry::Types["strict.integer"].default(16) 92 | end 93 | end 94 | 95 | expect(struct.new.to_h) 96 | .to eql({kid: {age: 16}}) 97 | end 98 | 99 | it "doesn't tolerate missing required keys for nested attributes" do 100 | struct = Class.new(Dry::Struct) do 101 | attribute :kid do 102 | attribute :name, Dry::Types["strict.string"].default("Jane") 103 | attribute :age, Dry::Types["strict.integer"] 104 | end 105 | end 106 | 107 | expect { struct.new }.to raise_error(Dry::Struct::Error, /:age is missing in Hash input/) 108 | end 109 | end 110 | 111 | it "doesn't coerce to a hash recursively" do 112 | properties = Class.new(Dry::Struct) do 113 | attribute :age, Dry::Types["strict.integer"].constructor(-> v { v + 1 }) 114 | end 115 | 116 | struct = Class.new(Dry::Struct) do 117 | attribute :name, Dry::Types["strict.string"] 118 | attribute :properties, properties 119 | end 120 | 121 | original = struct.new(name: "Jane", properties: {age: 20}) 122 | 123 | expect(original.properties.age).to eql(21) 124 | 125 | transformed = original.new(name: "John") 126 | 127 | expect(transformed.properties.age).to eql(21) 128 | end 129 | end 130 | 131 | describe ".call" do 132 | def construct_user(attributes) 133 | user_type.call(attributes) 134 | end 135 | 136 | it_behaves_like "typical constructor" 137 | 138 | it "returns itself when an argument is an instance of subclass" do 139 | user = root_type[ 140 | name: :Jane, age: "21", root: true, address: {city: "NYC", zipcode: 123} 141 | ] 142 | 143 | expect(construct_user(user)).to be_equal(user) 144 | end 145 | end 146 | 147 | it "defines .[] alias" do 148 | expect(described_class.method(:[])).to eq described_class.method(:call) 149 | end 150 | 151 | describe ".inherited", :suppress_deprecations do 152 | it "adds attributes to all descendants" do 153 | Test::User.attribute(:signed_on, Dry::Types["strict.time"]) 154 | 155 | expect(Test::SuperUser.schema.key(:signed_on).type).to eql(Dry::Types["strict.time"]) 156 | end 157 | 158 | it "doesn't override already defined attributes accidentally" do 159 | admin = Dry::Types["strict.string"].enum("admin") 160 | 161 | Test::SuperUser.attribute(:role, admin) 162 | Test::User.attribute(:role, Dry::Types["strict.string"].enum("author", "subscriber")) 163 | 164 | expect(Test::SuperUser.schema.key(:role).type).to be(admin) 165 | end 166 | end 167 | 168 | describe "when inheriting a struct from another struct" do 169 | it "also inherits the schema" do 170 | class Test::Parent < Dry::Struct; schema schema.strict; end 171 | 172 | class Test::Child < Test::Parent; end 173 | expect(Test::Child.schema).to be_strict 174 | end 175 | end 176 | 177 | describe "with a blank schema" do 178 | it "works for blank structs" do 179 | class Test::Foo < Dry::Struct; end 180 | expect(Test::Foo.new.to_h).to eql({}) 181 | end 182 | end 183 | 184 | describe "default values" do 185 | subject(:struct) do 186 | Class.new(Dry::Struct) do 187 | attribute :name, Dry::Types["strict.string"].default("Jane") 188 | attribute :age, Dry::Types["strict.integer"] 189 | attribute :admin, Dry::Types["strict.bool"].default(true) 190 | end 191 | end 192 | 193 | it "sets missing values using default-value types" do 194 | attrs = {name: "Jane", age: 21, admin: true} 195 | 196 | expect(struct.new(name: "Jane", age: 21).to_h).to eql(attrs) 197 | expect(struct.new(age: 21).to_h).to eql(attrs) 198 | end 199 | 200 | it "raises error when values have incorrect types" do 201 | expect { struct.new(name: "Jane", age: 21, admin: "true") }.to raise_error( 202 | Dry::Struct::Error, /"true" \(String\) has invalid type for :admin/ 203 | ) 204 | end 205 | end 206 | 207 | describe "#to_hash" do 208 | let(:parent_type) { Test::Parent } 209 | 210 | before do 211 | module Test 212 | class Parent < User 213 | attribute :children, Dry::Types["coercible.array"].of(Test::User) 214 | end 215 | end 216 | end 217 | 218 | it "returns hash with attributes" do 219 | attributes = { 220 | name: "Jane", 221 | age: 29, 222 | address: {city: "NYC", zipcode: "123"}, 223 | children: [ 224 | {name: "Joe", age: 3, address: {city: "NYC", zipcode: "123"}} 225 | ] 226 | } 227 | 228 | expect(parent_type[attributes].to_h).to eql(attributes) 229 | end 230 | 231 | it "doesn't unwrap blindly anything mappable" do 232 | struct = Class.new(Dry::Struct) do 233 | attribute :mappable, Dry::Types["any"] 234 | end 235 | 236 | mappable = Object.new.tap do |obj| 237 | def obj.map 238 | raise 239 | end 240 | end 241 | 242 | value = struct.new(mappable: mappable) 243 | 244 | expect(value.to_h).to eql(mappable: mappable) 245 | end 246 | 247 | context "with omittable keys" do 248 | it "returns hash with attributes but will not try fetching omittable keys if not set" do 249 | type = Class.new(Dry::Struct) do 250 | attribute :name, Dry::Types["string"] 251 | attribute :last_name, Dry::Types["string"].meta(required: false) 252 | end 253 | 254 | attributes = {name: "John"} 255 | expect(type.new(attributes).to_h).to eql(attributes) 256 | end 257 | 258 | it "returns hash with attributes but will fetch omittable keys if set" do 259 | type = Class.new(Dry::Struct) do 260 | attribute :name, Dry::Types["string"] 261 | attribute :last_name, Dry::Types["string"].meta(required: false) 262 | end 263 | 264 | attributes = {name: "John", last_name: "Doe"} 265 | expect(type.new(attributes).to_h).to eql(attributes) 266 | end 267 | 268 | it "returns empty hash if all attributes are ommitable and no value is set" do 269 | type = Class.new(Dry::Struct) do 270 | attribute :name, Dry::Types["string"].meta(required: false) 271 | end 272 | 273 | expect(type.new.to_h).to eql({}) 274 | end 275 | end 276 | 277 | context "with default value" do 278 | it "returns hash with attributes" do 279 | type = Class.new(Dry::Struct) do 280 | attribute :name, Dry::Types["string"].default("John") 281 | end 282 | 283 | attributes = {name: "John"} 284 | expect(type.new.to_h).to eql(attributes) 285 | end 286 | end 287 | 288 | context "on an Dry::Types::Hash.map with nested types", :suppress_deprecations do 289 | before { require "dry/struct/value" } 290 | 291 | let(:nested_type) do 292 | Class.new(Dry::Struct::Value) do 293 | attribute :age, Dry::Types["strict.integer"] 294 | end 295 | end 296 | 297 | let(:type) do 298 | nested_type = self.nested_type 299 | Class.new(Dry::Struct) do 300 | attribute :people, Dry::Types["hash"].map(Dry::Types["strict.string"], nested_type) 301 | end 302 | end 303 | 304 | it "hashifies the values within the hash map" do 305 | attributes = {people: {"John" => {age: 35}}} 306 | expect(type.new(attributes).to_h).to eql(attributes) 307 | end 308 | end 309 | end 310 | 311 | describe "pseudonamed structs" do 312 | let(:struct) do 313 | Class.new(Dry::Struct) do 314 | def self.name 315 | "PersonName" 316 | end 317 | 318 | attribute :name, "strict.string" 319 | end 320 | end 321 | 322 | before do 323 | struct_type = struct 324 | 325 | Test::Person = Class.new(Dry::Struct) do 326 | attribute :name, struct_type 327 | end 328 | end 329 | 330 | it "works fine" do 331 | expect(struct.new(name: "Jane")).to be_an_instance_of(struct) 332 | expect(Test::Person.new(name: {name: "Jane"})).to be_an_instance_of(Test::Person) 333 | end 334 | end 335 | 336 | describe "#[]" do 337 | before do 338 | module Test 339 | class Task < Dry::Struct 340 | attribute :user, "strict.string" 341 | undef user 342 | end 343 | end 344 | end 345 | 346 | it "fetches raw attributes" do 347 | value = Test::Task[user: "Jane"] 348 | expect(value[:user]).to eql("Jane") 349 | end 350 | 351 | it "raises a missing attribute error when no attribute exists" do 352 | value = Test::Task[user: "Jane"] 353 | 354 | expect { value[:name] } 355 | .to raise_error(Dry::Struct::MissingAttributeError) 356 | .with_message("Missing attribute: :name on Test::Task") 357 | end 358 | 359 | describe "optional attributes" do 360 | before do 361 | class Test::Task 362 | attribute :name?, "string" 363 | end 364 | end 365 | 366 | it "returns nil if the attribute is not set" do 367 | value = Test::Task[user: "Jane"] 368 | expect(value[:name]).to be_nil 369 | end 370 | end 371 | 372 | describe "protected methods" do 373 | before do 374 | class Test::Task 375 | attribute :hash, Dry::Types["string"] 376 | attribute :attributes, Dry::Types["array"].of(Dry::Types["string"]) 377 | end 378 | end 379 | 380 | it "allows having attributes with reserved names" do 381 | value = Test::Task[user: "Jane", hash: "abc", attributes: %w[name]] 382 | 383 | expect(value.hash).to be_a(Integer) 384 | expect(value.attributes) 385 | .to eql(user: "Jane", hash: "abc", attributes: %w[name]) 386 | expect(value[:hash]).to eql("abc") 387 | expect(value[:attributes]).to eql(%w[name]) 388 | end 389 | end 390 | end 391 | end 392 | -------------------------------------------------------------------------------- /spec/integration/sum_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Dry::Struct::Sum do 4 | before do 5 | module Test 6 | class Street < Dry::Struct 7 | attribute :name, "strict.string" 8 | end 9 | 10 | class City < Dry::Struct 11 | attribute :name, "strict.string" 12 | end 13 | 14 | class Region < Dry::Struct 15 | attribute :name, "strict.string" 16 | end 17 | 18 | class Highway < Street 19 | end 20 | end 21 | end 22 | 23 | subject(:sum) { Test::Street | Test::City | Test::Region } 24 | 25 | let(:street) { Test::Street.new(name: "Oxford") } 26 | let(:city) { Test::City.new(name: "London") } 27 | let(:england) { Test::Region.new(name: "England") } 28 | let(:highway) { Test::Highway.new(name: "Ratcliffe") } 29 | 30 | it "is constructed from two structs via |" do 31 | expect(Test::Street | Test::City).to be_a(Dry::Struct::Sum) 32 | end 33 | 34 | describe "#call" do 35 | it "first checks for type w/o coercing to hash" do 36 | expect(sum.(city)).to be_a(Test::City) 37 | expect(sum.(england)).to be_a(Test::Region) 38 | end 39 | 40 | it "works with hashes" do 41 | expect(sum.(name: "Baker")).to eql(Test::Street.new(name: "Baker")) 42 | end 43 | 44 | it "works with subclasses" do 45 | expect(sum.(highway)).to be(highway) 46 | end 47 | end 48 | 49 | describe "#optional?" do 50 | specify do 51 | expect(sum).not_to be_optional 52 | end 53 | end 54 | 55 | describe "#===" do 56 | it "recursively checks types without coercion" do 57 | # rubocop:disable Style/NilComparison 58 | expect(sum === nil).to be(false) 59 | expect((Dry::Struct | Dry::Struct) === nil).to be(false) 60 | # rubocop:enable Style/NilComparison 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /spec/integration/value_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe "Dry::Struct::Value", :suppress_deprecations do 4 | before do 5 | module Test 6 | class Address < Dry::Struct::Value 7 | attribute :city, "strict.string" 8 | attribute :zipcode, "coercible.string" 9 | end 10 | 11 | class User < Dry::Struct::Value 12 | attribute :name, "coercible.string" 13 | attribute :age, "coercible.integer" 14 | attribute :address, Test::Address 15 | end 16 | 17 | class SuperUser < User 18 | attributes(root: "strict.bool") 19 | end 20 | end 21 | end 22 | 23 | it_behaves_like Dry::Struct do 24 | subject(:type) { Test::SuperUser } 25 | end 26 | 27 | it "is deeply frozen" do 28 | address = Test::Address.new(city: "NYC", zipcode: 123) 29 | expect(address).to be_frozen 30 | expect(address.city).to be_frozen 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /spec/shared/struct.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.shared_examples_for Dry::Struct do 4 | let(:jane) { {name: :Jane, age: "21", root: true, address: {city: "NYC", zipcode: 123}} } 5 | let(:mike) { {name: :Mike, age: "43", root: false, address: {city: "Atlantis", zipcode: 456}} } 6 | let(:john) { {name: :John, age: "36", root: false, address: {city: "San Francisco", zipcode: 789}} } 7 | 8 | describe "#eql" do 9 | context "when struct values are equal" do 10 | let(:user_1) { type[jane] } 11 | let(:user_2) { type[jane] } 12 | 13 | it "returns true" do 14 | expect(user_1).to eql(user_2) 15 | end 16 | end 17 | 18 | context "when struct values are not equal" do 19 | let(:user_1) { type[jane] } 20 | let(:user_2) { type[mike] } 21 | 22 | it "returns false" do 23 | expect(user_1).to_not eql(user_2) 24 | end 25 | end 26 | end 27 | 28 | describe "#hash" do 29 | context "when struct values are equal" do 30 | let(:user_1) { type[jane] } 31 | let(:user_2) { type[jane] } 32 | 33 | it "the hashes are equal" do 34 | expect(user_1.hash).to eql(user_2.hash) 35 | end 36 | end 37 | 38 | context "when struct values are not equal" do 39 | let(:user_1) { type[jane] } 40 | let(:user_2) { type[mike] } 41 | 42 | it "the hashes are not equal" do 43 | expect(user_1.hash).to_not eql(user_2.hash) 44 | end 45 | end 46 | end 47 | 48 | describe "#new" do 49 | let(:original) { type[jane].freeze } 50 | let(:updated) { original.new(age: "25") } 51 | 52 | it "applies changeset" do 53 | expect(updated.age).to eq 25 54 | end 55 | 56 | it "remains other attributes the same" do 57 | expect(updated.name).to eq original.name 58 | expect(updated.root).to eq original.root 59 | expect(updated.address).to eq original.address 60 | end 61 | 62 | it "does not do deep merge" do 63 | expect { original.new(address: {city: "LA"}) } 64 | .to raise_error(Dry::Struct::Error) 65 | end 66 | 67 | it "has the __new__ alias" do 68 | expect(updated).to eql(original.__new__(age: "25")) 69 | end 70 | 71 | it "uses attribute values, not accessors result" do 72 | decorator = Module.new do 73 | def name 74 | :"#{super} Doe" 75 | end 76 | end 77 | 78 | original.class.prepend(decorator) 79 | expect(updated.name).to eql(:"Jane Doe") 80 | end 81 | 82 | it "raises a Struct::Error on invalid input" do 83 | expect { original.new(age: "aabb") }.to raise_error(Dry::Struct::Error) 84 | end 85 | 86 | context "default values" do 87 | subject(:struct) do 88 | Class.new(Dry::Struct) { 89 | attribute :name, "strict.string" 90 | attribute :age, Dry::Types["strict.integer"].default(18) 91 | }.new(name: "Jack", age: 20) 92 | end 93 | 94 | it "doesn't re-write values with defaults if keys are missing in the changeset" do 95 | expect(struct.new(name: "John").age).to eql(20) 96 | end 97 | end 98 | end 99 | 100 | describe "#inspect" do 101 | let(:user_1) { type[jane] } 102 | 103 | it "lists attributes" do 104 | expect(user_1.inspect).to eql( 105 | %(#<#{type} name="Jane" age=21 address=# root=true>) 106 | ) 107 | end 108 | end 109 | 110 | context "class interface" do 111 | it_behaves_like Dry::Types::Nominal 112 | 113 | describe ".|" do 114 | let(:sum_type) { type | Dry::Types["strict.nil"] } 115 | 116 | it "returns Sum type" do 117 | expect(sum_type).to be_constrained 118 | expect(sum_type[nil]).to be_nil 119 | expect(sum_type[jane]).to eql(type[jane]) 120 | end 121 | end 122 | 123 | describe ".constructor" do 124 | it "uses constructor function to process input" do 125 | expect(type.constructor(&:to_h)[jane.to_a]).to be_eql type[jane] 126 | end 127 | end 128 | 129 | describe ".default?" do 130 | it "is not a default" do 131 | expect(type).not_to be_default 132 | end 133 | end 134 | 135 | describe ".default" do 136 | let(:default_type) { type.default(type[jane].freeze) } 137 | 138 | it "returns Default type" do 139 | expect(default_type).to be_instance_of(Dry::Types::Default) 140 | expect(default_type[]).to eql(type[jane]) 141 | end 142 | end 143 | 144 | describe ".enum" do 145 | let(:enum_type) { type.enum(type[jane], type[mike]) } 146 | 147 | it "returns Enum type" do 148 | expect(enum_type[type[jane]]).to eql(type[jane]) 149 | expect { enum_type[type[john]] }.to raise_error(Dry::Types::ConstraintError) 150 | end 151 | end 152 | 153 | describe ".optional" do 154 | let(:optional_type) { type.optional } 155 | 156 | it "returns Sum type" do 157 | expect(optional_type).to eql(Dry::Types["strict.nil"] | type) 158 | expect(optional_type[nil]).to be_nil 159 | expect(optional_type[jane]).to eql(type[jane]) 160 | end 161 | 162 | it "rejects invalid input" do 163 | expect { optional_type[foo: :bar] }.to raise_error(Dry::Struct::Error) 164 | end 165 | end 166 | 167 | describe ".has_attribute?" do 168 | it "checks if a struct has an attribute" do 169 | expect(type.has_attribute?(:name)).to be true 170 | expect(type.has_attribute?(:last_name)).to be false 171 | end 172 | end 173 | 174 | describe ".attribute?", :suppress_deprecations do 175 | it "checks if a struct has an attribute" do 176 | expect(type.attribute?(:name)).to be true 177 | expect(type.attribute?(:last_name)).to be false 178 | end 179 | end 180 | 181 | describe ".attribute_names" do 182 | it "returns the list of schema keys" do 183 | expect(type.attribute_names).to eql(%i[name age address root]) 184 | end 185 | 186 | it "invalidates the cache on adding a new attribute" do 187 | expect(type.attribute_names).to eql(%i[name age address root]) 188 | type.attribute(:something_else, Dry::Types["any"]) 189 | expect(type.attribute_names).to eql(%i[name age address root something_else]) 190 | end 191 | end 192 | 193 | describe ".meta" do 194 | it "builds a new class with meta" do 195 | struct_with_meta = type.meta(foo: :bar) 196 | 197 | expect(struct_with_meta.meta).to eql(foo: :bar) 198 | end 199 | 200 | it "return an empty hash" do 201 | expect(type.meta).to eql({}) 202 | end 203 | end 204 | 205 | describe ".transform_types" do 206 | it "adds a type transformation" do 207 | type.transform_types { |t| t.meta(tranformed: true) } 208 | type.attribute(:city, Dry::Types["strict.string"]) 209 | expect(type.schema.key(:city).type.meta).to eql(tranformed: true) 210 | end 211 | 212 | it "accepts a proc" do 213 | type.transform_types(-> (key) { key.meta(tranformed: true) }) 214 | type.attribute(:city, Dry::Types["strict.string"]) 215 | expect(type.schema.key(:city).type.meta).to eql(tranformed: true) 216 | end 217 | end 218 | 219 | describe ".transform_keys" do 220 | let(:jane_str) do 221 | { 222 | "name" => :Jane, 223 | "age" => "21", 224 | "root" => true, 225 | "address" => {city: "NYC", zipcode: 123} 226 | } 227 | end 228 | 229 | it "adds a key tranformation" do 230 | type.transform_keys(&:to_sym) 231 | expect(type.(jane_str)).to eql(type.(jane)) 232 | end 233 | 234 | it "accepts a proc" do 235 | type.transform_keys(:to_sym.to_proc) 236 | expect(type.(jane_str)).to eql(type.(jane)) 237 | end 238 | end 239 | 240 | describe ".inherited", :suppress_deprecations do 241 | it "doesn't track Struct/Value descendats" do 242 | expect(Dry::Struct).not_to be_a(Dry::Core::DescendantsTracker) 243 | expect(Dry::Struct::Value).not_to be_a(Dry::Core::DescendantsTracker) 244 | end 245 | end 246 | 247 | describe ".primitive?" do 248 | it "is an alias for is_a?" do 249 | expect(Dry::Struct.primitive?(nil)).to be(false) 250 | expect(Dry::Struct.primitive?({})).to be(false) 251 | expect(Dry::Struct.primitive?(Dry::Struct.new)).to be(true) 252 | end 253 | end 254 | 255 | describe ".optional?" do 256 | specify do 257 | expect(Dry::Struct).not_to be_optional 258 | expect(Dry::Struct.optional).to be_optional 259 | expect(Dry::Struct | Dry::Struct).not_to be_optional 260 | end 261 | end 262 | 263 | describe ".call" do 264 | subject(:struct) do 265 | Class.new(Dry::Struct) do 266 | def self.new(attributes) 267 | if attributes.key?(:city) 268 | super 269 | else 270 | super({**attributes, city: "London"}) 271 | end 272 | end 273 | 274 | attribute :street, "string" 275 | attribute :city, "string" 276 | end 277 | end 278 | 279 | it "call .new so it is possible to override it" do 280 | expect(struct.(street: "Baker")).to eql(struct.new(city: "London", street: "Baker")) 281 | end 282 | end 283 | 284 | describe ".===" do 285 | it "acts in the same way as Class#===" do 286 | expect(Dry::Struct === nil).to be(false) # rubocop:disable Style/NilComparison 287 | expect(Dry::Struct === Dry::Struct.new).to be(true) 288 | expect(Dry::Struct === Class.new(Dry::Struct).new).to be(true) 289 | end 290 | end 291 | 292 | describe ".valid?" do 293 | let(:struct_a) do 294 | Class.new(Dry::Struct) { attribute :name, "string" } 295 | end 296 | 297 | let(:struct_b) do 298 | Class.new(Dry::Struct) { attribute :name, "string" } 299 | end 300 | 301 | it "tries to coerce input" do 302 | expect(struct_b.valid?(struct_a.(name: "John"))).to be(true) 303 | end 304 | end 305 | 306 | describe ".try" do 307 | let(:struct) { Dry::Struct(name: "string") } 308 | 309 | it "returns a result object" do 310 | expect(struct.try(name: "John")).to be_a(Dry::Types::Result::Success) 311 | expect(struct.try(name: 42)).to be_a(Dry::Types::Result::Failure) 312 | end 313 | 314 | it "keeps an error instance" do 315 | expect(struct.try(name: 42).error).to be_a(Dry::Struct::Error) 316 | end 317 | end 318 | end 319 | end 320 | -------------------------------------------------------------------------------- /spec/shared/user_type.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.shared_context "user type" do 4 | let(:user_type) { Test::User } 5 | let(:root_type) { Test::SuperUser } 6 | 7 | before do 8 | module Test 9 | class BaseAddress < Dry::Struct 10 | attribute :street, "string" 11 | end 12 | 13 | class Address < Dry::Struct 14 | attribute :city, "string" 15 | attribute :zipcode, "coercible.string" 16 | end 17 | 18 | # This abstract user guarantees User preserves schema definition 19 | class AbstractUser < Dry::Struct 20 | attribute :name, "coercible.string" 21 | attribute :age, "coercible.integer" 22 | attribute :address, Test::Address 23 | end 24 | 25 | class User < AbstractUser 26 | end 27 | 28 | class SuperUser < User 29 | attributes(root: "bool") 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "support/coverage" 4 | require_relative "support/warnings" 5 | 6 | require "pathname" 7 | 8 | Warning.ignore(/regexp_parser/) 9 | Warning.ignore(/parser/) 10 | Warning.ignore(/slice\.rb/) 11 | 12 | module DryStructSpec 13 | ROOT = Pathname.new(__dir__).parent.expand_path.freeze 14 | end 15 | 16 | $LOAD_PATH.unshift DryStructSpec::ROOT.join("lib").to_s 17 | $VERBOSE = true 18 | 19 | require "dry-struct" 20 | 21 | %w[debug pry-byebug pry byebug].each do |gem| 22 | require gem 23 | rescue LoadError 24 | nil 25 | else 26 | break 27 | end 28 | 29 | Dir[Pathname(__dir__).join("shared/*.rb")].each(&method(:require)) 30 | 31 | Warning.ignore(/rspec-expectations/) 32 | Warning.ignore(/super_diff/) 33 | Warning.process { raise _1 } 34 | 35 | require "dry/types/spec/types" 36 | 37 | RSpec.configure do |config| 38 | config.before { stub_const("Test", Module.new) } 39 | 40 | config.order = :random 41 | config.filter_run_when_matching :focus 42 | 43 | config.around :each, :suppress_deprecations do |ex| 44 | logger = Dry::Core::Deprecations.logger 45 | Dry::Core::Deprecations.set_logger!(DryStructSpec::ROOT.join("log/deprecations.log")) 46 | ex.run 47 | Dry::Core::Deprecations.set_logger!(logger) 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /spec/support/coverage.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # this file is managed by dry-rb/devtools 4 | 5 | if ENV["COVERAGE"] == "true" 6 | require "simplecov" 7 | require "simplecov-cobertura" 8 | 9 | SimpleCov.formatter = SimpleCov::Formatter::CoberturaFormatter 10 | 11 | SimpleCov.start do 12 | add_filter "/spec/" 13 | enable_coverage :branch 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/support/rspec_options.rb: -------------------------------------------------------------------------------- 1 | RSpec.configure do |config| 2 | # When no filter given, search and run focused tests 3 | config.filter_run_when_matching :focus 4 | 5 | # Disables rspec monkey patches (no reason for their existence tbh) 6 | config.disable_monkey_patching! 7 | 8 | # Run ruby in verbose mode 9 | config.warnings = true 10 | 11 | # Collect all failing expectations automatically, 12 | # without calling aggregate_failures everywhere 13 | config.define_derived_metadata do |meta| 14 | meta[:aggregate_failures] = true 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /spec/support/warnings.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # this file is managed by dry-rb/devtools project 4 | 5 | require "warning" 6 | 7 | Warning.ignore(%r{rspec/core}) 8 | Warning.ignore(%r{rspec/mocks}) 9 | Warning.ignore(/codacy/) 10 | Warning[:experimental] = false if Warning.respond_to?(:[]) 11 | -------------------------------------------------------------------------------- /spec/unit/struct_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "dry/struct" 4 | 5 | RSpec.describe Dry::Struct do 6 | describe ".to_ast" do 7 | let(:address) do 8 | Dry.Struct(street: "string", city?: "optional.string") 9 | end 10 | 11 | example "simple AST" do 12 | expect(address.to_ast).to eql( 13 | [ 14 | :struct, 15 | [address, address.schema.to_ast] 16 | ] 17 | ) 18 | end 19 | 20 | context "with meta" do 21 | let(:address) { super().meta(foo: :bar) } 22 | 23 | specify "on" do 24 | expect(address.to_ast).to eql( 25 | [ 26 | :struct, 27 | [address, address.schema.to_ast(meta: true)] 28 | ] 29 | ) 30 | end 31 | 32 | specify "off" do 33 | expect(address.to_ast(meta: false)).to eql( 34 | [ 35 | :struct, 36 | [address, address.schema.to_ast(meta: false)] 37 | ] 38 | ) 39 | end 40 | end 41 | end 42 | end 43 | --------------------------------------------------------------------------------