From 0cea7289d5c66a5880eea78b112a56ec9c1fa582 Mon Sep 17 00:00:00 2001 From: Roman Kalnytskyi Date: Thu, 9 Nov 2017 16:07:56 +0200 Subject: [PATCH 01/19] Decouple sql generator --- spec/adapter/sql_generator_spec.cr | 10 +- spec/model/base_spec.cr | 4 +- spec/query_builder/model_query_spec.cr | 8 +- spec/spec_helper.cr | 4 +- spec/view/base_spec.cr | 4 +- src/jennifer.cr | 1 - src/jennifer/adapter/base.cr | 25 ++-- src/jennifer/adapter/base_sql_generator.cr | 6 +- src/jennifer/adapter/mysql.cr | 4 + src/jennifer/adapter/mysql/sql_generator.cr | 55 ++++++++ src/jennifer/adapter/mysql/sql_notation.cr | 53 -------- src/jennifer/adapter/postgres.cr | 16 ++- .../adapter/postgres/sql_generator.cr | 125 ++++++++++++++++++ src/jennifer/adapter/postgres/sql_notation.cr | 121 ----------------- src/jennifer/adapter/request_methods.cr | 16 +-- src/jennifer/adapter/sql_generator.cr | 10 -- src/jennifer/adapter/sqlite3/sql_generator.cr | 8 ++ src/jennifer/adapter/sqlite3/sql_notation.rb | 6 - src/jennifer/query_builder/condition.cr | 4 +- src/jennifer/query_builder/criteria.cr | 4 +- src/jennifer/query_builder/executables.cr | 4 +- src/jennifer/query_builder/json_selector.cr | 2 +- src/jennifer/query_builder/query.cr | 2 +- 23 files changed, 253 insertions(+), 239 deletions(-) create mode 100644 src/jennifer/adapter/mysql/sql_generator.cr delete mode 100644 src/jennifer/adapter/mysql/sql_notation.cr create mode 100644 src/jennifer/adapter/postgres/sql_generator.cr delete mode 100644 src/jennifer/adapter/postgres/sql_notation.cr delete mode 100644 src/jennifer/adapter/sql_generator.cr create mode 100644 src/jennifer/adapter/sqlite3/sql_generator.cr delete mode 100644 src/jennifer/adapter/sqlite3/sql_notation.rb diff --git a/spec/adapter/sql_generator_spec.cr b/spec/adapter/sql_generator_spec.cr index c94e6319..4dcf22f3 100644 --- a/spec/adapter/sql_generator_spec.cr +++ b/spec/adapter/sql_generator_spec.cr @@ -1,8 +1,12 @@ require "../spec_helper" -describe Jennifer::Adapter::SqlGenerator do +def sb + String.build { |io| yield io } +end + +describe "Jennifer::Adapter::SQLGenerator" do adapter = Jennifer::Adapter.adapter - described_class = Jennifer::Adapter::SqlGenerator + described_class = Jennifer::Adapter.adapter.sql_generator describe "::select_query" do s = Contact.where { _age == 1 }.join(Contact) { _age == Contact._age }.order(age: :desc).limit(1) @@ -171,7 +175,7 @@ describe Jennifer::Adapter::SqlGenerator do it "adds next query to current one" do query = Jennifer::Query["contacts"].union(Jennifer::Query["users"]) - sb { |s| described_class.union_clause(s, query) }.should match(Regex.new(Jennifer::Adapter::SqlGenerator.select(Jennifer::Query["users"]))) + sb { |s| described_class.union_clause(s, query) }.should match(Regex.new(Jennifer::Adapter.adapter.sql_generator.select(Jennifer::Query["users"]))) end end diff --git a/spec/model/base_spec.cr b/spec/model/base_spec.cr index 440edc30..eeea6a14 100644 --- a/spec/model/base_spec.cr +++ b/spec/model/base_spec.cr @@ -277,7 +277,7 @@ describe Jennifer::Model::Base do describe "%scope" do context "with block" do it "executes in query context" do - ::Jennifer::Adapter::SqlGenerator.select(Contact.all.ordered).should match(/ORDER BY contacts\.name ASC/) + ::Jennifer::Adapter.adapter.sql_generator.select(Contact.all.ordered).should match(/ORDER BY contacts\.name ASC/) end context "without arguemnt" do @@ -312,7 +312,7 @@ describe Jennifer::Model::Base do context "with query object class" do it "executes in class context" do - ::Jennifer::Adapter::SqlGenerator.select(Contact.johny).should match(/name =/) + ::Jennifer::Adapter.adapter.sql_generator.select(Contact.johny).should match(/name =/) end context "without arguemnt" do diff --git a/spec/query_builder/model_query_spec.cr b/spec/query_builder/model_query_spec.cr index b995a89c..ecdd2ed4 100644 --- a/spec/query_builder/model_query_spec.cr +++ b/spec/query_builder/model_query_spec.cr @@ -50,7 +50,7 @@ describe Jennifer::QueryBuilder::ModelQuery do it "it generates proper request" do contact = Factory.create_contact query = Contact.all.eager_load(:main_address) - Jennifer::Adapter::SqlGenerator.select(query).should match(/addresses\.main/) + Jennifer::Adapter.adapter.sql_generator.select(query).should match(/addresses\.main/) end end @@ -97,9 +97,9 @@ describe Jennifer::QueryBuilder::ModelQuery do describe "#relation" do # TODO: refactor this bad test - this should be tested under sql generating process it "makes join using relation scope" do - ::Jennifer::Adapter::SqlGenerator - .select(Contact.all.relation(:addresses)) - .should match(/LEFT JOIN addresses ON addresses.contact_id = contacts.id/) + ::Jennifer::Adapter.adapter.sql_generator + .select(Contact.all.relation(:addresses)) + .should match(/LEFT JOIN addresses ON addresses.contact_id = contacts.id/) end end diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr index 6a82a067..3b895aa0 100644 --- a/spec/spec_helper.cr +++ b/spec/spec_helper.cr @@ -49,11 +49,11 @@ macro void_transaction end def select_clause(query) - String.build { |s| ::Jennifer::Adapter::SqlGenerator.select_clause(s, query) } + String.build { |s| ::Jennifer::Adapter.adapter.sql_generator.select_clause(s, query) } end def select_query(query) - ::Jennifer::Adapter::SqlGenerator.select(query) + ::Jennifer::Adapter.adapter.sql_generator.select(query) end def db_array(*element) diff --git a/spec/view/base_spec.cr b/spec/view/base_spec.cr index cd29e3de..a17ad3a0 100644 --- a/spec/view/base_spec.cr +++ b/spec/view/base_spec.cr @@ -54,7 +54,7 @@ describe Jennifer::View::Base do describe "%scope" do context "with block" do it "executes in query context" do - ::Jennifer::Adapter::SqlGenerator.select(MaleContact.all.older(18)).should match(/male_contacts.age >/) + ::Jennifer::Adapter.adapter.sql_generator.select(MaleContact.all.older(18)).should match(/male_contacts.age >/) end context "without arguemnt" do @@ -86,7 +86,7 @@ describe Jennifer::View::Base do context "with query object class" do it "executes in class context" do - ::Jennifer::Adapter::SqlGenerator.select(MaleContact.johny).should match(/name =/) + ::Jennifer::Adapter.adapter.sql_generator.select(MaleContact.johny).should match(/name =/) end context "without arguemnt" do diff --git a/src/jennifer.cr b/src/jennifer.cr index f85d5f55..32ec2002 100644 --- a/src/jennifer.cr +++ b/src/jennifer.cr @@ -8,7 +8,6 @@ require "./jennifer/macros" require "./jennifer/exceptions" require "./jennifer/adapter" require "./jennifer/adapter/record" -require "./jennifer/adapter/sql_generator" require "./jennifer/config" require "./jennifer/version" diff --git a/src/jennifer/adapter/base.cr b/src/jennifer/adapter/base.cr index 2a87b30b..dd1df8b9 100644 --- a/src/jennifer/adapter/base.cr +++ b/src/jennifer/adapter/base.cr @@ -66,12 +66,12 @@ module Jennifer raise BadQuery.new(e.message, _query, args) end - def parse_query(q : String, args : Array) - SqlGenerator.parse_query(q, args.size) + def parse_query(q : String, args) + sql_generator.parse_query(q, args.size) end def parse_query(q : String) - SqlGenerator.parse_query(q) + sql_generator.parse_query(q) end def truncate(klass : Class) @@ -79,22 +79,22 @@ module Jennifer end def truncate(table_name : String) - exec SqlGenerator.truncate(table_name) + exec sql_generator.truncate(table_name) end def delete(query : QueryBuilder::Query) args = query.select_args - exec SqlGenerator.delete(query), args + exec sql_generator.delete(query), args end def exists?(query : QueryBuilder::Query) args = query.select_args - scalar(SqlGenerator.exists(query), args) == 1 + scalar(sql_generator.exists(query), args) == 1 end def count(query : QueryBuilder::Query) args = query.select_args - scalar(SqlGenerator.count(query), args).as(Int64).to_i + scalar(sql_generator.count(query), args).as(Int64).to_i end def bulk_insert(collection : Array(Model::Base)) @@ -102,7 +102,7 @@ module Jennifer klass = collection[0].class fields = collection[0].arguments_to_insert[:fields] values = collection.flat_map(&.arguments_to_insert[:args]) - parsed_query = parse_query(SqlGenerator.bulk_insert(klass.table_name, fields, collection.size), values) + parsed_query = parse_query(sql_generator.bulk_insert(klass.table_name, fields, collection.size), values) with_table_lock(klass.table_name) do exec(parsed_query, values) @@ -119,7 +119,7 @@ module Jennifer return if values.empty? with_table_lock(table) do flat_values = values.flatten - exec(parse_query(SqlGenerator.bulk_insert(table, fields, values.size), flat_values), flat_values) + exec(parse_query(sql_generator.bulk_insert(table, fields, values.size), flat_values), flat_values) end nil end @@ -172,8 +172,8 @@ module Jennifer escape_string(arr.size) end - def self.escape_string(size : Int32 = 1) - SqlGenerator.escape_string(size) + def self.escape_string(size = 1) + Adapter.adapter.sql_generator.escape_string(size) end def self.drop_database @@ -300,7 +300,7 @@ module Jennifer buff = String.build do |s| s << "CREATE " s << "OR REPLACE " if silent - s << "VIEW " << name << " AS " << SqlGenerator.select(query) + s << "VIEW " << name << " AS " << sql_generator.select(query) end exec buff end @@ -328,6 +328,7 @@ module Jennifer result end + abstract def sql_generator abstract def view_exists?(name, silent = true) abstract def update(obj) abstract def update(q, h) diff --git a/src/jennifer/adapter/base_sql_generator.cr b/src/jennifer/adapter/base_sql_generator.cr index 4015d456..7237dea1 100644 --- a/src/jennifer/adapter/base_sql_generator.cr +++ b/src/jennifer/adapter/base_sql_generator.cr @@ -1,6 +1,6 @@ module Jennifer module Adapter - class BaseSqlGenerator + class BaseSQLGenerator ARRAY_ESCAPE = "\\\\\\\\" # Generates insert query @@ -156,9 +156,9 @@ module Jennifer query._from else if query.is_a?(QueryBuilder::ModelQuery) - SqlGenerator.select(query._from.as(QueryBuilder::ModelQuery)) + self.select(query._from.as(QueryBuilder::ModelQuery)) else - SqlGenerator.select(query._from.as(QueryBuilder::Query)) + self.select(query._from.as(QueryBuilder::Query)) end end io << " ) " diff --git a/src/jennifer/adapter/mysql.cr b/src/jennifer/adapter/mysql.cr index 25028295..9efd1b34 100644 --- a/src/jennifer/adapter/mysql.cr +++ b/src/jennifer/adapter/mysql.cr @@ -49,6 +49,10 @@ module Jennifer } class Mysql < Base + def sql_generator + SQLGenerator + end + def translate_type(name : Symbol) Adapter::TYPE_TRANSLATIONS[name] rescue e : KeyError diff --git a/src/jennifer/adapter/mysql/sql_generator.cr b/src/jennifer/adapter/mysql/sql_generator.cr new file mode 100644 index 00000000..98f53bc7 --- /dev/null +++ b/src/jennifer/adapter/mysql/sql_generator.cr @@ -0,0 +1,55 @@ +module Jennifer + module Adapter + class Mysql + class SQLGenerator < BaseSQLGenerator + def self.insert(obj : Model::Base) + opts = obj.arguments_to_insert + String.build do |s| + s << "INSERT INTO " << obj.class.table_name + unless opts[:fields].empty? + s << "(" + opts[:fields].join(", ", s) + s << ") VALUES (" << Adapter.adapter_class.escape_string(opts[:fields].size) << ") " + else + s << " VALUES ()" + end + end + end + + # Generates update request depending on given query and hash options. Allows + # joins inside of query. + def self.update(query, options : Hash) + esc = Adapter.adapter_class.escape_string(1) + String.build do |s| + s << "UPDATE " << query.table + s << "\n" + _joins = query._joins + + unless _joins.nil? + where_clause(s, _joins[0].on) + _joins[1..-1].join(" ", s) { |e| s << e.as_sql } + end + s << " SET " + options.join(", ", s) { |(k, v)| s << k << " = " << esc } + s << " " + where_clause(s, query.tree) + end + end + + def self.json_path(path : QueryBuilder::JSONSelector) + value = + if path.path.is_a?(Number) + quote("$[#{path.path.to_s}]") + else + quote(path.path) + end + "#{path.identifier}->#{value}" + end + + def self.quote(value : String) + "\"#{value.gsub(/\\/, "\&\&").gsub(/"/, "\"\"")}\"" + end + end + end + end +end diff --git a/src/jennifer/adapter/mysql/sql_notation.cr b/src/jennifer/adapter/mysql/sql_notation.cr deleted file mode 100644 index e0e96247..00000000 --- a/src/jennifer/adapter/mysql/sql_notation.cr +++ /dev/null @@ -1,53 +0,0 @@ -module Jennifer - module Adapter - module SqlNotation - def insert(obj : Model::Base) - opts = obj.arguments_to_insert - String.build do |s| - s << "INSERT INTO " << obj.class.table_name - unless opts[:fields].empty? - s << "(" - opts[:fields].join(", ", s) - s << ") VALUES (" << Adapter.adapter_class.escape_string(opts[:fields].size) << ") " - else - s << " VALUES ()" - end - end - end - - # Generates update request depending on given query and hash options. Allows - # joins inside of query. - def update(query, options : Hash) - esc = Adapter.adapter_class.escape_string(1) - String.build do |s| - s << "UPDATE " << query.table - s << "\n" - _joins = query._joins - - unless _joins.nil? - where_clause(s, _joins[0].on) - _joins[1..-1].join(" ", s) { |e| s << e.as_sql } - end - s << " SET " - options.join(", ", s) { |(k, v)| s << k << " = " << esc } - s << " " - where_clause(s, query.tree) - end - end - - def json_path(path : QueryBuilder::JSONSelector) - value = - if path.path.is_a?(Number) - quote("$[#{path.path.to_s}]") - else - quote(path.path) - end - "#{path.identifier}->#{value}" - end - - def quote(value : String) - "\"#{value.gsub(/\\/, "\&\&").gsub(/"/, "\"\"")}\"" - end - end - end -end diff --git a/src/jennifer/adapter/postgres.cr b/src/jennifer/adapter/postgres.cr index 67cd45a6..90d21d38 100644 --- a/src/jennifer/adapter/postgres.cr +++ b/src/jennifer/adapter/postgres.cr @@ -1,6 +1,10 @@ require "pg" require "../adapter" -require "./postgres/sql_notation" + +class Jennifer::Adapter::Postgres < Jennifer::Adapter::Base +end + +require "./postgres/sql_generator" module Jennifer alias DBAny = Array(Int32) | Array(Char) | Array(Float32) | Array(Float64) | @@ -72,6 +76,10 @@ module Jennifer } class Postgres < Base + def sql_generator + SQLGenerator + end + def prepare _query = <<-SQL SELECT e.enumtypid @@ -248,7 +256,7 @@ module Jennifer def insert(obj : Model::Base) opts = obj.arguments_to_insert - query = parse_query(SqlGenerator.insert(obj, obj.class.primary_auto_incrementable?), opts[:args]) + query = parse_query(sql_generator.insert(obj, obj.class.primary_auto_incrementable?), opts[:args]) id = -1i64 affected = 0i64 if obj.class.primary_auto_incrementable? @@ -268,7 +276,7 @@ module Jennifer def self.bulk_insert(collection : Array(Model::Base)) opts = collection.flat_map(&.arguments_to_insert[:args]) - query = parse_query(SqlGenerator.bulk_insert(collection)) + query = parse_query(sql_generator.bulk_insert(collection)) # TODO: change to checking for autoincrementability affected = exec(qyery, opts).rows_affected if true @@ -281,7 +289,7 @@ module Jennifer def exists?(query) args = query.select_args - body = SqlGenerator.exists(query) + body = sql_generator.exists(query) scalar(body, args) end diff --git a/src/jennifer/adapter/postgres/sql_generator.cr b/src/jennifer/adapter/postgres/sql_generator.cr new file mode 100644 index 00000000..d0df18b8 --- /dev/null +++ b/src/jennifer/adapter/postgres/sql_generator.cr @@ -0,0 +1,125 @@ +require "../base_sql_generator" + +module Jennifer + module Adapter + class Postgres + class SQLGenerator < BaseSQLGenerator + def self.insert(obj : Model::Base, with_primary_field = true) + opts = obj.arguments_to_insert + String.build do |s| + s << "INSERT INTO " << obj.class.table_name + unless opts[:fields].empty? + s << "(" + opts[:fields].join(", ", s) + s << ") VALUES (" << Adapter.adapter_class.escape_string(opts[:fields].size) << ") " + else + s << " DEFAULT VALUES" + end + + # TODO: uncomment after pg driver will raise error if inserting brakes smth + # if with_primary_field + # s << " RETURNING " << obj.class.primary_field_name + # end + end + end + + # Generates update request depending on given query and hash options. Allows + # joins inside of query. + def self.update(query, options : Hash) + esc = Adapter.adapter_class.escape_string(1) + String.build do |s| + s << "UPDATE " << query._table << " SET " + options.map { |k, v| "#{k.to_s}= #{esc}" }.join(", ", s) + s << "\n" + + from_clause(s, query, query._joins![0].table_name) if query._joins + where_clause(s, query.tree) + if query._joins + where_clause(s, query._joins![0].on) + query._joins![1..-1].join(" ", s) { |e| s << e.as_sql } + end + end + end + + # =================== utils + + def self.operator_to_sql(operator) + case operator + when :like + "LIKE" + when :not_like + "NOT LIKE" + when :regexp + "~" + when :not_regexp + "!~" + when :== + "=" + when :is + "IS" + when :is_not + "IS NOT" + when :contain + "@>" + when :contained + "<@" + when :overlap + "&&" + when :ilike + "ILIKE" + else + operator.to_s + end + end + + def self.json_path(path : QueryBuilder::JSONSelector) + operator = + case path.type + when :path + "#>" + when :take + "->" + else + raise ArgumentError.new("Wrong json path type") + end + "#{path.identifier}#{operator}#{quote(path.path)}" + end + + # for postgres column name + def self.escape(value : String) + case value + when "NULL", "TRUE", "FALSE" + value + else + value = value.gsub(/\\/, ARRAY_ESCAPE).gsub(/"/, "\\\"") + "\"#{value}\"" + end + end + + def self.escape(value : Nil) + quote(value) + end + + def self.escape(value : Bool) + quote(value) + end + + def self.escape(value : Int32 | Int16 | Float64 | Float32) + quote(value) + end + + def self.quote(value : String) + "'#{value.gsub(/\\/, "\&\&").gsub(/'/, "''")}'" + end + + def self.parse_query(query, arg_count) + arr = [] of String + arg_count.times do |i| + arr << "$#{i + 1}" + end + query % arr + end + end + end + end +end diff --git a/src/jennifer/adapter/postgres/sql_notation.cr b/src/jennifer/adapter/postgres/sql_notation.cr deleted file mode 100644 index 0f621488..00000000 --- a/src/jennifer/adapter/postgres/sql_notation.cr +++ /dev/null @@ -1,121 +0,0 @@ -module Jennifer - module Adapter - module SqlNotation - def insert(obj : Model::Base, with_primary_field = true) - opts = obj.arguments_to_insert - String.build do |s| - s << "INSERT INTO " << obj.class.table_name - unless opts[:fields].empty? - s << "(" - opts[:fields].join(", ", s) - s << ") VALUES (" << Adapter.adapter_class.escape_string(opts[:fields].size) << ") " - else - s << " DEFAULT VALUES" - end - - # TODO: uncomment after pg driver will raise error if inserting brakes smth - # if with_primary_field - # s << " RETURNING " << obj.class.primary_field_name - # end - end - end - - # Generates update request depending on given query and hash options. Allows - # joins inside of query. - def update(query, options : Hash) - esc = Adapter.adapter_class.escape_string(1) - String.build do |s| - s << "UPDATE " << query._table << " SET " - options.map { |k, v| "#{k.to_s}= #{esc}" }.join(", ", s) - s << "\n" - - from_clause(s, query, query._joins![0].table_name) if query._joins - where_clause(s, query.tree) - if query._joins - where_clause(s, query._joins![0].on) - query._joins![1..-1].join(" ", s) { |e| s << e.as_sql } - end - end - end - - # =================== utils - - def operator_to_sql(operator) - case operator - when :like - "LIKE" - when :not_like - "NOT LIKE" - when :regexp - "~" - when :not_regexp - "!~" - when :== - "=" - when :is - "IS" - when :is_not - "IS NOT" - when :contain - "@>" - when :contained - "<@" - when :overlap - "&&" - when :ilike - "ILIKE" - else - operator.to_s - end - end - - def json_path(path : QueryBuilder::JSONSelector) - operator = - case path.type - when :path - "#>" - when :take - "->" - else - raise ArgumentError.new("Wrong json path type") - end - "#{path.identifier}#{operator}#{quote(path.path)}" - end - - # for postgres column name - def escape(value : String) - case value - when "NULL", "TRUE", "FALSE" - value - else - value = value.gsub(/\\/, ARRAY_ESCAPE).gsub(/"/, "\\\"") - "\"#{value}\"" - end - end - - def escape(value : Nil) - quote(value) - end - - def escape(value : Bool) - quote(value) - end - - def escape(value : Int32 | Int16 | Float64 | Float32) - quote(value) - end - - def quote(value : String) - "'#{value.gsub(/\\/, "\&\&").gsub(/'/, "''")}'" - end - - def parse_query(query, arg_count) - arr = [] of String - arg_count.times do |i| - arr << "$#{i + 1}" - end - query % arr - end - end - end -end diff --git a/src/jennifer/adapter/request_methods.cr b/src/jennifer/adapter/request_methods.cr index 078856fa..76306987 100644 --- a/src/jennifer/adapter/request_methods.cr +++ b/src/jennifer/adapter/request_methods.cr @@ -5,19 +5,19 @@ module Jennifer def insert(table, opts : Hash) values = opts.values - exec parse_query(SqlGenerator.insert(table, opts), values), values + exec parse_query(sql_generator.insert(table, opts), values), values end def insert(obj : Model::Base) opts = obj.arguments_to_insert - exec parse_query(SqlGenerator.insert(obj), opts[:args]), opts[:args] + exec parse_query(sql_generator.insert(obj), opts[:args]), opts[:args] end def update(obj : Model::Base) opts = obj.arguments_to_save return DB::ExecResult.new(0i64, -1i64) if opts[:args].empty? opts[:args] << obj.primary - exec(parse_query(SqlGenerator.update(obj), opts[:args]), opts[:args]) + exec(parse_query(sql_generator.update(obj), opts[:args]), opts[:args]) end def update(query, options : Hash) @@ -26,11 +26,11 @@ module Jennifer args << v end args.concat(query.select_args) - exec(parse_query(SqlGenerator.update(query, options), args), args) + exec(parse_query(sql_generator.update(query, options), args), args) end def modify(q, modifications : Hash) - query = SqlGenerator.modify(q, modifications) + query = sql_generator.modify(q, modifications) args = [] of DBAny modifications.each do |k, v| args << v[:value] @@ -41,7 +41,7 @@ module Jennifer def pluck(query, fields : Array) result = [] of Array(DBAny) - body = SqlGenerator.select(query, fields) + body = sql_generator.select(query, fields) args = query.select_args query(parse_query(body, args), args) do |rs| rs.each do @@ -54,7 +54,7 @@ module Jennifer def pluck(query, field) result = [] of DBAny fields = [field.to_s] - body = SqlGenerator.select(query, fields) + body = sql_generator.select(query, fields) args = query.select_args query(parse_query(body, args), args) do |rs| rs.each do @@ -65,7 +65,7 @@ module Jennifer end def select(q) - body = SqlGenerator.select(q) + body = sql_generator.select(q) args = q.select_args query(parse_query(body, args), args) { |rs| yield rs } end diff --git a/src/jennifer/adapter/sql_generator.cr b/src/jennifer/adapter/sql_generator.cr deleted file mode 100644 index e93ba10a..00000000 --- a/src/jennifer/adapter/sql_generator.cr +++ /dev/null @@ -1,10 +0,0 @@ -require "../adapter" -require "./base_sql_generator" - -module Jennifer - module Adapter - class SqlGenerator < BaseSqlGenerator - extend SqlNotation - end - end -end diff --git a/src/jennifer/adapter/sqlite3/sql_generator.cr b/src/jennifer/adapter/sqlite3/sql_generator.cr new file mode 100644 index 00000000..5f5bebca --- /dev/null +++ b/src/jennifer/adapter/sqlite3/sql_generator.cr @@ -0,0 +1,8 @@ +module Jennifer + module Adapter + class Sqlite3 + class SQLGenerator < BaseSQLGenerator + end + end + end +end diff --git a/src/jennifer/adapter/sqlite3/sql_notation.rb b/src/jennifer/adapter/sqlite3/sql_notation.rb deleted file mode 100644 index acb18009..00000000 --- a/src/jennifer/adapter/sqlite3/sql_notation.rb +++ /dev/null @@ -1,6 +0,0 @@ -module Jennifer - module Adapter - module SqlNotation - end - end -end diff --git a/src/jennifer/query_builder/condition.cr b/src/jennifer/query_builder/condition.cr index 866c98c1..a30db854 100644 --- a/src/jennifer/query_builder/condition.cr +++ b/src/jennifer/query_builder/condition.cr @@ -71,11 +71,11 @@ module Jennifer when :bool _lhs when :in - "#{_lhs} IN(#{Adapter::SqlGenerator.escape_string(@rhs.as(Array).size)})" + "#{_lhs} IN(#{Adapter.adapter.sql_generator.escape_string(@rhs.as(Array).size)})" when :between "#{_lhs} BETWEEN #{Adapter.escape_string(1)} AND #{Adapter.escape_string(1)}" else - "#{_lhs} #{Adapter::SqlGenerator.operator_to_sql(@operator)} #{parsed_rhs}" + "#{_lhs} #{Adapter.adapter.sql_generator.operator_to_sql(@operator)} #{parsed_rhs}" end str = "NOT (#{str})" if @negative str diff --git a/src/jennifer/query_builder/criteria.cr b/src/jennifer/query_builder/criteria.cr index 3ade69d4..db6e31bc 100644 --- a/src/jennifer/query_builder/criteria.cr +++ b/src/jennifer/query_builder/criteria.cr @@ -154,11 +154,11 @@ module Jennifer private def translate(value : Symbol | Bool | Nil) case value when nil, true, false - Adapter::SqlGenerator.quote(value) + Adapter.adapter.sql_generator.quote(value) when :unknown "UNKNOWN" when :nil - Adapter::SqlGenerator.quote(nil) + Adapter.adapter.sql_generator.quote(nil) end end end diff --git a/src/jennifer/query_builder/executables.cr b/src/jennifer/query_builder/executables.cr index 9c7da025..e7733bd9 100644 --- a/src/jennifer/query_builder/executables.cr +++ b/src/jennifer/query_builder/executables.cr @@ -18,7 +18,7 @@ module Jennifer result = to_a reverse_order @limit = old_limit - raise RecordNotFound.new(Adapter::SqlGenerator.select(self)) if result.empty? + raise RecordNotFound.new(Adapter.adapter.sql_generator.select(self)) if result.empty? result[0] end @@ -34,7 +34,7 @@ module Jennifer old_limit = @limit result = to_a @limit = old_limit - raise RecordNotFound.new(Adapter::SqlGenerator.select(self)) if result.empty? + raise RecordNotFound.new(Adapter.adapter.sql_generator.select(self)) if result.empty? result[0] end diff --git a/src/jennifer/query_builder/json_selector.cr b/src/jennifer/query_builder/json_selector.cr index 21b65e56..d1c782dd 100644 --- a/src/jennifer/query_builder/json_selector.cr +++ b/src/jennifer/query_builder/json_selector.cr @@ -13,7 +13,7 @@ module Jennifer end def as_sql - Adapter::SqlGenerator.json_path(self) + Adapter.adapter.sql_generator.json_path(self) end end end diff --git a/src/jennifer/query_builder/query.cr b/src/jennifer/query_builder/query.cr index d9df8b03..700a8f03 100644 --- a/src/jennifer/query_builder/query.cr +++ b/src/jennifer/query_builder/query.cr @@ -122,7 +122,7 @@ module Jennifer end def to_sql - Adapter::SqlGenerator.select(self) + Adapter.adapter.sql_generator.select(self) end def as_sql From 238fb96d8a374e02f3a9150ebb6c7dac7b083410 Mon Sep 17 00:00:00 2001 From: Roman Kalnytskyi Date: Sat, 11 Nov 2017 22:55:38 +0200 Subject: [PATCH 02/19] resolve conflicts of mysql & postgres pair compilation --- spec/query_builder/condition_spec.cr | 21 ++--- spec/spec_helper.cr | 1 + src/jennifer/adapter.cr | 19 +++++ src/jennifer/adapter/mysql.cr | 76 ++++++++++--------- src/jennifer/adapter/mysql/sql_generator.cr | 2 + src/jennifer/adapter/postgres.cr | 6 -- .../migration/table_builder/change_enum.cr | 26 +++---- src/jennifer/adapter/shared/types.cr | 13 ++++ .../migration/table_builder/create_table.cr | 2 +- 9 files changed, 100 insertions(+), 66 deletions(-) create mode 100644 src/jennifer/adapter/shared/types.cr diff --git a/spec/query_builder/condition_spec.cr b/spec/query_builder/condition_spec.cr index 16ba8640..6739188e 100644 --- a/spec/query_builder/condition_spec.cr +++ b/spec/query_builder/condition_spec.cr @@ -10,6 +10,12 @@ describe Jennifer::QueryBuilder::Condition do end {% end %} + context "operator ==" do + it "returns short" do + (Factory.build_criteria == "asd").as_sql.should eq("tests.f1 = %s") + end + end + postgres_only do context "operator overlap" do it "accepts plain args" do @@ -42,18 +48,15 @@ describe Jennifer::QueryBuilder::Condition do end end - context "operator ==" do - it "returns short" do - (Factory.build_criteria == "asd").as_sql.should eq("tests.f1 = %s") - end - end - context "operator =~" do it "returns regexp operator" do cond = Factory.build_criteria =~ "asd" - if Jennifer::Adapter.adapters.keys.last == "postgres" + + postgres_only do cond.as_sql.should match(/~/) - else + end + + mysql_only do cond.as_sql.should match(/REGEXP/) end end @@ -140,7 +143,7 @@ describe Jennifer::QueryBuilder::Condition do end context "anything else" do - it "renders question mark" do + it "renders placeholder" do c1 = Factory.build_criteria.to_condition c1.filter_out(1).should eq("%s") c1.filter_out("s").should eq("%s") diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr index 3b895aa0..570b4cb5 100644 --- a/spec/spec_helper.cr +++ b/spec/spec_helper.cr @@ -37,6 +37,7 @@ def clean_db Jennifer::Model::Base.models.select { |t| t.has_table? }.each(&.all.delete) end +# Ends current transaction, yields and starts next one macro void_transaction begin Jennifer::Adapter.adapter.rollback_transaction diff --git a/src/jennifer/adapter.cr b/src/jennifer/adapter.cr index 5eeb39fd..8ac48efd 100644 --- a/src/jennifer/adapter.cr +++ b/src/jennifer/adapter.cr @@ -4,7 +4,26 @@ module Jennifer alias AnyResult = DB::Any | Int8 | Int16 | JSON::Any alias AnyArgument = AnyResult | Array(AnyResult) + alias DBAny = Array(Int32) | Array(Char) | Array(Float32) | Array(Float64) | + Array(Int16) | Array(Int32) | Array(Int64) | Array(String) | + Bool | Char | Float32 | Float64 | Int8 | Int16 | Int32 | Int64 | JSON::Any | PG::Geo::Box | + PG::Geo::Circle | PG::Geo::Line | PG::Geo::LineSegment | PG::Geo::Path | PG::Geo::Point | + PG::Geo::Polygon | PG::Numeric | Slice(UInt8) | String | Time | UInt32 | Nil + module Adapter + TYPES = %i( + integer short bigint oid + float double + numeric decimal + bool + string char text var_string varchar blchar + uuid + timestamp timestamptz date_time + blob bytea + json jsonb xml + point lseg path box polygon line circle + ) + @@adapter : Base? @@adapters = {} of String => Base.class @@adapter_class : Base.class | Nil diff --git a/src/jennifer/adapter/mysql.cr b/src/jennifer/adapter/mysql.cr index 9efd1b34..44fccb42 100644 --- a/src/jennifer/adapter/mysql.cr +++ b/src/jennifer/adapter/mysql.cr @@ -1,66 +1,68 @@ require "mysql" require "./base" -require "./mysql/sql_notation" -module Jennifer - alias DBAny = DB::Any | Int16 | Int8 | JSON::Any +class Jennifer::Adapter::Mysql < Jennifer::Adapter::Base +end + +require "./mysql/sql_generator" +module Jennifer module Adapter - alias EnumType = String + class Mysql < Base + alias EnumType = String - TYPE_TRANSLATIONS = { - :bool => "bool", - :enum => "enum", + TYPE_TRANSLATIONS = { + :bool => "bool", + :enum => "enum", - :bigint => "bigint", # Int64 - :integer => "int", # Int32 - :short => "SMALLINT", # Int16 - :tinyint => "TINYINT", # Int8 + :bigint => "bigint", # Int64 + :integer => "int", # Int32 + :short => "SMALLINT", # Int16 + :tinyint => "TINYINT", # Int8 - :float => "float", # Float32 - :double => "double", # Float64 + :float => "float", # Float32 + :double => "double", # Float64 - :decimal => "decimal", # Float64 + :decimal => "decimal", # Float64 - :string => "varchar", - :varchar => "varchar", - :text => "text", - :var_string => "varstring", + :string => "varchar", + :varchar => "varchar", + :text => "text", + :var_string => "varstring", - :timestamp => "datetime", # "timestamp", - :date_time => "datetime", + :timestamp => "datetime", # "timestamp", + :date_time => "datetime", - :blob => "blob", - :json => "json", + :blob => "blob", + :json => "json", - } + } - DEFAULT_SIZES = { - :string => 254, - } + DEFAULT_SIZES = { + :string => 254, + } - # NOTE: now is not used - TABLE_LOCK_TYPES = { - "r" => "READ", - "rl" => "READ LOCAL", - "w" => "WRITE", - "lpw" => "LOW_PRIORITY WRITE", - "default" => "READ", # "r" - } + # NOTE: now is not used + TABLE_LOCK_TYPES = { + "r" => "READ", + "rl" => "READ LOCAL", + "w" => "WRITE", + "lpw" => "LOW_PRIORITY WRITE", + "default" => "READ", # "r" + } - class Mysql < Base def sql_generator SQLGenerator end def translate_type(name : Symbol) - Adapter::TYPE_TRANSLATIONS[name] + TYPE_TRANSLATIONS[name] rescue e : KeyError raise BaseException.new("Unknown data alias #{name}") end def default_type_size(name) - Adapter::DEFAULT_SIZES[name]? + DEFAULT_SIZES[name]? end def table_column_count(table) diff --git a/src/jennifer/adapter/mysql/sql_generator.cr b/src/jennifer/adapter/mysql/sql_generator.cr index 98f53bc7..7cf079e0 100644 --- a/src/jennifer/adapter/mysql/sql_generator.cr +++ b/src/jennifer/adapter/mysql/sql_generator.cr @@ -1,3 +1,5 @@ +require "../base_sql_generator" + module Jennifer module Adapter class Mysql diff --git a/src/jennifer/adapter/postgres.cr b/src/jennifer/adapter/postgres.cr index 90d21d38..3d5f9f6e 100644 --- a/src/jennifer/adapter/postgres.cr +++ b/src/jennifer/adapter/postgres.cr @@ -7,12 +7,6 @@ end require "./postgres/sql_generator" module Jennifer - alias DBAny = Array(Int32) | Array(Char) | Array(Float32) | Array(Float64) | - Array(Int16) | Array(Int32) | Array(Int64) | Array(String) | - Bool | Char | Float32 | Float64 | Int16 | Int32 | Int64 | JSON::Any | PG::Geo::Box | - PG::Geo::Circle | PG::Geo::Line | PG::Geo::LineSegment | PG::Geo::Path | PG::Geo::Point | - PG::Geo::Polygon | PG::Numeric | Slice(UInt8) | String | Time | UInt32 | Nil - module Adapter alias EnumType = Bytes diff --git a/src/jennifer/adapter/postgres/migration/table_builder/change_enum.cr b/src/jennifer/adapter/postgres/migration/table_builder/change_enum.cr index 2ef7738c..b84e9de0 100644 --- a/src/jennifer/adapter/postgres/migration/table_builder/change_enum.cr +++ b/src/jennifer/adapter/postgres/migration/table_builder/change_enum.cr @@ -9,22 +9,15 @@ module Jennifer def process remove_values if @options.has_key?(:remove_values) - add_values if @options.has_key?(:add_values) - rename_values if @options.has_key?(:rename_values) - rename(name, @options[:new_name]) if @options.has_key?(:rename) end def remove_values - new_values = @adapter.enum_values(@name) - @options[:remove_values] - data_name = @name.dup - effected_tables = - Query["information_schema.columns"] - .select("table_name, column_name") - .where { (c("udt_name") == data_name) & (c("table_catalog") == Config.db) } - .pluck(:table_name, :column_name) + new_values = [] of String + @adapter.enum_values(@name).map { |e| new_values << e[0] } + new_values -= @options[:remove_values] if effected_tables.empty? recreate_enum(new_values) else @@ -48,9 +41,8 @@ module Jennifer count = @options[:rename_values].as(Array).size while i < count old_name = @options[:rename_values][i] - i += 1 - new_name = @options[:rename_values][i] - i += 1 + new_name = @options[:rename_values][i + 1] + i += 2 Query["pg_enum"].where do (c("enumlabel") == old_name) & (c("enumtypid") == sql("SELECT OID FROM pg_type WHERE typname = '#{name}'")) end.update({:enumlabel => new_name}) @@ -75,6 +67,14 @@ module Jennifer rename(temp_name, @name) end end + + private def effected_tables + @effected_tables ||= + Query["information_schema.columns"] + .select("table_name, column_name") + .where { (c("udt_name") == @name.dup) & (c("table_catalog") == Config.db) } + .pluck(:table_name, :column_name) + end end end end diff --git a/src/jennifer/adapter/shared/types.cr b/src/jennifer/adapter/shared/types.cr new file mode 100644 index 00000000..16eb307d --- /dev/null +++ b/src/jennifer/adapter/shared/types.cr @@ -0,0 +1,13 @@ +# Stubs for all adapters. Is added here to allow making general DBAny alias for +# all adapters +module PG + struct Numeric + end + + module Geo + {% for type in %w(Point Line Circle LineSegment Box Path Polygon) %} + struct {{type.id}} + end + {% end %} + end +end diff --git a/src/jennifer/migration/table_builder/create_table.cr b/src/jennifer/migration/table_builder/create_table.cr index 7c6661aa..9f7dadda 100644 --- a/src/jennifer/migration/table_builder/create_table.cr +++ b/src/jennifer/migration/table_builder/create_table.cr @@ -7,7 +7,7 @@ module Jennifer @indexes.each(&.process) end - {% for method in Jennifer::Adapter::TYPE_TRANSLATIONS.keys %} + {% for method in Jennifer::Adapter::TYPES %} def {{method.id}}(name, options = DB_OPTIONS.new) defaults = sym_hash({:type => {{method}}}, AAllowedTypes) @fields[name.to_s] = defaults.merge(options) From 975714413b38e17b158fce249878f222c2fee750 Mon Sep 17 00:00:00 2001 From: Roman Kalnytskyi Date: Mon, 13 Nov 2017 01:21:19 +0200 Subject: [PATCH 03/19] Proxy all migration related methods via MigrationProcessor --- spec/adapter/mysql_spec.cr | 7 +- spec/adapter/postgres_spec.cr | 26 +-- spec/config.cr | 3 +- src/jennifer.cr | 12 +- src/jennifer/adapter/base.cr | 29 ++- src/jennifer/adapter/migration_processor.cr | 98 +++++++++ src/jennifer/adapter/mysql.cr | 13 +- src/jennifer/adapter/mysql/result_set.cr | 33 ++- src/jennifer/adapter/mysql/sql_generator.cr | 82 ++++---- src/jennifer/adapter/postgres.cr | 194 ++++++++++-------- src/jennifer/adapter/postgres/exec_result.cr | 14 +- .../adapter/postgres/migration/base.cr | 29 --- .../migration/table_builder/change_enum.cr | 33 ++- .../migration/table_builder/create_enum.cr | 3 +- .../migration/table_builder/drop_enum.cr | 2 +- .../adapter/postgres/migration_processor.cr | 29 +++ .../adapter/postgres/sql_generator.cr | 194 +++++++++--------- src/jennifer/adapter/shared/result_set.cr | 2 + src/jennifer/adapter/sqlite3.cr | 4 - src/jennifer/migration/base.cr | 87 +------- .../migration/table_builder/create_index.cr | 14 +- 21 files changed, 487 insertions(+), 421 deletions(-) create mode 100644 src/jennifer/adapter/migration_processor.cr delete mode 100644 src/jennifer/adapter/postgres/migration/base.cr create mode 100644 src/jennifer/adapter/postgres/migration_processor.cr diff --git a/spec/adapter/mysql_spec.cr b/spec/adapter/mysql_spec.cr index 22ba2609..2210cf52 100644 --- a/spec/adapter/mysql_spec.cr +++ b/spec/adapter/mysql_spec.cr @@ -1,8 +1,9 @@ require "../spec_helper" + mysql_only do - describe Jennifer::Adapter::Mysql do - described_class = Jennifer::Adapter::Mysql - adapter = Jennifer::Adapter.adapter.as(Jennifer::Adapter::Mysql) + describe Jennifer::Mysql::Adapter do + described_class = Jennifer::Mysql::Adapter + adapter = Jennifer::Adapter.adapter.as(Jennifer::Mysql::Adapter) describe "#index_exists?" do it "returns true if table has index with given name" do diff --git a/spec/adapter/postgres_spec.cr b/spec/adapter/postgres_spec.cr index 7f9c77af..102f28ab 100644 --- a/spec/adapter/postgres_spec.cr +++ b/spec/adapter/postgres_spec.cr @@ -1,9 +1,9 @@ require "../spec_helper" postgres_only do - describe Jennifer::Adapter::Postgres do - described_class = Jennifer::Adapter::Postgres - adapter = Jennifer::Adapter.adapter.as(Jennifer::Adapter::Postgres) + describe Jennifer::Postgres::Adapter do + described_class = Jennifer::Postgres::Adapter + adapter = Jennifer::Adapter.adapter.as(Jennifer::Postgres::Adapter) describe "#translate_type" do it "returns sql type associated with given synonim" do @@ -23,7 +23,7 @@ postgres_only do end end - describe "index manipulation" do + context "index manipulation" do age_index_options = { :type => nil, :fields => [:age], @@ -32,7 +32,7 @@ postgres_only do } index_name = "contacts_age_index" - context "#index_exists?" do + describe "#index_exists?" do it "returns true if exists index with given name" do adapter.index_exists?("", "contacts_description_index").should be_true end @@ -42,20 +42,16 @@ postgres_only do end end - context "#add_index" do + describe "#add_index" do it "should add a covering index if no type is specified" do - delete_index_if_exists(adapter, index_name) - - adapter.add_index("contacts", index_name, age_index_options) + adapter.add_index("contacts", index_name, [:age]) adapter.index_exists?("", index_name).should be_true end end - context "#drop_index" do + describe "#drop_index" do it "should drop an index if it exists" do - delete_index_if_exists(adapter, index_name) - - adapter.add_index("contacts", index_name, age_index_options) + adapter.add_index("contacts", index_name, [:age]) adapter.index_exists?("", index_name).should be_true adapter.drop_index("", index_name) @@ -160,7 +156,3 @@ postgres_only do end end end - -def delete_index_if_exists(adapter, index) - adapter.drop_index("", index) if adapter.index_exists?("", index) -end diff --git a/spec/config.cr b/spec/config.cr index a6286865..28a04332 100644 --- a/spec/config.cr +++ b/spec/config.cr @@ -48,6 +48,8 @@ module Spec end end +require "../src/jennifer" + {% if env("DB") == "mysql" %} require "../src/jennifer/adapter/mysql" Spec.adapter = "mysql" @@ -58,7 +60,6 @@ end require "../src/jennifer/adapter/postgres" Spec.adapter = "postgres" {% end %} -require "../src/jennifer" def set_default_configuration Jennifer::Config.reset_config diff --git a/src/jennifer.cr b/src/jennifer.cr index 32ec2002..9db8a7b2 100644 --- a/src/jennifer.cr +++ b/src/jennifer.cr @@ -22,10 +22,20 @@ require "./jennifer/model/*" require "./jennifer/view/base" -require "./jennifer/migration/table_builder/*" require "./jennifer/migration/*" module Jennifer + alias Query = QueryBuilder::Query + {% if Jennifer.constant("AFTER_LOAD_SCRIPT") == nil %} + AFTER_LOAD_SCRIPT = [] of String + {% end %} + + macro after_load_hook + {% for script in AFTER_LOAD_SCRIPT %} + {{script.id}} + {% end %} + end + class StubRelation < ::Jennifer::Relation::IRelation def insert(a, b) raise "stubed relation" diff --git a/src/jennifer/adapter/base.cr b/src/jennifer/adapter/base.cr index dd1df8b9..93dbd8a5 100644 --- a/src/jennifer/adapter/base.cr +++ b/src/jennifer/adapter/base.cr @@ -1,4 +1,5 @@ require "db" +require "ifrit" require "./shared/*" require "./transactions" require "./result_parsers" @@ -223,25 +224,43 @@ module Jennifer exec "ALTER TABLE #{old_name.to_s} RENAME #{new_name.to_s}" end - def add_index(table : String | Symbol, name : String | Symbol, options) + def add_index(table : String | Symbol, name : String | Symbol, fields : Array, type : Symbol? = nil, order : Hash? = nil, length : Hash? = nil) query = String.build do |s| s << "CREATE " - s << index_type_translate(options[:type]) if options[:type]? + s << index_type_translate(type) if type s << "INDEX " << name << " ON " << table << "(" - fields = options.as(Hash)[:fields].as(Array) fields.each_with_index do |f, i| s << "," if i != 0 s << f - s << "(" << options[:length].as(Hash)[f] << ")" if options[:length]? && options[:length].as(Hash)[f]? - s << " " << options[:order].as(Hash)[f].to_s.upcase if options[:order]? && options[:order].as(Hash)[f]? + s << "(" << length[f] << ")" if length && length[f]? + s << " " << order[f].to_s.upcase if order && order[f]? end s << ")" end exec query end + # def add_index(table, name, options : Hash(Symbol, Symbol | Array(Symbol) | Hash(Symbol, Symbol) | Hash(Symbol, Int32) | Nil)) + # query = String.build do |s| + # s << "CREATE " + + # s << index_type_translate(options[:type]) if options[:type]? + + # s << "INDEX " << name << " ON " << table << "(" + # fields = options.as(Hash)[:fields].as(Array) + # fields.each_with_index do |f, i| + # s << "," if i != 0 + # s << f + # s << "(" << options[:length].as(Hash)[f] << ")" if options[:length]? && options[:length].as(Hash)[f]? + # s << " " << options[:order].as(Hash)[f].to_s.upcase if options[:order]? && options[:order].as(Hash)[f]? + # end + # s << ")" + # end + # exec query + # end + def drop_index(table : String | Symbol, name : String | Symbol) exec "DROP INDEX #{name} ON #{table}" end diff --git a/src/jennifer/adapter/migration_processor.cr b/src/jennifer/adapter/migration_processor.cr new file mode 100644 index 00000000..b5e8502e --- /dev/null +++ b/src/jennifer/adapter/migration_processor.cr @@ -0,0 +1,98 @@ +require "../migration/table_builder/*" + +module Jennifer + module Adapter + class MigrationProcessor + macro unsupported_methods(*names) + {% for name in names %} + def {{name.id}}(*args, **opts) + raise BaseException.new("Current adapter doesn't support this method: #{{{name.id}}}") + end + {% end %} + end + + getter adapter : Adapter::Base + + def initialize(@adapter) + end + + def create_table(name, id = true) + tb = Migration::TableBuilder::CreateTable.new(name) + tb.integer(:id, {:primary => true, :auto_increment => true}) if id + yield tb + tb.process + end + + # Creates join table; raises table builder to given block + def create_join_table(table1, table2, table_name : String? = nil) + 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) + yield tb + end + end + + # Creates join table. + def create_join_table(table1, table2, table_name : String? = nil) + create_join_table(table1, table2, table_name) { } + end + + def drop_join_table(table1, table2) + drop_table(@adapter.class.join_table_name(table1, table2)) + end + + def exec(string) + Migration::TableBuilder::Raw.new(string).process + end + + def drop_table(name) + Migration::TableBuilder::DropTable.new(name).process + end + + def change_table(name) + tb = Migration::TableBuilder::ChangeTable.new(name) + yield tb + tb.process + end + + def create_view(name, source) + Migration::TableBuilder::CreateView.new(name.to_s, source).process + end + + def drop_view(name) + Migration::TableBuilder::DropView.new(name.to_s).process + end + + def add_index(table_name, name : String, fields : Array(Symbol), type : Symbol, lengths : Hash(Symbol, Int32) = {} of Symbol => Int32, orders : Hash(Symbol, Symbol) = {} of Symbol => Symbol) + Migration::TableBuilder::CreateIndex.new(table_name, name, fields, type, lengths, orders).process + end + + def add_index(table_name, name : String, field : Symbol, type : Symbol, length : Int32? = nil, order : Symbol? = nil) + add_index( + table_name, + name, + [field], + type: type, + orders: (order ? {field => order.not_nil!} : {} of Symbol => Symbol), + lengths: (length ? {field => length.not_nil!} : {} of Symbol => Int32) + ) + end + + def drop_index(table_name, name) + Migration::TableBuilder::DropIndex.new(table_name, name).process + end + + unsupported_methods create_enum, drop_enum, change_enum, create_materialized_view, drop_materialized_view + + private def adapter_class + @adapter.class + end + end + + class Base + def migration_processor + @migration_processor ||= MigrationProcessor.new(self) + end + end + end +end diff --git a/src/jennifer/adapter/mysql.cr b/src/jennifer/adapter/mysql.cr index 44fccb42..05a39652 100644 --- a/src/jennifer/adapter/mysql.cr +++ b/src/jennifer/adapter/mysql.cr @@ -1,14 +1,10 @@ require "mysql" require "./base" - -class Jennifer::Adapter::Mysql < Jennifer::Adapter::Base -end - require "./mysql/sql_generator" module Jennifer - module Adapter - class Mysql < Base + module Mysql + class Adapter < Adapter::Base alias EnumType = String TYPE_TRANSLATIONS = { @@ -137,12 +133,9 @@ module Jennifer end end end - - macro after_load_hook - end end require "./mysql/result_set" require "./mysql/type" -::Jennifer::Adapter.register_adapter("mysql", ::Jennifer::Adapter::Mysql) +::Jennifer::Adapter.register_adapter("mysql", ::Jennifer::Mysql::Adapter) diff --git a/src/jennifer/adapter/mysql/result_set.cr b/src/jennifer/adapter/mysql/result_set.cr index 2d8ddf71..b407432c 100644 --- a/src/jennifer/adapter/mysql/result_set.cr +++ b/src/jennifer/adapter/mysql/result_set.cr @@ -1,18 +1,29 @@ -class DB::ResultSet - getter column_index +module MySql + class ResultSet + getter column_index, columns - @column_index = 0 - @columns = [] of MySql::ColumnSpec + @column_index = 0 - def current_column - @columns[@column_index] - end + def current_column + @columns[@column_index] + end - def current_column_name - column_name(@column_index) + def current_column_name + column_name(@column_index) + end end - def columns - @columns + class TextResultSet + getter column_index, columns + + @column_index = 0 + + def current_column + @columns[@column_index] + end + + def current_column_name + column_name(@column_index) + end end end diff --git a/src/jennifer/adapter/mysql/sql_generator.cr b/src/jennifer/adapter/mysql/sql_generator.cr index 7cf079e0..d88f56d7 100644 --- a/src/jennifer/adapter/mysql/sql_generator.cr +++ b/src/jennifer/adapter/mysql/sql_generator.cr @@ -1,56 +1,54 @@ require "../base_sql_generator" module Jennifer - module Adapter - class Mysql - class SQLGenerator < BaseSQLGenerator - def self.insert(obj : Model::Base) - opts = obj.arguments_to_insert - String.build do |s| - s << "INSERT INTO " << obj.class.table_name - unless opts[:fields].empty? - s << "(" - opts[:fields].join(", ", s) - s << ") VALUES (" << Adapter.adapter_class.escape_string(opts[:fields].size) << ") " - else - s << " VALUES ()" - end + module Mysql + class SQLGenerator < Adapter::BaseSQLGenerator + def self.insert(obj : Model::Base) + opts = obj.arguments_to_insert + String.build do |s| + s << "INSERT INTO " << obj.class.table_name + unless opts[:fields].empty? + s << "(" + opts[:fields].join(", ", s) + s << ") VALUES (" << Jennifer::Adapter.adapter_class.escape_string(opts[:fields].size) << ") " + else + s << " VALUES ()" end end + end - # Generates update request depending on given query and hash options. Allows - # joins inside of query. - def self.update(query, options : Hash) - esc = Adapter.adapter_class.escape_string(1) - String.build do |s| - s << "UPDATE " << query.table - s << "\n" - _joins = query._joins + # Generates update request depending on given query and hash options. Allows + # joins inside of query. + def self.update(query, options : Hash) + esc = Jennifer::Adapter.adapter_class.escape_string(1) + String.build do |s| + s << "UPDATE " << query.table + s << "\n" + _joins = query._joins - unless _joins.nil? - where_clause(s, _joins[0].on) - _joins[1..-1].join(" ", s) { |e| s << e.as_sql } - end - s << " SET " - options.join(", ", s) { |(k, v)| s << k << " = " << esc } - s << " " - where_clause(s, query.tree) + unless _joins.nil? + where_clause(s, _joins[0].on) + _joins[1..-1].join(" ", s) { |e| s << e.as_sql } end + s << " SET " + options.join(", ", s) { |(k, v)| s << k << " = " << esc } + s << " " + where_clause(s, query.tree) end + end - def self.json_path(path : QueryBuilder::JSONSelector) - value = - if path.path.is_a?(Number) - quote("$[#{path.path.to_s}]") - else - quote(path.path) - end - "#{path.identifier}->#{value}" - end + def self.json_path(path : QueryBuilder::JSONSelector) + value = + if path.path.is_a?(Number) + quote("$[#{path.path.to_s}]") + else + quote(path.path) + end + "#{path.identifier}->#{value}" + end - def self.quote(value : String) - "\"#{value.gsub(/\\/, "\&\&").gsub(/"/, "\"\"")}\"" - end + def self.quote(value : String) + "\"#{value.gsub(/\\/, "\&\&").gsub(/"/, "\"\"")}\"" end end end diff --git a/src/jennifer/adapter/postgres.cr b/src/jennifer/adapter/postgres.cr index 3d5f9f6e..8983ca4f 100644 --- a/src/jennifer/adapter/postgres.cr +++ b/src/jennifer/adapter/postgres.cr @@ -1,79 +1,86 @@ require "pg" require "../adapter" +require "./base" -class Jennifer::Adapter::Postgres < Jennifer::Adapter::Base -end +require "./postgres/result_set" +require "./postgres/field" +require "./postgres/exec_result" require "./postgres/sql_generator" +require "./postgres/migration_processor" module Jennifer - module Adapter - alias EnumType = Bytes - - TYPE_TRANSLATIONS = { - :integer => "int", # Int32 - :short => "SMALLINT", # Int16 - :bigint => "BIGINT", # Int64 - :oid => "oid", # UInt32 - - :float => "real", # Float32 - :double => "double precision", # Float64 - - :numeric => "numeric", # PG::Numeric - :decimal => "decimal", # PG::Numeric - is alias for numeric - - :string => "varchar", - :char => "char", - :bool => "boolean", - :text => "text", - :var_string => "varchar", - :varchar => "varchar", - :blchar => "blchar", # String - - :uuid => "uuid", # String - - :timestamp => "timestamp", - :timestamptz => "timestamptz", # Time - :date_time => "datetime", - - :blob => "blob", - :bytea => "bytea", - - :json => "json", # JSON - :jsonb => "jsonb", # JSON - :xml => "xml", # String - - :point => "point", - :lseg => "lseg", - :path => "path", - :box => "box", - :polygon => "polygon", - :line => "line", - :circle => "circle", - } - - DEFAULT_SIZES = { - :string => 254, - :var_string => 254, - } - - TABLE_LOCK_TYPES = { - "as" => "ACCESS SHARE", - "rs" => "ROW SHARE", - "re" => "ROW EXCLUSIVE", - "sue" => "SHARE UPDATE EXCLUSIVE", - "s" => "SHARE", - "sre" => "SHARE ROW EXCLUSIVE", - "e" => "EXCLUSIVE", - "ae" => "ACCESS EXCLUSIVE", - "default" => "SHARE", # "s" - } - - class Postgres < Base + module Postgres + class Adapter < Adapter::Base + alias EnumType = Bytes + + TYPE_TRANSLATIONS = { + :integer => "int", # Int32 + :short => "SMALLINT", # Int16 + :bigint => "BIGINT", # Int64 + :oid => "oid", # UInt32 + + :float => "real", # Float32 + :double => "double precision", # Float64 + + :numeric => "numeric", # PG::Numeric + :decimal => "decimal", # PG::Numeric - is alias for numeric + + :string => "varchar", + :char => "char", + :bool => "boolean", + :text => "text", + :var_string => "varchar", + :varchar => "varchar", + :blchar => "blchar", # String + + :uuid => "uuid", # String + + :timestamp => "timestamp", + :timestamptz => "timestamptz", # Time + :date_time => "datetime", + + :blob => "blob", + :bytea => "bytea", + + :json => "json", # JSON + :jsonb => "jsonb", # JSON + :xml => "xml", # String + + :point => "point", + :lseg => "lseg", + :path => "path", + :box => "box", + :polygon => "polygon", + :line => "line", + :circle => "circle", + } + + DEFAULT_SIZES = { + :string => 254, + :var_string => 254, + } + + TABLE_LOCK_TYPES = { + "as" => "ACCESS SHARE", + "rs" => "ROW SHARE", + "re" => "ROW EXCLUSIVE", + "sue" => "SHARE UPDATE EXCLUSIVE", + "s" => "SHARE", + "sre" => "SHARE ROW EXCLUSIVE", + "e" => "EXCLUSIVE", + "ae" => "ACCESS EXCLUSIVE", + "default" => "SHARE", # "s" + } + def sql_generator SQLGenerator end + def migration_processor + @migration_processor ||= MigrationProcessor.new(self) + end + def prepare _query = <<-SQL SELECT e.enumtypid @@ -90,19 +97,17 @@ module Jennifer end def translate_type(name) - Adapter::TYPE_TRANSLATIONS[name] + TYPE_TRANSLATIONS[name] rescue e : KeyError raise BaseException.new("Unknown data alias #{name}") end def default_type_size(name) - Adapter::DEFAULT_SIZES[name]? + DEFAULT_SIZES[name]? end def refresh_materialized_view(name) - exec <<-SQL - REFRESH MATERIALIZED VIEW #{name} - SQL + exec "REFRESH MATERIALIZED VIEW #{name}" end def table_column_count(table) @@ -176,9 +181,7 @@ module Jennifer # TODO: sanitize query def define_enum(name, values) - exec <<-SQL - CREATE TYPE #{name} AS ENUM(#{values.as(Array).map { |e| "'#{e}'" }.join(", ")}) - SQL + exec "CREATE TYPE #{name} AS ENUM(#{values.as(Array).map { |e| "'#{e}'" }.join(", ")})" end def drop_enum(name) @@ -191,19 +194,20 @@ module Jennifer # =========== overrides - def add_index(table, name, options) + def add_index(table, name, fields : Array, type : Symbol? = nil, order : Hash? = nil, length : Hash? = nil) query = String.build do |s| s << "CREATE " - s << index_type_translate(options[:type]) if options[:type]? + s << index_type_translate(type) if type + s << "INDEX " << name << " ON " << table # TODO: add using option to migration # s << " USING " << options[:using] if options.has_key?(:using) s << " (" - options[:fields].as(Array).each_with_index do |f, i| + fields.each_with_index do |f, i| s << "," if i != 0 s << f - s << " " << options[:order].as(Hash)[f].to_s.upcase if options[:order]? && options[:order].as(Hash)[f]? + s << " " << order[f].to_s.upcase if order && order[f]? end s << ")" # TODO: add partial support to migration @@ -212,6 +216,27 @@ module Jennifer exec query end + # def add_index(table, name, options : Hash(Symbol, Array(Symbol) | Hash(Symbol, Symbol) | Nil)) + # query = String.build do |s| + # s << "CREATE " + + # s << index_type_translate(options[:type]) if options[:type]? + # s << "INDEX " << name << " ON " << table + # # TODO: add using option to migration + # # s << " USING " << options[:using] if options.has_key?(:using) + # s << " (" + # options[:fields].as(Array).each_with_index do |f, i| + # s << "," if i != 0 + # s << f + # s << " " << options[:order].as(Hash)[f].to_s.upcase if options[:order]? && options[:order].as(Hash)[f]? + # end + # s << ")" + # # TODO: add partial support to migration + # # s << " " << options[:partial] if options.has_key?(:partial) + # end + # exec query + # end + def change_column(table, old_name, new_name, opts) column_name_part = " ALTER COLUMN #{old_name} " query = String.build do |s| @@ -353,17 +378,10 @@ module Jennifer end end end - - macro after_load_hook - require "./jennifer/adapter/postgres/criteria" - require "./jennifer/adapter/postgres/numeric" - require "./jennifer/adapter/postgres/migration/base" - require "./jennifer/adapter/postgres/migration/table_builder/*" - end end -require "./postgres/result_set" -require "./postgres/field" -require "./postgres/exec_result" +require "./postgres/criteria" +require "./postgres/numeric" +require "./postgres/migration/table_builder/*" -::Jennifer::Adapter.register_adapter("postgres", ::Jennifer::Adapter::Postgres) +::Jennifer::Adapter.register_adapter("postgres", ::Jennifer::Postgres::Adapter) diff --git a/src/jennifer/adapter/postgres/exec_result.cr b/src/jennifer/adapter/postgres/exec_result.cr index 203dcde5..cbe4d0eb 100644 --- a/src/jennifer/adapter/postgres/exec_result.cr +++ b/src/jennifer/adapter/postgres/exec_result.cr @@ -1,14 +1,12 @@ module Jennifer - module Adapter - class Postgres - struct ExecResult - getter last_insert_id : Int64, rows_affected = 0i64 + module Postgres + struct ExecResult + getter last_insert_id : Int64, rows_affected = 0i64 - def initialize(@last_insert_id) - end + def initialize(@last_insert_id) + end - def initialize(@last_insert_id, @rows_affected) - end + def initialize(@last_insert_id, @rows_affected) end end end diff --git a/src/jennifer/adapter/postgres/migration/base.cr b/src/jennifer/adapter/postgres/migration/base.cr deleted file mode 100644 index bc45f781..00000000 --- a/src/jennifer/adapter/postgres/migration/base.cr +++ /dev/null @@ -1,29 +0,0 @@ -module Jennifer - module Migration - abstract class Base - def create_enum(name : String | Symbol, values) - TableBuilder::CreateEnum.new(name, values).process - end - - def drop_enum(name : String | Symbol) - TableBuilder::DropEnum.new(name).process - end - - def change_enum(name : String | Symbol, options) - TableBuilder::ChangeEnum.new(name, options).process - end - - def data_type_exists?(name : String | Symbol) - Adapter.adapter.as(Postgres).data_type_exists?(name) - end - - def create_materialized_view(name : String | Symbol, source) - TableBuilder::CreateMaterializedView.new(name, source).process - end - - def drop_materialized_view(name : String | Symbol) - TableBuilder::DropMaterializedView.new(name).process - end - end - end -end diff --git a/src/jennifer/adapter/postgres/migration/table_builder/change_enum.cr b/src/jennifer/adapter/postgres/migration/table_builder/change_enum.cr index b84e9de0..7a971a7f 100644 --- a/src/jennifer/adapter/postgres/migration/table_builder/change_enum.cr +++ b/src/jennifer/adapter/postgres/migration/table_builder/change_enum.cr @@ -2,9 +2,12 @@ module Jennifer module Migration module TableBuilder class ChangeEnum < Base + @effected_tables : Array(Array(DBAny)) + def initialize(name, @options : Hash(Symbol, Array(String))) super(name) - @adapter = Adapter.adapter.as(Adapter::Postgres) + @adapter = Adapter.adapter.as(Postgres::Adapter) + @effected_tables = _effected_tables end def process @@ -18,10 +21,21 @@ module Jennifer new_values = [] of String @adapter.enum_values(@name).map { |e| new_values << e[0] } new_values -= @options[:remove_values] - if effected_tables.empty? - recreate_enum(new_values) + if @effected_tables.empty? + @adapter.drop_enum(@name) + @adapter.define_enum(@name, new_values) else - change_enum_with_related_tables(effected_tables, new_values) + temp_name = "#{@name}_temp" + @adapter.define_enum(temp_name, new_values) + @effected_tables.each do |row| + @adapter.exec <<-SQL + ALTER TABLE #{row[0]} + ALTER COLUMN #{row[1]} TYPE #{temp_name} + USING (#{row[1]}::text::#{temp_name}) + SQL + @adapter.drop_enum(@name) + rename(temp_name, @name) + end end end @@ -68,12 +82,11 @@ module Jennifer end end - private def effected_tables - @effected_tables ||= - Query["information_schema.columns"] - .select("table_name, column_name") - .where { (c("udt_name") == @name.dup) & (c("table_catalog") == Config.db) } - .pluck(:table_name, :column_name) + private def _effected_tables + Query["information_schema.columns"] + .select("table_name, column_name") + .where { (c("udt_name") == @name.dup) & (c("table_catalog") == Config.db) } + .pluck(:table_name, :column_name) end end end diff --git a/src/jennifer/adapter/postgres/migration/table_builder/create_enum.cr b/src/jennifer/adapter/postgres/migration/table_builder/create_enum.cr index fd700285..b7507228 100644 --- a/src/jennifer/adapter/postgres/migration/table_builder/create_enum.cr +++ b/src/jennifer/adapter/postgres/migration/table_builder/create_enum.cr @@ -4,11 +4,10 @@ module Jennifer class CreateEnum < Base def initialize(name, @values : Array(String)) super(name) - @adapter = Adapter.adapter.as(Adapter::Postgres) end def process - @adapter.define_enum(@name, @values) + Adapter.adapter.define_enum(@name, @values) end end end diff --git a/src/jennifer/adapter/postgres/migration/table_builder/drop_enum.cr b/src/jennifer/adapter/postgres/migration/table_builder/drop_enum.cr index e93933a6..ba862290 100644 --- a/src/jennifer/adapter/postgres/migration/table_builder/drop_enum.cr +++ b/src/jennifer/adapter/postgres/migration/table_builder/drop_enum.cr @@ -4,7 +4,7 @@ module Jennifer class DropEnum < Base def initialize(name) super(name) - @adapter = Adapter.adapter.as(Adapter::Postgres) + @adapter = Adapter.adapter.as(Postgres::Adapter) end def process diff --git a/src/jennifer/adapter/postgres/migration_processor.cr b/src/jennifer/adapter/postgres/migration_processor.cr new file mode 100644 index 00000000..290acb86 --- /dev/null +++ b/src/jennifer/adapter/postgres/migration_processor.cr @@ -0,0 +1,29 @@ +require "../migration_processor" + +module Jennifer + module Postgres + class MigrationProcessor < Adapter::MigrationProcessor + delegate data_type_exists?, to: adapter.as(Postgres) + + def create_enum(name : String | Symbol, values) + Migration::TableBuilder::CreateEnum.new(name, values).process + end + + def drop_enum(name : String | Symbol) + Migration::TableBuilder::DropEnum.new(name).process + end + + def change_enum(name : String | Symbol, options) + Migration::TableBuilder::ChangeEnum.new(name, options).process + end + + def create_materialized_view(name : String | Symbol, _as) + Migration::TableBuilder::CreateMaterializedView.new(name, _as).process + end + + def drop_materialized_view(name : String | Symbol) + Migration::TableBuilder::DropMaterializedView.new(name).process + end + end + end +end diff --git a/src/jennifer/adapter/postgres/sql_generator.cr b/src/jennifer/adapter/postgres/sql_generator.cr index d0df18b8..42c04837 100644 --- a/src/jennifer/adapter/postgres/sql_generator.cr +++ b/src/jennifer/adapter/postgres/sql_generator.cr @@ -1,124 +1,120 @@ require "../base_sql_generator" module Jennifer - module Adapter - class Postgres - class SQLGenerator < BaseSQLGenerator - def self.insert(obj : Model::Base, with_primary_field = true) - opts = obj.arguments_to_insert - String.build do |s| - s << "INSERT INTO " << obj.class.table_name - unless opts[:fields].empty? - s << "(" - opts[:fields].join(", ", s) - s << ") VALUES (" << Adapter.adapter_class.escape_string(opts[:fields].size) << ") " - else - s << " DEFAULT VALUES" - end - - # TODO: uncomment after pg driver will raise error if inserting brakes smth - # if with_primary_field - # s << " RETURNING " << obj.class.primary_field_name - # end + module Postgres + class SQLGenerator < Adapter::BaseSQLGenerator + def self.insert(obj : Model::Base, with_primary_field = true) + opts = obj.arguments_to_insert + String.build do |s| + s << "INSERT INTO " << obj.class.table_name + unless opts[:fields].empty? + s << "(" + opts[:fields].join(", ", s) + s << ") VALUES (" << Jennifer::Adapter.adapter_class.escape_string(opts[:fields].size) << ") " + else + s << " DEFAULT VALUES" end - end - # Generates update request depending on given query and hash options. Allows - # joins inside of query. - def self.update(query, options : Hash) - esc = Adapter.adapter_class.escape_string(1) - String.build do |s| - s << "UPDATE " << query._table << " SET " - options.map { |k, v| "#{k.to_s}= #{esc}" }.join(", ", s) - s << "\n" - - from_clause(s, query, query._joins![0].table_name) if query._joins - where_clause(s, query.tree) - if query._joins - where_clause(s, query._joins![0].on) - query._joins![1..-1].join(" ", s) { |e| s << e.as_sql } - end - end + # TODO: uncomment after pg driver will raise error if inserting brakes smth + # if with_primary_field + # s << " RETURNING " << obj.class.primary_field_name + # end end + end - # =================== utils + # Generates update request depending on given query and hash options. Allows + # joins inside of query. + def self.update(query, options : Hash) + esc = Jennifer::Adapter.adapter_class.escape_string(1) + String.build do |s| + s << "UPDATE " << query._table << " SET " + options.map { |k, v| "#{k.to_s}= #{esc}" }.join(", ", s) + s << "\n" - def self.operator_to_sql(operator) - case operator - when :like - "LIKE" - when :not_like - "NOT LIKE" - when :regexp - "~" - when :not_regexp - "!~" - when :== - "=" - when :is - "IS" - when :is_not - "IS NOT" - when :contain - "@>" - when :contained - "<@" - when :overlap - "&&" - when :ilike - "ILIKE" - else - operator.to_s + from_clause(s, query, query._joins![0].table_name) if query._joins + where_clause(s, query.tree) + if query._joins + where_clause(s, query._joins![0].on) + query._joins![1..-1].join(" ", s) { |e| s << e.as_sql } end end + end + + # =================== utils - def self.json_path(path : QueryBuilder::JSONSelector) - operator = - case path.type - when :path - "#>" - when :take - "->" - else - raise ArgumentError.new("Wrong json path type") - end - "#{path.identifier}#{operator}#{quote(path.path)}" + def self.operator_to_sql(operator) + case operator + when :like + "LIKE" + when :not_like + "NOT LIKE" + when :regexp + "~" + when :not_regexp + "!~" + when :== + "=" + when :is + "IS" + when :is_not + "IS NOT" + when :contain + "@>" + when :contained + "<@" + when :overlap + "&&" + else + operator.to_s end + end - # for postgres column name - def self.escape(value : String) - case value - when "NULL", "TRUE", "FALSE" - value + def self.json_path(path : QueryBuilder::JSONSelector) + operator = + case path.type + when :path + "#>" + when :take + "->" else - value = value.gsub(/\\/, ARRAY_ESCAPE).gsub(/"/, "\\\"") - "\"#{value}\"" + raise "Wrong json path type" end - end + "#{path.identifier}#{operator}#{quote(path.path)}" + end - def self.escape(value : Nil) - quote(value) + # for postgres column name + def self.escape(value : String) + case value + when "NULL", "TRUE", "FALSE" + value + else + value = value.gsub(/\\/, ARRAY_ESCAPE).gsub(/"/, "\\\"") + "\"#{value}\"" end + end - def self.escape(value : Bool) - quote(value) - end + def self.escape(value : Nil) + quote(value) + end - def self.escape(value : Int32 | Int16 | Float64 | Float32) - quote(value) - end + def self.escape(value : Bool) + quote(value) + end - def self.quote(value : String) - "'#{value.gsub(/\\/, "\&\&").gsub(/'/, "''")}'" - end + def self.escape(value : Int32 | Int16 | Float64 | Float32) + quote(value) + end - def self.parse_query(query, arg_count) - arr = [] of String - arg_count.times do |i| - arr << "$#{i + 1}" - end - query % arr + def self.quote(value : String) + "'#{value.gsub(/\\/, "\&\&").gsub(/'/, "''")}'" + end + + def self.parse_query(query, arg_count) + arr = [] of String + arg_count.times do |i| + arr << "$#{i + 1}" end + query % arr end end end diff --git a/src/jennifer/adapter/shared/result_set.cr b/src/jennifer/adapter/shared/result_set.cr index cfa07e1b..586f2440 100644 --- a/src/jennifer/adapter/shared/result_set.cr +++ b/src/jennifer/adapter/shared/result_set.cr @@ -4,4 +4,6 @@ abstract class DB::ResultSet read end end + + abstract def current_column_name end diff --git a/src/jennifer/adapter/sqlite3.cr b/src/jennifer/adapter/sqlite3.cr index 0f81e210..b2265555 100644 --- a/src/jennifer/adapter/sqlite3.cr +++ b/src/jennifer/adapter/sqlite3.cr @@ -115,10 +115,6 @@ module Jennifer end end end - - macro after_load_hook - - end end require "./sqlite3/result_set" diff --git a/src/jennifer/migration/base.cr b/src/jennifer/migration/base.cr index 58455896..19cbcadd 100644 --- a/src/jennifer/migration/base.cr +++ b/src/jennifer/migration/base.cr @@ -3,6 +3,12 @@ module Jennifer abstract class Base delegate create_data_type, to: Adapter.adapter delegate table_exists?, index_exists?, column_exists?, view_exists?, to: Adapter.adapter + delegate migration_processor, to: Adapter.adapter + + delegate create_table, create_join_table, drop_join_table, exec, drop_table, + change_table, create_view, create_materialized_view, drop_materialized_view, + drop_view, add_index, create_enum, drop_enum, change_enum, + to: migration_processor abstract def up abstract def down @@ -11,85 +17,6 @@ module Jennifer to_s[-17..-1] end - def create_table(name, id : Bool = true) - tb = TableBuilder::CreateTable.new(name) - tb.integer(:id, {:primary => true, :auto_increment => true}) if id - yield tb - tb.process - end - - # Creates join table; raises table builder to given block - def create_join_table(table1, table2, table_name : String? = nil) - create_table(table_name || Adapter.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) - yield tb - end - end - - # Creates join table. - def create_join_table(table1, table2, table_name : String? = nil) - create_join_table(table1, table2, table_name) { } - end - - def drop_join_table(table1, table2) - drop_table(Adapter.adapter_class.join_table_name(table1, table2)) - end - - def exec(string) - TableBuilder::Raw.new(string).process - end - - def drop_table(name) - TableBuilder::DropTable.new(name).process - end - - def change_table(name : String | Symbol) - tb = TableBuilder::ChangeTable.new(name) - yield tb - tb.process - end - - def create_view(name : String | Symbol, source) - TableBuilder::CreateView.new(name.to_s, source).process - end - - def drop_view(name : String | Symbol) - TableBuilder::DropView.new(name.to_s).process - end - - def add_index(table_name, name : String, fields : Array(Symbol), type : Symbol, lengths : Hash(Symbol, Int32) = {} of Symbol => Int32, orders : Hash(Symbol, Symbol) = {} of Symbol => Symbol) - TableBuilder::CreateIndex.new(table_name, name, fields, type, lengths, orders).process - end - - def add_index(table_name, name : String, field : Symbol, type : Symbol, length : Int32? = nil, order : Symbol? = nil) - add_index( - table_name, - name, - [field], - type: type, - orders: (order ? {field => order.not_nil!} : {} of Symbol => Symbol), - lengths: (length ? {field => length.not_nil!} : {} of Symbol => Int32) - ) - end - - def drop_index(table_name, name) - TableBuilder::DropIndex.new(table_name, name).process - self - end - - def create_enum(name, options) - raise BaseException.new("Current adapter doesn't support this method.") - end - - def drop_enum(name) - raise BaseException.new("Current adapter doesn't support this method.") - end - - def change_enum(name, options) - raise BaseException.new("Current adapter doesn't support this method.") - end - def self.versions migrations.map { |e| e.underscore.split("_").last } end @@ -110,3 +37,5 @@ module Jennifer end end end + +require "../adapter/migration_processor" diff --git a/src/jennifer/migration/table_builder/create_index.cr b/src/jennifer/migration/table_builder/create_index.cr index 465f1ad7..cefe71ce 100644 --- a/src/jennifer/migration/table_builder/create_index.cr +++ b/src/jennifer/migration/table_builder/create_index.cr @@ -2,23 +2,15 @@ module Jennifer module Migration module TableBuilder class CreateIndex < Base - getter index_name : String, _fields : Array(Symbol), type : Symbol?, lengths : Hash(Symbol, Int32), orders : Hash(Symbol, Symbol) + getter index_name : String, _fields : Array(Symbol), type : Symbol?, + lengths : Hash(Symbol, Int32), orders : Hash(Symbol, Symbol) def initialize(table_name, @index_name, @_fields, @type, @lengths, @orders) super(table_name) end def process - Adapter.adapter.add_index( - @name, - @index_name, - { - :type => @type, - :fields => _fields, - :length => @lengths, - :order => orders, - } - ) + Adapter.adapter.add_index(@name, @index_name, _fields, @type, orders, @lengths) end end end From 02b23449f91a9fdea058e56c9e168b21055f1b09 Mon Sep 17 00:00:00 2001 From: Roman Kalnytskyi Date: Mon, 13 Nov 2017 01:58:59 +0200 Subject: [PATCH 04/19] Isolate table builder classes from global adapter --- src/jennifer/adapter/base.cr | 2 +- src/jennifer/adapter/migration_processor.cr | 22 +-- src/jennifer/adapter/postgres.cr | 1 + .../postgres/migration/table_builder/base.cr | 13 ++ .../migration/table_builder/change_enum.cr | 132 ++++++++---------- .../migration/table_builder/create_enum.cr | 18 +-- .../table_builder/create_materialized_view.cr | 45 +++--- .../migration/table_builder/drop_enum.cr | 19 +-- .../table_builder/drop_materialized_view.cr | 18 +-- .../adapter/postgres/migration_processor.cr | 10 +- src/jennifer/migration/base.cr | 14 +- src/jennifer/migration/table_builder/base.cr | 7 +- .../migration/table_builder/change_table.cr | 14 +- .../migration/table_builder/create_index.cr | 6 +- .../migration/table_builder/create_table.cr | 2 +- .../migration/table_builder/create_view.cr | 6 +- .../migration/table_builder/drop_index.cr | 6 +- .../migration/table_builder/drop_table.cr | 2 +- .../migration/table_builder/drop_view.cr | 2 +- src/jennifer/migration/table_builder/raw.cr | 6 +- 20 files changed, 177 insertions(+), 168 deletions(-) create mode 100644 src/jennifer/adapter/postgres/migration/table_builder/base.cr diff --git a/src/jennifer/adapter/base.cr b/src/jennifer/adapter/base.cr index 93dbd8a5..84143b38 100644 --- a/src/jennifer/adapter/base.cr +++ b/src/jennifer/adapter/base.cr @@ -214,7 +214,7 @@ module Jennifer def ready_to_migrate! return if table_exists?(Migration::Version.table_name) - tb = Migration::TableBuilder::CreateTable.new(Migration::Version.table_name) + tb = Migration::TableBuilder::CreateTable.new(self, Migration::Base.table_name) tb.integer(:id, {:primary => true, :auto_increment => true}) .string(:version, {:size => 17}) create_table(tb) diff --git a/src/jennifer/adapter/migration_processor.cr b/src/jennifer/adapter/migration_processor.cr index b5e8502e..76c2ac83 100644 --- a/src/jennifer/adapter/migration_processor.cr +++ b/src/jennifer/adapter/migration_processor.cr @@ -3,7 +3,7 @@ require "../migration/table_builder/*" module Jennifer module Adapter class MigrationProcessor - macro unsupported_methods(*names) + macro unsupported_method(*names) {% for name in names %} def {{name.id}}(*args, **opts) raise BaseException.new("Current adapter doesn't support this method: #{{{name.id}}}") @@ -11,13 +11,15 @@ module Jennifer {% end %} end + unsupported_method create_enum, drop_enum, change_enum, create_materialized_view, drop_materialized_view + getter adapter : Adapter::Base def initialize(@adapter) end def create_table(name, id = true) - tb = Migration::TableBuilder::CreateTable.new(name) + tb = Migration::TableBuilder::CreateTable.new(@adapter, name) tb.integer(:id, {:primary => true, :auto_increment => true}) if id yield tb tb.process @@ -42,29 +44,29 @@ module Jennifer end def exec(string) - Migration::TableBuilder::Raw.new(string).process + Migration::TableBuilder::Raw.new(@adapter, string).process end def drop_table(name) - Migration::TableBuilder::DropTable.new(name).process + Migration::TableBuilder::DropTable.new(@adapter, name).process end def change_table(name) - tb = Migration::TableBuilder::ChangeTable.new(name) + tb = Migration::TableBuilder::ChangeTable.new(@adapter, name) yield tb tb.process end def create_view(name, source) - Migration::TableBuilder::CreateView.new(name.to_s, source).process + Migration::TableBuilder::CreateView.new(@adapter, name.to_s, source).process end def drop_view(name) - Migration::TableBuilder::DropView.new(name.to_s).process + Migration::TableBuilder::DropView.new(@adapter, name.to_s).process end def add_index(table_name, name : String, fields : Array(Symbol), type : Symbol, lengths : Hash(Symbol, Int32) = {} of Symbol => Int32, orders : Hash(Symbol, Symbol) = {} of Symbol => Symbol) - Migration::TableBuilder::CreateIndex.new(table_name, name, fields, type, lengths, orders).process + Migration::TableBuilder::CreateIndex.new(@adapter, table_name, name, fields, type, lengths, orders).process end def add_index(table_name, name : String, field : Symbol, type : Symbol, length : Int32? = nil, order : Symbol? = nil) @@ -79,11 +81,9 @@ module Jennifer end def drop_index(table_name, name) - Migration::TableBuilder::DropIndex.new(table_name, name).process + Migration::TableBuilder::DropIndex.new(@adapter, table_name, name).process end - unsupported_methods create_enum, drop_enum, change_enum, create_materialized_view, drop_materialized_view - private def adapter_class @adapter.class end diff --git a/src/jennifer/adapter/postgres.cr b/src/jennifer/adapter/postgres.cr index 8983ca4f..1dbe8d72 100644 --- a/src/jennifer/adapter/postgres.cr +++ b/src/jennifer/adapter/postgres.cr @@ -382,6 +382,7 @@ end require "./postgres/criteria" require "./postgres/numeric" +require "./postgres/migration/table_builder/base" require "./postgres/migration/table_builder/*" ::Jennifer::Adapter.register_adapter("postgres", ::Jennifer::Postgres::Adapter) diff --git a/src/jennifer/adapter/postgres/migration/table_builder/base.cr b/src/jennifer/adapter/postgres/migration/table_builder/base.cr new file mode 100644 index 00000000..5c698701 --- /dev/null +++ b/src/jennifer/adapter/postgres/migration/table_builder/base.cr @@ -0,0 +1,13 @@ +module Jennifer + module Postgres + module Migration + module TableBuilder + abstract class Base < Jennifer::Migration::TableBuilder::Base + def adapter + @adapter.as(Postgres::Adapter) + end + end + end + end + end +end diff --git a/src/jennifer/adapter/postgres/migration/table_builder/change_enum.cr b/src/jennifer/adapter/postgres/migration/table_builder/change_enum.cr index 7a971a7f..0c0b7306 100644 --- a/src/jennifer/adapter/postgres/migration/table_builder/change_enum.cr +++ b/src/jennifer/adapter/postgres/migration/table_builder/change_enum.cr @@ -1,92 +1,74 @@ module Jennifer - module Migration - module TableBuilder - class ChangeEnum < Base - @effected_tables : Array(Array(DBAny)) + module Postgres + module Migration + module TableBuilder + class ChangeEnum < Base + @effected_tables : Array(Array(DBAny)) - def initialize(name, @options : Hash(Symbol, Array(String))) - super(name) - @adapter = Adapter.adapter.as(Postgres::Adapter) - @effected_tables = _effected_tables - end + def initialize(adapter, name, @options : Hash(Symbol, Array(String))) + super(adapter, name) + @effected_tables = _effected_tables + end - def process - remove_values if @options.has_key?(:remove_values) - add_values if @options.has_key?(:add_values) - rename_values if @options.has_key?(:rename_values) - rename(name, @options[:new_name]) if @options.has_key?(:rename) - end + def process + remove_values if @options.has_key?(:remove_values) + add_values if @options.has_key?(:add_values) + rename_values if @options.has_key?(:rename_values) + rename(name, @options[:new_name]) if @options.has_key?(:rename) + end - def remove_values - new_values = [] of String - @adapter.enum_values(@name).map { |e| new_values << e[0] } - new_values -= @options[:remove_values] - if @effected_tables.empty? - @adapter.drop_enum(@name) - @adapter.define_enum(@name, new_values) - else - temp_name = "#{@name}_temp" - @adapter.define_enum(temp_name, new_values) - @effected_tables.each do |row| - @adapter.exec <<-SQL - ALTER TABLE #{row[0]} - ALTER COLUMN #{row[1]} TYPE #{temp_name} - USING (#{row[1]}::text::#{temp_name}) - SQL + def remove_values + new_values = [] of String + @adapter.enum_values(@name).map { |e| new_values << e[0] } + new_values -= @options[:remove_values] + if @effected_tables.empty? @adapter.drop_enum(@name) - rename(temp_name, @name) + @adapter.define_enum(@name, new_values) + else + temp_name = "#{@name}_temp" + @adapter.define_enum(temp_name, new_values) + @effected_tables.each do |row| + @adapter.exec <<-SQL + ALTER TABLE #{row[0]} + ALTER COLUMN #{row[1]} TYPE #{temp_name} + USING (#{row[1]}::text::#{temp_name}) + SQL + @adapter.drop_enum(@name) + rename(temp_name, @name) + end end end - end - - def rename(old_name, new_name) - @adapter.exec "ALTER TYPE #{old_name} RENAME TO #{new_name}" - end - def add_values - typed_array_cast(@options[:add_values].as(Array), String).each do |field| - @adapter.exec "ALTER TYPE #{@name} ADD VALUE '#{field}'" + def add_values + typed_array_cast(@options[:add_values].as(Array), String).each do |field| + @adapter.exec "ALTER TYPE #{@name} ADD VALUE '#{field}'" + end end - end - def rename_values - name = @name - i = 0 - count = @options[:rename_values].as(Array).size - while i < count - old_name = @options[:rename_values][i] - new_name = @options[:rename_values][i + 1] - i += 2 - Query["pg_enum"].where do - (c("enumlabel") == old_name) & (c("enumtypid") == sql("SELECT OID FROM pg_type WHERE typname = '#{name}'")) - end.update({:enumlabel => new_name}) + def rename_values + name = @name + i = 0 + count = @options[:rename_values].as(Array).size + while i < count + old_name = @options[:rename_values][i] + new_name = @options[:rename_values][i + 1] + i += 2 + Query["pg_enum"].where do + (c("enumlabel") == old_name) & (c("enumtypid") == sql("SELECT OID FROM pg_type WHERE typname = '#{name}'")) + end.update({:enumlabel => new_name}) + end end - end - private def recreate_enum(values) - @adapter.drop_enum(@name) - @adapter.define_enum(@name, values) - end - - private def change_enum_with_related_tables(effected_tables, values) - temp_name = "#{@name}_temp" - @adapter.define_enum(temp_name, values) - effected_tables.each do |row| - @adapter.exec <<-SQL - ALTER TABLE #{row[0]} - ALTER COLUMN #{row[1]} TYPE #{temp_name} - USING (#{row[1]}::text::#{temp_name}) - SQL - @adapter.drop_enum(@name) - rename(temp_name, @name) + def rename(old_name, new_name) + @adapter.exec "ALTER TYPE #{old_name} RENAME TO #{new_name}" end - end - private def _effected_tables - Query["information_schema.columns"] - .select("table_name, column_name") - .where { (c("udt_name") == @name.dup) & (c("table_catalog") == Config.db) } - .pluck(:table_name, :column_name) + private def _effected_tables + Query["information_schema.columns"] + .select("table_name, column_name") + .where { (c("udt_name") == @name.dup) & (c("table_catalog") == Config.db) } + .pluck(:table_name, :column_name) + end end end end diff --git a/src/jennifer/adapter/postgres/migration/table_builder/create_enum.cr b/src/jennifer/adapter/postgres/migration/table_builder/create_enum.cr index b7507228..ad11af5b 100644 --- a/src/jennifer/adapter/postgres/migration/table_builder/create_enum.cr +++ b/src/jennifer/adapter/postgres/migration/table_builder/create_enum.cr @@ -1,13 +1,15 @@ module Jennifer - module Migration - module TableBuilder - class CreateEnum < Base - def initialize(name, @values : Array(String)) - super(name) - end + module Postgres + module Migration + module TableBuilder + class CreateEnum < Base + def initialize(adapter, name, @values : Array(String)) + super(adapter, name) + end - def process - Adapter.adapter.define_enum(@name, @values) + def process + adapter.define_enum(@name, @values) + end end end end diff --git a/src/jennifer/adapter/postgres/migration/table_builder/create_materialized_view.cr b/src/jennifer/adapter/postgres/migration/table_builder/create_materialized_view.cr index d39e47c2..ba16202d 100644 --- a/src/jennifer/adapter/postgres/migration/table_builder/create_materialized_view.cr +++ b/src/jennifer/adapter/postgres/migration/table_builder/create_materialized_view.cr @@ -1,30 +1,31 @@ # NOTE: WIP module Jennifer - module Migration - module TableBuilder - class CreateMaterializedView < Base - @query : QueryBuilder::Query | String + module Postgres + module Migration + module TableBuilder + class CreateMaterializedView < Base + @query : QueryBuilder::Query | String - def initialize(name, @query) - super(name) - end + def initialize(name, @query) + super(name) + end - def process - buff = generate_query - adapter.exec buff - end + def process + buff = generate_query + adapter.exec buff + end - private def generate_query - if @query.is_a?(String) - puts "String was used for describing source request of materialized view. Use QueryBuilder::Query instead" - @query.as(String) - else - String.build do |s| - s << - "CREATE MATERIALIZED VIEW " << - @name << - " AS " << - Adapter::SqlGenerator.select(@query.as(QueryBuilder::Query)) + private def generate_query + if @query.is_a?(String) + puts "String was used for describing source request of materialized view. Use QueryBuilder::Query instead" + @query.as(String) + else + String.build do |s| + s << + "CREATE MATERIALIZED VIEW " << + @name << + " AS " << + Adapter::SqlGenerator.select(@query.as(QueryBuilder::Query)) end end end diff --git a/src/jennifer/adapter/postgres/migration/table_builder/drop_enum.cr b/src/jennifer/adapter/postgres/migration/table_builder/drop_enum.cr index ba862290..0495c00f 100644 --- a/src/jennifer/adapter/postgres/migration/table_builder/drop_enum.cr +++ b/src/jennifer/adapter/postgres/migration/table_builder/drop_enum.cr @@ -1,14 +1,15 @@ module Jennifer - module Migration - module TableBuilder - class DropEnum < Base - def initialize(name) - super(name) - @adapter = Adapter.adapter.as(Postgres::Adapter) - end + module Postgres + module Migration + module TableBuilder + class DropEnum < Jennifer::Migration::TableBuilder::Base + def initialize(adapter, name) + super(adapter, name) + end - def process - @adapter.drop_enum(@name) + def process + adapter.drop_enum(@name) + end end end end diff --git a/src/jennifer/adapter/postgres/migration/table_builder/drop_materialized_view.cr b/src/jennifer/adapter/postgres/migration/table_builder/drop_materialized_view.cr index c5dfdf3f..2f32fd2b 100644 --- a/src/jennifer/adapter/postgres/migration/table_builder/drop_materialized_view.cr +++ b/src/jennifer/adapter/postgres/migration/table_builder/drop_materialized_view.cr @@ -1,13 +1,15 @@ module Jennifer - module Migration - module TableBuilder - class DropMaterializedView < Base - def initialize(name) - super(name) - end + module Postgres + module Migration + module TableBuilder + class DropMaterializedView < Jennifer::Migration::TableBuilder::Base + def initialize(adapter, name) + super(adapter, name) + end - def process - adapter.exec "DROP MATERIALIZED VIEW #{name}" + def process + adapter.exec "DROP MATERIALIZED VIEW #{name}" + end end end end diff --git a/src/jennifer/adapter/postgres/migration_processor.cr b/src/jennifer/adapter/postgres/migration_processor.cr index 290acb86..2f8b23f4 100644 --- a/src/jennifer/adapter/postgres/migration_processor.cr +++ b/src/jennifer/adapter/postgres/migration_processor.cr @@ -6,23 +6,23 @@ module Jennifer delegate data_type_exists?, to: adapter.as(Postgres) def create_enum(name : String | Symbol, values) - Migration::TableBuilder::CreateEnum.new(name, values).process + Migration::TableBuilder::CreateEnum.new(@adapter, name, values).process end def drop_enum(name : String | Symbol) - Migration::TableBuilder::DropEnum.new(name).process + Migration::TableBuilder::DropEnum.new(@adapter, name).process end def change_enum(name : String | Symbol, options) - Migration::TableBuilder::ChangeEnum.new(name, options).process + Migration::TableBuilder::ChangeEnum.new(@adapter, name, options).process end def create_materialized_view(name : String | Symbol, _as) - Migration::TableBuilder::CreateMaterializedView.new(name, _as).process + Migration::TableBuilder::CreateMaterializedView.new(@adapter, name, _as).process end def drop_materialized_view(name : String | Symbol) - Migration::TableBuilder::DropMaterializedView.new(name).process + Migration::TableBuilder::DropMaterializedView.new(@adapter, name).process end end end diff --git a/src/jennifer/migration/base.cr b/src/jennifer/migration/base.cr index 19cbcadd..979ee9fd 100644 --- a/src/jennifer/migration/base.cr +++ b/src/jennifer/migration/base.cr @@ -1,15 +1,23 @@ module Jennifer module Migration abstract class Base - delegate create_data_type, to: Adapter.adapter - delegate table_exists?, index_exists?, column_exists?, view_exists?, to: Adapter.adapter - delegate migration_processor, to: Adapter.adapter + TABLE_NAME = "migration_versions" + + delegate adapter, to: Adapter + + delegate create_data_type, to: adapter + delegate table_exists?, index_exists?, column_exists?, view_exists?, to: adapter + delegate migration_processor, to: adapter delegate create_table, create_join_table, drop_join_table, exec, drop_table, change_table, create_view, create_materialized_view, drop_materialized_view, drop_view, add_index, create_enum, drop_enum, change_enum, to: migration_processor + def adapter_class + adapter.class + end + abstract def up abstract def down diff --git a/src/jennifer/migration/table_builder/base.cr b/src/jennifer/migration/table_builder/base.cr index 8972815e..077eb6c0 100644 --- a/src/jennifer/migration/table_builder/base.cr +++ b/src/jennifer/migration/table_builder/base.cr @@ -9,14 +9,13 @@ module Jennifer extend Ifrit - delegate table_exists?, index_exists?, column_exists?, to: Adapter.adapter - delegate adapter, to: Adapter + delegate table_exists?, index_exists?, column_exists?, to: adapter - getter fields + getter fields, adapter : Adapter::Base @name : String | Symbol - def initialize(@name) + def initialize(@adapter, @name) @fields = {} of String => DB_OPTIONS @indexes = [] of CreateIndex end diff --git a/src/jennifer/migration/table_builder/change_table.cr b/src/jennifer/migration/table_builder/change_table.cr index df9c890c..2cc0c9fe 100644 --- a/src/jennifer/migration/table_builder/change_table.cr +++ b/src/jennifer/migration/table_builder/change_table.cr @@ -4,7 +4,7 @@ module Jennifer class ChangeTable < Base getter changed_columns, drop_columns, drop_index, new_table_rename - def initialize(name) + def initialize(adapter, name) super @changed_columns = {} of String => DB_OPTIONS @drop_columns = [] of String @@ -45,7 +45,7 @@ module Jennifer # add_index("index_name", [:field1, :field2], { :length => { :field1 => 2, :field2 => 3 }, :order => { :field1 => :asc }}) # add_index("index_name", [:field1], { :length => { :field1 => 2, :field2 => 3 }, :order => { :field1 => :asc }}) def add_index(name : String, fields : Array(Symbol), type : Symbol? = nil, lengths : Hash(Symbol, Int32) = {} of Symbol => Int32, orders : Hash(Symbol, Symbol) = {} of Symbol => Symbol) - @indexes << CreateIndex.new(@name, name, fields, type, lengths, orders) + @indexes << CreateIndex.new(@adapter, @name, name, fields, type, lengths, orders) self end @@ -60,20 +60,20 @@ module Jennifer end def drop_index(name) - @drop_index << DropIndex.new(@name, name.to_s) + @drop_index << DropIndex.new(@adapter, @name, name.to_s) self end def process - @drop_columns.each { |c| Adapter.adapter.drop_column(@name, c) } - @fields.each { |n, opts| Adapter.adapter.add_column(@name, n, opts) } + @drop_columns.each { |c| adapter.drop_column(@name, c) } + @fields.each { |n, opts| adapter.add_column(@name, n, opts) } @changed_columns.each do |n, opts| - Adapter.adapter.change_column(@name, n, opts[:new_name].as(String | Symbol), opts) + adapter.change_column(@name, n, opts[:new_name].as(String | Symbol), opts) end @indexes.each(&.process) @drop_index.each(&.process) - Adapter.adapter.rename_table(@name, @new_table_name) unless @new_table_name.empty? + adapter.rename_table(@name, @new_table_name) unless @new_table_name.empty? end end end diff --git a/src/jennifer/migration/table_builder/create_index.cr b/src/jennifer/migration/table_builder/create_index.cr index cefe71ce..176898f0 100644 --- a/src/jennifer/migration/table_builder/create_index.cr +++ b/src/jennifer/migration/table_builder/create_index.cr @@ -5,12 +5,12 @@ module Jennifer getter index_name : String, _fields : Array(Symbol), type : Symbol?, lengths : Hash(Symbol, Int32), orders : Hash(Symbol, Symbol) - def initialize(table_name, @index_name, @_fields, @type, @lengths, @orders) - super(table_name) + def initialize(adapter, table_name, @index_name, @_fields, @type, @lengths, @orders) + super(adapter, table_name) end def process - Adapter.adapter.add_index(@name, @index_name, _fields, @type, orders, @lengths) + adapter.add_index(@name, @index_name, _fields, @type, orders, @lengths) end end end diff --git a/src/jennifer/migration/table_builder/create_table.cr b/src/jennifer/migration/table_builder/create_table.cr index 9f7dadda..24b264d8 100644 --- a/src/jennifer/migration/table_builder/create_table.cr +++ b/src/jennifer/migration/table_builder/create_table.cr @@ -3,7 +3,7 @@ module Jennifer module TableBuilder class CreateTable < Base def process - Adapter.adapter.create_table(self) + adapter.create_table(self) @indexes.each(&.process) end diff --git a/src/jennifer/migration/table_builder/create_view.cr b/src/jennifer/migration/table_builder/create_view.cr index f074ed44..299ce82f 100644 --- a/src/jennifer/migration/table_builder/create_view.cr +++ b/src/jennifer/migration/table_builder/create_view.cr @@ -4,14 +4,14 @@ module Jennifer class CreateView < Base @query : QueryBuilder::Query - def initialize(name, @query) - initialize(name) + def initialize(adapter, name, @query) + initialize(adapter, name) end # TODO: move query generating to SqlGenerator class and make # table builder classes to call executions by themselves def process - Adapter.adapter.create_view(@name, @query) + adapter.create_view(@name, @query) end end end diff --git a/src/jennifer/migration/table_builder/drop_index.cr b/src/jennifer/migration/table_builder/drop_index.cr index f7278f50..6b506f3f 100644 --- a/src/jennifer/migration/table_builder/drop_index.cr +++ b/src/jennifer/migration/table_builder/drop_index.cr @@ -2,12 +2,12 @@ module Jennifer module Migration module TableBuilder class DropIndex < Base - def initialize(name, @index_name : String) - super(name) + def initialize(adapter, name, @index_name : String) + super(adapter, name) end def process - Adapter.adapter.drop_index(@name, @index_name) + adapter.drop_index(@name, @index_name) end end end diff --git a/src/jennifer/migration/table_builder/drop_table.cr b/src/jennifer/migration/table_builder/drop_table.cr index 02c92bcd..b36bfd5d 100644 --- a/src/jennifer/migration/table_builder/drop_table.cr +++ b/src/jennifer/migration/table_builder/drop_table.cr @@ -3,7 +3,7 @@ module Jennifer module TableBuilder class DropTable < Base def process - Adapter.adapter.drop_table(self) + adapter.drop_table(self) end end end diff --git a/src/jennifer/migration/table_builder/drop_view.cr b/src/jennifer/migration/table_builder/drop_view.cr index a059936c..4b3b2096 100644 --- a/src/jennifer/migration/table_builder/drop_view.cr +++ b/src/jennifer/migration/table_builder/drop_view.cr @@ -3,7 +3,7 @@ module Jennifer module TableBuilder class DropView < Base def process - Adapter.adapter.drop_view(@name) + adapter.drop_view(@name) end end end diff --git a/src/jennifer/migration/table_builder/raw.cr b/src/jennifer/migration/table_builder/raw.cr index 88578be1..8fac4d7b 100644 --- a/src/jennifer/migration/table_builder/raw.cr +++ b/src/jennifer/migration/table_builder/raw.cr @@ -4,13 +4,13 @@ module Jennifer class Raw < Base getter raw_sql - def initialize(query : String) - super("") + def initialize(adapter, query : String) + super(adapter, "") @raw_sql = query end def process - Adapter.adapter.exec(@raw_sql) + adapter.exec(@raw_sql) end end end From c9183497895681dc859741dc1fb679bb900514f5 Mon Sep 17 00:00:00 2001 From: Roman Kalnytskyi Date: Mon, 13 Nov 2017 17:32:50 +0200 Subject: [PATCH 05/19] Move all schema modificating logic to MigrationProcessor --- spec/adapter/mysql/.keep | 0 .../postgres/migration_processor_spec.cr | 32 ++++ spec/adapter/postgres_spec.cr | 22 --- src/jennifer/adapter/base.cr | 161 +--------------- src/jennifer/adapter/migration_processor.cr | 180 ++++++++++++++++-- src/jennifer/adapter/postgres.cr | 131 ------------- .../postgres/migration/table_builder/base.cr | 4 + .../migration/table_builder/change_enum.cr | 14 +- .../migration/table_builder/create_enum.cr | 2 +- .../migration/table_builder/drop_enum.cr | 2 +- .../adapter/postgres/migration_processor.cr | 128 ++++++++++++- src/jennifer/adapter/sqlite3.cr | 52 ++--- .../adapter/sqlite3/migration_processor.cr | 44 +++++ src/jennifer/adapter/sqlite3/result_set.cr | 4 +- src/jennifer/adapter/sqlite3/sql_generator.cr | 8 +- src/jennifer/migration/base.cr | 16 +- src/jennifer/migration/table_builder/base.cr | 2 +- .../migration/table_builder/change_table.cr | 8 +- .../migration/table_builder/create_index.cr | 2 +- .../migration/table_builder/create_table.cr | 2 +- .../migration/table_builder/create_view.cr | 2 +- .../migration/table_builder/drop_index.cr | 2 +- .../migration/table_builder/drop_table.cr | 2 +- .../migration/table_builder/drop_view.cr | 2 +- 24 files changed, 422 insertions(+), 400 deletions(-) create mode 100644 spec/adapter/mysql/.keep create mode 100644 spec/adapter/postgres/migration_processor_spec.cr create mode 100644 src/jennifer/adapter/sqlite3/migration_processor.cr diff --git a/spec/adapter/mysql/.keep b/spec/adapter/mysql/.keep new file mode 100644 index 00000000..e69de29b diff --git a/spec/adapter/postgres/migration_processor_spec.cr b/spec/adapter/postgres/migration_processor_spec.cr new file mode 100644 index 00000000..8e89ef2c --- /dev/null +++ b/spec/adapter/postgres/migration_processor_spec.cr @@ -0,0 +1,32 @@ +require "../../spec_helper" + +postgres_only do + describe Jennifer::Postgres::MigrationProcessor do + adapter = Jennifer::Adapter.adapter + processor = adapter.migration_processor + + context "index manipulation" do + index_name = "contacts_age_index" + + describe "#add_index" do + it "should add a covering index if no type is specified" do + processor.add_index("contacts", index_name, [:age]) + adapter.index_exists?("", index_name).should be_true + end + end + + describe "#drop_index" do + it "should drop an index if it exists" do + processor.add_index("contacts", index_name, [:age]) + adapter.index_exists?("", index_name).should be_true + + processor.drop_index("", index_name) + adapter.index_exists?("", index_name).should be_false + end + end + end + + describe "#change_column" do + end + end +end diff --git a/spec/adapter/postgres_spec.cr b/spec/adapter/postgres_spec.cr index 102f28ab..15926b3d 100644 --- a/spec/adapter/postgres_spec.cr +++ b/spec/adapter/postgres_spec.cr @@ -41,28 +41,6 @@ postgres_only do adapter.index_exists?("", "contacts_description_index_test").should be_false end end - - describe "#add_index" do - it "should add a covering index if no type is specified" do - adapter.add_index("contacts", index_name, [:age]) - adapter.index_exists?("", index_name).should be_true - end - end - - describe "#drop_index" do - it "should drop an index if it exists" do - adapter.add_index("contacts", index_name, [:age]) - adapter.index_exists?("", index_name).should be_true - - adapter.drop_index("", index_name) - adapter.index_exists?("", index_name).should be_false - end - end - end - - describe "#change_column" do - pending "add" do - end end # Now those methods are tested by another cases diff --git a/src/jennifer/adapter/base.cr b/src/jennifer/adapter/base.cr index 84143b38..1ebf5909 100644 --- a/src/jennifer/adapter/base.cr +++ b/src/jennifer/adapter/base.cr @@ -213,124 +213,10 @@ module Jennifer # migration ======================== def ready_to_migrate! - return if table_exists?(Migration::Version.table_name) - tb = Migration::TableBuilder::CreateTable.new(self, Migration::Base.table_name) - tb.integer(:id, {:primary => true, :auto_increment => true}) - .string(:version, {:size => 17}) - create_table(tb) - end - - def rename_table(old_name : String | Symbol, new_name : String | Symbol) - exec "ALTER TABLE #{old_name.to_s} RENAME #{new_name.to_s}" - end - - def add_index(table : String | Symbol, name : String | Symbol, fields : Array, type : Symbol? = nil, order : Hash? = nil, length : Hash? = nil) - query = String.build do |s| - s << "CREATE " - - s << index_type_translate(type) if type - - s << "INDEX " << name << " ON " << table << "(" - fields.each_with_index do |f, i| - s << "," if i != 0 - s << f - s << "(" << length[f] << ")" if length && length[f]? - s << " " << order[f].to_s.upcase if order && order[f]? - end - s << ")" - end - exec query - end - - # def add_index(table, name, options : Hash(Symbol, Symbol | Array(Symbol) | Hash(Symbol, Symbol) | Hash(Symbol, Int32) | Nil)) - # query = String.build do |s| - # s << "CREATE " - - # s << index_type_translate(options[:type]) if options[:type]? - - # s << "INDEX " << name << " ON " << table << "(" - # fields = options.as(Hash)[:fields].as(Array) - # fields.each_with_index do |f, i| - # s << "," if i != 0 - # s << f - # s << "(" << options[:length].as(Hash)[f] << ")" if options[:length]? && options[:length].as(Hash)[f]? - # s << " " << options[:order].as(Hash)[f].to_s.upcase if options[:order]? && options[:order].as(Hash)[f]? - # end - # s << ")" - # end - # exec query - # end - - def drop_index(table : String | Symbol, name : String | Symbol) - exec "DROP INDEX #{name} ON #{table}" - end - - def drop_column(table : String | Symbol, name : String | Symbol) - exec "ALTER TABLE #{table} DROP COLUMN #{name}" - end - - def add_column(table : String | Symbol, name : String | Symbol, opts) - query = String.build do |s| - s << "ALTER TABLE " << table << " ADD COLUMN " - column_definition(name, opts, s) - end - - exec query - end - - def change_column(table : String | Symbol, old_name : String | Symbol, new_name : String | Symbol, opts) - query = String.build do |s| - s << "ALTER TABLE " << table << " CHANGE COLUMN " << old_name << " " - column_definition(new_name, opts, s) - end - - exec query - end - - def drop_table(builder : Migration::TableBuilder::DropTable) - exec "DROP TABLE #{builder.name}" - end - - def create_table(builder : Migration::TableBuilder::CreateTable) - buffer = String.build do |s| - s << "CREATE TABLE " << builder.name << " (" - builder.fields.each_with_index do |(name, options), i| - s << ", " if i != 0 - column_definition(name, options, s) - end - s << ")" - end - exec buffer - end - - def create_enum(name : String | Symbol, options) - raise BaseException.new("Current adapter doesn't support this method.") - end - - def drop_enum(name : String | Symbol, options) - raise BaseException.new("Current adapter doesn't support this method.") - end - - def change_enum(name : String | Symbol, options) - raise BaseException.new("Current adapter doesn't support this method.") - end - - def create_view(name : String | Symbol, query, silent : Bool = true) - buff = String.build do |s| - s << "CREATE " - s << "OR REPLACE " if silent - s << "VIEW " << name << " AS " << sql_generator.select(query) - end - exec buff - end - - def drop_view(name : String | Symbol, silent : Bool = true) - buff = String.build do |s| - s << "DROP VIEW " - s << "IF EXISTS " if silent - s << name + return if table_exists?(Migration::Base::TABLE_NAME) + migration_processor.build_create_table(Migration::Base::TABLE_NAME) do |t| + t.string(:version, {:size => 17}) end - exec buff end def query_array(_query : String, klass : T.class, field_count : Int32 = 1) forall T @@ -365,47 +251,6 @@ module Jennifer end # private =========================== - # NOTE: adding here type will bring a lot of small issues around - - private def index_type_translate(name) - case name - when :unique, :uniq - "UNIQUE " - when :fulltext - "FULLTEXT " - when :spatial - "SPATIAL " - when nil - " " - else - raise ArgumentError.new("Unknown index type: #{name}") - end - end - - private def column_definition(name, options, io) - type = options[:serial]? ? "serial" : (options[:sql_type]? || translate_type(options[:type].as(Symbol))) - size = options[:size]? || default_type_size(options[:type]) - io << name << " " << type - io << "(#{size})" if size - if options[:type] == :enum - io << " (" - options[:values].as(Array).each_with_index do |e, i| - io << ", " if i != 0 - io << "'#{e.as(String | Symbol)}'" - end - io << ") " - end - if options.has_key?(:null) - if options[:null] - io << " NULL" - else - io << " NOT NULL" - end - end - io << " PRIMARY KEY" if options[:primary]? - io << " DEFAULT #{self.class.t(options[:default])}" if options[:default]? - io << " AUTO_INCREMENT" if options[:auto_increment]? - end private def regular_query_message(time : Time::Span, query : String, args : Array) ms = time.nanoseconds / 1000 diff --git a/src/jennifer/adapter/migration_processor.cr b/src/jennifer/adapter/migration_processor.cr index 76c2ac83..8d3ad1ed 100644 --- a/src/jennifer/adapter/migration_processor.cr +++ b/src/jennifer/adapter/migration_processor.cr @@ -11,14 +11,19 @@ module Jennifer {% end %} end - unsupported_method create_enum, drop_enum, change_enum, create_materialized_view, drop_materialized_view + unsupported_method build_create_enum, build_drop_enum, build_change_enum, build_create_materialized_view, + build_drop_materialized_view, drop_enum getter adapter : Adapter::Base def initialize(@adapter) end - def create_table(name, id = true) + # ================ + # Builder methods + # ================ + + def build_create_table(name, id = true) tb = Migration::TableBuilder::CreateTable.new(@adapter, name) tb.integer(:id, {:primary => true, :auto_increment => true}) if id yield tb @@ -26,8 +31,8 @@ module Jennifer end # Creates join table; raises table builder to given block - def create_join_table(table1, table2, table_name : String? = nil) - create_table(table_name || adapter_class.join_table_name(table1, table2), false) do |tb| + 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) yield tb @@ -35,42 +40,42 @@ module Jennifer end # Creates join table. - def create_join_table(table1, table2, table_name : String? = nil) - create_join_table(table1, table2, table_name) { } + def build_create_join_table(table1, table2, table_name : String? = nil) + build_create_join_table(table1, table2, table_name) { } end - def drop_join_table(table1, table2) - drop_table(@adapter.class.join_table_name(table1, table2)) + def build_drop_join_table(table1, table2) + build_drop_table(@adapter.class.join_table_name(table1, table2)) end - def exec(string) + def build_exec(string) Migration::TableBuilder::Raw.new(@adapter, string).process end - def drop_table(name) + def build_drop_table(name) Migration::TableBuilder::DropTable.new(@adapter, name).process end - def change_table(name) + def build_change_table(name) tb = Migration::TableBuilder::ChangeTable.new(@adapter, name) yield tb tb.process end - def create_view(name, source) + def build_create_view(name, source) Migration::TableBuilder::CreateView.new(@adapter, name.to_s, source).process end - def drop_view(name) + def build_drop_view(name) Migration::TableBuilder::DropView.new(@adapter, name.to_s).process end - def add_index(table_name, name : String, fields : Array(Symbol), type : Symbol, lengths : Hash(Symbol, Int32) = {} of Symbol => Int32, orders : Hash(Symbol, Symbol) = {} of Symbol => Symbol) + def build_add_index(table_name, name : String, fields : Array(Symbol), type : Symbol, lengths : Hash(Symbol, Int32) = {} of Symbol => Int32, orders : Hash(Symbol, Symbol) = {} of Symbol => Symbol) Migration::TableBuilder::CreateIndex.new(@adapter, table_name, name, fields, type, lengths, orders).process end - def add_index(table_name, name : String, field : Symbol, type : Symbol, length : Int32? = nil, order : Symbol? = nil) - add_index( + def build_add_index(table_name, name : String, field : Symbol, type : Symbol, length : Int32? = nil, order : Symbol? = nil) + build_add_index( table_name, name, [field], @@ -80,13 +85,154 @@ module Jennifer ) end - def drop_index(table_name, name) + def build_drop_index(table_name, name) Migration::TableBuilder::DropIndex.new(@adapter, table_name, name).process end + # ============================ + # Schema manipulating methods + # ============================ + + def rename_table(old_name, new_name) + adapter.exec "ALTER TABLE #{old_name.to_s} RENAME #{new_name.to_s}" + end + + def add_index(table, name, fields : Array, type : Symbol? = nil, order : Hash? = nil, length : Hash? = nil) + query = String.build do |s| + s << "CREATE " + + s << index_type_translate(type) if type + + s << "INDEX " << name << " ON " << table << "(" + fields.each_with_index do |f, i| + s << "," if i != 0 + s << f + s << "(" << length[f] << ")" if length && length[f]? + s << " " << order[f].to_s.upcase if order && order[f]? + end + s << ")" + end + adapter.exec query + end + + def drop_index(table, name) + adapter.exec "DROP INDEX #{name} ON #{table}" + end + + def drop_column(table, name) + adapter.exec "ALTER TABLE #{table} DROP COLUMN #{name}" + end + + def add_column(table, name, opts) + query = String.build do |s| + s << "ALTER TABLE " << table << " ADD COLUMN " + column_definition(name, opts, s) + end + + adapter.exec query + end + + def change_column(table, old_name, new_name, opts) + query = String.build do |s| + s << "ALTER TABLE " << table << " CHANGE COLUMN " << old_name << " " + column_definition(new_name, opts, s) + end + + adapter.exec query + end + + def drop_table(builder : Migration::TableBuilder::DropTable) + adapter.exec "DROP TABLE #{builder.name}" + end + + def create_table(builder : Migration::TableBuilder::CreateTable) + buffer = String.build do |s| + s << "CREATE TABLE " << builder.name << " (" + builder.fields.each_with_index do |(name, options), i| + s << ", " if i != 0 + column_definition(name, options, s) + end + s << ")" + end + adapter.exec buffer + end + + def create_enum(name, options) + raise BaseException.new("Current adapter doesn't support this method.") + end + + def drop_enum(name, options) + raise BaseException.new("Current adapter doesn't support this method.") + end + + def change_enum(name, options) + raise BaseException.new("Current adapter doesn't support this method.") + end + + def create_view(name, query, silent = true) + buff = String.build do |s| + s << "CREATE " + s << "OR REPLACE " if silent + s << "VIEW " << name << " AS " << adapter.sql_generator.select(query) + end + args = query.select_args + adapter.exec adapter.parse_query(buff, args), args + end + + def drop_view(name, silent = true) + buff = String.build do |s| + s << "DROP VIEW " + s << "IF EXISTS " if silent + s << name + end + adapter.exec buff + end + private def adapter_class @adapter.class end + + # NOTE: adding here type will bring a lot of small issues around + + private def index_type_translate(name) + case name + when :unique, :uniq + "UNIQUE " + when :fulltext + "FULLTEXT " + when :spatial + "SPATIAL " + when nil + " " + else + raise ArgumentError.new("Unknown index type: #{name}") + end + end + + private def column_definition(name, options, io) + type = options[:serial]? ? "serial" : (options[:sql_type]? || adapter.translate_type(options[:type].as(Symbol))) + size = options[:size]? || adapter.default_type_size(options[:type]) + io << name << " " << type + io << "(#{size})" if size + if options[:type] == :enum + io << " (" + options[:values].as(Array).each_with_index do |e, i| + io << ", " if i != 0 + io << "'#{e.as(String | Symbol)}'" + end + io << ") " + end + if options.has_key?(:null) + if options[:null] + io << " NULL" + else + io << " NOT NULL" + end + end + io << " PRIMARY KEY" if options[:primary]? + io << " DEFAULT #{adapter_class.t(options[:default])}" if options[:default]? + io << " AUTO_INCREMENT" if options[:auto_increment]? + end end class Base diff --git a/src/jennifer/adapter/postgres.cr b/src/jennifer/adapter/postgres.cr index 1dbe8d72..821efbac 100644 --- a/src/jennifer/adapter/postgres.cr +++ b/src/jennifer/adapter/postgres.cr @@ -179,100 +179,6 @@ module Jennifer raise BaseException.new("Unknown table lock type '#{type}'.") end - # TODO: sanitize query - def define_enum(name, values) - exec "CREATE TYPE #{name} AS ENUM(#{values.as(Array).map { |e| "'#{e}'" }.join(", ")})" - end - - def drop_enum(name) - exec "DROP TYPE #{name}" - end - - def drop_index(table, name) - exec "DROP INDEX #{name}" - end - - # =========== overrides - - def add_index(table, name, fields : Array, type : Symbol? = nil, order : Hash? = nil, length : Hash? = nil) - query = String.build do |s| - s << "CREATE " - - s << index_type_translate(type) if type - - s << "INDEX " << name << " ON " << table - # TODO: add using option to migration - # s << " USING " << options[:using] if options.has_key?(:using) - s << " (" - fields.each_with_index do |f, i| - s << "," if i != 0 - s << f - s << " " << order[f].to_s.upcase if order && order[f]? - end - s << ")" - # TODO: add partial support to migration - # s << " " << options[:partial] if options.has_key?(:partial) - end - exec query - end - - # def add_index(table, name, options : Hash(Symbol, Array(Symbol) | Hash(Symbol, Symbol) | Nil)) - # query = String.build do |s| - # s << "CREATE " - - # s << index_type_translate(options[:type]) if options[:type]? - # s << "INDEX " << name << " ON " << table - # # TODO: add using option to migration - # # s << " USING " << options[:using] if options.has_key?(:using) - # s << " (" - # options[:fields].as(Array).each_with_index do |f, i| - # s << "," if i != 0 - # s << f - # s << " " << options[:order].as(Hash)[f].to_s.upcase if options[:order]? && options[:order].as(Hash)[f]? - # end - # s << ")" - # # TODO: add partial support to migration - # # s << " " << options[:partial] if options.has_key?(:partial) - # end - # exec query - # end - - def change_column(table, old_name, new_name, opts) - column_name_part = " ALTER COLUMN #{old_name} " - query = String.build do |s| - s << "ALTER TABLE " << table - if opts[:type]? - s << column_name_part << " TYPE " - column_type_definition(opts, s) - s << "," - end - if opts[:null]? - s << column_name_part - if opts[:null] - s << " DROP NOT NULL" - else - s << " SET NOT NULL" - end - s << "," - end - if opts[:default]? - s << column_name_part - if opts[:default].is_a?(Symbol) && opts[:default].as(Symbol) == :drop - s << "DROP DEFAULT " - else - s << "SET DEFAULT " << self.class.t(opts[:default]) - end - s << "," - end - if old_name.to_s != new_name.to_s - s << " RENAME COLUMN " << old_name << " TO " << new_name - s << "," - end - end - - exec query[0...-1] - end - def insert(obj : Model::Base) opts = obj.arguments_to_insert query = parse_query(sql_generator.insert(obj, obj.class.primary_auto_incrementable?), opts[:args]) @@ -312,43 +218,6 @@ module Jennifer scalar(body, args) end - private def column_definition(name, options, io) - io << name - column_type_definition(options, io) - if options.has_key?(:null) - if options[:null] - io << " NULL" - else - io << " NOT NULL" - end - end - io << " PRIMARY KEY" if options[:primary]? - io << " DEFAULT #{self.class.t(options[:default])}" if options[:default]? - end - - private def column_type_definition(options, io) - type = if options[:serial]? || options[:auto_increment]? - "serial" - else - options[:sql_type]? || translate_type(options[:type].as(Symbol)) - end - size = options[:size]? || default_type_size(options[:type]?) - io << " " << type - io << "(#{size})" if size - io << " ARRAY" if options[:array]? - end - - private def index_type_translate(name) - case name - when :unique, :uniq - "UNIQUE " - when nil - " " - else - raise ArgumentError.new("Unknown index type: #{name}") - end - end - def self.create_database opts = [Config.db, "-O", Config.user, "-h", Config.host, "-U", Config.user] Process.run("PGPASSWORD=#{Config.password} createdb \"${@}\"", opts, shell: true).inspect diff --git a/src/jennifer/adapter/postgres/migration/table_builder/base.cr b/src/jennifer/adapter/postgres/migration/table_builder/base.cr index 5c698701..a9ef060f 100644 --- a/src/jennifer/adapter/postgres/migration/table_builder/base.cr +++ b/src/jennifer/adapter/postgres/migration/table_builder/base.cr @@ -6,6 +6,10 @@ module Jennifer def adapter @adapter.as(Postgres::Adapter) end + + def migration_processor + @adapter.migration_processor.as(MigrationProcessor) + end end end end diff --git a/src/jennifer/adapter/postgres/migration/table_builder/change_enum.cr b/src/jennifer/adapter/postgres/migration/table_builder/change_enum.cr index 0c0b7306..b14c2380 100644 --- a/src/jennifer/adapter/postgres/migration/table_builder/change_enum.cr +++ b/src/jennifer/adapter/postgres/migration/table_builder/change_enum.cr @@ -19,21 +19,21 @@ module Jennifer def remove_values new_values = [] of String - @adapter.enum_values(@name).map { |e| new_values << e[0] } + adapter.enum_values(@name).map { |e| new_values << e[0] } new_values -= @options[:remove_values] if @effected_tables.empty? - @adapter.drop_enum(@name) - @adapter.define_enum(@name, new_values) + migration_processor.drop_enum(@name) + migration_processor.define_enum(@name, new_values) else temp_name = "#{@name}_temp" - @adapter.define_enum(temp_name, new_values) + migration_processor.define_enum(temp_name, new_values) @effected_tables.each do |row| @adapter.exec <<-SQL ALTER TABLE #{row[0]} ALTER COLUMN #{row[1]} TYPE #{temp_name} USING (#{row[1]}::text::#{temp_name}) SQL - @adapter.drop_enum(@name) + migration_processor.drop_enum(@name) rename(temp_name, @name) end end @@ -41,7 +41,7 @@ module Jennifer def add_values typed_array_cast(@options[:add_values].as(Array), String).each do |field| - @adapter.exec "ALTER TYPE #{@name} ADD VALUE '#{field}'" + adapter.exec "ALTER TYPE #{@name} ADD VALUE '#{field}'" end end @@ -60,7 +60,7 @@ module Jennifer end def rename(old_name, new_name) - @adapter.exec "ALTER TYPE #{old_name} RENAME TO #{new_name}" + adapter.exec "ALTER TYPE #{old_name} RENAME TO #{new_name}" end private def _effected_tables diff --git a/src/jennifer/adapter/postgres/migration/table_builder/create_enum.cr b/src/jennifer/adapter/postgres/migration/table_builder/create_enum.cr index ad11af5b..ad053ee0 100644 --- a/src/jennifer/adapter/postgres/migration/table_builder/create_enum.cr +++ b/src/jennifer/adapter/postgres/migration/table_builder/create_enum.cr @@ -8,7 +8,7 @@ module Jennifer end def process - adapter.define_enum(@name, @values) + migration_processor.define_enum(@name, @values) end end end diff --git a/src/jennifer/adapter/postgres/migration/table_builder/drop_enum.cr b/src/jennifer/adapter/postgres/migration/table_builder/drop_enum.cr index 0495c00f..b19d1cc4 100644 --- a/src/jennifer/adapter/postgres/migration/table_builder/drop_enum.cr +++ b/src/jennifer/adapter/postgres/migration/table_builder/drop_enum.cr @@ -8,7 +8,7 @@ module Jennifer end def process - adapter.drop_enum(@name) + migration_processor.drop_enum(@name) end end end diff --git a/src/jennifer/adapter/postgres/migration_processor.cr b/src/jennifer/adapter/postgres/migration_processor.cr index 2f8b23f4..1884567e 100644 --- a/src/jennifer/adapter/postgres/migration_processor.cr +++ b/src/jennifer/adapter/postgres/migration_processor.cr @@ -5,25 +5,143 @@ module Jennifer class MigrationProcessor < Adapter::MigrationProcessor delegate data_type_exists?, to: adapter.as(Postgres) - def create_enum(name : String | Symbol, values) + # ================ + # Builder methods + # ================ + + def build_create_enum(name, values) Migration::TableBuilder::CreateEnum.new(@adapter, name, values).process end - def drop_enum(name : String | Symbol) + def build_drop_enum(name) Migration::TableBuilder::DropEnum.new(@adapter, name).process end - def change_enum(name : String | Symbol, options) + def build_change_enum(name, options) Migration::TableBuilder::ChangeEnum.new(@adapter, name, options).process end - def create_materialized_view(name : String | Symbol, _as) + def build_create_materialized_view(name, _as) Migration::TableBuilder::CreateMaterializedView.new(@adapter, name, _as).process end - def drop_materialized_view(name : String | Symbol) + def build_drop_materialized_view(name) Migration::TableBuilder::DropMaterializedView.new(@adapter, name).process end + + # ============================ + # Schema manipulating methods + # ============================ + + # TODO: sanitize query + def define_enum(name, values) + adapter.exec "CREATE TYPE #{name} AS ENUM(#{values.as(Array).map { |e| "'#{e}'" }.join(", ")})" + end + + def drop_enum(name) + adapter.exec "DROP TYPE #{name}" + end + + def drop_index(table, name) + adapter.exec "DROP INDEX #{name}" + end + + # =========== overrides + + def add_index(table, name, fields : Array, type : Symbol? = nil, order : Hash? = nil, length : Hash? = nil) + query = String.build do |s| + s << "CREATE " + + s << index_type_translate(type) if type + + s << "INDEX " << name << " ON " << table + # TODO: add using option to migration + # s << " USING " << options[:using] if options.has_key?(:using) + s << " (" + fields.each_with_index do |f, i| + s << "," if i != 0 + s << f + s << " " << order[f].to_s.upcase if order && order[f]? + end + s << ")" + # TODO: add partial support to migration + # s << " " << options[:partial] if options.has_key?(:partial) + end + adapter.exec query + end + + def change_column(table, old_name, new_name, opts) + column_name_part = " ALTER COLUMN #{old_name} " + query = String.build do |s| + s << "ALTER TABLE " << table + if opts[:type]? + s << column_name_part << " TYPE " + column_type_definition(opts, s) + s << "," + end + if opts[:null]? + s << column_name_part + if opts[:null] + s << " DROP NOT NULL" + else + s << " SET NOT NULL" + end + s << "," + end + if opts[:default]? + s << column_name_part + if opts[:default].is_a?(Symbol) && opts[:default].as(Symbol) == :drop + s << "DROP DEFAULT " + else + s << "SET DEFAULT " << adapter_class.t(opts[:default]) + end + s << "," + end + if old_name.to_s != new_name.to_s + s << " RENAME COLUMN " << old_name << " TO " << new_name + s << "," + end + end + + adapter.exec query[0...-1] + end + + private def column_definition(name, options, io) + io << name + column_type_definition(options, io) + if options.has_key?(:null) + if options[:null] + io << " NULL" + else + io << " NOT NULL" + end + end + io << " PRIMARY KEY" if options[:primary]? + io << " DEFAULT #{adapter_class.t(options[:default])}" if options[:default]? + end + + private def column_type_definition(options, io) + type = if options[:serial]? || options[:auto_increment]? + "serial" + else + options[:sql_type]? || adapter.translate_type(options[:type].as(Symbol)) + end + size = options[:size]? || adapter.default_type_size(options[:type]?) + io << " " << type + io << "(#{size})" if size + io << " ARRAY" if options[:array]? + end + + private def index_type_translate(name) + case name + when :unique, :uniq + "UNIQUE " + when nil + " " + else + raise ArgumentError.new("Unknown index type: #{name}") + end + end end end end diff --git a/src/jennifer/adapter/sqlite3.cr b/src/jennifer/adapter/sqlite3.cr index b2265555..abd6bbe3 100644 --- a/src/jennifer/adapter/sqlite3.cr +++ b/src/jennifer/adapter/sqlite3.cr @@ -1,14 +1,13 @@ require "sqlite3" require "../adapter" require "./sqlite3/sql_notation" +require "./sqlite3/migration_processor" module Jennifer - alias DBAny = DB::Any + module Sqlite3 + class Adapter < Base + alias EnumType = String - module Adapter - alias EnumType = String - - class Sqlite3 < Base TYPE_TRANSLATIONS = { :integer => "integer", :bool => "integer", @@ -21,6 +20,14 @@ module Jennifer :float => "real", } + def sql_generator + SQLGenerator + end + + def migration_processor + @migration_processor ||= MigrationProcessor.new(self) + end + def translate_type(name) TYPE_TRANSLATIONS[name] rescue e : KeyError @@ -55,22 +62,6 @@ module Jennifer c == 1 end - def rename_table(old_name, new_name) - exec "ALTER TABLE #{old_name.to_s} RENAME TO #{new_name.to_s}" - end - - def drop_index(table, name) - exec "DROP INDEX #{name}" - end - - def change_column(table, old_name, new_name, opts) - raise "ALTER COLUMN is not implemented yet. Take a look on this http://www.sqlite.org/faq.html#q11" - end - - def drop_column(table, old_name, new_name, opts) - raise "DROP COLUMN is not implemented yet. Take a look on this http://www.sqlite.org/faq.html#q11" - end - def self.table_row_hash(rs) raise "Not supported" end @@ -96,27 +87,10 @@ module Jennifer private def self.db_path File.join(Config.host, Config.db) end - - private def column_definition(name, options, io) - type = options[:sql_type]? || translate_type(options[:type].as(Symbol)) - size = options[:size]? || default_type_size(options[:type]) - io << name << " " << type - io << "(#{size})" if size - if options.key?(:null) - if options[:null] - io << " NULL" - else - io << " NOT NULL" - end - end - io << " PRIMARY KEY" if options[:primary]? - io << " DEFAULT #{self.class.t(options[:default])}" if options[:default]? - io << " AUTOINCREMENT" if options[:auto_increment]? - end end end end require "./sqlite3/result_set" -::Jennifer::Adapter.register_adapter("sqlite3", ::Jennifer::Adapter::Sqlite3) +::Jennifer::Adapter.register_adapter("sqlite3", ::Jennifer::Sqlite3::Adapter) diff --git a/src/jennifer/adapter/sqlite3/migration_processor.cr b/src/jennifer/adapter/sqlite3/migration_processor.cr new file mode 100644 index 00000000..29f0bc27 --- /dev/null +++ b/src/jennifer/adapter/sqlite3/migration_processor.cr @@ -0,0 +1,44 @@ +require "../migration_processor" + +module Jennifer + module Sqlite3 + class MigrationProcessor < Adapter::MigrationProcessor + # ============================ + # Schema manipulating methods + # ============================ + + def rename_table(old_name, new_name) + exec "ALTER TABLE #{old_name.to_s} RENAME TO #{new_name.to_s}" + end + + def drop_index(table, name) + exec "DROP INDEX #{name}" + end + + def change_column(table, old_name, new_name, opts) + raise "ALTER COLUMN is not implemented yet. Take a look on this http://www.sqlite.org/faq.html#q11" + end + + def drop_column(table, old_name, new_name, opts) + raise "DROP COLUMN is not implemented yet. Take a look on this http://www.sqlite.org/faq.html#q11" + end + + private def column_definition(name, options, io) + type = options[:sql_type]? || translate_type(options[:type].as(Symbol)) + size = options[:size]? || default_type_size(options[:type]) + io << name << " " << type + io << "(#{size})" if size + if options.key?(:null) + if options[:null] + io << " NULL" + else + io << " NOT NULL" + end + end + io << " PRIMARY KEY" if options[:primary]? + io << " DEFAULT #{self.class.t(options[:default])}" if options[:default]? + io << " AUTOINCREMENT" if options[:auto_increment]? + end + end + end +end diff --git a/src/jennifer/adapter/sqlite3/result_set.cr b/src/jennifer/adapter/sqlite3/result_set.cr index eaa7c931..b0f89ae4 100644 --- a/src/jennifer/adapter/sqlite3/result_set.cr +++ b/src/jennifer/adapter/sqlite3/result_set.cr @@ -1,7 +1,5 @@ class SQLite3::ResultSet - def column_index - @column_index - end + getter column_index def current_column columns[column_index] diff --git a/src/jennifer/adapter/sqlite3/sql_generator.cr b/src/jennifer/adapter/sqlite3/sql_generator.cr index 5f5bebca..9de1d115 100644 --- a/src/jennifer/adapter/sqlite3/sql_generator.cr +++ b/src/jennifer/adapter/sqlite3/sql_generator.cr @@ -1,8 +1,8 @@ +require "../base_sql_generator" + module Jennifer - module Adapter - class Sqlite3 - class SQLGenerator < BaseSQLGenerator - end + module Sqlite3 + class SQLGenerator < Jennifer::Adapter::BaseSQLGenerator end end end diff --git a/src/jennifer/migration/base.cr b/src/jennifer/migration/base.cr index 979ee9fd..d1374c2e 100644 --- a/src/jennifer/migration/base.cr +++ b/src/jennifer/migration/base.cr @@ -3,6 +3,20 @@ module Jennifer abstract class Base TABLE_NAME = "migration_versions" + macro delegate(*methods, to object, prefix pref = "") + {% for method in methods %} + def {{method.id}}(*args, **options) + {{object.id}}.{{pref.id}}{{method.id}}(*args, **options) + end + + def {{method.id}}(*args, **options) + {{object.id}}.{{pref.id}}{{method.id}}(*args, **options) do |*yield_args| + yield *yield_args + end + end + {% end %} + end + delegate adapter, to: Adapter delegate create_data_type, to: adapter @@ -12,7 +26,7 @@ module Jennifer delegate create_table, create_join_table, drop_join_table, exec, drop_table, change_table, create_view, create_materialized_view, drop_materialized_view, drop_view, add_index, create_enum, drop_enum, change_enum, - to: migration_processor + to: migration_processor, prefix: "build_" def adapter_class adapter.class diff --git a/src/jennifer/migration/table_builder/base.cr b/src/jennifer/migration/table_builder/base.cr index 077eb6c0..86060201 100644 --- a/src/jennifer/migration/table_builder/base.cr +++ b/src/jennifer/migration/table_builder/base.cr @@ -9,7 +9,7 @@ module Jennifer extend Ifrit - delegate table_exists?, index_exists?, column_exists?, to: adapter + delegate migration_processor, table_exists?, index_exists?, column_exists?, to: adapter getter fields, adapter : Adapter::Base diff --git a/src/jennifer/migration/table_builder/change_table.cr b/src/jennifer/migration/table_builder/change_table.cr index 2cc0c9fe..d163bce9 100644 --- a/src/jennifer/migration/table_builder/change_table.cr +++ b/src/jennifer/migration/table_builder/change_table.cr @@ -65,15 +65,15 @@ module Jennifer end def process - @drop_columns.each { |c| adapter.drop_column(@name, c) } - @fields.each { |n, opts| adapter.add_column(@name, n, opts) } + @drop_columns.each { |c| migration_processor.drop_column(@name, c) } + @fields.each { |n, opts| migration_processor.add_column(@name, n, opts) } @changed_columns.each do |n, opts| - adapter.change_column(@name, n, opts[:new_name].as(String | Symbol), opts) + migration_processor.change_column(@name, n, opts[:new_name].as(String | Symbol), opts) end @indexes.each(&.process) @drop_index.each(&.process) - adapter.rename_table(@name, @new_table_name) unless @new_table_name.empty? + migration_processor.rename_table(@name, @new_table_name) unless @new_table_name.empty? end end end diff --git a/src/jennifer/migration/table_builder/create_index.cr b/src/jennifer/migration/table_builder/create_index.cr index 176898f0..b8b79b73 100644 --- a/src/jennifer/migration/table_builder/create_index.cr +++ b/src/jennifer/migration/table_builder/create_index.cr @@ -10,7 +10,7 @@ module Jennifer end def process - adapter.add_index(@name, @index_name, _fields, @type, orders, @lengths) + migration_processor.add_index(@name, @index_name, _fields, @type, orders, @lengths) end end end diff --git a/src/jennifer/migration/table_builder/create_table.cr b/src/jennifer/migration/table_builder/create_table.cr index 24b264d8..4e5929d8 100644 --- a/src/jennifer/migration/table_builder/create_table.cr +++ b/src/jennifer/migration/table_builder/create_table.cr @@ -3,7 +3,7 @@ module Jennifer module TableBuilder class CreateTable < Base def process - adapter.create_table(self) + migration_processor.create_table(self) @indexes.each(&.process) end diff --git a/src/jennifer/migration/table_builder/create_view.cr b/src/jennifer/migration/table_builder/create_view.cr index 299ce82f..bc72d14b 100644 --- a/src/jennifer/migration/table_builder/create_view.cr +++ b/src/jennifer/migration/table_builder/create_view.cr @@ -11,7 +11,7 @@ module Jennifer # TODO: move query generating to SqlGenerator class and make # table builder classes to call executions by themselves def process - adapter.create_view(@name, @query) + migration_processor.create_view(@name, @query) end end end diff --git a/src/jennifer/migration/table_builder/drop_index.cr b/src/jennifer/migration/table_builder/drop_index.cr index 6b506f3f..8508bfd8 100644 --- a/src/jennifer/migration/table_builder/drop_index.cr +++ b/src/jennifer/migration/table_builder/drop_index.cr @@ -7,7 +7,7 @@ module Jennifer end def process - adapter.drop_index(@name, @index_name) + migration_processor.drop_index(@name, @index_name) end end end diff --git a/src/jennifer/migration/table_builder/drop_table.cr b/src/jennifer/migration/table_builder/drop_table.cr index b36bfd5d..be12282b 100644 --- a/src/jennifer/migration/table_builder/drop_table.cr +++ b/src/jennifer/migration/table_builder/drop_table.cr @@ -3,7 +3,7 @@ module Jennifer module TableBuilder class DropTable < Base def process - adapter.drop_table(self) + migration_processor.drop_table(self) end end end diff --git a/src/jennifer/migration/table_builder/drop_view.cr b/src/jennifer/migration/table_builder/drop_view.cr index 4b3b2096..1569fc9b 100644 --- a/src/jennifer/migration/table_builder/drop_view.cr +++ b/src/jennifer/migration/table_builder/drop_view.cr @@ -3,7 +3,7 @@ module Jennifer module TableBuilder class DropView < Base def process - adapter.drop_view(@name) + migration_processor.drop_view(@name) end end end From 4df67987897607d112c36034e24ddb85e15dfc90 Mon Sep 17 00:00:00 2001 From: Roman Kalnytskyi Date: Tue, 14 Nov 2017 01:19:18 +0200 Subject: [PATCH 06/19] Setup parallel run --- .travis.install-mysql-5.7.sh | 7 ---- .travis.setup.sh | 32 +++++++++++++++++++ .travis.yml | 6 ++-- generate-docs.sh | 9 +++--- spec/adapter/base_spec.cr | 2 +- spec/adapter/postgres_spec.cr | 12 ------- spec/config.cr | 9 ++++++ spec/spec_helper.cr | 2 +- src/jennifer.cr | 1 - .../migration/table_builder/change_enum.cr | 3 +- .../table_builder/create_materialized_view.cr | 10 +++--- src/jennifer/query_builder/query.cr | 3 +- 12 files changed, 59 insertions(+), 37 deletions(-) delete mode 100644 .travis.install-mysql-5.7.sh create mode 100644 .travis.setup.sh diff --git a/.travis.install-mysql-5.7.sh b/.travis.install-mysql-5.7.sh deleted file mode 100644 index 6765b011..00000000 --- a/.travis.install-mysql-5.7.sh +++ /dev/null @@ -1,7 +0,0 @@ -sudo apt-key adv --keyserver pgp.mit.edu --recv-keys A4A9406876FCBD3C456770C88C718D3B5072E1F5 -wget http://dev.mysql.com/get/mysql-apt-config_0.8.1-1_all.deb -sudo dpkg -i mysql-apt-config_0.8.1-1_all.deb -sudo apt-get update -q -sudo apt-get install -q -y -o Dpkg::Options::=--force-confnew mysql-server -sudo mysql_upgrade -u root --force -sudo service mysql restart \ No newline at end of file diff --git a/.travis.setup.sh b/.travis.setup.sh new file mode 100644 index 00000000..543b33cf --- /dev/null +++ b/.travis.setup.sh @@ -0,0 +1,32 @@ +set -e +set -o pipefail + +if [ "$DB" == 'postgres' ] || [ "$PAIR" == '1' ]; then + echo "===================================" + echo "Create database for postgres" + echo "===================================" + psql -c 'create database jennifer_test;' -U postgres +fi + +if [ "$DB" == 'mysql' ] || [ "$PAIR" == '1' ]; then + echo "===================================" + echo "Install newer MySQL" + echo "===================================" + sudo apt-key adv --keyserver pgp.mit.edu --recv-keys A4A9406876FCBD3C456770C88C718D3B5072E1F5 + wget http://dev.mysql.com/get/mysql-apt-config_0.8.1-1_all.deb + sudo dpkg -i mysql-apt-config_0.8.1-1_all.deb + sudo apt-get update -q + sudo apt-get install -q -y -o Dpkg::Options::=--force-confnew mysql-server + sudo mysql_upgrade -u root --force + sudo service mysql restart + + echo "===================================" + echo "Create database for mysql" + echo "===================================" + crystal ./examples/run.cr -- db:create +fi + +echo "===================================" +echo "Run migrations" +echo "===================================" +crystal ./examples/run.cr -- db:migrate \ No newline at end of file diff --git a/.travis.yml b/.travis.yml index 52f031e6..59126777 100644 --- a/.travis.yml +++ b/.travis.yml @@ -9,7 +9,7 @@ addons: env: - DB=mysql DB_USER=root DB_PASSWORD="" - DB=postgres DB_USER=postgres DB_PASSWORD="" + - DB=postgres DB_USER=postgres DB_PASSWORD="" PAIR=1 + - DB=mysql DB_USER=root DB_PASSWORD="" PAIR=1 before_script: - - sh -c "if [ '$DB' = 'postgres' ]; then psql -c 'create database jennifer_test;' -U postgres; fi" - - sh -c "if [ '$DB' = 'mysql' ]; then bash .travis.install-mysql-5.7.sh; crystal ./examples/run.cr -- db:create; fi" - - crystal ./examples/run.cr -- db:migrate + - bash .travis.setup.sh diff --git a/generate-docs.sh b/generate-docs.sh index 62e39bf3..7c0f62af 100755 --- a/generate-docs.sh +++ b/generate-docs.sh @@ -1,4 +1,5 @@ -#f1="./src/jennifer/adapter/mysql.cr " # for mysql -f1="./src/jennifer/adapter/postgres.cr " # for postgres -f2="./src/jennifer.cr" -echo $f1$f2 | xargs crystal doc \ No newline at end of file +f1="./src/jennifer.cr " +f2="./src/jennifer/adapter/mysql.cr " # for mysql +f3="./src/jennifer/adapter/postgres.cr " # for postgres + +echo $f1$f2$f3 | xargs crystal doc diff --git a/spec/adapter/base_spec.cr b/spec/adapter/base_spec.cr index 30ba7d1a..3f544ead 100644 --- a/spec/adapter/base_spec.cr +++ b/spec/adapter/base_spec.cr @@ -237,7 +237,7 @@ describe Jennifer::Adapter::Base do describe "::escape_string" do it "returns proper escape string" do - described_class.escape_string(5).should eq(Jennifer::Adapter::SqlGenerator.escape_string(5)) + described_class.escape_string(5).should eq("%s, %s, %s, %s, %s") end end diff --git a/spec/adapter/postgres_spec.cr b/spec/adapter/postgres_spec.cr index 15926b3d..e8c43a88 100644 --- a/spec/adapter/postgres_spec.cr +++ b/spec/adapter/postgres_spec.cr @@ -5,18 +5,6 @@ postgres_only do described_class = Jennifer::Postgres::Adapter adapter = Jennifer::Adapter.adapter.as(Jennifer::Postgres::Adapter) - describe "#translate_type" do - it "returns sql type associated with given synonim" do - adapter.translate_type(:string).should eq("varchar") - end - end - - describe "#default_type_size" do - it "returns default type size for given alias" do - adapter.default_type_size(:string).should eq(254) - end - end - describe "#parse_query" do it "replaces %s by dollar-and-numbers" do adapter.parse_query("some %s query %s", ["a", "b"]).should eq("some $1 query $2") diff --git a/spec/config.cr b/spec/config.cr index 28a04332..d15fb597 100644 --- a/spec/config.cr +++ b/spec/config.cr @@ -61,6 +61,15 @@ require "../src/jennifer" Spec.adapter = "postgres" {% end %} +{% if env("PAIR") == "1" %} + # Additionally loads opposite adapter + {% if env("DB") == "mysql" %} + require "../src/jennifer/adapter/postgres" + {% elsif env("DB") == "postgres" %} + require "../src/jennifer/adapter/mysql" + {% end %} +{% end %} + def set_default_configuration Jennifer::Config.reset_config Jennifer::Config.configure do |conf| diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr index 570b4cb5..edf70aa4 100644 --- a/spec/spec_helper.cr +++ b/spec/spec_helper.cr @@ -32,7 +32,7 @@ end def clean_db postgres_only do - Jennifer::Adapter.adapter.refresh_materialized_view(FemaleContact.table_name) + Jennifer::Adapter.adapter.as(Jennifer::Postgres::Adapter).refresh_materialized_view(FemaleContact.table_name) end Jennifer::Model::Base.models.select { |t| t.has_table? }.each(&.all.delete) end diff --git a/src/jennifer.cr b/src/jennifer.cr index 9db8a7b2..f2bdfa5f 100644 --- a/src/jennifer.cr +++ b/src/jennifer.cr @@ -25,7 +25,6 @@ require "./jennifer/view/base" require "./jennifer/migration/*" module Jennifer - alias Query = QueryBuilder::Query {% if Jennifer.constant("AFTER_LOAD_SCRIPT") == nil %} AFTER_LOAD_SCRIPT = [] of String {% end %} diff --git a/src/jennifer/adapter/postgres/migration/table_builder/change_enum.cr b/src/jennifer/adapter/postgres/migration/table_builder/change_enum.cr index b14c2380..11e0ffaf 100644 --- a/src/jennifer/adapter/postgres/migration/table_builder/change_enum.cr +++ b/src/jennifer/adapter/postgres/migration/table_builder/change_enum.cr @@ -18,8 +18,7 @@ module Jennifer end def remove_values - new_values = [] of String - adapter.enum_values(@name).map { |e| new_values << e[0] } + new_values = adapter.enum_values(@name) new_values -= @options[:remove_values] if @effected_tables.empty? migration_processor.drop_enum(@name) diff --git a/src/jennifer/adapter/postgres/migration/table_builder/create_materialized_view.cr b/src/jennifer/adapter/postgres/migration/table_builder/create_materialized_view.cr index ba16202d..b579a982 100644 --- a/src/jennifer/adapter/postgres/migration/table_builder/create_materialized_view.cr +++ b/src/jennifer/adapter/postgres/migration/table_builder/create_materialized_view.cr @@ -6,13 +6,12 @@ module Jennifer class CreateMaterializedView < Base @query : QueryBuilder::Query | String - def initialize(name, @query) - super(name) + def initialize(adapter, name, @query) + super(adapter, name) end def process - buff = generate_query - adapter.exec buff + adapter.exec(generate_query) end private def generate_query @@ -25,7 +24,8 @@ module Jennifer "CREATE MATERIALIZED VIEW " << @name << " AS " << - Adapter::SqlGenerator.select(@query.as(QueryBuilder::Query)) + adapter.sql_generator.select(@query.as(QueryBuilder::Query)) + end end end end diff --git a/src/jennifer/query_builder/query.cr b/src/jennifer/query_builder/query.cr index 700a8f03..c3f9dea1 100644 --- a/src/jennifer/query_builder/query.cr +++ b/src/jennifer/query_builder/query.cr @@ -23,9 +23,10 @@ module Jennifer end {% end %} + getter table : String = "" + @having : Condition | LogicOperator? @limit : Int32? - @table : String = "" @distinct : Bool = false @offset : Int32? @raw_select : String? From 3941a9bd3190f8f630515d9f1be1a9e3546089fd Mon Sep 17 00:00:00 2001 From: Roman Kalnytskyi Date: Thu, 4 Jan 2018 12:43:08 +0200 Subject: [PATCH 07/19] Decouple adapter --- .travis.yml | 3 +- spec/adapter/base_spec.cr | 6 -- spec/adapter/sql_generator_spec.cr | 17 ++++++ spec/query_builder/condition_spec.cr | 19 ------ src/jennifer/adapter.cr | 19 +++--- src/jennifer/adapter/base.cr | 13 +---- src/jennifer/adapter/base_sql_generator.cr | 24 +++++--- src/jennifer/adapter/mysql/sql_generator.cr | 6 +- src/jennifer/adapter/postgres/field.cr | 1 + .../adapter/postgres/sql_generator.cr | 8 +-- src/jennifer/adapter/record.cr | 3 +- src/jennifer/migration/runner.cr | 23 +++++--- src/jennifer/model/base.cr | 12 ++-- src/jennifer/model/mapping.cr | 12 ++-- src/jennifer/model/relation_definition.cr | 2 +- src/jennifer/model/sti_mapping.cr | 4 +- src/jennifer/query_builder/aggregations.cr | 2 +- src/jennifer/query_builder/all.cr | 2 +- src/jennifer/query_builder/any.cr | 2 +- src/jennifer/query_builder/condition.cr | 58 +++++++++++-------- src/jennifer/query_builder/criteria.cr | 19 ++---- src/jennifer/query_builder/executables.cr | 24 ++++---- src/jennifer/query_builder/grouping.cr | 4 +- src/jennifer/query_builder/join.cr | 46 ++++++++------- src/jennifer/query_builder/json_selector.cr | 4 +- src/jennifer/query_builder/logic_operator.cr | 6 +- src/jennifer/query_builder/model_query.cr | 10 +++- src/jennifer/query_builder/query.cr | 12 +++- src/jennifer/query_builder/sql_node.cr | 6 +- src/jennifer/relation/base.cr | 4 ++ src/jennifer/relation/many_to_many.cr | 5 +- src/jennifer/view/experimental_mapping.cr | 2 +- 32 files changed, 199 insertions(+), 179 deletions(-) diff --git a/.travis.yml b/.travis.yml index 59126777..ebe57be1 100644 --- a/.travis.yml +++ b/.travis.yml @@ -11,5 +11,4 @@ env: - DB=postgres DB_USER=postgres DB_PASSWORD="" - DB=postgres DB_USER=postgres DB_PASSWORD="" PAIR=1 - DB=mysql DB_USER=root DB_PASSWORD="" PAIR=1 -before_script: - - bash .travis.setup.sh +before_script: bash .travis.setup.sh diff --git a/spec/adapter/base_spec.cr b/spec/adapter/base_spec.cr index 3f544ead..171a806d 100644 --- a/spec/adapter/base_spec.cr +++ b/spec/adapter/base_spec.cr @@ -235,12 +235,6 @@ describe Jennifer::Adapter::Base do end end - describe "::escape_string" do - it "returns proper escape string" do - described_class.escape_string(5).should eq("%s, %s, %s, %s, %s") - end - end - describe "#query_array" do it "returns array of given type" do Factory.create_contact diff --git a/spec/adapter/sql_generator_spec.cr b/spec/adapter/sql_generator_spec.cr index 4dcf22f3..d450ae0b 100644 --- a/spec/adapter/sql_generator_spec.cr +++ b/spec/adapter/sql_generator_spec.cr @@ -8,6 +8,23 @@ describe "Jennifer::Adapter::SQLGenerator" do adapter = Jennifer::Adapter.adapter described_class = Jennifer::Adapter.adapter.sql_generator + describe "::filter_out" do + context "is Criteria" do + it "renders sql of criteria" do + c2 = Factory.build_criteria + described_class.filter_out(c2).should eq(c2.as_sql) + end + end + + context "anything else" do + it "renders placeholder" do + described_class.filter_out(1).should eq("%s") + described_class.filter_out("s").should eq("%s") + described_class.filter_out(false).should eq("%s") + end + end + end + describe "::select_query" do s = Contact.where { _age == 1 }.join(Contact) { _age == Contact._age }.order(age: :desc).limit(1) select_query = described_class.select(s) diff --git a/spec/query_builder/condition_spec.cr b/spec/query_builder/condition_spec.cr index 6739188e..391b958f 100644 --- a/spec/query_builder/condition_spec.cr +++ b/spec/query_builder/condition_spec.cr @@ -132,23 +132,4 @@ describe Jennifer::QueryBuilder::Condition do end end end - - describe "#filter_out" do - context "is Criteria" do - it "renders sql of criteria" do - c1 = Factory.build_criteria.to_condition - c2 = Factory.build_criteria - c1.filter_out(c2).should eq(c2.as_sql) - end - end - - context "anything else" do - it "renders placeholder" do - c1 = Factory.build_criteria.to_condition - c1.filter_out(1).should eq("%s") - c1.filter_out("s").should eq("%s") - c1.filter_out(false).should eq("%s") - end - end - end end diff --git a/src/jennifer/adapter.cr b/src/jennifer/adapter.cr index 8ac48efd..4fe6d139 100644 --- a/src/jennifer/adapter.cr +++ b/src/jennifer/adapter.cr @@ -53,20 +53,15 @@ module Jennifer end end - def self.adapter_class - @@adapter_class ||= adapters[Config.adapter] - end - - def self.t(value) - adapter_class.t(value) + # Returns default adapter. + # + # NOTE: this is a temporary solution to decouple adapter dependency or query class. + def self.default_adapter + adapter end - def self.arg_replacement(rhs : Array(Bool | Float32 | Int32 | Jennifer::QueryBuilder::Criteria | String)) - adapter_class.arg_replacement(rhs) - end - - def self.escape_string(size = 1) - adapter_class.escape_string(size) + def self.adapter_class + @@adapter_class ||= adapters[Config.adapter] end def self.adapters diff --git a/src/jennifer/adapter/base.cr b/src/jennifer/adapter/base.cr index 1ebf5909..3eac4a07 100644 --- a/src/jennifer/adapter/base.cr +++ b/src/jennifer/adapter/base.cr @@ -169,14 +169,6 @@ module Jennifer {args: args, fields: fields} end - def self.arg_replacement(arr) - escape_string(arr.size) - end - - def self.escape_string(size = 1) - Adapter.adapter.sql_generator.escape_string(size) - end - def self.drop_database db_connection do |db| db.exec "DROP DATABASE #{Config.db}" @@ -190,11 +182,11 @@ module Jennifer end def self.generate_schema - raise "Not implemented" + raise AbstractMethod.new("generate_schema", self) end def self.load_schema - raise "Not implemented" + raise AbstractMethod.new("load_schema", self) end # filter out value; should be refactored @@ -233,6 +225,7 @@ module Jennifer result end + abstract def migration_processor abstract def sql_generator abstract def view_exists?(name, silent = true) abstract def update(obj) diff --git a/src/jennifer/adapter/base_sql_generator.cr b/src/jennifer/adapter/base_sql_generator.cr index 7237dea1..53022e1d 100644 --- a/src/jennifer/adapter/base_sql_generator.cr +++ b/src/jennifer/adapter/base_sql_generator.cr @@ -8,7 +8,7 @@ module Jennifer String.build do |s| s << "INSERT INTO " << table << "(" hash.keys.join(", ", s) - s << ") VALUES (" << Adapter.adapter_class.escape_string(hash.size) << ")" + s << ") VALUES (" << escape_string(hash.size) << ")" end end @@ -22,7 +22,7 @@ module Jennifer String.build do |s| s << "INSERT INTO " << table << "(" field_names.join(", ", s) { |e| s << e } - escaped_row = "(" + Adapter.adapter_class.escape_string(field_names.size) + ")" + escaped_row = "(" + escape_string(field_names.size) + ")" s << ") VALUES " rows.times.join(", ", s) { s << escaped_row } end @@ -85,7 +85,7 @@ module Jennifer end def self.update(query, options : Hash) - esc = Adapter.adapter_class.escape_string(1) + esc = escape_string(1) String.build do |s| s << "UPDATE " << query.table << " SET " options.map { |k, v| "#{k.to_s}= #{esc}" }.join(", ", s) @@ -167,18 +167,18 @@ module Jennifer def self.group_clause(io : String::Builder, query) return if !query._groups || query._groups.empty? io << "GROUP BY " - query._groups.not_nil!.each.join(", ", io) { |c| io << c.as_sql } + query._groups.not_nil!.each.join(", ", io) { |c| io << c.as_sql(self) } io << "\n" end def self.having_clause(io : String::Builder, query) return unless query._having - io << "HAVING " << query._having.not_nil!.as_sql << "\n" + io << "HAVING " << query._having.not_nil!.as_sql(self) << "\n" end def self.join_clause(io : String::Builder, query) return unless query._joins - query._joins.not_nil!.join(" ", io) { |j| io << j.as_sql } + query._joins.not_nil!.join(" ", io) { |j| io << j.as_sql(self) } end def self.where_clause(io : String::Builder, query : QueryBuilder::Query | QueryBuilder::ModelQuery) @@ -187,7 +187,7 @@ module Jennifer def self.where_clause(io : String::Builder, tree) return unless tree - io << "WHERE " << tree.not_nil!.as_sql << "\n" + io << "WHERE " << tree.not_nil!.as_sql(self) << "\n" end def self.limit_clause(io : String::Builder, query) @@ -198,7 +198,7 @@ module Jennifer def self.order_clause(io : String::Builder, query) return if !query._order || query._order.empty? io << "ORDER BY " - query._order.not_nil!.join(", ", io) { |(k, v)| io.print k.as_sql, " ", v.upcase } + query._order.not_nil!.join(", ", io) { |(k, v)| io.print k.as_sql(self), " ", v.upcase } io << "\n" end @@ -254,6 +254,14 @@ module Jennifer end end + def self.filter_out(arg : QueryBuilder::Criteria) + arg.as_sql(self) + end + + def self.filter_out(arg) + escape_string(1) + end + # TODO: optimize array initializing def self.parse_query(query : String, arg_count : Int32) arr = [] of String diff --git a/src/jennifer/adapter/mysql/sql_generator.cr b/src/jennifer/adapter/mysql/sql_generator.cr index d88f56d7..8301a4f3 100644 --- a/src/jennifer/adapter/mysql/sql_generator.cr +++ b/src/jennifer/adapter/mysql/sql_generator.cr @@ -10,7 +10,7 @@ module Jennifer unless opts[:fields].empty? s << "(" opts[:fields].join(", ", s) - s << ") VALUES (" << Jennifer::Adapter.adapter_class.escape_string(opts[:fields].size) << ") " + s << ") VALUES (" << escape_string(opts[:fields].size) << ") " else s << " VALUES ()" end @@ -20,7 +20,7 @@ module Jennifer # Generates update request depending on given query and hash options. Allows # joins inside of query. def self.update(query, options : Hash) - esc = Jennifer::Adapter.adapter_class.escape_string(1) + esc = escape_string(1) String.build do |s| s << "UPDATE " << query.table s << "\n" @@ -28,7 +28,7 @@ module Jennifer unless _joins.nil? where_clause(s, _joins[0].on) - _joins[1..-1].join(" ", s) { |e| s << e.as_sql } + _joins[1..-1].join(" ", s) { |e| s << e.as_sql(self) } end s << " SET " options.join(", ", s) { |(k, v)| s << k << " = " << esc } diff --git a/src/jennifer/adapter/postgres/field.cr b/src/jennifer/adapter/postgres/field.cr index 69955722..4ac65c84 100644 --- a/src/jennifer/adapter/postgres/field.cr +++ b/src/jennifer/adapter/postgres/field.cr @@ -12,6 +12,7 @@ class PQ::Field private def load_table_name : String value = "" + # TODO: decouple from adapter ::Jennifer::Adapter.adapter.query("select relname from pg_class where oid = $1", @col_oid) do |rs| rs.each do value = rs.read(String) diff --git a/src/jennifer/adapter/postgres/sql_generator.cr b/src/jennifer/adapter/postgres/sql_generator.cr index 42c04837..c8c38167 100644 --- a/src/jennifer/adapter/postgres/sql_generator.cr +++ b/src/jennifer/adapter/postgres/sql_generator.cr @@ -10,7 +10,7 @@ module Jennifer unless opts[:fields].empty? s << "(" opts[:fields].join(", ", s) - s << ") VALUES (" << Jennifer::Adapter.adapter_class.escape_string(opts[:fields].size) << ") " + s << ") VALUES (" << escape_string(opts[:fields].size) << ") " else s << " DEFAULT VALUES" end @@ -25,17 +25,17 @@ module Jennifer # Generates update request depending on given query and hash options. Allows # joins inside of query. def self.update(query, options : Hash) - esc = Jennifer::Adapter.adapter_class.escape_string(1) + esc = escape_string(1) String.build do |s| s << "UPDATE " << query._table << " SET " options.map { |k, v| "#{k.to_s}= #{esc}" }.join(", ", s) s << "\n" - from_clause(s, query, query._joins![0].table_name) if query._joins + from_clause(s, query, query._joins![0].table_name(self)) if query._joins where_clause(s, query.tree) if query._joins where_clause(s, query._joins![0].on) - query._joins![1..-1].join(" ", s) { |e| s << e.as_sql } + query._joins![1..-1].join(" ", s) { |e| s << e.as_sql(self) } end end end diff --git a/src/jennifer/adapter/record.cr b/src/jennifer/adapter/record.cr index e893c024..4dfbc3db 100644 --- a/src/jennifer/adapter/record.cr +++ b/src/jennifer/adapter/record.cr @@ -6,7 +6,8 @@ module Jennifer end def initialize(result_set : DB::ResultSet) - @attributes = Adapter.adapter.result_to_hash(result_set) + # TODO: decouple adapter + @attributes = Adapter.default_adapter.result_to_hash(result_set) end def initialize diff --git a/src/jennifer/migration/runner.cr b/src/jennifer/migration/runner.cr index 3a2a4898..bb6f5465 100644 --- a/src/jennifer/migration/runner.cr +++ b/src/jennifer/migration/runner.cr @@ -5,7 +5,7 @@ module Jennifer def self.migrate(count) performed = false - Adapter.adapter.ready_to_migrate! + default_adapter.ready_to_migrate! return if ::Jennifer::Migration::Base.migrations.empty? interpolation = {} of String => typeof(Base.migrations[0]) Base.migrations.each { |m| interpolation[m.version] = m } @@ -41,7 +41,8 @@ module Jennifer puts e.message puts e.backtrace.join("\n") ensure - Adapter.adapter_class.generate_schema if performed + # TODO: generate schema for each adapter + default_adapter.generate_schema if performed end def self.migrate @@ -49,19 +50,21 @@ module Jennifer end def self.create - r = Adapter.adapter_class.create_database + # TODO: allow to specify adapter + r = default_adapter.create_database puts "DB is created!" r end def self.drop - Adapter.adapter_class.drop_database + # TODO: allow to specify adapter + default_adapter.drop_database puts "DB is dropped!" end def self.rollback(options : Hash(Symbol, DBAny)) processed = true - Adapter.adapter.ready_to_migrate! + default_adapter.ready_to_migrate! return if ::Jennifer::Migration::Base.migrations.empty? || !Version.all.exists? interpolation = {} of String => typeof(Base.migrations[0]) Base.migrations.each { |m| interpolation[m.version] = m } @@ -86,11 +89,13 @@ module Jennifer rescue e puts e.message ensure - Adapter.adapter_class.generate_schema if processed + # TODO: generate schema for each adapter + default_adapter.generate_schema if processed end def self.load_schema - Adapter.adapter_class.load_schema + # TODO: load schema for each adapter + default_adapter.load_schema end def self.generate(name) @@ -102,6 +107,10 @@ module Jennifer rescue e puts e.message end + + def self.default_adapter + Adapter.default_adapter + end end end end diff --git a/src/jennifer/model/base.cr b/src/jennifer/model/base.cr index fcd57b2b..147d01d0 100644 --- a/src/jennifer/model/base.cr +++ b/src/jennifer/model/base.cr @@ -25,12 +25,12 @@ module Jennifer @@expression_builder : QueryBuilder::ExpressionBuilder? def self.has_table? - @@has_table ||= Jennifer::Adapter.adapter.table_exists?(table_name).as(Bool) + @@has_table ||= adapter.table_exists?(table_name).as(Bool) end # Represent actual amount of model's table column amount (is greped from db). def self.actual_table_field_count - @@actual_table_field_count ||= ::Jennifer::Adapter.adapter.table_column_count(table_name) + @@actual_table_field_count ||= adapter.table_column_count(table_name) end def self.table_name(value : String | Symbol) @@ -160,7 +160,7 @@ module Jennifer # Deletes object from db and calls callbacks def destroy - unless ::Jennifer::Adapter.adapter.under_transaction? + unless self.class.adapter.under_transaction? {{@type}}.transaction do destroy_without_transaction end @@ -199,7 +199,7 @@ module Jennifer # Starts transaction. def self.transaction - Adapter.adapter.transaction do |t| + adapter.transaction do |t| yield(t) end end @@ -271,7 +271,7 @@ module Jennifer def self.search_by_sql(query : String, args = [] of Supportable) result = [] of self - ::Jennifer::Adapter.adapter.query(query, args) do |rs| + adapter.query(query, args) do |rs| rs.each do result << build(rs) end @@ -280,7 +280,7 @@ module Jennifer end def self.import(collection : Array(self)) - Adapter.adapter.bulk_insert(collection) + adapter.bulk_insert(collection) end macro inherited diff --git a/src/jennifer/model/mapping.cr b/src/jennifer/model/mapping.cr index a224b9c5..c9c92df6 100644 --- a/src/jennifer/model/mapping.cr +++ b/src/jennifer/model/mapping.cr @@ -103,7 +103,7 @@ module Jennifer @@strict_mapping : Bool? def self.strict_mapping? - @@strict_mapping ||= ::Jennifer::Adapter.adapter.table_column_count(table_name) == field_count + @@strict_mapping ||= adapter.table_column_count(table_name) == field_count end {% @@ -204,7 +204,7 @@ module Jennifer \{% begin %} \{% klasses = @type.all_subclasses.select { |s| s.constant("STI") == true } %} \{% if !klasses.empty? %} - hash = ::Jennifer::Adapter.adapter.result_to_hash(pull) + hash = adapter.result_to_hash(pull) o = case hash["type"] when "", nil, "\{{@type}}" @@ -368,7 +368,7 @@ module Jennifer end def save(skip_validation : Bool = false) : Bool - unless ::Jennifer::Adapter.adapter.under_transaction? + unless self.class.adapter.under_transaction? {{@type}}.transaction do save_without_transaction(skip_validation) end || false @@ -389,7 +389,7 @@ module Jennifer response = if new_record? return false unless __before_create_callback - res = ::Jennifer::Adapter.adapter.insert(self) + res = self.class.adapter.insert(self) {% if primary && primary_auto_incrementable %} if primary.nil? && res.last_insert_id > -1 init_primary_field(res.last_insert_id.to_i) @@ -399,7 +399,7 @@ module Jennifer __after_create_callback res else - ::Jennifer::Adapter.adapter.update(self) + self.class.adapter.update(self) end __after_save_callback response.rows_affected == 1 @@ -470,7 +470,7 @@ module Jennifer end end - ::Jennifer::Adapter.adapter.update(self) + self.class.adapter.update(self) __refresh_changes end diff --git a/src/jennifer/model/relation_definition.cr b/src/jennifer/model/relation_definition.cr index 00990c7b..b12e0633 100644 --- a/src/jennifer/model/relation_definition.cr +++ b/src/jennifer/model/relation_definition.cr @@ -173,7 +173,7 @@ module Jennifer def __{{name.id}}_clean relation = self.class.{{name.id}}_relation this = self - ::Jennifer::Adapter.adapter.delete(::Jennifer::QueryBuilder::Query.new(relation.join_table!).where do + self.class.adapter.delete(::Jennifer::QueryBuilder::Query.new(relation.join_table!).where do c(relation.foreign_field) == this.attribute(relation.primary_field) end) end diff --git a/src/jennifer/model/sti_mapping.cr b/src/jennifer/model/sti_mapping.cr index 14354c65..b2c0c637 100644 --- a/src/jennifer/model/sti_mapping.cr +++ b/src/jennifer/model/sti_mapping.cr @@ -93,7 +93,7 @@ module Jennifer # creates object from db tuple def initialize(%pull : DB::ResultSet) - initialize(::Jennifer::Adapter.adapter.result_to_hash(%pull), false) + initialize(self.class.adapter.result_to_hash(%pull), false) end def initialize(values : Hash(Symbol, ::Jennifer::DBAny) | NamedTuple) @@ -125,7 +125,7 @@ module Jennifer raise ::Jennifer::RecordNotFound.new("It is not persisted yet") if new_record? this = self self.class.all.where { this.class.primary == this.primary }.limit(1).each_result_set do |rs| - values = ::Jennifer::Adapter.adapter.result_to_hash(rs) + values = self.class.adapter.result_to_hash(rs) init_attributes(values) end __refresh_changes diff --git a/src/jennifer/query_builder/aggregations.cr b/src/jennifer/query_builder/aggregations.cr index 62934cc7..01ff6f82 100644 --- a/src/jennifer/query_builder/aggregations.cr +++ b/src/jennifer/query_builder/aggregations.cr @@ -2,7 +2,7 @@ module Jennifer module QueryBuilder module Aggregations def count : Int32 - ::Jennifer::Adapter.adapter.count(self) + adapter.count(self) end def max(field, klass : T.class) : T forall T diff --git a/src/jennifer/query_builder/all.cr b/src/jennifer/query_builder/all.cr index f73dae88..a1f422a2 100644 --- a/src/jennifer/query_builder/all.cr +++ b/src/jennifer/query_builder/all.cr @@ -9,7 +9,7 @@ module Jennifer def initialize(@query) end - def as_sql + def as_sql(_sql_generator) "ALL (#{@query.to_sql})" end end diff --git a/src/jennifer/query_builder/any.cr b/src/jennifer/query_builder/any.cr index 69c6e11c..8063cfb7 100644 --- a/src/jennifer/query_builder/any.cr +++ b/src/jennifer/query_builder/any.cr @@ -9,7 +9,7 @@ module Jennifer def initialize(@query) end - def as_sql + def as_sql(_generator) "ANY (#{@query.to_sql})" end end diff --git a/src/jennifer/query_builder/condition.cr b/src/jennifer/query_builder/condition.cr index a30db854..b0ed0cff 100644 --- a/src/jennifer/query_builder/condition.cr +++ b/src/jennifer/query_builder/condition.cr @@ -54,28 +54,22 @@ module Jennifer as_sql end - def parsed_rhs - if filterable? - filter_out(@rhs) - elsif @rhs.is_a?(Criteria) - @rhs.as(Criteria).as_sql - else - @rhs.to_s - end + def as_sql + as_sql(Adapter.default_adapter.sql_generator) end - def as_sql - _lhs = @lhs.as_sql + def as_sql(generator) + _lhs = @lhs.as_sql(generator) str = case @operator when :bool _lhs when :in - "#{_lhs} IN(#{Adapter.adapter.sql_generator.escape_string(@rhs.as(Array).size)})" + "#{_lhs} IN(#{generator.escape_string(@rhs.as(Array).size)})" when :between - "#{_lhs} BETWEEN #{Adapter.escape_string(1)} AND #{Adapter.escape_string(1)}" + "#{_lhs} BETWEEN #{generator.escape_string(1)} AND #{generator.escape_string(1)}" else - "#{_lhs} #{Adapter.adapter.sql_generator.operator_to_sql(@operator)} #{parsed_rhs}" + "#{_lhs} #{generator.operator_to_sql(@operator)} #{parsed_rhs(generator)}" end str = "NOT (#{str})" if @negative str @@ -83,7 +77,7 @@ module Jennifer def sql_args : Array(DB::Any) res = [] of DB::Any - if filterable? + if filterable? && !(@operator == :is || @operator == :is_not) if @operator == :in || @operator == :between @rhs.as(Array).each do |e| unless e.is_a?(Criteria) @@ -100,7 +94,7 @@ module Jennifer end def sql_args_count - if filterable? + if filterable? && !(@operator == :is || @operator == :is_not) count = 0 if @operator == :in || @operator == :between @rhs.as(Array).each do |e| @@ -115,21 +109,35 @@ module Jennifer end end - def filter_out(arg : Criteria) - arg.as_sql - end - - def filter_out(arg) - Adapter.escape_string(1) - end - private def filterable? !( @rhs.is_a?(Criteria) || - @operator == :bool || - RAW_OPERATORS.includes?(@operator) + @operator == :bool ) end + + private def parsed_rhs(generator) + if @operator == :is || @operator == :is_not + translate(generator) + elsif filterable? + generator.filter_out(@rhs) + elsif @rhs.is_a?(Criteria) + @rhs.as(Criteria).as_sql(generator) + else + @rhs.to_s + end + end + + private def translate(generator) + case @rhs + when nil, true, false + generator.quote(@rhs.as(Nil | Bool)) + when :unknown + "UNKNOWN" + when :nil + generator.quote(nil) + end + end end end end diff --git a/src/jennifer/query_builder/criteria.cr b/src/jennifer/query_builder/criteria.cr index db6e31bc..3b501e7b 100644 --- a/src/jennifer/query_builder/criteria.cr +++ b/src/jennifer/query_builder/criteria.cr @@ -16,7 +16,7 @@ module Jennifer # NOTE: workaround for passing criteria to the hash as a key - somewhy any Criteria is realized as same one def hash - object_id.hash + as_sql.hash end def set_relation(table : String, name : String) @@ -95,11 +95,11 @@ module Jennifer end def is(value : Symbol | Bool | Nil) - Condition.new(self, :is, translate(value)) + Condition.new(self, :is, value) end def not(value : Symbol | Bool | Nil) - Condition.new(self, :is_not, translate(value)) + Condition.new(self, :is_not, value) end def not @@ -127,7 +127,7 @@ module Jennifer as_sql end - def as_sql : String + def as_sql(_generator) : String @ident ||= identifier end @@ -150,17 +150,6 @@ module Jennifer def to_condition Condition.new(self) end - - private def translate(value : Symbol | Bool | Nil) - case value - when nil, true, false - Adapter.adapter.sql_generator.quote(value) - when :unknown - "UNKNOWN" - when :nil - Adapter.adapter.sql_generator.quote(nil) - end - end end end end diff --git a/src/jennifer/query_builder/executables.cr b/src/jennifer/query_builder/executables.cr index e7733bd9..6e8db4b3 100644 --- a/src/jennifer/query_builder/executables.cr +++ b/src/jennifer/query_builder/executables.cr @@ -18,7 +18,7 @@ module Jennifer result = to_a reverse_order @limit = old_limit - raise RecordNotFound.new(Adapter.adapter.sql_generator.select(self)) if result.empty? + raise RecordNotFound.new(adapter.sql_generator.select(self)) if result.empty? result[0] end @@ -34,37 +34,37 @@ module Jennifer old_limit = @limit result = to_a @limit = old_limit - raise RecordNotFound.new(Adapter.adapter.sql_generator.select(self)) if result.empty? + raise RecordNotFound.new(adapter.sql_generator.select(self)) if result.empty? result[0] end def pluck(fields : Array) - ::Jennifer::Adapter.adapter.pluck(self, fields.map(&.to_s)) + adapter.pluck(self, fields.map(&.to_s)) end def pluck(field : String | Symbol) - ::Jennifer::Adapter.adapter.pluck(self, field.to_s) + adapter.pluck(self, field.to_s) end def pluck(*fields : String | Symbol) - ::Jennifer::Adapter.adapter.pluck(self, fields.to_a.map(&.to_s)) + adapter.pluck(self, fields.to_a.map(&.to_s)) end def delete - ::Jennifer::Adapter.adapter.delete(self) + adapter.delete(self) end def exists? - ::Jennifer::Adapter.adapter.exists?(self) + adapter.exists?(self) end # skips any callbacks and validations def modify(options : Hash) - ::Jennifer::Adapter.adapter.modify(self, options) + adapter.modify(self, options) end def update(options : Hash) - ::Jennifer::Adapter.adapter.update(self, options) + adapter.update(self, options) end def update(**options) @@ -115,7 +115,7 @@ module Jennifer result = [] of Hash(String, DBAny) return result if @do_nothing each_result_set do |rs| - result << Adapter.adapter.result_to_hash(rs) + result << adapter.result_to_hash(rs) end result end @@ -139,7 +139,7 @@ module Jennifer end def each_result_set(&block) - ::Jennifer::Adapter.adapter.select(self) do |rs| + adapter.select(self) do |rs| begin rs.each do yield rs @@ -217,7 +217,7 @@ module Jennifer def find_records_by_sql(query : String, args : Array(DBAny) = [] of DBAny) results = [] of Record return results if @do_nothing - ::Jennifer::Adapter.adapter.query(query, args) do |rs| + adapter.query(query, args) do |rs| begin rs.each do results << Record.new(rs) diff --git a/src/jennifer/query_builder/grouping.cr b/src/jennifer/query_builder/grouping.cr index 7dec3453..01898c62 100644 --- a/src/jennifer/query_builder/grouping.cr +++ b/src/jennifer/query_builder/grouping.cr @@ -12,8 +12,8 @@ module Jennifer def initialize(@condition) end - def as_sql - "(" + @condition.as_sql + ")" + def as_sql(generator) + "(" + @condition.as_sql(generator) + ")" end end end diff --git a/src/jennifer/query_builder/join.cr b/src/jennifer/query_builder/join.cr index c7dcbf7e..c77978a2 100644 --- a/src/jennifer/query_builder/join.cr +++ b/src/jennifer/query_builder/join.cr @@ -22,28 +22,33 @@ module Jennifer end def as_sql - sql_string = - case @type - when :left - "LEFT JOIN " - when :right - "RIGHT JOIN " - when :inner - "JOIN " - when :full, :full_outer - "FULL OUTER JOIN " - else - raise ArgumentError.new("Bad join type: #{@type}.") - end + as_sql(Adapter.default_adapter.sql_generator) + end + + def as_sql(generator) + type_definition + "#{table_definition(generator)} ON #{@on.as_sql(generator)}\n" + end - sql_string + "#{table_definition} ON #{@on.as_sql}\n" + def type_definition + case @type + when :left + "LEFT JOIN " + when :right + "RIGHT JOIN " + when :inner + "JOIN " + when :full, :full_outer + "FULL OUTER JOIN " + else + raise ArgumentError.new("Bad join type: #{@type}.") + end end - def table_definition - @aliass ? "#{table_name} #{@aliass}" : table_name + def table_definition(generator) + @aliass ? "#{table_name(generator)} #{@aliass}" : table_name(generator) end - def table_name : String + def table_name(generator) : String if @table.is_a?(String) @table.as(String) else @@ -66,9 +71,8 @@ module Jennifer end class LateralJoin < Join - def as_sql - sql_string = - case @type + def type_definition + case @type when :left "LEFT JOIN " when :right @@ -80,8 +84,6 @@ module Jennifer else raise ArgumentError.new("Bad join type: #{@type}.") end + "LATERAL " - - sql_string + "#{table_definition} ON #{@on.as_sql}\n" end end end diff --git a/src/jennifer/query_builder/json_selector.cr b/src/jennifer/query_builder/json_selector.cr index d1c782dd..263dccd2 100644 --- a/src/jennifer/query_builder/json_selector.cr +++ b/src/jennifer/query_builder/json_selector.cr @@ -12,8 +12,8 @@ module Jennifer initialize(criteria.field, criteria.table, criteria.relation) end - def as_sql - Adapter.adapter.sql_generator.json_path(self) + def as_sql(generator) + generator.json_path(self) end end end diff --git a/src/jennifer/query_builder/logic_operator.cr b/src/jennifer/query_builder/logic_operator.cr index 4b693ae7..24412bb9 100644 --- a/src/jennifer/query_builder/logic_operator.cr +++ b/src/jennifer/query_builder/logic_operator.cr @@ -64,7 +64,11 @@ module Jennifer end def as_sql - @lhs.as_sql + " " + operator + " " + @rhs.as_sql + as_sql(Adapter.default_adapter.sql_generator) + end + + def as_sql(generator) + @lhs.as_sql(generator) + " " + operator + " " + @rhs.as_sql(generator) end def sql_args diff --git a/src/jennifer/query_builder/model_query.cr b/src/jennifer/query_builder/model_query.cr index 18fb4d69..cf9dadf3 100644 --- a/src/jennifer/query_builder/model_query.cr +++ b/src/jennifer/query_builder/model_query.cr @@ -28,7 +28,7 @@ module Jennifer def find_by_sql(query : String, args : Array(DBAny) = [] of DBAny) results = [] of T return results if @do_nothing - ::Jennifer::Adapter.adapter.query(query, args) do |rs| + adapter.query(query, args) do |rs| begin rs.each do results << T.build(rs) @@ -47,7 +47,7 @@ module Jennifer add_aliases if @relation_used return to_a_with_relations unless @relations.empty? result = [] of T - ::Jennifer::Adapter.adapter.select(self) do |rs| + adapter.select(self) do |rs| rs.each do begin result << T.build(rs) @@ -68,7 +68,7 @@ module Jennifer models = @relations.map { |e| T.relation(e).model_class } existence = @relations.map { |_| {} of String => Bool } - ::Jennifer::Adapter.adapter.select(self) do |rs| + adapter.select(self) do |rs| rs.each do begin h = build_hash(rs, T.actual_table_field_count) @@ -102,6 +102,10 @@ module Jennifer end add_preloaded(collection) end + + private def adapter + T.adapter + end end end end diff --git a/src/jennifer/query_builder/query.cr b/src/jennifer/query_builder/query.cr index c3f9dea1..db54fa74 100644 --- a/src/jennifer/query_builder/query.cr +++ b/src/jennifer/query_builder/query.cr @@ -123,11 +123,15 @@ module Jennifer end def to_sql - Adapter.adapter.sql_generator.select(self) + adapter.sql_generator.select(self) end def as_sql - @tree ? @tree.not_nil!.as_sql : "" + @tree ? @tree.not_nil!.as_sql(adapter.sql_generator) : "" + end + + def as_sql(_generator) + @tree ? @tree.not_nil!.as_sql(adapter.sql_generator) : "" end def sql_args @@ -336,6 +340,10 @@ module Jennifer private def _groups(name : String) @group[name] ||= [] of String end + + private def adapter + Adapter.default_adapter + end end end diff --git a/src/jennifer/query_builder/sql_node.cr b/src/jennifer/query_builder/sql_node.cr index f753f05a..a1ae4797 100644 --- a/src/jennifer/query_builder/sql_node.cr +++ b/src/jennifer/query_builder/sql_node.cr @@ -1,9 +1,13 @@ module Jennifer module QueryBuilder abstract class SQLNode - abstract def as_sql + abstract def as_sql(sql_generator) abstract def sql_args : Array + def as_sql + as_sql(Adapter.default_adapter.sql_generator) + end + def sql_args_count : Int32 sql_args.size end diff --git a/src/jennifer/relation/base.cr b/src/jennifer/relation/base.cr index 658ec95d..0e15ddbc 100644 --- a/src/jennifer/relation/base.cr +++ b/src/jennifer/relation/base.cr @@ -32,6 +32,10 @@ module Jennifer T end + def adapter + Q.adapter + end + def condition_clause _foreign = foreign_field _primary = primary_field diff --git a/src/jennifer/relation/many_to_many.cr b/src/jennifer/relation/many_to_many.cr index a8331999..5a42075b 100644 --- a/src/jennifer/relation/many_to_many.cr +++ b/src/jennifer/relation/many_to_many.cr @@ -9,11 +9,10 @@ module Jennifer @primary = primary.to_s if primary @join_query = query.tree @join_query.not_nil!.set_relation(T.table_name, @name) if @join_query - @join_table = ::Jennifer::Adapter.adapter_class.join_table_name(Q.table_name, T.table_name) unless @join_table end def join_table! - @join_table.not_nil! + @join_table ? @join_table.not_nil! : adapter.class.join_table_name(Q.table_name, T.table_name) end def insert(obj : Q, rel : Hash) @@ -73,7 +72,7 @@ module Jennifer end private def add_join_table_record(obj, rel) - Adapter.adapter.insert( + adapter.insert( join_table!, { foreign_field => obj.attribute(primary_field), diff --git a/src/jennifer/view/experimental_mapping.cr b/src/jennifer/view/experimental_mapping.cr index 0b200b50..1004af86 100644 --- a/src/jennifer/view/experimental_mapping.cr +++ b/src/jennifer/view/experimental_mapping.cr @@ -249,7 +249,7 @@ module Jennifer @@strict_mapping : Bool? def self.strict_mapping? - @@strict_mapping ||= ::Jennifer::Adapter.adapter.table_column_count(table_name) == field_count + @@strict_mapping ||= adapter.table_column_count(table_name) == field_count end # Returns field count From 8af397f1601c986003fb2bfaab8e7388c14f6991 Mon Sep 17 00:00:00 2001 From: Roman Kalnytskyi Date: Thu, 4 Jan 2018 13:34:39 +0200 Subject: [PATCH 08/19] Remove result parser #table_row_hash --- .gitignore | 3 +- .travis.setup.sh | 3 +- ...essor_spec.cr => schema_processor_spec.cr} | 4 +- spec/adapter/result_parsers_spec.cr | 49 +++++++++++++++---- spec/spec_helper.cr | 11 +++++ src/jennifer/adapter.cr | 4 ++ src/jennifer/adapter/base.cr | 4 +- src/jennifer/adapter/postgres.cr | 7 ++- src/jennifer/adapter/postgres/field.cr | 24 --------- .../postgres/migration/table_builder/base.cr | 4 +- .../migration/table_builder/change_enum.cr | 8 +-- .../migration/table_builder/create_enum.cr | 2 +- .../migration/table_builder/drop_enum.cr | 2 +- ...ation_processor.cr => schema_processor.cr} | 4 +- src/jennifer/adapter/result_parsers.cr | 13 ----- ...ation_processor.cr => schema_processor.cr} | 6 +-- src/jennifer/adapter/sqlite3.cr | 6 +-- ...ation_processor.cr => schema_processor.cr} | 4 +- src/jennifer/migration/base.cr | 6 +-- src/jennifer/migration/runner.cr | 15 +++--- src/jennifer/migration/table_builder/base.cr | 2 +- .../migration/table_builder/change_table.cr | 8 +-- .../migration/table_builder/create_index.cr | 2 +- .../migration/table_builder/create_table.cr | 2 +- .../migration/table_builder/create_view.cr | 2 +- .../migration/table_builder/drop_index.cr | 2 +- .../migration/table_builder/drop_table.cr | 2 +- .../migration/table_builder/drop_view.cr | 2 +- src/jennifer/query_builder/criteria.cr | 2 +- 29 files changed, 107 insertions(+), 96 deletions(-) rename spec/adapter/postgres/{migration_processor_spec.cr => schema_processor_spec.cr} (89%) delete mode 100644 src/jennifer/adapter/postgres/field.cr rename src/jennifer/adapter/postgres/{migration_processor.cr => schema_processor.cr} (97%) rename src/jennifer/adapter/{migration_processor.cr => schema_processor.cr} (98%) rename src/jennifer/adapter/sqlite3/{migration_processor.cr => schema_processor.cr} (93%) diff --git a/.gitignore b/.gitignore index 1c48bf40..0e619abf 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ /bin/ /.shards/ .idea +jennifer.code-workspace /spec/fixtures/*.db # Libraries don't need dependency lock @@ -11,4 +12,4 @@ /shard.lock /examples/structure.sql /examples/test.cr -/examples/test.db \ No newline at end of file +/examples/test.db diff --git a/.travis.setup.sh b/.travis.setup.sh index 543b33cf..393bd19c 100644 --- a/.travis.setup.sh +++ b/.travis.setup.sh @@ -1,5 +1,4 @@ -set -e -set -o pipefail +set -exo pipefail if [ "$DB" == 'postgres' ] || [ "$PAIR" == '1' ]; then echo "===================================" diff --git a/spec/adapter/postgres/migration_processor_spec.cr b/spec/adapter/postgres/schema_processor_spec.cr similarity index 89% rename from spec/adapter/postgres/migration_processor_spec.cr rename to spec/adapter/postgres/schema_processor_spec.cr index 8e89ef2c..4051b38d 100644 --- a/spec/adapter/postgres/migration_processor_spec.cr +++ b/spec/adapter/postgres/schema_processor_spec.cr @@ -1,9 +1,9 @@ require "../../spec_helper" postgres_only do - describe Jennifer::Postgres::MigrationProcessor do + describe Jennifer::Postgres::SchemaProcessor do adapter = Jennifer::Adapter.adapter - processor = adapter.migration_processor + processor = adapter.schema_processor context "index manipulation" do index_name = "contacts_age_index" diff --git a/spec/adapter/result_parsers_spec.cr b/spec/adapter/result_parsers_spec.cr index 2d75b14d..c2384554 100644 --- a/spec/adapter/result_parsers_spec.cr +++ b/spec/adapter/result_parsers_spec.cr @@ -1,25 +1,56 @@ require "../spec_helper" describe Jennifer::Adapter::ResultParsers do - adapter = Jennifer::Adapter.adapter + adapter = Jennifer::Adapter.default_adapter + contact_fields = db_specific( + postgres: -> { %w(id name age tags ballance gender created_at updated_at description) }, + mysql: -> { %w(id name age ballance gender created_at updated_at description) } + ) describe "#result_to_hash" do - pending "converts result set to hash with string keys" do + it "converts result set to hash with string keys" do + Factory.create_contact + executed = false + Contact.all.each_result_set do |rs| + executed = true + hash = adapter.result_to_hash(rs) + hash.keys.should eq(contact_fields) + hash["id"].should_not be_nil + hash["name"].should eq("Deepthi") + hash["age"].should eq(28) + end + executed.should be_true end end describe "#result_to_array" do - pending "add" do + it "converts result set to array" do + Factory.create_contact + executed = false + Contact.all.each_result_set do |rs| + executed = true + array = adapter.result_to_array(rs) + array.size.should eq(contact_fields.size) + array[0].should_not be_nil + array[1].should eq("Deepthi") + array[2].should eq(28) + end + executed.should be_true end end describe "#result_to_array_by_names" do - pending "add" do - end - end - - describe "#table_row_hash" do - pending "add" do + it "converts result set to array" do + Factory.create_contact + executed = false + Contact.all.each_result_set do |rs| + executed = true + arr = adapter.result_to_array_by_names(rs, %w(name age)) + arr.size.should eq(2) + arr[0].should eq("Deepthi") + arr[1].should eq(28) + end + executed.should be_true end end end diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr index edf70aa4..22dbd5e3 100644 --- a/spec/spec_helper.cr +++ b/spec/spec_helper.cr @@ -81,6 +81,17 @@ def sb String.build { |io| yield io } end +def db_specific(mysql, postgres) + case Spec.adapter + when "postgres" + postgres.call + when "mysql" + mysql.call + else + raise "Unknown adapter type" + end +end + # Matchers ====================== def match_array(expect, target) diff --git a/src/jennifer/adapter.cr b/src/jennifer/adapter.cr index 4fe6d139..6aa58228 100644 --- a/src/jennifer/adapter.cr +++ b/src/jennifer/adapter.cr @@ -60,6 +60,10 @@ module Jennifer adapter end + def self.default_adapter_class + adapter_class + end + def self.adapter_class @@adapter_class ||= adapters[Config.adapter] end diff --git a/src/jennifer/adapter/base.cr b/src/jennifer/adapter/base.cr index 3eac4a07..20b33c94 100644 --- a/src/jennifer/adapter/base.cr +++ b/src/jennifer/adapter/base.cr @@ -206,7 +206,7 @@ module Jennifer def ready_to_migrate! return if table_exists?(Migration::Base::TABLE_NAME) - migration_processor.build_create_table(Migration::Base::TABLE_NAME) do |t| + schema_processor.build_create_table(Migration::Base::TABLE_NAME) do |t| t.string(:version, {:size => 17}) end end @@ -225,7 +225,7 @@ module Jennifer result end - abstract def migration_processor + abstract def schema_processor abstract def sql_generator abstract def view_exists?(name, silent = true) abstract def update(obj) diff --git a/src/jennifer/adapter/postgres.cr b/src/jennifer/adapter/postgres.cr index 821efbac..784445cc 100644 --- a/src/jennifer/adapter/postgres.cr +++ b/src/jennifer/adapter/postgres.cr @@ -3,11 +3,10 @@ require "../adapter" require "./base" require "./postgres/result_set" -require "./postgres/field" require "./postgres/exec_result" require "./postgres/sql_generator" -require "./postgres/migration_processor" +require "./postgres/schema_processor" module Jennifer module Postgres @@ -77,8 +76,8 @@ module Jennifer SQLGenerator end - def migration_processor - @migration_processor ||= MigrationProcessor.new(self) + def schema_processor + @schema_processor ||= SchemaProcessor.new(self) end def prepare diff --git a/src/jennifer/adapter/postgres/field.cr b/src/jennifer/adapter/postgres/field.cr deleted file mode 100644 index 4ac65c84..00000000 --- a/src/jennifer/adapter/postgres/field.cr +++ /dev/null @@ -1,24 +0,0 @@ -class PQ::Field - @@table_names = {} of Int32 => String - - def table - val = @@table_names[@col_oid]? - if val - val.not_nil! - else - @@table_names[@col_oid] = load_table_name - end - end - - private def load_table_name : String - value = "" - # TODO: decouple from adapter - ::Jennifer::Adapter.adapter.query("select relname from pg_class where oid = $1", @col_oid) do |rs| - rs.each do - value = rs.read(String) - end - end - raise "table not found" if value.empty? - value - end -end diff --git a/src/jennifer/adapter/postgres/migration/table_builder/base.cr b/src/jennifer/adapter/postgres/migration/table_builder/base.cr index a9ef060f..1f5d15ed 100644 --- a/src/jennifer/adapter/postgres/migration/table_builder/base.cr +++ b/src/jennifer/adapter/postgres/migration/table_builder/base.cr @@ -7,8 +7,8 @@ module Jennifer @adapter.as(Postgres::Adapter) end - def migration_processor - @adapter.migration_processor.as(MigrationProcessor) + def schema_processor + @adapter.schema_processor.as(SchemaProcessor) end end end diff --git a/src/jennifer/adapter/postgres/migration/table_builder/change_enum.cr b/src/jennifer/adapter/postgres/migration/table_builder/change_enum.cr index 11e0ffaf..5fe31676 100644 --- a/src/jennifer/adapter/postgres/migration/table_builder/change_enum.cr +++ b/src/jennifer/adapter/postgres/migration/table_builder/change_enum.cr @@ -21,18 +21,18 @@ module Jennifer new_values = adapter.enum_values(@name) new_values -= @options[:remove_values] if @effected_tables.empty? - migration_processor.drop_enum(@name) - migration_processor.define_enum(@name, new_values) + schema_processor.drop_enum(@name) + schema_processor.define_enum(@name, new_values) else temp_name = "#{@name}_temp" - migration_processor.define_enum(temp_name, new_values) + schema_processor.define_enum(temp_name, new_values) @effected_tables.each do |row| @adapter.exec <<-SQL ALTER TABLE #{row[0]} ALTER COLUMN #{row[1]} TYPE #{temp_name} USING (#{row[1]}::text::#{temp_name}) SQL - migration_processor.drop_enum(@name) + schema_processor.drop_enum(@name) rename(temp_name, @name) end end diff --git a/src/jennifer/adapter/postgres/migration/table_builder/create_enum.cr b/src/jennifer/adapter/postgres/migration/table_builder/create_enum.cr index ad053ee0..23abeedd 100644 --- a/src/jennifer/adapter/postgres/migration/table_builder/create_enum.cr +++ b/src/jennifer/adapter/postgres/migration/table_builder/create_enum.cr @@ -8,7 +8,7 @@ module Jennifer end def process - migration_processor.define_enum(@name, @values) + schema_processor.define_enum(@name, @values) end end end diff --git a/src/jennifer/adapter/postgres/migration/table_builder/drop_enum.cr b/src/jennifer/adapter/postgres/migration/table_builder/drop_enum.cr index b19d1cc4..e8047e3c 100644 --- a/src/jennifer/adapter/postgres/migration/table_builder/drop_enum.cr +++ b/src/jennifer/adapter/postgres/migration/table_builder/drop_enum.cr @@ -8,7 +8,7 @@ module Jennifer end def process - migration_processor.drop_enum(@name) + schema_processor.drop_enum(@name) end end end diff --git a/src/jennifer/adapter/postgres/migration_processor.cr b/src/jennifer/adapter/postgres/schema_processor.cr similarity index 97% rename from src/jennifer/adapter/postgres/migration_processor.cr rename to src/jennifer/adapter/postgres/schema_processor.cr index 1884567e..a6196926 100644 --- a/src/jennifer/adapter/postgres/migration_processor.cr +++ b/src/jennifer/adapter/postgres/schema_processor.cr @@ -1,8 +1,8 @@ -require "../migration_processor" +require "../schema_processor" module Jennifer module Postgres - class MigrationProcessor < Adapter::MigrationProcessor + class SchemaProcessor < Adapter::SchemaProcessor delegate data_type_exists?, to: adapter.as(Postgres) # ================ diff --git a/src/jennifer/adapter/result_parsers.cr b/src/jennifer/adapter/result_parsers.cr index be88616b..bfe30254 100644 --- a/src/jennifer/adapter/result_parsers.cr +++ b/src/jennifer/adapter/result_parsers.cr @@ -42,19 +42,6 @@ module Jennifer end h end - - # converts single ResultSet which contains several tables - def table_row_hash(rs) - h = {} of String => Hash(String, DBAny) - rs.columns.each do |col| - h[col.table] ||= {} of String => DBAny - h[col.table][col.name] = rs.read - if h[col.table][col.name].is_a?(Int8) - h[col.table][col.name] = h[col.table][col.name] == 1i8 - end - end - h - end end end end diff --git a/src/jennifer/adapter/migration_processor.cr b/src/jennifer/adapter/schema_processor.cr similarity index 98% rename from src/jennifer/adapter/migration_processor.cr rename to src/jennifer/adapter/schema_processor.cr index 8d3ad1ed..1a866bef 100644 --- a/src/jennifer/adapter/migration_processor.cr +++ b/src/jennifer/adapter/schema_processor.cr @@ -2,7 +2,7 @@ require "../migration/table_builder/*" module Jennifer module Adapter - class MigrationProcessor + class SchemaProcessor macro unsupported_method(*names) {% for name in names %} def {{name.id}}(*args, **opts) @@ -236,8 +236,8 @@ module Jennifer end class Base - def migration_processor - @migration_processor ||= MigrationProcessor.new(self) + def schema_processor + @schema_processor ||= SchemaProcessor.new(self) end end end diff --git a/src/jennifer/adapter/sqlite3.cr b/src/jennifer/adapter/sqlite3.cr index abd6bbe3..7bc2271f 100644 --- a/src/jennifer/adapter/sqlite3.cr +++ b/src/jennifer/adapter/sqlite3.cr @@ -1,7 +1,7 @@ require "sqlite3" require "../adapter" require "./sqlite3/sql_notation" -require "./sqlite3/migration_processor" +require "./sqlite3/schema_processor" module Jennifer module Sqlite3 @@ -24,8 +24,8 @@ module Jennifer SQLGenerator end - def migration_processor - @migration_processor ||= MigrationProcessor.new(self) + def schema_processor + @schema_processor ||= SchemaProcessor.new(self) end def translate_type(name) diff --git a/src/jennifer/adapter/sqlite3/migration_processor.cr b/src/jennifer/adapter/sqlite3/schema_processor.cr similarity index 93% rename from src/jennifer/adapter/sqlite3/migration_processor.cr rename to src/jennifer/adapter/sqlite3/schema_processor.cr index 29f0bc27..383bb495 100644 --- a/src/jennifer/adapter/sqlite3/migration_processor.cr +++ b/src/jennifer/adapter/sqlite3/schema_processor.cr @@ -1,8 +1,8 @@ -require "../migration_processor" +require "../schema_processor" module Jennifer module Sqlite3 - class MigrationProcessor < Adapter::MigrationProcessor + class SchemaProcessor < Adapter::SchemaProcessor # ============================ # Schema manipulating methods # ============================ diff --git a/src/jennifer/migration/base.cr b/src/jennifer/migration/base.cr index d1374c2e..5a3af5ff 100644 --- a/src/jennifer/migration/base.cr +++ b/src/jennifer/migration/base.cr @@ -21,12 +21,12 @@ module Jennifer delegate create_data_type, to: adapter delegate table_exists?, index_exists?, column_exists?, view_exists?, to: adapter - delegate migration_processor, to: adapter + delegate schema_processor, to: adapter delegate create_table, create_join_table, drop_join_table, exec, drop_table, change_table, create_view, create_materialized_view, drop_materialized_view, drop_view, add_index, create_enum, drop_enum, change_enum, - to: migration_processor, prefix: "build_" + to: schema_processor, prefix: "build_" def adapter_class adapter.class @@ -60,4 +60,4 @@ module Jennifer end end -require "../adapter/migration_processor" +require "../adapter/schema_processor" diff --git a/src/jennifer/migration/runner.cr b/src/jennifer/migration/runner.cr index bb6f5465..69b2c23d 100644 --- a/src/jennifer/migration/runner.cr +++ b/src/jennifer/migration/runner.cr @@ -42,7 +42,7 @@ module Jennifer puts e.backtrace.join("\n") ensure # TODO: generate schema for each adapter - default_adapter.generate_schema if performed + default_adapter.class.generate_schema if performed end def self.migrate @@ -51,14 +51,13 @@ module Jennifer def self.create # TODO: allow to specify adapter - r = default_adapter.create_database + r = default_adapter_class.create_database puts "DB is created!" - r end def self.drop # TODO: allow to specify adapter - default_adapter.drop_database + default_adapter_class.drop_database puts "DB is dropped!" end @@ -90,12 +89,12 @@ module Jennifer puts e.message ensure # TODO: generate schema for each adapter - default_adapter.generate_schema if processed + default_adapter_class.generate_schema if processed end def self.load_schema # TODO: load schema for each adapter - default_adapter.load_schema + default_adapter_class.load_schema end def self.generate(name) @@ -111,6 +110,10 @@ module Jennifer def self.default_adapter Adapter.default_adapter end + + def self.default_adapter_class + Adapter.default_adapter_class + end end end end diff --git a/src/jennifer/migration/table_builder/base.cr b/src/jennifer/migration/table_builder/base.cr index 86060201..80cd9024 100644 --- a/src/jennifer/migration/table_builder/base.cr +++ b/src/jennifer/migration/table_builder/base.cr @@ -9,7 +9,7 @@ module Jennifer extend Ifrit - delegate migration_processor, table_exists?, index_exists?, column_exists?, to: adapter + delegate schema_processor, table_exists?, index_exists?, column_exists?, to: adapter getter fields, adapter : Adapter::Base diff --git a/src/jennifer/migration/table_builder/change_table.cr b/src/jennifer/migration/table_builder/change_table.cr index d163bce9..facd2a07 100644 --- a/src/jennifer/migration/table_builder/change_table.cr +++ b/src/jennifer/migration/table_builder/change_table.cr @@ -65,15 +65,15 @@ module Jennifer end def process - @drop_columns.each { |c| migration_processor.drop_column(@name, c) } - @fields.each { |n, opts| migration_processor.add_column(@name, n, opts) } + @drop_columns.each { |c| schema_processor.drop_column(@name, c) } + @fields.each { |n, opts| schema_processor.add_column(@name, n, opts) } @changed_columns.each do |n, opts| - migration_processor.change_column(@name, n, opts[:new_name].as(String | Symbol), opts) + schema_processor.change_column(@name, n, opts[:new_name].as(String | Symbol), opts) end @indexes.each(&.process) @drop_index.each(&.process) - migration_processor.rename_table(@name, @new_table_name) unless @new_table_name.empty? + schema_processor.rename_table(@name, @new_table_name) unless @new_table_name.empty? end end end diff --git a/src/jennifer/migration/table_builder/create_index.cr b/src/jennifer/migration/table_builder/create_index.cr index b8b79b73..440a7e9d 100644 --- a/src/jennifer/migration/table_builder/create_index.cr +++ b/src/jennifer/migration/table_builder/create_index.cr @@ -10,7 +10,7 @@ module Jennifer end def process - migration_processor.add_index(@name, @index_name, _fields, @type, orders, @lengths) + schema_processor.add_index(@name, @index_name, _fields, @type, orders, @lengths) end end end diff --git a/src/jennifer/migration/table_builder/create_table.cr b/src/jennifer/migration/table_builder/create_table.cr index 4e5929d8..20c53bff 100644 --- a/src/jennifer/migration/table_builder/create_table.cr +++ b/src/jennifer/migration/table_builder/create_table.cr @@ -3,7 +3,7 @@ module Jennifer module TableBuilder class CreateTable < Base def process - migration_processor.create_table(self) + schema_processor.create_table(self) @indexes.each(&.process) end diff --git a/src/jennifer/migration/table_builder/create_view.cr b/src/jennifer/migration/table_builder/create_view.cr index bc72d14b..9ff04ed4 100644 --- a/src/jennifer/migration/table_builder/create_view.cr +++ b/src/jennifer/migration/table_builder/create_view.cr @@ -11,7 +11,7 @@ module Jennifer # TODO: move query generating to SqlGenerator class and make # table builder classes to call executions by themselves def process - migration_processor.create_view(@name, @query) + schema_processor.create_view(@name, @query) end end end diff --git a/src/jennifer/migration/table_builder/drop_index.cr b/src/jennifer/migration/table_builder/drop_index.cr index 8508bfd8..4cc3188d 100644 --- a/src/jennifer/migration/table_builder/drop_index.cr +++ b/src/jennifer/migration/table_builder/drop_index.cr @@ -7,7 +7,7 @@ module Jennifer end def process - migration_processor.drop_index(@name, @index_name) + schema_processor.drop_index(@name, @index_name) end end end diff --git a/src/jennifer/migration/table_builder/drop_table.cr b/src/jennifer/migration/table_builder/drop_table.cr index be12282b..e778f9c6 100644 --- a/src/jennifer/migration/table_builder/drop_table.cr +++ b/src/jennifer/migration/table_builder/drop_table.cr @@ -3,7 +3,7 @@ module Jennifer module TableBuilder class DropTable < Base def process - migration_processor.drop_table(self) + schema_processor.drop_table(self) end end end diff --git a/src/jennifer/migration/table_builder/drop_view.cr b/src/jennifer/migration/table_builder/drop_view.cr index 1569fc9b..d15fdf0d 100644 --- a/src/jennifer/migration/table_builder/drop_view.cr +++ b/src/jennifer/migration/table_builder/drop_view.cr @@ -3,7 +3,7 @@ module Jennifer module TableBuilder class DropView < Base def process - migration_processor.drop_view(@name) + schema_processor.drop_view(@name) end end end diff --git a/src/jennifer/query_builder/criteria.cr b/src/jennifer/query_builder/criteria.cr index 3b501e7b..399c728a 100644 --- a/src/jennifer/query_builder/criteria.cr +++ b/src/jennifer/query_builder/criteria.cr @@ -16,7 +16,7 @@ module Jennifer # NOTE: workaround for passing criteria to the hash as a key - somewhy any Criteria is realized as same one def hash - as_sql.hash + "#{@field}#{@table}".hash end def set_relation(table : String, name : String) From 9bcd68c16d91945f1ce0f039dce12d1668df5cc8 Mon Sep 17 00:00:00 2001 From: Roman Kalnytskyi Date: Mon, 22 Jan 2018 23:58:44 +0200 Subject: [PATCH 09/19] Switch postgres to use returning statement --- .travis.setup.sh | 17 ++----- .travis.yml | 1 + docs/configuration.md | 9 ++-- spec/adapter/base_spec.cr | 2 +- spec/config.cr | 4 ++ src/jennifer/adapter/base.cr | 6 +-- src/jennifer/adapter/postgres.cr | 9 +--- .../adapter/postgres/legacy_insert.cr | 51 +++++++++++++++++++ .../adapter/postgres/sql_generator.cr | 7 ++- 9 files changed, 74 insertions(+), 32 deletions(-) create mode 100644 src/jennifer/adapter/postgres/legacy_insert.cr diff --git a/.travis.setup.sh b/.travis.setup.sh index 393bd19c..0daed92b 100644 --- a/.travis.setup.sh +++ b/.travis.setup.sh @@ -1,17 +1,13 @@ set -exo pipefail if [ "$DB" == 'postgres' ] || [ "$PAIR" == '1' ]; then - echo "===================================" - echo "Create database for postgres" - echo "===================================" + # Create database for postgres psql -c 'create database jennifer_test;' -U postgres fi if [ "$DB" == 'mysql' ] || [ "$PAIR" == '1' ]; then - echo "===================================" - echo "Install newer MySQL" - echo "===================================" - sudo apt-key adv --keyserver pgp.mit.edu --recv-keys A4A9406876FCBD3C456770C88C718D3B5072E1F5 + # Install newer MySQL + sudo apt-key adv --recv-key --keyserver keyserver.ubuntu.com A4A9406876FCBD3C456770C88C718D3B5072E1F5 wget http://dev.mysql.com/get/mysql-apt-config_0.8.1-1_all.deb sudo dpkg -i mysql-apt-config_0.8.1-1_all.deb sudo apt-get update -q @@ -19,13 +15,8 @@ if [ "$DB" == 'mysql' ] || [ "$PAIR" == '1' ]; then sudo mysql_upgrade -u root --force sudo service mysql restart - echo "===================================" - echo "Create database for mysql" - echo "===================================" + # Create database for mysql crystal ./examples/run.cr -- db:create fi -echo "===================================" -echo "Run migrations" -echo "===================================" crystal ./examples/run.cr -- db:migrate \ No newline at end of file diff --git a/.travis.yml b/.travis.yml index ebe57be1..f48676d4 100644 --- a/.travis.yml +++ b/.travis.yml @@ -11,4 +11,5 @@ env: - DB=postgres DB_USER=postgres DB_PASSWORD="" - DB=postgres DB_USER=postgres DB_PASSWORD="" PAIR=1 - DB=mysql DB_USER=root DB_PASSWORD="" PAIR=1 + - DB=postgres DB_USER=postgres DB_PASSWORD="" LEGACY_INSERT=1 before_script: bash .travis.setup.sh diff --git a/docs/configuration.md b/docs/configuration.md index 6b1276ed..ef731509 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,18 +1,19 @@ # Configuration Put + ```crystal +require "jennifer" require "jennifer/adapter/mysql" # for mysql require "jennifer/adapter/postgres" # for postgres -require "jennifer" ``` -> Be attentive - adapter should be required before main staff. Only one adapter can be required at once. +> Be attentive - adapter should be required **after** main staff. From `0.5.0` several adapters could be required at the same time. This should be done before you load your application configurations (or at least models). Now configuration could be loaded from yaml file: ```crystal -Jennifer::Config.read("./spec/fixtures/database.yml", :development) +Jennifer::Config.read("./spec/fixtures/database.yml", :development) ``` Second argument represents environment and just use it as namespace key grapping values from yml. @@ -91,4 +92,4 @@ Jennifer::Config.configure do |conf| end conf.logger.level = Logger::DEBUG end -``` +``` \ No newline at end of file diff --git a/spec/adapter/base_spec.cr b/spec/adapter/base_spec.cr index 171a806d..6e119e69 100644 --- a/spec/adapter/base_spec.cr +++ b/spec/adapter/base_spec.cr @@ -47,7 +47,7 @@ describe Jennifer::Adapter::Base do it "raises exception if query is broken" do expect_raises(Jennifer::BadQuery, /Original query was/) do - adapter.exec("insert into countries(name) set values(?)", "new") + adapter.exec("insert into countries(name) set values(?)", ["new"]) end end end diff --git a/spec/config.cr b/spec/config.cr index d15fb597..d6dcc665 100644 --- a/spec/config.cr +++ b/spec/config.cr @@ -59,6 +59,10 @@ require "../src/jennifer" {% else %} require "../src/jennifer/adapter/postgres" Spec.adapter = "postgres" + + {% if env("LEGACY_INSERT") == "1" %} + require "../src/jennifer/adapter/postgres/legacy_insert" + {% end %} {% end %} {% if env("PAIR") == "1" %} diff --git a/src/jennifer/adapter/base.cr b/src/jennifer/adapter/base.cr index 20b33c94..93f0bcdc 100644 --- a/src/jennifer/adapter/base.cr +++ b/src/jennifer/adapter/base.cr @@ -29,7 +29,7 @@ module Jennifer ::Jennifer::Model::Base.models.each(&.actual_table_field_count) end - def exec(_query, args = [] of DB::Any) + def exec(_query, args : Array(DBAny) = [] of DBAny) time = Time.monotonic res = with_connection { |conn| conn.exec(_query, args) } time = Time.monotonic - time @@ -42,7 +42,7 @@ module Jennifer raise BadQuery.new(e.message, _query, args) end - def query(_query, args = [] of DB::Any) + def query(_query, args : Array(DBAny) = [] of DBAny) time = Time.monotonic res = with_connection { |conn| conn.query(_query, args) { |rs| time = Time.monotonic - time; yield rs } } Config.logger.debug { regular_query_message(time, _query, args) } @@ -54,7 +54,7 @@ module Jennifer raise BadQuery.new(e.message, _query, args) end - def scalar(_query, args : Array(DB::Any) = [] of DB::Any) + def scalar(_query, args : Array(DBAny) = [] of DBAny) time = Time.monotonic res = with_connection { |conn| conn.scalar(_query, args) } time = Time.monotonic - time diff --git a/src/jennifer/adapter/postgres.cr b/src/jennifer/adapter/postgres.cr index 784445cc..1c8f2c37 100644 --- a/src/jennifer/adapter/postgres.cr +++ b/src/jennifer/adapter/postgres.cr @@ -184,13 +184,8 @@ module Jennifer id = -1i64 affected = 0i64 if obj.class.primary_auto_incrementable? - # TODO: move this back when pg driver will raise exception when inserted record brake some constraint - # id = scalar(query, opts[:args]).as(Int32).to_i64 - # affected += 1 if id > 0 - affected = exec(query, opts[:args]).rows_affected - if affected != 0 - id = scalar("SELECT currval(pg_get_serial_sequence('#{obj.class.table_name}', '#{obj.class.primary_field_name}'))").as(Int64) - end + id = scalar(query, opts[:args]).as(Int32).to_i64 + affected += 1 if id > 0 else affected = exec(query, opts[:args]).rows_affected end diff --git a/src/jennifer/adapter/postgres/legacy_insert.cr b/src/jennifer/adapter/postgres/legacy_insert.cr new file mode 100644 index 00000000..f931578b --- /dev/null +++ b/src/jennifer/adapter/postgres/legacy_insert.cr @@ -0,0 +1,51 @@ +module Jennifer + module Postgres + class Adapter + def scalar(_query, args : Array(DBAny) = [] of DBAny) + time = Time.monotonic + res = with_connection { |conn| conn.scalar(_query, args) } + time = Time.monotonic - time + Config.logger.debug { regular_query_message(time, _query, args) } + res + rescue e : BaseException + BadQuery.prepend_information(e, _query, args) + raise e + rescue e : Exception + raise BadQuery.new(e.message, _query, args) + end + + def insert(obj : Model::Base) + opts = obj.arguments_to_insert + query = parse_query(sql_generator.insert(obj, obj.class.primary_auto_incrementable?), opts[:args]) + id = -1i64 + affected = 0i64 + if obj.class.primary_auto_incrementable? + affected = exec(query, opts[:args]).rows_affected + if affected != 0 + id = scalar("SELECT currval(pg_get_serial_sequence('#{obj.class.table_name}', '#{obj.class.primary_field_name}'))").as(Int64) + end + else + affected = exec(query, opts[:args]).rows_affected + end + + ExecResult.new(id, affected) + end + end + + class SQLGenerator + def self.insert(obj : Model::Base, with_primary_field = true) + opts = obj.arguments_to_insert + String.build do |s| + s << "INSERT INTO " << obj.class.table_name + unless opts[:fields].empty? + s << "(" + opts[:fields].join(", ", s) + s << ") VALUES (" << escape_string(opts[:fields].size) << ") " + else + s << " DEFAULT VALUES" + end + end + end + end + end +end diff --git a/src/jennifer/adapter/postgres/sql_generator.cr b/src/jennifer/adapter/postgres/sql_generator.cr index c8c38167..35d26827 100644 --- a/src/jennifer/adapter/postgres/sql_generator.cr +++ b/src/jennifer/adapter/postgres/sql_generator.cr @@ -15,10 +15,9 @@ module Jennifer s << " DEFAULT VALUES" end - # TODO: uncomment after pg driver will raise error if inserting brakes smth - # if with_primary_field - # s << " RETURNING " << obj.class.primary_field_name - # end + if with_primary_field + s << " RETURNING " << obj.class.primary_field_name + end end end From 101a85c2b911afe769fb01d50c91b4e03254e80c Mon Sep 17 00:00:00 2001 From: Roman Kalnytskyi Date: Tue, 23 Jan 2018 10:55:01 +0200 Subject: [PATCH 10/19] Add criteria container for query orders --- src/jennifer/adapter/base_sql_generator.cr | 2 +- src/jennifer/query_builder/criteria.cr | 8 +-- .../query_builder/criteria_container.cr | 69 +++++++++++++++++++ src/jennifer/query_builder/query.cr | 4 +- 4 files changed, 75 insertions(+), 8 deletions(-) create mode 100644 src/jennifer/query_builder/criteria_container.cr diff --git a/src/jennifer/adapter/base_sql_generator.cr b/src/jennifer/adapter/base_sql_generator.cr index 53022e1d..7a29b8bf 100644 --- a/src/jennifer/adapter/base_sql_generator.cr +++ b/src/jennifer/adapter/base_sql_generator.cr @@ -198,7 +198,7 @@ module Jennifer def self.order_clause(io : String::Builder, query) return if !query._order || query._order.empty? io << "ORDER BY " - query._order.not_nil!.join(", ", io) { |(k, v)| io.print k.as_sql(self), " ", v.upcase } + query._order.join(", ", io) { |(k, v), _| io.print k.as_sql(self), " ", v.upcase } io << "\n" end diff --git a/src/jennifer/query_builder/criteria.cr b/src/jennifer/query_builder/criteria.cr index 399c728a..f678fd6b 100644 --- a/src/jennifer/query_builder/criteria.cr +++ b/src/jennifer/query_builder/criteria.cr @@ -1,4 +1,5 @@ require "./json_selector" +require "./criteria_container" module Jennifer module QueryBuilder @@ -14,10 +15,7 @@ module Jennifer def initialize(@field : String, @table : String, @relation = nil) end - # NOTE: workaround for passing criteria to the hash as a key - somewhy any Criteria is realized as same one - def hash - "#{@field}#{@table}".hash - end + def_hash @field, @table def set_relation(table : String, name : String) @relation = name if @relation.nil? && @table == table @@ -65,7 +63,7 @@ module Jennifer end def ==(value : Rightable) - # NOTE: here crystal improperly resolves override methods with Nilargument + # NOTE: here crystal improperly resolves override methods with Nil argument if !value.nil? Condition.new(self, :==, value) else diff --git a/src/jennifer/query_builder/criteria_container.cr b/src/jennifer/query_builder/criteria_container.cr new file mode 100644 index 00000000..a2e113d0 --- /dev/null +++ b/src/jennifer/query_builder/criteria_container.cr @@ -0,0 +1,69 @@ +module Jennifer + module QueryBuilder + struct CriteriaContainer + include Enumerable({Criteria, String}) + + @value_bucket : Hash(String, String) + @key_bucket : Hash(String, Criteria) + + def initialize + @value_bucket = {} of String => String + @key_bucket = {} of String => Criteria + end + + def_clone + + def each + @key_bucket.each do |internal_key, criteria| + yield({criteria, @value_bucket[internal_key]}) + end + end + + def keys + @key_bucket.values + end + + def values + @value_bucket.values + end + + def []=(key : Criteria, value : String) + internal_key = key_value(key) + + @value_bucket[internal_key] = value + @key_bucket[internal_key] = key + key + end + + def [](key : Criteria) + internal_key = key_value(key) + @value_bucket[internal_key] + end + + def []?(key : Criteria) + internal_key = key_value(key) + @value_bucket[internal_key]? + end + + def clear + @value_bucket.clear + @key_bucket.clear + end + + def empty? + @key_bucket.empty? + end + + def remove(key : Criteria) + internal_key = key_value(key) + + @value_bucket.remove(internal_key) + @key_bucket.remove(internal_key) + end + + private def key_value(criteria : Criteria) + criteria.field + ":::" + criteria.table + end + end + end +end diff --git a/src/jennifer/query_builder/query.cr b/src/jennifer/query_builder/query.cr index db54fa74..37230544 100644 --- a/src/jennifer/query_builder/query.cr +++ b/src/jennifer/query_builder/query.cr @@ -42,7 +42,7 @@ module Jennifer def initialize @do_nothing = false @expression = ExpressionBuilder.new(@table) - @order = {} of Criteria => String + @order = CriteriaContainer.new @relations = [] of String @groups = [] of Criteria @relation_used = false @@ -59,7 +59,7 @@ module Jennifer @{{segment.id}} = other.@{{segment.id}}.clone unless except.includes?({{segment}}) {% end %} - @order = except.includes?("order") ? {} of Criteria => String : other.@order.clone + @order = except.includes?("order") ? CriteriaContainer.new : other.@order.clone @joins = other.@joins.clone unless except.includes?("join") @unions = other.@unions.clone unless except.includes?("union") @groups = except.includes?("group") ? [] of Criteria : other.@groups.clone From 7b5038bed527aeed21c3e8a52cd051173d91f2d7 Mon Sep 17 00:00:00 2001 From: Roman Kalnytskyi Date: Tue, 23 Jan 2018 13:08:55 +0200 Subject: [PATCH 11/19] Add tests for criteria container --- spec/query_builder/criteria_container_spec.cr | 103 ++++++++++++++++++ .../query_builder/criteria_container.cr | 10 +- 2 files changed, 110 insertions(+), 3 deletions(-) create mode 100644 spec/query_builder/criteria_container_spec.cr diff --git a/spec/query_builder/criteria_container_spec.cr b/spec/query_builder/criteria_container_spec.cr new file mode 100644 index 00000000..5cc237e3 --- /dev/null +++ b/spec/query_builder/criteria_container_spec.cr @@ -0,0 +1,103 @@ +require "../spec_helper" + +describe Jennifer::QueryBuilder::CriteriaContainer do + described_class = Jennifer::QueryBuilder::CriteriaContainer + + container = described_class.new + container[Factory.build_criteria(field: "a1")] = "desc" + container[Factory.build_criteria(field: "a2")] = "asc" + + describe "#each" do + it "iterates over all contained elements" do + size = 0 + container.each do |criteria, order| + criteria.should be_a(Jennifer::QueryBuilder::Criteria) + order.should be_a(String) + size += 1 + end + size.should eq(2) + end + end + + describe "#keys" do + it do + container.keys[0].field.should eq("a1") + container.keys[1].field.should eq("a2") + end + end + + describe "#values" do + it do + container.values[0].should eq("desc") + container.values[1].should eq("asc") + end + end + + describe "#[]=" do + it do + cont = described_class.new + + cont[Factory.build_criteria] = "desc" + cont.empty?.should be_false + end + end + + describe "#[]" do + it do + container[Factory.build_criteria(field: "a1")].should eq("desc") + end + + it do + expect_raises(KeyError) do + container[Factory.build_criteria(field: "a3")] + end + end + end + + describe "[]?" do + it do + container[Factory.build_criteria(field: "a1")]?.should eq("desc") + end + + it do + container[Factory.build_criteria(field: "a3")]?.should be_nil + end + end + + describe "#clear" do + it do + cont = described_class.new + cont[Factory.build_criteria(field: "a1")] = "desc" + cont.empty?.should be_false + cont.clear + cont.empty?.should be_true + end + end + + describe "#empty?" do + it do + cont = described_class.new + cont.empty?.should be_true + cont[Factory.build_criteria(field: "a1")] = "desc" + cont.empty?.should be_false + end + end + + describe "#size" do + it { container.size.should eq(2) } + end + + describe "#delete" do + it do + cont = described_class.new + + cont[Factory.build_criteria(field: "a1")] = "desc" + cont[Factory.build_criteria(field: "a2")] = "asc" + + cont.delete(Factory.build_criteria(field: "a2")) + + cont[Factory.build_criteria(field: "a1")].should eq("desc") + cont.size.should eq(1) + end + end +end diff --git a/src/jennifer/query_builder/criteria_container.cr b/src/jennifer/query_builder/criteria_container.cr index a2e113d0..9e708e8f 100644 --- a/src/jennifer/query_builder/criteria_container.cr +++ b/src/jennifer/query_builder/criteria_container.cr @@ -27,6 +27,10 @@ module Jennifer @value_bucket.values end + def size + @value_bucket.size + end + def []=(key : Criteria, value : String) internal_key = key_value(key) @@ -54,11 +58,11 @@ module Jennifer @key_bucket.empty? end - def remove(key : Criteria) + def delete(key : Criteria) internal_key = key_value(key) - @value_bucket.remove(internal_key) - @key_bucket.remove(internal_key) + @value_bucket.delete(internal_key) + @key_bucket.delete(internal_key) end private def key_value(criteria : Criteria) From 84adaab33cd56c3d72b07a6270b381a0230b863e Mon Sep 17 00:00:00 2001 From: Roman Kalnytskyi Date: Sun, 28 Jan 2018 18:50:23 +0200 Subject: [PATCH 12/19] Add time zone --- docs/configuration.md | 1 + docs/model_mapping.md | 1 + docs/timestamps.md | 7 +++- shard.yml | 3 ++ spec/adapter/base_spec.cr | 17 ++++---- spec/adapter/mysql_spec.cr | 6 +++ spec/adapter/postgres_spec.cr | 2 +- spec/adapter/sql_generator_spec.cr | 13 ++++++- spec/factories.cr | 3 +- spec/model/mapping_spec.cr | 19 +++++++++ spec/models.cr | 5 ++- spec/spec_helper.cr | 10 +++++ spec/view/experimental_mapping_spec.cr | 33 +++++++++++++++- src/jennifer.cr | 1 + src/jennifer/adapter/base.cr | 39 ++++++++++--------- src/jennifer/adapter/base_sql_generator.cr | 30 +++++++------- src/jennifer/adapter/postgres.cr | 20 ++++------ .../adapter/postgres/legacy_insert.cr | 4 +- .../adapter/postgres/sql_generator.cr | 11 ++++-- src/jennifer/adapter/request_methods.cr | 16 ++++---- src/jennifer/adapter/schema_processor.cr | 2 +- src/jennifer/config.cr | 36 +++++++++++------ src/jennifer/model/mapping.cr | 18 +++++---- src/jennifer/query_builder/i_model_query.cr | 2 - src/jennifer/view/experimental_mapping.cr | 6 ++- 25 files changed, 205 insertions(+), 100 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index ef731509..6c4770a5 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -68,6 +68,7 @@ All configs: | `retry_attempts` | 1 | | `checkout_timeout` | 5.0 | | `retry_delay` | 1.0 | +| `local_time_zone_name` | default time zone name for `TimeZone` | > `port = -1` will provide connection URI without port mention diff --git a/docs/model_mapping.md b/docs/model_mapping.md index a034ff9c..66e8bff9 100644 --- a/docs/model_mapping.md +++ b/docs/model_mapping.md @@ -1,6 +1,7 @@ # Mapping Several model examples + ```crystal class Contact < Jennifer::Model::Base with_timestamps diff --git a/docs/timestamps.md b/docs/timestamps.md index 19323285..231bd7d7 100644 --- a/docs/timestamps.md +++ b/docs/timestamps.md @@ -1,6 +1,7 @@ -# Timestamps +# Timestamps and Time `with_timestamps` macros adds callbacks for `created_at` and `updated_at` fields update. But now they still should be mentioned in mapping manually: + ```crystal class MyModel < Jennifer::Model::Base with_timestamps @@ -11,3 +12,7 @@ class MyModel < Jennifer::Model::Base ) end ``` + +### 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. diff --git a/shard.yml b/shard.yml index bf07d91e..86c126c4 100644 --- a/shard.yml +++ b/shard.yml @@ -33,3 +33,6 @@ dependencies: ifrit: github: imdrasil/ifrit version: "~> 0.1.2" + time_zone: + github: imdrasil/time_zone + version: "~> 0.1" diff --git a/spec/adapter/base_spec.cr b/spec/adapter/base_spec.cr index 6e119e69..46e93992 100644 --- a/spec/adapter/base_spec.cr +++ b/spec/adapter/base_spec.cr @@ -193,6 +193,15 @@ describe Jennifer::Adapter::Base do c.validate! c.valid?.should be_false end + + it "properly sets object attributes" do + c = Factory.build_contact(name: "Syd", age: 150) + adapter.bulk_insert([c]) + Contact.all.count.should eq(1) + c = Contact.all.first! + c.age.should eq(150) + c.name.should eq("Syd") + end end describe "::join_table_name" do @@ -249,14 +258,6 @@ describe Jennifer::Adapter::Base do end end - describe "#parse_query" do - it "returns string without %s placeholders" do - res = adapter.parse_query("asd %s asd", [2]) - res.should be_a(String) - res.should_not match(/%s/) - end - end - describe "#table_column_count" do context "given table name" do it "returns amount of table fields" do diff --git a/spec/adapter/mysql_spec.cr b/spec/adapter/mysql_spec.cr index 2210cf52..b47f17cb 100644 --- a/spec/adapter/mysql_spec.cr +++ b/spec/adapter/mysql_spec.cr @@ -26,5 +26,11 @@ mysql_only do adapter.default_type_size(:string).should eq(254) end end + + describe "#parse_query" do + it "returns string without %s placeholders" do + adapter.parse_query("asd %s asd", [2] of Jennifer::DBAny).should eq({"asd ? asd", [2]}) + end + end end end diff --git a/spec/adapter/postgres_spec.cr b/spec/adapter/postgres_spec.cr index e8c43a88..854dc61e 100644 --- a/spec/adapter/postgres_spec.cr +++ b/spec/adapter/postgres_spec.cr @@ -7,7 +7,7 @@ postgres_only do describe "#parse_query" do it "replaces %s by dollar-and-numbers" do - adapter.parse_query("some %s query %s", ["a", "b"]).should eq("some $1 query $2") + adapter.parse_query("some %s query %s", ["a", "b"] of Jennifer::DBAny).should eq({"some $1 query $2", %w(a b)}) end end diff --git a/spec/adapter/sql_generator_spec.cr b/spec/adapter/sql_generator_spec.cr index d450ae0b..0fc6bbbd 100644 --- a/spec/adapter/sql_generator_spec.cr +++ b/spec/adapter/sql_generator_spec.cr @@ -238,13 +238,22 @@ describe "Jennifer::Adapter::SQLGenerator" do describe "::parse_query" do postgres_only do it "replase placeholders with dollar numbers" do - described_class.parse_query("asd %s qwe %s", 2).should eq("asd $1 qwe $2") + described_class.parse_query("asd %s qwe %s", [1, 2] of Jennifer::DBAny).should eq({"asd $1 qwe $2", [1, 2]}) end end mysql_only do it "replace placeholders with question marks" do - described_class.parse_query("asd %s qwe %s", 2).should eq("asd ? qwe ?") + described_class.parse_query("asd %s qwe %s", [1, 2] of Jennifer::DBAny).should eq({"asd ? qwe ?", [1, 2]}) + end + end + + 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) + end end end end diff --git a/spec/factories.cr b/spec/factories.cr index b1bf5118..2b620527 100644 --- a/spec/factories.cr +++ b/spec/factories.cr @@ -103,10 +103,11 @@ end class MaleContactFactory < Factory::Base postgres_only do - argument_type (Array(Int32) | Int32 | PG::Numeric | String?) + argument_type (Array(Int32) | Int32 | PG::Numeric | String? | Time) end attr :name, "Raphael" attr :age, 21 attr :gender, "male" + attr :created_at, -> { Time.utc_now } end diff --git a/spec/model/mapping_spec.cr b/spec/model/mapping_spec.cr index c53e8fc1..3b9b2d7a 100644 --- a/spec/model/mapping_spec.cr +++ b/spec/model/mapping_spec.cr @@ -280,6 +280,25 @@ describe Jennifer::Model::Mapping do end end + describe Time do + it "stores to db time converted to UTC" do + with_time_zone("Etc/GMT+1") do + contact = Factory.create_contact + Contact.all.update(created_at: Time.utc_now) + Contact.all.select { [_created_at] }.each_result_set do |rs| + rs.read(Time).should be_close(Time.utc_now + 1.hour, 2.seconds) + end + end + end + + 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) + end + end + end + context "nilable field" do context "passed with ?" do it "properly sets field as nilable" do diff --git a/spec/models.cr b/spec/models.cr index 3961802f..a8e6e616 100644 --- a/spec/models.cr +++ b/spec/models.cr @@ -209,7 +209,9 @@ class OneFieldModel < Jennifer::Model::Base ) end -# mutated models ============ +# =================== +# synthetic models +# =================== class JohnPassport < Jennifer::Model::Base table_name "passports" @@ -313,6 +315,7 @@ class MaleContact < Jennifer::View::Base name: String, gender: String, age: Int32, + created_at: Time? }, false) scope :main { where { _age < 50 } } diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr index 22dbd5e3..60fccaa6 100644 --- a/spec/spec_helper.cr +++ b/spec/spec_helper.cr @@ -77,6 +77,16 @@ def read_to_end(rs) end end +def with_time_zone(zone_name : String) + old_zone = Jennifer::Config.local_time_zone_name + begin + Jennifer::Config.local_time_zone_name = zone_name + yield + ensure + Jennifer::Config.local_time_zone_name = old_zone + end +end + def sb String.build { |io| yield io } end diff --git a/spec/view/experimental_mapping_spec.cr b/spec/view/experimental_mapping_spec.cr index 6f88d909..bfb660fd 100644 --- a/spec/view/experimental_mapping_spec.cr +++ b/spec/view/experimental_mapping_spec.cr @@ -123,7 +123,7 @@ describe Jennifer::View::ExperimentalMapping do describe "::field_count" do it "returns correct number of model fields" do - MaleContact.field_count.should eq(4) + MaleContact.field_count.should eq(5) end end @@ -145,6 +145,35 @@ describe Jennifer::View::ExperimentalMapping do end end end + + describe JSON::Any do + pending "properly loads json field" do + # This checks nillable JSON as well + # c = Factory.create_address(street: "a st.", details: JSON.parse(%(["a", "b", 1]))) + # c = Address.find!(c.id) + # c.details.should be_a(JSON::Any) + # c.details![2].as_i.should eq(1) + end + end + + describe Time do + it "stores to db time converted to UTC" do + with_time_zone("Etc/GMT+1") do + contact = Factory.create_contact + Contact.all.update(created_at: Time.utc_now) + MaleContact.all.select { [_created_at] }.each_result_set do |rs| + rs.read(Time).should be_close(Time.utc_now + 1.hour, 2.seconds) + end + end + end + + 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) + end + end + end end describe "%__field_declaration" do @@ -228,7 +257,7 @@ describe Jennifer::View::ExperimentalMapping do describe "::field_names" do it "returns array of defined fields" do - MaleContact.field_names.should eq(%w(id name gender age)) + MaleContact.field_names.should eq(%w(id name gender age created_at)) end end end diff --git a/src/jennifer.cr b/src/jennifer.cr index f2bdfa5f..4c46e7c3 100644 --- a/src/jennifer.cr +++ b/src/jennifer.cr @@ -2,6 +2,7 @@ require "inflector" require "inflector/string" require "accord" require "ifrit/converter" +require "time_zone" require "./jennifer/macros" diff --git a/src/jennifer/adapter/base.cr b/src/jennifer/adapter/base.cr index 93f0bcdc..a4e7cae9 100644 --- a/src/jennifer/adapter/base.cr +++ b/src/jennifer/adapter/base.cr @@ -12,6 +12,9 @@ module Jennifer include ResultParsers include RequestMethods + alias ArgType = DBAny + alias ArgsType = Array(ArgType) + @db : DB::Database getter db @@ -29,7 +32,7 @@ module Jennifer ::Jennifer::Model::Base.models.each(&.actual_table_field_count) end - def exec(_query, args : Array(DBAny) = [] of DBAny) + def exec(_query, args : ArgsType = [] of DBAny) time = Time.monotonic res = with_connection { |conn| conn.exec(_query, args) } time = Time.monotonic - time @@ -42,7 +45,7 @@ module Jennifer raise BadQuery.new(e.message, _query, args) end - def query(_query, args : Array(DBAny) = [] of DBAny) + def query(_query, args : ArgsType = [] of DBAny) time = Time.monotonic res = with_connection { |conn| conn.query(_query, args) { |rs| time = Time.monotonic - time; yield rs } } Config.logger.debug { regular_query_message(time, _query, args) } @@ -54,7 +57,7 @@ module Jennifer raise BadQuery.new(e.message, _query, args) end - def scalar(_query, args : Array(DBAny) = [] of DBAny) + def scalar(_query, args : ArgsType = [] of DBAny) time = Time.monotonic res = with_connection { |conn| conn.scalar(_query, args) } time = Time.monotonic - time @@ -67,14 +70,6 @@ module Jennifer raise BadQuery.new(e.message, _query, args) end - def parse_query(q : String, args) - sql_generator.parse_query(q, args.size) - end - - def parse_query(q : String) - sql_generator.parse_query(q) - end - def truncate(klass : Class) truncate(klass.table_name) end @@ -85,17 +80,17 @@ module Jennifer def delete(query : QueryBuilder::Query) args = query.select_args - exec sql_generator.delete(query), args + exec *sql_generator.delete(query) end def exists?(query : QueryBuilder::Query) args = query.select_args - scalar(sql_generator.exists(query), args) == 1 + scalar(*sql_generator.exists(query)) == 1 end def count(query : QueryBuilder::Query) args = query.select_args - scalar(sql_generator.count(query), args).as(Int64).to_i + scalar(*sql_generator.count(query)).as(Int64).to_i end def bulk_insert(collection : Array(Model::Base)) @@ -106,7 +101,7 @@ module Jennifer parsed_query = parse_query(sql_generator.bulk_insert(klass.table_name, fields, collection.size), values) with_table_lock(klass.table_name) do - exec(parsed_query, values) + exec(*parsed_query) if klass.primary_auto_incrementable? klass.all.order({klass.primary => :desc}).limit(collection.size).pluck(:id).reverse_each.each_with_index do |id, i| collection[i].init_primary_field(id) @@ -116,15 +111,23 @@ module Jennifer collection end - def bulk_insert(table : String, fields : Array(String), values : Array(Array(DBAny))) : Nil + def bulk_insert(table : String, fields : Array(String), values : Array(ArgsType)) : Nil return if values.empty? with_table_lock(table) do flat_values = values.flatten - exec(parse_query(sql_generator.bulk_insert(table, fields, values.size), flat_values), flat_values) + exec(*parse_query(sql_generator.bulk_insert(table, fields, values.size), flat_values)) end nil end + def parse_query(q : String, args : ArgsType) + sql_generator.parse_query(q, args) + end + + def parse_query(q : String) + sql_generator.parse_query(q) + end + def self.db_connection DB.open(connection_string) do |db| yield(db) @@ -159,7 +162,7 @@ module Jennifer end end - def self.extract_arguments(hash : Hash) : NamedTuple(args: Array(Jennifer::DBAny), fields: Array(String)) + def self.extract_arguments(hash : Hash) : NamedTuple(args: ArgsType, fields: Array(String)) args = [] of DBAny fields = [] of String hash.each do |key, value| diff --git a/src/jennifer/adapter/base_sql_generator.cr b/src/jennifer/adapter/base_sql_generator.cr index 7a29b8bf..1f238509 100644 --- a/src/jennifer/adapter/base_sql_generator.cr +++ b/src/jennifer/adapter/base_sql_generator.cr @@ -2,6 +2,7 @@ module Jennifer module Adapter class BaseSQLGenerator ARRAY_ESCAPE = "\\\\\\\\" + ARGUMENT_ESCAPE_STRING = "%s" # Generates insert query def self.insert(table, hash) @@ -48,7 +49,7 @@ module Jennifer from_clause(s, query) body_section(s, query) end, - query.select_args_count + query.select_args ) end @@ -60,7 +61,7 @@ module Jennifer body_section(s, query) s << ")" end, - query.select_args_count + query.select_args ) end @@ -71,7 +72,7 @@ module Jennifer from_clause(s, query) body_section(s, query) end, - query.select_args_count + query.select_args ) end @@ -244,13 +245,13 @@ module Jennifer def self.escape_string(size : Int32 = 1) case size when 1 - "%s" + ARGUMENT_ESCAPE_STRING when 2 - "%s, %s" + "#{ARGUMENT_ESCAPE_STRING}, #{ARGUMENT_ESCAPE_STRING}" when 3 - "%s, %s, %s" + "#{ARGUMENT_ESCAPE_STRING}, #{ARGUMENT_ESCAPE_STRING}, #{ARGUMENT_ESCAPE_STRING}" else - size.times.map { "%s" }.join(", ") + size.times.join(", ") { ARGUMENT_ESCAPE_STRING } end end @@ -263,16 +264,13 @@ module Jennifer end # TODO: optimize array initializing - def self.parse_query(query : String, arg_count : Int32) - arr = [] of String - arg_count.times do - arr << "?" + 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 end - query % arr - end - - def self.parse_query(query : String) - query + {query % Array.new(args.size, "?"), args} end end end diff --git a/src/jennifer/adapter/postgres.cr b/src/jennifer/adapter/postgres.cr index 1c8f2c37..b3543300 100644 --- a/src/jennifer/adapter/postgres.cr +++ b/src/jennifer/adapter/postgres.cr @@ -180,14 +180,14 @@ module Jennifer def insert(obj : Model::Base) opts = obj.arguments_to_insert - query = parse_query(sql_generator.insert(obj, obj.class.primary_auto_incrementable?), opts[:args]) + query_opts = parse_query(sql_generator.insert(obj, obj.class.primary_auto_incrementable?), opts[:args]) id = -1i64 affected = 0i64 if obj.class.primary_auto_incrementable? - id = scalar(query, opts[:args]).as(Int32).to_i64 + id = scalar(*query_opts).as(Int32).to_i64 affected += 1 if id > 0 else - affected = exec(query, opts[:args]).rows_affected + affected = exec(*query_opts).rows_affected end ExecResult.new(id, affected) @@ -195,21 +195,17 @@ module Jennifer def self.bulk_insert(collection : Array(Model::Base)) opts = collection.flat_map(&.arguments_to_insert[:args]) - query = parse_query(sql_generator.bulk_insert(collection)) + # TODO: unify parse_query + query_opts = parse_query(sql_generator.bulk_insert(collection)) # TODO: change to checking for autoincrementability affected = exec(qyery, opts).rows_affected - if true - if affected == collection.size - else - raise ::Jennifer::BaseException.new("Bulk insert failed with #{collection.size - affected} records.") - end + if affected != collection.size + raise ::Jennifer::BaseException.new("Bulk insert failed with #{collection.size - affected} records.") end end def exists?(query) - args = query.select_args - body = sql_generator.exists(query) - scalar(body, args) + scalar(*sql_generator.exists(query)) end def self.create_database diff --git a/src/jennifer/adapter/postgres/legacy_insert.cr b/src/jennifer/adapter/postgres/legacy_insert.cr index f931578b..37576e56 100644 --- a/src/jennifer/adapter/postgres/legacy_insert.cr +++ b/src/jennifer/adapter/postgres/legacy_insert.cr @@ -20,12 +20,12 @@ module Jennifer id = -1i64 affected = 0i64 if obj.class.primary_auto_incrementable? - affected = exec(query, opts[:args]).rows_affected + affected = exec(*query).rows_affected if affected != 0 id = scalar("SELECT currval(pg_get_serial_sequence('#{obj.class.table_name}', '#{obj.class.primary_field_name}'))").as(Int64) end else - affected = exec(query, opts[:args]).rows_affected + affected = exec(*query).rows_affected end ExecResult.new(id, affected) diff --git a/src/jennifer/adapter/postgres/sql_generator.cr b/src/jennifer/adapter/postgres/sql_generator.cr index 35d26827..2297dbc2 100644 --- a/src/jennifer/adapter/postgres/sql_generator.cr +++ b/src/jennifer/adapter/postgres/sql_generator.cr @@ -108,12 +108,15 @@ module Jennifer "'#{value.gsub(/\\/, "\&\&").gsub(/'/, "''")}'" end - def self.parse_query(query, arg_count) - arr = [] of String - arg_count.times do |i| + 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 arr << "$#{i + 1}" end - query % arr + {query % arr, args} end end end diff --git a/src/jennifer/adapter/request_methods.cr b/src/jennifer/adapter/request_methods.cr index 76306987..bf08a406 100644 --- a/src/jennifer/adapter/request_methods.cr +++ b/src/jennifer/adapter/request_methods.cr @@ -5,19 +5,19 @@ module Jennifer def insert(table, opts : Hash) values = opts.values - exec parse_query(sql_generator.insert(table, opts), values), values + exec *parse_query(sql_generator.insert(table, opts), values) end def insert(obj : Model::Base) opts = obj.arguments_to_insert - exec parse_query(sql_generator.insert(obj), opts[:args]), opts[:args] + exec *parse_query(sql_generator.insert(obj), opts[:args]) end def update(obj : Model::Base) opts = obj.arguments_to_save return DB::ExecResult.new(0i64, -1i64) if opts[:args].empty? opts[:args] << obj.primary - exec(parse_query(sql_generator.update(obj), opts[:args]), opts[:args]) + exec(*parse_query(sql_generator.update(obj), opts[:args])) end def update(query, options : Hash) @@ -26,7 +26,7 @@ module Jennifer args << v end args.concat(query.select_args) - exec(parse_query(sql_generator.update(query, options), args), args) + exec(*parse_query(sql_generator.update(query, options), args)) end def modify(q, modifications : Hash) @@ -36,14 +36,14 @@ module Jennifer args << v[:value] end args.concat(q.select_args) - exec(parse_query(query, args), args) + exec(*parse_query(query, args)) end def pluck(query, fields : Array) result = [] of Array(DBAny) body = sql_generator.select(query, fields) args = query.select_args - query(parse_query(body, args), args) do |rs| + query(*parse_query(body, args)) do |rs| rs.each do result << result_to_array_by_names(rs, fields) end @@ -56,7 +56,7 @@ module Jennifer fields = [field.to_s] body = sql_generator.select(query, fields) args = query.select_args - query(parse_query(body, args), args) do |rs| + query(*parse_query(body, args)) do |rs| rs.each do result << result_to_array_by_names(rs, fields)[0] end @@ -67,7 +67,7 @@ module Jennifer def select(q) body = sql_generator.select(q) args = q.select_args - query(parse_query(body, args), args) { |rs| yield rs } + query(*parse_query(body, args)) { |rs| yield rs } end end end diff --git a/src/jennifer/adapter/schema_processor.cr b/src/jennifer/adapter/schema_processor.cr index 1a866bef..e829f85d 100644 --- a/src/jennifer/adapter/schema_processor.cr +++ b/src/jennifer/adapter/schema_processor.cr @@ -176,7 +176,7 @@ module Jennifer s << "VIEW " << name << " AS " << adapter.sql_generator.select(query) end args = query.select_args - adapter.exec adapter.parse_query(buff, args), args + adapter.exec *adapter.parse_query(buff, args) end def drop_view(name, silent = true) diff --git a/src/jennifer/config.cr b/src/jennifer/config.cr index 3c459a6f..6cdc6c2d 100644 --- a/src/jennifer/config.cr +++ b/src/jennifer/config.cr @@ -4,27 +4,28 @@ require "logger" module Jennifer class Config CONNECTION_URI_PARAMS = [:max_pool_size, :initial_pool_size, :max_idle_pool_size, :retry_attempts, :checkout_timeout, :retry_delay] - STRING_FIELDS = {:user, :password, :db, :host, :adapter, :migration_files_path, :schema, :structure_folder} + STRING_FIELDS = {:user, :password, :db, :host, :adapter, :migration_files_path, :schema, :structure_folder, :local_time_zone_name} INT_FIELDS = {:port, :max_pool_size, :initial_pool_size, :max_idle_pool_size, :retry_attempts} FLOAT_FIELDS = [:checkout_timeout, :retry_delay] macro define_fields(const, default) {% for field in @type.constant(const.stringify) %} - @@{{field.id}} = {{default}} + @@{{field.id}} = {{default}} - def self.{{field.id}}=(value) - @@{{field.id}} = value - end + def self.{{field.id}}=(value) + @@{{field.id}} = value + end - def self.{{field.id}} - @@{{field.id}} - end - {% end %} + def self.{{field.id}} + @@{{field.id}} + end + {% end %} end - define_fields(STRING_FIELDS, default: "") + define_fields(STRING_FIELDS, "") define_fields(INT_FIELDS, 0) define_fields(FLOAT_FIELDS, 0.0) + @@local_time_zone : TimeZone::Zone = TimeZone::Zone.utc def self.structure_folder if @@structure_folder.empty? @@ -45,6 +46,7 @@ module Jennifer @@migration_files_path = "./db/migrations" @@schema = "public" @@db = "" + @@local_time_zone_name = TimeZone::Zone.default.name @@initial_pool_size = 1 @@max_pool_size = 5 @@ -54,9 +56,11 @@ module Jennifer @@checkout_timeout = 5.0 @@retry_delay = 1.0 + @@local_time_zone = TimeZone::Zone.default + @@logger = Logger.new(STDOUT) @@logger.not_nil!.level = Logger::DEBUG - @@logger.not_nil!.formatter = Logger::Formatter.new do |severity, datetime, progname, message, io| + @@logger.not_nil!.formatter = Logger::Formatter.new do |_severity, datetime, _progname, message, io| io << datetime << ": " << message end end @@ -71,6 +75,16 @@ module Jennifer @@logger = value end + def self.local_time_zone_name=(value : String) + @@local_time_zone_name = value + @@local_time_zone = TimeZone::Zone.get(@@local_time_zone_name) + value + end + + def self.local_time_zone + @@local_time_zone + end + def self.configure(&block) yield self self.validate_config diff --git a/src/jennifer/model/mapping.cr b/src/jennifer/model/mapping.cr index c9c92df6..2f214e42 100644 --- a/src/jennifer/model/mapping.cr +++ b/src/jennifer/model/mapping.cr @@ -4,6 +4,7 @@ alias Primary64 = Int64 module Jennifer module Model module Mapping + # :nodoc: macro __bool_convert(value, type) {% if type.stringify == "Bool" %} ({{value.id}}.is_a?(Int8) ? {{value.id}} == 1i8 : {{value.id}}.as({{type}})) @@ -12,6 +13,7 @@ module Jennifer {% end %} end + # :nodoc: # Generates getter and setters macro __field_declaration(properties, primary_auto_incrementable) {% for key, value in properties %} @@ -80,12 +82,12 @@ module Jennifer # Sets `created_at` tocurrent time def __update_created_at - @created_at = Time.now + @created_at = Jennifer::Config.local_time_zone.now end # Sets `updated_at` to current time def __update_updated_at - @updated_at = Time.now + @updated_at = Jennifer::Config.local_time_zone.now end end @@ -119,9 +121,7 @@ module Jennifer {% end %} {% properties[key][:stringified_type] = properties[key][:type].stringify %} {% if properties[key][:stringified_type] == Jennifer::Macros::PRIMARY_32 || properties[key][:stringified_type] == Jennifer::Macros::PRIMARY_64 %} - {% - properties[key][:primary] = true - %} + {% properties[key][:primary] = true %} {% end %} {% if properties[key][:primary] %} {% @@ -258,7 +258,7 @@ module Jennifer # Extracts arguments due to mapping from *pull* and returns tuple for # fields assignment. It stands on that fact result set has all defined fields in a raw # TODO: think about moving it to class scope - # NOTE: don't use it manually - there is some dependencies on caller such as reading tesult set to the end + # NOTE: don't use it manually - there is some dependencies on caller such as reading result set to the end # if eception was raised def _extract_attributes(pull : DB::ResultSet) requested_columns_count = self.class.actual_table_field_count @@ -299,7 +299,8 @@ module Jennifer { {% for key, value in properties %} begin - %var{key.id}.as({{value[:parsed_type].id}}) + res = %var{key.id}.as({{value[:parsed_type].id}}) + !res.is_a?(Time) ? res : ::Jennifer::Config.local_time_zone.utc_to_local(res) rescue e : Exception raise ::Jennifer::DataTypeCasting.new({{key.id.stringify}}, {{@type}}, e) if ::Jennifer::DataTypeCasting.match?(e) raise e @@ -344,6 +345,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}) rescue e : Exception raise ::Jennifer::DataTypeCasting.new({{key.id.stringify}}, {{@type}}, e) if ::Jennifer::DataTypeCasting.match?(e) raise e @@ -536,7 +538,7 @@ module Jennifer def arguments_to_insert args = [] of ::Jennifer::DBAny - # TODO: think about moving this array to constant + # TODO: think about moving this array to constant; maybe use compiletime instead of runtime fields = [] of String {% for key, value in properties %} {% unless value[:primary] && primary_auto_incrementable %} diff --git a/src/jennifer/query_builder/i_model_query.cr b/src/jennifer/query_builder/i_model_query.cr index e6dbbb20..326633af 100644 --- a/src/jennifer/query_builder/i_model_query.cr +++ b/src/jennifer/query_builder/i_model_query.cr @@ -28,7 +28,6 @@ module Jennifer if @select_fields.empty? buff = [] of Criteria buff << @expression.star - if !@relations.empty? @relations.each do |r| table_name = @table_aliases[r]? || model_class.relation(r).table_name @@ -195,7 +194,6 @@ module Jennifer arr.each do |name| entries[name] += 1 end - result = [] of String entries.each { |k, v| result << k if v > 1 } result end diff --git a/src/jennifer/view/experimental_mapping.cr b/src/jennifer/view/experimental_mapping.cr index 1004af86..3a788963 100644 --- a/src/jennifer/view/experimental_mapping.cr +++ b/src/jennifer/view/experimental_mapping.cr @@ -72,7 +72,7 @@ module Jennifer # fields assignment. It stands on that fact result set has all defined fields in a raw # TODO: think about moving it to class scope # NOTE: don't use it manually - there is some dependencies on caller such as reading result set to the end - # if eception was raised + # if eception was raised def _extract_attributes(pull : DB::ResultSet) {% for key in COLUMNS_METADATA.keys %} %var{key.id} = nil @@ -113,7 +113,8 @@ module Jennifer { {% for key, value in COLUMNS_METADATA %} begin - %var{key.id}.as({{value[:parsed_type].id}}) + res = %var{key.id}.as({{value[:parsed_type].id}}) + !res.is_a?(Time) ? res : ::Jennifer::Config.local_time_zone.utc_to_local(res) rescue e : Exception raise ::Jennifer::DataTypeCasting.new({{key.id.stringify}}, {{@type}}, e) if ::Jennifer::DataTypeCasting.match?(e) raise e @@ -160,6 +161,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}) rescue e : Exception raise ::Jennifer::DataTypeCasting.new({{key.id.stringify}}, {{@type}}, e) if ::Jennifer::DataTypeCasting.match?(e) raise e From 19b6e45631132a0a3d986094ac6237f506733978 Mon Sep 17 00:00:00 2001 From: Roman Kalnytskyi Date: Sun, 11 Feb 2018 03:19:00 +0200 Subject: [PATCH 13/19] Add transaction callbacks; enhance sti mapping --- README.md | 2 +- docs/model_mapping.md | 4 +- docs/model_sti.md | 4 +- spec/adapter/base_spec.cr | 53 ++--- spec/model/callback_spec.cr | 219 +++++++++++++++++- spec/model/mapping_spec.cr | 4 +- spec/model/relation_definition_spec.cr | 8 +- spec/models.cr | 61 ++++- spec/spec_helper.cr | 2 +- src/jennifer/adapter/observer/base.cr | 36 +++ src/jennifer/adapter/transaction_observer.cr | 67 ++++++ src/jennifer/adapter/transactions.cr | 53 ++++- src/jennifer/model/base.cr | 22 +- src/jennifer/model/callback.cr | 223 ++++++++++--------- src/jennifer/model/mapping.cr | 72 ++++-- src/jennifer/model/sti_mapping.cr | 7 +- 16 files changed, 637 insertions(+), 200 deletions(-) create mode 100644 src/jennifer/adapter/observer/base.cr create mode 100644 src/jennifer/adapter/transaction_observer.cr diff --git a/README.md b/README.md index 6d7d344e..5c835e0c 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,7 @@ class Profile < Jennifer::Model::Base end class FacebookProfile < Profile - sti_mapping( + mapping( uid: String ) diff --git a/docs/model_mapping.md b/docs/model_mapping.md index 66e8bff9..f7e608d1 100644 --- a/docs/model_mapping.md +++ b/docs/model_mapping.md @@ -74,7 +74,7 @@ class Profile < Jennifer::Model::Base end class FacebookProfile < Profile - sti_mapping( + mapping( uid: String ) @@ -82,7 +82,7 @@ class FacebookProfile < Profile end class TwitterProfile < Profile - sti_mapping( + mapping( email: String ) end diff --git a/docs/model_sti.md b/docs/model_sti.md index 075377b5..870ea76d 100644 --- a/docs/model_sti.md +++ b/docs/model_sti.md @@ -14,7 +14,7 @@ class Profile < Jennifer::Model::Base end class FacebookProfile < Profile - sti_mapping( + mapping( uid: String ) @@ -22,7 +22,7 @@ class FacebookProfile < Profile end class TwitterProfile < Profile - sti_mapping( + mapping( email: String ) end diff --git a/spec/adapter/base_spec.cr b/spec/adapter/base_spec.cr index 46e93992..a8d21f52 100644 --- a/spec/adapter/base_spec.cr +++ b/spec/adapter/base_spec.cr @@ -66,43 +66,46 @@ describe Jennifer::Adapter::Base do describe "#transaction" do it "rollbacks if exception was raised" do - expect_raises(DivisionByZero) do - adapter.transaction do - Factory.create_contact - 1 / 0 + void_transaction do + expect_raises(DivisionByZero) do + adapter.transaction do |tx| + Factory.create_contact + 1 / 0 + end end + Contact.all.count.should eq(0) end - Contact.all.count.should eq(0) end - it "commit transaction otherwice" do - adapter.transaction do - Factory.create_contact + it "commit transaction otherwise" do + void_transaction do + adapter.transaction do + Factory.create_contact + end + Contact.all.count.should eq(1) end - Contact.all.count.should eq(1) end + # TODO: add several fibers and yields in them it "work with concurrent access" do - begin - ch = Channel(Nil).new - adapter.transaction do |t| - Factory.create_contact - raise DB::Rollback.new - end - spawn do + void_transaction do + begin + ch = Channel(Nil).new adapter.transaction do |t| Factory.create_contact + raise DB::Rollback.new end - ch.send(nil) - end - ch.receive + spawn do + adapter.transaction do |t| + Factory.create_contact + end + ch.send(nil) + end + ch.receive - adapter.with_manual_connection do |con| - con.scalar("select count(*) from contacts").should eq(1) - end - ensure - adapter.with_manual_connection do |con| - con.exec "DELETE FROM contacts" + adapter.with_manual_connection do |con| + con.scalar("select count(*) from contacts").should eq(1) + end end end end diff --git a/spec/model/callback_spec.cr b/spec/model/callback_spec.cr index 221a6744..724b7b08 100644 --- a/spec/model/callback_spec.cr +++ b/spec/model/callback_spec.cr @@ -1,6 +1,5 @@ require "../spec_helper" -# TODO: just dummy test to be sure everything are work; rewrite to better test of proper call moment describe Jennifer::Model::Callback do describe "before_save" do it "is called before any save" do @@ -37,7 +36,7 @@ describe Jennifer::Model::Callback do c.before_create_attr.should be_false end - it "not stops creating if before callback raises Skip exceptions" do + it "stops creating if before callback raises Skip exceptions" do c = Factory.create_country(name: "not create") c.new_record?.should be_true end @@ -104,6 +103,222 @@ describe Jennifer::Model::Callback do end end + describe "after_validation" do + it "is called after validation" do + c = CountryWithValidationCallbacks.build(name: "downcased") + c.save + c.name.should eq("DOWNCASED") + end + + it "is not called if record is invalid" do + c = CountryWithValidationCallbacks.create(name: "cOuntry") + c.valid?.should be_false + c.name.should eq("cOuntry") + end + end + + describe "before_validation" do + it "is called before validation" do + c = CountryWithValidationCallbacks.build(name: "UPCASED") + c.save + c.name.should eq("upcased") + end + + it "stop creating record if skip was raised " do + c = CountryWithValidationCallbacks.create(name: "skip") + c.valid?.should be_true + c.new_record?.should be_true + end + end + + describe "after_commit" do + describe "create" do + it "calls callback after top level transaction is committed" do + void_transaction do + country = nil + CountryWithTransactionCallbacks.transaction do + country = CountryWithTransactionCallbacks.create(name: "name") + country.create_commit_callback.should be_false + end + country.not_nil!.create_commit_callback.should be_true + end + end + + it "is not called if transaction is rolled back" do + void_transaction do + country = nil + CountryWithTransactionCallbacks.transaction do + country = CountryWithTransactionCallbacks.create(name: "name") + country.create_commit_callback.should be_false + raise DB::Rollback.new + end + country.not_nil!.create_commit_callback.should be_false + end + end + end + + describe "save" do + context "when creating new record" do + it "calls callback after top level transaction is committed" do + void_transaction do + country = nil + CountryWithTransactionCallbacks.transaction do + country = CountryWithTransactionCallbacks.create(name: "name") + country.save_commit_callback.should be_false + end + country.not_nil!.save_commit_callback.should be_true + end + end + end + + it "calls callback after top level transaction is committed" do + void_transaction do + CountryWithTransactionCallbacks.create(name: "name") + country = CountryWithTransactionCallbacks.all.first! + + CountryWithTransactionCallbacks.transaction do + country.name = "new_name" + country.save + country.save_commit_callback.should be_false + end + country.not_nil!.save_commit_callback.should be_true + end + end + + it "is not called if transaction is rolled back" do + void_transaction do + CountryWithTransactionCallbacks.create(name: "name") + country = CountryWithTransactionCallbacks.all.first! + + CountryWithTransactionCallbacks.transaction do + country = CountryWithTransactionCallbacks.create(name: "name") + country.save_commit_callback.should be_false + raise DB::Rollback.new + end + country.not_nil!.save_commit_callback.should be_false + end + end + end + + describe "destroy" do + it "calls callback after top level transaction is committed" do + void_transaction do + country = CountryWithTransactionCallbacks.create(name: "name") + + CountryWithTransactionCallbacks.transaction do + country.destroy + country.destroy_commit_callback.should be_false + end + country.not_nil!.destroy_commit_callback.should be_true + end + end + + it "is not called if transaction is rolled back" do + void_transaction do + country = CountryWithTransactionCallbacks.create(name: "name") + + CountryWithTransactionCallbacks.transaction do + country.destroy + country.destroy_commit_callback.should be_false + raise DB::Rollback.new + end + country.not_nil!.destroy_commit_callback.should be_false + end + end + end + end + + describe "after_rollback" do + describe "create" do + it "doesn't call callback after top level transaction is committed" do + void_transaction do + country = nil + CountryWithTransactionCallbacks.transaction do + country = CountryWithTransactionCallbacks.create(name: "name") + end + country.not_nil!.create_rollback_callback.should be_false + end + end + + it "called if transaction is rolled back" do + void_transaction do + country = nil + CountryWithTransactionCallbacks.transaction do + country = CountryWithTransactionCallbacks.create(name: "name") + raise DB::Rollback.new + end + country.not_nil!.create_rollback_callback.should be_true + end + end + end + + describe "save" do + context "when creating new record" do + it "calls callback after top level transaction is rolled back" do + void_transaction do + country = nil + CountryWithTransactionCallbacks.transaction do + country = CountryWithTransactionCallbacks.create(name: "name") + raise DB::Rollback.new + end + country.not_nil!.save_rollback_callback.should be_true + end + end + end + + it "doesn't call callback after top level transaction is committed" do + void_transaction do + CountryWithTransactionCallbacks.create(name: "name") + country = CountryWithTransactionCallbacks.all.first! + + CountryWithTransactionCallbacks.transaction do + country.name = "new_name" + country.save + end + country.not_nil!.save_rollback_callback.should be_false + end + end + + it "calls if transaction is rolled back" do + void_transaction do + CountryWithTransactionCallbacks.create(name: "name") + country = CountryWithTransactionCallbacks.all.first! + + CountryWithTransactionCallbacks.transaction do + country = CountryWithTransactionCallbacks.create(name: "name") + raise DB::Rollback.new + end + country.not_nil!.save_rollback_callback.should be_true + end + end + end + + describe "destroy" do + it "doesn't call callback after top level transaction is committed" do + void_transaction do + country = CountryWithTransactionCallbacks.create(name: "name") + + CountryWithTransactionCallbacks.transaction do + country.destroy + end + country.not_nil!.destroy_rollback_callback.should be_false + end + end + + it "calls callbacks if transaction is rolled back" do + void_transaction do + country = CountryWithTransactionCallbacks.create(name: "name") + + CountryWithTransactionCallbacks.transaction do + country.destroy + raise DB::Rollback.new + end + country.not_nil!.destroy_rollback_callback.should be_true + end + end + end + end + context "inherited" do it "is also invoked" do Factory.create_contact.super_class_callback_called.should be_true diff --git a/spec/model/mapping_spec.cr b/spec/model/mapping_spec.cr index 3b9b2d7a..f960efd1 100644 --- a/spec/model/mapping_spec.cr +++ b/spec/model/mapping_spec.cr @@ -580,8 +580,8 @@ describe Jennifer::Model::Mapping do describe "%with_timestamps" do it "adds callbacks" do - Contact::BEFORE_CREATE_CALLBACKS.should contain("__update_created_at") - Contact::BEFORE_SAVE_CALLBACKS.should contain("__update_updated_at") + Contact::CALLBACKS[:create][:before].should contain("__update_created_at") + Contact::CALLBACKS[:save][:before].should contain("__update_updated_at") end end diff --git a/spec/model/relation_definition_spec.cr b/spec/model/relation_definition_spec.cr index d6493a30..c91c2484 100644 --- a/spec/model/relation_definition_spec.cr +++ b/spec/model/relation_definition_spec.cr @@ -3,7 +3,7 @@ require "../spec_helper" describe Jennifer::Model::RelationDefinition do describe "%nullify_dependecy" do it "adds before_desctroy callback" do - ContactWithDependencies::BEFORE_DESTROY_CALLBACKS.includes?("__nullify_callback_facebook_profiles").should be_true + ContactWithDependencies::CALLBACKS[:destroy][:before].includes?("__nullify_callback_facebook_profiles").should be_true end it "doen't invoke callbacks on associated model" do @@ -19,7 +19,7 @@ describe Jennifer::Model::RelationDefinition do describe "%delete_dependency" do it "adds before_desctroy callback" do - ContactWithDependencies::BEFORE_DESTROY_CALLBACKS.includes?("__delete_callback_addresses").should be_true + ContactWithDependencies::CALLBACKS[:destroy][:before].includes?("__delete_callback_addresses").should be_true end it "doen't invoke callbacks on associated model" do @@ -36,7 +36,7 @@ describe Jennifer::Model::RelationDefinition do describe "%destroy_dependency" do it "adds before_desctroy callback" do - ContactWithDependencies::BEFORE_DESTROY_CALLBACKS.includes?("__destroy_callback_passports").should be_true + ContactWithDependencies::CALLBACKS[:destroy][:before].includes?("__destroy_callback_passports").should be_true end it "invokes callbacks on associated model" do @@ -53,7 +53,7 @@ describe Jennifer::Model::RelationDefinition do describe "%restrict_with_exception_dependency" do it "adds before_desctroy callback" do - ContactWithDependencies::BEFORE_DESTROY_CALLBACKS.includes?("__restrict_with_exception_callback_twitter_profiles").should be_true + ContactWithDependencies::CALLBACKS[:destroy][:before].includes?("__restrict_with_exception_callback_twitter_profiles").should be_true end it "raises exception if any associated record exists" do diff --git a/spec/models.cr b/spec/models.cr index a8e6e616..ab51439a 100644 --- a/spec/models.cr +++ b/spec/models.cr @@ -150,7 +150,7 @@ class Profile < ApplicationRecord end class FacebookProfile < Profile - sti_mapping( + mapping( uid: String? # for testing purposes ) @@ -160,7 +160,7 @@ class FacebookProfile < Profile end class TwitterProfile < Profile - sti_mapping( + mapping( email: {type: String, null: true} # for testing purposes ) end @@ -213,6 +213,63 @@ end # synthetic models # =================== +class CountryWithTransactionCallbacks < ApplicationRecord + table_name "countries" + + mapping({ + id: Primary32, + name: String + }) + + {% for action in [:create, :save, :destroy] %} + {% for type in [:commit, :rollback] %} + {% name = "#{action.id}_#{type.id}_callback".id %} + + after_{{type.id}} :set_{{name}}, on: {{action}} + + getter {{name}} = false + + def set_{{name}} + @{{name}} = true + end + {% end %} + {% end %} +end + +class CountryWithValidationCallbacks < ApplicationRecord + table_name "countries" + + mapping({ + id: Primary32, + name: String + }) + + before_validation :raise_skip, :before_validation_method + after_validation :after_validation_method + + validates_with_method :validate_downcase + + private def validate_downcase + errors.add(:name, "can't be downcased") if name =~ /[A-Z]/ + end + + private def before_validation_method + if name == "UPCASED" + self.name = name.downcase + end + end + + private def after_validation_method + if name == "downcased" + self.name = name.upcase + end + end + + private def raise_skip + raise Jennifer::Skip.new if name == "skip" + end +end + class JohnPassport < Jennifer::Model::Base table_name "passports" diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr index 60fccaa6..8cdfebcf 100644 --- a/spec/spec_helper.cr +++ b/spec/spec_helper.cr @@ -37,7 +37,7 @@ def clean_db Jennifer::Model::Base.models.select { |t| t.has_table? }.each(&.all.delete) end -# Ends current transaction, yields and starts next one +# Ends current transaction, yields to the block, clear and starts next one macro void_transaction begin Jennifer::Adapter.adapter.rollback_transaction diff --git a/src/jennifer/adapter/observer/base.cr b/src/jennifer/adapter/observer/base.cr new file mode 100644 index 00000000..e38f0264 --- /dev/null +++ b/src/jennifer/adapter/observer/base.cr @@ -0,0 +1,36 @@ +require "../../model/callback" + +module Jennifer + module Adapter + module Observer + class Base + getter record : Model::Callback, action : Symbol + + def initialize(@record, @action) + end + + def dispatch_commit + case @action + when :create + record.__after_create_commit_callback + when :save + record.__after_save_commit_callback + when :destroy + record.__after_destroy_commit_callback + end + end + + def dispatch_rollback + case @action + when :create + record.__after_create_rollback_callback + when :save + record.__after_save_rollback_callback + when :destroy + record.__after_destroy_rollback_callback + end + end + end + end + end +end diff --git a/src/jennifer/adapter/transaction_observer.cr b/src/jennifer/adapter/transaction_observer.cr new file mode 100644 index 00000000..eaf886dc --- /dev/null +++ b/src/jennifer/adapter/transaction_observer.cr @@ -0,0 +1,67 @@ +require "./observer/base" + +module Jennifer + module Adapter + class TransactionObserver + # property transaction : DB::Transaction + # @rolled_back = false + # @commit_observers = [] of Observer::Base + # @rollback_observers = [] of Observer::Base + + # delegate connection, to: transaction + + # def initialize(@transaction) + # end + + # def rollback + # @rolled_back = true + # end + + # def observe_commit(record, action) + # @commit_observers << Observer::Base.new(record, action) + # end + + # def observe_rollback(record, action) + # @rollback_observers << Observer::Base.new(record, action) + # end + + # def update + # unless @rolled_back + # @commit_observers.each(&.dispatch_commit) + # else + # @rollback_observers.each(&.dispatch_rollback) + # end + # end + + property transaction : DB::Transaction + @rolled_back = false + @commit_observers = [] of -> Bool + @rollback_observers = [] of -> Bool + + delegate connection, to: transaction + + def initialize(@transaction) + end + + def rollback + @rolled_back = true + end + + def observe_commit(block) + @commit_observers << block + end + + def observe_rollback(block) + @rollback_observers << block + end + + def update + unless @rolled_back + @commit_observers.each(&.call) + else + @rollback_observers.each(&.call) + end + end + end + end +end diff --git a/src/jennifer/adapter/transactions.cr b/src/jennifer/adapter/transactions.cr index 37f471ec..19f495b4 100644 --- a/src/jennifer/adapter/transactions.cr +++ b/src/jennifer/adapter/transactions.cr @@ -1,12 +1,13 @@ +require "./transaction_observer" + module Jennifer module Adapter module Transactions - @transaction : DB::Transaction? = nil - @locks = {} of UInt64 => DB::Transaction + @locks = {} of UInt64 => TransactionObserver def with_connection(&block) - if @locks.has_key?(Fiber.current.object_id) - yield @locks[Fiber.current.object_id].connection + if under_transaction? + yield @locks[fiber_id].connection else conn = @db.checkout res = yield conn @@ -23,8 +24,8 @@ module Jennifer end def with_transactionable(&block) - if @locks.has_key?(Fiber.current.object_id) - yield @locks[Fiber.current.object_id] + if under_transaction? + yield @locks[fiber_id].transaction else conn = @db.checkout res = yield conn @@ -34,19 +35,24 @@ module Jennifer end def lock_connection(transaction : DB::Transaction) - @locks[Fiber.current.object_id] = transaction + if @locks[fiber_id]? + @locks[fiber_id].transaction = transaction + else + @locks[fiber_id] = TransactionObserver.new(transaction) + end end def lock_connection(transaction : Nil) - @locks.delete(Fiber.current.object_id) + @locks[fiber_id].update + @locks.delete(fiber_id) end def current_transaction - @locks[Fiber.current.object_id]? + @locks[fiber_id]?.try(&.transaction) end def under_transaction? - @locks.has_key?(Fiber.current.object_id) + @locks.has_key?(fiber_id) end def transaction(&block) @@ -60,6 +66,7 @@ module Jennifer res = yield(tx) Config.logger.debug("TRANSACTION COMMIT") rescue e + @locks[fiber_id].rollback Config.logger.debug("TRANSACTION ROLLBACK") raise e ensure @@ -70,14 +77,31 @@ module Jennifer res end - # NOTE: designed for test usage + # def subscribe_on_commit(record, action) + # @locks[fiber_id].observe_commit(record, action) + # end + + # def subscribe_on_rollback(record, action) + # @locks[fiber_id].observe_rollback(record, action) + # end + + def subscribe_on_commit(block : -> Bool) + @locks[fiber_id].observe_commit(block) + end + + def subscribe_on_rollback(block : -> Bool) + @locks[fiber_id].observe_rollback(block) + end + + # Starts manual transaction for current fiber. Designed as test case isolation method. def begin_transaction raise ::Jennifer::BaseException.new("Couldn't manually begin non top level transaction") if current_transaction Config.logger.debug("TRANSACTION START") lock_connection(@db.checkout.begin_transaction) end - # NOTE: designed for test usage + # Closes manual transaction for current fiber. Will not process any commit or rollback callbacks on records. + # Designed as test case isolation method. def rollback_transaction t = current_transaction raise ::Jennifer::BaseException.new("No transaction to rollback") unless t @@ -87,6 +111,11 @@ module Jennifer t.connection.release lock_connection(nil) end + + @[AlwaysInline] + private def fiber_id + Fiber.current.object_id + end end end end diff --git a/src/jennifer/model/base.cr b/src/jennifer/model/base.cr index 147d01d0..3dfc32fc 100644 --- a/src/jennifer/model/base.cr +++ b/src/jennifer/model/base.cr @@ -158,23 +158,13 @@ module Jennifer hash.each { |k, v| set_attribute(k, v) } end - # Deletes object from db and calls callbacks - def destroy - unless self.class.adapter.under_transaction? - {{@type}}.transaction do - destroy_without_transaction - end - else - destroy_without_transaction + # Perform destroy without starting a transaction + def destroy_without_transaction + return false if new_record? || !__before_destroy_callback + @destroyed = true if delete + __after_destroy_callback if @destroyed + @destroyed end - end - - def destroy_without_transaction - return false if new_record? || !__before_destroy_callback - @destroyed = true if delete - __after_destroy_callback if @destroyed - @destroyed - end # Deletes object from DB without calling callbacks. def delete diff --git a/src/jennifer/model/callback.cr b/src/jennifer/model/callback.cr index e8c42ec2..c2ba8aaf 100644 --- a/src/jennifer/model/callback.cr +++ b/src/jennifer/model/callback.cr @@ -37,162 +37,175 @@ module Jennifer true end + def __after_save_commit_callback + true + end + + def __after_create_commit_callback + true + end + + def __after_destroy_commit_callback + true + end + + def __after_save_rollback_callback + true + end + + def __after_create_rollback_callback + true + end + + def __after_destroy_rollback_callback + true + end + macro before_save(*names) {% for name in names %} - {% BEFORE_SAVE_CALLBACKS << name.id.stringify %} + {% CALLBACKS[:save][:before] << name.id.stringify %} {% end %} end macro after_save(*names) {% for name in names %} - {% AFTER_SAVE_CALLBACKS << name.id.stringify %} + {% CALLBACKS[:save][:after] << name.id.stringify %} {% end %} end macro before_create(*names) {% for name in names %} - {% BEFORE_CREATE_CALLBACKS << name.id.stringify %} + {% CALLBACKS[:create][:before] << name.id.stringify %} {% end %} end macro after_create(*names) {% for name in names %} - {% AFTER_CREATE_CALLBACKS << name.id.stringify %} + {% CALLBACKS[:create][:after] << name.id.stringify %} {% end %} end macro after_initialize(*names) {% for name in names %} - {% AFTER_INITIALIZE_CALLBACKS << name.id.stringify %} + {% CALLBACKS[:initialize][:after] << name.id.stringify %} {% end %} end macro before_destroy(*names) {% for name in names %} - {% BEFORE_DESTROY_CALLBACKS << name.id.stringify %} + {% CALLBACKS[:destroy][:before] << name.id.stringify %} {% end %} end macro after_destroy(*names) {% for name in names %} - {% AFTER_DESTROY_CALLBACKS << name.id.stringify %} + {% CALLBACKS[:destroy][:after] << name.id.stringify %} {% end %} end macro before_validation(*names) {% for name in names %} - {% BEFORE_VALIDATION_CALLBACKS << name.id.stringify %} + {% CALLBACKS[:validation][:before] << name.id.stringify %} {% end %} end macro after_validation(*names) {% for name in names %} - {% AFTER_VALIDATION_CALLBACKS << name.id.stringify %} + {% CALLBACKS[:validation][:after] << name.id.stringify %} + {% end %} + end + + macro after_commit(*names, on) + {% unless [:create, :save, :destroy].includes?(on) %} + {% raise "#{on} is invalid action for %after_commit callback." %} + {% end %} + {% for name in names %} + {% CALLBACKS[on][:commit] << name %} + {% end %} + end + + macro after_rollback(*names, on) + {% unless [:create, :save, :destroy].includes?(on) %} + {% raise "#{on} is invalid action for %after_rollback callback." %} + {% end %} + {% for name in names %} + {% CALLBACKS[on][:rollback] << name %} {% end %} end macro inherited_hook - BEFORE_SAVE_CALLBACKS = [] of String - AFTER_SAVE_CALLBACKS = [] of String - BEFORE_CREATE_CALLBACKS = [] of String - AFTER_CREATE_CALLBACKS = [] of String - AFTER_INITIALIZE_CALLBACKS = [] of String - BEFORE_DESTROY_CALLBACKS = [] of String - AFTER_DESTROY_CALLBACKS = [] of String - BEFORE_VALIDATION_CALLBACKS = [] of String - AFTER_VALIDATION_CALLBACKS = [] of String + CALLBACKS = { + save: { + before: [] of String, + after: [] of String, + commit: [] of String, + rollback: [] of String + }, + create: { + before: [] of String, + after: [] of String, + commit: [] of String, + rollback: [] of String + }, + destroy: { + after: [] of String, + before: [] of String, + commit: [] of String, + rollback: [] of String + }, + initialize: { + after: [] of String + }, + validation: { + before: [] of String, + after: [] of String + } + } end macro finished_hook - def __before_save_callback - return false unless super - \{% for method in BEFORE_SAVE_CALLBACKS %} - \{{method.id}} - \{% end %} - true - rescue ::Jennifer::Skip - false - end - - def __after_save_callback - return false unless super - \{% for method in AFTER_SAVE_CALLBACKS %} - \{{method.id}} - \{% end %} - true - rescue ::Jennifer::Skip - false - end - - def __before_create_callback - return false unless super - \{% for method in BEFORE_CREATE_CALLBACKS %} - \{{method.id}} - \{% end %} - true - rescue ::Jennifer::Skip - false - end - - def __after_create_callback - return false unless super - \{% for method in AFTER_CREATE_CALLBACKS %} - \{{method.id}} - \{% end %} - true - rescue ::Jennifer::Skip - false - end - - def __after_initialize_callback - return false unless super - \{% for method in AFTER_INITIALIZE_CALLBACKS %} - \{{method.id}} - \{% end %} - true - rescue ::Jennifer::Skip - false - end - - def __before_destroy_callback - return false unless super - \{% for method in BEFORE_DESTROY_CALLBACKS %} - \{{method.id}} - \{% end %} - true - rescue ::Jennifer::Skip - false - end - - def __after_destroy_callback - return false unless super - \{% for method in AFTER_DESTROY_CALLBACKS %} - \{{method.id}} - \{% end %} - true - rescue ::Jennifer::Skip - false - end - - def __before_validation_callback - return false unless super - \{% for method in BEFORE_VALIDATION_CALLBACKS %} - \{{method.id}} + \{% for type in [:before, :after] %} + \{% for action in [:save, :create, :destroy, :validation] %} + \{% if !CALLBACKS[action][type].empty? %} + def __\{{type.id}}_\{{action.id}}_callback + return false unless super + \{{ CALLBACKS[action][type].join("\n").id }} + true + rescue ::Jennifer::Skip + false + end + \{% end %} \{% end %} - true - rescue ::Jennifer::Skip - false - end - - def __after_validation_callback - return false unless super - \{% for method in AFTER_VALIDATION_CALLBACKS %} - \{{method.id}} + \{% end %} + + \{% for action in ["save", "create", "destroy"] %} + \{% for type in ["commit", "rollback"] %} + \{% constant_name = "HAS_#{action.upcase.id}_#{type.upcase.id}_CALLBACK" %} + \{% if !CALLBACKS[action][type].empty? %} + \{{ "#{constant_name.id} = true".id}} + + def __after_\{{action.id}}_\{{type.id}}_callback + return false unless super + \{{ CALLBACKS[action][type].join("\n").id }} + true + rescue ::Jennifer::Skip + false + end + \{% else %} + \{{ "#{constant_name.id} = false".id}} + \{% end %} \{% end %} - true - rescue ::Jennifer::Skip - false - end + \{% end %} + + \{% if !CALLBACKS[:initialize][:after].empty? %} + def __after_initialize_callback + return false unless super + \{{ CALLBACKS[:initialize][:after].join("\n").id }} + true + rescue ::Jennifer::Skip + false + end + \{% end %} end end end diff --git a/src/jennifer/model/mapping.cr b/src/jennifer/model/mapping.cr index 2f214e42..a29a563b 100644 --- a/src/jennifer/model/mapping.cr +++ b/src/jennifer/model/mapping.cr @@ -91,7 +91,7 @@ module Jennifer end end - macro mapping(properties, strict = true) + private macro single_mapping(properties, strict = true) def self.children_classes {% begin %} {% if @type.all_subclasses.size > 0 %} @@ -197,8 +197,9 @@ module Jennifer initialize(values) end - #def attributes=(values : Hash) - #end + # TODO: think about next method + # def attributes=(values : Hash) + # end def self.build(pull : DB::ResultSet) \{% begin %} @@ -259,7 +260,7 @@ module Jennifer # fields assignment. It stands on that fact result set has all defined fields in a raw # TODO: think about moving it to class scope # NOTE: don't use it manually - there is some dependencies on caller such as reading result set to the end - # if eception was raised + # if exception was raised def _extract_attributes(pull : DB::ResultSet) requested_columns_count = self.class.actual_table_field_count ::Jennifer::BaseException.assert_column_count(requested_columns_count, pull.column_count) @@ -370,13 +371,18 @@ module Jennifer end def save(skip_validation : Bool = false) : Bool - unless self.class.adapter.under_transaction? - {{@type}}.transaction do + result = + unless self.class.adapter.under_transaction? + {{@type}}.transaction do + save_without_transaction(skip_validation) + end || false + else save_without_transaction(skip_validation) - end || false - else - save_without_transaction(skip_validation) - end + end + return result unless result + # adapter.subscribe_on_commit() if HAS_COMMIT_CALLBACK + # adapter.subscribe_on_rollback if HAS_ROLLBACK_CALLBACK + result end # Saves all changes to db without invoking transaction; if validation not passed - returns `false` @@ -399,14 +405,37 @@ module Jennifer {% end %} @new_record = false if res.rows_affected != 0 __after_create_callback + self.class.adapter.subscribe_on_commit(->__after_create_commit_callback) if HAS_CREATE_COMMIT_CALLBACK + self.class.adapter.subscribe_on_rollback(->__after_create_rollback_callback) if HAS_CREATE_ROLLBACK_CALLBACK res else self.class.adapter.update(self) end __after_save_callback + self.class.adapter.subscribe_on_commit(->__after_save_commit_callback) if HAS_SAVE_COMMIT_CALLBACK + self.class.adapter.subscribe_on_rollback(->__after_save_rollback_callback) if HAS_SAVE_ROLLBACK_CALLBACK response.rows_affected == 1 end + # Deletes object from db and calls callbacks + def destroy + {% begin %} + result = + unless self.class.adapter.under_transaction? + self.class.transaction do + destroy_without_transaction + end + else + destroy_without_transaction + end + if result + self.class.adapter.subscribe_on_commit(->__after_destroy_commit_callback) if HAS_DESTROY_COMMIT_CALLBACK + self.class.adapter.subscribe_on_rollback(->__after_destroy_rollback_callback) if HAS_DESTROY_ROLLBACK_CALLBACK + end + result + {% end %} + end + # Reloads all fields from db. def reload raise ::Jennifer::RecordNotFound.new("It is not persisted yet") if new_record? @@ -507,17 +536,6 @@ module Jennifer end end - # NOTE: This is deprecated method - it will be removed in 0.5.0. Use #to_h instead - def attributes_hash - hash = to_h - {% for key, value in properties %} - {% if value[:primary] %} - hash.delete(:{{key}}) unless hash[:{{key.id}}]?.nil? - {% end %} - {% end %} - hash - end - def arguments_to_save args = [] of ::Jennifer::DBAny fields = [] of String @@ -567,6 +585,18 @@ module Jennifer super {{properties.keys.map { |key| "@#{key.id}" }.join(", ").id}} = _extract_attributes(values) end + + macro inherited + MODEL = true + end + end + + macro mapping(properties, strict = true) + {% if !@type.constant("MODEL") %} + single_mapping({{properties}}, {{strict}}) + {% else %} + sti_mapping({{properties}}) + {% end %} end macro mapping(**properties) diff --git a/src/jennifer/model/sti_mapping.cr b/src/jennifer/model/sti_mapping.cr index b2c0c637..d696238a 100644 --- a/src/jennifer/model/sti_mapping.cr +++ b/src/jennifer/model/sti_mapping.cr @@ -1,7 +1,8 @@ module Jennifer module Model module STIMapping - macro sti_mapping(properties) + # Defines mapping using single table inheritance. Is automatically called by `%mapping` macro. + private macro sti_mapping(properties) STI = true def self.sti_condition @@ -282,10 +283,6 @@ module Jennifer ] end end - - macro sti_mapping(**properties) - sti_mapping({{properties}}) - end end end end From 5b1e6a18f583ca20039dea05c4961c03444a212b Mon Sep 17 00:00:00 2001 From: Roman Kalnytskyi Date: Sun, 11 Feb 2018 18:49:38 +0200 Subject: [PATCH 14/19] Add update callbacks and docs --- docs/callbacks.md | 131 ++++++++++++++++++- docs/model_sti.md | 7 +- spec/model/callback_spec.cr | 119 ++++++++++++++++- spec/models.cr | 22 +++- src/jennifer/adapter/observer/base.cr | 36 ----- src/jennifer/adapter/transaction_observer.cr | 32 ----- src/jennifer/adapter/transactions.cr | 13 +- src/jennifer/model/callback.cr | 42 +++++- src/jennifer/model/mapping.cr | 113 +++++++++------- src/jennifer/model/validation.cr | 16 ++- src/jennifer/query_builder/executables.cr | 1 + 11 files changed, 386 insertions(+), 146 deletions(-) delete mode 100644 src/jennifer/adapter/observer/base.cr diff --git a/docs/callbacks.md b/docs/callbacks.md index 9562e36b..0129c5ef 100644 --- a/docs/callbacks.md +++ b/docs/callbacks.md @@ -1,16 +1,133 @@ # Callbacks -There are next macroses for defining callbacks: +During the normal operation of your application, objects may be created, updated, and destroyed. Jennifer provides hooks into this object life cycle so that you can control your application and its data. + +Callbacks allow you to trigger logic before or after an alteration of an object's state. + +## Callbacks overview + +### Registration + +In order to use available callbacks, you need to define them. To do this implement callback as instance method and use macro to register it as callback: + +```crystal +class User < Jennifer::Base::Model + mapping( + id: Primary32, + email: String + ) + + before_validation :clean_up_email + + private def clean_up_email + self.email = email.gsub('+', "") + end +end +``` + +## Available callbacks + +Here is a list with all the available callbacks, listed in the same order in which they will get called during the respective operations: + +### Creating new object + +- `before_validation` +- `after_validation` - `before_save` -- `after_save` - `before_create` - `after_create` -- `after_initialize` -- `before_destroy` -- `after_destroy` +- `after_save` +- `after_commit` / `after_rollback` + +### Updating existing object + - `before_validation` - `after_validation` +- `before_save` +- `before_update` +- `after_update` +- `after_save` +- `after_commit` / `after_rollback` + +### Destroying an object + +- `before_destroy` +- `after_destroy` +- `after_commit` / `after_rollback` + +## Invoking callbacks + +The following methods trigger callbacks: + +- create +- create! +- destroy +- destroy_without_transaction +- save +- save! +- save_without_transaction + +The `after_initialize` callback is triggered each time record is initialized using method `::build`. + +## Skipping callbacks + +The following methods allows to skip some callbacks or process without them: + +- update +- validate +- save(skip_validation: true) +- destroy_without_transaction (no transaction callback will be triggered) +- save_without_transaction (no transaction callback will be triggered) +- update_column +- update_columns +- delete +- modify +- increment +- decrement + +Carefully use this methods because otherwise you might get invalid data in a db. + +## Stopping execution + +Raising `::Jennifer::Skip` exception inside of any callback will stop further callback invoking; such behavior in the any `before` callback stops current action from being processed. + +## Transaction callbacks + +There are 2 additional callbacks that are triggered right after database transaction completion: `after_commit` and `after_rollback`. The main difference of these callbacks is they will be executed only after top level transaction will be completed (committed or rolled back) - instead invoking just in place. Also they expect context to be invoked on: create, save, update or destroy. E.g.: + +```crystal +class User < Jennifer::Model::Base + mapping( + id: Primary32, + name: String + ) + after_save :saved + after_commit :committed, on: :save + + def saved + puts "saved" + end + + def committed + puts "committed" + end +end + +user = User.all.first! + +User.transaction do + user.name = "new name" + user.save + puts "end of transaction" +end +puts "after transaction" +``` -They accept method names. +will ends with next output: -Raising `::Jennifer::Skip` exception inside of any calback will stop further callback invoking; such behavior in the any before callback stops current action from being processed. +```text +saved +end of transaction +committed +after transaction +``` diff --git a/docs/model_sti.md b/docs/model_sti.md index 870ea76d..6dde28c0 100644 --- a/docs/model_sti.md +++ b/docs/model_sti.md @@ -1,6 +1,7 @@ # STI -Single table inheritance could be used in next way: +To use single table inheritance just inherit from your parent model and use regular `%mapping` macro: + ```crystal class Profile < Jennifer::Model::Base mapping( @@ -31,11 +32,9 @@ end Requirements: - created table for STI should include **all** fields of all subclasses (that's why it is cold STI); -- STI table have to have field named `type` of any string type which will be able to store class name of your models; +- STI table has to have field named as `type` of any string type which will be able to store class name of child models; - parent class should have definition for `type` field; -> Currently type field is not configurable and hardcoded to name `type`. - To extract from DB several subclasses in one request - just use parent class to query: ```crystal diff --git a/spec/model/callback_spec.cr b/spec/model/callback_spec.cr index 724b7b08..78ca31a8 100644 --- a/spec/model/callback_spec.cr +++ b/spec/model/callback_spec.cr @@ -60,6 +60,40 @@ describe Jennifer::Model::Callback do end end + describe "before_update" do + it "is not invoked after record creating" do + c = Factory.create_country + c.before_update_attr.should be_false + end + + it "is called before create" do + c = Factory.create_country + c.name = "zxc" + c.save + c.before_update_attr.should be_true + end + + it "stops updating if before callback raises Skip exceptions" do + c = Factory.create_country(name: "zxc") + c.name = "not create" + c.save.should be_false + end + end + + describe "after_update" do + it "is not invoked after record creating" do + c = Factory.create_country + c.after_update_attr.should be_false + end + + it "is called after update" do + c = Factory.create_country + c.name = "new name zxc" + c.save + c.after_update_attr.should be_true + end + end + describe "after_initialize" do it "is called after build" do c = CountryFactory.build @@ -133,6 +167,22 @@ describe Jennifer::Model::Callback do describe "after_commit" do describe "create" do + context "when model uses STI" do + it "calls all relevant callbacks after top level commit" do + void_transaction do + fb = nil + FacebookProfile.transaction do + fb = Factory.create_facebook_profile(name: "name") + fb.commit_callback_called.should be_false + fb.fb_commit_callback_called.should be_false + end + fb = fb.not_nil! + fb.commit_callback_called.should be_true + fb.fb_commit_callback_called.should be_true + end + end + end + it "calls callback after top level transaction is committed" do void_transaction do country = nil @@ -191,8 +241,8 @@ describe Jennifer::Model::Callback do country = CountryWithTransactionCallbacks.all.first! CountryWithTransactionCallbacks.transaction do - country = CountryWithTransactionCallbacks.create(name: "name") - country.save_commit_callback.should be_false + country.name = "another name" + country.save raise DB::Rollback.new end country.not_nil!.save_commit_callback.should be_false @@ -200,6 +250,48 @@ describe Jennifer::Model::Callback do end end + describe "update" do + context "when creating new record" do + it "doesn't calls callbacks after top level transaction is committed" do + void_transaction do + country = nil + CountryWithTransactionCallbacks.transaction do + country = CountryWithTransactionCallbacks.create(name: "name") + end + country.not_nil!.update_commit_callback.should be_false + end + end + end + + it "calls callback after top level transaction is committed" do + void_transaction do + CountryWithTransactionCallbacks.create(name: "name") + country = CountryWithTransactionCallbacks.all.first! + + CountryWithTransactionCallbacks.transaction do + country.name = "new_name" + country.save + country.update_commit_callback.should be_false + end + country.not_nil!.update_commit_callback.should be_true + end + end + + it "is not called if transaction is rolled back" do + void_transaction do + CountryWithTransactionCallbacks.create(name: "name") + country = CountryWithTransactionCallbacks.all.first! + + CountryWithTransactionCallbacks.transaction do + country.name = "another name" + country.save + raise DB::Rollback.new + end + country.not_nil!.update_commit_callback.should be_false + end + end + end + describe "destroy" do it "calls callback after top level transaction is committed" do void_transaction do @@ -252,6 +344,29 @@ describe Jennifer::Model::Callback do end end + describe "update" do + it "doesn't call callback after top level transaction is committed" do + void_transaction do + country = CountryWithTransactionCallbacks.create(name: "name") + country.name = "new name" + country.save + country.not_nil!.update_rollback_callback.should be_false + end + end + + it "called if transaction is rolled back" do + void_transaction do + country = CountryWithTransactionCallbacks.create(name: "name") + CountryWithTransactionCallbacks.transaction do + country.name = "new name" + country.save + raise DB::Rollback.new + end + country.not_nil!.update_rollback_callback.should be_true + end + end + end + describe "save" do context "when creating new record" do it "calls callback after top level transaction is rolled back" do diff --git a/spec/models.cr b/spec/models.cr index ab51439a..1a6f5798 100644 --- a/spec/models.cr +++ b/spec/models.cr @@ -146,7 +146,15 @@ class Profile < ApplicationRecord type: String ) + getter commit_callback_called = false + belongs_to :contact, Contact + + after_commit :set_commit, on: :create + + def set_commit + @commit_callback_called = true + end end class FacebookProfile < Profile @@ -154,9 +162,17 @@ class FacebookProfile < Profile uid: String? # for testing purposes ) + getter fb_commit_callback_called = false + validates_length :uid, is: 4 has_and_belongs_to_many :facebook_contacts, Contact, foreign: :profile_id + + after_commit :fb_set_commit, on: :create + + def fb_set_commit + @fb_commit_callback_called = true + end end class TwitterProfile < Profile @@ -177,7 +193,8 @@ class Country < Jennifer::Model::Base has_and_belongs_to_many :contacts, Contact - {% for callback in %i(before_save after_save after_create before_create after_initialize before_destroy after_destroy) %} + {% for callback in %i(before_save after_save after_create before_create after_initialize + before_destroy after_destroy before_update after_update) %} getter {{callback.id}}_attr = false {{callback.id}} {{callback}}_check @@ -188,6 +205,7 @@ class Country < Jennifer::Model::Base {% end %} before_create :test_skip + before_update :test_skip def test_skip if name == "not create" @@ -221,7 +239,7 @@ class CountryWithTransactionCallbacks < ApplicationRecord name: String }) - {% for action in [:create, :save, :destroy] %} + {% for action in [:create, :save, :destroy, :update] %} {% for type in [:commit, :rollback] %} {% name = "#{action.id}_#{type.id}_callback".id %} diff --git a/src/jennifer/adapter/observer/base.cr b/src/jennifer/adapter/observer/base.cr deleted file mode 100644 index e38f0264..00000000 --- a/src/jennifer/adapter/observer/base.cr +++ /dev/null @@ -1,36 +0,0 @@ -require "../../model/callback" - -module Jennifer - module Adapter - module Observer - class Base - getter record : Model::Callback, action : Symbol - - def initialize(@record, @action) - end - - def dispatch_commit - case @action - when :create - record.__after_create_commit_callback - when :save - record.__after_save_commit_callback - when :destroy - record.__after_destroy_commit_callback - end - end - - def dispatch_rollback - case @action - when :create - record.__after_create_rollback_callback - when :save - record.__after_save_rollback_callback - when :destroy - record.__after_destroy_rollback_callback - end - end - end - end - end -end diff --git a/src/jennifer/adapter/transaction_observer.cr b/src/jennifer/adapter/transaction_observer.cr index eaf886dc..93493c30 100644 --- a/src/jennifer/adapter/transaction_observer.cr +++ b/src/jennifer/adapter/transaction_observer.cr @@ -1,38 +1,6 @@ -require "./observer/base" - module Jennifer module Adapter class TransactionObserver - # property transaction : DB::Transaction - # @rolled_back = false - # @commit_observers = [] of Observer::Base - # @rollback_observers = [] of Observer::Base - - # delegate connection, to: transaction - - # def initialize(@transaction) - # end - - # def rollback - # @rolled_back = true - # end - - # def observe_commit(record, action) - # @commit_observers << Observer::Base.new(record, action) - # end - - # def observe_rollback(record, action) - # @rollback_observers << Observer::Base.new(record, action) - # end - - # def update - # unless @rolled_back - # @commit_observers.each(&.dispatch_commit) - # else - # @rollback_observers.each(&.dispatch_rollback) - # end - # end - property transaction : DB::Transaction @rolled_back = false @commit_observers = [] of -> Bool diff --git a/src/jennifer/adapter/transactions.cr b/src/jennifer/adapter/transactions.cr index 19f495b4..0ff20470 100644 --- a/src/jennifer/adapter/transactions.cr +++ b/src/jennifer/adapter/transactions.cr @@ -77,14 +77,6 @@ module Jennifer res end - # def subscribe_on_commit(record, action) - # @locks[fiber_id].observe_commit(record, action) - # end - - # def subscribe_on_rollback(record, action) - # @locks[fiber_id].observe_rollback(record, action) - # end - def subscribe_on_commit(block : -> Bool) @locks[fiber_id].observe_commit(block) end @@ -93,15 +85,14 @@ module Jennifer @locks[fiber_id].observe_rollback(block) end - # Starts manual transaction for current fiber. Designed as test case isolation method. + # Starts manual transaction for current fiber. Designed as test case isolating method. def begin_transaction raise ::Jennifer::BaseException.new("Couldn't manually begin non top level transaction") if current_transaction Config.logger.debug("TRANSACTION START") lock_connection(@db.checkout.begin_transaction) end - # Closes manual transaction for current fiber. Will not process any commit or rollback callbacks on records. - # Designed as test case isolation method. + # Closes manual transaction for current fiber. Designed as test case isolating method. def rollback_transaction t = current_transaction raise ::Jennifer::BaseException.new("No transaction to rollback") unless t diff --git a/src/jennifer/model/callback.cr b/src/jennifer/model/callback.cr index c2ba8aaf..003165c4 100644 --- a/src/jennifer/model/callback.cr +++ b/src/jennifer/model/callback.cr @@ -9,6 +9,10 @@ module Jennifer true end + def __before_update_callback + true + end + def __before_destroy_callback true end @@ -25,6 +29,10 @@ module Jennifer true end + def __after_update_callback + true + end + def __after_initialize_callback true end @@ -45,6 +53,10 @@ module Jennifer true end + def __after_update_commit_callback + true + end + def __after_destroy_commit_callback true end @@ -61,6 +73,10 @@ module Jennifer true end + def __after_update_rollback_callback + true + end + macro before_save(*names) {% for name in names %} {% CALLBACKS[:save][:before] << name.id.stringify %} @@ -85,6 +101,18 @@ module Jennifer {% end %} end + macro before_update(*names) + {% for name in names %} + {% CALLBACKS[:update][:before] << name.id.stringify %} + {% end %} + end + + macro after_update(*names) + {% for name in names %} + {% CALLBACKS[:update][:after] << name.id.stringify %} + {% end %} + end + macro after_initialize(*names) {% for name in names %} {% CALLBACKS[:initialize][:after] << name.id.stringify %} @@ -116,7 +144,7 @@ module Jennifer end macro after_commit(*names, on) - {% unless [:create, :save, :destroy].includes?(on) %} + {% unless [:create, :save, :destroy, :update].includes?(on) %} {% raise "#{on} is invalid action for %after_commit callback." %} {% end %} {% for name in names %} @@ -125,7 +153,7 @@ module Jennifer end macro after_rollback(*names, on) - {% unless [:create, :save, :destroy].includes?(on) %} + {% unless [:create, :save, :destroy, :update].includes?(on) %} {% raise "#{on} is invalid action for %after_rollback callback." %} {% end %} {% for name in names %} @@ -147,6 +175,12 @@ module Jennifer commit: [] of String, rollback: [] of String }, + update: { + before: [] of String, + after: [] of String, + commit: [] of String, + rollback: [] of String + }, destroy: { after: [] of String, before: [] of String, @@ -165,7 +199,7 @@ module Jennifer macro finished_hook \{% for type in [:before, :after] %} - \{% for action in [:save, :create, :destroy, :validation] %} + \{% for action in [:save, :create, :destroy, :validation, :update] %} \{% if !CALLBACKS[action][type].empty? %} def __\{{type.id}}_\{{action.id}}_callback return false unless super @@ -178,7 +212,7 @@ module Jennifer \{% end %} \{% end %} - \{% for action in ["save", "create", "destroy"] %} + \{% for action in ["save", "create", "destroy", "update"] %} \{% for type in ["commit", "rollback"] %} \{% constant_name = "HAS_#{action.upcase.id}_#{type.upcase.id}_CALLBACK" %} \{% if !CALLBACKS[action][type].empty? %} diff --git a/src/jennifer/model/mapping.cr b/src/jennifer/model/mapping.cr index a29a563b..05587bfb 100644 --- a/src/jennifer/model/mapping.cr +++ b/src/jennifer/model/mapping.cr @@ -183,7 +183,7 @@ module Jennifer {{properties.keys.map { |key| "@#{key.id}" }.join(", ").id}} = _extract_attributes(%pull) end - # Accepts symbol hash or named tuple, stringify it and calls + # Accepts symbol hash or named tuple, stringify it and calls constructor with string-based keys hash. # TODO: check how converting affects performance def initialize(values : Hash(Symbol, ::Jennifer::DBAny) | NamedTuple) initialize(stringify_hash(values, Jennifer::DBAny)) @@ -371,69 +371,44 @@ module Jennifer end def save(skip_validation : Bool = false) : Bool - result = - unless self.class.adapter.under_transaction? - {{@type}}.transaction do - save_without_transaction(skip_validation) - end || false - else - save_without_transaction(skip_validation) - end - return result unless result - # adapter.subscribe_on_commit() if HAS_COMMIT_CALLBACK - # adapter.subscribe_on_rollback if HAS_ROLLBACK_CALLBACK - result + unless self.class.adapter.under_transaction? + self.class.transaction do + save_record_under_transaction(skip_validation) + end || false + else + save_record_under_transaction(skip_validation) + end end # Saves all changes to db without invoking transaction; if validation not passed - returns `false` def save_without_transaction(skip_validation : Bool = false) : Bool - unless skip_validation - return false unless __before_validation_callback - validate! - return false unless valid? - __after_validation_callback - end + return false unless skip_validation || validate_record return false unless __before_save_callback response = if new_record? - return false unless __before_create_callback - res = self.class.adapter.insert(self) - {% if primary && primary_auto_incrementable %} - if primary.nil? && res.last_insert_id > -1 - init_primary_field(res.last_insert_id.to_i) - end - {% end %} - @new_record = false if res.rows_affected != 0 - __after_create_callback - self.class.adapter.subscribe_on_commit(->__after_create_commit_callback) if HAS_CREATE_COMMIT_CALLBACK - self.class.adapter.subscribe_on_rollback(->__after_create_rollback_callback) if HAS_CREATE_ROLLBACK_CALLBACK - res + store_record else - self.class.adapter.update(self) + update_record end __after_save_callback - self.class.adapter.subscribe_on_commit(->__after_save_commit_callback) if HAS_SAVE_COMMIT_CALLBACK - self.class.adapter.subscribe_on_rollback(->__after_save_rollback_callback) if HAS_SAVE_ROLLBACK_CALLBACK - response.rows_affected == 1 + response end # Deletes object from db and calls callbacks def destroy - {% begin %} - result = - unless self.class.adapter.under_transaction? - self.class.transaction do - destroy_without_transaction - end - else + result = + unless self.class.adapter.under_transaction? + self.class.transaction do destroy_without_transaction end - if result - self.class.adapter.subscribe_on_commit(->__after_destroy_commit_callback) if HAS_DESTROY_COMMIT_CALLBACK - self.class.adapter.subscribe_on_rollback(->__after_destroy_rollback_callback) if HAS_DESTROY_ROLLBACK_CALLBACK + else + destroy_without_transaction end - result - {% end %} + if result + self.class.adapter.subscribe_on_commit(->__after_destroy_commit_callback) if HAS_DESTROY_COMMIT_CALLBACK + self.class.adapter.subscribe_on_rollback(->__after_destroy_rollback_callback) if HAS_DESTROY_ROLLBACK_CALLBACK + end + result end # Reloads all fields from db. @@ -586,6 +561,50 @@ module Jennifer {{properties.keys.map { |key| "@#{key.id}" }.join(", ").id}} = _extract_attributes(values) end + private def store_record : Bool + return false unless __before_create_callback + res = self.class.adapter.insert(self) + {% if primary && primary_auto_incrementable %} + if primary.nil? && res.last_insert_id > -1 + init_primary_field(res.last_insert_id.to_i) + end + {% end %} + raise ::Jennifer::BaseException.new("Record hasn't been stored to the db") if res.rows_affected == 0 + @new_record = false + __after_create_callback + true + end + + private def update_record : Bool + return false unless __before_update_callback + res = self.class.adapter.update(self) + __after_update_callback + res.rows_affected == 1 + end + + private def validate_record : Bool + return false unless __before_validation_callback + validate! + return false unless valid? + __after_validation_callback + true + end + + private def save_record_under_transaction(skip_validation) : Bool + is_new_record = new_record? + return false unless save_without_transaction(skip_validation) + if is_new_record + self.class.adapter.subscribe_on_commit(->__after_create_commit_callback) if HAS_CREATE_COMMIT_CALLBACK + self.class.adapter.subscribe_on_rollback(->__after_create_rollback_callback) if HAS_CREATE_ROLLBACK_CALLBACK + else + self.class.adapter.subscribe_on_commit(->__after_update_commit_callback) if HAS_CREATE_COMMIT_CALLBACK + self.class.adapter.subscribe_on_rollback(->__after_update_rollback_callback) if HAS_CREATE_ROLLBACK_CALLBACK + end + self.class.adapter.subscribe_on_commit(->__after_save_commit_callback) if HAS_SAVE_COMMIT_CALLBACK + self.class.adapter.subscribe_on_rollback(->__after_save_rollback_callback) if HAS_SAVE_ROLLBACK_CALLBACK + true + end + macro inherited MODEL = true end diff --git a/src/jennifer/model/validation.cr b/src/jennifer/model/validation.cr index 5a0cae4b..44b60b82 100644 --- a/src/jennifer/model/validation.cr +++ b/src/jennifer/model/validation.cr @@ -9,6 +9,20 @@ module Jennifer def validate(skip = false) end + # TODO: invoke validation callbacks + def validate!(skip = false) + errors.clear! + return if skip + + # TODO: think about global validation + if self.responds_to?(:validate_global) + self.validate_global + end + if self.responds_to?(:validate) + self.validate + end + end + macro validates_with_method(name) {% VALIDATION_METHODS << name.id.stringify %} end @@ -111,7 +125,7 @@ module Jennifer def %validate_method value = @{{field.id}} - if {{@type}}.where { _{{field.id}} == value }.exists? + if self.class.where { _{{field.id}} == value }.exists? errors.add({{field}}, must_be_unique_message(value)) end end diff --git a/src/jennifer/query_builder/executables.cr b/src/jennifer/query_builder/executables.cr index 6e8db4b3..b73f7678 100644 --- a/src/jennifer/query_builder/executables.cr +++ b/src/jennifer/query_builder/executables.cr @@ -63,6 +63,7 @@ module Jennifer adapter.modify(self, options) end + # TODO: load objects and perform all callbacks and validation def update(options : Hash) adapter.update(self, options) end From 0712f7b261fbf4f247c791b40d17491c2626a72d Mon Sep 17 00:00:00 2001 From: Roman Kalnytskyi Date: Mon, 29 Jan 2018 10:28:53 +0200 Subject: [PATCH 15/19] Add human attribute and model name translations --- docs/internationalization.md | 0 docs/time.md | 23 +++++++++++ docs/timestamps.md | 6 +-- shard.yml | 3 ++ spec/config.cr | 4 ++ spec/fixtures/locales/en.yml | 21 ++++++++++ spec/fixtures/locales/file1.yml | 8 ++++ spec/fixtures/locales/file2.yml | 17 ++++++++ spec/model/localization_spec.cr | 24 +++++++++++ spec/models.cr | 16 ++++++++ spec/view/localization_spec.cr | 46 +++++++++++++++++++++ src/jennifer.cr | 7 +++- src/jennifer/config.cr | 7 +++- src/jennifer/model/base.cr | 2 + src/jennifer/model/localization.cr | 66 ++++++++++++++++++++++++++++++ src/jennifer/translation.cr | 26 ++++++++++++ src/jennifer/view/base.cr | 11 ++++- 17 files changed, 278 insertions(+), 9 deletions(-) create mode 100644 docs/internationalization.md create mode 100644 docs/time.md create mode 100644 spec/fixtures/locales/en.yml create mode 100644 spec/fixtures/locales/file1.yml create mode 100644 spec/fixtures/locales/file2.yml create mode 100644 spec/model/localization_spec.cr create mode 100644 spec/view/localization_spec.cr create mode 100644 src/jennifer/model/localization.cr create mode 100644 src/jennifer/translation.cr diff --git a/docs/internationalization.md b/docs/internationalization.md new file mode 100644 index 00000000..e69de29b diff --git a/docs/time.md b/docs/time.md new file mode 100644 index 00000000..1120169d --- /dev/null +++ b/docs/time.md @@ -0,0 +1,23 @@ +# 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: + +```crystal +Jennifer::Config.config do |conf| + # default is one stored in TZ env variable or UTC if absent + conf.local_time_zone_name = "Etc/GMT+1" +end +``` + +Jennifer use own default time zone, so `Time.zone.now` still uses it's own default zone. If you need same time zone for this case as well - just assign it as well or make assignment of default TimeZone zone instead of setting it to the Jennifer itself: + +```crystal +TimeZone::Zone.default = "Etc/GMT+1" + +Jennifer::Config.config do |conf| + # ... + # this isn't needed now + # conf.local_time_zone_name = "Etc/GMT+1" +end +``` diff --git a/docs/timestamps.md b/docs/timestamps.md index 231bd7d7..289728a7 100644 --- a/docs/timestamps.md +++ b/docs/timestamps.md @@ -1,4 +1,4 @@ -# Timestamps and Time +# Timestamps `with_timestamps` macros adds callbacks for `created_at` and `updated_at` fields update. But now they still should be mentioned in mapping manually: @@ -12,7 +12,3 @@ class MyModel < Jennifer::Model::Base ) end ``` - -### 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. diff --git a/shard.yml b/shard.yml index 86c126c4..dc218280 100644 --- a/shard.yml +++ b/shard.yml @@ -36,3 +36,6 @@ dependencies: time_zone: github: imdrasil/time_zone version: "~> 0.1" + i18n: + github: TechMagister/i18n.cr + commit: "be5d67671de6c578482d74605927234d6eab32e1" diff --git a/spec/config.cr b/spec/config.cr index d6dcc665..9496d62b 100644 --- a/spec/config.cr +++ b/spec/config.cr @@ -100,3 +100,7 @@ def set_default_configuration end set_default_configuration + +I18n.load_path += ["spec/fixtures/locales"] +I18n.default_locale = "en" +I18n.init diff --git a/spec/fixtures/locales/en.yml b/spec/fixtures/locales/en.yml new file mode 100644 index 00000000..3a5b8da8 --- /dev/null +++ b/spec/fixtures/locales/en.yml @@ -0,0 +1,21 @@ +# --- +# jennifer: +# models: +# contact: tContact +# views: +# female_contact: tFemale contact +# male_contact: tMale contact +# attributes: +# male_contact: +# id: tId +# female_contact: +# id: tId +# contact: +# id: tId +# name: tName +# ballance: tBallance +# age: yAge +# profile: +# login: tLogin +# twitter_profile: +# login: phone \ No newline at end of file diff --git a/spec/fixtures/locales/file1.yml b/spec/fixtures/locales/file1.yml new file mode 100644 index 00000000..ea5d151b --- /dev/null +++ b/spec/fixtures/locales/file1.yml @@ -0,0 +1,8 @@ +--- +en: + jennifer: + models: + contact: tContact + views: + female_contact: tFemale contact + male_contact: tMale contact diff --git a/spec/fixtures/locales/file2.yml b/spec/fixtures/locales/file2.yml new file mode 100644 index 00000000..b8d43232 --- /dev/null +++ b/spec/fixtures/locales/file2.yml @@ -0,0 +1,17 @@ +--- +en: + jennifer: + attributes: + male_contact: + id: tId + female_contact: + id: tId + contact: + id: tId + name: tName + ballance: tBallance + age: yAge + profile: + login: tLogin + twitter_profile: + login: phone diff --git a/spec/model/localization_spec.cr b/spec/model/localization_spec.cr new file mode 100644 index 00000000..2a5665f4 --- /dev/null +++ b/spec/model/localization_spec.cr @@ -0,0 +1,24 @@ +require "../spec_helper" + +describe Jennifer::Model::Localization do + describe "::human_attribute_name" do + context "when attributes has localication" do + it { Contact.human_attribute_name(:id).should eq("tId") } + end + + context "when attributes have no localication" do + it { Contact.human_attribute_name(:tags).should eq("Tags") } + it { Contact.human_attribute_name(:created_at).should eq("Created at") } + end + + context "when attributes defined by parent class" do + it { FacebookProfile.human_attribute_name(:login).should eq("tLogin") } + it { TwitterProfile.human_attribute_name(:login).should eq("phone") } + end + end + + describe "::human" do + it { Contact.human.should eq("tContact") } + it { FacebookProfile.human.should eq("Facebook profile") } + end +end \ No newline at end of file diff --git a/spec/models.cr b/spec/models.cr index 1a6f5798..c3f3a21e 100644 --- a/spec/models.cr +++ b/spec/models.cr @@ -398,6 +398,22 @@ class MaleContact < Jennifer::View::Base 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" diff --git a/spec/view/localization_spec.cr b/spec/view/localization_spec.cr new file mode 100644 index 00000000..0e4c4878 --- /dev/null +++ b/spec/view/localization_spec.cr @@ -0,0 +1,46 @@ +require "../spec_helper" + +# View and materialized view localization +describe Jennifer::Model::Localization do + describe "View" do + describe "::human_attribute_name" do + context "when attributes has localication" do + it { MaleContact.human_attribute_name(:id).should eq("tId") } + end + + context "when attributes have no localication" do + it { MaleContact.human_attribute_name(:age).should eq("Age") } + it { MaleContact.human_attribute_name(:created_at).should eq("Created at") } + end + + pending "when attributes defined by parent class" do + end + end + + describe "::human" do + it { MaleContact.human.should eq("tMale contact") } + it { FakeContactView.human.should eq("Fake contact view") } + end + end + + describe "Materialized view" do + describe "::human_attribute_name" do + context "when attributes has localication" do + it { FemaleContact.human_attribute_name(:id).should eq("tId") } + end + + context "when attributes have no localication" do + it { FemaleContact.human_attribute_name(:age).should eq("Age") } + it { FemaleContact.human_attribute_name(:created_at).should eq("Created at") } + end + + pending "when attributes defined by parent class" do + end + end + + describe "::human" do + it { FemaleContact.human.should eq("tFemale contact") } + it { FakeFemaleContact.human.should eq("Fake female contact") } + end + end +end \ No newline at end of file diff --git a/src/jennifer.cr b/src/jennifer.cr index 4c46e7c3..f7ca0712 100644 --- a/src/jennifer.cr +++ b/src/jennifer.cr @@ -3,6 +3,9 @@ require "inflector/string" require "accord" require "ifrit/converter" require "time_zone" +require "i18n" + +require "./jennifer/translation" require "./jennifer/macros" @@ -19,7 +22,8 @@ require "./jennifer/query_builder/*" require "./jennifer/adapter/base" require "./jennifer/relation/base" require "./jennifer/relation/*" -require "./jennifer/model/*" + +require "./jennifer/model/base" require "./jennifer/view/base" @@ -74,3 +78,4 @@ struct JSON::Any end ::Jennifer.after_load_hook +I18n.backend = Jennifer::Translation::MultifileYAML.new diff --git a/src/jennifer/config.cr b/src/jennifer/config.cr index 6cdc6c2d..b46b2df6 100644 --- a/src/jennifer/config.cr +++ b/src/jennifer/config.cr @@ -4,9 +4,12 @@ require "logger" module Jennifer class Config CONNECTION_URI_PARAMS = [:max_pool_size, :initial_pool_size, :max_idle_pool_size, :retry_attempts, :checkout_timeout, :retry_delay] - STRING_FIELDS = {:user, :password, :db, :host, :adapter, :migration_files_path, :schema, :structure_folder, :local_time_zone_name} + STRING_FIELDS = { + :user, :password, :db, :host, :adapter, :migration_files_path, :schema, + :structure_folder, :local_time_zone_name, :locale + } INT_FIELDS = {:port, :max_pool_size, :initial_pool_size, :max_idle_pool_size, :retry_attempts} - FLOAT_FIELDS = [:checkout_timeout, :retry_delay] + FLOAT_FIELDS = {:checkout_timeout, :retry_delay} macro define_fields(const, default) {% for field in @type.constant(const.stringify) %} diff --git a/src/jennifer/model/base.cr b/src/jennifer/model/base.cr index 3dfc32fc..96bef044 100644 --- a/src/jennifer/model/base.cr +++ b/src/jennifer/model/base.cr @@ -4,11 +4,13 @@ require "./validation" require "./callback" require "./relation_definition" require "./scoping" +require "./localization" module Jennifer module Model abstract class Base extend Ifrit + extend Localization include Mapping include STIMapping include Validation diff --git a/src/jennifer/model/localization.cr b/src/jennifer/model/localization.cr new file mode 100644 index 00000000..35ae6e3e --- /dev/null +++ b/src/jennifer/model/localization.cr @@ -0,0 +1,66 @@ +module Jennifer + module Model + # Includes localization methods. + # + # Depends of parent class `::lookup_ancestors` and `::i18n_scope` methods. + module Localization + GLOBAL_SCOPE = "jennifer" + + # Search translation for given attribute. + def human_attribute_name(attribute : String | Symbol) + prefix = "#{GLOBAL_SCOPE}.attributes." + + tr = try_translate(prefix + "#{i18n_key}.#{attribute}") + return tr.as(String) if tr + lookup_ancestors do |ancestor| + tr = try_translate(prefix + "#{ancestor.i18n_key}.#{attribute}") + return tr.as(String) if tr + end + + Inflector.humanize(attribute) + end + + # Returns localized model name. + def human + prefix = "#{GLOBAL_SCOPE}.#{i18n_scope}." + + tr = try_translate(prefix + i18n_key) + return tr.as(String) if tr + lookup_ancestors do |ancestor| + tr = try_translate(prefix + ancestor.i18n_key) + return tr.as(String) if tr + end + + Inflector.humanize(i18n_key) + end + + def i18n_scope + :models + end + + # Represents key whcih be used to search any related to current class localization information. + def i18n_key + return @@i18n_key unless @@i18n_key.empty? + @@i18n_key = Inflector.underscore(Inflector.demodulize(to_s)).downcase + end + + private def lookup_ancestors(&block) + klass = superclass + while true + yield klass + break if !klass.responds_to?(:superclass) + klass = klass.superclass + end + end + + private def try_translate(path) + tr = I18n.backend.translations[I18n.default_locale][path]? + tr ? tr.to_s : tr + end + + macro extended + @@i18n_key : String = "" + end + end + end +end diff --git a/src/jennifer/translation.cr b/src/jennifer/translation.cr new file mode 100644 index 00000000..52531786 --- /dev/null +++ b/src/jennifer/translation.cr @@ -0,0 +1,26 @@ +module Jennifer + module Translation + # NOTE: temporary workarond for native I18n::Backend::Yaml until it suppurts + # parsing locale from file first key + class MultifileYAML < ::I18n::Backend::Yaml + def load(path) + files = Dir.glob(path + "/*.yml") + + files.each do |file| + lang = File.basename(file, ".yml") + lang_data = load_file(file) + next if lang_data.raw.nil? + + lang_data.each do |lang, data| + next if data.raw.nil? + + lang = lang.to_s + @translations[lang] ||= {} of String => YAML::Type + @translations[lang].merge!(self.class.normalize(data.as_h)) + @available_locales << lang unless @available_locales.includes?(lang) + end + end + end + end + end +end diff --git a/src/jennifer/view/base.cr b/src/jennifer/view/base.cr index 52fb1448..6e70fadd 100644 --- a/src/jennifer/view/base.cr +++ b/src/jennifer/view/base.cr @@ -4,6 +4,7 @@ module Jennifer module View abstract class Base extend Ifrit + extend Model::Localization include ExperimentalMapping include Model::Scoping @@ -21,7 +22,7 @@ module Jennifer @@view_name = value end - # NOTE: for query generating + # NOTE: is used for query generating def self.table_name view_name end @@ -71,6 +72,10 @@ module Jennifer Adapter.adapter end + def self.i18n_scope + :views + end + def append_relation(name : String, hash) raise Jennifer::UnknownRelation.new(self.class, name) end @@ -116,6 +121,10 @@ module Jennifer COLUMNS_METADATA.size end + def self.superclass + {{@type.superclass}} + end + macro finished def __after_initialize_callback return false unless super From a9b7ead952bb65624e30f67de9fea3de5e1e13da Mon Sep 17 00:00:00 2001 From: Roman Kalnytskyi Date: Mon, 12 Feb 2018 16:45:04 +0200 Subject: [PATCH 16/19] Moves all error messages to yaml --- shard.yml | 2 +- spec/config.cr | 2 +- spec/fixtures/locales/en.yml | 21 -- spec/fixtures/locales/errors/en.yml | 16 + spec/fixtures/locales/file1.yml | 8 - spec/fixtures/locales/file2.yml | 17 - spec/fixtures/locales/models/en.yml | 21 ++ spec/model/mapping_spec.cr | 4 +- ...calization_spec.cr => translation_spec.cr} | 16 +- spec/model/validation_spec.cr | 292 ++++++++++++++---- spec/models.cr | 13 +- spec/spec_helper.cr | 58 ++++ ...calization_spec.cr => translation_spec.cr} | 2 +- src/jennifer.cr | 5 +- src/jennifer/config.cr | 2 +- src/jennifer/locale/en.yml | 34 ++ src/jennifer/model/base.cr | 4 +- src/jennifer/model/localization.cr | 66 ---- src/jennifer/model/translation.cr | 108 +++++++ src/jennifer/model/validation.cr | 90 ++++-- src/jennifer/model/validation_messages.cr | 41 --- src/jennifer/translation.cr | 26 -- src/jennifer/view/base.cr | 2 +- 23 files changed, 573 insertions(+), 277 deletions(-) delete mode 100644 spec/fixtures/locales/en.yml create mode 100644 spec/fixtures/locales/errors/en.yml delete mode 100644 spec/fixtures/locales/file1.yml delete mode 100644 spec/fixtures/locales/file2.yml create mode 100644 spec/fixtures/locales/models/en.yml rename spec/model/{localization_spec.cr => translation_spec.cr} (51%) rename spec/view/{localization_spec.cr => translation_spec.cr} (97%) create mode 100644 src/jennifer/locale/en.yml delete mode 100644 src/jennifer/model/localization.cr create mode 100644 src/jennifer/model/translation.cr delete mode 100644 src/jennifer/model/validation_messages.cr delete mode 100644 src/jennifer/translation.cr diff --git a/shard.yml b/shard.yml index dc218280..2c270ce6 100644 --- a/shard.yml +++ b/shard.yml @@ -38,4 +38,4 @@ dependencies: version: "~> 0.1" i18n: github: TechMagister/i18n.cr - commit: "be5d67671de6c578482d74605927234d6eab32e1" + commit: "fc96c6b12547c84da2e76495f9c970acda64976b" diff --git a/spec/config.cr b/spec/config.cr index 9496d62b..04ce6108 100644 --- a/spec/config.cr +++ b/spec/config.cr @@ -101,6 +101,6 @@ end set_default_configuration -I18n.load_path += ["spec/fixtures/locales"] +I18n.load_path += ["spec/fixtures/locales/**"] I18n.default_locale = "en" I18n.init diff --git a/spec/fixtures/locales/en.yml b/spec/fixtures/locales/en.yml deleted file mode 100644 index 3a5b8da8..00000000 --- a/spec/fixtures/locales/en.yml +++ /dev/null @@ -1,21 +0,0 @@ -# --- -# jennifer: -# models: -# contact: tContact -# views: -# female_contact: tFemale contact -# male_contact: tMale contact -# attributes: -# male_contact: -# id: tId -# female_contact: -# id: tId -# contact: -# id: tId -# name: tName -# ballance: tBallance -# age: yAge -# profile: -# login: tLogin -# twitter_profile: -# login: phone \ No newline at end of file diff --git a/spec/fixtures/locales/errors/en.yml b/spec/fixtures/locales/errors/en.yml new file mode 100644 index 00000000..9d230037 --- /dev/null +++ b/spec/fixtures/locales/errors/en.yml @@ -0,0 +1,16 @@ +jennifer: + errors: + messages: + global_error: global error + name: + global_error: name global error + facebook_profile: + child_error: model child error + attributes: + uid: + child_error: uid child error + profile: + parent_error: model parent error + attributes: + id: + parent_error: id parent error diff --git a/spec/fixtures/locales/file1.yml b/spec/fixtures/locales/file1.yml deleted file mode 100644 index ea5d151b..00000000 --- a/spec/fixtures/locales/file1.yml +++ /dev/null @@ -1,8 +0,0 @@ ---- -en: - jennifer: - models: - contact: tContact - views: - female_contact: tFemale contact - male_contact: tMale contact diff --git a/spec/fixtures/locales/file2.yml b/spec/fixtures/locales/file2.yml deleted file mode 100644 index b8d43232..00000000 --- a/spec/fixtures/locales/file2.yml +++ /dev/null @@ -1,17 +0,0 @@ ---- -en: - jennifer: - attributes: - male_contact: - id: tId - female_contact: - id: tId - contact: - id: tId - name: tName - ballance: tBallance - age: yAge - profile: - login: tLogin - twitter_profile: - login: phone diff --git a/spec/fixtures/locales/models/en.yml b/spec/fixtures/locales/models/en.yml new file mode 100644 index 00000000..467cc935 --- /dev/null +++ b/spec/fixtures/locales/models/en.yml @@ -0,0 +1,21 @@ +--- +jennifer: + models: + contact: tContact + views: + female_contact: tFemale contact + male_contact: tMale contact + attributes: + male_contact: + id: tId + female_contact: + id: tId + contact: + id: tId + name: tName + ballance: tBallance + age: yAge + profile: + login: tLogin + twitter_profile: + login: phone diff --git a/spec/model/mapping_spec.cr b/spec/model/mapping_spec.cr index f960efd1..dad75919 100644 --- a/spec/model/mapping_spec.cr +++ b/spec/model/mapping_spec.cr @@ -48,8 +48,8 @@ describe Jennifer::Model::Mapping do rescue ex : Jennifer::RecordInvalid ex.errors.size.should eq(3) raw_errors = ex.errors.@errors - validate_error(raw_errors[0], :age, "must be in 13..75 but is 12") - validate_error(raw_errors[1], :name, "must be less than or equal 15 but is 22") + validate_error(raw_errors[0], :age, "is not included in the list") + validate_error(raw_errors[1], :name, "is too long (maximum is 15 characters)") validate_error(raw_errors[2], :description, "Too large description") end end diff --git a/spec/model/localization_spec.cr b/spec/model/translation_spec.cr similarity index 51% rename from spec/model/localization_spec.cr rename to spec/model/translation_spec.cr index 2a5665f4..1c5ff949 100644 --- a/spec/model/localization_spec.cr +++ b/spec/model/translation_spec.cr @@ -1,6 +1,6 @@ require "../spec_helper" -describe Jennifer::Model::Localization do +describe Jennifer::Model::Translation do describe "::human_attribute_name" do context "when attributes has localication" do it { Contact.human_attribute_name(:id).should eq("tId") } @@ -21,4 +21,18 @@ describe Jennifer::Model::Localization do it { Contact.human.should eq("tContact") } it { FacebookProfile.human.should eq("Facebook profile") } end + + describe "::human_error" do + context "without count" do + klass = FacebookProfile + + it { klass.human_error(:uid, :child_error).should eq("uid child error") } + it { klass.human_error(:id, :child_error).should eq("model child error") } + it { klass.human_error(:id, :parent_error).should eq("id parent error") } + it { klass.human_error(:uid, :parent_error).should eq("model parent error") } + it { klass.human_error(:name, :global_error).should eq("name global error") } + it { klass.human_error(:id, :global_error).should eq("global error") } + it { klass.human_error(:id, :unknown_message).should eq("unknown message") } + end + end end \ No newline at end of file diff --git a/spec/model/validation_spec.cr b/spec/model/validation_spec.cr index 362dde58..bb84a07b 100644 --- a/spec/model/validation_spec.cr +++ b/spec/model/validation_spec.cr @@ -1,14 +1,39 @@ require "../spec_helper" +macro validation_class_generator(klass, name, **options) + class {{klass}} < AbstractContactModel + validates_numericality {{name}}, {{**options}} + end +end + +validation_class_generator(GTContact, :age, greater_than: 20) +validation_class_generator(GTEContact, :age, greater_than_or_equal_to: 20) +validation_class_generator(EContact, :age, equal_to: 20) +validation_class_generator(LTContact, :age, less_than: 20) +validation_class_generator(LTEContact, :age, less_than_or_equal_to: 20) +validation_class_generator(OTContact, :age, other_than: 20) +validation_class_generator(OddContact, :age, odd: true) +validation_class_generator(EvenContact, :age, even: true) +validation_class_generator(SeveralValidationsContact, :age, greater_than: 20, less_than_or_equal_to: 30) + +class GTNContact < Jennifer::Model::Base + table_name "contacts" + + mapping({ + id: Primary32, + age: Int32? + }, false) + + validates_numericality :age, greater_than: 20, allow_blank: true +end + describe Jennifer::Model::Validation do describe "%validates_with" do it "accepts accord class validators" do p = Factory.build_passport(enn: "abc") - p.validate! - p.valid?.should be_false + p.should_not be_valid p.enn = "bca" - p.validate! - p.valid?.should be_true + p.should be_valid p.save p.new_record?.should be_false end @@ -17,56 +42,72 @@ describe Jennifer::Model::Validation do describe "%validates_with_method" do it "pass valid" do a = Factory.build_contact(description: "1234567890") - a.validate! - a.valid?.should be_true + a.should be_valid end it "doesn't pass invalid" do a = Factory.build_contact(description: "12345678901") - a.validate! - a.valid?.should be_false + a.should_not be_valid end end describe "%validates_inclucions" do it "pass valid" do a = Factory.build_contact(age: 75) - a.validate! - a.valid?.should be_true + a.should be_valid end it "doesn't pass invalid" do a = Factory.build_contact(age: 76) - a.validate! - a.valid?.should be_false + a.should validate(:age).with("is not included in the list") + end + + context "allows blank" do + pending "doesn't add error message" do + end + + pending "validates if presence" do + end end end describe "%validates_exclusion" do it "pass valid" do c = Factory.build_country(name: "Costa") - c.validate! - c.valid?.should be_true + c.should be_valid end it "doesn't pass invalid" do c = Factory.build_country(name: "asd") - c.validate! - c.valid?.should be_false + c.should validate(:name).with("is reserved") + end + + context "allows blank" do + pending "doesn't add error message" do + end + + pending "validates if presence" do + end end end describe "%validates_format" do it "pass valid names" do a = Factory.build_address(street: "Saint Moon st.") - a.validate! - a.valid?.should be_true + a.should be_valid end it "doesn't pass invalid names" do a = Factory.build_address(street: "Saint Moon walk") - a.validate! - a.valid?.should be_false + a.should validate(:street).with("is invalid") + end + + context "allows blank" do + pending "doesn't add error message" do + end + + pending "validates if presence" do + end end end @@ -74,41 +115,46 @@ describe Jennifer::Model::Validation do context "minimum" do it "pass valid names" do a = Factory.build_contact(name: "a") - a.validate! - a.valid?.should be_true + a.should be_valid end it "doesn't pass invalid names" do a = Factory.build_contact(name: "") - a.validate! - a.valid?.should be_false + a.should validate(:name).with("is too short (minimum is 1 character)") + end + + context "allows blank" do + it "doesn't add error message" do + c = ContactWithDependencies.new({:name => "asd", :description => nil}) + c.should be_valid + end + + it "validates if presence" do + c = ContactWithInValidation.new({:name => "1"}) + c.should validate(:name).with("is too short (minimum is 2 characters)") + end end end context "maximum" do context "doesn't allow blank" do - it "adds error message" do - c = ContactWithDependencies.new({:name => nil, :description => "asdasd"}) + it "adds error message if size is grater" do + c = Factory.build_contact(name: "1234567890123456") + c.should validate(:name).with("is too long (maximum is 15 characters)") + end + + it "doesn't add error if size is less" do + c = Factory.build_contact(name: "123456789012345") c.validate! - c.valid?.should be_false - c.errors[:name].empty?.should_not be_true + c.errors[:name].empty?.should be_true end end context "allows blank" do - it "doesn't add error message" do - c = ContactWithDependencies.new({:name => "asd", :description => nil}) - c.validate! - c.valid?.should be_true + pending "doesn't add error message" do end - it "validates if presence" do - c = ContactWithDependencies.new({:name => "asd", :description => "a"}) - c.validate! - c.valid?.should be_false - c.description = "sd" - c.validate! - c.valid?.should be_true + pending "validates if presence" do end end end @@ -117,18 +163,24 @@ describe Jennifer::Model::Validation do context "doesn't allow blank" do it "adds error message" do c = ContactWithInValidation.new({:name => nil}) - c.validate! - c.valid?.should be_false - c.errors[:name].empty?.should_not be_true + c.should validate(:name).with("can't be blank") end - it "validates if presence" do - c = ContactWithInValidation.new({:name => "a"}) - c.validate! - c.valid?.should be_false - c.name = "sd" - c.validate! - c.valid?.should be_true + context "with present value" do + it "validates too long" do + c = ContactWithInValidation.new({:name => "12345678901"}) + c.should validate(:name).with("is too long (maximum is 10 characters)") + end + + it "validates too short" do + c = ContactWithInValidation.new({:name => "1"}) + c.should validate(:name).with("is too short (minimum is 2 characters)") + end + + it "pass validation if satisfies" do + c = ContactWithInValidation.new({:name => "12"}) + c.should be_valid + end end end end @@ -136,14 +188,12 @@ describe Jennifer::Model::Validation do context "is" do it "adds error if invalid" do p = Factory.create_facebook_profile(login: "asd", uid: "12") - p.validate! - p.valid?.should be_false + p.should validate(:uid).with("is the wrong length (should be 4 characters)") end it "does nothing if valid" do p = Factory.create_facebook_profile(login: "asd", uid: "1234") - p.validate! - p.valid?.should be_true + p.should be_valid end end end @@ -151,15 +201,13 @@ describe Jennifer::Model::Validation do describe "%validates_uniqueness" do it "pass valid" do p = Factory.build_country(name: "123asd") - p.validate! - p.valid?.should be_true + p.should be_valid end it "doesn't pass invalid" do Factory.create_country(name: "123asd") p = Factory.build_country(name: "123asd") - p.validate! - p.valid?.should be_false + p.should validate(:name).with("has already been taken") end end @@ -167,16 +215,138 @@ describe Jennifer::Model::Validation do context "when field is not nil" do it "pass validation" do c = Country.build({:name => "New country"}) - c.validate! - c.valid?.should be_true + c.should be_valid end end context "when field is nil" do it "doesn't pass validation" do c = Country.build({} of String => String) - c.validate! - c.valid?.should be_false + c.should validate(:name).with("can't be blank") + end + end + end + + describe "%validates_numericality" do + context "with allowed nil value" do + it "passes validation if value is nil" do + c = GTNContact.build({ :age => nil }) + c.should be_valid + end + + it "process validation if value is not nil" do + c = GTNContact.build({ :age => 20 }) + c.should_not be_valid + end + end + + context "with greater_than option" do + it "adds error message if it breaks a condition" do + c = GTContact.build(age: 20) + c.should validate(:age).with("must be greater than 20") + end + + it "pass validation if an attribute satisfies condition" do + c = GTContact.build(age: 21) + c.should be_valid + end + end + + context "with greater_than_or_equal_to option" do + it "adds error message if it breaks a condition" do + c = GTEContact.build(age: 19) + c.should validate(:age).with("must be greater than or equal to 20") + end + + it "pass validation if an attribute satisfies condition" do + c = GTEContact.build(age: 20) + c.should be_valid + end + end + + context "with equal_to option" do + it "adds error message if it breaks a condition" do + c = EContact.build(age: 19) + c.should validate(:age).with("must be equal to 20") + end + + it "pass validation if an attribute satisfies condition" do + c = EContact.build(age: 20) + c.should be_valid + end + end + + context "with less_than option" do + it "adds error message if it breaks a condition" do + c = LTContact.build(age: 20) + c.should validate(:age).with("must be less than 20") + end + + it "pass validation if an attribute satisfies condition" do + c = LTContact.build(age: 19) + c.should be_valid + end + end + + context "with less_than_or_equal_to option" do + it "adds error message if it breaks a condition" do + c = LTEContact.build(age: 21) + c.should validate(:age).with("must be less than or equal to 20") + end + + it "pass validation if an attribute satisfies condition" do + c = LTEContact.build(age: 20) + c.should be_valid + end + end + + context "with other_than option" do + it "adds error message if it breaks a condition" do + c = OTContact.build(age: 20) + c.should validate(:age).with("must be other than 20") + end + + it "pass validation if an attribute satisfies condition" do + c = OTContact.build(age: 21) + c.should be_valid + end + end + + context "with odd option" do + it "adds error message if it breaks a condition" do + c = OddContact.build(age: 20) + c.should validate(:age).with("must be odd") + end + + it "pass validation if an attribute satisfies condition" do + c = OddContact.build(age: 21) + c.should be_valid + end + end + + context "with even option" do + it "adds error message if it breaks a condition" do + c = EvenContact.build(age: 21) + c.should validate(:age).with("must be even") + end + + it "pass validation if an attribute satisfies condition" do + c = EvenContact.build(age: 20) + c.should be_valid + end + end + + context "with several specified validations" do + it "adds error message if it breaks any condition" do + c = SeveralValidationsContact.build(age: 20) + c.should validate(:age).with("must be greater than 20") + c.age = 31 + c.should validate(:age).with("must be less than or equal to 30") + end + + it "pass validation if an attribute satisfies all conditions" do + c = SeveralValidationsContact.build(age: 21) + c.should be_valid end end end diff --git a/spec/models.cr b/spec/models.cr index c3f3a21e..4a134644 100644 --- a/spec/models.cr +++ b/spec/models.cr @@ -72,7 +72,9 @@ class Contact < ApplicationRecord has_one :passport, Passport validates_inclucion :age, 13..75 - validates_length :name, minimum: 1, maximum: 15 + validates_length :name, minimum: 1 + # NOTE: only for testing purposes - this is a bad practice; prefer to use `in` + validates_length :name, maximum: 15 validates_with_method :name_check scope :main { where { _age > 18 } } @@ -373,6 +375,15 @@ class ContactWithNillableName < Jennifer::Model::Base }, false) end +class AbstractContactModel < Jennifer::Model::Base + table_name "contacts" + mapping({ + id: Primary32, + name: String?, + age: Int32 + }, false) +end + # =========== # views # =========== diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr index 8cdfebcf..60d9c897 100644 --- a/spec/spec_helper.cr +++ b/spec/spec_helper.cr @@ -135,3 +135,61 @@ macro match_fields(object, **fields) {{object}}.{{field.id}}.should eq({{value}}) {% end %} end + +module Spec + # :nodoc: + struct BeValidExpectation + def match(object) + object.validate! + object.valid? + end + + def failure_message(object) + "Expected: #{object.inspect} to be valid" + end + + def negative_failure_message(object) + "Expected: #{object.inspect} not to be valid" + end + end + + struct AttributeValidationExpectation + @error_message : String? + + def initialize(@attr : Symbol) + end + + def with(msg) + @error_message = msg + self + end + + def match(object) + raise ArgumentError.new("validation message should be specified.") if @error_message.nil? + _error_message = @error_message.not_nil! + + object.validate! + object.errors[@attr].includes?(@error_message) + end + + def failure_message(object) + "Expected: #{object.inspect} to have error message: "\ + "'#{@error_message}', but got: '#{object.errors[@attr].inspect}'" + end + + def negative_failure_message(object) + "Expected: #{object.inspect} not to have error message: "\ + "'#{@error_message}', but got: '#{object.errors[@attr].inspect}'" + end + end + + module Expectations + def be_valid + BeValidExpectation.new + end + + def validate(attr) + AttributeValidationExpectation.new(attr) + end + end +end diff --git a/spec/view/localization_spec.cr b/spec/view/translation_spec.cr similarity index 97% rename from spec/view/localization_spec.cr rename to spec/view/translation_spec.cr index 0e4c4878..7cf765c0 100644 --- a/spec/view/localization_spec.cr +++ b/spec/view/translation_spec.cr @@ -1,7 +1,7 @@ require "../spec_helper" # View and materialized view localization -describe Jennifer::Model::Localization do +describe Jennifer::Model::Translation do describe "View" do describe "::human_attribute_name" do context "when attributes has localication" do diff --git a/src/jennifer.cr b/src/jennifer.cr index f7ca0712..7165fe26 100644 --- a/src/jennifer.cr +++ b/src/jennifer.cr @@ -5,8 +5,6 @@ require "ifrit/converter" require "time_zone" require "i18n" -require "./jennifer/translation" - require "./jennifer/macros" require "./jennifer/exceptions" @@ -78,4 +76,5 @@ struct JSON::Any end ::Jennifer.after_load_hook -I18n.backend = Jennifer::Translation::MultifileYAML.new + +I18n.load_path << File.join(__DIR__, "jennifer/locale") diff --git a/src/jennifer/config.cr b/src/jennifer/config.cr index b46b2df6..329e2e3d 100644 --- a/src/jennifer/config.cr +++ b/src/jennifer/config.cr @@ -6,7 +6,7 @@ module Jennifer CONNECTION_URI_PARAMS = [:max_pool_size, :initial_pool_size, :max_idle_pool_size, :retry_attempts, :checkout_timeout, :retry_delay] STRING_FIELDS = { :user, :password, :db, :host, :adapter, :migration_files_path, :schema, - :structure_folder, :local_time_zone_name, :locale + :structure_folder, :local_time_zone_name } INT_FIELDS = {:port, :max_pool_size, :initial_pool_size, :max_idle_pool_size, :retry_attempts} FLOAT_FIELDS = {:checkout_timeout, :retry_delay} diff --git a/src/jennifer/locale/en.yml b/src/jennifer/locale/en.yml new file mode 100644 index 00000000..26324cb8 --- /dev/null +++ b/src/jennifer/locale/en.yml @@ -0,0 +1,34 @@ +jennifer: + errors: + # format: "%{attribute} %{message}" + messages: + # model_invalid: "Validation failed: %{errors}" + inclusion: "is not included in the list" + exclusion: "is reserved" + invalid: "is invalid" + taken: "has already been taken" + # required: "must exist" + # confirmation: "doesn't match %{attribute}" + # accepted: "must be accepted" + empty: "can't be empty" + blank: "can't be blank" + present: "must be blank" + too_long: + one: "is too long (maximum is 1 character)" + other: "is too long (maximum is %{count} characters)" + too_short: + one: "is too short (minimum is 1 character)" + other: "is too short (minimum is %{count} characters)" + wrong_length: + one: "is the wrong length (should be 1 character)" + other: "is the wrong length (should be %{count} characters)" + # not_a_number: "is not a number" + # not_an_integer: "must be an integer" + greater_than: "must be greater than %{value}" + greater_than_or_equal_to: "must be greater than or equal to %{value}" + equal_to: "must be equal to %{value}" + less_than: "must be less than %{value}" + less_than_or_equal_to: "must be less than or equal to %{value}" + other_than: "must be other than %{value}" + odd: "must be odd" + even: "must be even" diff --git a/src/jennifer/model/base.cr b/src/jennifer/model/base.cr index 96bef044..9f4f4b5b 100644 --- a/src/jennifer/model/base.cr +++ b/src/jennifer/model/base.cr @@ -4,13 +4,13 @@ require "./validation" require "./callback" require "./relation_definition" require "./scoping" -require "./localization" +require "./translation" module Jennifer module Model abstract class Base extend Ifrit - extend Localization + extend Translation include Mapping include STIMapping include Validation diff --git a/src/jennifer/model/localization.cr b/src/jennifer/model/localization.cr deleted file mode 100644 index 35ae6e3e..00000000 --- a/src/jennifer/model/localization.cr +++ /dev/null @@ -1,66 +0,0 @@ -module Jennifer - module Model - # Includes localization methods. - # - # Depends of parent class `::lookup_ancestors` and `::i18n_scope` methods. - module Localization - GLOBAL_SCOPE = "jennifer" - - # Search translation for given attribute. - def human_attribute_name(attribute : String | Symbol) - prefix = "#{GLOBAL_SCOPE}.attributes." - - tr = try_translate(prefix + "#{i18n_key}.#{attribute}") - return tr.as(String) if tr - lookup_ancestors do |ancestor| - tr = try_translate(prefix + "#{ancestor.i18n_key}.#{attribute}") - return tr.as(String) if tr - end - - Inflector.humanize(attribute) - end - - # Returns localized model name. - def human - prefix = "#{GLOBAL_SCOPE}.#{i18n_scope}." - - tr = try_translate(prefix + i18n_key) - return tr.as(String) if tr - lookup_ancestors do |ancestor| - tr = try_translate(prefix + ancestor.i18n_key) - return tr.as(String) if tr - end - - Inflector.humanize(i18n_key) - end - - def i18n_scope - :models - end - - # Represents key whcih be used to search any related to current class localization information. - def i18n_key - return @@i18n_key unless @@i18n_key.empty? - @@i18n_key = Inflector.underscore(Inflector.demodulize(to_s)).downcase - end - - private def lookup_ancestors(&block) - klass = superclass - while true - yield klass - break if !klass.responds_to?(:superclass) - klass = klass.superclass - end - end - - private def try_translate(path) - tr = I18n.backend.translations[I18n.default_locale][path]? - tr ? tr.to_s : tr - end - - macro extended - @@i18n_key : String = "" - end - end - end -end diff --git a/src/jennifer/model/translation.cr b/src/jennifer/model/translation.cr new file mode 100644 index 00000000..1fe5f1b4 --- /dev/null +++ b/src/jennifer/model/translation.cr @@ -0,0 +1,108 @@ +# :nodoc: +private macro _new_translate(*args, **opts) + {% if opts.empty? %} + return I18n.translate({{args.splat}}) if I18n.exists?({{args[0]}}) + {% else %} + return I18n.translate({{args.splat}}, {{**opts}}) if I18n.exists?({{args[0]}}, count: {{opts[:count]}}) + {% end %} +end + +module Jennifer + module Model + # Includes localization methods. + # + # Depends of parent class `::lookup_ancestors` and `::i18n_scope` methods. + module Translation + alias LocalizeableTypes = Int32 | Int64 | Nil | Float32 | Float64 | Time | String | Symbol | Bool + + GLOBAL_SCOPE = "jennifer" + + # Search translation for given attribute. + 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) + end + + path = "#{prefix}.#{attribute}" + return I18n.translate(path) if I18n.exists?(path) + Inflector.humanize(attribute) + end + + def human_error(attr, message, options : Hash = {} of String => String) + human_error(attr, message, nil, options) + end + + def human_error(attr, message, count : Int?, options = {} of String => String) + prefix = "#{GLOBAL_SCOPE}.errors." + opts = { count: count, options: options} + + path = "#{prefix}#{i18n_key}.attributes.#{attr}.#{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) + + lookup_ancestors do |ancestor| + path = "#{prefix}#{ancestor.i18n_key}.attributes.#{attr}.#{message}" + return I18n.translate(path, **opts) if I18n.exists?(path, count: count) + path = "#{prefix}#{ancestor.i18n_key}.#{message}" + return I18n.translate(path, **opts) if I18n.exists?(path, count: count) + end + + path = "#{prefix}#{attr}.#{message}" + return I18n.translate(path, **opts) if I18n.exists?(path, count: count) + path = "#{prefix}messages.#{message}" + return I18n.translate(path, **opts) if I18n.exists?(path, count: count) + + Inflector.humanize(message).downcase + end + + # Returns localized model name. + def human + prefix = "#{GLOBAL_SCOPE}.#{i18n_scope}." + + _new_translate(prefix + i18n_key) + lookup_ancestors do |ancestor| + _new_translate(prefix + ancestor.i18n_key) + end + + Inflector.humanize(i18n_key) + end + + def i18n_scope + :models + end + + # Represents key whcih be used to search any related to current class localization information. + def i18n_key + return @@i18n_key unless @@i18n_key.empty? + @@i18n_key = Inflector.underscore(Inflector.demodulize(to_s)).downcase + end + + private def lookup_ancestors(&block) + klass = superclass + while true + yield klass + break unless klass.responds_to?(:superclass) + klass = klass.superclass + end + end + + macro extended + @@i18n_key : String = "" + end + end + end +end + +# TODO: make a PR to the i18n repo +module I18n + def self.exists?(key, locale = config.locale, count = nil) + key += (count == 1 ? ".one" : ".other") if count + config.backend.translations[locale].has_key?(key) + end +end diff --git a/src/jennifer/model/validation.cr b/src/jennifer/model/validation.cr index 44b60b82..20819986 100644 --- a/src/jennifer/model/validation.cr +++ b/src/jennifer/model/validation.cr @@ -1,10 +1,7 @@ -require "./validation_messages" - module Jennifer module Model module Validation include Accord - include ValidationMessages def validate(skip = false) end @@ -52,7 +49,7 @@ module Jennifer {% if allow_blank %} return if @{{field.id}}.nil? {% else %} - return errors.add({{field}}, not_blank_message) if @{{field.id}}.nil? + return errors.add({{field}}, self.class.human_error({{field}}, :blank)) if @{{field.id}}.nil? {% end %} @{{field.id}}.not_nil! end @@ -64,7 +61,7 @@ module Jennifer def %validate_method value = _not_nil_validation({{field}}, {{allow_blank}}) unless ({{value}}).includes?(value) - errors.add({{field}}, must_be_message({{value}}, value)) + errors.add({{field}}, self.class.human_error({{field}}, :inclusion)) end end end @@ -75,7 +72,7 @@ module Jennifer def %validate_method value = _not_nil_validation({{field}}, {{allow_blank}}) if ({{value}}).includes?(value) - errors.add(:{{field.id}}, must_not_be_message({{value}}, value)) + errors.add(:{{field.id}}, self.class.human_error({{field}}, :exclusion)) end end end @@ -86,7 +83,7 @@ module Jennifer def %validate_method value = _not_nil_validation({{field}}, {{allow_blank}}) unless {{value}} =~ value - errors.add({{field}}, must_be_like_message({{value}}, value)) + errors.add({{field}}, self.class.human_error({{field}}, :invalid)) end end end @@ -98,24 +95,23 @@ module Jennifer value = _not_nil_validation({{field}}, {{options[:allow_blank] || false}}) size = value.not_nil!.size {% if options[:in] %} - unless ({{options[:in]}}).includes?(size) - errors.add({{field}}, length_in_message({{options[:in]}}, size)) + if ({{options[:in]}}).max < size + errors.add({{field}}, self.class.human_error({{field}}, :too_long, ({{options[:in]}}).max)) + elsif ({{options[:in]}}).min > size + errors.add({{field}}, self.class.human_error({{field}}, :too_short, ({{options[:in]}}).min)) end {% elsif options[:is] %} if {{options[:is]}} != size - errors.add({{field}}, length_is_message({{options[:is]}}, size)) + errors.add({{field}}, self.class.human_error({{field}}, :wrong_length, {{options[:is]}})) + end + {% elsif options[:minimum] %} + if {{options[:minimum]}} > size + errors.add({{field}}, self.class.human_error({{field}}, :too_short, {{options[:minimum]}})) + end + {% elsif options[:maximum] %} + if {{options[:maximum]}} < size + errors.add({{field}}, self.class.human_error({{field}}, :too_long, {{options[:maximum]}})) end - {% else %} - {% if options[:minimum] %} - if {{options[:minimum]}} > size - errors.add({{field}}, length_min_message({{options[:minimum]}}, size)) - end - {% end %} - {% if options[:maximum] %} - if {{options[:maximum]}} < size - errors.add({{field}}, length_max_message({{options[:maximum]}}, size)) - end - {% end %} {% end %} end end @@ -126,7 +122,7 @@ module Jennifer def %validate_method value = @{{field.id}} if self.class.where { _{{field.id}} == value }.exists? - errors.add({{field}}, must_be_unique_message(value)) + errors.add({{field}}, self.class.human_error({{field}}, :taken)) end end end @@ -138,10 +134,58 @@ module Jennifer def %validate_method value = @{{field.id}} if value.nil? - errors.add({{field}}, not_blank_message) + errors.add({{field}}, self.class.human_error({{field}}, :presence)) end end end + + macro validates_numericality(field, **options) + validates_with_method(%validate_method) + + def %validate_method + value = _not_nil_validation({{field}}, {{options[:allow_blank] || false}}) + {% if options[:greater_than] %} + if {{options[:greater_than]}} >= value + errors.add({{field}}, self.class.human_error({{field}}, :greater_than, { :value => {{options[:greater_than]}} })) + end + {% end %} + {% if options[:greater_than_or_equal_to] %} + if {{options[:greater_than_or_equal_to]}} > value + errors.add({{field}}, self.class.human_error({{field}}, :greater_than_or_equal_to, { :value => {{options[:greater_than_or_equal_to]}} })) + end + {% end %} + {% if options[:equal_to] %} + if {{options[:equal_to]}} != value + errors.add({{field}}, self.class.human_error({{field}}, :equal_to, { :value => {{options[:equal_to]}} })) + end + {% end %} + {% if options[:less_than] %} + if {{options[:less_than]}} <= value + errors.add({{field}}, self.class.human_error({{field}}, :less_than, { :value => {{options[:less_than]}} })) + end + {% end %} + {% if options[:less_than_or_equal_to] %} + if {{options[:less_than_or_equal_to]}} < value + errors.add({{field}}, self.class.human_error({{field}}, :less_than_or_equal_to, { :value => {{options[:less_than_or_equal_to]}} })) + end + {% end %} + {% if options[:other_than] %} + if {{options[:other_than]}} == value + errors.add({{field}}, self.class.human_error({{field}}, :other_than, { :value => {{options[:other_than]}} })) + end + {% end %} + {% if options[:odd] %} + if value.even? + errors.add({{field}}, self.class.human_error({{field}}, :odd)) + end + {% end %} + {% if options[:even] %} + if value.odd? + errors.add({{field}}, self.class.human_error({{field}}, :even)) + end + {% end %} + end + end end end end diff --git a/src/jennifer/model/validation_messages.cr b/src/jennifer/model/validation_messages.cr deleted file mode 100644 index 263cbcc6..00000000 --- a/src/jennifer/model/validation_messages.cr +++ /dev/null @@ -1,41 +0,0 @@ -module Jennifer - module Model - module ValidationMessages - def must_be_message(expected, actual) - "must be in #{expected} but is #{actual}" - end - - def must_not_be_message(expected, actual) - "must not be in #{expected} but is #{actual}" - end - - def not_blank_message - "must not be blank" - end - - def length_in_message(expected, actual) - "must be of size #{expected} but has #{actual}" - end - - def length_is_message(expected, actual) - "must be of size#{expected} but has #{actual}" - end - - def length_min_message(expected, actual) - "must be greater than or equal #{expected} but is #{actual}" - end - - def length_max_message(expected, actual) - "must be less than or equal #{expected} but is #{actual}" - end - - def must_be_like_message(expected, actual) - "must be like #{expected} but is #{actual}" - end - - def must_be_unique_message(value) - "must be unique" - end - end - end -end diff --git a/src/jennifer/translation.cr b/src/jennifer/translation.cr deleted file mode 100644 index 52531786..00000000 --- a/src/jennifer/translation.cr +++ /dev/null @@ -1,26 +0,0 @@ -module Jennifer - module Translation - # NOTE: temporary workarond for native I18n::Backend::Yaml until it suppurts - # parsing locale from file first key - class MultifileYAML < ::I18n::Backend::Yaml - def load(path) - files = Dir.glob(path + "/*.yml") - - files.each do |file| - lang = File.basename(file, ".yml") - lang_data = load_file(file) - next if lang_data.raw.nil? - - lang_data.each do |lang, data| - next if data.raw.nil? - - lang = lang.to_s - @translations[lang] ||= {} of String => YAML::Type - @translations[lang].merge!(self.class.normalize(data.as_h)) - @available_locales << lang unless @available_locales.includes?(lang) - end - end - end - end - end -end diff --git a/src/jennifer/view/base.cr b/src/jennifer/view/base.cr index 6e70fadd..111e0cc1 100644 --- a/src/jennifer/view/base.cr +++ b/src/jennifer/view/base.cr @@ -4,7 +4,7 @@ module Jennifer module View abstract class Base extend Ifrit - extend Model::Localization + extend Model::Translation include ExperimentalMapping include Model::Scoping From 1326693b36130f3304d0c98df4b5b7762ac165e7 Mon Sep 17 00:00:00 2001 From: Roman Kalnytskyi Date: Tue, 13 Feb 2018 01:29:54 +0200 Subject: [PATCH 17/19] Validation enhancement --- README.md | 2 +- docs/callbacks.md | 5 +- docs/internationalization.md | 55 +++++ docs/model_mapping.md | 2 +- docs/validation.md | 370 ++++++++++++++++++++++++++-- spec/adapter/base_spec.cr | 6 +- spec/fixtures/locales/models/en.yml | 3 + spec/model/base_spec.cr | 3 +- spec/model/callback_spec.cr | 3 +- spec/model/translation_spec.cr | 3 + spec/model/validation_spec.cr | 130 +++++++++- spec/models.cr | 16 +- spec/spec_helper.cr | 1 - src/jennifer.cr | 5 + src/jennifer/locale/en.yml | 6 +- src/jennifer/model/mapping.cr | 10 +- src/jennifer/model/translation.cr | 22 +- src/jennifer/model/validation.cr | 118 ++++++--- src/jennifer/validator.cr | 16 ++ 19 files changed, 674 insertions(+), 102 deletions(-) create mode 100644 src/jennifer/validator.cr diff --git a/README.md b/README.md index 5c835e0c..636dd451 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ class Passport < Jennifer::Model::Base contact_id: Int32? ) - validates_with [EnnValidator] + validates_with EnnValidator belongs_to :contact, Contact end diff --git a/docs/callbacks.md b/docs/callbacks.md index 0129c5ef..9b4869c5 100644 --- a/docs/callbacks.md +++ b/docs/callbacks.md @@ -59,6 +59,8 @@ Here is a list with all the available callbacks, listed in the same order in whi The following methods trigger callbacks: +- validate! +- valid? - create - create! - destroy @@ -73,8 +75,9 @@ The `after_initialize` callback is triggered each time record is initialized usi The following methods allows to skip some callbacks or process without them: -- update - validate +- invalid? +- update - save(skip_validation: true) - destroy_without_transaction (no transaction callback will be triggered) - save_without_transaction (no transaction callback will be triggered) diff --git a/docs/internationalization.md b/docs/internationalization.md index e69de29b..211ab837 100644 --- a/docs/internationalization.md +++ b/docs/internationalization.md @@ -0,0 +1,55 @@ +# Internationalization + +To provided different languages support [i18n](https://github.com/TechMagister/i18n.cr) lib is used. For feather reading please check it's README. + +## Translation for Jennifer Models + +You can use the `Jennifer::Model::Base.human` and `Jennifer::Model::Base.human_attribute_name(attribute)` to get translation for your model and attribute names. If there is no defined translation - it will be guessed by `Inflector.humanize`. + +### Model translation lookup + +Firstly path `jennifer.models.your_model_name` will be checked. If there is no such - all ancestors will be iterated: `jennifer.models.parent_model` and feather. Otherwise - `Inflector.humanize(i18n_key)` will be invoked. + +Also model name could be pluralized passing `count` as argument. + +```crystal +User.human(count: 2) # Customers +``` + +### Attribute translation lookup + +`::human_attribute_name` will use following lookup: + +- `jennifer.attributes.[model_name].attributes.attribute_name` +- `jennifer.attributes.[parent_model_name].attributes.attribute_name` and so on for all ancestors +- `jennifer.attributes.attribute_name` + +### Error message translation + +Error messages of predefined validation helper macros are generated using `::human_error_message` method and is retrieved from local files. Lets take a look how it will search `blank` error message: + +- `jennifer.errors.[model_name].attributes.[attribute_name].blank` +- `jennifer.errors.[model_name].[attribute_name].blank` +- `jennifer.errors.[ancestor_model_name].attributes.[attribute_name].blank` +- `jennifer.errors.[ancestor_model_name].[attribute_name].blank` (this and previous one will be repeated for all ancestors) +- `jennifer.errors.[attribute_name].blank` +- `jennifer.errors.messages.blank` + +Based on this you can specify specific message for any error. + +#### Interpolation + +Some message accepts arguments to pe inserted into translation. Here is full list of them: + +| Validation | Message | Interpolation | +| --- | --- | --- | +| confirmation | :confirmation | attribute | +| length | :too_long | count | +| length | :too_short | count | +| length | :wrong_length | count | +| numericality | :greater_than | value | +| numericality | :greater_than_or_equal_to | value | +| numericality | :equal_to | value | +| numericality | :less_than | value | +| numericality | :less_than_or_equal_to | value | +| numericality | :other_than | value | diff --git a/docs/model_mapping.md b/docs/model_mapping.md index f7e608d1..a215fad3 100644 --- a/docs/model_mapping.md +++ b/docs/model_mapping.md @@ -58,7 +58,7 @@ class Passport < Jennifer::Model::Base contact_id: {type: Int32, null: true} ) - validates_with [EnnValidator] + validates_with EnnValidator belongs_to :contact, Contact end diff --git a/docs/validation.md b/docs/validation.md index 0d73c529..8d20d5c7 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -1,43 +1,369 @@ # Validation -For validation purposes is used [accord](https://github.com/neovintage/accord) shard. So this allows to specify custom class validator: +Here is an example of validation usage: ```crystal +class User < Jennifer::Model::Base + mapping({ + # ... + login: String + }) + + validates_length :login, in: [8..16] +end + +user = User.build(login: "login") +user.validate! +user.valid? # false +user.login = "longlogin" +user.validate! +user.valid? # true +``` + +> For validation purposes is used [accord](https://github.com/neovintage/accord) shard which provides some basic functionality. + +## Trigger validation + +The following methods triggers validations and will save the object only if all validations will pass: + +- validate +- validate! +- valid? +- create +- create! +- save +- save_without_transaction +- save! +- update +- update! + +> NOTE: Bang method version will raise an exception if record is invalid. +> +> NOTE: `#valid?` is an alias for `#validate!`. + +`#validate!` method will also invoke validation callbacks. Be aware: `after_validation` callbacks may not be triggered if record is invalid or `before_validation` has raised `Jennifer::Skip` exception. + +## Skip validation + +Not all methods which hit db perform validation. They are: + +- invalid? +- save(skip_validation: true) +- update_column +- update_columns +- modify +- increment +- decrement +- update + +> NOTE: `#invalid?` method will only check if `#errors` is empty. + +## Accessing errors list + +Each record has a container to hold error messages - `Accord::ErrorList`. To retrieve it use `#errors` method. + +By it own `#errors` doesn't trigger validation so first of all you need to perform it explicitly by listed upper methods or using `#validate!` method: + +```crystal +user = User.build(login: "login") +user.errors.any? # false +user.validate! +user.errors.any? # true +``` + +### `errors[]` + +To check whether or not a particular attribute of an object is valid you can use `#errors[:attribute]`. It returns an array of error messages for `:attribute`. If there is no error - empty array will be returned. + +### `#errors.add` + +This methods let you add an error message related to a particular attribute. It takes as arguments tre attribute and the error message. + +```crystal +user = User.create(login: "login") +user.errors.add(:login, "Some custom message") +``` + +### `errors.clear!` + +The `#clear!` method is used when all error messages should be removed. It is automatically invoked by `#validate!`. + +## Validation macros + +### `acceptance` + +This macro validates that a given field equals to true or be one of given values. This is useful for validating checkbox value: + +```crystal +class User < Jennifer::Model::Base + mapping( + # ... + ) + + property terms_of_service = false + property eula : String? + + validates_acceptance :terms_of_service + validates_acceptance :eula, accept: %w(true accept yes) +end +``` + +By default `"1"` and `true` is recognized as accepted values, but, as described, this behavior could be override by passing `accept` option with array. + +### `confirmation` + +This validation helps to check if confirmation field was filled with same value as specified. + +```crystal +class User < Jennifer::Model::Base + mapping( + # ... + email: String?, + address: String? + ) + + property email_confirmation : String?, address_confirmation : String? + + validates_confirmation :email + validates_confirmation :address, case_insensitive: true +end +``` + +If confirmation is nil - this validation will be skipped. Such behavior allows to normally proceed in places where this validation is not needed (e.g. email confirmation is important only during new user creating). + +To make comparison case insensitive - specify second argument as `true`. + +### `exclusion` + +This macro validates that the attribute's value aren't included in a given set. This could be any object which responds to `#includes?` method. + +```crystal +class Country < Jennifer::Base::Model + mapping( + # ... + code: String + ) + + validates_exclusion :code, in: %w(AA DD) +end +``` + +### `format` + +This macro validates that the attribute's value satisfies given regular expression. + +```crystal +class Contact < Jennifer::Model::Base + mapping( + # ... + street: String + ) + + validates_format :street, /st\.|street/i +end +``` + +### `inclusion` + +This macro validates that the attribute's value are included in the set. This could be any object which responds to `#includes?` method. + +```crystal +class User < Jennifer::Base::Model + mapping( + # ... + country_code: String + ) + + validates_inclusion :code, in: Country::KNOWN_COUNTRIES +end +``` + +### `length` + +This macro validates the attribute's value length. There are a lot of options so constraint can be specified in different ways. + +```crystal +class User < Jennifer::Model::Base + mapping( + # ... + ) + + validates_length :name, minimum: 2 + validates_length :login, in: 4..16 + validates_length :uid, is: 16 +end +``` + +The possible constraints are: + +- `minimum` - length can't be less than specified one, +- `maximum` - length can't be greater than specified on, +- `in` - length must be included in given **interval** +- `is` - length must be same as specified + +### `numericality` + +This macro validates if given number field satisfies specified constraints. + +``` crystal +class Player < Jennifer::Model::Base + mapping( + # ... + points: Float64? + ) + + validates_numericality :points, greater_than: 0 +end +``` + +This macro accepts following constraints: + +- `greater_than` +- `greater_than_or_equal_to` +- `equal_to` +- `less_than` +- `less_than_or_equal_to` +- `other_than` +- `odd` +- `even` + +### `presence_of` + +This macro validates that attribute's value is not empty. It uses `#blank?` method from the core pact of [Ifrit](https://github.com/imdrasil/ifrit#core). + +```crystal +class User < Jennifer::Model::Base + mapping( + # ... + email: String? + ) + + validates_presence :email +end +``` + +### `absence` + +This validates that attribute's value is blank. It uses `#blank?` method from Ifrit as well as `presence` validation. + +```crystal +class SuperUser < User + validates_absence :title +end +``` + +### `uniqueness` + +This validates that the attribute's value is unique right before object gets validated. It doesn't create any db constraint so it doesn't totally guaranty that another another application instance creates record with save value in overlapping time. **Don't use** this validation for sensitive data. On the other hand this could help in generating readable error messages. + +```crystal +class Country < Jennifer::Model::Base + mapping( + # ... + code: String + ) + + validate_uniqueness :code +end +``` + +> NOTE: Be aware that mysql performs case insensitive string comparison. + +### `validates_with` + +This passes the record to a new instance of given validator class to be validated. + +```crystal +class EnnValidator < Jennifer::Validator + def validate(subject : Passport) + if subject.enn!.size < 4 && subject.enn![0].downcase == 'a' + errors.add(:enn, "Invalid enn") + end + end +end + class Passport < Jennifer::Model::Base mapping( - enn: {type: String, primary: true}, - contact_id: {type: Int32, null: true} + # ... + enn: {type: String, primary: true} ) - validates_with [EnnValidator] - belongs_to :contact, Contact + validates_with EnnValidator end +``` + +### `validates_with_method` + +This invokes specified record method to perform validation -class EnnValidator < Accord::Validator - def initialize(context : Passport) - @context = context +```crystal +class User < Jennifer::Model::Base + mapping( + id: Primary? + ) + + validates_with_method :thirteen + + def thirteen + if id == 13 + errors.add(:id, "Can't be 13") + end end +end +``` + +### `allow_blank` validation option + +This option skip validation if attribute's value is `nil`. All validation methods accepts this except: + +- `uniqueness` +- `presence` +- `absence` +- `acceptance` +- `confirmation` + +By default it is set to `false`. - def call(errors : Accord::ErrorList) - if @context.enn!.size < 4 && @context.enn![0].downcase == 'a' +## Custom validation + +### Custom validators + +Custom validators are classes that inherit from `Jennifer::Validator` and implement `#validate` method. + +```crystal +class Passport < Jennifer::Model::Base + mapping( + enn: {type: String, primary: true} + ) + + validates_with EnnValidator +end + +class EnnValidator < Jennifer::Validator + def validate(subject) + if subject.enn!.size < 4 && subject.enn![0].downcase == 'a' errors.add(:enn, "Invalid enn") end end end ``` -Also there are several general macroses for declaring validations: - -- `validates_with_method(*names)` - accepts method name/names -- `validates_inclusion(field, value)` - checks if `value` includes `@{{field}}` -- `validates_exclusion(field, value)` - checks if `value` excludes `@{{field}}` -- `validates_format(field, format)` - checks if `{{format}}` matches `@{{field}}` -- `validates_length(field, **options)` - check `@{{field}}` size; allowed options are: `:in`, `:is`, `:maximum`, `:minimum` -- `validates_uniqueness(field)` - check if `@{{field.id}}` is unique -- `validates_presence_of(field)` - check if `@{{field.id}}` is not `nil` +If there is any named argument specified after validator class - it will be passed to `#validate` method as well. -Next macrosses accept `allow_blank` key (which is be default is `false`) which describe `null` allowness during validation: `validates_inclucion`, `validates_exclusion`, `validates_format` and `validates_length`. This means that if `allow_blank: true` is passed, validation will pass if field is `nil` or validation condition is satified. In another words - validation will be sciped if field is `nil`. +```crystal +class Passport < Jennifer::Model::Base + mapping( + enn: {type: String, primary: true} + ) -Methods `#save!` and `#create!` will raise an exception if at validation fails. `#save` will return true\false representing object saving. + validates_with EnnValidator, length: 6 +end -> To manually check validity call `#validate!` before `#valid?`. +class EnnValidator < Jennifer::Validator + def validate(subject, length) + if subject.enn!.size < length && subject.enn![0].downcase == 'a' + errors.add(:enn, "Invalid enn") + end + end +end +``` diff --git a/spec/adapter/base_spec.cr b/spec/adapter/base_spec.cr index a8d21f52..001c1eae 100644 --- a/spec/adapter/base_spec.cr +++ b/spec/adapter/base_spec.cr @@ -189,12 +189,10 @@ describe Jennifer::Adapter::Base do it "avoid model validation" do c = Factory.build_contact(age: 12) - c.validate! - c.valid?.should be_false + c.should_not be_valid adapter.bulk_insert([c]) c = Contact.all.first! - c.validate! - c.valid?.should be_false + c.should_not be_valid end it "properly sets object attributes" do diff --git a/spec/fixtures/locales/models/en.yml b/spec/fixtures/locales/models/en.yml index 467cc935..78d43c1e 100644 --- a/spec/fixtures/locales/models/en.yml +++ b/spec/fixtures/locales/models/en.yml @@ -2,6 +2,9 @@ jennifer: models: contact: tContact + passport: + one: Passport + other: Many Passports views: female_contact: tFemale contact male_contact: tMale contact diff --git a/spec/model/base_spec.cr b/spec/model/base_spec.cr index eeea6a14..e2ba722f 100644 --- a/spec/model/base_spec.cr +++ b/spec/model/base_spec.cr @@ -69,8 +69,7 @@ describe Jennifer::Model::Base do describe "::create" do it "doesn't raise exception if object is invalid" do country = Country.create - country.validate! - country.valid?.should be_false + country.should_not be_valid country.id.should be_nil end diff --git a/spec/model/callback_spec.cr b/spec/model/callback_spec.cr index 78ca31a8..ad782dc5 100644 --- a/spec/model/callback_spec.cr +++ b/spec/model/callback_spec.cr @@ -146,7 +146,7 @@ describe Jennifer::Model::Callback do it "is not called if record is invalid" do c = CountryWithValidationCallbacks.create(name: "cOuntry") - c.valid?.should be_false + c.errors.any?.should be_true c.name.should eq("cOuntry") end end @@ -160,7 +160,6 @@ describe Jennifer::Model::Callback do it "stop creating record if skip was raised " do c = CountryWithValidationCallbacks.create(name: "skip") - c.valid?.should be_true c.new_record?.should be_true end end diff --git a/spec/model/translation_spec.cr b/spec/model/translation_spec.cr index 1c5ff949..9fec0a4e 100644 --- a/spec/model/translation_spec.cr +++ b/spec/model/translation_spec.cr @@ -20,6 +20,9 @@ describe Jennifer::Model::Translation do describe "::human" do it { Contact.human.should eq("tContact") } it { FacebookProfile.human.should eq("Facebook profile") } + it { Passport.human(1).should eq("Passport") } + it { Passport.human(2).should eq("Many Passports") } + it { Country.human(2).should eq("Countries") } end describe "::human_error" do diff --git a/spec/model/validation_spec.cr b/spec/model/validation_spec.cr index bb84a07b..fca9ba7d 100644 --- a/spec/model/validation_spec.cr +++ b/spec/model/validation_spec.cr @@ -27,9 +27,63 @@ class GTNContact < Jennifer::Model::Base validates_numericality :age, greater_than: 20, allow_blank: true end +class AcceptanceContact < ApplicationRecord + mapping({ + id: Primary32, + name: String + }, false) + + property terms_of_service = false + property eula : String? + + validates_acceptance :terms_of_service + validates_acceptance :eula, accept: %w(true accept yes) +end + +class ConfirmationContact < ApplicationRecord + mapping({ + id: Primary32, + name: String?, + case_insensitive_name: String? + }, false) + + property name_confirmation : String?, case_insensitive_name_confirmation : String? + + validates_confirmation :name + validates_confirmation :case_insensitive_name, case_sensitive: false +end + +class PresenceContact < ApplicationRecord + mapping({ + id: Primary32, + name: String?, + address: String? + }) + + validates_presence :name + validates_absence :address +end + +class ValidatorWithOptions < Jennifer::Validator + def validate(subject, field, message = nil) + if subject.attribute(field) == "invalid" + errors.add(field, message || "blank") + end + end +end + +class CustomValidatorModel < ApplicationRecord + mapping({ + id: Primary32, + name: String + }) + + validates_with ValidatorWithOptions, field: :name, message: "Custom Message" +end + describe Jennifer::Model::Validation do describe "%validates_with" do - it "accepts accord class validators" do + it "accepts class validators" do p = Factory.build_passport(enn: "abc") p.should_not be_valid p.enn = "bca" @@ -37,6 +91,18 @@ describe Jennifer::Model::Validation do p.save p.new_record?.should be_false end + + context "with extra options" do + it do + subject = CustomValidatorModel.build(name: "valid") + subject.should be_valid + end + + it do + subject = CustomValidatorModel.build(name: "invalid") + subject.should validate(:name).with("Custom Message") + end + end end describe "%validates_with_method" do @@ -211,22 +277,38 @@ describe Jennifer::Model::Validation do end end - describe "%validates_presence_of" do + describe "%validates_presence" do context "when field is not nil" do it "pass validation" do - c = Country.build({:name => "New country"}) + c = PresenceContact.build({:name => "New country"}) c.should be_valid end end context "when field is nil" do it "doesn't pass validation" do - c = Country.build({} of String => String) + c = PresenceContact.build c.should validate(:name).with("can't be blank") end end end + describe "%validates_absence" do + context "when field is not nil" do + it "pass validation" do + c = PresenceContact.build({:name => "New country"}) + c.should be_valid + end + end + + context "when field is nil" do + it "doesn't pass validation" do + c = PresenceContact.build({ :address => "asd" }) + c.should validate(:address).with("must be blank") + end + end + end + describe "%validates_numericality" do context "with allowed nil value" do it "passes validation if value is nil" do @@ -350,4 +432,44 @@ describe Jennifer::Model::Validation do end end end + + describe "%validates_acceptance" do + it "pass validation" do + c = AcceptanceContact.build({:name => "New country"}) + c.terms_of_service = true + c.eula = "yes" + c.should be_valid + end + + it "adds error message if doesn't satisfies validation" do + c = AcceptanceContact.build({:name => "New country"}) + c.eula = "no" + c.should validate(:terms_of_service).with("must be accepted") + c.should validate(:eula).with("must be accepted") + end + end + + describe "%validates_acceptance" do + context "with nil confirmations" do + it "pass validation" do + c = ConfirmationContact.build({:name => "name"}) + c.should be_valid + end + end + + it "pass validation" do + c = ConfirmationContact.build({:name => "name", :case_insensitive_name => "cin"}) + c.name_confirmation = "name" + c.case_insensitive_name_confirmation = "CIN" + c.should be_valid + end + + it "adds error message if doesn't satisfies validation" do + c = ConfirmationContact.build({:name => "name", :case_insensitive_name => "cin"}) + c.name_confirmation = "Name" + c.case_insensitive_name_confirmation = "NIC" + c.should validate(:name).with("doesn't match Name") + c.should validate(:case_insensitive_name).with("doesn't match Case insensitive name") + end + end end diff --git a/spec/models.cr b/spec/models.cr index 4a134644..04091da3 100644 --- a/spec/models.cr +++ b/spec/models.cr @@ -11,13 +11,9 @@ struct WithArgumentQuery < Jennifer::QueryBuilder::QueryObject end end -class EnnValidator < Accord::Validator - def initialize(context : Passport) - @context = context - end - - def call(errors : Accord::ErrorList) - if @context.enn!.size < 4 && @context.enn![0].downcase == 'a' +class EnnValidator < Jennifer::Validator + def validate(subject : Passport) + if subject.enn!.size < 4 && subject.enn![0].downcase == 'a' errors.add(:enn, "Invalid enn") end end @@ -71,7 +67,7 @@ class Contact < ApplicationRecord has_one :main_address, Address, {where { _main }}, inverse_of: :contact has_one :passport, Passport - validates_inclucion :age, 13..75 + validates_inclusion :age, 13..75 validates_length :name, minimum: 1 # NOTE: only for testing purposes - this is a bad practice; prefer to use `in` validates_length :name, maximum: 15 @@ -124,7 +120,7 @@ class Passport < Jennifer::Model::Base contact_id: Int32? ) - validates_with [EnnValidator] + validates_with EnnValidator belongs_to :contact, Contact after_destroy :increment_destroy_counter @@ -191,7 +187,7 @@ class Country < Jennifer::Model::Base validates_exclusion :name, ["asd", "qwe"] validates_uniqueness :name - validates_presence_of :name + validates_presence :name has_and_belongs_to_many :contacts, Contact diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr index 60d9c897..dcbf7aa2 100644 --- a/spec/spec_helper.cr +++ b/spec/spec_helper.cr @@ -140,7 +140,6 @@ module Spec # :nodoc: struct BeValidExpectation def match(object) - object.validate! object.valid? end diff --git a/src/jennifer.cr b/src/jennifer.cr index 7165fe26..af82045c 100644 --- a/src/jennifer.cr +++ b/src/jennifer.cr @@ -1,7 +1,10 @@ require "inflector" require "inflector/string" require "accord" + require "ifrit/converter" +require "ifrit/core" + require "time_zone" require "i18n" @@ -23,6 +26,8 @@ require "./jennifer/relation/*" require "./jennifer/model/base" +require "./jennifer/validator" + require "./jennifer/view/base" require "./jennifer/migration/*" diff --git a/src/jennifer/locale/en.yml b/src/jennifer/locale/en.yml index 26324cb8..57766da0 100644 --- a/src/jennifer/locale/en.yml +++ b/src/jennifer/locale/en.yml @@ -8,8 +8,8 @@ jennifer: invalid: "is invalid" taken: "has already been taken" # required: "must exist" - # confirmation: "doesn't match %{attribute}" - # accepted: "must be accepted" + confirmation: "doesn't match %{attribute}" + accepted: "must be accepted" empty: "can't be empty" blank: "can't be blank" present: "must be blank" @@ -22,8 +22,6 @@ jennifer: wrong_length: one: "is the wrong length (should be 1 character)" other: "is the wrong length (should be %{count} characters)" - # not_a_number: "is not a number" - # not_an_integer: "must be an integer" greater_than: "must be greater than %{value}" greater_than_or_equal_to: "must be greater than or equal to %{value}" equal_to: "must be equal to %{value}" diff --git a/src/jennifer/model/mapping.cr b/src/jennifer/model/mapping.cr index 05587bfb..cc1a230a 100644 --- a/src/jennifer/model/mapping.cr +++ b/src/jennifer/model/mapping.cr @@ -382,7 +382,7 @@ module Jennifer # Saves all changes to db without invoking transaction; if validation not passed - returns `false` def save_without_transaction(skip_validation : Bool = false) : Bool - return false unless skip_validation || validate_record + return false unless skip_validation || validate! return false unless __before_save_callback response = if new_record? @@ -582,14 +582,6 @@ module Jennifer res.rows_affected == 1 end - private def validate_record : Bool - return false unless __before_validation_callback - validate! - return false unless valid? - __after_validation_callback - true - end - private def save_record_under_transaction(skip_validation) : Bool is_new_record = new_record? return false unless save_without_transaction(skip_validation) diff --git a/src/jennifer/model/translation.cr b/src/jennifer/model/translation.cr index 1fe5f1b4..cad281f0 100644 --- a/src/jennifer/model/translation.cr +++ b/src/jennifer/model/translation.cr @@ -1,12 +1,3 @@ -# :nodoc: -private macro _new_translate(*args, **opts) - {% if opts.empty? %} - return I18n.translate({{args.splat}}) if I18n.exists?({{args[0]}}) - {% else %} - return I18n.translate({{args.splat}}, {{**opts}}) if I18n.exists?({{args[0]}}, count: {{opts[:count]}}) - {% end %} -end - module Jennifer module Model # Includes localization methods. @@ -62,15 +53,20 @@ module Jennifer end # Returns localized model name. - def human + def human(count = nil) prefix = "#{GLOBAL_SCOPE}.#{i18n_scope}." - _new_translate(prefix + i18n_key) + path = prefix + i18n_key + return I18n.translate(path, count: count) if I18n.exists?(path, count: count) + lookup_ancestors do |ancestor| - _new_translate(prefix + ancestor.i18n_key) + path = prefix + ancestor.i18n_key + return I18n.translate(path) if I18n.exists?(path, count: count) end - Inflector.humanize(i18n_key) + name = Inflector.humanize(i18n_key) + name = Inflector.pluralize(name) if count && count > 1 + name end def i18n_scope diff --git a/src/jennifer/model/validation.cr b/src/jennifer/model/validation.cr index 20819986..8f4c5db1 100644 --- a/src/jennifer/model/validation.cr +++ b/src/jennifer/model/validation.cr @@ -1,33 +1,29 @@ module Jennifer module Model module Validation - include Accord - - def validate(skip = false) + def errors + @errors ||= Accord::ErrorList.new end - # TODO: invoke validation callbacks - def validate!(skip = false) - errors.clear! - return if skip + def valid? + validate! + end - # TODO: think about global validation - if self.responds_to?(:validate_global) - self.validate_global - end - if self.responds_to?(:validate) - self.validate - end + def invalid? + errors.any? end - macro validates_with_method(name) - {% VALIDATION_METHODS << name.id.stringify %} + def validate(skip = false) end - macro validates_with_method(*names) - {% for method in names %} - {% VALIDATION_METHODS << method.id.stringify %} - {% end %} + def validate!(skip = false) : Bool + errors.clear! + return false if skip + return false unless __before_validation_callback + validate + return false if invalid? + __after_validation_callback + true end macro inherited_hook @@ -55,23 +51,45 @@ module Jennifer end end - macro validates_inclucion(field, value, allow_blank = false) + 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 %} + {% end %} + end + + macro validates_with(klass, **options) + validates_with_method(%validate_method) + + def %validate_method + {% if options %} + {{klass}}.new(errors).validate(self, {{**options}}) + {% else %} + {{klass}}.new(errors).validate(self) + {% end %} + end + end + + macro validates_inclusion(field, in, allow_blank = false) validates_with_method(%validate_method) def %validate_method value = _not_nil_validation({{field}}, {{allow_blank}}) - unless ({{value}}).includes?(value) + unless ({{in}}).includes?(value) errors.add({{field}}, self.class.human_error({{field}}, :inclusion)) end end end - macro validates_exclusion(field, value, allow_blank = false) + macro validates_exclusion(field, in, allow_blank = false) validates_with_method(%validate_method) def %validate_method value = _not_nil_validation({{field}}, {{allow_blank}}) - if ({{value}}).includes?(value) + if ({{in}}).includes?(value) errors.add(:{{field.id}}, self.class.human_error({{field}}, :exclusion)) end end @@ -116,6 +134,8 @@ module Jennifer end end + # TODO: add scope + macro validates_uniqueness(field) validates_with_method(%validate_method) @@ -127,14 +147,24 @@ module Jennifer end end - # Validates field to not be nil - macro validates_presence_of(field) + macro validates_presence(field) + validates_with_method(%validate_method) + + def %validate_method + value = @{{field.id}} + if value.blank? + errors.add({{field}}, self.class.human_error({{field}}, :blank)) + end + end + end + + macro validates_absence(field) validates_with_method(%validate_method) def %validate_method value = @{{field.id}} - if value.nil? - errors.add({{field}}, self.class.human_error({{field}}, :presence)) + if value.present? + errors.add({{field}}, self.class.human_error({{field}}, :present)) end end end @@ -186,6 +216,38 @@ module Jennifer {% end %} end end + + macro validates_acceptance(field, accept = nil) + validates_with_method(%validate_method) + + def %validate_method + value = @{{field.id}} + {% condition = accept ? "!(#{accept}).includes?(value)" : "value != true && value != '1'" %} + if {{condition.id}} + errors.add({{field}}, self.class.human_error({{field}}, :accepted)) + end + end + end + + macro validates_confirmation(field, case_sensitive = true) + validates_with_method(%validate_method) + + def %validate_method + return if @{{field.id}}_confirmation.nil? + value = _not_nil_validation({{field}}, false) + + if value.compare(@{{field.id}}_confirmation.not_nil!, !{{case_sensitive}}) != 0 + errors.add( + {{field}}, + self.class.human_error( + {{field}}, + :confirmation, + options: { :attribute => self.class.human_attribute_name(:{{field.id}}) } + ) + ) + end + end + end end end end diff --git a/src/jennifer/validator.cr b/src/jennifer/validator.cr new file mode 100644 index 00000000..cb4996a2 --- /dev/null +++ b/src/jennifer/validator.cr @@ -0,0 +1,16 @@ +module Jennifer + abstract class Validator + getter errors : Accord::ErrorList + + def initialize(@errors) + end + + def validate(subject) + raise raise AbstractMethod.new("validate", self.class) + end + + def validate(subject, **options) + raise AbstractMethod.new("validate", self.class) + end + end +end From a284fd14fa3727bbfca319e2e138bf602b9b7dcb Mon Sep 17 00:00:00 2001 From: Roman Kalnytskyi Date: Tue, 13 Feb 2018 21:16:15 +0200 Subject: [PATCH 18/19] Add new update methods --- docs/callbacks.md | 3 +- docs/validation.md | 1 - spec/model/base_spec.cr | 123 ++++++++++++++++++++ spec/model/mapping_spec.cr | 2 +- spec/query_builder/model_query_spec.cr | 29 +++++ src/jennifer/model/base.cr | 36 ++++-- src/jennifer/model/mapping.cr | 4 - src/jennifer/query_builder/executables.cr | 1 - src/jennifer/query_builder/i_model_query.cr | 32 +++-- 9 files changed, 209 insertions(+), 22 deletions(-) diff --git a/docs/callbacks.md b/docs/callbacks.md index 9b4869c5..fa37a996 100644 --- a/docs/callbacks.md +++ b/docs/callbacks.md @@ -68,6 +68,8 @@ The following methods trigger callbacks: - save - save! - save_without_transaction +- update +- update! The `after_initialize` callback is triggered each time record is initialized using method `::build`. @@ -77,7 +79,6 @@ The following methods allows to skip some callbacks or process without them: - validate - invalid? -- update - save(skip_validation: true) - destroy_without_transaction (no transaction callback will be triggered) - save_without_transaction (no transaction callback will be triggered) diff --git a/docs/validation.md b/docs/validation.md index 8d20d5c7..9892b187 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -54,7 +54,6 @@ Not all methods which hit db perform validation. They are: - modify - increment - decrement -- update > NOTE: `#invalid?` method will only check if `#errors` is empty. diff --git a/spec/model/base_spec.cr b/spec/model/base_spec.cr index e2ba722f..a62f3a32 100644 --- a/spec/model/base_spec.cr +++ b/spec/model/base_spec.cr @@ -1,5 +1,12 @@ require "../spec_helper" +class ModelWithIntName < Jennifer::Model::Base + mapping({ + id: Primary32, + name: Int32 + }) +end + describe Jennifer::Model::Base do describe "#changed?" do it "returns true if at list one field was changed" do @@ -508,4 +515,120 @@ describe Jennifer::Model::Base do end end end + + describe "#update_attributes" do + context "when given attribute exists" do + it "raises exception if value has wrong type" do + c = Factory.build_contact + expect_raises(::Jennifer::BaseException) do + c.update_attributes({ :name => 123 }) + end + end + + it "marks changed field as modified" do + c = Factory.build_contact + c.update_attributes({ "name" => "asd" }) + c.name.should eq("asd") + c.name_changed?.should be_true + end + end + + context "when no such setter" do + it "raises exception" do + c = Factory.build_contact + expect_raises(::Jennifer::BaseException) do + c.update_attributes({ :asd => 123 }) + end + end + end + + context "with named tuple" do + it do + c = Factory.build_contact + c.update_attributes({name: "asd"}) + c.name.should eq("asd") + end + end + + context "with splatted named tuple" do + it do + c = Factory.build_contact + c.update_attributes(name: "asd") + c.name.should eq("asd") + end + + it do + subject = ModelWithIntName.build(name: 1) + subject.update_attributes(name: 2) + subject.name.should eq(2) + end + end + end + + describe "#update" do + context "when given attribute exists" do + it "stores given fields" do + c = Factory.create_contact + c.update({ "name" => "asd" }) + Contact.all.where { _name == "asd" }.exists?.should be_true + end + end + + context "when no such setter" do + it "raises exception" do + c = Factory.build_contact + expect_raises(::Jennifer::BaseException) do + c.update({ :asd => 123 }) + end + end + end + + context "with splatted named tuple" do + it do + c = Factory.create_contact + c.update(name: "asd") + Contact.all.where { _name == "asd" }.exists?.should be_true + end + end + + it "doesn't store invalid data" do + c = Factory.create_contact + c.update(age: 12).should be_false + Contact.where { _age == 12 }.exists?.should be_false + end + end + + describe "#update!" do + context "when given attribute exists" do + it "stores given fields" do + c = Factory.create_contact + c.update!({ "name" => "asd" }) + Contact.all.where { _name == "asd" }.exists?.should be_true + end + end + + context "when no such setter" do + it "raises exception" do + c = Factory.build_contact + expect_raises(::Jennifer::BaseException) do + c.update!({ :asd => 123 }) + end + end + end + + context "with splatted named tuple" do + it do + c = Factory.create_contact + c.update!(name: "asd") + Contact.all.where { _name == "asd" }.exists?.should be_true + end + end + + it "doesn't store invalid data" do + expect_raises(Jennifer::RecordInvalid) do + c = Factory.create_contact + c.update!(age: 12) + end + end + end end diff --git a/spec/model/mapping_spec.cr b/spec/model/mapping_spec.cr index dad75919..8ead0b7e 100644 --- a/spec/model/mapping_spec.cr +++ b/spec/model/mapping_spec.cr @@ -487,7 +487,7 @@ describe Jennifer::Model::Mapping do c.name.should eq("123") end - it "raises exeption if value has wrong type" do + it "raises exception if value has wrong type" do c = Factory.build_contact expect_raises(::Jennifer::BaseException) do c.set_attribute(:name, 123) diff --git a/spec/query_builder/model_query_spec.cr b/spec/query_builder/model_query_spec.cr index ecdd2ed4..7da7c05e 100644 --- a/spec/query_builder/model_query_spec.cr +++ b/spec/query_builder/model_query_spec.cr @@ -112,6 +112,35 @@ describe Jennifer::QueryBuilder::ModelQuery do end end + describe "#patch" do + it "triggers validation" do + Factory.create_contact(age: 20) + Contact.all.patch(age: 12) + Contact.all.where { _age == 12 }.exists?.should be_false + end + + it do + Factory.create_contact(age: 20) + Contact.all.patch(age: 30) + Contact.all.where { _age == 30 }.exists?.should be_true + end + end + + describe "#patch!" do + it "raises exception if is invalid" do + Factory.create_contact(age: 20) + expect_raises(Jennifer::RecordInvalid) do + Contact.all.patch!(age: 12) + end + end + + it do + Factory.create_contact(age: 20) + Contact.all.patch!(age: 30) + Contact.all.where { _age == 30 }.exists?.should be_true + end + end + describe "#select_args" do it "returns array of join and condition args" do Contact.where { _id == 2 }.join(Address) { _name == "asd" }.select_args.should eq(db_array("asd", 2)) diff --git a/src/jennifer/model/base.cr b/src/jennifer/model/base.cr index 9f4f4b5b..daf61919 100644 --- a/src/jennifer/model/base.cr +++ b/src/jennifer/model/base.cr @@ -156,17 +156,39 @@ module Jennifer {% end %} end - def update_attributes(hash : Hash) + def update(hash : Hash | NamedTuple) + update_attributes(hash) + save + end + + def update(**opts) + update(opts) + end + + def update!(hash : Hash | NamedTuple) + update_attributes(hash) + save! + end + + def update!(**opts) + update!(opts) + end + + def update_attributes(hash : Hash | NamedTuple) hash.each { |k, v| set_attribute(k, v) } end + def update_attributes(**opts) + update_attributes(opts) + end + # Perform destroy without starting a transaction - def destroy_without_transaction - return false if new_record? || !__before_destroy_callback - @destroyed = true if delete - __after_destroy_callback if @destroyed - @destroyed - end + def destroy_without_transaction + return false if new_record? || !__before_destroy_callback + @destroyed = true if delete + __after_destroy_callback if @destroyed + @destroyed + end # Deletes object from DB without calling callbacks. def delete diff --git a/src/jennifer/model/mapping.cr b/src/jennifer/model/mapping.cr index cc1a230a..f5eb7aa9 100644 --- a/src/jennifer/model/mapping.cr +++ b/src/jennifer/model/mapping.cr @@ -197,10 +197,6 @@ module Jennifer initialize(values) end - # TODO: think about next method - # def attributes=(values : Hash) - # end - def self.build(pull : DB::ResultSet) \{% begin %} \{% klasses = @type.all_subclasses.select { |s| s.constant("STI") == true } %} diff --git a/src/jennifer/query_builder/executables.cr b/src/jennifer/query_builder/executables.cr index b73f7678..6e8db4b3 100644 --- a/src/jennifer/query_builder/executables.cr +++ b/src/jennifer/query_builder/executables.cr @@ -63,7 +63,6 @@ module Jennifer adapter.modify(self, options) end - # TODO: load objects and perform all callbacks and validation def update(options : Hash) adapter.update(self, options) end diff --git a/src/jennifer/query_builder/i_model_query.cr b/src/jennifer/query_builder/i_model_query.cr index 326633af..6ce187e5 100644 --- a/src/jennifer/query_builder/i_model_query.cr +++ b/src/jennifer/query_builder/i_model_query.cr @@ -103,11 +103,11 @@ module Jennifer relation(name) end - # NOTE: Not implemented yet - def eager_load(rels : Array(String), aliases = [] of String?) - @relations << name.to_s - raise "Not implemented" - end + # TODO: add eager load with aliases + # def eager_load(rels : Array(String), aliases = [] of String?) + # @relations << name.to_s + # raise "Not implemented" + # end def relation(name, type = :left) model_class.relation(name.to_s).join_condition(self, type) @@ -121,9 +121,27 @@ module Jennifer super(model_class.primary, batch_size, start, direction) { |record| yield record } end - # Loads all records and call `#destroy` on the each + # Triggers `#destroy` on the each matched object def destroy - to_a.each(&.destroy) + find_each(&.destroy) + end + + # Triggers `#update` on the each matched object + def patch(options : Hash | NamedTuple) + find_each(&.update(options)) + end + + def patch(**opts) + patch(opts) + end + + # Triggers `#update!` on the each matched object + def patch!(options : Hash | NamedTuple) + find_each(&.update!(options)) + end + + def patch!(**opts) + patch!(opts) end # ========= private ============== From cf733b7d7650d3b1194602e2523ad979f6bad059 Mon Sep 17 00:00:00 2001 From: Roman Kalnytskyi Date: Tue, 13 Feb 2018 22:50:07 +0200 Subject: [PATCH 19/19] Bump to 0.5.0 --- CHANGELOG.md | 134 +++++++++++++++++++--------------------- shard.yml | 2 +- src/jennifer/version.cr | 2 +- 3 files changed, 64 insertions(+), 74 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 94cb014c..4d1b1aac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,51 @@ +# 0.5.0 (13-02-2018) + +* `ifrit/core` pact is required +* adds `i18n` lib support +* adds `time_zone` lib support + +**QueryBuilder** + +* now `#destroy` uses `#find_each` +* adds `#patch` and `#patch!` which invokes `#update` on each object +* introduced `CriteriaContainer` to resolve issue with using `Criteria` object as a key for `@order` hash +* all `#as_sql` methods now accept `SQLGenerator` class + +**Model** + +* added methods `#update` & `#update!` which allows to massassign attributes and store object to the db +* added support of localization lib (i18n) +* added methods `::human_attribute_name`, `::human_error` and `::human` to translate model attribute name, error message and model name +* added own `#valid?` and `#validate!` methods - they performs validation and trigger callbacks each call +* added `#invalid?` - doesn't trigger validation and callbacks +* moved all validation error messages to yaml file +* now `%validates_with` accepts oly one class and allows to pass extra arguments to validator class +* `%validate_presence_of` is renamed to `%validate_presence` +* adds new validation macros: `%validate_absence`, `%validates_numericality`, `%validates_acceptance` and `%validates_confirmation` +* introduced own validator class +* adds `after_update`/`before_update` callbacks +* adds `after_commit`/`after_rollback` callbacks +* reorganizes the way how callback method names are stored +* now `%mapping` automatically guess is it should be sti or common mapping (should be used in places of `%sti_mapping`) +* removed `#attributes_hash` +* any time object is converted to UTC when is stored and to local when is retrieved from db + +**View** + +* any time object is converted to local when is retrieved from db + +**Config** + +* adds `::local_time_zone_name` method to set application time zone +* adds `::local_time_zone` - returns local time zone object + +**Adapter** + +* any time object passed as argument is converted from local time to UTC +* `postgres` adapter now use `INSERT` with `RETURNING` +* now several adapters could be required at the same time +* all schema manipulation methods now in located in the `SchemaProcessor` + # 0.4.3 (2-01-2018) * All macro methods were rewritten to new 0.24.1 crystal syntax @@ -16,7 +64,7 @@ * move `Jennifer::Mode::build` method to `%mapping` macro * allow retrieving and building sti records using base class * fix `#reload` method for sti record -* optimize building sti record from hash +* optimize building sti record from hash **QueryBuilder** @@ -27,14 +75,14 @@ * introduce `View::Materialized` superclass for materialized views * add `COLUMNS_METADATA` constant -* add `::columns_tuple` which retrns `COLUMNS_METADATA` +* add `::columns_tuple` which returns `COLUMNS_METADATA` * remove `::children_classes` * make `after_initialize` callback respect inheritance * add `::adapter` **Exceptions** -* add `AbstractMethod` exception which represents expectation of overriding current method by parents (is usefull when method can't be real abstract one) +* add `AbstractMethod` exception which represents expectation of overriding current method by parents (is useful when method can't be real abstract one) * add `UnknownSTIType` # 0.4.2 (24-11-2017) @@ -45,48 +93,31 @@ **Migration** -* rename `TableBuilder::DropInde` to `TableBuilder::DropIndex` - -* remove printing out redundand execution information during db drop and create - +* rename `TableBuilder::DropIndex` to `TableBuilder::DropIndex` +* remove printing out redundant execution information during db drop and create * remove `Migration::Base::TABLE_NAME` constant - * allow to pass `QueryBuilder::Query` as source to the `CreateMaterializedView` (postgres only) **Model** -* move `Base#build` method witout arguments to `Mapping` module under the `%mapping` - +* move `Base#build` method without arguments to `Mapping` module under the `%mapping` * added `validates_presence_of` validation macros - -* fixed callback invokation from parent classes - +* fixed callback invocation from parent classes * add `allow_blank` key to `validates_inclusion`, `validates_exclusion`, `validates_format` - * add `ValidationMessages` module which includes methods generating validation error messages - * add `Primary32` and `Primary64` shortcuts for `Int32` and `Int64` primary field declarations for model and view - * allow use nil usions instead of `null: true` named tuple option **QueryBuilder** * `#count` method is moved from `Executables` module to the `Aggregations` one - * changed method signature of `#find_in_batches` - * add `#find_each` - works same way as `#find_in_batches` but yields each record instead of array - * add `#ordered?` method to `Ordering` module - * switch `Criteria#hash` to use `object_id` as seed - * add `Query#eql?` - * add `Query#clone` and all related methods - * add `Query#except` - creates clone except given clauses - * make `IModelQuery` class as new superclass of `ModelQuery(T)`; move all methods no depending on `T` to the new class # 0.4.1 (20-10-2017) @@ -94,45 +125,29 @@ **Config** * added `port` configuration - * `::reset_config` resets to default configurations - * added validation for adapter and db - -* `::from_uri` allows to load onfiguration from uri +* `::from_uri` allows to load configuration from uri **Adapter** * added `#query_array` method to request array of arrays of given type - * added `#with_table_lock` which allows to lock table (mysql and postgres have different behaviors) **Query** * added `all` and `any` statements - * refactored logical operators - now they don't group themselves with "()" - * added `ExpressionBuilder#g` (`ExpressionBuilder#grouping`) to group some condition - * added `XOR` - * moved all executable methods to `Executables` module - * change behavior of `#distinct` - now it accepts no arguments and just prepend `DISTINCT` to common select query - -* added `#find_in_batches` - allows to search over requested collection reqtrieved only determined amount of records per iteration - +* added `#find_in_batches` - allows to search over requested collection required only determined amount of records per iteration * `#find_records_by_sql` - returns array of `Record` by given sql string - * added `:full_outer` join type - * added `#lateral_join` to make `LATERAL JOIN` (for now is supported only by PostgreSQL) - * extracted all join methods to `Joining` module - * extracted all ordering methods to `Ordering` module - * added `#reorder` method allowing to reorder existing query **ModelQuery** @@ -142,19 +157,14 @@ **Model** * added `::with_table_lock` - * added `::adapter` - * added `::import` to perform one query import - * fixed bug with reloading empty relations **Mapping** * added `inverse_of` option to `has_many` and `has_one` relations to sets owner during relation loading - - # 0.4.0 (30-09-2017) **Exception** @@ -168,43 +178,27 @@ **QueryBuilder** * now `#eager_load` behaves as old variant of `#includes` - via joining relations and adding them to the `SELECT` statement (**breaking changes**) - * added `#preload` method which allows to load all listed relations after execution of main request - * new behavior of `#includes` is same as `#preload` (**breaking changes**) - * added `Jennifer::QueryBuilder::QueryObject` which designed to be as a abstract class for query objects for `Model::Base` scopes (**will be renamed in futher releases**) - * all query related objects are clonable - * now `GROUP` clause is placed right after the `WHERE` clause - * aggregation methods is moved to `Jennifer::QueryBuilder::Aggregations` module which is included in the `Query` class -* `Query#select` now accepts `Criteria` object, `Symbol` (which now will be transformed to corresponding `Criteria`), 'String' (which will be transformed to `RawSql`), string and symbol tuples, array of criterias and could raise a block with `ExpressionBuilder` as a current context (`Array(Criteria)` is expeted to be returned) - -* `Query#group` got same behavior as `Query#select - +* `Query#select` now accepts `Criteria` object, `Symbol` (which now will be transformed to corresponding `Criteria`), 'String' (which will be transformed to `RawSql`), string and symbol tuples, array of criterion and could raise a block with `ExpressionBuilder` as a current context (`Array(Criteria)` is expected to be returned) +* `Query#group` got same behavior as `Query#select` * `Query#order` realize same idea as with `Query#select` but with hashes - -* added `Criteria#alias` method wich allows to alias field in the `SELECT` clause - -* `ExpressionBuilder#star` creates "all" attribute; allows optional argument specifing table name - -* `RawSql` now has `@use_brakets` atttribute representing if sql statement should be surrounded by brackets - +* added `Criteria#alias` method which allows to alias field in the `SELECT` clause +* `ExpressionBuilder#star` creates "all" attribute; allows optional argument specifying table name +* `RawSql` now has `@use_brakets` attribute representing if sql statement should be surrounded by brackets * `Criteria#sql` method now accepts `use_brackets` argument which is passed to `RawSql` **Migration** * mysql got `#varchar` method for column definition - * added invoking of `TableBuilder::CreateMaterializedView` in `#create_materialized_view` method - * now `Jennifer::TableBuilder::CreateMaterializedView` accepts only `String` query - * added `#drop_materialized_view` - * added `CreateIndex`, `DropIndex`, `CreateView`, `DropView` classes and corresponding methods **Record** @@ -214,13 +208,9 @@ **Model** * added `::context` method which return expression builder for current model - * added `::star` method which returns "all" criteria - * moved scope definition to `Scoping` module - * now scopes accepts `QueryBuilder::QueryObject` class name as a 2nd argument - * now object inserting into db use old variant with inserting and grepping last inserted id (because of bug with pg crystal driver) **View** diff --git a/shard.yml b/shard.yml index 2c270ce6..bbb22d83 100644 --- a/shard.yml +++ b/shard.yml @@ -1,5 +1,5 @@ name: jennifer -version: 0.4.3 +version: 0.5.0 authors: - Roman Kalnytskyi diff --git a/src/jennifer/version.cr b/src/jennifer/version.cr index 9a987469..05cb254e 100644 --- a/src/jennifer/version.cr +++ b/src/jennifer/version.cr @@ -1,3 +1,3 @@ module Jennifer - VERSION = "0.4.3" + VERSION = "0.5.0" end