Skip to content

Commit

Permalink
Add relations support and associated tests
Browse files Browse the repository at this point in the history
Introduced a comprehensive set of relationship macros to define 'belongs_to', 'has_many', 'has_one', and 'many_to_many' associations within the Cql module. Added extensive unit tests to validate these associations using mock entities and database schema.

Updated core query and record handling methods to improve flexibility and ensure compatibility with the new relations. This enhancement facilitates defining and managing complex data object relationships, significantly simplifying development workflows involving relational databases.
  • Loading branch information
eliasjpr committed Aug 14, 2024
1 parent 40d8dda commit 174ef1d
Show file tree
Hide file tree
Showing 9 changed files with 354 additions and 5 deletions.
180 changes: 180 additions & 0 deletions spec/relations_spec.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
require "./spec_helper"
AcmeDB = Cql::Schema.build(
:acme_db,
adapter: Cql::Adapter::Postgres,
uri: ENV["DATABASE_URL"]) do
table :movies do
primary :id, Int64, auto_increment: true
text :title
end

table :screenplays do
primary :id, Int64, auto_increment: true
bigint :movie_id
text :content
end

table :actors do
primary :id, Int64, auto_increment: true
text :name
end

table :movies_actors do
primary :id, Int64, auto_increment: true
bigint :movie_id
bigint :actor_id
end
end

struct Movie
include Cql::Record(Movie)
include Cql::Relations

define AcmeDB, :movies

has_one :screenplay, Screenplay
many_to_many :actors, Actor, join_through: :movies_actors

getter id : Int64?
getter title : String

def initialize(@title : String)
end
end

struct Screenplay
include Cql::Record(Screenplay)
include Cql::Relations

define AcmeDB, :screenplays

belongs_to :movie, foreign_key: :movie_id

getter id : Int64?
getter content : String

def initialize(@movie_id : Int64, @content : String)
end
end

struct Actor
include Cql::Record(Actor)
include Cql::Relations

define AcmeDB, :actors

getter id : Int64?
getter name : String

def initialize(@name : String)
end
end

struct MoviesActors
include Cql::Record(MoviesActors)

define AcmeDB, :movies_actors

getter id : Int64?
getter movie_id : Int64
getter actor_id : Int64

def initialize(@movie_id : Int64, @actor_id : Int64)
end
end

describe Cql::Relations do
before_each do
AcmeDB.movies.create!
AcmeDB.screenplays.create!
AcmeDB.actors.create!
AcmeDB.movies_actors.create!
end

# after_each do
# AcmeDB.movies.drop!
# AcmeDB.screenplays.drop!
# AcmeDB.actors.drop!
# # AcmeDB.movies_actors.drop!
# end
#

describe "belongs_to" do
it "defines the belongs_to association" do
movie_id = Movie.create(title: "The Godfather")
screenplay_id = Screenplay.create(movie_id: movie_id, content: "The screenplay")
screenplay = Screenplay.find!(screenplay_id)
movie = Movie.find!(movie_id)

screenplay.movie = movie
screenplay.save

screenplay.movie.title.should eq "The Godfather"
end

it "builds the association" do
movie_id = Movie.create(title: "The Godfather")
screenplay_id = Screenplay.create(movie_id: movie_id, content: "The screenplay")
screenplay = Screenplay.find!(screenplay_id)
movie = screenplay.build_movie(title: "The Godfather")

movie.title.should eq "The Godfather"
end

it "updates the association" do
movie_id = Movie.create(title: "The Godfather")
screenplay_id = Screenplay.create(movie_id: movie_id, content: "The screenplay")
screenplay = Screenplay.find!(screenplay_id)

screenplay.movie.title.should eq "The Godfather"
screenplay.update_movie(title: "The Godfather 10")
screenplay.movie.title.should eq "The Godfather 10"
end

it "deletes the association" do
movie_id = Movie.create(title: "The Godfather")
screenplay_id = Screenplay.create(movie_id: movie_id, content: "The screenplay")
screenplay = Screenplay.find!(screenplay_id)

screenplay.movie.title.should eq "The Godfather"
screenplay.delete_movie

expect_raises DB::NoResultsError do
screenplay.movie
end
end
end

describe "has_one" do
it "defines the has_one association" do
movie_id = Movie.create(title: "The Godfather")
screenplay_id = Screenplay.create(movie_id: movie_id, content: "The screenplay")
movie = Movie.find!(movie_id)
movie.screenplay = Screenplay.find!(screenplay_id)
movie.save

screenplay = movie.screenplay
screenplay.movie = movie

screenplay.content.should eq "The screenplay"
end
end

describe "many_to_many" do
it "defines the many_to_many association" do
movie_id = Movie.create(title: "The Godfather")
actor1 = Actor.create(name: "Marlon Brando")
actor2 = Actor.create(name: "Al Pacino")
movie = Movie.find!(movie_id)

movie << Actor.find!(actor1)
movie << Actor.find!(actor2)

actors = movie.actors!

actors.size.should eq 2
actors[0].name.should eq "Marlon Brando"
actors[1].name.should eq "Al Pacino"
end
end
end
1 change: 1 addition & 0 deletions src/cql.cr
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ require "./delete"
require "./schema"
require "./repository"
require "./record"
require "./relations"
require "./migrations"

module Cql
Expand Down
2 changes: 1 addition & 1 deletion src/query.cr
Original file line number Diff line number Diff line change
Expand Up @@ -403,7 +403,7 @@ module Cql
join_table = Expression::Table.new(tbl)
tables = @tables.dup
tables[table] = tbl
builder = with Expression::FilterBuilder.new(tables) yield table_expr
builder = with Expression::FilterBuilder.new(tables) yield
@joins << Expression::Join.new(Expression::JoinType::INNER, join_table, builder.condition)
self
end
Expand Down
21 changes: 17 additions & 4 deletions src/record.cr
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,6 @@ module Cql
module Record(T)
macro included
include DB::Serializable

@@schema : Cql::Schema? = nil
@@table : Symbol? = nil

Expand Down Expand Up @@ -168,6 +167,18 @@ module Cql
query.where(**fields).first(T)
end

# Find a record by specific fields, raise an error if not found
# - **@param** fields [Hash(Symbol, DB::Any)] The fields to match
# - **@return** [T] The record
#
# **Example** Fetching a record by email
# ```
# user_repo.find_by!(email: " [email protected]")
# ```
def self.find_by!(**fields)
query.where(**fields).first!(T)
end

# Find all records matching specific fields
# - **@param** fields [Hash(Symbol, DB::Any)] The fields to match
# - **@return** [Array(T)] The records
Expand Down Expand Up @@ -262,15 +273,17 @@ module Cql
end

def self.update(id, fields : Hash(Symbol, DB::Any))
update.set(fields).where(id: id).commit
update.set(**fields).where(id: id).commit
end

def self.update(id, record : T)
update.set(record.attributes).where(id: id).commit
end

def self.update(record : T)
update.set(record.attributes).where(id: record.id).commit
attrs = record.attributes
attrs.delete(:id)
update.set(attrs).where(id: record.id).commit
end

# Update records matching where attributes with update attributes
Expand Down Expand Up @@ -464,7 +477,7 @@ module Cql
hash = Hash(Symbol, DB::Any).new
{% for ivar in T.instance_vars %}
hash[:{{ ivar }}] = {{ ivar }}
{% end %}
{% end %}
hash
end

Expand Down
14 changes: 14 additions & 0 deletions src/relations.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
require "./relations/*"

module Cql
module Relations
# Define class-level storage for associations
@@associations = {} of Symbol => Hash(Symbol, Symbol)
macro included
include Cql::Relations::BelongsTo
include Cql::Relations::HasMany
include Cql::Relations::HasOne
include Cql::Relations::ManyToMany
end
end
end
35 changes: 35 additions & 0 deletions src/relations/belongs_to.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
module Cql::Relations
module BelongsTo
# Define the belongs_to association
macro belongs_to(assoc, foreign_key)
@{{foreign_key.id}} : {{assoc.camelcase.id}}

def {{assoc.id}} : {{assoc.camelcase.id}}
{{assoc.camelcase.id}}.find!(@{{foreign_key.id}})
end

property {{assoc.id}}_id : Int64

def {{assoc.id}}=(record : {{assoc.camelcase.id}})
@{{foreign_key.id}} = record.id.not_nil!
end

def build_{{assoc.id}}(**attributes) : {{assoc.camelcase.id}}
{{assoc.camelcase.id}}.new(**attributes)
end

def create_{{assoc.id}}(**attributes) : {{assoc.camelcase.id}}
@{{foreign_key.id}} = {{assoc.camelcase.id}}.create!(**attributes)
end

def update_{{assoc.id}}(**attributes) : {{assoc.camelcase.id}}
{{assoc.camelcase.id}}.update(@{{foreign_key.id}}, **attributes)
movie
end

def delete_{{assoc.id}} : Bool
{{assoc.camelcase.id}}.delete(@{{foreign_key.id}}).rows_affected > 0
end
end
end
end
39 changes: 39 additions & 0 deletions src/relations/has_many.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
module Cql::Relations
# Define the has_many association
module HasMany
macro has_many(name, foreign_key)
def {{name.id}} : Array(T)
primary_key_value = @id
T.query.where({{foreign_key.id}} => primary_key_value).all
end

def build_{{name.id.singularize}}(attributes : Hash(Symbol, _)) : T
attributes[:{{foreign_key.id}}] = @id
record = T.new(attributes)
record
end

def create_{{name.id.singularize}}(attributes : Hash(Symbol, _)) : T
record = build_{{name.id.singularize}}(attributes)
record.save
record
end

def update_{{name.id.singularize}}(id : Int64, attributes : Hash(Symbol, _)) : T
record = T.query.where(id: id).first
record.update(attributes)
record
end

def delete_{{name.id.singularize}}(id : Int64) : Bool
record = T.query.where(id: id).first
record.delete
end

def <<(record : T) : Bool
record.{{foreign_key.id}} = @id
record.save
end
end
end
end
41 changes: 41 additions & 0 deletions src/relations/has_one.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
module Cql::Relations
# Define the has_one association
module HasOne
macro has_one(name, foreign_key)
@@associations[:has_one] ||= {} of Symbol => Symbol
@@associations[:has_one][:{{name.id}}] = :{{foreign_key.stringify.underscore.id}}

def {{name.id}} : {{foreign_key.id}}
{{foreign_key}}.find_by({{T.stringify.underscore.id}}_id: @id)
end

def {{name.id}}=(record : {{foreign_key.id}})
record.{{T.name.underscore.id}}_id = @id.not_nil!
end

def build_{{name.id}}(attributes : Hash(Symbol, DB::Any)) : T
attributes[:{{foreign_key.stringify.underscore.id}}] = @id
record = T.new(attributes)
record
end

def create_{{name.id}}(attributes : Hash(Symbol, DB::Any)) : T
attributes[:{{foreign_key.id}}] = @id
record = build_{{name.id}}(attributes)
record.save
record
end

def update_{{name.id}}(attributes : Hash(Symbol, DB::Any)) : T
record = {{name.id}}
record.update(attributes)
record
end

def delete_{{name.id}} : Bool
record = {{name.id}}
record.delete
end
end
end
end
Loading

0 comments on commit 174ef1d

Please sign in to comment.