├── .gitignore ├── .hound.yml ├── .rspec ├── .rubocop.yml ├── .travis.yml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── _config.yml ├── benchmarks └── surrealist_vs_ams.rb ├── bin └── console ├── gemfiles └── activerecord42.gemfile ├── lib ├── surrealist.rb └── surrealist │ ├── any.rb │ ├── bool.rb │ ├── builder.rb │ ├── carrier.rb │ ├── class_methods.rb │ ├── copier.rb │ ├── exception_raiser.rb │ ├── hash_utils.rb │ ├── helper.rb │ ├── instance_methods.rb │ ├── schema_definer.rb │ ├── serializer.rb │ ├── string_utils.rb │ ├── type_helper.rb │ ├── value_assigner.rb │ ├── vars_helper.rb │ ├── version.rb │ └── wrapper.rb ├── spec ├── build_schema_spec.rb ├── builder_spec.rb ├── carrier_spec.rb ├── config_spec.rb ├── copier_spec.rb ├── dry_types │ ├── invalid_dry_types_spec.rb │ └── valid_dry_types_spec.rb ├── exception_raiser_spec.rb ├── hash_utils_spec.rb ├── helper_spec.rb ├── include_namespaces_spec.rb ├── include_root_spec.rb ├── multiple_serializers_spec.rb ├── nested_record_spec.rb ├── orms │ ├── active_record │ │ ├── active_record_spec.rb │ │ └── models.rb │ ├── rom │ │ └── rom_5_spec.rb │ └── sequel │ │ ├── models.rb │ │ └── sequel_spec.rb ├── root_spec.rb ├── schema_definer_spec.rb ├── schema_spec.rb ├── serializer_spec.rb ├── spec_helper.rb ├── string_utils_spec.rb ├── support │ ├── parameters.rb │ ├── shared_contexts │ │ └── parameters_contexts.rb │ └── shared_examples │ │ └── hash_examples.rb ├── surrealist_spec.rb ├── type_helper_spec.rb ├── ultimate_spec.rb └── wrapper_spec.rb ├── surrealist-icon.png └── surrealist.gemspec /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | .ruby-version 11 | TODO.md 12 | *.gem 13 | -------------------------------------------------------------------------------- /.hound.yml: -------------------------------------------------------------------------------- 1 | fail_on_violations: false 2 | ruby: 3 | config_file: .rubocop.yml 4 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --require spec_helper 3 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | TargetRubyVersion: 2.5 3 | NewCops: enable 4 | SuggestExtensions: false 5 | Exclude: 6 | - './gemfiles/*gemfile' 7 | - './tmp/*' 8 | 9 | Style/Documentation: 10 | Exclude: 11 | - benchmarks/*rb 12 | - spec/**/*rb 13 | 14 | # Layout 15 | 16 | Layout/ParameterAlignment: 17 | Enabled: true 18 | 19 | Layout/EmptyLineBetweenDefs: 20 | Exclude: 21 | - spec/**/*rb 22 | 23 | Layout/FirstArrayElementIndentation: 24 | EnforcedStyle: consistent 25 | 26 | Layout/FirstHashElementIndentation: 27 | EnforcedStyle: consistent 28 | 29 | Layout/MultilineMethodCallIndentation: 30 | Enabled: false 31 | 32 | Layout/MultilineOperationIndentation: 33 | Enabled: false 34 | 35 | Layout/SpaceInLambdaLiteral: 36 | EnforcedStyle: require_space 37 | 38 | Layout/ClosingParenthesisIndentation: 39 | Enabled: true 40 | 41 | Layout/LineLength: 42 | Exclude: [ benchmarks/*rb ] 43 | Max: 110 44 | 45 | # Lint 46 | Lint/AmbiguousBlockAssociation: 47 | Enabled: false 48 | 49 | Lint/AmbiguousOperator: 50 | Enabled: false 51 | 52 | Lint/EmptyWhen: 53 | Enabled: false 54 | 55 | Lint/NonLocalExitFromIterator: 56 | Enabled: false 57 | 58 | Lint/RescueType: 59 | Enabled: true 60 | 61 | Lint/MissingSuper: 62 | Enabled: false 63 | 64 | Lint/ConstantDefinitionInBlock: 65 | Exclude: 66 | - spec/**/*rb 67 | 68 | Lint/EmptyClass: 69 | Enabled: false 70 | 71 | Lint/SymbolConversion: 72 | Enabled: false 73 | 74 | # Metrics 75 | 76 | Metrics/BlockLength: 77 | Exclude: 78 | - spec/**/*rb 79 | - benchmarks 80 | 81 | Metrics/CyclomaticComplexity: 82 | Enabled: false 83 | 84 | Metrics/MethodLength: 85 | Max: 15 86 | 87 | Metrics/PerceivedComplexity: 88 | Enabled: false 89 | 90 | Metrics/ParameterLists: 91 | Enabled: false 92 | 93 | # Naming 94 | 95 | Naming/AccessorMethodName: 96 | Enabled: false 97 | 98 | Naming/PredicateName: 99 | ForbiddenPrefixes: 100 | - is_ 101 | 102 | # Style 103 | 104 | # ¯\_(ツ)_/¯ 105 | Style/AsciiComments: 106 | Enabled: false 107 | 108 | Style/AccessModifierDeclarations: 109 | Enabled: false 110 | 111 | Style/MixinUsage: 112 | Exclude: 113 | - spec/**/*rb 114 | 115 | Style/DateTime: 116 | Exclude: 117 | - spec/**/*rb 118 | 119 | Style/SingleLineMethods: 120 | Exclude: 121 | - spec/**/*rb 122 | 123 | Style/ClassAndModuleChildren: 124 | Enabled: false 125 | 126 | Style/Dir: 127 | Enabled: true 128 | 129 | Style/StringLiterals: 130 | EnforcedStyle: single_quotes 131 | ConsistentQuotesInMultiline: true 132 | 133 | Style/FormatStringToken: 134 | Enabled: true 135 | 136 | Style/IfUnlessModifier: 137 | Enabled: true 138 | 139 | Style/GuardClause: 140 | Enabled: false 141 | 142 | Style/MultipleComparison: 143 | Enabled: true 144 | 145 | Style/PerlBackrefs: 146 | Enabled: true 147 | 148 | Style/RedundantConditional: 149 | Enabled: true 150 | 151 | Style/RegexpLiteral: 152 | EnforcedStyle: mixed 153 | Enabled: true 154 | 155 | Style/SignalException: 156 | EnforcedStyle: only_raise 157 | 158 | Style/TrailingCommaInArguments: 159 | EnforcedStyleForMultiline: comma 160 | 161 | Style/TrailingCommaInArrayLiteral: 162 | EnforcedStyleForMultiline: comma 163 | 164 | Style/TrailingCommaInHashLiteral: 165 | EnforcedStyleForMultiline: comma 166 | 167 | Style/MissingRespondToMissing: 168 | Enabled: false 169 | 170 | Style/EvalWithLocation: 171 | Exclude: 172 | - spec/**/*.rb 173 | 174 | Style/ClassVars: 175 | Enabled: false 176 | 177 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | sudo: false 3 | before_install: gem install bundler 4 | script: bundle exec rake 5 | matrix: 6 | fast_finish: true 7 | include: 8 | - rvm: 3.0 9 | gemfile: Gemfile 10 | - rvm: 2.7 11 | gemfile: Gemfile 12 | - rvm: 2.6 13 | gemfile: Gemfile 14 | - rvm: 2.5 15 | gemfile: gemfiles/activerecord42.gemfile 16 | 17 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 1.3.4 2 | 3 | ## Fixed 4 | * Performance and memory improvements ([@krzysiek1507][])[#140](https://github.com/nesaulov/surrealist/pull/140) 5 | * Fix serializing when direct instance method call returns nil ([@past-one][]) [#141](https://github.com/nesaulov/surrealist/pull/141) 6 | 7 | # 1.3.3 8 | 9 | ## Fixed 10 | * Struct serialization ([@wildkain][]) [#135](https://github.com/nesaulov/surrealist/pull/135) 11 | 12 | # 1.3.2 13 | 14 | ## Fixed 15 | * Preserve exception backtrace in case of Surrealist::UndefinedMethodError ([@akxcv][]) [#122](https://github.com/nesaulov/surrealist/pull/122) 16 | * Fix nil values for Boolean type ([@nesaulov][]) [#127](https://github.com/nesaulov/surrealist/pull/127) 17 | 18 | # 1.3.1 19 | 20 | ## Fixed 21 | * Invoke parent serializer method instead of object method with the same names ([@kolasss][]) [#118](https://github.com/nesaulov/surrealist/pull/118) 22 | 23 | # 1.3.0 24 | 25 | ## Added 26 | 27 | * Hash serialization ([@stefkin][]) [#106](https://github.com/nesaulov/surrealist/pull/106) 28 | 29 | ## Fixed 30 | 31 | * Conflict when root and object property share the same name ([@akxcv][]) [#99](https://github.com/nesaulov/surrealist/pull/99) 32 | * Precedence was given to object method instead of serializer one ([@gjhenrique][] & [@nesaulov][]) [#110](https://github.com/nesaulov/surrealist/pull/110) & [#111](https://github.com/nesaulov/surrealist/pull/111) 33 | 34 | # 1.2.0 35 | 36 | ## Added 37 | * `.defined_schema` to return the schema that has been defined with `json_schema` ([@glaucocustodio][]) [#98](https://github.com/nesaulov/surrealist/pull/98) 38 | 39 | ## Fixed 40 | * Incorrect method delegation ([@nesaulov][]) [#103](https://github.com/nesaulov/surrealist/pull/103) 41 | 42 | # 1.1.2 43 | 44 | ## Fixed 45 | * Bug with inheritance and mixins ([@nesaulov][]) [#93](https://github.com/nesaulov/surrealist/pull/93) 46 | 47 | # 1.1.1 48 | 49 | ## Fixed 50 | * Bug with several serializations in a row ([@past-one][]) [#90](https://github.com/nesaulov/surrealist/pull/90) 51 | 52 | # 1.1.0 53 | 54 | ## Added 55 | * Configuration of default serialization params ([@nesaulov][]) [#77](https://github.com/nesaulov/surrealist/pull/77) 56 | * DSL for custom serializers context ([@nesaulov][]) [#80](https://github.com/nesaulov/surrealist/pull/80) 57 | 58 | ## Fixed 59 | * Fix failing serialization with sequel & custom serializers ([@azhi][]) [#84](https://github.com/nesaulov/surrealist/pull/84) 60 | 61 | ## Miscellaneous 62 | * Pin Oj version to 3.4.0 [#79](https://github.com/nesaulov/surrealist/pull/79) 63 | 64 | # 1.0.0 65 | 66 | ## Added 67 | * `#build_schema` for collections from `Surrealist::Serializer` ([@nesaulov][]) [#74](https://github.com/nesaulov/surrealist/pull/74) 68 | * Oj dependency 69 | * Multiple serializers API ([@nulldef][]) [#66](https://github.com/nesaulov/surrealist/pull/66) 70 | 71 | ## Miscellaneous 72 | * Benchmarks for Surrealist vs AMS 73 | * A lot of memory & performance optimizations ([@nesaulov][]) [#64](https://github.com/nesaulov/surrealist/pull/64) 74 | 75 | # 0.4.0 76 | 77 | ## Added 78 | * Introduce an abstract serializer class ([@nesaulov][]) [#61](https://github.com/nesaulov/surrealist/pull/61) 79 | * Full integration for Sequel ([@nesaulov][]) [#47](https://github.com/nesaulov/surrealist/pull/47) 80 | * Integration for ROM 4.x ([@nesaulov][]) [#56](https://github.com/nesaulov/surrealist/pull/56) 81 | * Ruby 2.5 support ([@nesaulov][]) [#57](https://github.com/nesaulov/surrealist/pull/57) 82 | 83 | ## Miscellaneous 84 | * Memory & performance optimizations ([@nesaulov][]) [#51](https://github.com/nesaulov/surrealist/pull/51) 85 | * Refactorings ([@nulldef][]) [#55](https://github.com/nesaulov/surrealist/pull/55) 86 | 87 | # 0.3.0 88 | 89 | ## Added 90 | * Full integration for ActiveRecord ([@nesaulov][], [@AlessandroMinali][]) [#37](https://github.com/nesaulov/surrealist/pull/37) 91 | * Full integration for ROM <= 3 ([@nesaulov][], [@AlessandroMinali][]) [#37](https://github.com/nesaulov/surrealist/pull/37) 92 | * `root` optional argument ([@chrisatanasian][]) [#32](https://github.com/nesaulov/surrealist/pull/32) 93 | * Nested records surrealization ([@AlessandroMinali][]) [#34](https://github.com/nesaulov/surrealist/pull/34) 94 | 95 | ## Fixed 96 | * Dependencies update ([@nesaulov][]) [#48](https://github.com/nesaulov/surrealist/pull/48) 97 | 98 | # 0.2.0 99 | ## Added 100 | * `delegate_surrealization_to` class method 101 | * `include_namespaces` optional argument 102 | * `namespaces_nesting_level` optional argument 103 | * `Surrealist.surrealize_collection` method for collection serialization 104 | 105 | # 0.1.4 106 | ## Added 107 | * Optional `include_root` argument to wrap schema in a root key. [#15](https://github.com/nesaulov/surrealist/pull/15) 108 | ## Fixed 109 | * Performance of schema cloning. 110 | ## Changed 111 | * `Boolean` module renamed to `Bool`. 112 | 113 | # 0.1.2 114 | ## Added 115 | * `Any` module for skipping type checks. 116 | * Optional `camelize` argument to convert keys to camelBacks. 117 | 118 | # 0.1.0 119 | ## Fixed 120 | * Fix schema mutability issue. 121 | ## Changed 122 | * Change `schema` class method to `json_schema` due to compatibility issues with other gems. 123 | 124 | # 0.0.6 125 | ## Added 126 | * `build_schema` instance method that builds hash from the schema without serializing it to json. 127 | ## Changed 128 | * Allow nil values by default. 129 | * Allow nested objects. 130 | 131 | [@glaucocustodio]: https://github.com/glaucocustodio 132 | [@nesaulov]: https://github.com/nesaulov 133 | [@AlessandroMinali]: https://github.com/AlessandroMinali 134 | [@nulldef]: https://github.com/nulldef 135 | [@azhi]: https://github.com/azhi 136 | [@chrisatanasian]: https://github.com/chrisatanasian 137 | [@past-one]: https://github.com/past-one 138 | [@stefkin]: https://github.com/stefkin 139 | [@gjhenrique]: https://github.com/gjhenrique 140 | [@kolasss]: https://github.com/kolasss 141 | [@wildkain]: https://github.com/wildkain 142 | [@krzysiek1507]: https://github.com/krzysiek1507 143 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Issue Guidelines 2 | 3 | ## Reporting bugs 4 | If you found a bug, report an issue and describe what's the expected behavior versus what actually happens. 5 | If the bug causes a crash, attach a full backtrace. If possible, a reproduction script showing the problem 6 | is highly appreciated. 7 | 8 | ## Reporting feature requests 9 | Please provide a concise description of the feature and the reasons behind the request. 10 | 11 | # Pull Request Guidelines 12 | A Pull Request will only be accepted if it addresses a specific issue that was reported previously, or fixes typos, mistakes in documentation etc. 13 | 14 | Other requirements: 15 | 16 | 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. 17 | 2) Follow the style conventions of the surrounding code. In most cases, this is standard ruby style. 18 | 3) Add API documentation if it's a new feature 19 | 4) Update API documentation if it changes an existing feature 20 | 21 | # Asking for help 22 | If these guidelines aren't helpful, and you're stuck, don't hesitate to open an issue and ask anything. 23 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | gemspec 5 | 6 | group :development, :test do 7 | gem 'active_model_serializers', '~> 0.10' 8 | gem 'activerecord' 9 | gem 'benchmark-ips' 10 | gem 'blueprinter' 11 | gem 'coveralls', require: false 12 | gem 'dry-struct' 13 | gem 'dry-types' 14 | gem 'rom', '~> 5.0' 15 | gem 'rom-repository' 16 | gem 'rom-sql' 17 | gem 'sequel' 18 | gem 'sqlite3', '~> 1.4' 19 | gem 'yard', require: false unless ENV['TRAVIS'] 20 | end 21 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Nikita Esaulov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all 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, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/gem_tasks' 4 | require 'rspec/core/rake_task' 5 | require 'rubocop/rake_task' 6 | 7 | RSpec::Core::RakeTask.new(:spec) 8 | RuboCop::RakeTask.new 9 | 10 | task default: %i[rubocop spec] 11 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-cayman -------------------------------------------------------------------------------- /benchmarks/surrealist_vs_ams.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # rubocop:disable Metrics/MethodLength 4 | require_relative '../lib/surrealist' 5 | require 'benchmark/ips' 6 | require 'active_record' 7 | require 'active_model' 8 | require 'active_model_serializers' 9 | require 'blueprinter' 10 | 11 | ActiveRecord::Base.establish_connection( 12 | adapter: 'sqlite3', 13 | database: ':memory:', 14 | ) 15 | 16 | ActiveRecord::Migration.verbose = false 17 | ActiveRecord::Schema.define do 18 | create_table :users do |table| 19 | table.column :name, :string 20 | table.column :email, :string 21 | end 22 | 23 | create_table :authors do |table| 24 | table.column :name, :string 25 | table.column :last_name, :string 26 | table.column :age, :int 27 | end 28 | 29 | create_table :books do |table| 30 | table.column :title, :string 31 | table.column :year, :string 32 | table.belongs_to :author, foreign_key: true 33 | end 34 | end 35 | 36 | ActiveModelSerializers.config.adapter = :json 37 | 38 | def random_name 39 | ('a'..'z').to_a.shuffle.join.first(10).capitalize 40 | end 41 | 42 | class User < ActiveRecord::Base 43 | include Surrealist 44 | 45 | json_schema { { name: String, email: String } } 46 | end 47 | 48 | class UserSerializer < ActiveModel::Serializer 49 | attributes :name, :email 50 | end 51 | 52 | class UserSurrealistSerializer < Surrealist::Serializer 53 | json_schema { { name: String, email: String } } 54 | end 55 | 56 | class UserAMSSerializer < ActiveModel::Serializer 57 | attributes :name, :email 58 | end 59 | 60 | class UserBlueprint < Blueprinter::Base 61 | fields :name, :email 62 | end 63 | 64 | ### Associations ### 65 | 66 | class AuthorSurrealistSerializer < Surrealist::Serializer 67 | json_schema do 68 | { name: String, last_name: String, full_name: String, age: Integer, books: Array } 69 | end 70 | 71 | def books 72 | object.books.to_a 73 | end 74 | 75 | def full_name 76 | "#{object.name} #{object.last_name}" 77 | end 78 | end 79 | 80 | class BookSurrealistSerializer < Surrealist::Serializer 81 | json_schema { { title: String, year: String } } 82 | end 83 | 84 | class BookAMSSerializer < ActiveModel::Serializer 85 | attributes :title, :year 86 | end 87 | 88 | class BookBlueprint < Blueprinter::Base 89 | fields :title, :year 90 | end 91 | 92 | class AuthorAMSSerializer < ActiveModel::Serializer 93 | attributes :name, :last_name, :full_name, :age 94 | has_many :books, serializer: BookAMSSerializer 95 | end 96 | 97 | class AuthorBlueprint < Blueprinter::Base 98 | fields :name, :last_name, :age 99 | field :full_name do |author| 100 | "#{author.name} #{author.last_name}" 101 | end 102 | association :books, blueprint: BookBlueprint 103 | end 104 | 105 | class Author < ActiveRecord::Base 106 | include Surrealist 107 | surrealize_with AuthorSurrealistSerializer 108 | 109 | has_many :books 110 | 111 | def full_name 112 | "#{name} #{last_name}" 113 | end 114 | end 115 | 116 | class Book < ActiveRecord::Base 117 | include Surrealist 118 | surrealize_with BookSurrealistSerializer 119 | 120 | belongs_to :author, required: true 121 | end 122 | 123 | N = 3000 124 | N.times { User.create!(name: random_name, email: "#{random_name}@test.com") } 125 | (N / 2).times { Author.create!(name: random_name, last_name: random_name, age: rand(80)) } 126 | N.times { Book.create!(title: random_name, year: "19#{rand(10..99)}", author_id: rand(1..N / 2)) } 127 | 128 | def sort(obj) 129 | case obj 130 | when Array then obj.map { |el| sort(el) } 131 | when Hash then obj.transform_values { |v| sort(v) } 132 | else obj 133 | end 134 | end 135 | 136 | def check_correctness(serializers) 137 | results = serializers.map(&:call).map { |r| sort(JSON.parse(r)) } 138 | raise 'Results are not the same' if results.uniq.size > 1 139 | end 140 | 141 | def benchmark(names, serializers) 142 | check_correctness(serializers) 143 | 144 | Benchmark.ips do |x| 145 | x.config(time: 5, warmup: 2) 146 | 147 | names.zip(serializers).each { |name, proc| x.report(name, &proc) } 148 | 149 | x.compare! 150 | end 151 | end 152 | 153 | def benchmark_instance(ams_arg: '', oj_arg: '') 154 | user = User.find(rand(1..N)) 155 | 156 | names = ["AMS#{[ams_arg, oj_arg].join(' ')}: instance", 157 | 'Surrealist: instance through .surrealize', 158 | 'Surrealist: instance through Surrealist::Serializer', 159 | "ActiveModel::Serializers::JSON#{oj_arg} instance", 160 | "Blueprinter#{oj_arg}"] 161 | 162 | serializers = [-> { UserAMSSerializer.new(user).to_json }, 163 | -> { user.surrealize }, 164 | -> { UserSurrealistSerializer.new(user).surrealize }, 165 | -> { user.to_json(only: %i[name email]) }, 166 | -> { UserBlueprint.render(user) }] 167 | 168 | benchmark(names, serializers) 169 | end 170 | 171 | def benchmark_collection(ams_arg: '', oj_arg: '') 172 | users = User.all 173 | 174 | names = ["AMS#{[ams_arg, oj_arg].join(' ')}: collection", 175 | 'Surrealist: collection through Surrealist.surrealize_collection()', 176 | 'Surrealist: collection through Surrealist::Serializer', 177 | "ActiveModel::Serializers::JSON#{oj_arg} collection", 178 | "Blueprinter collection#{oj_arg}"] 179 | 180 | serializers = [lambda do 181 | ActiveModel::Serializer::CollectionSerializer.new( 182 | users, root: nil, serializer: UserAMSSerializer 183 | ).to_json 184 | end, 185 | -> { Surrealist.surrealize_collection(users) }, 186 | -> { UserSurrealistSerializer.new(users).surrealize }, 187 | -> { users.to_json(only: %i[name email]) }, 188 | -> { UserBlueprint.render(users) }] 189 | 190 | benchmark(names, serializers) 191 | end 192 | 193 | def benchmark_associations_instance 194 | instance = Author.find(rand((1..(N / 2)))) 195 | 196 | names = ['AMS (associations): instance', 197 | 'Surrealist (associations): instance through .surrealize', 198 | 'Surrealist (associations): instance through Surrealist::Serializer', 199 | 'ActiveModel::Serializers::JSON (associations)', 200 | 'Blueprinter (associations)'] 201 | 202 | serializers = [-> { AuthorAMSSerializer.new(instance).to_json }, 203 | -> { instance.surrealize }, 204 | -> { AuthorSurrealistSerializer.new(instance).surrealize }, 205 | lambda do 206 | instance.to_json(only: %i[name last_name age], methods: %i[full_name], 207 | include: { books: { only: %i[title year] } }) 208 | end, 209 | -> { AuthorBlueprint.render(instance) }] 210 | 211 | benchmark(names, serializers) 212 | end 213 | 214 | def benchmark_associations_collection 215 | collection = Author.all 216 | 217 | names = ['AMS (associations): collection', 218 | 'Surrealist (associations): collection through Surrealist.surrealize_collection()', 219 | 'Surrealist (associations): collection through Surrealist::Serializer', 220 | 'ActiveModel::Serializers::JSON (associations): collection', 221 | 'Blueprinter (associations): collection'] 222 | 223 | serializers = [lambda do 224 | ActiveModel::Serializer::CollectionSerializer.new( 225 | collection, root: nil, serializer: AuthorAMSSerializer 226 | ).to_json 227 | end, 228 | -> { Surrealist.surrealize_collection(collection) }, 229 | -> { AuthorSurrealistSerializer.new(collection).surrealize }, 230 | lambda do 231 | collection.to_json(only: %i[name last_name age], methods: %i[full_name], 232 | include: { books: { only: %i[title year] } }) 233 | end, 234 | -> { AuthorBlueprint.render(collection) }] 235 | 236 | benchmark(names, serializers) 237 | end 238 | 239 | # Default configuration 240 | benchmark_instance 241 | benchmark_collection 242 | 243 | # With AMS logger turned off 244 | puts "\n------- Turning off AMS logger -------\n" 245 | ActiveModelSerializers.logger.level = Logger::Severity::UNKNOWN 246 | 247 | benchmark_instance(ams_arg: '(without logging)') 248 | benchmark_collection(ams_arg: '(without logging)') 249 | 250 | # Associations 251 | benchmark_associations_instance 252 | benchmark_associations_collection 253 | 254 | puts "\n------- Enabling Oj.optimize_rails() & Blueprinter config.generator = Oj -------\n" 255 | Oj.optimize_rails 256 | Blueprinter.configure do |config| 257 | config.generator = Oj 258 | end 259 | 260 | benchmark_instance(ams_arg: '(without logging)', oj_arg: '(with Oj)') 261 | benchmark_collection(ams_arg: '(without logging)', oj_arg: '(with Oj)') 262 | 263 | # Associations 264 | benchmark_associations_instance 265 | benchmark_associations_collection 266 | 267 | # ruby 2.5.0p0 (2017-12-25 revision 61468) [x86_64-darwin16] 268 | # -- Instance -- 269 | # Comparison: 270 | # Surrealist: instance through .surrealize: 39599.6 i/s 271 | # Surrealist: instance through Surrealist::Serializer: 36452.5 i/s - same-ish: difference falls within error 272 | # AMS: instance: 1938.9 i/s - 20.42x slower 273 | # 274 | # -- Collection -- 275 | # Comparison: 276 | # Surrealist: collection through Surrealist.surrealize_collection(): 15.0 i/s 277 | # Surrealist: collection through Surrealist::Serializer: 12.8 i/s - 1.17x slower 278 | # AMS: collection: 6.1 i/s - 2.44x slower 279 | # 280 | # --- Without AMS logging (which is turned on by default) --- 281 | # 282 | # -- Instance -- 283 | # Comparison: 284 | # Surrealist: instance through .surrealize: 40401.4 i/s 285 | # Surrealist: instance through Surrealist::Serializer: 29488.3 i/s - 1.37x slower 286 | # AMS(without logging): instance: 4571.7 i/s - 8.84x slower 287 | # 288 | # -- Collection -- 289 | # Comparison: 290 | # Surrealist: collection through Surrealist.surrealize_collection(): 15.2 i/s 291 | # Surrealist: collection through Surrealist::Serializer: 12.0 i/s - 1.27x slower 292 | # AMS(without logging): collection: 6.1 i/s - 2.50x slower 293 | # 294 | # --- Associations --- 295 | # 296 | # -- Instance -- 297 | # Comparison: 298 | # Surrealist (associations): instance through Surrealist::Serializer: 4016.3 i/s 299 | # Surrealist (associations): instance through .surrealize: 4004.6 i/s - same-ish: difference falls within error 300 | # AMS (associations): instance: 1303.0 i/s - 3.08x slower 301 | # 302 | # -- Collection -- 303 | # Comparison: 304 | # Surrealist (associations): collection through Surrealist.surrealize_collection(): 2.4 i/s 305 | # Surrealist (associations): collection through Surrealist::Serializer: 2.4 i/s - 1.03x slower 306 | # AMS (associations): collection: 1.5 i/s - 1.60x slower 307 | # rubocop:enable Metrics/MethodLength 308 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require_relative '../lib/surrealist' 5 | require 'bundler/setup' 6 | require 'pry' 7 | 8 | Pry.start 9 | -------------------------------------------------------------------------------- /gemfiles/activerecord42.gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | group :development, :test do 6 | gem 'activerecord', '~> 4.2' 7 | gem 'coveralls', require: false 8 | gem 'dry-struct' 9 | gem 'dry-types' 10 | gem 'rom', '~> 5.0' 11 | gem 'rom-repository' 12 | gem 'rom-sql' 13 | gem 'sequel' 14 | gem 'sqlite3', '~> 1.3.6' 15 | gem 'yard', require: false unless ENV['TRAVIS'] 16 | end 17 | 18 | gemspec path: '..' 19 | -------------------------------------------------------------------------------- /lib/surrealist.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'oj' 4 | require 'set' 5 | require_relative 'surrealist/any' 6 | require_relative 'surrealist/bool' 7 | require_relative 'surrealist/builder' 8 | require_relative 'surrealist/carrier' 9 | require_relative 'surrealist/class_methods' 10 | require_relative 'surrealist/copier' 11 | require_relative 'surrealist/exception_raiser' 12 | require_relative 'surrealist/hash_utils' 13 | require_relative 'surrealist/helper' 14 | require_relative 'surrealist/instance_methods' 15 | require_relative 'surrealist/schema_definer' 16 | require_relative 'surrealist/serializer' 17 | require_relative 'surrealist/string_utils' 18 | require_relative 'surrealist/type_helper' 19 | require_relative 'surrealist/value_assigner' 20 | require_relative 'surrealist/vars_helper' 21 | require_relative 'surrealist/wrapper' 22 | 23 | # Main module that provides the +json_schema+ class method and +surrealize+ instance method. 24 | module Surrealist 25 | # Default namespaces nesting level 26 | DEFAULT_NESTING_LEVEL = 666 27 | 28 | class << self 29 | # @param [Class] base class to include/extend +Surrealist+. 30 | def included(base) 31 | base.extend(Surrealist::ClassMethods) 32 | base.include(Surrealist::InstanceMethods) 33 | end 34 | 35 | # Iterates over a collection of Surrealist Objects and 36 | # maps surrealize to each object. 37 | # 38 | # @param [Object] collection of instances of a class that has +Surrealist+ included. 39 | # @param [Boolean] [optional] camelize optional argument for converting hash to camelBack. 40 | # @param [Boolean] [optional] include_root optional argument for having the root key of the resulting hash 41 | # as instance's class name. 42 | # @param [Boolean] [optional] include_namespaces optional argument for having root key as a nested hash of 43 | # instance's namespaces. Animal::Cat.new.surrealize -> (animal: { cat: { weight: '3 kilos' } }) 44 | # @param [String] [optional] root optional argument for using a specified root key for the hash. 45 | # @param [Integer] [optional] namespaces_nesting_level level of namespaces nesting. 46 | # @param [Boolean] [optional] raw optional argument for specifying the expected output format. 47 | # 48 | # @return [JSON | Hash] the Collection#map with elements being json-formatted string corresponding 49 | # to the schema provided in the object's class. Values will be taken from the return values 50 | # of appropriate methods from the object. 51 | # 52 | # @raise +Surrealist::InvalidCollectionError+ if invalid collection passed. 53 | # 54 | # @example surrealize a given collection of Surrealist objects 55 | # Surrealist.surrealize_collection(User.all) 56 | # # => "[{\"name\":\"Nikita\",\"age\":23}, {\"name\":\"Alessandro\",\"age\":24}]" 57 | # # For more examples see README 58 | def surrealize_collection(collection, **args) 59 | Surrealist::ExceptionRaiser.raise_invalid_collection! unless Helper.collection?(collection) 60 | 61 | result = collection.map do |object| 62 | Helper.surrealist?(object.class) ? __build_schema(object, **args) : object 63 | end 64 | 65 | args[:raw] ? result : Oj.dump(result, mode: :compat) 66 | end 67 | 68 | # Dumps the object's methods corresponding to the schema 69 | # provided in the object's class and type-checks the values. 70 | # 71 | # @param [Boolean] [optional] camelize optional argument for converting hash to camelBack. 72 | # @param [Boolean] [optional] include_root optional argument for having the root key of the resulting hash 73 | # as instance's class name. 74 | # @param [Boolean] [optional] include_namespaces optional argument for having root key as a nested hash of 75 | # instance's namespaces. Animal::Cat.new.surrealize -> (animal: { cat: { weight: '3 kilos' } }) 76 | # @param [String] [optional] root optional argument for using a specified root key for the hash 77 | # @param [Integer] [optional] namespaces_nesting_level level of namespaces nesting. 78 | # 79 | # @return [String] a json-formatted string corresponding to the schema 80 | # provided in the object's class. Values will be taken from the return values 81 | # of appropriate methods from the object. 82 | def surrealize(instance:, **args) 83 | Oj.dump(build_schema(instance: instance, **args), mode: :compat) 84 | end 85 | 86 | # rubocop:disable Metrics/AbcSize 87 | 88 | # Builds hash from schema provided in the object's class and type-checks the values. 89 | # 90 | # @param [Object] instance of a class that has +Surrealist+ included. 91 | # @param [Hash] args optional arguments 92 | # 93 | # @return [Hash] a hash corresponding to the schema 94 | # provided in the object's class. Values will be taken from the return values 95 | # of appropriate methods from the object. 96 | # 97 | # @raise +Surrealist::UnknownSchemaError+ if no schema was provided in the object's class. 98 | # 99 | # @raise +Surrealist::InvalidTypeError+ if type-check failed at some point. 100 | # 101 | # @raise +Surrealist::UndefinedMethodError+ if a key defined in the schema 102 | # does not have a corresponding method on the object. 103 | # 104 | # @example Define a schema and surrealize the object 105 | # class User 106 | # include Surrealist 107 | # 108 | # json_schema do 109 | # { 110 | # name: String, 111 | # age: Integer, 112 | # } 113 | # end 114 | # 115 | # def name 116 | # 'Nikita' 117 | # end 118 | # 119 | # def age 120 | # 23 121 | # end 122 | # end 123 | # 124 | # User.new.build_schema 125 | # # => { name: 'Nikita', age: 23 } 126 | # # For more examples see README 127 | def build_schema(instance:, **args) 128 | schema = Surrealist::VarsHelper.find_schema(instance.class) 129 | Surrealist::ExceptionRaiser.raise_unknown_schema!(instance) if schema.nil? 130 | 131 | parameters = config ? config.merge(**args) : args 132 | 133 | # TODO: Refactor (something pipeline-like would do here, perhaps a builder of some sort) 134 | carrier = Surrealist::Carrier.call(**parameters) 135 | copied_schema = Surrealist::Copier.deep_copy(schema) 136 | built_schema = Builder.new(carrier, copied_schema, instance).call 137 | wrapped_schema = Surrealist::Wrapper.wrap(built_schema, carrier, klass: instance.class.name) 138 | carrier.camelize ? Surrealist::HashUtils.camelize_hash(wrapped_schema) : wrapped_schema 139 | end 140 | # rubocop:enable Metrics/AbcSize 141 | 142 | # Reads current default serialization arguments. 143 | # 144 | # @return [Hash] default arguments (@see Surrealist::Carrier) 145 | def config 146 | @default_args || Surrealist::HashUtils::EMPTY_HASH 147 | end 148 | 149 | # Sets default serialization arguments with a block 150 | # 151 | # @param [Hash] hash arguments to be set (@see Surrealist::Carrier) 152 | # @param [Proc] _block a block which will be yielded to Surrealist::Carrier instance 153 | # 154 | # @example set config 155 | # Surrealist.configure do |config| 156 | # config.camelize = true 157 | # config.include_root = true 158 | # end 159 | def configure(hash = nil, &_block) 160 | if block_given? 161 | carrier = Surrealist::Carrier.new 162 | yield(carrier) 163 | @default_args = carrier.parameters 164 | else 165 | @default_args = hash.nil? ? Surrealist::HashUtils::EMPTY_HASH : hash 166 | end 167 | end 168 | 169 | private 170 | 171 | # Checks if there is a serializer (< Surrealist::Serializer) defined for the object and delegates 172 | # surrealization to it. 173 | # 174 | # @param [Object] object serializable object 175 | # @param [Hash] args optional arguments passed to +surrealize_collection+ 176 | # 177 | # @return [Hash] a hash corresponding to the schema 178 | # provided in the object's class. Values will be taken from the return values 179 | # of appropriate methods from the object. 180 | def __build_schema(object, **args) 181 | return args[:serializer].new(object, **args[:context].to_h).build_schema(**args) if args[:serializer] 182 | 183 | if (serializer = Surrealist::VarsHelper.find_serializer(object.class, tag: args[:for])) 184 | serializer.new(object, **args[:context].to_h).build_schema(**args) 185 | else 186 | build_schema(instance: object, **args) 187 | end 188 | end 189 | end 190 | end 191 | -------------------------------------------------------------------------------- /lib/surrealist/any.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # A module for any type-checks. 4 | module Any; end 5 | -------------------------------------------------------------------------------- /lib/surrealist/bool.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # A module for boolean type-checks. 4 | module Bool; end 5 | -------------------------------------------------------------------------------- /lib/surrealist/builder.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Surrealist 4 | # A class that builds a hash from the schema and type-checks the values. 5 | class Builder 6 | # Struct to carry schema along 7 | Schema = Struct.new(:key, :value) 8 | Schema.freeze 9 | 10 | # @param [Carrier] carrier instance of Surrealist::Carrier 11 | # @param [Hash] schema the schema defined in the object's class. 12 | # @param [Object] instance the instance of the object which methods from the schema are called on. 13 | def initialize(carrier, schema, instance) 14 | @carrier = carrier 15 | @schema = schema 16 | @instance = instance 17 | end 18 | 19 | # A method that goes recursively through the schema hash, defines the values and type-checks them. 20 | # 21 | # @param [Hash] schema the schema defined in the object's class. 22 | # @param [Object] instance the instance of the object which methods from the schema are called on. 23 | # 24 | # @raise +Surrealist::UndefinedMethodError+ if a key defined in the schema 25 | # does not have a corresponding method on the object. 26 | # 27 | # @return [Hash] a hash that will be dumped into JSON. 28 | def call(schema = @schema, instance = @instance) 29 | schema.each do |schema_key, schema_value| 30 | if schema_value.is_a?(Hash) 31 | check_for_ar(schema, instance, schema_key, schema_value) 32 | else 33 | ValueAssigner.assign(Schema.new(schema_key, schema_value), 34 | instance) { |coerced_value| schema[schema_key] = coerced_value } 35 | end 36 | end 37 | rescue NoMethodError => e 38 | Surrealist::ExceptionRaiser.raise_invalid_key!(e) 39 | end 40 | 41 | private 42 | 43 | attr_reader :carrier, :instance, :schema 44 | 45 | # Checks if result is an instance of ActiveRecord::Relation 46 | # 47 | # @param [Hash] schema the schema defined in the object's class. 48 | # @param [Object] instance the instance of the object which methods from the schema are called on. 49 | # @param [Symbol] key the symbol that represents method on the instance 50 | # @param [Any] value returned when key is called on instance 51 | # 52 | # @return [Hash] the schema hash 53 | def check_for_ar(schema, instance, key, value) 54 | if ar_collection?(instance, key) 55 | construct_collection(schema, instance, key, value) 56 | else 57 | call(value, instance.respond_to?(key) ? instance.send(key) : instance) 58 | end 59 | end 60 | 61 | # Checks if the instance responds to the method and whether it returns an AR::Relation 62 | # 63 | # @param [Object] instance 64 | # @param [Symbol] method 65 | # 66 | # @return [Boolean] 67 | def ar_collection?(instance, method) 68 | defined?(ActiveRecord) && 69 | instance.respond_to?(method) && 70 | instance.send(method).is_a?(ActiveRecord::Relation) 71 | end 72 | 73 | # Makes the value of appropriate key of the schema an array and pushes in results of iterating through 74 | # records and surrealizing them 75 | # 76 | # @param [Hash] schema the schema defined in the object's class. 77 | # @param [Object] instance the instance of the object which methods from the schema are called on. 78 | # @param [Symbol] key the symbol that represents method on the instance 79 | # @param [Any] value returned when key is called on instance 80 | # 81 | # @return [Hash] the schema hash 82 | def construct_collection(schema, instance, key, value) 83 | schema[key] = instance.send(key).map do |inst| 84 | call(Copier.deep_copy(value), inst) 85 | end 86 | end 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /lib/surrealist/carrier.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Surrealist 4 | # A data structure to carry arguments across methods. 5 | # @api private 6 | class Carrier 7 | BOOLEANS = [true, false, nil].freeze 8 | 9 | attr_accessor :camelize, :include_root, :include_namespaces, :root, :namespaces_nesting_level 10 | 11 | # Public wrapper for Carrier. 12 | # 13 | # @param [Boolean] camelize optional argument for converting hash to camelBack. 14 | # @param [Boolean] include_root optional argument for having the root key of the resulting hash 15 | # as instance's class name. 16 | # @param [Boolean] include_namespaces optional argument for having root key as a nested hash of 17 | # instance's namespaces. Animal::Cat.new.surrealize -> (animal: { cat: { weight: '3 kilos' } }) 18 | # @param [String] root optional argument for using a specified root key for the resulting hash 19 | # @param [Integer] namespaces_nesting_level level of namespaces nesting. 20 | # 21 | # @raise ArgumentError if types of arguments are wrong. 22 | # 23 | # @return [Carrier] self if type checks were passed. 24 | def self.call(**args) 25 | new(**args).sanitize! 26 | end 27 | 28 | def initialize(**args) 29 | @camelize = args.delete(:camelize) || false 30 | @include_root = args.delete(:include_root) || false 31 | @include_namespaces = args.delete(:include_namespaces) || false 32 | @root = args.delete(:root) || nil 33 | @namespaces_nesting_level = args.delete(:namespaces_nesting_level) || DEFAULT_NESTING_LEVEL 34 | end 35 | 36 | # Performs type checks 37 | # 38 | # @return [Carrier] self if check were passed 39 | def sanitize! 40 | check_booleans! 41 | check_namespaces_nesting! 42 | check_root! 43 | strip_root! 44 | self 45 | end 46 | 47 | # Checks if all arguments are set to default 48 | def no_args_provided? 49 | @no_args_provided ||= no_args_provided 50 | end 51 | 52 | # Returns all arguments 53 | # 54 | # @return [Hash] 55 | def parameters 56 | { camelize: camelize, include_root: include_root, include_namespaces: include_namespaces, 57 | root: root, namespaces_nesting_level: namespaces_nesting_level } 58 | end 59 | 60 | private 61 | 62 | # Checks all boolean arguments 63 | # @raise ArgumentError 64 | def check_booleans! 65 | booleans_hash.each do |key, value| 66 | unless BOOLEANS.include?(value) 67 | raise ArgumentError, "Expected `#{key}` to be either true, false or nil, got #{value}" 68 | end 69 | end 70 | end 71 | 72 | # Helper hash for all boolean arguments 73 | def booleans_hash 74 | { camelize: camelize, include_root: include_root, include_namespaces: include_namespaces } 75 | end 76 | 77 | # Checks if +namespaces_nesting_level+ is a positive integer 78 | # @raise ArgumentError 79 | def check_namespaces_nesting! 80 | if !namespaces_nesting_level.is_a?(Integer) || namespaces_nesting_level <= 0 81 | Surrealist::ExceptionRaiser.raise_invalid_nesting!(namespaces_nesting_level) 82 | end 83 | end 84 | 85 | # Checks if root is not nil, a non-empty string, or symbol 86 | # @raise ArgumentError 87 | def check_root! 88 | unless root.nil? || (root.is_a?(String) && !root.strip.empty?) || root.is_a?(Symbol) 89 | Surrealist::ExceptionRaiser.raise_invalid_root!(root) 90 | end 91 | end 92 | 93 | # Strips root of empty whitespaces 94 | def strip_root! 95 | root.is_a?(String) && @root = root.strip 96 | end 97 | 98 | # Checks if all arguments are set to default 99 | def no_args_provided 100 | !camelize && !include_root && !include_namespaces && root.nil? && 101 | namespaces_nesting_level == DEFAULT_NESTING_LEVEL 102 | end 103 | end 104 | end 105 | -------------------------------------------------------------------------------- /lib/surrealist/class_methods.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Surrealist 4 | # Class methods that are extended by the object. 5 | module ClassMethods 6 | # A DSL method to define schema in a declarative style. Schema should be defined with a block 7 | # that contains a hash. 8 | # Every key of the schema should be either a name of a method of the 9 | # surrealizable object (or it's parents/mixins), or - in case value is a hash - a symbol: 10 | # to build nested JSON structures. Every value of the hash should be a constant that represents 11 | # a Ruby class, that will be used for type-checks. 12 | # 13 | # @param [Proc] _block that contains hash defining the schema 14 | # 15 | # @example DSL usage example 16 | # class User 17 | # include Surrealist 18 | # 19 | # json_schema do 20 | # { 21 | # foo: String, 22 | # bar: Integer, 23 | # } 24 | # end 25 | # 26 | # def foo; 'A string'; end 27 | # def bar; 42; end 28 | # end 29 | # 30 | # User.new.surrealize 31 | # # => "{\"foo\":\"A string\",\"bar\":42}" 32 | # # For more examples see README 33 | # 34 | # @example Schema with nested structure 35 | # class Person 36 | # include Surrealist 37 | # 38 | # json_schema do 39 | # { 40 | # foo: String, 41 | # nested: { 42 | # bar: Integer, 43 | # } 44 | # } 45 | # end 46 | # 47 | # def foo; 'A string'; end 48 | # def bar; 42; end 49 | # end 50 | # 51 | # Person.new.surrealize 52 | # # => "{\"foo\":\"A string\",\"nested\":{\"bar\":42}}" 53 | # # For more examples see README 54 | def json_schema(&_block) 55 | SchemaDefiner.call(self, yield) 56 | end 57 | 58 | # A DSL method to return the defined schema. 59 | # @example DSL usage example 60 | # class Person 61 | # include Surrealist 62 | # 63 | # json_schema do 64 | # { name: String } 65 | # end 66 | # 67 | # def name 68 | # 'Parent' 69 | # end 70 | # end 71 | # 72 | # Person.defined_schema 73 | # # => { name: String } 74 | def defined_schema 75 | read_schema.tap do |schema| 76 | raise UnknownSchemaError if schema.nil? 77 | end 78 | end 79 | 80 | # A DSL method to delegate schema in a declarative style. Must reference a valid 81 | # class that includes Surrealist 82 | # 83 | # @param [Class] klass 84 | # 85 | # @example DSL usage example 86 | # class Host 87 | # include Surrealist 88 | # 89 | # json_schema do 90 | # { name: String } 91 | # end 92 | # 93 | # def name 94 | # 'Parent' 95 | # end 96 | # end 97 | # 98 | # class Guest < Host 99 | # delegate_surrealization_to Host 100 | # 101 | # def name 102 | # 'Child' 103 | # end 104 | # end 105 | # 106 | # Guest.new.surrealize 107 | # # => "{\"name\":\"Child\"}" 108 | # # For more examples see README 109 | def delegate_surrealization_to(klass) 110 | raise TypeError, "Expected type of Class got #{klass.class} instead" unless klass.is_a?(Class) 111 | 112 | Surrealist::ExceptionRaiser.raise_invalid_schema_delegation! unless Helper.surrealist?(klass) 113 | 114 | hash = Surrealist::VarsHelper.find_schema(klass) 115 | Surrealist::VarsHelper.set_schema(self, hash) 116 | end 117 | 118 | # A DSL method for defining a class that holds serialization logic. 119 | # 120 | # @param [Class] klass a class that should inherit form Surrealist::Serializer 121 | # 122 | # @raise ArgumentError if Surrealist::Serializer is not found in the ancestors chain 123 | def surrealize_with(klass, tag: Surrealist::VarsHelper::DEFAULT_TAG) 124 | if klass < Surrealist::Serializer 125 | Surrealist::VarsHelper.add_serializer(self, klass, tag: tag) 126 | instance_variable_set(VarsHelper::PARENT_VARIABLE, klass.defined_schema) 127 | else 128 | raise ArgumentError, "#{klass} should be inherited from Surrealist::Serializer" 129 | end 130 | end 131 | 132 | private 133 | 134 | def read_schema 135 | instance_variable_get(VarsHelper::INSTANCE_VARIABLE) || 136 | instance_variable_get(VarsHelper::PARENT_VARIABLE) 137 | end 138 | end 139 | end 140 | -------------------------------------------------------------------------------- /lib/surrealist/copier.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Surrealist 4 | # A helper class for deep copying and wrapping hashes. 5 | module Copier 6 | class << self 7 | # Goes through the hash recursively and deeply copies it. 8 | # 9 | # @param [Hash] hash the hash to be copied. 10 | # @param [Hash] wrapper the wrapper of the resulting hash. 11 | # 12 | # @return [Hash] deeply copied hash. 13 | def deep_copy(hash, wrapper = {}) 14 | hash.each_with_object(wrapper) do |(key, value), new| 15 | new[key] = value.is_a?(Hash) ? deep_copy(value) : value 16 | end 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/surrealist/exception_raiser.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Surrealist 4 | # Error class for classes without defined +schema+. 5 | class UnknownSchemaError < RuntimeError; end 6 | 7 | # Error class for classes with +json_schema+ defined not as a hash. 8 | class InvalidSchemaError < ArgumentError; end 9 | 10 | # Error class for +NoMethodError+. 11 | class UndefinedMethodError < ArgumentError; end 12 | 13 | # Error class for failed type-checks. 14 | class InvalidTypeError < TypeError; end 15 | 16 | # Error class for undefined root keys for schema wrapping. 17 | class UnknownRootError < ArgumentError; end 18 | 19 | # Error class for undefined class to delegate schema. 20 | class InvalidSchemaDelegation < ArgumentError; end 21 | 22 | # Error class for invalid object given to iteratively apply surrealize. 23 | class InvalidCollectionError < ArgumentError; end 24 | 25 | # Error class for cases where +namespaces_nesting_level+ is set to 0. 26 | class InvalidNestingLevel < ArgumentError; end 27 | 28 | # Error class for unknown tag passed 29 | class UnknownTagError < ArgumentError; end 30 | 31 | # A class that raises all Surrealist exceptions 32 | module ExceptionRaiser 33 | CLASS_NAME_NOT_PASSED = "Can't wrap schema in root key - class name was not passed" 34 | MUST_BEHAVE_LIKE_ENUMERABLE = "Can't serialize collection - must behave like enumerable" 35 | CLASS_DOESNT_INCLUDE_SURREALIST = 'Class does not include Surrealist' 36 | 37 | class << self 38 | # Raises Surrealist::InvalidSchemaDelegation if destination of delegation does not 39 | # include Surrealist. 40 | # 41 | # @raise Surrealist::InvalidSchemaDelegation 42 | def raise_invalid_schema_delegation! 43 | raise Surrealist::InvalidSchemaDelegation, CLASS_DOESNT_INCLUDE_SURREALIST 44 | end 45 | 46 | # Raises Surrealist::UnknownSchemaError 47 | # 48 | # @param [Object] instance instance of the class without schema defined. 49 | # 50 | # @raise Surrealist::UnknownSchemaError 51 | def raise_unknown_schema!(instance) 52 | raise Surrealist::UnknownSchemaError, 53 | "Can't serialize #{instance.class} - no schema was provided." 54 | end 55 | 56 | # Raises Surrealist::UnknownRootError if class's name is unknown. 57 | # 58 | # @raise Surrealist::UnknownRootError 59 | def raise_unknown_root! 60 | raise Surrealist::UnknownRootError, CLASS_NAME_NOT_PASSED 61 | end 62 | 63 | # Raises Surrealist::InvalidCollectionError 64 | # 65 | # @raise Surrealist::InvalidCollectionError 66 | def raise_invalid_collection! 67 | raise Surrealist::InvalidCollectionError, MUST_BEHAVE_LIKE_ENUMERABLE 68 | end 69 | 70 | # Raises ArgumentError if namespaces_nesting_level is not an integer. 71 | # 72 | # @raise ArgumentError 73 | def raise_invalid_nesting!(value) 74 | raise ArgumentError, 75 | "Expected `namespaces_nesting_level` to be a positive integer, got: #{value}" 76 | end 77 | 78 | # Raises ArgumentError if root is not nil, a non-empty string or symbol. 79 | # 80 | # @raise ArgumentError 81 | def raise_invalid_root!(value) 82 | raise ArgumentError, 83 | "Expected `root` to be nil, a non-empty string, or symbol, got: #{value}" 84 | end 85 | 86 | # Raises ArgumentError if a key defined in the schema does not have a corresponding 87 | # method on the object. 88 | # 89 | # @raise Surrealist::UndefinedMethodError 90 | def raise_invalid_key!(err) 91 | raise Surrealist::UndefinedMethodError, 92 | "#{err.message}. You have probably defined a key " \ 93 | "in the schema that doesn't have a corresponding method.", 94 | err.backtrace 95 | end 96 | 97 | # Raises ArgumentError if a tag has no corresponding serializer 98 | # 99 | # @param [String] tag Wrong tag 100 | # 101 | # @raise Surrealist::UnknownTagError 102 | def raise_unknown_tag!(tag) 103 | raise Surrealist::UnknownTagError, 104 | "The tag specified (#{tag}) has no corresponding serializer" 105 | end 106 | end 107 | end 108 | end 109 | -------------------------------------------------------------------------------- /lib/surrealist/hash_utils.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Surrealist 4 | # A helper class for hashes transformations. 5 | module HashUtils 6 | EMPTY_HASH = {}.freeze 7 | 8 | class << self 9 | # Converts hash's keys to camelBack keys. 10 | # 11 | # @param [Hash] hash a hash to be camelized. 12 | # 13 | # @return [Hash] camelized hash. 14 | def camelize_hash(hash) 15 | return hash unless hash.is_a?(Hash) 16 | 17 | hash.each_with_object({}) do |(k, v), obj| 18 | obj[camelize_key(k, first_upper: false)] = camelize_hash(v) 19 | end 20 | end 21 | 22 | private 23 | 24 | # Converts symbol to string and camelizes it. 25 | # 26 | # @param [String | Symbol] key a key to be camelized. 27 | # @param [Boolean] first_upper should the first letter be capitalized. 28 | # 29 | # @return [String | Symbol] camelized key of a hash. 30 | def camelize_key(key, first_upper: true) 31 | case key 32 | when Symbol 33 | Surrealist::StringUtils.camelize(key.to_s, first_upper: first_upper).to_sym 34 | when String 35 | Surrealist::StringUtils.camelize(key, first_upper: first_upper) 36 | else 37 | key 38 | end 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/surrealist/helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Surrealist 4 | # A generic helper. 5 | module Helper 6 | # Determines if the class uses the Surrealist mixin. 7 | # 8 | # @param [Class] klass a class to be checked. 9 | # 10 | # @return [Boolean] if Surrealist is included in class. 11 | def self.surrealist?(klass) 12 | klass < Surrealist || klass < Surrealist::Serializer 13 | end 14 | 15 | def self.collection?(object) 16 | # 4.2 AR relation object did not include Enumerable (it defined 17 | # all necessary method through ActiveRecord::Delegation module), 18 | # so we need to explicitly check for this 19 | return false if object.is_a?(Struct) 20 | 21 | object.is_a?(Enumerable) && !object.instance_of?(Hash) || ar_relation?(object) 22 | end 23 | 24 | def self.ar_relation?(object) 25 | defined?(ActiveRecord) && object.is_a?(ActiveRecord::Relation) 26 | end 27 | private_class_method :ar_relation? 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/surrealist/instance_methods.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Surrealist 4 | # Instance methods that are included to the object's class 5 | module InstanceMethods 6 | # Dumps the object's methods corresponding to the schema 7 | # provided in the object's class and type-checks the values. 8 | # 9 | # @param [Boolean] [optional] camelize optional argument for converting hash to camelBack. 10 | # @param [Boolean] [optional] include_root optional argument for having the root key of the resulting hash 11 | # as instance's class name. 12 | # @param [Boolean] [optional] include_namespaces optional argument for having root key as a nested hash of 13 | # instance's namespaces. Animal::Cat.new.surrealize -> (animal: { cat: { weight: '3 kilos' } }) 14 | # @param [String] [optional] root optional argument for using a specified root key for the hash 15 | # @param [Integer] [optional] namespaces_nesting_level level of namespaces nesting. 16 | # 17 | # @return [String] a json-formatted string corresponding to the schema 18 | # provided in the object's class. Values will be taken from the return values 19 | # of appropriate methods from the object. 20 | # 21 | # @raise +Surrealist::UnknownSchemaError+ if no schema was provided in the object's class. 22 | # 23 | # @raise +Surrealist::InvalidTypeError+ if type-check failed at some point. 24 | # 25 | # @raise +Surrealist::UndefinedMethodError+ if a key defined in the schema 26 | # does not have a corresponding method on the object. 27 | # 28 | # @example Define a schema and surrealize the object 29 | # class User 30 | # include Surrealist 31 | # 32 | # json_schema do 33 | # { 34 | # name: String, 35 | # age: Integer, 36 | # } 37 | # end 38 | # 39 | # def name 40 | # 'Nikita' 41 | # end 42 | # 43 | # def age 44 | # 23 45 | # end 46 | # end 47 | # 48 | # User.new.surrealize 49 | # # => "{\"name\":\"Nikita\",\"age\":23}" 50 | # # For more examples see README 51 | def surrealize(**args) 52 | return args[:serializer].new(self).surrealize(**args) if args[:serializer] 53 | 54 | if (serializer = find_serializer(args[:for])) 55 | return serializer.new(self).surrealize(**args) 56 | end 57 | 58 | Oj.dump(Surrealist.build_schema(instance: self, **args), mode: :compat) 59 | end 60 | 61 | # Invokes +Surrealist+'s class method +build_schema+ 62 | def build_schema(**args) 63 | return args[:serializer].new(self).build_schema(**args) if args[:serializer] 64 | 65 | if (serializer = find_serializer(args[:for])) 66 | return serializer.new(self).build_schema(**args) 67 | end 68 | 69 | Surrealist.build_schema(instance: self, **args) 70 | end 71 | 72 | private 73 | 74 | def find_serializer(tag) 75 | Surrealist::VarsHelper.find_serializer(self.class, tag: tag) 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /lib/surrealist/schema_definer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Surrealist 4 | # A class that defines a method on the object that stores the schema. 5 | module SchemaDefiner 6 | SCHEMA_TYPE_ERROR = 'Schema should be defined as a hash' 7 | 8 | class << self 9 | # Defines an instance variable on the object that stores the schema. 10 | # 11 | # @param [Object] klass class of the object that needs to be surrealized. 12 | # 13 | # @param [Hash] hash the schema defined in the object's class. 14 | # 15 | # @return [Hash] +@__surrealist_schema+ variable that stores the schema of the object. 16 | # 17 | # @raise +Surrealist::InvalidSchemaError+ if schema was defined not through a hash. 18 | def call(klass, hash) 19 | raise Surrealist::InvalidSchemaError, SCHEMA_TYPE_ERROR unless hash.is_a?(Hash) 20 | 21 | Surrealist::VarsHelper.set_schema(klass, hash) 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/surrealist/serializer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Surrealist 4 | # Abstract class to be inherited from 5 | # 6 | # @example Usage 7 | # class CatSerializer < Surrealist::Serializer 8 | # json_schema { { age: Integer, age_group: String } } 9 | # 10 | # def age_group 11 | # age <= 5 ? 'kitten' : 'cat' 12 | # end 13 | # end 14 | # 15 | # class Cat 16 | # include Surrealist 17 | # attr_reader :age 18 | # 19 | # surrealize_with CatSerializer 20 | # 21 | # def initialize(age) 22 | # @age = age 23 | # end 24 | # end 25 | # 26 | # Cat.new(12).surrealize # Checks for schema in CatSerializer (if .surrealize_with is stated) 27 | # # => '{ "age": 12, "age_group": "cat" }' 28 | # 29 | # CatSerializer.new(Cat.new(3)).surrealize # explicit usage of CatSerializer 30 | # # => '{ "age": 3, "age_group": "kitten" }' 31 | class Serializer 32 | extend Surrealist::ClassMethods 33 | 34 | class << self 35 | # Defines instance methods that read values from the context hash. 36 | # 37 | # @param [Array] array 38 | # an array of symbols which represent method names 39 | # 40 | # @raise ArgumentError if type of argument is not an array of symbols 41 | def serializer_context(*array) 42 | unless array.all? { |i| i.is_a? Symbol } 43 | raise ArgumentError, 'Please provide an array of symbols to `.serializer_context`' 44 | end 45 | 46 | array.each { |method| define_method(method) { context[method] } } 47 | end 48 | 49 | # Plural form ¯\_(ツ)_/¯ 50 | alias serializer_contexts serializer_context 51 | 52 | # Only lookup for methods defined in Surrealist::Serializer subclasses 53 | # to prevent invoke of Kernel methods 54 | # 55 | # @param [Symbol] method method to be invoked 56 | # 57 | # @return [Boolean] 58 | def method_defined?(method) 59 | return true if instance_methods(false).include?(method) 60 | return false if superclass == Surrealist::Serializer 61 | 62 | super 63 | end 64 | 65 | def private_method_defined?(method) 66 | return true if private_instance_methods(false).include?(method) 67 | return false if superclass == Surrealist::Serializer 68 | 69 | super 70 | end 71 | end 72 | 73 | # NOTE: #context will work only when using serializer explicitly, 74 | # e.g `CatSerializer.new(Cat.new(3), food: CatFood.new)` 75 | # And then food will be available inside serializer via `context[:food]` 76 | def initialize(object, **context) 77 | @object = wrap_hash_into_struct(object) 78 | @context = context 79 | end 80 | 81 | # Checks whether object is a collection or an instance and serializes it 82 | def surrealize(**args) 83 | if Helper.collection?(object) 84 | Surrealist.surrealize_collection(object, **args.merge(context: context)) 85 | else 86 | Surrealist.surrealize(instance: self, **args) 87 | end 88 | end 89 | 90 | # Passes build_schema to Surrealist 91 | def build_schema(**args) 92 | if Helper.collection?(object) 93 | build_collection_schema(**args) 94 | else 95 | Surrealist.build_schema(instance: self, **args) 96 | end 97 | end 98 | 99 | private 100 | 101 | attr_reader :object, :context 102 | 103 | # Maps collection and builds schema for each instance. 104 | def build_collection_schema(**args) 105 | object.map { |object| self.class.new(object, **context).build_schema(**args) } 106 | end 107 | 108 | # Methods not found inside serializer will be invoked on the object 109 | def method_missing(method, *args, &block) 110 | return super unless object.respond_to?(method) 111 | 112 | object.public_send(method, *args, &block) 113 | end 114 | 115 | # Methods not found inside serializer will be invoked on the object 116 | def respond_to_missing?(method, include_private = false) 117 | object.respond_to?(method) || super 118 | end 119 | 120 | def wrap_hash_into_struct(object) 121 | object.instance_of?(Hash) ? OpenStruct.new(object) : object 122 | end 123 | end 124 | end 125 | -------------------------------------------------------------------------------- /lib/surrealist/string_utils.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Surrealist 4 | # A helper class for strings transformations. 5 | module StringUtils 6 | DASH = '-' 7 | UNDERSCORE = '_' 8 | EMPTY_STRING = '' 9 | DASH_REGEXP1 = /([A-Z]+)([A-Z][a-z])/o.freeze 10 | DASH_REGEXP2 = /([a-z\d])([A-Z])/o.freeze 11 | UNDERSCORE_REGEXP = /(?:^|_)([^_\s]+)/o.freeze 12 | NAMESPACES_SEPARATOR = '::' 13 | UNDERSCORE_SUBSTITUTE = '\1_\2' 14 | 15 | class << self 16 | # Converts a string to snake_case. 17 | # 18 | # @param [String] string a string to be underscored. 19 | # 20 | # @return [String] new underscored string. 21 | def underscore(string) 22 | dup = string.gsub(NAMESPACES_SEPARATOR, UNDERSCORE) 23 | dup.gsub!(DASH_REGEXP1, UNDERSCORE_SUBSTITUTE) 24 | dup.gsub!(DASH_REGEXP2, UNDERSCORE_SUBSTITUTE) 25 | dup.tr!(DASH, UNDERSCORE) 26 | dup.downcase! 27 | dup 28 | end 29 | 30 | # Camelizes a string. 31 | # 32 | # @param [String] snake_string a string to be camelized. 33 | # @param [Boolean] first_upper should the first letter be capitalized. 34 | # 35 | # @return [String] camelized string. 36 | def camelize(snake_string, first_upper: true) 37 | if first_upper 38 | snake_string.to_s.gsub(UNDERSCORE_REGEXP) { Regexp.last_match[1].capitalize } 39 | else 40 | parts = snake_string.split(UNDERSCORE, 2) 41 | parts[0].concat(camelize(parts[1])) if parts.size > 1 42 | parts[0] || EMPTY_STRING 43 | end 44 | end 45 | 46 | # Extracts bottom-level class from a namespace. 47 | # 48 | # @param [String] string full namespace 49 | # 50 | # @example Extract class 51 | # extract_class('Animal::Dog::Collie') # => 'Collie' 52 | # 53 | # @return [String] extracted class 54 | def extract_class(string) 55 | uncapitalize(string.split(NAMESPACES_SEPARATOR).last) 56 | end 57 | 58 | # Extracts n amount of classes from a namespaces and returns a nested hash. 59 | # 60 | # @param [String] klass full namespace as a string. 61 | # @param [Boolean] camelize optional camelize argument. 62 | # @param [Integer] nesting_level level of required nesting. 63 | # 64 | # @example 3 levels 65 | # klass = 'Business::System::Cashier::Reports::Withdraws' 66 | # break_namespaces(klass, camelize: false, nesting_level: 3) 67 | # # => { cashier: { reports: { withdraws: {} } } } 68 | # 69 | # @raise Surrealist::InvalidNestingLevel if nesting level is specified as 0. 70 | # 71 | # @return [Hash] a nested hash. 72 | def break_namespaces(klass, camelize, nesting_level) 73 | Surrealist::ExceptionRaiser.raise_invalid_nesting!(nesting_level) unless nesting_level.positive? 74 | 75 | klass.split(NAMESPACES_SEPARATOR).last(nesting_level).reverse!.inject({}) do |a, n| 76 | key = (camelize ? camelize(uncapitalize(n), first_upper: false) : underscore(n)).to_sym 77 | 78 | { key => a } 79 | end 80 | end 81 | 82 | private 83 | 84 | # Clones a string and converts first character to lower case. 85 | # 86 | # @param [String] string a string to be cloned. 87 | # 88 | # @return [String] new string with lower cased first character. 89 | def uncapitalize(string) 90 | str = string.dup 91 | str[0] = str[0].downcase 92 | str 93 | end 94 | end 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /lib/surrealist/type_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Surrealist 4 | # Service class for type checking 5 | module TypeHelper 6 | # Dry-types class matcher 7 | DRY_TYPE_CLASS = /Dry::Types/.freeze 8 | 9 | class << self 10 | # Checks if value returned from a method is an instance of type class specified 11 | # in schema or NilClass. 12 | # 13 | # @param [any] value value returned from a method. 14 | # @param [Class] type class representing data type. 15 | # 16 | # @return [boolean] 17 | def valid_type?(value, type) 18 | return true if type == Any 19 | 20 | if type == Bool 21 | Surrealist::Carrier::BOOLEANS.include?(value) 22 | elsif defined?(Dry::Types) && dry_type?(type) 23 | type.try(value).success? 24 | else 25 | value.nil? || value.is_a?(type) 26 | end 27 | end 28 | 29 | # Coerces value is it should be coerced 30 | # 31 | # @param [any] value value that will be coerced 32 | # @param [Class] type class representing data type 33 | # 34 | # @return [any] coerced value 35 | def coerce(value, type) 36 | return value unless dry_type?(type) 37 | return value if type.try(value).input == value 38 | 39 | type[value] 40 | end 41 | 42 | private 43 | 44 | # Checks if type is an instance of dry-type 45 | # 46 | # @param [Object] type type to be checked 47 | # 48 | # @return [Boolean] is type an instance of dry-type 49 | def dry_type?(type) 50 | if type.respond_to?(:primitive) || type.class.name.nil? 51 | true 52 | else 53 | type.class.name =~ DRY_TYPE_CLASS 54 | end 55 | end 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/surrealist/value_assigner.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Surrealist 4 | # A class that determines the correct value to return for serialization. May descend recursively. 5 | module ValueAssigner 6 | class << self 7 | # Assigns value returned from a method to a corresponding key in the schema hash. 8 | # 9 | # @param [Object] instance the instance of the object which methods from the schema are called on. 10 | # @param [Struct] schema containing a single schema key and value 11 | # 12 | # @return [Hash] schema 13 | def assign(schema, instance) 14 | value = raw_value(instance, schema) 15 | 16 | # set to track and prevent infinite self references in surrealization 17 | @skip_set ||= Set.new 18 | 19 | if value.respond_to?(:build_schema) 20 | yield assign_nested_record(instance, value) 21 | elsif Helper.collection?(value) && !value.empty? && value.all? { |v| Helper.surrealist?(v.class) } 22 | yield assign_nested_collection(instance, value) 23 | else 24 | yield value 25 | end 26 | end 27 | 28 | private 29 | 30 | # Generates first pass of serializing value, doing type check and coercion 31 | # 32 | # @param [Object] instance the instance of the object which methods from the schema are called on. 33 | # @param [Struct] schema containing a single schema key and value 34 | # 35 | # @return [Object] value to be further processed 36 | def raw_value(instance, schema) 37 | value = instance.is_a?(Hash) ? instance[schema.key] : invoke_method(instance, schema.key) 38 | coerce_value(value, schema) 39 | end 40 | 41 | # Checks if there is a custom serializer defined for the object and invokes the method 42 | # on it first only if the serializer has not defined the same method. 43 | # 44 | # @param [Object] instance an instance of a model or a serializer 45 | # @param [Symbol] method the schema key that represents the method to be invoked 46 | # 47 | # @return [Object] the return value of the method 48 | def invoke_method(instance, method) 49 | object = instance.instance_variable_get(:@object) 50 | instance_method = instance.class.method_defined?(method) || 51 | instance.class.private_method_defined?(method) 52 | invoke_object = !instance_method && object && object.respond_to?(method, true) 53 | invoke_object ? object.send(method) : instance.send(method) 54 | end 55 | 56 | # Coerces value if type check is passed 57 | # 58 | # @param [Object] value the value to be checked and coerced 59 | # @param [Struct] schema containing a single schema key and value 60 | # 61 | # @raise +Surrealist::InvalidTypeError+ if type-check failed at some point. 62 | # 63 | # @return [Object] value to be further processed 64 | def coerce_value(value, schema) 65 | unless TypeHelper.valid_type?(value, schema.value) 66 | raise Surrealist::InvalidTypeError, 67 | "Wrong type for key `#{schema.key}`. Expected #{schema.value}, got #{value.class}." 68 | end 69 | TypeHelper.coerce(value, schema.value) 70 | end 71 | 72 | # Assists in recursively generating schema for records while preventing infinite self-referencing 73 | # 74 | # @param [Object] instance the instance of the object which methods from the schema are called on. 75 | # @param [Object] value a value that has to be type-checked. 76 | # 77 | # @return [Array] of schemas 78 | def assign_nested_collection(instance, value) 79 | return if @skip_set.include?(value.first.class) 80 | 81 | with_skip_set(instance.class) { Surrealist.surrealize_collection(value, raw: true) } 82 | end 83 | 84 | # Assists in recursively generating schema for a record while preventing infinite self-referencing 85 | # 86 | # @param [Object] instance the instance of the object which methods from the schema are called on. 87 | # @param [Object] value a value that has to be type-checked. 88 | # 89 | # @return [Hash] schema 90 | def assign_nested_record(instance, value) 91 | return if @skip_set.include?(value.class) 92 | 93 | with_skip_set(instance.class) { value.build_schema } 94 | end 95 | 96 | # Run block with klass in skip set 97 | # 98 | # @param [Class] klass of current instance. 99 | # 100 | # @return [Object] block result 101 | def with_skip_set(klass) 102 | return yield if @skip_set.include?(klass) 103 | 104 | @skip_set.add(klass) 105 | result = yield 106 | @skip_set.delete(klass) 107 | result 108 | end 109 | end 110 | end 111 | end 112 | -------------------------------------------------------------------------------- /lib/surrealist/vars_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Surrealist 4 | # Module for finding and setting hash into vars 5 | module VarsHelper 6 | # Instance variable name that is set by SchemaDefiner 7 | INSTANCE_VARIABLE = '@__surrealist_schema' 8 | # Instance's parent instance variable name that is set by SchemaDefiner 9 | PARENT_VARIABLE = '@__surrealist_schema_parent' 10 | # Class variable name that is set by SchemaDefiner 11 | CLASS_VARIABLE = '@@__surrealist_schema' 12 | # Regexp to resolve ROM structure 13 | ROM_REGEXP = /ROM::Struct/o.freeze 14 | # Instance variable that keeps serializer classes 15 | SERIALIZER_CLASSES = '@__surrealist_serializers' 16 | # Tag for default behaviour in multiple serializers 17 | DEFAULT_TAG = :default 18 | 19 | class << self 20 | # Find the schema 21 | # 22 | # @param [Class] klass Class that included Surrealist 23 | # 24 | # @return [Hash] Found hash 25 | def find_schema(klass) 26 | if use_class_var?(klass) 27 | klass.class_variable_get(CLASS_VARIABLE) if klass.class_variable_defined?(CLASS_VARIABLE) 28 | else 29 | klass.instance_variable_get(INSTANCE_VARIABLE) 30 | end 31 | end 32 | 33 | # Setting schema into var 34 | # 35 | # @param [Class] klass Class that included Surrealist 36 | # @param [Hash] hash Schema hash 37 | def set_schema(klass, hash) 38 | if use_class_var?(klass) 39 | klass.class_variable_set(CLASS_VARIABLE, hash) 40 | else 41 | klass.instance_variable_set(INSTANCE_VARIABLE, hash) 42 | end 43 | end 44 | 45 | # Checks if there is a serializer defined for a given class and returns it 46 | # 47 | # @param [Class] klass a class to check 48 | # @param [Symbol] tag a tag associated with serializer 49 | # 50 | # @return [Class | nil] 51 | def find_serializer(klass, tag: nil) 52 | tag ||= DEFAULT_TAG 53 | hash = klass.instance_variable_get(SERIALIZER_CLASSES) 54 | serializer = hash&.fetch(tag.to_sym, nil) 55 | Surrealist::ExceptionRaiser.raise_unknown_tag!(tag) if serializer.nil? && tag != DEFAULT_TAG 56 | serializer 57 | end 58 | 59 | # Sets a serializer for class 60 | # 61 | # @param [Class] self_class class of object that points to serializer 62 | # @param [Class] serializer_class class of serializer 63 | # @param [Symbol] tag a tag associated with serializer 64 | def add_serializer(self_class, serializer_class, tag: nil) 65 | tag ||= DEFAULT_TAG 66 | hash = self_class.instance_variable_get(SERIALIZER_CLASSES) || {} 67 | hash[tag.to_sym] = serializer_class 68 | self_class.instance_variable_set(SERIALIZER_CLASSES, hash) 69 | end 70 | 71 | private 72 | 73 | def use_class_var?(klass) 74 | klass.name =~ ROM_REGEXP 75 | end 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /lib/surrealist/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Surrealist 4 | # Defines the version of Surrealist 5 | VERSION = '2.0.0' 6 | end 7 | -------------------------------------------------------------------------------- /lib/surrealist/wrapper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Surrealist 4 | # A helper class for wrapping hashes. 5 | module Wrapper 6 | class << self 7 | # Wraps the schema hash into root/namespaces if there is a need to. 8 | # 9 | # @param [Object] hash to be wrapped. 10 | # @param [Object] carrier instance of Carrier class that carries arguments passed to +surrealize+ 11 | # @param [String] klass instance's class name. 12 | # 13 | # @return [Hash] a wrapped hash. 14 | def wrap(hash, carrier, klass: false) 15 | namespaces_condition = carrier.include_namespaces || carrier.namespaces_nesting_level != DEFAULT_NESTING_LEVEL # rubocop:disable Layout/LineLength 16 | 17 | if !klass && (carrier.include_root || namespaces_condition) 18 | Surrealist::ExceptionRaiser.raise_unknown_root! 19 | end 20 | 21 | possibly_wrapped_hash(hash, klass, carrier, namespaces_condition) 22 | end 23 | 24 | private 25 | 26 | # Deeply copies the schema hash and wraps it if there is a need to. 27 | # TODO: refactor 28 | # 29 | # @param [Object] hash object to be copied. 30 | # @param [String] klass instance's class name. 31 | # @param [Object] carrier instance of Carrier class that carries arguments passed to +surrealize+ 32 | # @param [Bool] namespaces_condition whether to wrap into namespace. 33 | # 34 | # @return [Hash] deeply copied hash, possibly wrapped. 35 | def possibly_wrapped_hash(hash, klass, carrier, namespaces_condition) 36 | return hash if carrier.no_args_provided? 37 | 38 | if carrier.root 39 | wrap_schema_into_root(hash, carrier, carrier.root.to_s) 40 | elsif namespaces_condition 41 | wrap_schema_into_namespace(hash, carrier, klass) 42 | elsif carrier.include_root 43 | actual_class = Surrealist::StringUtils.extract_class(klass) 44 | wrap_schema_into_root(hash, carrier, actual_class) 45 | else 46 | hash 47 | end 48 | end 49 | 50 | # Wraps schema into a root key if `include_root` is passed to Surrealist. 51 | # 52 | # @param [Hash] schema schema hash. 53 | # @param [Object] carrier instance of Carrier class that carries arguments passed to +surrealize+ 54 | # @param [String] root what the schema will be wrapped into 55 | # 56 | # @return [Hash] a hash with schema wrapped inside a root key. 57 | def wrap_schema_into_root(schema, carrier, root) 58 | root_key = if carrier.camelize 59 | Surrealist::StringUtils.camelize(root, first_upper: false).to_sym 60 | else 61 | Surrealist::StringUtils.underscore(root).to_sym 62 | end 63 | result = { root_key => {} } 64 | Surrealist::Copier.deep_copy(schema, result[root_key]) 65 | 66 | result 67 | end 68 | 69 | # Wraps schema into a nested hash of namespaces. 70 | # 71 | # @param [Hash] schema main schema. 72 | # @param [String] klass name of the class where schema is defined. 73 | # @param [Object] carrier instance of Carrier class that carries arguments passed to +surrealize+ 74 | # 75 | # @return [Hash] nested hash (see +inject_schema+) 76 | def wrap_schema_into_namespace(schema, carrier, klass) 77 | nested_hash = Surrealist::StringUtils.break_namespaces( 78 | klass, carrier.camelize, carrier.namespaces_nesting_level 79 | ) 80 | 81 | inject_schema(nested_hash, Surrealist::Copier.deep_copy(schema)) 82 | end 83 | 84 | # Injects one hash into another nested hash. 85 | # 86 | # @param [Hash] hash wrapper-hash. 87 | # @param [Hash] sub_hash hash to be injected. 88 | # 89 | # @example wrapping hash 90 | # hash = { one: { two: { three: {} } } } 91 | # sub_hash = { four: '4' } 92 | # 93 | # inject_schema(hash, sub_hash) 94 | # # => { one: { two: { three: { four: '4' } } } } 95 | # 96 | # @return [Hash] resulting hash. 97 | def inject_schema(hash, sub_hash) 98 | hash.each do |k, v| 99 | v == Surrealist::HashUtils::EMPTY_HASH ? hash[k] = sub_hash : inject_schema(v, sub_hash) 100 | end 101 | end 102 | end 103 | end 104 | end 105 | -------------------------------------------------------------------------------- /spec/build_schema_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Note 4 | include Surrealist 5 | 6 | json_schema do 7 | { 8 | foo: Integer, 9 | bar: Array, 10 | nested: { 11 | left: String, 12 | right: Bool, 13 | }, 14 | } 15 | end 16 | 17 | def foo 18 | 4 19 | end 20 | 21 | def bar 22 | [1, 3, 5] 23 | end 24 | 25 | private 26 | 27 | def left 28 | 'left' 29 | end 30 | 31 | def right 32 | true 33 | end 34 | 35 | # expecting: 36 | # { 37 | # foo: 4, bar: [1, 3, 5], nested: { 38 | # left: 'left', 39 | # right: true 40 | # } 41 | # } 42 | end 43 | 44 | class IncorrectTypes 45 | include Surrealist 46 | 47 | json_schema do 48 | { foo: Integer } 49 | end 50 | 51 | def foo 52 | 'string' 53 | end 54 | 55 | # expecting: Surrealist::InvalidTypeError 56 | end 57 | 58 | class Ancestor 59 | private def foo 60 | 'foo' 61 | end 62 | 63 | # expecting: see Child 64 | end 65 | 66 | class Infant < Ancestor 67 | include Surrealist 68 | 69 | json_schema do 70 | { 71 | foo: String, 72 | bar: Array, 73 | } 74 | end 75 | 76 | def bar 77 | [1, 2] 78 | end 79 | 80 | # expecting: { foo: 'foo', bar: [1, 2] } 81 | end 82 | 83 | class Host 84 | include Surrealist 85 | 86 | json_schema do 87 | { name: String } 88 | end 89 | 90 | def name 91 | 'Parent' 92 | end 93 | end 94 | 95 | class Guest < Host 96 | delegate_surrealization_to Host 97 | 98 | def name 99 | 'Child' 100 | end 101 | 102 | # expecting: { name: 'Child' } 103 | end 104 | 105 | class FriendOfGuest < Guest 106 | def name 107 | 'Friend' 108 | end 109 | end 110 | 111 | class Invite < Host; end 112 | 113 | class InvitedGuest < Invite 114 | delegate_surrealization_to Host 115 | 116 | def name 117 | 'Invited' 118 | end 119 | 120 | # expecting: { name: 'Invited' } 121 | end 122 | 123 | class RandomClass 124 | include Surrealist 125 | 126 | json_schema do 127 | { name: String } 128 | end 129 | end 130 | 131 | class DifferentClass 132 | include Surrealist 133 | 134 | delegate_surrealization_to RandomClass 135 | 136 | def name 137 | 'smth' 138 | end 139 | 140 | # expecting: { name: 'smth' } 141 | end 142 | 143 | class Vegetable; include Surrealist; end 144 | 145 | class Potato < Vegetable 146 | delegate_surrealization_to Host 147 | 148 | def name 149 | 'Potato' 150 | end 151 | # expecting: { name: 'Potato' } 152 | end 153 | 154 | class Chips < Potato 155 | def name 156 | 'Lays' 157 | end 158 | # expecting: Surrealist::UnknownSchemaError 159 | end 160 | 161 | class ComplexNumber < Surrealist::Serializer 162 | json_schema do 163 | { 164 | real: Integer, 165 | imaginary: Integer, 166 | } 167 | end 168 | end 169 | 170 | class DeepHash < Surrealist::Serializer 171 | json_schema do 172 | { 173 | list: Array, 174 | nested: { 175 | left: String, 176 | right: Integer, 177 | }, 178 | } 179 | end 180 | end 181 | 182 | class HashRoot < Surrealist::Serializer 183 | json_schema do 184 | { nested: ComplexNumber.defined_schema } 185 | end 186 | end 187 | 188 | RSpec.describe Surrealist do 189 | describe '#build_schema' do 190 | context 'with hash arg' do 191 | specify do 192 | expect(ComplexNumber.new({ real: 1, imaginary: 2 }).build_schema) 193 | .to eq(real: 1, imaginary: 2) 194 | end 195 | end 196 | 197 | context 'deep hash arg' do 198 | specify do 199 | expect(DeepHash.new({ list: [1, 2], nested: { right: 4, left: 'three' } }).build_schema) 200 | .to eq(list: [1, 2], nested: { right: 4, left: 'three' }) 201 | end 202 | end 203 | 204 | context 'root hash with object inside' do 205 | specify do 206 | expect(HashRoot.new({ nested: OpenStruct.new(real: 1, imaginary: -1) }).build_schema) 207 | .to eq(nested: { real: 1, imaginary: -1 }) 208 | end 209 | end 210 | 211 | context 'with defined schema' do 212 | context 'with correct types' do 213 | it 'works' do 214 | expect(Note.new.build_schema).to eq(foo: 4, bar: [1, 3, 5], nested: { 215 | left: 'left', right: true 216 | }) 217 | end 218 | end 219 | 220 | context 'with wrong types' do 221 | it 'raises TypeError' do 222 | expect { IncorrectTypes.new.build_schema } 223 | .to raise_error(Surrealist::InvalidTypeError, 224 | 'Wrong type for key `foo`. Expected Integer, got String.') 225 | end 226 | end 227 | 228 | context 'with inheritance' do 229 | it 'works' do 230 | expect(Infant.new.build_schema).to eq(foo: 'foo', bar: [1, 2]) 231 | end 232 | end 233 | end 234 | 235 | context 'with delegated schema' do 236 | it 'works' do 237 | expect(Guest.new.build_schema).to eq(name: 'Child') 238 | end 239 | 240 | context 'inheritance of class that has delegated but we don\'t delegate' do 241 | it 'raises RuntimeError' do 242 | expect { FriendOfGuest.new.build_schema } 243 | .to raise_error(Surrealist::UnknownSchemaError, 244 | "Can't serialize FriendOfGuest - no schema was provided.") 245 | end 246 | end 247 | 248 | context 'inheritance of class that has not delegated but we delegate' do 249 | it 'uses current delegation' do 250 | expect(InvitedGuest.new.build_schema).to eq(name: 'Invited') 251 | end 252 | end 253 | 254 | context 'with invalid klass' do 255 | it 'raises RuntimeError' do 256 | expect do 257 | eval 'class IncorrectGuest < Host 258 | delegate_surrealization_to Integer 259 | end' 260 | end 261 | .to raise_error(Surrealist::InvalidSchemaDelegation, 262 | 'Class does not include Surrealist') 263 | end 264 | end 265 | 266 | context 'with invalid argument type' do 267 | it 'raises TypeError' do 268 | expect do 269 | eval "class InvalidGuest < Host 270 | delegate_surrealization_to 'InvalidHost' 271 | end" 272 | end 273 | .to raise_error(TypeError, 274 | 'Expected type of Class got String instead') 275 | end 276 | end 277 | 278 | context 'with unrelated class' do 279 | it 'works' do 280 | expect(DifferentClass.new.build_schema).to eq(name: 'smth') 281 | end 282 | end 283 | 284 | context 'when parent class includes surrealist, but delegation is not specified' do 285 | it 'raises RuntimeError' do 286 | expect { Chips.new.surrealize } 287 | .to raise_error(Surrealist::UnknownSchemaError, 288 | "Can't serialize Chips - no schema was provided.") 289 | end 290 | end 291 | end 292 | end 293 | end 294 | -------------------------------------------------------------------------------- /spec/builder_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Table 4 | include Surrealist 5 | 6 | def foo 7 | 'A string' 8 | end 9 | 10 | def bar 11 | [1, 2, 4] 12 | end 13 | 14 | def baz 15 | { key: :value } 16 | end 17 | 18 | def struct 19 | Struct.new(:foo, :bar).new(42, [1]) 20 | end 21 | 22 | def multi_struct 23 | Struct.new(:foo, :bar).new(42, Struct.new(:baz).new([1])) 24 | end 25 | end 26 | 27 | RSpec.describe Surrealist::Builder do 28 | subject(:result) { described_class.new(carrier, schema, instance).call } 29 | 30 | let(:instance) { Table.new } 31 | let(:carrier) { nil } 32 | 33 | context 'valid schema is passed' do 34 | let(:schema) { { foo: String, bar: Array } } 35 | 36 | it 'returns hash with correct values' do 37 | is_expected.to eq(foo: 'A string', bar: [1, 2, 4]) 38 | end 39 | 40 | context 'with hash as a type' do 41 | let(:schema) { { foo: String, baz: Hash } } 42 | 43 | it 'returns hash with correct values' do 44 | is_expected.to eq(foo: 'A string', baz: { key: :value }) 45 | end 46 | end 47 | 48 | context 'with nested values' do 49 | let(:schema) { { foo: String, nested: { bar: Array, again: { baz: Hash } } } } 50 | 51 | it 'returns hash with correct values' do 52 | is_expected.to eq(foo: 'A string', nested: { bar: [1, 2, 4], again: { baz: { key: :value } } }) 53 | end 54 | end 55 | 56 | context 'with nested objects' do 57 | let(:schema) { { foo: String, struct: { foo: Integer, bar: Array } } } 58 | 59 | it 'invokes nested methods on the object' do 60 | is_expected.to eq(foo: 'A string', struct: { foo: 42, bar: [1] }) 61 | end 62 | end 63 | 64 | context 'with multi-nested objects' do 65 | let(:schema) { { foo: String, multi_struct: { foo: Integer, bar: { baz: Array } } } } 66 | 67 | it 'invokes nested methods on the objects' do 68 | is_expected.to eq(foo: 'A string', multi_struct: { foo: 42, bar: { baz: [1] } }) 69 | end 70 | end 71 | end 72 | 73 | context 'invalid schema is passed' do 74 | context 'with undefined method' do 75 | let(:schema) { { not_a_method: String } } 76 | let(:message) { /undefined method `not_a_method'.* You have probably defined a key in the schema that doesn't have a corresponding method/ } # rubocop:disable Layout/LineLength 77 | 78 | it 'raises UndefinedMethodError' do 79 | expect { result }.to raise_error(Surrealist::UndefinedMethodError, message) 80 | end 81 | end 82 | 83 | context 'with invalid types' do 84 | let(:schema) { { foo: Integer, bar: String } } 85 | 86 | it 'raises TypeError' do 87 | expect { result } 88 | .to raise_error(TypeError, 'Wrong type for key `foo`. Expected Integer, got String.') 89 | end 90 | end 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /spec/carrier_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Surrealist::Carrier do 4 | describe '#call' do 5 | context 'invalid arguments' do 6 | INVALID_PARAMS.each do |hsh| 7 | it "raises ArgumentError with #{hsh} params" do 8 | expect { described_class.call(**hsh) }.to raise_error(ArgumentError, /Expected.*to be.*, got/) 9 | end 10 | end 11 | end 12 | 13 | context 'valid arguments' do 14 | VALID_PARAMS.each do |hsh| 15 | result = described_class.call(**hsh) 16 | %i[camelize include_namespaces include_root namespaces_nesting_level root].each do |method| 17 | it "stores #{method} in Carrier and returns self for #{hsh}" do 18 | expect(result).to be_a(Surrealist::Carrier) 19 | expect(result.send(method)).to eq(hsh[method]) 20 | end 21 | end 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/config_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../lib/surrealist' 4 | 5 | TestClass = Class.new do 6 | include Surrealist 7 | 8 | json_schema { { id_num: Integer } } 9 | 10 | def id_num 11 | 2 12 | end 13 | end 14 | 15 | RSpec.describe Surrealist do 16 | let(:instance) { TestClass.new } 17 | after { Surrealist.configure(nil) } 18 | 19 | describe '.configure(hash)' do 20 | before { Surrealist.configure(nil) } 21 | 22 | context 'without config' do 23 | it { expect(instance.build_schema(root: :test)).to eq(test: { id_num: 2 }) } 24 | end 25 | 26 | context 'with config' do 27 | before { Surrealist.configure(root: :nope, camelize: true) } 28 | 29 | it 'applies config' do 30 | expect(instance.build_schema).to eq(nope: { idNum: 2 }) 31 | end 32 | 33 | it 'merges arguments' do 34 | expect(instance.build_schema(root: :test)).to eq(test: { idNum: 2 }) 35 | end 36 | 37 | it 'applies to other classes as well' do 38 | New = Class.new do 39 | include Surrealist 40 | 41 | json_schema { { one_two: String } } 42 | 43 | def one_two 44 | '12' 45 | end 46 | end 47 | 48 | expect(New.new.build_schema).to eq(nope: { oneTwo: '12' }) 49 | end 50 | 51 | it 'is accessible as .config()' do 52 | expect(Surrealist.config).to eq(camelize: true, root: :nope) 53 | end 54 | end 55 | end 56 | 57 | describe '.configure { }' do 58 | context 'with config' do 59 | before { Surrealist.configure { |c| c.root = :new } } 60 | 61 | it 'applies config' do 62 | expect(instance.build_schema).to eq(new: { id_num: 2 }) 63 | end 64 | 65 | it 'merges arguments' do 66 | expect(instance.build_schema(root: :test)).to eq(test: { id_num: 2 }) 67 | end 68 | 69 | it 'applies to other classes as well' do 70 | Loop = Class.new do 71 | include Surrealist 72 | 73 | json_schema { { one_two: String } } 74 | 75 | def one_two 76 | '12' 77 | end 78 | end 79 | 80 | expect(Loop.new.build_schema).to eq(new: { one_two: '12' }) 81 | end 82 | 83 | it 'is accessible as .config() (with all other args merged)' do 84 | expect(Surrealist.config) 85 | .to eq(camelize: false, include_root: false, include_namespaces: false, 86 | root: :new, namespaces_nesting_level: 666) 87 | end 88 | end 89 | end 90 | 91 | describe '.configure { } && .configure(hash)' do 92 | before { Surrealist.configure { |c| c.root = :new } } 93 | 94 | describe 'last write wins' do 95 | before { Surrealist.configure(root: :nope, camelize: true) } 96 | 97 | it { expect(instance.build_schema).to eq(nope: { idNum: 2 }) } 98 | end 99 | end 100 | 101 | describe '.configure(hash) && .configure { }' do 102 | before { Surrealist.configure(root: :nope, camelize: true) } 103 | 104 | describe 'last write wins' do 105 | before { Surrealist.configure { |c| c.root = :new } } 106 | 107 | it { expect(instance.build_schema).to eq(new: { id_num: 2 }) } 108 | end 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /spec/copier_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Surrealist::Copier do 4 | describe '#deep_copy' do 5 | let(:object) { { animal: { kind: 'dog', name: 'Rusty' } } } 6 | 7 | it_behaves_like 'hash is cloned deeply and it`s structure is not changed' do 8 | let(:copy) { Surrealist::Copier.deep_copy(object) } 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/dry_types/invalid_dry_types_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'date' 4 | 5 | class ExampleClass; end 6 | 7 | RSpec.describe 'Dry-types with invalid scenarios' do 8 | shared_examples 'type error is raised' do 9 | it { expect { instance.build_schema }.to raise_error(Surrealist::InvalidTypeError) } 10 | end 11 | 12 | context 'with strict types' do 13 | context 'nil' do 14 | let(:instance) do 15 | Class.new(Object) do 16 | include Surrealist 17 | json_schema { { a_nil: Types::Strict::Nil } } 18 | 19 | def a_nil; 23; end 20 | end.new 21 | end 22 | 23 | it_behaves_like 'type error is raised' 24 | end 25 | 26 | context 'symbol' do 27 | let(:instance) do 28 | Class.new(Object) do 29 | include Surrealist 30 | json_schema { { a_symbol: Types::Strict::Symbol } } 31 | 32 | def a_symbol; 23; end 33 | end.new 34 | end 35 | 36 | it_behaves_like 'type error is raised' 37 | end 38 | 39 | context 'class' do 40 | let(:instance) do 41 | Class.new(Object) do 42 | include Surrealist 43 | json_schema { { a_class: Types::Strict::Class } } 44 | 45 | def a_class; 23; end 46 | end.new 47 | end 48 | 49 | it_behaves_like 'type error is raised' 50 | end 51 | 52 | context 'true' do 53 | let(:instance) do 54 | Class.new(Object) do 55 | include Surrealist 56 | json_schema { { a_true: Types::Strict::True } } 57 | 58 | def a_true; 23; end 59 | end.new 60 | end 61 | 62 | it_behaves_like 'type error is raised' 63 | end 64 | 65 | context 'false' do 66 | let(:instance) do 67 | Class.new(Object) do 68 | include Surrealist 69 | json_schema { { a_false: Types::Strict::False } } 70 | 71 | def a_false; 23; end 72 | end.new 73 | end 74 | 75 | it_behaves_like 'type error is raised' 76 | end 77 | 78 | context 'bool' do 79 | let(:instance) do 80 | Class.new(Object) do 81 | include Surrealist 82 | json_schema { { a_false: Types::Strict::Bool } } 83 | 84 | def a_false; 23; end 85 | end.new 86 | end 87 | 88 | it_behaves_like 'type error is raised' 89 | end 90 | 91 | context 'int' do 92 | let(:instance) do 93 | Class.new(Object) do 94 | include Surrealist 95 | json_schema { { an_int: Types::Strict::Integer } } 96 | 97 | def an_int; '23'; end 98 | end.new 99 | end 100 | 101 | it_behaves_like 'type error is raised' 102 | end 103 | 104 | context 'float' do 105 | let(:instance) do 106 | Class.new(Object) do 107 | include Surrealist 108 | json_schema { { a_float: Types::Strict::Float } } 109 | 110 | def a_float; 23; end 111 | end.new 112 | end 113 | 114 | it_behaves_like 'type error is raised' 115 | end 116 | 117 | context 'decimal' do 118 | let(:instance) do 119 | Class.new(Object) do 120 | include Surrealist 121 | json_schema { { a_decimal: Types::Strict::Decimal } } 122 | 123 | def a_decimal; 23; end 124 | end.new 125 | end 126 | 127 | it_behaves_like 'type error is raised' 128 | end 129 | 130 | context 'string' do 131 | let(:instance) do 132 | Class.new(Object) do 133 | include Surrealist 134 | json_schema { { a_string: Types::Strict::String } } 135 | 136 | def a_string; 23; end 137 | end.new 138 | end 139 | 140 | it_behaves_like 'type error is raised' 141 | end 142 | 143 | context 'array' do 144 | let(:instance) do 145 | Class.new(Object) do 146 | include Surrealist 147 | json_schema { { an_array: Types::Strict::Array } } 148 | 149 | def an_array; 23; end 150 | end.new 151 | end 152 | 153 | it_behaves_like 'type error is raised' 154 | end 155 | 156 | context 'hash' do 157 | let(:instance) do 158 | Class.new(Object) do 159 | include Surrealist 160 | json_schema { { a_hash: Types::Strict::Hash } } 161 | 162 | def a_hash; 23; end 163 | end.new 164 | end 165 | 166 | it_behaves_like 'type error is raised' 167 | end 168 | end 169 | 170 | context 'with constrainted types' do 171 | context 'integer' do 172 | let(:instance) do 173 | Class.new(Object) do 174 | include Surrealist 175 | json_schema { { an_int: Types::Strict::Integer.constrained(gteq: 29) } } 176 | 177 | def an_int; 23; end 178 | end.new 179 | end 180 | 181 | it_behaves_like 'type error is raised' 182 | end 183 | 184 | context 'string - format' do 185 | let(:instance) do 186 | Class.new do 187 | include Surrealist 188 | json_schema do 189 | { 190 | a_string: Types::Strict::String.constrained( 191 | format: /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z]+)*\.[a-z]+\z/i, 192 | ), 193 | } 194 | end 195 | 196 | def a_string; 'johndoeATemail.com'; end 197 | end.new 198 | end 199 | 200 | it_behaves_like 'type error is raised' 201 | end 202 | 203 | context 'string - size' do 204 | let(:instance) do 205 | Class.new do 206 | include Surrealist 207 | json_schema { { a_string: Types::Strict::String.constrained(min_size: 3) } } 208 | 209 | def a_string; '2'; end 210 | end.new 211 | end 212 | 213 | it_behaves_like 'type error is raised' 214 | end 215 | end 216 | end 217 | -------------------------------------------------------------------------------- /spec/dry_types/valid_dry_types_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'date' 4 | 5 | class ExampleClass; end 6 | 7 | class ValidWithBaseTypes 8 | include Surrealist 9 | 10 | json_schema do 11 | { 12 | an_any: Types::Any, 13 | a_nil: Types::Nil, 14 | a_symbol: Types::Symbol, 15 | a_class: Types::Class, 16 | a_true: Types::True, 17 | a_false: Types::False, 18 | a_bool: Types::Bool, 19 | an_int: Types::Integer, 20 | a_float: Types::Float, 21 | a_decimal: Types::Decimal, 22 | a_string: Types::String, 23 | an_array: Types::Array, 24 | a_hash: Types::Hash, 25 | times: { 26 | a_date: Types::Date, 27 | a_date_time: Types::DateTime, 28 | a_time: Types::Time, 29 | }, 30 | } 31 | end 32 | 33 | def an_any; 'smth'; end 34 | def a_nil; end 35 | def a_symbol; :a; end 36 | def a_class; ExampleClass; end 37 | def a_true; true; end 38 | def a_false; false; end 39 | def a_bool; true; end 40 | def an_int; 42; end 41 | def a_float; 42.5; end 42 | def a_decimal; BigDecimal(23); end 43 | def a_string; 'string'; end 44 | def a_date; Date.new(42); end 45 | def a_date_time; DateTime.new(42); end 46 | def a_time; Time.new(42); end 47 | def an_array; [1, 2, 3]; end 48 | def a_hash; { key: :value }; end 49 | end 50 | 51 | class ValidWithStrictTypes 52 | include Surrealist 53 | 54 | json_schema do 55 | { 56 | a_nil: Types::Strict::Nil, 57 | a_symbol: Types::Strict::Symbol, 58 | a_class: Types::Strict::Class, 59 | a_true: Types::Strict::True, 60 | a_false: Types::Strict::False, 61 | a_bool: Types::Strict::Bool, 62 | an_int: Types::Strict::Integer, 63 | a_float: Types::Strict::Float, 64 | a_decimal: Types::Strict::Decimal, 65 | a_string: Types::Strict::String, 66 | an_array: Types::Strict::Array, 67 | a_hash: Types::Strict::Hash, 68 | times: { 69 | a_date: Types::Strict::Date, 70 | a_date_time: Types::Strict::DateTime, 71 | a_time: Types::Strict::Time, 72 | }, 73 | } 74 | end 75 | 76 | def a_nil; end 77 | def a_symbol; :a; end 78 | def a_class; ExampleClass; end 79 | def a_true; true; end 80 | def a_false; false; end 81 | def a_bool; true; end 82 | def an_int; 42; end 83 | def a_float; 42.5; end 84 | def a_decimal; BigDecimal(23); end 85 | def a_string; 'string'; end 86 | def a_date; Date.new(42); end 87 | def a_date_time; DateTime.new(42); end 88 | def a_time; Time.new(42); end 89 | def an_array; [1, 2, 3]; end 90 | def a_hash; { key: :value }; end 91 | end 92 | 93 | class WithValidCoercibleTypes 94 | include Surrealist 95 | 96 | json_schema do 97 | { 98 | a_string: Types::Coercible::String, 99 | an_int: Types::Coercible::Integer, 100 | a_float: Types::Coercible::Float, 101 | a_decimal: Types::Coercible::Decimal, 102 | an_array: Types::Coercible::Array, 103 | a_hash: Types::Coercible::Hash, 104 | } 105 | end 106 | 107 | def a_string; 42; end 108 | def an_int; '42'; end 109 | def a_float; '43.6'; end 110 | def a_decimal; BigDecimal('23'); end 111 | def an_array; '[1, 2, 3]'; end 112 | def a_hash; []; end 113 | end 114 | 115 | class WithValidJsonTypes 116 | include Surrealist 117 | 118 | json_schema do 119 | { 120 | a_nil: Types::JSON::Nil, 121 | a_date: Types::JSON::Date, 122 | a_date_time: Types::JSON::DateTime, 123 | a_time: Types::JSON::Time, 124 | a_decimal: Types::JSON::Decimal, 125 | an_array: Types::JSON::Array, 126 | a_hash: Types::JSON::Hash, 127 | } 128 | end 129 | 130 | def a_nil; nil; end 131 | def a_date; Date.new(42); end 132 | def a_date_time; DateTime.new(42); end 133 | def a_time; Time.new(42); end 134 | def a_decimal; BigDecimal(23); end 135 | def an_array; []; end 136 | def a_hash; {}; end 137 | end 138 | 139 | class WithValidOptionalAndConstrained 140 | include Surrealist 141 | 142 | json_schema do 143 | { 144 | a_string: Types::String.optional, 145 | an_int: Types::Strict::Integer.constrained(gteq: 18), 146 | } 147 | end 148 | 149 | def a_string; end 150 | def an_int; 32; end 151 | end 152 | 153 | RSpec.describe 'Dry-types with valid scenarios' do 154 | context 'with base types' do 155 | let(:instance) { ValidWithBaseTypes.new } 156 | 157 | it 'builds schema' do 158 | expect(instance.build_schema) 159 | .to eq(an_any: 'smth', a_nil: nil, a_symbol: :a, a_class: ExampleClass, 160 | a_true: true, a_false: false, a_bool: true, an_int: 42, a_float: 42.5, 161 | a_decimal: 23, a_string: 'string', an_array: [1, 2, 3], a_hash: { key: :value }, 162 | times: { 163 | a_date: Date.new(42), a_date_time: DateTime.new(42), 164 | a_time: Time.new(42) 165 | }) 166 | end 167 | 168 | it 'surrealizes' do 169 | expect(JSON.parse(instance.surrealize)) 170 | .to eq('an_any' => 'smth', 'a_nil' => nil, 'a_symbol' => 'a', 'a_class' => 'ExampleClass', 171 | 'a_true' => true, 'a_false' => false, 'a_bool' => true, 'an_int' => 42, 172 | 'a_float' => 42.5, 'a_decimal' => BigDecimal(23).to_s, 'a_string' => 'string', 173 | 'an_array' => [1, 2, 3], 'a_hash' => { 'key' => 'value' }, 'times' => { 174 | 'a_date' => Date.new(42).to_s, 'a_date_time' => DateTime.new(42).strftime('%FT%T.%L%:z'), 175 | 'a_time' => Time.new(42).strftime('%FT%T.%L%:z') 176 | }) 177 | end 178 | 179 | it 'camelizes' do 180 | expect(instance.build_schema(camelize: true)) 181 | .to eq(anAny: 'smth', aNil: nil, aSymbol: :a, aClass: ExampleClass, 182 | aTrue: true, aFalse: false, aBool: true, anInt: 42, aFloat: 42.5, 183 | aDecimal: 23, aString: 'string', anArray: [1, 2, 3], aHash: { key: :value }, 184 | times: { 185 | aDate: Date.new(42), aDateTime: DateTime.new(42), 186 | aTime: Time.new(42) 187 | }) 188 | 189 | expect(JSON.parse(instance.surrealize(camelize: true))) 190 | .to eq('anAny' => 'smth', 'aNil' => nil, 'aSymbol' => 'a', 'aClass' => 'ExampleClass', 191 | 'aTrue' => true, 'aFalse' => false, 'aBool' => true, 'anInt' => 42, 192 | 'aFloat' => 42.5, 'aDecimal' => BigDecimal(23).to_s, 'aString' => 'string', 193 | 'anArray' => [1, 2, 3], 'aHash' => { 'key' => 'value' }, 'times' => { 194 | 'aDate' => Date.new(42).to_s, 'aDateTime' => DateTime.new(42).strftime('%FT%T.%L%:z'), 195 | 'aTime' => Time.new(42).strftime('%FT%T.%L%:z') 196 | }) 197 | end 198 | end 199 | 200 | context 'with strict types' do 201 | let(:instance) { ValidWithStrictTypes.new } 202 | 203 | it 'builds schema' do 204 | expect(instance.build_schema) 205 | .to eq(a_nil: nil, a_symbol: :a, a_class: ExampleClass, a_true: true, a_false: false, 206 | a_bool: true, an_int: 42, a_float: 42.5, a_decimal: 23, a_string: 'string', 207 | an_array: [1, 2, 3], a_hash: { key: :value }, times: { 208 | a_date: Date.new(42), a_date_time: DateTime.new(42), 209 | a_time: Time.new(42) 210 | }) 211 | end 212 | 213 | it 'surrealizes' do 214 | expect(JSON.parse(instance.surrealize)) 215 | .to eq('a_nil' => nil, 'a_symbol' => 'a', 'a_class' => 'ExampleClass', 216 | 'a_true' => true, 'a_false' => false, 'a_bool' => true, 'an_int' => 42, 217 | 'a_float' => 42.5, 'a_decimal' => BigDecimal(23).to_s, 'a_string' => 'string', 218 | 'an_array' => [1, 2, 3], 'a_hash' => { 'key' => 'value' }, 'times' => { 219 | 'a_date' => Date.new(42).to_s, 220 | 'a_date_time' => DateTime.new(42).strftime('%FT%T.%L%:z'), 221 | 'a_time' => Time.new(42).strftime('%FT%T.%L%:z'), 222 | }) 223 | end 224 | 225 | it 'camelizes' do 226 | expect(instance.build_schema(camelize: true)) 227 | .to eq(aNil: nil, aSymbol: :a, aClass: ExampleClass, aTrue: true, aFalse: false, 228 | aBool: true, anInt: 42, aFloat: 42.5, aDecimal: 23, aString: 'string', 229 | anArray: [1, 2, 3], aHash: { key: :value }, times: { 230 | aDate: Date.new(42), aDateTime: DateTime.new(42), 231 | aTime: Time.new(42) 232 | }) 233 | 234 | expect(JSON.parse(instance.surrealize(camelize: true))) 235 | .to eq('aNil' => nil, 'aSymbol' => 'a', 'aClass' => 'ExampleClass', 236 | 'aTrue' => true, 'aFalse' => false, 'aBool' => true, 'anInt' => 42, 237 | 'aFloat' => 42.5, 'aDecimal' => BigDecimal(23).to_s, 'aString' => 'string', 238 | 'anArray' => [1, 2, 3], 'aHash' => { 'key' => 'value' }, 'times' => { 239 | 'aDate' => Date.new(42).to_s, 'aDateTime' => DateTime.new(42).strftime('%FT%T.%L%:z'), 240 | 'aTime' => Time.new(42).strftime('%FT%T.%L%:z') 241 | }) 242 | end 243 | end 244 | 245 | context 'with coercible types' do 246 | let(:instance) { WithValidCoercibleTypes.new } 247 | 248 | it 'builds schema' do 249 | expect(instance.build_schema) 250 | .to eq(a_string: '42', an_int: 42, a_float: 43.6, a_decimal: BigDecimal(23), 251 | an_array: ['[1, 2, 3]'], a_hash: {}) 252 | end 253 | 254 | it 'surrealizes' do 255 | expect(JSON.parse(instance.surrealize)) 256 | .to eq('a_string' => '42', 'an_int' => 42, 'a_float' => 43.6, 257 | 'a_decimal' => BigDecimal(23).to_s, 'an_array' => ['[1, 2, 3]'], 'a_hash' => {}) 258 | end 259 | 260 | it 'camelizes' do 261 | expect(instance.build_schema(camelize: true)) 262 | .to eq(aString: '42', anInt: 42, aFloat: 43.6, aDecimal: BigDecimal(23), 263 | anArray: ['[1, 2, 3]'], aHash: {}) 264 | 265 | expect(JSON.parse(instance.surrealize(camelize: true))) 266 | .to eq('aString' => '42', 'anInt' => 42, 'aFloat' => 43.6, 267 | 'aDecimal' => BigDecimal(23).to_s, 'anArray' => ['[1, 2, 3]'], 'aHash' => {}) 268 | end 269 | end 270 | 271 | context 'with json types' do 272 | let(:instance) { WithValidJsonTypes.new } 273 | 274 | it 'builds schema' do 275 | expect(instance.build_schema) 276 | .to eq(a_nil: nil, a_date: Date.new(42), a_date_time: DateTime.new(42), 277 | a_time: Time.new(42), a_decimal: BigDecimal(23), an_array: [], a_hash: {}) 278 | end 279 | 280 | it 'surrealizes' do 281 | expect(JSON.parse(instance.surrealize)) 282 | .to eq('a_nil' => nil, 'a_date' => Date.new(42).to_s, 283 | 'a_date_time' => DateTime.new(42).strftime('%FT%T.%L%:z'), 284 | 'a_time' => Time.new(42).strftime('%FT%T.%L%:z'), 'a_decimal' => BigDecimal(23).to_s, 285 | 'an_array' => [], 'a_hash' => {}) 286 | end 287 | 288 | it 'camelizes' do 289 | expect(instance.build_schema(camelize: true)) 290 | .to eq(aNil: nil, aDate: Date.new(42), aDateTime: DateTime.new(42), 291 | aTime: Time.new(42), aDecimal: BigDecimal(23), anArray: [], aHash: {}) 292 | 293 | expect(JSON.parse(instance.surrealize(camelize: true))) 294 | .to eq('aNil' => nil, 'aDate' => Date.new(42).to_s, 295 | 'aDateTime' => DateTime.new(42).strftime('%FT%T.%L%:z'), 296 | 'aTime' => Time.new(42).strftime('%FT%T.%L%:z'), 'aDecimal' => BigDecimal(23).to_s, 297 | 'anArray' => [], 'aHash' => {}) 298 | end 299 | end 300 | 301 | context 'with optional & constrained types' do 302 | let(:instance) { WithValidOptionalAndConstrained.new } 303 | 304 | it 'builds schema' do 305 | expect(instance.build_schema).to eq(a_string: nil, an_int: 32) 306 | end 307 | 308 | it 'surrealizes' do 309 | expect(JSON.parse(instance.surrealize)).to eq('a_string' => nil, 'an_int' => 32) 310 | end 311 | 312 | it 'camelizes' do 313 | expect(instance.build_schema(camelize: true)).to eq(aString: nil, anInt: 32) 314 | expect(JSON.parse(instance.surrealize(camelize: true))).to eq('aString' => nil, 'anInt' => 32) 315 | end 316 | end 317 | end 318 | -------------------------------------------------------------------------------- /spec/exception_raiser_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Surrealist::ExceptionRaiser do 4 | describe '.raise_invalid_key!' do 5 | let(:backtrace) { %w[a b c] } 6 | 7 | it 'preserves exception backtrace and displays correct message' do 8 | raise NoMethodError, 'my error message', backtrace 9 | rescue NoMethodError => e 10 | begin 11 | described_class.raise_invalid_key!(e) 12 | rescue Surrealist::UndefinedMethodError => e 13 | expect(e.message).to eq( 14 | "my error message. " \ 15 | "You have probably defined a key " \ 16 | "in the schema that doesn't have a corresponding method.", 17 | ) 18 | expect(e.backtrace).to eq(backtrace) 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /spec/hash_utils_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Surrealist::HashUtils do 4 | describe '#camelize_hash' do 5 | subject(:camelized_hash) { described_class.camelize_hash(hash) } 6 | 7 | context 'not nested hash' do 8 | let(:hash) { { snake_key: 'some value' } } 9 | 10 | it { expect(camelized_hash.keys.first).to eq(:snakeKey) } 11 | end 12 | 13 | context 'nested hash' do 14 | let(:hash) { { snake_key: { nested_key: { one_more_level: true } } } } 15 | 16 | it 'camelizes hash recursively' do 17 | expect(camelized_hash).to eq(snakeKey: { nestedKey: { oneMoreLevel: true } }) 18 | end 19 | end 20 | 21 | context 'mixed symbols and string' do 22 | let(:hash) { { snake_key: { 'nested_key' => { 'one_more_level': true } } } } 23 | 24 | it 'camelizes hash recursively' do 25 | expect(camelized_hash).to eq(snakeKey: { 'nestedKey' => { 'oneMoreLevel': true } }) 26 | end 27 | end 28 | 29 | context 'array as hash key' do 30 | let(:hash) { { ['some_key'] => 'value' } } 31 | 32 | it { expect(camelized_hash.keys.first).to eq(['some_key']) } 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /spec/helper_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Surrealist::Helper do 4 | describe 'serializing a single struct' do 5 | let(:person) { Struct.new(:name, :other_param).new('John', 'Dow') } 6 | 7 | specify 'a struct is not treated as a collection' do 8 | expect(Surrealist::Helper.collection?(person)).to eq false 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/include_root_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Cat 4 | include Surrealist 5 | 6 | json_schema do 7 | { cat_weight: String } 8 | end 9 | 10 | def cat_weight 11 | '3 kilos' 12 | end 13 | end 14 | 15 | class SeriousCat 16 | include Surrealist 17 | 18 | json_schema do 19 | { 20 | weight: Types::String, 21 | cat_food: { 22 | amount: Types::Strict::Integer, 23 | brand: Types::Strict::String, 24 | }, 25 | } 26 | end 27 | 28 | def weight 29 | '3 kilos' 30 | end 31 | 32 | def cat_food 33 | Struct.new(:amount, :brand).new(3, 'Whiskas') 34 | end 35 | end 36 | 37 | class Animal 38 | class Dog 39 | include Surrealist 40 | 41 | json_schema do 42 | { breed: String } 43 | end 44 | 45 | def breed 46 | 'Collie' 47 | end 48 | end 49 | end 50 | 51 | module Instrument 52 | class Guitar 53 | include Surrealist 54 | 55 | json_schema do 56 | { brand_name: Types::Strict::String } 57 | end 58 | 59 | def brand_name 60 | 'Fender' 61 | end 62 | end 63 | end 64 | 65 | class Code 66 | class Language 67 | class Ruby 68 | include Surrealist 69 | 70 | json_schema do 71 | { age: Types::String } 72 | end 73 | 74 | def age 75 | '22 years' 76 | end 77 | end 78 | end 79 | end 80 | 81 | class Matryoshka 82 | include Surrealist 83 | 84 | json_schema do 85 | { matryoshka: Integer, color: String } 86 | end 87 | 88 | def matryoshka 89 | 10 90 | end 91 | 92 | def color 93 | 'blue' 94 | end 95 | end 96 | 97 | RSpec.describe Surrealist do 98 | describe 'include_root option' do 99 | context 'simple example' do 100 | let(:instance) { Cat.new } 101 | 102 | it 'builds schema' do 103 | expect(instance.build_schema(include_root: true)).to eq(cat: { cat_weight: '3 kilos' }) 104 | end 105 | 106 | it 'surrealizes' do 107 | expect(JSON.parse(instance.surrealize(include_root: true))) 108 | .to eq('cat' => { 'cat_weight' => '3 kilos' }) 109 | end 110 | 111 | it 'camelizes' do 112 | expect(instance.build_schema(include_root: true, camelize: true)) 113 | .to eq(cat: { catWeight: '3 kilos' }) 114 | 115 | expect(JSON.parse(instance.surrealize(include_root: true, camelize: true))) 116 | .to eq('cat' => { 'catWeight' => '3 kilos' }) 117 | end 118 | end 119 | 120 | context 'with nested objects' do 121 | let(:instance) { SeriousCat.new } 122 | 123 | it 'builds schema' do 124 | expect(instance.build_schema(include_root: true)) 125 | .to eq(serious_cat: { weight: '3 kilos', cat_food: { amount: 3, brand: 'Whiskas' } }) 126 | end 127 | 128 | it 'surrealizes' do 129 | expect(JSON.parse(instance.surrealize(include_root: true))) 130 | .to eq('serious_cat' => { 'weight' => '3 kilos', 131 | 'cat_food' => { 'amount' => 3, 'brand' => 'Whiskas' } }) 132 | end 133 | 134 | it 'camelizes' do 135 | expect(instance.build_schema(include_root: true, camelize: true)) 136 | .to eq(seriousCat: { weight: '3 kilos', catFood: { amount: 3, brand: 'Whiskas' } }) 137 | 138 | expect(JSON.parse(instance.surrealize(include_root: true, camelize: true))) 139 | .to eq('seriousCat' => { 'weight' => '3 kilos', 140 | 'catFood' => { 'amount' => 3, 'brand' => 'Whiskas' } }) 141 | end 142 | end 143 | 144 | context 'with nested classes' do 145 | let(:instance) { Animal::Dog.new } 146 | 147 | it 'builds schema' do 148 | expect(instance.build_schema(include_root: true)).to eq(dog: { breed: 'Collie' }) 149 | end 150 | 151 | it 'surrealizes' do 152 | expect(JSON.parse(instance.surrealize(include_root: true))) 153 | .to eq('dog' => { 'breed' => 'Collie' }) 154 | end 155 | 156 | it 'camelizes' do 157 | expect(instance.build_schema(include_root: true, camelize: true)) 158 | .to eq(dog: { breed: 'Collie' }) 159 | 160 | expect(JSON.parse(instance.surrealize(include_root: true, camelize: true))) 161 | .to eq('dog' => { 'breed' => 'Collie' }) 162 | end 163 | end 164 | 165 | context 'Module::Class' do 166 | let(:instance) { Instrument::Guitar.new } 167 | 168 | it 'builds schema' do 169 | expect(instance.build_schema(include_root: true)) 170 | .to eq(guitar: { brand_name: 'Fender' }) 171 | end 172 | 173 | it 'surrealizes' do 174 | expect(JSON.parse(instance.surrealize(include_root: true))) 175 | .to eq('guitar' => { 'brand_name' => 'Fender' }) 176 | end 177 | 178 | it 'camelizes' do 179 | expect(instance.build_schema(include_root: true, camelize: true)) 180 | .to eq(guitar: { brandName: 'Fender' }) 181 | 182 | expect(JSON.parse(instance.surrealize(include_root: true, camelize: true))) 183 | .to eq('guitar' => { 'brandName' => 'Fender' }) 184 | end 185 | end 186 | 187 | context 'triple nesting' do 188 | let(:instance) { Code::Language::Ruby.new } 189 | 190 | it 'builds schema' do 191 | expect(instance.build_schema(include_root: true)) 192 | .to eq(ruby: { age: '22 years' }) 193 | end 194 | 195 | it 'surrealizes' do 196 | expect(JSON.parse(instance.surrealize(include_root: true))) 197 | .to eq('ruby' => { 'age' => '22 years' }) 198 | end 199 | 200 | it 'camelizes' do 201 | expect(instance.build_schema(include_root: true, camelize: true)) 202 | .to eq(ruby: { age: '22 years' }) 203 | 204 | expect(JSON.parse(instance.surrealize(include_root: true, camelize: true))) 205 | .to eq('ruby' => { 'age' => '22 years' }) 206 | end 207 | end 208 | 209 | context 'root with same name as one of props' do 210 | let(:instance) { Matryoshka.new } 211 | 212 | it 'builds schema' do 213 | expect(instance.build_schema(include_root: true)) 214 | .to eq(matryoshka: { matryoshka: 10, color: 'blue' }) 215 | end 216 | 217 | it 'surrealizes' do 218 | expect(JSON.parse(instance.surrealize(include_root: true))) 219 | .to eq('matryoshka' => { 'matryoshka' => 10, 'color' => 'blue' }) 220 | end 221 | 222 | it 'camelizes' do 223 | expect(instance.build_schema(include_root: true, camelize: true)) 224 | .to eq(matryoshka: { matryoshka: 10, color: 'blue' }) 225 | 226 | expect(JSON.parse(instance.surrealize(include_root: true, camelize: true))) 227 | .to eq('matryoshka' => { 'matryoshka' => 10, 'color' => 'blue' }) 228 | end 229 | end 230 | end 231 | end 232 | -------------------------------------------------------------------------------- /spec/multiple_serializers_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ShortPostSerializer < Surrealist::Serializer 4 | json_schema do 5 | { 6 | id: Integer, 7 | title: String, 8 | } 9 | end 10 | end 11 | 12 | class FullPostSerializer < Surrealist::Serializer 13 | json_schema do 14 | { 15 | id: Integer, 16 | title: String, 17 | author: { 18 | name: String, 19 | }, 20 | } 21 | end 22 | end 23 | 24 | class Post 25 | include Surrealist 26 | 27 | surrealize_with FullPostSerializer 28 | surrealize_with ShortPostSerializer, tag: :short 29 | 30 | attr_reader :id, :title, :author 31 | 32 | def initialize(id, title, author) 33 | @id = id 34 | @title = title 35 | @author = author 36 | end 37 | end 38 | 39 | RSpec.describe 'Multiple serializers' do 40 | let(:author) { Struct.new(:name).new('John') } 41 | let(:post) { Post.new(1, 'Ruby is dead', author) } 42 | 43 | describe 'single item' do 44 | context 'default' do 45 | let(:expectation) { { id: 1, title: 'Ruby is dead', author: { name: 'John' } } } 46 | 47 | it { expect(post.surrealize).to eq(expectation.to_json) } 48 | end 49 | 50 | context 'specific' do 51 | let(:expectation) { { id: 1, title: 'Ruby is dead' } } 52 | 53 | it { expect(post.surrealize(for: :short)).to eq(expectation.to_json) } 54 | it { expect(post.surrealize(serializer: ShortPostSerializer)).to eq(expectation.to_json) } 55 | end 56 | 57 | context 'unknown tag passed' do 58 | it 'raises error' do 59 | expect { post.surrealize(for: :kek) } 60 | .to raise_error Surrealist::UnknownTagError, 61 | 'The tag specified (kek) has no corresponding serializer' 62 | end 63 | end 64 | end 65 | 66 | describe 'collection' do 67 | let(:collection) { [post, post, post] } 68 | 69 | context 'default' do 70 | let(:expectation) do 71 | [ 72 | { id: 1, title: 'Ruby is dead', author: { name: 'John' } }, 73 | { id: 1, title: 'Ruby is dead', author: { name: 'John' } }, 74 | { id: 1, title: 'Ruby is dead', author: { name: 'John' } }, 75 | ] 76 | end 77 | 78 | it { expect(Surrealist.surrealize_collection(collection)).to eq(expectation.to_json) } 79 | end 80 | 81 | context 'specific' do 82 | let(:expectation) do 83 | [ 84 | { id: 1, title: 'Ruby is dead' }, 85 | { id: 1, title: 'Ruby is dead' }, 86 | { id: 1, title: 'Ruby is dead' }, 87 | ] 88 | end 89 | 90 | let(:json) { Surrealist.surrealize_collection(collection, for: :short) } 91 | let(:explicit_json) { Surrealist.surrealize_collection(collection, serializer: ShortPostSerializer) } 92 | 93 | it { expect(json).to eq(expectation.to_json) } 94 | it { expect(explicit_json).to eq(expectation.to_json) } 95 | end 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /spec/nested_record_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative './orms/active_record/models' 4 | 5 | RSpec.describe Surrealist do 6 | describe 'nested object surrealization' do 7 | context 'object that has self-referencing assocation to surrealize' do 8 | it 'works' do 9 | expect(Executive.first.build_schema.fetch(:assistant).fetch(:executive)) 10 | .to be_nil 11 | end 12 | end 13 | 14 | context 'object that has self-referencing nested association to surrealize' do 15 | it 'works' do 16 | expect(PromKing.first.build_schema.fetch(:prom).fetch(:prom_couple).fetch(:prom_king)) 17 | .to be_nil 18 | end 19 | end 20 | 21 | context 'object that has association to surrealize' do 22 | it 'works' do 23 | expect(Employee.first.build_schema.fetch(:manager).keys) 24 | .to include(:name) 25 | end 26 | end 27 | 28 | context 'nested collection of objects to surrealize' do 29 | let(:subject) { Answer.first.question.build_schema } 30 | 31 | it 'works' do 32 | expect(subject.fetch(:answers)).to be_a Array 33 | expect(subject.fetch(:answers)[0].fetch(:question)).to be_nil 34 | end 35 | end 36 | 37 | context 'collection of objects to surrealize' do 38 | context 'has self-referencing assocations' do 39 | let(:subject) { Surrealist.surrealize_collection(Executive.all) } 40 | 41 | it 'works' do 42 | expect(subject).to match(/"executive":null/) 43 | expect(subject).to match(/"assistant":\{.+?\}/) 44 | end 45 | end 46 | 47 | context 'collection several times' do 48 | before { Book.first.build_schema(for: :awards) } 49 | let(:subject) { Book.first.build_schema(for: :awards) } 50 | 51 | it { expect(subject).to match(awards: Array.new(3) { { title: 'Nobel Prize' } }) } 52 | end 53 | 54 | context 'has associations' do 55 | it 'works' do 56 | expect(Surrealist.surrealize_collection(Employee.all)) 57 | .not_to include('Manager') 58 | end 59 | end 60 | end 61 | 62 | context 'parameters' do 63 | let(:instance) { Employee.first } 64 | 65 | it_behaves_like 'error is raised for invalid params: instance' 66 | it_behaves_like 'error is not raised for valid params: instance' 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /spec/orms/active_record/active_record_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'models' 4 | 5 | RSpec.describe 'ActiveRecord integration' do 6 | collection_scopes = [ 7 | -> { ARScope.coll_where }, 8 | -> { ARScope.coll_where_not }, 9 | -> { ARScope.coll_order }, 10 | -> { ARScope.coll_take }, 11 | -> { ARScope.coll_limit }, 12 | -> { ARScope.coll_offset }, 13 | -> { ARScope.coll_lock }, 14 | -> { ARScope.coll_readonly }, 15 | -> { ARScope.coll_reorder }, 16 | -> { ARScope.coll_distinct }, 17 | -> { ARScope.coll_find_each }, 18 | -> { ARScope.coll_select }, 19 | -> { ARScope.coll_group }, 20 | -> { ARScope.coll_order }, 21 | -> { ARScope.coll_except }, 22 | ] 23 | 24 | record_scopes = [ 25 | -> { ARScope.rec_find_by }, 26 | -> { ARScope.rec_find_by! }, 27 | -> { ARScope.rec_find }, 28 | -> { ARScope.rec_take! }, 29 | -> { ARScope.rec_first }, 30 | -> { ARScope.rec_first! }, 31 | -> { ARScope.rec_second }, 32 | -> { ARScope.rec_second! }, 33 | -> { ARScope.rec_third }, 34 | -> { ARScope.rec_third! }, 35 | -> { ARScope.rec_fourth }, 36 | -> { ARScope.rec_fourth! }, 37 | -> { ARScope.rec_fifth }, 38 | -> { ARScope.rec_fifth! }, 39 | -> { ARScope.rec_forty_two }, 40 | -> { ARScope.rec_forty_two! }, 41 | -> { ARScope.rec_last }, 42 | -> { ARScope.rec_last! }, 43 | ] 44 | 45 | collection_scopes.push(-> { ARScope.coll_extending }) unless ruby25 46 | 47 | unless ruby25 # AR 4.2 doesn't have these methods 48 | record_scopes.push([ 49 | -> { ARScope.rec_third_to_last }, 50 | -> { ARScope.rec_third_to_last! }, 51 | -> { ARScope.rec_second_to_last }, 52 | -> { ARScope.rec_second_to_last! }, 53 | ]) 54 | end 55 | 56 | describe 'Surrealist.surrealize_collection()' do 57 | let(:result) { Surrealist.surrealize_collection(collection) } 58 | let(:parsed_result) { JSON.parse(result) } 59 | 60 | context 'basics' do 61 | let(:collection) { Book.all } 62 | 63 | it 'works with #all' do 64 | expect(parsed_result.length).to eq(3) 65 | expect(parsed_result).to be_an Array 66 | end 67 | end 68 | 69 | context 'inheritance' do 70 | let(:collection) { TestAR.all } 71 | 72 | it 'surrealizes children as well as parents' do 73 | expect(result).to eq( 74 | [ 75 | { name: 'testing active record' }, 76 | { name: 'testing active record inherit' }, 77 | { name: 'testing active record inherit again' }, 78 | ].to_json, 79 | ) 80 | end 81 | 82 | it 'works with nested inheritance' do 83 | expect(Surrealist.surrealize_collection(InheritAR.all)) 84 | .to eq( 85 | [{ name: 'testing active record inherit' }, 86 | { name: 'testing active record inherit again' }].to_json, 87 | ) 88 | 89 | expect(Surrealist.surrealize_collection(InheritAgainAR.all)) 90 | .to eq([{ name: 'testing active record inherit again' }].to_json) 91 | end 92 | 93 | it 'fails with inheritance and without schema' do 94 | expect { Surrealist.surrealize_collection(SchemaLessAR.all) } 95 | .to raise_error Surrealist::UnknownSchemaError, 96 | 'Can\'t serialize SchemaLessAR - no schema was provided.' 97 | end 98 | end 99 | 100 | context 'scopes' do 101 | context 'query methods' do 102 | collection_scopes.flatten.each do |lambda| 103 | it 'works if scope returns a collection of records' do 104 | expect { Surrealist.surrealize_collection(lambda.call) } 105 | .not_to raise_error 106 | end 107 | end 108 | end 109 | 110 | context 'finder methods' do 111 | record_scopes.flatten.each do |lambda| 112 | it 'fails if scope returns a single record' do 113 | expect { Surrealist.surrealize_collection(lambda.call) } 114 | .to raise_error Surrealist::InvalidCollectionError, 115 | 'Can\'t serialize collection - must behave like enumerable' 116 | end 117 | end 118 | end 119 | end 120 | 121 | context 'associations' do 122 | let(:first_book) do 123 | [ 124 | { title: 'The Adventures of Tom Sawyer', 125 | genre: { name: 'Adventures' }, 126 | awards: [ 127 | { title: 'Nobel Prize', id: 1 }, 128 | { title: 'Nobel Prize', id: 4 }, 129 | { title: 'Nobel Prize', id: 7 }, 130 | ] }, 131 | ] 132 | end 133 | 134 | context 'has one' do 135 | let(:collection) { Book.joins(:publisher).limit(1) } 136 | 137 | it 'raises exception on single record reference' do 138 | expect { Surrealist.surrealize_collection(Book.first.publisher) } 139 | .to raise_error Surrealist::InvalidCollectionError 140 | end 141 | 142 | it 'works with query methods that return relations' do 143 | expect(result).to eq(first_book.to_json) 144 | end 145 | end 146 | 147 | context 'belongs to' do 148 | let(:collection) { Book.joins(:genre).limit(1) } 149 | 150 | it 'raises exception on single record reference' do 151 | expect { Surrealist.surrealize_collection(Book.first.genre) } 152 | .to raise_error Surrealist::InvalidCollectionError 153 | end 154 | 155 | it 'works with query methods that return relations' do 156 | expect(result).to eq(first_book.to_json) 157 | end 158 | end 159 | 160 | context 'has_many' do 161 | let(:collection) { Book.first.awards } 162 | 163 | it 'works' do 164 | expect(result).to eq(Array.new(3) { { title: 'Nobel Prize' } }.to_json) 165 | end 166 | end 167 | 168 | context 'has and belongs to many' do 169 | it 'works both ways' do 170 | expect(Surrealist.surrealize_collection(Book.second.authors)) 171 | .to eq([{ name: 'Jerome' }].to_json) 172 | 173 | expect(Surrealist.surrealize_collection(Author.first.books)) 174 | .to eq(first_book.to_json) 175 | end 176 | end 177 | end 178 | 179 | context 'includes' do 180 | let(:collection) { Book.includes(:authors) } 181 | 182 | it 'works' do 183 | expect(parsed_result.length).to eq(3) 184 | expect(parsed_result).to be_an Array 185 | end 186 | end 187 | 188 | context 'joins' do 189 | let(:collection) { Book.joins(:genre) } 190 | 191 | it 'works' do 192 | expect(JSON.parse(result).length).to eq(3) 193 | expect(JSON.parse(result)).to be_an Array 194 | end 195 | end 196 | 197 | context 'parameters' do 198 | let(:collection) { TestAR.all } 199 | 200 | it_behaves_like 'error is raised for invalid params: collection' 201 | it_behaves_like 'error is not raised for valid params: collection' 202 | end 203 | 204 | context 'not a proper collection' do 205 | it 'fails' do 206 | expect { Surrealist.surrealize_collection(Object) } 207 | .to raise_error(Surrealist::InvalidCollectionError, 208 | 'Can\'t serialize collection - must behave like enumerable') 209 | end 210 | end 211 | 212 | describe 'with serializer defined in a separate class' do 213 | subject(:json) { Surrealist.surrealize_collection(Tree.all) } 214 | let(:expectation) do 215 | [{ name: 'Oak', height: 200, color: 'green' }, 216 | { name: 'Pine', height: 140, color: 'green' }].to_json 217 | end 218 | 219 | it { is_expected.to eq(expectation) } 220 | end 221 | end 222 | 223 | describe 'ActiveRecord instance #surrealize' do 224 | let(:no_method_message) { /undefined .*method `surrealize' for/ } 225 | 226 | context 'scopes' do 227 | context 'query methods' do 228 | error = ruby25 ? NameError : NoMethodError 229 | 230 | collection_scopes.flatten.each do |lambda| 231 | it 'fails if scope returns a collection of records' do 232 | expect { lambda.call.surrealize } 233 | .to raise_error error, no_method_message 234 | end 235 | end 236 | end 237 | 238 | context 'finder methods' do 239 | record_scopes.flatten.each do |lambda| 240 | it 'works if scope returns a single record' do 241 | expect(lambda.call.surrealize).to be_a String 242 | expect(JSON.parse(lambda.call.surrealize)).to have_key('title') 243 | expect(JSON.parse(lambda.call.surrealize)).to have_key('money') 244 | end 245 | end 246 | end 247 | end 248 | 249 | context 'parameters' do 250 | let(:instance) { Book.first.publisher } 251 | 252 | it_behaves_like 'error is raised for invalid params: instance' 253 | it_behaves_like 'error is not raised for valid params: instance' 254 | end 255 | 256 | context 'associations' do 257 | context 'has one' do 258 | it 'works for a single record reference' do 259 | expect(Book.first.publisher.surrealize) 260 | .to eq('{"name":"Cengage Learning"}') 261 | end 262 | 263 | it 'fails with query methods that return relations' do 264 | expect { Book.joins(:publisher).limit(1).surrealize } 265 | .to raise_error NoMethodError, no_method_message 266 | end 267 | end 268 | 269 | context 'belongs to' do 270 | it 'works for a single record reference' do 271 | expect(Book.first.genre.surrealize) 272 | .to eq('{"name":"Adventures"}') 273 | end 274 | 275 | it 'fails with query methods that return relations' do 276 | expect { Book.joins(:genre).limit(1).surrealize } 277 | .to raise_error NoMethodError, no_method_message 278 | end 279 | end 280 | 281 | context 'has_many' do 282 | it 'fails' do 283 | expect { Book.first.awards.surrealize } 284 | .to raise_error NoMethodError, no_method_message 285 | end 286 | end 287 | 288 | context 'has and belongs to many' do 289 | it 'fails both ways' do 290 | expect { Book.second.authors.surrealize } 291 | .to raise_error NoMethodError, no_method_message 292 | 293 | expect { Author.first.books.surrealize } 294 | .to raise_error NoMethodError, no_method_message 295 | end 296 | end 297 | end 298 | 299 | describe 'with serializer defined in a separate class' do 300 | subject(:json) { Tree.find_by(name: 'Oak').surrealize } 301 | let(:expectation) { { name: 'Oak', height: 200, color: 'green' }.to_json } 302 | 303 | it { is_expected.to eq(expectation) } 304 | end 305 | end 306 | end 307 | -------------------------------------------------------------------------------- /spec/orms/active_record/models.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'active_record' 4 | require_relative '../../../lib/surrealist' 5 | 6 | ActiveRecord::Base.establish_connection( 7 | adapter: 'sqlite3', 8 | database: ':memory:', 9 | ) 10 | 11 | ActiveRecord::Migration.verbose = false 12 | 13 | ActiveRecord::Schema.define do 14 | create_table :test_ars do |table| 15 | table.column :name, :string 16 | table.string :type 17 | end 18 | 19 | create_table(:schema_less_ars) { |table| table.column :name, :string } 20 | 21 | create_table(:ar_scopes) do |table| 22 | table.column :title, :string 23 | table.column :money, :int 24 | end 25 | 26 | create_table :books do |table| 27 | table.column :title, :string 28 | table.integer :genre_id 29 | table.integer :author_id 30 | table.integer :publisher_id 31 | table.integer :award_id 32 | end 33 | 34 | create_table(:authors) { |table| table.column :name, :string } 35 | 36 | create_table(:genres) { |table| table.column :name, :string } 37 | 38 | create_table :publishers do |table| 39 | table.column :name, :string 40 | table.integer :book_id 41 | end 42 | 43 | create_table :awards do |table| 44 | table.column :title, :string 45 | table.integer :book_id 46 | end 47 | 48 | create_table :authors_books do |table| 49 | table.integer :author_id 50 | table.integer :book_id 51 | end 52 | 53 | create_table :employees do |table| 54 | table.column :name, :string 55 | table.integer :manager_id 56 | end 57 | 58 | create_table :managers do |table| 59 | table.column :name, :string 60 | end 61 | 62 | create_table :executives do |table| 63 | table.column :name, :string 64 | table.integer :ceo_id 65 | end 66 | 67 | create_table :assistants do |table| 68 | table.column :name, :string 69 | table.integer :executive_id 70 | end 71 | 72 | create_table :proms do |table| 73 | table.column :prom_name, :string 74 | end 75 | 76 | create_table :prom_couples do |table| 77 | table.column :prom_queen, :string 78 | table.integer :prom_id 79 | end 80 | 81 | create_table :prom_kings do |table| 82 | table.column :prom_king_name, :string 83 | table.integer :prom_couple_id 84 | end 85 | 86 | create_table :questions do |table| 87 | table.column :name, :string 88 | end 89 | 90 | create_table :answers do |table| 91 | table.column :name, :string 92 | table.integer :question_id 93 | end 94 | 95 | create_table :trees do |table| 96 | table.column :name, :string 97 | table.column :height, :int 98 | end 99 | end 100 | 101 | def name_string 102 | ('a'..'z').to_a.sample(8).join 103 | end 104 | 105 | # 106 | # 107 | # Basics 108 | # 109 | 110 | class SchemaLessAR < ActiveRecord::Base 111 | include Surrealist 112 | end 113 | 114 | 3.times { SchemaLessAR.create!(name: 'testing active record without schema') } 115 | 116 | # 117 | # 118 | # Inheritance 119 | # 120 | 121 | class TestAR < ActiveRecord::Base 122 | include Surrealist 123 | 124 | json_schema { { name: String } } 125 | end 126 | 127 | class InheritAR < TestAR 128 | delegate_surrealization_to TestAR 129 | end 130 | 131 | class InheritAgainAR < InheritAR 132 | delegate_surrealization_to TestAR 133 | end 134 | 135 | TestAR.create!(name: 'testing active record') 136 | InheritAR.create!(name: 'testing active record inherit') 137 | InheritAgainAR.create!(name: 'testing active record inherit again') 138 | 139 | # 140 | # 141 | # Scopes 142 | # 143 | 144 | class ARScope < ActiveRecord::Base 145 | include Surrealist 146 | 147 | # Surrealist.surrealize_collection() should work properly with scopes that return collections. 148 | scope :coll_where, -> { where(id: 3) } 149 | scope :coll_where_not, -> { where.not(title: 'nope') } 150 | scope :coll_order, -> { order(id: :asc) } 151 | scope :coll_take, -> { take(1) } 152 | scope :coll_limit, -> { limit(34) } 153 | scope :coll_offset, -> { offset(43) } 154 | scope :coll_lock, -> { lock(12) } 155 | scope :coll_readonly, -> { readonly } 156 | scope :coll_reorder, -> { reorder(id: :desc) } 157 | scope :coll_distinct, -> { distinct } 158 | scope :coll_find_each, -> { find_each { |rec| rec.title.length > 2 } } 159 | scope :coll_select, -> { select(:title, :money) } 160 | scope :coll_group, -> { group(:title) } 161 | scope :coll_order, -> { order(title: :desc) } 162 | scope :coll_except, -> { except(id: 65) } 163 | scope :coll_extending, -> { extending Surrealist } 164 | scope :coll_having, -> { having('sum(money) > 43').group(:money) } 165 | scope :coll_references, -> { references(:book) } 166 | 167 | # Surrealist.surrealize_collection() will fail with scopes that return an instance. 168 | scope :rec_find, -> { find(2) } 169 | scope :rec_find_by, -> { find_by(id: 3) } 170 | scope :rec_find_by!, -> { find_by!(id: 3) } 171 | scope :rec_take!, -> { take! } 172 | scope :rec_first, -> { first } 173 | scope :rec_first!, -> { first! } 174 | scope :rec_second, -> { second } 175 | scope :rec_second!, -> { second! } 176 | scope :rec_third, -> { third } 177 | scope :rec_third!, -> { third! } 178 | scope :rec_fourth, -> { fourth } 179 | scope :rec_fourth!, -> { fourth! } 180 | scope :rec_fifth, -> { fifth } 181 | scope :rec_fifth!, -> { fifth! } 182 | scope :rec_forty_two, -> { forty_two } 183 | scope :rec_forty_two!, -> { forty_two! } 184 | scope :rec_last, -> { last } 185 | scope :rec_last!, -> { last! } 186 | scope :rec_third_to_last, -> { third_to_last } 187 | scope :rec_third_to_last!, -> { third_to_last! } 188 | scope :rec_second_to_last, -> { second_to_last } 189 | scope :rec_second_to_last!, -> { second_to_last! } 190 | 191 | json_schema { { title: String, money: Integer } } 192 | end 193 | 194 | 45.times { ARScope.create!(title: name_string, money: rand(5432)) } 195 | 196 | # 197 | # 198 | # Associations 199 | # 200 | 201 | class BookSerializer < Surrealist::Serializer 202 | json_schema { { awards: Object } } 203 | end 204 | 205 | class Book < ActiveRecord::Base 206 | has_and_belongs_to_many :authors 207 | belongs_to :genre 208 | has_one :publisher 209 | has_many :awards 210 | 211 | include Surrealist 212 | 213 | json_schema do 214 | { 215 | title: String, 216 | genre: { 217 | name: String, 218 | }, 219 | awards: { 220 | title: String, 221 | id: Integer, 222 | }, 223 | } 224 | end 225 | surrealize_with BookSerializer, tag: :awards 226 | end 227 | 228 | class Author < ActiveRecord::Base 229 | has_and_belongs_to_many :books 230 | 231 | include Surrealist 232 | 233 | json_schema { { name: String } } 234 | end 235 | 236 | class Publisher < ActiveRecord::Base 237 | include Surrealist 238 | 239 | json_schema { { name: String } } 240 | end 241 | 242 | class Award < ActiveRecord::Base 243 | include Surrealist 244 | 245 | json_schema { { title: String } } 246 | end 247 | 248 | class Genre < ActiveRecord::Base 249 | include Surrealist 250 | 251 | json_schema { { name: String } } 252 | end 253 | 254 | %w[Adventures Comedy Drama].each_with_index do |name, i| 255 | Genre.create!(name: name, id: i + 1) 256 | end 257 | 258 | %w[Twain Jerome Shakespeare].each_with_index do |name, i| 259 | Author.create!(name: name, id: i + 1) 260 | end 261 | 262 | [ 263 | 'The Adventures of Tom Sawyer', 264 | 'Three Men in a Boat', 265 | 'Romeo and Juliet', 266 | ].each_with_index do |title, i| 267 | Book.create!(title: title, id: i + 1, genre_id: i + 1, author_ids: [i + 1]) 268 | end 269 | 270 | [ 271 | 'Cengage Learning', 272 | 'Houghton Mifflin Harcourt', 273 | 'McGraw-Hill Education', 274 | ].each_with_index { |name, i| Publisher.create!(name: name, book_id: i + 1) } 275 | 276 | 3.times do 277 | [ 278 | 'Nobel Prize', 279 | 'Franz Kafka Prize', 280 | 'America Award', 281 | ].each_with_index { |title, i| Award.create!(title: title, book_id: i + 1) } 282 | end 283 | 284 | # 285 | # 286 | # Nested records 287 | # 288 | 289 | class Executive < ActiveRecord::Base 290 | has_one :assistant 291 | 292 | include Surrealist 293 | 294 | json_schema { { name: String, assistant: Object } } 295 | end 296 | 297 | class Assistant < ActiveRecord::Base 298 | belongs_to :executive 299 | 300 | include Surrealist 301 | 302 | json_schema { { name: String, executive: Executive } } 303 | end 304 | 305 | class Manager < ActiveRecord::Base 306 | has_many :employees 307 | belongs_to :executive 308 | 309 | include Surrealist 310 | 311 | json_schema { { name: String } } 312 | end 313 | 314 | class Employee < ActiveRecord::Base 315 | belongs_to :manager 316 | 317 | include Surrealist 318 | 319 | json_schema { { name: String, manager: Manager } } 320 | end 321 | 322 | class PromKing < ActiveRecord::Base 323 | belongs_to :prom_couple 324 | has_one :prom, through: :prom_couple 325 | 326 | include Surrealist 327 | 328 | json_schema { { prom_king_name: String, prom: Object } } 329 | end 330 | 331 | class PromCouple < ActiveRecord::Base 332 | belongs_to :prom 333 | has_one :prom_king 334 | 335 | include Surrealist 336 | 337 | json_schema { { prom_king: PromKing, prom_queen: String } } 338 | end 339 | 340 | class Prom < ActiveRecord::Base 341 | has_one :prom_couple 342 | 343 | include Surrealist 344 | 345 | json_schema { { prom_name: String, prom_couple: PromCouple } } 346 | end 347 | 348 | class Question < ActiveRecord::Base 349 | has_many :answers 350 | 351 | include Surrealist 352 | 353 | json_schema { { name: String, answers: Object } } 354 | end 355 | 356 | class Answer < ActiveRecord::Base 357 | belongs_to :question 358 | 359 | include Surrealist 360 | 361 | json_schema { { name: String, question: Question } } 362 | end 363 | 364 | # Using a separate class 365 | 366 | TreeSerializer = Class.new(Surrealist::Serializer) do 367 | json_schema { { name: String, height: Integer, color: String } } 368 | 369 | def color; 'green'; end 370 | end 371 | 372 | class Tree < ActiveRecord::Base 373 | include Surrealist 374 | 375 | surrealize_with TreeSerializer 376 | end 377 | 378 | 2.times { Executive.create(name: name_string) } 379 | 3.times { Manager.create(name: name_string) } 380 | 5.times { Employee.create(name: name_string, manager_id: Manager.all.sample.id) } 381 | Assistant.create(name: name_string, executive_id: Executive.first.id) 382 | Assistant.create(name: name_string, executive_id: Executive.second.id) 383 | Prom.create(prom_name: name_string) 384 | PromCouple.create(prom_id: Prom.first.id, prom_queen: name_string) 385 | PromKing.create(prom_king_name: name_string, prom_couple_id: PromCouple.first.id) 386 | 5.times { Question.create(name: name_string) } 387 | 10.times { Answer.create(name: name_string, question_id: Question.all.sample.id) } 388 | 389 | Tree.create!(name: 'Oak', height: 200) 390 | Tree.create!(name: 'Pine', height: 140) 391 | -------------------------------------------------------------------------------- /spec/orms/rom/rom_5_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | unless ruby25 4 | class UserModel 5 | include Surrealist 6 | 7 | json_schema { { id: Integer, email: String } } 8 | 9 | attr_reader :id, :name, :email 10 | 11 | def initialize(attributes) 12 | @id = attributes[:id] 13 | @name = attributes[:name] 14 | @email = attributes[:email] 15 | end 16 | end 17 | 18 | class UsersMapper < ROM::Mapper 19 | register_as :user_obj 20 | relation :users 21 | model UserModel 22 | end 23 | 24 | class SchemalessUser 25 | include Surrealist 26 | 27 | attr_reader :id, :name, :email 28 | 29 | def initialize(attributes) 30 | @id = attributes.id 31 | @name = attributes.name 32 | @email = attributes.email 33 | end 34 | end 35 | 36 | class SomeUser 37 | include Surrealist 38 | 39 | json_schema { { id: Integer, email: String } } 40 | 41 | attr_reader :id, :name, :email 42 | 43 | def initialize(attributes) 44 | @id = attributes.id 45 | @name = attributes.name 46 | @email = attributes.email 47 | end 48 | end 49 | 50 | class DelegatedUser < SomeUser 51 | delegate_surrealization_to SomeUser 52 | 53 | def initialize(attributes) 54 | super 55 | @email = 'delegated@example.com' 56 | end 57 | end 58 | 59 | class GrandChildUser < DelegatedUser 60 | end 61 | 62 | class MapperWithoutSchema < ROM::Mapper 63 | register_as :user_wo_schema 64 | relation :users 65 | model SchemalessUser 66 | end 67 | 68 | class MapperDelegatedSchema < ROM::Mapper 69 | register_as :user_de_schema 70 | relation :users 71 | model DelegatedUser 72 | end 73 | 74 | class MapperInheritedDelegatedSchema < ROM::Mapper 75 | register_as :user_child_de_schema 76 | relation :users 77 | model GrandChildUser 78 | end 79 | 80 | container = ROM.container(:sql, 'sqlite::memory') do |config| 81 | config.default.connection.create_table(:users) do 82 | primary_key :id 83 | column :name, String, null: false 84 | column :email, String, null: false 85 | end 86 | 87 | config.relation(:users) do 88 | schema(infer: true) 89 | auto_map false 90 | end 91 | 92 | config.register_mapper(UsersMapper) 93 | config.register_mapper(MapperWithoutSchema) 94 | config.register_mapper(MapperDelegatedSchema) 95 | config.register_mapper(MapperInheritedDelegatedSchema) 96 | end 97 | 98 | class UserRepo < ROM::Repository[:users] 99 | commands :create, update: :by_pk, delete: :by_pk 100 | end 101 | 102 | class RomUser < Dry::Struct 103 | include Surrealist 104 | 105 | attribute :name, 'string' 106 | attribute :email, 'string' 107 | 108 | json_schema { { email: String } } 109 | end 110 | 111 | class ROM::Struct::User < ROM::Struct 112 | include Surrealist 113 | 114 | json_schema { { name: String } } 115 | end 116 | 117 | RSpec.describe 'ROM Integration' do 118 | let(:user_repo) { UserRepo.new(container) } 119 | let(:users) { user_repo.users } 120 | let(:parsed_collection) { JSON.parse(Surrealist.surrealize_collection(collection)) } 121 | 122 | before(:all) do 123 | user_repo = UserRepo.new(container) 124 | [ 125 | { name: 'Jane Struct', email: 'jane@struct.rom' }, 126 | { name: 'Dane As', email: 'dane@as.rom' }, 127 | { name: 'Jack Mapper', email: 'jack@mapper.rom' }, 128 | ].each { |user| user_repo.create(user) } 129 | end 130 | 131 | context 'with schema defined in ROM::Struct::Model' do 132 | context 'instance' do 133 | let(:instance) { users.to_a.first } 134 | let(:result) { '{"name":"Jane Struct"}' } 135 | 136 | it { expect(instance.surrealize).to eq(result) } 137 | it_behaves_like 'error is not raised for valid params: instance' 138 | it_behaves_like 'error is raised for invalid params: instance' 139 | 140 | context '#where().first' do 141 | let(:instance) { users.where(id: 1).first } 142 | let(:result) { '{"name":"Jane Struct"}' } 143 | 144 | it { expect(instance.surrealize).to eq(result) } 145 | it_behaves_like 'error is not raised for valid params: instance' 146 | it_behaves_like 'error is raised for invalid params: instance' 147 | end 148 | end 149 | 150 | context 'collection' do 151 | let(:collection) { users.to_a } 152 | let(:result) do 153 | [{ 'name' => 'Jane Struct' }, 154 | { 'name' => 'Dane As' }, 155 | { 'name' => 'Jack Mapper' }] 156 | end 157 | 158 | it { expect(parsed_collection).to eq(result) } 159 | it_behaves_like 'error is not raised for valid params: collection' 160 | it_behaves_like 'error is raised for invalid params: collection' 161 | 162 | context '#where().to_a' do 163 | let(:collection) { users.where { id < 4 }.to_a } 164 | 165 | it { expect(parsed_collection).to eq(result) } 166 | it_behaves_like 'error is not raised for valid params: collection' 167 | it_behaves_like 'error is raised for invalid params: collection' 168 | end 169 | end 170 | end 171 | 172 | context 'using ROM::Struct::Model#as(Representative)' do 173 | context 'instance' do 174 | let(:instance) { users.map_to(RomUser).to_a[1] } 175 | let(:result) { '{"email":"dane@as.rom"}' } 176 | 177 | it { expect(instance.surrealize).to eq(result) } 178 | it_behaves_like 'error is not raised for valid params: instance' 179 | it_behaves_like 'error is raised for invalid params: instance' 180 | 181 | context '#where().first' do 182 | let(:instance) { users.map_to(RomUser).where(id: 2).first } 183 | 184 | it { expect(instance.surrealize).to eq(result) } 185 | it_behaves_like 'error is not raised for valid params: instance' 186 | it_behaves_like 'error is raised for invalid params: instance' 187 | end 188 | end 189 | 190 | context 'collection' do 191 | let(:collection) { users.map_to(RomUser).to_a } 192 | let(:result) do 193 | [{ 'email' => 'jane@struct.rom' }, 194 | { 'email' => 'dane@as.rom' }, 195 | { 'email' => 'jack@mapper.rom' }] 196 | end 197 | 198 | it { expect(parsed_collection).to eq(result) } 199 | it_behaves_like 'error is not raised for valid params: collection' 200 | it_behaves_like 'error is raised for invalid params: collection' 201 | 202 | context '#where().to_a' do 203 | let(:collection) { users.map_to(RomUser).where { id < 4 }.to_a } 204 | 205 | it { expect(parsed_collection).to eq(result) } 206 | it_behaves_like 'error is not raised for valid params: collection' 207 | it_behaves_like 'error is raised for invalid params: collection' 208 | end 209 | end 210 | end 211 | 212 | context 'using mapper' do 213 | context 'instance' do 214 | let(:instance) { users.map_with(:user_obj).to_a[2] } 215 | let(:result) { '{"id":3,"email":"jack@mapper.rom"}' } 216 | 217 | it { expect(instance.surrealize).to eq(result) } 218 | it { expect(users.map_with(:user_obj).to_a.size).to eq(3) } 219 | it_behaves_like 'error is not raised for valid params: instance' 220 | it_behaves_like 'error is raised for invalid params: instance' 221 | 222 | context '#where().first' do 223 | let(:instance) { users.map_with(:user_obj).where(id: 3).first } 224 | 225 | it { expect(instance.surrealize).to eq(result) } 226 | it_behaves_like 'error is not raised for valid params: instance' 227 | it_behaves_like 'error is raised for invalid params: instance' 228 | end 229 | end 230 | 231 | context 'collection' do 232 | let(:collection) { users.map_with(:user_obj).to_a } 233 | let(:result) do 234 | [{ 'id' => 1, 'email' => 'jane@struct.rom' }, 235 | { 'id' => 2, 'email' => 'dane@as.rom' }, 236 | { 'id' => 3, 'email' => 'jack@mapper.rom' }] 237 | end 238 | 239 | it { expect(parsed_collection).to eq(result) } 240 | it_behaves_like 'error is not raised for valid params: collection' 241 | it_behaves_like 'error is raised for invalid params: collection' 242 | 243 | context '#where().to_a' do 244 | let(:collection) { users.map_with(:user_obj).where { id < 4 }.to_a } 245 | 246 | it { expect(parsed_collection).to eq(result) } 247 | it_behaves_like 'error is not raised for valid params: collection' 248 | it_behaves_like 'error is raised for invalid params: collection' 249 | end 250 | end 251 | end 252 | 253 | context 'with no schema provided' do 254 | let(:instance) { users.map_with(:user_wo_schema).first } 255 | let(:collection) { users.map_with(:user_wo_schema).to_a } 256 | 257 | describe 'UnknownSchemaError is raised' do 258 | specify 'for instance' do 259 | expect { instance.surrealize } 260 | .to raise_error(Surrealist::UnknownSchemaError, 261 | "Can't serialize SchemalessUser - no schema was provided.") 262 | end 263 | 264 | specify 'for collection' do 265 | expect { Surrealist.surrealize_collection(collection) } 266 | .to raise_error(Surrealist::UnknownSchemaError, 267 | "Can't serialize SchemalessUser - no schema was provided.") 268 | end 269 | end 270 | end 271 | 272 | context 'with delegated schema' do 273 | context 'for instance' do 274 | let(:instance) { users.map_with(:user_de_schema).to_a[2] } 275 | let(:result) { '{"id":3,"email":"delegated@example.com"}' } 276 | 277 | it { expect(instance.surrealize).to eq(result) } 278 | it { expect(users.map_with(:user_de_schema).to_a.size).to eq(3) } 279 | it_behaves_like 'error is not raised for valid params: instance' 280 | it_behaves_like 'error is raised for invalid params: instance' 281 | 282 | context '#where().first' do 283 | let(:instance) { users.map_with(:user_de_schema).where(id: 3).first } 284 | 285 | it { expect(instance.surrealize).to eq(result) } 286 | it_behaves_like 'error is not raised for valid params: instance' 287 | it_behaves_like 'error is raised for invalid params: instance' 288 | end 289 | end 290 | 291 | context 'for collection' do 292 | let(:collection) { users.map_with(:user_de_schema).to_a } 293 | let(:result) do 294 | [{ 'id' => 1, 'email' => 'delegated@example.com' }, 295 | { 'id' => 2, 'email' => 'delegated@example.com' }, 296 | { 'id' => 3, 'email' => 'delegated@example.com' }] 297 | end 298 | 299 | it { expect(parsed_collection).to eq(result) } 300 | it_behaves_like 'error is not raised for valid params: collection' 301 | it_behaves_like 'error is raised for invalid params: collection' 302 | 303 | context '#where().to_a' do 304 | let(:collection) { users.map_with(:user_de_schema).where { id < 4 }.to_a } 305 | 306 | it { expect(parsed_collection).to eq(result) } 307 | it_behaves_like 'error is not raised for valid params: collection' 308 | it_behaves_like 'error is raised for invalid params: collection' 309 | end 310 | end 311 | end 312 | 313 | context 'with inheritance of class that has delegated but we don\'t delegate' do 314 | let(:instance) { users.map_with(:user_child_de_schema).first } 315 | let(:collection) { users.map_with(:user_child_de_schema).to_a } 316 | 317 | describe 'UnknownSchemaError is raised' do 318 | specify 'for instance' do 319 | expect { instance.surrealize } 320 | .to raise_error(Surrealist::UnknownSchemaError, 321 | "Can't serialize GrandChildUser - no schema was provided.") 322 | end 323 | 324 | specify 'for collection' do 325 | expect { Surrealist.surrealize_collection(collection) } 326 | .to raise_error(Surrealist::UnknownSchemaError, 327 | "Can't serialize GrandChildUser - no schema was provided.") 328 | end 329 | end 330 | end 331 | end 332 | end 333 | -------------------------------------------------------------------------------- /spec/orms/sequel/models.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'sequel' 4 | require 'surrealist' 5 | 6 | DB = Sequel.sqlite 7 | 8 | DB.create_table :artists do 9 | primary_key :id 10 | String :name 11 | Float :age 12 | end 13 | 14 | DB.create_table :albums do 15 | primary_key :id 16 | foreign_key :artist_id, :artists, null: false 17 | String :title, unique: true 18 | Integer :year 19 | end 20 | 21 | DB.create_table :labels do 22 | primary_key :id 23 | foreign_key :album_id, :albums, null: false 24 | String :label_name 25 | end 26 | 27 | DB.create_table :songs do 28 | primary_key :id 29 | String :length 30 | String :title 31 | end 32 | 33 | DB.create_table :artists_songs do 34 | foreign_key :artist_id, :artists, null: false 35 | foreign_key :song_id, :songs, null: false 36 | end 37 | 38 | class Artist < Sequel::Model 39 | include Surrealist 40 | 41 | one_to_many :albums 42 | many_to_many :songs 43 | 44 | json_schema { { name: String } } 45 | end 46 | 47 | class Album < Sequel::Model 48 | include Surrealist 49 | 50 | many_to_one :artist 51 | one_to_one :label 52 | 53 | json_schema { { title: String, year: Integer } } 54 | end 55 | 56 | class Label < Sequel::Model 57 | include Surrealist 58 | 59 | one_to_one :album 60 | 61 | json_schema { { label_name: String } } 62 | end 63 | 64 | class Song < Sequel::Model 65 | include Surrealist 66 | 67 | many_to_many :artists 68 | 69 | json_schema { { title: String } } 70 | end 71 | 72 | class ArtistSerializer < Surrealist::Serializer 73 | json_schema { { name: String } } 74 | end 75 | 76 | class ArtistWithCustomSerializer < Sequel::Model(:artists) 77 | include Surrealist 78 | 79 | one_to_many :albums 80 | many_to_many :songs 81 | 82 | surrealize_with ArtistSerializer 83 | end 84 | 85 | 7.times { |i| Artist.insert(name: "Artist #{i}", age: (18 + i * 4)) } 86 | 87 | Artist.each_with_index do |artist, i| 88 | artist.add_album(title: "Album #{i}", year: (1950 + i * 5)) 89 | 2.times { |t| artist.add_song(title: "Song #{i}#{t}", length: (120 + i * 5)) } 90 | end 91 | 92 | Album.each_with_index do |album, i| 93 | Label.new(label_name: "Label #{i}", album_id: album.id).save 94 | end 95 | 96 | Song.each { |song| song.add_artist(Artist.last) } 97 | -------------------------------------------------------------------------------- /spec/root_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Cat 4 | include Surrealist 5 | 6 | json_schema do 7 | { cat_weight: String } 8 | end 9 | 10 | def cat_weight 11 | '3 kilos' 12 | end 13 | end 14 | 15 | class SeriousCat 16 | include Surrealist 17 | 18 | json_schema do 19 | { 20 | weight: Types::String, 21 | cat_food: { 22 | amount: Types::Strict::Integer, 23 | brand: Types::Strict::String, 24 | }, 25 | } 26 | end 27 | 28 | def weight 29 | '3 kilos' 30 | end 31 | 32 | def cat_food 33 | Struct.new(:amount, :brand).new(3, 'Whiskas') 34 | end 35 | end 36 | 37 | class Animal 38 | class Dog 39 | include Surrealist 40 | 41 | json_schema do 42 | { breed: String } 43 | end 44 | 45 | def breed 46 | 'Collie' 47 | end 48 | end 49 | end 50 | 51 | module Instrument 52 | class Guitar 53 | include Surrealist 54 | 55 | json_schema do 56 | { brand_name: Types::Strict::String } 57 | end 58 | 59 | def brand_name 60 | 'Fender' 61 | end 62 | end 63 | end 64 | 65 | class Code 66 | class Language 67 | class Ruby 68 | include Surrealist 69 | 70 | json_schema do 71 | { age: Types::String } 72 | end 73 | 74 | def age 75 | '22 years' 76 | end 77 | end 78 | end 79 | end 80 | 81 | RSpec.describe Surrealist do 82 | describe 'root option' do 83 | context 'nil root' do 84 | let(:instance) { Cat.new } 85 | 86 | it 'builds schema' do 87 | expect(instance.build_schema(root: nil)).to eq(cat_weight: '3 kilos') 88 | end 89 | 90 | it 'surrealizes' do 91 | expect(JSON.parse(instance.surrealize(root: nil))) 92 | .to eq('cat_weight' => '3 kilos') 93 | end 94 | 95 | it 'camelizes' do 96 | expect(instance.build_schema(root: nil, camelize: true)) 97 | .to eq(catWeight: '3 kilos') 98 | 99 | expect(JSON.parse(instance.surrealize(root: nil, camelize: true))) 100 | .to eq('catWeight' => '3 kilos') 101 | end 102 | end 103 | 104 | context 'simple example with extra whitespaces' do 105 | let(:instance) { Cat.new } 106 | 107 | it 'builds schema' do 108 | expect(instance.build_schema(root: ' kitten ')).to eq(kitten: { cat_weight: '3 kilos' }) 109 | end 110 | 111 | it 'surrealizes' do 112 | expect(JSON.parse(instance.surrealize(root: ' kitten '))) 113 | .to eq('kitten' => { 'cat_weight' => '3 kilos' }) 114 | end 115 | 116 | it 'camelizes' do 117 | expect(instance.build_schema(root: ' kitten ', camelize: true)) 118 | .to eq(kitten: { catWeight: '3 kilos' }) 119 | 120 | expect(JSON.parse(instance.surrealize(root: ' kitten ', camelize: true))) 121 | .to eq('kitten' => { 'catWeight' => '3 kilos' }) 122 | end 123 | 124 | it 'overrides include_root' do 125 | expect(JSON.parse(instance.surrealize(root: ' kitten ', include_root: true))) 126 | .to eq('kitten' => { 'cat_weight' => '3 kilos' }) 127 | end 128 | 129 | it 'overrides include_namespaces' do 130 | expect(JSON.parse(instance.surrealize(root: ' kitten ', include_namespaces: true))) 131 | .to eq('kitten' => { 'cat_weight' => '3 kilos' }) 132 | end 133 | end 134 | 135 | context 'simple example' do 136 | let(:instance) { Cat.new } 137 | 138 | it 'builds schema' do 139 | expect(instance.build_schema(root: 'kitten')).to eq(kitten: { cat_weight: '3 kilos' }) 140 | end 141 | 142 | it 'surrealizes' do 143 | expect(JSON.parse(instance.surrealize(root: 'kitten'))) 144 | .to eq('kitten' => { 'cat_weight' => '3 kilos' }) 145 | end 146 | 147 | it 'camelizes' do 148 | expect(instance.build_schema(root: 'kitten', camelize: true)) 149 | .to eq(kitten: { catWeight: '3 kilos' }) 150 | 151 | expect(JSON.parse(instance.surrealize(root: 'kitten', camelize: true))) 152 | .to eq('kitten' => { 'catWeight' => '3 kilos' }) 153 | end 154 | 155 | it 'overrides include_root' do 156 | expect(JSON.parse(instance.surrealize(root: 'kitten', include_root: true))) 157 | .to eq('kitten' => { 'cat_weight' => '3 kilos' }) 158 | end 159 | 160 | it 'overrides include_namespaces' do 161 | expect(JSON.parse(instance.surrealize(root: 'kitten', include_namespaces: true))) 162 | .to eq('kitten' => { 'cat_weight' => '3 kilos' }) 163 | end 164 | end 165 | 166 | context 'simple example using a symbol' do 167 | let(:instance) { Cat.new } 168 | 169 | it 'builds schema' do 170 | expect(instance.build_schema(root: :kitten)).to eq(kitten: { cat_weight: '3 kilos' }) 171 | end 172 | 173 | it 'surrealizes' do 174 | expect(JSON.parse(instance.surrealize(root: :kitten))) 175 | .to eq('kitten' => { 'cat_weight' => '3 kilos' }) 176 | end 177 | 178 | it 'camelizes' do 179 | expect(instance.build_schema(root: :kitten, camelize: true)) 180 | .to eq(kitten: { catWeight: '3 kilos' }) 181 | 182 | expect(JSON.parse(instance.surrealize(root: :kitten, camelize: true))) 183 | .to eq('kitten' => { 'catWeight' => '3 kilos' }) 184 | end 185 | 186 | it 'overrides include_root' do 187 | expect(JSON.parse(instance.surrealize(root: :kitten, include_root: true))) 188 | .to eq('kitten' => { 'cat_weight' => '3 kilos' }) 189 | end 190 | 191 | it 'overrides include_namespaces' do 192 | expect(JSON.parse(instance.surrealize(root: :kitten, include_namespaces: true))) 193 | .to eq('kitten' => { 'cat_weight' => '3 kilos' }) 194 | end 195 | end 196 | 197 | context 'with nested objects' do 198 | let(:instance) { SeriousCat.new } 199 | 200 | it 'builds schema' do 201 | expect(instance.build_schema(root: 'serious_kitten')) 202 | .to eq(serious_kitten: { weight: '3 kilos', cat_food: { amount: 3, brand: 'Whiskas' } }) 203 | end 204 | 205 | it 'surrealizes' do 206 | expect(JSON.parse(instance.surrealize(root: 'serious_kitten'))) 207 | .to eq('serious_kitten' => { 'weight' => '3 kilos', 208 | 'cat_food' => { 'amount' => 3, 'brand' => 'Whiskas' } }) 209 | end 210 | 211 | it 'camelizes' do 212 | expect(instance.build_schema(root: 'serious_kitten', camelize: true)) 213 | .to eq(seriousKitten: { weight: '3 kilos', catFood: { amount: 3, brand: 'Whiskas' } }) 214 | 215 | expect(JSON.parse(instance.surrealize(root: 'serious_kitten', camelize: true))) 216 | .to eq('seriousKitten' => { 'weight' => '3 kilos', 217 | 'catFood' => { 'amount' => 3, 'brand' => 'Whiskas' } }) 218 | end 219 | 220 | it 'overrides include_root' do 221 | expect(JSON.parse(instance.surrealize(root: 'serious_kitten', include_root: true))) 222 | .to eq('serious_kitten' => { 'weight' => '3 kilos', 223 | 'cat_food' => { 'amount' => 3, 224 | 'brand' => 'Whiskas' } }) 225 | end 226 | 227 | it 'overrides include_namespaces' do 228 | expect(JSON.parse(instance.surrealize(root: 'serious_kitten', include_namespaces: true))) 229 | .to eq('serious_kitten' => { 'weight' => '3 kilos', 230 | 'cat_food' => { 'amount' => 3, 231 | 'brand' => 'Whiskas' } }) 232 | end 233 | end 234 | 235 | context 'with nested classes' do 236 | let(:instance) { Animal::Dog.new } 237 | 238 | it 'builds schema' do 239 | expect(instance.build_schema(root: 'new_dog')).to eq(new_dog: { breed: 'Collie' }) 240 | end 241 | 242 | it 'surrealizes' do 243 | expect(JSON.parse(instance.surrealize(root: 'new_dog'))) 244 | .to eq('new_dog' => { 'breed' => 'Collie' }) 245 | end 246 | 247 | it 'camelizes' do 248 | expect(instance.build_schema(root: 'newDog', camelize: true)) 249 | .to eq(newDog: { breed: 'Collie' }) 250 | 251 | expect(JSON.parse(instance.surrealize(root: 'new_dog', camelize: true))) 252 | .to eq('newDog' => { 'breed' => 'Collie' }) 253 | end 254 | 255 | it 'overrides include_root' do 256 | expect(JSON.parse(instance.surrealize(root: 'new_dog', include_root: true))) 257 | .to eq('new_dog' => { 'breed' => 'Collie' }) 258 | end 259 | 260 | it 'overrides include_namespaces' do 261 | expect(JSON.parse(instance.surrealize(root: 'new_dog', include_namespaces: true))) 262 | .to eq('new_dog' => { 'breed' => 'Collie' }) 263 | end 264 | end 265 | 266 | context 'Module::Class' do 267 | let(:instance) { Instrument::Guitar.new } 268 | 269 | it 'builds schema' do 270 | expect(instance.build_schema(root: 'new_guitar')) 271 | .to eq(new_guitar: { brand_name: 'Fender' }) 272 | end 273 | 274 | it 'surrealizes' do 275 | expect(JSON.parse(instance.surrealize(root: 'new_guitar'))) 276 | .to eq('new_guitar' => { 'brand_name' => 'Fender' }) 277 | end 278 | 279 | it 'camelizes' do 280 | expect(instance.build_schema(root: 'new_guitar', camelize: true)) 281 | .to eq(newGuitar: { brandName: 'Fender' }) 282 | 283 | expect(JSON.parse(instance.surrealize(root: 'new_guitar', camelize: true))) 284 | .to eq('newGuitar' => { 'brandName' => 'Fender' }) 285 | end 286 | 287 | it 'overrides include_root' do 288 | expect(JSON.parse(instance.surrealize(root: 'new_guitar', include_root: true))) 289 | .to eq('new_guitar' => { 'brand_name' => 'Fender' }) 290 | end 291 | 292 | it 'overrides include_namespaces' do 293 | expect(JSON.parse(instance.surrealize(root: 'new_guitar', include_namespaces: true))) 294 | .to eq('new_guitar' => { 'brand_name' => 'Fender' }) 295 | end 296 | end 297 | 298 | context 'triple nesting' do 299 | let(:instance) { Code::Language::Ruby.new } 300 | 301 | it 'builds schema' do 302 | expect(instance.build_schema(root: 'new_ruby')) 303 | .to eq(new_ruby: { age: '22 years' }) 304 | end 305 | 306 | it 'surrealizes' do 307 | expect(JSON.parse(instance.surrealize(root: 'new_ruby'))) 308 | .to eq('new_ruby' => { 'age' => '22 years' }) 309 | end 310 | 311 | it 'camelizes' do 312 | expect(instance.build_schema(root: 'new_ruby', camelize: true)) 313 | .to eq(newRuby: { age: '22 years' }) 314 | 315 | expect(JSON.parse(instance.surrealize(root: 'new_ruby', camelize: true))) 316 | .to eq('newRuby' => { 'age' => '22 years' }) 317 | end 318 | 319 | it 'overrides include_root' do 320 | expect(JSON.parse(instance.surrealize(root: 'new_ruby', include_root: true))) 321 | .to eq('new_ruby' => { 'age' => '22 years' }) 322 | end 323 | 324 | it 'overrides include_namespaces' do 325 | expect(JSON.parse(instance.surrealize(root: 'new_ruby', include_namespaces: true))) 326 | .to eq('new_ruby' => { 'age' => '22 years' }) 327 | end 328 | end 329 | end 330 | end 331 | -------------------------------------------------------------------------------- /spec/schema_definer_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Person; include Surrealist; end 4 | 5 | RSpec.describe Surrealist::SchemaDefiner do 6 | let(:instance) { Person } 7 | 8 | context 'when hash is passed' do 9 | let(:schema) { { a: 1, b: {} } } 10 | 11 | before { described_class.call(instance, schema) } 12 | 13 | it 'defines a method on class' do 14 | expect(instance.new.class.instance_variable_get('@__surrealist_schema')).to eq(schema) 15 | end 16 | end 17 | 18 | context 'when something else is passed' do 19 | shared_examples 'error is raised' do 20 | specify do 21 | expect { described_class.call(instance, schema) } 22 | .to raise_error(Surrealist::InvalidSchemaError, 'Schema should be defined as a hash') 23 | end 24 | end 25 | 26 | context 'with array' do 27 | let(:schema) { %w[it is an array] } 28 | 29 | it_behaves_like 'error is raised' 30 | end 31 | 32 | context 'with struct' do 33 | let(:schema) { Struct.new(:a, :b) } 34 | 35 | it_behaves_like 'error is raised' 36 | end 37 | 38 | context 'with number' do 39 | let(:schema) { 2 } 40 | 41 | it_behaves_like 'error is raised' 42 | end 43 | 44 | context 'with string' do 45 | let(:schema) { 'schema' } 46 | 47 | it_behaves_like 'error is raised' 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /spec/schema_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class WithSchema 4 | include Surrealist 5 | 6 | json_schema { { name: String, age: Integer } } 7 | 8 | def name 9 | 'John' 10 | end 11 | 12 | def age 13 | 21 14 | end 15 | end 16 | 17 | class WithoutSchema 18 | include Surrealist 19 | end 20 | 21 | class PersonSerializer < Surrealist::Serializer 22 | json_schema { { age: Integer } } 23 | 24 | def age 25 | 20 26 | end 27 | end 28 | 29 | class WithCustomSerializer 30 | include Surrealist 31 | attr_reader :age 32 | 33 | surrealize_with PersonSerializer 34 | 35 | def initialize(age) 36 | @age = age 37 | end 38 | end 39 | 40 | RSpec.describe Surrealist do 41 | describe '.defined_schema' do 42 | context 'json schema defined' do 43 | context 'using custom serializer' do 44 | it 'returns the defined json_schema' do 45 | expect(PersonSerializer.defined_schema).to eq(age: Integer) 46 | expect(WithCustomSerializer.defined_schema).to eq(age: Integer) 47 | end 48 | end 49 | 50 | context 'not using custom serializer' do 51 | it 'returns the defined json_schema' do 52 | expect(WithSchema.defined_schema).to eq(name: String, age: Integer) 53 | end 54 | end 55 | end 56 | 57 | context 'json schema not defined' do 58 | it 'raises UnknownSchemaError' do 59 | expect { WithoutSchema.defined_schema }.to raise_error(Surrealist::UnknownSchemaError) 60 | end 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'coveralls' 4 | require 'simplecov' 5 | 6 | SimpleCov.formatter = SimpleCov::Formatter::MultiFormatter.new( 7 | [ 8 | SimpleCov::Formatter::HTMLFormatter, 9 | Coveralls::SimpleCov::Formatter, 10 | ], 11 | ) 12 | Coveralls.wear! { add_filter 'spec' } 13 | 14 | $LOAD_PATH.unshift File.expand_path('../lib', __dir__) 15 | 16 | require 'dry-struct' 17 | require 'dry-types' 18 | require 'pry' 19 | require 'rom' 20 | require 'rom-repository' 21 | require_relative '../lib/surrealist' 22 | 23 | require_relative 'support/shared_contexts/parameters_contexts' 24 | require_relative 'support/shared_examples/hash_examples' 25 | require_relative 'orms/active_record/models' 26 | require_relative 'orms/sequel/models' 27 | 28 | RSpec.configure do |config| 29 | config.disable_monkey_patching! 30 | config.order = 'random' 31 | end 32 | 33 | def ruby25 34 | ::RUBY_VERSION =~ /2.5/ 35 | end 36 | 37 | module Types 38 | include Dry.Types 39 | end 40 | 41 | srand RSpec.configuration.seed 42 | -------------------------------------------------------------------------------- /spec/string_utils_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Surrealist::StringUtils do 4 | describe '#underscore' do 5 | let(:expectations) do 6 | %w[ 7 | camel_case camel_back name_space triple_name_space 8 | camel_back_name_space snake_case snake_case_namespace 9 | with1_numbers with_dashes_and_namespaces 10 | ] 11 | end 12 | 13 | %w[ 14 | CamelCase camelBack Name::Space Triple::Name::Space camel::BackNameSpace 15 | snake_case snake_case::Namespace With1::Numbers with-dashes::and-Namespaces 16 | ].each_with_index do |string, index| 17 | it "underscores #{string}" do 18 | expect(described_class.underscore(string)).to eq(expectations[index]) 19 | end 20 | end 21 | end 22 | 23 | describe '#camelize' do 24 | let(:camelback_expectations) do 25 | %w[ 26 | camelCase camelBack nameSpace tripleNameSpace 27 | camelBackNameSpace snakeCase snakeCaseNamespace 28 | with1Numbers withDashesAndNamespaces camelBack 29 | CamelCase 30 | ] 31 | end 32 | 33 | let(:camelcase_expectations) do 34 | %w[ 35 | CamelCase CamelBack NameSpace TripleNameSpace 36 | CamelBackNameSpace SnakeCase SnakeCaseNamespace 37 | With1Numbers WithDashesAndNamespaces Camelback 38 | Camelcase 39 | ] 40 | end 41 | 42 | %w[ 43 | camel_case camel_back name_space triple_name_space 44 | camel_back_name_space snake_case snake_case_namespace 45 | with1_numbers with_dashes_and_namespaces camelBack 46 | CamelCase 47 | ].each_with_index do |string, index| 48 | it "converts #{string} to camelBack" do 49 | expect(described_class.camelize(string, first_upper: false)).to eq(camelback_expectations[index]) 50 | end 51 | 52 | it "converts #{string} to CamelCase" do 53 | expect(described_class.camelize(string)).to eq(camelcase_expectations[index]) 54 | end 55 | end 56 | end 57 | 58 | describe '#extract class' do 59 | let(:expectations) do 60 | %w[ 61 | camelCase camelBack space space backNameSpace 62 | snake_case namespace numbers and-Namespaces 63 | numbers 1Numbers nesting :Columns 64 | ] 65 | end 66 | 67 | %w[ 68 | CamelCase camelBack Name::Space Triple::Name::Space camel::BackNameSpace 69 | snake_case snake_case::Namespace With1::Numbers with-dashes::and-Namespaces 70 | with1::Numbers with::1Numbers Weird::::Nesting Three:::Columns 71 | ].each_with_index do |string, index| 72 | it "extracts bottom-level class from #{string}" do 73 | expect(described_class.extract_class(string)).to eq(expectations[index]) 74 | end 75 | end 76 | end 77 | 78 | describe '#break_namespaces' do 79 | let(:snake_expectations) do 80 | [ 81 | { camel_case: {} }, { camel_back: {} }, { name: { space: {} } }, 82 | { triple: { name: { space: {} } } }, { camel: { back_name_space: {} } }, { snake_case: {} }, 83 | { snake_case: { name_space: {} } }, { with1: { numbers: {} } }, 84 | { with_dashes: { and_namespaces: {} } } 85 | ] 86 | end 87 | 88 | let(:camelized_expectations) do 89 | [ 90 | { camelCase: {} }, { camelBack: {} }, { name: { space: {} } }, 91 | { triple: { name: { space: {} } } }, { camel: { backNameSpace: {} } }, { snakeCase: {} }, 92 | { snakeCase: { nameSpace: {} } }, { with1: { numbers: {} } }, 93 | { 'with-dashes': { 'and-Namespaces': {} } } 94 | ] 95 | end 96 | 97 | let(:nested_expectations) do 98 | [ 99 | { camel_case: {} }, { camel_back: {} }, { name: { space: {} } }, 100 | { name: { space: {} } }, { back_name_space: { then_snake_case: {} } }, { to: { camel_case: {} } }, 101 | { name_space_maybe: { another: {} } }, { with1: { numbers: {} } }, 102 | { with_dashes: { and_namespaces: {} } } 103 | ] 104 | end 105 | 106 | let(:camelized_nested_expectations) do 107 | [ 108 | { camelCase: {} }, { camelBack: {} }, { name: { space: {} } }, 109 | { name: { space: {} } }, { backNameSpace: { thenSnakeCase: {} } }, { to: { camelCase: {} } }, 110 | { nameSpaceMaybe: { another: {} } }, { with1: { numbers: {} } }, 111 | { 'with-dashes': { 'and-Namespaces': {} } } 112 | ] 113 | end 114 | 115 | %w[ 116 | CamelCase camelBack Name::Space Triple::Name::Space camel::BackNameSpace 117 | snake_case snake_case::NameSpace With1::Numbers with-dashes::and-Namespaces 118 | ].each_with_index do |klass, index| 119 | it "breaks namespaces from #{klass} with default nesting level" do 120 | expect(described_class.break_namespaces(klass, false, 666)) 121 | .to eq(snake_expectations[index]) 122 | end 123 | 124 | it "breaks namespaces from #{klass} & camelizes" do 125 | expect(described_class.break_namespaces(klass, true, 666)) 126 | .to eq(camelized_expectations[index]) 127 | end 128 | 129 | it 'raises exception on nesting_level == 0' do 130 | expect { described_class.break_namespaces(klass, true, 0) } 131 | .to raise_error(ArgumentError, 132 | 'Expected `namespaces_nesting_level` to be a positive integer, got: 0') 133 | end 134 | end 135 | 136 | %w[ 137 | CamelCase camelBack Name::Space Triple::Name::Space camel::BackNameSpace::then_snake_case 138 | snake_case::To::CamelCase snake_case::NameSpace_maybe::Another Namespace::With1::Numbers 139 | Another::with-dashes::and-Namespaces 140 | ].each_with_index do |klass, index| 141 | it "breaks namespaces from #{klass} with nesting level == 2" do 142 | expect(described_class.break_namespaces(klass, false, 2)) 143 | .to eq(nested_expectations[index]) 144 | end 145 | 146 | it "breaks namespaces from #{klass} with nesting level == 2 & camelizes them" do 147 | expect(described_class.break_namespaces(klass, true, 2)) 148 | .to eq(camelized_nested_expectations[index]) 149 | end 150 | end 151 | end 152 | end 153 | -------------------------------------------------------------------------------- /spec/support/parameters.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | VALID_PARAMS = [ 4 | { camelize: true, include_namespaces: true, include_root: true, 5 | root: 'root', namespaces_nesting_level: 3 }, 6 | { camelize: false, include_namespaces: true, include_root: true, 7 | root: :root, namespaces_nesting_level: 3 }, 8 | { camelize: false, include_namespaces: false, include_root: true, 9 | root: nil, namespaces_nesting_level: 3 }, 10 | { camelize: false, include_namespaces: false, include_root: false, 11 | root: :root, namespaces_nesting_level: 3 }, 12 | { camelize: true, include_namespaces: false, include_root: false, 13 | root: 'root', namespaces_nesting_level: 3 }, 14 | { camelize: true, include_namespaces: true, include_root: false, 15 | root: nil, namespaces_nesting_level: 3 }, 16 | { camelize: true, include_namespaces: false, include_root: true, 17 | root: 'root', namespaces_nesting_level: 3 }, 18 | { camelize: true, include_namespaces: false, include_root: true, 19 | root: :root, namespaces_nesting_level: 435 }, 20 | { camelize: true, include_namespaces: false, include_root: true, 21 | root: nil, namespaces_nesting_level: 666 }, 22 | ].freeze 23 | 24 | INVALID_PARAMS = [ 25 | { camelize: 'NO', include_namespaces: false, include_root: true, 26 | root: 'root', namespaces_nesting_level: 3 }, 27 | { camelize: true, include_namespaces: 'false', include_root: true, 28 | root: :root, namespaces_nesting_level: 3 }, 29 | { camelize: true, include_namespaces: false, include_root: true, 30 | root: 'root', namespaces_nesting_level: 0 }, 31 | { camelize: true, include_namespaces: false, include_root: false, 32 | root: :root, namespaces_nesting_level: -3 }, 33 | { camelize: true, include_namespaces: false, include_root: 'yep', 34 | root: 'root', namespaces_nesting_level: 3 }, 35 | { camelize: 'NO', include_namespaces: false, include_root: true, 36 | root: :root, namespaces_nesting_level: '3' }, 37 | { camelize: 'NO', include_namespaces: false, include_root: true, 38 | root: 'root', namespaces_nesting_level: 3.14 }, 39 | { camelize: Integer, include_namespaces: false, include_root: true, 40 | root: :root, namespaces_nesting_level: 3 }, 41 | { camelize: 'NO', include_namespaces: 'no', include_root: true, 42 | root: 'root', namespaces_nesting_level: '3.4' }, 43 | { camelize: 'f', include_namespaces: false, include_root: 't', 44 | root: :root, namespaces_nesting_level: true }, 45 | { camelize: true, include_namespaces: false, include_root: true, 46 | root: '', namespaces_nesting_level: 666 }, 47 | { camelize: true, include_namespaces: false, include_root: true, 48 | root: 3, namespaces_nesting_level: 666 }, 49 | { camelize: true, include_namespaces: false, include_root: true, 50 | root: -3, namespaces_nesting_level: 666 }, 51 | { camelize: true, include_namespaces: false, include_root: true, 52 | root: 3.14, namespaces_nesting_level: 666 }, 53 | ].freeze 54 | -------------------------------------------------------------------------------- /spec/support/shared_contexts/parameters_contexts.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../parameters' 4 | 5 | shared_context 'error is raised for invalid params: instance' do 6 | INVALID_PARAMS.each do |params| 7 | it "fails with #{params}" do 8 | expect { instance.surrealize(**params) }.to raise_error(ArgumentError) 9 | end 10 | end 11 | end 12 | 13 | shared_context 'error is not raised for valid params: instance' do 14 | VALID_PARAMS.each do |params| 15 | it "works with #{params}" do 16 | expect { instance.surrealize(**params) }.not_to raise_error 17 | expect(instance.surrealize(**params)).to be_a(String) 18 | end 19 | end 20 | end 21 | 22 | shared_context 'error is raised for invalid params: collection' do 23 | INVALID_PARAMS.each do |params| 24 | it "fails with #{params}" do 25 | expect { Surrealist.surrealize_collection(collection, **params) } 26 | .to raise_error(ArgumentError) 27 | end 28 | end 29 | end 30 | 31 | shared_context 'error is not raised for valid params: collection' do 32 | VALID_PARAMS.each do |params| 33 | it "works with #{params}" do 34 | expect { Surrealist.surrealize_collection(collection, **params) }.not_to raise_error 35 | expect(Surrealist.surrealize_collection(collection, **params)).to be_a(String) 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /spec/support/shared_examples/hash_examples.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | shared_examples 'hash is cloned deeply and it`s structure is not changed' do 4 | specify do 5 | expect(copy).to eq(object) 6 | expect(copy).to eql(object) 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /spec/surrealist_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Baz 4 | include Surrealist 5 | 6 | json_schema do 7 | { 8 | foo: Integer, 9 | bar: Array, 10 | anything: Any, 11 | nested: { 12 | left_side: String, 13 | right_side: Bool, 14 | }, 15 | } 16 | end 17 | 18 | def foo 19 | 4 20 | end 21 | 22 | def bar 23 | [1, 3, 5] 24 | end 25 | 26 | protected 27 | 28 | def anything 29 | [{ some: 'thing' }] 30 | end 31 | 32 | private 33 | 34 | def left_side 35 | 'left' 36 | end 37 | 38 | def right_side 39 | true 40 | end 41 | 42 | # expecting: 43 | # { 44 | # foo: 4, bar: [1, 3, 5], 45 | # anything: [{ some: 'thing' }], 46 | # nested: { 47 | # left_side: 'left', 48 | # right_side: true 49 | # } 50 | # } 51 | end 52 | 53 | class WrongTypes 54 | include Surrealist 55 | 56 | json_schema do 57 | { foo: Integer } 58 | end 59 | 60 | def foo 61 | 'string' 62 | end 63 | 64 | # expecting: Surrealist::InvalidTypeError 65 | end 66 | 67 | class WithoutSchema 68 | include Surrealist 69 | 70 | # expecting: Surrealist::UnknownSchemaError 71 | end 72 | 73 | class Parent 74 | private def foo 75 | 'foo' 76 | end 77 | 78 | # expecting: see Child 79 | end 80 | 81 | class Child < Parent 82 | include Surrealist 83 | 84 | json_schema do 85 | { 86 | foo: String, 87 | bar: Array, 88 | } 89 | end 90 | 91 | def bar 92 | [1, 2] 93 | end 94 | 95 | # expecting: { foo: 'foo', bar: [1, 2] } 96 | end 97 | 98 | class WithAttrReaders 99 | include Surrealist 100 | 101 | attr_reader :foo, :bar 102 | 103 | json_schema do 104 | { 105 | foo: String, 106 | bar: Array, 107 | } 108 | end 109 | 110 | def initialize 111 | @foo = 'foo' 112 | @bar = [1, 2] 113 | end 114 | 115 | # expecting: { foo: 'foo', bar: [1, 2] } 116 | end 117 | 118 | class WithNil 119 | include Surrealist 120 | 121 | json_schema do 122 | { foo: NilClass } 123 | end 124 | 125 | def foo; end 126 | 127 | # expecting: { foo: nil } 128 | end 129 | 130 | class WithNull 131 | include Surrealist 132 | 133 | json_schema do 134 | { foo: String } 135 | end 136 | 137 | def foo; end 138 | 139 | # expecting: { foo: nil } 140 | end 141 | 142 | class WithNestedObjects 143 | include Surrealist 144 | 145 | json_schema do 146 | { foo: { bar_bar: Integer } } 147 | end 148 | 149 | def foo 150 | Struct.new(:bar_bar).new(123) 151 | end 152 | 153 | # expecting: { foo: { bar_bar: 123 } } 154 | end 155 | 156 | class MultiMethodStruct 157 | include Surrealist 158 | 159 | json_schema do 160 | { 161 | foo: { 162 | bar_bar: Integer, 163 | baz_baz: String, 164 | }, 165 | } 166 | end 167 | 168 | def foo 169 | Struct.new(:bar_bar, :baz_baz).new(123, 'string') 170 | end 171 | 172 | # expecting: { foo: { bar_bar: 123, baz_baz: 'string' } } 173 | end 174 | 175 | RSpec.describe Surrealist do 176 | describe '#surrealize & #build_schema' do 177 | context 'with defined schema' do 178 | context 'with correct types' do 179 | let(:instance) { Baz.new } 180 | 181 | it 'surrealizes' do 182 | expect(JSON.parse(instance.surrealize)) 183 | .to eq('foo' => 4, 'bar' => [1, 3, 5], 'anything' => [{ 'some' => 'thing' }], 184 | 'nested' => { 'left_side' => 'left', 'right_side' => true }) 185 | end 186 | 187 | it 'builds schema' do 188 | expect(instance.build_schema) 189 | .to eq(foo: 4, bar: [1, 3, 5], anything: [{ some: 'thing' }], 190 | nested: { left_side: 'left', right_side: true }) 191 | end 192 | 193 | it 'camelizes' do 194 | expect(JSON.parse(instance.surrealize(camelize: true))) 195 | .to eq('foo' => 4, 'bar' => [1, 3, 5], 'anything' => [{ 'some' => 'thing' }], 196 | 'nested' => { 'leftSide' => 'left', 'rightSide' => true }) 197 | 198 | expect(instance.build_schema(camelize: true)) 199 | .to eq(foo: 4, bar: [1, 3, 5], anything: [{ some: 'thing' }], 200 | nested: { leftSide: 'left', rightSide: true }) 201 | end 202 | end 203 | 204 | context 'with wrong types' do 205 | it 'raises TypeError' do 206 | error_text = 'Wrong type for key `foo`. Expected Integer, got String.' 207 | 208 | expect { WrongTypes.new.surrealize } 209 | .to raise_error(Surrealist::InvalidTypeError, error_text) 210 | expect { WrongTypes.new.build_schema } 211 | .to raise_error(Surrealist::InvalidTypeError, error_text) 212 | end 213 | end 214 | 215 | context 'with inheritance' do 216 | let(:instance) { Child.new } 217 | 218 | it 'surrealizes' do 219 | expect(JSON.parse(instance.surrealize)).to eq('foo' => 'foo', 'bar' => [1, 2]) 220 | end 221 | 222 | it 'builds schema' do 223 | expect(instance.build_schema).to eq(foo: 'foo', bar: [1, 2]) 224 | end 225 | end 226 | 227 | context 'with attr_readers' do 228 | let(:instance) { WithAttrReaders.new } 229 | 230 | it 'surrealizes' do 231 | expect(JSON.parse(instance.surrealize)).to eq('foo' => 'foo', 'bar' => [1, 2]) 232 | end 233 | 234 | it 'builds schema' do 235 | expect(instance.build_schema).to eq(foo: 'foo', bar: [1, 2]) 236 | end 237 | end 238 | 239 | context 'with NilClass' do 240 | it 'works' do 241 | expect(JSON.parse(WithNil.new.surrealize)).to eq('foo' => nil) 242 | expect(WithNil.new.build_schema).to eq(foo: nil) 243 | end 244 | end 245 | 246 | context 'with nil values' do 247 | it 'returns null' do 248 | instance = WithNull.new 249 | 250 | expect(JSON.parse(instance.surrealize)).to eq('foo' => nil) 251 | expect(instance.build_schema).to eq(foo: nil) 252 | end 253 | end 254 | 255 | context 'with nested objects' do 256 | let(:instance) { WithNestedObjects.new } 257 | 258 | it 'surrealizes & tries to invoke the method on the object' do 259 | expect(JSON.parse(instance.surrealize)).to eq('foo' => { 'bar_bar' => 123 }) 260 | end 261 | 262 | it 'builds schema & tries to invoke the method on the object' do 263 | expect(instance.build_schema).to eq(foo: { bar_bar: 123 }) 264 | end 265 | 266 | it 'camelizes' do 267 | expect(JSON.parse(instance.surrealize(camelize: true))) 268 | .to eq('foo' => { 'barBar' => 123 }) 269 | 270 | expect(instance.build_schema(camelize: true)).to eq(foo: { barBar: 123 }) 271 | end 272 | end 273 | 274 | context 'with multi-method struct' do 275 | let(:instance) { MultiMethodStruct.new } 276 | 277 | it 'surrealizes' do 278 | expect(JSON.parse(instance.surrealize)) 279 | .to eq('foo' => { 'bar_bar' => 123, 'baz_baz' => 'string' }) 280 | end 281 | 282 | it 'builds schema' do 283 | expect(instance.build_schema) 284 | .to eq(foo: { bar_bar: 123, baz_baz: 'string' }) 285 | end 286 | 287 | it 'camelizes' do 288 | expect(JSON.parse(instance.surrealize(camelize: true))) 289 | .to eq('foo' => { 'barBar' => 123, 'bazBaz' => 'string' }) 290 | 291 | expect(instance.build_schema(camelize: true)) 292 | .to eq(foo: { barBar: 123, bazBaz: 'string' }) 293 | end 294 | end 295 | end 296 | 297 | context 'with undefined schema' do 298 | let(:error) { "Can't serialize WithoutSchema - no schema was provided." } 299 | 300 | it 'raises Surrealist::UnknownSchemaError on #surrealize' do 301 | expect { WithoutSchema.new.surrealize } 302 | .to raise_error(Surrealist::UnknownSchemaError, error) 303 | 304 | expect { WithoutSchema.new.surrealize(camelize: true) } 305 | .to raise_error(Surrealist::UnknownSchemaError, error) 306 | end 307 | end 308 | 309 | context 'with anonymous classes' do 310 | context 'with `include_root` passed' do 311 | let(:error) { "Can't wrap schema in root key - class name was not passed" } 312 | let(:instance) do 313 | Class.new do 314 | include Surrealist 315 | 316 | json_schema { { name: String } } 317 | 318 | def name; 'string'; end 319 | end.new 320 | end 321 | 322 | it 'raises Surrealist::UnknownRootError on #surrealize' do 323 | expect { instance.surrealize(include_root: true) } 324 | .to raise_error(Surrealist::UnknownRootError, error) 325 | end 326 | end 327 | 328 | context 'without `include_root`' do 329 | let(:instance) do 330 | Class.new do 331 | include Surrealist 332 | 333 | json_schema { { name: String } } 334 | 335 | def name; 'string'; end 336 | end.new 337 | end 338 | 339 | it 'surrealizes' do 340 | expect(JSON.parse(instance.surrealize)).to eq('name' => 'string') 341 | end 342 | end 343 | end 344 | 345 | context 'plain Ruby array' do 346 | let(:array) { [1, 3, 'dog'] } 347 | 348 | it { expect(Surrealist.surrealize_collection(array)).to eq('[1,3,"dog"]') } 349 | end 350 | end 351 | end 352 | -------------------------------------------------------------------------------- /spec/type_helper_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Surrealist::TypeHelper do 4 | describe '#valid_type?' do 5 | context 'nil values' do 6 | context 'plain ruby classes' do 7 | [ 8 | [nil, String], 9 | [nil, Integer], 10 | [nil, Array], 11 | [nil, Object], 12 | [nil, Any], 13 | [nil, Float], 14 | [nil, Hash], 15 | [nil, BigDecimal], 16 | [nil, Symbol], 17 | ].each do |params| 18 | it "returns true for #{params} because value is nil" do 19 | expect(described_class.valid_type?(*params)).to eq true 20 | end 21 | end 22 | end 23 | 24 | context 'optional dry-types' do 25 | [ 26 | [nil, Types::Any], 27 | [nil, Types::JSON::Nil], 28 | [nil, Types::String.optional], 29 | [nil, Types::Integer.optional], 30 | [nil, Types::Array.optional], 31 | [nil, Types::Bool.optional], 32 | [nil, Types::Any.optional], 33 | [nil, Types::Any.optional], 34 | [nil, Types::Symbol.optional], 35 | [nil, Types::Class.optional], 36 | [nil, Types::True.optional], 37 | [nil, Types::False.optional], 38 | [nil, Types::Float.optional], 39 | [nil, Types::Decimal.optional], 40 | [nil, Types::Hash.optional], 41 | [nil, Types::Date.optional], 42 | [nil, Types::DateTime.optional], 43 | [nil, Types::Time.optional], 44 | [nil, Types::JSON::Nil.optional], 45 | [nil, Types::JSON::Date.optional], 46 | [nil, Types::JSON::DateTime.optional], 47 | [nil, Types::JSON::Time.optional], 48 | [nil, Types::JSON::Decimal.optional], 49 | [nil, Types::JSON::Array.optional], 50 | [nil, Types::JSON::Hash.optional], 51 | ].each do |params| 52 | it "returns true for #{params} because value is nil and type is not strict" do 53 | expect(described_class.valid_type?(*params)).to eq true 54 | end 55 | end 56 | end 57 | 58 | context 'non-optional dry-types' do 59 | [ 60 | [nil, Types::String], 61 | [nil, Types::Integer], 62 | [nil, Types::Array], 63 | [nil, Types::Bool], 64 | [nil, Types::Symbol], 65 | [nil, Types::Class], 66 | [nil, Types::True], 67 | [nil, Types::False], 68 | [nil, Types::Float], 69 | [nil, Types::Decimal], 70 | [nil, Types::Hash], 71 | [nil, Types::Date], 72 | [nil, Types::DateTime], 73 | [nil, Types::Time], 74 | [nil, Types::JSON::Date], 75 | [nil, Types::JSON::DateTime], 76 | [nil, Types::JSON::Time], 77 | [nil, Types::JSON::Array], 78 | [nil, Types::JSON::Hash], 79 | ].each do |params| 80 | it "returns false for #{params} because value is nil" do 81 | expect(described_class.valid_type?(*params)).to eq false 82 | end 83 | end 84 | end 85 | 86 | context 'strict dry-types' do 87 | [ 88 | [nil, Types::Strict::Class], 89 | [nil, Types::Strict::True], 90 | [nil, Types::Strict::False], 91 | [nil, Types::Strict::Bool], 92 | [nil, Types::Strict::Integer], 93 | [nil, Types::Strict::Float], 94 | [nil, Types::Strict::Decimal], 95 | [nil, Types::Strict::String], 96 | [nil, Types::Strict::Date], 97 | [nil, Types::Strict::DateTime], 98 | [nil, Types::Strict::Time], 99 | [nil, Types::Strict::Array], 100 | [nil, Types::Strict::Hash], 101 | ].each do |params| 102 | it "returns false for #{params} because value is nil & type is strict" do 103 | expect(described_class.valid_type?(*params)).to eq false 104 | end 105 | end 106 | end 107 | 108 | context 'coercible dry-types' do 109 | context 'types that can be coerced from nil' do 110 | [ 111 | [nil, Types::Coercible::String], 112 | [nil, Types::Coercible::Array], 113 | [nil, Types::Coercible::Hash], 114 | ].each do |params| 115 | it "returns true for #{params} because nil can be coerced" do 116 | expect(described_class.valid_type?(*params)).to eq true 117 | end 118 | end 119 | end 120 | 121 | context 'types that can\'t be coerced' do 122 | [ 123 | [nil, Types::Coercible::Integer], 124 | [nil, Types::Coercible::Float], 125 | [nil, Types::Coercible::Decimal], 126 | ].each do |params| 127 | it "returns false for #{params} because nil can't be coerced" do 128 | expect(described_class.valid_type?(*params)).to eq false 129 | end 130 | end 131 | end 132 | end 133 | end 134 | 135 | context 'string values' do 136 | [ 137 | ['string', String], 138 | ['string', Types::String], 139 | ['string', Types::Coercible::String], 140 | ['string', Types::Strict::String], 141 | ['string', Types::String.optional], 142 | ].each do |params| 143 | it "returns true for #{params}" do 144 | expect(described_class.valid_type?(*params)).to eq true 145 | end 146 | end 147 | end 148 | 149 | context 'integer values' do 150 | [ 151 | [666, Integer], 152 | [666, Types::Integer], 153 | [666, Types::Integer.optional], 154 | [666, Types::Strict::Integer], 155 | [666, Types::Coercible::Integer], 156 | ].each do |params| 157 | it "returns true for #{params}" do 158 | expect(described_class.valid_type?(*params)).to eq true 159 | end 160 | end 161 | end 162 | 163 | context 'array values' do 164 | [ 165 | [%w[an array], Array], 166 | [%w[an array], Types::Array], 167 | [%w[an array], Types::Array.optional], 168 | [%w[an array], Types::Strict::Array], 169 | [%w[an array], Types::JSON::Array], 170 | [%w[an array], Types::JSON::Array.optional], 171 | [%w[an array], Types::Coercible::Array], 172 | [%w[an array], Types::Coercible::Array.optional], 173 | ].each do |params| 174 | it "returns true for #{params}" do 175 | expect(described_class.valid_type?(*params)).to eq true 176 | end 177 | end 178 | end 179 | 180 | context 'hash values' do 181 | [ 182 | [{ a: :b }, Hash], 183 | [{ a: :b }, Types::Hash], 184 | [{ a: :b }, Types::Hash.optional], 185 | [{ a: :b }, Types::Strict::Hash], 186 | [{ a: :b }, Types::JSON::Hash], 187 | [{ a: :b }, Types::JSON::Hash.optional], 188 | [{ a: :b }, Types::Coercible::Hash], 189 | [{ a: :b }, Types::Coercible::Hash.optional], 190 | ].each do |params| 191 | it "returns true for #{params}" do 192 | expect(described_class.valid_type?(*params)).to eq true 193 | end 194 | end 195 | end 196 | 197 | context 'any values' do 198 | [ 199 | [nil, Any], 200 | ['nil', Any], 201 | [:nil, Any], 202 | [[nil], Any], 203 | [{ nil: :nil }, Any], 204 | [nil, Types::Any], 205 | ['nil', Types::Any], 206 | [:nil, Types::Any], 207 | [[nil], Types::Any], 208 | [{ nil: :nil }, Types::Any], 209 | ].each do |params| 210 | it "returns true for #{params}" do 211 | expect(described_class.valid_type?(*params)).to eq true 212 | end 213 | end 214 | end 215 | 216 | context 'boolean values' do 217 | [ 218 | [true, Bool], 219 | [false, Bool], 220 | [false, Types::Bool], 221 | [true, Types::Bool], 222 | [nil, Bool], 223 | ].each do |params| 224 | it "returns true for #{params}" do 225 | expect(described_class.valid_type?(*params)).to eq true 226 | end 227 | end 228 | end 229 | 230 | context 'symbol values' do 231 | [ 232 | [:sym, Symbol], 233 | [:sym, Types::Symbol], 234 | [:sym, Types::Symbol.optional], 235 | [:sym, Types::Strict::Symbol], 236 | ].each do |params| 237 | it "returns true for #{params}" do 238 | expect(described_class.valid_type?(*params)).to eq true 239 | end 240 | end 241 | end 242 | end 243 | 244 | describe '#coerce' do 245 | [ 246 | [nil, String], 247 | [nil, Integer], 248 | [nil, Array], 249 | [nil, Object], 250 | [nil, Any], 251 | [nil, Float], 252 | [nil, Hash], 253 | [nil, BigDecimal], 254 | [nil, Symbol], 255 | ['something', String], 256 | ['something', Integer], 257 | ['something', Array], 258 | ['something', Object], 259 | ['something', Any], 260 | ['something', Float], 261 | ['something', Hash], 262 | ['something', BigDecimal], 263 | ['something', Symbol], 264 | ].each do |params| 265 | it "returns value for non-dry-types for #{params}" do 266 | expect(described_class.coerce(*params)).to eq(params.first) 267 | end 268 | end 269 | 270 | [ 271 | ['smth', Types::String], 272 | [23, Types::Integer], 273 | [[2], Types::Array], 274 | [true, Types::Bool], 275 | [:sym, Types::Symbol], 276 | [Array, Types::Class], 277 | [true, Types::True], 278 | [false, Types::False], 279 | [2.4, Types::Float], 280 | [35.4, Types::Decimal], 281 | [{ k: :v }, Types::Hash], 282 | [Date.new, Types::Date], 283 | [DateTime.new, Types::DateTime], 284 | [Time.new, Types::Time], 285 | [Date.new, Types::JSON::Date], 286 | [DateTime.new, Types::JSON::DateTime], 287 | [Time.new, Types::JSON::Time], 288 | [%w[ar ray], Types::JSON::Array], 289 | [{ k: :v }, Types::JSON::Hash], 290 | ].each do |params| 291 | it "returns value if it doesn't have to be coerced for #{params}" do 292 | expect(described_class.coerce(*params)).to eq(params.first) 293 | end 294 | end 295 | end 296 | 297 | # Testing coercing itself is kind of pointless, because dry-types have enough specs in their repo. 298 | end 299 | -------------------------------------------------------------------------------- /spec/ultimate_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Human 4 | include Surrealist 5 | 6 | attr_reader :name, :last_name 7 | 8 | json_schema do 9 | { 10 | name: String, 11 | last_name: String, 12 | properties: { 13 | gender: String, 14 | age: Integer, 15 | }, 16 | credit_card: { 17 | card_number: Integer, 18 | card_holder: String, 19 | }, 20 | children: { 21 | male: { 22 | count: Integer, 23 | }, 24 | female: { 25 | count: Integer, 26 | }, 27 | }, 28 | } 29 | end 30 | 31 | def initialize(name, last_name) 32 | @name = name 33 | @last_name = last_name 34 | end 35 | 36 | def properties 37 | Properties.new('male', 42) 38 | end 39 | 40 | def full_name 41 | "#{name} #{last_name}" 42 | end 43 | 44 | def credit_card 45 | CreditCard.new(number: 1234, holder: full_name) 46 | end 47 | 48 | def children 49 | Kid.find(person: 'John') 50 | end 51 | end 52 | 53 | Properties = Struct.new(:gender, :age) 54 | 55 | class CreditCard 56 | attr_reader :card_number, :card_holder 57 | 58 | def initialize(number:, holder:) 59 | @card_number = number 60 | @card_holder = holder 61 | end 62 | end 63 | 64 | class Kid 65 | attr_reader :person 66 | 67 | def self.find(person:) 68 | new(person: person) 69 | end 70 | 71 | def initialize(person:) 72 | @person = person 73 | end 74 | 75 | def male 76 | person == 'John' ? { count: 2 } : { count: 1 } 77 | end 78 | 79 | def female 80 | person == 'John' ? { count: 1 } : { count: 2 } 81 | end 82 | end 83 | 84 | class WithWeirdSchema 85 | include Surrealist 86 | 87 | json_schema do 88 | { 89 | one: { 90 | two: { 91 | three: { 92 | something: String, 93 | level: { 94 | deeper: {}, 95 | another: String, 96 | }, 97 | }, 98 | }, 99 | }, 100 | } 101 | end 102 | 103 | def something 104 | 'a string' 105 | end 106 | 107 | def another 108 | 'another string' 109 | end 110 | end 111 | 112 | RSpec.describe Surrealist do 113 | context 'ultimate spec' do 114 | let(:human) { Human.new('John', 'Doe') } 115 | 116 | it 'surrealizes' do 117 | expect(JSON.parse(human.surrealize)) 118 | .to eq('name' => 'John', 'last_name' => 'Doe', 'properties' => { 119 | 'gender' => 'male', 'age' => 42 120 | }, 'credit_card' => { 121 | 'card_number' => 1234, 'card_holder' => 'John Doe' 122 | }, 'children' => { 123 | 'male' => { 'count' => 2 }, 124 | 'female' => { 'count' => 1 }, 125 | }) 126 | end 127 | 128 | it 'builds schema' do 129 | expect(human.build_schema) 130 | .to eq(name: 'John', last_name: 'Doe', properties: { gender: 'male', age: 42 }, 131 | credit_card: { card_number: 1234, card_holder: 'John Doe' }, 132 | children: { male: { count: 2 }, female: { count: 1 } }) 133 | end 134 | 135 | it 'camelizes' do 136 | expect(JSON.parse(human.surrealize(camelize: true))) 137 | .to eq('name' => 'John', 'lastName' => 'Doe', 'properties' => { 138 | 'gender' => 'male', 'age' => 42 139 | }, 'creditCard' => { 140 | 'cardNumber' => 1234, 'cardHolder' => 'John Doe' 141 | }, 'children' => { 142 | 'male' => { 'count' => 2 }, 143 | 'female' => { 'count' => 1 }, 144 | }) 145 | 146 | expect(human.build_schema(camelize: true)) 147 | .to eq(name: 'John', lastName: 'Doe', properties: { gender: 'male', age: 42 }, 148 | creditCard: { cardNumber: 1234, cardHolder: 'John Doe' }, 149 | children: { male: { count: 2 }, female: { count: 1 } }) 150 | end 151 | 152 | it 'includes root' do 153 | expect(JSON.parse(human.surrealize(camelize: true, include_root: true))) 154 | .to eq('human' => { 155 | 'name' => 'John', 'lastName' => 'Doe', 'properties' => { 156 | 'gender' => 'male', 'age' => 42 157 | }, 'creditCard' => { 158 | 'cardNumber' => 1234, 'cardHolder' => 'John Doe' 159 | }, 'children' => { 160 | 'male' => { 'count' => 2 }, 161 | 'female' => { 'count' => 1 }, 162 | } 163 | }) 164 | 165 | expect(human.build_schema(camelize: true, include_root: true)) 166 | .to eq(human: { 167 | name: 'John', lastName: 'Doe', properties: { gender: 'male', age: 42 }, 168 | creditCard: { cardNumber: 1234, cardHolder: 'John Doe' }, 169 | children: { male: { count: 2 }, female: { count: 1 } } 170 | }) 171 | end 172 | end 173 | 174 | context 'weird schema' do 175 | it 'works' do 176 | expect(WithWeirdSchema.new.build_schema) 177 | .to eq(one: { two: { three: { 178 | something: 'a string', level: { deeper: {}, another: 'another string' } 179 | } } }) 180 | end 181 | end 182 | end 183 | -------------------------------------------------------------------------------- /spec/wrapper_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class NullCarrier 4 | attr_reader :camelize, :include_root, :include_namespaces, :namespaces_nesting_level, :root 5 | 6 | def initialize(camelize: false) 7 | @camelize = camelize 8 | @include_root = false 9 | @include_namespaces = false 10 | @namespaces_nesting_level = Surrealist::DEFAULT_NESTING_LEVEL 11 | @root = nil 12 | end 13 | 14 | def no_args_provided? 15 | camelize == false && include_root == false && include_namespaces == false && 16 | root.nil? && namespaces_nesting_level == Surrealist::DEFAULT_NESTING_LEVEL 17 | end 18 | end 19 | 20 | RSpec.describe Surrealist::Wrapper do 21 | describe '#wrap' do 22 | shared_examples 'UnknownRootError is raised' do 23 | specify do 24 | expect { wrapped_hash }.to raise_error(Surrealist::UnknownRootError, error) 25 | end 26 | end 27 | 28 | shared_examples 'schema is camelized and wrapped in the klass root key' do 29 | specify do 30 | expect(wrapped_hash).to eq(someClass: { a: 3, nested_thing: { key: :value } }) 31 | end 32 | end 33 | 34 | shared_examples 'schema is wrapped in the klass root key' do 35 | specify do 36 | expect(wrapped_hash).to eq(some_class: { a: 3, nested_thing: { key: :value } }) 37 | end 38 | end 39 | 40 | let(:object) { { a: 3, nested_thing: { key: :value } } } 41 | let(:klass) { 'SomeClass' } 42 | let(:error) { "Can't wrap schema in root key - class name was not passed" } 43 | 44 | args_with_root_and_camelize = [ 45 | { camelize: true, include_namespaces: true, include_root: true, 46 | root: nil, namespaces_nesting_level: 3 }, 47 | { camelize: true, include_namespaces: true, include_root: true, 48 | root: nil, namespaces_nesting_level: 666 }, 49 | { camelize: true, include_namespaces: false, include_root: true, 50 | root: nil, namespaces_nesting_level: 3 }, 51 | { camelize: true, include_namespaces: false, include_root: true, 52 | root: nil, namespaces_nesting_level: 666 }, 53 | { camelize: true, include_namespaces: true, include_root: false, 54 | root: nil, namespaces_nesting_level: 3 }, 55 | { camelize: true, include_namespaces: true, include_root: false, 56 | root: nil, namespaces_nesting_level: 666 }, 57 | { camelize: true, include_namespaces: false, include_root: false, 58 | root: nil, namespaces_nesting_level: 3 }, 59 | ] 60 | 61 | args_with_root_and_without_camelize = [ 62 | { camelize: false, include_namespaces: true, include_root: true, 63 | root: nil, namespaces_nesting_level: 3 }, 64 | { camelize: false, include_namespaces: true, include_root: true, 65 | root: nil, namespaces_nesting_level: 666 }, 66 | { camelize: false, include_namespaces: false, include_root: true, 67 | root: nil, namespaces_nesting_level: 3 }, 68 | { camelize: false, include_namespaces: false, include_root: true, 69 | root: nil, namespaces_nesting_level: 666 }, 70 | { camelize: false, include_namespaces: true, include_root: false, 71 | root: nil, namespaces_nesting_level: 3 }, 72 | { camelize: false, include_namespaces: true, include_root: false, 73 | root: nil, namespaces_nesting_level: 666 }, 74 | { camelize: false, include_namespaces: false, include_root: false, 75 | root: nil, namespaces_nesting_level: 3 }, 76 | ] 77 | 78 | args_without_root = [ 79 | { camelize: false, include_namespaces: false, include_root: false, 80 | root: nil, namespaces_nesting_level: 666 }, 81 | { camelize: true, include_namespaces: false, include_root: false, 82 | root: nil, namespaces_nesting_level: 666 }, 83 | ] 84 | 85 | context 'with `camelize: true`' do 86 | args_with_root_and_camelize.each do |hsh| 87 | carrier = Surrealist::Carrier.call(**hsh) 88 | it_behaves_like 'schema is camelized and wrapped in the klass root key' do 89 | let(:wrapped_hash) { described_class.wrap(object, carrier, klass: klass) } 90 | end 91 | end 92 | end 93 | 94 | context 'with `camelize: false`' do 95 | args_with_root_and_without_camelize.each do |hsh| 96 | carrier = Surrealist::Carrier.call(**hsh) 97 | it_behaves_like 'schema is wrapped in the klass root key' do 98 | let(:wrapped_hash) { described_class.wrap(object, carrier, klass: klass) } 99 | end 100 | end 101 | end 102 | 103 | context 'without klass' do 104 | args_with_root_and_camelize.zip(args_with_root_and_without_camelize).flatten.compact.each do |hsh| 105 | carrier = Surrealist::Carrier.call(**hsh) 106 | it_behaves_like 'UnknownRootError is raised' do 107 | let(:wrapped_hash) { described_class.wrap(object, carrier) } 108 | end 109 | end 110 | end 111 | 112 | context 'without wrapping' do 113 | args_without_root.each do |hsh| 114 | carrier = Surrealist::Carrier.call(**hsh) 115 | it_behaves_like 'hash is cloned deeply and it`s structure is not changed' do 116 | let(:copy) { described_class.wrap(object, carrier, klass: klass) } 117 | end 118 | end 119 | end 120 | 121 | context 'with NullCarrier' do 122 | context 'hash & carrier' do 123 | let(:copy) { described_class.wrap(object, NullCarrier.new) } 124 | 125 | it_behaves_like 'hash is cloned deeply and it`s structure is not changed' 126 | end 127 | 128 | context 'with camelize' do 129 | let(:copy) { described_class.wrap(object, NullCarrier.new(camelize: true)) } 130 | 131 | it_behaves_like 'hash is cloned deeply and it`s structure is not changed' 132 | end 133 | 134 | context 'with klass' do 135 | let(:copy) { described_class.wrap(object, NullCarrier.new, klass: klass) } 136 | 137 | it_behaves_like 'hash is cloned deeply and it`s structure is not changed' 138 | end 139 | 140 | context 'with klass and camelize' do 141 | let(:copy) do 142 | described_class.wrap(object, NullCarrier.new(camelize: true), klass: klass) 143 | end 144 | 145 | it_behaves_like 'hash is cloned deeply and it`s structure is not changed' 146 | end 147 | end 148 | end 149 | end 150 | -------------------------------------------------------------------------------- /surrealist-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nesaulov/surrealist/26c6a1949354e34b2e07eb210acee179c516030f/surrealist-icon.png -------------------------------------------------------------------------------- /surrealist.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | lib = File.expand_path('lib', __dir__) 4 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 5 | require 'surrealist/version' 6 | 7 | Gem::Specification.new do |spec| 8 | spec.name = 'surrealist' 9 | spec.version = Surrealist::VERSION 10 | spec.authors = ['Nikita Esaulov'] 11 | spec.email = ['billikota@gmail.com'] 12 | 13 | spec.summary = 'A gem that provides DSL for serialization of plain old Ruby objects to JSON ' \ 14 | 'in a declarative style.' 15 | spec.description = 'A gem that provides DSL for serialization of plain old Ruby objects to JSON ' \ 16 | 'in a declarative style by defining a `schema`. ' \ 17 | 'It also provides a trivial type checking in the runtime before serialization.' 18 | spec.homepage = 'https://github.com/nesaulov/surrealist' 19 | spec.license = 'MIT' 20 | 21 | spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 22 | spec.bindir = 'exe' 23 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 24 | spec.require_paths = ['lib'] 25 | spec.required_ruby_version = '>= 2.5.0' 26 | 27 | spec.add_runtime_dependency 'oj', '~> 3.11' 28 | 29 | spec.add_development_dependency 'bundler', '~> 2.0' 30 | spec.add_development_dependency 'pry', '~> 0.13' 31 | spec.add_development_dependency 'rake', '~> 13.0' 32 | spec.add_development_dependency 'rspec', '~> 3.10' 33 | spec.add_development_dependency 'rubocop', '~> 1.9' 34 | end 35 | --------------------------------------------------------------------------------