back end dev
Complex Schema in Rails
Handling confusing schema and difficult database design

Rails Relations

After one year working with an amazing dev team building interactive demos for large events and running into administration issues dealing with the complexity, it started to make sense to look into bootstrapping a tool to help keep track. It started while troubleshooting a permanent physical installation in a new skyscraper: two US National Park style viewfinders wired to 360 cameras on the roof. The install uses two Samsung phones locked into an enclosure with too many security screws. It's hard to access, it lives on a segmented network, and it goes down all the freaking time.

Our proposed architecture is a binary that we can deploy on devices and a Rails API to receive and store the information.

We need a laundry list of data for this to scale across multiple events, with multiple demos at each event, and each demo itself containing multiple devices that in turn contain multiple network interfaces. We have to network with all of them for any remote support. It's a nesting doll of systems administration pain. Ignoring the top few bits of the layer cake, we can focus on just these three objects: Demo, Device, and Nic (Network Interface Controller). Demos can have many devices, and devices can have many Nics.

The binary is written in golang for easy cross-compiling (this specific event used Android, iOS, Windows, and Linux). Currently the "agent" collects a hostname, screenshot, mac addresses and IP addresses and hands them off either directly to our API or to a Lambda function middleware.

This quick case study will focus mostly on the associations required to handle the complex relations.

Setup

rails new <app name> --database=postgresql
Postgres

Using the custom multiple database postgres docker image:

docker run -id --name rails-db -e POSTGRES_MULTIPLE_DATABASES="railyard-dev","railyard-test","railyard-prod" -e POSTGRES_USER=<database user> -e POSTGRES_PASSWORD=<password> -p 5432:5432 db:latest

Rails

Config
#Gemfile
gem 'dotenv'
#/config/database.yml

default: &default
  adapter: postgresql
  encoding: unicode
  pool: 5
  timeout: 5000
  username: worker
  password: foobarbat
  host: 127.0.0.1
  port: 5432

development:
  <<: *default
  database: railyard-dev

test:
  <<: *default
  database: railyard-test

production:
  <<: *default
  database: railyard-prod
Generating Initial Models

The first step is generating the two models that we need: one for Demos, one for Devices, and one for Nics. Each has slightly different schema:

rails generate model Demo name:string location:string
rails generate model Device hostname:string
rails generate model Nic ip_addr:string mac_addr:string iface_name:string

The next step is to link the models using these "polymorphic" tables (I think). I'm not sure if this is the best way, but coming from Django it's the only thing that works and makes sense. It's kind of funky, but it seems to work pretty well.

rails generate model DemosDevice
rails generate model DevicesNic

Inside the migrations that these last two generate, we need to add:

# DemosDevice
class CreateDemosDevices < ActiveRecord::Migration[5.2]
  def change
    create_table :demos_devices do |t|
      t.belongs_to :demo, index: true
      t.belongs_to :device, index: true
      t.timestamps
    end
  end
end
# DevicesNic
class CreateDevicesNics < ActiveRecord::Migration[5.2]
  def change
    create_table :devices_nics do |t|
      t.belongs_to :device, index: true
      t.belongs_to :nic, index: true
      t.timestamps
    end
  end
end
Models

** Define the Associations: **

Now inside app/models, we need to add the polymorphic association to the individual models using a trick. The basic format is this:

# app/models/device.rb
class Device < ApplicationRecord
  has_many :devices_nics
  has_many :nics, through: :devices_nics
end

The two lines we add are key! The first creates a new has_many relation (many-to-one), and then links it to the :nics model.

Nics are slightly different, because they can only have one device:

# app/models/nic.rb
class Nic < ApplicationRecord
  has_one :device, through: :devices_nics
end

And the last piece is the DevicesNic middleware model:

# app/models/devices_nic.rb
class DevicesNic < ApplicationRecord
  belongs_to :device
  belongs_to :nic
end

The same thing gets repeated for DemosDevices, with Demos having many devices through demos_devices, etc.

Controllers

Generating the models and creating the associations manually is labor intensive and unintuitive, but creating objects in the Controllers is a little easier. Because I am passing a large data object with lots of information directly to the API, we just need the ability to create the nested associations in the parents. Of course, Rails has its own idiosyncracies with this as well. The idea is that we need to wait until the parent has been created and saved, and then construct the child from data within the same request.

It looks like this:

# app/controllers/device_controller.rb
# POST /devices
  def create
    body = JSON.parse request.raw_post      # Get Raw Request Body
    id = body['id']
    ip_addrs = body['ip_addrs']

    @device = Device.new(:hostname => id)   # Create new Device

    if @device.save                         # Wait for Device to Save
      ip_addrs.each do |ip|
        @device.nics.create(ip)             # Create Nic
      end
      render json: @device.as_json(include:[:nics]), status: :created, location: @device
    else
      render json: @device.errors, status: :unprocessable_entity
    end
  end