conceptql 0.2.0 → 0.4.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 +5 -5
- data/CHANGELOG.md +226 -117
- data/LICENSE +21 -0
- data/README.md +35 -83
- data/bin/conceptql +2 -1
- data/bin/conceptql_utils +6 -0
- data/config/multiple_vocabularies.csv +4 -0
- data/config/provenance.yml +489 -0
- data/config/race.yml +50 -0
- data/config/vocabularies.csv +87 -0
- data/lib/conceptql/behaviors/code_lister.rb +23 -0
- data/lib/conceptql/behaviors/drugish.rb +17 -0
- data/lib/conceptql/behaviors/labish.rb +19 -0
- data/lib/conceptql/behaviors/metadatable.rb +226 -73
- data/lib/conceptql/behaviors/provenanceable.rb +26 -0
- data/lib/conceptql/behaviors/single_event.rb +11 -0
- data/lib/conceptql/behaviors/timeless.rb +11 -0
- data/lib/conceptql/behaviors/utilizable.rb +206 -0
- data/lib/conceptql/behaviors/windowable.rb +27 -0
- data/lib/conceptql/cli.rb +117 -108
- data/lib/conceptql/columnizer.rb +75 -0
- data/lib/conceptql/data_model/base.rb +462 -0
- data/lib/conceptql/data_model/gdm.rb +155 -0
- data/lib/conceptql/data_model/gdm_wide.rb +42 -0
- data/lib/conceptql/data_model/omopv4_plus.rb +12 -0
- data/lib/conceptql/data_model.rb +30 -0
- data/lib/conceptql/database.rb +214 -0
- data/lib/conceptql/date_adjuster.rb +66 -18
- data/lib/conceptql/diagram.rb +243 -0
- data/lib/conceptql/lexicon/lexicon_gdm.rb +47 -0
- data/lib/conceptql/lexicon/lexicon_no_db.rb +49 -0
- data/lib/conceptql/lexicon/lexicon_ohdsi.rb +63 -0
- data/lib/conceptql/lexicon/lexicon_strategy.rb +116 -0
- data/lib/conceptql/lexicon.rb +111 -0
- data/lib/conceptql/nodifier.rb +65 -24
- data/lib/conceptql/operators/after.rb +34 -14
- data/lib/conceptql/operators/any_overlap.rb +8 -6
- data/lib/conceptql/operators/before.rb +34 -10
- data/lib/conceptql/operators/binary_operator_operator.rb +66 -23
- data/lib/conceptql/operators/casting_operator.rb +51 -36
- data/lib/conceptql/operators/co_reported.rb +111 -0
- data/lib/conceptql/operators/concurrent_within.rb +69 -0
- data/lib/conceptql/operators/contains.rb +12 -8
- data/lib/conceptql/operators/count.rb +17 -9
- data/lib/conceptql/operators/date_range.rb +48 -14
- data/lib/conceptql/operators/death.rb +14 -4
- data/lib/conceptql/operators/dedup.rb +31 -0
- data/lib/conceptql/operators/during.rb +7 -12
- data/lib/conceptql/operators/episode.rb +146 -0
- data/lib/conceptql/operators/equal.rb +9 -3
- data/lib/conceptql/operators/ethnicity.rb +49 -0
- data/lib/conceptql/operators/except.rb +19 -12
- data/lib/conceptql/operators/filter.rb +23 -8
- data/lib/conceptql/operators/first.rb +16 -3
- data/lib/conceptql/operators/from.rb +52 -4
- data/lib/conceptql/operators/gender.rb +34 -11
- data/lib/conceptql/operators/hospice.rb +19 -0
- data/lib/conceptql/operators/hospitalization.rb +19 -0
- data/lib/conceptql/operators/information_periods.rb +36 -0
- data/lib/conceptql/operators/invalid.rb +35 -0
- data/lib/conceptql/operators/last.rb +16 -3
- data/lib/conceptql/operators/match.rb +91 -0
- data/lib/conceptql/operators/multiple_vocabularies.rb +172 -0
- data/lib/conceptql/operators/numeric.rb +65 -23
- data/lib/conceptql/operators/numeric_filter.rb +68 -0
- data/lib/conceptql/operators/occurrence.rb +153 -32
- data/lib/conceptql/operators/one_in_two_out.rb +237 -38
- data/lib/conceptql/operators/operator.rb +624 -178
- data/lib/conceptql/operators/pass_thru.rb +17 -3
- data/lib/conceptql/operators/person.rb +18 -6
- data/lib/conceptql/operators/person_filter.rb +9 -3
- data/lib/conceptql/operators/place_of_service_filter.rb +43 -0
- data/lib/conceptql/operators/provenance.rb +55 -0
- data/lib/conceptql/operators/provider_filter.rb +62 -0
- data/lib/conceptql/operators/race.rb +55 -7
- data/lib/conceptql/operators/read.rb +168 -0
- data/lib/conceptql/operators/recall.rb +49 -14
- data/lib/conceptql/operators/snf.rb +19 -0
- data/lib/conceptql/operators/sum.rb +29 -12
- data/lib/conceptql/operators/temporal_operator.rb +108 -16
- data/lib/conceptql/operators/time_window.rb +20 -32
- data/lib/conceptql/operators/trim_date.rb +71 -0
- data/lib/conceptql/operators/trim_date_end.rb +28 -40
- data/lib/conceptql/operators/trim_date_start.rb +28 -41
- data/lib/conceptql/operators/union.rb +67 -6
- data/lib/conceptql/operators/vocabulary.rb +151 -0
- data/lib/conceptql/paths.rb +41 -0
- data/lib/conceptql/query.rb +191 -26
- data/lib/conceptql/query_modifiers/gdm/admission_date_query_modifier.rb +46 -0
- data/lib/conceptql/query_modifiers/gdm/drug_query_modifier.rb +54 -0
- data/lib/conceptql/query_modifiers/gdm/lab_query_modifier.rb +57 -0
- data/lib/conceptql/query_modifiers/gdm/pos_query_modifier.rb +46 -0
- data/lib/conceptql/query_modifiers/gdm/provenance_query_modifier.rb +45 -0
- data/lib/conceptql/query_modifiers/gdm/provider_query_modifier.rb +53 -0
- data/lib/conceptql/query_modifiers/gdm_wide/admission_date_query_modifier.rb +42 -0
- data/lib/conceptql/query_modifiers/gdm_wide/drug_query_modifier.rb +52 -0
- data/lib/conceptql/query_modifiers/gdm_wide/lab_query_modifier.rb +55 -0
- data/lib/conceptql/query_modifiers/gdm_wide/pos_query_modifier.rb +37 -0
- data/lib/conceptql/query_modifiers/gdm_wide/provenance_query_modifier.rb +38 -0
- data/lib/conceptql/query_modifiers/omopv4_plus/admission_date_query_modifier.rb +45 -0
- data/lib/conceptql/query_modifiers/omopv4_plus/drug_query_modifier.rb +80 -0
- data/lib/conceptql/query_modifiers/omopv4_plus/pos_query_modifier.rb +47 -0
- data/lib/conceptql/query_modifiers/omopv4_plus/provenance_query_modifier.rb +45 -0
- data/lib/conceptql/query_modifiers/omopv4_plus/provider_query_modifier.rb +26 -0
- data/lib/conceptql/query_modifiers/query_modifier.rb +20 -0
- data/lib/conceptql/rdbms/duckdb.rb +25 -0
- data/lib/conceptql/rdbms/generic.rb +120 -0
- data/lib/conceptql/rdbms/postgres.rb +51 -0
- data/lib/conceptql/rdbms/spark.rb +22 -0
- data/lib/conceptql/rdbms.rb +22 -0
- data/lib/conceptql/scope.rb +481 -31
- data/lib/conceptql/sql_formatters/formatter.rb +25 -0
- data/lib/conceptql/sql_formatters.rb +22 -0
- data/lib/conceptql/utils.rb +113 -0
- data/lib/conceptql/utils_cli.rb +17 -0
- data/lib/conceptql/version.rb +3 -1
- data/lib/conceptql/visitors/metadata_extractor.rb +54 -0
- data/lib/conceptql/vocabularies/behaviors/costish.rb +24 -0
- data/lib/conceptql/vocabularies/behaviors/gdmish.rb +19 -0
- data/lib/conceptql/vocabularies/behaviors/omopish.rb +32 -0
- data/lib/conceptql/vocabularies/dynamic_vocabularies.rb +63 -0
- data/lib/conceptql/vocabularies/entry.rb +138 -0
- data/lib/conceptql/vocabularies/vocabulary_class_factory.rb +85 -0
- data/lib/conceptql/window/base.rb +26 -0
- data/lib/conceptql/window/date_range.rb +25 -0
- data/lib/conceptql/window/table.rb +116 -0
- data/lib/conceptql/window.rb +72 -0
- data/lib/conceptql.rb +65 -2
- data/lib/sequel/extensions/pg_ctas_explain.rb +22 -0
- data/lib/sequel/extensions/pg_vacuum_table.rb +23 -0
- data/schemas/gdm.yml +882 -0
- data/schemas/gdm_wide.yml +757 -0
- data/schemas/ohdsi_vocabs.yml +143 -0
- data/schemas/omopv4_plus.yml +729 -0
- data/schemas/omopv4_plus_more_ignorables.tsv +13 -0
- metadata +267 -207
- data/.gitignore +0 -23
- data/Gemfile +0 -4
- data/Guardfile +0 -56
- data/LICENSE.txt +0 -22
- data/Rakefile +0 -1
- data/conceptql.gemspec +0 -32
- data/doc/ConceptQL Specification (alpha).pdf +0 -0
- data/doc/diagram_0.png +0 -0
- data/doc/implementation_notes.md +0 -39
- data/doc/spec.md +0 -1344
- data/lib/conceptql/behaviors/debuggable.rb +0 -70
- data/lib/conceptql/behaviors/dottable.rb +0 -103
- data/lib/conceptql/behaviors/preppable.rb +0 -20
- data/lib/conceptql/converter.rb +0 -65
- data/lib/conceptql/debugger.rb +0 -48
- data/lib/conceptql/fake_grapher.rb +0 -20
- data/lib/conceptql/graph.rb +0 -52
- data/lib/conceptql/graph_nodifier.rb +0 -203
- data/lib/conceptql/logger.rb +0 -10
- data/lib/conceptql/operators/complement.rb +0 -38
- data/lib/conceptql/operators/concept.rb +0 -70
- data/lib/conceptql/operators/condition_type.rb +0 -71
- data/lib/conceptql/operators/cpt.rb +0 -25
- data/lib/conceptql/operators/drug_type_concept.rb +0 -21
- data/lib/conceptql/operators/from_seer_visits.rb +0 -23
- data/lib/conceptql/operators/hcpcs.rb +0 -25
- data/lib/conceptql/operators/icd10.rb +0 -28
- data/lib/conceptql/operators/icd9.rb +0 -28
- data/lib/conceptql/operators/icd9_procedure.rb +0 -25
- data/lib/conceptql/operators/intersect.rb +0 -34
- data/lib/conceptql/operators/loinc.rb +0 -25
- data/lib/conceptql/operators/medcode.rb +0 -28
- data/lib/conceptql/operators/medcode_procedure.rb +0 -27
- data/lib/conceptql/operators/ndc.rb +0 -29
- data/lib/conceptql/operators/observation_by_enttype.rb +0 -29
- data/lib/conceptql/operators/observation_period.rb +0 -30
- data/lib/conceptql/operators/overlapped_by.rb +0 -23
- data/lib/conceptql/operators/overlaps.rb +0 -19
- data/lib/conceptql/operators/place_of_service_code.rb +0 -24
- data/lib/conceptql/operators/procedure_occurrence.rb +0 -25
- data/lib/conceptql/operators/prodcode.rb +0 -29
- data/lib/conceptql/operators/rxnorm.rb +0 -24
- data/lib/conceptql/operators/snomed.rb +0 -24
- data/lib/conceptql/operators/snomed_condition.rb +0 -26
- data/lib/conceptql/operators/source_vocabulary_operator.rb +0 -58
- data/lib/conceptql/operators/standard_vocabulary_operator.rb +0 -45
- data/lib/conceptql/operators/started_by.rb +0 -22
- data/lib/conceptql/operators/to_seer_visits.rb +0 -24
- data/lib/conceptql/operators/visit.rb +0 -15
- data/lib/conceptql/operators/visit_occurrence.rb +0 -30
- data/lib/conceptql/tree.rb +0 -53
- data/lib/conceptql/utils/temp_table.rb +0 -72
- data/spec/conceptql/behaviors/dottable_spec.rb +0 -99
- data/spec/conceptql/converter_spec.rb +0 -51
- data/spec/conceptql/date_adjuster_spec.rb +0 -68
- data/spec/conceptql/operators/after_spec.rb +0 -16
- data/spec/conceptql/operators/before_spec.rb +0 -16
- data/spec/conceptql/operators/casting_operator_spec.rb +0 -68
- data/spec/conceptql/operators/complement_spec.rb +0 -15
- data/spec/conceptql/operators/concept_spec.rb +0 -40
- data/spec/conceptql/operators/condition_type_spec.rb +0 -128
- data/spec/conceptql/operators/contains_spec.rb +0 -19
- data/spec/conceptql/operators/cpt_spec.rb +0 -29
- data/spec/conceptql/operators/date_range_spec.rb +0 -33
- data/spec/conceptql/operators/death_spec.rb +0 -10
- data/spec/conceptql/operators/during_spec.rb +0 -30
- data/spec/conceptql/operators/except_spec.rb +0 -15
- data/spec/conceptql/operators/first_spec.rb +0 -35
- data/spec/conceptql/operators/from_spec.rb +0 -13
- data/spec/conceptql/operators/gender_spec.rb +0 -27
- data/spec/conceptql/operators/hcpcs_spec.rb +0 -29
- data/spec/conceptql/operators/icd10_spec.rb +0 -34
- data/spec/conceptql/operators/icd9_procedure_spec.rb +0 -29
- data/spec/conceptql/operators/icd9_spec.rb +0 -34
- data/spec/conceptql/operators/intersect_spec.rb +0 -28
- data/spec/conceptql/operators/last_spec.rb +0 -36
- data/spec/conceptql/operators/loinc_spec.rb +0 -29
- data/spec/conceptql/operators/medcode_procedure_spec.rb +0 -34
- data/spec/conceptql/operators/medcode_spec.rb +0 -34
- data/spec/conceptql/operators/observation_period_spec.rb +0 -10
- data/spec/conceptql/operators/occurrence_spec.rb +0 -87
- data/spec/conceptql/operators/overlapped_by_spec.rb +0 -32
- data/spec/conceptql/operators/overlaps_spec.rb +0 -21
- data/spec/conceptql/operators/person_filter_spec.rb +0 -15
- data/spec/conceptql/operators/person_spec.rb +0 -10
- data/spec/conceptql/operators/place_of_service_code_spec.rb +0 -24
- data/spec/conceptql/operators/procedure_occurrence_spec.rb +0 -10
- data/spec/conceptql/operators/prodcode_spec.rb +0 -35
- data/spec/conceptql/operators/query_double.rb +0 -20
- data/spec/conceptql/operators/query_double_spec.rb +0 -7
- data/spec/conceptql/operators/race_spec.rb +0 -21
- data/spec/conceptql/operators/rxnorm_spec.rb +0 -29
- data/spec/conceptql/operators/snomed_spec.rb +0 -29
- data/spec/conceptql/operators/source_vocabulary_operator_spec.rb +0 -35
- data/spec/conceptql/operators/standard_vocabulary_operator_spec.rb +0 -35
- data/spec/conceptql/operators/started_by_spec.rb +0 -22
- data/spec/conceptql/operators/temporal_operator_spec.rb +0 -51
- data/spec/conceptql/operators/time_window_spec.rb +0 -77
- data/spec/conceptql/operators/union_spec.rb +0 -21
- data/spec/conceptql/operators/visit_occurrence_spec.rb +0 -10
- data/spec/conceptql/query_spec.rb +0 -22
- data/spec/conceptql/tree_spec.rb +0 -50
- data/spec/doubles/stream_for_casting_double.rb +0 -9
- data/spec/doubles/stream_for_occurrence_double.rb +0 -25
- data/spec/doubles/stream_for_temporal_double.rb +0 -6
- data/spec/spec_helper.rb +0 -102
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "binary_operator_operator"
|
|
4
|
+
|
|
5
|
+
module ConceptQL
|
|
6
|
+
module Operators
|
|
7
|
+
# Compares the columns from #columns. Without options, that is every query
|
|
8
|
+
# column, so a stream matched with itself keeps every record.
|
|
9
|
+
class Match < BinaryOperatorOperator
|
|
10
|
+
register __FILE__
|
|
11
|
+
|
|
12
|
+
desc "Keeps left hand records that have the same values as a right hand side record. By default, every column is compared. only_columns or except_columns narrows that set, and join_columns compares only the person and the listed columns. Two NULL values count as the same value."
|
|
13
|
+
default_query_columns
|
|
14
|
+
|
|
15
|
+
# match reads only_columns, except_columns, join_columns and
|
|
16
|
+
# invert_match, but does not declare them with `option`: only t_shank
|
|
17
|
+
# gives them, and a declared option would appear in ConceptQL.metadata,
|
|
18
|
+
# so the diagram editor would offer it (Ryan, 2026-10-26). Each column
|
|
19
|
+
# list is an array of column names or a comma-separated string (see
|
|
20
|
+
# Operator#column_names_option).
|
|
21
|
+
|
|
22
|
+
# These columns identify a record and are never NULL, so match compares
|
|
23
|
+
# them with =. It compares every other column NULL-safe: the value
|
|
24
|
+
# columns are often NULL, and with = a NULL never equals a NULL.
|
|
25
|
+
KEY_COLUMNS = %i[person_id criterion_id criterion_table criterion_domain start_date uuid window_id].freeze
|
|
26
|
+
|
|
27
|
+
def query(db)
|
|
28
|
+
rhs = right.evaluate(db)
|
|
29
|
+
rhs = rhs.from_self.select_group(*columns)
|
|
30
|
+
query = db.from(Sequel.as(left.evaluate(db), :l))
|
|
31
|
+
|
|
32
|
+
join_check = columns.map { |column| column_check(column) }.inject(&:&)
|
|
33
|
+
sub_select = rhs.from_self(alias: :r)
|
|
34
|
+
.select(1)
|
|
35
|
+
.where(join_check)
|
|
36
|
+
|
|
37
|
+
query.send(where_method(:where), sub_select.exists).select_all(:l)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def columns
|
|
41
|
+
@columns ||= determine_columns
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# join_columns has the meaning that BinaryOperatorOperator gives it:
|
|
45
|
+
# the person (and window) columns plus the listed columns.
|
|
46
|
+
def determine_columns
|
|
47
|
+
join_columns = column_names_option(:join_columns)
|
|
48
|
+
return (matching_columns + join_columns).uniq if join_columns
|
|
49
|
+
|
|
50
|
+
only_columns = column_names_option(:only_columns)
|
|
51
|
+
except_columns = column_names_option(:except_columns)
|
|
52
|
+
columns = dynamic_columns
|
|
53
|
+
columns &= only_columns if only_columns
|
|
54
|
+
columns -= except_columns if except_columns
|
|
55
|
+
columns
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def where_method(meth)
|
|
59
|
+
return meth unless invert_match
|
|
60
|
+
|
|
61
|
+
meth == :where ? :exclude : :where
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Existing statements give the string "true". The string "false" (in any
|
|
65
|
+
# case, with any spaces around it) and a blank value do not invert.
|
|
66
|
+
def invert_match
|
|
67
|
+
text = options[:invert_match].to_s.strip
|
|
68
|
+
!(text.empty? || text.casecmp?("false"))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def column_check(column)
|
|
74
|
+
left_column = Sequel[:l][column]
|
|
75
|
+
right_column = Sequel[:r][column]
|
|
76
|
+
return Sequel.expr([ [ left_column, right_column ] ]) if KEY_COLUMNS.include?(column)
|
|
77
|
+
|
|
78
|
+
rdbms.null_safe_equal(left_column, right_column)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# join_columns sets the whole column set, so only_columns and
|
|
82
|
+
# except_columns would have no effect next to it.
|
|
83
|
+
def additional_validation(_db, _opts = {})
|
|
84
|
+
return unless column_names_option(:join_columns)
|
|
85
|
+
|
|
86
|
+
conflicts = %i[only_columns except_columns].select { |name| column_names_option(name) }
|
|
87
|
+
add_error("conflicting options", "join_columns", *conflicts.map(&:to_s)) unless conflicts.empty?
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ConceptQL
|
|
4
|
+
module Operators
|
|
5
|
+
class MultipleVocabularies < Operator
|
|
6
|
+
include ConceptQL::Behaviors::Windowable
|
|
7
|
+
include ConceptQL::Behaviors::CodeLister
|
|
8
|
+
|
|
9
|
+
class << self
|
|
10
|
+
def multiple_vocabularies
|
|
11
|
+
@multiple_vocabularies ||= get_multiple_vocabularies
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def get_multiple_vocabularies
|
|
15
|
+
[ ConceptQL.multiple_vocabularies_file_path,
|
|
16
|
+
ConceptQL.custom_multiple_vocabularies_file_path ].select(&:exist?).map do |path|
|
|
17
|
+
CSV.foreach(path, headers: true, header_converters: :symbol).each_with_object({}) do |row, h|
|
|
18
|
+
(h[operator_symbol(row[:operator])] ||= []) << row.to_hash
|
|
19
|
+
end
|
|
20
|
+
end.inject(&:merge)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def register_many
|
|
24
|
+
multiple_vocabularies.each_key do |operator_sym|
|
|
25
|
+
register(operator_sym)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def operator_symbol(word)
|
|
30
|
+
word.gsub(/\W+/, "_").downcase
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# This will override the to_metadata method and return the preferred name
|
|
34
|
+
# based on the name listed in the file.
|
|
35
|
+
#
|
|
36
|
+
# This method will be called once for each vocabulary we register
|
|
37
|
+
# for this operator
|
|
38
|
+
def to_metadata(name, opts = {})
|
|
39
|
+
h = super
|
|
40
|
+
op_info = multiple_vocabularies[name].first
|
|
41
|
+
h[:preferred_name] = op_info[:operator]
|
|
42
|
+
h[:predominant_domains] = multiple_vocabularies[name].map { |mv| mv[:domain] }.uniq.compact
|
|
43
|
+
h
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
register_many
|
|
48
|
+
|
|
49
|
+
desc "Selects records based on the given vocabularies."
|
|
50
|
+
argument :codes, type: :codelist
|
|
51
|
+
basic_type :selection
|
|
52
|
+
category "Select by Clinical Codes"
|
|
53
|
+
validate_no_upstreams
|
|
54
|
+
validate_at_least_one_argument
|
|
55
|
+
conceptql_spec_id "vocabulary"
|
|
56
|
+
|
|
57
|
+
def query(db)
|
|
58
|
+
# TODO: A much-more efficient method would be to find all those vocabs
|
|
59
|
+
# sharing a common table and feed them into a single single query,
|
|
60
|
+
# but I think this would require some revamping of Vocabulary, and I'm
|
|
61
|
+
# just not interested in taking that on right now.
|
|
62
|
+
vocab_ops.map { |vo| vo.evaluate(db) }.inject do |union, q|
|
|
63
|
+
union.union(q.from_self)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def domains(_db)
|
|
68
|
+
vocab_ops.map(&:domain).uniq
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Each vocabulary operator in vocab_ops has already applied the scope's
|
|
72
|
+
# windows. Applying them again to the union multiplied rows: an event in
|
|
73
|
+
# k overlapping windows came out k*k times instead of k (conceptql-7cd).
|
|
74
|
+
def skip_windows?
|
|
75
|
+
true
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def source_table
|
|
79
|
+
nil
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def table
|
|
83
|
+
nil
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def domain
|
|
87
|
+
nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def tables
|
|
91
|
+
[]
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# The per-code warnings a Vocabulary operator gives, from most to least
|
|
95
|
+
# specific. See #code_warnings.
|
|
96
|
+
BAD_FORMAT = "improperly formatted code"
|
|
97
|
+
UNKNOWN_CODE = "unknown code(s)"
|
|
98
|
+
CODE_WARNINGS = [ BAD_FORMAT, UNKNOWN_CODE ].freeze
|
|
99
|
+
|
|
100
|
+
def additional_validation(db, opts = {})
|
|
101
|
+
vocab_ops.each { |vo| vo.valid?(db, opts) }
|
|
102
|
+
|
|
103
|
+
@errors += aggregate_messages(vocab_ops.map(&:errors))
|
|
104
|
+
@warnings += aggregate_messages(vocab_ops.map { |vo| vo.warnings.reject { |w| CODE_WARNINGS.include?(w.first) } })
|
|
105
|
+
@warnings += code_warnings
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# A code gets a warning only when every vocabulary rejects it, for any
|
|
109
|
+
# reason. It is an improperly formatted code only when every vocabulary
|
|
110
|
+
# says so. When at least one vocabulary accepts its format but does not
|
|
111
|
+
# know it, it is an unknown code. (12345 is badly formatted for HCPCS but
|
|
112
|
+
# a well-formed, unknown CPT4 code, so it is unknown.)
|
|
113
|
+
def code_warnings
|
|
114
|
+
verdicts = vocab_ops.map do |vo|
|
|
115
|
+
vo.warnings.each_with_object({}) do |(message, *codes), verdict|
|
|
116
|
+
codes.each { |code| verdict[code] ||= message } if CODE_WARNINGS.include?(message)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
rejected = arguments.uniq.select { |code| verdicts.all? { |verdict| verdict.key?(code) } }
|
|
121
|
+
bad_format, unknown = rejected.partition { |code| verdicts.all? { |verdict| verdict[code] == BAD_FORMAT } }
|
|
122
|
+
|
|
123
|
+
[ [ BAD_FORMAT, *bad_format ], [ UNKNOWN_CODE, *unknown ] ].reject { |warning| warning.length == 1 }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def aggregate_messages(message_sets)
|
|
127
|
+
messages = message_sets.map do |message_set|
|
|
128
|
+
message_set.to_h { |message, *args| [ message, args ] }
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
messages_keys = messages.map(&:keys).inject(:|)
|
|
132
|
+
|
|
133
|
+
messages = messages_keys.each.with_object({}) do |key, h|
|
|
134
|
+
h[key] = messages.map { |w| w[key] || [] }.inject(:&)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
messages = messages.reject { |_, v| v.empty? }
|
|
138
|
+
|
|
139
|
+
messages.map { |k, v| [ k, *v ] }
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def describe_codes(db, codes)
|
|
143
|
+
codes = vocab_ops.flat_map do |vo|
|
|
144
|
+
vo.describe_codes(db, codes)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# The first vocabulary that describes a code wins, so CPT4_HCPCS adds
|
|
148
|
+
# descriptions only for codes that CPT4 and HCPCS do not know.
|
|
149
|
+
codes_with_descriptions = codes.select(&:last).uniq(&:first)
|
|
150
|
+
codes_without_descriptions = codes.reject(&:last).map(&:first)
|
|
151
|
+
codes_without_descriptions -= codes_with_descriptions.map(&:first)
|
|
152
|
+
codes_with_descriptions + codes_without_descriptions.zip([])
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# An optional vocabulary (CPT4_HCPCS) has an operator only on a Database
|
|
156
|
+
# whose lexicon carries it, so it is skipped elsewhere. A required one
|
|
157
|
+
# that is missing still raises.
|
|
158
|
+
def vocab_ops
|
|
159
|
+
@vocab_ops ||= self.class.multiple_vocabularies[op_name].filter_map do |op_info|
|
|
160
|
+
name = op_info[:vocabulary_id].to_s.downcase
|
|
161
|
+
next if op_info[:optional] == "yes" && !nodifier.operators.key?(name)
|
|
162
|
+
|
|
163
|
+
nodifier.operators.fetch(name).new(nodifier, name, *arguments)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def preferred_name
|
|
168
|
+
self.class.multiple_vocabularies[op_name].first[:operator]
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "pass_thru"
|
|
2
4
|
|
|
3
5
|
module ConceptQL
|
|
4
6
|
module Operators
|
|
5
7
|
# Represents a operator that will either:
|
|
6
8
|
# - create a value_as_number value for every person in the database
|
|
7
|
-
# - change the value_as_number value for every every
|
|
9
|
+
# - change the value_as_number value for every every record passed in
|
|
8
10
|
# - either to a number
|
|
9
11
|
# - or a value from a column in the origin row
|
|
10
12
|
#
|
|
@@ -12,43 +14,83 @@ module ConceptQL
|
|
|
12
14
|
# - Either a number value or a symbol representing a column name
|
|
13
15
|
# - An optional stream
|
|
14
16
|
class Numeric < PassThru
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
17
|
+
register __FILE__
|
|
18
|
+
|
|
19
|
+
desc <<~EOF
|
|
20
|
+
Represents an operator that will either:
|
|
21
|
+
- Create a value_as_number value for every person in the database
|
|
22
|
+
- Change the value_as_number value for every record passed in
|
|
23
|
+
- Either to a number
|
|
24
|
+
- Or a value from a column in the origin row
|
|
25
|
+
|
|
26
|
+
Accepts two params:
|
|
27
|
+
- Either a number value or a symbol representing a column name
|
|
28
|
+
- An optional stream
|
|
25
29
|
EOF
|
|
26
30
|
argument :value, type: :float
|
|
27
31
|
allows_one_upstream
|
|
32
|
+
validate_at_most_one_upstream
|
|
33
|
+
validate_one_argument
|
|
34
|
+
default_query_columns
|
|
35
|
+
require_column :value_as_number
|
|
28
36
|
|
|
29
37
|
def query(db)
|
|
30
38
|
stream.nil? ? as_criterion(db) : with_kids(db)
|
|
31
39
|
end
|
|
32
40
|
|
|
33
|
-
def
|
|
34
|
-
stream.nil? ? [:person] : super
|
|
41
|
+
def domains(db)
|
|
42
|
+
stream.nil? ? [ :person ] : super
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Without an upstream, Numeric emits exactly one row per patient via the
|
|
46
|
+
# as_criterion path (SELECT value_as_number FROM patients). The leaf
|
|
47
|
+
# default in Operator#events_per_patient assumes :multiple, which is wrong
|
|
48
|
+
# for this single-row-per-patient shape. Override here so that downstream
|
|
49
|
+
# operators (Union, etc.) can correctly conclude that a numeric leaf is
|
|
50
|
+
# single-event-per-patient.
|
|
51
|
+
def events_per_patient
|
|
52
|
+
stream.nil? ? :single : super
|
|
35
53
|
end
|
|
36
54
|
|
|
37
55
|
private
|
|
56
|
+
|
|
38
57
|
def with_kids(db)
|
|
39
|
-
db.from(stream.evaluate(db))
|
|
40
|
-
.select(*(COLUMNS - [:value_as_number]))
|
|
41
|
-
.select_append(Sequel.lit('?', arguments.first).cast(Float).as(:value_as_number))
|
|
42
|
-
.from_self
|
|
58
|
+
dm.selectify(db.from(stream.evaluate(db)), replace: { value_as_number: first_argument })
|
|
43
59
|
end
|
|
44
60
|
|
|
45
61
|
def as_criterion(db)
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
62
|
+
# Explicitly set column_family to DEFAULT_COLUMN_FAMILY here. The
|
|
63
|
+
# selectify path through `domain: :person` runs against the patients
|
|
64
|
+
# table, which doesn't have a column_family column. Without this
|
|
65
|
+
# replace, nullified_columns inserts CAST(NULL AS text) AS
|
|
66
|
+
# "column_family", and the outer evaluate -> select_it wrapper does not
|
|
67
|
+
# re-inject a value (Numeric has no source_table/table/domain methods,
|
|
68
|
+
# so determine_table returns nil and select_it's `cf =
|
|
69
|
+
# DEFAULT_COLUMN_FAMILY` branch never fires). Result: every Numeric
|
|
70
|
+
# as_criterion row carried column_family = NULL. Explicitly replacing
|
|
71
|
+
# it here keeps the fix at the same level as Episode#query, which
|
|
72
|
+
# already sets column_family in its replace hash for the same reason.
|
|
73
|
+
dm.selectify(db.from(dm.table_by_domain(:person)),
|
|
74
|
+
domain: :person,
|
|
75
|
+
replace: {
|
|
76
|
+
value_as_number: first_argument,
|
|
77
|
+
column_family: ConceptQL::Scope::DEFAULT_COLUMN_FAMILY
|
|
78
|
+
})
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def first_argument
|
|
82
|
+
value = arguments.first.to_s.strip
|
|
83
|
+
value = nil if value.blank?
|
|
84
|
+
if !value || is_a_number?(value)
|
|
85
|
+
Sequel.cast_numeric(value, Float)
|
|
86
|
+
else
|
|
87
|
+
Sequel.identifier(value)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def is_a_number?(value)
|
|
92
|
+
value =~ /\A[-+]?[0-9]*\.?[0-9]+\Z/
|
|
50
93
|
end
|
|
51
94
|
end
|
|
52
95
|
end
|
|
53
96
|
end
|
|
54
|
-
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "operator"
|
|
4
|
+
|
|
5
|
+
module ConceptQL
|
|
6
|
+
module Operators
|
|
7
|
+
# Filters the incoming stream of records to only those that have a
|
|
8
|
+
# value_as_number column with a value that matches the specified criteria
|
|
9
|
+
# are passed through
|
|
10
|
+
#
|
|
11
|
+
# If an record has NULL for value_as_number, it is filtered out.
|
|
12
|
+
class NumericFilter < Operator
|
|
13
|
+
register __FILE__
|
|
14
|
+
|
|
15
|
+
desc "Includes records with a value_as_number that match the given criteria."
|
|
16
|
+
|
|
17
|
+
option :greater_than_or_equal_to, type: :float
|
|
18
|
+
option :less_than_or_equal_to, type: :float
|
|
19
|
+
category "Filter Single Stream"
|
|
20
|
+
basic_type :temporal
|
|
21
|
+
allows_one_upstream
|
|
22
|
+
validate_one_upstream
|
|
23
|
+
require_column :value_as_number
|
|
24
|
+
default_query_columns
|
|
25
|
+
|
|
26
|
+
VALUE_COLUMN = Sequel[:value_as_number]
|
|
27
|
+
|
|
28
|
+
def query(db)
|
|
29
|
+
db.from(stream.evaluate(db))
|
|
30
|
+
.where(range_criteria)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def range_criteria
|
|
36
|
+
criteria = []
|
|
37
|
+
criteria << Sequel.~(VALUE_COLUMN => nil)
|
|
38
|
+
criteria << gte_criteria if gte.present?
|
|
39
|
+
criteria << lte_criteria if lte.present?
|
|
40
|
+
criteria.inject(&:&)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def gte
|
|
44
|
+
options[:greater_than_or_equal_to]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def lte
|
|
48
|
+
options[:less_than_or_equal_to]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def gte_literal
|
|
52
|
+
Sequel.cast_numeric(gte.to_f, Float)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def lte_literal
|
|
56
|
+
Sequel.cast_numeric(lte.to_f, Float)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def gte_criteria
|
|
60
|
+
gte_literal <= VALUE_COLUMN
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def lte_criteria
|
|
64
|
+
lte_literal >= VALUE_COLUMN
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "operator"
|
|
4
|
+
require_relative "../behaviors/single_event"
|
|
2
5
|
|
|
3
6
|
module ConceptQL
|
|
4
7
|
module Operators
|
|
@@ -16,55 +19,173 @@ module ConceptQL
|
|
|
16
19
|
# by start_date and then select at most one row per person, regardless
|
|
17
20
|
# of how many different types of streams enter the operator
|
|
18
21
|
#
|
|
19
|
-
# If two rows have the same start_date,
|
|
20
|
-
# is
|
|
22
|
+
# If two rows have the same start_date, criterion_id breaks the tie.
|
|
23
|
+
# criterion_id is unique within one criterion_table, so only rows from
|
|
24
|
+
# two different tables with the same id and start_date still rank in
|
|
25
|
+
# arbitrary order
|
|
21
26
|
#
|
|
22
27
|
# If we ask for the second occurrence of something and a person has only one
|
|
23
28
|
# occurrence, this operator returns nothing for that person
|
|
24
29
|
class Occurrence < Operator
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
-2 => second-to-last
|
|
34
|
-
|
|
35
|
-
If two rows have the same start_date, the order of their ranking
|
|
36
|
-
is arbitrary
|
|
37
|
-
|
|
38
|
-
If we ask for the second occurrence of something and a person has only one
|
|
39
|
-
occurrence, this operator returns nothing for that person
|
|
40
|
-
EOF
|
|
30
|
+
include ConceptQL::Behaviors::SingleEvent
|
|
31
|
+
|
|
32
|
+
register __FILE__
|
|
33
|
+
|
|
34
|
+
preferred_name "Nth Occurrence"
|
|
35
|
+
|
|
36
|
+
desc "Groups all records by person, then orders by start_date and finds the nth occurrence. Can be positive or negative, e.g 2 means 'second' and -3 means 'third from last'."
|
|
37
|
+
|
|
41
38
|
argument :occurrence, type: :integer
|
|
39
|
+
category "Filter Single Stream"
|
|
40
|
+
basic_type :temporal
|
|
42
41
|
allows_one_upstream
|
|
43
|
-
|
|
42
|
+
validate_at_least_one_upstream
|
|
44
43
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
44
|
+
option :at_least, type: :string,
|
|
45
|
+
instructions: 'Enter a numeric value and specify "d", "m", or "y" for "days", "months", or "years". Negative numbers change dates prior to the existing date. Example: -30d = 30 days before the existing date.'
|
|
46
|
+
option :within, type: :string,
|
|
47
|
+
instructions: 'Enter a numeric value and specify "d", "m", or "y" for "days", "months", or "years". Negative numbers change dates prior to the existing date. Example: -30d = 30 days before the existing date.'
|
|
48
|
+
option :group_by_date, type: :boolean,
|
|
49
|
+
instructions: "Choose whether to group by date when determining the nth occurrence, treating occurrences on the same day as a single occurrence"
|
|
50
|
+
|
|
51
|
+
def query_cols
|
|
52
|
+
dynamic_columns + [ :rn ]
|
|
52
53
|
end
|
|
53
54
|
|
|
54
55
|
def occurrence
|
|
55
56
|
@occurrence ||= arguments.first
|
|
56
57
|
end
|
|
57
58
|
|
|
59
|
+
def query(db)
|
|
60
|
+
ds = stream.evaluate(db)
|
|
61
|
+
additional_matching_columns = []
|
|
62
|
+
additional_matching_columns << :start_date if grouped?
|
|
63
|
+
|
|
64
|
+
ds = ds.select_append(
|
|
65
|
+
Sequel[:ROW_NUMBER].function.over(
|
|
66
|
+
partition: matching_columns_plus(*additional_matching_columns),
|
|
67
|
+
order: ordered_columns(:criterion_id)
|
|
68
|
+
).as(:rn)
|
|
69
|
+
)
|
|
70
|
+
ds = ds.from_self
|
|
71
|
+
return ds.where(rn: occurrence.abs) unless timed? || grouped?
|
|
72
|
+
|
|
73
|
+
additional_order_columns = []
|
|
74
|
+
|
|
75
|
+
if grouped?
|
|
76
|
+
one_date_ds = ds.from_self.where(rn: 1).from_self.select_remove(:rn)
|
|
77
|
+
ds = one_date_ds.select_append(
|
|
78
|
+
Sequel[:ROW_NUMBER].function.over(
|
|
79
|
+
partition: matching_columns_plus,
|
|
80
|
+
order: ordered_columns
|
|
81
|
+
).as(:rn)
|
|
82
|
+
).from_self
|
|
83
|
+
end
|
|
84
|
+
return ds.where(rn: occurrence.abs) unless timed?
|
|
85
|
+
|
|
86
|
+
additional_order_columns << :criterion_id
|
|
87
|
+
|
|
88
|
+
input_name = cte_name(:occurrence_input)
|
|
89
|
+
joined_name = cte_name(:occurrence_joined)
|
|
90
|
+
|
|
91
|
+
input_ds = ds.select_append(
|
|
92
|
+
Sequel[:ROW_NUMBER].function.over(
|
|
93
|
+
partition: matching_columns_plus,
|
|
94
|
+
order: ordered_columns(*additional_order_columns, allow_reverse: false)
|
|
95
|
+
).as(:global_rn)
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
first_ds = db[input_name].from_self(alias: :first)
|
|
99
|
+
first = Sequel[:first]
|
|
100
|
+
rest = Sequel[:rest]
|
|
101
|
+
|
|
102
|
+
joined_ds = first_ds.join(input_name, matching_columns_plus.map { |c| [ c, c ] }, table_alias: :rest) do
|
|
103
|
+
cond = rest[:global_rn] > first[:global_rn]
|
|
104
|
+
cond &= rest[:start_date] >= adjust_date(at_least_option, first[:end_date]) if at_least_option
|
|
105
|
+
cond &= rest[:start_date] <= adjust_date(within_option, first[:end_date]) if within_option
|
|
106
|
+
cond | { rest[:global_rn] => first[:global_rn] }
|
|
107
|
+
end
|
|
108
|
+
.select_all(:rest)
|
|
109
|
+
.select_append(first[:global_rn].as(:initial_rn))
|
|
110
|
+
.select_append(
|
|
111
|
+
Sequel[:ROW_NUMBER].function.over(
|
|
112
|
+
partition: matching_columns_plus(:global_rn).map { |c| first[c] },
|
|
113
|
+
order: ordered_columns(*additional_order_columns, qualifier: :rest)
|
|
114
|
+
).as(:timed_rn)
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
db[joined_name]
|
|
118
|
+
.with(input_name, input_ds)
|
|
119
|
+
.with(joined_name, joined_ds)
|
|
120
|
+
.where(timed_rn: occurrence.abs)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def grouped?
|
|
124
|
+
options[:group_by_date]
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# A blank at_least or within counts as not set. An editor can send
|
|
128
|
+
# a blank value for an empty field.
|
|
129
|
+
def timed?
|
|
130
|
+
!(at_least_option || within_option).nil?
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def matching_columns_plus(*columns)
|
|
134
|
+
matching_columns + columns
|
|
135
|
+
end
|
|
136
|
+
|
|
58
137
|
private
|
|
138
|
+
|
|
139
|
+
def within_option
|
|
140
|
+
return unless (v = options[:within])
|
|
141
|
+
return if v.strip.empty?
|
|
142
|
+
|
|
143
|
+
v
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def at_least_option
|
|
147
|
+
return unless (v = options[:at_least])
|
|
148
|
+
return if v.strip.empty?
|
|
149
|
+
|
|
150
|
+
v
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def adjust_date(adjustment, column, reverse = false)
|
|
154
|
+
adjuster = DateAdjuster.new(self, adjustment)
|
|
155
|
+
adjuster.adjust(column, reverse)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def additional_validation(_db, _opts = {})
|
|
159
|
+
if instance_of?(Occurrence)
|
|
160
|
+
validate_one_argument
|
|
161
|
+
else
|
|
162
|
+
validate_no_arguments
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
59
166
|
def asc_or_desc
|
|
60
|
-
occurrence
|
|
167
|
+
occurrence.negative? ? :desc : :asc
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def qualify_with(qualifier, col)
|
|
171
|
+
if qualifier
|
|
172
|
+
Sequel.qualify(qualifier, col)
|
|
173
|
+
else
|
|
174
|
+
col
|
|
175
|
+
end
|
|
61
176
|
end
|
|
62
177
|
|
|
63
|
-
def ordered_columns
|
|
64
|
-
|
|
65
|
-
|
|
178
|
+
def ordered_columns(*columns, qualifier: nil, allow_reverse: true)
|
|
179
|
+
start_date = qualify_with(qualifier, :start_date)
|
|
180
|
+
start_date = Sequel.send(asc_or_desc, start_date) if allow_reverse
|
|
181
|
+
order = [ start_date ]
|
|
182
|
+
order += columns.map do |c|
|
|
183
|
+
c = qualify_with(qualifier, c)
|
|
184
|
+
c = Sequel.send(asc_or_desc, c) if allow_reverse
|
|
185
|
+
c
|
|
186
|
+
end
|
|
187
|
+
order
|
|
66
188
|
end
|
|
67
189
|
end
|
|
68
190
|
end
|
|
69
191
|
end
|
|
70
|
-
|