When I started this project I thought the database would be the last stuff, and for whole development process, CSV files would be enaugh. But now I realize, it would be much simpler to follow want I want to achieve if switch to a regular database now.

There are a lot of possibilities to choose from, a lot of databases, and since I don’t sync data from external services yet, probably I can make a mistake, and choose wrongly. But on the other hand, in this moment of life application whatever I chose would be fine.

I decide to go with sqlite. SQLite is a C-language library that implements a small, fast, self-contained, high-reliability, full-featured, SQL database engine. SQLite is the most used database engine in the world. SQLite is built into all mobile phones and most computers and comes bundled inside countless other applications that people use every day

For ruby there is a gem sqlite3

Here’s an updated version of my controller that uses SQLite3 and raw SQL queries instead of CSV:

require "json"
require "sqlite3"
require "dotenv"

Dotenv.load(".env.development") if ENV["RACK_ENV"] == "development"
Dotenv.load(".env.test") if ENV["RACK_ENV"] == "test"

module Bikes
  class Controller
    def initialize
      @database = ENV["BIKES_DB"]
      setup_database
    end

    def index(request)
      bikes = read_database

      [200, { "content-type" => "application/json" }, [bikes.to_json]]
    end

    def create(request)
      bike_data = JSON.parse(request.body.read)
      bike_id = generate_bike_id
      bike_data["id"] = bike_id

      insert_bike(bike_data)

      [201, { "content-type" => "text/plain" }, ["Create"]]
    end

    def read(request, bike_id)
      bike = find_bike_by_id(bike_id)

      if bike
        [200, { "content-type" => "application/json" }, [bike.to_json]]
      else
        [404, { "content-type" => "text/plain" }, ["Not Found"]]
      end
    end

    def update(request, bike_id)
      bike_data = JSON.parse(request.body.read)
      result = update_bike(bike_id, bike_data)

      if result
        [200, { "content-type" => "text/plain" }, ["Update with ID #{bike_id}"]]
      else
        [404, { "content-type" => "text/plain" }, ["Not Found"]]
      end
    end

    def delete(request, bike_id)
      result = delete_bike(bike_id)

      if result
        [200, { "content-type" => "text/plain" }, ["Delete with ID #{bike_id}"]]
      else
        [404, { "content-type" => "text/plain" }, ["Not Found"]]
      end
    end

    private

    def setup_database
      db = SQLite3::Database.new(@database)
      db.execute <<-SQL
        CREATE TABLE IF NOT EXISTS bikes (
          id INTEGER PRIMARY KEY,
          name TEXT,
          description TEXT
        );
      SQL
    end

    def read_database
      db = SQLite3::Database.new(@database)
      db.execute("SELECT * FROM bikes").map { |row| { "id" => row[0], "name" => row[1], "description" => row[2] } }
    end

    def generate_bike_id
      db = SQLite3::Database.new(@database)
      result = db.execute("SELECT MAX(id) FROM bikes").flatten.first
      result.nil? ? 1 : result + 1
    end

    def insert_bike(bike_data)
      db = SQLite3::Database.new(@database)
      db.execute("INSERT INTO bikes (id, name, description) VALUES (?, ?, ?)", [bike_data["id"], bike_data["name"], bike_data["description"]])
    end

    def find_bike_by_id(bike_id)
      db = SQLite3::Database.new(@database)
      result = db.execute("SELECT * FROM bikes WHERE id = ?", [bike_id]).first
      result ? { "id" => result[0], "name" => result[1], "description" => result[2] } : nil
    end

    def update_bike(bike_id, bike_data)
      db = SQLite3::Database.new(@database)
      db.execute("UPDATE bikes SET name = ?, description = ? WHERE id = ?", [bike_data["name"], bike_data["description"], bike_id])
      db.changes > 0
    end

    def delete_bike(bike_id)
      db = SQLite3::Database.new(@database)
      db.execute("DELETE FROM bikes WHERE id = ?", [bike_id])
      db.changes > 0
    end
  end
end

This updated version uses SQLite3 as the database backend and performs raw SQL queries to interact with the database. The setup_database method is called during initialization to create the necessary table if it doesn’t exist. The other methods have been modified accordingly to use SQLite3’s APIs for database operations.

In my envs I can set separate databases for development and tests. Everything looks great. But I’m not a masochist. Writing an app without a framework is enough pain. I’m going to ORM, ActiveRecord to be more precise. I have a lot of issues with ActiveRecord In the ruby world, the active record is the name of an ORM gem, which implemented an active record pattern described by Martin Fowler

“An object that wraps a row in a database table or view, encapsulates the database access, and adds domain logic on that data.”

Unfortunately, this is not everything. In Rails world we totally overuse ActiveRecord.

We get users, and from users we get posts, one post could have comments, comment author, and we back to the model user. It’s not bad if you have control over that, but in larger projects, sometimes a small change can break everything (ofc, everybody catches it fast, we have tests). The second thing is Active Record classes. Developers put there everything, not only associations, but also validations, custom methods, and callbacks.

But I know what I want to achieve with active record, so I’m going to use it there.

Gem activercord should be familiar to everybody who touched ruby on rails.

I’m going to create db directory, with a new namespace - Db where I’m going to put everything related to databases. I have to create a new file, let’s call it bike.rb, and define a Bike class that inherits from ActiveRecord::Base.

require "active_record"

module Db
  class Bike < ActiveRecord::Base
    has_many :components
  end
end
require "json"
require "active_record"
require_relative "../db/Bike"

module Bikes
  class Controller
    def initialize
      setup_database
    end

    def index(request)
      bikes = Db::Bike.all
      [200, {"content-type" => "application/json"}, [bikes.to_json]]
    end

    def create(request)
      bike_data = JSON.parse(request.body.read)
      bike = Db::Bike.create(bike_data)
      if bike
        [201, {"content-type" => "text/plain"}, ["Create"]]
      else
        [500, {"content-type" => "text/plain"}, ["Error creating bike"]]
      end
    end

    def read(request, bike_id)
      bike = Db::Bike.find_by(id: bike_id)
      if bike
        [200, {"content-type" => "application/json"}, [bike.to_json]]
      else
        [404, {"content-type" => "text/plain"}, ["Not Found"]]
      end
    end

    def update(request, bike_id)
      bike_data = JSON.parse(request.body.read)
      bike = Db::Bike.find_by(id: bike_id)
      if bike
        if bike.update(bike_data)
          [200, {"content-type" => "text/plain"}, ["Update with ID #{bike_id}"]]
        else
          [500, {"content-type" => "text/plain"}, ["Error updating bike"]]
        end
      else
        [404, {"content-type" => "text/plain"}, ["Not Found"]]
      end
    end

    def delete(request, bike_id)
      bike = Db::Bike.find_by(id: bike_id)
      if bike
        if bike.destroy
          [200, {"content-type" => "text/plain"}, ["Delete with ID #{bike_id}"]]
        else
          [500, {"content-type" => "text/plain"}, ["Error deleting bike"]]
        end
      else
        [404, {"content-type" => "text/plain"}, ["Not Found"]]
      end
    end

    private

    def setup_database
      ActiveRecord::Base.configurations = YAML.load_file('db/configuration.yml')
      ActiveRecord::Base.establish_connection(ENV["RACK_ENV"].to_sym)
    end
  end
end

I have to update my tests

require "csv"
require "json"
require_relative "./../../bikes/controller"

RSpec.describe Bikes::Controller do
  let(:controller) { Bikes::Controller.new }

  describe "#index" do
    it "returns the list of bikes" do
      allow(Db::Bike).to receive(:all).and_return([{id: 1, name: "Mountain Bike"}])

      response = controller.index(nil)

      expect(response).to eq([200, {"content-type" => "application/json"}, [[{id: 1, name: "Mountain Bike"}].to_json]])
    end
  end

  describe "#create" do
    it "creates a new bike" do
      allow(Db::Bike).to receive(:create).and_return([{id: 1, name: "Mountain Bike"}])

      request = double("request", body: double("body", read: {name: "Mountain Bike"}.to_json))

      response = controller.create(request)

      expect(response).to eq([201, {"content-type" => "text/plain"}, ["Create"]])
      expect(Db::Bike).to have_received(:create).with({"name" => "Mountain Bike"})
    end
  end

  describe "#read" do
    it "returns the bike with the given id if it exists" do
      allow(Db::Bike).to receive(:find_by).and_return({id: 1, name: "Mountain Bike"})

      response = controller.read(nil, 1)

      expect(response).to eq([200, {"content-type" => "application/json"}, [{id: 1, name: "Mountain Bike"}.to_json]])
    end

    it "returns 404 Not Found if the bike does not exist" do
      allow(controller).to receive(:read_database).and_return([])

      response = controller.read(nil, 1)

      expect(response).to eq([404, {"content-type" => "text/plain"}, ["Not Found"]])
    end
  end

  describe "#update" do
    it "updates the bike with the given id if it exists" do
      allow(Db::Bike).to receive(:find_by).and_return({id: 1, name: "Mountain Bike"})
      allow(Db::Bike).to receive(:update)

      request = double("request", body: double("body", read: {name: "Road Bike"}.to_json))

      response = controller.update(request, 1)

      expect(response).to eq([200, {"content-type" => "text/plain"}, ["Update with ID 1"]])
    end

    it "returns 404 Not Found if the bike does not exist" do
      allow(controller).to receive(:read_database).and_return([])

      request = double("request", body: double("body", read: {name: "Road Bike"}.to_json))

      response = controller.update(request, 1)

      expect(response).to eq([404, {"content-type" => "text/plain"}, ["Not Found"]])
    end
  end

  describe "#delete" do
    it "deletes the bike with the given id if it exists" do
      bike = double("bike", {id: 1, name: "Mountain Bike"})
      allow(Db::Bike).to receive(:find_by).and_return(bike)
      allow(bike).to receive(:destroy).and_return(true)

      response = controller.delete(nil, 1)

      expect(response).to eq([200, {"content-type" => "text/plain"}, ["Delete with ID 1"]])
    end

    it "returns 404 Not Found if the bike does not exist" do
      allow(Db::Bike).to receive(:find_by).and_return(nil)

      response = controller.delete(nil, 1)

      expect(response).to eq([404, {"content-type" => "text/plain"}, ["Not Found"]])
    end
  end
end

And now similar stuff I have to do with my second CRUD, components. The last topic for today are migrations. With rails we could just run rails db:migrate and everything is handle for us. In the root directory, I created a file Rackfile to handle that.

require 'yaml'
require 'logger'
require 'active_record'

include ActiveRecord::Tasks

class Seeder
  def initialize(seed_file)
    @seed_file = seed_file
  end

  def load_seed
    raise "Seed file '#{@seed_file}' does not exist" unless File.file?(@seed_file)
    load @seed_file
  end
end

root = File.expand_path '..', __FILE__
DatabaseTasks.env = ENV['RACK_ENV'] || 'development'
DatabaseTasks.database_configuration = YAML.load(File.read(File.join(root, 'db/configuration.yml')))
DatabaseTasks.db_dir = File.join root, 'db'
DatabaseTasks.fixtures_path = File.join root, 'spec/fixtures'
DatabaseTasks.migrations_paths = [File.join(root, 'db/migrations')]
DatabaseTasks.seed_loader = Seeder.new File.join root, 'db/seeds.rb'
DatabaseTasks.root = root

task :environment do
  ActiveRecord::Base.configurations = DatabaseTasks.database_configuration
  ActiveRecord::Base.establish_connection DatabaseTasks.env.to_sym
end

load 'active_record/railties/databases.rake'

And now I can create two files with database migrations.

class CreateBikes < ActiveRecord::Migration[7.0]
  def change
    create_table :bikes do |t|
      t.string :name
      t.timestamps
    end
  end
end
class CreateComponents < ActiveRecord::Migration[7.0]
  def change
    create_table :components do |t|
      t.references :bike
      t.string :name
      t.text :description
      t.timestamps
    end
  end
end

I think it shows how complicated are things, which are super simple in the framework. I made a shortcut and I used ActiveRecord. But without any ORM it would be much harder.

Like always code is available on github.