diff --git a/.releaserc.js b/.releaserc.js index 6e4ddb8fc..0ec1706c6 100644 --- a/.releaserc.js +++ b/.releaserc.js @@ -46,42 +46,41 @@ module.exports = { }, ], '@semantic-release/github', - // 'semantic-release-rubygem', - // [ - // 'semantic-release-slack-bot', - // { - // markdownReleaseNotes: true, - // notifyOnSuccess: true, - // notifyOnFail: false, - // onSuccessTemplate: { - // text: "📦 $package_name@$npm_package_version has been released!", - // blocks: [{ - // type: 'section', - // text: { - // type: 'mrkdwn', - // text: '*New `$package_name` package released!*' - // } - // }, { - // type: 'context', - // elements: [{ - // type: 'mrkdwn', - // text: "📦 *Version:* <$repo_url/releases/tag/v$npm_package_version|$npm_package_version>" - // }] - // }, { - // type: 'divider', - // }], - // attachments: [{ - // blocks: [{ - // type: 'section', - // text: { - // type: 'mrkdwn', - // text: '*Changes* of version $release_notes', - // }, - // }], - // }], - // }, - // packageName: 'agent_ruby', - // } - // ], + [ + 'semantic-release-slack-bot', + { + markdownReleaseNotes: true, + notifyOnSuccess: true, + notifyOnFail: false, + onSuccessTemplate: { + text: "📦 $package_name@$npm_package_version has been released!", + blocks: [{ + type: 'section', + text: { + type: 'mrkdwn', + text: '*New `$package_name` package released!*' + } + }, { + type: 'context', + elements: [{ + type: 'mrkdwn', + text: "📦 *Version:* <$repo_url/releases/tag/v$npm_package_version|$npm_package_version>" + }] + }, { + type: 'divider', + }], + attachments: [{ + blocks: [{ + type: 'section', + text: { + type: 'mrkdwn', + text: '*Changes* of version $release_notes', + }, + }], + }], + }, + packageName: 'agent_ruby', + } + ], ], } diff --git a/.rubocop.yml b/.rubocop.yml index 2982ac4cc..71404442f 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -187,12 +187,12 @@ Metrics/MethodLength: Max: 20 Exclude: - 'packages/forest_admin_agent/lib/forest_admin_agent/auth/oauth2/forest_provider.rb' + - 'packages/forest_admin_agent/lib/forest_admin_agent/routes/charts/charts.rb' + - 'packages/forest_admin_agent/lib/forest_admin_agent/serializer/json_api_serializer.rb' - 'packages/forest_admin_datasource_active_record/lib/forest_admin_datasource_active_record/parser/validation.rb' - 'packages/forest_admin_datasource_active_record/lib/forest_admin_datasource_active_record/collection.rb' - 'packages/forest_admin_datasource_active_record/spec/dummy/**/*' - 'packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/collection.rb' - - 'packages/forest_admin_agent/lib/forest_admin_agent/auth/oauth2/forest_provider.rb' - - 'packages/forest_admin_agent/lib/forest_admin_agent/serializer/json_api_serializer.rb' - 'packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/condition_tree/condition_tree_factory.rb' - 'packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/filter_factory.rb' - 'packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/condition_tree/nodes/condition_tree_leaf.rb' @@ -207,7 +207,9 @@ Metrics/ClassLength: Exclude: - 'packages/forest_admin_agent/lib/forest_admin_agent/utils/schema/generator_field.rb' - 'packages/forest_admin_agent/lib/forest_admin_agent/services/permissions.rb' + - 'packages/forest_admin_agent/lib/forest_admin_agent/routes/charts/charts.rb' - 'packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/utils/collection.rb' + - 'packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/aggregation.rb' - 'packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/filter_factory.rb' - 'packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/condition_tree/nodes/condition_tree_leaf.rb' - 'packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/condition_tree/transforms/comparisons.rb' @@ -232,8 +234,9 @@ Layout/LineLength: - 'packages/forest_admin_agent/spec/lib/forest_admin_agent/http/forest_admin_api_requester_spec.rb' - 'packages/forest_admin_agent/lib/forest_admin_agent/routes/resources/list.rb' - 'packages/forest_admin_agent/lib/forest_admin_agent/services/permissions.rb' - - 'packages/forest_admin_agent/spec/lib/forest_admin_agent/utils/context_variables_injector_spec.rb' + - 'packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/resources/charts/charts_spec.rb' - 'packages/forest_admin_agent/spec/lib/forest_admin_agent/services/smart_action_checker_spec.rb' + - 'packages/forest_admin_agent/spec/lib/forest_admin_agent/utils/context_variables_injector_spec.rb' - 'packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/condition_tree/condition_tree_factory.rb' - 'packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/utils/collection.rb' - 'packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/filter_factory.rb' diff --git a/package.json b/package.json index 3fa046a17..01c1b967c 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,6 @@ "@semantic-release/exec": "6.0.3", "@semantic-release/git": "10.0.1", "semantic-release": "21.1.1", - "semantic-release-rubygem": "^1.2.0", "semantic-release-slack-bot": "4.0.2" } } diff --git a/packages/forest_admin_agent/forest_admin_agent.gemspec b/packages/forest_admin_agent/forest_admin_agent.gemspec index 0d0801bed..c820e7610 100644 --- a/packages/forest_admin_agent/forest_admin_agent.gemspec +++ b/packages/forest_admin_agent/forest_admin_agent.gemspec @@ -34,6 +34,7 @@ admin work on any Ruby application." spec.require_paths = ["lib"] spec.add_dependency "activesupport", ">= 6.1" + spec.add_dependency "deepsort", "~> 0.4.5" spec.add_dependency "dry-container", "~> 0.11" spec.add_dependency "faraday", "~> 2.7" spec.add_dependency "filecache", "~> 1.0" diff --git a/packages/forest_admin_agent/lib/forest_admin_agent/builder/agent_factory.rb b/packages/forest_admin_agent/lib/forest_admin_agent/builder/agent_factory.rb index 53b9d1c89..1bd597b0e 100644 --- a/packages/forest_admin_agent/lib/forest_admin_agent/builder/agent_factory.rb +++ b/packages/forest_admin_agent/lib/forest_admin_agent/builder/agent_factory.rb @@ -63,9 +63,7 @@ def build_cache return unless @has_env_secret cache = @container.resolve(:cache) - cache.get_or_set 'config' do - @options.to_h - end + cache.set('config', @options.to_h) end def build_logger 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 b9e982b2d..797a9f343 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 @@ -9,6 +9,7 @@ def self.routes # api_charts_routes, System::HealthCheck.new.routes, Security::Authentication.new.routes, + Charts::Charts.new.routes, Resources::Count.new.routes, Resources::Delete.new.routes, Resources::List.new.routes, diff --git a/packages/forest_admin_agent/lib/forest_admin_agent/routes/charts/charts.rb b/packages/forest_admin_agent/lib/forest_admin_agent/routes/charts/charts.rb new file mode 100644 index 000000000..54ef99c6b --- /dev/null +++ b/packages/forest_admin_agent/lib/forest_admin_agent/routes/charts/charts.rb @@ -0,0 +1,214 @@ +require 'jsonapi-serializers' +require 'active_support/inflector' + +module ForestAdminAgent + module Routes + module Charts + class Charts < AbstractAuthenticatedRoute + include ForestAdminAgent::Builder + include ForestAdminAgent::Utils + include ForestAdminDatasourceToolkit::Components::Query + include ForestAdminDatasourceToolkit::Components::Query::ConditionTree + include ForestAdminDatasourceToolkit::Components::Charts + + attr_reader :filter + + FORMAT = { + Day: '%d/%m/%Y', + Week: 'W%W-%Y', + Month: '%b %y', + Year: '%Y' + }.freeze + + def setup_routes + add_route('forest_chart', 'post', '/stats/:collection_name', lambda { |args| + handle_request(args) + }) + self + end + + def handle_request(args = {}) + build(args) + @permissions.can_chart?(args[:params]) + @args = args + self.type = args[:params][:type] + @filter = Filter.new( + condition_tree: ConditionTreeFactory.intersect( + [ + @permissions.get_scope(@collection), + ForestAdminAgent::Utils::QueryStringParser.parse_condition_tree( + @collection, args + ) + ] + ) + ) + + inject_context_variables + + { content: Serializer::ForestChartSerializer.serialize(send(:"make_#{@type}")) } + 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 inject_context_variables + user = @permissions.get_user_data(@caller.id) + team = @permissions.get_team(@caller.rendering_id) + + context_variables = ForestAdminAgent::Utils::ContextVariables.new(team, user, + @args[:params][:contextVariables]) + return unless @args[:params][:filter] + + @filter = @filter.override(condition_tree: ContextVariablesInjector.inject_context_in_filter( + @filter.condition_tree, context_variables + )) + end + + def make_value + value = compute_value(@filter) + previous_value = nil + is_and_aggregator = @filter.condition_tree&.try(:aggregator) == 'And' + with_count_previous = @filter.condition_tree&.some_leaf(&:use_interval_operator) + + if with_count_previous && !is_and_aggregator + previous_value = compute_value(FilterFactory.get_previous_period_filter(@filter, @caller.timezone)) + end + + ValueChart.new(value, previous_value) + end + + def make_objective + ObjectiveChart.new(compute_value(@filter)) + end + + def make_pie + group_field = @args[:params][:groupByFieldName] + aggregation = Aggregation.new( + operation: @args[:params][:aggregator], + field: @args[:params][:aggregateFieldName], + groups: group_field ? [{ field: group_field }] : [] + ) + + result = @collection.aggregate(@caller, @filter, aggregation) + + PieChart.new(result.map { |row| { key: row[:group][group_field], value: row[:value] } }) + end + + def make_line + group_by_field_name = @args[:params][:groupByFieldName] + time_range = @args[:params][:timeRange] + filter_only_with_values = @filter.override( + condition_tree: ConditionTree::ConditionTreeFactory.intersect( + [ + @filter.condition_tree, + ConditionTree::Nodes::ConditionTreeLeaf.new(group_by_field_name, ConditionTree::Operators::PRESENT) + ] + ) + ) + rows = @collection.aggregate( + @caller, + filter_only_with_values, + Aggregation.new( + operation: @args[:params][:aggregator], + field: @args[:params][:aggregateField], + groups: [{ field: group_by_field_name, operation: time_range }] + ) + ) + + values = {} + rows.each { |row| values[row[:group][group_by_field_name]] = row[:value] } + dates = values.keys.sort + current = dates[0] + last = dates.last + result = [] + while current <= last + result << { + label: current.strftime(FORMAT[time_range.to_sym]), + values: { value: values[current] || 0 } + } + current += 1.send(time_range.downcase.pluralize.to_sym) + end + + LineChart.new(result) + end + + def make_leaderboard + field = @collection.fields[@args[:params][:relationshipFieldName]] + + if field && field.type == 'OneToMany' + inverse = ForestAdminDatasourceToolkit::Utils::Collection.get_inverse_relation( + @collection, + @args[:params][:relationshipFieldName] + ) + if inverse + collection = field.foreign_collection + filter = @filter.nest(inverse) + aggregation = Aggregation.new( + operation: @args[:params][:aggregator], + field: @args[:params][:aggregateFieldName], + groups: [{ field: "#{inverse}:#{@args[:params][:labelFieldName]}" }] + ) + end + end + + if field && field.type == 'ManyToMany' + origin = ForestAdminDatasourceToolkit::Utils::Collection.get_through_origin( + @collection, + @args[:params][:relationshipFieldName] + ) + target = ForestAdminDatasourceToolkit::Utils::Collection.get_through_target( + @collection, + @args[:params][:relationshipFieldName] + ) + if origin && target + collection = field.through_collection + filter = @filter.nest(origin) + aggregation = Aggregation.new( + operation: @args[:params][:aggregator], + field: @args[:params][:aggregateFieldName] ? "#{target}:#{@args[:params][:aggregateFieldName]}" : nil, + groups: [{ field: "#{origin}:#{@args[:params][:labelFieldName]}" }] + ) + end + end + + if collection && filter && aggregation + rows = @datasource.collection(collection).aggregate( + @caller, + filter, + aggregation, + @args[:params][:limit] + ) + + result = rows.map do |row| + { + key: row[:group][aggregation.groups[0][:field]], + value: row[:value] + } + end + + return LeaderboardChart.new(result) + end + + raise ForestAdminDatasourceToolkit::Exceptions::ForestException, + 'Failed to generate leaderboard chart: parameters do not match pre-requisites' + end + + def compute_value(filter) + aggregation = Aggregation.new(operation: @args[:params][:aggregator], + field: @args[:params][:aggregateFieldName]) + result = @collection.aggregate(@caller, filter, aggregation) + + result[0][:value] || 0 + end + end + end + end +end diff --git a/packages/forest_admin_agent/lib/forest_admin_agent/routes/resources/store.rb b/packages/forest_admin_agent/lib/forest_admin_agent/routes/resources/store.rb index 54a7aba50..1db52746b 100644 --- a/packages/forest_admin_agent/lib/forest_admin_agent/routes/resources/store.rb +++ b/packages/forest_admin_agent/lib/forest_admin_agent/routes/resources/store.rb @@ -33,17 +33,17 @@ def handle_request(args = {}) def link_one_to_one_relations(args, record) args[:params][:data][:relationships]&.map do |field, value| schema = @collection.fields[field] - if schema.type == 'OneToOne' - id = Utils::Id.unpack_id(@collection, value['data']['id'], with_key: true) - foreign_collection = @datasource.collection(schema.foreign_collection) - # Load the value that will be used as origin_key - origin_value = record[schema.origin_key_target] + next unless schema.type == 'OneToOne' - # update new relation (may update zero or one records). - condition_tree = ConditionTree::ConditionTreeFactory.match_records(foreign_collection, [id]) - filter = Filter.new(condition_tree: condition_tree) - foreign_collection.update(@caller, filter, { schema.origin_key => origin_value }) - end + id = Utils::Id.unpack_id(@collection, value['data']['id'], with_key: true) + foreign_collection = @datasource.collection(schema.foreign_collection) + # Load the value that will be used as origin_key + origin_value = record[schema.origin_key_target] + + # update new relation (may update zero or one records). + condition_tree = ConditionTree::ConditionTreeFactory.match_records(foreign_collection, [id]) + filter = Filter.new(condition_tree: condition_tree) + foreign_collection.update(@caller, filter, { schema.origin_key => origin_value }) end end end diff --git a/packages/forest_admin_agent/lib/forest_admin_agent/serializer/forest_chart_serializer.rb b/packages/forest_admin_agent/lib/forest_admin_agent/serializer/forest_chart_serializer.rb new file mode 100644 index 000000000..c3ca89a6e --- /dev/null +++ b/packages/forest_admin_agent/lib/forest_admin_agent/serializer/forest_chart_serializer.rb @@ -0,0 +1,19 @@ +require 'securerandom' + +module ForestAdminAgent + module Serializer + class ForestChartSerializer + def self.serialize(chart) + { + data: { + id: SecureRandom.uuid, + type: 'stats', + attributes: { + value: chart.serialize + } + } + } + end + end + end +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 b632d2d9f..54d20a64c 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 @@ -1,4 +1,5 @@ require 'filecache' +require 'deepsort' module ForestAdminAgent module Services @@ -56,7 +57,7 @@ def can?(action, collection, allow_fetch: false) end def can_chart?(parameters) - attributes = sanitize_chart_parameters(parameters) + attributes = sanitize_chart_parameters(parameters.deep_symbolize_keys) hash_request = "#{attributes[:type]}:#{array_hash(attributes)}" is_allowed = get_chart_data(caller.rendering_id).include?(hash_request) @@ -99,8 +100,6 @@ def can_smart_action?(request, collection, filter, allow_fetch: true) def get_scope(collection) permissions = get_scope_and_team_data(caller.rendering_id) scope = permissions[:scopes][collection.name.to_sym] - team = permissions[:team] - user = get_user_data(caller.id) return nil if scope.nil? @@ -170,16 +169,21 @@ def get_chart_data(rendering_id, force_fetch: false) end def sanitize_chart_parameters(parameters) - # parameters = parameters.to_h parameters.delete(:timezone) parameters.delete(:collection) parameters.delete(:contextVariables) + # rails + parameters.delete(:route_alias) + parameters.delete(:controller) + parameters.delete(:action) + parameters.delete(:collection_name) + parameters.delete(:forest) parameters.select { |_, value| !value.nil? && value != '' } end def array_hash(data) - Digest::SHA1.hexdigest(data.sort.to_h.to_s) + Digest::SHA1.hexdigest(data.deep_sort.to_h.to_s) end def get_scope_and_team_data(rendering_id) @@ -197,7 +201,7 @@ def get_scope_and_team_data(rendering_id) def permission_system? cache.get_or_set('forest.has_permission') do response = fetch('/liana/v4/permissions/environment') - { enable: !response.nil? } + { enable: response != true } end[:enable] end diff --git a/packages/forest_admin_agent/lib/forest_admin_agent/utils/query_string_parser.rb b/packages/forest_admin_agent/lib/forest_admin_agent/utils/query_string_parser.rb index a32598e4d..8f933d1d6 100644 --- a/packages/forest_admin_agent/lib/forest_admin_agent/utils/query_string_parser.rb +++ b/packages/forest_admin_agent/lib/forest_admin_agent/utils/query_string_parser.rb @@ -1,4 +1,5 @@ require 'jwt' +require 'active_support' require 'active_support/time' module ForestAdminAgent diff --git a/packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/resources/charts/charts_spec.rb b/packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/resources/charts/charts_spec.rb new file mode 100644 index 000000000..b11ea2d1e --- /dev/null +++ b/packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/resources/charts/charts_spec.rb @@ -0,0 +1,505 @@ +require 'spec_helper' +require 'singleton' +require 'ostruct' +require 'shared/caller' +require 'json' + +module ForestAdminAgent + module Routes + module Charts + include ForestAdminDatasourceToolkit + include ForestAdminDatasourceToolkit::Schema + include ForestAdminDatasourceToolkit::Components::Query + include ForestAdminDatasourceToolkit::Components::Query::ConditionTree + include ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes + + describe Charts do + include_context 'with caller' + subject(:chart) { described_class.new } + let(:args) do + { + headers: { 'HTTP_AUTHORIZATION' => bearer }, + params: { + 'collection_name' => 'book', + 'timezone' => 'Europe/Paris' + } + } + end + let(:permissions) { instance_double(ForestAdminAgent::Services::Permissions) } + + before do + book_class = Struct.new(:id, :title, :price, :date, :year) + stub_const('Book', book_class) + book_review_class = Struct.new(:id, :book_id, :review_id) + stub_const('BookReview', book_review_class) + review_class = Struct.new(:id, :book_id, :author) + stub_const('Review', review_class) + + @datasource = Datasource.new + collection_book = instance_double( + Collection, + name: 'book', + fields: { + 'id' => ColumnSchema.new(column_type: 'Number', is_primary_key: true), + 'title' => ColumnSchema.new(column_type: 'String'), + 'price' => ColumnSchema.new(column_type: 'Number'), + 'date' => ColumnSchema.new(column_type: 'Date', filter_operators: [Operators::YESTERDAY]), + 'year' => ColumnSchema.new(column_type: 'Number', filter_operators: [Operators::EQUAL]), + 'reviews' => Relations::ManyToManySchema.new( + foreign_key: 'review_id', + foreign_key_target: 'id', + foreign_collection: 'review', + through_collection: 'book_review', + origin_key_target: 'id', + origin_key: 'book_id' + ), + 'bookReviews' => Relations::OneToManySchema.new( + origin_key: 'book_id', + foreign_collection: 'review', + origin_key_target: 'id' + ) + } + ) + collection_book_review = instance_double( + Collection, + name: 'book_review', + fields: { + 'id' => ColumnSchema.new(column_type: 'Number', is_primary_key: true), + 'book' => Relations::ManyToOneSchema.new( + foreign_key: 'book_id', + foreign_collection: 'book', + foreign_key_target: 'id' + ), + 'review' => Relations::ManyToOneSchema.new( + foreign_key: 'review_id', + foreign_collection: 'review', + foreign_key_target: 'id' + ) + } + ) + collection_review = instance_double( + Collection, + name: 'review', + fields: { + 'id' => ColumnSchema.new(column_type: 'Number', is_primary_key: true), + 'author' => ColumnSchema.new(column_type: 'String'), + 'book' => Relations::ManyToOneSchema.new( + foreign_key: 'book_id', + foreign_collection: 'book', + foreign_key_target: 'id' + ) + } + ) + allow(ForestAdminAgent::Builder::AgentFactory.instance).to receive(:send_schema).and_return(nil) + @datasource.add_collection(collection_book) + @datasource.add_collection(collection_review) + @datasource.add_collection(collection_book_review) + ForestAdminAgent::Builder::AgentFactory.instance.add_datasource(@datasource) + ForestAdminAgent::Builder::AgentFactory.instance.build + + 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_chart' do + chart.setup_routes + expect(chart.routes.include?('forest_chart')).to be true + expect(chart.routes.length).to eq 1 + end + + it 'throw an error when request has a bad chart type' do + args[:params][:type] = 'unknown_type' + + expect do + chart.handle_request(args) + end.to raise_error( + ForestAdminDatasourceToolkit::Exceptions::ForestException, '🌳🌳🌳 Invalid Chart type unknown_type' + ) + end + + describe 'makeValue' do + it 'return a valueChart' do + args[:params] = args[:params].merge({ + aggregateFieldName: 'price', + aggregator: 'Sum', + sourceCollectionName: 'book', + type: 'Value', + timezone: 'Europe/Paris' + }) + allow(@datasource.collection('book')).to receive(:aggregate).and_return([{ value: 10, group: [] }]) + result = chart.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 filter' do + args[:params] = args[:params].merge({ + aggregateFieldName: 'price', + aggregator: 'Sum', + sourceCollectionName: 'book', + filter: '{"field":"date","operator":"yesterday","value":null}', + type: 'Value', + timezone: 'Europe/Paris' + }) + allow(@datasource.collection('book')).to receive(:aggregate).and_return( + [{ value: 10, group: [] }], # first call + [{ value: 5, group: [] }] # second call + ) + result = chart.handle_request(args) + + expect(result).to match( + content: { + data: { + id: be_a(String), + type: 'stats', + attributes: { + value: { countCurrent: 10, countPrevious: 5 } + } + } + } + ) + end + end + + describe 'makeObjective' do + it 'return a ObjectiveChart' do + args[:params] = args[:params].merge({ + aggregateFieldName: 'price', + aggregator: 'Count', + sourceCollectionName: 'book', + type: 'Objective', + timezone: 'Europe/Paris' + }) + allow(@datasource.collection('book')).to receive(:aggregate).and_return([{ value: 10, group: [] }]) + result = chart.handle_request(args) + + expect(result).to match( + content: { + data: { + id: be_a(String), + type: 'stats', + attributes: { + value: { value: 10 } + } + } + } + ) + end + end + + describe 'makePie' do + it 'return a PieChart' do + args[:params] = args[:params].merge({ + groupByFieldName: 'year', + aggregator: 'Count', + sourceCollectionName: 'book', + type: 'Pie', + timezone: 'Europe/Paris' + }) + allow(@datasource.collection('book')).to receive(:aggregate).and_return( + [ + { value: 100, group: { 'year' => 2021 } }, + { value: 150, group: { 'year' => 2022 } } + ] + ) + result = chart.handle_request(args) + + expect(result).to match( + content: { + data: { + id: be_a(String), + type: 'stats', + attributes: { + value: [{ key: 2021, value: 100 }, { key: 2022, value: 150 }] + } + } + } + ) + end + end + + describe 'makeLine' do + it 'return a LineChart with day time range' do + args[:params] = args[:params].merge({ + groupByFieldName: 'date', + aggregator: 'Count', + sourceCollectionName: 'book', + timeRange: 'Day', + type: 'Line', + timezone: 'Europe/Paris' + }) + allow(@datasource.collection('book')).to receive(:aggregate).and_return( + [ + { value: 10, group: { 'date' => Time.parse('2022-01-03 00:00:00') } }, + { value: 15, group: { 'date' => Time.parse('2022-01-07 00:00:00') } } + ] + ) + result = chart.handle_request(args) + + expect(result).to match( + content: { + data: { + id: be_a(String), + type: 'stats', + attributes: { + value: [ + { label: '03/01/2022', values: { value: 10 } }, + { label: '04/01/2022', values: { value: 0 } }, + { label: '05/01/2022', values: { value: 0 } }, + { label: '06/01/2022', values: { value: 0 } }, + { label: '07/01/2022', values: { value: 15 } } + ] + } + } + } + ) + end + + it 'return a LineChart with week time range' do + args[:params] = args[:params].merge({ + groupByFieldName: 'date', + aggregator: 'Count', + sourceCollectionName: 'book', + timeRange: 'Week', + type: 'Line', + timezone: 'Europe/Paris' + }) + allow(@datasource.collection('book')).to receive(:aggregate).and_return( + [ + { value: 10, group: { 'date' => Time.parse('2022-01-03 00:00:00') } }, + { value: 15, group: { 'date' => Time.parse('2022-01-10 00:00:00') } } + ] + ) + result = chart.handle_request(args) + + expect(result).to match( + content: { + data: { + id: be_a(String), + type: 'stats', + attributes: { + value: [ + { label: 'W01-2022', values: { value: 10 } }, + { label: 'W02-2022', values: { value: 15 } } + ] + } + } + } + ) + end + + it 'return a LineChart with month time range' do + args[:params] = args[:params].merge({ + groupByFieldName: 'date', + aggregator: 'Count', + sourceCollectionName: 'book', + timeRange: 'Month', + type: 'Line', + timezone: 'Europe/Paris' + }) + allow(@datasource.collection('book')).to receive(:aggregate).and_return( + [ + { value: 10, group: { 'date' => Time.parse('2022-01-01 00:00:00') } }, + { value: 15, group: { 'date' => Time.parse('2022-02-01 00:00:00') } } + ] + ) + result = chart.handle_request(args) + + expect(result).to match( + content: { + data: { + id: be_a(String), + type: 'stats', + attributes: { + value: [ + { label: 'Jan 22', values: { value: 10 } }, + { label: 'Feb 22', values: { value: 15 } } + ] + } + } + } + ) + end + + it 'return a LineChart with year time range' do + args[:params] = args[:params].merge({ + groupByFieldName: 'date', + aggregator: 'Count', + sourceCollectionName: 'book', + timeRange: 'Year', + type: 'Line', + timezone: 'Europe/Paris' + }) + allow(@datasource.collection('book')).to receive(:aggregate).and_return( + [ + { value: 10, group: { 'date' => Time.parse('2022-01-01 00:00:00') } }, + { value: 15, group: { 'date' => Time.parse('2023-01-01 00:00:00') } } + ] + ) + result = chart.handle_request(args) + + expect(result).to match( + content: { + data: { + id: be_a(String), + type: 'stats', + attributes: { + value: [ + { label: '2022', values: { value: 10 } }, + { label: '2023', values: { value: 15 } } + ] + } + } + } + ) + end + end + + describe 'makeLeaderboard' do + it 'return a LeaderboardChart with a OneToMany Relation' do + args[:params] = args[:params].merge({ + labelFieldName: 'author', + relationshipFieldName: 'bookReviews', + aggregator: 'Count', + aggregateFieldName: 'id', + sourceCollectionName: 'book', + type: 'Leaderboard', + timezone: 'Europe/Paris' + }) + allow(@datasource.collection('book')).to receive(:datasource).and_return(@datasource) + allow(@datasource.collection('review')).to receive(:aggregate).and_return( + [ + { value: 10, group: { 'author' => 'Isaac Asimov' } }, + { value: 15, group: { 'author' => 'Jules Verne' } } + ] + ) + result = chart.handle_request(args) + + expect(result).to match( + content: { + data: { + id: be_a(String), + type: 'stats', + attributes: { + value: [{ key: nil, value: 10 }, { key: nil, value: 15 }] + } + } + } + ) + end + + it 'return a LeaderboardChart with a ManyToMany Relation' do + args[:params] = args[:params].merge({ + labelFieldName: 'year', + relationshipFieldName: 'reviews', + aggregator: 'Count', + aggregateFieldName: 'id', + sourceCollectionName: 'book', + type: 'Leaderboard', + timezone: 'Europe/Paris' + }) + allow(@datasource.collection('book')).to receive(:datasource).and_return(@datasource) + allow(@datasource.collection('book_review')).to receive(:aggregate).and_return( + [ + { value: 10, group: { 'book:year' => 2022 } }, + { value: 15, group: { 'book:year' => 2023 } } + ] + ) + result = chart.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 'throw a ForestException when the request is not filled correctly' do + args[:params] = args[:params].merge({ + relationshipFieldName: 'unknown_relation', + aggregator: 'Count', + sourceCollectionName: 'book', + type: 'Leaderboard', + timezone: 'Europe/Paris' + }) + + expect do + chart.handle_request(args) + end.to raise_error( + ForestAdminDatasourceToolkit::Exceptions::ForestException, + '🌳🌳🌳 Failed to generate leaderboard chart: parameters do not match pre-requisites' + ) + end + end + + describe 'inject_context_variables' do + it 'overrides the filter with the context variables' do + args[:params] = args[:params].merge({ + type: 'Value', + sourceCollectionName: 'book', + aggregateFieldName: nil, + aggregator: 'Count', + filter: { 'aggregator' => 'and', + 'conditions' => [{ 'operator' => 'equal', + 'value' => '{{dropdown1.selectedValue}}', 'field' => 'title' }] }, + contextVariables: { 'dropdown1.selectedValue' => 'FOO' }, + timezone: 'Europe/Paris' + }) + allow(@datasource.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 + )) + end + + it 'doeses not override the filter when there is no filter with a context variable' do + args[:params] = args[:params].merge({ + type: 'Value', + sourceCollectionName: 'book', + aggregateFieldName: nil, + aggregator: 'Count', + timezone: 'Europe/Paris' + }) + allow(@datasource.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)) + end + end + end + end + end +end diff --git a/packages/forest_admin_agent/spec/lib/forest_admin_agent/utils/context_variables_spec.rb b/packages/forest_admin_agent/spec/lib/forest_admin_agent/utils/context_variables_spec.rb index 94c0e7125..54f807366 100644 --- a/packages/forest_admin_agent/spec/lib/forest_admin_agent/utils/context_variables_spec.rb +++ b/packages/forest_admin_agent/spec/lib/forest_admin_agent/utils/context_variables_spec.rb @@ -9,7 +9,7 @@ module Utils firstName: 'John', lastName: 'Doe', fullName: 'John Doe', - email: 'John Doe', + email: 'johndoe@forestadmin.com', tags: { 'foo' => 'bar' }, roleId: 1, permissionLevel: 'admin' diff --git a/packages/forest_admin_datasource_active_record/forest_admin_datasource_active_record.gemspec b/packages/forest_admin_datasource_active_record/forest_admin_datasource_active_record.gemspec index 029eb54af..8d50cb056 100644 --- a/packages/forest_admin_datasource_active_record/forest_admin_datasource_active_record.gemspec +++ b/packages/forest_admin_datasource_active_record/forest_admin_datasource_active_record.gemspec @@ -33,5 +33,6 @@ admin work on any Ruby application." spec.require_paths = ["lib"] spec.add_dependency "activerecord", ">= 6.1" + spec.add_dependency "activesupport", ">= 6.1" spec.add_dependency "zeitwerk", "~> 2.3" end diff --git a/packages/forest_admin_datasource_active_record/lib/forest_admin_datasource_active_record/collection.rb b/packages/forest_admin_datasource_active_record/lib/forest_admin_datasource_active_record/collection.rb index e853d4b40..c38387e4f 100644 --- a/packages/forest_admin_datasource_active_record/lib/forest_admin_datasource_active_record/collection.rb +++ b/packages/forest_admin_datasource_active_record/lib/forest_admin_datasource_active_record/collection.rb @@ -20,15 +20,8 @@ def list(_caller, filter, projection) query.all end - def aggregate(_caller, _filter, aggregation, _limit = nil) - field = aggregation.field || '*' - - [ - { - value: @model.send(aggregation.operation.downcase, field), - group: [] - } - ] + def aggregate(_caller, filter, aggregation, limit = nil) + Utils::QueryAggregate.new(self, aggregation, filter, limit).get end def create(_caller, data) diff --git a/packages/forest_admin_datasource_active_record/lib/forest_admin_datasource_active_record/utils/query.rb b/packages/forest_admin_datasource_active_record/lib/forest_admin_datasource_active_record/utils/query.rb index 1a33dc740..f8b132706 100644 --- a/packages/forest_admin_datasource_active_record/lib/forest_admin_datasource_active_record/utils/query.rb +++ b/packages/forest_admin_datasource_active_record/lib/forest_admin_datasource_active_record/utils/query.rb @@ -9,13 +9,13 @@ def initialize(collection, projection, filter) @projection = projection @filter = filter @arel_table = @collection.model.arel_table + @select = [] end def build - @query = select - @query = apply_filter - - @query + build_select + apply_filter + apply_select end def apply_filter @@ -71,19 +71,30 @@ def compute_main_operator(condition_tree, aggregator) @query end - def select + def build_select unless @projection.nil? - query_select = @projection.columns.map { |field| "#{@collection.model.table_name}.#{field}" } + @select += @projection.columns.map { |field| "#{@collection.model.table_name}.#{field}" } @projection.relations.each_key do |relation| relation_schema = @collection.fields[relation] - if relation_schema.type == 'OneToOne' - query_select.push("#{@collection.model.table_name}.#{relation_schema.origin_key_target}") - else - query_select.push("#{@collection.model.table_name}.#{relation_schema.foreign_key}") - end + @select << if relation_schema.type == 'OneToOne' + "#{@collection.model.table_name}.#{relation_schema.origin_key_target}" + else + "#{@collection.model.table_name}.#{relation_schema.foreign_key}" + end end - @query = @query.select(query_select.join(', ')) + # @query = @query.select(query_select.join(', ')) + # @query = @query.eager_load(@projection.relations.keys.map(&:to_sym)) + # # TODO: replace eager_load by joins because eager_load select ALL columns of relation + # # @query = @query.joins(@projection.relations.keys.map(&:to_sym)) + end + + @query + end + + def apply_select + unless @projection.nil? + @query = @query.select(@select.join(', ')) @query = @query.eager_load(@projection.relations.keys.map(&:to_sym)) # TODO: replace eager_load by joins because eager_load select ALL columns of relation # @query = @query.joins(@projection.relations.keys.map(&:to_sym)) diff --git a/packages/forest_admin_datasource_active_record/lib/forest_admin_datasource_active_record/utils/query_aggregate.rb b/packages/forest_admin_datasource_active_record/lib/forest_admin_datasource_active_record/utils/query_aggregate.rb new file mode 100644 index 000000000..400f86f1a --- /dev/null +++ b/packages/forest_admin_datasource_active_record/lib/forest_admin_datasource_active_record/utils/query_aggregate.rb @@ -0,0 +1,49 @@ +module ForestAdminDatasourceActiveRecord + module Utils + class QueryAggregate < Query + include ForestAdminDatasourceToolkit::Components::Query::ConditionTree + include ForestAdminDatasourceToolkit::Components::Query + + def initialize(collection, aggregation, filter = nil, limit = nil) + super(collection, ForestAdminDatasourceToolkit::Components::Query::Projection.new, filter) + @aggregation = aggregation + @limit = limit + @operation = aggregation.operation.downcase + @field = aggregation.field.nil? ? '*' : format_field(aggregation.field) + end + + def get + group_fields = [] + @aggregation.groups.each do |group| + field = format_field(group[:field]) + if group[:operation] + @select << "DATE_TRUNC('#{group[:operation].downcase}', #{field}) AS \"#{group[:field]}\"" + group_fields << "DATE_TRUNC('#{group[:operation].downcase}', #{field})" + else + @select << "#{field} AS \"#{group[:field]}\"" + group_fields << field + end + end + + @select << "#{@operation}(#{@field}) AS #{@operation}" + @query = @query.order("#{@operation} DESC") + @query = @query.limit(@limit) if @limit + @query = @query.group(group_fields.join(',')) + build + + compute_result_aggregate(@query) + end + + def compute_result_aggregate(rows) + rows.map do |row| + { + value: row.send(@operation.to_sym), + group: @aggregation.groups.each_with_object({}) do |group, memo| + memo[group[:field]] = row.send(group[:field].to_sym) + end + } + end + end + end + end +end diff --git a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/charts/chart.rb b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/charts/chart.rb new file mode 100644 index 000000000..55a7b1d82 --- /dev/null +++ b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/charts/chart.rb @@ -0,0 +1,11 @@ +module ForestAdminDatasourceToolkit + module Components + module Charts + class Chart + def serialize + raise NotImplementedError, "#{self.class} has not implemented method '#{__method__}'" + end + end + end + end +end diff --git a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/charts/leaderboard_chart.rb b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/charts/leaderboard_chart.rb new file mode 100644 index 000000000..26d420425 --- /dev/null +++ b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/charts/leaderboard_chart.rb @@ -0,0 +1,24 @@ +module ForestAdminDatasourceToolkit + module Components + module Charts + class LeaderboardChart + include ForestAdminDatasourceToolkit::Validations + + attr_reader :data + + def initialize(data) + super() + @data = data + end + + def serialize + data.each do |item| + ChartValidator.validate?(!item.key?(:key) || !item.key?(:value), item, "'key', 'value'") + end + + data + end + end + end + end +end diff --git a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/charts/line_chart.rb b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/charts/line_chart.rb new file mode 100644 index 000000000..3c01d43ab --- /dev/null +++ b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/charts/line_chart.rb @@ -0,0 +1,24 @@ +module ForestAdminDatasourceToolkit + module Components + module Charts + class LineChart + include ForestAdminDatasourceToolkit::Validations + + attr_reader :data + + def initialize(data) + super() + @data = data + end + + def serialize + data.each do |item| + ChartValidator.validate?(!item.key?(:label) || !item.key?(:values), item, "'label', 'values'") + end + + data + end + end + end + end +end diff --git a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/charts/objective_chart.rb b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/charts/objective_chart.rb new file mode 100644 index 000000000..5e4d22015 --- /dev/null +++ b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/charts/objective_chart.rb @@ -0,0 +1,22 @@ +module ForestAdminDatasourceToolkit + module Components + module Charts + class ObjectiveChart + attr_reader :value, :objective + + def initialize(value, objective = nil) + super() + @value = value + @objective = objective + end + + def serialize + result = { value: value } + result[:objective] = objective if objective + + result + end + end + end + end +end diff --git a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/charts/percentage_chart.rb b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/charts/percentage_chart.rb new file mode 100644 index 000000000..3d279e7be --- /dev/null +++ b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/charts/percentage_chart.rb @@ -0,0 +1,18 @@ +module ForestAdminDatasourceToolkit + module Components + module Charts + class PercentageChart + attr_reader :value + + def initialize(value) + super() + @value = value + end + + def serialize + value + end + end + end + end +end diff --git a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/charts/pie_chart.rb b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/charts/pie_chart.rb new file mode 100644 index 000000000..54d5d7826 --- /dev/null +++ b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/charts/pie_chart.rb @@ -0,0 +1,24 @@ +module ForestAdminDatasourceToolkit + module Components + module Charts + class PieChart + include ForestAdminDatasourceToolkit::Validations + + attr_reader :data + + def initialize(data) + super() + @data = data + end + + def serialize + data.each do |item| + ChartValidator.validate?(!item.key?(:key) || !item.key?(:value), item, "'key', 'value'") + end + + data + end + end + end + end +end diff --git a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/charts/smart_chart.rb b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/charts/smart_chart.rb new file mode 100644 index 000000000..c81e7ddb4 --- /dev/null +++ b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/charts/smart_chart.rb @@ -0,0 +1,18 @@ +module ForestAdminDatasourceToolkit + module Components + module Charts + class SmartChart + attr_reader :data + + def initialize(data) + super() + @data = data + end + + def serialize + data + end + end + end + end +end diff --git a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/charts/value_chart.rb b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/charts/value_chart.rb new file mode 100644 index 000000000..38a6b57c4 --- /dev/null +++ b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/charts/value_chart.rb @@ -0,0 +1,19 @@ +module ForestAdminDatasourceToolkit + module Components + module Charts + class ValueChart + attr_reader :value, :previous_value + + def initialize(value, previous_value = nil) + super() + @value = value + @previous_value = previous_value + end + + def serialize + { countCurrent: value, countPrevious: previous_value } + end + end + end + end +end diff --git a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/aggregation.rb b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/aggregation.rb index 27e008591..97c35a749 100644 --- a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/aggregation.rb +++ b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/aggregation.rb @@ -1,3 +1,6 @@ +require 'active_support/all' +require 'active_support/core_ext/numeric/time' + module ForestAdminDatasourceToolkit module Components module Query @@ -17,6 +20,171 @@ def validate(operation) raise ForestException, "Aggregate operation #{operation} not allowed" end + + def projection + aggregate_fields = [] + aggregate_fields << field if field + + groups.each do |group| + aggregate_fields << group[:field] + end + + Projection.new(aggregate_fields) + end + + def replace_fields(handler) + result = clone + result.field = handler.call(result.field) if result.field + result.groups = result.groups.map do |group| + { + field: handler.call(group[:field]), + operation: group[:operation] || nil + } + end + result + end + + def override(**args) + Aggregation.new(**to_h.merge(args)) + end + + def apply(records, timezone, limit = nil) + rows = format_summaries(create_summaries(records, timezone)) + rows.sort do |r1, r2| + if r1[:value] == r2[:value] + 0 + else + r1[:value] < r2[:value] ? 1 : -1 + end + end + + rows = rows[0..limit - 1] if limit && rows.size > limit + + rows + end + + def nest(prefix = nil) + return self unless prefix + + nested_field = nil + nested_groups = [] + nested_field = "#{prefix}:#{field}" if field + + if groups.size.positive? + nested_groups = groups.map do |item| + { + field: "#{prefix}:#{item[:field]}", + operation: item[:operation] + } + end + end + + self.class.new(operation: operation, field: nested_field, groups: nested_groups) + end + + def to_h + { + operation: operation, + field: field, + groups: groups + } + end + + private + + def create_summaries(records, timezone) + grouping_map = {} + + records.each do |record| + group = create_group(record, timezone) + unique_key = Digest::SHA1.hexdigest(group.to_json) + summary = grouping_map[unique_key] || create_summary(group) + + update_summary_in_place(summary, record) + + grouping_map[unique_key] = summary + end + + grouping_map.values + end + + def format_summaries(summaries) + if operation == 'Avg' + summaries + .select { |summary| (summary['Count']).positive? } + .map do |summary| + { + group: summary['group'], + value: summary['Sum'] / summary['Count'] + } + end + else + summaries.map do |summary| + { + group: summary['group'], + value: operation == 'Count' && !field ? summary['starCount'] : summary[operation] + } + end + end + end + + def create_group(record, timezone) + group = {} + + groups.each do |value| + group_value = record[value[:field]] + group[value[:field]] = apply_date_operation(group_value, value[:operation], timezone) + end + + group + end + + def apply_date_operation(value, operation, timezone) + return value unless operation + + case operation + when 'Year' + DateTime.parse(value).in_time_zone(timezone).strftime('%Y-01-01') + when 'Month' + DateTime.parse(value).in_time_zone(timezone).strftime('%Y-%m-01') + when 'Day' + DateTime.parse(value).in_time_zone(timezone).strftime('%Y-%m-%d') + when 'Week' + DateTime.parse(value).in_time_zone(timezone).beginning_of_month.strftime('%Y-%m-%d') + else + value + end + end + + def create_summary(group) + { + 'group' => group, + 'starCount' => 0, + 'Count' => 0, + 'Sum' => 0, + 'Min' => nil, + 'Max' => nil + } + end + + def update_summary_in_place(summary, record) + summary['starCount'] += 1 + + return unless field + + value = ForestAdminDatasourceToolkit::Utils::Record.field_value(record, field) + + if value + min = summary['Min'] + max = summary['Max'] + + summary['Count'] += 1 + summary['Min'] = value if min.nil? || value < min + summary['Max'] = value if max.nil? || value < max + end + + summary['Sum'] += value if value.is_a?(Numeric) + end end end end diff --git a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/condition_tree/condition_tree_equivalent.rb b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/condition_tree/condition_tree_equivalent.rb index 86cbc36d6..e4936854d 100644 --- a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/condition_tree/condition_tree_equivalent.rb +++ b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/condition_tree/condition_tree_equivalent.rb @@ -41,18 +41,18 @@ def get_replacer(operator, filter_operators, column_type, visited = []) depends_on = alt[:depends_on] valid = alt[:for_types].nil? || alt[:for_types].include?(column_type) - if valid && !visited.include?(alt) - depends_replacers = depends_on.map do |replacement| - get_replacer(replacement, filter_operators, column_type, visited + [alt]) - end + next unless valid && !visited.include?(alt) - if depends_replacers.all? { |r| !r.nil? } - return lambda { |leaf, timezone| - replacer.call(leaf).replace_leafs do |sub_leaf| - depends_replacers[depends_on.index(sub_leaf.operator)].call(sub_leaf, timezone) - end - } - end + depends_replacers = depends_on.map do |replacement| + get_replacer(replacement, filter_operators, column_type, visited + [alt]) + end + + if depends_replacers.all? { |r| !r.nil? } + return lambda { |leaf, timezone| + replacer.call(leaf).replace_leafs do |sub_leaf| + depends_replacers[depends_on.index(sub_leaf.operator)].call(sub_leaf, timezone) + end + } end end diff --git a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/utils/collection.rb b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/utils/collection.rb index 06211f2ce..f3807f064 100644 --- a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/utils/collection.rb +++ b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/utils/collection.rb @@ -106,6 +106,23 @@ def self.get_through_target(collection, relation_name) nil end + def self.get_through_origin(collection, relation_name) + relation = collection.fields[relation_name] + raise ForestException, 'Relation must be many to many' unless relation.is_a?(ManyToManySchema) + + through_collection = collection.datasource.collection(relation.through_collection) + through_collection.fields.select do |field_name, field| + if field.is_a?(ManyToOneSchema) && + field.foreign_collection == collection.name && + field.foreign_key == relation.origin_key && + field.foreign_key_target == relation.origin_key_target + return field_name + end + end + + nil + end + def self.list_relation(collection, id, relation_name, caller, foreign_filter, projection) relation = collection.fields[relation_name] foreign_collection = collection.datasource.collection(relation.foreign_collection) diff --git a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/validations/chart_validator.rb b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/validations/chart_validator.rb new file mode 100644 index 000000000..8240bfc82 --- /dev/null +++ b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/validations/chart_validator.rb @@ -0,0 +1,15 @@ +module ForestAdminDatasourceToolkit + module Validations + class ChartValidator + def self.validate?(condition, result, key_names) + if condition + result_keys = result.keys.join(',') + raise ForestAdminDatasourceToolkit::Exceptions::ForestException, + "The result columns must be named '#{key_names}' instead of '#{result_keys}'" + end + + true + end + end + end +end diff --git a/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/charts/leaderboard_chart_spec.rb b/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/charts/leaderboard_chart_spec.rb new file mode 100644 index 000000000..d9718d489 --- /dev/null +++ b/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/charts/leaderboard_chart_spec.rb @@ -0,0 +1,18 @@ +require 'spec_helper' + +module ForestAdminDatasourceToolkit + module Components + module Charts + describe LeaderboardChart do + it 'serialize should return the correct data' do + data = [ + { key: 'key1', value: 10 }, + { key: 'key2', value: 20 } + ] + chart = described_class.new(data) + expect(chart.serialize).to eq(data) + end + end + end + end +end diff --git a/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/charts/line_chart_spec.rb b/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/charts/line_chart_spec.rb new file mode 100644 index 000000000..57bfd70e3 --- /dev/null +++ b/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/charts/line_chart_spec.rb @@ -0,0 +1,18 @@ +require 'spec_helper' + +module ForestAdminDatasourceToolkit + module Components + module Charts + describe LineChart do + it 'serialize should return the correct data' do + data = [ + { label: 'key1', values: 10 }, + { label: 'key2', values: 20 } + ] + chart = described_class.new(data) + expect(chart.serialize).to eq(data) + end + end + end + end +end diff --git a/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/charts/objective_chart_spec.rb b/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/charts/objective_chart_spec.rb new file mode 100644 index 000000000..ce874af99 --- /dev/null +++ b/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/charts/objective_chart_spec.rb @@ -0,0 +1,25 @@ +require 'spec_helper' + +module ForestAdminDatasourceToolkit + module Components + module Charts + describe ObjectiveChart do + context 'when objective is not defined' do + it 'serialize should return the correct data without objective' do + chart = described_class.new(10) + + expect(chart.serialize).to eq({ value: 10 }) + end + end + + context 'when objective is defined' do + it 'serialize should return the correct data with objective' do + chart = described_class.new(10, 20) + + expect(chart.serialize).to eq({ value: 10, objective: 20 }) + end + end + end + end + end +end diff --git a/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/charts/percentage_chart_spec.rb b/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/charts/percentage_chart_spec.rb new file mode 100644 index 000000000..85387351a --- /dev/null +++ b/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/charts/percentage_chart_spec.rb @@ -0,0 +1,13 @@ +require 'spec_helper' + +module ForestAdminDatasourceToolkit + module Components + module Charts + describe PercentageChart do + it 'serialize should return the correct data' do + expect(described_class.new(10).serialize).to eq(10) + end + end + end + end +end diff --git a/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/charts/pie_chart_spec.rb b/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/charts/pie_chart_spec.rb new file mode 100644 index 000000000..b2eb1723d --- /dev/null +++ b/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/charts/pie_chart_spec.rb @@ -0,0 +1,14 @@ +require 'spec_helper' + +module ForestAdminDatasourceToolkit + module Components + module Charts + describe PieChart do + it 'serialize should return the correct data' do + chart = described_class.new([{ key: 'key1', value: 10 }, { key: 'key2', value: 20 }]) + expect(chart.serialize).to eq([{ key: 'key1', value: 10 }, { key: 'key2', value: 20 }]) + end + end + end + end +end diff --git a/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/charts/smart_chart_spec.rb b/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/charts/smart_chart_spec.rb new file mode 100644 index 000000000..6a277834b --- /dev/null +++ b/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/charts/smart_chart_spec.rb @@ -0,0 +1,14 @@ +require 'spec_helper' + +module ForestAdminDatasourceToolkit + module Components + module Charts + describe SmartChart do + it 'serialize should return the correct data' do + chart = described_class.new([{ label: 'smart', value: 'chart' }]) + expect(chart.serialize).to eq([{ label: 'smart', value: 'chart' }]) + end + end + end + end +end diff --git a/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/charts/value_chart_spec.rb b/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/charts/value_chart_spec.rb new file mode 100644 index 000000000..4d0aa3c71 --- /dev/null +++ b/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/charts/value_chart_spec.rb @@ -0,0 +1,25 @@ +require 'spec_helper' + +module ForestAdminDatasourceToolkit + module Components + module Charts + describe ValueChart do + context 'when previous_value is not defined' do + it 'serialize should return the correct data with nil value for countPrevious' do + chart = described_class.new(10) + + expect(chart.serialize).to eq({ countCurrent: 10, countPrevious: nil }) + end + end + + context 'when previous_value is defined' do + it 'serialize should return the correct data' do + chart = described_class.new(10, 5) + + expect(chart.serialize).to eq({ countCurrent: 10, countPrevious: 5 }) + end + end + end + 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 54d0ed70a..2261d7773 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 @@ -13,6 +13,192 @@ module Query '🌳🌳🌳 Aggregate operation foo not allowed' ) end + + context 'when projection is called' do + it 'works with a null field and an empty groups' do + aggregation = described_class.new(operation: 'Count') + + expect(aggregation.projection).to eq(Projection.new([])) + end + + it 'works with a field and a groups' do + aggregation = described_class.new(operation: 'Count', field: 'aggregateField', + groups: [{ field: 'groupField' }]) + + expect(aggregation.projection).to eq(Projection.new(['aggregateField', 'groupField'])) + end + end + + context 'when override is called' do + it 'works with one arg' do + aggregation = described_class.new(operation: 'Count') + + expect(aggregation.override(operation: 'Sum')).eql?(described_class.new(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' }])) + end + end + + context 'when apply is called' do + it 'works with records, timezone and limit null' do + aggregation = described_class.new(operation: 'Count') + records = [ + { 'id' => 1 }, + { 'id' => 1 }, + { 'id' => 1 }, + { 'id' => 2 }, + { 'id' => 2 }, + { 'id' => 2 } + ] + + expect(aggregation.apply(records, 'Europe/Paris')).to eq([{ group: {}, value: 6 }]) + end + + it 'works with records, timezone and limit null on Avg operation' do + aggregation = described_class.new(operation: 'Avg', field: 'id') + records = [ + { 'id' => 1 }, + { 'id' => 2 }, + { 'id' => 3 } + ] + + expect(aggregation.apply(records, 'Europe/Paris')).to eq([{ group: {}, value: 2 }]) + end + + it 'works with group field on year' do + aggregation = described_class.new(operation: 'Avg', field: 'field', + groups: [{ field: 'groupField', operation: 'Year' }]) + records = [ + { 'field' => 5, 'groupField' => '2022-05-01' }, + { 'field' => 10, 'groupField' => '2022-05-01' }, + { 'field' => 15, 'groupField' => '2022-01-02' }, + { 'field' => 10, 'groupField' => '2023-07-11' } + ] + + expect(aggregation.apply(records, 'Europe/Paris')).to eq([ + { group: { 'groupField' => '2022-01-01' }, + value: 10 }, + { group: { 'groupField' => '2023-01-01' }, + value: 10 } + ]) + end + + it 'works with group field on month' do + aggregation = described_class.new(operation: 'Avg', field: 'field', + groups: [{ field: 'groupField', operation: 'Month' }]) + records = [ + { 'field' => 5, 'groupField' => '2022-05-01' }, + { 'field' => 5, 'groupField' => '2022-05-01' }, + { 'field' => 10, 'groupField' => '2022-01-02' }, + { 'field' => 1, 'groupField' => '2023-07-11' } + ] + + expect(aggregation.apply(records, 'Europe/Paris')).to eq([ + { group: { 'groupField' => '2022-05-01' }, + value: 5 }, + { group: { 'groupField' => '2022-01-01' }, + value: 10 }, + { group: { 'groupField' => '2023-07-01' }, + value: 1 } + ]) + end + + it 'works with group field on day' do + aggregation = described_class.new(operation: 'Avg', field: 'field', + groups: [{ field: 'groupField', operation: 'Day' }]) + records = [ + { 'field' => 5, 'groupField' => '2022-05-01' }, + { 'field' => 5, 'groupField' => '2022-05-01' }, + { 'field' => 10, 'groupField' => '2022-01-02' }, + { 'field' => 1, 'groupField' => '2023-07-11' } + ] + + expect(aggregation.apply(records, 'Europe/Paris')).to eq([ + { group: { 'groupField' => '2022-05-01' }, + value: 5 }, + { group: { 'groupField' => '2022-01-02' }, + value: 10 }, + { group: { 'groupField' => '2023-07-11' }, + value: 1 } + ]) + end + + it 'works with group field on day with limit' do + aggregation = described_class.new(operation: 'Avg', field: 'field', + groups: [{ field: 'groupField', operation: 'Day' }]) + + records = [ + { 'field' => 5, 'groupField' => '2022-05-01' }, + { 'field' => 5, 'groupField' => '2022-05-01' }, + { 'field' => 10, 'groupField' => '2022-01-02' }, + { 'field' => 1, 'groupField' => '2023-07-11' } + ] + + expect(aggregation.apply(records, 'Europe/Paris', 2)).to eq([ + { group: { 'groupField' => '2022-05-01' }, + value: 5 }, + { group: { 'groupField' => '2022-01-02' }, + value: 10 } + ]) + end + + it 'works with group field on week' do + aggregation = described_class.new(operation: 'Avg', field: 'field', + groups: [{ field: 'groupField', operation: 'Week' }]) + records = [ + { 'field' => 5, 'groupField' => '2022-05-01' }, + { 'field' => 5, 'groupField' => '2022-05-01' }, + { 'field' => 10, 'groupField' => '2022-01-02' }, + { 'field' => 1, 'groupField' => '2023-07-11' } + ] + + expect(aggregation.apply(records, 'Europe/Paris')).to eq([ + { group: { 'groupField' => '2022-05-01' }, + value: 5 }, + { group: { 'groupField' => '2022-01-01' }, + value: 10 }, + { group: { 'groupField' => '2023-07-01' }, + value: 1 } + ]) + end + end + + context 'when nest is called' do + it 'returns a new aggregation with field and group prefixed' do + aggregation = described_class.new( + operation: 'Sum', + field: 'aggregateField', + groups: [{ field: 'groupField', operation: 'Week' }] + ) + + expect(aggregation.nest('prefix')) + .eql?(described_class.new( + operation: 'Sum', + field: 'prefix:aggregateField', + groups: [{ field: 'prefix:groupField', operation: 'Week' }] + )) + end + + it 'works with null prefix' do + aggregation = described_class.new( + operation: 'Sum', + field: 'aggregateField', + groups: [{ field: 'groupField', operation: 'Week' }] + ) + + expect(aggregation.nest(nil)) + .eql?(described_class.new( + operation: 'Sum', + field: 'aggregateField', + groups: [{ field: 'groupField', operation: 'Week' }] + )) + end + end 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 72262bfe2..6f5c4e0ee 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 @@ -229,6 +229,17 @@ module Utils expect(described_class.get_through_target(collection_book, 'myPersons')).to eq('myPerson') end + it 'get_through_origin should throw with invalid relation type' do + expect do + described_class.get_through_origin(collection_book, + 'myBookPersons') + end.to raise_error(ForestException, '🌳🌳🌳 Relation must be many to many') + end + + it 'get_through_origin should work' do + expect(described_class.get_through_origin(collection_book, 'myPersons')).to eq('myBook') + end + it 'get_value should work' do allow(collection_person).to receive(:list).and_return({ 'id' => 1, 'name' => 'foo' }) diff --git a/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/validations/chart_validator_spec.rb b/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/validations/chart_validator_spec.rb new file mode 100644 index 000000000..a3c67b2d8 --- /dev/null +++ b/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/validations/chart_validator_spec.rb @@ -0,0 +1,22 @@ +require 'spec_helper' + +module ForestAdminDatasourceToolkit + module Validations + describe ChartValidator do + context 'when the condition is true' do + it 'raises an exception' do + expect do + described_class.validate?(true, { key1: 1, key2: 2 }, 'key1,key2') + end.to raise_error(ForestAdminDatasourceToolkit::Exceptions::ForestException, + "🌳🌳🌳 The result columns must be named 'key1,key2' instead of 'key1,key2'") + end + end + + context 'when the condition is false' do + it 'returns true' do + expect(described_class.validate?(false, { key1: 1, key2: 2 }, 'key1,key2')).to be true + end + end + end + end +end diff --git a/packages/forest_admin_rails/app/controllers/forest_admin_rails/forest_controller.rb b/packages/forest_admin_rails/app/controllers/forest_admin_rails/forest_controller.rb index 67a25e414..fb59ec373 100644 --- a/packages/forest_admin_rails/app/controllers/forest_admin_rails/forest_controller.rb +++ b/packages/forest_admin_rails/app/controllers/forest_admin_rails/forest_controller.rb @@ -33,19 +33,19 @@ def exception_handler(exception) { name: exception.name, detail: exception.message, - status: exception.status + status: exception.try(:status) } ] } - data[:errors][0][:data] = exception.data if exception.defined? :data + data[:errors][0][:data] = exception.try(:data) # TODO: IMPLEMENT LOGGING # if Facades::Container.cache(:is_production) # end end - render json: data, status: exception.status + render json: data, status: exception.try(:status) end end end diff --git a/yarn.lock b/yarn.lock index f8b73928e..bf9c24d21 100644 --- a/yarn.lock +++ b/yarn.lock @@ -917,11 +917,6 @@ arrify@^1.0.1: resolved "https://registry.yarnpkg.com/arrify/-/arrify-1.0.1.tgz#898508da2226f380df904728456849c1501a4b0d" integrity sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA== -at-least-node@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/at-least-node/-/at-least-node-1.0.0.tgz#602cd4b46e844ad4effc92a8011a3c46e0238dc2" - integrity sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg== - bail@^1.0.0: version "1.0.5" resolved "https://registry.yarnpkg.com/bail/-/bail-1.0.5.tgz#b6fa133404a392cbc1f8c4bf63f5953351e7a776" @@ -1313,11 +1308,6 @@ cross-spawn@^7.0.0, cross-spawn@^7.0.3: shebang-command "^2.0.0" which "^2.0.1" -crypto-random-string@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-2.0.0.tgz#ef2a7a966ec11083388369baa02ebead229b30d5" - integrity sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA== - crypto-random-string@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-4.0.0.tgz#5a3cc53d7dd86183df5da0312816ceeeb5bb1fc2" @@ -1435,13 +1425,6 @@ encoding@^0.1.13: dependencies: iconv-lite "^0.6.2" -end-of-stream@^1.1.0: - version "1.4.4" - resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0" - integrity sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q== - dependencies: - once "^1.4.0" - env-ci@^9.0.0: version "9.1.1" resolved "https://registry.yarnpkg.com/env-ci/-/env-ci-9.1.1.tgz#f081684c64a639c6ff5cb801bd70464bd40498a4" @@ -1502,21 +1485,6 @@ events@^3.3.0: resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== -execa@^4.0.2: - version "4.1.0" - resolved "https://registry.yarnpkg.com/execa/-/execa-4.1.0.tgz#4e5491ad1572f2f17a77d388c6c857135b22847a" - integrity sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA== - dependencies: - cross-spawn "^7.0.0" - get-stream "^5.0.0" - human-signals "^1.1.1" - is-stream "^2.0.0" - merge-stream "^2.0.0" - npm-run-path "^4.0.0" - onetime "^5.1.0" - signal-exit "^3.0.2" - strip-final-newline "^2.0.0" - execa@^5.0.0: version "5.1.1" resolved "https://registry.yarnpkg.com/execa/-/execa-5.1.1.tgz#f80ad9cbf4298f7bd1d4c9555c21e93741c411dd" @@ -1685,16 +1653,6 @@ fs-extra@^11.0.0: jsonfile "^6.0.1" universalify "^2.0.0" -fs-extra@^9.0.1: - version "9.1.0" - resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-9.1.0.tgz#5954460c764a8da2094ba3554bf839e6b9a7c86d" - integrity sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ== - dependencies: - at-least-node "^1.0.0" - graceful-fs "^4.2.0" - jsonfile "^6.0.1" - universalify "^2.0.0" - fs-minipass@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-2.1.0.tgz#7f5036fdbf12c63c169190cbe4199c852271f9fb" @@ -1752,13 +1710,6 @@ get-caller-file@^2.0.5: resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== -get-stream@^5.0.0: - version "5.2.0" - resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-5.2.0.tgz#4966a1795ee5ace65e706c4b7beb71257d6e22d3" - integrity sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA== - dependencies: - pump "^3.0.0" - get-stream@^6.0.0, get-stream@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-6.0.1.tgz#a262d8eef67aced57c2852ad6167526a43cbf7b7" @@ -1815,7 +1766,7 @@ glob@^10.2.2, glob@^10.2.7: minipass "^5.0.0 || ^6.0.2 || ^7.0.0" path-scurry "^1.10.1" -glob@^7.1.3, glob@^7.1.4, glob@^7.1.6: +glob@^7.1.3, glob@^7.1.4: version "7.2.3" resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== @@ -1963,11 +1914,6 @@ https-proxy-agent@^7.0.0: agent-base "^7.0.2" debug "4" -human-signals@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-1.1.1.tgz#c5b1cd14f50aeae09ab6c59fe63ba3395fe4dfa3" - integrity sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw== - human-signals@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0" @@ -3156,7 +3102,7 @@ npm-registry-fetch@^14.0.0, npm-registry-fetch@^14.0.3, npm-registry-fetch@^14.0 npm-package-arg "^10.0.0" proc-log "^3.0.0" -npm-run-path@^4.0.0, npm-run-path@^4.0.1: +npm-run-path@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-4.0.1.tgz#b7ecd1e5ed53da8e37a55e1c2269e0b97ed748ea" integrity sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw== @@ -3269,14 +3215,14 @@ npmlog@^7.0.1: gauge "^5.0.0" set-blocking "^2.0.0" -once@^1.3.0, once@^1.3.1, once@^1.4.0: +once@^1.3.0, once@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== dependencies: wrappy "1" -onetime@^5.1.0, onetime@^5.1.2: +onetime@^5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/onetime/-/onetime-5.1.2.tgz#d0e96ebb56b07476df1dd9c4806e5237985ca45e" integrity sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg== @@ -3602,14 +3548,6 @@ proto-list@~1.2.1: resolved "https://registry.yarnpkg.com/proto-list/-/proto-list-1.2.4.tgz#212d5bfe1318306a420f6402b8e26ff39647a849" integrity sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA== -pump@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.0.tgz#b4a2116815bde2f4e1ea602354e8c75565107a64" - integrity sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww== - dependencies: - end-of-stream "^1.1.0" - once "^1.3.1" - punycode@^2.1.0: version "2.3.0" resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.0.tgz#f67fa67c94da8f4d0cfff981aee4118064199b8f" @@ -3865,17 +3803,6 @@ safe-buffer@~5.2.0: resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== -semantic-release-rubygem@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/semantic-release-rubygem/-/semantic-release-rubygem-1.2.0.tgz#c127043327272786b68baafd4af777385836f6e0" - integrity sha512-w/McMlrO0cJR7UdTx47tvDJuxRNr0vBf+Xv4nZQ5lOHCbNa07ZhYNRo3EfLxNfnlnCHUCWpYFGdpiofTHldrug== - dependencies: - "@semantic-release/error" "^2.2.0" - execa "^4.0.2" - fs-extra "^9.0.1" - glob "^7.1.6" - tempy "^0.5.0" - semantic-release-slack-bot@4.0.2: version "4.0.2" resolved "https://registry.yarnpkg.com/semantic-release-slack-bot/-/semantic-release-slack-bot-4.0.2.tgz#7e4f7e04fbbf355c64fb8791df8870cdcda4dd35" @@ -3961,7 +3888,7 @@ shebang-regex@^3.0.0: resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== -signal-exit@^3.0.2, signal-exit@^3.0.3, signal-exit@^3.0.7: +signal-exit@^3.0.3, signal-exit@^3.0.7: version "3.0.7" resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== @@ -4225,26 +4152,11 @@ tar@^6.1.11, tar@^6.1.13, tar@^6.1.15, tar@^6.1.2: mkdirp "^1.0.3" yallist "^4.0.0" -temp-dir@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/temp-dir/-/temp-dir-2.0.0.tgz#bde92b05bdfeb1516e804c9c00ad45177f31321e" - integrity sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg== - temp-dir@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/temp-dir/-/temp-dir-3.0.0.tgz#7f147b42ee41234cc6ba3138cd8e8aa2302acffa" integrity sha512-nHc6S/bwIilKHNRgK/3jlhDoIHcp45YgyiwcAk46Tr0LfEqGBVpmiAyuiuxeVE44m3mXnEeVhaipLOEWmH+Njw== -tempy@^0.5.0: - version "0.5.0" - resolved "https://registry.yarnpkg.com/tempy/-/tempy-0.5.0.tgz#2785c89df39fcc4d1714fc554813225e1581d70b" - integrity sha512-VEY96x7gbIRfsxqsafy2l5yVxxp3PhwAGoWMyC2D2Zt5DmEv+2tGiPOrquNRpf21hhGnKLVEsuqleqiZmKG/qw== - dependencies: - is-stream "^2.0.0" - temp-dir "^2.0.0" - type-fest "^0.12.0" - unique-string "^2.0.0" - tempy@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/tempy/-/tempy-3.1.0.tgz#00958b6df85db8589cb595465e691852aac038e9" @@ -4355,11 +4267,6 @@ tuf-js@^1.1.7: debug "^4.3.4" make-fetch-happen "^11.1.1" -type-fest@^0.12.0: - version "0.12.0" - resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.12.0.tgz#f57a27ab81c68d136a51fd71467eff94157fa1ee" - integrity sha512-53RyidyjvkGpnWPMF9bQgFtWp+Sl8O2Rp13VavmJgfAP9WWG6q6TkrKU8iyJdnwnfgHI6k2hTlgqH4aSdjoTbg== - type-fest@^0.18.0: version "0.18.1" resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.18.1.tgz#db4bc151a4a2cf4eebf9add5db75508db6cc841f" @@ -4431,13 +4338,6 @@ unique-slug@^4.0.0: dependencies: imurmurhash "^0.1.4" -unique-string@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/unique-string/-/unique-string-2.0.0.tgz#39c6451f81afb2749de2b233e3f7c5e8843bd89d" - integrity sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg== - dependencies: - crypto-random-string "^2.0.0" - unique-string@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/unique-string/-/unique-string-3.0.0.tgz#84a1c377aff5fd7a8bc6b55d8244b2bd90d75b9a"