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.
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.