diff --git a/docs/configuration.md b/docs/configuration.md index 0009fd0f..6d3471d2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -81,7 +81,7 @@ Jennifer::Config.from_uri(db) | `retry_attempts` | 1 | | `checkout_timeout` | 5.0 | | `retry_delay` | 1.0 | -| `local_time_zone_name` | default time zone name for `TimeZone` | +| `local_time_zone_name` | default time zone name | | `skip_dumping_schema_sql` | `false` | | `command_shell` | `"bash"` | | `docker_container` | `""` | diff --git a/docs/time.md b/docs/time.md index 1120169d..9560e7a9 100644 --- a/docs/time.md +++ b/docs/time.md @@ -1,4 +1,5 @@ # Time + Any model or view `Time` attribute will be automatically converted from local time zone (which could be set using `Jennifer::Config.local_time_zone_name=`) to UTC and converted back during reading from the DB. Also during querying the db all `Time` arguments will be converted same way as well. Only `Jennifer::Record` time attributes is not automatically converted from UTC to local time during loading from the result set. Local time could be set using: diff --git a/shard.yml b/shard.yml index 989a82b4..949f3194 100644 --- a/shard.yml +++ b/shard.yml @@ -4,22 +4,20 @@ version: 0.5.1 authors: - Roman Kalnytskyi -crystal: 0.24.1 +crystal: 0.25.0 license: MIT development_dependencies: mysql: github: crystal-lang/crystal-mysql - version: "~> 0.4" + version: "~> 0.5" pg: github: will/crystal-pg - version: "~> 0.14.1" - sqlite3: - github: crystal-lang/crystal-sqlite3 + version: "~> 0.15.0" factory: github: imdrasil/factory - branch: master + version: "~> 0.1.3" dependencies: sam: github: imdrasil/sam.cr @@ -30,9 +28,7 @@ dependencies: ifrit: github: imdrasil/ifrit version: "~> 0.1.2" - time_zone: - github: imdrasil/time_zone - version: "~> 0.1" i18n: github: TechMagister/i18n.cr - commit: "fc96c6b12547c84da2e76495f9c970acda64976b" + version: "~> 0.2.0" + # commit: "fc96c6b12547c84da2e76495f9c970acda64976b" diff --git a/spec/adapter/base_spec.cr b/spec/adapter/base_spec.cr index 426e3f6c..ed0ac5e9 100644 --- a/spec/adapter/base_spec.cr +++ b/spec/adapter/base_spec.cr @@ -67,7 +67,7 @@ describe Jennifer::Adapter::Base do describe "#transaction" do it "rollbacks if exception was raised" do void_transaction do - expect_raises(DivisionByZero) do + expect_raises(DivisionByZeroError) do adapter.transaction do |tx| Factory.create_contact 1 / 0 diff --git a/spec/adapter/sql_generator_spec.cr b/spec/adapter/sql_generator_spec.cr index 18198942..ac1ef1c3 100644 --- a/spec/adapter/sql_generator_spec.cr +++ b/spec/adapter/sql_generator_spec.cr @@ -261,8 +261,8 @@ describe "Jennifer::Adapter::SQLGenerator" do context "with given Time object" do it do with_time_zone("Etc/GMT+1") do - time = Time.utc_now - adapter.parse_query("%s", [time] of Jennifer::DBAny)[1][0].as(Time).should be_close(time + 1.hour, 1.second) + adapter.parse_query("%s", [Time.now(local_time_zone)] of Jennifer::DBAny)[1][0].as(Time) + .should be_close(Time.utc_now, 1.second) end end end diff --git a/spec/model/authentication_spec.cr b/spec/model/authentication_spec.cr index 6f88c4f7..37db4ab3 100644 --- a/spec/model/authentication_spec.cr +++ b/spec/model/authentication_spec.cr @@ -3,13 +3,11 @@ require "../spec_helper" describe Jennifer::Model::Authentication do describe "%with_authentication" do context "with default field names" do - default_user = Factory.build_user - describe "validations" do it do user = Factory.build_user - user.password = "1" * 52 - user.should validate(:password).with("is too long (maximum is 51 characters)") + user.password = "1" * 72 + user.should validate(:password).with("is too long (maximum is 71 characters)") end it { Factory.build_user([:with_invalid_password_confirmation]).should validate(:password).with("doesn't match Password") } @@ -21,6 +19,7 @@ describe Jennifer::Model::Authentication do user.password_digest = Crypto::Bcrypt::Password.create("password").to_s user.should be_valid end + it do Factory.create_user([:with_password_digest]) user = User.all.last! @@ -47,7 +46,7 @@ describe Jennifer::Model::Authentication do it do user = Factory.build_user - user.password = "1" * 53 + user.password = "1" * 72 user.password_digest.should eq("") end diff --git a/spec/model/base_spec.cr b/spec/model/base_spec.cr index f71ef42e..272345ee 100644 --- a/spec/model/base_spec.cr +++ b/spec/model/base_spec.cr @@ -431,7 +431,7 @@ describe Jennifer::Model::Base do describe "#with_lock" do it "starts transaction" do void_transaction do - expect_raises(DivisionByZero) do + expect_raises(DivisionByZeroError) do Factory.create_contact.with_lock do Factory.create_contact Contact.all.count.should eq(2) @@ -452,7 +452,7 @@ describe Jennifer::Model::Base do describe "::transaction" do it "allow to start transaction" do void_transaction do - expect_raises(DivisionByZero) do + expect_raises(DivisionByZeroError) do Contact.transaction do Factory.create_contact 1 / 0 @@ -511,7 +511,7 @@ describe Jennifer::Model::Base do describe "::models" do it "returns all model classes" do models = Jennifer::Model::Base.models - models.is_a?(Array(Jennifer::Model::Base.class)).should be_true + models.is_a?(Array).should be_true # I tired from modifying this each time new model is added (models.size > 6).should be_true end diff --git a/spec/model/mapping_spec.cr b/spec/model/mapping_spec.cr index 1516c9f6..ec22441c 100644 --- a/spec/model/mapping_spec.cr +++ b/spec/model/mapping_spec.cr @@ -327,11 +327,12 @@ describe Jennifer::Model::Mapping do describe Time do it "stores to db time converted to UTC" do + contact = Factory.create_contact + new_time = Time.now(local_time_zone) with_time_zone("Etc/GMT+1") do - contact = Factory.create_contact - Contact.all.update(created_at: Time.utc_now) + Contact.all.update(created_at: new_time) Contact.all.select { [_created_at] }.each_result_set do |rs| - rs.read(Time).should be_close(Time.utc_now + 1.hour, 2.seconds) + rs.read(Time).should be_close(new_time, 1.second) end end end @@ -339,7 +340,7 @@ describe Jennifer::Model::Mapping do it "converts values from utc to local" do contact = Factory.create_contact with_time_zone("Etc/GMT+1") do - contact.reload.created_at!.should be_close(Time.utc_now - 1.hour, 2.seconds) + contact.reload.created_at!.should be_close(Time.now(local_time_zone), 1.second) end end end diff --git a/spec/model/parameter_converter_spec.cr b/spec/model/parameter_converter_spec.cr index 25884d95..4180eced 100644 --- a/spec/model/parameter_converter_spec.cr +++ b/spec/model/parameter_converter_spec.cr @@ -30,12 +30,30 @@ describe Jennifer::Model::ParameterConverter do it { converter.to_numeric("-1").to_s.should eq("-1") } it { converter.to_numeric("1.12345").to_s.should eq("1.12345") } it { converter.to_numeric("-1.12345").to_s.should eq("-1.12345") } + + # NOTE: some cases from the will/crystal-pg + + it { converter.to_numeric("0").to_s.should eq("0") } + it { converter.to_numeric("0.0").to_s.should eq("0.0") } + it { converter.to_numeric("1.30").to_s.should eq("1.30") } + it { converter.to_numeric("-0.00009").to_s.should eq("-0.00009") } + it { converter.to_numeric("-0.00000009").to_s.should eq("-0.00000009") } + it { converter.to_numeric("50093").to_s.should eq("50093") } + it { converter.to_numeric("500000093").to_s.should eq("500000093") } + it { converter.to_numeric("0.0000006000000").to_s.should eq("0.0000006000000") } + it { converter.to_numeric("0.3").to_s.should eq("0.3") } + it { converter.to_numeric("0.03").to_s.should eq("0.03") } + it { converter.to_numeric("0.003").to_s.should eq("0.003") } + it { converter.to_numeric("0.000300003").to_s.should eq("0.000300003") } end end describe "#to_time" do it { converter.to_time("2010-10-10").should eq(Time.new(2010, 10, 10)) } it { converter.to_time("2010-10-10 20:10:10").should eq(Time.new(2010, 10, 10, 20, 10, 10)) } + it "ignores given time zone" do + converter.to_time("2010-10-10 20:10:10 +01:00").should eq(Time.new(2010, 10, 10, 20, 10, 10, location: local_time_zone)) + end end describe "#to_b" do diff --git a/spec/models.cr b/spec/models.cr index 27b8e471..c74fc8e4 100644 --- a/spec/models.cr +++ b/spec/models.cr @@ -1,3 +1,4 @@ +require "./views" require "../src/jennifer/model/authentication" struct JohnyQuery < Jennifer::QueryBuilder::QueryObject @@ -431,76 +432,3 @@ class ContactWithFloatMapping < Jennifer::Model::Base mapping({id: Primary32}, false) {% end %} end - -# =========== -# views -# =========== - -class FemaleContact < Jennifer::View::Materialized - mapping({ - id: Primary32, - name: String?, - }, false) -end - -class MaleContact < Jennifer::View::Base - mapping({ - id: Primary32, - name: String, - gender: String, - age: Int32, - created_at: Time?, - }, false) - - scope :main { where { _age < 50 } } - scope :older { |age| where { _age >= age } } - scope :johny, JohnyQuery -end - -# ================== -# synthetic views -# ================== - -class FakeFemaleContact < Jennifer::View::Base - view_name "female_contacs" - - mapping({ - id: Primary32, - name: String, - gender: String, - age: Int32, - created_at: Time?, - }, false) -end - -class FakeContactView < Jennifer::View::Base - view_name "male_contacs" - - mapping({ - id: Primary32, - }, false) -end - -class StrinctBrokenMaleContact < Jennifer::View::Base - view_name "male_contacts" - mapping({ - id: Primary32, - name: String, - }) -end - -class StrictMaleContactWithExtraField < Jennifer::View::Base - view_name "male_contacts" - mapping({ - id: Primary64, - missing_field: String, - }) -end - -class MaleContactWithDescription < Jennifer::View::Base - view_name "male_contacts" - mapping({ - id: Primary32, - description: String, - }, false) -end diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr index 8aa186df..5b538040 100644 --- a/spec/spec_helper.cr +++ b/spec/spec_helper.cr @@ -78,6 +78,10 @@ def read_to_end(rs) end end +def local_time_zone + Jennifer::Config.local_time_zone +end + def with_time_zone(zone_name : String) old_zone = Jennifer::Config.local_time_zone_name begin diff --git a/spec/view/base_spec.cr b/spec/view/base_spec.cr index 4dbc9255..3da3bb4b 100644 --- a/spec/view/base_spec.cr +++ b/spec/view/base_spec.cr @@ -6,7 +6,7 @@ describe Jennifer::View::Base do Factory.create_contact(gender: "male") view = MaleContact.all.first! view.inspect.should eq("#") + "gender: \"male\", age: 28, created_at: #{view.created_at.inspect}>") } end @@ -140,7 +140,7 @@ describe Jennifer::View::Base do describe "::views" do it "returns all model classes" do views = Jennifer::View::Base.views - views.is_a?(Array(Jennifer::View::Base.class)).should be_true + views.is_a?(Array).should be_true # I tired from modifing this each time new view is added (views.size > 0).should be_true end diff --git a/spec/view/experimental_mapping_spec.cr b/spec/view/experimental_mapping_spec.cr index ecbe4313..d992d387 100644 --- a/spec/view/experimental_mapping_spec.cr +++ b/spec/view/experimental_mapping_spec.cr @@ -158,11 +158,13 @@ describe Jennifer::View::ExperimentalMapping do describe Time do it "stores to db time converted to UTC" do + contact = Factory.create_contact + new_time = Time.now(local_time_zone) + with_time_zone("Etc/GMT+1") do - contact = Factory.create_contact - Contact.all.update(created_at: Time.utc_now) + Contact.all.update(created_at: new_time) MaleContact.all.select { [_created_at] }.each_result_set do |rs| - rs.read(Time).should be_close(Time.utc_now + 1.hour, 2.seconds) + rs.read(Time).should be_close(new_time, 1.second) end end end @@ -170,7 +172,7 @@ describe Jennifer::View::ExperimentalMapping do it "converts values from utc to local" do contact = Factory.create_contact with_time_zone("Etc/GMT+1") do - MaleContact.all.first!.created_at!.should be_close(Time.utc_now - 1.hour, 2.seconds) + MaleContact.all.first!.created_at!.should be_close(Time.now(local_time_zone), 2.seconds) end end end diff --git a/spec/views.cr b/spec/views.cr new file mode 100644 index 00000000..f62cec88 --- /dev/null +++ b/spec/views.cr @@ -0,0 +1,68 @@ +class FemaleContact < Jennifer::View::Materialized + mapping({ + id: Primary32, + name: String?, + }, false) +end + +class MaleContact < Jennifer::View::Base + mapping({ + id: Primary32, + name: String, + gender: String, + age: Int32, + created_at: Time?, + }, false) + + scope :main { where { _age < 50 } } + scope :older { |age| where { _age >= age } } + scope :johny, JohnyQuery +end + +# ================== +# synthetic views +# ================== + +class FakeFemaleContact < Jennifer::View::Base + view_name "female_contacs" + + mapping({ + id: Primary32, + name: String, + gender: String, + age: Int32, + created_at: Time?, + }, false) +end + +class FakeContactView < Jennifer::View::Base + view_name "male_contacs" + + mapping({ + id: Primary32, + }, false) +end + +class StrinctBrokenMaleContact < Jennifer::View::Base + view_name "male_contacts" + mapping({ + id: Primary32, + name: String, + }) +end + +class StrictMaleContactWithExtraField < Jennifer::View::Base + view_name "male_contacts" + mapping({ + id: Primary64, + missing_field: String, + }) +end + +class MaleContactWithDescription < Jennifer::View::Base + view_name "male_contacts" + mapping({ + id: Primary32, + description: String, + }, false) +end diff --git a/src/jennifer.cr b/src/jennifer.cr index 75b04f25..1097bec3 100644 --- a/src/jennifer.cr +++ b/src/jennifer.cr @@ -1,10 +1,8 @@ require "inflector" -require "inflector/string" require "ifrit/converter" require "ifrit/core" -require "time_zone" require "i18n" require "./jennifer/macros" @@ -84,6 +82,14 @@ struct Time def_clone end +class Time::Location + def_clone + + struct Zone + def_clone + end +end + struct JSON::Any def_clone end diff --git a/src/jennifer/adapter/base_sql_generator.cr b/src/jennifer/adapter/base_sql_generator.cr index 4db50099..c75612d3 100644 --- a/src/jennifer/adapter/base_sql_generator.cr +++ b/src/jennifer/adapter/base_sql_generator.cr @@ -264,9 +264,7 @@ module Jennifer # TODO: optimize array initializing def self.parse_query(query : String, args : Array(DBAny)) args.each_with_index do |arg, i| - if arg.is_a?(Time) - args[i] = Config.local_time_zone.local_to_utc(arg.as(Time)) - end + args[i] = arg.as(Time).to_utc if arg.is_a?(Time) end {query % Array.new(args.size, "?"), args} end diff --git a/src/jennifer/adapter/mysql/type.cr b/src/jennifer/adapter/mysql/type.cr index 7360c422..63bbe902 100644 --- a/src/jennifer/adapter/mysql/type.cr +++ b/src/jennifer/adapter/mysql/type.cr @@ -1,14 +1,18 @@ require "json" struct MySql::Type - alias JsonType = ::JSON::Any | ::JSON::Type - - def self.type_for(t : JsonType.class) + def self.type_for(t : ::JSON::Any.class) MySql::Type::Json end - decl_type Json, 0xF5u8, ::String do - def self.write(packet, v : JsonType) + struct Json < Type + @@hex_value = 0xF5u8 + + def self.db_any_type + ::String + end + + def self.write(packet, v : ::JSON::Any) packet.write_lenenc_string v.to_json end @@ -21,6 +25,8 @@ struct MySql::Type end end + Type.types_by_code[0xF5u8] = Json + # TODO: remove this monkeypatching after merging this PR # https://github.com/crystal-lang/crystal-mysql/pull/29 struct DateTime diff --git a/src/jennifer/adapter/postgres/sql_generator.cr b/src/jennifer/adapter/postgres/sql_generator.cr index 2297dbc2..3cc7d708 100644 --- a/src/jennifer/adapter/postgres/sql_generator.cr +++ b/src/jennifer/adapter/postgres/sql_generator.cr @@ -111,9 +111,7 @@ module Jennifer def self.parse_query(query, args : Array(DBAny)) arr = Array(String).new(args.size) args.each_with_index do |arg, i| - if arg.is_a?(Time) - args[i] = Config.local_time_zone.local_to_utc(arg.as(Time)) - end + args[i] = arg.as(Time).to_utc if arg.is_a?(Time) arr << "$#{i + 1}" end {query % arr, args} diff --git a/src/jennifer/adapter/schema_processor.cr b/src/jennifer/adapter/schema_processor.cr index 6eb8c5cc..3f9aa976 100644 --- a/src/jennifer/adapter/schema_processor.cr +++ b/src/jennifer/adapter/schema_processor.cr @@ -33,8 +33,8 @@ module Jennifer # Creates join table; raises table builder to given block def build_create_join_table(table1, table2, table_name : String? = nil) build_create_table(table_name || adapter_class.join_table_name(table1, table2), false) do |tb| - tb.integer(table1.to_s.singularize.foreign_key) - tb.integer(table2.to_s.singularize.foreign_key) + tb.integer(Inflector.foreign_key(Inflector.singularize(table1.to_s))) + tb.integer(Inflector.foreign_key(Inflector.singularize(table2.to_s))) yield tb end end diff --git a/src/jennifer/config.cr b/src/jennifer/config.cr index 99586ddb..43871fc8 100644 --- a/src/jennifer/config.cr +++ b/src/jennifer/config.cr @@ -51,7 +51,7 @@ module Jennifer define_fields(FLOAT_FIELDS, 0.0) define_fields(BOOL_FIELDS, false) - @local_time_zone : TimeZone::Zone + @local_time_zone : Time::Location @@instance = new @@ -61,8 +61,8 @@ module Jennifer @port = -1 @migration_files_path = "./db/migrations" @schema = "public" - @local_time_zone_name = TimeZone::Zone.default.name - @local_time_zone = TimeZone::Zone.default + @local_time_zone_name = Time::Location.local.name + @local_time_zone = Time::Location.local @initial_pool_size = 1 @max_pool_size = 5 @@ -124,7 +124,7 @@ module Jennifer def local_time_zone_name=(value : String) @local_time_zone_name = value - @local_time_zone = TimeZone::Zone.get(@local_time_zone_name) + @local_time_zone = Time::Location.load(value) value end diff --git a/src/jennifer/migration/base.cr b/src/jennifer/migration/base.cr index 8c66b694..d232a925 100644 --- a/src/jennifer/migration/base.cr +++ b/src/jennifer/migration/base.cr @@ -58,19 +58,19 @@ module Jennifer end def self.versions - migrations.map(&.version) + migrations.keys end def self.migrations {% begin %} {% if @type.all_subclasses.size > 0 %} - [ + { {% for model in @type.all_subclasses %} - {{model.id}}, + {{model.id}}.version => {{model.id}}, {% end %} - ] + } {% else %} - [] of Jennifer::Migration::Base.class + {} of String => Jennifer::Migration::Base.class {% end %} {% end %} end diff --git a/src/jennifer/migration/runner.cr b/src/jennifer/migration/runner.cr index f00c4cf8..2b5ae79f 100644 --- a/src/jennifer/migration/runner.cr +++ b/src/jennifer/migration/runner.cr @@ -3,20 +3,10 @@ module Jennifer module Runner MIGRATION_DATE_FORMAT = "%Y%m%d%H%M%S%L" - @@migration_classes = {} of String => Base.class @@pending_versions = [] of String - def self.migration_classes - if @@migration_classes.empty? - Base.migrations.each { |m| @@migration_classes[m.version] = m } - end - @@migration_classes - end - def self.pending_versions - if @@pending_versions.empty? - @@pending_versions = (migration_classes.keys - Version.list).sort! - end + @@pending_versions = (Base.versions - Version.list).sort! if @@pending_versions.empty? @@pending_versions end @@ -24,12 +14,13 @@ module Jennifer def self.migrate(count : Int) performed = false default_adapter.ready_to_migrate! - return if Base.migrations.empty? || pending_versions.empty? + migrations = Base.migrations + return if migrations.empty? || pending_versions.empty? assert_outdated_pending_migrations - pending_versions.each_with_index do |p, i| + pending_versions.each_with_index do |version, i| return if count > 0 && i >= count - process_up_migration(migration_classes[p].new) + process_up_migration(migrations[version].new) performed = true end rescue e @@ -59,7 +50,8 @@ module Jennifer def self.rollback(options : Hash(Symbol, DBAny)) processed = true default_adapter.ready_to_migrate! - return if Base.migrations.empty? || !Version.all.exists? + migrations = Base.migrations + return if migrations.empty? || !Version.all.exists? versions = if options[:count]? @@ -71,8 +63,8 @@ module Jennifer raise ArgumentError.new end - versions.each do |v| - process_down_migration(migration_classes[v].new) + versions.each do |version| + process_down_migration(migrations[version].new) processed = true end rescue e @@ -85,14 +77,13 @@ module Jennifer def self.assert_outdated_pending_migrations return unless Version.all.exists? db_version = Version.all.order(version: :desc).limit(1).pluck(:version)[0].as(String) - brocken = pending_versions.select { |version| version < db_version } - unless brocken.empty? - message = <<-MESSAGE + broken = pending_versions.select { |version| version < db_version } + unless broken.empty? + raise <<-MESSAGE Can't run migrations because some of them are older then release version. They are: - #{brocken.map { |v| "- #{v}" }.join("\n")} + #{broken.map { |v| "- #{v}" }.join("\n")} MESSAGE - raise message end end diff --git a/src/jennifer/model/authentication.cr b/src/jennifer/model/authentication.cr index 060a0e1e..0f001c0d 100644 --- a/src/jennifer/model/authentication.cr +++ b/src/jennifer/model/authentication.cr @@ -10,13 +10,15 @@ module Jennifer setter: false } + PASSWORD_RANGE = Crypto::Bcrypt::PASSWORD_RANGE.min...Crypto::Bcrypt::PASSWORD_RANGE.max + {% Macros::TYPES << "Password" %} # Adds methods to set and authenticate against a Crypto::Bcrypt password. # - `password` - password field name (default is `"password"`); # - `password_hash` - password digest attribute name (default is `"password_digest"`). macro with_authentication(password = "password", password_hash = "password_digest") - validates_length :{{password.id}}, maximum: Crypto::Bcrypt::PASSWORD_RANGE.max, allow_blank: true + validates_length :{{password.id}}, in: PASSWORD_RANGE, allow_blank: true validates_confirmation :{{password.id}} validates_with_method :validate_{{password.id}}_presence @@ -26,7 +28,7 @@ module Jennifer def {{password.id}}=(unencrypted_password) @{{password.id}} = unencrypted_password - if unencrypted_password.nil? || unencrypted_password.empty? || !Crypto::Bcrypt::PASSWORD_RANGE.includes?(unencrypted_password.not_nil!.size) + if unencrypted_password.nil? || unencrypted_password.empty? || !PASSWORD_RANGE.includes?(unencrypted_password.not_nil!.size) self.{{password_hash.id}} = "" else self.{{password_hash.id}} = Crypto::Bcrypt::Password.create( diff --git a/src/jennifer/model/errors.cr b/src/jennifer/model/errors.cr index 14bf209b..28355bbd 100644 --- a/src/jennifer/model/errors.cr +++ b/src/jennifer/model/errors.cr @@ -1,7 +1,7 @@ module Jennifer module Model class Errors - getter base : Resource + getter base : Base getter messages : Hash(Symbol, Array(String)) def initialize(@base) @@ -125,12 +125,6 @@ module Jennifer def generate_message(attribute : Symbol, message : Symbol, count, options : Hash) prefix = "#{Translation::GLOBAL_SCOPE}.errors." opts = { count: count, options: options} - i18n_key = @base.class.i18n_key - - path = "#{prefix}#{i18n_key}.attributes.#{attribute}.#{message}" - return I18n.translate(path, **opts) if I18n.exists?(path, count: count) - path = "#{prefix}#{i18n_key}.#{message}" - return I18n.translate(path, **opts) if I18n.exists?(path, count: count) @base.class.lookup_ancestors do |ancestor| path = "#{prefix}#{ancestor.i18n_key}.attributes.#{attribute}.#{message}" @@ -150,6 +144,15 @@ module Jennifer def generate_message(attribute : Symbol, message : String, count, options : Hash) message end + + def inspect(io) : Nil + io << "#<" << {{@type.name.id.stringify}} << ":0x" + object_id.to_s(16, io) + io << " @messages=" + @messages.inspect(io) + io << '>' + nil + end end end end diff --git a/src/jennifer/model/mapping.cr b/src/jennifer/model/mapping.cr index cd386dca..df19db94 100644 --- a/src/jennifer/model/mapping.cr +++ b/src/jennifer/model/mapping.cr @@ -85,7 +85,7 @@ module Jennifer # Sets `created_at` to current time def __update_created_at - @created_at = Jennifer::Config.local_time_zone.now + @created_at = Time.new(Jennifer::Config.local_time_zone) end {% end %} @@ -94,7 +94,7 @@ module Jennifer # Sets `updated_at` to current time def __update_updated_at - @updated_at = Jennifer::Config.local_time_zone.now + @updated_at = Time.new(Jennifer::Config.local_time_zone) end {% end %} end @@ -300,7 +300,7 @@ module Jennifer {% for key, value in properties %} begin res = %var{key.id}.as({{value[:parsed_type].id}}) - !res.is_a?(Time) ? res : ::Jennifer::Config.local_time_zone.utc_to_local(res) + !res.is_a?(Time) ? res : res.in(::Jennifer::Config.local_time_zone) rescue e : Exception raise ::Jennifer::DataTypeCasting.build({{key.id.stringify}}, {{@type}}, e) end, @@ -350,7 +350,7 @@ module Jennifer {% else %} %casted_var{key.id} = __bool_convert(%var{key.id}, {{value[:parsed_type].id}}) {% end %} - %casted_var{key.id} = !%casted_var{key.id}.is_a?(Time) ? %casted_var{key.id} : ::Jennifer::Config.local_time_zone.utc_to_local(%casted_var{key.id}) + %casted_var{key.id} = !%casted_var{key.id}.is_a?(Time) ? %casted_var{key.id} : %casted_var{key.id}.in(::Jennifer::Config.local_time_zone) rescue e : Exception raise ::Jennifer::DataTypeCasting.match?(e) ? ::Jennifer::DataTypeCasting.new({{key.id.stringify}}, {{@type}}, e) : e end diff --git a/src/jennifer/model/parameter_converter.cr b/src/jennifer/model/parameter_converter.cr index 93f57630..b9d1f46c 100644 --- a/src/jennifer/model/parameter_converter.cr +++ b/src/jennifer/model/parameter_converter.cr @@ -76,7 +76,7 @@ module Jennifer def to_time(value) format = value =~ / / ? "%F %T" : "%F" - Time.parse(value, format) + Time.parse(value, format, Config.local_time_zone) end def to_numeric(value) @@ -95,19 +95,19 @@ module Jennifer weight = value.index('.') || -1 if weight == -1 int_part = value - digits = str_to_i16_array(int_part) + digits = integer_str_to_i16_array(int_part) PG::Numeric.build(digits.size.to_i16, (digits.size - 1).to_i16, sign, 0i16, digits) else int_part = value[0...weight] - digits = str_to_i16_array(int_part) + digits = integer_str_to_i16_array(int_part) int_digits_size = digits.size - str_to_i16_array(value[(weight + 1)..-1], digits) - PG::Numeric.build(digits.size.to_i16, (int_digits_size - 1).to_i16, sign, (digits.size - int_digits_size).to_i16, digits) + float_str_to_i16_array(value[(weight + 1)..-1], digits) + PG::Numeric.build(digits.size.to_i16, (int_digits_size - 1).to_i16, sign, (value.size - weight - 1).to_i16, digits) end end def to_array(value, str_class) - array = to_json(value) + array = to_json(value).as_a case str_class when /Int32/ array.map(&.as_i) @@ -126,16 +126,33 @@ module Jennifer end end - private def str_to_i16_array(value, out_array = Array(Int16).new(1)) + private def integer_str_to_i16_array(value) + array = [] of Int16 weight = value.size - start_i, end_i = 0, 4 + first_part_size = weight % 4 + start_i = 0 + end_i = first_part_size == 0 ? 4 : first_part_size while true - out_array << value[start_i...end_i].to_i16 + array << value[start_i...end_i].to_i16 break if weight <= end_i start_i = end_i end_i += 4 end - out_array + array + end + + def float_str_to_i16_array(value : String, array = [] of Int16) + weight = value.size + start_i = 0 + end_i = 4 + while true + array << value[start_i...end_i].ljust(4, '0').to_i16 + break if weight <= end_i + start_i = end_i + end_i += 4 + end + array.delete_at(-1) if array[-1] == 0i16 + array end end end diff --git a/src/jennifer/model/resource.cr b/src/jennifer/model/resource.cr index 57539ae3..4f8812f7 100644 --- a/src/jennifer/model/resource.cr +++ b/src/jennifer/model/resource.cr @@ -9,6 +9,12 @@ module Jennifer abstract def table_name abstract def build(values, new_record : Bool) abstract def relation(name) + + abstract def actual_table_field_count + abstract def primary_field_name + abstract def build + abstract def all + abstract def superclass end extend AbstractClassMethods @@ -17,6 +23,8 @@ module Jennifer include Scoping include RelationDefinition + def self.superclass; end + alias Supportable = DBAny | self @@expression_builder : QueryBuilder::ExpressionBuilder? @@ -24,8 +32,8 @@ module Jennifer def inspect(io) : Nil {% begin %} io << "#<" << {{@type.name.id.stringify}} << ":0x" + object_id.to_s(16, io) {% if @type.constant("COLUMNS_METADATA") %} - object_id.to_s(16, io) io << ' ' {% for var, i in @type.constant("COLUMNS_METADATA").keys %} {% if i > 0 %} @@ -67,8 +75,10 @@ module Jennifer @@expression_builder ||= QueryBuilder::ExpressionBuilder.new(table_name) end - def self.all : QueryBuilder::ModelQuery(self) - QueryBuilder::ModelQuery(self).build(table_name) + def self.all + {% begin %} + QueryBuilder::ModelQuery({{@type}}).build(table_name) + {% end %} end def self.where(&block) diff --git a/src/jennifer/model/sti_mapping.cr b/src/jennifer/model/sti_mapping.cr index 48d44529..53df25b0 100644 --- a/src/jennifer/model/sti_mapping.cr +++ b/src/jennifer/model/sti_mapping.cr @@ -69,7 +69,7 @@ module Jennifer {% else %} %casted_var{key.id} = __bool_convert(%var{key.id}, {{value[:parsed_type].id}}) {% end %} - %casted_var{key.id} = !%casted_var{key.id}.is_a?(Time) ? %casted_var{key.id} : ::Jennifer::Config.local_time_zone.utc_to_local(%casted_var{key.id}) + %casted_var{key.id} = !%casted_var{key.id}.is_a?(Time) ? %casted_var{key.id} : %casted_var{key.id}.in(::Jennifer::Config.local_time_zone) rescue e : Exception raise ::Jennifer::DataTypeCasting.build({{key.id.stringify}}, {{@type}}, e) end @@ -232,7 +232,7 @@ module Jennifer { args: args, fields: fields } end - def self.all + def self.all : ::Jennifer::QueryBuilder::ModelQuery({{@type}}) ::Jennifer::QueryBuilder::ModelQuery({{@type}}).build(table_name).where { _type == {{@type.stringify}} } end diff --git a/src/jennifer/model/translation.cr b/src/jennifer/model/translation.cr index 0bfb6c39..23ba2ebc 100644 --- a/src/jennifer/model/translation.cr +++ b/src/jennifer/model/translation.cr @@ -12,8 +12,6 @@ module Jennifer def human_attribute_name(attribute : String | Symbol) prefix = "#{GLOBAL_SCOPE}.attributes." - path = "#{prefix}#{i18n_key}.#{attribute}" - return I18n.translate(path) if I18n.exists?(path) lookup_ancestors do |ancestor| path = "#{prefix}#{ancestor.i18n_key}.#{attribute}" return I18n.translate(path) if I18n.exists?(path) @@ -28,12 +26,9 @@ module Jennifer def human(count = nil) prefix = "#{GLOBAL_SCOPE}.#{i18n_scope}." - path = prefix + i18n_key - return I18n.translate(path, count: count) if I18n.exists?(path, count: count) - lookup_ancestors do |ancestor| path = prefix + ancestor.i18n_key - return I18n.translate(path) if I18n.exists?(path, count: count) + return I18n.translate(path, count: count) if I18n.exists?(path, count: count) end name = Inflector.humanize(i18n_key) @@ -51,12 +46,11 @@ module Jennifer @@i18n_key = Inflector.underscore(Inflector.demodulize(to_s)).downcase end + # Yields all ancestors which respond to `.superclass`. def lookup_ancestors(&block) - return unless responds_to?(:superclass) - klass = superclass - while true + klass = self + while klass yield klass - break unless klass.responds_to?(:superclass) klass = klass.superclass end end diff --git a/src/jennifer/model/validation.cr b/src/jennifer/model/validation.cr index e2332955..41e4bddd 100644 --- a/src/jennifer/model/validation.cr +++ b/src/jennifer/model/validation.cr @@ -18,8 +18,7 @@ module Jennifer def validate!(skip = false) : Bool errors.clear - return false if skip - return false unless __before_validation_callback + return false if skip || !__before_validation_callback validate return false if invalid? __after_validation_callback @@ -43,18 +42,14 @@ module Jennifer macro _not_nil_validation(field, allow_blank) begin {% if allow_blank %} - return if @{{field.id}}.nil? + return if {{field.id}}.nil? {% else %} - return errors.add({{field}}, :blank) if @{{field.id}}.nil? + return errors.add({{field}}, :blank) if {{field.id}}.nil? {% end %} - @{{field.id}}.not_nil! + {{field.id}}.not_nil! end end - macro validates_with_method(name) - {% VALIDATION_METHODS << name.id.stringify %} - end - macro validates_with_method(*names) {% for method in names %} {% VALIDATION_METHODS << method.id.stringify %} @@ -111,7 +106,7 @@ module Jennifer def %validate_method value = _not_nil_validation({{field}}, {{options[:allow_blank] || false}}) - size = value.not_nil!.size + size = value.size {% if options[:in] %} if ({{options[:in]}}).max < size errors.add({{field}}, :too_long, ({{options[:in]}}).max) diff --git a/src/jennifer/query_builder/eager_loading.cr b/src/jennifer/query_builder/eager_loading.cr new file mode 100644 index 00000000..eaf93395 --- /dev/null +++ b/src/jennifer/query_builder/eager_loading.cr @@ -0,0 +1,84 @@ +module Jennifer + module QueryBuilder + module EagerLoading + @eager_load : Bool = false + @include_relations : Bool = false + + abstract def nested_relation_tree : NestedRelationTree + abstract def multi_query_relation_tree + protected abstract def preload_relations + + def _select_fields : Array(Criteria) + if @select_fields.empty? + if @eager_load + nested_relation_tree.select_fields(self) + else + [@expression.star] of Criteria + end + else + @select_fields + end + end + + def with(*arr) + self.with(arr.to_a.map(&.to_s)) + end + + def with(arr : Array) + raise BaseException.new("#with should be called after correspond join") unless @joins + arr.each do |name| + table_name = model_class.relation(name).table_name + temp_joins = _joins!.select { |j| j.table == table_name } + join = temp_joins.find(&.relation.nil?) + if join + join.not_nil!.relation = name + elsif temp_joins.size == 0 + raise BaseException.new("#with should be called after correspond join: no such table \"#{table_name}\" of relation \"#{name}\"") + end + @eager_load = true + nested_relation_tree.add_relation(name) + end + self + end + + def includes(*names, **deep_relations) + @include_relations = true + + names.each do |name| + multi_query_relation_tree.add_relation(self, name) + end + + deep_relations.each do |rel, nested_rel| + multi_query_relation_tree.add_deep_relation(self, rel, nested_rel) + end + self + end + + # Alias for includes + def preload(*names, **deep_relations) + includes(*names, **deep_relations) + end + + # Adds to select statement given relations (with correspond joins) and loads them from result + def eager_load(*names, **deep_relations) + @eager_load = true + + names.each do |name| + nested_relation_tree.add_relation(self, name) + end + + deep_relations.each do |rel, nested_rel| + nested_relation_tree.add_deep_relation(self, rel, nested_rel) + end + self + end + + # Loads relations added by `preload` method; makes one separate request per each relation + private def add_preloaded(collection) + return collection if collection.empty? || !@include_relations + multi_query_relation_tree.preload(collection) + collection + end + end + end +end diff --git a/src/jennifer/query_builder/executables.cr b/src/jennifer/query_builder/executables.cr index 6e8db4b3..2ab4aa6f 100644 --- a/src/jennifer/query_builder/executables.cr +++ b/src/jennifer/query_builder/executables.cr @@ -168,8 +168,8 @@ module Jennifer def find_in_batches(primary_key : Nil, batch_size : Int32 = 1000, start : Int32 = 0, &block) Config.logger.warn("#find_in_batches is invoked with already ordered query - it will be reordered") if ordered? Config.logger.warn("#find_in_batches methods was invoked without passing primary_key" \ - " key field name which may results in not proper records extraction; 'start' argument" \ - " was realized as page number.") + " key field name which may results in not proper records extraction; 'start' argument" \ + " was realized as page number.") request = clone.reorder({} of String => String).limit(batch_size) records = request.offset(start * batch_size).to_a diff --git a/src/jennifer/query_builder/i_model_query.cr b/src/jennifer/query_builder/i_model_query.cr index 186ab407..1a184856 100644 --- a/src/jennifer/query_builder/i_model_query.cr +++ b/src/jennifer/query_builder/i_model_query.cr @@ -1,13 +1,10 @@ require "./query" +require "./eager_loading" module Jennifer module QueryBuilder abstract class IModelQuery < Query - @eager_load : Bool = false - @include_relations : Bool = false - - abstract def nested_relation_tree - abstract def multi_query_relation_tree + include EagerLoading # NOTE: improperly detects source of #abstract_class if run sam with only Version model def model_class @@ -19,78 +16,11 @@ module Jennifer raise AbstractMethod.new(:clone, {{@type}}) end - protected abstract def preload_relations - # Returns target table name def table @table.empty? ? model_class.table_name : @table end - def _select_fields : Array(Criteria) - if @select_fields.empty? - if @eager_load - nested_relation_tree.select_fields(self) - else - [@expression.star] of Criteria - end - else - @select_fields - end - end - - def with(*arr) - self.with(arr.to_a.map(&.to_s)) - end - - def with(arr : Array) - raise BaseException.new("#with should be called after correspond join") unless @joins - arr.each do |name| - table_name = model_class.relation(name).table_name - temp_joins = _joins!.select { |j| j.table == table_name } - join = temp_joins.find(&.relation.nil?) - if join - join.not_nil!.relation = name - elsif temp_joins.size == 0 - raise BaseException.new("#with should be called after correspond join: no such table \"#{table_name}\" of relation \"#{name}\"") - end - @eager_load = true - nested_relation_tree.add_relation(name) - end - self - end - - def includes(*names, **deep_relations) - @include_relations = true - - names.each do |name| - multi_query_relation_tree.add_relation(self, name) - end - - deep_relations.each do |rel, nested_rel| - multi_query_relation_tree.add_deep_relation(self, rel, nested_rel) - end - self - end - - # Alias for includes - def preload(*names, **deep_relations) - includes(*names, **deep_relations) - end - - # Adds to select statement given relations (with correspond joins) and loads them from result - def eager_load(*names, **deep_relations) - @eager_load = true - - names.each do |name| - nested_relation_tree.add_relation(self, name) - end - - deep_relations.each do |rel, nested_rel| - nested_relation_tree.add_deep_relation(self, rel, nested_rel) - end - self - end - def relation(name, type = :left) model_class.relation(name.to_s).join_condition(self, type) end @@ -128,13 +58,6 @@ module Jennifer # ========= private ============== - # Loads relations added by `preload` method; makes one separate request per each relation - private def add_preloaded(collection) - return collection if collection.empty? || !@include_relations - multi_query_relation_tree.preload(collection) - collection - end - private def add_aliases table_names = [table] table_names.concat(_joins!.map { |e| e.table unless e.has_alias? }.compact) if @joins diff --git a/src/jennifer/query_builder/nested_relation_tree.cr b/src/jennifer/query_builder/nested_relation_tree.cr index 204de54c..664685bb 100644 --- a/src/jennifer/query_builder/nested_relation_tree.cr +++ b/src/jennifer/query_builder/nested_relation_tree.cr @@ -10,7 +10,7 @@ module Jennifer @bucket << {index, relation} end - def select_fields(query) + def select_fields(query : Query) eb = query.expression_builder buff = Array(Criteria).new(@bucket.size + 1) buff << eb.star diff --git a/src/jennifer/query_builder/query.cr b/src/jennifer/query_builder/query.cr index bb1b6ee6..62b700aa 100644 --- a/src/jennifer/query_builder/query.cr +++ b/src/jennifer/query_builder/query.cr @@ -101,7 +101,7 @@ module Jennifer def _select_fields : Array(Criteria) if @select_fields.empty? - ([] of Criteria) << @expression.star + [@expression.star] of Criteria else @select_fields end diff --git a/src/jennifer/relation/many_to_many.cr b/src/jennifer/relation/many_to_many.cr index b2b60418..c5aba7eb 100644 --- a/src/jennifer/relation/many_to_many.cr +++ b/src/jennifer/relation/many_to_many.cr @@ -57,9 +57,8 @@ module Jennifer _foreign = foreign_field _primary = primary_field jt = join_table! - jtk = @association_foreign || T.to_s.foreign_key q = query.join(jt, type: type) { Q.c(_primary) == c(_foreign) }.join(T, type: type) do - T.primary == c(jtk, jt) + T.primary == c(association_foreign_key, jt) end if @join_query _tree = @join_query.not_nil! @@ -69,9 +68,8 @@ module Jennifer end end - # Stands for def association_foreign_key - @association_foreign || T.to_s.foreign_key + @association_foreign || Inflector.foreign_key(T.to_s) end def preload_relation(collection, out_collection : Array(Model::Resource), pk_repo) diff --git a/src/jennifer/view/base.cr b/src/jennifer/view/base.cr index d675ba42..ae4615a5 100644 --- a/src/jennifer/view/base.cr +++ b/src/jennifer/view/base.cr @@ -12,7 +12,7 @@ module Jennifer end def self.view_name - @@view_name ||= to_s.underscore.pluralize + @@view_name ||= Inflector.pluralize(to_s.underscore) end def self.view_name(value : String) diff --git a/src/jennifer/view/experimental_mapping.cr b/src/jennifer/view/experimental_mapping.cr index b458bb5d..e43cb457 100644 --- a/src/jennifer/view/experimental_mapping.cr +++ b/src/jennifer/view/experimental_mapping.cr @@ -113,7 +113,7 @@ module Jennifer {% for key, value in COLUMNS_METADATA %} begin res = %var{key.id}.as({{value[:parsed_type].id}}) - !res.is_a?(Time) ? res : ::Jennifer::Config.local_time_zone.utc_to_local(res) + !res.is_a?(Time) ? res : res.in(::Jennifer::Config.local_time_zone) rescue e : Exception raise ::Jennifer::DataTypeCasting.build({{key.id.stringify}}, {{@type}}, e) end, @@ -158,7 +158,7 @@ module Jennifer {% else %} %casted_var{key.id} = Jennifer::Model::Mapping.__bool_convert(%var{key.id}, {{value["parsed_type"].id}}) {% end %} - %casted_var{key.id} = !%casted_var{key.id}.is_a?(Time) ? %casted_var{key.id} : ::Jennifer::Config.local_time_zone.utc_to_local(%casted_var{key.id}) + %casted_var{key.id} = !%casted_var{key.id}.is_a?(Time) ? %casted_var{key.id} : %casted_var{key.id}.in(::Jennifer::Config.local_time_zone) rescue e : Exception raise ::Jennifer::DataTypeCasting.build({{key.id.stringify}}, {{@type}}, e) end