From 01613aa94bb610bf7e9282794f4f0e78d72276f9 Mon Sep 17 00:00:00 2001 From: Jeremy Evans Date: Fri, 6 Oct 2023 15:48:17 -0700 Subject: [PATCH] Re-add is_json and is_not_json methods to the pg_json_ops extension, as the support was re-added in PostgreSQL 16 This reverts commit 98f8c9b3f67e46089b38401539b777e13f68fa38. It updates the documentation and guard to use PostgreSQL 16 instead of 15, and fixes some documentation issues. --- CHANGELOG | 2 + lib/sequel/extensions/pg_json_ops.rb | 52 ++++++++++++++++++++ spec/adapters/postgres_spec.rb | 39 +++++++++++++++ spec/extensions/pg_json_ops_spec.rb | 73 ++++++++++++++++++++++++++++ 4 files changed, 166 insertions(+) diff --git a/CHANGELOG b/CHANGELOG index 57198fa4bb..615353838d 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,7 @@ === master +* Re-add is_json and is_not_json methods to the pg_json_ops extension, as the support was re-added in PostgreSQL 16 (jeremyevans) + * Avoid infinite loop when handling exceptions with a cause loop in jdbc adapter (jeremyevans) === 5.73.0 (2023-10-01) diff --git a/lib/sequel/extensions/pg_json_ops.rb b/lib/sequel/extensions/pg_json_ops.rb index 31b623e911..257f141110 100644 --- a/lib/sequel/extensions/pg_json_ops.rb +++ b/lib/sequel/extensions/pg_json_ops.rb @@ -123,6 +123,15 @@ # c = Sequel.pg_jsonb_op(:c) # DB[:t].update(c['key1'] => 1.to_json, c['key2'] => "a".to_json) # +# On PostgreSQL 16+, the IS [NOT] JSON operator is supported: +# +# j.is_json # j IS JSON +# j.is_json(type: :object) # j IS JSON OBJECT +# j.is_json(type: :object, unique: true) # j IS JSON OBJECT WITH UNIQUE +# j.is_not_json # j IS NOT JSON +# j.is_not_json(type: :array) # j IS NOT JSON ARRAY +# j.is_not_json(unique: true) # j IS NOT JSON WITH UNIQUE +# # If you are also using the pg_json extension, you should load it before # loading this extension. Doing so will allow you to use the #op method on # JSONHash, JSONHarray, JSONBHash, and JSONBArray, allowing you to perform json/jsonb operations @@ -151,6 +160,18 @@ class JSONBaseOp < Sequel::SQL::Wrapper GET_PATH = ["(".freeze, " #> ".freeze, ")".freeze].freeze GET_PATH_TEXT = ["(".freeze, " #>> ".freeze, ")".freeze].freeze + IS_JSON = ["(".freeze, " IS JSON".freeze, "".freeze, ")".freeze].freeze + IS_NOT_JSON = ["(".freeze, " IS NOT JSON".freeze, "".freeze, ")".freeze].freeze + EMPTY_STRING = Sequel::LiteralString.new('').freeze + WITH_UNIQUE = Sequel::LiteralString.new(' WITH UNIQUE').freeze + IS_JSON_MAP = { + nil => EMPTY_STRING, + :value => Sequel::LiteralString.new(' VALUE').freeze, + :scalar => Sequel::LiteralString.new(' SCALAR').freeze, + :object => Sequel::LiteralString.new(' OBJECT').freeze, + :array => Sequel::LiteralString.new(' ARRAY').freeze + }.freeze + # Get JSON array element or object field as json. If an array is given, # gets the object at the specified path. # @@ -233,6 +254,30 @@ def get_text(key) end end + # Return whether the json object can be parsed as JSON. + # + # Options: + # :type :: Check whether the json object can be parsed as a specific type + # of JSON (:value, :scalar, :object, :array). + # :unique :: Check JSON objects for unique keys. + # + # json_op.is_json # json IS JSON + # json_op.is_json(type: :object) # json IS JSON OBJECT + # json_op.is_json(unique: true) # json IS JSON WITH UNIQUE + def is_json(opts=OPTS) + _is_json(IS_JSON, opts) + end + + # Return whether the json object cannot be parsed as JSON. The opposite + # of #is_json. See #is_json for options. + # + # json_op.is_not_json # json IS NOT JSON + # json_op.is_not_json(type: :object) # json IS NOT JSON OBJECT + # json_op.is_not_json(unique: true) # json IS NOT JSON WITH UNIQUE + def is_not_json(opts=OPTS) + _is_json(IS_NOT_JSON, opts) + end + # Returns a set of keys AS text in the json object. # # json_op.keys # json_object_keys(json) @@ -286,6 +331,13 @@ def typeof private + # Internals of IS [NOT] JSON support + def _is_json(lit_array, opts) + raise Error, "invalid is_json :type option: #{opts[:type].inspect}" unless type = IS_JSON_MAP[opts[:type]] + unique = opts[:unique] ? WITH_UNIQUE : EMPTY_STRING + Sequel::SQL::BooleanExpression.new(:NOOP, Sequel::SQL::PlaceholderLiteralString.new(lit_array, [self, type, unique])) + end + # Return a placeholder literal with the given str and args, wrapped # in an JSONOp or JSONBOp, used by operators that return json or jsonb. def json_op(str, args) diff --git a/spec/adapters/postgres_spec.rb b/spec/adapters/postgres_spec.rb index 9692d3aedc..fb72a3aa5c 100644 --- a/spec/adapters/postgres_spec.rb +++ b/spec/adapters/postgres_spec.rb @@ -4177,6 +4177,45 @@ def left_item_id Sequel.pg_jsonb_op(Sequel[:i])['d'] => Sequel.pg_jsonb('e'=>4)) @db[:items].all.must_equal [{:i=>{'a'=>{'b'=>2, 'c'=>3}, 'd'=>{'e'=>4}}}] end if DB.server_version >= 140000 && json_type == :jsonb + + it "15 #{json_type} operations/functions with pg_json_ops" do + meth = Sequel.method(:"pg_#{json_type}_op") + @db.get(meth.call('{}').is_json).must_equal true + @db.get(meth.call('null').is_json).must_equal true + @db.get(meth.call('1').is_json).must_equal true + @db.get(meth.call('"a"').is_json).must_equal true + @db.get(meth.call('[]').is_json).must_equal true + @db.get(meth.call('').is_json).must_equal false + + @db.get(meth.call('1').is_json(:type=>:scalar)).must_equal true + @db.get(meth.call('null').is_json(:type=>:value)).must_equal true + @db.get(meth.call('{}').is_json(:type=>:object)).must_equal true + @db.get(meth.call('{}').is_json(:type=>:array)).must_equal false + @db.get(meth.call('{"a": 1, "a": 2}').is_json(:type=>:object, :unique=>true)).must_equal false + @db.get(meth.call('{"a": 1, "b": 2}').is_json(:type=>:object, :unique=>true)).must_equal true + @db.get(meth.call('[]').is_json(:type=>:object, :unique=>true)).must_equal false + @db.get(meth.call('{"a": 1, "a": 2}').is_json(:unique=>true)).must_equal false + @db.get(meth.call('{"a": 1, "b": 2}').is_json(:unique=>true)).must_equal true + @db.get(meth.call('[]').is_json(:unique=>true)).must_equal true + + @db.get(meth.call('{}').is_not_json).must_equal false + @db.get(meth.call('null').is_not_json).must_equal false + @db.get(meth.call('1').is_not_json).must_equal false + @db.get(meth.call('"a"').is_not_json).must_equal false + @db.get(meth.call('[]').is_not_json).must_equal false + @db.get(meth.call('').is_not_json).must_equal true + + @db.get(meth.call('1').is_not_json(:type=>:scalar)).must_equal false + @db.get(meth.call('null').is_not_json(:type=>:value)).must_equal false + @db.get(meth.call('{}').is_not_json(:type=>:object)).must_equal false + @db.get(meth.call('{}').is_not_json(:type=>:array)).must_equal true + @db.get(meth.call('{"a": 1, "a": 2}').is_not_json(:type=>:object, :unique=>true)).must_equal true + @db.get(meth.call('{"a": 1, "b": 2}').is_not_json(:type=>:object, :unique=>true)).must_equal false + @db.get(meth.call('[]').is_not_json(:type=>:object, :unique=>true)).must_equal true + @db.get(meth.call('{"a": 1, "a": 2}').is_not_json(:unique=>true)).must_equal true + @db.get(meth.call('{"a": 1, "b": 2}').is_not_json(:unique=>true)).must_equal false + @db.get(meth.call('[]').is_not_json(:unique=>true)).must_equal false + end if DB.server_version >= 160000 end end if DB.server_version >= 90200 diff --git a/spec/extensions/pg_json_ops_spec.rb b/spec/extensions/pg_json_ops_spec.rb index 4c9a318a73..05bdf316de 100644 --- a/spec/extensions/pg_json_ops_spec.rb +++ b/spec/extensions/pg_json_ops_spec.rb @@ -111,6 +111,79 @@ def @db.server_version(*); 130000; end @l[@jb.extract_text('a') + 'a'].must_equal "(jsonb_extract_path_text(j, 'a') || 'a')" end + it "should have #is_json work without arguments" do + @l[@j.is_json].must_equal "(j IS JSON)" + @l[@jb.is_json].must_equal "(j IS JSON)" + end + + it "should have #is_json respect :type option" do + [@j, @jb].each do |j| + @l[j.is_json(:type=>:value)].must_equal "(j IS JSON VALUE)" + @l[j.is_json(:type=>:scalar)].must_equal "(j IS JSON SCALAR)" + @l[j.is_json(:type=>:object)].must_equal "(j IS JSON OBJECT)" + @l[j.is_json(:type=>:array)].must_equal "(j IS JSON ARRAY)" + end + end + + it "should have #is_json respect :unique option" do + @l[@j.is_json(:unique=>true)].must_equal "(j IS JSON WITH UNIQUE)" + @l[@jb.is_json(:unique=>true)].must_equal "(j IS JSON WITH UNIQUE)" + end + + it "should have #is_json respect :type and :unique options" do + [@j, @jb].each do |j| + @l[j.is_json(:type=>:value, :unique=>true)].must_equal "(j IS JSON VALUE WITH UNIQUE)" + @l[j.is_json(:type=>:scalar, :unique=>true)].must_equal "(j IS JSON SCALAR WITH UNIQUE)" + @l[j.is_json(:type=>:object, :unique=>true)].must_equal "(j IS JSON OBJECT WITH UNIQUE)" + @l[j.is_json(:type=>:array, :unique=>true)].must_equal "(j IS JSON ARRAY WITH UNIQUE)" + end + end + + it "should have #is_json return an SQL::BooleanExpression" do + @l[~@j.is_json].must_equal "NOT (j IS JSON)" + @l[~@jb.is_json].must_equal "NOT (j IS JSON)" + end + + it "should have #is_not_json work without arguments" do + @l[@j.is_not_json].must_equal "(j IS NOT JSON)" + @l[@jb.is_not_json].must_equal "(j IS NOT JSON)" + end + + it "should have #is_not_json respect :type option" do + [@j, @jb].each do |j| + @l[j.is_not_json(:type=>:value)].must_equal "(j IS NOT JSON VALUE)" + @l[j.is_not_json(:type=>:scalar)].must_equal "(j IS NOT JSON SCALAR)" + @l[j.is_not_json(:type=>:object)].must_equal "(j IS NOT JSON OBJECT)" + @l[j.is_not_json(:type=>:array)].must_equal "(j IS NOT JSON ARRAY)" + end + end + + it "should have #is_not_json respect :unique option" do + @l[@j.is_not_json(:unique=>true)].must_equal "(j IS NOT JSON WITH UNIQUE)" + @l[@jb.is_not_json(:unique=>true)].must_equal "(j IS NOT JSON WITH UNIQUE)" + end + + it "should have #is_not_json respect :type and :unique options" do + [@j, @jb].each do |j| + @l[j.is_not_json(:type=>:value, :unique=>true)].must_equal "(j IS NOT JSON VALUE WITH UNIQUE)" + @l[j.is_not_json(:type=>:scalar, :unique=>true)].must_equal "(j IS NOT JSON SCALAR WITH UNIQUE)" + @l[j.is_not_json(:type=>:object, :unique=>true)].must_equal "(j IS NOT JSON OBJECT WITH UNIQUE)" + @l[j.is_not_json(:type=>:array, :unique=>true)].must_equal "(j IS NOT JSON ARRAY WITH UNIQUE)" + end + end + + it "should have #is_not_json return an SQL::BooleanExpression" do + @l[~@j.is_not_json].must_equal "NOT (j IS NOT JSON)" + @l[~@jb.is_not_json].must_equal "NOT (j IS NOT JSON)" + end + + it "should have #is_json and #is_not_json raise for invalid :type" do + proc{@j.is_json(:type=>:foo)}.must_raise Sequel::Error + proc{@jb.is_json(:type=>:foo)}.must_raise Sequel::Error + proc{@j.is_not_json(:type=>:foo)}.must_raise Sequel::Error + proc{@jb.is_not_json(:type=>:foo)}.must_raise Sequel::Error + end + it "should have #keys use the json_object_keys function" do @l[@j.keys].must_equal "json_object_keys(j)" @l[@jb.keys].must_equal "jsonb_object_keys(j)"