diff --git a/spec/model/validation_spec.cr b/spec/model/validation_spec.cr index 373d6a41d..0c5569706 100644 --- a/spec/model/validation_spec.cr +++ b/spec/model/validation_spec.cr @@ -25,13 +25,13 @@ module ValidationSpec def validate ensure_than email, "must be email", &.strip.=~(/^[a-z0-9_+\.]+@[a-z0-9_\.]+$/i) ensure_than email, "must not be a free email" do |value| - value = value.strip + v = value.strip ![ /gmail\.com$/, /hotmail\.[A-Za-z\.]+$/, /yahoo.[A-Za-z\.]+$/, - ].any? { |x| value =~ x } + ].any? { |x| v =~ x } end end end diff --git a/spec/sql/jsonb_spec.cr b/spec/sql/jsonb_spec.cr index fbdd7f05b..1b1864a99 100644 --- a/spec/sql/jsonb_spec.cr +++ b/spec/sql/jsonb_spec.cr @@ -20,10 +20,9 @@ module JSONBSpec end it "can generate arrow writing" do - jsonb_text("data.x.y.z").should eq("data->'x'->'y'->'z'::text") - jsonb_text("data.x.\\.y.z").should eq("data->'x'->'.y'->'z'::text") - jsonb_text("data.x.y'b.z").should eq("data->'x'->'y''b'->'z'::text") - jsonb_text("data").should eq("data::text") + jsonb_resolve("data", "x.y.z").should eq("data->'x'->'y'->>'z'") + jsonb_resolve("data", "x.\\.y.z").should eq("data->'x'->'.y'->>'z'") + jsonb_resolve("data", "x.y'b.z").should eq("data->'x'->'y''b'->>'z'") end it "can use `?|` operator" do @@ -39,18 +38,31 @@ module JSONBSpec end it "can use @> operator" do - jsonb_eq("data.x.y", "value").should eq("data @> '{\"x\":{\"y\":\"value\"}}'") - jsonb_eq("data.x.y", 1).should eq("data @> '{\"x\":{\"y\":1}}'") + jsonb_eq("data", "x.y", "value").should eq("data @> '{\"x\":{\"y\":\"value\"}}'") + jsonb_eq("data", "x.y", 1).should eq("data @> '{\"x\":{\"y\":1}}'") end - it "fits with the expression engine" do - Clear::SQL.select("*").from("users") - .where { jsonb_eq("data.security.role", "admin") }.to_sql - .should eq %(SELECT * FROM users WHERE data @> '{"security":{"role":"admin"}}') + describe "Expression engine" do + it "use -> operator when it cannot test presence" do + Clear::SQL.select("*").from("users") + .where { data.jsonb("personal email").like "%@gmail.com" }.to_sql + .should eq %(SELECT * FROM users WHERE (data->>'personal email' LIKE '%@gmail.com')) - Clear::SQL.select("*").from("users") - .where { jsonb_text("data.personal email").like "%@gmail.com" }.to_sql - .should eq %(SELECT * FROM users WHERE (data->'personal email'::text LIKE '%@gmail.com')) + # v-- Usage of 'raw' should trigger usage of arrow, since it's not a literal. + Clear::SQL.select.from("users") + .where { data.jsonb("test") == raw("afunction()") }.to_sql + .should eq %(SELECT * FROM users WHERE (data->>'test' = afunction())) + end + + it "merges the jsonb instructions (optimization)" do + Clear::SQL.select("*").from("users") + .where { + (data.jsonb("security.role") == "admin") & + (data.jsonb("security.level") == 1) + }.to_sql + .should eq "SELECT * FROM users WHERE (data @> '{\"security\":{\"role\":\"admin\"}}') OR " + + "(data @> '{\"security\":{\"role\":1}}')" + end end end end diff --git a/src/clear/expression/expression.cr b/src/clear/expression/expression.cr index 8c837cd9d..261afc2bb 100644 --- a/src/clear/expression/expression.cr +++ b/src/clear/expression/expression.cr @@ -68,6 +68,10 @@ class Clear::Expression def to_s @value end + + def to_json(b = nil) + @value + end end alias AvailableLiteral = Int8 | Int16 | Int32 | Int64 | Float32 | Float64 | diff --git a/src/clear/expression/nodes/literal.cr b/src/clear/expression/nodes/literal.cr index 8836f0f83..daa696b2c 100644 --- a/src/clear/expression/nodes/literal.cr +++ b/src/clear/expression/nodes/literal.cr @@ -1,10 +1,12 @@ require "./node" class Clear::Expression::Node::Literal < Clear::Expression::Node - def initialize(@lit : AvailableLiteral) + getter value : AvailableLiteral + + def initialize(@value : AvailableLiteral) end def resolve : String - Clear::Expression[@lit] + Clear::Expression[@value] end end diff --git a/src/clear/expression/nodes/not_between.cr b/src/clear/expression/nodes/not_between.cr index 915cfb8ce..99afd1c1b 100644 --- a/src/clear/expression/nodes/not_between.cr +++ b/src/clear/expression/nodes/not_between.cr @@ -1,6 +1,6 @@ require "./node" -### +# # class Clear::Expression::Node::NotBetween < Clear::Expression::Node alias BetweenType = Int32 | Int64 | Float32 | Float64 | String | Time | Node diff --git a/src/clear/model/validation/helper.cr b/src/clear/model/validation/helper.cr index 836c6d494..7ca42524f 100644 --- a/src/clear/model/validation/helper.cr +++ b/src/clear/model/validation/helper.cr @@ -12,14 +12,15 @@ module Clear::Validation::Helper # Usage example: # # ```crystal - # ensure_than email, "must be an email" do |v| - # EmailRegexp.valid?(v) - # end + # ensure_than email, "must be an email" do |v| + # EmailRegexp.valid?(v) + # end # ``` macro ensure_than(field, message, &block) if {{field.id}}_column.defined? o = {{field.id}} + fn = Clear::Util.lambda(typeof(o), Object) {{block}} unless fn.call(o) @@ -28,5 +29,4 @@ module Clear::Validation::Helper end end - end diff --git a/src/clear/sql/jsonb/expression.cr b/src/clear/sql/jsonb/expression.cr new file mode 100644 index 000000000..7dc7afc29 --- /dev/null +++ b/src/clear/sql/jsonb/expression.cr @@ -0,0 +1,72 @@ +require "./jsonb" + +class Clear::Expression::Node::JSONB::Field < Clear::Expression::Node + include Clear::SQL::JSONB + + getter field : Node + getter key : String + + def initialize(@field, @key) + end + + def resolve + jsonb_resolve(@field.resolve, jsonb_k2a(key)) + end + + def ==(value : Clear::Expression::Node) + super(value) # << Keep same for node which are not literal value + end + + def ==(value : _) # << For other type, literalize and use smart JSONB equality + Clear::Expression::Node::JSONB::Equality.new(field.resolve, jsonb_k2h(key, value)) + end +end + +# Define a __value contains?__ operation between a jsonb column and a json hash +class Clear::Expression::Node::JSONB::Equality < Clear::Expression::Node + include Clear::SQL::JSONB + + getter jsonb_field : String + getter value : JSONBHash + + def initialize(@jsonb_field, @value) + end + + def resolve + {@jsonb_field, Clear::Expression[@value.to_json]}.join(" @> ") + end + + # In case of AND with another JSON equality test + # we merge both expression in only one ! + def &(other : self) + if (other.jsonb_field == jsonb_field) + Clear::Expression::Node::JSONB::Equality.new(jsonb_field, + Clear::Util.hash_union(value, other.value) + ) + else + super(other) + end + end +end + +class Clear::Expression::Node::Variable < Clear::Expression::Node + def jsonb_key_exists?(key : String) + Clear::Expression::Node::DoubleOperator.new(self, Clear::Expression::Node::Literal.new(key), "?") + end + + def jsonb_any_key_exists?(keys : Array(String)) + Clear::Expression::Node::DoubleOperator.new(self, + {"array[", keys.join(", "), "]"}.join + ,"?|") + end + + def jsonb_all_keys_exists?(keys : Array(String)) + Clear::Expression::Node::DoubleOperator.new(self, + {"array[", keys.join(", "), "]"}.join + ,"?&") + end + + def jsonb(key : String) + Clear::Expression::Node::JSONB::Field.new(self, key) + end +end diff --git a/src/clear/sql/jsonb/jsonb.cr b/src/clear/sql/jsonb/jsonb.cr index 03d742316..6d9c8699c 100644 --- a/src/clear/sql/jsonb/jsonb.cr +++ b/src/clear/sql/jsonb/jsonb.cr @@ -1,34 +1,61 @@ -# List of helpers for JSONB +# +# ## JSONB Integration with Clear +# +# Clear supports natively postgres jsonb columns +# +# Functions can be used calling or including Clear::SQL::JSONB methods as helper methods: +# +# ```crystal +# class MyClass +# include Clear::SQL::JSONB +# +# def create_sql_with_json +# Clear::SQL.select.where(jsonb_any_exists?("attributes", ["a", "b", "c"])) +# # ^-- operator `?|`, if the keys a, b or c exists in the jsonb table 'attributes' +# end +# end +# ``` +# Moreover, jsonb is directly integrated into the Expression Engine. +# For that, just call `jsonb` after a variable to activate the methods: +# +# ### Filter by jsonb +# +# ```crystal +# Product.query.where { (attributes.jsonb("category") == "Book") & (attributes.jsonb("author.name") == "Philip K. Dick") } +# # ^-- Will produce optimized for gin index jsonb filter query: +# # WHERE attributes @> '{"category": "Book", "author": {"name": "Philip K. Dick"} }'::jsonb +# ``` +# # require "json" module Clear::SQL::JSONB extend self - alias JSONBKey = JSONBHash | String | Int32 | Int64 + alias JSONBKey = JSONBHash | Clear::Expression::AvailableLiteral alias JSONBHash = Hash(String, JSONBKey) # Transform a key to a hash - def jsonb_k2h(key : String) : JSONBHash - jsonb_arr2h(jsonb_k2a(key)) + def jsonb_k2h(key : String, value : JSONBKey) : JSONBHash + jsonb_arr2h(jsonb_k2a(key), value) end # jsonb `?|` operator # Do any of these array strings exist as top-level keys? # def jsonb_any_exists?(field, keys : Array(String)) - [field, "array[" + keys.map { |x| Clear::SQL.sanitize(x) }.join(",") + "]"].join(" ?| ") + {field, "array[" + keys.map { |x| Clear::SQL.sanitize(x) }.join(",") + "]"}.join(" ?| ") end # Does the string exist as a top-level key within the JSON value? def jsonb_exists?(field, value) - [field, Clear::SQL.sanitize(value)].join(" ? ") + {field, Clear::SQL.sanitize(value)}.join(" ? ") end # jsonb `?&` operator # Do all of these array strings exist as top-level keys? def jsonb_all_exists?(field, keys : Array(String)) - [field, "array[" + keys.map { |x| Clear::SQL.sanitize(x) }.join(",") + "]"].join(" ?& ") + {field, "array[" + keys.map { |x| Clear::SQL.sanitize(x) }.join(",") + "]"}.join(" ?& ") end # :nodoc: @@ -83,13 +110,25 @@ module Clear::SQL::JSONB # ``` # # => `data @> '{"sub": {"key": "value"}}' ` - def jsonb_eq(key, value) + def jsonb_eq(field, key, value) arr = jsonb_k2a(key) + if arr.empty? + return {field, Clear::Expression[value]}.join(" = ") + else + return {field, Clear::Expression[jsonb_arr2h(arr, value).to_json]}.join(" @> ") + end + end + + def jsonb_resolve(field, arr : Array(String)) : String + return field if arr.empty? + + arr = arr.map { |x| Clear::Expression[x] } + if arr.size == 1 - return [arr[0], Clear::Expression[value]].join(" = ") + {field, arr[0]}.join("->>") else - return [arr[0], Clear::Expression[jsonb_arr2h(arr[1..-1], value).to_json]].join(" @> ") + {([field] + arr[0..-2]).join("->"), arr[-1]}.join("->>") end end @@ -97,27 +136,11 @@ module Clear::SQL::JSONB # # ```crystal # jsonb_text("data", "sub.key").like("user%") - # # => "data->'sub'->'key'::text LIKE 'user%'" + # # => "data->'sub'->>'key' LIKE 'user%'" # ``` # - def jsonb_text(key) + def jsonb_resolve(field, key : String) arr = jsonb_k2a(key) - arr.map_with_index do |v, idx| - idx == 0 ? v : Clear::Expression[v] - end.join("->") + "::text" + jsonb_resolve(field, arr) end end - -# Add json helpers methods into expression engine. -class Clear::Expression - # Delegate all the methods of Clear::SQL::JSONB methods - {% for method in Clear::SQL::JSONB.methods %} - def {{method.name}}({{method.args.join(", ").id}}) - {% arg_names = [] of String %} - {% for arg in method.args %} - {% arg_names << arg.name %} - {% end %} - raw(Clear::SQL::JSONB.{{method.name}}({{arg_names.join(", ").id}})) - end - {% end %} -end diff --git a/src/clear/util.cr b/src/clear/util.cr index 381cde767..900a8456e 100644 --- a/src/clear/util.cr +++ b/src/clear/util.cr @@ -7,4 +7,32 @@ module Clear::Util def lambda(u : U.class, v : V.class, &block : U -> V) forall U, V block end + + # Return a new hash which is union of two hash (some kind of deep merge) + def hash_union(h1 : Hash(A, B), h2 : Hash(C, D)) forall A, B, C, D + o = Hash(A | C, B | D).new + + h1.each do |k, v| + o[k] = v + end + + h2.each do |k, v| + case v + when Hash + if (v1 = o[k]).is_a?(Hash) + o[k] = hash_union(v1, v) + else + o[k] = v + end + else + o[k] = v + end + end + + o + end + + macro to_proc(*args, &block) + -> ({{args.join(", ").id}}) { {{block.body}} } + end end