diff --git a/manual/CHANGELOG.md b/manual/CHANGELOG.md index f511d2f5e..4a01bcab2 100644 --- a/manual/CHANGELOG.md +++ b/manual/CHANGELOG.md @@ -1,3 +1,30 @@ +# v0.8 + +## Features + +- Add `or_where` clause + +This provide a way to chain where clause with `or` operator instead of `and`: + +```crystal +query.where{ a == b }.or_where{ b == c } # WHERE (A = B) OR (b = C) +query.where{ a == b }.where{ c == d}.or_where{ a == nil } # WHERE ( A=B AND C=D ) OR A IS NULL +``` + +- Add `raw` method into `Clear::SQL` module. + +This provide a fast way to create SQL fragment while escaping items, both with `?` and `:key` system: + +```crystal +query = Mode.query.select( Clear::SQL.raw("CASE WHEN x=:x THEN 1 ELSE 0 END as check", x: "blabla") ) +query = Mode.query.select( Clear::SQL.raw("CASE WHEN x=? THEN 1 ELSE 0 END as check", "blabla") ) +``` + +## Bugfixes + +- Migrate to crystal v0.29 +- Fix issue with combinaison of `join`, `distinct` and `select` + # v0.7 ## Features diff --git a/sample/cli/cli.cr b/sample/cli/cli.cr index de341f325..026ddc694 100644 --- a/sample/cli/cli.cr +++ b/sample/cli/cli.cr @@ -34,6 +34,10 @@ class ApplyChange2 end end +Clear.seed do + puts "This is a seed" +end + Clear.with_cli do puts "Usage: crystal sample/cli/cli.cr -- clear [args]" end \ No newline at end of file diff --git a/shard.yml b/shard.yml index f83806deb..fe84e05be 100644 --- a/shard.yml +++ b/shard.yml @@ -1,5 +1,6 @@ name: clear -version: 0.7.2 + +version: 0.8 authors: - Yacine Petitprez diff --git a/spec/model/model_different_column_name_spec.cr b/spec/model/model_different_column_name_spec.cr new file mode 100644 index 000000000..dc9673ed1 --- /dev/null +++ b/spec/model/model_different_column_name_spec.cr @@ -0,0 +1,45 @@ +require "../spec_helper" + +module ModelDifferentColumnNameSpec + class Brand + include Clear::Model + + primary_key + column name : String, column_name: "brand_name" + self.table = "brands" + end + + class ModelDifferentColumnNameSpecMigration8273 + include Clear::Migration + + def change(dir) + create_table "brands" do |t| + t.column "brand_name", "string" + + t.timestamps + end + end + end + + def self.reinit + reinit_migration_manager + ModelDifferentColumnNameSpecMigration8273.new.apply(Clear::Migration::Direction::UP) + end + + describe "Clear::Model" do + context "Column definition" do + it "can define properties in the model with a name different of the column name in PG" do + # Here the column "name" is linked to "brand_name" in postgreSQL + temporary do + reinit + + Brand.create! name: "Nike" + Brand.query.first!.name.should eq "Nike" + Brand.query.where(brand_name: "Nike").count.should eq 1 + end + end + end + end + + +end \ No newline at end of file diff --git a/spec/model/model_spec.cr b/spec/model/model_spec.cr index b474ef39d..6a8e768c7 100644 --- a/spec/model/model_spec.cr +++ b/spec/model/model_spec.cr @@ -266,6 +266,15 @@ module ModelSpec #reload the model now u.reload.first_name.should eq "Malcom" u.changed?.should be_false + + u2 = User.create!({id: 2, first_name: "y"}) + + p = Post.create! user: u, title: "Reload testing post" + + p.user.id.should eq(1) + p.user = u2 #Change the user, DO NOT SAVE. + p.reload # Reload the model now: + p.user.id.should eq(1) # Cache should be invalidated end end @@ -697,6 +706,25 @@ module ModelSpec user_with_a_post_minimum.with_posts.each { } # Should just execute end end + + it "should wildcard with default model only if no select is made (before OR after)" do + temporary do + reinit + u = User.create!({first_name: "Join User"}) + Post.create!({title: "A Post", user_id: u.id}) + + user_with_a_post_minimum = User.query.distinct + .join(:model_posts) { model_posts.user_id == model_users.id } + .select(:first_name, :last_name) + + user_with_a_post_minimum.to_sql.should eq \ + "SELECT DISTINCT \"first_name\", \"last_name\" FROM \"model_users\" INNER JOIN " + + "\"model_posts\" ON (\"model_posts\".\"user_id\" = \"model_users\".\"id\")" + + user_with_a_post_minimum.with_posts.each { } # Should just execute + end + end + end context "with pagination" do diff --git a/spec/sql/misc_spec.cr b/spec/sql/misc_spec.cr index 8bbcb6456..180d0be49 100644 --- a/spec/sql/misc_spec.cr +++ b/spec/sql/misc_spec.cr @@ -11,6 +11,33 @@ module SQLMiscSpec describe "Clear::SQL" do describe "miscalleanous" do + + it "can escape for SQL-safe object" do + Clear::SQL.escape("order").should eq "\"order\"" + Clear::SQL.escape("").should eq "\"\"" + Clear::SQL.escape(:hello).should eq "\"hello\"" + + Clear::SQL.escape("some.weird.column name").should eq "\"some.weird.column name\"" + Clear::SQL.escape("\"hello world\"").should eq "\"\"\"hello world\"\"\"" + end + + it "can sanitize for SQL-safe string" do + Clear::SQL.sanitize(1).should eq "1" + Clear::SQL.sanitize("").should eq "''" + Clear::SQL.sanitize(nil).should eq "NULL" + Clear::SQL.sanitize("l'hotel").should eq "'l''hotel'" + end + + it "can create SQL fragment" do + Clear::SQL.raw("SELECT * FROM table WHERE x = ?", "hello").should eq( + %(SELECT * FROM table WHERE x = 'hello') + ) + + Clear::SQL.raw("SELECT * FROM table WHERE x = :x", x: 1).should eq( + %(SELECT * FROM table WHERE x = 1) + ) + end + it "can truncate a table" do begin diff --git a/spec/sql/parser_spec.cr b/spec/sql/parser_spec.cr new file mode 100644 index 000000000..0ca73b74e --- /dev/null +++ b/spec/sql/parser_spec.cr @@ -0,0 +1,21 @@ +require "../spec_helper" + +module ParserSpec + extend self + + describe "Clear::SQL" do + describe "Parser" do + + it "parse correctly" do + Clear::SQL::Parser.parse(<<-SQL + SELECT * FROM "users" where (id > 100 and active is null); + SELECT 'string' as text; + -- This is a comment + SQL + ) do |token| + pp token + end + end + end + end +end \ No newline at end of file diff --git a/spec/sql/select_spec.cr b/spec/sql/select_spec.cr index 37f678858..e4f6c1f4a 100644 --- a/spec/sql/select_spec.cr +++ b/spec/sql/select_spec.cr @@ -53,6 +53,17 @@ module SelectSpec r.to_sql.should eq "SELECT *" end + it "can select distinct" do + r = select_request.distinct.select("*") + r.to_sql.should eq "SELECT DISTINCT *" + + r = select_request.distinct.select("a", "b", "c") + r.to_sql.should eq "SELECT DISTINCT a, b, c" + + r = select_request.distinct.select(:first_name, :last_name, :id) + r.to_sql.should eq "SELECT DISTINCT \"first_name\", \"last_name\", \"id\"" + end + it "can select any string" do r = select_request.select("1") r.to_sql.should eq "SELECT 1" @@ -80,6 +91,13 @@ module SelectSpec end end + describe "the ORDER BY clause" do + it "can add NULLS FIRST and NULLS LAST" do + r = select_request.from("users").order_by("email", "ASC", "NULLS LAST") + r.to_sql.should eq "SELECT * FROM users ORDER BY email ASC NULLS LAST" + end + end + describe "the FROM clause" do it "can build simple from" do r = select_request.from(:users) @@ -156,6 +174,18 @@ module SelectSpec r.to_sql.should eq "SELECT * FROM \"users\" WHERE (\"user_id\" IS NULL)" end + it "can use or_where" do + select_request.from(:users).where("a = ?", {1}).or_where("b = ?", {2}).to_sql.should( + eq %(SELECT * FROM "users" WHERE ((a = 1) OR (b = 2))) + ) + + # First OR WHERE acts as a simple WHERE: + select_request.from(:users).or_where("a = ?", {1}).or_where("b = ?", {2}).to_sql.should( + eq %(SELECT * FROM "users" WHERE ((a = 1) OR (b = 2))) + ) + end + + it "can use `in` operators in case of array" do r = select_request.from(:users).where({user_id: [1, 2, 3, 4, "hello"]}) r.to_sql.should eq "SELECT * FROM \"users\" WHERE \"user_id\" IN (1, 2, 3, 4, 'hello')" @@ -199,6 +229,11 @@ module SelectSpec select_request.from(:users).where("a LIKE :halo AND b LIKE :world", {hello: "h", world: "w"}) end + + expect_raises Clear::SQL::QueryBuildingError do + select_request.from(:users).or_where("a LIKE :halo AND b LIKE :world", + {hello: "h", world: "w"}) + end end it "can prepare group by query" do diff --git a/src/clear/cli.cr b/src/clear/cli.cr index aa1dcea43..e5e95f805 100644 --- a/src/clear/cli.cr +++ b/src/clear/cli.cr @@ -3,6 +3,7 @@ require "admiral" require "./core" require "./cli/command" require "./cli/migration" +require "./cli/seed" require "./cli/generator" module Clear @@ -19,6 +20,7 @@ module Clear register_sub_command migrate, type: Clear::CLI::Migration register_sub_command generate, type: Clear::CLI::Generator + register_sub_command seed, type: Clear::CLI::Seed def run_impl STDOUT.puts help diff --git a/src/clear/cli/db.cr b/src/clear/cli/db.cr deleted file mode 100644 index 9ed09a265..000000000 --- a/src/clear/cli/db.cr +++ /dev/null @@ -1,23 +0,0 @@ -# class Clear::CLI::DBCommand < Clear::CLI::Command -# def get_help_string -# <<-HELP -# clear-cli [cli-options] db [db-command] - -# Commands: -# create # Create the database -# HELP -# end - -# def run_impl(args) -# OptionParser.parse(args) do |opts| -# opts.unknown_args do |args, _| -# arg = args.shift -# case arg -# when "create" -# puts "TODO" -# exit -# end -# end -# end -# end -# end diff --git a/src/clear/cli/seed.cr b/src/clear/cli/seed.cr new file mode 100644 index 000000000..1aa2cbb31 --- /dev/null +++ b/src/clear/cli/seed.cr @@ -0,0 +1,10 @@ +class Clear::CLI::Seed < Admiral::Command + include Clear::CLI::Command + + define_help description: "Seed the database with seed data" + + def run_impl + Clear.apply_seeds + end + +end diff --git a/src/clear/expression/expression.cr b/src/clear/expression/expression.cr index 11acf5383..1e67ba543 100644 --- a/src/clear/expression/expression.cr +++ b/src/clear/expression/expression.cr @@ -235,17 +235,75 @@ class Clear::Expression # # def raw(x : String, *args) + Node::Raw.new(self.class.raw(x, *args)) + end + + + # In case the name of the variable is a reserved word (e.g. `not`, `var`, `raw` ) + # or in case of a complex piece of computation impossible to express with the expression engine + # (e.g. usage of functions) you can use then raw to pass the String. + # + # BE AWARE than the String is pasted AS-IS and can lead to SQL injection if not used properly. + # + # ``` + # having { raw("COUNT(*)") > 5 } # SELECT ... FROM ... HAVING COUNT(*) > 5 + # where { raw("func(?, ?) = ?", a, b, c) } # SELECT ... FROM ... WHERE function(a, b) = c + # ``` + # + # + def self.raw(x : String, *args) + raw_enum(x, args) + end + + # See `self.raw` + # Can pass an array to this version + def self.raw_enum(x : String, args) idx = -1 - clause = x.gsub("?") do |_| + x.gsub("?") do |_| begin Clear::Expression[args[idx += 1]] rescue e : IndexError raise Clear::ErrorMessages.query_building_error(e.message) end end + end + + # In case the name of the variable is a reserved word (e.g. `not`, `var`, `raw` ) + # or in case of a complex piece of computation impossible to express with the expression engine + # (e.g. usage of functions) you can use then raw to pass the String. + # + # BE AWARE than the String is pasted AS-IS and can lead to SQL injection if not used properly. + # + # ``` + # having { raw("COUNT(*)") > 5 } # SELECT ... FROM ... HAVING COUNT(*) > 5 + # where { raw("func(:a, :b) = :c", a: a, b: b, c: c) } # SELECT ... FROM ... WHERE function(a, b) = c + # ``` + # + def raw(__template : String, **tuple) + Node::Raw.new(self.class.raw(__template, **tuple)) + end - Node::Raw.new(clause) + # In case the name of the variable is a reserved word (e.g. `not`, `var`, `raw` ) + # or in case of a complex piece of computation impossible to express with the expression engine + # (e.g. usage of functions) you can use then raw to pass the String. + # + # BE AWARE than the String is pasted AS-IS and can lead to SQL injection if not used properly. + # + # ``` + # having { raw("COUNT(*)") > 5 } # SELECT ... FROM ... HAVING COUNT(*) > 5 + # where { raw("func(:a, :b) = :c", a: a, b: b, c: c) } # SELECT ... FROM ... WHERE function(a, b) = c + # ``` + # + def self.raw(__template : String, **tuple) + __template.gsub(/\:[a-zA-Z0-9_]+/) do |question_mark| + begin + sym = question_mark[1..-1] + Clear::Expression[tuple[sym]] + rescue e : KeyError + raise Clear::ErrorMessages.query_building_error(e.message) + end + end end # Use var to create expression of variable. Variables are columns with or without the namespace and tablename: diff --git a/src/clear/expression/nodes/and_array.cr b/src/clear/expression/nodes/and_array.cr new file mode 100644 index 000000000..3d0bd973a --- /dev/null +++ b/src/clear/expression/nodes/and_array.cr @@ -0,0 +1,22 @@ +require "./node" + +# This node is used to generate expression like `( a AND b AND ... AND k )` +class Clear::Expression::Node::AndArray < Clear::Expression::Node + @expression : Array(Node) + + def initialize(expression : Array(Node)) + @expression = expression.dup + end + + def resolve : String + if @expression.any? + { + "(", + @expression.map(&.resolve).join(" AND "), + ")" + }.join + else + "" + end + end +end \ No newline at end of file diff --git a/src/clear/extensions/full_text_searchable/model.cr b/src/clear/extensions/full_text_searchable/model.cr index eb314b52d..df9a83dd0 100644 --- a/src/clear/extensions/full_text_searchable/model.cr +++ b/src/clear/extensions/full_text_searchable/model.cr @@ -89,7 +89,8 @@ module Clear::Model::FullTextSearchable column( {{through.id}} : Clear::TSVector, presence: false) scope "{{scope_name.id}}" do |str| - where{ op({{through.id}}, to_tsquery({{catalog}}, + table = self.item_class.table + where{ op( var(table, "{{through.id}}"), to_tsquery({{catalog}}, Clear::Model::FullTextSearchable.to_tsq(str)), "@@") } end end diff --git a/src/clear/extensions/time/time_in_day.cr b/src/clear/extensions/time/time_in_day.cr index 62102ef39..307ee1657 100644 --- a/src/clear/extensions/time/time_in_day.cr +++ b/src/clear/extensions/time/time_in_day.cr @@ -128,18 +128,18 @@ struct Time timezone = Time::Location.load(timezone) end - self.in(timezone).at_beginning_of_day + hm.ms.microseconds + self.in(timezone).at_beginning_of_day + hm.microseconds.microseconds else - at_beginning_of_day + hm.ms.microseconds + at_beginning_of_day + hm.microseconds.microseconds end end def +(t : Clear::TimeInDay) - self + t.ms.microseconds + self + t.microseconds.microseconds end def -(t : Clear::TimeInDay) - self - t.ms.microseconds + self - t.microseconds.microseconds end end diff --git a/src/clear/migration/migration.cr b/src/clear/migration/migration.cr index be75238d3..f13a405cc 100644 --- a/src/clear/migration/migration.cr +++ b/src/clear/migration/migration.cr @@ -68,6 +68,24 @@ module Clear::Migration class IrreversibleMigration < Exception; end module Helper + TYPE_MAPPING = { + "string" => "text", + "int32" => "int", + + "int64" => "bigint", + "long" => "bigint", + + "datetime" => "timestamp without time zone", + } + + # Replace some common type to their equivalent in postgresql + # if the type is not found in the correspondance table, then return + # itself + def self.datatype(type : String) + ts = type + TYPE_MAPPING[type]? || ts + end + def irreversible! raise IrreversibleMigration.new(migration_irreversible(self.class.name)) end diff --git a/src/clear/migration/operation/columns.cr b/src/clear/migration/operation/columns.cr index 70a3da805..88bd48505 100644 --- a/src/clear/migration/operation/columns.cr +++ b/src/clear/migration/operation/columns.cr @@ -1,14 +1,34 @@ module Clear::Migration class AddColumn < Operation + # ALTER TABLE {TABLENAME} + # ADD {COLUMNNAME} {TYPE} {NULL|NOT NULL} + # CONSTRAINT {CONSTRAINT_NAME} DEFAULT {DEFAULT_VALUE} + # WITH VALUES + @table : String @column : String @datatype : String - def initialize(@table, @column, @datatype) + @constraint : String? + @default : String? + @nullable : Bool + + @with_values : Bool + + def initialize(@table, @column, datatype, @nullable = false, @constraint = nil, @default = nil, @with_values = false ) + @datatype = Clear::Migration::Helper.datatype(datatype.to_s) end def up : Array(String) - ["ALTER TABLE #{@table} ADD #{@column} #{@datatype}"] + constraint = @constraint + default = @default + with_values = @with_values + + [[ + "ALTER TABLE", @table, "ADD", @column, @datatype, @nullable ? "NULL" : "NOT NULL", + constraint ? "CONSTRAINT #{constraint}" : nil, default ? "DEFAULT #{default}" : nil, + with_values ? "WITH VALUES" : nil + ].compact.join(" ")] end def down : Array(String) @@ -21,7 +41,8 @@ module Clear::Migration @column : String @datatype : String? - def initialize(@table, @column, @datatype = nil) + def initialize(@table, @column, datatype = nil) + @datatype = Clear::Migration::Helper.datatype(datatype) end def up : Array(String) @@ -46,9 +67,9 @@ module Clear::Migration @new_column_name : String? @new_column_type : String? - def initialize(@table, @column_name, @column_type, new_column_name, new_column_type) + def initialize(@table, @column_name, column_type, new_column_name, new_column_type) @new_column_name ||= @column_name - @new_column_type ||= @column_type + @new_column_type ||= Clear::Migration::Helper.datatype(column_type) end def up : Array(String) @@ -80,8 +101,9 @@ end module Clear::Migration::Helper # Add a column to a specific table - def add_column(table, column, datatype) - self.add_operation(Clear::Migration::AddColumn.new(table, column, datatype)) + def add_column(table, column, datatype, nullable = false, constraint = nil, default = nil, with_values = false) + self.add_operation(Clear::Migration::AddColumn.new(table, column, datatype, + nullable, constraint, default, with_values)) end def rename_column(table, from, to) diff --git a/src/clear/model/collection.cr b/src/clear/model/collection.cr index 9cd8c1d0e..1450bc832 100644 --- a/src/clear/model/collection.cr +++ b/src/clear/model/collection.cr @@ -232,6 +232,11 @@ module Clear::Model T.connection end + # Return the model class for this collection + def item_class + T + end + # :nodoc: # Set a query cache on this Collection. Fetching and enumerate will use the cache instead of calling the SQL. @@ -537,10 +542,7 @@ module Clear::Model # Redefinition of `join_impl` to avoid ambiguity on the column # name if no specific column have been selected. protected def join_impl(name, type, lateral, clear_expr) - if @columns.empty? - self.select("#{Clear::SQL.escape(T.table)}.*") - end - + self.set_default_table_wildcard(Clear::SQL.escape(T.table)) super(name, type, lateral, clear_expr) end @@ -553,7 +555,7 @@ module Clear::Model begin new_order = arr.map do |x| - Clear::SQL::Query::OrderBy::Record.new(x.op, (x.dir == :asc ? :desc : :asc)) + Clear::SQL::Query::OrderBy::Record.new(x.op, (x.dir == :asc ? :desc : :asc), nil) end clear_order_bys.order_by(new_order) diff --git a/src/clear/model/converters/array_converter.cr b/src/clear/model/converters/array_converter.cr index 461a5d07c..76417ab66 100644 --- a/src/clear/model/converters/array_converter.cr +++ b/src/clear/model/converters/array_converter.cr @@ -37,6 +37,8 @@ module Clear::Model::Converter::ArrayConverter{{exp.id}} nil end end.compact + when Array(::JSON::Any) + return x.map(&.as_{{k.id}}) when ::JSON::Any if arr = x.as_a? return arr.map(&.as_{{k.id}}) diff --git a/src/clear/model/converters/time_converter.cr b/src/clear/model/converters/time_converter.cr index 7ce230323..f6a0cc792 100644 --- a/src/clear/model/converters/time_converter.cr +++ b/src/clear/model/converters/time_converter.cr @@ -8,7 +8,15 @@ class Clear::Model::Converter::TimeConverter when Time x.to_local else - Time.parse_local(x.to_s, "%F %X.%L") + time = x.to_s + case time + when /[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}.[0-9]+/ + Time.parse_local(x.to_s, "%F %X.%L") + when /[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z/ + Time.parse(x.to_s, "%FT%XZ", Time::Location::UTC) + else + raise Time::Format::Error.new("Bad format for Time: #{time}") + end end end diff --git a/src/clear/model/model.cr b/src/clear/model/model.cr index 68df2dc66..f5483db1b 100644 --- a/src/clear/model/model.cr +++ b/src/clear/model/model.cr @@ -65,6 +65,13 @@ module Clear::Model reset(t) end + # Force to clean-up the caches for the relations + # connected to this model. + def invalidate_caching : self + @cache = nil + self + end + # :nodoc: # This is a tricky method which is overriden by inherited models. # diff --git a/src/clear/model/modules/has_saving.cr b/src/clear/model/modules/has_saving.cr index 963858207..48245de61 100644 --- a/src/clear/model/modules/has_saving.cr +++ b/src/clear/model/modules/has_saving.cr @@ -171,6 +171,8 @@ module Clear::Model::HasSaving def reload : self set(self.class.query.where{ var("#{self.class.pkey}") == pkey }.fetch_first!) + invalidate_caching + @attributes.clear clear_change_flags @persisted = true diff --git a/src/clear/model/modules/relations/belongs_to_macro.cr b/src/clear/model/modules/relations/belongs_to_macro.cr index 88ec764f2..02cc632d1 100644 --- a/src/clear/model/modules/relations/belongs_to_macro.cr +++ b/src/clear/model/modules/relations/belongs_to_macro.cr @@ -14,30 +14,37 @@ module Clear::Model::Relations::BelongsToMacro column {{foreign_key.id}} : {{key_type}}, primary: {{primary}}, presence: {{nilable}} getter _cached_{{method_name}} : {{relation_type}}? + protected def invalidate_caching + previous_def + + @_cached_{{method_name}} = nil + self + end + # The method {{method_name}} is a `belongs_to` relation # to {{relation_type}} def {{method_name}} : {{relation_type_nilable}} - if cached = @cached_{{method_name}} + if cached = @_cached_{{method_name}} cached else cache = @cache if cache && cache.active? "{{method_name}}" {% if nilable %} - @cached_{{method_name}} = cache.hit("{{method_name}}", + @_cached_{{method_name}} = cache.hit("{{method_name}}", self.{{foreign_key.id}}_column.to_sql_value, {{relation_type}} ).first? {% else %} - @cached_{{method_name}} = cache.hit("{{method_name}}", + @_cached_{{method_name}} = cache.hit("{{method_name}}", self.{{foreign_key.id}}_column.to_sql_value, {{relation_type}} ).first? || raise Clear::SQL::RecordNotFoundError.new {% end %} else {% if nilable %} - @cached_{{method_name}} = {{relation_type}}.query.where{ raw({{relation_type}}.pkey) == self.{{foreign_key.id}} }.first + @_cached_{{method_name}} = {{relation_type}}.query.where{ raw({{relation_type}}.pkey) == self.{{foreign_key.id}} }.first {% else %} - @cached_{{method_name}} = {{relation_type}}.query.where{ raw({{relation_type}}.pkey) == self.{{foreign_key.id}} }.first! + @_cached_{{method_name}} = {{relation_type}}.query.where{ raw({{relation_type}}.pkey) == self.{{foreign_key.id}} }.first! {% end %} end end @@ -47,22 +54,42 @@ module Clear::Model::Relations::BelongsToMacro def {{method_name}}! : {{relation_type}} {{method_name}}.not_nil! end # / *! - {% end %} - def {{method_name}}=(x : {{relation_type_nilable}}) - if x && x.persisted? - raise "#{x.pkey_column.name} must be defined when assigning a belongs_to relation." unless x.pkey_column.defined? - @{{foreign_key.id}}_column.value = x.pkey + def {{method_name}}=(model : {{relation_type_nilable}}) + if model + + if model.persisted? + raise "#{model.pkey_column.name} must be defined when assigning a belongs_to relation." unless model.pkey_column.defined? + @{{foreign_key.id}}_column.value = model.pkey + end + + @_cached_{{method_name}} = model + else + @{{foreign_key.id}}_column.value = nil end + end - @cached_{{method_name}} = x + {% else %} + + def {{method_name}}=(model : {{relation_type}}) + + if model.persisted? + raise "#{model.pkey_column.name} must be defined when assigning a belongs_to relation." unless model.pkey_column.defined? + @{{foreign_key.id}}_column.value = model.pkey + end + + + @_cached_{{method_name}} = model end + {% end %} + + # :nodoc: # save the belongs_to model first if needed def _bt_save_{{method_name}} - c = @cached_{{method_name}} + c = @_cached_{{method_name}} return if c.nil? unless c.persisted? @@ -85,7 +112,7 @@ module Clear::Model::Relations::BelongsToMacro before_query do sub_query = self.dup.clear_select.select("#{{{self_type}}.table}.{{foreign_key.id}}") - cached_qry = {{relation_type}}.query.where{ raw({{relation_type}}.pkey).in?(sub_query) } + cached_qry = {{relation_type}}.query.where{ raw("#{{{relation_type}}.table}.#{{{relation_type}}.pkey}").in?(sub_query) } block.call(cached_qry) diff --git a/src/clear/sql/logger.cr b/src/clear/sql/logger.cr index 44c0b3b21..1e6e6b75a 100644 --- a/src/clear/sql/logger.cr +++ b/src/clear/sql/logger.cr @@ -3,6 +3,8 @@ require "logger" require "benchmark" module Clear::SQL::Logger + class_property colorize : Bool = STDOUT.tty? && STDERR.tty? + private SQL_KEYWORDS = Set(String).new(%w( ADD ALL ALTER ANALYSE ANALYZE AND ANY ARRAY AS ASC ASYMMETRIC BEGIN BOTH BY CASE CAST CHECK COLLATE COLUMN COMMIT CONSTRAINT COUNT CREATE CROSS @@ -18,6 +20,8 @@ module Clear::SQL::Logger )) def self.colorize_query(qry : String) + return qry unless @@colorize + o = qry.to_s.split(/([a-zA-Z0-9_]+)/).map do |word| if SQL_KEYWORDS.includes?(word.upcase) word.colorize.bold.blue.to_s diff --git a/src/clear/sql/parser.cr b/src/clear/sql/parser.cr new file mode 100644 index 000000000..d52cea5b5 --- /dev/null +++ b/src/clear/sql/parser.cr @@ -0,0 +1,134 @@ + +# :nodoc: +# +# Small & ugly SQL parser used ONLY for colorizing the query. +module Clear::SQL::Parser + + SQL_KEYWORDS = Set(String).new(%w( + ADD ALL ALTER ANALYSE ANALYZE AND ANY ARRAY AS ASC ASYMMETRIC + BEGIN BOTH BY CASE CAST CHECK COLLATE COLUMN COMMIT CONSTRAINT COUNT CREATE CROSS + CURRENT_DATE CURRENT_ROLE CURRENT_TIME CURRENT_TIMESTAMP + CURRENT_USER CURSOR DECLARE DEFAULT DELETE DEFERRABLE DESC + DISTINCT DROP DO ELSE END EXCEPT EXISTS FALSE FETCH FULL FOR FOREIGN FROM GRANT + GROUP HAVING IF IN INDEX INNER INSERT INITIALLY INTERSECT INTO JOIN LAGGING + LEADING LIMIT LEFT LOCALTIME LOCALTIMESTAMP NATURAL NEW NOT NULL OFF OFFSET + OLD ON ONLY OR ORDER OUTER PLACING PRIMARY REFERENCES RELEASE RETURNING + RIGHT ROLLBACK SAVEPOINT SELECT SESSION_USER SET SOME SYMMETRIC + TABLE THEN TO TRAILING TRIGGER TRUE UNION UNIQUE UPDATE USER USING VALUES + WHEN WHERE WINDOW + )) + + enum Modes + Normal + Relation + String + Comment + end + + enum TokenType + Keyword + Relation + Number + String + Wildcard + Symbol + SimpleWord + Comment + Space + end + + record Token, content : String, type : TokenType + + private def self.findtype( x : String ) + return TokenType::Wildcard if x == "*" + return TokenType::Space if x == " " || x == "\n" + return TokenType::Keyword if SQL_KEYWORDS.includes?(x.upcase) + return TokenType::Number if x =~ /[0-9]+(\.[0-9]+)?(e[0-9]+)?/ + return TokenType::SimpleWord if x =~ /^[A-Za-z_]([A-Za-z_0-9]+)?$/ + + TokenType::Symbol + end + + # ameba:disable Metrics/CyclomaticComplexity + def self.parse(sql : String) + mode = Modes::Normal + + io = Char::Reader.new(sql) + + content = IO::Memory.new + + while io.has_next? + c = io.next_char + + content << c + + case mode + when Modes::Normal + case c + when ' ' + if io.peek_next_char != ' ' + yield Token.new(content.to_s, TokenType::Space ) + content.clear + end + when '"', '\'' + keyword = content.to_s[0..-2] #Remove the last ' ' + + yield Token.new(keyword, findtype(keyword) ) unless keyword.empty? + yield Token.new(" ", TokenType::Space ) if c == " " + + content.clear + content << c + + mode = Modes::Relation if c == '"' + mode = Modes::String if c == '\'' + when '-' + if io.peek_next_char == '-' + keyword = content.to_s[0..-2] #Remove the '-' + yield Token.new(keyword, findtype(keyword) ) + + content.clear + content << c + + content.clear + mode = Modes::Comment + end + end + when Modes::Comment + case c + when '\n' + keyword = content.to_s + yield Token.new(keyword, findtype(keyword) ) + + content.clear + mode = Modes::Normal + end + when Modes::Relation + case c + when '"' + if io.peek_next_char != '"' + mode = Modes::Normal + yield Token.new(content.to_s, TokenType::Relation) + content.clear + else + content << io.next_char + end + end + when Modes::String + case c + when '\'' + if io.peek_next_char != '\'' + # Close the string + mode = Modes::Normal + yield Token.new(content.to_s, TokenType::String) + content.clear + else + content << io.next_char + end + end + end + + end + + end + +end \ No newline at end of file diff --git a/src/clear/sql/query/order_by.cr b/src/clear/sql/query/order_by.cr index e3f121a8a..412337b5e 100644 --- a/src/clear/sql/query/order_by.cr +++ b/src/clear/sql/query/order_by.cr @@ -1,5 +1,5 @@ module Clear::SQL::Query::OrderBy - record Record, op : String, dir : Symbol + record Record, op : String, dir : Symbol, nulls : String? macro included getter order_bys : Array(Record) = [] of Record @@ -32,23 +32,23 @@ module Clear::SQL::Query::OrderBy def order_by(tuple : NamedTuple) tuple.each do |k, v| - @order_bys << Record.new(k.to_s, _order_by_to_symbol(v.to_s)) + @order_bys << Record.new(k.to_s, _order_by_to_symbol(v.to_s), nil) end change! end - def order_by(expression : Symbol, direction = "ASC") - @order_bys << Record.new(SQL.escape(expression.to_s), _order_by_to_symbol(direction)) + def order_by(expression : Symbol, direction = "ASC", nulls : String? = nil) + @order_bys << Record.new(SQL.escape(expression.to_s), _order_by_to_symbol(direction), nulls) change! end - def order_by(expression : String, direction = "ASC") - @order_bys << Record.new(expression, _order_by_to_symbol(direction)) + def order_by(expression : String, direction = "ASC", nulls : String? = nil) + @order_bys << Record.new(expression, _order_by_to_symbol(direction), nulls) change! end protected def print_order_bys return unless @order_bys.any? - "ORDER BY " + @order_bys.map { |r| [r.op, r.dir.to_s.upcase].join(" ") }.join(", ") + "ORDER BY " + @order_bys.map { |r| [r.op, r.dir.to_s.upcase, r.nulls].compact.join(" ") }.join(", ") end end diff --git a/src/clear/sql/query/select.cr b/src/clear/sql/query/select.cr index 141363b07..eaf744f69 100644 --- a/src/clear/sql/query/select.cr +++ b/src/clear/sql/query/select.cr @@ -1,6 +1,7 @@ module Clear::SQL::Query::Select macro included getter columns : Array(SQL::Column) = [] of SQL::Column + getter default_wildcard_table = nil def is_distinct? !!@distinct_value @@ -10,6 +11,14 @@ module Clear::SQL::Query::Select @columns : Array(SQL::Column) getter distinct_value : String? + # In some case you want you query to return `table.*` instead of `*` + # if no select parameters has been set. This occurs in the case of joins + # between models. + def set_default_table_wildcard(table : String? = nil) + @default_wildcard_table = table + change! + end + # def select(name : Symbolic, var = nil) # @columns << Column.new(name, var) # self @@ -69,8 +78,16 @@ module Clear::SQL::Query::Select end end + protected def print_wildcard + if table = @default_wildcard_table + {table, "*"}.join('.') + else + "*" + end + end + protected def print_columns - (@columns.any? ? @columns.map(&.to_sql.as(String)).join(", ") : "*") + (@columns.any? ? @columns.map(&.to_sql.as(String)).join(", ") : print_wildcard) end protected def print_select diff --git a/src/clear/sql/query/where.cr b/src/clear/sql/query/where.cr index 03e9f7753..822f41e53 100644 --- a/src/clear/sql/query/where.cr +++ b/src/clear/sql/query/where.cr @@ -40,6 +40,7 @@ module Clear::SQL::Query::Where def where(**tuple) where(conditions: tuple) end + # Build SQL `where` condition using a NamedTuple. # this will use: # - the `=` operator if compared with a literal @@ -91,18 +92,16 @@ module Clear::SQL::Query::Where # where("x = ? OR y = ?", {1, "l'eau"}) # WHERE x = 1 OR y = 'l''eau' # ``` # Raise error if there's not enough parameters to cover all the `?` placeholders - def where(str : String, parameters : Array(T) | Tuple) forall T - idx = -1 - - clause = str.gsub("?") do |_| - begin - Clear::Expression[parameters[idx += 1]] - rescue e : IndexError - raise Clear::ErrorMessages.query_building_error(e.message) - end - end + def where(str : String, parameters : Tuple | Enumerable(T)) forall T + self.where(Clear::SQL.raw_enum(str, parameters)) + end - self.where(clause) + def or_where(str : String, parameters : Tuple | Enumerable(T)) forall T + return where(str, parameters) if @wheres.empty? + old_clause = Clear::Expression::Node::AndArray.new(@wheres) + @wheres.clear + @wheres << Clear::Expression::Node::DoubleOperator.new(old_clause, Clear::Expression::Node::Raw.new( Clear::Expression.raw_enum("(#{str})", parameters) ), "OR") + change! end # Build SQL `where` interpolating `:keyword` with the NamedTuple passed in argument. @@ -111,18 +110,18 @@ module Clear::SQL::Query::Where # # WHERE id = 1 AND date >= '201x-xx-xx ...' # ``` def where(str : String, parameters : NamedTuple) - clause = str.gsub(/\:[a-zA-Z0-9_]+/) do |question_mark| - begin - sym = question_mark[1..-1] - Clear::Expression[parameters[sym]] - rescue e : KeyError - raise Clear::ErrorMessages.query_building_error(e.message) - end - end + self.where(Clear::SQL.raw(str, **parameters)) + end - self.where(clause) + def or_where(str : String, parameters : NamedTuple) + return where(str, parameters) if @wheres.empty? + old_clause = Clear::Expression::Node::AndArray.new(@wheres) + @wheres.clear + @wheres << Clear::Expression::Node::DoubleOperator.new(old_clause, Clear::Expression::Node::Raw.new( Clear::Expression.raw("(#{str})", **parameters) ), "OR") + change! end + # Build custom SQL `where` # beware of SQL injections! # ```crystal @@ -133,6 +132,16 @@ module Clear::SQL::Query::Where change! end + def or_where(str : String) + return where(str) if @wheres.empty? + old_clause = Clear::Expression::Node::AndArray.new(@wheres) + @wheres = [ + Clear::Expression::Node::DoubleOperator.new(old_clause, + Clear::Expression::Node::Raw.new("(#{str})"), "OR") + ] + change! + end + # Clear all the where clauses and return `self` def clear_wheres @wheres.clear diff --git a/src/clear/sql/sql.cr b/src/clear/sql/sql.cr index 0b7ad613e..dbbabdb7e 100644 --- a/src/clear/sql/sql.cr +++ b/src/clear/sql/sql.cr @@ -66,6 +66,27 @@ module Clear Clear::Expression[x] end + # This provide a fast way to create SQL fragment while escaping items, both with `?` and `:key` system: + # + # ``` + # query = Mode.query.select( Clear::SQL.raw("CASE WHEN x=:x THEN 1 ELSE 0 END as check", x: "blabla") ) + # query = Mode.query.select( Clear::SQL.raw("CASE WHEN x=? THEN 1 ELSE 0 END as check", "blabla") ) + # ``` + def raw(x, *params) + Clear::Expression.raw(x, *params) + end + + # See `self.raw` + # Can pass an array to this version + def raw_enum(x, params : Enumerable(T)) forall T + Clear::Expression.raw_enum(x, params) + end + + + def raw(__template, **params) + Clear::Expression.raw(__template, **params) + end + # Escape the expression, double quoting it. # # It allows use of reserved keywords as table or column name diff --git a/src/clear/version.cr b/src/clear/version.cr index 4d7034c40..8ac56c71a 100644 --- a/src/clear/version.cr +++ b/src/clear/version.cr @@ -1,3 +1,3 @@ module Clear - VERSION = "v0.7.2" + VERSION = "v0.8" end