Skip to content

Commit

Permalink
Support SQLite 3.45+ jsonb functions in the sqlite_json_ops extension
Browse files Browse the repository at this point in the history
  • Loading branch information
jeremyevans committed Feb 21, 2024
1 parent ec34d09 commit f912253
Show file tree
Hide file tree
Showing 4 changed files with 281 additions and 20 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
=== master

* Support SQLite 3.45+ jsonb functions in the sqlite_json_ops extension (jeremyevans) (#2133)

* Support compounds (e.g. UNION) in conjunction with Database#values on PostgreSQL (jeremyevans) (#2137)

* Support :use_advisory_lock option to Migrator.run to use advisory locks when running migrations (jeremyevans) (#2089)
Expand Down
94 changes: 76 additions & 18 deletions lib/sequel/extensions/sqlite_json_ops.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,62 +2,79 @@
#
# The sqlite_json_ops extension adds support to Sequel's DSL to make
# it easier to call SQLite JSON functions and operators (added
# first in SQLite 3.38.0).
# first in SQLite 3.38.0). It also supports the SQLite JSONB functions
# added in SQLite 3.45.0.
#
# To load the extension:
#
# Sequel.extension :sqlite_json_ops
#
# This extension works by calling methods on Sequel::SQLite::JSONOp objects,
# which you can create via Sequel.sqlite_json_op:
# This extension works by calling methods on Sequel::SQLite::JSONOp and
# Sequel::SQLite::JSONBOp objects, which you can create using
# Sequel.sqlite_json_op and Sequel.sqlite_jsonb_op:
#
# j = Sequel.sqlite_json_op(:json_column)
# jb = Sequel.sqlite_jsonb_op(:jsonb_column)
#
# Also, on most Sequel expression objects, you can call the sqlite_json_op method
# to create a Sequel::SQLite::JSONOp object:
# Also, on most Sequel expression objects, you can call the sqlite_json_op or
# sqlite_jsonb_op method to create a Sequel::SQLite::JSONOp or
# Sequel::SQLite::JSONBOp object:
#
# j = Sequel[:json_column].sqlite_json_op
# jb = Sequel[:jsonb_column].sqlite_jsonb_op
#
# If you have loaded the {core_extensions extension}[rdoc-ref:doc/core_extensions.rdoc],
# or you have loaded the core_refinements extension
# and have activated refinements for the file, you can also use Symbol#sqlite_json_op:
# or Symbol#sqlite_jsonb_op:
#
# j = :json_column.sqlite_json_op
# jb = :json_column.sqlite_jsonb_op
#
# The following methods are available for Sequel::SQLite::JSONOp instances:
#
# j[1] # (json_column ->> 1)
# j.get(1) # (json_column ->> 1)
# j.get_text(1) # (json_column -> 1)
# j.extract('$.a') # json_extract(json_column, '$.a')
# jb.extract('$.a') # jsonb_extract(jsonb_column, '$.a')
#
# j.array_length # json_array_length(json_column)
# j.type # json_type(json_column)
# j.valid # json_valid(json_column)
# j.json # json(json_column)
# jb.json # json(jsonb_column)
# j.jsonb # jsonb(json_column)
#
# j.insert('$.a', 1) # json_insert(json_column, '$.a', 1)
# j.set('$.a', 1) # json_set(json_column, '$.a', 1)
# j.replace('$.a', 1) # json_replace(json_column, '$.a', 1)
# j.remove('$.a') # json_remove(json_column, '$.a')
# j.patch('{"a":2}') # json_patch(json_column, '{"a":2}')
#
# jb.insert('$.a', 1) # jsonb_insert(jsonb_column, '$.a', 1)
# jb.set('$.a', 1) # jsonb_set(jsonb_column, '$.a', 1)
# jb.replace('$.a', 1) # jsonb_replace(jsonb_column, '$.a', 1)
# jb.remove('$.a') # jsonb_remove(jsonb_column, '$.a')
# jb.patch('{"a":2}') # jsonb_patch(jsonb_column, '{"a":2}')
#
# j.each # json_each(json_column)
# j.tree # json_tree(json_column)
#
# Related modules: Sequel::SQLite::JSONOp
# Related modules: Sequel::SQLite::JSONBaseOp, Sequel::SQLite::JSONOp,
# Sequel::SQLite::JSONBOp

#
module Sequel
module SQLite
# The JSONOp class is a simple container for a single object that
# defines methods that yield Sequel expression objects representing
# SQLite json operators and functions.
# JSONBaseOp is an abstract base wrapper class for a object that
# defines methods that return Sequel expression objects representing
# SQLite json operators and functions. It is subclassed by both
# JSONOp and JSONBOp for json and jsonb specific behavior.
#
# In the method documentation examples, assume that:
#
# json_op = Sequel.sqlite_json_op(:json)
class JSONOp < Sequel::SQL::Wrapper
class JSONBaseOp < Sequel::SQL::Wrapper
GET = ["(".freeze, " ->> ".freeze, ")".freeze].freeze
private_constant :GET

Expand All @@ -82,7 +99,7 @@ def [](key)
# json_op.array_length # json_array_length(json)
# json_op.array_length('$[1]') # json_array_length(json, '$[1]')
def array_length(*args)
Sequel::SQL::NumericExpression.new(:NOOP, function(:array_length, *args))
Sequel::SQL::NumericExpression.new(:NOOP, SQL::Function.new(:json_array_length, self, *args))
end

# Returns an expression for a set of information extracted from the top-level
Expand All @@ -92,7 +109,7 @@ def array_length(*args)
# json_op.each # json_each(json)
# json_op.each('$.a') # json_each(json, '$.a')
def each(*args)
function(:each, *args)
SQL::Function.new(:json_each, self, *args)
end

# Returns an expression for the JSON array element or object field at the specified
Expand Down Expand Up @@ -129,10 +146,17 @@ def insert(path, value, *args)
#
# json_op.json # json(json)
def json
self.class.new(SQL::Function.new(:json, self))
JSONOp.new(SQL::Function.new(:json, self))
end
alias minify json

# Returns the JSONB format of the JSON.
#
# json_op.jsonb # jsonb(json)
def jsonb
JSONBOp.new(SQL::Function.new(:jsonb, self))
end

# Returns an expression for updating the JSON object using the RFC 7396 MergePatch algorithm
#
# json_op.patch('{"a": 1, "b": null}') # json_patch(json, '{"a": 1, "b": null}')
Expand Down Expand Up @@ -172,21 +196,21 @@ def set(path, value, *args)
# json_op.tree # json_tree(json)
# json_op.tree('$.a') # json_tree(json, '$.a')
def tree(*args)
function(:tree, *args)
SQL::Function.new(:json_tree, self, *args)
end

# Returns an expression for the type of the JSON value or the JSON value at the given path.
#
# json_op.type # json_type(json)
# json_op.type('$[1]') # json_type(json, '$[1]')
def type(*args)
Sequel::SQL::StringExpression.new(:NOOP, function(:type, *args))
Sequel::SQL::StringExpression.new(:NOOP, SQL::Function.new(:json_type, self, *args))
end
alias typeof type

# Returns a boolean expression for whether the JSON is valid or not.
def valid
Sequel::SQL::BooleanExpression.new(:NOOP, function(:valid))
Sequel::SQL::BooleanExpression.new(:NOOP, SQL::Function.new(:json_valid, self))
end

private
Expand All @@ -198,7 +222,7 @@ def json_op(str, args)

# Internals of the methods that return functions prefixed with +json_+.
def function(name, *args)
SQL::Function.new("json_#{name}", self, *args)
SQL::Function.new("#{function_prefix}_#{name}", self, *args)
end

# Internals of the methods that return functions prefixed with +json_+, that
Expand All @@ -208,12 +232,36 @@ def wrapped_function(*args)
end
end

# JSONOp is used for SQLite json-specific functions and operators.
class JSONOp < JSONBaseOp
private

def function_prefix
"json"
end
end

# JSONOp is used for SQLite jsonb-specific functions and operators.
class JSONBOp < JSONBaseOp
private

def function_prefix
"jsonb"
end
end

module JSONOpMethods
# Wrap the receiver in an JSONOp so you can easily use the SQLite
# json functions and operators with it.
def sqlite_json_op
JSONOp.new(self)
end

# Wrap the receiver in an JSONBOp so you can easily use the SQLite
# jsonb functions and operators with it.
def sqlite_jsonb_op
JSONBOp.new(self)
end
end
end

Expand All @@ -227,6 +275,16 @@ def sqlite_json_op(v)
SQLite::JSONOp.new(v)
end
end

# Return the object wrapped in an SQLite::JSONBOp.
def sqlite_jsonb_op(v)
case v
when SQLite::JSONBOp
v
else
SQLite::JSONBOp.new(v)
end
end
end

class SQL::GenericExpression
Expand Down
41 changes: 41 additions & 0 deletions spec/adapters/sqlite_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -971,6 +971,47 @@

@db.get(jo.valid).must_equal 1
@db.get(ja.valid).must_equal 1

if @db.sqlite_version >= 34500
direct_jo = Sequel.sqlite_jsonb_op('{"a": 1 ,"b": {"c": 2, "d": {"e": 3}}}')
direct_ja = Sequel.sqlite_jsonb_op('[2, 3, ["a", "b"]]')

[[jo.jsonb, ja.jsonb], [direct_jo, direct_ja]].each do |jo, ja|
@db.get(jo.json).must_equal '{"a":1,"b":{"c":2,"d":{"e":3}}}'
@db.get(jo.jsonb.json).must_equal '{"a":1,"b":{"c":2,"d":{"e":3}}}'

@db.get(jo.extract('$.a')).must_equal 1
@db.get(jo.extract('$.a', '$.b.c').sqlite_json_op.json).must_equal '[1,2]'
@db.get(jo.extract('$.a', '$.b.d.e').sqlite_json_op.json).must_equal '[1,3]'

@db.get(ja.insert('$[1]', 5).json).must_equal '[2,3,["a","b"]]'
@db.get(ja.replace('$[1]', 5).json).must_equal '[2,5,["a","b"]]'
@db.get(ja.set('$[1]', 5).json).must_equal '[2,5,["a","b"]]'
@db.get(ja.insert('$[3]', 5).json).must_equal '[2,3,["a","b"],5]'
@db.get(ja.replace('$[3]', 5).json).must_equal '[2,3,["a","b"]]'
@db.get(ja.set('$[3]', 5).json).must_equal '[2,3,["a","b"],5]'
@db.get(ja.insert('$[1]', 5, '$[3]', 6).json).must_equal '[2,3,["a","b"],6]'
@db.get(ja.replace('$[1]', 5, '$[3]', 6).json).must_equal '[2,5,["a","b"]]'
@db.get(ja.set('$[1]', 5, '$[3]', 6).json).must_equal '[2,5,["a","b"],6]'

@db.get(jo.insert('$.f', 4).json).must_equal '{"a":1,"b":{"c":2,"d":{"e":3}},"f":4}'
@db.get(jo.replace('$.f', 4).json).must_equal '{"a":1,"b":{"c":2,"d":{"e":3}}}'
@db.get(jo.set('$.f', 4).json).must_equal '{"a":1,"b":{"c":2,"d":{"e":3}},"f":4}'
@db.get(jo.insert('$.a', 4).json).must_equal '{"a":1,"b":{"c":2,"d":{"e":3}}}'
@db.get(jo.replace('$.a', 4).json).must_equal '{"a":4,"b":{"c":2,"d":{"e":3}}}'
@db.get(jo.set('$.a', 4).json).must_equal '{"a":4,"b":{"c":2,"d":{"e":3}}}'
@db.get(jo.insert('$.f', 4, '$.a', 5).json).must_equal '{"a":1,"b":{"c":2,"d":{"e":3}},"f":4}'
@db.get(jo.replace('$.f', 4, '$.a', 5).json).must_equal '{"a":5,"b":{"c":2,"d":{"e":3}}}'
@db.get(jo.set('$.f', 4, '$.a', 5).json).must_equal '{"a":5,"b":{"c":2,"d":{"e":3}},"f":4}'

@db.get(jo.patch('{"e": 4, "b": 5, "a": null}').json).must_equal '{"b":5,"e":4}'

@db.get(ja.remove('$[1]').json).must_equal '[2,["a","b"]]'
@db.get(ja.remove('$[1]', '$[1]').json).must_equal '[2]'
@db.get(jo.remove('$.a').json).must_equal '{"b":{"c":2,"d":{"e":3}}}'
@db.get(jo.remove('$.a', '$.b.c').json).must_equal '{"b":{"d":{"e":3}}}'
end
end
end
end if DB.sqlite_version >= 33800

Expand Down
Loading

0 comments on commit f912253

Please sign in to comment.