diff --git a/.rubocop.yml b/.rubocop.yml index 70e822775..fef3fba85 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -42,12 +42,19 @@ Metrics/AbcSize: - 'packages/forest_admin_agent/lib/forest_admin_agent/utils/query_string_parser.rb' - 'packages/forest_admin_agent/lib/forest_admin_agent/utils/schema/generator_field.rb' - 'packages/forest_admin_agent/lib/forest_admin_agent/utils/schema/schema_emitter.rb' + - 'packages/forest_admin_agent/lib/forest_admin_agent/utils/id.rb' - 'packages/forest_admin_agent/lib/forest_admin_agent/serializer/json_api_serializer.rb' + - 'packages/forest_admin_agent/lib/forest_admin_agent/http/router.rb' + - 'packages/forest_admin_agent/lib/forest_admin_agent/routes/abstract_authenticated_route.rb' + - 'packages/forest_admin_agent/lib/forest_admin_agent/routes/resources/show.rb' + - 'packages/forest_admin_agent/lib/forest_admin_agent/routes/resources/store.rb' + - 'packages/forest_admin_agent/lib/forest_admin_agent/routes/resources/update.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/utils/query.rb' - 'packages/forest_admin_datasource_active_record/lib/forest_admin_datasource_active_record/collection.rb' - 'packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/projection.rb' - 'packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/projection_factory.rb' - - + - 'packages/forest_admin_rails/app/controllers/forest_admin_rails/forest_controller.rb' Metrics/CyclomaticComplexity: Exclude: diff --git a/packages/forest_admin_agent/lib/forest_admin_agent/http/Exceptions/not_found_error.rb b/packages/forest_admin_agent/lib/forest_admin_agent/http/Exceptions/not_found_error.rb new file mode 100644 index 000000000..7e1da5633 --- /dev/null +++ b/packages/forest_admin_agent/lib/forest_admin_agent/http/Exceptions/not_found_error.rb @@ -0,0 +1,15 @@ +module ForestAdminAgent + module Http + module Exceptions + class NotFoundError < StandardError + attr_reader :name, :status + + def initialize(msg, name = 'NotFoundError') + super msg + @name = name + @status = 404 + end + end + end + end +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 ed3d016c1..b9a6739f6 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,8 +9,12 @@ def self.routes # api_charts_routes, System::HealthCheck.new.routes, Security::Authentication.new.routes, + Resources::Count.new.routes, + Resources::Delete.new.routes, Resources::List.new.routes, - Resources::Count.new.routes + Resources::Show.new.routes, + Resources::Store.new.routes, + Resources::Update.new.routes ].inject(&:merge) end diff --git a/packages/forest_admin_agent/lib/forest_admin_agent/routes/abstract_authenticated_route.rb b/packages/forest_admin_agent/lib/forest_admin_agent/routes/abstract_authenticated_route.rb index 65bce570d..559ee96bc 100644 --- a/packages/forest_admin_agent/lib/forest_admin_agent/routes/abstract_authenticated_route.rb +++ b/packages/forest_admin_agent/lib/forest_admin_agent/routes/abstract_authenticated_route.rb @@ -5,8 +5,21 @@ def build(args = {}) if args.dig(:headers, 'action_dispatch.remote_ip') Facades::Whitelist.check_ip(args[:headers]['action_dispatch.remote_ip'].to_s) end + @caller = Utils::QueryStringParser.parse_caller(args) super end + + def format_attributes(args) + record = args[:params][:data][:attributes] + + args[:params][:data][:relationships]&.map do |field, value| + schema = @collection.fields[field] + + record[schema.foreign_key] = value['data'][schema.foreign_key_target] if schema.type == 'ManyToOne' + end + + record + end end end end diff --git a/packages/forest_admin_agent/lib/forest_admin_agent/routes/resources/delete.rb b/packages/forest_admin_agent/lib/forest_admin_agent/routes/resources/delete.rb new file mode 100644 index 000000000..9a1084c49 --- /dev/null +++ b/packages/forest_admin_agent/lib/forest_admin_agent/routes/resources/delete.rb @@ -0,0 +1,45 @@ +require 'jsonapi-serializers' +require 'ostruct' + +module ForestAdminAgent + module Routes + module Resources + class Delete < AbstractAuthenticatedRoute + include ForestAdminAgent::Builder + include ForestAdminDatasourceToolkit::Components::Query + + def setup_routes + add_route('forest_delete_bulk', 'delete', '/:collection_name', ->(args) { handle_request_bulk(args) }) + add_route('forest_delete', 'delete', '/:collection_name/:id', ->(args) { handle_request(args) }) + + self + end + + def handle_request(args = {}) + build(args) + id = Utils::Id.unpack_id(@collection, args[:params]['id']) + delete_records(args, { ids: [id], are_excluded: false }) + + { content: nil, status: 204 } + end + + def handle_request_bulk(args = {}) + build(args) + selection_ids = Utils::Id.parse_selection_ids(@collection, args[:params].to_unsafe_h) + delete_records(args, selection_ids) + + { content: nil, status: 204 } + end + + def delete_records(_args, selection_ids) + # TODO: replace by ConditionTreeFactory.matchIds(this.collection.schema, selectionIds.ids) + condition_tree = OpenStruct.new(field: 'id', operator: 'IN', value: selection_ids[:ids][0]) + condition_tree.inverse if selection_ids[:are_excluded] + filter = ForestAdminDatasourceToolkit::Components::Query::Filter.new(condition_tree: condition_tree) + + @collection.delete(@caller, filter) + end + end + end + end +end diff --git a/packages/forest_admin_agent/lib/forest_admin_agent/routes/resources/show.rb b/packages/forest_admin_agent/lib/forest_admin_agent/routes/resources/show.rb new file mode 100644 index 000000000..c41db08d1 --- /dev/null +++ b/packages/forest_admin_agent/lib/forest_admin_agent/routes/resources/show.rb @@ -0,0 +1,45 @@ +require 'jsonapi-serializers' +require 'ostruct' + +module ForestAdminAgent + module Routes + module Resources + class Show < AbstractAuthenticatedRoute + include ForestAdminAgent::Builder + include ForestAdminDatasourceToolkit::Components::Query + def setup_routes + add_route('forest_show', 'get', '/:collection_name/:id', ->(args) { handle_request(args) }) + + self + end + + def handle_request(args = {}) + build(args) + id = Utils::Id.unpack_id(@collection, args[:params]['id'], with_key: true) + caller = ForestAdminAgent::Utils::QueryStringParser.parse_caller(args) + condition_tree = OpenStruct.new(field: 'id', operator: 'EQUAL', value: id['id']) + # TODO: replace condition_tree by ConditionTreeFactory.matchIds(this.collection.schema, [id]), + filter = ForestAdminDatasourceToolkit::Components::Query::Filter.new( + condition_tree: condition_tree, + page: ForestAdminAgent::Utils::QueryStringParser.parse_pagination(args) + ) + projection = ProjectionFactory.all(@collection) + + records = @collection.list(caller, filter, projection) + + raise Http::Exceptions::NotFoundError, 'Record does not exists' unless records.size.positive? + + { + name: args[:params]['collection_name'], + content: JSONAPI::Serializer.serialize( + records[0], + is_collection: false, + serializer: Serializer::ForestSerializer, + include: projection.relations.keys + ) + } + 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 new file mode 100644 index 000000000..2925bab3d --- /dev/null +++ b/packages/forest_admin_agent/lib/forest_admin_agent/routes/resources/store.rb @@ -0,0 +1,54 @@ +require 'jsonapi-serializers' +require 'ostruct' + +module ForestAdminAgent + module Routes + module Resources + class Store < AbstractAuthenticatedRoute + include ForestAdminAgent::Builder + def setup_routes + add_route('forest_store', 'post', '/:collection_name', ->(args) { handle_request(args) }) + + self + end + + def handle_request(args = {}) + build(args) + data = format_attributes(args) + record = @collection.create(@caller, data) + link_one_to_one_relations(args, record) + + { + name: args[:params]['collection_name'], + content: JSONAPI::Serializer.serialize( + record, + is_collection: false, + serializer: Serializer::ForestSerializer + ) + } + end + + def link_one_to_one_relations(args, record) + relations = {} + + 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) + relations[field] = id + 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). + # TODO: replace by ConditionTreeFactory.matchRecords(foreignCollection.schema, [linked]); + condition_tree = OpenStruct.new(field: 'id', operator: 'EQUAL', value: id['id']) + filter = ForestAdminDatasourceToolkit::Components::Query::Filter.new(condition_tree: condition_tree) + foreign_collection.update(@caller, filter, { schema.origin_key => origin_value }) + end + end + 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 new file mode 100644 index 000000000..e48396c38 --- /dev/null +++ b/packages/forest_admin_agent/lib/forest_admin_agent/routes/resources/update.rb @@ -0,0 +1,43 @@ +require 'jsonapi-serializers' +require 'ostruct' + +module ForestAdminAgent + module Routes + module Resources + class Update < AbstractAuthenticatedRoute + include ForestAdminAgent::Builder + include ForestAdminDatasourceToolkit::Components::Query + + def setup_routes + add_route('forest_update', 'put', '/:collection_name/:id', ->(args) { handle_request(args) }) + + self + end + + def handle_request(args = {}) + build(args) + id = Utils::Id.unpack_id(@collection, args[:params]['id'], with_key: true) + caller = ForestAdminAgent::Utils::QueryStringParser.parse_caller(args) + condition_tree = OpenStruct.new(field: 'id', operator: 'EQUAL', value: id['id']) + # TODO: replace condition_tree by ConditionTreeFactory.matchIds(this.collection.schema, [id]), + filter = ForestAdminDatasourceToolkit::Components::Query::Filter.new( + condition_tree: condition_tree, + page: ForestAdminAgent::Utils::QueryStringParser.parse_pagination(args) + ) + data = format_attributes(args) + @collection.update(@caller, filter, data) + records = @collection.list(caller, filter, ProjectionFactory.all(@collection)) + + { + name: args[:params]['collection_name'], + content: JSONAPI::Serializer.serialize( + records[0], + is_collection: false, + serializer: Serializer::ForestSerializer + ) + } + end + end + end + end +end diff --git a/packages/forest_admin_agent/lib/forest_admin_agent/serializer/forest_serializer.rb b/packages/forest_admin_agent/lib/forest_admin_agent/serializer/forest_serializer.rb index 54013e7d2..2dc7b626e 100644 --- a/packages/forest_admin_agent/lib/forest_admin_agent/serializer/forest_serializer.rb +++ b/packages/forest_admin_agent/lib/forest_admin_agent/serializer/forest_serializer.rb @@ -47,6 +47,7 @@ def attributes return {} if attributes_map.nil? attributes = {} + attributes_map.each do |attribute_name, attr_data| next if !should_include_attr?(attribute_name, attr_data) value = evaluate_attr_or_block(attribute_name, attr_data[:attr_or_block]) @@ -55,6 +56,16 @@ def attributes attributes end + def evaluate_attr_or_block(attribute_name, attr_or_block) + if attr_or_block.is_a?(Proc) + # A custom block was given, call it to get the value. + instance_eval(&attr_or_block) + else + # Default behavior, call a method by the name of the attribute. + object.try(attr_or_block) + end + end + def add_to_one_association(name, options = {}, &block) options[:include_links] = options.fetch(:include_links, true) options[:include_data] = options.fetch(:include_data, false) diff --git a/packages/forest_admin_agent/lib/forest_admin_agent/utils/id.rb b/packages/forest_admin_agent/lib/forest_admin_agent/utils/id.rb new file mode 100644 index 000000000..258a2c084 --- /dev/null +++ b/packages/forest_admin_agent/lib/forest_admin_agent/utils/id.rb @@ -0,0 +1,39 @@ +module ForestAdminAgent + module Utils + class Id + include ForestAdminDatasourceToolkit::Utils + include ForestAdminDatasourceToolkit + def self.unpack_id(collection, packed_id, with_key: false) + primary_keys = ForestAdminDatasourceToolkit::Utils::Schema.primary_keys(collection) + primary_key_values = packed_id.to_s.split('|') + if (nb_pks = primary_keys.size) != (nb_values = primary_key_values.size) + raise Exceptions::ForestException, "Expected #{nb_pks} primary keys, found #{nb_values}" + end + + result = primary_keys.map.with_index do |pk_name, index| + field = collection.fields[pk_name] + value = primary_key_values[index] + casted_value = field.column_type == 'Number' ? value.to_i : value + # TODO: call FieldValidator::validateValue($value, $field, $castedValue); + + [pk_name, casted_value] + end.to_h + + with_key ? result : result.values + end + + def self.unpack_ids(collection, packed_ids) + packed_ids.map { |item| unpack_id(collection, item) } + end + + def self.parse_selection_ids(collection, params) + attributes = params.dig('data', 'attributes') + are_excluded = attributes&.key?('all_records') ? attributes['all_records'] : false + input_ids = attributes&.key?('ids') ? attributes['ids'] : params['data'].map { |item| item['id'] } + ids = unpack_ids(collection, are_excluded ? attributes['all_records_ids_excluded'] : input_ids) + + { are_excluded: are_excluded, ids: ids } + end + end + end +end diff --git a/packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/resources/delete_spec.rb b/packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/resources/delete_spec.rb new file mode 100644 index 000000000..4cfd266f8 --- /dev/null +++ b/packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/resources/delete_spec.rb @@ -0,0 +1,33 @@ +require 'spec_helper' +require 'singleton' +require 'ostruct' +require 'shared/caller' + +module ForestAdminAgent + module Routes + module Resources + include ForestAdminDatasourceToolkit + include ForestAdminDatasourceToolkit::Schema + describe Delete do + include_context 'with caller' + subject(:delete) { described_class.new } + let(:args) do + { + headers: { 'HTTP_AUTHORIZATION' => bearer }, + params: { + 'collection_name' => 'book', + 'timezone' => 'Europe/Paris' + } + } + end + + it 'adds the route forest_store' do + delete.setup_routes + expect(delete.routes.include?('forest_delete')).to be true + expect(delete.routes.include?('forest_delete_bulk')).to be true + expect(delete.routes.length).to eq 2 + 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 ce1a4ef6d..77db68924 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 @@ -22,11 +22,7 @@ module Resources end before do - user_class = Struct.new(:id, :first_name, :last_name) do - def name - 'user' - end - end + user_class = Struct.new(:id, :first_name, :last_name) stub_const('User', user_class) datasource = Datasource.new diff --git a/packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/resources/show_spec.rb b/packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/resources/show_spec.rb new file mode 100644 index 000000000..c573482b5 --- /dev/null +++ b/packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/resources/show_spec.rb @@ -0,0 +1,85 @@ +require 'spec_helper' +require 'singleton' +require 'ostruct' +require 'shared/caller' + +module ForestAdminAgent + module Routes + module Resources + include ForestAdminDatasourceToolkit + include ForestAdminDatasourceToolkit::Schema + describe Show do + include_context 'with caller' + subject(:show) { described_class.new } + let(:args) do + { + headers: { 'HTTP_AUTHORIZATION' => bearer }, + params: { + 'collection_name' => 'user', + 'timezone' => 'Europe/Paris' + } + } + end + + let(:datasource) do + user_class = Struct.new(:id, :first_name, :last_name) do + def respond_to?(arg) + return false if arg == :each + + super arg + end + end + stub_const('User', user_class) + + datasource = Datasource.new + collection = Collection.new(datasource, 'user') + collection.add_fields( + { + 'id' => ColumnSchema.new(column_type: 'Number', is_primary_key: true), + 'first_name' => ColumnSchema.new(column_type: 'String'), + 'last_name' => ColumnSchema.new(column_type: 'String') + } + ) + allow(ForestAdminAgent::Builder::AgentFactory.instance).to receive(:send_schema).and_return(nil) + datasource.add_collection(collection) + ForestAdminAgent::Builder::AgentFactory.instance.add_datasource(datasource) + ForestAdminAgent::Builder::AgentFactory.instance.build + + datasource + end + + it 'adds the route forest_list' do + show.setup_routes + expect(show.routes.include?('forest_show')).to be true + expect(show.routes.length).to eq 1 + end + + it 'return an serialized content' do + allow(datasource.collection('user')).to receive(:list).and_return( + [ + User.new(1, 'foo', 'foo') + ] + ) + args[:params]['id'] = 1 + + result = show.handle_request(args) + + expect(result[:name]).to eq('user') + expect(result[:content]).to eq( + 'data' => { + 'type' => 'user', + 'id' => '1', + 'attributes' => { + 'id' => 1, + 'first_name' => 'foo', + 'last_name' => 'foo' + }, + 'links' => { 'self' => 'forest/user/1' } + }, + 'included' => [] + ) + end + end + end + end +end diff --git a/packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/resources/store_spec.rb b/packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/resources/store_spec.rb new file mode 100644 index 000000000..11b9f0a38 --- /dev/null +++ b/packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/resources/store_spec.rb @@ -0,0 +1,211 @@ +require 'spec_helper' +require 'singleton' +require 'ostruct' +require 'shared/caller' + +module ForestAdminAgent + module Routes + module Resources + include ForestAdminDatasourceToolkit + include ForestAdminDatasourceToolkit::Schema + describe Store do + include_context 'with caller' + subject(:store) { described_class.new } + let(:args) do + { + headers: { 'HTTP_AUTHORIZATION' => bearer }, + params: { + 'collection_name' => 'book', + 'timezone' => 'Europe/Paris' + } + } + end + + it 'adds the route forest_store' do + store.setup_routes + expect(store.routes.include?('forest_store')).to be true + expect(store.routes.length).to eq 1 + end + + describe 'simple case' do + before do + book_class = Struct.new(:id, :title, :published_at, :price) do + # def name + # 'book' + # end + + def respond_to?(arg) + return false if arg == :each + + super arg + end + end + stub_const('Book', book_class) + end + + it 'call create and return an serialized content' do + attributes = { + 'title' => 'Harry potter and the goblet of fire', + 'published_at' => '2000-07-07T21:00:00.000Z', + 'price' => 6.75 + } + + datasource = Datasource.new + collection = Collection.new(datasource, 'book') + collection.add_fields( + { + 'id' => ColumnSchema.new(column_type: 'Number', is_primary_key: true), + 'title' => ColumnSchema.new(column_type: 'String'), + 'published_at' => ColumnSchema.new(column_type: 'Date'), + 'price' => ColumnSchema.new(column_type: 'Number') + } + ) + allow(ForestAdminAgent::Builder::AgentFactory.instance).to receive(:send_schema).and_return(nil) + datasource.add_collection(collection) + ForestAdminAgent::Builder::AgentFactory.instance.add_datasource(datasource) + ForestAdminAgent::Builder::AgentFactory.instance.build + args[:params][:data] = { attributes: attributes, type: 'books' } + book = Book.new(1, attributes['title'], attributes['published_at'], attributes['price']) + + allow(collection).to receive(:create).and_return(book) + result = store.handle_request(args) + expect(result[:name]).to eq('book') + expect(result[:content]).to eq( + 'data' => + { + 'type' => 'book', + 'id' => '1', + 'attributes' => { + 'id' => 1, + 'title' => 'Harry potter and the goblet of fire', + 'published_at' => '2000-07-07T21:00:00.000Z', + 'price' => 6.75 + }, + 'links' => { 'self' => 'forest/book/1' } + } + ) + end + end + + describe 'with relation' do + before do + person_class = Struct.new(:id, :name) do + def respond_to?(arg) + return false if arg == :each + + super arg + end + end + + passport_class = Struct.new(:id, :person_id) do + def respond_to?(arg) + return false if arg == :each + + super arg + end + end + stub_const('Person', person_class) + stub_const('Passport', passport_class) + + @datasource = Datasource.new + collection_person = Collection.new(@datasource, 'person') + collection_person.add_fields( + { + 'id' => ColumnSchema.new(column_type: 'Number', is_primary_key: true), + 'name' => ColumnSchema.new(column_type: 'String'), + 'passport' => Relations::OneToOneSchema.new( + origin_key: 'person_id', + origin_key_target: 'id', + foreign_collection: 'passport' + ) + } + ) + + collection_passport = Collection.new(@datasource, 'passport') + collection_passport.add_fields( + { + 'id' => ColumnSchema.new(column_type: 'Number', is_primary_key: true), + 'person_id' => ColumnSchema.new(column_type: 'Number'), + 'person' => Relations::ManyToOneSchema.new( + foreign_key: 'person_id', + foreign_key_target: 'id', + foreign_collection: 'passport' + ) + } + ) + @datasource.add_collection(collection_person) + @datasource.add_collection(collection_passport) + allow(ForestAdminAgent::Builder::AgentFactory.instance).to receive(:send_schema).and_return(nil) + ForestAdminAgent::Builder::AgentFactory.instance.add_datasource(@datasource) + ForestAdminAgent::Builder::AgentFactory.instance.build + end + + describe 'with one to one relation' do + it 'call create and return an serialized content' do + args[:params][:data] = { + attributes: { 'name' => 'john' }, + relationships: { 'passport' => { 'data' => { 'type' => 'passports', 'id' => 1 } } }, + type: 'persons' + } + args[:params]['collection_name'] = 'person' + + allow(@datasource.collection('person')).to receive(:create).and_return(Person.new(1, 'john')) + allow(@datasource.collection('passport')).to receive(:update).and_return(Passport.new(1, 1)) + result = store.handle_request(args) + expect(result[:name]).to eq('person') + expect(result[:content]).to eq( + 'data' => + { + 'type' => 'person', + 'id' => '1', + 'attributes' => { 'id' => 1, 'name' => 'john' }, + 'links' => { 'self' => 'forest/person/1' }, + 'relationships' => { + 'passport' => { + 'data' => nil, + 'links' => { 'related' => { 'href' => 'forest/person/1/relationships/passport' } } + } + } + } + ) + end + end + + describe 'with many to one relation' do + it 'call create and return an serialized content' do + args[:params][:data] = { + attributes: {}, + relationships: { 'person' => { 'data' => { 'type' => 'person', 'id' => 1 } } }, + type: 'persons' + } + args[:params]['collection_name'] = 'passport' + + allow(@datasource.collection('passport')).to receive(:create).and_return(Passport.new(1, 1)) + result = store.handle_request(args) + expect(result[:name]).to eq('passport') + expect(result[:content]).to eq( + 'data' => + { + 'type' => 'passport', + 'id' => '1', + 'attributes' => { 'id' => 1, 'person_id' => 1 }, + 'links' => { 'self' => 'forest/passport/1' }, + 'relationships' => { + 'person' => { + 'data' => nil, + 'links' => { + 'related' => { + 'href' => 'forest/passport/1/relationships/person' + } + } + } + } + } + ) + end + end + end + end + end + end +end diff --git a/packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/resources/update_spec.rb b/packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/resources/update_spec.rb new file mode 100644 index 000000000..3fd8daee5 --- /dev/null +++ b/packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/resources/update_spec.rb @@ -0,0 +1,79 @@ +require 'spec_helper' +require 'singleton' +require 'ostruct' +require 'shared/caller' + +module ForestAdminAgent + module Routes + module Resources + include ForestAdminDatasourceToolkit + include ForestAdminDatasourceToolkit::Schema + describe Update do + include_context 'with caller' + subject(:update) { described_class.new } + let(:args) do + { + headers: { 'HTTP_AUTHORIZATION' => bearer }, + params: { + 'collection_name' => 'book', + 'timezone' => 'Europe/Paris' + } + } + end + + it 'adds the route forest_store' do + update.setup_routes + expect(update.routes.include?('forest_update')).to be true + expect(update.routes.length).to eq 1 + end + + describe 'handle_request' do + before do + book_class = Struct.new(:id, :title, :published_at, :price) do + def respond_to?(arg) + return false if arg == :each + + super arg + end + end + stub_const('Book', book_class) + end + + it 'call update and return an serialized content' do + datasource = Datasource.new + collection = Collection.new(datasource, 'book') + collection.add_fields( + { + 'id' => ColumnSchema.new(column_type: 'Number', is_primary_key: true), + 'title' => ColumnSchema.new(column_type: 'String') + } + ) + allow(ForestAdminAgent::Builder::AgentFactory.instance).to receive(:send_schema).and_return(nil) + datasource.add_collection(collection) + ForestAdminAgent::Builder::AgentFactory.instance.add_datasource(datasource) + ForestAdminAgent::Builder::AgentFactory.instance.build + + args[:params][:data] = { attributes: { 'title' => 'Harry potter and the goblet of fire' } } + args[:params]['id'] = '1' + book = Book.new(1, 'Harry potter and the goblet of fire') + allow(collection).to receive_messages(list: [book], update: true) + result = update.handle_request(args) + expect(result[:name]).to eq('book') + expect(result[:content]).to eq( + 'data' => + { + 'type' => 'book', + 'id' => '1', + 'attributes' => { + 'id' => 1, + 'title' => 'Harry potter and the goblet of fire' + }, + 'links' => { 'self' => 'forest/book/1' } + } + ) + end + end + end + end + end +end diff --git a/packages/forest_admin_agent/spec/lib/forest_admin_agent/utils/id_spec.rb b/packages/forest_admin_agent/spec/lib/forest_admin_agent/utils/id_spec.rb new file mode 100644 index 000000000..5dc3449af --- /dev/null +++ b/packages/forest_admin_agent/spec/lib/forest_admin_agent/utils/id_spec.rb @@ -0,0 +1,146 @@ +require 'spec_helper' +require 'shared/caller' + +module ForestAdminAgent + module Utils + include ForestAdminDatasourceToolkit + include ForestAdminDatasourceToolkit::Schema + + describe Id do + let(:datasource) do + datasource = Datasource.new + collection_person = Collection.new(datasource, 'person') + collection_person.add_fields( + { + 'id' => ColumnSchema.new(column_type: 'Number', is_primary_key: true), + 'first_name' => ColumnSchema.new(column_type: 'String'), + 'last_name' => ColumnSchema.new(column_type: 'String') + } + ) + + collection_pks = Collection.new(datasource, 'pks') + collection_pks.add_fields( + { + 'key1' => ColumnSchema.new(column_type: 'Number', is_primary_key: true), + 'key2' => ColumnSchema.new(column_type: 'Number', is_primary_key: true) + } + ) + + collection_foo = Collection.new(datasource, 'foo') + collection_foo.add_fields( + { + 'name' => ColumnSchema.new(column_type: 'String') + } + ) + + datasource.add_collection(collection_person) + datasource.add_collection(collection_pks) + datasource.add_collection(collection_foo) + + datasource + end + + describe 'unpack_id' do + context 'when collection has one pk' do + it 'return the list of id value' do + collection = datasource.collection('person') + expect(described_class.unpack_id(collection, 1)).to eq([1]) + end + + it 'raise when not expected number of pks is unpack' do + collection = datasource.collection('person') + + expect do + described_class.unpack_id(collection, '1|foo') + end.to raise_error( + ForestAdminDatasourceToolkit::Exceptions::ForestException, + '🌳🌳🌳 Expected 1 primary keys, found 2' + ) + end + end + + context 'when collection has multiple pks' do + it 'return the list of id value' do + collection = datasource.collection('pks') + expect(described_class.unpack_id(collection, '1|1')).to eq([1, 1]) + end + + it 'raise when not expected number of pks is unpack' do + collection = datasource.collection('pks') + + expect do + described_class.unpack_id(collection, '1') + end.to raise_error( + ForestAdminDatasourceToolkit::Exceptions::ForestException, + '🌳🌳🌳 Expected 2 primary keys, found 1' + ) + end + end + end + + describe 'unpack_ids' do + it 'return an array of list id values' do + collection = datasource.collection('pks') + expect(described_class.unpack_ids(collection, ['1|1'])).to eq([[1, 1]]) + end + end + + describe 'parse_selection_ids' do + it 'return a hash with excluded_ids' do + collection = datasource.collection('person') + args = { + 'data' => { + 'attributes' => { + 'ids' => %w[1 2 3], + 'collection_name' => 'User', + 'parent_collection_name' => nil, + 'parent_collection_id' => nil, + 'parent_association_name' => nil, + 'all_records' => true, + 'all_records_subset_query' => [ + 'fields[Car]' => 'id,first_name,last_name', + 'page[number]' => 1, + 'page[size]' => 15 + ], + 'all_records_ids_excluded' => ['4'], + 'smart_action_id' => nil + } + } + } + + expect(described_class.parse_selection_ids(collection, args)).to eq({ are_excluded: true, ids: [[4]] }) + end + + it 'return a hash with ids' do + collection = datasource.collection('person') + args = { + 'data' => { + 'attributes' => { + 'ids' => %w[1 2 3], + 'collection_name' => 'User', + 'parent_collection_name' => nil, + 'parent_collection_id' => nil, + 'parent_association_name' => nil, + 'all_records' => false, + 'all_records_subset_query' => [ + 'fields[Car]' => 'id,first_name,last_name', + 'page[number]' => 1, + 'page[size]' => 15 + ], + 'all_records_ids_excluded' => ['4'], + 'smart_action_id' => nil + } + } + } + + expect(described_class.parse_selection_ids(collection, args)).to eq( + { + are_excluded: false, + ids: [[1], [2], [3]] + } + ) + end + end + end + end +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 a8135358e..d408e46a6 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 @@ -8,17 +8,15 @@ class Collection < ForestAdminDatasourceToolkit::Collection def initialize(datasource, model) @model = model - name = model.name.split('::').last.downcase + name = model.name.demodulize.underscore super(datasource, name) fetch_fields fetch_associations end def list(_caller, filter, projection) - query_joins(projection) - query_select(projection) - - @model.offset(filter.page.offset).limit(filter.page.limit).all + query = Utils::Query.new(self, projection, filter).build + query.offset(filter.page.offset).limit(filter.page.limit).all end def aggregate(_caller, _filter, aggregation) @@ -32,23 +30,22 @@ def aggregate(_caller, _filter, aggregation) ] end - private - - def query_joins(projection) - @model.joins(projection.relations.keys.map(&:to_sym)) + def create(_caller, data) + @model.create(data) end - def query_select(projection) - query = projection.columns.join(', ') - - projection.relations.each do |relation, fields| - relation_table = datasource.collection(relation).model.table_name - fields.each { |field| query += ", #{relation_table}.#{field}" } - end + def update(_caller, filter, data) + entity = Utils::Query.new(self, nil, filter).build.first + entity.update(data) + end - @model.select(query) + def delete(_caller, filter) + entities = Utils::Query.new(self, nil, filter).build + entities&.each(&:destroy) end + private + def fetch_fields @model.columns_hash.each do |column_name, column| # TODO: check is not sti column @@ -75,18 +72,18 @@ def fetch_associations add_field( association.name.to_s, ForestAdminDatasourceToolkit::Schema::Relations::OneToOneSchema.new( - foreign_collection: association.class_name.downcase, + foreign_collection: association.class_name.demodulize.underscore, origin_key: association.foreign_key, - origin_key_target: association.join_foreign_key + origin_key_target: association.association_primary_key ) ) when :belongs_to add_field( association.name.to_s, ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema.new( - foreign_collection: association.class_name.downcase, + foreign_collection: association.class_name.demodulize.underscore, foreign_key: association.foreign_key, - foreign_key_target: association.join_foreign_key + foreign_key_target: association.association_primary_key ) ) when :has_many @@ -94,21 +91,21 @@ def fetch_associations add_field( association.name.to_s, ForestAdminDatasourceToolkit::Schema::Relations::ManyToManySchema.new( - foreign_collection: association.class_name.downcase, + foreign_collection: association.class_name.demodulize.underscore, origin_key: association.through_reflection.join_foreign_key, origin_key_target: association.through_reflection.foreign_key, foreign_key: association.join_foreign_key, foreign_key_target: association.association_primary_key, - through_collection: association.through_reflection.class_name.downcase + through_collection: association.through_reflection.class_name.demodulize.underscore ) ) else add_field( association.name.to_s, ForestAdminDatasourceToolkit::Schema::Relations::OneToManySchema.new( - foreign_collection: association.class_name.downcase, + foreign_collection: association.class_name.demodulize.underscore, origin_key: association.foreign_key, - origin_key_target: association.join_foreign_key + origin_key_target: association.association_primary_key ) ) end 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 new file mode 100644 index 000000000..fa258f2c7 --- /dev/null +++ b/packages/forest_admin_datasource_active_record/lib/forest_admin_datasource_active_record/utils/query.rb @@ -0,0 +1,66 @@ +module ForestAdminDatasourceActiveRecord + module Utils + class Query + def initialize(collection, projection, filter) + @collection = collection + @query = @collection.model + @projection = projection + @filter = filter + end + + def build + @query = select + @query = apply_filter + + @query + end + + def apply_filter + @query = apply_condition_tree(@filter.condition_tree) unless @filter.condition_tree.nil? + + @query + end + + def apply_condition_tree(condition_tree, aggregator = nil) + # if condition_tree.is_a ConditionTreeBranch + # TODO: add for ConditionTreeBranch + # else + compute_main_operator(condition_tree, aggregator || 'and') + # end + end + + def compute_main_operator(condition_tree, aggregator) + field = condition_tree.field + value = condition_tree.value + case condition_tree.operator + when 'EQUAL', 'IN' + @query = @query.send(aggregator, @query.where({ field => value })) + end + + @query + end + + def select + unless @projection.nil? + query_select = @projection.columns.map { |field| "#{@collection.model.table_name}.#{field}" }.join(', ') + + @projection.relations.each do |relation, _fields| + relation_schema = @collection.fields[relation] + query_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) + @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 + end + end +end diff --git a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/contracts/collection_contract.rb b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/contracts/collection_contract.rb index ba79cb9ec..6a0c61fd8 100644 --- a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/contracts/collection_contract.rb +++ b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/contracts/collection_contract.rb @@ -22,7 +22,7 @@ def form raise NotImplementedError, "#{self.class} has not implemented method '#{__method__}'" end - def create + def create(caller, data) raise NotImplementedError, "#{self.class} has not implemented method '#{__method__}'" end @@ -30,11 +30,11 @@ def list(caller, filter, projection) raise NotImplementedError, "#{self.class} has not implemented method '#{__method__}'" end - def update + def update(caller, filter, data) raise NotImplementedError, "#{self.class} has not implemented method '#{__method__}'" end - def delete + def delete(caller, filter) raise NotImplementedError, "#{self.class} has not implemented method '#{__method__}'" end diff --git a/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/contracts/collection_contract_spec.rb b/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/contracts/collection_contract_spec.rb index 083cd6b44..d11775143 100644 --- a/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/contracts/collection_contract_spec.rb +++ b/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/contracts/collection_contract_spec.rb @@ -27,10 +27,10 @@ module Contracts it { expect { collection.name }.to raise_error(NotImplementedError) } it { expect { collection.execute }.to raise_error(NotImplementedError) } it { expect { collection.form }.to raise_error(NotImplementedError) } - it { expect { collection.create }.to raise_error(NotImplementedError) } + it { expect { collection.create(caller, {}) }.to raise_error(NotImplementedError) } it { expect { collection.list(caller, Filter.new, Projection.new) }.to raise_error(NotImplementedError) } - it { expect { collection.update }.to raise_error(NotImplementedError) } - it { expect { collection.delete }.to raise_error(NotImplementedError) } + it { expect { collection.update(caller, Filter.new, {}) }.to raise_error(NotImplementedError) } + it { expect { collection.delete(caller, Filter.new) }.to raise_error(NotImplementedError) } it { expect { collection.render_chart }.to raise_error(NotImplementedError) } it { 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 6aa97e5c7..853e05bf1 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 @@ -6,7 +6,7 @@ def index if ForestAdminAgent::Http::Router.routes.key? params['route_alias'] route = ForestAdminAgent::Http::Router.routes[params['route_alias']] - forest_response route[:closure].call({ params: params, headers: request.headers.to_h }) + forest_response route[:closure].call({ params: params.to_unsafe_h, headers: request.headers.to_h }) else render json: { error: 'Route not found' }, status: 404 end