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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +23 -0
- data/LICENSE +175 -0
- data/NOTICE +1 -0
- data/README.md +168 -0
- data/THIRD-PARTY-LICENSES +473 -0
- data/aws_advanced_ruby_driver_wrapper.gemspec +73 -0
- data/lib/aws_advanced_ruby_driver_wrapper/active_record/aws_mysql2_adapter.rb +73 -0
- data/lib/aws_advanced_ruby_driver_wrapper/active_record/aws_postgresql_adapter.rb +95 -0
- data/lib/aws_advanced_ruby_driver_wrapper/custom_configuration.rb +58 -0
- data/lib/aws_advanced_ruby_driver_wrapper/db_dialects/aurora_mysql_dialect.rb +103 -0
- data/lib/aws_advanced_ruby_driver_wrapper/db_dialects/aurora_pg_dialect.rb +124 -0
- data/lib/aws_advanced_ruby_driver_wrapper/db_dialects/dialect_codes.rb +38 -0
- data/lib/aws_advanced_ruby_driver_wrapper/db_dialects/global_mysql_dialect.rb +91 -0
- data/lib/aws_advanced_ruby_driver_wrapper/db_dialects/global_pg_dialect.rb +92 -0
- data/lib/aws_advanced_ruby_driver_wrapper/db_dialects/multi_az_cluster_mysql_dialect.rb +95 -0
- data/lib/aws_advanced_ruby_driver_wrapper/db_dialects/multi_az_cluster_pg_dialect.rb +86 -0
- data/lib/aws_advanced_ruby_driver_wrapper/db_dialects/mysql_dialect.rb +98 -0
- data/lib/aws_advanced_ruby_driver_wrapper/db_dialects/pg_dialect.rb +95 -0
- data/lib/aws_advanced_ruby_driver_wrapper/db_dialects/rds_mysql_dialect.rb +88 -0
- data/lib/aws_advanced_ruby_driver_wrapper/db_dialects/rds_pg_dialect.rb +86 -0
- data/lib/aws_advanced_ruby_driver_wrapper/db_dialects/unknown_dialect.rb +72 -0
- data/lib/aws_advanced_ruby_driver_wrapper/db_dialects/utils/dialect_utils.rb +71 -0
- data/lib/aws_advanced_ruby_driver_wrapper/driver_dialects/driver_dialect.rb +154 -0
- data/lib/aws_advanced_ruby_driver_wrapper/driver_dialects/driver_dialect_manager.rb +55 -0
- data/lib/aws_advanced_ruby_driver_wrapper/driver_dialects/mysql_driver_dialect.rb +165 -0
- data/lib/aws_advanced_ruby_driver_wrapper/driver_dialects/pg_driver_dialect.rb +201 -0
- data/lib/aws_advanced_ruby_driver_wrapper/errors/error_handler.rb +62 -0
- data/lib/aws_advanced_ruby_driver_wrapper/errors/mysql_error_handler.rb +80 -0
- data/lib/aws_advanced_ruby_driver_wrapper/errors/pg_error_handler.rb +126 -0
- data/lib/aws_advanced_ruby_driver_wrapper/errors.rb +59 -0
- data/lib/aws_advanced_ruby_driver_wrapper/host/connection_string_host_list_provider.rb +95 -0
- data/lib/aws_advanced_ruby_driver_wrapper/host/global_aurora_host_list_provider.rb +65 -0
- data/lib/aws_advanced_ruby_driver_wrapper/host/host_availability.rb +24 -0
- data/lib/aws_advanced_ruby_driver_wrapper/host/host_availability_strategy.rb +27 -0
- data/lib/aws_advanced_ruby_driver_wrapper/host/host_info.rb +137 -0
- data/lib/aws_advanced_ruby_driver_wrapper/host/host_role.rb +25 -0
- data/lib/aws_advanced_ruby_driver_wrapper/host/random_host_selector.rb +40 -0
- data/lib/aws_advanced_ruby_driver_wrapper/host/rds_host_list_provider.rb +206 -0
- data/lib/aws_advanced_ruby_driver_wrapper/logging.rb +110 -0
- data/lib/aws_advanced_ruby_driver_wrapper/monitoring/cluster_topology_monitor.rb +709 -0
- data/lib/aws_advanced_ruby_driver_wrapper/monitoring/global_cluster_topology_monitor.rb +72 -0
- data/lib/aws_advanced_ruby_driver_wrapper/monitoring/monitor.rb +99 -0
- data/lib/aws_advanced_ruby_driver_wrapper/monitoring/monitor_connection.rb +57 -0
- data/lib/aws_advanced_ruby_driver_wrapper/monitoring/monitor_state.rb +25 -0
- data/lib/aws_advanced_ruby_driver_wrapper/mysql.rb +429 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/blue_green_plugin.rb +205 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/host_mapper.rb +132 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/iam_host_tracker.rb +84 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/interim_status.rb +92 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/interval_rate.rb +27 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/phase.rb +69 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/phase_event_log.rb +85 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/phase_time_info.rb +25 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/role.rb +38 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/routing/base_routing.rb +83 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/routing/reject_connect_routing.rb +40 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/routing/substitute_connect_routing.rb +136 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/routing/suspend_connect_routing.rb +53 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/routing/suspend_execute_routing.rb +52 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/routing/suspend_until_corresponding_host_found_connect_routing.rb +83 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/status.rb +68 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/status_builder.rb +244 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/status_info.rb +30 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/status_monitor.rb +564 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/status_provider.rb +414 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/switchover_state.rb +98 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/switchover_timer.rb +46 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/custom_endpoint/custom_endpoint_monitor.rb +266 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/custom_endpoint/custom_endpoint_plugin.rb +158 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/custom_endpoint/info.rb +111 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/custom_endpoint/member_list_type.rb +31 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/custom_endpoint/role.rb +45 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/default_plugin.rb +108 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/failover_mode.rb +43 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/failover_plugin.rb +467 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/gdb/gdb_failover_mode.rb +68 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/gdb/gdb_failover_plugin.rb +403 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/iam_auth_plugin.rb +159 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/initial_connection_strategy_plugin.rb +485 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/audit_logger.rb +157 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/column_cipher.rb +159 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/column_encryption_config.rb +61 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/connection_source.rb +91 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/data_key_cache.rb +220 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/encryption_algorithm.rb +75 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/encryption_config.rb +146 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/encryption_service.rb +391 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/error_context.rb +198 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/errors.rb +259 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/key_management_utility.rb +435 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/key_manager.rb +378 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/key_metadata.rb +86 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/kms_encryption_plugin.rb +890 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/kms_encryption_utility.rb +281 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/metadata_manager.rb +332 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/sanitizer.rb +147 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/schema_name.rb +70 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/schema_validator.rb +211 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/sql_runner.rb +147 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/type_marker.rb +109 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/secrets_manager_plugin.rb +358 -0
- data/lib/aws_advanced_ruby_driver_wrapper/postgresql.rb +659 -0
- data/lib/aws_advanced_ruby_driver_wrapper/property_definition.rb +409 -0
- data/lib/aws_advanced_ruby_driver_wrapper/ruby_method.rb +122 -0
- data/lib/aws_advanced_ruby_driver_wrapper/services/connection_service.rb +143 -0
- data/lib/aws_advanced_ruby_driver_wrapper/services/dialect_service.rb +267 -0
- data/lib/aws_advanced_ruby_driver_wrapper/services/host_service.rb +199 -0
- data/lib/aws_advanced_ruby_driver_wrapper/services/monitor_service.rb +186 -0
- data/lib/aws_advanced_ruby_driver_wrapper/services/plugin_call_context.rb +63 -0
- data/lib/aws_advanced_ruby_driver_wrapper/services/plugin_manager.rb +273 -0
- data/lib/aws_advanced_ruby_driver_wrapper/services/service_container.rb +30 -0
- data/lib/aws_advanced_ruby_driver_wrapper/services/service_utility.rb +78 -0
- data/lib/aws_advanced_ruby_driver_wrapper/services/session_state_service.rb +56 -0
- data/lib/aws_advanced_ruby_driver_wrapper/utils/accessible_regions.rb +52 -0
- data/lib/aws_advanced_ruby_driver_wrapper/utils/ar_constants.rb +25 -0
- data/lib/aws_advanced_ruby_driver_wrapper/utils/aurora_topology_utils.rb +99 -0
- data/lib/aws_advanced_ruby_driver_wrapper/utils/aws_credentials_utils.rb +62 -0
- data/lib/aws_advanced_ruby_driver_wrapper/utils/connection_config.rb +91 -0
- data/lib/aws_advanced_ruby_driver_wrapper/utils/connection_config_parser.rb +368 -0
- data/lib/aws_advanced_ruby_driver_wrapper/utils/conversion_utils.rb +51 -0
- data/lib/aws_advanced_ruby_driver_wrapper/utils/events/batching_event_publisher.rb +119 -0
- data/lib/aws_advanced_ruby_driver_wrapper/utils/events/data_access_event.rb +26 -0
- data/lib/aws_advanced_ruby_driver_wrapper/utils/events/monitor_reset_event.rb +26 -0
- data/lib/aws_advanced_ruby_driver_wrapper/utils/global_aurora_topology_utils.rb +185 -0
- data/lib/aws_advanced_ruby_driver_wrapper/utils/host_list_utils.rb +27 -0
- data/lib/aws_advanced_ruby_driver_wrapper/utils/iam_auth_utils.rb +112 -0
- data/lib/aws_advanced_ruby_driver_wrapper/utils/multi_az_topology_utils.rb +117 -0
- data/lib/aws_advanced_ruby_driver_wrapper/utils/parser/encryption_annotation_parser.rb +99 -0
- data/lib/aws_advanced_ruby_driver_wrapper/utils/parser/mysql_statement_analyzer.rb +641 -0
- data/lib/aws_advanced_ruby_driver_wrapper/utils/parser/pg_statement_analyzer.rb +502 -0
- data/lib/aws_advanced_ruby_driver_wrapper/utils/parser/query_analysis.rb +63 -0
- data/lib/aws_advanced_ruby_driver_wrapper/utils/parser/query_type.rb +35 -0
- data/lib/aws_advanced_ruby_driver_wrapper/utils/parser/routing_hint.rb +27 -0
- data/lib/aws_advanced_ruby_driver_wrapper/utils/parser/routing_hint_parser.rb +50 -0
- data/lib/aws_advanced_ruby_driver_wrapper/utils/parser/sql_parser.rb +139 -0
- data/lib/aws_advanced_ruby_driver_wrapper/utils/rds_url_type.rb +71 -0
- data/lib/aws_advanced_ruby_driver_wrapper/utils/rds_utils.rb +575 -0
- data/lib/aws_advanced_ruby_driver_wrapper/utils/retry_util.rb +153 -0
- data/lib/aws_advanced_ruby_driver_wrapper/utils/sql_encoding.rb +56 -0
- data/lib/aws_advanced_ruby_driver_wrapper/utils/sql_method_analyzer.rb +195 -0
- data/lib/aws_advanced_ruby_driver_wrapper/utils/storage/cache_entry.rb +56 -0
- data/lib/aws_advanced_ruby_driver_wrapper/utils/storage/expiration_cache.rb +108 -0
- data/lib/aws_advanced_ruby_driver_wrapper/utils/storage/sliding_expiration_cache.rb +137 -0
- data/lib/aws_advanced_ruby_driver_wrapper/utils/storage/storage_service.rb +172 -0
- data/lib/aws_advanced_ruby_driver_wrapper/utils/topology_utils.rb +127 -0
- data/lib/aws_advanced_ruby_driver_wrapper/version.rb +19 -0
- data/lib/aws_advanced_ruby_driver_wrapper/wrapper_property.rb +64 -0
- data/lib/aws_advanced_ruby_driver_wrapper.rb +116 -0
- 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
|