conceptql 0.5.0 → 0.6.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.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +454 -0
  3. data/config/vocabularies.csv +85 -87
  4. data/config/vocabulary_formats.sha +1 -0
  5. data/config/vocabulary_formats.tsv +39 -0
  6. data/doc/spec.md +207 -67
  7. data/lib/conceptql/behaviors/metadatable.rb +15 -3
  8. data/lib/conceptql/behaviors/provenanceable.rb +82 -8
  9. data/lib/conceptql/behaviors/utilizable.rb +5 -1
  10. data/lib/conceptql/data_model/base.rb +2 -4
  11. data/lib/conceptql/data_model/gdm.rb +3 -1
  12. data/lib/conceptql/database.rb +7 -2
  13. data/lib/conceptql/date_adjuster.rb +12 -5
  14. data/lib/conceptql/lexicon/lexicon_gdm.rb +4 -1
  15. data/lib/conceptql/lexicon/lexicon_ohdsi.rb +19 -1
  16. data/lib/conceptql/lexicon/lexicon_strategy.rb +35 -1
  17. data/lib/conceptql/lexicon.rb +210 -2
  18. data/lib/conceptql/nodifier.rb +7 -1
  19. data/lib/conceptql/one_query_counts.rb +120 -0
  20. data/lib/conceptql/operators/count.rb +4 -0
  21. data/lib/conceptql/operators/death.rb +1 -1
  22. data/lib/conceptql/operators/dedup.rb +1 -1
  23. data/lib/conceptql/operators/episode.rb +10 -2
  24. data/lib/conceptql/operators/ethnicity.rb +0 -4
  25. data/lib/conceptql/operators/except.rb +5 -0
  26. data/lib/conceptql/operators/filter.rb +12 -2
  27. data/lib/conceptql/operators/hospice.rb +1 -1
  28. data/lib/conceptql/operators/hospitalization.rb +1 -1
  29. data/lib/conceptql/operators/information_periods.rb +4 -1
  30. data/lib/conceptql/operators/invalid.rb +7 -1
  31. data/lib/conceptql/operators/multiple_vocabularies.rb +19 -6
  32. data/lib/conceptql/operators/numeric.rb +51 -14
  33. data/lib/conceptql/operators/occurrence.rb +16 -4
  34. data/lib/conceptql/operators/operator.rb +63 -19
  35. data/lib/conceptql/operators/person.rb +1 -1
  36. data/lib/conceptql/operators/person_filter.rb +5 -0
  37. data/lib/conceptql/operators/place_of_service_filter.rb +38 -10
  38. data/lib/conceptql/operators/provenance.rb +12 -2
  39. data/lib/conceptql/operators/provider_filter.rb +5 -1
  40. data/lib/conceptql/operators/race.rb +0 -4
  41. data/lib/conceptql/operators/recall.rb +1 -1
  42. data/lib/conceptql/operators/snf.rb +2 -2
  43. data/lib/conceptql/operators/sum.rb +46 -5
  44. data/lib/conceptql/operators/temporal_operator.rb +6 -2
  45. data/lib/conceptql/operators/trim_date.rb +52 -6
  46. data/lib/conceptql/operators/trim_date_end.rb +11 -1
  47. data/lib/conceptql/operators/trim_date_start.rb +11 -1
  48. data/lib/conceptql/operators/union.rb +45 -1
  49. data/lib/conceptql/operators/vocabulary.rb +7 -3
  50. data/lib/conceptql/paths.rb +10 -0
  51. data/lib/conceptql/query.rb +78 -24
  52. data/lib/conceptql/query_modifiers/gdm/provider_query_modifier.rb +7 -7
  53. data/lib/conceptql/rdbms/generic.rb +21 -1
  54. data/lib/conceptql/rdbms/spark.rb +12 -0
  55. data/lib/conceptql/scope.rb +187 -24
  56. data/lib/conceptql/version.rb +1 -1
  57. data/lib/conceptql/vocabularies/drg_systems.rb +118 -0
  58. data/lib/conceptql/vocabularies/dynamic_vocabularies.rb +10 -4
  59. data/lib/conceptql/vocabularies/entry.rb +15 -3
  60. data/lib/conceptql/vocabularies/formats.rb +59 -0
  61. data/lib/conceptql/vocabularies/vocabulary_class_factory.rb +6 -1
  62. data/lib/conceptql/window/table.rb +12 -3
  63. metadata +8 -4
  64. data/lib/conceptql/operators/concurrent_within.rb +0 -69
@@ -14,6 +14,18 @@ module ConceptQL
14
14
  #
15
15
  # If the RHS record's start_date is later than the LHS end_date, the LHS
16
16
  # record is passed thru unaffected.
17
+ #
18
+ # With join_columns (only t_shank gives it), "per person" means per
19
+ # person and join_columns values, with or without compare_all.
20
+ #
21
+ # With compare_all (only t_shank gives it), each LHS record is compared
22
+ # with every RHS record of the person and of the join_columns, not only
23
+ # with the person's first (trim_date_end) or last (trim_date_start) RHS
24
+ # record. Each LHS record comes out once, trimmed by the nearest RHS
25
+ # record that passes: the earliest start_date on or after its start_date
26
+ # (trim_date_end), or the latest end_date on or before its end_date
27
+ # (trim_date_start). An LHS record is discarded only when it has RHS
28
+ # records and none of them passes.
17
29
  class TrimDate < TemporalOperator
18
30
  desc <<~EOF
19
31
  Trims the end_date of the left hand records (LHR) by the earliest
@@ -38,6 +50,8 @@ module ConceptQL
38
50
  .left_join(Sequel.as(right_stream_query(db), :r), join_columns.inject(&:&))
39
51
  .where(where_criteria)
40
52
 
53
+ ds = nearest_trim_date(ds) if compare_all?
54
+
41
55
  ds = dm.selectify(ds, qualifier: :l, replace: replacement_columns)
42
56
 
43
57
  ds = apply_selectors(ds)
@@ -51,16 +65,48 @@ module ConceptQL
51
65
  options[:compare_all]
52
66
  end
53
67
 
68
+ # With compare_all, each left row joins every right row of the person
69
+ # (and of the join columns), and the where clause keeps the right rows
70
+ # that pass. Group those back to one row for each left row, and keep
71
+ # the nearest passing trim date (trim_function). A left row without a
72
+ # right row has a NULL trim date, so it passes through unchanged (bead
73
+ # conceptql-x6m, seed conceptql-ysx). Every stream is a set, so left
74
+ # rows that are equal in every column come out once (seed conceptql-0eb).
75
+ def nearest_trim_date(ds)
76
+ ds.select_group(*dynamic_columns.map { |column| Sequel[:l][column] })
77
+ .select_append(Sequel.function(trim_function, trim_bound).as(:trim_bound))
78
+ .from_self(alias: :l)
79
+ end
80
+
81
+ # The trim date of the joined right row, or the nearest one of the left
82
+ # row when compare_all grouped the right rows.
83
+ def trim_date_column
84
+ compare_all? ? Sequel[:l][:trim_bound] : trim_bound
85
+ end
86
+
54
87
  def right_stream_query(db)
55
- rh = if compare_all?
56
- super
57
- else
58
- occ = to_op([ :occurrence, occurrence_number, right ])
59
- occ.evaluate(db)
60
- end
88
+ rh = compare_all? ? super : nearest_right_row(db)
61
89
  columnizer.apply(rh)
62
90
  end
63
91
 
92
+ # Without compare_all, the first (trim_date_end) or last
93
+ # (trim_date_start) right row of each person and join_columns values,
94
+ # the grouping compare_all uses. The rows are ordered as the occurrence
95
+ # operator orders them: by start_date, then criterion_id. The occurrence
96
+ # operator groups by person only, so a left row whose join values
97
+ # differed from those of the person's first right row passed through
98
+ # untrimmed (bead conceptql-18n).
99
+ def nearest_right_row(db)
100
+ direction = occurrence_number.negative? ? :desc : :asc
101
+ right.evaluate(db)
102
+ .select_append(Sequel[:ROW_NUMBER].function.over(
103
+ partition: join_columns_option.uniq,
104
+ order: %i[start_date criterion_id].map { |column| Sequel.send(direction, column) }
105
+ ).as(:rn))
106
+ .from_self
107
+ .where(rn: occurrence_number.abs)
108
+ end
109
+
64
110
  def columnizer
65
111
  columnizer = Columnizer.new
66
112
  columnizer.add_columns(:person_id, trim_date, *join_columns_option)
@@ -32,7 +32,17 @@ module ConceptQL
32
32
  end
33
33
 
34
34
  def replacement_columns
35
- { end_date: Sequel.function(:least, l_end_date, Sequel.function(:coalesce, within_start, l_end_date)) }
35
+ { end_date: Sequel.function(:least, l_end_date, Sequel.function(:coalesce, trim_date_column, l_end_date)) }
36
+ end
37
+
38
+ # The date that trims a left row, from one right row.
39
+ def trim_bound
40
+ within_start
41
+ end
42
+
43
+ # With compare_all, the nearest trim date of the right rows that pass.
44
+ def trim_function
45
+ :min
36
46
  end
37
47
 
38
48
  def occurrence_number
@@ -32,7 +32,17 @@ module ConceptQL
32
32
  end
33
33
 
34
34
  def replacement_columns
35
- { start_date: Sequel.function(:greatest, l_start_date, Sequel.function(:coalesce, within_end, l_start_date)) }
35
+ { start_date: Sequel.function(:greatest, l_start_date, Sequel.function(:coalesce, trim_date_column, l_start_date)) }
36
+ end
37
+
38
+ # The date that trims a left row, from one right row.
39
+ def trim_bound
40
+ within_end
41
+ end
42
+
43
+ # With compare_all, the nearest trim date of the right rows that pass.
44
+ def trim_function
45
+ :max
36
46
  end
37
47
 
38
48
  def occurrence_number
@@ -70,11 +70,55 @@ module ConceptQL
70
70
 
71
71
  queries += Combiner.new(combinables).queries(db)
72
72
 
73
+ all = disjoint_upstreams?
73
74
  queries.map! { |ds| ds.from_self.select(*query_cols).from_self }
74
75
  queries.inject do |q, query|
75
- q.union(query, all: true)
76
+ q.union(query, all: all)
76
77
  end
77
78
  end
79
+
80
+ # The (criterion_domain, vocabulary condition) pairs of every row this
81
+ # union can emit, or nil when an upstream does not fix them at build
82
+ # time. See #disjoint_upstreams?.
83
+ def branches
84
+ upstream_branches = upstreams.map { |upstream| branches_of(upstream) }
85
+ upstream_branches.flatten(1) if upstream_branches.all?
86
+ end
87
+
88
+ private
89
+
90
+ # Every stream is a set (seed conceptql-0eb), so union removes the rows
91
+ # that arrive from more than one upstream: UNION, not UNION ALL. The
92
+ # duplicate removal is expensive on large data (conceptql-5pz), so union
93
+ # skips it when no row can arrive from two of its queries. It proves
94
+ # that only for vocabulary operators, cpt_or_hcpcs-style operators and
95
+ # unions of them, because each of their rows carries the operator's
96
+ # domain as criterion_domain and comes from a row of its vocabulary.
97
+ # Two rows with different domains differ, and so do two rows of one
98
+ # domain (one table) from different vocabularies of one column. Any
99
+ # other upstream can overlap anything, so union removes duplicates.
100
+ def disjoint_upstreams?
101
+ combinables, individuals = upstreams.partition { |upstream| upstream.is_a?(Vocabulary) }
102
+ queries = individuals.map { |upstream| branches_of(upstream) }
103
+ queries += combinables.group_by(&:domain).values.map { |combos| combos.flat_map { |c| branches_of(c) } }
104
+ return false unless queries.all?
105
+
106
+ queries.combination(2).all? do |one, other|
107
+ one.product(other).all? { |a, b| disjoint_branches?(a, b) }
108
+ end
109
+ end
110
+
111
+ def branches_of(upstream)
112
+ case upstream
113
+ when Vocabulary then [ [ upstream.domain, upstream.vocabulary_condition ] ]
114
+ when MultipleVocabularies then upstream.vocab_ops.flat_map { |vo| branches_of(vo) }
115
+ when Union then upstream.branches
116
+ end
117
+ end
118
+
119
+ def disjoint_branches?((domain, condition), (other_domain, other_condition))
120
+ domain != other_domain || (condition.keys == other_condition.keys && condition != other_condition)
121
+ end
78
122
  end
79
123
  end
80
124
  end
@@ -38,7 +38,7 @@ module ConceptQL
38
38
  args -= bad_arguments
39
39
 
40
40
  missing_args = args - dm.known_codes(db, vocabulary_id, args)
41
- add_warning("unknown code(s)", *missing_args) unless missing_args.empty?
41
+ add_warning(UNKNOWN_CODE, *missing_args) unless missing_args.empty?
42
42
 
43
43
  warn_about_reused_codes(db, args - missing_args)
44
44
  end
@@ -137,13 +137,17 @@ module ConceptQL
137
137
  unless defined?(@code_regexp)
138
138
  @code_regexp = nil
139
139
 
140
- if (reg_str = vocab_entry.format_regexp)
141
- @code_regexp = Regexp.union(Regexp.new(reg_str, Regexp::IGNORECASE), /\A\*\Z/)
140
+ if (format = Vocabularies::Formats.regexp(vocab_entry.omopv5_id))
141
+ @code_regexp = Regexp.union(format, /\A\*\Z/)
142
142
  end
143
143
  end
144
144
  @code_regexp
145
145
  end
146
146
 
147
+ def bad_format_message
148
+ vocab_entry.omopv5_id == "NDC" ? NDC_BAD_FORMAT : BAD_FORMAT
149
+ end
150
+
147
151
  def vocabulary_id
148
152
  @vocabulary_id ||= translated_vocabulary_id
149
153
  end
@@ -22,6 +22,16 @@ module ConceptQL
22
22
  ConceptQL.config_dir / "vocabularies.csv"
23
23
  end
24
24
 
25
+ # A pinned copy of vocabulary_formats.tsv (ConceptQL::Vocabularies::Formats)
26
+ def vocabulary_formats_file_path
27
+ ConceptQL.config_dir / "vocabulary_formats.tsv"
28
+ end
29
+
30
+ # The vocabulary_formats commit that the copy came from
31
+ def vocabulary_formats_sha_file_path
32
+ ConceptQL.config_dir / "vocabulary_formats.sha"
33
+ end
34
+
25
35
  def multiple_vocabularies_file_path
26
36
  ConceptQL.config_dir / "multiple_vocabularies.csv"
27
37
  end
@@ -4,6 +4,7 @@ require "json"
4
4
  require "forwardable"
5
5
  require_relative "scope"
6
6
  require_relative "nodifier"
7
+ require_relative "one_query_counts"
7
8
  require "sequel/sqlformat"
8
9
 
9
10
  module ConceptQL
@@ -20,29 +21,39 @@ module ConceptQL
20
21
  [ super, "OG ERROR:", og_message ].join("\n")
21
22
  end
22
23
  end
24
+ end
23
25
 
24
- # Raised by #code_list when the statement has validation errors: the
25
- # operator tree of an invalid statement can have missing operands, and a
26
- # partial code list would look complete. #errors is the errors hash of
27
- # #scope_annotate (operator name => list of [message, *details]).
28
- class InvalidStatementError < QueryError
29
- attr_reader :statement, :errors
30
-
31
- def initialize(statement, errors)
32
- @statement = statement
33
- @errors = errors
34
- super([ "Invalid ConceptQL statement, so it has no code list. Validation errors:",
35
- *errors.flat_map { |op, op_errors| op_errors.map { |e| " #{op}: #{describe(e)}" } },
36
- "Statement: #{statement.inspect}" ].join("\n"))
37
- end
26
+ # Raised when a Query builds the SQL of a statement (#query, #sql,
27
+ # #sql_statements, and #all, #count, #execute, #order and #analyze through
28
+ # #query) or its code list (#code_list), and the statement has validation
29
+ # errors. An invalid statement can crash while it builds, or build SQL that
30
+ # silently returns fewer rows. Warnings do not raise.
31
+ #
32
+ # #errors is the errors hash that #scope_annotate reports (operator name =>
33
+ # list of [message, *details]), so it holds the errors that annotate shows.
34
+ # #statement is the statement that the Query holds.
35
+ class InvalidStatementError < Query::QueryError
36
+ attr_reader :statement, :errors
37
+
38
+ def initialize(statement, errors)
39
+ @statement = statement
40
+ @errors = errors
41
+ super([ "Invalid ConceptQL statement. Validation errors:",
42
+ *errors.flat_map { |op, op_errors| op_errors.map { |e| " #{op}: #{describe(e)}" } },
43
+ "Statement: #{statement.inspect}" ].join("\n"))
44
+ end
38
45
 
39
- private
46
+ private
40
47
 
41
- def describe(error)
42
- message, *details = error
43
- details.empty? ? message.to_s : "#{message} (#{details.flatten.map(&:to_s).join(', ')})"
44
- end
48
+ def describe(error)
49
+ message, *details = error
50
+ details.empty? ? message.to_s : "#{message} (#{details.flatten.map(&:to_s).join(', ')})"
45
51
  end
52
+ end
53
+
54
+ class Query
55
+ # The name of InvalidStatementError in 0.4.0 to 0.5.0.
56
+ InvalidStatementError = ConceptQL::InvalidStatementError
46
57
 
47
58
  extend Forwardable
48
59
  def_delegators :query, :all, :count, :execute, :order
@@ -66,7 +77,9 @@ module ConceptQL
66
77
  query(explain: true, analyze: true).analyze
67
78
  end
68
79
 
80
+ # Raises InvalidStatementError if the statement has validation errors.
69
81
  def query(opts = {})
82
+ validate!
70
83
  (@queries ||= {})[opts] ||= nodifier.scope.with_ctes(operator, db, opts)
71
84
  end
72
85
 
@@ -112,11 +125,26 @@ module ConceptQL
112
125
  end
113
126
  end
114
127
  Hash[stmts]
128
+ rescue InvalidStatementError
129
+ raise
115
130
  rescue StandardError
116
131
  raise QueryError, "Failed to generate SQL for #{statement.inspect}"
117
132
  end
118
133
 
134
+ # With counts on, DuckDB and Postgres count every operator with one SQL
135
+ # statement (OneQueryCounts). Operators memoize #annotate, so a later call
136
+ # with other opts returns the first result.
137
+ #
138
+ # The errors of the window table's own statement are reported under
139
+ # "window_table" (see #window_table_errors). Then nothing is counted,
140
+ # because every count joins the window table, and building its SQL raises.
119
141
  def annotate(opts = {})
142
+ window_errors = window_table_errors
143
+ unless window_errors.empty?
144
+ nodifier.scope.add_errors("window_table", window_errors)
145
+ opts = opts.merge(skip_counts: true)
146
+ end
147
+ nodifier.scope.one_query_counts = OneQueryCounts.new(operator, db, opts)
120
148
  operator.annotate(db, opts)
121
149
  end
122
150
 
@@ -164,12 +192,11 @@ module ConceptQL
164
192
  operator.rdbms
165
193
  end
166
194
 
167
- # Raises InvalidStatementError if the statement has validation errors.
168
- # Warnings do not raise.
195
+ # Raises InvalidStatementError if the statement has validation errors:
196
+ # the operator tree of an invalid statement can have missing operands,
197
+ # and a partial code list would look complete. Warnings do not raise.
169
198
  def code_list(_ignored_db = nil)
170
- errors = validation_errors
171
- raise InvalidStatementError.new(statement, errors) unless errors.empty?
172
-
199
+ validate!
173
200
  operator.code_list(db).uniq
174
201
  end
175
202
 
@@ -189,6 +216,33 @@ module ConceptQL
189
216
 
190
217
  attr_reader :cdb, :nodifier
191
218
 
219
+ # Raises InvalidStatementError if #validation_errors is not empty. The
220
+ # statement and the opts of a Query do not change, so it checks once.
221
+ # Warnings do not raise. cause: nil keeps an error that a caller is
222
+ # handling out of the message (QueryError#message prints the cause).
223
+ def validate!
224
+ return if @valid
225
+
226
+ errors = validation_errors
227
+ raise InvalidStatementError.new(statement, errors), cause: nil unless errors.empty?
228
+
229
+ @valid = true
230
+ end
231
+
232
+ # The validation errors of the window table's statement, when the window
233
+ # table is a ConceptQL statement (an Array, as Window::Table reads it), as
234
+ # one list of [message, *details]. Each message starts with the name of
235
+ # the operator that reported it. A table name has no errors.
236
+ def window_table_errors
237
+ scope_opts = nodifier.scope.opts
238
+ window_table = scope_opts.merge(scope_opts[:window_opts] || {})[:window_table]
239
+ return [] unless window_table.is_a?(Array)
240
+
241
+ cdb.query(window_table).validation_errors.flat_map do |op, op_errors|
242
+ op_errors.map { |message, *details| [ "#{op}: #{message}", *details ] }
243
+ end
244
+ end
245
+
192
246
  def extract_statement(stmt, opts)
193
247
  if !stmt.is_a?(Array)
194
248
  raise "Improper ConceptQL statement: Expected an Array, got a #{stmt.class}"
@@ -29,20 +29,20 @@ module ConceptQL
29
29
  Sequel[:p][:specialty_concept_id].as(:practitioner_specialty_concept_id)
30
30
  )
31
31
  else
32
+ # The data model names the columns of the source table. ds.columns
33
+ # asked the database for them while the SQL was built (bead
34
+ # conceptql-6k8).
35
+ cols = dm.table_cols(source_table)
32
36
  ds = query.select_all
33
- ds = if ds.columns.include?(:provider_id)
37
+ ds = if cols.include?(:provider_id)
34
38
  ds
35
- elsif dm.table_cols(source_table).include?(:practitioner_id)
39
+ elsif cols.include?(:practitioner_id)
36
40
  ds.select_append(Sequel[:practitioner_id].as(:provider_id))
37
41
  else
38
42
  ds.select_append(Sequel.cast(nil, :Bigint).as(:provider_id))
39
43
  end
40
44
  %i[role_type_concept_id context_specialty_concept_id practitioner_specialty_concept_id].each do |col|
41
- ds = if ds.columns.include?(col)
42
- ds
43
- else
44
- ds.select_append(Sequel.cast(nil, :Bigint).as(col))
45
- end
45
+ ds = ds.select_append(Sequel.cast(nil, :Bigint).as(col)) unless cols.include?(col)
46
46
  end
47
47
  ds
48
48
  end.from_self
@@ -53,9 +53,13 @@ module ConceptQL
53
53
  .inject(:+)
54
54
  end
55
55
 
56
+ # A NULL part is an empty string. The || operator gives NULL when one
57
+ # part is NULL, and a NULL uuid matches nothing (bead conceptql-18n).
58
+ # COALESCE keeps the place of each part, so "1//t/2008-01-01/" cannot
59
+ # be read as other parts. Postgres, DuckDB and Spark all support it.
56
60
  def uuid_items
57
61
  %w[person_id criterion_id criterion_table start_date end_date].map do |column|
58
- Sequel.cast_string(column.to_sym)
62
+ Sequel.function(:coalesce, Sequel.cast_string(column.to_sym), "")
59
63
  end
60
64
  end
61
65
 
@@ -79,6 +83,22 @@ module ConceptQL
79
83
  false
80
84
  end
81
85
 
86
+ # True if an IN subquery may sit in a SELECT-list expression, such as
87
+ # the CASE flags of One In Two Out. Scope#with_ctes reads a long IN list
88
+ # from its list CTE there only when this is true.
89
+ def supports_in_subquery_in_select?
90
+ true
91
+ end
92
+
93
+ # True if a NOT IN subquery may sit in a nested condition, such as the
94
+ # "IS NULL OR NOT IN" of the gender operator. Scope#with_ctes reads a
95
+ # long NOT IN list from its list CTE only when this is true. It does not
96
+ # check whether a NOT IN condition is nested, so false keeps every long
97
+ # NOT IN list a NOT IN list.
98
+ def supports_nested_not_in_subquery?
99
+ true
100
+ end
101
+
82
102
  def create_options(_scope, _ds)
83
103
  opts = {}
84
104
  opts[:analyze] = opts[:explain] = explain_temp_tables?
@@ -13,6 +13,18 @@ module ConceptQL
13
13
  def null_safe_equal(lhs, rhs)
14
14
  Sequel.lit("(? <=> ?)", lhs, rhs)
15
15
  end
16
+
17
+ # Spark accepts an IN subquery only in a filter or a join: "IN/EXISTS
18
+ # predicate sub-queries can only be used in Filter/Join" (Spark 3.3).
19
+ def supports_in_subquery_in_select?
20
+ false
21
+ end
22
+
23
+ # Spark rejects a NOT IN subquery in a nested condition: "Null-aware
24
+ # predicate sub-queries cannot be used in nested conditions" (Spark 3.3).
25
+ def supports_nested_not_in_subquery?
26
+ false
27
+ end
16
28
  end
17
29
  end
18
30
  end