├── .gitignore ├── Appraisals ├── Changes ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── class2.gemspec ├── gemfiles ├── as4.gemfile ├── as5.gemfile └── as6.gemfile ├── lib ├── class2.rb └── class2 │ ├── autoload.rb │ ├── autoload │ └── namespaced.rb │ └── version.rb └── spec ├── class2_spec.rb └── fixtures ├── autoload.rb ├── dependency.rb └── main.rb /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/emacs,ruby 3 | 4 | ### Emacs ### 5 | # -*- mode: gitignore; -*- 6 | *~ 7 | \#*\# 8 | /.emacs.desktop 9 | /.emacs.desktop.lock 10 | *.elc 11 | auto-save-list 12 | tramp 13 | .\#* 14 | 15 | # Org-mode 16 | .org-id-locations 17 | *_archive 18 | 19 | # flymake-mode 20 | *_flymake.* 21 | 22 | # eshell files 23 | /eshell/history 24 | /eshell/lastdir 25 | 26 | # elpa packages 27 | /elpa/ 28 | 29 | # reftex files 30 | *.rel 31 | 32 | # AUCTeX auto folder 33 | /auto/ 34 | 35 | # cask packages 36 | .cask/ 37 | dist/ 38 | 39 | # Flycheck 40 | flycheck_*.el 41 | 42 | # server auth directory 43 | /server/ 44 | 45 | # projectiles files 46 | .projectile 47 | 48 | # directory configuration 49 | .dir-locals.el 50 | 51 | ### Ruby ### 52 | *.gem 53 | *.rbc 54 | /.config 55 | /coverage/ 56 | /InstalledFiles 57 | /pkg/ 58 | /spec/reports/ 59 | /spec/examples.txt 60 | /test/tmp/ 61 | /test/version_tmp/ 62 | /tmp/ 63 | 64 | # Used by dotenv library to load environment variables. 65 | # .env 66 | 67 | ## Specific to RubyMotion: 68 | .dat* 69 | .repl_history 70 | build/ 71 | *.bridgesupport 72 | build-iPhoneOS/ 73 | build-iPhoneSimulator/ 74 | 75 | ## Specific to RubyMotion (use of CocoaPods): 76 | # 77 | # We recommend against adding the Pods directory to your .gitignore. However 78 | # you should judge for yourself, the pros and cons are mentioned at: 79 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 80 | # 81 | # vendor/Pods/ 82 | 83 | ## Documentation cache and generated files: 84 | /.yardoc/ 85 | /_yardoc/ 86 | /doc/ 87 | /rdoc/ 88 | 89 | ## Environment normalization: 90 | /.bundle/ 91 | /vendor/bundle 92 | /lib/bundler/man/ 93 | 94 | # for a library or gem, you might want to ignore these files since the code is 95 | # intended to run in multiple environments; otherwise, check them in: 96 | Gemfile.lock 97 | # .ruby-version 98 | # .ruby-gemset 99 | 100 | # unless supporting rvm < 1.11.0 or doing something fancy, ignore this: 101 | .rvmrc 102 | 103 | # End of https://www.gitignore.io/api/emacs,ruby 104 | -------------------------------------------------------------------------------- /Appraisals: -------------------------------------------------------------------------------- 1 | appraise "as4" do 2 | gem "activesupport", "~> 4.2" 3 | end 4 | 5 | appraise "as5" do 6 | gem "activesupport", "~> 5.2" 7 | end 8 | 9 | appraise "as6" do 10 | gem "activesupport", "> 5", "< 7" 11 | end 12 | -------------------------------------------------------------------------------- /Changes: -------------------------------------------------------------------------------- 1 | 2024-10-15 v0.6.0 2 | -------------------- 3 | * Bug fix: autoloading in data section with namespace 4 | * Add support for Time conversion 5 | * Remove use of deprecated Fixnum 6 | 7 | 2020-04-24 v0.5.2 8 | -------------------- 9 | * Bug fix: Fix NoMethodError in ActiveSupport 6's Module.parent 10 | 11 | 2018-06-26 v0.5.1 12 | -------------------- 13 | * Bug fix: autoload backtrace search "improvemnts" 14 | 15 | 2018-06-07 v0.5.0 16 | -------------------- 17 | * Add support for autoloading class definitions from JSON in a DATA section 18 | 19 | 2018-05-29 v0.4.1 20 | -------------------- 21 | * Fix a namespace'd class from not being defined if top-level class exists 22 | 23 | 2018-05-27 v0.4.0 24 | -------------------- 25 | * Bug fix: conversion bug when a module is used as a type specifier 26 | * Bug fix: Fixnum deprecation 27 | * Add support for JSON serialization and accessor formats that differ from definition 28 | * Add method that modules can use for initialization 29 | 30 | 2017-12-04 v0.3.0 31 | -------------------- 32 | * For attributes with a type of String don't convert nil to an empty string 33 | 34 | 2017-10-22 v0.2.4 35 | -------------------- 36 | * Add class2 filename and linenum to generated classes 37 | * Bug fix: use the correct class name when instantiating a default nested instance 38 | 39 | 2017-10-11 v0.2.3 40 | -------------------- 41 | * Bug fix: don't try to convert to Float, Fixnum, Date, DateTime when nil 42 | * Bug fix: more constant checking before creating 43 | 44 | 2017-09-28 v0.2.2 45 | -------------------- 46 | * Bug fix: check constant existance before creating 47 | 48 | 2017-09-23 v0.2.1 49 | -------------------- 50 | * Bug fix: to_h values should not always be a Hash 51 | 52 | 2017-09-14 v0.2.0 53 | -------------------- 54 | * Add class2 method 55 | 56 | 2017-08-15 v0.1.0 57 | -------------------- 58 | * Add support for blocks 59 | * Add Class2::StrictConstructor 60 | * Bug fix: to_h conversion failure caused element to be skipped 61 | 62 | 2017-08-10 v0.0.2 63 | -------------------- 64 | * Add support for types 65 | * Bug fix: don't attempt to create invalid method names 66 | * Bug fix: don't assume constructor arg is a Hash 67 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in class2.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Skye Shaw 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # class2 2 | 3 | Easily create class hierarchies that support nested attributes, type conversion, equality, and more. 4 | 5 | ## Usage 6 | 7 | ```rb 8 | class2 :user => [ 9 | :name, :age, 10 | :addresses => [ 11 | :city, :state, :zip, 12 | :country => [ :name, :code ] 13 | ] 14 | ] 15 | ``` 16 | 17 | This creates 3 classes: `User`, `Address`, and `Country` with the following attribute accessors: 18 | 19 | * `User`: name, age, addresses 20 | * `Address`: city, state, zip, country 21 | * `Country`: name, code 22 | 23 | 24 | Each of these classes are created with 25 | [several additional methods](#methods). You can also specify types 26 | (or [namespaces](#namespaces)): 27 | 28 | ```rb 29 | class2 :user => { 30 | :name => String, 31 | :age => Integer, 32 | :addresses => [ 33 | :city, :state, :zip, # No explicit types for these 34 | :country => { 35 | :name => String, 36 | :code => String 37 | } 38 | ] 39 | } 40 | ``` 41 | 42 | Attributes without types are treated as is. 43 | 44 | After calling either one of the above you can do the following: 45 | 46 | ```rb 47 | user = User.new( 48 | :name => "sshaw", 49 | :age => 99, 50 | :addresses => [ 51 | { :city => "LA", 52 | :country => { :code => "US" } }, 53 | { :city => "NY Sizzle", 54 | :country => { :code => "US" } }, 55 | { :city => "São José dos Campos", 56 | :country => { :code => "BR" } } 57 | ] 58 | ) 59 | 60 | user.name # "sshaw" 61 | user.addresses.size # 3 62 | user.addresses.first.city # "LA" 63 | user.to_h # {:name => "sshaw", :age => 99, :addresses => [ { ... } ]} 64 | 65 | # keys can be strings too 66 | country = Country.new("name" => "America", "code" => "US") 67 | address = Address.new(:city => "Da Bay", :state => "CA", :country => country) 68 | user.addresses << address 69 | 70 | User.new(:name => "sshaw") == User.new(:name => "sshaw") # true 71 | ``` 72 | 73 | `class2` can create classes with typed attributes from example hashes (with some caveats). 74 | This makes it possible to build classes for things like API responses using the API response 75 | itself as the specification: 76 | 77 | ```rb 78 | # From JSON.parse 79 | # of https://api.github.com/repos/sshaw/selfie_formatter/commits 80 | response = [ 81 | { 82 | "sha" => "f52f1ed9144e1f73346176ab79a61af78df1b6bd", 83 | "commit" => { 84 | "author"=> { 85 | "name"=>"sshaw", 86 | "email"=>"skye.shaw@gmail.com", 87 | "date"=>"2016-06-30T03:51:00Z" 88 | } 89 | }, 90 | "comment_count": 0 91 | 92 | # snip full response 93 | } 94 | ] 95 | 96 | class2 :commit => response.first do 97 | include Class2::SnakeCase::JSON 98 | end 99 | 100 | commit = Commit.new(response.first) 101 | commit.author.name # "sshaw" 102 | commit.comment_count # 0 103 | JSON.dump(commit) 104 | ``` 105 | 106 | If the JSON uses `camelCase` but you want your class to use `snake_case` you can do the following: 107 | 108 | ```rb 109 | class2 "commit" => { "camelCase" => { "someKey" => 123, "anotherKey" => 456 } } do 110 | include Class2::SnakeCase::Attributes # snake_case accessors 111 | include Class2::LowerCamelCase::JSON # but serialize using camelCase 112 | end 113 | 114 | commit = Commit.new(:camel_case => { :some_key => 55 }) 115 | commit.camel_case.some_key # 55 116 | 117 | commit = Commit.new(:camelCase => { :someKey => 55 }) 118 | commit.camel_case.some_key # 55 119 | ``` 120 | 121 | For more info on accessor formats and JSON see: 122 | 123 | * [`Class2::SnakeCase`](https://www.rubydoc.info/gems/class2/Class2/SnakeCase) 124 | * [`Class2::UpperCamelCase`](https://www.rubydoc.info/gems/class2/Class2/UpperCamelCase) 125 | * [`Class2::LowerCamelCase`](https://www.rubydoc.info/gems/class2/Class2/LowerCamelCase) 126 | 127 | [Using Ruby-specific JSON extensions](https://github.com/ruby/json?tab=readme-ov-file#usage) you can define 128 | Ruby types in the JSON class2 will use for type conversion: 129 | 130 | ```json 131 | { 132 | "your_class": { 133 | "id": 0, 134 | "name": "string", 135 | "updated_at": {"json_class":"Time","s":0,"n":0}, 136 | } 137 | } 138 | ``` 139 | 140 | Then require the appropriate JSON conversion class: 141 | 142 | ```rb 143 | require "json/add/time" 144 | ``` 145 | 146 | This will result in class2 creating a `MyClass` class with the following: 147 | 148 | - `#id` returning an `Integer` instance 149 | - `#name` returning a `String` instance 150 | - `#updated_at` returning a `Time` instance 151 | 152 | This will work for any [supported conversions](#conversions). 153 | 154 | You can also autoload a definition from a DATA section: 155 | 156 | ```rb 157 | require "class2/autoload" # builds classes from below JSON 158 | require "pp" 159 | 160 | commit = Commit.new(:author => { :name => "luser1" }) 161 | pp commit.to_h 162 | 163 | __END__ 164 | { 165 | "response": { 166 | "sha": "f52f1ed9144e1f73346176ab79a61af78df1b6bd", 167 | "commit": { 168 | "author": { 169 | "name": "sshaw", 170 | "email": "skye.shaw@gmail.com", 171 | "date": "2016-06-30T03:51:00Z" 172 | } 173 | }, 174 | "comment_count": 0 175 | } 176 | } 177 | ``` 178 | 179 | ### class2 API 180 | 181 | The are 3 ways to use class2. Pick the one that suites your style and/or requirements: 182 | 183 | * `class2()` 184 | * `Class2()` 185 | * `Class2.new` 186 | 187 | They all create classes the same way. They all return `nil`. 188 | 189 | To control the creation of the top-level methods, see the 190 | [`CLASS2_NO_EXPORT` environment variable](https://github.com/sshaw/class2/blob/a7ebe022b48db33d532cc483b0e036e4ec7d2e66/lib/class2.rb#L9-L23). 191 | 192 | #### Naming 193 | 194 | `class2` uses 195 | [`String#classify`](http://api.rubyonrails.org/classes/String.html#method-i-classify) 196 | to turn keys into class names: `:foo` will be `Foo`, `:foo_bars` will 197 | be `FooBar`. 198 | 199 | Plural keys with an array value are always assumed to be accessors for 200 | a collection and will default to returning an `Array`. `#classify` is 201 | used to derive the class names from the plural attribute names. An 202 | `:addresses` key with an `Array` value will result in a class named 203 | `Address` being created. 204 | 205 | Plurality is determined by [`String#pluralize`](http://api.rubyonrails.org/classes/String.html#method-i-pluralize). 206 | 207 | #### Conversions 208 | 209 | An attempt is made to convert the attribute's type when a value is passed to the constructor 210 | or set via its accessor. 211 | 212 | You can use any of these classes or their instances in your class definitions: 213 | 214 | * `Array` 215 | * `Date` 216 | * `DateTime` 217 | * `Float` 218 | * `Hash` 219 | * `Integer` 220 | * `Time` 221 | * `TrueClass`/`FalseClass` - either one will cause a boolean conversion 222 | 223 | Custom conversions are possible, just add the conversion to 224 | [`Class2::CONVERSIONS`](https://github.com/sshaw/class2/blob/517239afc76a4d80677e169958a1dc7836726659/lib/class2.rb#L14-L29) 225 | 226 | #### Namespaces 227 | 228 | `class2` can use an exiting namespace or create a new one: 229 | 230 | ```rb 231 | class2 My::Namespace, 232 | :user => %i[name age] 233 | 234 | My::Namespace::User.new(:name => "sshaw") 235 | 236 | class2 "New::Namespace", 237 | :user => %i[name age] 238 | 239 | New::Namespace::User.new(:name => "sshaw") 240 | ``` 241 | 242 | #### Methods 243 | 244 | Classes created by `class2` will have: 245 | 246 | * A constructor that accepts a nested attribute hash 247 | * Attribute readers and writers 248 | * `#to_h` 249 | * `#eql?` and `#==` 250 | * `#hash` 251 | 252 | #### Customizations 253 | 254 | To add methods or include modules just open up the class and write or include them: 255 | 256 | ```rb 257 | class2 :user => :name 258 | 259 | class User 260 | include SomeModule 261 | 262 | def first_initial 263 | name[0] if name 264 | end 265 | end 266 | 267 | User.new(:name => "sshaw").first_initial 268 | ``` 269 | 270 | `class2` does accept a block whose contents will be added to 271 | *every* class defined within the call: 272 | 273 | ```rb 274 | class2 :user => :name, :address => :city do 275 | include ActiveModel::Conversion 276 | extend ActiveModel::Naming 277 | end 278 | 279 | User.new.model_name.route_key 280 | Address.new.model_name.route_key 281 | ``` 282 | 283 | #### Constructor 284 | 285 | The default constructor ignores unknown attributes. 286 | If you prefer to raise an exception include `Class2::StrictConstructor`: 287 | 288 | ```rb 289 | class2 :user => %w[id name age] do 290 | include Class2::StrictConstructor 291 | end 292 | ``` 293 | 294 | Now an `ArgumentError` will be raised if anything but `id`, `name`, or 295 | `age` are passed in. 296 | 297 | Also see [Customizations](#customizations). 298 | 299 | ## See Also 300 | 301 | The Perl modules that served as inspiration: 302 | 303 | * [`MooseX::NestedAttributesConstructor`](https://github.com/sshaw/MooseX-NestedAttributesConstructor) 304 | * [`Class::Tiny`](https://metacpan.org/pod/Class::Tiny) 305 | * [`Moose`](https://metacpan.org/pod/Moose), [`Moo`](https://metacpan.org/pod/Moo), and [`Mouse`](https://metacpan.org/pod/Mouse) 306 | * [`Type::Tiny`](https://metacpan.org/pod/Type::Tiny) 307 | * [`MooseX::Types`](https://metacpan.org/pod/MooseX::Types) 308 | * [`Rubyish`](https://metacpan.org/pod/Rubyish) 309 | 310 | Surely others I cannot remember... 311 | 312 | And these Ruby modules: 313 | 314 | * [`require3`](https://github.com/sshaw/require3) 315 | * [`alias2`](https://github.com/sshaw/alias2) 316 | 317 | ## Author 318 | 319 | Skye Shaw [sshaw AT gmail.com] 320 | 321 | ## License 322 | 323 | Released under the MIT License: www.opensource.org/licenses/MIT 324 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rake/testtask" 3 | 4 | task :default => :test 5 | Rake::TestTask.new do |t| 6 | t.pattern = "spec/*_spec.rb" 7 | end 8 | -------------------------------------------------------------------------------- /class2.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'class2/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "class2" 8 | spec.version = Class2::VERSION 9 | spec.authors = ["Skye Shaw"] 10 | spec.email = ["skye.shaw@gmail.com"] 11 | 12 | spec.summary = %q{Easily create hierarchies of classes that support nested attributes, type conversion, equality, and more.} 13 | spec.homepage = "https://github.com/sshaw/class2" 14 | spec.license = "MIT" 15 | 16 | spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 17 | spec.bindir = "exe" 18 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 19 | spec.require_paths = ["lib"] 20 | 21 | spec.add_dependency "activesupport", ">= 3.2", "< 7" 22 | 23 | spec.add_development_dependency "bundler" 24 | spec.add_development_dependency "rake", "~> 10.0" 25 | spec.add_development_dependency "appraisal" 26 | spec.add_development_dependency "minitest" 27 | end 28 | -------------------------------------------------------------------------------- /gemfiles/as4.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activesupport", "~> 4.2" 6 | 7 | gemspec path: "../" 8 | -------------------------------------------------------------------------------- /gemfiles/as5.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activesupport", "~> 5.2" 6 | 7 | gemspec path: "../" 8 | -------------------------------------------------------------------------------- /gemfiles/as6.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activesupport", "> 5", "< 7" 6 | 7 | gemspec path: "../" 8 | -------------------------------------------------------------------------------- /lib/class2.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "date" 4 | require "time" # for parse() 5 | require "json" 6 | require "active_support/core_ext/module" 7 | require "active_support/inflector" 8 | 9 | require "class2/version" 10 | 11 | no_export = ENV["CLASS2_NO_EXPORT"] 12 | 13 | unless no_export == "1" 14 | unless no_export == "Class2" 15 | def Class2(*args, &block) 16 | Class2.new(*args, &block) 17 | end 18 | end 19 | 20 | unless no_export == "class2" 21 | def class2(*args, &block) 22 | Class2.new(*args, &block) 23 | end 24 | end 25 | end 26 | 27 | class Class2 28 | CONVERSIONS = { 29 | Array => lambda { |v| "Array(#{v})" }, 30 | Date => lambda { |v| "#{v} && Date.parse(#{v})" }, 31 | DateTime => lambda { |v| "#{v} && DateTime.parse(#{v})" }, 32 | Float => lambda { |v| "#{v} && Float(#{v})" }, 33 | Hash => lambda { |v| sprintf "%s.respond_to?(:to_h) ? %s.to_h : %s", v, v, v }, 34 | Integer => lambda { |v| "#{v} && Integer(#{v})" }, 35 | String => lambda { |v| "#{v} && String(#{v})" }, 36 | Time => lambda { |v| "#{v} && Time.parse(#{v})" }, 37 | TrueClass => lambda do |v| 38 | sprintf '["1", 1, 1.0, true].freeze.include?(%s.is_a?(String) ? %s.strip : %s)', v, v, v 39 | end 40 | } 41 | 42 | CONVERSIONS[FalseClass] = CONVERSIONS[TrueClass] 43 | CONVERSIONS.default = lambda { |v| v } 44 | 45 | class << self 46 | def new(*argz, &block) 47 | specs = argz 48 | namespace = Object 49 | 50 | if specs[0].is_a?(String) || specs[0].is_a?(Module) 51 | namespace = specs[0].is_a?(String) ? create_namespace(specs.shift) : specs.shift 52 | end 53 | 54 | specs.each do |spec| 55 | spec = [spec] unless spec.respond_to?(:each) 56 | spec.each { |klass, attributes| make_class(namespace, klass, attributes, block) } 57 | end 58 | 59 | nil 60 | end 61 | 62 | def autoload(namespace = Object, stack = nil) # :nodoc: 63 | failure = lambda { |message| abort "class2: cannot autoload class definitions: #{message}" } 64 | failure["cannot find the right caller"] unless (stack || caller).find do |line| 65 | # Ignore our autoload file and require() 66 | line.index("/class2/autoload.rb:").nil? && line.index("/kernel_require.rb:").nil? && line =~ /(.+):\d+:in\s+`\S/ 67 | end 68 | 69 | # Give this precedence over global DATA constant 70 | data = String.new 71 | File.open($1) do |io| 72 | while line = io.gets 73 | if line == "__END__\n" 74 | data << line while line = io.gets 75 | end 76 | end 77 | end 78 | 79 | # Fallback to global constant if nothing found 80 | data = ::DATA.read if data.empty? && defined?(::DATA) 81 | failure["no data section found"] if data.empty? 82 | 83 | spec = JSON.parse(data) 84 | Class2.new(namespace, spec) 85 | rescue IOError, SystemCallError, JSON::ParserError => e 86 | failure[e.message] 87 | end 88 | 89 | private 90 | 91 | def create_namespace(str) 92 | str.split("::").inject(Object) do |parent, child| 93 | # empty? to handle "::Namespace" 94 | child.empty? ? parent : parent.const_defined?(child) ? 95 | # With 2.1 we can just say Object.const_defined?(str) but keep this around for now. 96 | parent.const_get(child) : parent.const_set(child, Module.new) 97 | end 98 | end 99 | 100 | def split_and_normalize_attributes(attributes) 101 | nested = [] 102 | simple = [] 103 | 104 | attributes = [attributes] unless attributes.is_a?(Array) 105 | attributes.compact.each do |attr| 106 | # Just an attribute name, no type 107 | if !attr.is_a?(Hash) 108 | simple << { attr => nil } 109 | next 110 | end 111 | 112 | attr.each do |k, v| 113 | if v.is_a?(Hash) || v.is_a?(Array) 114 | if v.empty? 115 | # If it's empty it's not a nested spec, the attributes type is a Hash or Array 116 | simple << { k => v.class } 117 | else 118 | nested << { k => v } 119 | end 120 | else 121 | # Type can be a class name or an instance 122 | # If it's an instance, use its type 123 | v = v.class unless v.is_a?(Class) || v.is_a?(Module) 124 | simple << { k => v } 125 | end 126 | end 127 | end 128 | 129 | [ nested, simple ] 130 | end 131 | 132 | def make_class(namespace, name, attributes, block) 133 | nested, simple = split_and_normalize_attributes(attributes) 134 | nested.each do |object| 135 | object.each { |klass, attrs| make_class(namespace, klass, attrs, block) } 136 | end 137 | 138 | name = name.to_s.classify 139 | return if namespace.const_defined?(name, false) 140 | 141 | make_method_name = lambda { |x| x.to_s.gsub(/[^\w]+/, "_") } # good enough 142 | 143 | klass = Class.new do 144 | def initialize(attributes = nil) 145 | __initialize(attributes) 146 | end 147 | 148 | class_eval <<-CODE, __FILE__, __LINE__ 149 | def hash 150 | to_h.hash 151 | end 152 | 153 | def ==(other) 154 | return false unless other.instance_of?(self.class) 155 | to_h == other.to_h 156 | end 157 | 158 | alias eql? == 159 | 160 | def to_h 161 | hash = {} 162 | self.class.__attributes.each do |name| 163 | hash[name] = v = public_send(name) 164 | # Don't turn nil into a Hash 165 | next if v.nil? || !v.respond_to?(:to_h) 166 | # Don't turn empty Arrays into a Hash 167 | next if v.is_a?(Array) && v.empty? 168 | 169 | errors = [ ArgumentError, TypeError ] 170 | # Seems needlessly complicated, why doesn't Hash() do some of this? 171 | begin 172 | hash[name] = v.to_h 173 | # to_h is dependent on its contents 174 | rescue *errors 175 | next unless v.is_a?(Enumerable) 176 | hash[name] = v.map do |e| 177 | begin 178 | e.respond_to?(:to_h) ? e.to_h : e 179 | rescue *errors 180 | e 181 | end 182 | end 183 | end 184 | end 185 | 186 | hash 187 | end 188 | 189 | def self.__nested_attributes 190 | #{nested.map { |n| n.keys.first.to_sym }}.freeze 191 | end 192 | 193 | def self.__attributes 194 | (#{simple.map { |n| n.keys.first.to_sym }} + __nested_attributes).freeze 195 | end 196 | CODE 197 | 198 | simple.each do |cfg| 199 | method, type = cfg.first 200 | method = make_method_name[method] 201 | 202 | # Use Enum somehow? 203 | retval = if type == Array || type.is_a?(Array) 204 | "[]" 205 | elsif type == Hash || type.is_a?(Hash) 206 | "{}" 207 | else 208 | "nil" 209 | end 210 | 211 | class_eval <<-CODE, __FILE__, __LINE__ 212 | def #{method} 213 | @#{method} = #{retval} unless defined? @#{method} 214 | @#{method} 215 | end 216 | 217 | def #{method}=(v) 218 | @#{method} = #{CONVERSIONS[type]["v"]} 219 | end 220 | CODE 221 | end 222 | 223 | nested.map { |n| n.keys.first }.each do |method, _| 224 | method = make_method_name[method] 225 | attr_writer method 226 | 227 | retval = method == method.pluralize ? "[]" : "#{namespace}::#{method.classify}.new" 228 | class_eval <<-CODE 229 | def #{method} 230 | @#{method} ||= #{retval} 231 | end 232 | CODE 233 | end 234 | 235 | # Do this last to allow for overriding the methods we define 236 | class_eval(&block) unless block.nil? 237 | 238 | protected 239 | 240 | def __initialize(attributes) 241 | return unless attributes.is_a?(Hash) 242 | assign_attributes(attributes) 243 | end 244 | 245 | private 246 | 247 | def assign_attributes(attributes) 248 | attributes.each do |key, value| 249 | if self.class.__nested_attributes.include?(key.respond_to?(:to_sym) ? key.to_sym : key) && 250 | (value.is_a?(Hash) || value.is_a?(Array)) 251 | 252 | name = key.to_s.classify 253 | 254 | # parent is deprecated in ActiveSupport 6 and its warning uses Strong#squish! which they don't include! 255 | parent = self.class.respond_to?(:module_parent) ? self.class.module_parent : self.class.parent 256 | next unless parent.const_defined?(name) 257 | 258 | klass = parent.const_get(name) 259 | value = value.is_a?(Hash) ? klass.new(value) : value.map { |v| klass.new(v) } 260 | end 261 | 262 | method = "#{key}=" 263 | public_send(method, value) if respond_to?(method) 264 | end 265 | end 266 | end 267 | 268 | namespace.const_set(name, klass) 269 | end 270 | end 271 | 272 | # 273 | # By default unknown arguments are ignored. includeing this will 274 | # cause an ArgumentError to be raised if an attribute is unknown. 275 | # 276 | module StrictConstructor 277 | def self.included(klass) 278 | klass.class_eval do 279 | def initialize(attributes = nil) 280 | return unless __initialize(attributes) 281 | attributes.each do |name, _| 282 | next if self.class.__attributes.include?(name.respond_to?(:to_sym) ? name.to_sym : name) 283 | raise ArgumentError, "unknown attribute: #{name}" 284 | end 285 | end 286 | end 287 | end 288 | end 289 | 290 | # 291 | # Support +CamelCase+ attributes. See Class2::SnakeCase. 292 | # 293 | module UpperCamelCase 294 | module Attributes 295 | def self.included(klass) 296 | Util.convert_attributes(klass) { |v| v.camelize } 297 | end 298 | end 299 | 300 | module JSON 301 | def as_json(*) 302 | Util.as_json(self, :camelize) 303 | end 304 | 305 | def to_json(*argz) 306 | as_json.to_json(*argz) 307 | end 308 | end 309 | end 310 | 311 | # 312 | # Support +camelCase+ attributes. See Class2::SnakeCase . 313 | # 314 | module LowerCamelCase 315 | module Attributes 316 | def self.included(klass) 317 | Util.convert_attributes(klass) { |v| v.camelize(:lower) } 318 | end 319 | end 320 | 321 | module JSON 322 | def as_json(*) 323 | Util.as_json(self, :camelize, :lower) 324 | end 325 | 326 | def to_json(*argz) 327 | as_json.to_json(*argz) 328 | end 329 | end 330 | end 331 | 332 | # 333 | # Use this when the class was not defined using a Hash with +snake_case+ keys 334 | # but +snake_case+ is a desired access or serialization mechanism. 335 | # 336 | module SnakeCase 337 | # 338 | # Support +snake_case+ attributes. 339 | # This will accept them in the constructor and return them via #to_h. 340 | # 341 | # The key format used to define the class will still be accepted and its accessors will 342 | # remain. 343 | # 344 | module Attributes 345 | def self.included(klass) 346 | Util.convert_attributes(klass) { |v| v.underscore } 347 | end 348 | end 349 | 350 | # 351 | # Create JSON documents that have +snake_case+ properties. 352 | # This will add #as_json and #to_json methods. 353 | # 354 | module JSON 355 | def as_json(*) 356 | Util.as_json(self, :underscore) 357 | end 358 | 359 | def to_json(*argz) 360 | as_json.to_json(*argz) 361 | end 362 | end 363 | end 364 | 365 | module Util 366 | def self.as_json(klass, *argz) 367 | hash = {} 368 | klass.to_h.each do |k, v| 369 | if v.is_a?(Hash) 370 | v = as_json(v, *argz) 371 | elsif v.is_a?(Array) 372 | v = v.map { |e| as_json(e, *argz) } 373 | elsif v.respond_to?(:as_json) 374 | v = v.as_json 375 | end 376 | 377 | hash[k.to_s.public_send(*argz)] = v 378 | end 379 | 380 | hash 381 | end 382 | 383 | def self.convert_attributes(klass) 384 | klass.class_eval do 385 | new_nested = [] 386 | new_attributes = [] 387 | 388 | __attributes.map do |old_name| 389 | new_name = yield(old_name.to_s) 390 | alias_method new_name, old_name 391 | alias_method "#{new_name}=", "#{old_name}=" 392 | 393 | new_attributes << new_name.to_sym 394 | new_nested << new_attributes.last if __nested_attributes.include?(old_name) 395 | end 396 | 397 | class_eval <<-CODE 398 | def self.__attributes 399 | #{new_attributes}.freeze 400 | end 401 | 402 | # We need both styles nere to support proper assignment of nested attributes... :( 403 | def self.__nested_attributes 404 | #{new_nested + __nested_attributes}.freeze 405 | end 406 | CODE 407 | end 408 | end 409 | end 410 | 411 | private_constant :Util 412 | 413 | end 414 | -------------------------------------------------------------------------------- /lib/class2/autoload.rb: -------------------------------------------------------------------------------- 1 | require "class2" 2 | Class2.autoload 3 | -------------------------------------------------------------------------------- /lib/class2/autoload/namespaced.rb: -------------------------------------------------------------------------------- 1 | require "class2" 2 | 3 | unless caller.find { |bt| bt =~ /(.+):\d+:in\s+`require'\z/ } 4 | abort "class2: cannot auto detect namespace: cannot find what required me" 5 | end 6 | 7 | source = $1 8 | namespace = source =~ %r{/lib/(.+?)(?:\.rb)?\z} ? $1 : File.basename(source, File.extname(source)) 9 | Class2.autoload(namespace.camelize, caller.unshift(caller[0])) 10 | -------------------------------------------------------------------------------- /lib/class2/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Class2 4 | VERSION = "0.6.0" 5 | end 6 | -------------------------------------------------------------------------------- /spec/class2_spec.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | require "minitest/autorun" 3 | require "set" 4 | require "class2" 5 | require "uri" 6 | 7 | describe Class2 do 8 | def delete_constant(name) 9 | Object.const_defined?(name) && Object.send(:remove_const, name) 10 | end 11 | 12 | describe "defining classes without type conversions" do 13 | before do 14 | @classes = %w[User Address Country] 15 | 16 | Class2( 17 | :user => [ 18 | :id, :name, 19 | :addresses => [ 20 | :city, :state, :zip, 21 | :country => [ :name, :code ] 22 | ] 23 | ] 24 | ) 25 | end 26 | 27 | after do 28 | @classes.each { |klass| delete_constant(klass) } 29 | end 30 | 31 | it "creates the classes" do 32 | @classes.each do |klass| 33 | Object.const_defined?(klass).must_equal true 34 | Object.const_get(klass).must_be_instance_of Class 35 | end 36 | end 37 | 38 | it "creates a read write accessor for each attribute" do 39 | user = User.new 40 | user.must_respond_to(:id) 41 | user.must_respond_to(:id=) 42 | 43 | user.must_respond_to(:name) 44 | user.must_respond_to(:name=) 45 | 46 | user.must_respond_to(:addresses) 47 | user.must_respond_to(:addresses=) 48 | 49 | user.id = 1 50 | user.id.must_equal 1 51 | 52 | user.name = "sshaw" 53 | user.name.must_equal "sshaw" 54 | 55 | a = [ Address.new ] 56 | user.addresses = a 57 | user.addresses.must_equal a 58 | end 59 | 60 | it "creates equality methods" do 61 | user1 = User.new(:id => 1) 62 | user2 = User.new(:id => 1) 63 | user1.must_equal user2 64 | 65 | user1.id = 99 66 | user1.wont_equal user2 67 | user1.wont_equal "foo" 68 | end 69 | 70 | it "creates a #to_h method" do 71 | user = User.new(:id => 1, :name => "sshaw", :addresses => [ :city => "NYC" ]) 72 | user.to_h.must_equal( 73 | :id => 1, 74 | :name => "sshaw", 75 | :addresses => [ 76 | :city => "NYC", 77 | :state => nil, 78 | :zip => nil, 79 | :country => { 80 | :name => nil, 81 | :code => nil 82 | } 83 | ] 84 | ) 85 | 86 | user.name = user.addresses = nil 87 | user.to_h.must_equal( 88 | :id => 1, 89 | :name => nil, 90 | :addresses => [] 91 | ) 92 | end 93 | 94 | describe "attributes that accept an Array" do 95 | it "returns an Array by default" do 96 | User.new.addresses.must_equal [] 97 | end 98 | end 99 | 100 | describe "constructors" do 101 | it "accepts the class' attributes" do 102 | user = User.new(:id => 1, :name => "fofinho") 103 | user.id.must_equal 1 104 | user.name.must_equal "fofinho" 105 | 106 | country = Country.new(:name => "America", :code => "US") 107 | country.name.must_equal "America" 108 | country.code.must_equal "US" 109 | 110 | address = Address.new(:city => "Da Bay", :state => "CA", :country => country) 111 | address.city.must_equal "Da Bay" 112 | address.state.must_equal "CA" 113 | address.country.must_equal country 114 | end 115 | 116 | it "accepts know and unknown attributes" do 117 | user = User.new(:id => 1, :what_what_what => 999) 118 | user.id.must_equal 1 119 | user.respond_to?(:what_what_what).must_equal false 120 | end 121 | 122 | it "does not require any arguments" do 123 | User.new 124 | end 125 | 126 | it "silently ignores arguments that are not a Hash" do 127 | User.new "foo" 128 | User.new nil 129 | end 130 | 131 | it "accepts attributes for the entire class hierarchy" do 132 | user = User.new( 133 | :id => 1, 134 | :name => "sshaw", 135 | :addresses => [ 136 | { :city => "LA", 137 | :country => { :code => "US" } }, 138 | { :city => "São José dos Campos", 139 | :country => { :code => "BR" } } 140 | ] 141 | ) 142 | 143 | user.id.must_equal 1 144 | user.name.must_equal "sshaw" 145 | user.addresses.size.must_equal 2 146 | user.addresses[0].city.must_equal "LA" 147 | user.addresses[0].country.code.must_equal "US" 148 | user.addresses[1].city.must_equal "São José dos Campos" 149 | user.addresses[1].country.code.must_equal "BR" 150 | end 151 | end 152 | end 153 | 154 | describe "namespaces" do 155 | after { delete_constant("A") } 156 | 157 | it "creates a namespaced class from a string" do 158 | namespace = "A" 159 | Class2( 160 | namespace, 161 | :user => :id 162 | ) 163 | 164 | klass = "#{namespace}::User" 165 | Object.const_defined?(klass).must_equal true 166 | Object.const_get(klass).must_be_instance_of Class 167 | end 168 | 169 | it "creates a namespaced class from module" do 170 | module A end 171 | 172 | Class2( 173 | A, 174 | :user => :id 175 | ) 176 | 177 | klass = "A::User" 178 | Object.const_defined?(klass).must_equal true 179 | Object.const_get(klass).must_be_instance_of Class 180 | end 181 | 182 | it "instantiates classes within the namespace using an attribute key" do 183 | module A end 184 | 185 | Class2( 186 | A, 187 | :user => { :foo => [:bar] } 188 | ) 189 | 190 | user = A::User.new(:foo => { :bar => 123 }) 191 | user.foo.bar.must_equal 123 192 | end 193 | 194 | it "instantiates default instances of a nested attribute's class" do 195 | Class2( 196 | "A", 197 | :user => { :foo => [:bar] } 198 | ) 199 | 200 | A::User.new.foo.must_be_instance_of(A::Foo) 201 | end 202 | 203 | describe "when a class with the same name exists outside the namespace" do 204 | before { User = Class.new } 205 | after { delete_constant("User") } 206 | 207 | it "creates the class within the given namespace" do 208 | class2 "A", :user => %w[id] 209 | 210 | klass = "A::User" 211 | Object.const_defined?(klass).must_equal true 212 | Object.const_get(klass).must_be_instance_of Class 213 | end 214 | end 215 | end 216 | 217 | describe "defining classes with type conversions" do 218 | describe "using classes for types" do 219 | before do 220 | Class2(:all => { 221 | :array => Array, 222 | :boolean => TrueClass, 223 | :boolean2 => FalseClass, 224 | :date => Date, 225 | :datetime => DateTime, 226 | :fixnum => Integer, 227 | :float => Float, 228 | :hash => Hash, 229 | :integer => Integer, 230 | :time => Time, 231 | :string => String, 232 | }, 233 | 234 | :mixed => [ 235 | :default, 236 | :float => Float 237 | ], 238 | 239 | :nested => { 240 | :float => Float, :child => { :id => Integer } 241 | } 242 | ) 243 | end 244 | 245 | after do 246 | delete_constant("All") 247 | delete_constant("Mixed") 248 | delete_constant("Nested") 249 | delete_constant("Child") 250 | end 251 | 252 | it "converts on assignment" do 253 | all = All.new 254 | 255 | all.integer = "123" 256 | all.integer.must_equal 123 257 | end 258 | 259 | it "converts in the constructor" do 260 | all = All.new(:integer => "123") 261 | all.integer.must_equal 123 262 | end 263 | 264 | it "converts nested types" do 265 | nested = Nested.new(:float => "1", :child => { :id => "1" }) 266 | nested.float.must_equal 1.0 267 | nested.child.id.must_equal 1 268 | end 269 | 270 | it "does not convert attributes without types" do 271 | mixed = Mixed.new(:default => /foo/, :float => 1) 272 | mixed.float.must_equal 1.0 273 | mixed.default.must_equal(/foo/) 274 | end 275 | 276 | it "converts to Array" do 277 | all = All.new(:array => Set.new([1,2])) 278 | all.array.must_equal [1,2] 279 | end 280 | 281 | it "converts to boolean" do 282 | all = All.new 283 | ["1", " 1 ", 1, 1.0, true].each do |value| 284 | all.boolean = value 285 | all.boolean.must_equal true 286 | end 287 | 288 | ["0", " 0 ", 0, false, "sshaw", Class].each do |value| 289 | all.boolean = value 290 | all.boolean.must_equal false 291 | end 292 | end 293 | 294 | it "converts to Date" do 295 | date = "2017-01-01" 296 | all = All.new(:date => date) 297 | all.date.must_equal Date.parse("2017-01-01") 298 | end 299 | 300 | it "does not try to convert nil to Date" do 301 | All.new(:date => nil).date.must_be_nil 302 | end 303 | 304 | it "converts to DateTime" do 305 | time = "2017-01-01T01:02:03" 306 | all = All.new(:datetime => time) 307 | all.datetime.must_equal DateTime.parse(time) 308 | end 309 | 310 | it "converts to Time" do 311 | time = "2017-01-01T01:02:03" 312 | all = All.new(:time => time) 313 | all.time.must_equal Time.parse(time) 314 | end 315 | 316 | it "does not try to convert nil to DateTime" do 317 | All.new(:datetime => nil).datetime.must_be_nil 318 | end 319 | 320 | it "converts to Float" do 321 | all = All.new(:float => 10) 322 | all.float.must_equal 10.0 323 | 324 | all.float = "10.5" 325 | all.float.must_equal 10.5 326 | end 327 | 328 | it "does not try to convert nil to Float" do 329 | All.new(:float => nil).float.must_be_nil 330 | end 331 | 332 | it "converts to Hash" do 333 | all = All.new(:hash => [%w[a 1], %w[b 2]]) 334 | all.hash.must_equal "a" => "1", "b" => "2" 335 | end 336 | 337 | it "converts to Integer" do 338 | all = All.new(:fixnum => "123") 339 | all.fixnum.must_equal 123 340 | end 341 | 342 | it "does not try to convert nil to Integer" do 343 | All.new(:fixnum => nil).fixnum.must_be_nil 344 | end 345 | 346 | it "converts to String" do 347 | all = All.new(:string => 123) 348 | all.string.must_equal "123" 349 | end 350 | 351 | it "defaults to an empty Array for array types" do 352 | All.new.array.must_equal [] 353 | end 354 | 355 | it "defaults to an empty Hash for hash types" do 356 | All.new.hash.must_equal Hash.new 357 | end 358 | end 359 | 360 | describe "using instances for types" do 361 | before do 362 | @classes = %w[User Address] 363 | 364 | Class2( 365 | :user => { 366 | :id => 1, 367 | :name => "sshaw", 368 | :foo => {}, 369 | :bar => [], 370 | :addresses => [ 371 | { :city => "LA", :lat => 75.12345 }, 372 | { :city => "NYC", :lat => 75.12345 } 373 | ] 374 | } 375 | ) 376 | end 377 | 378 | after do 379 | @classes.each { |name| delete_constant(name) } 380 | end 381 | 382 | it "creates the classes" do 383 | @classes.each do |klass| 384 | Object.const_defined?(klass).must_equal true 385 | Object.const_get(klass).must_be_instance_of Class 386 | end 387 | end 388 | 389 | it "creates a read write accessor for each attribute" do 390 | user = User.new 391 | user.must_respond_to(:id) 392 | user.must_respond_to(:id=) 393 | 394 | user.must_respond_to(:name) 395 | user.must_respond_to(:name=) 396 | 397 | address = Address.new 398 | address.must_respond_to(:city) 399 | address.must_respond_to(:city=) 400 | 401 | address.must_respond_to(:lat) 402 | address.must_respond_to(:lat=) 403 | end 404 | 405 | it "converts types based on the instance's type" do 406 | user = User.new(:id => "1", :name => 123, :addresses => [ :lat => 75 ]) 407 | user.id.must_equal 1 408 | user.name.must_equal "123" 409 | user.addresses.first.must_be_instance_of(Address) 410 | user.addresses.first.lat.must_equal 75.0 411 | end 412 | 413 | it "defaults to an empty Array for array types" do 414 | User.new.foo.must_equal Hash.new 415 | end 416 | 417 | it "defaults to an empty Hash for hash types" do 418 | User.new.bar.must_equal [] 419 | end 420 | 421 | describe "a String attribute that's nil" do 422 | it "does not convert nil to empty str" do 423 | User.new(:name => nil).name.must_be_nil 424 | end 425 | end 426 | end 427 | 428 | describe "using modules for types" do 429 | before do 430 | Class2::CONVERSIONS[URI] = lambda { |v| "#{v} && URI(#{v})" } 431 | class2 :user => [ :homepage => URI ] 432 | end 433 | 434 | after do 435 | Class2::CONVERSIONS.delete(URI) 436 | delete_constant("User") 437 | end 438 | 439 | it "converters the value" do 440 | u = User.new(:homepage => "http://example.com") 441 | u.homepage.must_be_instance_of(URI::HTTP) 442 | end 443 | end 444 | end 445 | 446 | describe "when Class2::UpperCamelCase::Attributes is included" do 447 | before do 448 | class2 :foo => %w[some_value another_value] do 449 | include Class2::UpperCamelCase::Attributes 450 | end 451 | end 452 | 453 | after do 454 | delete_constant("Foo") 455 | end 456 | 457 | it "adds CamelCase versions of the snake_case methods" do 458 | foo = Foo.new 459 | foo.must_respond_to(:SomeValue) 460 | foo.must_respond_to(:SomeValue=) 461 | 462 | foo.must_respond_to(:AnotherValue) 463 | foo.must_respond_to(:AnotherValue=) 464 | end 465 | 466 | it "assigns snake_case arguments to their CamelCase attributes" do 467 | foo = Foo.new(:some_value => 1, :another_value => 2) 468 | foo.SomeValue.must_equal 1 469 | foo.AnotherValue.must_equal 2 470 | end 471 | 472 | it "assigns CamelCase arguments to their snake_case attributes" do 473 | foo = Foo.new(:SomeValue => 1, :AnotherValue => 2) 474 | foo.some_value.must_equal 1 475 | foo.another_value.must_equal 2 476 | end 477 | 478 | it "#to_h uses CamelCase keys" do 479 | foo = Foo.new(:some_value => 1, :another_value => 2) 480 | foo.to_h.must_equal :SomeValue => 1, :AnotherValue => 2 481 | end 482 | end 483 | 484 | describe "when Class2::UpperCamelCase::Attributes is included" do 485 | before do 486 | class2 :foo => %w[some_value another_value] do 487 | include Class2::UpperCamelCase::Attributes 488 | end 489 | end 490 | 491 | after do 492 | delete_constant("Foo") 493 | end 494 | 495 | it "assigns snake_case arguments to their camelCase attributes" do 496 | foo = Foo.new(:some_value => 1, :another_value => 2) 497 | foo.SomeValue.must_equal 1 498 | foo.AnotherValue.must_equal 2 499 | end 500 | end 501 | 502 | describe "when Class2::UpperCamelCase::JSON is included" do 503 | before do 504 | class2 :foo => %w[some_value another_value] do 505 | include Class2::UpperCamelCase::JSON 506 | end 507 | end 508 | 509 | after do 510 | delete_constant("Foo") 511 | end 512 | 513 | it "#as_json uses CamelCase keys" do 514 | foo = Foo.new(:some_value => 1, :another_value => 2) 515 | foo.as_json.must_equal "SomeValue" => 1, "AnotherValue" => 2 516 | end 517 | 518 | it "#to_h uses the format used to define the class" do 519 | foo = Foo.new(:some_value => 1, :another_value => 2) 520 | foo.to_h.must_equal :some_value => 1, :another_value => 2 521 | end 522 | end 523 | 524 | describe "when Class2::SnakeCase::Attributes is included" do 525 | before do 526 | class2 :foo => [:someValue, :AnotherValue, :nestedValue => [:id]] do 527 | include Class2::SnakeCase::Attributes 528 | end 529 | end 530 | 531 | after do 532 | delete_constant("Foo") 533 | end 534 | 535 | it "adds snake_case versions of the camelCase methods" do 536 | foo = Foo.new 537 | foo.must_respond_to(:some_value) 538 | foo.must_respond_to(:some_value=) 539 | 540 | foo.must_respond_to(:another_value) 541 | foo.must_respond_to(:another_value=) 542 | end 543 | 544 | it "assigns camelCase arguments to their snake_case attributes" do 545 | foo = Foo.new(:someValue => 1, :AnotherValue => 2) 546 | foo.some_value.must_equal 1 547 | foo.another_value.must_equal 2 548 | end 549 | 550 | it "assigns nested camelCase arguments to their snake_case attributes" do 551 | foo = Foo.new(:nestedValue => { :id => 1 }) 552 | foo.nested_value.must_equal NestedValue.new(:id => 1) 553 | end 554 | 555 | it "assigns snake_case arguments to their cameCamel attributes" do 556 | foo = Foo.new(:some_value => 1, :another_value => 2) 557 | foo.some_value.must_equal 1 558 | foo.another_value.must_equal 2 559 | end 560 | 561 | it "assigns nested snake_case arguments" do 562 | foo = Foo.new(:nested_value => { :id => 1 }) 563 | foo.nested_value.must_equal NestedValue.new(:id => 1) 564 | end 565 | end 566 | 567 | 568 | describe "when Class2::SnakeCase::JSON is included" do 569 | before do 570 | class2 :foo => %w[someValue anotherValue] do 571 | include Class2::SnakeCase::JSON 572 | end 573 | end 574 | 575 | after do 576 | delete_constant("Foo") 577 | end 578 | 579 | it "#as_json uses snake_case keys" do 580 | foo = Foo.new(:someValue => 1, :anotherValue => 2) 581 | foo.as_json.must_equal "some_value" => 1, "another_value" => 2 582 | end 583 | 584 | it "#to_h uses the format used to define the class" do 585 | foo = Foo.new(:someValue => 1, :anotherValue => 2) 586 | foo.to_h.must_equal :someValue => 1, :anotherValue => 2 587 | end 588 | end 589 | 590 | describe "when Class2::StrictConstructor is included" do 591 | before do 592 | Class2(:foo => :bar) do 593 | include Class2::StrictConstructor 594 | end 595 | end 596 | 597 | after { delete_constant("Foo") } 598 | 599 | it "creates a constructor that accepts know attributes" do 600 | Foo.new(:bar => 123).bar.must_equal 123 601 | Foo.new("bar" => 123).bar.must_equal 123 602 | end 603 | 604 | it "creates a constructor that raises an ArgumentError for unknown attributes" do 605 | lambda { Foo.new(:baz => 123) }.must_raise ArgumentError, "unknown attribute: baz" 606 | end 607 | end 608 | 609 | describe "require 'class2/autoload'" do 610 | after { delete_constant("User") } 611 | 612 | it "defines the file's in calling file's DATA section" do 613 | require_relative "./fixtures/autoload" 614 | Object.const_defined?("User").must_equal true 615 | end 616 | 617 | it "defines the file's in global DATA section" do 618 | cmd = sprintf("%s -I %s %s", 619 | RbConfig::CONFIG["RUBY_INSTALL_NAME"], 620 | $:[0], 621 | File.join(__dir__, "fixtures/main.rb")) 622 | 623 | `#{cmd}`.must_equal "constant\n" 624 | end 625 | 626 | end 627 | end 628 | -------------------------------------------------------------------------------- /spec/fixtures/autoload.rb: -------------------------------------------------------------------------------- 1 | require "class2/autoload" 2 | __END__ 3 | { 4 | "user": { 5 | "id": 1 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /spec/fixtures/dependency.rb: -------------------------------------------------------------------------------- 1 | # Used to test that the global DATA constant is used if 2 | # caller of class2/autoload has no data section 3 | require "class2/autoload" 4 | -------------------------------------------------------------------------------- /spec/fixtures/main.rb: -------------------------------------------------------------------------------- 1 | require_relative "dependency" 2 | puts defined?(User) 3 | __END__ 4 | { 5 | "user": { 6 | "id": 1 7 | } 8 | } 9 | --------------------------------------------------------------------------------