Ruby on Rails One-to-Many Associations

It’s incredible how often, with technology, we pour hours into learning something new only to have forgotten it by the time we need to do it again. If it’s only a little thing then a bookmark will cover it. If you’re lucky to find a blog post that covers exactly what you want — again a bookmark is all you need. But what if you pull together information from all over and need it preserved in one coherent source for later use? I use my own blog to do a memory post.

One to Many

The simplest example of a Rails app would be to have a single model, maybe a list of people. There are no other models, just people. Then perhaps the second simplest form of Rails app has two models with a relationship between them. In Rails the relationship is called an association.

So lets model a simple scenario: cars. We’re not going to store individual cars, just the Brand and Model of car types that exist.

New App

I’ve assumed you have Ruby on Rails all set up and working (GoRails if you don’t). I’m going to call this app Cars and build it like this:

~: rails new Cars
create
create README.md
create Rakefile
create config.ru
create .gitignore
create Gemfile
create app
create app/assets/config/manifest.js
create app/assets/javascripts/application.js
create app/assets/javascripts/cable.js
create app/assets/stylesheets/application.css
create app/channels/application_cable/channel.rb
create app/channels/application_cable/connection.rb
create app/controllers/application_controller.rb
create app/helpers/application_helper.rb
create app/jobs/application_job.rb
create app/mailers/application_mailer.rb
create app/models/application_record.rb
create app/views/layouts/application.html.erb
create app/views/layouts/mailer.html.erb
create app/views/layouts/mailer.text.erb
create app/assets/images/.keep
create app/assets/javascripts/channels
create app/assets/javascripts/channels/.keep
create app/controllers/concerns/.keep
create app/models/concerns/.keep
create bin
create bin/bundle
create bin/rails
create bin/rake
create bin/setup
create bin/update
create config
create config/routes.rb
create config/application.rb
create config/environment.rb
create config/secrets.yml
create config/cable.yml
create config/puma.rb
create config/spring.rb
create config/environments
create config/environments/development.rb
create config/environments/production.rb
create config/environments/test.rb
create config/initializers
create config/initializers/application_controller_renderer.rb
create config/initializers/assets.rb
create config/initializers/backtrace_silencers.rb
create config/initializers/cookies_serializer.rb
create config/initializers/cors.rb
create config/initializers/filter_parameter_logging.rb
create config/initializers/inflections.rb
create config/initializers/mime_types.rb
create config/initializers/new_framework_defaults.rb
create config/initializers/session_store.rb
create config/initializers/wrap_parameters.rb
create config/locales
create config/locales/en.yml
create config/boot.rb
create config/database.yml
create db
create db/seeds.rb
create lib
create lib/tasks
create lib/tasks/.keep
create lib/assets
create lib/assets/.keep
create log
create log/.keep
create public
create public/404.html
create public/422.html
create public/500.html
create public/apple-touch-icon-precomposed.png
create public/apple-touch-icon.png
create public/favicon.ico
create public/robots.txt
create test/fixtures
create test/fixtures/.keep
create test/fixtures/files
create test/fixtures/files/.keep
create test/controllers
create test/controllers/.keep
create test/mailers
create test/mailers/.keep
create test/models
create test/models/.keep
create test/helpers
create test/helpers/.keep
create test/integration
create test/integration/.keep
create test/test_helper.rb
create tmp
create tmp/.keep
create tmp/cache
create tmp/cache/assets
create vendor/assets/javascripts
create vendor/assets/javascripts/.keep
create vendor/assets/stylesheets
create vendor/assets/stylesheets/.keep
remove config/initializers/cors.rb
run bundle install
The dependency tzinfo-data (>= 0) will be unused by any of the platforms Bundler is installing for. Bundler is installing for ruby but the dependency is only for x86-mingw32, x86-mswin32, x64-mingw32, java. To add those platforms to the bundle, run `bundle lock --add-platform x86-mingw32 x86-mswin32 x64-mingw32 java`.
Fetching gem metadata from https://rubygems.org/..........
Fetching version metadata from https://rubygems.org/..
Fetching dependency metadata from https://rubygems.org/.
Resolving dependencies...
Using rake 12.2.1
Using concurrent-ruby 1.0.5
Using minitest 5.10.3
Using thread_safe 0.3.6
Using builder 3.2.3
Using erubis 2.7.0
Using mini_portile2 2.3.0
Using crass 1.0.2
Using rack 2.0.3
Using nio4r 2.1.0
Using websocket-extensions 0.1.2
Using mini_mime 0.1.4
Using arel 7.1.4
Using bindex 0.5.0
Using bundler 1.14.6
Using byebug 9.1.0
Using coffee-script-source 1.12.2
Using execjs 2.7.0
Using method_source 0.9.0
Using thor 0.20.0
Using ffi 1.9.18
Using multi_json 1.12.2
Using rb-fsevent 0.10.2
Using puma 3.10.0
Using tilt 2.0.8
Using sqlite3 1.3.13
Using turbolinks-source 5.0.3
Using i18n 0.9.1
Using tzinfo 1.2.4
Using nokogiri 1.8.1
Using rack-test 0.6.3
Using sprockets 3.7.1
Using websocket-driver 0.6.5
Using mail 2.7.0
Using coffee-script 2.4.1
Using uglifier 3.2.0
Using rb-inotify 0.9.10
Using turbolinks 5.0.1
Using activesupport 5.0.6
Using loofah 2.1.1
Using listen 3.0.8
Using sass-listen 4.0.0
Using rails-dom-testing 2.0.3
Using globalid 0.4.1
Using activemodel 5.0.6
Using jbuilder 2.7.0
Using spring 2.0.2
Using rails-html-sanitizer 1.0.3
Using sass 3.5.3
Using activejob 5.0.6
Using activerecord 5.0.6
Using spring-watcher-listen 2.0.1
Using actionview 5.0.6
Using actionpack 5.0.6
Using actioncable 5.0.6
Using actionmailer 5.0.6
Using railties 5.0.6
Using sprockets-rails 3.2.1
Using coffee-rails 4.2.2
Using jquery-rails 4.3.1
Using web-console 3.5.1
Using rails 5.0.6
Using sass-rails 5.0.6
Bundle complete! 15 Gemfile dependencies, 63 gems now installed.
Use `bundle show [gemname]` to see where a bundled gem is installed.
run bundle exec spring binstub --all
* bin/rake: spring inserted
* bin/rails: spring inserted
~:

Then change to the app directory:

~: cd Cars
~/Cars:

And create the database:

~/Cars: rake db:create
Database 'db/development.sqlite3' already exists
Database 'db/test.sqlite3' already exists
~/Cars: 

All simple stuff. No need to involve MySQL, this is just a demo.

Brands

Each Brand (also called a “marque” in the car industry) sells many different models of cars. It’s sufficient for this example to have only one attribute for each brand, its name, so I’ll create the model with just that. I’m deliberately using a scaffold to get the HTML pages created at the same time.

~/Cars: rails generate scaffold Brand name:string
Running via Spring preloader in process 1596
      invoke  active_record
      create    db/migrate/20171103064502_create_brands.rb
      create    app/models/brand.rb
      invoke    test_unit
      create      test/models/brand_test.rb
      create      test/fixtures/brands.yml
      invoke  resource_route
       route    resources :brands
      invoke  scaffold_controller
      create    app/controllers/brands_controller.rb
      invoke    erb
      create      app/views/brands
      create      app/views/brands/index.html.erb
      create      app/views/brands/edit.html.erb
      create      app/views/brands/show.html.erb
      create      app/views/brands/new.html.erb
      create      app/views/brands/_form.html.erb
      invoke    test_unit
      create      test/controllers/brands_controller_test.rb
      invoke    helper
      create      app/helpers/brands_helper.rb
      invoke      test_unit
      invoke    jbuilder
      create      app/views/brands/index.json.jbuilder
      create      app/views/brands/show.json.jbuilder
      create      app/views/brands/_brand.json.jbuilder
      invoke  assets
      invoke    coffee
      create      app/assets/javascripts/brands.coffee
      invoke    scss
      create      app/assets/stylesheets/brands.scss
      invoke  scss
      create    app/assets/stylesheets/scaffolds.scss
~/Cars: 

Let’s make sure that works. We’ll push the new model into the database schema.

~/Cars: rake db:migrate
== 20171103064502 CreateBrands: migrating =====================================
-- create_table(:brands)
   -> 0.0006s
== 20171103064502 CreateBrands: migrated (0.0007s) ============================

~/Cars: 

Console

Let’s use the Rails Console to test it quickly before using the HTML pages and then JSON.

~/Cars: rails c
Running via Spring preloader in process 3477
Loading development environment (Rails 5.0.6)
irb(main):001:0> Brand.all
 Brand Load (0.9ms) SELECT "brands".* FROM "brands"
=> #<ActiveRecord::Relation []>
irb(main):002:0> Brand.create(name: "Ford")
 (0.1ms) begin transaction
 SQL (0.4ms) INSERT INTO "brands" ("name", "created_at", "updated_at") VALUES (?, ?, ?) [["name", "Ford"], ["created_at", "2017-11-03 21:41:03.398413"], ["updated_at", "2017-11-03 21:41:03.398413"]]
 (2.3ms) commit transaction
=> #<Brand id: 1, name: "Ford", created_at: "2017-11-03 21:41:03", updated_at: "2017-11-03 21:41:03">
irb(main):003:0> Brand.all
 Brand Load (0.1ms) SELECT "brands".* FROM "brands"
=> #<ActiveRecord::Relation [#<Brand id: 1, name: "Ford", created_at: "2017-11-03 21:41:03", updated_at: "2017-11-03 21:41:03">]>
irb(main):004:0> exit
~/Cars:

HTML

To use HTML and JSON we’ll need to start the built-in Rails server:

~/Cars: rails s
=> Booting Puma
=> Rails 5.0.6 application starting in development on http://localhost:3000
=> Run `rails server -h` for more startup options
Puma starting in single mode...
* Version 3.10.0 (ruby 2.4.0-p0), codename: Russell's Teapot
* Min threads: 5, max threads: 5
* Environment: development
* Listening on tcp://0.0.0.0:3000
Use Ctrl-C to stop

No with a browser we can hit http://localhost:3000

Rails Server Confirmation Page
Rails server confirmation page

Then hit the Brand pages directly with http://localhost:3000/brands

Brands Index Page
Brands index page

By clicking the links on this page you should be able to edit the brands in the database directly. Check that works.

Brands Index Page
Brands index page with “Jeep” added

JSON

Now with JSON: http://localhost:3000/brands.json

JSON Index Page
JSON index page

We can use the URLs provided for each object to open them individually.

JSON Show Page
JSON show page

Rails by default implements a security feature for POST operations to prevent cross-site request forgery (CSRF). We can casually switch that off for this test but don’t be so casual about code that could end up in production! This change is in the Cars/app/controllers/application_controller.rb file and will affect every model in the app. We need to comment out the protect_from_forgery line, like so:

class ApplicationController < ActionController::Base
  # protect_from_forgery with: :exception
end

During this change I have left the rails server running in another terminal. The Rails server will pick up the file changes immediately without requiring a restart. We can now submit JSON to the server to create new objects:

~: curl -v -H "Accept: application/json" -H "Content-type: application/json" -d '{"name":"Saab"}' http://localhost:3000/brands
*   Trying ::1...
* TCP_NODELAY set
*   Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 3000 (#0)
> POST /brands HTTP/1.1
> Host: localhost:3000
> User-Agent: curl/7.54.0
> Accept: application/json
> Content-type: application/json
> Content-Length: 15
> 
* upload completely sent off: 15 out of 15 bytes
< HTTP/1.1 201 Created
< X-Frame-Options: SAMEORIGIN
< X-XSS-Protection: 1; mode=block
< X-Content-Type-Options: nosniff
< Content-Type: application/json; charset=utf-8
< Location: http://localhost:3000/brands/4
< ETag: W/"f646eb896d23aae26e46593cf7b482e3"
< Cache-Control: max-age=0, private, must-revalidate
< X-Request-Id: 476befc2-1d6f-44a7-b4a7-a5ac5fe7ffc2
< X-Runtime: 0.013770
< Transfer-Encoding: chunked
< 
* Connection #0 to host localhost left intact
{"id":4,"name":"Saab","created_at":"2017-11-03T22:20:42.563Z","updated_at":"2017-11-03T22:20:42.563Z","url":"http://localhost:3000/brands/4.json"}~: 

And of course the new data is now available in JSON format:

JSON Index Page
JSON index page with “Saab” added

I’ve now stopped the rails server.

Model

Since every Model belongs to only one Brand, we create the model with a reference to a Brand. I had to do a bit of research here: it’s important to know when to capitalise a model’s name in Rails. In this instance it seems we use the lowercase singular name:

~/Cars: rails generate scaffold Model name:string brand:references
Running via Spring preloader in process 4027
      invoke  active_record
      create    db/migrate/20171103223511_create_models.rb
      create    app/models/model.rb
      invoke    test_unit
      create      test/models/model_test.rb
      create      test/fixtures/models.yml
      invoke  resource_route
       route    resources :models
      invoke  scaffold_controller
      create    app/controllers/models_controller.rb
      invoke    erb
      create      app/views/models
      create      app/views/models/index.html.erb
      create      app/views/models/edit.html.erb
      create      app/views/models/show.html.erb
      create      app/views/models/new.html.erb
      create      app/views/models/_form.html.erb
      invoke    test_unit
      create      test/controllers/models_controller_test.rb
      invoke    helper
      create      app/helpers/models_helper.rb
      invoke      test_unit
      invoke    jbuilder
      create      app/views/models/index.json.jbuilder
      create      app/views/models/show.json.jbuilder
      create      app/views/models/_model.json.jbuilder
      invoke  assets
      invoke    coffee
      create      app/assets/javascripts/models.coffee
      invoke    scss
      create      app/assets/stylesheets/models.scss
      invoke  scss
   identical    app/assets/stylesheets/scaffolds.scss
~/Cars: 

Push this change to the database:

~/Cars: rake db:migrate
== 20171103223511 CreateModels: migrating =====================================
-- create_table(:models)
   -> 0.0033s
== 20171103223511 CreateModels: migrated (0.0034s) ============================

~/Cars: 

Now we’ll start testing with the Rails Console; but I already know there’s something wrong.

~/Cars: Rails c
Running via Spring preloader in process 4094
Loading development environment (Rails 5.0.6)
No entry for terminal type "y6r0000gn/T/";
using dumb terminal settings.
irb(main):001:0> Brand.all
 Brand Load (1.1ms) SELECT "brands".* FROM "brands"
=> #<ActiveRecord::Relation [#<Brand id: 1, name: "Ford", created_at: "2017-11-03 21:41:03", updated_at: "2017-11-03 21:41:03">, #<Brand id: 2, name: "Jeep", created_at: "2017-11-03 22:00:13", updated_at: "2017-11-03 22:00:13">, #<Brand id: 4, name: "Saab", created_at: "2017-11-03 22:20:42", updated_at: "2017-11-03 22:20:42">]>
irb(main):002:0> exit
~/Cars: 

Ah, I forgot about formatting the JSON in the Console. I find the standard formatting (above) really hard on the eyes. If we drop out of the console for a minute we can make it look a lot nicer. Edit the Cars/Gemfile and add the following two lines. I put them near the top of the file but it shouldn’t matter where you put them.

# Formatting for Rails Console
gem 'hirb'

Now do a bundle install to pull in the new gem:

~/Cars: bundle install
Using rake 12.2.1
Using concurrent-ruby 1.0.5
Using minitest 5.10.3
Using thread_safe 0.3.6
Using builder 3.2.3
Using erubis 2.7.0
Using mini_portile2 2.3.0
Using crass 1.0.2
Using rack 2.0.3
Using nio4r 2.1.0
Using websocket-extensions 0.1.2
Using mini_mime 0.1.4
Using arel 7.1.4
Using bindex 0.5.0
Using byebug 9.1.0
Using coffee-script-source 1.12.2
Using execjs 2.7.0
Using method_source 0.9.0
Using thor 0.20.0
Using ffi 1.9.18
Using hirb 0.7.3
Using multi_json 1.12.2
Using rb-fsevent 0.10.2
Using puma 3.10.0
Using bundler 1.14.6
Using tilt 2.0.8
Using sqlite3 1.3.13
Using turbolinks-source 5.0.3
Using i18n 0.9.1
Using tzinfo 1.2.4
Using nokogiri 1.8.1
Using rack-test 0.6.3
Using sprockets 3.7.1
Using websocket-driver 0.6.5
Using mail 2.7.0
Using coffee-script 2.4.1
Using uglifier 3.2.0
Using rb-inotify 0.9.10
Using turbolinks 5.0.1
Using activesupport 5.0.6
Using loofah 2.1.1
Using listen 3.0.8
Using sass-listen 4.0.0
Using rails-dom-testing 2.0.3
Using globalid 0.4.1
Using activemodel 5.0.6
Using jbuilder 2.7.0
Using spring 2.0.2
Using rails-html-sanitizer 1.0.3
Using sass 3.5.3
Using activejob 5.0.6
Using activerecord 5.0.6
Using spring-watcher-listen 2.0.1
Using actionview 5.0.6
Using actionpack 5.0.6
Using actioncable 5.0.6
Using actionmailer 5.0.6
Using railties 5.0.6
Using sprockets-rails 3.2.1
Using coffee-rails 4.2.2
Using jquery-rails 4.3.1
Using web-console 3.5.1
Using rails 5.0.6
Using sass-rails 5.0.6
Bundle complete! 16 Gemfile dependencies, 64 gems now installed.
Use `bundle show [gemname]` to see where a bundled gem is installed.
~/Cars:

Now if we go back to the Rails Console and get all the Brands we should get a nicer ASCII table of results:

~/Cars: rails c
Running via Spring preloader in process 4150
Loading development environment (Rails 5.0.6)
irb(main):001:0> Brand.all
D, [2017-11-04T09:41:56.670153 #4150] DEBUG -- : Brand Load (1.0ms) SELECT "brands".* FROM "brands"
+----+------+-------------------------+-------------------------+
| id | name | created_at              | updated_at              |
+----+------+-------------------------+-------------------------+
|  1 | Ford | 2017-11-03 21:41:03 UTC | 2017-11-03 21:41:03 UTC |
|  2 | Jeep | 2017-11-03 22:00:13 UTC | 2017-11-03 22:00:13 UTC |
|  4 | Saab | 2017-11-03 22:20:42 UTC | 2017-11-03 22:20:42 UTC |
+----+------+-------------------------+-------------------------+
3 rows in set
irb(main):002:0>

You may be wondering why I’m looking at Brands and not Models. Well, to create a Model we want to assign the Brand to it. Like this:

irb(main):001:0> Brand.all
D, [2017-11-04T09:41:56.670153 #4150] DEBUG -- : Brand Load (1.0ms) SELECT "brands".* FROM "brands"
+----+------+-------------------------+-------------------------+
| id | name | created_at              | updated_at              |
+----+------+-------------------------+-------------------------+
|  1 | Ford | 2017-11-03 21:41:03 UTC | 2017-11-03 21:41:03 UTC |
|  2 | Jeep | 2017-11-03 22:00:13 UTC | 2017-11-03 22:00:13 UTC |
|  4 | Saab | 2017-11-03 22:20:42 UTC | 2017-11-03 22:20:42 UTC |
+----+------+-------------------------+-------------------------+
3 rows in set
irb(main):002:0> f = Brand.find(1)
D, [2017-11-04T10:22:33.900937 #4150] DEBUG -- : Brand Load (0.2ms) SELECT "brands".* FROM "brands" WHERE "brands"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]]
+----+------+-------------------------+-------------------------+
| id | name | created_at              | updated_at              |
+----+------+-------------------------+-------------------------+
|  1 | Ford | 2017-11-03 21:41:03 UTC | 2017-11-03 21:41:03 UTC |
+----+------+-------------------------+-------------------------+
1 row in set
irb(main):003:0> f
+----+------+-------------------------+-------------------------+
| id | name | created_at              | updated_at              |
+----+------+-------------------------+-------------------------+
|  1 | Ford | 2017-11-03 21:41:03 UTC | 2017-11-03 21:41:03 UTC |
+----+------+-------------------------+-------------------------+
1 row in set
irb(main):004:0> Model.create(name: "Thunderbird", brand: f)
D, [2017-11-04T10:23:03.570762 #4150] DEBUG -- : (0.1ms) begin transaction
D, [2017-11-04T10:23:03.573956 #4150] DEBUG -- : SQL (0.4ms) INSERT INTO "models" ("name", "brand_id", "created_at", "updated_at") VALUES (?, ?, ?, ?) [["name", "Thunderbird"], ["brand_id", 1], ["created_at", "2017-11-03 23:23:03.571579"], ["updated_at", "2017-11-03 23:23:03.571579"]]
D, [2017-11-04T10:23:03.575053 #4150] DEBUG -- : (0.7ms) commit transaction
+----+-------------+----------+----------------+----------------+
| id | name        | brand_id | created_at     | updated_at     |
+----+-------------+----------+----------------+----------------+
|  1 | Thunderbird |        1 | 2017-11-03 ... | 2017-11-03 ... |
+----+-------------+----------+----------------+----------------+
1 row in set
irb(main):005:0> Model.all
D, [2017-11-04T10:23:22.981933 #4150] DEBUG -- : Model Load (0.2ms) SELECT "models".* FROM "models"
+----+-------------+----------+----------------+----------------+
| id | name        | brand_id | created_at     | updated_at     |
+----+-------------+----------+----------------+----------------+
|  1 | Thunderbird |        1 | 2017-11-03 ... | 2017-11-03 ... |
+----+-------------+----------+----------------+----------------+
1 row in set
irb(main):006:0>

So we can see Rails has understood the relationship between the Model and Brand. It’s taken the Brand.ID from “Ford” and added that to the foreign key field brand_id. Nice. So we should be able to look up the Model “Thunderbird” and see it’s Brand is “Ford”, right?

irb(main):006:0> t = Model.find(1)
D, [2017-11-04T10:35:49.381515 #4150] DEBUG -- : Model Load (0.2ms) SELECT "models".* FROM "models" WHERE "models"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]]
+----+-------------+----------+----------------+----------------+
| id | name        | brand_id | created_at     | updated_at     |
+----+-------------+----------+----------------+----------------+
|  1 | Thunderbird |        1 | 2017-11-03 ... | 2017-11-03 ... |
+----+-------------+----------+----------------+----------------+
1 row in set
irb(main):007:0> t.brand
D, [2017-11-04T10:35:56.251826 #4150] DEBUG -- : Brand Load (0.1ms) SELECT "brands".* FROM "brands" WHERE "brands"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]]
+----+------+-------------------------+-------------------------+
| id | name | created_at              | updated_at              |
+----+------+-------------------------+-------------------------+
|  1 | Ford | 2017-11-03 21:41:03 UTC | 2017-11-03 21:41:03 UTC |
+----+------+-------------------------+-------------------------+
1 row in set
irb(main):008:0>

And in the other direction we should be able to look up the “Ford” Brand and get a list of the Models under that brand too.

irb(main):009:0> f.models
NoMethodError: undefined method `models' for #<Brand:0x007fa7b69b3a10>
Did you mean?  model_name
        from /Users/prawnhead/.rbenv/versions/2.4.0/lib/ruby/gems/2.4.0/gems/activemodel-5.0.6/lib/active_model/attribute_methods.rb:433:in `method_missing' 
        from (irb):9
        from /Users/prawnhead/.rbenv/versions/2.4.0/lib/ruby/gems/2.4.0/gems/railties-5.0.6/lib/rails/commands/console.rb:65:in `start' 
        from /Users/prawnhead/.rbenv/versions/2.4.0/lib/ruby/gems/2.4.0/gems/railties-5.0.6/lib/rails/commands/console_helper.rb:9:in `start' 
        from /Users/prawnhead/.rbenv/versions/2.4.0/lib/ruby/gems/2.4.0/gems/railties-5.0.6/lib/rails/commands/commands_tasks.rb:78:in `console'
        from /Users/prawnhead/.rbenv/versions/2.4.0/lib/ruby/gems/2.4.0/gems/railties-5.0.6/lib/rails/commands/commands_tasks.rb:49:in `run_command!' 
        from /Users/prawnhead/.rbenv/versions/2.4.0/lib/ruby/gems/2.4.0/gems/railties-5.0.6/lib/rails/commands.rb:18:in `<top (required)>' 
        from /Users/prawnhead/.rbenv/versions/2.4.0/lib/ruby/gems/2.4.0/gems/activesupport-5.0.6/lib/active_support/dependencies.rb:293:in `require' 
        from /Users/prawnhead/.rbenv/versions/2.4.0/lib/ruby/gems/2.4.0/gems/activesupport-5.0.6/lib/active_support/dependencies.rb:293:in `block in require' 
        from /Users/prawnhead/.rbenv/versions/2.4.0/lib/ruby/gems/2.4.0/gems/activesupport-5.0.6/lib/active_support/dependencies.rb:259:in `load_dependency' 
        from /Users/prawnhead/.rbenv/versions/2.4.0/lib/ruby/gems/2.4.0/gems/activesupport-5.0.6/lib/active_support/dependencies.rb:293:in `require' 
        from /Users/prawnhead/Cars/bin/rails:9:in `<top (required)>' 
        from /Users/prawnhead/.rbenv/versions/2.4.0/lib/ruby/gems/2.4.0/gems/activesupport-5.0.6/lib/active_support/dependencies.rb:287:in `load' 
        from /Users/prawnhead/.rbenv/versions/2.4.0/lib/ruby/gems/2.4.0/gems/activesupport-5.0.6/lib/active_support/dependencies.rb:287:in `block in load' 
        from /Users/prawnhead/.rbenv/versions/2.4.0/lib/ruby/gems/2.4.0/gems/activesupport-5.0.6/lib/active_support/dependencies.rb:259:in `load_dependency' 
        from /Users/prawnhead/.rbenv/versions/2.4.0/lib/ruby/gems/2.4.0/gems/activesupport-5.0.6/lib/active_support/dependencies.rb:287:in `load' 
        from /Users/prawnhead/.rbenv/versions/2.4.0/lib/ruby/gems/2.4.0/gems/spring-2.0.2/lib/spring/commands/rails.rb:6:in `call' 
        from /Users/prawnhead/.rbenv/versions/2.4.0/lib/ruby/gems/2.4.0/gems/spring-2.0.2/lib/spring/command_wrapper.rb:38:in `call' 
        from /Users/prawnhead/.rbenv/versions/2.4.0/lib/ruby/gems/2.4.0/gems/spring-2.0.2/lib/spring/application.rb:201:in `block in serve'
        from /Users/prawnhead/.rbenv/versions/2.4.0/lib/ruby/gems/2.4.0/gems/spring-2.0.2/lib/spring/application.rb:171:in `fork' 
        from /Users/prawnhead/.rbenv/versions/2.4.0/lib/ruby/gems/2.4.0/gems/spring-2.0.2/lib/spring/application.rb:171:in `serve' 
        from /Users/prawnhead/.rbenv/versions/2.4.0/lib/ruby/gems/2.4.0/gems/spring-2.0.2/lib/spring/application.rb:141:in `block in run'
        from /Users/prawnhead/.rbenv/versions/2.4.0/lib/ruby/gems/2.4.0/gems/spring-2.0.2/lib/spring/application.rb:135:in `loop' 
        from /Users/prawnhead/.rbenv/versions/2.4.0/lib/ruby/gems/2.4.0/gems/spring-2.0.2/lib/spring/application.rb:135:in `run' 
        from /Users/prawnhead/.rbenv/versions/2.4.0/lib/ruby/gems/2.4.0/gems/spring-2.0.2/lib/spring/application/boot.rb:19:in `<top (required)>' 
        from /Users/prawnhead/.rbenv/versions/2.4.0/lib/ruby/2.4.0/rubygems/core_ext/kernel_require.rb:55:in `require' 
        from /Users/prawnhead/.rbenv/versions/2.4.0/lib/ruby/2.4.0/rubygems/core_ext/kernel_require.rb:55:in `require' 
        from -e:1:in `<main>'irb(main):010:0>

Kaboom! It blew up like I thought it would. The issue here is that Rails likes to have it’s model associations (object relationships) defined from both sides. When we created Brand we told it nothing about Model. How could we? Model didn’t even exists! But when we created Model, we told it to store references to Brand. So we have a one-sided association that only works one way. Model > Brand, OK. Brand > Model, FAIL.

This will happen every time we create an association. So the pattern is:

  1. Create the first model (class), the model that is referenced by some other model
  2. Create the second model with references to the first model.
  3. Edit the first model to describe its relationship to the second.

If we open Cars/app/models/model.rb we can see it describes its relationship to Brand.

class Model < ApplicationRecord
  belongs_to :brand
end

If we open Cars/app/models/brand.rb we don’t have the reverse relationship described.

class Brand < ApplicationRecord
end

For a one-to-many association, the inverse of belongs_to is has_many. So we steal the formatting from Model to fix Brand. Note the plural!

class Brand < ApplicationRecord
  has_many :models # Line added
end

This time we need to close out of the console and come back in.

irb(main):011:0> exit
~/Cars: rails c
Running via Spring preloader in process 4762
Loading development environment (Rails 5.0.6)
No entry for terminal type "s/prawnhead/Cars/bin/rails";
using dumb terminal settings.
irb(main):001:0> f = Brand.find(1)
D, [2017-11-04T11:08:08.979027 #4762] DEBUG -- : Brand Load (0.1ms) SELECT "brands".* FROM "brands" WHERE "brands"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]]
+----+------+-------------------------+-------------------------+
| id | name | created_at              | updated_at              |
+----+------+-------------------------+-------------------------+
|  1 | Ford | 2017-11-03 21:41:03 UTC | 2017-11-03 21:41:03 UTC |
+----+------+-------------------------+-------------------------+
1 row in set
irb(main):002:0> f.models
D, [2017-11-04T11:08:11.331683 #4762] DEBUG -- : Model Load (0.1ms) SELECT "models".* FROM "models" WHERE "models"."brand_id" = ? [["brand_id", 1]]
+----+-------------+----------+----------------+----------------+
| id | name        | brand_id | created_at     | updated_at     |
+----+-------------+----------+----------------+----------------+
|  1 | Thunderbird |        1 | 2017-11-03 ... | 2017-11-03 ... |
+----+-------------+----------+----------------+----------------+
1 row in set
irb(main):003:0>

Data Integrity

So we finally have Brand and Model working as they should. But it’s possible to create instances of either of these models with no data at all. The fields are optional. For example:

irb(main):004:0> Brand.create
D, [2017-11-04T11:52:16.413464 #4762] DEBUG -- : (0.2ms) begin transaction
D, [2017-11-04T11:52:16.418481 #4762] DEBUG -- : SQL (0.5ms) INSERT INTO "brands" ("created_at", "updated_at") VALUES (?, ?) [["created_at", "2017-11-04 00:52:16.414780"], ["updated_at", "2017-11-04 00:52:16.414780"]]
D, [2017-11-04T11:52:16.421529 #4762] DEBUG -- : (2.4ms) commit transaction
+----+------+------------------+------------------+
| id | name | created_at       | updated_at       |
+----+------+------------------+------------------+
|  5 |      | 2017-11-04 00... | 2017-11-04 00... |
+----+------+------------------+------------------+
1 row in set
irb(main):005:0> Brand.all
D, [2017-11-04T11:52:23.454823 #4762] DEBUG -- : Brand Load (0.2ms) SELECT "brands".* FROM "brands"
+----+------+------------------+------------------+
| id | name | created_at       | updated_at       |
+----+------+------------------+------------------+
|  1 | Ford | 2017-11-03 21... | 2017-11-03 21... |
|  2 | Jeep | 2017-11-03 22... | 2017-11-03 22... |
|  4 | Saab | 2017-11-03 22... | 2017-11-03 22... |
|  5 |      | 2017-11-04 00... | 2017-11-04 00... |
+----+------+------------------+------------------+
4 rows in set
irb(main):006:0>

We’ll edit brand.rb and model.rb one last time to force all the attributes to be present before an instance of either can be created.

Cars/app/models/brand.rb

class Brand < ApplicationRecord
  has_many :models
  validates :name, presence: true
end

Cars/app/models/model.rb

class Model < ApplicationRecord
  belongs_to :brand
  validates :name, presence: true
  validates :brand_id, presence: true
end

Having made these changes, we can verify they have the desired effect using the console. First, exit and restart the console, then try to create a Brand with no name:

irb(main):003:0> Brand.create              
D, [2017-11-04T12:55:28.144687 #5143] DEBUG -- :    (0.0ms)  begin transaction
D, [2017-11-04T12:55:28.155571 #5143] DEBUG -- :    (0.1ms)  rollback transaction
+----+------+------------+------------+
| id | name | created_at | updated_at |
+----+------+------------+------------+
|    |      |            |            |
+----+------+------------+------------+
1 row in set
irb(main):004:0> 

If that failed as it should, create a Brand with a name:

irb(main):002:0> Brand.create(name: "Tata")
D, [2017-11-04T12:58:38.896413 #5557] DEBUG -- : (0.1ms) begin transaction
D, [2017-11-04T12:58:38.907523 #5557] DEBUG -- : SQL (0.4ms) INSERT INTO "brands" ("name", "created_at", "updated_at") VALUES (?, ?, ?) [["name", "Tata"], ["created_at", "2017-11-04 01:58:38.897141"], ["updated_at", "2017-11-04 01:58:38.897141"]]
D, [2017-11-04T12:58:38.910222 #5557] DEBUG -- : (2.2ms) commit transaction
+----+------+------------------+------------------+
| id | name | created_at       | updated_at       |
+----+------+------------------+------------------+
|  9 | Tata | 2017-11-04 01... | 2017-11-04 01... |
+----+------+------------------+------------------+
1 row in set
irb(main):003:0>

Fixing the HTML

Let’s exit the Rails console and start the Rails server again.

irb(main):001:0> exit
~/Cars: rails s
=> Booting Puma
=> Rails 5.0.6 application starting in development on http://localhost:3000
=> Run `rails server -h` for more startup options
Puma starting in single mode...
* Version 3.10.0 (ruby 2.4.0-p0), codename: Russell's Teapot
* Min threads: 5, max threads: 5
* Environment: development
* Listening on tcp://0.0.0.0:3000
Use Ctrl-C to stop

Now, using a browser let’s go an look at the HTML pages for the Model http://localhost:3000/models.

Model Page With Broken Brand Data
Model page with broken brand field

Not so pretty. But easy to fix. Edit Cars/app/views/models/index.html.erb and replace model.brand with model.brand.name.

<p id="notice"><%= notice %></p>

<h1>Models</h1>

<table>
  <thead>
    <tr>
      <th>Name</th>
      <th>Brand</th>
      <th colspan="3"></th>
    </tr>
  </thead>

  <tbody>
    <% @models.each do |model| %>
      <tr>
        <td><%= model.name %></td>
        <td><%= model.brand.name %></td> <!-- # Was model.brand -->
        <td><%= link_to 'Show', model %></td>
        <td><%= link_to 'Edit', edit_model_path(model) %></td>
        <td><%= link_to 'Destroy', model, method: :delete, data: { confirm: 'Are you sure?' } %></td>
      </tr>
    <% end %>
  </tbody>
</table>

<br>

<%= link_to 'New Model', new_model_path %>

Excellent.

Models Page With Brand Fixed
Models page with Brand fixed

If you click Show next to any Model we have the same issue. The same solution needs to be applied to show.html.erb.

Now, if we click New Model we see the new.html.erb file needs fixing too. But if you open new.html.erb you find it’s just a shell around a form. The form file’s name is _form.html.erb. Open that instead. We need only change the line <%= f.text_field :brand_id %> to read <%= f.collection_select :brand_id, Brand.order(:name), :id, :name, include_blank: true %>

<%= form_for(model) do |f| %>
  <% if model.errors.any? %>
    <div id="error_explanation">
      <h2><%= pluralize(model.errors.count, "error") %> prohibited this model from being saved:</h2>

      <ul>
      <% model.errors.full_messages.each do |message| %>
        <li><%= message %></li>
      <% end %>
      </ul>
    </div>
  <% end %>

  <div class="field">
    <%= f.label :name %>
    <%= f.text_field :name %>
  </div>

  <div class="field">
    <%= f.label :brand_id %>
    <!-- <%= f.text_field :brand_id %> --> <!-- Line removed -->
    <%= f.collection_select :brand_id, Brand.order(:name), :id, :name, include_blank: true %> <!-- Line added -->
  </div>

  <div class="actions">
    <%= f.submit %>
  </div>
<% end %>

So the text field is replaced with a selection list:

Model Form with Brand Fixed
Model form with Brand changed to selection box

Because we’ve fixed the form file (_form.html.erb) we have also fixed the edit page edit.html.erb. Use the links to move around between the various pages and make sure they all render without error and show the Brand correctly. Create, edit, update and delete (CRUD) a few Models to verify functionality.

Correctly Using JSON

JSON operates differently to the HTML pages. If we want to create a Model with the appropriate Brand, we need to first read the list of Brands and then submit our new Model with the appropriate brand_id only, not an object representing a Brand. Like this:

~/Cars: curl http://localhost:3000/brands.json
[{"id":1,"name":"Ford","created_at":"2017-11-03T21:41:03.398Z","updated_at":"2017-11-03T21:41:03.398Z","url":"http://localhost:3000/brands/1.json"},{"id":2,"name":"Jeep","created_at":"2017-11-03T22:00:13.182Z","updated_at":"2017-11-03T22:00:13.182Z","url":"http://localhost:3000/brands/2.json"},{"id":4,"name":"Saab","created_at":"2017-11-03T22:20:42.563Z","updated_at":"2017-11-03T22:20:42.563Z","url":"http://localhost:3000/brands/4.json"},{"id":9,"name":"Tata","created_at":"2017-11-04T01:58:38.897Z","updated_at":"2017-11-04T01:58:38.897Z","url":"http://localhost:3000/brands/9.json"}]~/Cars:

Let’s add a new Model for the Tata Nano. So we’ll POST JSON to the server like this:

~/Cars: curl -v -H "Accept: application/json" -H "Content-type: application/json" -d '{"name":"Nano", "brand_id":9}' http://localhost:3000/models
* Trying ::1...
* TCP_NODELAY set
* Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 3000 (#0)
> POST /models HTTP/1.1
> Host: localhost:3000
> User-Agent: curl/7.54.0
> Accept: application/json
> Content-type: application/json
> Content-Length: 29
>
* upload completely sent off: 29 out of 29 bytes
< HTTP/1.1 201 Created
< X-Frame-Options: SAMEORIGIN
< X-XSS-Protection: 1; mode=block
< X-Content-Type-Options: nosniff
< Content-Type: application/json; charset=utf-8
< Location: http://localhost:3000/models/2
< ETag: W/"5834060a392972f9b378f79c3c3e86d5"
< Cache-Control: max-age=0, private, must-revalidate
< X-Request-Id: 52a1b0a9-c930-43b8-ac4f-21479e7e9ba6
< X-Runtime: 0.015670
< Transfer-Encoding: chunked
<
* Connection #0 to host localhost left intact
{"id":2,"name":"Nano","brand_id":9,"created_at":"2017-11-04T03:49:37.296Z","updated_at":"2017-11-04T03:49:37.296Z","url":"http://localhost:3000/models/2.json"}~/Cars:

You can see the POST was responded to by returning the JSON text of the new record that was created. Wouldn’t it be nice though to have the JSON of the Brand appearing within the JSON of the Model? We can do that. We need to edit two files as shown:

Cars/app/views/index.json.jbuilder

# The following line was removed
# json.array! @models, partial: 'models/model', as: :model

# The following code block was added
json.array!(@models) do |model|
  json.extract! model, :id, :name
  json.url model_url(model, format: :json)
  json.brand do
    json.id model.brand.id
    json.name model.brand.name
    json.url brand_url(model.brand, format: :json)
  end
end

Cars/app/views/show.json.jbuilder.

# The following line was removed
# json.partial! "models/model", model: @model

# The following code block was added
json.extract! @model, :id, :name, :brand_id, :created_at, :updated_at
json.url model_url(@model, format: :json)
json.brand do
  json.id @model.brand.id
  json.name @model.brand.name
  json.url brand_url(@model.brand, format: :json)
end

With these changes saved, you should be able to check the results using URLs like http://localhost:3000/models.json and http://localhost:3000/models/1.json

The correct results will look like this:

Models in JSON with Embedded Brand JSON
Models in JSON with embedded Brand JSON
A Single Model's JSON with Embedded Brand JSON
A single Model’s JSON with embedded Brand JSON

Oh, one other interesting thing to note: referential integrity is automatically enforced. If, using JSON, you try to use a brand_id that doesn’t exist in the Brand table, your update will fail like this. Note the last line.

~: curl -v -H "Accept: application/json" -H "Content-type: application/json" -d '{"name":"ShouldNotExist", "brand_id":9}' http://localhost:3000/models
* Trying ::1...
* TCP_NODELAY set
* Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 3000 (#0)
> POST /models HTTP/1.1
> Host: localhost:3000
> User-Agent: curl/7.54.0
> Accept: application/json
> Content-type: application/json
> Content-Length: 39
>
* upload completely sent off: 39 out of 39 bytes
< HTTP/1.1 422 Unprocessable Entity
< X-Frame-Options: SAMEORIGIN
< X-XSS-Protection: 1; mode=block
< X-Content-Type-Options: nosniff
< Content-Type: application/json; charset=utf-8
< Cache-Control: no-cache
< X-Request-Id: df29519d-a639-49bd-8302-4ebd6819fe08
< X-Runtime: 0.013680
< Transfer-Encoding: chunked
<
* Connection #0 to host localhost left intact
{"brand":["must exist"]}~:

Summary

This hasn’t, of course, covered every use case that might be similar to what I’ve outlined, but it’s general ground in Rails, Rails Server, Rails Console and HTTP access to the server utilising by HTML and JSON.

Thanks for reading!