diff --git a/spec/relations_spec.cr b/spec/relations_spec.cr new file mode 100644 index 0000000..878cf50 --- /dev/null +++ b/spec/relations_spec.cr @@ -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 diff --git a/src/cql.cr b/src/cql.cr index 99869d3..e9cb04e 100644 --- a/src/cql.cr +++ b/src/cql.cr @@ -17,6 +17,7 @@ require "./delete" require "./schema" require "./repository" require "./record" +require "./relations" require "./migrations" module Cql diff --git a/src/query.cr b/src/query.cr index 7d66356..4d10f31 100644 --- a/src/query.cr +++ b/src/query.cr @@ -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 diff --git a/src/record.cr b/src/record.cr index 89eeaef..81d5528 100644 --- a/src/record.cr +++ b/src/record.cr @@ -49,7 +49,6 @@ module Cql module Record(T) macro included include DB::Serializable - @@schema : Cql::Schema? = nil @@table : Symbol? = nil @@ -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 @@ -262,7 +273,7 @@ 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) @@ -270,7 +281,9 @@ module Cql 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 @@ -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 diff --git a/src/relations.cr b/src/relations.cr new file mode 100644 index 0000000..604b240 --- /dev/null +++ b/src/relations.cr @@ -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 diff --git a/src/relations/belongs_to.cr b/src/relations/belongs_to.cr new file mode 100644 index 0000000..443e4b1 --- /dev/null +++ b/src/relations/belongs_to.cr @@ -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 diff --git a/src/relations/has_many.cr b/src/relations/has_many.cr new file mode 100644 index 0000000..73b1792 --- /dev/null +++ b/src/relations/has_many.cr @@ -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 diff --git a/src/relations/has_one.cr b/src/relations/has_one.cr new file mode 100644 index 0000000..95530c6 --- /dev/null +++ b/src/relations/has_one.cr @@ -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 diff --git a/src/relations/many_to_many.cr b/src/relations/many_to_many.cr new file mode 100644 index 0000000..7b6d628 --- /dev/null +++ b/src/relations/many_to_many.cr @@ -0,0 +1,26 @@ +module Cql::Relations + module ManyToMany + macro many_to_many(name, type, join_through) + def <<(record : {{type.id}}) + {{join_through.camelcase.id}}.create({{T.stringify.underscore.id}}_id: @id, {{type.stringify.downcase.id}}_id: record.id) + end + + def {{name.id}}! : Array({{type.id}}) + {{type.id}}.query + .inner(:{{join_through.id}}) { ({{join_through.id}}.{{type.stringify.underscore.id}}_id == {{name.id}}.id)}.where{({{join_through.id}}.{{T.name.underscore.id}}_id == @id)}.all({{type.id}}) + end + + def add_{{name.id}}(record : {{type.id}}) : Bool + join_table = {{join_through.id}} + join_record = join_table.new({{T.stringify.underscore.id}}_id: @id, {{type.id}}_id: record.id) + join_record.save + end + + def remove_{{name.id}}(record : {{type.id}}) : Bool + join_table = {{join_through.id}} + join_record = join_table.query.where({{T.stringify.underscore.id}}_id: @id, {{type.id}}_id: record.id).first + join_record.delete + end + end + end +end