Class: ForestAdminAgent::Utils::ContextVariablesInjector
- Inherits:
-
Object
- Object
- ForestAdminAgent::Utils::ContextVariablesInjector
- Includes:
- Builder, ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes
- Defined in:
- lib/forest_admin_agent/utils/context_variables_injector.rb
Constant Summary collapse
- REGEX =
/{{([^}]+)}}/- FULL_REFERENCE_REGEX =
/\A{{([^}]+)}}\z/- COMMENT_MARKERS =
['--', '/*'].freeze
- PLACEHOLDER_CONTEXT_ERRORS =
{ string: 'is inside a quoted string literal, which native query bindings do not support', comment: 'is inside a SQL comment, which native query bindings do not support', identifier: 'is inside a quoted identifier - a bind can only ever be a value, never a column or table name' }.freeze
Class Method Summary collapse
- .inject_context_in_filter(filter, context_variables) ⇒ Object
- .inject_context_in_native_query(datasource, connection_name, query, context_variables) ⇒ Object
- .inject_context_in_value(value, context_variables) ⇒ Object
- .inject_context_in_value_custom(value) ⇒ Object
- .raise_unless_live_sql(key, text_before_match) ⇒ Object
-
.resolve_bind_symbol(key, datasource, connection_name, context_variables, state) ⇒ Object
A repeated key's bind can be reused (true, e.g. postgres' $N) or needs a fresh slot per occurrence (false, e.g. "?", purely positional) - unknown until the first repeat, since build_binding_symbol can't declare it upfront.
- .serialize_for_injection(resolved_value) ⇒ Object
- .skip_sql_comment(text, index) ⇒ Object
-
.sql_context_before(text) ⇒ Object
A placeholder inside a quoted string, a "quoted identifier", or a SQL comment can never be a real bind, so scan everything before the match for one.
Class Method Details
.inject_context_in_filter(filter, context_variables) ⇒ Object
137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 |
# File 'lib/forest_admin_agent/utils/context_variables_injector.rb', line 137 def self.inject_context_in_filter(filter, context_variables) return nil unless filter if filter.is_a?(ConditionTreeBranch) return ConditionTreeBranch.new( filter.aggregator, filter.conditions.map { |condition| inject_context_in_filter(condition, context_variables) } ) end ConditionTreeLeaf.new( filter.field, filter.operator, inject_context_in_value(filter.value, context_variables) ) end |
.inject_context_in_native_query(datasource, connection_name, query, context_variables) ⇒ Object
33 34 35 36 37 38 39 40 41 42 43 44 45 |
# File 'lib/forest_admin_agent/utils/context_variables_injector.rb', line 33 def self.inject_context_in_native_query(datasource, connection_name, query, context_variables) return query unless query.is_a?(String) state = { binds: [], replacements: {}, reusable: nil } injected_query = query.gsub(REGEX) do key = ::Regexp.last_match(1) raise_unless_live_sql(key, ::Regexp.last_match.pre_match) resolve_bind_symbol(key, datasource, connection_name, context_variables, state) end [injected_query, state[:binds]] end |
.inject_context_in_value(value, context_variables) ⇒ Object
13 14 15 16 17 18 19 20 21 22 23 24 25 |
# File 'lib/forest_admin_agent/utils/context_variables_injector.rb', line 13 def self.inject_context_in_value(value, context_variables) return value unless value.is_a?(String) # A value that is exactly one {{variable}} reference (no surrounding text) keeps the # resolved object as-is, so a jsonb/array field gets typed-cast correctly by the # datasource instead of being compared against a serialized string that can never match. full_reference = FULL_REFERENCE_REGEX.match(value) return context_variables.get_value(full_reference[1]) if full_reference inject_context_in_value_custom(value) do |context_variable_key| serialize_for_injection(context_variables.get_value(context_variable_key)) end end |
.inject_context_in_value_custom(value) ⇒ Object
121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 |
# File 'lib/forest_admin_agent/utils/context_variables_injector.rb', line 121 def self.inject_context_in_value_custom(value) return value unless value.is_a?(String) # Resolve every distinct {{key}} found in the ORIGINAL string upfront, then substitute # in a single gsub pass. A resolved value can itself contain "{{...}}"-looking text (e.g. # a tag whose data happens to include it); re-scanning a mutated string for placeholders # (the previous while+gsub! approach) would misinterpret that text as a new reference, or # loop forever if it matched an already-resolved key without ever changing the string. keys = value.scan(REGEX).map(&:first).uniq return value if keys.empty? replacements = keys.to_h { |key| [key, yield(key)] } value.gsub(REGEX) { replacements[::Regexp.last_match(1)] } end |
.raise_unless_live_sql(key, text_before_match) ⇒ Object
113 114 115 116 117 118 119 |
# File 'lib/forest_admin_agent/utils/context_variables_injector.rb', line 113 def self.raise_unless_live_sql(key, text_before_match) context = sql_context_before(text_before_match) return if context == :code raise ForestAdminDatasourceToolkit::Exceptions::ForestException, "The '{{#{key}}}' placeholder #{PLACEHOLDER_CONTEXT_ERRORS[context]}." end |
.resolve_bind_symbol(key, datasource, connection_name, context_variables, state) ⇒ Object
A repeated key's bind can be reused (true, e.g. postgres' $N) or needs a fresh slot per occurrence (false, e.g. "?", purely positional) - unknown until the first repeat, since build_binding_symbol can't declare it upfront. Reusing when unsafe drops a bind value a positional driver needs; always assuming fresh costs an extra RPC round-trip.
51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 |
# File 'lib/forest_admin_agent/utils/context_variables_injector.rb', line 51 def self.resolve_bind_symbol(key, datasource, connection_name, context_variables, state) if state[:replacements].key?(key) if state[:reusable].nil? probe = datasource.build_binding_symbol(connection_name, state[:binds]) state[:reusable] = (probe != state[:replacements][key]) end state[:binds] << context_variables.get_value(key) unless state[:reusable] state[:replacements][key] else symbol = datasource.build_binding_symbol(connection_name, state[:binds]) state[:binds] << context_variables.get_value(key) state[:replacements][key] = symbol end end |
.serialize_for_injection(resolved_value) ⇒ Object
27 28 29 30 31 |
# File 'lib/forest_admin_agent/utils/context_variables_injector.rb', line 27 def self.serialize_for_injection(resolved_value) return JSON.generate(resolved_value) if resolved_value.is_a?(Array) || resolved_value.is_a?(Hash) resolved_value.to_s end |
.skip_sql_comment(text, index) ⇒ Object
101 102 103 104 105 |
# File 'lib/forest_admin_agent/utils/context_variables_injector.rb', line 101 def self.skip_sql_comment(text, index) return text.index("\n", index)&.succ if text[index, 2] == '--' text.index('*/', index + 2)&.succ&.succ end |
.sql_context_before(text) ⇒ Object
A placeholder inside a quoted string, a "quoted identifier", or a SQL comment can never
be a real bind, so scan everything before the match for one. Both quote styles share the
same escape convention (a doubled quote character, never a boundary) so one quote
variable tracks whichever is currently open. Comments are skipped wholesale rather than
scanned char by char - a naive quote count would misfire on an apostrophe inside one (e.g.
-- user's note).
73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 |
# File 'lib/forest_admin_agent/utils/context_variables_injector.rb', line 73 def self.sql_context_before(text) quote = nil i = 0 while i < text.length if quote if text[i] == quote && text[i + 1] == quote i += 1 elsif text[i] == quote quote = nil end elsif /['"]/.match?(text[i]) quote = text[i] elsif COMMENT_MARKERS.include?(text[i, 2]) i = skip_sql_comment(text, i) return :comment if i.nil? next end i += 1 end return :code unless quote quote == "'" ? :string : :identifier end |