Skip to content

Commit fe1bc3f

Browse files
committed
Support json_table on PostgreSQL 17+ in the pg_json_ops extension
1 parent e38045c commit fe1bc3f

File tree

4 files changed

+616
-8
lines changed

4 files changed

+616
-8
lines changed

CHANGELOG

+2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
=== master
22

3+
* Support json_table on PostgreSQL 17+ in the pg_json_ops extension (jeremyevans)
4+
35
* Make Dataset#get and #first without argument not create intermediate datasets if receiver uses raw SQL (jeremyevans)
46

57
* Add dataset_run extension, for building SQL using datasets, and running with Database#run (jeremyevans)

lib/sequel/extensions/pg_json_ops.rb

+314-8
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,12 @@
8888
# j.path_query_array('$.foo') # jsonb_path_query_array(jsonb_column, '$.foo')
8989
# j.path_query_first('$.foo') # jsonb_path_query_first(jsonb_column, '$.foo')
9090
#
91+
# For the PostgreSQL 12+ SQL/JSON path functions, one argument is required (+path+) and
92+
# two more arguments are optional (+vars+ and +silent+). +path+ specifies the JSON path.
93+
# +vars+ specifies a hash or a string in JSON format of named variables to be
94+
# substituted in +path+. +silent+ specifies whether errors are suppressed. By default,
95+
# errors are not suppressed.
96+
#
9197
# On PostgreSQL 13+ timezone-aware SQL/JSON path functions and operators are supported:
9298
#
9399
# j.path_exists_tz!('$.foo') # jsonb_path_exists_tz(jsonb_column, '$.foo')
@@ -96,12 +102,6 @@
96102
# j.path_query_array_tz('$.foo') # jsonb_path_query_array_tz(jsonb_column, '$.foo')
97103
# j.path_query_first_tz('$.foo') # jsonb_path_query_first_tz(jsonb_column, '$.foo')
98104
#
99-
# For the PostgreSQL 12+ SQL/JSON path functions, one argument is required (+path+) and
100-
# two more arguments are optional (+vars+ and +silent+). +path+ specifies the JSON path.
101-
# +vars+ specifies a hash or a string in JSON format of named variables to be
102-
# substituted in +path+. +silent+ specifies whether errors are suppressed. By default,
103-
# errors are not suppressed.
104-
#
105105
# On PostgreSQL 14+, The JSONB <tt>[]</tt> method will use subscripts instead of being
106106
# the same as +get+, if the value being wrapped is an identifer:
107107
#
@@ -129,8 +129,8 @@
129129
# j.is_json(type: :object) # j IS JSON OBJECT
130130
# j.is_json(type: :object, unique: true) # j IS JSON OBJECT WITH UNIQUE
131131
# j.is_not_json # j IS NOT JSON
132-
# j.is_not_json(type: :array) # j IS NOT JSON ARRAY
133-
# j.is_not_json(unique: true) # j IS NOT JSON WITH UNIQUE
132+
# j.is_not_json(type: :array) # j IS NOT JSON ARRAY
133+
# j.is_not_json(unique: true) # j IS NOT JSON WITH UNIQUE
134134
#
135135
# On PostgreSQL 17+, the additional JSON functions are supported (see method documentation
136136
# for additional options):
@@ -143,6 +143,29 @@
143143
# j.value('$.foo', returning: Time) # json_value(jsonb_column, '$.foo' RETURNING timestamp)
144144
# j.query('$.foo', wrapper: true) # json_query(jsonb_column, '$.foo' WITH WRAPPER)
145145
#
146+
# j.table('$.foo') do
147+
# String :bar
148+
# Integer :baz
149+
# end
150+
# # json_table('$.foo' COLUMNS(bar text, baz integer))
151+
#
152+
# j.table('$.foo', passing: {a: 1}) do
153+
# ordinality :id
154+
# String :bar, format: :json, on_error: :empty_object
155+
# nested '$.baz' do
156+
# Integer :q, path: '$.quux', on_empty: :error
157+
# end
158+
# exists :x, on_error: false
159+
# end
160+
# # json_table("j", '$.foo' PASSING 1 AS a COLUMNS(
161+
# # "id" FOR ORDINALITY,
162+
# # "bar" text FORMAT JSON EMPTY OBJECT ON ERROR,
163+
# # NESTED '$.baz' COLUMNS(
164+
# # "q" integer PATH '$.quux' ERROR ON EMPTY
165+
# # ),
166+
# # "d" date EXISTS FALSE ON ERROR
167+
# # ))
168+
#
146169
# If you are also using the pg_json extension, you should load it before
147170
# loading this extension. Doing so will allow you to use the #op method on
148171
# JSONHash, JSONHarray, JSONBHash, and JSONBArray, allowing you to perform json/jsonb operations
@@ -364,6 +387,72 @@ def strip_nulls
364387
self.class.new(function(:strip_nulls))
365388
end
366389

390+
# Returns json_table SQL function expression, querying JSON data and returning
391+
# the results as a relational view, which can be accessed similarly to a regular
392+
# SQL table. This accepts a block that is handled in a similar manner to
393+
# Database#create_table, though it operates differently.
394+
#
395+
# Table level options:
396+
#
397+
# :on_error :: How to handle errors when evaluating the JSON path expression.
398+
# :empty_array :: Return an empty array/result set
399+
# :error :: raise a DatabaseError
400+
# :passing :: Variables to pass to the JSON path expression. Keys are variable
401+
# names, values are the values of the variable.
402+
#
403+
# Inside the block, the following methods can be used:
404+
#
405+
# ordinality(name) :: Include a FOR ORDINALITY column, which operates similar to an
406+
# autoincrementing primary key.
407+
# column(name, type, opts={}) :: Return a normal column that uses the given type.
408+
# exists(name, type, opts={}) :: Return a boolean column for whether the JSON path yields any values.
409+
# nested(path, &block) :: Extract nested data from the result set at the given path.
410+
# This block is treated the same as a json_table block, and
411+
# arbitrary levels of nesting are supported.
412+
#
413+
# The +column+ method supports the following options:
414+
#
415+
# :path :: JSON path to the object (the default is <tt>$.NAME</tt>, where +NAME+ is the
416+
# name of the column).
417+
# :format :: Set to +:json+ to use FORMAT JSON, when you expect the value to be a
418+
# valid JSON object.
419+
# :on_empty, :on_error :: How to handle case where JSON path evaluation is empty or
420+
# results in an error. Values supported are:
421+
# :empty_array :: Return empty array (requires <tt>format: :json</tt>)
422+
# :empty_object :: Return empty object (requires <tt>format: :json</tt>)
423+
# :error :: Raise a DatabaseError
424+
# :null :: Return nil (NULL)
425+
# :wrapper :: How to wrap returned values:
426+
# true, :unconditional :: Always wrap returning values in an array
427+
# :conditional :: Only wrap multiple return values in an array
428+
# :keep_quotes :: Wrap scalar strings in quotes
429+
# :omit_quotes :: Do not wrap scalar strings in quotes
430+
#
431+
# The +exists+ method supports the following options:
432+
#
433+
# :path :: JSON path to the object (same as +column+ option)
434+
# :on_error :: How to handle case where JSON path evaluation results in an error.
435+
# Values supported are:
436+
# :error :: Raise a DatabaseError
437+
# true :: Return true
438+
# false :: Return false
439+
# :null :: Return nil (NULL)
440+
#
441+
# Inside the block, methods for Ruby class names are also supported, allowing you
442+
# to use syntax such as:
443+
#
444+
# json_op.table('$.a') do
445+
# String :b
446+
# Integer :c, path: '$.d'
447+
# end
448+
#
449+
# One difference between this method and Database#create_table is that method_missing
450+
# is not supported inside the block. Use the +column+ method for PostgreSQL types
451+
# that are not mapped to Ruby classes.
452+
def table(path, opts=OPTS, &block)
453+
JSONTableOp.new(self, path, opts, &block)
454+
end
455+
367456
# Builds arbitrary record from json object. You need to define the
368457
# structure of the record using #as on the resulting object:
369458
#
@@ -1032,6 +1121,223 @@ def on_sql_value(value)
10321121
end
10331122
end
10341123

1124+
# Object representing json_table calls
1125+
class JSONTableOp < SQL::Expression
1126+
TABLE_ON_ERROR_SQL = {
1127+
:error => ' ERROR ON ERROR',
1128+
:empty_array => ' EMPTY ARRAY ON ERROR',
1129+
}.freeze
1130+
private_constant :TABLE_ON_ERROR_SQL
1131+
1132+
COLUMN_ON_SQL = {
1133+
:null => ' NULL',
1134+
:error => ' ERROR',
1135+
:empty_array => ' EMPTY ARRAY',
1136+
:empty_object => ' EMPTY OBJECT',
1137+
}.freeze
1138+
private_constant :COLUMN_ON_SQL
1139+
1140+
EXISTS_ON_ERROR_SQL = {
1141+
:error => ' ERROR',
1142+
true => ' TRUE',
1143+
false => ' FALSE',
1144+
:null => ' UNKNOWN',
1145+
}.freeze
1146+
private_constant :EXISTS_ON_ERROR_SQL
1147+
1148+
WRAPPER = {
1149+
:conditional => ' WITH CONDITIONAL WRAPPER',
1150+
:unconditional => ' WITH WRAPPER',
1151+
:omit_quotes => ' OMIT QUOTES',
1152+
:keep_quotes => ' KEEP QUOTES',
1153+
}
1154+
WRAPPER[true] = WRAPPER[:unconditional]
1155+
WRAPPER.freeze
1156+
private_constant :WRAPPER
1157+
1158+
# Class used to evaluate json_table blocks and nested blocks
1159+
class ColumnDSL
1160+
# Return array of column information recorded for the instance
1161+
attr_reader :columns
1162+
1163+
def self.columns(&block)
1164+
new(&block).columns.freeze
1165+
end
1166+
1167+
def initialize(&block)
1168+
@columns = []
1169+
instance_exec(&block)
1170+
end
1171+
1172+
# Include a FOR ORDINALITY column
1173+
def ordinality(name)
1174+
@columns << [:ordinality, name].freeze
1175+
end
1176+
1177+
# Include a regular column with the given type
1178+
def column(name, type, opts=OPTS)
1179+
@columns << [:column, name, type, opts].freeze
1180+
end
1181+
1182+
# Include an EXISTS column with the given type
1183+
def exists(name, type, opts=OPTS)
1184+
@columns << [:exists, name, type, opts].freeze
1185+
end
1186+
1187+
# Include a nested set of columns at the given path.
1188+
def nested(path, &block)
1189+
@columns << [:nested, path, ColumnDSL.columns(&block)].freeze
1190+
end
1191+
1192+
# Include a bigint column
1193+
def Bignum(name, opts=OPTS)
1194+
@columns << [:column, name, :Bignum, opts].freeze
1195+
end
1196+
1197+
# Define methods for handling other generic types
1198+
%w'String Integer Float Numeric BigDecimal Date DateTime Time File TrueClass FalseClass'.each do |meth|
1199+
klass = Object.const_get(meth)
1200+
define_method(meth) do |name, opts=OPTS|
1201+
@columns << [:column, name, klass, opts].freeze
1202+
end
1203+
end
1204+
end
1205+
private_constant :ColumnDSL
1206+
1207+
# See JSONBaseOp#table for documentation on the options.
1208+
def initialize(expr, path, opts=OPTS, &block)
1209+
@expr = expr
1210+
@path = path
1211+
@passing = opts[:passing]
1212+
@on_error = opts[:on_error]
1213+
@columns = opts[:_columns] || ColumnDSL.columns(&block)
1214+
freeze
1215+
end
1216+
1217+
# Append the json_table function call expression to the SQL
1218+
def to_s_append(ds, sql)
1219+
sql << 'json_table('
1220+
ds.literal_append(sql, @expr)
1221+
sql << ', '
1222+
default_literal_append(ds, sql, @path)
1223+
1224+
if (passing = @passing) && !passing.empty?
1225+
sql << ' PASSING '
1226+
comma = false
1227+
passing.each do |k, v|
1228+
if comma
1229+
sql << ', '
1230+
else
1231+
comma = true
1232+
end
1233+
ds.literal_append(sql, v)
1234+
sql << " AS " << k.to_s
1235+
end
1236+
end
1237+
1238+
to_s_append_columns(ds, sql, @columns)
1239+
sql << TABLE_ON_ERROR_SQL.fetch(@on_error) if @on_error
1240+
sql << ')'
1241+
end
1242+
1243+
# Support transforming of json_table expression
1244+
def sequel_ast_transform(transformer)
1245+
opts = {:on_error=>@on_error, :_columns=>@columns}
1246+
1247+
if @passing
1248+
passing = opts[:passing] = {}
1249+
@passing.each do |k, v|
1250+
passing[k] = transformer.call(v)
1251+
end
1252+
end
1253+
1254+
self.class.new(transformer.call(@expr), @path, opts)
1255+
end
1256+
1257+
private
1258+
1259+
# Append the set of column information to the SQL. Separated to handle
1260+
# nested sets of columns.
1261+
def to_s_append_columns(ds, sql, columns)
1262+
sql << ' COLUMNS('
1263+
comma = nil
1264+
columns.each do |column|
1265+
if comma
1266+
sql << comma
1267+
else
1268+
comma = ', '
1269+
end
1270+
to_s_append_column(ds, sql, column)
1271+
end
1272+
sql << ')'
1273+
end
1274+
1275+
# Append the column information to the SQL. Handles the various
1276+
# types of json_table columns.
1277+
def to_s_append_column(ds, sql, column)
1278+
case column[0]
1279+
when :column
1280+
_, name, type, opts = column
1281+
ds.literal_append(sql, name)
1282+
sql << ' ' << ds.db.send(:type_literal, opts.merge(:type=>type)).to_s
1283+
sql << ' FORMAT JSON' if opts[:format] == :json
1284+
to_s_append_path(ds, sql, opts[:path])
1285+
sql << WRAPPER.fetch(opts[:wrapper]) if opts[:wrapper]
1286+
to_s_append_on_value(ds, sql, opts[:on_empty], " ON EMPTY")
1287+
to_s_append_on_value(ds, sql, opts[:on_error], " ON ERROR")
1288+
when :ordinality
1289+
ds.literal_append(sql, column[1])
1290+
sql << ' FOR ORDINALITY'
1291+
when :exists
1292+
_, name, type, opts = column
1293+
ds.literal_append(sql, name)
1294+
sql << ' ' << ds.db.send(:type_literal, opts.merge(:type=>type)).to_s
1295+
sql << ' EXISTS'
1296+
to_s_append_path(ds, sql, opts[:path])
1297+
unless (on_error = opts[:on_error]).nil?
1298+
sql << EXISTS_ON_ERROR_SQL.fetch(on_error) << " ON ERROR"
1299+
end
1300+
else # when :nested
1301+
_, path, columns = column
1302+
sql << 'NESTED '
1303+
default_literal_append(ds, sql, path)
1304+
to_s_append_columns(ds, sql, columns)
1305+
end
1306+
end
1307+
1308+
# Handle DEFAULT values in ON EMPTY/ON ERROR fragments
1309+
def to_s_append_on_value(ds, sql, value, cond)
1310+
if value
1311+
if v = COLUMN_ON_SQL[value]
1312+
sql << v
1313+
else
1314+
sql << ' DEFAULT '
1315+
default_literal_append(ds, sql, value)
1316+
end
1317+
sql << cond
1318+
end
1319+
end
1320+
1321+
# Append path caluse to the SQL
1322+
def to_s_append_path(ds, sql, path)
1323+
if path
1324+
sql << ' PATH '
1325+
default_literal_append(ds, sql, path)
1326+
end
1327+
end
1328+
1329+
# Do not auto paramterize default value or path value, as PostgreSQL doesn't allow it.
1330+
def default_literal_append(ds, sql, v)
1331+
if sql.respond_to?(:skip_auto_param)
1332+
sql.skip_auto_param do
1333+
ds.literal_append(sql, v)
1334+
end
1335+
else
1336+
ds.literal_append(sql, v)
1337+
end
1338+
end
1339+
end
1340+
10351341
module JSONOpMethods
10361342
# Wrap the receiver in an JSONOp so you can easily use the PostgreSQL
10371343
# json functions and operators with it.

0 commit comments

Comments
 (0)