aws_advanced_ruby_driver_wrapper 1.0.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 (150) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +23 -0
  3. data/LICENSE +175 -0
  4. data/NOTICE +1 -0
  5. data/README.md +168 -0
  6. data/THIRD-PARTY-LICENSES +473 -0
  7. data/aws_advanced_ruby_driver_wrapper.gemspec +73 -0
  8. data/lib/aws_advanced_ruby_driver_wrapper/active_record/aws_mysql2_adapter.rb +73 -0
  9. data/lib/aws_advanced_ruby_driver_wrapper/active_record/aws_postgresql_adapter.rb +95 -0
  10. data/lib/aws_advanced_ruby_driver_wrapper/custom_configuration.rb +58 -0
  11. data/lib/aws_advanced_ruby_driver_wrapper/db_dialects/aurora_mysql_dialect.rb +103 -0
  12. data/lib/aws_advanced_ruby_driver_wrapper/db_dialects/aurora_pg_dialect.rb +124 -0
  13. data/lib/aws_advanced_ruby_driver_wrapper/db_dialects/dialect_codes.rb +38 -0
  14. data/lib/aws_advanced_ruby_driver_wrapper/db_dialects/global_mysql_dialect.rb +91 -0
  15. data/lib/aws_advanced_ruby_driver_wrapper/db_dialects/global_pg_dialect.rb +92 -0
  16. data/lib/aws_advanced_ruby_driver_wrapper/db_dialects/multi_az_cluster_mysql_dialect.rb +95 -0
  17. data/lib/aws_advanced_ruby_driver_wrapper/db_dialects/multi_az_cluster_pg_dialect.rb +86 -0
  18. data/lib/aws_advanced_ruby_driver_wrapper/db_dialects/mysql_dialect.rb +98 -0
  19. data/lib/aws_advanced_ruby_driver_wrapper/db_dialects/pg_dialect.rb +95 -0
  20. data/lib/aws_advanced_ruby_driver_wrapper/db_dialects/rds_mysql_dialect.rb +88 -0
  21. data/lib/aws_advanced_ruby_driver_wrapper/db_dialects/rds_pg_dialect.rb +86 -0
  22. data/lib/aws_advanced_ruby_driver_wrapper/db_dialects/unknown_dialect.rb +72 -0
  23. data/lib/aws_advanced_ruby_driver_wrapper/db_dialects/utils/dialect_utils.rb +71 -0
  24. data/lib/aws_advanced_ruby_driver_wrapper/driver_dialects/driver_dialect.rb +154 -0
  25. data/lib/aws_advanced_ruby_driver_wrapper/driver_dialects/driver_dialect_manager.rb +55 -0
  26. data/lib/aws_advanced_ruby_driver_wrapper/driver_dialects/mysql_driver_dialect.rb +165 -0
  27. data/lib/aws_advanced_ruby_driver_wrapper/driver_dialects/pg_driver_dialect.rb +201 -0
  28. data/lib/aws_advanced_ruby_driver_wrapper/errors/error_handler.rb +62 -0
  29. data/lib/aws_advanced_ruby_driver_wrapper/errors/mysql_error_handler.rb +80 -0
  30. data/lib/aws_advanced_ruby_driver_wrapper/errors/pg_error_handler.rb +126 -0
  31. data/lib/aws_advanced_ruby_driver_wrapper/errors.rb +59 -0
  32. data/lib/aws_advanced_ruby_driver_wrapper/host/connection_string_host_list_provider.rb +95 -0
  33. data/lib/aws_advanced_ruby_driver_wrapper/host/global_aurora_host_list_provider.rb +65 -0
  34. data/lib/aws_advanced_ruby_driver_wrapper/host/host_availability.rb +24 -0
  35. data/lib/aws_advanced_ruby_driver_wrapper/host/host_availability_strategy.rb +27 -0
  36. data/lib/aws_advanced_ruby_driver_wrapper/host/host_info.rb +137 -0
  37. data/lib/aws_advanced_ruby_driver_wrapper/host/host_role.rb +25 -0
  38. data/lib/aws_advanced_ruby_driver_wrapper/host/random_host_selector.rb +40 -0
  39. data/lib/aws_advanced_ruby_driver_wrapper/host/rds_host_list_provider.rb +206 -0
  40. data/lib/aws_advanced_ruby_driver_wrapper/logging.rb +110 -0
  41. data/lib/aws_advanced_ruby_driver_wrapper/monitoring/cluster_topology_monitor.rb +709 -0
  42. data/lib/aws_advanced_ruby_driver_wrapper/monitoring/global_cluster_topology_monitor.rb +72 -0
  43. data/lib/aws_advanced_ruby_driver_wrapper/monitoring/monitor.rb +99 -0
  44. data/lib/aws_advanced_ruby_driver_wrapper/monitoring/monitor_connection.rb +57 -0
  45. data/lib/aws_advanced_ruby_driver_wrapper/monitoring/monitor_state.rb +25 -0
  46. data/lib/aws_advanced_ruby_driver_wrapper/mysql.rb +429 -0
  47. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/blue_green_plugin.rb +205 -0
  48. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/host_mapper.rb +132 -0
  49. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/iam_host_tracker.rb +84 -0
  50. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/interim_status.rb +92 -0
  51. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/interval_rate.rb +27 -0
  52. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/phase.rb +69 -0
  53. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/phase_event_log.rb +85 -0
  54. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/phase_time_info.rb +25 -0
  55. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/role.rb +38 -0
  56. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/routing/base_routing.rb +83 -0
  57. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/routing/reject_connect_routing.rb +40 -0
  58. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/routing/substitute_connect_routing.rb +136 -0
  59. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/routing/suspend_connect_routing.rb +53 -0
  60. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/routing/suspend_execute_routing.rb +52 -0
  61. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/routing/suspend_until_corresponding_host_found_connect_routing.rb +83 -0
  62. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/status.rb +68 -0
  63. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/status_builder.rb +244 -0
  64. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/status_info.rb +30 -0
  65. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/status_monitor.rb +564 -0
  66. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/status_provider.rb +414 -0
  67. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/switchover_state.rb +98 -0
  68. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/switchover_timer.rb +46 -0
  69. data/lib/aws_advanced_ruby_driver_wrapper/plugins/custom_endpoint/custom_endpoint_monitor.rb +266 -0
  70. data/lib/aws_advanced_ruby_driver_wrapper/plugins/custom_endpoint/custom_endpoint_plugin.rb +158 -0
  71. data/lib/aws_advanced_ruby_driver_wrapper/plugins/custom_endpoint/info.rb +111 -0
  72. data/lib/aws_advanced_ruby_driver_wrapper/plugins/custom_endpoint/member_list_type.rb +31 -0
  73. data/lib/aws_advanced_ruby_driver_wrapper/plugins/custom_endpoint/role.rb +45 -0
  74. data/lib/aws_advanced_ruby_driver_wrapper/plugins/default_plugin.rb +108 -0
  75. data/lib/aws_advanced_ruby_driver_wrapper/plugins/failover_mode.rb +43 -0
  76. data/lib/aws_advanced_ruby_driver_wrapper/plugins/failover_plugin.rb +467 -0
  77. data/lib/aws_advanced_ruby_driver_wrapper/plugins/gdb/gdb_failover_mode.rb +68 -0
  78. data/lib/aws_advanced_ruby_driver_wrapper/plugins/gdb/gdb_failover_plugin.rb +403 -0
  79. data/lib/aws_advanced_ruby_driver_wrapper/plugins/iam_auth_plugin.rb +159 -0
  80. data/lib/aws_advanced_ruby_driver_wrapper/plugins/initial_connection_strategy_plugin.rb +485 -0
  81. data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/audit_logger.rb +157 -0
  82. data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/column_cipher.rb +159 -0
  83. data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/column_encryption_config.rb +61 -0
  84. data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/connection_source.rb +91 -0
  85. data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/data_key_cache.rb +220 -0
  86. data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/encryption_algorithm.rb +75 -0
  87. data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/encryption_config.rb +146 -0
  88. data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/encryption_service.rb +391 -0
  89. data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/error_context.rb +198 -0
  90. data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/errors.rb +259 -0
  91. data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/key_management_utility.rb +435 -0
  92. data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/key_manager.rb +378 -0
  93. data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/key_metadata.rb +86 -0
  94. data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/kms_encryption_plugin.rb +890 -0
  95. data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/kms_encryption_utility.rb +281 -0
  96. data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/metadata_manager.rb +332 -0
  97. data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/sanitizer.rb +147 -0
  98. data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/schema_name.rb +70 -0
  99. data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/schema_validator.rb +211 -0
  100. data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/sql_runner.rb +147 -0
  101. data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/type_marker.rb +109 -0
  102. data/lib/aws_advanced_ruby_driver_wrapper/plugins/secrets_manager_plugin.rb +358 -0
  103. data/lib/aws_advanced_ruby_driver_wrapper/postgresql.rb +659 -0
  104. data/lib/aws_advanced_ruby_driver_wrapper/property_definition.rb +409 -0
  105. data/lib/aws_advanced_ruby_driver_wrapper/ruby_method.rb +122 -0
  106. data/lib/aws_advanced_ruby_driver_wrapper/services/connection_service.rb +143 -0
  107. data/lib/aws_advanced_ruby_driver_wrapper/services/dialect_service.rb +267 -0
  108. data/lib/aws_advanced_ruby_driver_wrapper/services/host_service.rb +199 -0
  109. data/lib/aws_advanced_ruby_driver_wrapper/services/monitor_service.rb +186 -0
  110. data/lib/aws_advanced_ruby_driver_wrapper/services/plugin_call_context.rb +63 -0
  111. data/lib/aws_advanced_ruby_driver_wrapper/services/plugin_manager.rb +273 -0
  112. data/lib/aws_advanced_ruby_driver_wrapper/services/service_container.rb +30 -0
  113. data/lib/aws_advanced_ruby_driver_wrapper/services/service_utility.rb +78 -0
  114. data/lib/aws_advanced_ruby_driver_wrapper/services/session_state_service.rb +56 -0
  115. data/lib/aws_advanced_ruby_driver_wrapper/utils/accessible_regions.rb +52 -0
  116. data/lib/aws_advanced_ruby_driver_wrapper/utils/ar_constants.rb +25 -0
  117. data/lib/aws_advanced_ruby_driver_wrapper/utils/aurora_topology_utils.rb +99 -0
  118. data/lib/aws_advanced_ruby_driver_wrapper/utils/aws_credentials_utils.rb +62 -0
  119. data/lib/aws_advanced_ruby_driver_wrapper/utils/connection_config.rb +91 -0
  120. data/lib/aws_advanced_ruby_driver_wrapper/utils/connection_config_parser.rb +368 -0
  121. data/lib/aws_advanced_ruby_driver_wrapper/utils/conversion_utils.rb +51 -0
  122. data/lib/aws_advanced_ruby_driver_wrapper/utils/events/batching_event_publisher.rb +119 -0
  123. data/lib/aws_advanced_ruby_driver_wrapper/utils/events/data_access_event.rb +26 -0
  124. data/lib/aws_advanced_ruby_driver_wrapper/utils/events/monitor_reset_event.rb +26 -0
  125. data/lib/aws_advanced_ruby_driver_wrapper/utils/global_aurora_topology_utils.rb +185 -0
  126. data/lib/aws_advanced_ruby_driver_wrapper/utils/host_list_utils.rb +27 -0
  127. data/lib/aws_advanced_ruby_driver_wrapper/utils/iam_auth_utils.rb +112 -0
  128. data/lib/aws_advanced_ruby_driver_wrapper/utils/multi_az_topology_utils.rb +117 -0
  129. data/lib/aws_advanced_ruby_driver_wrapper/utils/parser/encryption_annotation_parser.rb +99 -0
  130. data/lib/aws_advanced_ruby_driver_wrapper/utils/parser/mysql_statement_analyzer.rb +641 -0
  131. data/lib/aws_advanced_ruby_driver_wrapper/utils/parser/pg_statement_analyzer.rb +502 -0
  132. data/lib/aws_advanced_ruby_driver_wrapper/utils/parser/query_analysis.rb +63 -0
  133. data/lib/aws_advanced_ruby_driver_wrapper/utils/parser/query_type.rb +35 -0
  134. data/lib/aws_advanced_ruby_driver_wrapper/utils/parser/routing_hint.rb +27 -0
  135. data/lib/aws_advanced_ruby_driver_wrapper/utils/parser/routing_hint_parser.rb +50 -0
  136. data/lib/aws_advanced_ruby_driver_wrapper/utils/parser/sql_parser.rb +139 -0
  137. data/lib/aws_advanced_ruby_driver_wrapper/utils/rds_url_type.rb +71 -0
  138. data/lib/aws_advanced_ruby_driver_wrapper/utils/rds_utils.rb +575 -0
  139. data/lib/aws_advanced_ruby_driver_wrapper/utils/retry_util.rb +153 -0
  140. data/lib/aws_advanced_ruby_driver_wrapper/utils/sql_encoding.rb +56 -0
  141. data/lib/aws_advanced_ruby_driver_wrapper/utils/sql_method_analyzer.rb +195 -0
  142. data/lib/aws_advanced_ruby_driver_wrapper/utils/storage/cache_entry.rb +56 -0
  143. data/lib/aws_advanced_ruby_driver_wrapper/utils/storage/expiration_cache.rb +108 -0
  144. data/lib/aws_advanced_ruby_driver_wrapper/utils/storage/sliding_expiration_cache.rb +137 -0
  145. data/lib/aws_advanced_ruby_driver_wrapper/utils/storage/storage_service.rb +172 -0
  146. data/lib/aws_advanced_ruby_driver_wrapper/utils/topology_utils.rb +127 -0
  147. data/lib/aws_advanced_ruby_driver_wrapper/version.rb +19 -0
  148. data/lib/aws_advanced_ruby_driver_wrapper/wrapper_property.rb +64 -0
  149. data/lib/aws_advanced_ruby_driver_wrapper.rb +116 -0
  150. metadata +227 -0
@@ -0,0 +1,641 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License").
6
+ # You may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+
17
+ require_relative 'query_analysis'
18
+ require_relative 'query_type'
19
+
20
+ module AwsAdvancedRubyDriverWrapper
21
+ module Utils
22
+ module Parser
23
+ module MysqlStatementAnalyzer
24
+ # One part of a name, quoted or not. MySQL quotes a part on its own, which is how it writes a
25
+ # name that would otherwise be a reserved word.
26
+ #
27
+ # The non-capturing group is required, not redundant: this constant is interpolated into larger
28
+ # patterns (IDENTIFIER_CAP, QUALIFIED_COLUMN, ...) where its alternation must stay grouped so it
29
+ # binds correctly against the surrounding pattern.
30
+ IDENTIFIER_PART = /(?:`[^`]+`|"[^"]+"|\w+)/
31
+ # A name, in as many parts as it was written in: +ssn+, +u.ssn+, +`users`.`ssn`+,
32
+ # +mydb.users.ssn+. The parts are spelled out rather than assuming that a quoted name is a
33
+ # name of one part.
34
+ IDENTIFIER_CAP = /(#{IDENTIFIER_PART}(?:\.#{IDENTIFIER_PART})*)/
35
+ # Same as IDENTIFIER_CAP but without the outer capturing group. No wrapping group is needed:
36
+ # the content is a sequence, not an alternation, so it binds correctly wherever it is interpolated.
37
+ IDENTIFIER_NC = /#{IDENTIFIER_PART}(?:\.#{IDENTIFIER_PART})*/
38
+
39
+ # The modifiers MySQL allows between the keyword and the table it writes. They say how the
40
+ # statement behaves, not what it writes, so the table has to be looked for past them: a
41
+ # statement whose table went unread is one whose columns go unencrypted.
42
+ # REPLACE writes exactly like INSERT does, so it is read the same way.
43
+ INSERT_START = /\b(?:INSERT|REPLACE)\s+(?:(?:LOW_PRIORITY|HIGH_PRIORITY|DELAYED)\s+)?(?:IGNORE\s+)?INTO\s+/i
44
+ # Whitespace runs are possessive so they cannot be given back to a following +(.*?)+, which
45
+ # would make UPDATE_REFERENCES quadratic on an UPDATE with a long run of whitespace and no SET.
46
+ UPDATE_START = /\bUPDATE\s++(?:LOW_PRIORITY\s++)?(?:IGNORE\s++)?/i
47
+
48
+ INSERT_INTO = /#{INSERT_START}#{IDENTIFIER_CAP}/i
49
+ UPDATE_TABLE = /#{UPDATE_START}#{IDENTIFIER_CAP}/i
50
+ DELETE_FROM = /\bDELETE\s+FROM\s+#{IDENTIFIER_CAP}/i
51
+ CREATE_TABLE = /\bCREATE\s+(?:TEMPORARY\s+)?TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?#{IDENTIFIER_CAP}/i
52
+ DROP_TABLE = /\bDROP\s+TABLE\s+(?:IF\s+EXISTS\s+)?#{IDENTIFIER_CAP}/i
53
+
54
+ # Everything an UPDATE names between its keyword and its SET clause: one table reference, or
55
+ # several when it writes more than one table.
56
+ UPDATE_REFERENCES = /#{UPDATE_START}(.*?)\bSET\b/im
57
+ # What one table reference is joined to the next with. STRAIGHT_JOIN is spelled out because
58
+ # there is no word boundary in front of the JOIN inside it.
59
+ JOIN_KEYWORD = /\bSTRAIGHT_JOIN\b|\bJOIN\b/i
60
+ # The table a reference begins with, whatever follows it: an alias, an index hint, an ON
61
+ # condition.
62
+ LEADING_IDENTIFIER = /\A\s*#{IDENTIFIER_CAP}/
63
+
64
+ INSERT_COLUMNS = /#{INSERT_START}#{IDENTIFIER_NC}\s*\(([^)]+)\)/i
65
+ # The body of a SET clause, ended at the WHERE that follows it, at the statement terminator, or
66
+ # at the end of the text. Matched against a copy with every quoted literal blanked (see
67
+ # +mask_quoted_literals+), so a +WHERE+ or +;+ that is only part of a string value does not end
68
+ # the clause early; the offsets it reports still index the original text.
69
+ SET_CLAUSE = /\bSET\b([^;]+?)(?:\bWHERE\b|\z)/im
70
+ VALUES_CLAUSE = /\A\s*VALUES?\s*/i
71
+ ON_DUPLICATE = /\AON\s+DUPLICATE\s+KEY\s+UPDATE\b/i
72
+ ASSIGNMENT = /\A#{IDENTIFIER_CAP}\s*=\s*(.+)\z/m
73
+ NULL_VALUE = /\ANULL\z/i
74
+ QUOTES = ["'", '"', '`'].freeze
75
+ # The body of a WHERE clause, ended at the first trailing-clause keyword or at the end of the
76
+ # text. Like SET_CLAUSE, matched against a quote-blanked copy so a keyword that is only part of
77
+ # a string value - +WHERE note = 'a ORDER BY b'+ - does not end the clause early.
78
+ WHERE_CLAUSE = /
79
+ \bWHERE\b([^;]+?)
80
+ (?:\bGROUP\s+BY\b|\bHAVING\b|\bORDER\s+BY\b|\bLIMIT\b|
81
+ \bFOR\s+(?:UPDATE|SHARE|NO\s+KEY\s+UPDATE|KEY\s+SHARE)\b|\z)
82
+ /imx
83
+ FOR_UPDATE = /\bFOR\s+(?:UPDATE|SHARE|NO\s+KEY\s+UPDATE|KEY\s+SHARE)\b/i
84
+
85
+ # Captured column-name group per alternative: 1 = BETWEEN col (2 = BETWEEN sentinel),
86
+ # 3 = +col OP ?+, 4 = +? OP col+ (the parameter on the left), 5 = IN col, 6 = LIKE col.
87
+ WHERE_PATTERN = /
88
+ (#{IDENTIFIER_NC})
89
+ \s+(?:NOT\s+)?BETWEEN\s*\?\s+AND\s*\?()
90
+ |
91
+ (#{IDENTIFIER_NC})\s*[=<>!]+\s*\?
92
+ |
93
+ \?\s*[=<>!]+\s*(#{IDENTIFIER_NC})
94
+ |
95
+ (#{IDENTIFIER_NC})\s+IN\s*\([^)]*\?[^)]*\)
96
+ |
97
+ (#{IDENTIFIER_NC})\s+(?:NOT\s+)?LIKE\s*\?
98
+ /imx
99
+
100
+ FROM_TABLE = /\bFROM\s+#{IDENTIFIER_CAP}/i
101
+ JOIN_TABLE = /\bJOIN\s+#{IDENTIFIER_CAP}/i
102
+
103
+ SELECT_KEYWORD = /\ASELECT\b/
104
+ INSERT_KEYWORD = /\A(?:INSERT|REPLACE)\b/
105
+ UPDATE_KEYWORD = /\AUPDATE\b/
106
+ DELETE_KEYWORD = /\ADELETE\b/
107
+ CREATE_KEYWORD = /\ACREATE\b/
108
+ DROP_KEYWORD = /\ADROP\b/
109
+
110
+ # Whitespace and comments in front of a statement. Query instrumentation and ORMs prepend a
111
+ # comment routinely, and it says nothing about what the statement does.
112
+ # Wrapped in an atomic group, and matching whitespace one character at a time rather than in
113
+ # +\s++ runs, so that leading whitespace and comments cannot be re-partitioned on backtracking
114
+ # - which would be quadratic on input like "/* /* /* ...".
115
+ LEADING_NOISE = %r{\A(?>(?:\s|/\*.*?\*/|--[^\n]*|#[^\n]*)+)}m
116
+ CTE_START = /\AWITH\s+(?:RECURSIVE\s+)?/i
117
+ CTE_NAME = /\A#{IDENTIFIER_NC}\s*/
118
+ CTE_AS = /\AAS\s+(?:(?:NOT\s+)?MATERIALIZED\s*)?/i
119
+
120
+ # Every part of a name can be quoted, so all of them are taken off rather than only the ones
121
+ # at the ends: +`mydb`.`users`+ is the one table +mydb.users+.
122
+ STRIP_QUOTES = /[`"']/
123
+
124
+ # A column named with something in front of it: +u.ssn+, +users.ssn+, +db.users.ssn+. Only
125
+ # the last part is the column. A quoted identifier can hold a dot of its own, so the parts
126
+ # are matched rather than split on, which keeps a column actually named +`a.b`+ intact.
127
+ QUALIFIED_COLUMN = /\A(?:#{IDENTIFIER_PART}\.)+(#{IDENTIFIER_PART})\z/
128
+
129
+ module_function
130
+
131
+ def analyze(sql)
132
+ return QueryAnalysis.unknown unless sql.is_a?(String) && !sql.strip.empty?
133
+
134
+ body, preceding_parameters = statement_body(sql)
135
+ return QueryAnalysis.unknown if body.nil?
136
+
137
+ # A statement that writes is read from its keyword onwards, so that a value list belonging
138
+ # to a common table expression is not mistaken for its own. A SELECT is read from the whole
139
+ # text, so that the tables a common table expression reads are reported as well.
140
+ case body.upcase
141
+ when SELECT_KEYWORD then extract_select(sql)
142
+ when INSERT_KEYWORD then extract_insert(body, preceding_parameters + 1)
143
+ when UPDATE_KEYWORD then extract_update(body, preceding_parameters + 1)
144
+ when DELETE_KEYWORD then extract_delete(body)
145
+ when CREATE_KEYWORD then extract_create(body)
146
+ when DROP_KEYWORD then extract_drop(body)
147
+ else
148
+ QueryAnalysis.unknown
149
+ end
150
+ end
151
+
152
+ # A statement with whatever precedes its keyword taken off.
153
+ #
154
+ # @return [Array(String, Integer)] the statement from its own keyword onwards, and the number
155
+ # of bind parameters that come before it; a pair of nils when what precedes the keyword
156
+ # cannot be read, since then neither the statement nor its parameter numbering is known
157
+ def statement_body(sql)
158
+ body = sql.sub(LEADING_NOISE, '')
159
+ return [body, 0] unless CTE_START.match?(body)
160
+
161
+ rest = cte_tail(body)
162
+ return [nil, nil] if rest.nil?
163
+
164
+ [rest.sub(LEADING_NOISE, ''), placeholder_count(body[0...(body.length - rest.length)])]
165
+ end
166
+
167
+ # Walks a +WITH+ clause, one +name [(columns)] AS (subquery)+ entry at a time.
168
+ #
169
+ # @return [String, nil] the text that follows the clause, or nil when an entry could not be
170
+ # taken apart
171
+ def cte_tail(text)
172
+ rest = text.sub(CTE_START, '')
173
+
174
+ loop do
175
+ rest = rest.sub(LEADING_NOISE, '')
176
+ name = CTE_NAME.match(rest)
177
+ return nil unless name
178
+
179
+ rest = rest[name.end(0)..]
180
+ rest = skip_group(rest) if rest.start_with?('(') # the entry names its own columns
181
+ return nil if rest.nil?
182
+
183
+ as_keyword = CTE_AS.match(rest)
184
+ return nil unless as_keyword
185
+
186
+ rest = skip_group(rest[as_keyword.end(0)..])
187
+ return nil if rest.nil?
188
+
189
+ rest = rest.lstrip
190
+ break unless rest.start_with?(',')
191
+
192
+ rest = rest[1..]
193
+ end
194
+
195
+ rest
196
+ end
197
+
198
+ # @return [String, nil] the text that follows a leading +(...)+, or nil when it is not
199
+ # balanced
200
+ def skip_group(text)
201
+ _group, rest = balanced_group(text)
202
+ rest&.lstrip
203
+ end
204
+
205
+ def extract_select(sql)
206
+ tables = extract_all_tables(sql)
207
+ where_cols = extract_where_columns(sql)
208
+ QueryAnalysis.new(
209
+ query_type: QueryType::SELECT,
210
+ tables: tables.freeze,
211
+ write_columns: [].freeze,
212
+ where_columns: where_cols.freeze,
213
+ for_update: FOR_UPDATE.match?(sql),
214
+ parameterized: sql.include?('?')
215
+ )
216
+ end
217
+
218
+ # @param first_index [Integer] the number the statement's first bind parameter has
219
+ def extract_insert(sql, first_index)
220
+ table = extract_first_capture(INSERT_INTO, sql)
221
+ bound, unbound, complete = extract_insert_columns(sql, table, first_index)
222
+ QueryAnalysis.new(
223
+ query_type: QueryType::INSERT,
224
+ tables: table ? [table].freeze : [].freeze,
225
+ write_columns: bound.freeze,
226
+ where_columns: [].freeze,
227
+ for_update: false,
228
+ parameterized: sql.include?('?'),
229
+ unbound_write_columns: unbound.freeze,
230
+ write_columns_complete: complete
231
+ )
232
+ end
233
+
234
+ # @param first_index [Integer] the number the statement's first bind parameter has
235
+ def extract_update(sql, first_index)
236
+ references = UPDATE_REFERENCES.match(sql)&.[](1)
237
+ return extract_multi_table_update(sql, references, first_index) if references && multiple_references?(references)
238
+
239
+ table = extract_first_capture(UPDATE_TABLE, sql)
240
+ set_cols, unbound, complete = extract_set_columns(sql, table, first_index)
241
+ where_cols = extract_where_columns(sql, first_index)
242
+ QueryAnalysis.new(
243
+ query_type: QueryType::UPDATE,
244
+ tables: table ? [table].freeze : [].freeze,
245
+ write_columns: set_cols.freeze,
246
+ where_columns: where_cols.freeze,
247
+ for_update: false,
248
+ parameterized: sql.include?('?'),
249
+ unbound_write_columns: unbound.freeze,
250
+ write_columns_complete: complete
251
+ )
252
+ end
253
+
254
+ # Whether an UPDATE writes more than one table, which MySQL allows and which changes what an
255
+ # assignment means: with two tables in front of it, +SET a.ssn = ?+ belongs to whichever of
256
+ # them the reference list gave the name +a+ to, and that is more than this reader tracks.
257
+ # A reference list it cannot split is counted as more than one reference for the same reason.
258
+ def multiple_references?(references)
259
+ return true if JOIN_KEYWORD.match?(references)
260
+
261
+ entries = split_top_level(references)
262
+ entries.nil? || entries.length > 1
263
+ end
264
+
265
+ # An UPDATE of more than one table, reported as a statement whose written columns could not be
266
+ # enumerated. Attributing an assignment to the first table named would be a guess, and a wrong
267
+ # guess either leaves a plaintext in an encrypted column or writes a value under a key nothing
268
+ # reads it back with, so nothing is attributed at all. Every table the statement names is
269
+ # reported, which is what lets a caller see whether any of them holds a column worth
270
+ # protecting, and a column assigned something other than a bind parameter is reported without
271
+ # its table, since the column name is known even when the table it sits in is not.
272
+ #
273
+ # @param first_index [Integer] the number the statement's first bind parameter has
274
+ def extract_multi_table_update(sql, references, first_index)
275
+ _bound, unbound, = extract_set_columns(sql, nil, first_index)
276
+ QueryAnalysis.new(
277
+ query_type: QueryType::UPDATE,
278
+ tables: reference_tables(references).freeze,
279
+ write_columns: [].freeze,
280
+ where_columns: extract_where_columns(sql, first_index),
281
+ for_update: false,
282
+ parameterized: sql.include?('?'),
283
+ unbound_write_columns: unbound.freeze,
284
+ write_columns_complete: false
285
+ )
286
+ end
287
+
288
+ # The tables a reference list names, as far as they can be read. A reference this cannot read
289
+ # contributes nothing rather than a guess, and a list it reads nothing from leaves no tables at
290
+ # all, which is what says the statement could not be placed anywhere.
291
+ def reference_tables(references)
292
+ (split_top_level(references) || [references])
293
+ .flat_map { |entry| entry.split(JOIN_KEYWORD) }
294
+ .filter_map { |fragment| extract_first_capture(LEADING_IDENTIFIER, fragment) }
295
+ .uniq
296
+ end
297
+
298
+ def extract_delete(sql)
299
+ table = extract_first_capture(DELETE_FROM, sql)
300
+ where_cols = extract_where_columns(sql)
301
+ QueryAnalysis.new(
302
+ query_type: QueryType::DELETE,
303
+ tables: table ? [table].freeze : [].freeze,
304
+ write_columns: [].freeze,
305
+ where_columns: where_cols.freeze,
306
+ for_update: false,
307
+ parameterized: sql.include?('?')
308
+ )
309
+ end
310
+
311
+ def extract_create(sql)
312
+ table = extract_first_capture(CREATE_TABLE, sql)
313
+ QueryAnalysis.new(
314
+ query_type: QueryType::CREATE,
315
+ tables: table ? [table].freeze : [].freeze,
316
+ write_columns: [].freeze,
317
+ where_columns: [].freeze,
318
+ for_update: false,
319
+ parameterized: false
320
+ )
321
+ end
322
+
323
+ def extract_drop(sql)
324
+ table = extract_first_capture(DROP_TABLE, sql)
325
+ QueryAnalysis.new(
326
+ query_type: QueryType::DROP,
327
+ tables: table ? [table].freeze : [].freeze,
328
+ write_columns: [].freeze,
329
+ where_columns: [].freeze,
330
+ for_update: false,
331
+ parameterized: false
332
+ )
333
+ end
334
+
335
+ def extract_first_capture(pattern, sql)
336
+ match = pattern.match(sql)
337
+ match && strip_quotes(match[1])
338
+ end
339
+
340
+ def extract_all_tables(sql)
341
+ table_names = []
342
+ sql.scan(FROM_TABLE) { table_names << strip_quotes(Regexp.last_match(1)) }
343
+ sql.scan(JOIN_TABLE) { table_names << strip_quotes(Regexp.last_match(1)) }
344
+ table_names.uniq.freeze
345
+ end
346
+
347
+ # The columns an INSERT writes, each paired with the bind parameter that fills it.
348
+ #
349
+ # Both of the shapes MySQL accepts are read here: a column list followed by a VALUES clause,
350
+ # and the +INSERT ... SET+ form. Anything else leaves the written columns unenumerated, since
351
+ # without a column list the values are positional over the table's own column order, which
352
+ # the statement does not carry, and with a nested SELECT the values never pass through the
353
+ # client at all.
354
+ #
355
+ # @param first_index [Integer] the number the statement's first bind parameter has
356
+ # @return [Array(Array<ColumnInfo>, Array<ColumnInfo>, Boolean)] the columns filled by a bind
357
+ # parameter, those filled by something else, and whether every written column was found
358
+ def extract_insert_columns(sql, table_name, first_index)
359
+ declared = INSERT_COLUMNS.match(sql)
360
+ return extract_set_columns(sql, table_name, first_index) unless declared
361
+
362
+ columns = split_top_level(declared[1])&.map { |column_token| column_name_of(column_token) }
363
+ rows, trailing = value_rows(sql[declared.end(0)..])
364
+ return [[], [], false] if columns.nil? || rows.nil?
365
+
366
+ bound = []
367
+ unbound = []
368
+ complete = true
369
+ index = first_index
370
+
371
+ rows.each do |values|
372
+ complete = false unless values.length == columns.length
373
+ columns.each_with_index do |column_name, position|
374
+ value = values[position]
375
+ if value == '?'
376
+ bound << ColumnInfo.new(table_name: table_name, column_name: column_name, parameter_index: index)
377
+ elsif !NULL_VALUE.match?(value.to_s)
378
+ unbound << ColumnInfo.new(table_name: table_name, column_name: column_name)
379
+ end
380
+ index += placeholder_count(value.to_s)
381
+ end
382
+ # A row with more values than columns is malformed, but its parameters are still counted
383
+ # so that anything after it keeps the right index.
384
+ values.drop(columns.length).each { |extra| index += placeholder_count(extra) }
385
+ end
386
+
387
+ upsert_bound, upsert_unbound, upsert_complete = extract_on_duplicate(trailing, table_name, index)
388
+ [bound + upsert_bound, unbound + upsert_unbound, complete && upsert_complete]
389
+ end
390
+
391
+ # @param first_index [Integer] the number the statement's first bind parameter has
392
+ # @return [Array(Array<ColumnInfo>, Array<ColumnInfo>, Boolean)] as extract_insert_columns
393
+ def extract_set_columns(sql, table_name, first_index)
394
+ match = SET_CLAUSE.match(mask_quoted_literals(sql))
395
+ return [[], [], false] unless match
396
+
397
+ # The offsets come from the masked copy; the clause read from them is the original text.
398
+ clause = sql[match.begin(1)...match.end(1)]
399
+ bound, unbound, complete, = extract_assignments(clause, table_name, first_index)
400
+ [bound, unbound, complete]
401
+ end
402
+
403
+ # What follows the value rows of an INSERT. An +ON DUPLICATE KEY UPDATE+ clause assigns to
404
+ # columns just as a SET clause does, so it is read the same way. Anything else that neither
405
+ # assigns nor binds - a row alias, a RETURNING list - writes nothing.
406
+ #
407
+ # @param index [Integer] the number of bind parameters that came before, plus one
408
+ # @return [Array(Array<ColumnInfo>, Array<ColumnInfo>, Boolean)] as extract_insert_columns
409
+ def extract_on_duplicate(trailing, table_name, index)
410
+ text = trailing.to_s.strip.delete_suffix(';').strip
411
+ return [[], [], true] if text.empty?
412
+
413
+ match = ON_DUPLICATE.match(text)
414
+ return [[], [], !text.include?('=') && !text.include?('?')] unless match
415
+
416
+ bound, unbound, complete, = extract_assignments(text[match.end(0)..], table_name, index)
417
+ [bound, unbound, complete]
418
+ end
419
+
420
+ # Reads a SET style clause, one +column = value+ per entry.
421
+ #
422
+ # @param index [Integer] the number of bind parameters that come before the clause, plus one
423
+ # @return [Array(Array<ColumnInfo>, Array<ColumnInfo>, Boolean, Integer)] the columns
424
+ # assigned from a bind parameter, those assigned from something else, whether every
425
+ # assignment was read, and the index the next bind parameter would have
426
+ def extract_assignments(clause, table_name, index)
427
+ entries = split_top_level(clause)
428
+ return [[], [], false, index] if entries.nil?
429
+
430
+ bound = []
431
+ unbound = []
432
+ complete = true
433
+
434
+ entries.each do |entry|
435
+ match = ASSIGNMENT.match(entry)
436
+ unless match
437
+ complete = false
438
+ index += placeholder_count(entry)
439
+ next
440
+ end
441
+
442
+ value = match[2].strip
443
+ column = ColumnInfo.new(table_name: table_name, column_name: column_name_of(match[1]), parameter_index: index)
444
+ if value == '?'
445
+ bound << column
446
+ elsif !NULL_VALUE.match?(value)
447
+ unbound << column.with(parameter_index: nil)
448
+ end
449
+ index += placeholder_count(value)
450
+ end
451
+
452
+ [bound, unbound, complete, index]
453
+ end
454
+
455
+ # The columns a WHERE clause compares against a bind parameter, each paired with the parameter
456
+ # that fills it. Unlike PostgreSQL, MySQL numbers its parameters positionally, so the index of
457
+ # each one has to be counted rather than read: a parameter is numbered after everything that
458
+ # comes before the clause - an UPDATE's SET assignments, a SELECT list, a CTE - and after every
459
+ # parameter of an earlier predicate. Without this a clause is numbered from one and a parameter
460
+ # that follows an unrecognised one, or a multi-value +IN (?, ?, ...)+, shifts every column after
461
+ # it onto the wrong parameter.
462
+ #
463
+ # @param first_index [Integer] the number the statement's first bind parameter has
464
+ def extract_where_columns(sql, first_index = 1)
465
+ match = WHERE_CLAUSE.match(mask_quoted_literals(sql))
466
+ return [].freeze unless match
467
+
468
+ # The offsets come from the masked copy; the clause read from them is the original text.
469
+ where_body = sql[match.begin(1)...match.end(1)]
470
+ return [].freeze unless where_body.include?('?')
471
+
472
+ base = first_index + placeholder_count(sql[0...match.begin(1)])
473
+
474
+ cols = []
475
+ pos = 0
476
+ while (m = WHERE_PATTERN.match(where_body, pos))
477
+ pos = m.end(0)
478
+ # Groups: 1=BETWEEN col, 2=BETWEEN sentinel, 3=+col OP ?+, 4=+? OP col+, 5=IN col, 6=LIKE col
479
+ col = strip_quotes((m[1] || m[3] || m[4] || m[5] || m[6]).to_s)
480
+ index = base + placeholder_count(where_body[0...m.begin(0)])
481
+ # One entry per bound parameter the predicate consumes: one for +=+ or +LIKE+, two for a
482
+ # +BETWEEN+, and one per placeholder for an +IN (?, ?, ...)+.
483
+ placeholder_count(m[0]).times do |offset|
484
+ cols << ColumnInfo.new(table_name: nil, column_name: col, parameter_index: index + offset)
485
+ end
486
+ end
487
+ cols.freeze
488
+ end
489
+
490
+ def strip_quotes(identifier)
491
+ identifier.gsub(STRIP_QUOTES, '')
492
+ end
493
+
494
+ # The column an identifier names, with whatever qualifies it dropped.
495
+ #
496
+ # MySQL lets the target of an assignment carry a qualifier, +SET u.ssn = ?+, where +u+ is
497
+ # either the table or an alias for it. Which of the two it is cannot be told from the text,
498
+ # but a statement with one table to write can only mean that table either way, so only the
499
+ # column name is kept. Where the qualifier would carry the answer, an UPDATE naming more than
500
+ # one table, the assignments are not attributed at all: see +extract_multi_table_update+.
501
+ def column_name_of(identifier)
502
+ strip_quotes(QUALIFIED_COLUMN.match(identifier)&.[](1) || identifier)
503
+ end
504
+
505
+ # -- Reading the text without a grammar --
506
+ #
507
+ # MySQL has no parser here the way PostgreSQL does, so the value lists have to be walked by
508
+ # hand. All of this refuses to guess: text it cannot take apart is reported as such rather
509
+ # than half read, since a caller deciding whether a column is written in the clear needs to
510
+ # know the difference.
511
+
512
+ # The parenthesised rows of a VALUES clause.
513
+ #
514
+ # @param text [String] what follows the column list
515
+ # @return [Array(Array<Array<String>>, String), nil] one array of value expressions per row,
516
+ # and the text that follows the last of them; nil when the text is not a VALUES clause or
517
+ # cannot be taken apart
518
+ def value_rows(text)
519
+ return [nil, nil] unless text.to_s.match?(VALUES_CLAUSE)
520
+
521
+ rest = text.sub(VALUES_CLAUSE, '')
522
+ rows = []
523
+ loop do
524
+ body, rest = balanced_group(rest)
525
+ return [nil, nil] if body.nil?
526
+
527
+ values = split_top_level(body)
528
+ return [nil, nil] if values.nil?
529
+
530
+ rows << values
531
+ rest = rest.lstrip
532
+ break unless rest.start_with?(',')
533
+
534
+ rest = rest[1..]
535
+ end
536
+
537
+ [rows, rest]
538
+ end
539
+
540
+ # Splits a leading +(...)+ off the text.
541
+ #
542
+ # @return [Array(String, String)] what was inside the parentheses and what follows it, or a
543
+ # pair of nils when the text does not begin with a balanced group
544
+ def balanced_group(text)
545
+ group = text.lstrip
546
+ return [nil, nil] unless group.start_with?('(')
547
+
548
+ depth = 0
549
+ closed_at = nil
550
+ unclosed_quote = each_unquoted_char(group) do |char, index|
551
+ next if closed_at
552
+
553
+ # No else: only parentheses affect the depth, so every other character is ignored.
554
+ case char
555
+ when '(' then depth += 1
556
+ when ')'
557
+ depth -= 1
558
+ closed_at = index if depth.zero?
559
+ end
560
+ end
561
+ return [nil, nil] if unclosed_quote || closed_at.nil?
562
+
563
+ [group[1...closed_at], group[(closed_at + 1)..]]
564
+ end
565
+
566
+ # Splits a comma separated list, ignoring commas inside quotes or nested parentheses.
567
+ #
568
+ # @return [Array<String>, nil] nil when the quotes or parentheses do not balance
569
+ def split_top_level(text)
570
+ depth = 0
571
+ boundaries = []
572
+ unclosed_quote = each_unquoted_char(text) do |char, index|
573
+ # No else: only parentheses and top-level commas matter, so every other character is ignored.
574
+ case char
575
+ when '(' then depth += 1
576
+ when ')' then depth -= 1
577
+ when ',' then boundaries << index if depth.zero?
578
+ end
579
+ end
580
+ return nil if unclosed_quote || !depth.zero?
581
+
582
+ from = 0
583
+ parts = boundaries.map do |at|
584
+ part = text[from...at]
585
+ from = at + 1
586
+ part
587
+ end
588
+ (parts << text[from..]).map(&:strip)
589
+ end
590
+
591
+ # How many bind parameters a value expression consumes. A question mark inside a quoted
592
+ # literal is not one of them.
593
+ def placeholder_count(text)
594
+ count = 0
595
+ each_unquoted_char(text) { |char, _index| count += 1 if char == '?' }
596
+ count
597
+ end
598
+
599
+ # The text with the contents of every quoted literal, and the quote marks themselves, replaced
600
+ # by spaces. Length and every offset are left unchanged, so a clause boundary found here with a
601
+ # pattern slices the original text exactly, while a keyword or terminator that was only part of
602
+ # a string value is no longer there to be found.
603
+ def mask_quoted_literals(text)
604
+ masked = ' ' * text.length
605
+ each_unquoted_char(text) { |char, index| masked[index] = char }
606
+ masked
607
+ end
608
+
609
+ # Walks the text once, yielding every character that is not inside a quoted literal along
610
+ # with its index. Backslash escapes are skipped, and a doubled quote reads as one closing and
611
+ # one opening quote, which leaves the state right either way.
612
+ #
613
+ # @return [String, nil] the quote character still open when the text ran out, nil when the
614
+ # text ended outside a quoted literal
615
+ def each_unquoted_char(text)
616
+ index = 0
617
+ quote = nil
618
+
619
+ while index < text.length
620
+ char = text[index]
621
+ if quote
622
+ if char == '\\' && quote != '`'
623
+ index += 2
624
+ next
625
+ end
626
+
627
+ quote = nil if char == quote
628
+ elsif QUOTES.include?(char)
629
+ quote = char
630
+ else
631
+ yield(char, index)
632
+ end
633
+ index += 1
634
+ end
635
+
636
+ quote
637
+ end
638
+ end
639
+ end
640
+ end
641
+ end