From 599606639d5b7dc43e005eb5b482df8a20332406 Mon Sep 17 00:00:00 2001 From: Holden Omans Date: Mon, 24 Apr 2023 16:53:54 -0400 Subject: [PATCH] Merge fork with enhancements/fixes (#456) * Add a check to ensure created_at and updated_at are Time If a column is named `created_at` or `updated_at` is not a type `Time?`, then the following occurs: ```There was a problem expanding macro 'macro_4801706624' Code in lib/granite/src/granite/transactions.cr:82:5 82 | {% if @type.instance_vars.select { |ivar| ivar.annotation(Granite::Column) }.map(&.name.stringify).includes? "created_at" %} ^ Called macro defined in lib/granite/src/granite/transactions.cr:82:5 82 | {% if @type.instance_vars.select { |ivar| ivar.annotation(Granite::Column) }.map(&.name.stringify).includes? "created_at" %} Which expanded to: > 1 | > 2 | if mode == :create > 3 | @created_at = time.at_beginning_of_second ^--------------------- ``` this simply adds a type check. * fix allowing enum columns * ameba fixes * fix build_find_by_clause for bool values * minor update * Fix issue where primary key can be pushed twice on create * fix infinate recusion when searching with enum * add retry to adapter base * add retry to adapters * minor update * add support for querying with Array(UUID) * format docs * FIX: add converter to read_attribute * Fix for type declaration in has_one * Fix callback runs on #import * more has_one fixes; fixes for through associations --------- Co-authored-by: Seth T --- docs/crud.md | 4 + docs/imports.md | 217 +++++++++++++------------- docs/migrations.md | 3 + docs/models.md | 27 ++-- docs/querying.md | 23 ++- docs/readme.md | 7 +- docs/relationships.md | 11 +- docs/validations.md | 1 + src/adapter/base.cr | 14 +- src/adapter/mysql.cr | 8 +- src/granite.cr | 1 + src/granite/association_collection.cr | 5 +- src/granite/associations.cr | 18 ++- src/granite/columns.cr | 34 ++-- src/granite/query/assemblers/base.cr | 9 +- src/granite/query/builder.cr | 6 + src/granite/querying.cr | 2 +- src/granite/transactions.cr | 48 ++++-- 18 files changed, 271 insertions(+), 167 deletions(-) diff --git a/docs/crud.md b/docs/crud.md index 8fb51a72..1bb89d9a 100644 --- a/docs/crud.md +++ b/docs/crud.md @@ -39,6 +39,7 @@ end post = Post.find! 1 # raises when no records found ``` + ### find_by Finds the record(s) that match the given criteria @@ -52,6 +53,7 @@ end post = Post.find_by!(slug: "foo") # raises when no records found. other_post = Post.find_by(slug: "foo", type: "bar") # Also works for multiple arguments. ``` + ### first Returns the first record. @@ -99,6 +101,7 @@ post.update(name: "Granite Really Rocks!") # Assigns attributes and calls save post = Post.find 1 post.update!(name: "Granite Really Rocks!") # Assigns attributes and calls save!. Will throw an exception when the save failed ``` + ## Delete Delete a specific record. @@ -111,6 +114,7 @@ puts "deleted" if post.destroyed? post = Post.find 1 post.destroy! # raises when delete failed ``` + Clear all records of a model ```crystal diff --git a/docs/imports.md b/docs/imports.md index 1b0465f7..739e4fc4 100644 --- a/docs/imports.md +++ b/docs/imports.md @@ -2,124 +2,129 @@ ## Import -> **Note:** Imports do not trigger callbacks automatically. See [Running Callbacks](#running-callbacks). +> **Note:** Imports do not trigger callbacks automatically. See [Running Callbacks](#running-callbacks). Each model has an `.import` method that will save an array of models in one bulk insert statement. - ```Crystal - models = [ - Model.new(id: 1, name: "Fred", age: 14), - Model.new(id: 2, name: "Joe", age: 25), - Model.new(id: 3, name: "John", age: 30), - ] - - Model.import(models) - ``` + +```Crystal +models = [ + Model.new(id: 1, name: "Fred", age: 14), + Model.new(id: 2, name: "Joe", age: 25), + Model.new(id: 3, name: "John", age: 30), +] + +Model.import(models) +``` ## update_on_duplicate -The `import` method has an optional `update_on_duplicate` + `columns` params that allows you to specify the columns (as an array of strings) that should be updated if primary constraint is violated. - ```Crystal - models = [ - Model.new(id: 1, name: "Fred", age: 14), - Model.new(id: 2, name: "Joe", age: 25), - Model.new(id: 3, name: "John", age: 30), - ] - - Model.import(models) - - Model.find!(1).name # => Fred - - models = [ - Model.new(id: 1, name: "George", age: 14), - ] - - Model.import(models, update_on_duplicate: true, columns: %w(name)) - - Model.find!(1).name # => George - ``` - -**NOTE: If using PostgreSQL you must have version 9.5+ to have the on_duplicate_key_update feature.** +The `import` method has an optional `update_on_duplicate` + `columns` params that allows you to specify the columns (as an array of strings) that should be updated if primary constraint is violated. + +```Crystal +models = [ + Model.new(id: 1, name: "Fred", age: 14), + Model.new(id: 2, name: "Joe", age: 25), + Model.new(id: 3, name: "John", age: 30), +] + +Model.import(models) + +Model.find!(1).name # => Fred + +models = [ + Model.new(id: 1, name: "George", age: 14), +] + +Model.import(models, update_on_duplicate: true, columns: %w(name)) + +Model.find!(1).name # => George +``` + +**NOTE: If using PostgreSQL you must have version 9.5+ to have the on_duplicate_key_update feature.** ## ignore_on_duplicate The `import` method has an optional `ignore_on_duplicate` param, that takes a boolean, which will skip records if the primary constraint is violated. - ```Crystal - models = [ - Model.new(id: 1, name: "Fred", age: 14), - Model.new(id: 2, name: "Joe", age: 25), - Model.new(id: 3, name: "John", age: 30), - ] - - Model.import(models) - - Model.find!(1).name # => Fred - - models = [ - Model.new(id: 1, name: "George", age: 14), - ] - - Model.import(models, ignore_on_duplicate: true) - - Model.find!(1).name # => Fred - ``` + +```Crystal +models = [ + Model.new(id: 1, name: "Fred", age: 14), + Model.new(id: 2, name: "Joe", age: 25), + Model.new(id: 3, name: "John", age: 30), +] + +Model.import(models) + +Model.find!(1).name # => Fred + +models = [ + Model.new(id: 1, name: "George", age: 14), +] + +Model.import(models, ignore_on_duplicate: true) + +Model.find!(1).name # => Fred +``` ## batch_size -The `import` method has an optional `batch_size` param, that takes an integer. The batch_size determines the number of models to import in each INSERT statement. This defaults to the size of the models array, i.e. only 1 INSERT statement. - ```Crystal - models = [ - Model.new(id: 1, name: "Fred", age: 14), - Model.new(id: 2, name: "Joe", age: 25), - Model.new(id: 3, name: "John", age: 30), - Model.new(id: 3, name: "Bill", age: 66), - ] - - Model.import(models, batch_size: 2) - # => First SQL INSERT statement imports Fred and Joe - # => Second SQL INSERT statement imports John and Bill - ``` +The `import` method has an optional `batch_size` param, that takes an integer. The batch_size determines the number of models to import in each INSERT statement. This defaults to the size of the models array, i.e. only 1 INSERT statement. + +```Crystal +models = [ + Model.new(id: 1, name: "Fred", age: 14), + Model.new(id: 2, name: "Joe", age: 25), + Model.new(id: 3, name: "John", age: 30), + Model.new(id: 3, name: "Bill", age: 66), +] + +Model.import(models, batch_size: 2) +# => First SQL INSERT statement imports Fred and Joe +# => Second SQL INSERT statement imports John and Bill +``` ## Running Callbacks -Since the `import` method runs on the class level, callbacks are not triggered automatically, they have to be triggered manually. For example, using the Item class with a UUID primary key: - ```Crystal - require "uuid" - - class Item < Granite::Base - connection mysql - table items - - column item_id : String, primary: true, auto: false - column item_name : String - - before_create :generate_uuid - - def generate_uuid - @item_id = UUID.random.to_s - end - end - ``` - - ```Crystal - items = [ - Item.new(item_name: "item1"), - Item.new(item_name: "item2"), - Item.new(item_name: "item3"), - Item.new(item_name: "item4"), - ] - - # If we did `Item.import(items)` now, it would fail since the item_id wouldn't get set before saving the record, violating the primary key constraint. - - # Manually run the callback on each model to generate the item_id. - items.each(&.before_create) - - # Each model in the array now has a item_id set, so can be imported. - Item.import(items) - - # This can also be used for a single record. - item = Item.new(item_name: "item5") - item.before_create - item.save - ``` - -> **Note:** Manually running your callbacks is mainly aimed at bulk imports. Running them before a normal `.save`, for example, would run your callbacks twice. +Since the `import` method runs on the class level, callbacks are not triggered automatically, they have to be triggered manually. For example, using the Item class with a UUID primary key: + +```Crystal +require "uuid" + +class Item < Granite::Base + connection mysql + table items + + column item_id : String, primary: true, auto: false + column item_name : String + + before_create :generate_uuid + + def generate_uuid + @item_id = UUID.random.to_s + end +end +``` + +```Crystal +items = [ + Item.new(item_name: "item1"), + Item.new(item_name: "item2"), + Item.new(item_name: "item3"), + Item.new(item_name: "item4"), +] + +# If we did `Item.import(items)` now, it would fail since the item_id wouldn't get set before saving the record, violating the primary key constraint. + +# Manually run the callback on each model to generate the item_id. +items.each(&.before_create) + +# Each model in the array now has a item_id set, so can be imported. +Item.import(items) + +# This can also be used for a single record. +item = Item.new(item_name: "item5") +item.before_create +item.save +``` + +> **Note:** Manually running your callbacks is mainly aimed at bulk imports. Running them before a normal `.save`, for example, would run your callbacks twice. diff --git a/docs/migrations.md b/docs/migrations.md index b4a08b79..60402993 100644 --- a/docs/migrations.md +++ b/docs/migrations.md @@ -15,6 +15,7 @@ dependencies: ``` Update shards + ```sh $ shards update ``` @@ -36,6 +37,7 @@ Micrate::Cli.run ``` Make it executable: + ```sh $ chmod +x bin/micrate ``` @@ -71,6 +73,7 @@ DROP TABLE posts; ``` And now let's run the migration + ```sh $ bin/micrate up ``` diff --git a/docs/models.md b/docs/models.md index a9c842d6..b9eddcfb 100644 --- a/docs/models.md +++ b/docs/models.md @@ -21,7 +21,7 @@ class Bar < Granite::Base end ``` -In this example, we defined two connections. One to a MySQL database named "legacy_db", and another to a PG database named "new_db". The connection name given in the model maps to the name of a registered connection. +In this example, we defined two connections. One to a MySQL database named "legacy_db", and another to a PG database named "new_db". The connection name given in the model maps to the name of a registered connection. > **NOTE:** How you store/supply each connection's URL is up to you; Granite only cares that it gets registered via `Granite::Connections << adapter_object`. @@ -51,7 +51,7 @@ end ## Primary Keys -Each model is required to have a primary key defined. Use the `column` macro with the `primary: true` option to denote the primary key. +Each model is required to have a primary key defined. Use the `column` macro with the `primary: true` option to denote the primary key. > **NOTE:** Composite primary keys are not yet supported. @@ -90,7 +90,7 @@ end ### Natural Keys -Primary keys are defined as auto incrementing by default. For natural keys, you can set `auto: false` option. +Primary keys are defined as auto incrementing by default. For natural keys, you can set `auto: false` option. ```crystal class Site < Granite::Base @@ -103,7 +103,7 @@ end ### UUIDs -For databases that utilize UUIDs as the primary key, the type of the primary key can be set to `UUID`. This will generate a secure UUID when the model is saved. +For databases that utilize UUIDs as the primary key, the type of the primary key can be set to `UUID`. This will generate a secure UUID when the model is saved. ```crystal class Book < Granite::Base @@ -119,6 +119,7 @@ book.isbn # => nil book.save book.isbn # => RFC4122 V4 UUID string ``` + ## Default values A default value can be defined that will be used if another value is not specified/supplied. @@ -137,7 +138,7 @@ book.name # => "DefaultBook" ## Generating Documentation -By default, running `crystal docs` will **not** include Granite methods, constants, and properties. To include these, use the `granite_docs` flag when generating the documentation. E.x. `crystal docs -D granite_docs`. +By default, running `crystal docs` will **not** include Granite methods, constants, and properties. To include these, use the `granite_docs` flag when generating the documentation. E.x. `crystal docs -D granite_docs`. Doc block comments can be applied above the `column` macro. @@ -148,7 +149,7 @@ column published : Bool ## Annotations -Annotations can be a powerful method of adding property specific features with minimal amounts of code. Since Granite utilizes the `property` keyword for its columns, annotations are able to be applied easily. These can either be `JSON::Field`, `YAML::Field`, or third party annotations. +Annotations can be a powerful method of adding property specific features with minimal amounts of code. Since Granite utilizes the `property` keyword for its columns, annotations are able to be applied easily. These can either be `JSON::Field`, `YAML::Field`, or third party annotations. ```crystal class Foo < Granite::Base @@ -168,18 +169,18 @@ end ## Converters -Granite supports custom/special types via converters. Converters will convert the type into something the database can store when saving the model, and will convert the returned database value into that type on read. +Granite supports custom/special types via converters. Converters will convert the type into something the database can store when saving the model, and will convert the returned database value into that type on read. -Each converter has a `T` generic argument that tells the converter what type should be read from the `DB::ResultSet`. For example, if you wanted to use the `JSON` converter and your underlying database column is `BLOB`, you would use `Bytes`, if it was `TEXT`, you would use `String`. +Each converter has a `T` generic argument that tells the converter what type should be read from the `DB::ResultSet`. For example, if you wanted to use the `JSON` converter and your underlying database column is `BLOB`, you would use `Bytes`, if it was `TEXT`, you would use `String`. Currently Granite supports various converters, each with their own supported database column types: - `Enum(E, T)` - Converts an Enum of type `E` to/from a database column of type `T`. Supported types for `T` are: `Number`, `String`, and `Bytes`. -- `Json(M, T)` - Converters an `Object` of type `M` to/from a database column of type `T.` Supported types for `T` are: `String`, `JSON::Any`, and `Bytes`. - - **NOTE:** `M` must implement `#to_json` and `.from_json` methods. +- `Json(M, T)` - Converters an `Object` of type `M` to/from a database column of type `T.` Supported types for `T` are: `String`, `JSON::Any`, and `Bytes`. + - **NOTE:** `M` must implement `#to_json` and `.from_json` methods. - `PgNumeric` - Converts a `PG::Numeric` value to a `Float64` on read. -The converter is defined on a per field basis. This example has an `OrderStatus` enum typed field. When saved, the enum value would be converted to a string to be stored in the DB. Then, when read, the string would be used to parse a new instance of `OrderStatus`. +The converter is defined on a per field basis. This example has an `OrderStatus` enum typed field. When saved, the enum value would be converted to a string to be stored in the DB. Then, when read, the string would be used to parse a new instance of `OrderStatus`. ```crystal enum OrderStatus @@ -193,10 +194,10 @@ class Order < Granite::Base table foos # Other fields - column status : OrderStatus, converter: Granite::Converters::Enum(OrderStatus, String) + column status : OrderStatus, converter: Granite::Converters::Enum(OrderStatus, String) end ``` ## Serialization -Granite implements [JSON::Serializable](https://crystal-lang.org/api/JSON/Serializable.html) and [YAML::Serializable](https://crystal-lang.org/api/YAML/Serializable.html) by default. As such, models can be serialized to/from JSON/YAML via the `#to_json`/`#to_yaml` and `.from_json`/`.from_yaml` methods. +Granite implements [JSON::Serializable](https://crystal-lang.org/api/JSON/Serializable.html) and [YAML::Serializable](https://crystal-lang.org/api/YAML/Serializable.html) by default. As such, models can be serialized to/from JSON/YAML via the `#to_json`/`#to_yaml` and `.from_json`/`.from_yaml` methods. diff --git a/docs/querying.md b/docs/querying.md index eeaa8fd4..3afbd473 100644 --- a/docs/querying.md +++ b/docs/querying.md @@ -5,11 +5,13 @@ The query macro and where clause combine to give you full control over your quer ## Where Where is using a QueryBuilder that allows you to chain where clauses together to build up a complete query. + ```crystal posts = Post.where(published: true, author_id: User.first!.id) ``` It supports different operators: + ```crystal Post.where(:created_at, :gt, Time.local - 7.days) ``` @@ -17,33 +19,38 @@ Post.where(:created_at, :gt, Time.local - 7.days) Supported operators are :eq, :gteq, :lteq, :neq, :gt, :lt, :nlt, :ngt, :ltgt, :in, :nin, :like, :nlike Alternatively, `#where`, `#and`, and `#or` accept a raw SQL clause, with an optional placeholder (`?` for MySQL/SQLite, `$` for Postgres) to avoid SQL Injection. + ```crystal # Example using Postgres adapter Post.where(:created_at, :gt, Time.local - 7.days) .where("LOWER(author_name) = $", name) .where("tags @> '{"Journal", "Book"}') # PG's array contains operator ``` -This is useful for building more sophisticated queries, including queries dependent on database specific features not supported by the operators above. However, **clauses built with this method are not validated.** +This is useful for building more sophisticated queries, including queries dependent on database specific features not supported by the operators above. However, **clauses built with this method are not validated.** ## Order Order is using the QueryBuilder and supports providing an ORDER BY clause: + ```crystal Post.order(:created_at) ``` Direction + ```crystal Post.order(updated_at: :desc) ``` Multiple fields + ```crystal Post.order([:created_at, :title]) ``` With direction + ```crystal Post.order(created_at: :desc, title: :asc) ``` @@ -51,11 +58,13 @@ Post.order(created_at: :desc, title: :asc) ## Group By Group is using the QueryBuilder and supports providing an GROUP BY clause: + ```crystal posts = Post.group_by(:published) ``` Multiple fields + ```crystal Post.group_by([:published, :author_id]) ``` @@ -63,6 +72,7 @@ Post.group_by([:published, :author_id]) ## Limit Limit is using the QueryBuilder and provides the ability to limit the number of tuples returned: + ```crystal Post.limit(50) ``` @@ -70,19 +80,20 @@ Post.limit(50) ## Offset Offset is using the QueryBuilder and provides the ability to offset the results. This is used for pagination: + ```crystal Post.offset(100).limit(50) ``` ## All -All is not using the QueryBuilder. It allows you to directly query the database using SQL. +All is not using the QueryBuilder. It allows you to directly query the database using SQL. When using the `all` method, the selected fields will match the fields specified in the model unless the `select` macro was used to customize the SELECT. -Always pass in parameters to avoid SQL Injection. Use a `?` +Always pass in parameters to avoid SQL Injection. Use a `?` in your query as placeholder. Checkout the [Crystal DB Driver](https://github.com/crystal-lang/crystal-db) for documentation of the drivers. @@ -108,7 +119,7 @@ posts = Post.all("JOIN comments c ON c.post_id = post.id ## Customizing SELECT -The `select_statement` macro allows you to customize the entire query, including the SELECT portion. This shouldn't be necessary in most cases, but allows you to craft more complex (i.e. cross-table) queries if needed: +The `select_statement` macro allows you to customize the entire query, including the SELECT portion. This shouldn't be necessary in most cases, but allows you to craft more complex (i.e. cross-table) queries if needed: ```crystal class CustomView < Granite::Base @@ -135,9 +146,9 @@ results = CustomView.all("WHERE articles.author = ?", ["Noah"]) ## Exists? -The `exists?` class method returns `true` if a record exists in the table that matches the provided *id* or *criteria*, otherwise `false`. +The `exists?` class method returns `true` if a record exists in the table that matches the provided _id_ or _criteria_, otherwise `false`. -If passed a `Number` or `String`, it will attempt to find a record with that primary key. If passed a `Hash` or `NamedTuple`, it will find the record that matches that criteria, similar to `find_by`. +If passed a `Number` or `String`, it will attempt to find a record with that primary key. If passed a `Hash` or `NamedTuple`, it will find the record that matches that criteria, similar to `find_by`. ```crystal # Assume a model named Post with a title field diff --git a/docs/readme.md b/docs/readme.md index f1533a0a..e5857d8b 100644 --- a/docs/readme.md +++ b/docs/readme.md @@ -5,8 +5,8 @@ ### Installation Add this library to your projects dependencies along with the driver in -your `shard.yml`. This can be used with any framework but was originally -designed to work with the amber framework in mind. This library will work +your `shard.yml`. This can be used with any framework but was originally +designed to work with the amber framework in mind. This library will work with Kemal or any other framework as well. ```yaml @@ -23,12 +23,11 @@ dependencies: pg: github: will/crystal-pg - ``` ### Register a Connection -Next you will need to register a connection. This should be one of the first things in your main Crystal file, before Granite is required. +Next you will need to register a connection. This should be one of the first things in your main Crystal file, before Granite is required. ```crystal Granite::Connections << Granite::Adapter::Mysql.new(name: "mysql", url: "YOUR_DATABASE_URL") diff --git a/docs/relationships.md b/docs/relationships.md index 75bfc840..8e29f69d 100644 --- a/docs/relationships.md +++ b/docs/relationships.md @@ -79,12 +79,13 @@ end class Coach < Granite::Base belongs_to :team - + column id : Int64, primary: true end ``` The class name inferred from the name but you can specify the class name: + ```crystal class Team < Granite::Base has_one coach : Coach, foreign_key: :custom_id @@ -101,7 +102,7 @@ class Coach < Granite::Base # provide a custom foreign key belongs_to team : Team, foreign_key: team_uuid : String - + column id : Int64, primary: true end ``` @@ -186,7 +187,7 @@ CREATE INDEX user_id_idx ON posts (user_id); ## Many to Many -Instead of using a hidden many-to-many table, Granite recommends always creating a model for your join tables. For example, let's say you have many `users` that belong to many `rooms`. We recommend adding a new model called `participants` to represent the many-to-many relationship. +Instead of using a hidden many-to-many table, Granite recommends always creating a model for your join tables. For example, let's say you have many `users` that belong to many `rooms`. We recommend adding a new model called `participants` to represent the many-to-many relationship. Then you can use the `belongs_to` and `has_many` relationships going both ways. @@ -203,7 +204,7 @@ class Participant < Granite::Base belongs_to :user belongs_to :room - + column id : Int64, primary: true end @@ -250,7 +251,7 @@ end class Participant < Granite::Base belongs_to :user belongs_to :room - + column id : Int64, primary: true end diff --git a/docs/validations.md b/docs/validations.md index 53aa6ba8..a5620a8a 100644 --- a/docs/validations.md +++ b/docs/validations.md @@ -7,6 +7,7 @@ post = Post.new post.save post.errors[0].to_s.should eq "ERROR: name cannot be null" ``` + ## Validations Validations can be made on models to ensure that given criteria are met. diff --git a/src/adapter/base.cr b/src/adapter/base.cr index aa0395e7..7ba4f570 100644 --- a/src/adapter/base.cr +++ b/src/adapter/base.cr @@ -25,7 +25,19 @@ abstract class Granite::Adapter::Base end def open(&block) - yield database + database.retry do + database.using_connection do |conn| + yield conn + rescue ex : IO::Error + raise ::DB::ConnectionLost.new(conn) + rescue ex : Exception + if ex.message =~ /client was disconnected/ + raise ::DB::ConnectionLost.new(conn) + else + raise ex + end + end + end end def log(query : String, elapsed_time : Time::Span, params = [] of String) : Nil diff --git a/src/adapter/mysql.cr b/src/adapter/mysql.cr index 4e7a572f..3dd01e85 100644 --- a/src/adapter/mysql.cr +++ b/src/adapter/mysql.cr @@ -41,11 +41,9 @@ class Granite::Adapter::Mysql < Granite::Adapter::Base last_id = -1_i64 elapsed_time = Time.measure do - open do |db| - db.using_connection do |conn| - conn.exec statement, args: params - last_id = conn.scalar(last_val()).as(Int64) if lastval - end + open do |conn| + conn.exec statement, args: params + last_id = conn.scalar(last_val()).as(Int64) if lastval end end diff --git a/src/granite.cr b/src/granite.cr index 5060cceb..9a618065 100644 --- a/src/granite.cr +++ b/src/granite.cr @@ -10,6 +10,7 @@ module Granite alias ModelArgs = Hash(Symbol | String, Granite::Columns::Type) + annotation Relationship; end annotation Column; end annotation Table; end end diff --git a/src/granite/association_collection.cr b/src/granite/association_collection.cr index c7abfbc4..65482658 100644 --- a/src/granite/association_collection.cr +++ b/src/granite/association_collection.cr @@ -1,7 +1,7 @@ class Granite::AssociationCollection(Owner, Target) forward_missing_to all - def initialize(@owner : Owner, @foreign_key : (Symbol | String), @through : (Symbol | String | Nil) = nil) + def initialize(@owner : Owner, @foreign_key : (Symbol | String), @through : (Symbol | String | Nil) = nil, @primary_key : (Symbol | String | Nil) = nil) end def all(clause = "", params = [] of DB::Any) @@ -38,7 +38,8 @@ class Granite::AssociationCollection(Owner, Target) if through.nil? "WHERE #{Target.table_name}.#{@foreign_key} = ?" else - "JOIN #{through} ON #{through}.#{Target.to_s.underscore}_id = #{Target.table_name}.#{Target.primary_name} " \ + key = @primary_key || "#{Target.to_s.underscore}_id" + "JOIN #{through} ON #{through}.#{key} = #{Target.table_name}.#{Target.primary_name} " \ "WHERE #{through}.#{@foreign_key} = ?" end end diff --git a/src/granite/associations.cr b/src/granite/associations.cr index 0db6d347..45f120bf 100644 --- a/src/granite/associations.cr +++ b/src/granite/associations.cr @@ -17,6 +17,8 @@ module Granite::Associations {% end %} {% primary_key = options[:primary_key] || "id" %} + @[Granite::Relationship(target: {{class_name.id}}, type: :belongs_to, + primary_key: {{primary_key.id}}, foreign_key: {{foreign_key.id}})] def {{method_name.id}} : {{class_name.id}}? if parent = {{class_name.id}}.find_by({{primary_key.id}}: {{foreign_key.id}}) parent @@ -43,7 +45,16 @@ module Granite::Associations {% class_name = options[:class_name] || model.id.camelcase %} {% end %} {% foreign_key = options[:foreign_key] || @type.stringify.split("::").last.underscore + "_id" %} - {% primary_key = options[:primary_key] || "id" %} + + {% if options[:primary_key] && options[:primary_key].is_a? TypeDeclaration %} + {% primary_key = options[:primary_key].var %} + column {{options[:primary_key]}} + {% else %} + {% primary_key = "id" %} + {% end %} + + @[Granite::Relationship(target: {{class_name.id}}, type: :has_one, + primary_key: {{primary_key.id}}, foreign_key: {{foreign_key.id}})] def {{method_name}} : {{class_name}}? {{class_name.id}}.find_by({{foreign_key.id}}: self.{{primary_key.id}}) @@ -67,9 +78,12 @@ module Granite::Associations {% class_name = options[:class_name] || model.id.camelcase %} {% end %} {% foreign_key = options[:foreign_key] || @type.stringify.split("::").last.underscore + "_id" %} + {% primary_key = options[:primary_key] || class_name.stringify.split("::").last.underscore + "_id" %} {% through = options[:through] %} + @[Granite::Relationship(target: {{class_name.id}}, through: {{through.id}}, type: :has_many, + primary_key: {{through}}, foreign_key: {{foreign_key.id}})] def {{method_name.id}} - Granite::AssociationCollection(self, {{class_name.id}}).new(self, {{foreign_key}}, {{through}}) + Granite::AssociationCollection(self, {{class_name.id}}).new(self, {{foreign_key}}, {{through}}, {{primary_key}}) end end end diff --git a/src/granite/columns.cr b/src/granite/columns.cr index b299e171..1dd65fff 100644 --- a/src/granite/columns.cr +++ b/src/granite/columns.cr @@ -2,7 +2,7 @@ require "json" require "uuid" module Granite::Columns - alias SupportedArrayTypes = Array(String) | Array(Int16) | Array(Int32) | Array(Int64) | Array(Float32) | Array(Float64) | Array(Bool) + alias SupportedArrayTypes = Array(String) | Array(Int16) | Array(Int32) | Array(Int64) | Array(Float32) | Array(Float64) | Array(Bool) | Array(UUID) alias Type = DB::Any | SupportedArrayTypes | UUID module ClassMethods @@ -112,19 +112,27 @@ module Granite::Columns fields = {{"Hash(String, Union(#{@type.instance_vars.select(&.annotation(Granite::Column)).map(&.type.id).splat})).new".id}} {% for column in @type.instance_vars.select(&.annotation(Granite::Column)) %} - {% if column.type.id == Time.id %} - fields["{{column.name}}"] = {{column.name.id}}.try(&.in(Granite.settings.default_timezone).to_s(Granite::DATETIME_FORMAT)) - {% elsif column.type.id == Slice.id %} - fields["{{column.name}}"] = {{column.name.id}}.try(&.to_s("")) - {% else %} - fields["{{column.name}}"] = {{column.name.id}} - {% end %} + {% nilable = (column.type.is_a?(Path) ? column.type.resolve.nilable? : (column.type.is_a?(Union) ? column.type.types.any?(&.resolve.nilable?) : (column.type.is_a?(Generic) ? column.type.resolve.nilable? : column.type.nilable?))) %} + + begin + {% if column.type.id == Time.id %} + fields["{{column.name}}"] = {{column.name.id}}.try(&.in(Granite.settings.default_timezone).to_s(Granite::DATETIME_FORMAT)) + {% elsif column.type.id == Slice.id %} + fields["{{column.name}}"] = {{column.name.id}}.try(&.to_s("")) + {% else %} + fields["{{column.name}}"] = {{column.name.id}} {% end %} + rescue ex : NilAssertionError + {% if nilable %} + fields["{{column.name}}"] = nil + {% end %} + end + {% end %} fields end - def set_attributes(hash : Hash(String | Symbol, Type)) : self + def set_attributes(hash : Hash(String | Symbol, T)) : self forall T {% for column in @type.instance_vars.select { |ivar| (ann = ivar.annotation(Granite::Column)) && (!ann[:primary] || (ann[:primary] && ann[:auto] == false)) } %} if hash.has_key?({{column.stringify}}) begin @@ -149,7 +157,13 @@ module Granite::Columns {% begin %} case attribute_name.to_s {% for column in @type.instance_vars.select(&.annotation(Granite::Column)) %} - when "{{ column.name }}" then @{{ column.name.id }} + {% ann = column.annotation(Granite::Column) %} + when "{{ column.name }}" + {% if ann[:converter] %} + {{ann[:converter]}}.to_db @{{column.name.id}} + {% else %} + @{{ column.name.id }} + {% end %} {% end %} else raise "Cannot read attribute #{attribute_name}, invalid attribute" diff --git a/src/granite/query/assemblers/base.cr b/src/granite/query/assemblers/base.cr index 59a48a68..6e95b05b 100644 --- a/src/granite/query/assemblers/base.cr +++ b/src/granite/query/assemblers/base.cr @@ -65,9 +65,12 @@ module Granite::Query::Assembler in_stmt = String.build do |str| str << '(' expression[:value].as(Array).each_with_index do |val, idx| - str << '\'' if expression[:value].is_a?(Array(String)) - str << val - str << '\'' if expression[:value].is_a?(Array(String)) + case val + when Bool, Float32, Float64, Int16 + str << val + else + str << add_parameter val + end str << ',' if expression[:value].as(Array).size - 1 != idx end str << ')' diff --git a/src/granite/query/builder.cr b/src/granite/query/builder.cr index f187edb2..6ed10d1c 100644 --- a/src/granite/query/builder.cr +++ b/src/granite/query/builder.cr @@ -59,6 +59,8 @@ class Granite::Query::Builder(Model) matches.each do |field, value| if value.is_a?(Array) and(field: field.to_s, operator: :in, value: value.compact) + elsif value.is_a?(Enum) + and(field: field.to_s, operator: :eq, value: value.to_s) else and(field: field.to_s, operator: :eq, value: value) end @@ -95,6 +97,8 @@ class Granite::Query::Builder(Model) matches.each do |field, value| if value.is_a?(Array) and(field: field.to_s, operator: :in, value: value.compact) + elsif value.is_a?(Enum) + and(field: field.to_s, operator: :eq, value: value.to_s) else and(field: field.to_s, operator: :eq, value: value) end @@ -110,6 +114,8 @@ class Granite::Query::Builder(Model) matches.each do |field, value| if value.is_a?(Array) or(field: field.to_s, operator: :in, value: value.compact) + elsif value.is_a?(Enum) + or(field: field.to_s, operator: :eq, value: value.to_s) else or(field: field.to_s, operator: :eq, value: value) end diff --git a/src/granite/querying.cr b/src/granite/querying.cr index f019d5a5..45e2ed50 100644 --- a/src/granite/querying.cr +++ b/src/granite/querying.cr @@ -135,7 +135,7 @@ module Granite::Querying criteria_hash = criteria.dup clauses = keys.map do |name| - if criteria_hash[name] + if criteria_hash.has_key?(name) && !criteria_hash[name].nil? matcher = "= ?" else matcher = "IS NULL" diff --git a/src/granite/transactions.cr b/src/granite/transactions.cr index e882a8ea..fb7da2d5 100644 --- a/src/granite/transactions.cr +++ b/src/granite/transactions.cr @@ -10,7 +10,7 @@ module Granite::Transactions create(args.to_h) end - def create(args : Granite::ModelArgs) + def create(args) instance = new instance.set_attributes(args.transform_keys(&.to_s)) instance.save @@ -21,10 +21,10 @@ module Granite::Transactions create!(args.to_h) end - def create!(args : Granite::ModelArgs) + def create!(args) instance = create(args) - if !instance.errors.empty? + unless instance.errors.empty? raise Granite::RecordNotSaved.new(self.name, instance) end @@ -41,7 +41,15 @@ module Granite::Transactions {% ann = primary_key.annotation(Granite::Column) %} fields_duplicate = fields.dup model_array.each_slice(batch_size, true) do |slice| + slice.each do |i| + i.before_save + i.before_create + end adapter.import(table_name, {{primary_key.name.stringify}}, {{ann[:auto]}}, fields_duplicate, slice) + slice.each do |i| + i.after_create + i.after_save + end end {% end %} rescue err @@ -55,7 +63,15 @@ module Granite::Transactions {% ann = primary_key.annotation(Granite::Column) %} fields_duplicate = fields.dup model_array.each_slice(batch_size, true) do |slice| + slice.each do |i| + i.before_save + i.before_create + end adapter.import(table_name, {{primary_key.name.stringify}}, {{ann[:auto]}}, fields_duplicate, slice, update_on_duplicate: update_on_duplicate, columns: columns) + slice.each do |i| + i.after_create + i.after_save + end end {% end %} rescue err @@ -69,7 +85,15 @@ module Granite::Transactions {% ann = primary_key.annotation(Granite::Column) %} fields_duplicate = fields.dup model_array.each_slice(batch_size, true) do |slice| + slice.each do |i| + i.before_save + i.before_create + end adapter.import(table_name, {{primary_key.name.stringify}}, {{ann[:auto]}}, fields_duplicate, slice, ignore_on_duplicate: ignore_on_duplicate) + slice.each do |i| + i.after_create + i.after_save + end end {% end %} rescue err @@ -78,13 +102,13 @@ module Granite::Transactions end def set_timestamps(*, to time = Time.local(Granite.settings.default_timezone), mode = :create) - {% if @type.instance_vars.select(&.annotation(Granite::Column)).map(&.name.stringify).includes? "created_at" %} + {% if @type.instance_vars.select { |ivar| ivar.annotation(Granite::Column) && ivar.type == Time? }.map(&.name.stringify).includes? "created_at" %} if mode == :create @created_at = time.at_beginning_of_second end {% end %} - {% if @type.instance_vars.select(&.annotation(Granite::Column)).map(&.name.stringify).includes? "updated_at" %} + {% if @type.instance_vars.select { |ivar| ivar.annotation(Granite::Column) && ivar.type == Time? }.map(&.name.stringify).includes? "updated_at" %} @updated_at = time.at_beginning_of_second {% end %} end @@ -99,19 +123,25 @@ module Granite::Transactions set_timestamps fields = self.class.content_fields.dup params = content_values + if value = @{{primary_key.name.id}} fields << {{primary_key.name.stringify}} params << value end + {% if primary_key.type == Int32? && ann[:auto] == true %} @{{primary_key.name.id}} = self.class.adapter.insert(self.class.table_name, fields, params, lastval: {{primary_key.name.stringify}}).to_i32 {% elsif primary_key.type == Int64? && ann[:auto] == true %} @{{primary_key.name.id}} = self.class.adapter.insert(self.class.table_name, fields, params, lastval: {{primary_key.name.stringify}}) {% elsif primary_key.type == UUID? && ann[:auto] == true %} - _uuid = UUID.random - @{{primary_key.name.id}} = _uuid - params << _uuid - fields << {{primary_key.name.stringify}} + # if the primary key has not been set, then do so + + unless fields.includes?({{primary_key.name.stringify}}) + _uuid = UUID.random + @{{primary_key.name.id}} = _uuid + params << _uuid + fields << {{primary_key.name.stringify}} + end self.class.adapter.insert(self.class.table_name, fields, params, lastval: nil) {% elsif ann[:auto] == true %} {% raise "Failed to define #{@type.name}#save: Primary key must be Int(32|64) or UUID, or set `auto: false` for natural keys.\n\n column #{primary_key.name} : #{primary_key.type}, primary: true, auto: false\n" %}