diff --git a/.rubocop.yml b/.rubocop.yml index eb3e61bc1..d4761e1fb 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -16,6 +16,7 @@ Gemspec/OrderedDependencies: - 'packages/forest_admin_agent/forest_admin_agent.gemspec' - 'packages/forest_admin_rails/forest_admin_rails.gemspec' - 'packages/forest_admin_datasource_toolkit/forest_admin_datasource_toolkit.gemspec' + - 'packages/forest_admin_test_toolkit/forest_admin_test_toolkit.gemspec' - 'packages/forest_admin_datasource_customizer/forest_admin_datasource_customizer.gemspec' - 'packages/forest_admin_datasource_active_record/forest_admin_datasource_active_record.gemspec' @@ -25,6 +26,7 @@ Gemspec/RequireMFA: - 'packages/forest_admin_agent/forest_admin_agent.gemspec' - 'packages/forest_admin_rails/forest_admin_rails.gemspec' - 'packages/forest_admin_datasource_toolkit/forest_admin_datasource_toolkit.gemspec' + - 'packages/forest_admin_test_toolkit/forest_admin_test_toolkit.gemspec' - 'packages/forest_admin_datasource_customizer/forest_admin_datasource_customizer.gemspec' - 'packages/forest_admin_datasource_active_record/forest_admin_datasource_active_record.gemspec' @@ -125,6 +127,13 @@ Style/StringLiterals: - 'packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit.rb' - 'packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/version.rb' - 'packages/forest_admin_datasource_toolkit/spec/spec_helper.rb' + - 'packages/forest_admin_datasource_toolkit/forest_admin_test_toolkit.gemspec' + - 'packages/forest_admin_test_toolkit/Gemfile' + - 'packages/forest_admin_test_toolkit/Rakefile' + - 'packages/forest_admin_test_toolkit/bin/console' + - 'packages/forest_admin_test_toolkit/lib/forest_admin_test_toolkit.rb' + - 'packages/forest_admin_test_toolkit/lib/forest_admin_test_toolkit/version.rb' + - 'packages/forest_admin_test_toolkit/spec/spec_helper.rb' - 'packages/forest_admin_datasource_customizer/forest_admin_datasource_customizer.gemspec' - 'packages/forest_admin_datasource_customizer/Gemfile' - 'packages/forest_admin_datasource_customizer/Rakefile' @@ -248,6 +257,7 @@ Metrics/ClassLength: - 'packages/forest_admin_agent/lib/forest_admin_agent/utils/query_string_parser.rb' - 'packages/forest_admin_datasource_active_record/lib/forest_admin_datasource_active_record/utils/query.rb' - 'packages/forest_admin_datasource_active_record/lib/forest_admin_datasource_active_record/collection.rb' + - 'packages/forest_admin_datasource_active_record/lib/forest_admin_datasource_active_record/datasource.rb' - 'packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/collection_customizer.rb' - 'packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/binary/binary_collection_decorator.rb' - 'packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/search/search_collection_decorator.rb' diff --git a/Gemfile b/Gemfile index e7ab838eb..5d63e709a 100644 --- a/Gemfile +++ b/Gemfile @@ -5,9 +5,9 @@ gemspec group :development, :tests do gem "overcommit", "~> 0.60" gem "rspec", "~> 3.0" - gem "rubocop", "~> 1.33" - gem "rubocop-performance", "~> 1.19" - gem "rubocop-rspec", "~> 2.23" + gem "rubocop" + gem "rubocop-performance" + gem "rubocop-rspec" gem 'simplecov', "~> 0.22", require: false gem 'simplecov_json_formatter', "~> 0.1.4" end diff --git a/packages/forest_admin_agent/lib/forest_admin_agent/http/router.rb b/packages/forest_admin_agent/lib/forest_admin_agent/http/router.rb index f1547216b..dd70e73c2 100644 --- a/packages/forest_admin_agent/lib/forest_admin_agent/http/router.rb +++ b/packages/forest_admin_agent/lib/forest_admin_agent/http/router.rb @@ -12,6 +12,7 @@ def self.routes Security::ScopeInvalidation.new.routes, Charts::Charts.new.routes, Capabilities::Collections.new.routes, + Resources::NativeQuery.new.routes, Resources::Count.new.routes, Resources::Delete.new.routes, Resources::Csv.new.routes, diff --git a/packages/forest_admin_agent/lib/forest_admin_agent/routes/abstract_route.rb b/packages/forest_admin_agent/lib/forest_admin_agent/routes/abstract_route.rb index ba4574fe7..072d31870 100644 --- a/packages/forest_admin_agent/lib/forest_admin_agent/routes/abstract_route.rb +++ b/packages/forest_admin_agent/lib/forest_admin_agent/routes/abstract_route.rb @@ -8,6 +8,8 @@ def initialize def build(args) @datasource = ForestAdminAgent::Facades::Container.datasource + return unless args[:params]['collection_name'] + @collection = @datasource.get_collection(args[:params]['collection_name']) end diff --git a/packages/forest_admin_agent/lib/forest_admin_agent/routes/capabilities/collections.rb b/packages/forest_admin_agent/lib/forest_admin_agent/routes/capabilities/collections.rb index 08283acf9..f8f96f216 100644 --- a/packages/forest_admin_agent/lib/forest_admin_agent/routes/capabilities/collections.rb +++ b/packages/forest_admin_agent/lib/forest_admin_agent/routes/capabilities/collections.rb @@ -18,6 +18,13 @@ def handle_request(args = {}) @datasource = ForestAdminAgent::Facades::Container.datasource collections = args[:params]['collectionNames'] || [] + connections = [] + ForestAdminAgent::Builder::AgentFactory.instance.customizer.datasources.map do |root_datasource| + connections = connections.union( + root_datasource.live_query_connections.keys.map { |connection_name| { name: connection_name } } + ) + end + result = collections.map do |collection_name| collection = @datasource.get_collection(collection_name) { @@ -34,7 +41,8 @@ def handle_request(args = {}) { content: { - collections: result + collections: result, + nativeQueryConnections: connections }, status: 200 } diff --git a/packages/forest_admin_agent/lib/forest_admin_agent/routes/query_handler.rb b/packages/forest_admin_agent/lib/forest_admin_agent/routes/query_handler.rb new file mode 100644 index 000000000..e3ae77e56 --- /dev/null +++ b/packages/forest_admin_agent/lib/forest_admin_agent/routes/query_handler.rb @@ -0,0 +1,56 @@ +module ForestAdminAgent + module Routes + module QueryHandler + include ForestAdminAgent::Utils + include ForestAdminAgent::Builder + include ForestAdminDatasourceToolkit::Components::Query + include ForestAdminDatasourceToolkit::Validations + + def inject_context_variables(connection_name, query, permissions, caller, context_variables) + user = permissions.get_user_data(caller.id) + team = permissions.get_team(caller.rendering_id) + context_variables = ContextVariables.new(team, user, context_variables) + + ContextVariablesInjector.inject_context_in_native_query(connection_name, query, context_variables) + end + + def execute_query(query, connection_name, permissions, caller, context_variables) + root_datasource = AgentFactory.instance.customizer.get_root_datasource_by_connection(connection_name) + query = query.strip + query, context_variables = inject_context_variables(connection_name, query, permissions, caller, + context_variables) + + root_datasource.execute_native_query( + connection_name, + query, + context_variables.values + ) + end + + def parse_query_segment(collection, args, permissions, caller) + return unless args[:params][:segmentQuery] + + unless args[:params][:connectionName] + raise ForestAdminAgent::Http::Exceptions::UnprocessableError, 'Missing native query connection attribute' + end + + QueryValidator.valid?(args[:params][:segmentQuery]) + + permissions.can_execute_query_segment?(collection, args[:params][:segmentQuery], args[:params][:connectionName]) + + ids = execute_query( + args[:params][:segmentQuery], + args[:params][:connectionName], + permissions, + caller, + args[:params][:contextVariables] + ).map(&:values) + + condition_tree_segment = ConditionTree::ConditionTreeFactory.match_ids(collection, ids) + ConditionTreeValidator.validate(condition_tree_segment, collection) + + condition_tree_segment + end + end + end +end diff --git a/packages/forest_admin_agent/lib/forest_admin_agent/routes/resources/count.rb b/packages/forest_admin_agent/lib/forest_admin_agent/routes/resources/count.rb index f9df7e9ba..9070e4eae 100644 --- a/packages/forest_admin_agent/lib/forest_admin_agent/routes/resources/count.rb +++ b/packages/forest_admin_agent/lib/forest_admin_agent/routes/resources/count.rb @@ -6,6 +6,8 @@ module Resources class Count < AbstractAuthenticatedRoute include ForestAdminAgent::Builder include ForestAdminDatasourceToolkit::Components::Query::ConditionTree + include ForestAdminAgent::Routes::QueryHandler + def setup_routes add_route('forest_count', 'get', '/:collection_name/count', ->(args) { handle_request(args) }) @@ -18,7 +20,16 @@ def handle_request(args = {}) if @collection.is_countable? filter = ForestAdminDatasourceToolkit::Components::Query::Filter.new( - condition_tree: @permissions.get_scope(@collection) + condition_tree: ConditionTreeFactory.intersect( + [ + @permissions.get_scope(@collection), + parse_query_segment(@collection, args, @permissions, @caller), + ForestAdminAgent::Utils::QueryStringParser.parse_condition_tree(@collection, args) + ] + ), + search: QueryStringParser.parse_search(@collection, args), + search_extended: QueryStringParser.parse_search_extended(args), + segment: QueryStringParser.parse_segment(@collection, args) ) aggregation = ForestAdminDatasourceToolkit::Components::Query::Aggregation.new(operation: 'Count') result = @collection.aggregate(@caller, filter, aggregation) diff --git a/packages/forest_admin_agent/lib/forest_admin_agent/routes/resources/csv.rb b/packages/forest_admin_agent/lib/forest_admin_agent/routes/resources/csv.rb index 6fc52d575..e49b89a89 100644 --- a/packages/forest_admin_agent/lib/forest_admin_agent/routes/resources/csv.rb +++ b/packages/forest_admin_agent/lib/forest_admin_agent/routes/resources/csv.rb @@ -4,6 +4,7 @@ module Resources class Csv < AbstractAuthenticatedRoute include ForestAdminDatasourceToolkit::Components::Query::ConditionTree include ForestAdminAgent::Utils + include ForestAdminAgent::Routes::QueryHandler def setup_routes add_route( @@ -25,6 +26,7 @@ def handle_request(args = {}) condition_tree: ConditionTreeFactory.intersect( [ @permissions.get_scope(@collection), + parse_query_segment(@collection, args, @permissions, @caller), ForestAdminAgent::Utils::QueryStringParser.parse_condition_tree( @collection, args ) diff --git a/packages/forest_admin_agent/lib/forest_admin_agent/routes/resources/list.rb b/packages/forest_admin_agent/lib/forest_admin_agent/routes/resources/list.rb index d2fc01c3e..5cd018464 100644 --- a/packages/forest_admin_agent/lib/forest_admin_agent/routes/resources/list.rb +++ b/packages/forest_admin_agent/lib/forest_admin_agent/routes/resources/list.rb @@ -5,6 +5,8 @@ module Routes module Resources class List < AbstractAuthenticatedRoute include ForestAdminDatasourceToolkit::Components::Query::ConditionTree + include ForestAdminAgent::Utils + include ForestAdminAgent::Routes::QueryHandler def setup_routes add_route('forest_list', 'get', '/:collection_name', ->(args) { handle_request(args) }) @@ -17,20 +19,21 @@ def handle_request(args = {}) @permissions.can?(:browse, @collection) filter = ForestAdminDatasourceToolkit::Components::Query::Filter.new( - condition_tree: ConditionTreeFactory.intersect([ - @permissions.get_scope(@collection), - ForestAdminAgent::Utils::QueryStringParser.parse_condition_tree( - @collection, args - ) - ]), - page: ForestAdminAgent::Utils::QueryStringParser.parse_pagination(args), - search: ForestAdminAgent::Utils::QueryStringParser.parse_search(@collection, args), - search_extended: ForestAdminAgent::Utils::QueryStringParser.parse_search_extended(args), - sort: ForestAdminAgent::Utils::QueryStringParser.parse_sort(@collection, args), - segment: ForestAdminAgent::Utils::QueryStringParser.parse_segment(@collection, args) + condition_tree: ConditionTreeFactory.intersect( + [ + @permissions.get_scope(@collection), + QueryStringParser.parse_condition_tree(@collection, args), + parse_query_segment(@collection, args, @permissions, @caller) + ] + ), + page: QueryStringParser.parse_pagination(args), + search: QueryStringParser.parse_search(@collection, args), + search_extended: QueryStringParser.parse_search_extended(args), + sort: QueryStringParser.parse_sort(@collection, args), + segment: QueryStringParser.parse_segment(@collection, args) ) - projection = ForestAdminAgent::Utils::QueryStringParser.parse_projection_with_pks(@collection, args) + projection = QueryStringParser.parse_projection_with_pks(@collection, args) records = @collection.list(@caller, filter, projection) { diff --git a/packages/forest_admin_agent/lib/forest_admin_agent/routes/resources/native_query.rb b/packages/forest_admin_agent/lib/forest_admin_agent/routes/resources/native_query.rb new file mode 100644 index 000000000..4b365595e --- /dev/null +++ b/packages/forest_admin_agent/lib/forest_admin_agent/routes/resources/native_query.rb @@ -0,0 +1,117 @@ +require 'jsonapi-serializers' +require 'active_support/inflector' + +module ForestAdminAgent + module Routes + module Resources + class NativeQuery < AbstractAuthenticatedRoute + include ForestAdminAgent::Builder + include ForestAdminAgent::Utils + include ForestAdminDatasourceToolkit::Exceptions + include ForestAdminDatasourceToolkit::Components::Charts + include ForestAdminAgent::Routes::QueryHandler + + def setup_routes + add_route( + 'forest_native_query', + 'post', + '/_internal/native_query', + lambda { |args| + handle_request(args) + } + ) + + self + end + + def handle_request(args = {}) + build(args) + query = args[:params][:query].strip + + QueryValidator.valid?(query) + unless args[:params][:connectionName] + raise ForestAdminAgent::Http::Exceptions::UnprocessableError, 'Missing native query connection attribute' + end + + @permissions.can_chart?(args[:params]) + + query.gsub!('?', args[:params][:record_id].to_s) if args[:params][:record_id] + self.type = args[:params][:type] + result = execute_query( + query, + args[:params][:connectionName], + @permissions, + @caller, + args[:params][:contextVariables] + ) + + { content: Serializer::ForestChartSerializer.serialize(send(:"make_#{@type}", result)) } + end + + private + + def type=(type) + chart_types = %w[Value Objective Pie Line Leaderboard] + unless chart_types.include?(type) + raise ForestAdminDatasourceToolkit::Exceptions::ForestException, "Invalid Chart type #{type}" + end + + @type = type.downcase + end + + def raise_error(result, key_names) + raise ForestException, + "The result columns must be named #{key_names} instead of '#{result.keys.join("', '")}'" + end + + def make_value(result) + return unless result.count + + result = result.first + + raise_error(result, "'value'") unless result.key?(:value) + + ValueChart.new(result[:value] || 0, result[:previous] || nil).serialize + end + + def make_objective(result) + return unless result.count + + result = result.first + + raise_error(result, "'value', 'objective'") unless result.key?(:value) || result.key?(:objective) + + ObjectiveChart.new(result[:value] || 0, result[:objective]).serialize + end + + def make_pie(result) + return unless result.count + + raise_error(result[0], "'key', 'value'") if !result[0]&.key?(:value) || !result[0]&.key?(:key) + + PieChart.new(result).serialize + end + + def make_leaderboard(result) + return unless result.count + + raise_error(result[0], "'key', 'value'") if !result[0]&.key?(:value) || !result[0]&.key?(:key) + + LeaderboardChart.new(result).serialize + end + + def make_line(result) + return unless result.count + + result = result.map! do |result_line| + raise_error(result_line, "'key', 'value'") if !result_line.key?(:value) || !result_line.key?(:key) + + { label: result_line[:key], values: { value: result_line[:value] } } + end + + LineChart.new(result).serialize + end + end + end + end +end diff --git a/packages/forest_admin_agent/lib/forest_admin_agent/routes/resources/update.rb b/packages/forest_admin_agent/lib/forest_admin_agent/routes/resources/update.rb index a86c2c053..0b0f960be 100644 --- a/packages/forest_admin_agent/lib/forest_admin_agent/routes/resources/update.rb +++ b/packages/forest_admin_agent/lib/forest_admin_agent/routes/resources/update.rb @@ -21,8 +21,7 @@ def handle_request(args = {}) id = Utils::Id.unpack_id(@collection, args[:params]['id'], with_key: true) condition_tree = ConditionTree::ConditionTreeFactory.match_records(@collection, [id]) filter = ForestAdminDatasourceToolkit::Components::Query::Filter.new( - condition_tree: ConditionTree::ConditionTreeFactory.intersect([condition_tree, scope]), - page: ForestAdminAgent::Utils::QueryStringParser.parse_pagination(args) + condition_tree: ConditionTree::ConditionTreeFactory.intersect([condition_tree, scope]) ) data = format_attributes(args) @collection.update(@caller, filter, data) diff --git a/packages/forest_admin_agent/lib/forest_admin_agent/routes/security/scope_invalidation.rb b/packages/forest_admin_agent/lib/forest_admin_agent/routes/security/scope_invalidation.rb index 7eb1cc5df..5e2ec9bb5 100644 --- a/packages/forest_admin_agent/lib/forest_admin_agent/routes/security/scope_invalidation.rb +++ b/packages/forest_admin_agent/lib/forest_admin_agent/routes/security/scope_invalidation.rb @@ -18,7 +18,7 @@ def setup_routes def handle_request(args) # Check if user is logged Utils::QueryStringParser.parse_caller(args) - Permissions.invalidate_cache('forest.scopes') + Permissions.invalidate_cache('forest.rendering') { content: nil, status: 204 } end diff --git a/packages/forest_admin_agent/lib/forest_admin_agent/services/permissions.rb b/packages/forest_admin_agent/lib/forest_admin_agent/services/permissions.rb index 538040b65..4c9902337 100644 --- a/packages/forest_admin_agent/lib/forest_admin_agent/services/permissions.rb +++ b/packages/forest_admin_agent/lib/forest_admin_agent/services/permissions.rb @@ -80,6 +80,31 @@ def can_chart?(parameters) is_allowed end + def can_execute_query_segment?(collection, query, connection_name) + hash_request = array_hash({ query: query, connectionName: connection_name }) + is_allowed = get_segments(collection).include?(hash_request) + + # Refetch + is_allowed ||= get_segments(collection, force_fetch: true).include?(hash_request) + + # still not allowed - throw forbidden message + unless is_allowed + ForestAdminAgent::Facades::Container.logger.log( + 'Debug', + "User #{caller.id} cannot retrieve query segment on rendering #{caller.rendering_id}" + ) + + raise ForbiddenError, "You don't have permission to use this query segment." + end + + ForestAdminAgent::Facades::Container.logger.log( + 'Debug', + "User #{caller.id} can retrieve query segment on rendering #{caller.rendering_id}" + ) + + is_allowed + end + def can_smart_action?(request, collection, filter, allow_fetch: true) return true unless permission_system? @@ -103,7 +128,7 @@ def can_smart_action?(request, collection, filter, allow_fetch: true) end def get_scope(collection) - permissions = get_scope_and_team_data(caller.rendering_id) + permissions = get_rendering_data(caller.rendering_id) scope = permissions[:scopes][collection.name.to_sym] return nil if scope.nil? @@ -116,6 +141,12 @@ def get_scope(collection) ContextVariablesInjector.inject_context_in_filter(scope, context_variables) end + def get_segments(collection, force_fetch: false) + permissions = get_rendering_data(caller.rendering_id, force_fetch: force_fetch) + + permissions[:segments][collection.name.to_sym] + end + def get_user_data(user_id) cache.get_or_set('forest.users') do response = fetch('/liana/v4/permissions/users') @@ -132,7 +163,7 @@ def get_user_data(user_id) end def get_team(rendering_id) - permissions = get_scope_and_team_data(rendering_id) + permissions = get_rendering_data(rendering_id) permissions[:team] end @@ -157,35 +188,23 @@ def get_collections_permissions_data(force_fetch: false) end def get_chart_data(rendering_id, force_fetch: false) - self.class.invalidate_cache('forest.stats') if force_fetch == true - - cache.get_or_set('forest.stats') do - response = fetch("/liana/v4/permissions/renderings/#{rendering_id}") - stat_hash = [] - response[:stats].each do |stat| - stat = stat.select { |_, value| !value.nil? && value != '' } - stat_hash << "#{stat[:type]}:#{array_hash(stat)}" - end - - ForestAdminAgent::Facades::Container.logger.log( - 'Debug', - "Loading rendering permissions for rendering #{rendering_id}" - ) + rendering_data = get_rendering_data(rendering_id, force_fetch: force_fetch) - stat_hash - end + rendering_data[:charts] end def sanitize_chart_parameters(parameters) parameters.delete(:timezone) parameters.delete(:collection) parameters.delete(:contextVariables) + parameters.delete(:record_id) # rails parameters.delete(:route_alias) parameters.delete(:controller) parameters.delete(:action) parameters.delete(:collection_name) parameters.delete(:forest) + parameters.delete(:format) parameters.select { |_, value| !value.nil? && value != '' } end @@ -194,13 +213,17 @@ def array_hash(data) Digest::SHA1.hexdigest(data.deep_sort.to_h.to_s) end - def get_scope_and_team_data(rendering_id) - cache.get_or_set('forest.scopes') do + def get_rendering_data(rendering_id, force_fetch: false) + self.class.invalidate_cache('forest.rendering') if force_fetch == true + + cache.get_or_set('forest.rendering') do data = {} response = fetch("/liana/v4/permissions/renderings/#{rendering_id}") data[:scopes] = decode_scope_permissions(response[:collections]) data[:team] = response[:team] + data[:segments] = decode_segment_permissions(response[:collections]) + data[:charts] = decode_charts_permissions(response[:stats]) data end @@ -265,6 +288,26 @@ def decode_scope_permissions(raw_permissions) scopes end + def decode_charts_permissions(raw_permissions) + charts = [] + + raw_permissions.each do |chart| + chart = chart.select { |_, value| !value.nil? && value != '' } + charts << "#{chart[:type]}:#{array_hash(chart)}" + end + + charts + end + + def decode_segment_permissions(raw_permissions) + segments = {} + raw_permissions.each do |collection_name, value| + segments[collection_name] = value[:liveQuerySegments].map { |segment| array_hash(segment) } + end + + segments + end + def fetch(url) response = forest_api.get(url) diff --git a/packages/forest_admin_agent/lib/forest_admin_agent/services/sse_cache_invalidation.rb b/packages/forest_admin_agent/lib/forest_admin_agent/services/sse_cache_invalidation.rb index 114f307cb..7cee8165f 100644 --- a/packages/forest_admin_agent/lib/forest_admin_agent/services/sse_cache_invalidation.rb +++ b/packages/forest_admin_agent/lib/forest_admin_agent/services/sse_cache_invalidation.rb @@ -8,7 +8,7 @@ class SSECacheInvalidation MESSAGE_CACHE_KEYS = { 'refresh-users': %w[forest.users], 'refresh-roles': %w[forest.collections], - 'refresh-renderings': %w[forest.collections forest.stats forest.scopes] + 'refresh-renderings': %w[forest.collections forest.rendering] # TODO: add one for ip whitelist when server implement it }.freeze diff --git a/packages/forest_admin_agent/lib/forest_admin_agent/utils/context_variables_injector.rb b/packages/forest_admin_agent/lib/forest_admin_agent/utils/context_variables_injector.rb index 4e2c0673c..7d4e54d54 100644 --- a/packages/forest_admin_agent/lib/forest_admin_agent/utils/context_variables_injector.rb +++ b/packages/forest_admin_agent/lib/forest_admin_agent/utils/context_variables_injector.rb @@ -2,6 +2,9 @@ module ForestAdminAgent module Utils class ContextVariablesInjector include ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes + include ForestAdminAgent::Builder + + REGEX = /{{([^}]+)}}/ def self.inject_context_in_value(value, context_variables) inject_context_in_value_custom(value) do |context_variable_key| @@ -9,14 +12,36 @@ def self.inject_context_in_value(value, context_variables) end end + def self.inject_context_in_native_query(connection_name, query, context_variables) + return query unless query.is_a?(String) + + query_with_context_variables_injected = query + encountered_variables = {} + datasource = AgentFactory.instance.customizer.get_root_datasource_by_connection(connection_name) + + while (match = REGEX.match(query_with_context_variables_injected)) + context_variable_key = match[1] + + next if encountered_variables.value?(context_variable_key) + + index = datasource.build_binding_symbol(connection_name, encountered_variables) + query_with_context_variables_injected.gsub!( + /{{#{context_variable_key}}}/, + index + ) + encountered_variables[index] = context_variables.get_value(context_variable_key) + end + + [query_with_context_variables_injected, encountered_variables] + end + def self.inject_context_in_value_custom(value) return value unless value.is_a?(String) value_with_context_variables_injected = value - regex = /{{([^}]+)}}/ encountered_variables = [] - while (match = regex.match(value_with_context_variables_injected)) + while (match = REGEX.match(value_with_context_variables_injected)) context_variable_key = match[1] unless encountered_variables.include?(context_variable_key) diff --git a/packages/forest_admin_agent/lib/forest_admin_agent/utils/query_validator.rb b/packages/forest_admin_agent/lib/forest_admin_agent/utils/query_validator.rb new file mode 100644 index 000000000..4eb4c1c47 --- /dev/null +++ b/packages/forest_admin_agent/lib/forest_admin_agent/utils/query_validator.rb @@ -0,0 +1,73 @@ +module ForestAdminAgent + module Utils + module QueryValidator + FORBIDDEN_KEYWORDS = %w[DROP DELETE INSERT UPDATE ALTER].freeze + INJECTION_PATTERNS = [ + /\bOR\s+1=1\b/i # OR 1=1 + ].freeze + + def self.valid?(query) + query = query.strip + raise ForestAdminDatasourceToolkit::Exceptions::ForestException, 'Query cannot be empty.' if query.empty? + + sanitized_query = remove_content_inside_strings(query) + check_select_only(sanitized_query) + check_semicolon_placement(sanitized_query) + check_forbidden_keywords(sanitized_query) + check_unbalanced_parentheses(sanitized_query) + check_sql_injection_patterns(sanitized_query) + + true + end + + class << self + include ForestAdminDatasourceToolkit::Exceptions + + private + + def check_select_only(query) + return if query.strip.upcase.start_with?('SELECT') + + raise ForestException, 'Only SELECT queries are allowed.' + end + + def check_semicolon_placement(query) + semicolon_count = query.scan(';').size + + raise ForestException, 'Only one query is allowed.' if semicolon_count > 1 + return if semicolon_count != 1 || query.strip[-1] == ';' + + raise ForestException, 'Semicolon must only appear as the last character in the query.' + end + + def check_forbidden_keywords(query) + FORBIDDEN_KEYWORDS.each do |keyword| + if /\b#{Regexp.escape(keyword)}\b/i.match?(query) + raise ForestException, "The query contains forbidden keyword: #{keyword}." + end + end + end + + def check_unbalanced_parentheses(query) + open_count = query.count('(') + close_count = query.count(')') + + return if open_count == close_count + + raise ForestException, 'The query contains unbalanced parentheses.' + end + + def check_sql_injection_patterns(query) + INJECTION_PATTERNS.each do |pattern| + raise ForestException, 'The query contains a potential SQL injection pattern.' if pattern.match?(query) + end + end + + def remove_content_inside_strings(query) + # remove content inside single and double quotes + query.gsub(/'(?:[^']|\\')*'/, '').gsub(/"(?:[^"]|\\")*"/, '') + end + end + end + end +end diff --git a/packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/capabilities/collections_spec.rb b/packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/capabilities/collections_spec.rb index 75fca5073..bd4854241 100644 --- a/packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/capabilities/collections_spec.rb +++ b/packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/capabilities/collections_spec.rb @@ -45,6 +45,9 @@ module Capabilities ForestAdminAgent::Builder::AgentFactory.instance.build @datasource = ForestAdminAgent::Facades::Container.datasource allow(ForestAdminAgent::Services::Permissions).to receive(:new).and_return(permissions) + allow(datasource).to receive(:live_query_connections).and_return( + { 'primary_db' => 'primary', 'replica_db' => 'replica' } + ) end it 'adds the route forest_list' do @@ -69,19 +72,28 @@ module Capabilities it 'returns no collection' do expect(result[:content][:collections].length).to eq(0) end + + it 'returns all existing native query connections' do + expect(result[:content][:nativeQueryConnections]).to eq( + [{ name: 'primary_db' }, { name: 'replica_db' }] + ) + end end context 'when there is collectionNames in params' do - it 'return the collections provided in params' do - args = { + let(:args) do + { headers: { 'HTTP_AUTHORIZATION' => bearer }, params: { 'collectionNames' => ['user'], 'timezone' => 'Europe/Paris' } } - result = capabilities_collections.handle_request(args) + end + + let(:result) { capabilities_collections.handle_request(args) } + it 'return the collections provided in params' do expect(result[:content][:collections].length).to eq(1) expect(result[:content][:collections][0][:name]).to eq('user') fields = result[:content][:collections][0][:fields] @@ -96,6 +108,12 @@ module Capabilities expect(fields[2]).to include(name: 'last_name', type: 'String') expect(fields[2][:operators]).to include('equal', 'present', 'in', 'missing') end + + it 'returns all existing native query connections' do + expect(result[:content][:nativeQueryConnections]).to eq( + [{ name: 'primary_db' }, { name: 'replica_db' }] + ) + end end it 'throws an error when th collection does not exist' do diff --git a/packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/charts/charts_spec.rb b/packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/charts/charts_spec.rb index b3d5d6dc2..0d4498c1c 100644 --- a/packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/charts/charts_spec.rb +++ b/packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/charts/charts_spec.rb @@ -492,13 +492,17 @@ module Charts allow(@datasource.get_collection('book')).to receive(:aggregate).and_return([{ value: 10, group: [] }]) chart.handle_request(args) - expect(chart.filter).eql?(Filter.new( - condition_tree: { field: 'title', operator: Operators::EQUAL, - value: 'FOO' }, search: nil, search_extended: nil, segment: nil, sort: nil, page: nil - )) + expect(chart.filter).to have_attributes( + condition_tree: have_attributes(field: 'title', operator: Operators::EQUAL, value: 'FOO'), + search: nil, + search_extended: nil, + segment: nil, + sort: nil, + page: nil + ) end - it 'doeses not override the filter when there is no filter with a context variable' do + it 'does not override the filter when there is no filter with a context variable' do args[:params] = args[:params].merge({ type: 'Value', sourceCollectionName: 'book', @@ -509,8 +513,14 @@ module Charts allow(@datasource.get_collection('book')).to receive(:aggregate).and_return([{ value: 10, group: [] }]) chart.handle_request(args) - expect(chart.filter).eql?(Filter.new(condition_tree: nil, search: nil, search_extended: nil, segment: nil, - sort: nil, page: nil)) + expect(chart.filter).to have_attributes( + condition_tree: nil, + search: nil, + search_extended: nil, + segment: nil, + sort: nil, + page: nil + ) end end end diff --git a/packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/query_handler_spec.rb b/packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/query_handler_spec.rb new file mode 100644 index 000000000..d6101fe30 --- /dev/null +++ b/packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/query_handler_spec.rb @@ -0,0 +1,150 @@ +require 'spec_helper' +require 'shared/caller' + +module ForestAdminAgent + module Routes + include ForestAdminDatasourceToolkit + include ForestAdminDatasourceToolkit::Schema + include ForestAdminDatasourceToolkit::Components::Query::ConditionTree + include ForestAdminAgent::Http::Exceptions + + describe QueryHandler do + include_context 'with caller' + + let(:dummy_class) { Class.new { extend QueryHandler } } + let(:datasource) { datasource_build(execute_native_query: [{ id: 1 }, { id: 2 }], build_binding_symbol: '$1') } + + let(:permission) do + instance_double( + ForestAdminAgent::Services::Permissions, + get_user_data: { + id: 1, + firstName: 'John', + lastName: 'Doe', + fullName: 'John Doe', + email: 'johndoe@forestadmin.com', + tags: { 'foo' => 'bar' }, + roleId: 1, + permissionLevel: 'admin' + }, + get_team: { id: 100, name: 'Operations' }, + can_execute_query_segment?: nil + ) + end + + let(:collection) do + collection = collection_build( + datasource: datasource, + name: 'Category', + schema: { + fields: { + 'id' => ColumnSchema.new(column_type: 'Number', is_primary_key: true, + filter_operators: [Operators::EQUAL, Operators::IN]), + 'label' => ColumnSchema.new(column_type: 'String') + } + } + ) + + datasource.add_collection(collection) + + collection + end + + before do + datasource_customizer = instance_double( + ForestAdminDatasourceCustomizer::DatasourceCustomizer, + { + get_root_datasource_by_connection: collection.datasource + } + ) + allow(ForestAdminAgent::Builder::AgentFactory.instance).to receive(:customizer) + .and_return(datasource_customizer) + end + + describe 'parse_query_segment' do + it 'return null when not provided' do + expect(dummy_class.parse_query_segment(collection, { params: {} }, permission, caller)).to be_nil + end + + it 'raise an error when connectionName not provided' do + expect do + dummy_class.parse_query_segment( + collection, + { params: { segmentQuery: 'select id from user' } }, + permission, + caller + ) + end.to raise_error(UnprocessableError, 'Missing native query connection attribute') + end + + it 'work when passed in the querystring for list' do + args = { + params: { + segmentQuery: 'SELECT id from user', + connectionName: 'primary' + } + } + + condition_tree = dummy_class.parse_query_segment(collection, args, permission, caller) + expect(condition_tree.to_h).to eq({ field: 'id', operator: Operators::IN, value: [1, 2] }) + end + + it 'work with inject context variable' do + args = { + params: { + segmentQuery: 'SELECT id FROM users WHERE id > {{currentUser.id}};', + connectionName: 'primary' + } + } + + dummy_class.parse_query_segment(collection, args, permission, caller) + expect(datasource).to have_received(:execute_native_query) do |connection_name, query, binds| + expect(connection_name).to eq('primary') + expect(query).to eq('SELECT id FROM users WHERE id > $1;') + expect(binds).to eq([1]) + end + end + end + + describe 'execute_query' do + it 'raise an error when connectionName was unknown' do + datasource_customizer = ForestAdminDatasourceCustomizer::DatasourceCustomizer.new + allow(ForestAdminAgent::Builder::AgentFactory.instance).to receive(:customizer) + .and_return(datasource_customizer) + + expect do + dummy_class.execute_query( + 'select id from user', + 'foo', + permission, + caller, + {} + ) + end.to raise_error(NotFoundError, "Native query connection 'foo' is unknown.") + end + + it 'work when passed in the querystring for list' do + result = dummy_class.execute_query('SELECT id from user', 'primary', permission, caller, {}) + + expect(result).to eq([{ id: 1 }, { id: 2 }]) + end + + it 'work with inject context variables' do + dummy_class.execute_query( + 'SELECT id FROM users WHERE id > {{foo.id}};', + 'primary', + permission, + caller, + { 'foo.id' => 1 } + ) + + expect(datasource).to have_received(:execute_native_query) do |connection_name, query, binds| + expect(connection_name).to eq('primary') + expect(query).to eq('SELECT id FROM users WHERE id > $1;') + expect(binds).to eq([1]) + end + end + end + end + end +end diff --git a/packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/resources/list_spec.rb b/packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/resources/list_spec.rb index 7f3881007..01b14fd07 100644 --- a/packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/resources/list_spec.rb +++ b/packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/resources/list_spec.rb @@ -51,7 +51,21 @@ module Resources allow(@datasource.get_collection('user')).to receive(:list).and_return([User.new(1, 'foo', 'foo')]) allow(ForestAdminAgent::Services::Permissions).to receive(:new).and_return(permissions) - allow(permissions).to receive_messages(can?: true, get_scope: nil) + allow(permissions).to receive_messages( + can?: true, + get_scope: nil, + get_user_data: { + id: 1, + firstName: 'John', + lastName: 'Doe', + fullName: 'John Doe', + email: 'johndoe@forestadmin.com', + tags: { 'foo' => 'bar' }, + roleId: 1, + permissionLevel: 'admin' + }, + get_team: { id: 100, name: 'Operations' } + ) end it 'adds the route forest_list' do diff --git a/packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/resources/native_query_spec.rb b/packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/resources/native_query_spec.rb new file mode 100644 index 000000000..b718883a1 --- /dev/null +++ b/packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/resources/native_query_spec.rb @@ -0,0 +1,386 @@ +require 'spec_helper' +require 'singleton' +require 'ostruct' +require 'shared/caller' +require 'json' + +module ForestAdminAgent + module Routes + module Resources + include ForestAdminDatasourceToolkit + include ForestAdminDatasourceToolkit::Exceptions + include ForestAdminDatasourceToolkit::Schema + + describe NativeQuery do + include_context 'with caller' + subject(:native_query) { described_class.new } + let(:args) do + { + headers: { 'HTTP_AUTHORIZATION' => bearer }, + params: { + 'connectionName' => 'primary', + 'timezone' => 'Europe/Paris' + } + } + end + let(:permissions) { instance_double(ForestAdminAgent::Services::Permissions) } + + before do + @root_datasource = Datasource.new + ForestAdminAgent::Builder::AgentFactory.instance.add_datasource(@root_datasource) + customizer = instance_double( + ForestAdminDatasourceCustomizer::DatasourceCustomizer, + get_root_datasource_by_connection: @root_datasource + ) + allow(ForestAdminAgent::Builder::AgentFactory.instance).to receive_messages( + send_schema: nil, + customizer: customizer + ) + + allow(ForestAdminAgent::Services::Permissions).to receive(:new).and_return(permissions) + allow(permissions).to receive_messages( + can_chart?: true, + get_scope: nil, + get_user_data: { + id: 1, + firstName: 'John', + lastName: 'Doe', + fullName: 'John Doe', + email: 'johndoe@forestadmin.com', + tags: { 'foo' => 'bar' }, + roleId: 1, + permissionLevel: 'admin' + }, + get_team: { id: 100, name: 'Operations' } + ) + end + + it 'adds the route forest_native_query' do + native_query.setup_routes + expect(native_query.routes.include?('forest_native_query')).to be true + expect(native_query.routes.length).to eq 1 + end + + it 'throw an error when request does not have connectionName' do + args[:params][:type] = 'unknown_type' + args[:params][:query] = 'select * from table' + + expect do + native_query.handle_request(args) + end.to raise_error( + ForestAdminAgent::Http::Exceptions::UnprocessableError, 'Missing native query connection attribute' + ) + end + + it 'throw an error when request has a bad chart type' do + args[:params][:type] = 'unknown_type' + args[:params][:connectionName] = 'primary' + args[:params][:query] = 'select * from table' + + expect do + native_query.handle_request(args) + end.to raise_error( + ForestException, '🌳🌳🌳 Invalid Chart type unknown_type' + ) + end + + describe 'makeValue' do + it 'return a valueChart' do + args[:params] = args[:params].merge( + { + query: 'SELECT COUNT(*) AS value FROM customers;', + type: 'Value', + connectionName: 'primary' + } + ) + allow(@root_datasource).to receive(:execute_native_query).and_return([{ value: 10 }]) + result = native_query.handle_request(args) + + expect(result).to match( + content: { + data: { + id: be_a(String), + type: 'stats', + attributes: { + value: { countCurrent: 10, countPrevious: nil } + } + } + } + ) + end + + it 'return a valueChart with previous value' do + args[:params] = args[:params].merge( + { + query: 'SELECT COUNT(*) AS value, COUNT(*) AS previous FROM customers;', + type: 'Value', + connectionName: 'primary' + } + ) + allow(@root_datasource).to receive(:execute_native_query).and_return([{ value: 10, previous: 10 }]) + result = native_query.handle_request(args) + + expect(result).to match( + content: { + data: { + id: be_a(String), + type: 'stats', + attributes: { + value: { countCurrent: 10, countPrevious: 10 } + } + } + } + ) + end + + it 'raise an error if result query has not the expected column' do + args[:params] = args[:params].merge( + { + query: 'SELECT COUNT(*) AS foo FROM customers;', + type: 'Value', + connectionName: 'primary' + } + ) + allow(@root_datasource).to receive(:execute_native_query).and_return([{ foo: 10 }]) + + expect { native_query.handle_request(args) }.to raise_error( + ForestException, + "🌳🌳🌳 The result columns must be named 'value' instead of 'foo'" + ) + end + end + + describe 'makeObjective' do + it 'return a objectiveChart' do + args[:params] = args[:params].merge( + { + query: 'SELECT COUNT(orders) AS value, 750 AS objective FROM orders;', + type: 'Objective', + connectionName: 'primary' + } + ) + allow(@root_datasource).to receive(:execute_native_query).and_return( + [{ value: 200, objective: 750 }] + ) + result = native_query.handle_request(args) + + expect(result).to match( + content: { + data: { + id: be_a(String), + type: 'stats', + attributes: { + value: { value: 200, objective: 750 } + } + } + } + ) + end + + it 'SELECT COUNT(orders) AS foo, 750 AS objective FROM orders;' do + args[:params] = args[:params].merge( + { + query: 'SELECT COUNT(*) AS foo FROM customers;', + type: 'Value', + connectionName: 'primary' + } + ) + allow(@root_datasource).to receive(:execute_native_query).and_return([{ foo: 10 }]) + + expect { native_query.handle_request(args) }.to raise_error( + ForestException, + "🌳🌳🌳 The result columns must be named 'value' instead of 'foo'" + ) + end + end + + describe 'makePie' do + it 'return a PieChart' do + args[:params] = args[:params].merge( + { + query: 'SELECT transactions.status AS key, COUNT(*) AS value FROM transactions GROUP BY status;', + type: 'Pie', + connectionName: 'primary' + } + ) + allow(@root_datasource).to receive(:execute_native_query).and_return( + [{ key: 'pending', value: 10 }, { key: 'done', value: 100 }] + ) + result = native_query.handle_request(args) + + expect(result).to match( + content: { + data: { + id: be_a(String), + type: 'stats', + attributes: { + value: [{ key: 'pending', value: 10 }, { key: 'done', value: 100 }] + } + } + } + ) + end + + it 'raise an error if result query has not the expected column' do + args[:params] = args[:params].merge( + { + query: 'SELECT transactions.status AS foo, COUNT(*) AS value FROM transactions GROUP BY status;', + type: 'Pie', + connectionName: 'primary' + } + ) + allow(@root_datasource).to receive(:execute_native_query).and_return([{ foo: 10 }]) + + expect { native_query.handle_request(args) }.to raise_error( + ForestException, + "🌳🌳🌳 The result columns must be named 'key', 'value' instead of 'foo'" + ) + end + end + + describe 'makeLine' do + it 'return a LineChart with day time range' do + args[:params] = args[:params].merge( + { + query: "SELECT DATE_TRUNC('month', start_date) AS key, COUNT(*) as value + FROM appointments GROUP BY key ORDER BY key;", + type: 'Line', + connectionName: 'primary' + } + ) + allow(@root_datasource).to receive(:execute_native_query).and_return( + [ + { value: 10, key: '2022-01-01 00:00:00' }, + { value: 15, key: '2022-02-01 00:00:00' } + ] + ) + result = native_query.handle_request(args) + + expect(result).to match( + content: { + data: { + id: be_a(String), + type: 'stats', + attributes: { + value: [ + { label: '2022-01-01 00:00:00', values: { value: 10 } }, + { label: '2022-02-01 00:00:00', values: { value: 15 } } + ] + } + } + } + ) + end + + it 'raise an error if result query has not the expected column' do + args[:params] = args[:params].merge( + { + query: "SELECT DATE_TRUNC('month', start_date) AS foo, COUNT(*) as value + FROM appointments GROUP BY key ORDER BY key;", + type: 'Line', + connectionName: 'primary' + } + ) + allow(@root_datasource).to receive(:execute_native_query).and_return( + [ + { value: 10, foo: '2022-01-01 00:00:00' }, + { value: 15, foo: '2022-02-01 00:00:00' } + ] + ) + + expect { native_query.handle_request(args) }.to raise_error( + ForestException, + "🌳🌳🌳 The result columns must be named 'key', 'value' instead of 'value', 'foo'" + ) + end + end + + describe 'makeLeaderboard' do + it 'return a LeaderboardChart' do + args[:params] = args[:params].merge( + { + query: "SELECT companies.name AS key, SUM(transactions.amount) AS value + FROM transactions + JOIN companies ON transactions.beneficiary_company_id = companies.id + GROUP BY key + ORDER BY value DESC + LIMIT 10;", + type: 'Leaderboard', + connectionName: 'primary' + } + ) + allow(@root_datasource).to receive(:execute_native_query).and_return( + [ + { value: 10, key: 2022 }, + { value: 15, key: 2023 } + ] + ) + + result = native_query.handle_request(args) + + expect(result).to match( + content: { + data: { + id: be_a(String), + type: 'stats', + attributes: { + value: [{ key: 2022, value: 10 }, { key: 2023, value: 15 }] + } + } + } + ) + end + + it 'raise an error if result query has not the expected column' do + args[:params] = args[:params].merge( + { + query: "SELECT companies.name AS foo, SUM(transactions.amount) AS value + FROM transactions + JOIN companies ON transactions.beneficiary_company_id = companies.id + GROUP BY key + ORDER BY value DESC + LIMIT 10;", + type: 'Leaderboard', + connectionName: 'primary' + } + ) + allow(@root_datasource).to receive(:execute_native_query).and_return( + [ + { value: 10, foo: 2022 }, + { value: 15, foo: 2023 } + ] + ) + + expect { native_query.handle_request(args) }.to raise_error( + ForestException, + "🌳🌳🌳 The result columns must be named 'key', 'value' instead of 'value', 'foo'" + ) + end + end + + describe 'inject_context_variables' do + it 'overrides the query with the context variables' do + args[:params] = args[:params].merge( + { + query: 'SELECT COUNT(*) AS value FROM customers WHERE id > {{dropdown1.selectedValue}};', + type: 'Value', + connectionName: 'primary', + contextVariables: { 'dropdown1.selectedValue' => 'FOO' }, + timezone: 'Europe/Paris' + } + ) + + allow(@root_datasource).to receive_messages(execute_native_query: [{ value: 10, previous: 10 }], build_binding_symbol: '$1') + native_query.handle_request(args) + + expect(@root_datasource).to have_received(:execute_native_query) do |connection_name, query, binds| + expect(connection_name).to eq('primary') + expect(query).to eq('SELECT COUNT(*) AS value FROM customers WHERE id > $1;') + expect(binds).to eq(['FOO']) + end + end + end + end + end + end +end diff --git a/packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/security/scope_invalidation_spec.rb b/packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/security/scope_invalidation_spec.rb index a65fbcdbf..0a574b434 100644 --- a/packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/security/scope_invalidation_spec.rb +++ b/packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/security/scope_invalidation_spec.rb @@ -41,7 +41,7 @@ module Security it 'call the invalidate_cache method' do scope_invalidation.handle_request(args) - expect(permissions).to have_received(:invalidate_cache).with('forest.scopes') + expect(permissions).to have_received(:invalidate_cache).with('forest.rendering') end end end diff --git a/packages/forest_admin_agent/spec/lib/forest_admin_agent/services/permissions_spec.rb b/packages/forest_admin_agent/spec/lib/forest_admin_agent/services/permissions_spec.rb index 7f8211c2d..78e899c5c 100644 --- a/packages/forest_admin_agent/spec/lib/forest_admin_agent/services/permissions_spec.rb +++ b/packages/forest_admin_agent/spec/lib/forest_admin_agent/services/permissions_spec.rb @@ -365,6 +365,78 @@ module Services end end + context 'when can_execute_query_segment? is called' do + let(:query) { 'select id from books' } + let(:connection_name) { 'default' } + + before do + allow(forest_api_requester).to receive(:get).with('/liana/v4/permissions/renderings/114').and_return( + instance_double( + Faraday::Response, + status: 200, + body: { + 'collections' => { + 'Book' => { + 'scope' => nil, + 'segments' => [], + 'liveQuerySegments' => [{ query: query, connectionName: connection_name }] + } + }, + 'stats' => [], + 'team' => {} + }.to_json + ) + ) + end + + it 'returns true when user is allowed' do + expect(@permissions.can_execute_query_segment?(@datasource.collections['Book'], query, connection_name)).to be true + end + + it 'calls get_segments to check if permissions has changed' do + allow(forest_api_requester).to receive(:get).with('/liana/v4/permissions/renderings/114').and_return( + instance_double( + Faraday::Response, + status: 200, + body: { + 'collections' => { + 'Book' => { + 'scope' => nil, + 'segments' => [], + 'liveQuerySegments' => [{ query: query, connectionName: connection_name }] + } + }, + 'stats' => [], + 'team' => {} + }.to_json + ), + instance_double( + Faraday::Response, + status: 200, + body: { + 'collections' => { + 'Book' => { + 'scope' => nil, + 'segments' => [], + 'liveQuerySegments' => [{ query: query, connectionName: 'primary' }] + } + }, + 'stats' => [], + 'team' => {} + }.to_json + ) + ) + + expect(@permissions.can_execute_query_segment?(@datasource.collections['Book'], query, 'primary')).to be true + end + + it "raise error when user doesn't have the right access" do + expect do + @permissions.can?(@permissions.can_execute_query_segment?(@datasource.collections['Book'], query, 'foo')) + end.to raise_error(ForbiddenError, "You don't have permission to use this query segment.") + end + end + context 'when can_chart is called' do before do allow(forest_api_requester).to receive(:get).with('/liana/v4/permissions/renderings/114').and_return( @@ -374,7 +446,8 @@ module Services 'collections' => { 'Book' => { 'scope' => nil, - 'segments' => [] + 'segments' => [], + 'liveQuerySegments' => [] } }, 'stats' => [ @@ -425,9 +498,12 @@ module Services allow(@permissions).to receive(:fetch).and_return( { - stats: [] + collections: {}, + stats: [], + team: {} }, { + collections: {}, stats: [ { type: 'Pie', @@ -437,7 +513,8 @@ module Services aggregateFieldName: nil, sourceCollectionName: 'Book' } - ] + ], + team: {} } ) @@ -452,7 +529,13 @@ module Services type: 'Pie' } - allow(@permissions).to receive(:fetch).and_return({ stats: [] }) + allow(@permissions).to receive(:fetch).and_return( + { + collections: {}, + stats: [], + team: {} + } + ) expect do @permissions.can_chart?(args[:params]) @@ -462,45 +545,25 @@ module Services end context 'when get_scope is called' do - let(:scope) { nil } - - before do + it 'returns nil when permission has no scopes' do allow(forest_api_requester).to receive(:get).with('/liana/v4/permissions/renderings/114').and_return( - instance_double(Faraday::Response, - status: 200, - body: { - 'collections' => { - 'Book' => { - 'scope' => scope, - 'segments' => [] - } - }, - 'stats' => [ - { - 'type' => 'Pie', - 'filter' => nil, - 'aggregator' => 'Count', - 'groupByFieldName' => 'id', - 'aggregateFieldName' => nil, - 'sourceCollectionName' => 'Book' - }, - { - 'type' => 'Value', - 'filter' => nil, - 'aggregator' => 'Count', - 'aggregateFieldName' => nil, - 'sourceCollectionName' => 'Book' - } - ], - 'team' => { - 'id' => 1, - 'name' => 'Operations' - } - }.to_json) + instance_double( + Faraday::Response, + status: 200, + body: { + 'collections' => { + 'Book' => { + 'scope' => nil, + 'segments' => [], + 'liveQuerySegments' => [] + } + }, + 'stats' => [], + 'team' => {} + }.to_json + ) ) - end - it 'returns nil when permission has no scopes' do fake_collection = instance_double(Collection, name: 'FakeCollection') expect(@permissions.get_scope(fake_collection)).to be_nil end @@ -521,25 +584,69 @@ module Services } ] } + allow(forest_api_requester).to receive(:get).with('/liana/v4/permissions/renderings/114').and_return( + instance_double( + Faraday::Response, + status: 200, + body: { + 'collections' => { + 'Book' => { + 'scope' => scope, + 'segments' => [], + 'liveQuerySegments' => [] + } + }, + 'stats' => [], + 'team' => {} + }.to_json + ) + ) - expect(@permissions.get_scope(@datasource.collections['Book'])) - .eql?(ConditionTreeFactory.from_plain_object(scope)) + expect(@permissions.get_scope(@datasource.collections['Book']).to_h) + .to eq(ConditionTreeFactory.from_plain_object(scope).to_h) end it 'works with substitutions' do - scope = { - aggregator: 'and', - conditions: [ + allow(forest_api_requester).to receive(:get).with('/liana/v4/permissions/renderings/114').and_return( + instance_double( + Faraday::Response, + status: 200, + body: { + 'collections' => { + 'Book' => { + 'scope' => { + aggregator: 'and', + conditions: [ + { + field: 'id', + operator: 'equal', + value: '{{currentUser.id}}' + } + ] + }, + 'segments' => [], + 'liveQuerySegments' => [] + } + }, + 'stats' => [], + 'team' => {} + }.to_json + ) + ) + + expect(@permissions.get_scope(@datasource.collections['Book']).to_h) + .to eq(ConditionTreeFactory.from_plain_object( { - field: 'id', - operator: 'equal', - value: '{{currentUser.id}}' + aggregator: 'and', + conditions: [ + { + field: 'id', + operator: 'equal', + value: '1' + } + ] } - ] - } - - expect(@permissions.get_scope(@datasource.collections['Book'])) - .eql?(ConditionTreeFactory.from_plain_object(scope)) + ).to_h) end end diff --git a/packages/forest_admin_agent/spec/lib/forest_admin_agent/services/sse_cache_invalidation_spec.rb b/packages/forest_admin_agent/spec/lib/forest_admin_agent/services/sse_cache_invalidation_spec.rb index 6dea7dcc6..2deb667a1 100644 --- a/packages/forest_admin_agent/spec/lib/forest_admin_agent/services/sse_cache_invalidation_spec.rb +++ b/packages/forest_admin_agent/spec/lib/forest_admin_agent/services/sse_cache_invalidation_spec.rb @@ -60,8 +60,7 @@ module Services described_class.run expect(permissions).to have_received(:invalidate_cache).with('forest.collections') - expect(permissions).to have_received(:invalidate_cache).with('forest.stats') - expect(permissions).to have_received(:invalidate_cache).with('forest.scopes') + expect(permissions).to have_received(:invalidate_cache).with('forest.rendering') end end end diff --git a/packages/forest_admin_agent/spec/lib/forest_admin_agent/utils/condition_tree_parser_spec.rb b/packages/forest_admin_agent/spec/lib/forest_admin_agent/utils/condition_tree_parser_spec.rb index 2da105015..bdfb372e8 100644 --- a/packages/forest_admin_agent/spec/lib/forest_admin_agent/utils/condition_tree_parser_spec.rb +++ b/packages/forest_admin_agent/spec/lib/forest_admin_agent/utils/condition_tree_parser_spec.rb @@ -39,55 +39,52 @@ module Utils filters = { aggregator: 'And', conditions: [ - { field: 'id', operator: 'Less_Than', value: 'something' }, - { field: 'id', operator: 'Greater_Than', value: 'something' } + { field: 'id', operator: Operators::LESS_THAN, value: 'something' }, + { field: 'id', operator: Operators::GREATER_THAN, value: 'something' } ] } - - expect(condition_tree_parser.from_plain_object(collection_category, filters)) - .eql?(ConditionTreeBranch.new( - filters[:aggregator].capitalize, - [ - ConditionTreeLeaf.new( - filters[:conditions][0][:field], - filters[:conditions][0][:operator], - filters[:conditions][0][:value] - ), - ConditionTreeLeaf.new( - filters[:conditions][1][:field], - filters[:conditions][1][:operator], - filters[:conditions][1][:value] - ) - ] - )) + result = condition_tree_parser.from_plain_object(collection_category, filters) + expect(result) + .to have_attributes( + aggregator: filters[:aggregator].capitalize, + conditions: contain_exactly(have_attributes( + field: filters[:conditions][0][:field], + operator: filters[:conditions][0][:operator], + value: filters[:conditions][0][:value] + ), have_attributes( + field: filters[:conditions][1][:field], + operator: filters[:conditions][1][:operator], + value: filters[:conditions][1][:value] + )) + ) end it 'works with single condition without aggregator' do - filters = { field: 'id', operator: 'Less_Than', value: 'something' } + filters = { field: 'id', operator: Operators::LESS_THAN, value: 'something' } expect(condition_tree_parser.from_plain_object(collection_category, filters)) - .eql?(ConditionTreeLeaf.new(filters[:field], filters[:operator], filters[:value])) + .to have_attributes(field: filters[:field], operator: filters[:operator], value: filters[:value]) end it 'works with "IN" on a string' do - filters = { field: 'label', operator: 'In', value: ' id1,id2 , id3' } + filters = { field: 'label', operator: Operators::IN, value: ' id1,id2 , id3' } expect(condition_tree_parser.from_plain_object(collection_category, filters)) - .eql?(ConditionTreeLeaf.new(filters[:field], filters[:operator], filters[:value])) + .to have_attributes(field: filters[:field], operator: filters[:operator], value: filters[:value]) end it 'works with "IN" on a boolean' do - filters = { field: 'active', operator: 'In', value: 'true,0,false,yes,no' } + filters = { field: 'active', operator: Operators::IN, value: 'true,0,false,yes,no' } expect(condition_tree_parser.from_plain_object(collection_category, filters)) - .eql?(ConditionTreeLeaf.new(filters[:field], filters[:operator], filters[:value])) + .to have_attributes(field: filters[:field], operator: filters[:operator], value: filters[:value]) end it 'works with "IN" on a number' do - filters = { field: 'id', operator: 'In', value: '1,2,3' } + filters = { field: 'id', operator: Operators::IN, value: '1,2,3' } expect(condition_tree_parser.from_plain_object(collection_category, filters)) - .eql?(ConditionTreeLeaf.new(filters[:field], filters[:operator], filters[:value])) + .to have_attributes(field: filters[:field], operator: filters[:operator], value: filters[:value]) end end end diff --git a/packages/forest_admin_agent/spec/lib/forest_admin_agent/utils/query_string_parser_spec.rb b/packages/forest_admin_agent/spec/lib/forest_admin_agent/utils/query_string_parser_spec.rb index 6c935fdb9..fc4ad0516 100644 --- a/packages/forest_admin_agent/spec/lib/forest_admin_agent/utils/query_string_parser_spec.rb +++ b/packages/forest_admin_agent/spec/lib/forest_admin_agent/utils/query_string_parser_spec.rb @@ -328,30 +328,12 @@ module Utils it 'work when passed in the querystring for list' do args = { params: { - filters: '{"aggregator":"And","conditions": [{"field":"id","operator":"Equal","value":"123e4567-e89b-12d3-a456-426614174000"}]}' + filters: '{"aggregator":"And","conditions": [{"field":"id","operator":"equal","value":"123e4567-e89b-12d3-a456-426614174000"}]}' } } - expect(described_class.parse_condition_tree(collection_category, args)).eql?( - ConditionTreeLeaf.new('id', 'Equal', '123e4567-e89b-12d3-a456-426614174000') - ) - end - - it 'works when passed in the body for charts' do - args = { - params: { - data: { - attributes: { - all_records_subset_query: { - filters: '{"field":"id","operator":"Equal","value":"123e4567-e89b-12d3-a456-426614174000"}' - } - } - } - } - } - - expect(described_class.parse_condition_tree(collection_category, args)).eql?( - ConditionTreeLeaf.new('id', 'Equal', '123e4567-e89b-12d3-a456-426614174001') + expect(described_class.parse_condition_tree(collection_category, args)).to have_attributes( + field: 'id', operator: 'equal', value: '123e4567-e89b-12d3-a456-426614174000' ) end @@ -361,15 +343,15 @@ module Utils data: { attributes: { all_records_subset_query: { - filters: '{"field":"id","operator":"Equal","value":"123e4567-e89b-12d3-a456-426614174000"}' + filters: '{"field":"id","operator":"equal","value":"123e4567-e89b-12d3-a456-426614174000"}' } } } } } - expect(described_class.parse_condition_tree(collection_category, args)).eql?( - ConditionTreeLeaf.new('id', 'Equal', '123e4567-e89b-12d3-a456-426614174000') + expect(described_class.parse_condition_tree(collection_category, args)).to have_attributes( + field: 'id', operator: 'equal', value: '123e4567-e89b-12d3-a456-426614174000' ) end diff --git a/packages/forest_admin_agent/spec/lib/forest_admin_agent/utils/query_validator_spec.rb b/packages/forest_admin_agent/spec/lib/forest_admin_agent/utils/query_validator_spec.rb new file mode 100644 index 000000000..2971657f2 --- /dev/null +++ b/packages/forest_admin_agent/spec/lib/forest_admin_agent/utils/query_validator_spec.rb @@ -0,0 +1,94 @@ +require 'spec_helper' +require 'shared/caller' + +module ForestAdminAgent + module Utils + include ForestAdminDatasourceToolkit::Exceptions + describe QueryValidator do + describe 'valid queries' do + it 'allows a simple SELECT query' do + query = 'SELECT * FROM users;' + expect(described_class).to be_valid(query) + end + + it 'allows a query with a WHERE clause containing parentheses' do + query = "SELECT * FROM users WHERE (id > 1 AND name = 'John');" + expect(described_class).to be_valid(query) + end + + it 'allows balanced parentheses in subqueries' do + query = 'SELECT * FROM (SELECT id FROM users) AS subquery;' + expect(described_class).to be_valid(query) + end + + it 'allows a query with a subquery using the IN clause' do + query = 'SELECT id, name FROM users WHERE id IN (SELECT user_id FROM orders WHERE total > 100);' + expect(described_class).to be_valid(query) + end + + it 'allows a query without a semicolon when semicolon is not required' do + query = 'SELECT name FROM users' + expect(described_class).to be_valid(query) + end + + it 'does not raise an error for a semicolon inside a string in the WHERE clause' do + query = 'SELECT * FROM users WHERE name = "test;";' + expect { described_class.valid?(query) }.not_to raise_error + end + + it 'does not raise an error for a parenthesis inside a string in the WHERE clause' do + query = 'SELECT * FROM users WHERE name = "(test)";' + expect { described_class.valid?(query) }.not_to raise_error + end + + it 'allows a query with a lowercase SELECT' do + query = "select * from users WHERE username = 'admin';" + expect { described_class.valid?(query) }.not_to raise_error + end + end + + describe 'queries that raise exceptions' do + it 'raises an error for an empty query' do + query = ' ' + expect { described_class.valid?(query) }.to raise_error(ForestException, '🌳🌳🌳 Query cannot be empty.') + end + + it 'raises an error for non-SELECT queries' do + query = 'DELETE FROM users;' + expect { described_class.valid?(query) }.to raise_error(ForestException, '🌳🌳🌳 Only SELECT queries are allowed.') + end + + it 'raises an error for multiple queries' do + query = 'SELECT * FROM users; SELECT * FROM orders;' + expect { described_class.valid?(query) }.to raise_error(ForestException, '🌳🌳🌳 Only one query is allowed.') + end + + it 'raises an error for unbalanced parentheses outside WHERE clause' do + query = 'SELECT (id, name FROM users WHERE (id > 1);' + expect { described_class.valid?(query) }.to raise_error(ForestException, '🌳🌳🌳 The query contains unbalanced parentheses.') + end + + it 'raises an error for a semicolon not at the end of the query' do + query = 'SELECT * FROM users; WHERE id > 1' + expect { described_class.valid?(query) }.to raise_error(ForestException, '🌳🌳🌳 Semicolon must only appear as the last character in the query.') + end + + it 'raises an error for forbidden keywords even inside subqueries' do + query = 'SELECT * FROM users WHERE id IN (DROP TABLE users);' + expect { described_class.valid?(query) }.to raise_error(ForestException, '🌳🌳🌳 The query contains forbidden keyword: DROP.') + end + + it 'raises an error for unbalanced parentheses in subqueries' do + query = 'SELECT * FROM (SELECT id, name FROM users WHERE id > 1;' + expect { described_class.valid?(query) }.to raise_error(ForestException, '🌳🌳🌳 The query contains unbalanced parentheses.') + end + + it 'raises an error for an OR-based injection' do + query = "SELECT * FROM users WHERE username = 'admin' OR 1=1;" + expect { described_class.valid?(query) } + .to raise_error(ForestException, '🌳🌳🌳 The query contains a potential SQL injection pattern.') + end + end + end + end +end diff --git a/packages/forest_admin_agent/spec/shared/caller.rb b/packages/forest_admin_agent/spec/shared/caller.rb index e5a1e49e5..c864dc792 100644 --- a/packages/forest_admin_agent/spec/shared/caller.rb +++ b/packages/forest_admin_agent/spec/shared/caller.rb @@ -2,4 +2,20 @@ let(:bearer) do 'Bearer eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjEiLCJlbWFpbCI6Im5pY29sYXNhQGZvcmVzdGFkbWluLmNvbSIsImZpcnN0X25hbWUiOiJOaWNvbGFzIiwibGFzdF9uYW1lIjoiQWxleGFuZHJlIiwidGVhbSI6Ik9wZXJhdGlvbnMiLCJ0YWdzIjpbXSwicmVuZGVyaW5nX2lkIjoxMTQsImV4cCI6MTk5ODAzNjQ0OSwicGVybWlzc2lvbl9sZXZlbCI6ImFkbWluIn0.5LFmtMqZMfinLZLGdPvTlr22YDfU-B30z7MQxlb8vng' end + + let(:caller) do + ForestAdminDatasourceToolkit::Components::Caller.new( + id: 1, + email: 'sarah.connor@skynet.com', + first_name: 'sarah', + last_name: 'connor', + team: 'survivor', + rendering_id: 1, + tags: [], + timezone: 'Europe/Paris', + permission_level: 'admin', + role: 'dev', + request: { ip: '127.0.0.1' } + ) + end end diff --git a/packages/forest_admin_agent/spec/shared/factory.rb b/packages/forest_admin_agent/spec/shared/factory.rb index a36e44b7e..c1826bcc1 100644 --- a/packages/forest_admin_agent/spec/shared/factory.rb +++ b/packages/forest_admin_agent/spec/shared/factory.rb @@ -8,6 +8,7 @@ def datasource_build(args = {}) get_collection: nil, add_collection: nil, render_chart: nil, + live_query_connections: {}, **args } ) diff --git a/packages/forest_admin_datasource_active_record/lib/forest_admin_datasource_active_record/datasource.rb b/packages/forest_admin_datasource_active_record/lib/forest_admin_datasource_active_record/datasource.rb index 318f1ef8b..cf0b6cad5 100644 --- a/packages/forest_admin_datasource_active_record/lib/forest_admin_datasource_active_record/datasource.rb +++ b/packages/forest_admin_datasource_active_record/lib/forest_admin_datasource_active_record/datasource.rb @@ -4,15 +4,56 @@ module ForestAdminDatasourceActiveRecord class Datasource < ForestAdminDatasourceToolkit::Datasource attr_reader :models - def initialize(db_config = {}, support_polymorphic_relations: false) + def initialize( + db_config = {}, + support_polymorphic_relations: false, + live_query_connections: nil + ) super() @models = [] @support_polymorphic_relations = support_polymorphic_relations @habtm_models = {} + @connection_drivers = {} + + @live_query_connections = if live_query_connections.is_a?(String) + { live_query_connections => 'primary' } + elsif live_query_connections.is_a?(Hash) + live_query_connections + else + {} + end + init_orm(db_config) generate end + def execute_native_query(connection_name, query, binds) + unless @live_query_connections[connection_name] + raise ForestAdminAgent::Http::Exceptions::NotFoundError, + "Native query connection '#{connection_name}' is unknown." + end + + begin + connection = @live_query_connections[connection_name] + + result = ActiveRecord::Base.connects_to(database: { reading: connection.to_sym }).first.connection + .exec_query(query, "SQL Native Query on '#{connection_name}'", binds) + + ForestAdminDatasourceToolkit::Utils::HashHelper.convert_keys(result.to_a) + rescue StandardError => e + raise ForestAdminDatasourceToolkit::Exceptions::ForestException, + "Error when executing SQL query: '#{e.full_message}'" + end + end + + def build_binding_symbol(connection_name, binds) + if @connection_drivers[@live_query_connections[connection_name]] == 'postgresql' + "$#{binds.size + 1}" + else + '?' + end + end + private def generate @@ -43,6 +84,17 @@ def fetch_model(model) def init_orm(db_config) ActiveRecord::Base.establish_connection(db_config) + current_config = ActiveRecord::Base.connection_db_config.env_name + configurations = ActiveRecord::Base.configurations + .configurations + .group_by(&:env_name) + .transform_values do |configs| + configs.to_h do |config| + [config.name, config.adapter] + end + end.to_h + + @connection_drivers = configurations[current_config] end def build_habtm(model) diff --git a/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/datasource_customizer.rb b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/datasource_customizer.rb index 11cd9cbe4..e5b973018 100644 --- a/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/datasource_customizer.rb +++ b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/datasource_customizer.rb @@ -1,10 +1,11 @@ module ForestAdminDatasourceCustomizer class DatasourceCustomizer - attr_reader :stack + attr_reader :stack, :datasources def initialize(_db_config = {}) @composite_datasource = ForestAdminDatasourceToolkit::Datasource.new @stack = Decorators::DecoratorsStack.new(@composite_datasource) + @datasources = [] end def schema @@ -46,6 +47,8 @@ def add_datasource(datasource, options) end }) + @datasources << datasource + self end @@ -72,6 +75,19 @@ def remove_collection(*names) self end + def get_root_datasource_by_connection(name) + root_datasource = @datasources.find do |datasource| + datasource.live_query_connections.any? { |connection_name, _connection| connection_name == name } + end + + unless root_datasource + raise ForestAdminAgent::Http::Exceptions::NotFoundError, + "Native query connection '#{name}' is unknown." + end + + root_datasource + end + private def push_customization(&customization) diff --git a/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/segment/segment_collection_decorator.rb b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/segment/segment_collection_decorator.rb index 9c04de3b4..628dd4211 100644 --- a/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/segment/segment_collection_decorator.rb +++ b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/segment/segment_collection_decorator.rb @@ -25,34 +25,37 @@ def refine_schema(sub_schema) sub_schema end - def refine_filter(caller, filter = nil) + def refine_filter(_caller, filter = nil) return nil unless filter condition_tree = filter.condition_tree segment = filter.segment - if segment && @segments.key?(segment) - definition = @segments[segment] + condition_tree = compute_segment(segment, filter) + segment = nil + end - result = if definition.respond_to? :call - definition.call(Context::CollectionCustomizationContext.new(self, caller)) - else - definition - end + filter.override(condition_tree: condition_tree, segment: segment) + end - condition_tree_segment = if result.is_a? Nodes::ConditionTree - result - else - ConditionTreeFactory.from_plain_object(result) - end + def compute_segment(segment_name, filter) + definition = @segments[segment_name] - ConditionTreeValidator.validate(condition_tree_segment, self) + result = if definition.respond_to? :call + definition.call(Context::CollectionCustomizationContext.new(self, caller)) + else + definition + end - condition_tree = ConditionTreeFactory.intersect([condition_tree_segment, filter.condition_tree]) - segment = nil - end + condition_tree_segment = if result.is_a? Nodes::ConditionTree + result + else + ConditionTreeFactory.from_plain_object(result) + end - filter.override(condition_tree: condition_tree, segment: segment) + ConditionTreeValidator.validate(condition_tree_segment, self) + + ConditionTreeFactory.intersect([condition_tree_segment, filter.condition_tree]) end end end diff --git a/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/sort/sort_collection_decorator.rb b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/sort/sort_collection_decorator.rb index 9f57afd53..8b955d728 100644 --- a/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/sort/sort_collection_decorator.rb +++ b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/sort/sort_collection_decorator.rb @@ -42,10 +42,10 @@ def list(caller, filter = nil, projection = nil) reference_records = child_filter.page.apply(reference_records) if child_filter.page # We now have the information we need to sort by the field - new_filter = Filter.new(condition_tree: ConditionTree::ConditionTreeFactory.match_records(schema, + new_filter = Filter.new(condition_tree: ConditionTree::ConditionTreeFactory.match_records(self, reference_records)) - records = child_collection.list(caller, new_filter, projection.with_pks(self)) + records = child_collection.list(caller, new_filter, projection.clone.with_pks(self)) records = sort_records(reference_records, records) projection.apply(records) @@ -89,7 +89,7 @@ def rewrite_plain_sort_clause(clause) def emulated?(path) index = path.index(':') - return @sorts[path] if index.nil? + return @sorts.key?(path) if index.nil? foreign_collection = schema[:fields][path[0, index]].foreign_collection association = datasource.get_collection(foreign_collection) @@ -101,7 +101,7 @@ def emulated?(path) def replace_or_emulate_field_sorting(name, equivalent_sort) FieldValidator.validate(self, name) - @sorts[name] = + @sorts[name.to_s] = equivalent_sort ? ForestAdminDatasourceToolkit::Components::Query::Sort.new(equivalent_sort) : nil mark_schema_as_dirty end @@ -111,11 +111,11 @@ def sort_records(reference_records, records) sorted = Array.new(records.length) reference_records.each_with_index do |record, index| - position_by_id[Record.primary_keys(schema, record).join('|')] = index + position_by_id[Record.primary_keys(self, record).join('|')] = index end records.each do |record| - id = Record.primary_keys(schema, record).join('|') + id = Record.primary_keys(self, record).join('|') sorted[position_by_id[id]] = record end diff --git a/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/collection_customizer_spec.rb b/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/collection_customizer_spec.rb index 249356228..8051e9bb5 100644 --- a/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/collection_customizer_spec.rb +++ b/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/collection_customizer_spec.rb @@ -384,7 +384,7 @@ module ForestAdminDatasourceCustomizer sort_collection = @datasource_customizer.stack.sort.get_collection('person') expect(sort_collection.sorts).to have_key('name') - expect(sort_collection.emulated?('name')).to be_nil + expect(sort_collection.emulated?('name')).to be(true) end end diff --git a/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/datasource_customizer_spec.rb b/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/datasource_customizer_spec.rb index b95917ed8..413f2f2ab 100644 --- a/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/datasource_customizer_spec.rb +++ b/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/datasource_customizer_spec.rb @@ -2,6 +2,7 @@ module ForestAdminDatasourceCustomizer include ForestAdminDatasourceToolkit::Schema + include ForestAdminAgent::Http::Exceptions describe DatasourceCustomizer do let(:datasource) { ForestAdminDatasourceToolkit::Datasource.new } @@ -62,5 +63,26 @@ module ForestAdminDatasourceCustomizer expect(datasource.render_chart(caller, 'my_chart')).to eq({ countCurrent: 10, countPrevious: nil }) end end + + context 'when using get_root_datasource_by_connection' do + it 'raise an error when connection is unknown' do + datasource_customizer = described_class.new + + expect do + datasource_customizer.get_root_datasource_by_connection('unknown_connection') + end.to raise_error(NotFoundError, "Native query connection 'unknown_connection' is unknown.") + end + + it 'return the expected datasource' do + datasource_customizer = described_class.new + first_datasource = datasource_build(live_query_connections: { 'primary' => 'primary' }) + second_datasource = datasource_build(live_query_connections: { 'replica' => 'replica' }) + datasource_customizer.add_datasource(first_datasource, {}) + datasource_customizer.add_datasource(second_datasource, {}) + + expect(datasource_customizer.get_root_datasource_by_connection('primary')) + .to eq(first_datasource) + end + end end end diff --git a/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/search/search_collection_decorator_spec.rb b/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/search/search_collection_decorator_spec.rb index 8220286bd..35f4dd11a 100644 --- a/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/search/search_collection_decorator_spec.rb +++ b/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/search/search_collection_decorator_spec.rb @@ -101,7 +101,7 @@ module Search collection = instance_double(ForestAdminDatasourceToolkit::Collection) search_collection_decorator = described_class.new(collection, datasource) filter = Filter.new(search: nil) - expect(search_collection_decorator.refine_filter(nil, filter)).eql?(filter) + expect(search_collection_decorator.refine_filter(nil, filter).to_h).to eq(filter.to_h) end end @@ -111,7 +111,7 @@ module Search search_collection_decorator = described_class.new(@collection_user, datasource) refined_filter = search_collection_decorator.refine_filter(caller, filter) - expect(refined_filter).eql?(Filter.new) + expect(refined_filter.to_h).to eq(Filter.new.to_h) end end @@ -130,7 +130,7 @@ module Search filter = Filter.new(search: 'a text') refined_filter = search_collection_decorator.refine_filter(caller, filter) - expect(refined_filter).eql?(filter) + expect(refined_filter).to eq(filter) end end @@ -273,11 +273,10 @@ module Search search_collection_decorator = described_class.new(collection, nil) refined_filter = search_collection_decorator.refine_filter(caller, filter) - expect(refined_filter).eql?(Filter.new(search: nil, - condition_tree: ConditionTree::Nodes::ConditionTreeLeaf.new( - 'fieldName', - ConditionTree::Operators::EQUAL, 'a text' - ))) + expect(refined_filter.search).to be_nil + expect(refined_filter.condition_tree.to_h).to eq( + ConditionTree::Nodes::ConditionTreeLeaf.new('fieldName', ConditionTree::Operators::EQUAL, 'a text').to_h + ) end end @@ -400,14 +399,9 @@ module Search search_collection_decorator = described_class.new(collection, nil) refined_filter = search_collection_decorator.refine_filter(caller, filter) - expect(refined_filter).eql?( - Filter.new( - search: nil, - condition_tree: ConditionTree::Nodes::ConditionTreeLeaf.new( - 'fieldName', - ConditionTree::Operators::EQUAL, 'AnEnUmVaLue' - ) - ) + expect(refined_filter.search).to be_nil + expect(refined_filter.condition_tree.to_h).to eq( + ConditionTree::Nodes::ConditionTreeLeaf.new('fieldName', ConditionTree::Operators::EQUAL, 'AnEnUmVaLue').to_h ) end diff --git a/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/sort/sort_collection_decorator_spec.rb b/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/sort/sort_collection_decorator_spec.rb index 63ba6a3b7..777fde3f4 100644 --- a/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/sort/sort_collection_decorator_spec.rb +++ b/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/sort/sort_collection_decorator_spec.rb @@ -45,7 +45,7 @@ module Sort name: 'book', schema: { fields: { - 'id' => ColumnSchema.new(column_type: 'Number', is_primary_key: true), + 'id' => ColumnSchema.new(column_type: 'Number', is_primary_key: true, filter_operators: [Operators::IN, Operators::EQUAL]), 'author_id' => ColumnSchema.new(column_type: 'String'), 'author' => Relations::ManyToOneSchema.new( foreign_key: 'author_id', diff --git a/packages/forest_admin_datasource_customizer/spec/shared/factory.rb b/packages/forest_admin_datasource_customizer/spec/shared/factory.rb index 53b453442..f29b4de71 100644 --- a/packages/forest_admin_datasource_customizer/spec/shared/factory.rb +++ b/packages/forest_admin_datasource_customizer/spec/shared/factory.rb @@ -8,6 +8,7 @@ def datasource_build(args = {}) get_collection: nil, add_collection: nil, render_chart: nil, + live_query_connections: {}, **args } ) diff --git a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/condition_tree/nodes/condition_tree_branch.rb b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/condition_tree/nodes/condition_tree_branch.rb index 34678e6c1..a8a92c24c 100644 --- a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/condition_tree/nodes/condition_tree_branch.rb +++ b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/condition_tree/nodes/condition_tree_branch.rb @@ -43,7 +43,7 @@ def match(record, collection, timezone) end def for_each_leaf(&handler) - @conditions.each { |condition| condition.for_each_leaf(&handler) } + @conditions.map! { |condition| condition.for_each_leaf(&handler) } self end diff --git a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/condition_tree/nodes/condition_tree_leaf.rb b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/condition_tree/nodes/condition_tree_leaf.rb index 31b8a6a7d..d64f8366f 100644 --- a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/condition_tree/nodes/condition_tree_leaf.rb +++ b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/condition_tree/nodes/condition_tree_leaf.rb @@ -40,9 +40,9 @@ def inverse case @operator when Operators::BLANK - override(operator: Operators::BLANK) - when Operators::PRESENT override(operator: Operators::PRESENT) + when Operators::PRESENT + override(operator: Operators::BLANK) else raise ForestException, "Operator: #{@operator} cannot be inverted." end @@ -133,7 +133,7 @@ def override(args) ConditionTreeLeaf.new( args[:field] || @field, args[:operator] || @operator, - args[:value] || @value + args[:value].nil? ? @value : args[:value] ) end diff --git a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/condition_tree/transforms/times.rb b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/condition_tree/transforms/times.rb index d4b2e86cf..2a5ab16e6 100644 --- a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/condition_tree/transforms/times.rb +++ b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/condition_tree/transforms/times.rb @@ -14,7 +14,7 @@ def self.format(value) def self.compare(operator) { depends_on: [operator], - for_types: ['Date', 'Dateonly'], + for_types: %w[Date Dateonly], replacer: proc { |leaf, tz| leaf.override(operator: operator, value: format(yield(Time.now.in_time_zone(tz), leaf.value))) } @@ -24,7 +24,7 @@ def self.compare(operator) def self.interval(start_fn, end_fn) { depends_on: [Operators::LESS_THAN, Operators::GREATER_THAN], - for_types: ['Date', 'Dateonly'], + for_types: %w[Date Dateonly], replacer: proc do |leaf, tz| value_greater_than = if leaf.value.nil? format(start_fn.call(Time.now.in_time_zone(tz))) diff --git a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/filter.rb b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/filter.rb index 68bad61a1..0e5b4f54f 100644 --- a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/filter.rb +++ b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/filter.rb @@ -4,7 +4,14 @@ module Query class Filter attr_reader :condition_tree, :segment, :sort, :search, :search_extended, :page - def initialize(condition_tree: nil, search: nil, search_extended: nil, segment: nil, sort: nil, page: nil) + def initialize( + condition_tree: nil, + search: nil, + search_extended: nil, + segment: nil, + sort: nil, + page: nil + ) @condition_tree = condition_tree @search = search @search_extended = search_extended diff --git a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/filter_factory.rb b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/filter_factory.rb index 8a0dc9af7..770a778f1 100644 --- a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/filter_factory.rb +++ b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/filter_factory.rb @@ -33,9 +33,9 @@ def self.get_previous_period_filter(filter, timezone) when Operators::TODAY leaf.override(operator: Operators::YESTERDAY) when Operators::PREVIOUS_X_DAYS - get_previous_x_days_period(leaf, timezone, 'Previous_X_Days') + get_previous_x_days_period(leaf, timezone, Operators::PREVIOUS_X_DAYS) when Operators::PREVIOUS_X_DAYS_TO_DATE - get_previous_x_days_period(leaf, timezone, 'Previous_X_Days_To_Date') + get_previous_x_days_period(leaf, timezone, Operators::PREVIOUS_X_DAYS_TO_DATE) else leaf end diff --git a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/datasource.rb b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/datasource.rb index 0cf013b7e..eb96f2459 100644 --- a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/datasource.rb +++ b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/datasource.rb @@ -1,11 +1,12 @@ module ForestAdminDatasourceToolkit class Datasource < Components::Contracts::DatasourceContract - attr_reader :collections, :schema + attr_reader :collections, :schema, :live_query_connections def initialize super @schema = { charts: [] } @collections = {} + @live_query_connections = {} end def get_collection(name) @@ -25,5 +26,13 @@ def add_collection(collection) def render_chart(_caller, name) raise Exceptions::ForestException, "No chart named #{name} exists on this datasource." end + + def execute_native_query(_connection_name, _query, _binds) + raise Exceptions::ForestException, 'this datasource do not support native query.' + end + + def build_binding_symbol(_connection_name, _binds) + raise Exceptions::ForestException, 'this datasource do not support native query.' + end end end diff --git a/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/query/aggregation_spec.rb b/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/query/aggregation_spec.rb index 2261d7773..d1c23b09c 100644 --- a/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/query/aggregation_spec.rb +++ b/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/query/aggregation_spec.rb @@ -33,14 +33,14 @@ module Query it 'works with one arg' do aggregation = described_class.new(operation: 'Count') - expect(aggregation.override(operation: 'Sum')).eql?(described_class.new(operation: 'Sum')) + expect(aggregation.override(operation: 'Sum')).to have_attributes(operation: 'Sum') end it 'works with all args' do aggregation = described_class.new(operation: 'Count') expect(aggregation.override(operation: 'Sum', field: 'aggregateField', groups: [{ field: 'groupField' }])) - .eql?(described_class.new(operation: 'Sum', field: 'aggregateField', groups: [{ field: 'groupField' }])) + .to have_attributes(operation: 'Sum', field: 'aggregateField', groups: [{ field: 'groupField' }]) end end @@ -177,11 +177,11 @@ module Query ) expect(aggregation.nest('prefix')) - .eql?(described_class.new( - operation: 'Sum', - field: 'prefix:aggregateField', - groups: [{ field: 'prefix:groupField', operation: 'Week' }] - )) + .to have_attributes( + operation: 'Sum', + field: 'prefix:aggregateField', + groups: [{ field: 'prefix:groupField', operation: 'Week' }] + ) end it 'works with null prefix' do @@ -192,11 +192,11 @@ module Query ) expect(aggregation.nest(nil)) - .eql?(described_class.new( - operation: 'Sum', - field: 'aggregateField', - groups: [{ field: 'groupField', operation: 'Week' }] - )) + .to have_attributes( + operation: 'Sum', + field: 'aggregateField', + groups: [{ field: 'groupField', operation: 'Week' }] + ) end end end diff --git a/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/query/condition_tree/condition_tree_factory_spec.rb b/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/query/condition_tree/condition_tree_factory_spec.rb index fbf74846d..402dae054 100644 --- a/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/query/condition_tree/condition_tree_factory_spec.rb +++ b/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/query/condition_tree/condition_tree_factory_spec.rb @@ -41,7 +41,7 @@ module ConditionTree is_primary_key: true ) }) - expect(condition_tree_factory.match_records(collection, [])).eql?(ConditionTreeBranch.new('Or', [])) + expect(condition_tree_factory.match_records(collection, [])).to have_attributes(aggregator: 'Or', conditions: []) end it 'generates equal with simple PK' do @@ -52,7 +52,7 @@ module ConditionTree is_primary_key: true ) }) - expect(condition_tree_factory.match_records(collection, [{ 'id' => 1 }])).eql?(ConditionTreeLeaf.new('id', Operators::EQUAL, 1)) + expect(condition_tree_factory.match_records(collection, [{ 'id' => 1 }])).to have_attributes(field: 'id', operator: Operators::EQUAL, value: 1) end it 'generates "In" with simple PK' do @@ -63,7 +63,7 @@ module ConditionTree is_primary_key: true ) }) - expect(condition_tree_factory.match_records(collection, [{ 'id' => 1 }, { 'id' => 2 }])).eql?(ConditionTreeLeaf.new('id', Operators::IN, [1, 2])) + expect(condition_tree_factory.match_records(collection, [{ 'id' => 1 }, { 'id' => 2 }])).to have_attributes(field: 'id', operator: Operators::IN, value: [1, 2]) end it 'generates a simple "And" with a composite PK' do @@ -83,12 +83,14 @@ module ConditionTree } ) - expect do - condition_tree_factory.match_records(collection, [{ 'col1' => 1, 'col2' => 1 }]) - end.eql?(ConditionTreeBranch.new('And', [ - ConditionTreeLeaf.new('col1', Operators::EQUAL, 1), - ConditionTreeLeaf.new('col2', Operators::EQUAL, 1) - ])) + expect(condition_tree_factory.match_records(collection, [{ 'col1' => 1, 'col2' => 1 }])) + .to have_attributes( + aggregator: 'And', + conditions: contain_exactly( + have_attributes(field: 'col1', operator: Operators::EQUAL, value: 1), + have_attributes(field: 'col2', operator: Operators::EQUAL, value: 1) + ) + ) end it 'factorizes with a composite PK' do @@ -108,12 +110,14 @@ module ConditionTree } ) - expect do - condition_tree_factory.match_records(collection, [{ 'col1' => 1, 'col2' => 1 }, { 'col1' => 1, 'col2' => 2 }]) - end.eql?(ConditionTreeBranch.new('And', [ - ConditionTreeLeaf.new('col1', Operators::EQUAL, 1), - ConditionTreeLeaf.new('col2', Operators::IN, [1, 2]) - ])) + expect(condition_tree_factory.match_records(collection, [{ 'col1' => 1, 'col2' => 1 }, { 'col1' => 1, 'col2' => 2 }])) + .to have_attributes( + aggregator: 'And', + conditions: contain_exactly( + have_attributes(field: 'col1', operator: Operators::EQUAL, value: 1), + have_attributes(field: 'col2', operator: Operators::IN, value: [1, 2]) + ) + ) end it 'does not factorize with a composite PK' do @@ -133,18 +137,26 @@ module ConditionTree } ) - expect do - condition_tree_factory.match_records(collection, [{ 'col1' => 1, 'col2' => 1 }, { 'col1' => 2, 'col2' => 2 }]) - end.eql?(ConditionTreeBranch.new('Or', [ - ConditionTreeBranch.new('And', [ - ConditionTreeLeaf.new('col1', Operators::EQUAL, 1), - ConditionTreeLeaf.new('col2', Operators::EQUAL, 1) - ]), - ConditionTreeBranch.new('And', [ - ConditionTreeLeaf.new('col1', Operators::EQUAL, 2), - ConditionTreeLeaf.new('col2', Operators::EQUAL, 2) - ]) - ])) + expect(condition_tree_factory.match_records(collection, [{ 'col1' => 1, 'col2' => 1 }, { 'col1' => 2, 'col2' => 2 }])) + .to have_attributes( + aggregator: 'Or', + conditions: contain_exactly( + have_attributes( + aggregator: 'And', + conditions: contain_exactly( + have_attributes(field: 'col1', operator: Operators::EQUAL, value: 1), + have_attributes(field: 'col2', operator: Operators::EQUAL, value: 1) + ) + ), + have_attributes( + aggregator: 'And', + conditions: contain_exactly( + have_attributes(field: 'col1', operator: Operators::EQUAL, value: 2), + have_attributes(field: 'col2', operator: Operators::EQUAL, value: 2) + ) + ) + ) + ) end end @@ -158,13 +170,13 @@ module ConditionTree it 'returns the parameter when called with only one param' do tree = condition_tree_factory.intersect([ConditionTreeLeaf.new('column', Operators::EQUAL, true)]) - expect(tree).eql?(ConditionTreeLeaf.new('column', Operators::EQUAL, true)) + expect(tree).to have_attributes(field: 'column', operator: Operators::EQUAL, value: true) end it 'ignores null params' do tree = condition_tree_factory.intersect([nil, ConditionTreeLeaf.new('column', Operators::EQUAL, true), nil]) - expect(tree).eql?(ConditionTreeLeaf.new('column', Operators::EQUAL, true)) + expect(tree).to have_attributes(field: 'column', operator: Operators::EQUAL, value: true) end it 'returns multiple trees when receiving multiple trees' do @@ -172,10 +184,13 @@ module ConditionTree other_condition_tree = ConditionTreeLeaf.new('otherColumn', Operators::EQUAL, true) tree = condition_tree_factory.intersect([condition_tree, other_condition_tree]) - expect(tree).eql?(ConditionTreeBranch.new('And', [ - ConditionTreeLeaf.new('column', Operators::EQUAL, true), - ConditionTreeLeaf.new('otherColumn', Operators::EQUAL, true) - ])) + expect(tree).to have_attributes( + aggregator: 'And', + conditions: contain_exactly( + have_attributes(field: 'column', operator: Operators::EQUAL, value: true), + have_attributes(field: 'otherColumn', operator: Operators::EQUAL, value: true) + ) + ) end end @@ -188,18 +203,18 @@ module ConditionTree it 'works with a simple case' do tree = condition_tree_factory.from_plain_object( - { field: 'field', operator: 'Equal', value: 'something' } + { field: 'field', operator: Operators::EQUAL, value: 'something' } ) - expect(tree).eql?(ConditionTreeLeaf.new('field', 'Equal', 'something')) + expect(tree).to have_attributes(field: 'field', operator: Operators::EQUAL, value: 'something') end it 'removes useless aggregators from the frontend' do tree = condition_tree_factory.from_plain_object( - { aggregator: 'And', conditions: [{ field: 'field', operator: 'Equal', value: 'something' }] } + { aggregator: 'And', conditions: [{ field: 'field', operator: Operators::EQUAL, value: 'something' }] } ) - expect(tree).eql?(ConditionTreeLeaf.new('field', 'Equal', 'something')) + expect(tree).to have_attributes(field: 'field', operator: Operators::EQUAL, value: 'something') end it 'works with an aggregator' do @@ -207,16 +222,19 @@ module ConditionTree { aggregator: 'And', conditions: [ - { field: 'field', operator: 'Equal', value: 'something' }, - { field: 'field', operator: 'Equal', value: 'something' } + { field: 'field', operator: Operators::EQUAL, value: 'something' }, + { field: 'field', operator: Operators::EQUAL, value: 'something' } ] } ) - expect(tree).eql?(ConditionTreeBranch.new('And', [ - ConditionTreeLeaf.new('field', 'Equal', 'something'), - ConditionTreeLeaf.new('field', 'Equal', 'something') - ])) + expect(tree).to have_attributes( + aggregator: 'And', + conditions: contain_exactly( + have_attributes(field: 'field', operator: Operators::EQUAL, value: 'something'), + have_attributes(field: 'field', operator: Operators::EQUAL, value: 'something') + ) + ) end end end diff --git a/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/query/condition_tree/nodes/condition_tree_spec.rb b/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/query/condition_tree/nodes/condition_tree_spec.rb index 3bf9c25f3..f513ef328 100644 --- a/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/query/condition_tree/nodes/condition_tree_spec.rb +++ b/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/query/condition_tree/nodes/condition_tree_spec.rb @@ -20,21 +20,22 @@ module Nodes context 'when calling inverse method' do it 'works with not_equal' do - expect(@condition_tree_branch.inverse).eql?( - ConditionTreeBranch.new('Or', [ - ConditionTreeLeaf.new('column1', Operators::NOT_EQUAL, true), - ConditionTreeLeaf.new('column2', Operators::NOT_EQUAL, true) - ]) + expect(@condition_tree_branch.inverse).to have_attributes( + aggregator: 'Or', + conditions: contain_exactly( + have_attributes(field: 'column1', operator: Operators::NOT_EQUAL, value: true), + have_attributes(field: 'column2', operator: Operators::NOT_EQUAL, value: true) + ) ) - expect(@condition_tree_branch.inverse.inverse).eql?(@condition_tree_branch) + expect(@condition_tree_branch.inverse.inverse.to_h).to eq(@condition_tree_branch.to_h) end it 'works with blank' do condition_tree_leaf = ConditionTreeLeaf.new('column1', Operators::BLANK) - expect(condition_tree_leaf.inverse).eql?( - ConditionTreeLeaf.new('column1', Operators::PRESENT) + expect(condition_tree_leaf.inverse.to_h).to eq( + ConditionTreeLeaf.new('column1', Operators::PRESENT).to_h ) - expect(condition_tree_leaf.inverse.inverse).eql?(condition_tree_leaf) + expect(condition_tree_leaf.inverse.inverse.to_h).to eq(ConditionTreeLeaf.new('column1', Operators::BLANK).to_h) end it 'crashes with unsupported operator' do @@ -46,11 +47,11 @@ module Nodes end it 'when calling replace_leafs should work' do - expect(@condition_tree_branch.replace_leafs { |leaf| leaf.override(value: !leaf.value) }).eql?( + expect(@condition_tree_branch.replace_leafs { |leaf| leaf.override(value: !leaf.value) }.to_h).to eq( ConditionTreeBranch.new('And', [ ConditionTreeLeaf.new('column1', Operators::EQUAL, false), ConditionTreeLeaf.new('column2', Operators::EQUAL, false) - ]) + ]).to_h ) end @@ -136,11 +137,11 @@ module Nodes end it 'when calling for_each_leaf should work' do - expect(@condition_tree_branch.for_each_leaf { |leaf| leaf.override(field: 'field') }).eql?( + expect(@condition_tree_branch.for_each_leaf { |leaf| leaf.override(field: 'field') }.to_h).to eq( ConditionTreeBranch.new('And', [ ConditionTreeLeaf.new('field', Operators::EQUAL, true), ConditionTreeLeaf.new('field', Operators::EQUAL, true) - ]) + ]).to_h ) end @@ -181,23 +182,23 @@ module Nodes end it 'when calling nest should work' do - expect(@condition_tree_branch.nest('prefix')).eql?( + expect(@condition_tree_branch.nest('prefix').to_h).to eq( ConditionTreeBranch.new('And', [ ConditionTreeLeaf.new('prefix:column1', Operators::EQUAL, true), ConditionTreeLeaf.new('prefix:column2', Operators::EQUAL, true) - ]) + ]).to_h ) end context 'when calling unnest' do it 'works with conditionTreeBranch' do - expect(@condition_tree_branch.nest('prefix').unnest).eql?(@condition_tree_branch) + expect(@condition_tree_branch.nest('prefix').unnest.to_h).to eq(@condition_tree_branch.to_h) end it 'works with conditionTreeLeaf' do @condition_tree_branch = @condition_tree_branch.nest('prefix') condition_tree_leaf = @condition_tree_branch.conditions[0] - expect(condition_tree_leaf.unnest).eql?(ConditionTreeLeaf.new('column1', Operators::EQUAL, true)) + expect(condition_tree_leaf.unnest.to_h).to eq(ConditionTreeLeaf.new('column1', Operators::EQUAL, true).to_h) end it 'throws' do @@ -208,11 +209,11 @@ module Nodes end it 'when calling replace_fields should work' do - expect(@condition_tree_branch.replace_fields { |field| "#{field}:suffix" }).eql?( + expect(@condition_tree_branch.replace_fields { |field| "#{field}:suffix" }.to_h).to eq( ConditionTreeBranch.new('And', [ ConditionTreeLeaf.new('column1:suffix', Operators::EQUAL, true), ConditionTreeLeaf.new('column2:suffix', Operators::EQUAL, true) - ]) + ]).to_h ) end end diff --git a/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/query/condition_tree/transforms/comparisons_spec.rb b/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/query/condition_tree/transforms/comparisons_spec.rb index 33b8ba58a..e16342e6e 100644 --- a/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/query/condition_tree/transforms/comparisons_spec.rb +++ b/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/query/condition_tree/transforms/comparisons_spec.rb @@ -16,71 +16,71 @@ module Transforms it 'rewrites blank for strings' do expect(@comparisons[Operators::BLANK][0][:replacer].call(ConditionTreeLeaf.new('column', - Operators::BLANK))) - .eql?(ConditionTreeLeaf.new('column', Operators::IN, [nil, ''])) + Operators::BLANK)).to_h) + .to eq(ConditionTreeLeaf.new('column', Operators::IN, [nil, '']).to_h) end it 'rewrites blank for other types' do expect(@comparisons[Operators::BLANK][1][:replacer].call(ConditionTreeLeaf.new('column', - Operators::BLANK))) - .eql?(ConditionTreeLeaf.new('column', Operators::MISSING)) + Operators::BLANK)).to_h) + .to eq(ConditionTreeLeaf.new('column', Operators::MISSING).to_h) end it 'missing should be rewritten' do expect(@comparisons[Operators::MISSING][0][:replacer].call(ConditionTreeLeaf.new('column', - Operators::MISSING))) - .eql?(ConditionTreeLeaf.new('column', Operators::EQUAL, nil)) + Operators::MISSING)).to_h) + .to eq(ConditionTreeLeaf.new('column', Operators::EQUAL, nil).to_h) end it 'Present should be rewritten for strings' do expect(@comparisons[Operators::PRESENT][0][:replacer].call(ConditionTreeLeaf.new('column', - Operators::PRESENT))) - .eql?(ConditionTreeLeaf.new('column', Operators::NOT_IN, [nil, ''])) + Operators::PRESENT)).to_h) + .to eq(ConditionTreeLeaf.new('column', Operators::NOT_IN, [nil, '']).to_h) end it 'Present should be rewritten for other types' do expect(@comparisons[Operators::PRESENT][1][:replacer].call(ConditionTreeLeaf.new('column', - Operators::PRESENT))) - .eql?(ConditionTreeLeaf.new('column', Operators::NOT_EQUAL, nil)) + Operators::PRESENT)).to_h) + .to eq(ConditionTreeLeaf.new('column', Operators::NOT_EQUAL, nil).to_h) end it 'Equal should be rewritten' do expect(@comparisons[Operators::EQUAL][0][:replacer].call(ConditionTreeLeaf.new('column', - Operators::EQUAL, 'something'))) - .eql?(ConditionTreeLeaf.new('column', Operators::IN, ['something'])) + Operators::EQUAL, 'something')).to_h) + .to eq(ConditionTreeLeaf.new('column', Operators::IN, ['something']).to_h) end it 'In should be rewritten with one element' do expect(@comparisons[Operators::IN][0][:replacer].call(ConditionTreeLeaf.new('column', Operators::IN, - ['something', 'else']))) - .eql?(ConditionTreeLeaf.new('column', Operators::MATCH, '/(something|else)/g')) + %w[something else])).to_h) + .to eq(ConditionTreeLeaf.new('column', Operators::MATCH, '/(something|else)/g').to_h) end it 'In should be rewritten with multiple elements' do expect(@comparisons[Operators::IN][0][:replacer].call(ConditionTreeLeaf.new('column', Operators::IN, - [nil, 'something', 'else']))) - .eql?(ConditionTreeBranch.new('Or', [ - ConditionTreeLeaf.new('column', Operators::EQUAL, nil), - ConditionTreeLeaf.new('column', Operators::MATCH, '/(something|else)/g') - ])) + [nil, 'something', 'else'])).to_h) + .to eq(ConditionTreeBranch.new('Or', [ + ConditionTreeLeaf.new('column', Operators::EQUAL, nil), + ConditionTreeLeaf.new('column', Operators::MATCH, '/(something|else)/g') + ]).to_h) end it 'NotEqual should be rewritten' do expect(@comparisons[Operators::NOT_EQUAL][0][:replacer].call(ConditionTreeLeaf.new('column', - Operators::NOT_EQUAL, 'something'))) - .eql?(ConditionTreeLeaf.new('column', Operators::NOT_IN, ['something'])) + Operators::NOT_EQUAL, 'something')).to_h) + .to eq(ConditionTreeLeaf.new('column', Operators::NOT_IN, ['something']).to_h) end it 'NotIn should be rewritten with one element' do expect(@comparisons[Operators::NOT_IN][0][:replacer].call(ConditionTreeLeaf.new('column', - Operators::NOT_IN, ['something']))) - .eql?(ConditionTreeLeaf.new('column', Operators::MATCH, '/(?!something)/g')) + Operators::NOT_IN, ['something'])).to_h) + .to eq(ConditionTreeLeaf.new('column', Operators::MATCH, '/(?!(something))/g').to_h) end it 'NotIn should be rewritten with multiple elements' do expect(@comparisons[Operators::NOT_IN][0][:replacer].call(ConditionTreeLeaf.new('column', - Operators::NOT_IN, ['something', 'else']))) - .eql?(ConditionTreeLeaf.new('column', Operators::MATCH, '/(?!something|else)/g')) + Operators::NOT_IN, %w[something else])).to_h) + .to eq(ConditionTreeLeaf.new('column', Operators::MATCH, '/(?!(something|else))/g').to_h) end end end diff --git a/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/query/condition_tree/transforms/pattern_spec.rb b/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/query/condition_tree/transforms/pattern_spec.rb index cbe0d8af6..7544bc10e 100644 --- a/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/query/condition_tree/transforms/pattern_spec.rb +++ b/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/query/condition_tree/transforms/pattern_spec.rb @@ -16,50 +16,50 @@ module Transforms it 'Contains should be rewritten' do expect(@pattern[Operators::CONTAINS][0][:replacer].call(ConditionTreeLeaf.new('column', - Operators::CONTAINS, 'something'))) - .eql?(ConditionTreeLeaf.new('column', Operators::LIKE, '%something%')) + Operators::CONTAINS, 'something')).to_h) + .to eq(ConditionTreeLeaf.new('column', Operators::LIKE, '%something%').to_h) end it 'StartsWith should be rewritten' do expect(@pattern[Operators::STARTS_WITH][0][:replacer].call(ConditionTreeLeaf.new('column', - Operators::STARTS_WITH, 'something'))) - .eql?(ConditionTreeLeaf.new('column', Operators::LIKE, 'something%')) + Operators::STARTS_WITH, 'something')).to_h) + .to eq(ConditionTreeLeaf.new('column', Operators::LIKE, 'something%').to_h) end it 'EndsWith should be rewritten' do expect(@pattern[Operators::ENDS_WITH][0][:replacer].call(ConditionTreeLeaf.new('column', - Operators::ENDS_WITH, 'something'))) - .eql?(ConditionTreeLeaf.new('column', Operators::LIKE, '%something')) + Operators::ENDS_WITH, 'something')).to_h) + .to eq(ConditionTreeLeaf.new('column', Operators::LIKE, '%something').to_h) end it 'I_Contains should be rewritten' do expect(@pattern[Operators::I_CONTAINS][0][:replacer].call(ConditionTreeLeaf.new('column', - Operators::CONTAINS, 'something'))) - .eql?(ConditionTreeLeaf.new('column', Operators::I_LIKE, '%something%')) + Operators::CONTAINS, 'something')).to_h) + .to eq(ConditionTreeLeaf.new('column', Operators::I_LIKE, '%something%').to_h) end it 'IStartsWith should be rewritten' do expect(@pattern[Operators::I_STARTS_WITH][0][:replacer].call(ConditionTreeLeaf.new('column', - Operators::STARTS_WITH, 'something'))) - .eql?(ConditionTreeLeaf.new('column', Operators::I_LIKE, 'something%')) + Operators::STARTS_WITH, 'something')).to_h) + .to eq(ConditionTreeLeaf.new('column', Operators::I_LIKE, 'something%').to_h) end it 'IEndsWith should be rewritten' do expect(@pattern[Operators::I_ENDS_WITH][0][:replacer].call(ConditionTreeLeaf.new('column', - Operators::ENDS_WITH, 'something'))) - .eql?(ConditionTreeLeaf.new('column', Operators::I_LIKE, '%something')) + Operators::ENDS_WITH, 'something')).to_h) + .to eq(ConditionTreeLeaf.new('column', Operators::I_LIKE, '%something').to_h) end it 'Like should be rewritten' do expect(@pattern[Operators::LIKE][0][:replacer].call(ConditionTreeLeaf.new('column', Operators::EQUAL, - 'something'))) - .eql?(ConditionTreeLeaf.new('column', Operators::MATCH, '/^something$/')) + 'something')).to_h) + .to eq(ConditionTreeLeaf.new('column', Operators::MATCH, '/^something$/').to_h) end it 'ILike should be rewritten' do expect(@pattern[Operators::I_LIKE][0][:replacer].call(ConditionTreeLeaf.new('column', Operators::EQUAL, - 'something'))) - .eql?(ConditionTreeLeaf.new('column', Operators::MATCH, '/^something$/i')) + 'something')).to_h) + .to eq(ConditionTreeLeaf.new('column', Operators::MATCH, '/^something$/i').to_h) end end end diff --git a/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/query/condition_tree/transforms/times_spec.rb b/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/query/condition_tree/transforms/times_spec.rb index dfea6d172..4a3013ea9 100644 --- a/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/query/condition_tree/transforms/times_spec.rb +++ b/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/query/condition_tree/transforms/times_spec.rb @@ -12,193 +12,195 @@ module Transforms describe Times do subject(:times) { described_class } + let(:tz) { 'Europe/Paris' } + before do @times = times.transforms end it 'Before should rewrite' do expect(@times[Operators::BEFORE][0][:replacer].call( - ConditionTreeLeaf.new('column', Operators::BEFORE, Time.now), 'Europe/Paris' - )) - .eql?(ConditionTreeLeaf.new('column', Operators::LESS_THAN, times.format(Time.now))) + ConditionTreeLeaf.new('column', Operators::BEFORE, Time.now), tz + ).to_h) + .to eq(ConditionTreeLeaf.new('column', Operators::LESS_THAN, times.format(Time.now)).to_h) end it 'After should rewrite' do expect(@times[Operators::AFTER][0][:replacer].call( - ConditionTreeLeaf.new('column', Operators::AFTER, Time.now), 'Europe/Paris' - )) - .eql?(ConditionTreeLeaf.new('column', Operators::GREATER_THAN, times.format(Time.now))) + ConditionTreeLeaf.new('column', Operators::AFTER, Time.now), tz + ).to_h) + .to eq(ConditionTreeLeaf.new('column', Operators::GREATER_THAN, times.format(Time.now)).to_h) end it 'Past should rewrite' do expect(@times[Operators::PAST][0][:replacer].call( - ConditionTreeLeaf.new('column', Operators::PAST, Time.now), 'Europe/Paris' - )) - .eql?(ConditionTreeLeaf.new('column', Operators::LESS_THAN, times.format(Time.now))) + ConditionTreeLeaf.new('column', Operators::PAST, Time.now), tz + ).to_h) + .to eq(ConditionTreeLeaf.new('column', Operators::LESS_THAN, times.format(Time.now)).to_h) end it 'Future should rewrite' do expect(@times[Operators::FUTURE][0][:replacer].call( - ConditionTreeLeaf.new('column', Operators::FUTURE, Time.now), 'Europe/Paris' - )) - .eql?(ConditionTreeLeaf.new('column', Operators::GREATER_THAN, times.format(Time.now))) + ConditionTreeLeaf.new('column', Operators::FUTURE, Time.now), tz + ).to_h) + .to eq(ConditionTreeLeaf.new('column', Operators::GREATER_THAN, times.format(Time.now)).to_h) end it 'BeforeXHoursAgo should rewrite' do expect(@times[Operators::BEFORE_X_HOURS_AGO][0][:replacer].call( - ConditionTreeLeaf.new('column', Operators::BEFORE_X_HOURS_AGO, 24), 'Europe/Paris' - )) - .eql?(ConditionTreeLeaf.new('column', Operators::LESS_THAN, times.format(24.hours.ago))) + ConditionTreeLeaf.new('column', Operators::BEFORE_X_HOURS_AGO, 24), tz + ).to_h) + .to eq(ConditionTreeLeaf.new('column', Operators::LESS_THAN, times.format(24.hours.ago)).to_h) end it 'AfterXHoursAgo should rewrite' do expect(@times[Operators::AFTER_X_HOURS_AGO][0][:replacer].call( - ConditionTreeLeaf.new('column', Operators::AFTER_X_HOURS_AGO, 24), 'Europe/Paris' - )) - .eql?(ConditionTreeLeaf.new('column', Operators::GREATER_THAN, times.format(24.hours.ago))) + ConditionTreeLeaf.new('column', Operators::AFTER_X_HOURS_AGO, 24), tz + ).to_h) + .to eq(ConditionTreeLeaf.new('column', Operators::GREATER_THAN, times.format(24.hours.ago)).to_h) end it 'PreviousMonthToDate should rewrite' do expect(@times[Operators::PREVIOUS_MONTH_TO_DATE][0][:replacer].call( - ConditionTreeLeaf.new('column', Operators::PREVIOUS_MONTH_TO_DATE), 'Europe/Paris' - )) - .eql?(ConditionTreeBranch.new('And', [ - ConditionTreeLeaf.new('column', Operators::GREATER_THAN, - times.format(Time.now.beginning_of_month)), - ConditionTreeLeaf.new('column', Operators::LESS_THAN, - times.format(Time.now)) - ])) + ConditionTreeLeaf.new('column', Operators::PREVIOUS_MONTH_TO_DATE), tz + ).to_h) + .to eq(ConditionTreeBranch.new('And', [ + ConditionTreeLeaf.new('column', Operators::GREATER_THAN, + times.format(Time.now.in_time_zone(tz).beginning_of_month)), + ConditionTreeLeaf.new('column', Operators::LESS_THAN, + times.format(Time.now.in_time_zone(tz))) + ]).to_h) end it 'PreviousMonth should rewrite' do expect(@times[Operators::PREVIOUS_MONTH][0][:replacer].call( - ConditionTreeLeaf.new('column', Operators::PREVIOUS_MONTH), 'Europe/Paris' - )) - .eql?(ConditionTreeBranch.new('And', [ - ConditionTreeLeaf.new('column', Operators::GREATER_THAN, - times.format(Time.now.beginning_of_month)), - ConditionTreeLeaf.new('column', Operators::LESS_THAN, - times.format(Time.now)) - ])) + ConditionTreeLeaf.new('column', Operators::PREVIOUS_MONTH), tz + ).to_h) + .to eq(ConditionTreeBranch.new('And', [ + ConditionTreeLeaf.new('column', Operators::GREATER_THAN, + times.format((Time.now.in_time_zone(tz) - 1.month).beginning_of_month)), + ConditionTreeLeaf.new('column', Operators::LESS_THAN, + times.format(Time.now.in_time_zone(tz).beginning_of_month)) + ]).to_h) end it 'PreviousQuarterToDate should rewrite' do expect(@times[Operators::PREVIOUS_QUARTER_TO_DATE][0][:replacer].call( - ConditionTreeLeaf.new('column', Operators::PREVIOUS_QUARTER_TO_DATE), 'Europe/Paris' - )) - .eql?(ConditionTreeBranch.new('And', [ - ConditionTreeLeaf.new('column', Operators::GREATER_THAN, - times.format(Time.now.beginning_of_quarter)), - ConditionTreeLeaf.new('column', Operators::LESS_THAN, - times.format(Time.now)) - ])) + ConditionTreeLeaf.new('column', Operators::PREVIOUS_QUARTER_TO_DATE), tz + ).to_h) + .to eq(ConditionTreeBranch.new('And', [ + ConditionTreeLeaf.new('column', Operators::GREATER_THAN, + times.format(Time.now.in_time_zone(tz).beginning_of_quarter)), + ConditionTreeLeaf.new('column', Operators::LESS_THAN, + times.format(Time.now.in_time_zone(tz))) + ]).to_h) end it 'PreviousQuarter should rewrite' do expect(@times[Operators::PREVIOUS_QUARTER][0][:replacer].call( - ConditionTreeLeaf.new('column', Operators::PREVIOUS_QUARTER), 'Europe/Paris' - )) - .eql?(ConditionTreeBranch.new('And', [ - ConditionTreeLeaf.new('column', Operators::GREATER_THAN, - times.format(Time.now.prev_quarter.beginning_of_quarter)), - ConditionTreeLeaf.new('column', Operators::LESS_THAN, - times.format(Time.now.beginning_of_quarter)) - ])) + ConditionTreeLeaf.new('column', Operators::PREVIOUS_QUARTER), tz + ).to_h) + .to eq(ConditionTreeBranch.new('And', [ + ConditionTreeLeaf.new('column', Operators::GREATER_THAN, + times.format(Time.now.in_time_zone(tz).prev_quarter.beginning_of_quarter)), + ConditionTreeLeaf.new('column', Operators::LESS_THAN, + times.format(Time.now.in_time_zone(tz).beginning_of_quarter)) + ]).to_h) end it 'PreviousWeekToDate should rewrite' do expect(@times[Operators::PREVIOUS_WEEK_TO_DATE][0][:replacer].call( - ConditionTreeLeaf.new('column', Operators::PREVIOUS_WEEK_TO_DATE), 'Europe/Paris' - )) - .eql?(ConditionTreeBranch.new('And', [ - ConditionTreeLeaf.new('column', Operators::GREATER_THAN, - times.format(Time.now.beginning_of_week)), - ConditionTreeLeaf.new('column', Operators::LESS_THAN, - times.format(Time.now)) - ])) + ConditionTreeLeaf.new('column', Operators::PREVIOUS_WEEK_TO_DATE), tz + ).to_h) + .to eq(ConditionTreeBranch.new('And', [ + ConditionTreeLeaf.new('column', Operators::GREATER_THAN, + times.format(Time.now.in_time_zone(tz).beginning_of_week)), + ConditionTreeLeaf.new('column', Operators::LESS_THAN, + times.format(Time.now.in_time_zone(tz))) + ]).to_h) end it 'PreviousWeek should rewrite' do expect(@times[Operators::PREVIOUS_WEEK][0][:replacer].call( - ConditionTreeLeaf.new('column', Operators::PREVIOUS_WEEK), 'Europe/Paris' - )) - .eql?(ConditionTreeBranch.new('And', [ - ConditionTreeLeaf.new('column', Operators::GREATER_THAN, - times.format(Time.now.prev_week.beginning_of_week)), - ConditionTreeLeaf.new('column', Operators::LESS_THAN, - times.format(Time.now.beginning_of_week)) - ])) + ConditionTreeLeaf.new('column', Operators::PREVIOUS_WEEK), tz + ).to_h) + .to eq(ConditionTreeBranch.new('And', [ + ConditionTreeLeaf.new('column', Operators::GREATER_THAN, + times.format(Time.now.in_time_zone(tz).prev_week.beginning_of_week)), + ConditionTreeLeaf.new('column', Operators::LESS_THAN, + times.format(Time.now.in_time_zone(tz).beginning_of_week)) + ]).to_h) end it 'PreviousXDaysToDate should rewrite' do expect(@times[Operators::PREVIOUS_X_DAYS_TO_DATE][0][:replacer].call( - ConditionTreeLeaf.new('column', Operators::PREVIOUS_X_DAYS_TO_DATE, 14), 'Europe/Paris' - )) - .eql?(ConditionTreeBranch.new('And', [ - ConditionTreeLeaf.new('column', Operators::GREATER_THAN, - times.format(14.days.ago.beginning_of_day)), - ConditionTreeLeaf.new('column', Operators::LESS_THAN, - times.format(Time.now)) - ])) + ConditionTreeLeaf.new('column', Operators::PREVIOUS_X_DAYS_TO_DATE, 14), tz + ).to_h) + .to eq(ConditionTreeBranch.new('And', [ + ConditionTreeLeaf.new('column', Operators::GREATER_THAN, + times.format(14.days.ago.in_time_zone(tz).beginning_of_day)), + ConditionTreeLeaf.new('column', Operators::LESS_THAN, + times.format(Time.now.in_time_zone(tz))) + ]).to_h) end it 'PreviousXDays should rewrite' do expect(@times[Operators::PREVIOUS_X_DAYS][0][:replacer].call( - ConditionTreeLeaf.new('column', Operators::PREVIOUS_X_DAYS, 14), 'Europe/Paris' - )) - .eql?(ConditionTreeBranch.new('And', [ - ConditionTreeLeaf.new('column', Operators::GREATER_THAN, - times.format(14.days.ago.beginning_of_day)), - ConditionTreeLeaf.new('column', Operators::LESS_THAN, - times.format(Time.now.beginning_of_day)) - ])) + ConditionTreeLeaf.new('column', Operators::PREVIOUS_X_DAYS, 14), tz + ).to_h) + .to eq(ConditionTreeBranch.new('And', [ + ConditionTreeLeaf.new('column', Operators::GREATER_THAN, + times.format(14.days.ago.in_time_zone(tz).beginning_of_day)), + ConditionTreeLeaf.new('column', Operators::LESS_THAN, + times.format(Time.now.in_time_zone(tz).beginning_of_day)) + ]).to_h) end it 'PreviousYearToDate should rewrite' do expect(@times[Operators::PREVIOUS_YEAR_TO_DATE][0][:replacer].call( - ConditionTreeLeaf.new('column', Operators::PREVIOUS_YEAR_TO_DATE), 'Europe/Paris' - )) - .eql?(ConditionTreeBranch.new('And', [ - ConditionTreeLeaf.new('column', Operators::GREATER_THAN, - times.format(Time.now.beginning_of_year)), - ConditionTreeLeaf.new('column', Operators::LESS_THAN, - times.format(Time.now)) - ])) + ConditionTreeLeaf.new('column', Operators::PREVIOUS_YEAR_TO_DATE), tz + ).to_h) + .to eq(ConditionTreeBranch.new('And', [ + ConditionTreeLeaf.new('column', Operators::GREATER_THAN, + times.format(Time.now.in_time_zone(tz).beginning_of_year)), + ConditionTreeLeaf.new('column', Operators::LESS_THAN, + times.format(Time.now.in_time_zone(tz))) + ]).to_h) end it 'PreviousYear should rewrite' do expect(@times[Operators::PREVIOUS_YEAR][0][:replacer].call( - ConditionTreeLeaf.new('column', Operators::PREVIOUS_YEAR), 'Europe/Paris' - )) - .eql?(ConditionTreeBranch.new('And', [ - ConditionTreeLeaf.new('column', Operators::GREATER_THAN, - times.format(Time.now.prev_year.beginning_of_year)), - ConditionTreeLeaf.new('column', Operators::LESS_THAN, - times.format(Time.now.beginning_of_year)) - ])) + ConditionTreeLeaf.new('column', Operators::PREVIOUS_YEAR), tz + ).to_h) + .to eq(ConditionTreeBranch.new('And', [ + ConditionTreeLeaf.new('column', Operators::GREATER_THAN, + times.format(Time.now.in_time_zone(tz).prev_year.beginning_of_year)), + ConditionTreeLeaf.new('column', Operators::LESS_THAN, + times.format(Time.now.in_time_zone(tz).beginning_of_year)) + ]).to_h) end it 'Today should rewrite' do expect(@times[Operators::TODAY][0][:replacer].call(ConditionTreeLeaf.new('column', Operators::TODAY), - 'Europe/Paris')) - .eql?(ConditionTreeBranch.new('And', [ - ConditionTreeLeaf.new('column', Operators::GREATER_THAN, - times.format(Time.now.beginning_of_day)), - ConditionTreeLeaf.new('column', Operators::LESS_THAN, - times.format(Time.now.beginning_of_day + 1.day)) - ])) + tz).to_h) + .to eq(ConditionTreeBranch.new('And', [ + ConditionTreeLeaf.new('column', Operators::GREATER_THAN, + times.format(Time.now.in_time_zone(tz).beginning_of_day)), + ConditionTreeLeaf.new('column', Operators::LESS_THAN, + times.format(Time.now.in_time_zone(tz).beginning_of_day + 1.day)) + ]).to_h) end it 'Yesterday should rewrite' do expect(@times[Operators::YESTERDAY][0][:replacer].call( - ConditionTreeLeaf.new('column', Operators::YESTERDAY), 'Europe/Paris' - )) - .eql?(ConditionTreeBranch.new('And', [ - ConditionTreeLeaf.new('column', Operators::GREATER_THAN, - times.format(Time.now.yesterday.beginning_of_day)), - ConditionTreeLeaf.new('column', Operators::LESS_THAN, - times.format(Time.now.beginning_of_day)) - ])) + ConditionTreeLeaf.new('column', Operators::YESTERDAY), tz + ).to_h) + .to eq(ConditionTreeBranch.new('And', [ + ConditionTreeLeaf.new('column', Operators::GREATER_THAN, + times.format(Time.now.in_time_zone(tz).yesterday.beginning_of_day)), + ConditionTreeLeaf.new('column', Operators::LESS_THAN, + times.format(Time.now.in_time_zone(tz).beginning_of_day)) + ]).to_h) end end end diff --git a/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/query/filter_factory_spec.rb b/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/query/filter_factory_spec.rb index 759ecce3f..8e4038d66 100644 --- a/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/query/filter_factory_spec.rb +++ b/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/query/filter_factory_spec.rb @@ -20,7 +20,7 @@ module Query it 'when no interval operator is present in the condition tree should not modify the condition tree' do leaf = ConditionTreeLeaf.new('someField', 'Like', 'someValue') filter = Filter.new(condition_tree: leaf) - expect(described_class.get_previous_period_filter(filter, timezone)).eql?(filter) + expect(described_class.get_previous_period_filter(filter, timezone).to_h).to eq(filter.to_h) end it 'overrides baseOperator by previousOperator' do @@ -34,8 +34,8 @@ module Query operators.each do |operator| filter = Filter.new(condition_tree: ConditionTreeLeaf.new('someField', operator[:base], 'someValue')) - expect(described_class.get_previous_period_filter(filter, timezone).condition_tree) - .eql?(ConditionTreeLeaf.new('someField', operator[:previous], 'someValue')) + expect(described_class.get_previous_period_filter(filter, timezone).condition_tree.to_h) + .to eq(ConditionTreeLeaf.new('someField', operator[:previous], 'someValue').to_h) end end @@ -52,49 +52,68 @@ module Query filter = Filter.new(condition_tree: ConditionTreeLeaf.new('someField', operator[:base], 'someValue')) start = "beginning_of_#{operator[:unit].downcase}" end_ = "end_of_#{operator[:unit].downcase}" - start_period = Time.now.in_time_zone(timezone).send(:"prev_#{operator[:unit].downcase}").send(start) - end_period = Time.now.in_time_zone(timezone).send(:"prev_#{operator[:unit].downcase}").send(end_) - - expect(described_class.get_previous_period_filter(filter, timezone).condition_tree) - .eql?(ConditionTreeBranch.new( - 'And', - [ - ConditionTreeLeaf.new('someField', Operators::GREATER_THAN, start_period.to_datetime), - ConditionTreeLeaf.new('someField', Operators::LESS_THAN, end_period.to_datetime) - ] - )) + start_period = Time.now.in_time_zone(timezone) + .send(:"prev_#{operator[:unit].downcase}") + .send(start) + .to_datetime + .strftime('%Y-%m-%d %H:%M:%S') + end_period = Time.now.in_time_zone(timezone) + .send(:"prev_#{operator[:unit].downcase}") + .send(end_) + .to_datetime + .strftime('%Y-%m-%d %H:%M:%S') + + expect(described_class.get_previous_period_filter(filter, timezone).condition_tree.to_h) + .to eq(ConditionTreeBranch.new( + 'And', + [ + ConditionTreeLeaf.new('someField', Operators::GREATER_THAN, start_period), + ConditionTreeLeaf.new('someField', Operators::LESS_THAN, end_period) + ] + ).to_h) end end it 'replaces PreviousXDaysToDate operator by a greater/less than' do filter = Filter.new(condition_tree: ConditionTreeLeaf.new('someField', Operators::PREVIOUS_X_DAYS_TO_DATE, 3)) - start_period = Time.now.in_time_zone(timezone).prev_day(2 * filter.condition_tree.value).beginning_of_day - end_period = Time.now.in_time_zone(timezone).prev_day(filter.condition_tree.value).beginning_of_day - - expect(described_class.get_previous_period_filter(filter, timezone).condition_tree) - .eql?(ConditionTreeBranch.new( - 'And', - [ - ConditionTreeLeaf.new('someField', Operators::GREATER_THAN, start_period.to_datetime), - ConditionTreeLeaf.new('someField', Operators::LESS_THAN, end_period.to_datetime) - ] - )) + start_period = Time.now.in_time_zone(timezone).prev_day(2 * filter.condition_tree.value) + .beginning_of_day + .to_datetime + .strftime('%Y-%m-%d %H:%M:%S') + end_period = Time.now.in_time_zone(timezone).prev_day(filter.condition_tree.value) + .to_datetime + .strftime('%Y-%m-%d %H:%M:%S') + + expect(described_class.get_previous_period_filter(filter, timezone).condition_tree.to_h) + .to eq(ConditionTreeBranch.new( + 'And', + [ + ConditionTreeLeaf.new('someField', Operators::GREATER_THAN, start_period), + ConditionTreeLeaf.new('someField', Operators::LESS_THAN, end_period) + ] + ).to_h) end it 'replaces PreviousXDays operator by a greater/less than' do filter = Filter.new(condition_tree: ConditionTreeLeaf.new('someField', Operators::PREVIOUS_X_DAYS, 3)) - start_period = Time.now.in_time_zone(timezone).prev_day(filter.condition_tree.value).beginning_of_day - end_period = Time.now.in_time_zone(timezone).beginning_of_day - - expect(described_class.get_previous_period_filter(filter, timezone).condition_tree) - .eql?(ConditionTreeBranch.new( - 'And', - [ - ConditionTreeLeaf.new('someField', Operators::GREATER_THAN, start_period.to_datetime), - ConditionTreeLeaf.new('someField', Operators::LESS_THAN, end_period.to_datetime) - ] - )) + start_period = Time.now.in_time_zone(timezone).prev_day(filter.condition_tree.value * 2) + .beginning_of_day + .to_datetime + .strftime('%Y-%m-%d %H:%M:%S') + end_period = Time.now.in_time_zone(timezone).prev_day(filter.condition_tree.value) + .beginning_of_day + .to_datetime + .strftime('%Y-%m-%d %H:%M:%S') + + expect(described_class.get_previous_period_filter(filter, timezone).condition_tree.to_h) + .to eq(ConditionTreeBranch.new( + 'And', + [ + ConditionTreeLeaf.new('someField', Operators::GREATER_THAN, start_period), + ConditionTreeLeaf.new('someField', Operators::LESS_THAN, end_period) + ] + ).to_h) end end @@ -140,7 +159,7 @@ module Query collection.add_fields( { 'id' => ColumnSchema.new(column_type: PrimitiveType::NUMBER, is_primary_key: true), - 'review_id' => ColumnSchema.new(column_type: PrimitiveType::NUMBER), + 'review_id' => ColumnSchema.new(column_type: PrimitiveType::NUMBER, filter_operators: [Operators::PRESENT]), 'review' => ManyToOneSchema.new( foreign_key: 'review_id', foreign_key_target: 'id', @@ -172,17 +191,15 @@ module Query base_filter = Filter.new(condition_tree: ConditionTreeLeaf.new('someField', Operators::EQUAL, 1)) filter = described_class.make_through_filter(collection_book, [1], 'reviews', caller, base_filter) - expect(filter).eql?( - Filter.new( - condition_tree: ConditionTreeBranch.new( - 'And', - [ - ConditionTreeLeaf.new('book_id', Operators::EQUAL, value: 1), - ConditionTreeLeaf.new('review_id', Operators::PRESENT), - ConditionTreeLeaf.new('review:someField', Operators::EQUAL, value: 1) - ] - ) - ) + expect(filter.condition_tree.to_h).to eq( + ConditionTreeBranch.new( + 'And', + [ + ConditionTreeLeaf.new('book_id', Operators::EQUAL, 1), + ConditionTreeLeaf.new('review:someField', Operators::EQUAL, 1), + ConditionTreeLeaf.new('review_id', Operators::PRESENT) + ] + ).to_h ) end @@ -191,16 +208,14 @@ module Query segment: 'someSegment') filter = described_class.make_through_filter(collection_book, [1], 'reviews', caller, base_filter) - expect(filter).eql?( - Filter.new( - condition_tree: ConditionTreeBranch.new( - 'And', - [ - ConditionTreeLeaf.new('book_id', Operators::EQUAL, value: 1), - ConditionTreeLeaf.new('review_id', Operators::IN, value: [1, 2]) - ] - ) - ) + expect(filter.condition_tree.to_h).to eq( + ConditionTreeBranch.new( + 'And', + [ + ConditionTreeLeaf.new('book_id', Operators::EQUAL, 1), + ConditionTreeLeaf.new('review_id', Operators::IN, [1, 2]) + ] + ).to_h ) end @@ -209,36 +224,15 @@ module Query segment: 'someSegment') filter = described_class.make_foreign_filter(collection_book, [1], 'bookReviews', caller, base_filter) - expect(filter).eql?( - Filter.new( - condition_tree: ConditionTreeBranch.new( - 'And', - [ - ConditionTreeLeaf.new('someField', Operators::EQUAL, value: 1), - ConditionTreeLeaf.new('book_id', Operators::EQUAL, value: 1) - ] - ), - segment: 'someSegment' - ) - ) - end - - it 'queries the through collection many to many' do - base_filter = Filter.new(condition_tree: ConditionTreeLeaf.new('someField', Operators::EQUAL, 1), - segment: 'someSegment') - filter = described_class.make_through_filter(collection_book, [1], 'reviews', caller, base_filter) - - expect(filter).eql?( - Filter.new( - condition_tree: ConditionTreeBranch.new( - 'And', - [ - ConditionTreeLeaf.new('someField', Operators::EQUAL, value: 1), - ConditionTreeLeaf.new('book_id', Operators::IN, value: [1, 2]) - ] - ), - segment: 'someSegment' - ) + expect(filter.segment).to eq('someSegment') + expect(filter.condition_tree.to_h).to eq( + ConditionTreeBranch.new( + 'And', + [ + ConditionTreeLeaf.new('someField', Operators::EQUAL, 1), + ConditionTreeLeaf.new('book_id', Operators::EQUAL, 1) + ] + ).to_h ) end end diff --git a/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/datasource_spec.rb b/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/datasource_spec.rb index b7aa9cf79..548502207 100644 --- a/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/datasource_spec.rb +++ b/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/datasource_spec.rb @@ -42,5 +42,23 @@ module ForestAdminDatasourceToolkit '🌳🌳🌳 Collection __collection__ already defined in datasource' ) end + + it 'raise an error when datasource not support native query' do + expect do + @datasource.execute_native_query('_connection_name', '_query', '_binds') + end.to raise_error( + ForestAdminDatasourceToolkit::Exceptions::ForestException, + '🌳🌳🌳 this datasource do not support native query.' + ) + end + + it 'raise an error when call build_binding_symbol and datasource not support native query' do + expect do + @datasource.build_binding_symbol('_connection_name', '_binds') + end.to raise_error( + ForestAdminDatasourceToolkit::Exceptions::ForestException, + '🌳🌳🌳 this datasource do not support native query.' + ) + end end end diff --git a/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/utils/collection_spec.rb b/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/utils/collection_spec.rb index 0a872c0a4..08d444d5f 100644 --- a/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/utils/collection_spec.rb +++ b/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/utils/collection_spec.rb @@ -227,7 +227,7 @@ module Utils it 'get_field_schema should work with simple column' do expect(described_class.get_field_schema(collection_person, 'name')) - .eql?(ColumnSchema.new(column_type: PrimitiveType::STRING)) + .to have_attributes(class: ColumnSchema, column_type: PrimitiveType::STRING) end it 'get_field_schema should throw with unknown relation:column' do @@ -246,7 +246,7 @@ module Utils it 'get_field_schema should work with relation column' do expect(described_class.get_field_schema(collection_book_person, 'myPerson:name')) - .eql?(ColumnSchema.new(column_type: PrimitiveType::STRING)) + .to have_attributes(class: ColumnSchema, column_type: PrimitiveType::STRING) end it 'get_through_target should throw with invalid relation type' do diff --git a/packages/forest_admin_test_toolkit/forest_admin_test_toolkit.gemspec b/packages/forest_admin_test_toolkit/forest_admin_test_toolkit.gemspec index f1c84b026..d21423db2 100644 --- a/packages/forest_admin_test_toolkit/forest_admin_test_toolkit.gemspec +++ b/packages/forest_admin_test_toolkit/forest_admin_test_toolkit.gemspec @@ -18,7 +18,7 @@ admin work on any Ruby application." spec.metadata['homepage_uri'] = spec.homepage spec.metadata['source_code_uri'] = 'https://github.com/ForestAdmin/agent-ruby' spec.metadata['changelog_uri'] = 'https://github.com/ForestAdmin/agent-ruby/blob/main/CHANGELOG.md' - spec.metadata['rubygems_mfa_required'] = 'true' + spec.metadata['rubygems_mfa_required'] = 'false' # Specify which files should be added to the gem when it is released. # The `git ls-files -z` loads the files in the RubyGem that have been added into git.