diff --git a/helpers/sql-obfuscation/lib/opentelemetry/helpers/query_summary.rb b/helpers/sql-obfuscation/lib/opentelemetry/helpers/query_summary.rb new file mode 100644 index 000000000..b2fd84469 --- /dev/null +++ b/helpers/sql-obfuscation/lib/opentelemetry/helpers/query_summary.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +require_relative 'query_summary/tokenizer' +require_relative 'query_summary/cache' +require_relative 'query_summary/parser' + +module OpenTelemetry + module Helpers + # QuerySummary generates high-level summaries of SQL queries, made up of + # key operations and table names. + # + # Example: + # QuerySummary.generate_summary("SELECT * FROM users WHERE id = 1") + # # => "SELECT users" + module QuerySummary + class << self + def configure_cache(size: Cache::DEFAULT_SIZE) + cache_instance.configure(size: size) + end + + def generate_summary(query) + cache_instance.fetch(query) do + tokens = Tokenizer.tokenize(query) + Parser.build_summary_from_tokens(tokens) + end + rescue StandardError + 'UNKNOWN' + end + + private + + def cache_instance + @cache_instance ||= Cache.new + end + end + end + end +end diff --git a/helpers/sql-obfuscation/lib/opentelemetry/helpers/query_summary/cache.rb b/helpers/sql-obfuscation/lib/opentelemetry/helpers/query_summary/cache.rb new file mode 100644 index 000000000..56486facc --- /dev/null +++ b/helpers/sql-obfuscation/lib/opentelemetry/helpers/query_summary/cache.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +module OpenTelemetry + module Helpers + module QuerySummary + # Cache provides thread-safe LRU caching for query summaries. + # + # Stores generated query summaries to avoid reprocessing identical queries. + # Uses mutex synchronization for thread safety. + # + # @example + # cache = Cache.new + # cache.fetch("SELECT * FROM users") { "SELECT users" } # => "SELECT users" + class Cache + DEFAULT_SIZE = 1000 + + def initialize(size: DEFAULT_SIZE) + @cache = {} + @cache_mutex = Mutex.new + @cache_size = size + end + + def fetch(key) + @cache_mutex.synchronize do + return @cache[key] if @cache.key?(key) + + result = yield + evict_if_needed + @cache[key] = result + result + end + end + + private + + def configure(size: DEFAULT_SIZE) + @cache_size = size + @cache.clear if @cache.size > size + end + + def clear + @cache.clear + end + + def evict_if_needed + @cache.shift if @cache.size >= @cache_size + end + end + end + end +end \ No newline at end of file diff --git a/helpers/sql-obfuscation/lib/opentelemetry/helpers/query_summary/parser.rb b/helpers/sql-obfuscation/lib/opentelemetry/helpers/query_summary/parser.rb new file mode 100644 index 000000000..f27566136 --- /dev/null +++ b/helpers/sql-obfuscation/lib/opentelemetry/helpers/query_summary/parser.rb @@ -0,0 +1,167 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +module OpenTelemetry + module Helpers + module QuerySummary + # Parser builds high-level SQL query summaries from tokenized input. + # + # Processes tokens to extract key operations and table names, creating + # summaries like "SELECT users" or "INSERT INTO orders". + # + # @example + # tokens = [Token.new(:keyword, "SELECT"), Token.new(:identifier, "users")] + # Parser.build_summary_from_tokens(tokens) # => "SELECT users" + class Parser + # Two states: normal parsing vs. waiting for table names + PARSING_STATE = :parsing + EXPECT_COLLECTION_STATE = :expect_collection + + MAIN_OPERATIONS = %w[SELECT INSERT DELETE].freeze # Operations that start queries and need table names + COLLECTION_OPERATIONS = %w[WITH UPDATE].freeze # Operations that work with existing data and expect table names to follow + TRIGGER_COLLECTION = %w[FROM INTO JOIN IN].freeze # Keywords that signal a table name is coming next + TABLE_OPERATIONS = %w[CREATE ALTER DROP TRUNCATE].freeze # Database structure operations that create, modify, or remove objects + TABLE_OBJECTS = %w[TABLE INDEX PROCEDURE VIEW DATABASE].freeze # Types of database objects that can be created, modified, or removed + + class << self + def build_summary_from_tokens(tokens) + summary_parts = [] + state = PARSING_STATE + skip_until = 0 # Skip tokens we've already processed when looking ahead + + tokens.each_with_index do |token, index| + next if index < skip_until + + result = process_token(token, tokens, index, state) + + summary_parts.concat(result[:parts]) + state = result[:new_state] + skip_until = result[:next_index] + end + + summary_parts.join(' ') + end + + def process_token(token, tokens, index, state) + operation_result = process_main_operation(token, tokens, index, state) + return operation_result if operation_result[:processed] + + collection_result = process_collection_token(token, tokens, index, state) + return collection_result if collection_result[:processed] + + { processed: false, parts: [], new_state: state, next_index: index + 1 } + end + + def process_main_operation(token, tokens, index, current_state) + upcased_value = token.value.upcase + + case upcased_value + when *MAIN_OPERATIONS + add_to_summary(token.value, PARSING_STATE, index + 1) + when *COLLECTION_OPERATIONS + add_to_summary(token.value, EXPECT_COLLECTION_STATE, index + 1) + when *TRIGGER_COLLECTION + expect_table_names_next(index + 1) + when *TABLE_OPERATIONS + handle_table_operation(token, tokens, index) + when 'UNION' + handle_union(token, tokens, index) + else + not_processed(current_state, index + 1) + end + end + + def process_collection_token(token, tokens, index, state) + return not_processed(state, index + 1) unless state == EXPECT_COLLECTION_STATE + + upcased_value = token.value.upcase + + if identifier_like?(token) || (token.type == :keyword && can_be_table_name?(upcased_value)) + process_table_name_and_alias(token, tokens, index) + elsif token.value == '(' || token.type == :operator + handle_collection_operator(token, state, index) + else + return_to_normal_parsing(token, index) + end + end + + def process_table_name_and_alias(token, tokens, index) + # Look ahead to skip table aliases (e.g., "users u" or "users AS u") + skip_count = calculate_alias_skip(tokens, index) + # Check if there's a comma - if so, expect more table names in the list + new_state = tokens[index + 1 + skip_count]&.value == ',' ? EXPECT_COLLECTION_STATE : PARSING_STATE + skip_count += 1 if tokens[index + 1 + skip_count]&.value == ',' + + { processed: true, parts: [token.value], new_state: new_state, next_index: index + 1 + skip_count } + end + + def handle_collection_operator(token, state, index) + { processed: true, parts: [], new_state: state, next_index: index + 1 } + end + + def return_to_normal_parsing(token, index) + { processed: true, parts: [], new_state: PARSING_STATE, next_index: index + 1 } + end + + def identifier_like?(token) + %i[identifier quoted_identifier string].include?(token.type) + end + + def can_be_table_name?(upcased_value) + # Object types that can appear after DDL operations + TABLE_OBJECTS.include?(upcased_value) + end + + def calculate_alias_skip(tokens, index) + # Handle both "table AS alias" and "table alias" patterns + next_token = tokens[index + 1] + if next_token && next_token.value&.upcase == 'AS' + 2 + elsif next_token && next_token.type == :identifier + 1 + else + 0 + end + end + + def add_to_summary(part, new_state, next_index) + { processed: true, parts: [part], new_state: new_state, next_index: next_index } + end + + def expect_table_names_next(next_index) + { processed: true, parts: [], new_state: EXPECT_COLLECTION_STATE, next_index: next_index } + end + + def not_processed(current_state, next_index) + { processed: false, parts: [], new_state: current_state, next_index: next_index } + end + + def handle_union(token, tokens, index) + next_token = tokens[index + 1] + if next_token && next_token.value&.upcase == 'ALL' + { processed: true, parts: ["#{token.value} #{next_token.value}"], new_state: PARSING_STATE, next_index: index + 2 } + else + add_to_summary(token.value, PARSING_STATE, index + 1) + end + end + + def handle_table_operation(token, tokens, index) + # Combine DDL operations with object types: "CREATE TABLE", "DROP INDEX", etc. + next_token_obj = tokens[index + 1] + next_token = next_token_obj&.value&.upcase + + case next_token + when 'TABLE', 'INDEX', 'PROCEDURE', 'VIEW', 'DATABASE' + { processed: true, parts: ["#{token.value} #{next_token}"], new_state: EXPECT_COLLECTION_STATE, next_index: index + 2 } + else + add_to_summary(token.value, PARSING_STATE, index + 1) + end + end + end + end + end + end +end diff --git a/helpers/sql-obfuscation/lib/opentelemetry/helpers/query_summary/tokenizer.rb b/helpers/sql-obfuscation/lib/opentelemetry/helpers/query_summary/tokenizer.rb new file mode 100644 index 000000000..6bad41cd8 --- /dev/null +++ b/helpers/sql-obfuscation/lib/opentelemetry/helpers/query_summary/tokenizer.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +require 'strscan' + +module OpenTelemetry + module Helpers + module QuerySummary + # Tokenizer breaks down SQL queries into structured tokens for analysis. + # + # Parses SQL query strings into typed tokens (keywords, identifiers, operators, literals) + # for generating query summaries while filtering out sensitive data. + # + # @example + # tokens = Tokenizer.tokenize("SELECT * FROM users WHERE id = 1") + # # Returns tokens: [keyword: SELECT], [operator: *], [keyword: FROM], etc. + class Tokenizer + # Token holds the type (e.g., :keyword) and value (e.g., "SELECT") + Token = Struct.new(:type, :value) + + # The order of token matching is important for correct parsing, + # as more specific patterns should be matched before more general ones. + TOKEN_REGEX = { + whitespace: /\s+/, + comment: %r{--[^\r\n]*|\/\*.*?\*\/}m, + numeric: /[+-]?(?:0x[0-9a-fA-F]+|\d+\.?\d*(?:[eE][+-]?\d+)?|\.\d+(?:[eE][+-]?\d+)?)/, + string: /'(?:''|[^'\r\n])*'?/, + quoted_identifier: /"(?:""|[^"\r\n])*"|`(?:``|[^`\r\n])*`|\[(?:[^\]\r\n])*\]/, + keyword: /\b(?:SELECT|INSERT|UPDATE|DELETE|FROM|INTO|JOIN|CREATE|ALTER|DROP|TRUNCATE|WITH|UNION|TABLE|INDEX|PROCEDURE|VIEW|DATABASE)\b/i, + identifier: /[a-zA-Z_][a-zA-Z0-9_.]*/, + operator: %r{<=|>=|<>|!=|[=<>+\-*\/%,;()!?]} + }.freeze + + EXCLUDED_TYPES = %i[whitespace comment].freeze + + class << self + def tokenize(query) + scanner = StringScanner.new(query) + tokens = [] + + scan_next_token(scanner, tokens) until scanner.eos? + + tokens + end + + def scan_next_token(scanner, tokens) + matched = TOKEN_REGEX.any? do |type, regex| + next unless (value = scanner.scan(regex)) + + tokens << Token.new(type, value) unless EXCLUDED_TYPES.include?(type) + true + end + scanner.getch unless matched + end + end + end + end + end +end diff --git a/helpers/sql-obfuscation/test/fixtures/query_summary.json b/helpers/sql-obfuscation/test/fixtures/query_summary.json new file mode 100644 index 000000000..ec0d505a4 --- /dev/null +++ b/helpers/sql-obfuscation/test/fixtures/query_summary.json @@ -0,0 +1,436 @@ +[ + { + "name": "numeric_literal_integers", + "input": { + "query": "SELECT 12, -12, +12" + }, + "expected": { + "db.query.summary": "SELECT" + } + }, + { + "name": "caching_query_summaries", + "input": { + "query": "SELECT 12, -12, +12" + }, + "expected": { + "db.query.summary": "SELECT" + } + }, + { + "name": "nil_input", + "input": { + "query": null + }, + "expected": { + "db.query.summary": "UNKNOWN" + } + }, + { + "name": "deeply_nested_subqueries", + "input": { + "query": "SELECT * FROM (SELECT * FROM (SELECT * FROM my_table))" + }, + "expected": { + "db.query.summary": "SELECT SELECT SELECT my_table" + } + }, + { + "name": "numeric_literal_with_decimal_point", + "input": { + "query": "SELECT 12.34, -12.34, +12.34, .01, -.01" + }, + "expected": { + "db.query.summary": "SELECT" + } + }, + { + "name": "numeric_literal_exponential", + "input": { + "query": "SELECT 12.34e56, -12.34e56, +12.34e56" + }, + "expected": { + "db.query.summary": "SELECT" + } + }, + { + "name": "numeric_literal_negative_exponential", + "input": { + "query": "SELECT 12.34e-56, -12.34e-56, +12.34e-56" + }, + "expected": { + "db.query.summary": "SELECT" + } + }, + { + "name": "arithmetic_on_numeric_literals", + "input": { + "query": "SELECT 99+100" + }, + "expected": { + "db.query.summary": "SELECT" + } + }, + { + "name": "hex_literal", + "input": { + "query": "SELECT 0xDEADBEEF, 0XdeadBEEF" + }, + "expected": { + "db.query.summary": "SELECT" + } + }, + { + "name": "string_literal", + "input": { + "query": "SELECT 'hello'" + }, + "expected": { + "db.query.summary": "SELECT" + } + }, + { + "name": "string_literal_escaped_single_quote", + "input": { + "query": "SELECT 'My name''s not important'" + }, + "expected": { + "db.query.summary": "SELECT" + } + }, + { + "name": "string_with_embedded_newline", + "input": { + "query": "SELECT 'My name is \n not important'" + }, + "expected": { + "db.query.summary": "SELECT" + } + }, + { + "name": "numbers_in_identifiers", + "input": { + "query": "SELECT c3po, r2d2 FROM covid19 WHERE n1h1=1234" + }, + "expected": { + "db.query.summary": "SELECT covid19" + } + }, + { + "name": "periods_in_identifiers", + "input": { + "query": "SELECT a FROM dbo.Table JOIN dbo.AnotherTable" + }, + "expected": { + "db.query.summary": "SELECT dbo.Table dbo.AnotherTable" + } + }, + { + "name": "insert_into", + "input": { + "query": "INSERT INTO X VALUES(1, 23456, 123.456, 99+100)" + }, + "expected": { + "db.query.summary": "INSERT X" + } + }, + { + "name": "uuid", + "input": { + "query": "SELECT { guid '01234567-89ab-cdef-0123-456789abcdef' }" + }, + "expected": { + "db.query.summary": "SELECT" + } + }, + { + "name": "in_clause", + "input": { + "query": "SELECT * FROM table WHERE value IN (123, 456, 'abc')" + }, + "expected": { + "db.query.summary": "SELECT table" + } + }, + { + "name": "comments", + "input": { + "query": "SELECT column -- end of line comment\nFROM /* block \n comment */ table" + }, + "expected": { + "db.query.summary": "SELECT table" + } + }, + { + "name": "insert_into_select", + "input": { + "query": "INSERT INTO shipping_details\n(order_id,\naddress)\nSELECT order_id,\naddress\nFROM orders\nWHERE order_id = 1" + }, + "expected": { + "db.query.summary": "INSERT shipping_details SELECT orders" + } + }, + { + "name": "select_nested_query", + "input": { + "query": "SELECT order_date\nFROM (SELECT *\nFROM orders o\nJOIN customers c\nON o.customer_id = c.customer_id)" + }, + "expected": { + "db.query.summary": "SELECT SELECT orders customers" + } + }, + { + "name": "select_nested_query_case_preserved", + "input": { + "query": "SELEcT order_date\nFROM (sELECT *\nFROM orders o\nJOIN customers c\nON o.customer_id = c.customer_id)" + }, + "expected": { + "db.query.summary": "SELEcT sELECT orders customers" + } + }, + { + "name": "case_preserved", + "input": { + "query": "SELEcT order_date\nFROM ORders" + }, + "expected": { + "db.query.summary": "SELEcT ORders" + } + }, + { + "name": "cross_join", + "input": { + "query": "SELECT * FROM Orders o CROSS JOIN OrderDetails od" + }, + "expected": { + "db.query.summary": "SELECT Orders OrderDetails" + } + }, + { + "name": "cross_join_comma_separated_syntax", + "input": { + "query": "SELECT * FROM Orders o, OrderDetails od" + }, + "expected": { + "db.query.summary": "SELECT Orders OrderDetails" + } + }, + { + "name": "left_outer_join", + "input": { + "query": "SELECT c.name, o.id FROM customers c LEFT JOIN orders o ON c.id = o.customer_id" + }, + "expected": { + "db.query.summary": "SELECT customers orders" + } + }, + { + "name": "create_table", + "input": { + "query": "CREATE TABLE MyTable (\n ID NOT NULL IDENTITY(1,1) PRIMARY KEY\n)" + }, + "expected": { + "db.query.summary": "CREATE TABLE MyTable" + } + }, + { + "name": "alter_table", + "input": { + "query": "ALTER TABLE MyTable ADD Name varchar(255)" + }, + "expected": { + "db.query.summary": "ALTER TABLE MyTable" + } + }, + { + "name": "drop_table", + "input": { + "query": "DROP TABLE MyTable" + }, + "expected": { + "db.query.summary": "DROP TABLE MyTable" + } + }, + { + "name": "query_that_performs_multiple_operations", + "input": { + "query": "INSERT INTO shipping_details(order_id, address) SELECT order_id, address FROM orders WHERE order_id = ?" + }, + "expected": { + "db.query.summary": "INSERT shipping_details SELECT orders" + } + }, + { + "name": "query_that_performs_an_operation_thats_applied_to_multiple_collections", + "input": { + "db.system.name": "other_sql", + "query": "SELECT * FROM songs, artists WHERE songs.artist_id == artists.id" + }, + "expected": { + "db.query.summary": "SELECT songs artists" + } + }, + { + "name": "query_that_performs_operation_on_multiple_collections_with_double-quotes_or_other_punctuation", + "input": { + "query": "SELECT * FROM \"song list\", 'artists'" + }, + "expected": { + "db.query.summary": "SELECT \"song list\" 'artists'" + } + }, + { + "name": "update_statement", + "input": { + "query": "UPDATE Customers SET ContactName = 'Alfred Schmidt', City= 'Frankfurt' WHERE CustomerID = 1" + }, + "expected": { + "db.query.summary": "UPDATE Customers" + } + }, + { + "name": "delete_statement", + "input": { + "query": "DELETE FROM Customers WHERE CustomerName='Alfreds Futterkiste'" + }, + "expected": { + "db.query.summary": "DELETE Customers" + } + }, + { + "name": "truncate_table_statement", + "input": { + "query": "TRUNCATE TABLE Customers" + }, + "expected": { + "db.query.summary": "TRUNCATE TABLE Customers" + } + }, + { + "name": "with_clause_cte", + "input": { + "query": "WITH regional_sales AS (SELECT region, SUM(amount) AS total_sales FROM orders GROUP BY region) SELECT region, total_sales FROM regional_sales WHERE total_sales > 1000" + }, + "expected": { + "db.query.summary": "WITH regional_sales SELECT orders SELECT regional_sales" + } + }, + { + "name": "union_statement", + "input": { + "query": "SELECT City FROM Customers UNION ALL SELECT City FROM Suppliers ORDER BY City" + }, + "expected": { + "db.query.summary": "SELECT Customers UNION ALL SELECT Suppliers" + } + }, + { + "name": "group_by_and_having_clauses", + "input": { + "query": "SELECT COUNT(CustomerID), Country FROM Customers WHERE Country != 'USA' GROUP BY Country HAVING COUNT(CustomerID) > 5" + }, + "expected": { + "db.query.summary": "SELECT Customers" + } + }, + { + "name": "boolean_and_null_literals", + "input": { + "query": "SELECT * FROM my_table WHERE a IS NOT NULL AND b = TRUE AND c = FALSE" + }, + "expected": { + "db.query.summary": "SELECT my_table" + } + }, + { + "name": "multiple_joins_and_aliases", + "input": { + "query": "SELECT o.OrderID, c.CustomerName, s.ShipperName FROM ((Orders AS o INNER JOIN Customers AS c ON o.CustomerID = c.CustomerID) INNER JOIN Shippers AS s ON o.ShipperID = s.ShipperID)" + }, + "expected": { + "db.query.summary": "SELECT Orders Customers Shippers" + } + }, + { + "name": "window_function_over_partition", + "input": { + "query": "SELECT name, salary, ROW_NUMBER() OVER (PARTITION BY department ORDER BY salary DESC) as rank FROM employees" + }, + "expected": { + "db.query.summary": "SELECT employees" + } + }, + { + "name": "case_statement", + "input": { + "query": "SELECT OrderID, Quantity, CASE WHEN Quantity > 30 THEN 'Large' WHEN Quantity > 10 THEN 'Medium' ELSE 'Small' END AS QuantityText FROM OrderDetails" + }, + "expected": { + "db.query.summary": "SELECT OrderDetails" + } + }, + { + "name": "like_predicate", + "input": { + "query": "SELECT * FROM products WHERE product_name LIKE 'Chai%'" + }, + "expected": { + "db.query.summary": "SELECT products" + } + }, + { + "name": "between_predicate", + "input": { + "query": "SELECT * FROM products WHERE price BETWEEN 10 AND 20" + }, + "expected": { + "db.query.summary": "SELECT products" + } + }, + { + "name": "create_index", + "input": { + "query": "CREATE INDEX idx_name ON MyTable (column1)" + }, + "expected": { + "db.query.summary": "CREATE INDEX idx_name" + } + }, + { + "name": "create_database", + "input": { + "query": "CREATE DATABASE my_db" + }, + "expected": { + "db.query.summary": "CREATE DATABASE my_db" + } + }, + { + "name": "create_procedure", + "input": { + "query": "CREATE PROCEDURE my_proc AS BEGIN SELECT * FROM MyTable END" + }, + "expected": { + "db.query.summary": "CREATE PROCEDURE my_proc SELECT MyTable" + } + }, + { + "name": "oracle_angle_quote", + "input": { + "query": "select * from foo where bar=q'' and x=5" + }, + "expected": { + "db.query.summary": "select foo" + } + }, + { + "name": "cassandra_blobs", + "input" : { + "query": "select * from foo where bar=0xabcdef123 and x=5" + }, + "expected": { + "db.query.summary": "select foo" + } + } +] + diff --git a/helpers/sql-obfuscation/test/helpers/query_summary/cache_test.rb b/helpers/sql-obfuscation/test/helpers/query_summary/cache_test.rb new file mode 100644 index 000000000..a194b2aad --- /dev/null +++ b/helpers/sql-obfuscation/test/helpers/query_summary/cache_test.rb @@ -0,0 +1,55 @@ +require_relative '../../test_helper' +require_relative '../../../lib/opentelemetry/helpers/query_summary/cache' + +class CacheTest < Minitest::Test + def setup + @cache = OpenTelemetry::Helpers::QuerySummary::Cache.new + end + + def test_fetch_returns_new_value_when_key_does_not_exist + result = @cache.fetch('key1') { 'value1' } + assert_equal 'value1', result + end + + def test_fetch_returns_value_when_key_exists + @cache.fetch('key1') { 'value1' } + result = @cache.fetch('key1') { 'different_value' } + + assert_equal 'value1', result + end + + def test_eviction_when_cache_size_exceeded + small_cache = OpenTelemetry::Helpers::QuerySummary::Cache.new(size: 2) + + small_cache.fetch('key1') { 'value1' } + small_cache.fetch('key2') { 'value2' } + small_cache.fetch('key3') { 'value3' } + + result = small_cache.fetch('key1') { 'new_value1' } + assert_equal 'new_value1', result + end + + def test_cache_thread_safety + threads = Array.new(10) do |i| + Thread.new do + @cache.fetch('shared_key') { "thread_#{i}_value" } + end + end + + results = threads.map(&:value) + + assert_equal 1, results.uniq.size + end + + def test_empty_string + @cache.fetch('') { 'empty_string_value' } + + assert_equal 'empty_string_value', @cache.fetch('') + end + + def test_nil + @cache.fetch(nil) { 'nil_value' } + + assert_equal 'nil_value', @cache.fetch(nil) + end +end diff --git a/helpers/sql-obfuscation/test/helpers/query_summary/query_summary_test.rb b/helpers/sql-obfuscation/test/helpers/query_summary/query_summary_test.rb new file mode 100644 index 000000000..7009eac02 --- /dev/null +++ b/helpers/sql-obfuscation/test/helpers/query_summary/query_summary_test.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +require_relative '../../test_helper' +require_relative '../../../lib/opentelemetry/helpers/query_summary' + +class QuerySummaryTest < Minitest::Test + def self.load_fixture + data = File.read("#{Dir.pwd}/test/fixtures/query_summary.json") + JSON.parse(data) + end + + def build_failure_message(query, expected_summary, actual_summary) + "Failed to generate query summary correctly.\n" \ + "Input: #{query}\n" \ + "Expected: #{expected_summary}\n" \ + "Actual: #{actual_summary}\n" + end + + load_fixture.each do |test_case| + name = test_case['name'] + query = test_case['input']['query'] + expected_summary = test_case['expected']['db.query.summary'] + + define_method(:"test_query_summary_#{name}") do + actual_summary = OpenTelemetry::Helpers::QuerySummary.generate_summary(query) + message = build_failure_message(query, expected_summary, actual_summary) + + assert_equal(expected_summary, actual_summary, message) + end + end +end