gitlab-pg_query 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +321 -0
- data/LICENSE +28 -0
- data/README.md +180 -0
- data/Rakefile +23 -0
- data/ext/pg_query/extconf.rb +46 -0
- data/ext/pg_query/pg_query_ruby.c +129 -0
- data/ext/pg_query/pg_query_ruby.h +10 -0
- data/ext/pg_query/pg_query_ruby.sym +1 -0
- data/lib/pg_query.rb +16 -0
- data/lib/pg_query/deep_dup.rb +16 -0
- data/lib/pg_query/deparse.rb +1588 -0
- data/lib/pg_query/deparse/alter_table.rb +42 -0
- data/lib/pg_query/deparse/interval.rb +105 -0
- data/lib/pg_query/deparse/keywords.rb +159 -0
- data/lib/pg_query/deparse/rename.rb +41 -0
- data/lib/pg_query/filter_columns.rb +107 -0
- data/lib/pg_query/fingerprint.rb +115 -0
- data/lib/pg_query/legacy_parsetree.rb +109 -0
- data/lib/pg_query/node_types.rb +296 -0
- data/lib/pg_query/param_refs.rb +45 -0
- data/lib/pg_query/parse.rb +247 -0
- data/lib/pg_query/parse_error.rb +9 -0
- data/lib/pg_query/treewalker.rb +53 -0
- data/lib/pg_query/truncate.rb +60 -0
- data/lib/pg_query/version.rb +3 -0
- metadata +130 -0
@@ -0,0 +1,42 @@
|
|
1
|
+
class PgQuery
|
2
|
+
module Deparse
|
3
|
+
module AlterTable
|
4
|
+
# Returns a list of strings of length one or length two. The first string
|
5
|
+
# will be placed before the column name and the second, if present, will be
|
6
|
+
# placed after.
|
7
|
+
#
|
8
|
+
# If node['subtype'] is the integer 4 (AT_DropNotNull),
|
9
|
+
# then return value of this method will be:
|
10
|
+
#
|
11
|
+
# ['ALTER COLUMN', 'DROP NOT NULL']
|
12
|
+
#
|
13
|
+
# Which will be composed into the SQL as:
|
14
|
+
#
|
15
|
+
# ALTER COLUMN {column_name} DROP NOT NULL
|
16
|
+
#
|
17
|
+
def self.commands(node)
|
18
|
+
action = ALTER_TABLE_TYPES_MAPPING[node['subtype']] || raise(format("Can't deparse: %s", node.inspect))
|
19
|
+
PgQuery::Deparse.instance_exec(node, &action)
|
20
|
+
end
|
21
|
+
|
22
|
+
ALTER_TABLE_TYPES_MAPPING = {
|
23
|
+
AT_AddColumn => ->(_node) { ['ADD COLUMN'] },
|
24
|
+
AT_ColumnDefault => ->(node) { ['ALTER COLUMN', node['def'] ? 'SET DEFAULT' : 'DROP DEFAULT'] },
|
25
|
+
AT_DropNotNull => ->(_node) { ['ALTER COLUMN', 'DROP NOT NULL'] },
|
26
|
+
AT_SetNotNull => ->(_node) { ['ALTER COLUMN', 'SET NOT NULL'] },
|
27
|
+
AT_SetStatistics => ->(_node) { ['ALTER COLUMN', 'SET STATISTICS'] },
|
28
|
+
AT_SetOptions => ->(_node) { ['ALTER COLUMN', 'SET'] },
|
29
|
+
AT_ResetOptions => ->(_node) { ['ALTER COLUMN', 'RESET'] },
|
30
|
+
AT_SetStorage => ->(_node) { ['ALTER COLUMN', 'SET STORAGE'] },
|
31
|
+
AT_DropColumn => ->(_node) { ['DROP'] },
|
32
|
+
AT_AddIndex => ->(_node) { ['ADD INDEX'] },
|
33
|
+
AT_AddConstraint => ->(_node) { ['ADD'] },
|
34
|
+
AT_AlterConstraint => ->(_node) { ['ALTER CONSTRAINT'] },
|
35
|
+
AT_ValidateConstraint => ->(_node) { ['VALIDATE CONSTRAINT'] },
|
36
|
+
AT_DropConstraint => ->(_node) { ['DROP CONSTRAINT'] },
|
37
|
+
AT_AlterColumnType => ->(_node) { ['ALTER COLUMN', 'TYPE'] },
|
38
|
+
AT_AlterColumnGenericOptions => ->(_node) { ['ALTER COLUMN', 'OPTIONS'] }
|
39
|
+
}.freeze
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
@@ -0,0 +1,105 @@
|
|
1
|
+
class PgQuery
|
2
|
+
module Deparse
|
3
|
+
module Interval
|
4
|
+
# A type called 'interval hour to minute' is stored in a compressed way by
|
5
|
+
# simplifying 'hour to minute' to a simple integer. This integer is computed
|
6
|
+
# by looking up the arbitrary number (always a power of two) for 'hour' and
|
7
|
+
# the one for 'minute' and XORing them together.
|
8
|
+
#
|
9
|
+
# For example, when parsing "interval hour to minute":
|
10
|
+
#
|
11
|
+
# HOUR_MASK = 10
|
12
|
+
# MINUTE_MASK = 11
|
13
|
+
# mask = (1 << 10) | (1 << 11)
|
14
|
+
# mask = 1024 | 2048
|
15
|
+
# mask = (010000000000
|
16
|
+
# xor
|
17
|
+
# 100000000000)
|
18
|
+
# mask = 110000000000
|
19
|
+
# mask = 3072
|
20
|
+
#
|
21
|
+
# Postgres will store this type as 'interval,3072'
|
22
|
+
# We deparse it by simply reversing that process.
|
23
|
+
#
|
24
|
+
def self.from_int(int)
|
25
|
+
SQL_BY_MASK[int]
|
26
|
+
end
|
27
|
+
|
28
|
+
# From src/include/utils/datetime.h
|
29
|
+
# The number is the power of 2 used for the mask.
|
30
|
+
MASKS = {
|
31
|
+
0 => 'RESERV',
|
32
|
+
1 => 'MONTH',
|
33
|
+
2 => 'YEAR',
|
34
|
+
3 => 'DAY',
|
35
|
+
4 => 'JULIAN',
|
36
|
+
5 => 'TZ',
|
37
|
+
6 => 'DTZ',
|
38
|
+
7 => 'DYNTZ',
|
39
|
+
8 => 'IGNORE_DTF',
|
40
|
+
9 => 'AMPM',
|
41
|
+
10 => 'HOUR',
|
42
|
+
11 => 'MINUTE',
|
43
|
+
12 => 'SECOND',
|
44
|
+
13 => 'MILLISECOND',
|
45
|
+
14 => 'MICROSECOND',
|
46
|
+
15 => 'DOY',
|
47
|
+
16 => 'DOW',
|
48
|
+
17 => 'UNITS',
|
49
|
+
18 => 'ADBC',
|
50
|
+
19 => 'AGO',
|
51
|
+
20 => 'ABS_BEFORE',
|
52
|
+
21 => 'ABS_AFTER',
|
53
|
+
22 => 'ISODATE',
|
54
|
+
23 => 'ISOTIME',
|
55
|
+
24 => 'WEEK',
|
56
|
+
25 => 'DECADE',
|
57
|
+
26 => 'CENTURY',
|
58
|
+
27 => 'MILLENNIUM',
|
59
|
+
28 => 'DTZMOD'
|
60
|
+
}.freeze
|
61
|
+
KEYS = MASKS.invert
|
62
|
+
|
63
|
+
# Postgres stores the interval 'day second' as 'day hour minute second' so
|
64
|
+
# we need to reconstruct the sql with only the largest and smallest time
|
65
|
+
# values. Since the rules for this are hardcoded in the grammar (and the
|
66
|
+
# above list is not sorted in any sensible way) it makes sense to hardcode
|
67
|
+
# the patterns here, too.
|
68
|
+
#
|
69
|
+
# This hash takes the form:
|
70
|
+
#
|
71
|
+
# { (1 << 1) | (1 << 2) => 'year to month' }
|
72
|
+
#
|
73
|
+
# Which is:
|
74
|
+
#
|
75
|
+
# { 6 => 'year to month' }
|
76
|
+
#
|
77
|
+
SQL_BY_MASK = {
|
78
|
+
(1 << KEYS['YEAR']) => %w[year],
|
79
|
+
(1 << KEYS['MONTH']) => %w[month],
|
80
|
+
(1 << KEYS['DAY']) => %w[day],
|
81
|
+
(1 << KEYS['HOUR']) => %w[hour],
|
82
|
+
(1 << KEYS['MINUTE']) => %w[minute],
|
83
|
+
(1 << KEYS['SECOND']) => %w[second],
|
84
|
+
(1 << KEYS['YEAR'] |
|
85
|
+
1 << KEYS['MONTH']) => %w[year month],
|
86
|
+
(1 << KEYS['DAY'] |
|
87
|
+
1 << KEYS['HOUR']) => %w[day hour],
|
88
|
+
(1 << KEYS['DAY'] |
|
89
|
+
1 << KEYS['HOUR'] |
|
90
|
+
1 << KEYS['MINUTE']) => %w[day minute],
|
91
|
+
(1 << KEYS['DAY'] |
|
92
|
+
1 << KEYS['HOUR'] |
|
93
|
+
1 << KEYS['MINUTE'] |
|
94
|
+
1 << KEYS['SECOND']) => %w[day second],
|
95
|
+
(1 << KEYS['HOUR'] |
|
96
|
+
1 << KEYS['MINUTE']) => %w[hour minute],
|
97
|
+
(1 << KEYS['HOUR'] |
|
98
|
+
1 << KEYS['MINUTE'] |
|
99
|
+
1 << KEYS['SECOND']) => %w[hour second],
|
100
|
+
(1 << KEYS['MINUTE'] |
|
101
|
+
1 << KEYS['SECOND']) => %w[minute second]
|
102
|
+
}.freeze
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
@@ -0,0 +1,159 @@
|
|
1
|
+
class PgQuery
|
2
|
+
module Deparse # rubocop:disable Metrics/ModuleLength
|
3
|
+
# Keywords that need to be escaped during deparsing. This matches they keywords
|
4
|
+
# excaped by quote_identifier in Postgres ruleutils.c. You can refresh this
|
5
|
+
# list using the kwlist.h file (make sure to ignore UNRESERVED_KEYWORD entries)
|
6
|
+
KEYWORDS = %w[
|
7
|
+
ALL
|
8
|
+
ANALYSE
|
9
|
+
ANALYZE
|
10
|
+
AND
|
11
|
+
ANY
|
12
|
+
ARRAY
|
13
|
+
AS
|
14
|
+
ASC
|
15
|
+
ASYMMETRIC
|
16
|
+
AUTHORIZATION
|
17
|
+
BETWEEN
|
18
|
+
BIGINT
|
19
|
+
BINARY
|
20
|
+
BIT
|
21
|
+
BOOLEAN
|
22
|
+
BOTH
|
23
|
+
CASE
|
24
|
+
CAST
|
25
|
+
CHAR
|
26
|
+
CHARACTER
|
27
|
+
CHECK
|
28
|
+
COALESCE
|
29
|
+
COLLATE
|
30
|
+
COLLATION
|
31
|
+
COLUMN
|
32
|
+
CONCURRENTLY
|
33
|
+
CONSTRAINT
|
34
|
+
CREATE
|
35
|
+
CROSS
|
36
|
+
CURRENT_CATALOG
|
37
|
+
CURRENT_DATE
|
38
|
+
CURRENT_ROLE
|
39
|
+
CURRENT_SCHEMA
|
40
|
+
CURRENT_TIME
|
41
|
+
CURRENT_TIMESTAMP
|
42
|
+
CURRENT_USER
|
43
|
+
DEC
|
44
|
+
DECIMAL
|
45
|
+
DEFAULT
|
46
|
+
DEFERRABLE
|
47
|
+
DESC
|
48
|
+
DISTINCT
|
49
|
+
DO
|
50
|
+
ELSE
|
51
|
+
END
|
52
|
+
EXCEPT
|
53
|
+
EXISTS
|
54
|
+
EXTRACT
|
55
|
+
FALSE
|
56
|
+
FETCH
|
57
|
+
FLOAT
|
58
|
+
FOR
|
59
|
+
FOREIGN
|
60
|
+
FREEZE
|
61
|
+
FROM
|
62
|
+
FULL
|
63
|
+
GRANT
|
64
|
+
GREATEST
|
65
|
+
GROUP
|
66
|
+
GROUPING
|
67
|
+
HAVING
|
68
|
+
ILIKE
|
69
|
+
IN
|
70
|
+
INITIALLY
|
71
|
+
INNER
|
72
|
+
INOUT
|
73
|
+
INT
|
74
|
+
INTEGER
|
75
|
+
INTERSECT
|
76
|
+
INTERVAL
|
77
|
+
INTO
|
78
|
+
IS
|
79
|
+
ISNULL
|
80
|
+
JOIN
|
81
|
+
LATERAL
|
82
|
+
LEADING
|
83
|
+
LEAST
|
84
|
+
LEFT
|
85
|
+
LIKE
|
86
|
+
LIMIT
|
87
|
+
LOCALTIME
|
88
|
+
LOCALTIMESTAMP
|
89
|
+
NATIONAL
|
90
|
+
NATURAL
|
91
|
+
NCHAR
|
92
|
+
NONE
|
93
|
+
NOT
|
94
|
+
NOTNULL
|
95
|
+
NULL
|
96
|
+
NULLIF
|
97
|
+
NUMERIC
|
98
|
+
OFFSET
|
99
|
+
ON
|
100
|
+
ONLY
|
101
|
+
OR
|
102
|
+
ORDER
|
103
|
+
OUT
|
104
|
+
OUTER
|
105
|
+
OVERLAPS
|
106
|
+
OVERLAY
|
107
|
+
PLACING
|
108
|
+
POSITION
|
109
|
+
PRECISION
|
110
|
+
PRIMARY
|
111
|
+
REAL
|
112
|
+
REFERENCES
|
113
|
+
RETURNING
|
114
|
+
RIGHT
|
115
|
+
ROW
|
116
|
+
SELECT
|
117
|
+
SESSION_USER
|
118
|
+
SETOF
|
119
|
+
SIMILAR
|
120
|
+
SMALLINT
|
121
|
+
SOME
|
122
|
+
SUBSTRING
|
123
|
+
SYMMETRIC
|
124
|
+
TABLE
|
125
|
+
TABLESAMPLE
|
126
|
+
THEN
|
127
|
+
TIME
|
128
|
+
TIMESTAMP
|
129
|
+
TO
|
130
|
+
TRAILING
|
131
|
+
TREAT
|
132
|
+
TRIM
|
133
|
+
TRUE
|
134
|
+
UNION
|
135
|
+
UNIQUE
|
136
|
+
USER
|
137
|
+
USING
|
138
|
+
VALUES
|
139
|
+
VARCHAR
|
140
|
+
VARIADIC
|
141
|
+
VERBOSE
|
142
|
+
WHEN
|
143
|
+
WHERE
|
144
|
+
WINDOW
|
145
|
+
WITH
|
146
|
+
XMLATTRIBUTES
|
147
|
+
XMLCONCAT
|
148
|
+
XMLELEMENT
|
149
|
+
XMLEXISTS
|
150
|
+
XMLFOREST
|
151
|
+
XMLNAMESPACES
|
152
|
+
XMLPARSE
|
153
|
+
XMLPI
|
154
|
+
XMLROOT
|
155
|
+
XMLSERIALIZE
|
156
|
+
XMLTABLE
|
157
|
+
].freeze
|
158
|
+
end
|
159
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
class PgQuery
|
2
|
+
module Deparse
|
3
|
+
module Rename
|
4
|
+
# relation, subname and object is the array key in the node.
|
5
|
+
# Array return five value. First is the type like a TRIGGER, TABLE, DOMAIN
|
6
|
+
# Other values may be parameter or SQL key.
|
7
|
+
#
|
8
|
+
# If node['renameType'] is the integer 13 (OBJECT_TYPE_DOMCONSTRAINT),
|
9
|
+
# then return value of this method will be:
|
10
|
+
#
|
11
|
+
# %w[DOMAIN object RENAME CONSTRAINT subname]
|
12
|
+
#
|
13
|
+
# Which will be composed into the SQL as:
|
14
|
+
#
|
15
|
+
# ALTER {type} {name} RENAME CONSTRAINT {subname} TO {newname}
|
16
|
+
#
|
17
|
+
def self.commands(node)
|
18
|
+
action = RENAME_MAPPING[node['renameType']] || raise(format("Can't deparse: %s", node.inspect))
|
19
|
+
PgQuery::Deparse.instance_exec(node, &action)
|
20
|
+
end
|
21
|
+
|
22
|
+
RENAME_MAPPING = {
|
23
|
+
OBJECT_TYPE_CONVERSION => ->(_node) { %w[CONVERSION object RENAME] },
|
24
|
+
OBJECT_TYPE_TABLE => ->(_node) { %w[TABLE relation RENAME] },
|
25
|
+
OBJECT_TYPE_TABCONSTRAINT => ->(_node) { %w[TABLE relation RENAME CONSTRAINT subname] },
|
26
|
+
OBJECT_TYPE_INDEX => ->(_node) { %w[INDEX relation RENAME] },
|
27
|
+
OBJECT_TYPE_MATVIEW => ->(_node) { ['MATERIALIZED VIEW', 'relation', 'RENAME'] },
|
28
|
+
OBJECT_TYPE_TABLESPACE => ->(_node) { %w[TABLESPACE subname RENAME] },
|
29
|
+
OBJECT_TYPE_VIEW => ->(_node) { %w[VIEW relation RENAME] },
|
30
|
+
OBJECT_TYPE_COLUMN => ->(_node) { %w[TABLE relation RENAME COLUMN subname] },
|
31
|
+
OBJECT_TYPE_COLLATION => ->(_node) { %w[COLLATION object RENAME] },
|
32
|
+
OBJECT_TYPE_TYPE => ->(_node) { %w[TYPE object RENAME] },
|
33
|
+
OBJECT_TYPE_DOMCONSTRAINT => ->(_node) { %w[DOMAIN object RENAME CONSTRAINT subname] },
|
34
|
+
OBJECT_TYPE_RULE => ->(_node) { %w[RULE subname ON relation RENAME] },
|
35
|
+
OBJECT_TYPE_TRIGGER => ->(_node) { %w[TRIGGER subname ON relation RENAME] },
|
36
|
+
OBJECT_TYPE_AGGREGATE => ->(_node) { %w[AGGREGATE object RENAME] },
|
37
|
+
OBJECT_TYPE_FUNCTION => ->(_node) { %w[FUNCTION object RENAME] }
|
38
|
+
}.freeze
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
@@ -0,0 +1,107 @@
|
|
1
|
+
class PgQuery
|
2
|
+
# Returns a list of columns that the query filters by - this excludes the
|
3
|
+
# target list, but includes things like JOIN condition and WHERE clause.
|
4
|
+
#
|
5
|
+
# Note: This also traverses into sub-selects.
|
6
|
+
def filter_columns # rubocop:disable Metrics/CyclomaticComplexity
|
7
|
+
load_tables_and_aliases! if @aliases.nil?
|
8
|
+
|
9
|
+
# Get condition items from the parsetree
|
10
|
+
statements = @tree.dup
|
11
|
+
condition_items = []
|
12
|
+
filter_columns = []
|
13
|
+
loop do
|
14
|
+
statement = statements.shift
|
15
|
+
if statement
|
16
|
+
if statement[RAW_STMT]
|
17
|
+
statements << statement[RAW_STMT][STMT_FIELD]
|
18
|
+
elsif statement[SELECT_STMT]
|
19
|
+
case statement[SELECT_STMT]['op']
|
20
|
+
when 0
|
21
|
+
if statement[SELECT_STMT][FROM_CLAUSE_FIELD]
|
22
|
+
# FROM subselects
|
23
|
+
statement[SELECT_STMT][FROM_CLAUSE_FIELD].each do |item|
|
24
|
+
next unless item['RangeSubselect']
|
25
|
+
statements << item['RangeSubselect']['subquery']
|
26
|
+
end
|
27
|
+
|
28
|
+
# JOIN ON conditions
|
29
|
+
condition_items += conditions_from_join_clauses(statement[SELECT_STMT][FROM_CLAUSE_FIELD])
|
30
|
+
end
|
31
|
+
|
32
|
+
# WHERE clause
|
33
|
+
condition_items << statement[SELECT_STMT]['whereClause'] if statement[SELECT_STMT]['whereClause']
|
34
|
+
|
35
|
+
# CTEs
|
36
|
+
if statement[SELECT_STMT]['withClause']
|
37
|
+
statement[SELECT_STMT]['withClause']['WithClause']['ctes'].each do |item|
|
38
|
+
statements << item['CommonTableExpr']['ctequery'] if item['CommonTableExpr']
|
39
|
+
end
|
40
|
+
end
|
41
|
+
when 1
|
42
|
+
statements << statement[SELECT_STMT]['larg'] if statement[SELECT_STMT]['larg']
|
43
|
+
statements << statement[SELECT_STMT]['rarg'] if statement[SELECT_STMT]['rarg']
|
44
|
+
end
|
45
|
+
elsif statement['UpdateStmt']
|
46
|
+
condition_items << statement['UpdateStmt']['whereClause'] if statement['UpdateStmt']['whereClause']
|
47
|
+
elsif statement['DeleteStmt']
|
48
|
+
condition_items << statement['DeleteStmt']['whereClause'] if statement['DeleteStmt']['whereClause']
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
# Process both JOIN and WHERE conditions here
|
53
|
+
next_item = condition_items.shift
|
54
|
+
if next_item
|
55
|
+
if next_item[A_EXPR]
|
56
|
+
%w[lexpr rexpr].each do |side|
|
57
|
+
expr = next_item.values[0][side]
|
58
|
+
next unless expr && expr.is_a?(Hash)
|
59
|
+
condition_items << expr
|
60
|
+
end
|
61
|
+
elsif next_item[BOOL_EXPR]
|
62
|
+
condition_items += next_item[BOOL_EXPR]['args']
|
63
|
+
elsif next_item[ROW_EXPR]
|
64
|
+
condition_items += next_item[ROW_EXPR]['args']
|
65
|
+
elsif next_item[COLUMN_REF]
|
66
|
+
column, table = next_item[COLUMN_REF]['fields'].map { |f| f['String']['str'] }.reverse
|
67
|
+
filter_columns << [@aliases[table] || table, column]
|
68
|
+
elsif next_item[NULL_TEST]
|
69
|
+
condition_items << next_item[NULL_TEST]['arg']
|
70
|
+
elsif next_item[BOOLEAN_TEST]
|
71
|
+
condition_items << next_item[BOOLEAN_TEST]['arg']
|
72
|
+
elsif next_item[FUNC_CALL]
|
73
|
+
# FIXME: This should actually be extracted as a funccall and be compared with those indices
|
74
|
+
condition_items += next_item[FUNC_CALL]['args'] if next_item[FUNC_CALL]['args']
|
75
|
+
elsif next_item[SUB_LINK]
|
76
|
+
condition_items << next_item[SUB_LINK]['testexpr']
|
77
|
+
statements << next_item[SUB_LINK]['subselect']
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
break if statements.empty? && condition_items.empty?
|
82
|
+
end
|
83
|
+
|
84
|
+
filter_columns.uniq
|
85
|
+
end
|
86
|
+
|
87
|
+
protected
|
88
|
+
|
89
|
+
def conditions_from_join_clauses(from_clause)
|
90
|
+
condition_items = []
|
91
|
+
from_clause.each do |item|
|
92
|
+
next unless item[JOIN_EXPR]
|
93
|
+
|
94
|
+
joinexpr_items = [item[JOIN_EXPR]]
|
95
|
+
loop do
|
96
|
+
next_item = joinexpr_items.shift
|
97
|
+
break unless next_item
|
98
|
+
condition_items << next_item['quals'] if next_item['quals']
|
99
|
+
%w[larg rarg].each do |side|
|
100
|
+
next unless next_item[side][JOIN_EXPR]
|
101
|
+
joinexpr_items << next_item[side][JOIN_EXPR]
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
105
|
+
condition_items
|
106
|
+
end
|
107
|
+
end
|