Skip to content

Commit

Permalink
WIP on JSONB integration
Browse files Browse the repository at this point in the history
  • Loading branch information
Yacine Petitprez committed Jun 29, 2018
1 parent 70a57dd commit 7d33a3a
Show file tree
Hide file tree
Showing 9 changed files with 192 additions and 51 deletions.
4 changes: 2 additions & 2 deletions spec/model/validation_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 25 additions & 13 deletions spec/sql/jsonb_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions src/clear/expression/expression.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
6 changes: 4 additions & 2 deletions src/clear/expression/nodes/literal.cr
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion src/clear/expression/nodes/not_between.cr
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
require "./node"

###
#
#
class Clear::Expression::Node::NotBetween < Clear::Expression::Node
alias BetweenType = Int32 | Int64 | Float32 | Float64 | String | Time | Node
Expand Down
8 changes: 4 additions & 4 deletions src/clear/model/validation/helper.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -28,5 +29,4 @@ module Clear::Validation::Helper
end

end

end
72 changes: 72 additions & 0 deletions src/clear/sql/jsonb/expression.cr
Original file line number Diff line number Diff line change
@@ -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
81 changes: 52 additions & 29 deletions src/clear/sql/jsonb/jsonb.cr
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -83,41 +110,37 @@ 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

# Return text selector for the field/key :
#
# ```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
28 changes: 28 additions & 0 deletions src/clear/util.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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

0 comments on commit 7d33a3a

Please sign in to comment.