diff --git a/Neo4j.Driver/.editorconfig b/Neo4j.Driver/.editorconfig index ea2bc1af6..8a0505924 100644 --- a/Neo4j.Driver/.editorconfig +++ b/Neo4j.Driver/.editorconfig @@ -369,11 +369,11 @@ dotnet_naming_symbols.types_and_namespaces_symbols.applicable_kinds = namespace, dotnet_naming_symbols.type_parameters_symbols.applicable_accessibilities = * dotnet_naming_symbols.type_parameters_symbols.applicable_kinds = type_parameter dotnet_naming_symbols.unity_serialized_field_symbols.applicable_accessibilities = * -dotnet_naming_symbols.unity_serialized_field_symbols.applicable_kinds = +dotnet_naming_symbols.unity_serialized_field_symbols.applicable_kinds = dotnet_naming_symbols.unity_serialized_field_symbols.resharper_applicable_kinds = unity_serialised_field dotnet_naming_symbols.unity_serialized_field_symbols.resharper_required_modifiers = instance dotnet_naming_symbols.unity_serialized_field_symbols_1.applicable_accessibilities = * -dotnet_naming_symbols.unity_serialized_field_symbols_1.applicable_kinds = +dotnet_naming_symbols.unity_serialized_field_symbols_1.applicable_kinds = dotnet_naming_symbols.unity_serialized_field_symbols_1.resharper_applicable_kinds = unity_serialised_field dotnet_naming_symbols.unity_serialized_field_symbols_1.resharper_required_modifiers = instance dotnet_separate_import_directive_groups = false @@ -663,8 +663,8 @@ resharper_line_break_before_requires_clause = do_not_change resharper_linkage_specification_braces = end_of_line resharper_linkage_specification_indentation = none resharper_local_function_body = block_body -resharper_macro_block_begin = -resharper_macro_block_end = +resharper_macro_block_begin = +resharper_macro_block_end = resharper_max_array_initializer_elements_on_line = 10000 resharper_max_attribute_length_for_same_line = 38 resharper_max_enum_members_on_line = 1 @@ -698,7 +698,7 @@ resharper_parentheses_non_obvious_operations = none, shift, bitwise_and, bitwise resharper_parentheses_redundancy_style = remove_if_not_clarifies_precedence resharper_parentheses_same_type_operations = false resharper_pi_attributes_indent = align_by_first_attribute -resharper_place_accessorholder_attribute_on_same_line = if_owner_is_single_line +resharper_place_accessorholder_attribute_on_same_line = false resharper_place_accessor_attribute_on_same_line = if_owner_is_single_line resharper_place_comments_at_first_column = false resharper_place_constructor_initializer_on_same_line = true @@ -734,7 +734,7 @@ resharper_resx_attribute_indent = single_indent resharper_resx_blank_line_after_pi = true resharper_resx_indent_text = OneIndent resharper_resx_keep_user_linebreaks = true -resharper_resx_linebreak_before_elements = +resharper_resx_linebreak_before_elements = resharper_resx_max_blank_lines_between_tags = 0 resharper_resx_pi_attribute_style = do_not_touch resharper_resx_space_before_self_closing = false @@ -963,7 +963,7 @@ resharper_xml_attribute_indent = align_by_first_attribute resharper_xml_blank_line_after_pi = true resharper_xml_indent_text = OneIndent resharper_xml_keep_user_linebreaks = true -resharper_xml_linebreak_before_elements = +resharper_xml_linebreak_before_elements = resharper_xml_max_blank_lines_between_tags = 2 resharper_xml_pi_attribute_style = do_not_touch resharper_xml_space_before_self_closing = true diff --git a/Neo4j.Driver/Neo4j.Driver.Tests/.editorconfig b/Neo4j.Driver/Neo4j.Driver.Tests/.editorconfig index 3224c02cb..66268474e 100644 --- a/Neo4j.Driver/Neo4j.Driver.Tests/.editorconfig +++ b/Neo4j.Driver/Neo4j.Driver.Tests/.editorconfig @@ -1,4 +1,4 @@ -[*] +[*] charset = utf-8-bom end_of_line = crlf indent_size = 4 @@ -369,11 +369,11 @@ dotnet_naming_symbols.types_and_namespaces_symbols.applicable_kinds = namespace, dotnet_naming_symbols.type_parameters_symbols.applicable_accessibilities = * dotnet_naming_symbols.type_parameters_symbols.applicable_kinds = type_parameter dotnet_naming_symbols.unity_serialized_field_symbols.applicable_accessibilities = * -dotnet_naming_symbols.unity_serialized_field_symbols.applicable_kinds = +dotnet_naming_symbols.unity_serialized_field_symbols.applicable_kinds = dotnet_naming_symbols.unity_serialized_field_symbols.resharper_applicable_kinds = unity_serialised_field dotnet_naming_symbols.unity_serialized_field_symbols.resharper_required_modifiers = instance dotnet_naming_symbols.unity_serialized_field_symbols_1.applicable_accessibilities = * -dotnet_naming_symbols.unity_serialized_field_symbols_1.applicable_kinds = +dotnet_naming_symbols.unity_serialized_field_symbols_1.applicable_kinds = dotnet_naming_symbols.unity_serialized_field_symbols_1.resharper_applicable_kinds = unity_serialised_field dotnet_naming_symbols.unity_serialized_field_symbols_1.resharper_required_modifiers = instance dotnet_separate_import_directive_groups = false @@ -663,8 +663,8 @@ resharper_line_break_before_requires_clause = do_not_change resharper_linkage_specification_braces = end_of_line resharper_linkage_specification_indentation = none resharper_local_function_body = block_body -resharper_macro_block_begin = -resharper_macro_block_end = +resharper_macro_block_begin = +resharper_macro_block_end = resharper_max_array_initializer_elements_on_line = 10000 resharper_max_attribute_length_for_same_line = 38 resharper_max_enum_members_on_line = 1 @@ -698,7 +698,7 @@ resharper_parentheses_non_obvious_operations = none, shift, bitwise_and, bitwise resharper_parentheses_redundancy_style = remove_if_not_clarifies_precedence resharper_parentheses_same_type_operations = false resharper_pi_attributes_indent = align_by_first_attribute -resharper_place_accessorholder_attribute_on_same_line = if_owner_is_single_line +resharper_place_accessorholder_attribute_on_same_line = false resharper_place_accessor_attribute_on_same_line = if_owner_is_single_line resharper_place_comments_at_first_column = false resharper_place_constructor_initializer_on_same_line = true @@ -734,7 +734,7 @@ resharper_resx_attribute_indent = single_indent resharper_resx_blank_line_after_pi = true resharper_resx_indent_text = OneIndent resharper_resx_keep_user_linebreaks = true -resharper_resx_linebreak_before_elements = +resharper_resx_linebreak_before_elements = resharper_resx_max_blank_lines_between_tags = 0 resharper_resx_pi_attribute_style = do_not_touch resharper_resx_space_before_self_closing = false @@ -963,7 +963,7 @@ resharper_xml_attribute_indent = align_by_first_attribute resharper_xml_blank_line_after_pi = true resharper_xml_indent_text = OneIndent resharper_xml_keep_user_linebreaks = true -resharper_xml_linebreak_before_elements = +resharper_xml_linebreak_before_elements = resharper_xml_max_blank_lines_between_tags = 2 resharper_xml_pi_attribute_style = do_not_touch resharper_xml_space_before_self_closing = true diff --git a/Neo4j.Driver/Neo4j.Driver.Tests/Mapping/DictAsRecordTests.cs b/Neo4j.Driver/Neo4j.Driver.Tests/Mapping/DictAsRecordTests.cs new file mode 100644 index 000000000..050767f19 --- /dev/null +++ b/Neo4j.Driver/Neo4j.Driver.Tests/Mapping/DictAsRecordTests.cs @@ -0,0 +1,49 @@ +// Copyright (c) "Neo4j" +// Neo4j Sweden AB [http://neo4j.com] +// +// This file is part of Neo4j. +// +// Licensed under the Apache License, Version 2.0 (the "License"). +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Collections.Generic; +using FluentAssertions; +using Neo4j.Driver.Preview.Mapping; +using Xunit; +using Record = Neo4j.Driver.Internal.Result.Record; + +namespace Neo4j.Driver.Tests.Mapping +{ + public class DictAsRecordTests + { + [Fact] + public void ShouldReturnCorrectValues() + { + var originalRecord = new Record(new[] {"name", "age"}, new object[] {"Bob", 42}); + var dict = new Dictionary + { + {"key1", "value1"}, + {"key2", "value2"} + }; + + var subject = new DictAsRecord(dict, originalRecord); + + subject.Record.Should().BeSameAs(originalRecord); + subject.Keys.Should().BeEquivalentTo(dict.Keys); + subject.Values.Should().BeEquivalentTo(dict); + subject[0].Should().Be("value1"); + subject[1].Should().Be("value2"); + subject["key1"].Should().Be("value1"); + subject["key2"].Should().Be("value2"); + } + } +} diff --git a/Neo4j.Driver/Neo4j.Driver.Tests/Mapping/LabelCaptureTests.cs b/Neo4j.Driver/Neo4j.Driver.Tests/Mapping/LabelCaptureTests.cs new file mode 100644 index 000000000..85251ea43 --- /dev/null +++ b/Neo4j.Driver/Neo4j.Driver.Tests/Mapping/LabelCaptureTests.cs @@ -0,0 +1,130 @@ +// Copyright (c) "Neo4j" +// Neo4j Sweden AB [http://neo4j.com] +// +// This file is part of Neo4j. +// +// Licensed under the Apache License, Version 2.0 (the "License"). +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Collections.Generic; +using System.Linq; +using FluentAssertions; +using Neo4j.Driver.Internal.Types; +using Neo4j.Driver.Preview.Mapping; +using Xunit; +using Record = Neo4j.Driver.Internal.Result.Record; + +namespace Neo4j.Driver.Tests.Mapping +{ + public class LabelCaptureTests + { + public class TestMappedClass + { + [MappingSource("Person", EntityMappingSource.NodeLabel)] + public string Label { get; set; } + + [MappingSource("Person", EntityMappingSource.NodeLabel)] + public List Labels { get; set; } + + [MappingSource("Relationship", EntityMappingSource.RelationshipType)] + public string RelationshipType { get; set; } + } + + public LabelCaptureTests() + { + RecordObjectMapping.Reset(); + } + + [Fact] + public void ShouldCaptureSingleNodeLabel() + { + var node = new Node(1, new[] { "Test" }, new Dictionary()); + var record = new Record(new[] { "Person" }, new object[] { node }); + + var mapped = record.AsObject(); + + mapped.Label.Should().Be("Test"); + } + + [Fact] + public void ShouldCaptureMultipleNodeLabelsIntoString() + { + var node = new Node(1, new[] { "Alpha", "Bravo", "Charlie" }, new Dictionary()); + var record = new Record(new[] { "Person" }, new object[] { node }); + + var mapped = record.AsObject(); + + mapped.Label.Should().Be("Alpha,Bravo,Charlie"); + } + + [Fact] + public void ShouldCaptureRelationshipType() + { + var node = new Relationship(1, 2, 3, "ACTED_IN", new Dictionary()); + var record = new Record(new[] { "Relationship" }, new object[] { node }); + + var mapped = record.AsObject(); + + mapped.RelationshipType.Should().Be("ACTED_IN"); + } + + class CustomMapper : IMappingProvider + { + /// + public void CreateMappers(IMappingRegistry registry) + { + registry.RegisterMapping( + b => b + .Map( + x => x.Label, + "Person", + EntityMappingSource.NodeLabel, + x => string.Join("|", ((string[])x).Select(y => y.ToUpper()))) + .Map( + x => x.Labels, + "Person", + EntityMappingSource.NodeLabel, + x => ((string[])x).Select(y => y.Replace("a", "x")).ToList()) + .Map( + x => x.RelationshipType, + "Relationship", + EntityMappingSource.RelationshipType, + x => x?.ToString()?.ToLower())); + } + } + + [Fact] + public void ShouldCaptureAndConvertLabels() + { + RecordObjectMapping.RegisterProvider(); + var node = new Node(1, new[] { "Alpha", "Bravo", "Charlie" }, new Dictionary()); + var record = new Record(new[] { "Person" }, new object[] { node }); + + var mapped = record.AsObject(); + + mapped.Label.Should().Be("ALPHA|BRAVO|CHARLIE"); + mapped.Labels.Should().BeEquivalentTo(new[] { "Alphx", "Brxvo", "Chxrlie" }); + } + + [Fact] + public void ShouldCaptureAndConvertRelationshipType() + { + RecordObjectMapping.RegisterProvider(); + var node = new Relationship(1, 2, 3, "ACTED_IN", new Dictionary()); + var record = new Record(new[] { "Relationship" }, new object[] { node }); + + var mapped = record.AsObject(); + + mapped.RelationshipType.Should().Be("acted_in"); + } + } +} diff --git a/Neo4j.Driver/Neo4j.Driver.Tests/Mapping/MappingBuilderTests.cs b/Neo4j.Driver/Neo4j.Driver.Tests/Mapping/MappingBuilderTests.cs new file mode 100644 index 000000000..7f3345eb7 --- /dev/null +++ b/Neo4j.Driver/Neo4j.Driver.Tests/Mapping/MappingBuilderTests.cs @@ -0,0 +1,58 @@ +// Copyright (c) "Neo4j" +// Neo4j Sweden AB [http://neo4j.com] +// +// This file is part of Neo4j. +// +// Licensed under the Apache License, Version 2.0 (the "License"). +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using FluentAssertions; +using Neo4j.Driver.Preview.Mapping; +using Xunit; + +namespace Neo4j.Driver.Tests.Mapping +{ + public class MappingBuilderTests + { + private class TestClass + { + public int Settable { get; set; } + public int NotSettable { get; } = -1; + public int NotAProperty = 0x6060B017; + } + + [Fact] + public void ShouldThrowIfNotAMemberExpression() + { + var subject = new MappingBuilder(); + var act = () => subject.Map(x => "something", "foo"); + act.Should().Throw(); + } + + [Fact] + public void ShouldThrowIfNotAPropertyExpression() + { + var subject = new MappingBuilder(); + var act = () => subject.Map(x => x.NotAProperty, "foo"); + act.Should().Throw(); + } + + [Fact] + public void ShouldThrowIfPropertyDoesNotHaveASetter() + { + var subject = new MappingBuilder(); + var act = () => subject.Map(x => x.NotSettable, "foo"); + act.Should().Throw(); + } + } +} diff --git a/Neo4j.Driver/Neo4j.Driver.Tests/Mapping/MappingProviderTests.cs b/Neo4j.Driver/Neo4j.Driver.Tests/Mapping/MappingProviderTests.cs new file mode 100644 index 000000000..c619dace0 --- /dev/null +++ b/Neo4j.Driver/Neo4j.Driver.Tests/Mapping/MappingProviderTests.cs @@ -0,0 +1,130 @@ +// Copyright (c) "Neo4j" +// Neo4j Sweden AB [http://neo4j.com] +// +// This file is part of Neo4j. +// +// Licensed under the Apache License, Version 2.0 (the "License"). +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using FluentAssertions; +using Neo4j.Driver.Preview.Mapping; +using Xunit; +using Record = Neo4j.Driver.Internal.Result.Record; + +namespace Neo4j.Driver.Tests.Mapping +{ + public class MappingProviderTests + { + private class TestObject + { + public int IntValue { get; set; } + public string Text { get; set; } = null!; + } + + private class SecondTestObject + { + public int Number { get; set; } + public string Text { get; set; } = null!; + } + + private class ThirdTestObject + { + public int IntValue { get; set; } = -1; + public string StringValue { get; set; } = "unset"; + } + + private class PersonWithAge + { + public string Name { get; set; } = null!; + public int Age { get; set; } + } + + private class TestMappingProvider : IMappingProvider + { + public void CreateMappers(IMappingRegistry registry) + { + registry + .RegisterMapping( + b => b + .UseDefaultMapping() + .Map(x => x.Text, "stringValue", converter: x => x.As().ToUpper() + "!")) + .RegisterMapping( + b => b + .MapWholeObject( + r => new SecondTestObject + { + Number = r.GetValue("intValue") + 1, + Text = r.GetValue("stringValue").ToLower() + })) + .RegisterMapping(b => {}) + .RegisterMapping( + b => b + .UseDefaultMapping() + .Map(x => x.Age, r => r.GetValue("active") - r.GetValue("born"))); + } + } + + public MappingProviderTests() + { + RecordObjectMapping.Reset(); + } + + [Fact] + public void ShouldOverrideDefaultMapping() + { + var record = new Record(new[] { "stringValue", "intValue" }, new object[] { "test", 69 }); + RecordObjectMapping.RegisterProvider(); + + var obj = record.AsObject(); + + obj.Text.Should().Be("TEST!"); + obj.IntValue.Should().Be(69); + } + + [Fact] + public void ShouldUseWholeObjectMapping() + { + var record = new Record(new[] { "stringValue", "intValue" }, new object[] { "TEST", 100 }); + RecordObjectMapping.RegisterProvider(); + + var obj = record.AsObject(); + + obj.Text.Should().Be("test"); + obj.Number.Should().Be(101); + } + + [Fact] + public void ShouldNotUseDefaultMapperIfEmptyMappingConfigInProvider() + { + var record = new Record(new[] { "stringValue", "intValue" }, new object[] { "TEST", 100 }); + RecordObjectMapping.RegisterProvider(); + + var obj = record.AsObject(); + + obj.StringValue.Should().Be("unset"); + obj.IntValue.Should().Be(-1); + } + + [Fact] + public void ShouldMapPropertiesFromRecordIfRequired() + { + var record = new Record(new[] { "name", "born", "active" }, new object[] { "Bob", 1977, 2000 }); + RecordObjectMapping.RegisterProvider(); + + var obj = record.AsObject(); + + obj.Name.Should().Be("Bob"); + obj.Age.Should().Be(23); + } + } +} diff --git a/Neo4j.Driver/Neo4j.Driver.Tests/Mapping/MappingSourceDelegateBuilderTests.cs b/Neo4j.Driver/Neo4j.Driver.Tests/Mapping/MappingSourceDelegateBuilderTests.cs new file mode 100644 index 000000000..78eba0839 --- /dev/null +++ b/Neo4j.Driver/Neo4j.Driver.Tests/Mapping/MappingSourceDelegateBuilderTests.cs @@ -0,0 +1,86 @@ +// Copyright (c) "Neo4j" +// Neo4j Sweden AB [http://neo4j.com] +// +// This file is part of Neo4j. +// +// Licensed under the Apache License, Version 2.0 (the "License"). +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Collections.Generic; +using FluentAssertions; +using Neo4j.Driver.Internal.Types; +using Neo4j.Driver.Preview.Mapping; +using Xunit; +using Record = Neo4j.Driver.Internal.Result.Record; + +namespace Neo4j.Driver.Tests.Mapping +{ + public class MappingSourceDelegateBuilderTests + { + [Fact] + public void ShouldGetSimplePaths() + { + var record = new Record(new[] { "a" }, new object[] { "b" }); + var getter = new MappingSourceDelegateBuilder(); + var mappingSource = new MappingSource("a", EntityMappingSource.Property); + + var mappingDelegate = getter.GetMappingDelegate(mappingSource); + var found = mappingDelegate(record, out var value); + + found.Should().BeTrue(); + value.Should().Be("b"); + } + + [Fact] + public void ShouldReturnFalseWhenPathNotFound() + { + var record = new Record(new[] { "a" }, new object[] { "b" }); + var getter = new MappingSourceDelegateBuilder(); + var mappingSource = new MappingSource("c", EntityMappingSource.Property); + + var mappingDelegate = getter.GetMappingDelegate(mappingSource); + var found = mappingDelegate(record, out var value); + + found.Should().BeFalse(); + } + + [Fact] + public void ShouldGetNodeLabels() + { + var node = new Node(1, new[] { "Actor", "Director" }, new Dictionary()); + var record = new Record(new[] { "a" }, new object[] { node }); + var getter = new MappingSourceDelegateBuilder(); + var mappingSource = new MappingSource("a", EntityMappingSource.NodeLabel); + + var mappingDelegate = getter.GetMappingDelegate(mappingSource); + var found = mappingDelegate(record, out var value); + + found.Should().BeTrue(); + value.Should().BeEquivalentTo(new[] { "Actor", "Director" }); + } + + [Fact] + public void ShouldGetRelationshipType() + { + var rel = new Relationship(1, 2, 3, "ACTED_IN", new Dictionary()); + var record = new Record(new[] { "a" }, new object[] { rel }); + var getter = new MappingSourceDelegateBuilder(); + var mappingSource = new MappingSource("a", EntityMappingSource.RelationshipType); + + var mappingDelegate = getter.GetMappingDelegate(mappingSource); + var found = mappingDelegate(record, out var value); + + found.Should().BeTrue(); + value.Should().Be("ACTED_IN"); + } + } +} diff --git a/Neo4j.Driver/Neo4j.Driver.Tests/Mapping/RecordMappingTests.cs b/Neo4j.Driver/Neo4j.Driver.Tests/Mapping/RecordMappingTests.cs new file mode 100644 index 000000000..0eba36e36 --- /dev/null +++ b/Neo4j.Driver/Neo4j.Driver.Tests/Mapping/RecordMappingTests.cs @@ -0,0 +1,377 @@ +// Copyright (c) "Neo4j" +// Neo4j Sweden AB [http://neo4j.com] +// +// This file is part of Neo4j. +// +// Licensed under the Apache License, Version 2.0 (the "License"). +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Collections.Generic; +using System.Threading.Tasks; +using FluentAssertions; +using Neo4j.Driver.Internal.Types; +using Neo4j.Driver.Preview.Mapping; +using Xunit; +using Record = Neo4j.Driver.Internal.Result.Record; + +namespace Neo4j.Driver.Tests.Mapping +{ + public class RecordMappingTests + { + private class TestPerson + { + public string Name { get; set; } = "A. Test Name"; + public int? Born { get; set; } + public List Hobbies { get; set; } = null!; + } + + [Fact] + public void ShouldMapPrimitives() + { + var record = new Record(new[] { "name", "born" }, new object[] { "Bob", 1977 }); + var person = record.AsObject(); + person.Name.Should().Be("Bob"); + person.Born.Should().Be(1977); + } + + [Fact] + public void ShouldMapList() + { + var record = new Record(new[] { "hobbies" }, new object[] { new List { "Coding", "Swimming" } }); + var person = record.AsObject(); + person.Hobbies.Should().BeEquivalentTo("Coding", "Swimming"); + } + + private class PersonInDict + { + [MappingSource("person.name")] + public string Name { get; set; } = ""; + + [MappingSource("person.born")] + public int Born { get; set; } + } + + [Fact] + public void ShouldMapFromInsideDictionaries() + { + var dict = new Dictionary { { "name", "Dani" }, { "born", 1977 } }; + var record = new Record(new[] { "Person" }, new object[] { dict }); + var person = record.AsObject(); + person.Name.Should().Be("Dani"); + person.Born.Should().Be(1977); + } + + [Fact] + public void ShouldLeaveDefaultsIfFieldAbsent() + { + var record = new Record(new[] { "born" }, new object[] { 1977 }); + var person = record.AsObject(); + person.Name.Should().Be("A. Test Name"); + person.Born.Should().Be(1977); + } + + private class Movie + { + public string Title { get; set; } = ""; + public int Released { get; set; } + public string? Tagline { get; set; } + } + + private class Person + { + public string Name { get; set; } = ""; + public int? Born { get; set; } + } + + private class ProducingCareer + { + [MappingSource("person")] + public Person Producer { get; set; } = null!; + + [MappingSource("titles")] + public List MovieTitleIdeas { get; set; } = null!; + + [MappingSource("movies")] + public List HistoricalMovies { get; set; } = null!; + + [MappingSource("moviesDict")] + public List OtherMovies { get; set; } = null!; + } + + [Fact] + public void ShouldMapComplexObjects() + { + var person = new Node( + 0, + new[] { "Person" }, + new Dictionary { { "name", "Ron Grazer" }, { "born", 1956 } }); + + var movie1 = new Node( + 0, + new[] { "Movie" }, + new Dictionary + { + { "title", "Forrest Gump" }, + { "released", 1994 }, + { "tagline", "Life is like a box of chocolates..." } + }); + + var movie2 = new Node( + 0, + new[] { "Movie" }, + new Dictionary + { + { "title", "Cast Away" }, + { "released", 2000 }, + { "tagline", "At the edge of the world, his journey begins." } + }); + + var movie3 = new Node( + 0, + new[] { "Movie" }, + new Dictionary + { + { "title", "The Green Mile" }, + { "released", 1999 }, + { "tagline", null } + }); + + var movieNodes = new List { movie1, movie2, movie3 }; + + var stringList = new List { "A Band Apart", "Amazing Squad", "Ten Men Named Ben" }; + + var movie4 = new Dictionary + { + { "title", "The Blind Venetian" }, + { "released", 2023 }, + { "tagline", "Read between the lines" } + }; + + var movie5 = new Dictionary + { + { "title", "When The Night Ends" }, + { "released", 2022 }, + { "tagline", "Just when you thought it was safe to go to sleep" } + }; + + var moviesDict = new List> { movie4, movie5 }; + + var record = new Record( + new[] { "person", "movies", "titles", "moviesDict" }, + new object[] { person, movieNodes, stringList, moviesDict }); + + var mappedObject = record.AsObject(); + + mappedObject.Producer.Name.Should().Be("Ron Grazer"); + mappedObject.Producer.Born.Should().Be(1956); + + mappedObject.MovieTitleIdeas.Should().BeEquivalentTo("A Band Apart", "Amazing Squad", "Ten Men Named Ben"); + + mappedObject.HistoricalMovies.Should() + .BeEquivalentTo( + new Movie + { + Title = "Forrest Gump", Released = 1994, Tagline = "Life is like a box of chocolates..." + }, + new Movie + { + Title = "Cast Away", Released = 2000, Tagline = "At the edge of the world, his journey begins." + }, + new Movie { Title = "The Green Mile", Released = 1999, Tagline = null }); + + mappedObject.OtherMovies.Should() + .BeEquivalentTo( + new Movie { Title = "The Blind Venetian", Released = 2023, Tagline = "Read between the lines" }, + new Movie + { + Title = "When The Night Ends", Released = 2022, + Tagline = "Just when you thought it was safe to go to sleep" + }); + } + + [Fact] + public void ShouldMapAllRecords() + { + Task>> GetRecordsAsync() + { + var record1 = new Record(new[] { "name", }, new object[] { "Bob", }); + var record2 = new Record(new[] { "name", "born" }, new object[] { "Alice", 1988 }); + var record3 = new Record(new[] { "name", "born" }, new object[] { "Eve", 1999 }); + + var result = new EagerResult>( + new List { record1, record2, record3 }, + null, + new[] { "name", "born" }); + + return Task.FromResult(result); + } + + GetRecordsAsync() + .AsObjectsAsync() + .Result.Should() + .BeEquivalentTo( + new TestPerson { Name = "Bob" }, + new TestPerson { Name = "Alice", Born = 1988 }, + new TestPerson { Name = "Eve", Born = 1999 }); + } + + [Fact] + public void ShouldMapAllRecordsFromCursor() + { + async IAsyncEnumerable GetRecordsAsync() + { + var record1 = new Record(new[] { "name", }, new object[] { "Bob", }); + var record2 = new Record(new[] { "name", "born" }, new object[] { "Alice", 1988 }); + var record3 = new Record(new[] { "name", "born" }, new object[] { "Eve", 1999 }); + + var result = new List { record1, record2, record3 }; + + foreach (var record in result) + { + await Task.Yield(); + yield return record; + } + } + + GetRecordsAsync() + .ToListAsync() + .Result.Should() + .BeEquivalentTo( + new TestPerson { Name = "Bob" }, + new TestPerson { Name = "Alice", Born = 1988 }, + new TestPerson { Name = "Eve", Born = 1999 }); + } + + private class CarAndPainting + { + public Car Car { get; set; } = null!; + public Painting Painting { get; set; } = null!; + } + + private class Painting + { + public string Artist { get; set; } = ""; + public string Title { get; set; } = ""; + } + + private class Car + { + [MappingSource("car.make")] + public string Make { get; set; } = ""; + + [MappingSource("model")] + public string Model { get; set; } = ""; + + [MappingSource("car.madeup")] + public string MadeUp { get; set; } = "unset"; + } + + [Fact] + public void ShouldMapSubNodesWithAbsolutePaths() + { + var carNode = new Node( + 0, + new[] { "Car" }, + new Dictionary + { + { "make", "Tesla" }, + { "model", "Model 3" } + }); + + var paintingNode = new Node( + 0, + new[] { "Painting" }, + new Dictionary + { + { "artist", "Leonardo da Vinci" }, + { "title", "Mona Lisa" } + }); + + var carAndPaintingRecord = new Record(new[] { "car", "painting" }, new object[] { carNode, paintingNode }); + + var mappedObject = carAndPaintingRecord.AsObject(); + + mappedObject.Car.Make.Should().Be("Tesla"); + mappedObject.Car.Model.Should().Be("Model 3"); + mappedObject.Painting.Artist.Should().Be("Leonardo da Vinci"); + mappedObject.Painting.Title.Should().Be("Mona Lisa"); + mappedObject.Car.MadeUp.Should().Be("unset"); + } + + private class PersonWithoutBornSetter + { + public string Name { get; set; } = ""; + public int? Born { get; } = 1999; // no setter + } + + [Fact] + public void DefaultMapperShouldIgnorePropertiesWithoutSetter() + { + var record = new Record(new[] { "name", "born" }, new object[] { "Bob", 1977 }); + var person = record.AsObject(); + person.Name.Should().Be("Bob"); + person.Born.Should().Be(1999); + } + + private class TestPersonWithoutBornMapped + { + public string Name { get; set; } = "A. Test Name"; + + [MappingIgnored] + public int? Born { get; set; } = 9999; + } + + [Fact] + public void ShouldIgnorePropertiesWithDoNotMapAttribute() + { + var record = new Record(new[] { "name", "born" }, new object[] { "Bob", 1977 }); + var person = record.AsObject(); + person.Name.Should().Be("Bob"); + person.Born.Should().Be(9999); + } + + private class Book + { + public string Title { get; set; } + } + + private class Author + { + public string Name { get; set; } + public List Books { get; set; } + } + + [Fact] + public void ShouldMapEntitiesWithListsOfNodes() + { + var bookNodeList = new List + { + new Node(0, new[] { "Book" }, new Dictionary { { "title", "The Green Man" } }), + new Node(0, new[] { "Book" }, new Dictionary { { "title", "The Thin End" } }) + }; + + var authorNode = new Node( + 0, + new[] { "Author" }, + new Dictionary { { "name", "Kate Grenville" }, { "books", bookNodeList } }); + + var record = new Record(new[] { "author" }, new object[] { authorNode }); + + var mappedObject = record.AsObject(); + + mappedObject.Name.Should().Be("Kate Grenville"); + mappedObject.Books.Should().HaveCount(2); + mappedObject.Books[0].Title.Should().Be("The Green Man"); + mappedObject.Books[1].Title.Should().Be("The Thin End"); + } + } +} diff --git a/Neo4j.Driver/Neo4j.Driver.Tests/Mapping/RecordNodeExtensionsTests.cs b/Neo4j.Driver/Neo4j.Driver.Tests/Mapping/RecordNodeExtensionsTests.cs new file mode 100644 index 000000000..764eb9243 --- /dev/null +++ b/Neo4j.Driver/Neo4j.Driver.Tests/Mapping/RecordNodeExtensionsTests.cs @@ -0,0 +1,129 @@ +// Copyright (c) "Neo4j" +// Neo4j Sweden AB [http://neo4j.com] +// +// This file is part of Neo4j. +// +// Licensed under the Apache License, Version 2.0 (the "License"). +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Collections.Generic; +using FluentAssertions; +using Neo4j.Driver.Internal.Types; +using Neo4j.Driver.Preview.Mapping; +using Xunit; +using Record = Neo4j.Driver.Internal.Result.Record; + +namespace Neo4j.Driver.Tests.Mapping +{ + public class RecordNodeExtensionsTests + { + [Fact] + public void ShouldGetTypedValueFromRecord_string() + { + var record = new Record(new[] { "key" }, new object[] { "value" }); + record.GetString("key").Should().Be("value"); + } + + [Fact] + public void ShouldGetTypedValueFromRecord_int() + { + var record = new Record(new[] { "key" }, new object[] { 1L }); + record.GetInt("key").Should().Be(1); + } + + [Fact] + public void ShouldGetTypedValueFromRecord_long() + { + var record = new Record(new[] { "key" }, new object[] { 1L }); + record.GetLong("key").Should().Be(1L); + } + + [Fact] + public void ShouldGetTypedValueFromRecord_double() + { + var record = new Record(new[] { "key" }, new object[] { 1.0 }); + record.GetDouble("key").Should().Be(1.0); + } + + [Fact] + public void ShouldGetTypedValueFromRecord_float() + { + var record = new Record(new[] { "key" }, new object[] { 1.0 }); + record.GetFloat("key").Should().Be(1.0f); + } + + [Fact] + public void ShouldGetTypedValueFromRecord_bool() + { + var record = new Record(new[] { "key" }, new object[] { true }); + record.GetBool("key").Should().Be(true); + } + + [Fact] + public void ShouldGetTypedValueFromRecord_entity() + { + var node = new Node(1, new[] { "Node" }, new Dictionary { { "key", "value" } }); + var record = new Record(new[] { "key" }, new object[] { node }); + record.GetEntity("key").Should().Be(node); + } + + [Fact] + public void ShouldGetValueFromEntity_string() + { + var node = new Node(1, new[] { "Node" }, new Dictionary { { "key", "value" } }); + node.GetString("key").Should().Be("value"); + } + + [Fact] + public void ShouldGetValueFromEntity_int() + { + var node = new Node(1, new[] { "Node" }, new Dictionary { { "key", 1L } }); + node.GetInt("key").Should().Be(1); + } + + [Fact] + public void ShouldGetValueFromEntity_long() + { + var node = new Node(1, new[] { "Node" }, new Dictionary { { "key", 1L } }); + node.GetLong("key").Should().Be(1L); + } + + [Fact] + public void ShouldGetValueFromEntity_double() + { + var node = new Node(1, new[] { "Node" }, new Dictionary { { "key", 1.0 } }); + node.GetDouble("key").Should().Be(1.0); + } + + [Fact] + public void ShouldGetValueFromEntity_float() + { + var node = new Node(1, new[] { "Node" }, new Dictionary { { "key", 1.0 } }); + node.GetFloat("key").Should().Be(1.0f); + } + + [Fact] + public void ShouldGetValueFromEntity_bool() + { + var node = new Node(1, new[] { "Node" }, new Dictionary { { "key", true } }); + node.GetBool("key").Should().Be(true); + } + + [Fact] + public void ShouldGetValueFromEntity_Dictionary() + { + var dictionary = new Dictionary { { "key", "value" } }; + var node = new Node(1, new[] { "Node" }, new Dictionary { { "field", dictionary } }); + node.GetValue>("field").Should().BeEquivalentTo(dictionary); + } + } +} diff --git a/Neo4j.Driver/Neo4j.Driver.Tests/Mapping/RecordPathFinderTests.cs b/Neo4j.Driver/Neo4j.Driver.Tests/Mapping/RecordPathFinderTests.cs new file mode 100644 index 000000000..d92c9e7a7 --- /dev/null +++ b/Neo4j.Driver/Neo4j.Driver.Tests/Mapping/RecordPathFinderTests.cs @@ -0,0 +1,92 @@ +// Copyright (c) "Neo4j" +// Neo4j Sweden AB [http://neo4j.com] +// +// This file is part of Neo4j. +// +// Licensed under the Apache License, Version 2.0 (the "License"). +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Collections.Generic; +using FluentAssertions; +using Neo4j.Driver.Internal.Result; +using Neo4j.Driver.Internal.Types; +using Neo4j.Driver.Preview.Mapping; +using Xunit; +using Record = Neo4j.Driver.Internal.Result.Record; + +namespace Neo4j.Driver.Tests.Mapping +{ + public class RecordPathFinderTests + { + [Fact] + public void ShouldFindSimplePath() + { + var record = new Record(new[] { "a" }, new object[] { "b" }); + var finder = new RecordPathFinder(); + + var found = finder.TryGetValueByPath(record, "a", out var value); + + found.Should().BeTrue(); + value.Should().Be("b"); + } + + [Fact] + public void ShouldReturnFalseWhenPathNotFound() + { + var record = new Record(new[] { "a" }, new object[] { "b" }); + var finder = new RecordPathFinder(); + + var found = finder.TryGetValueByPath(record, "c", out var value); + + found.Should().BeFalse(); + } + + [Fact] + public void ShouldFindSimplePathNestedInANode() + { + var node = new Node(1, new[] { "Test" }, new Dictionary() { { "name", "Bob" } }); + var record = new Record(new[] { "person" }, new object[] { node }); + var finder = new RecordPathFinder(); + + var found = finder.TryGetValueByPath(record, "name", out var value); + + found.Should().BeTrue(); + value.Should().Be("Bob"); + } + + [Fact] + public void ShouldFindComplexPathNestedInANode() + { + var node = new Node(1, new[] { "Test" }, new Dictionary() { { "name", "Bob" } }); + var record = new Record(new[] { "person" }, new object[] { node }); + var finder = new RecordPathFinder(); + + var found = finder.TryGetValueByPath(record, "person.name", out var value); + + found.Should().BeTrue(); + value.Should().Be("Bob"); + } + + [Fact] + public void ShouldFindComplexPathNestedInADictionary() + { + var dictionary = new Dictionary() { { "name", "Bob" } }; + var record = new Record(new[] { "person" }, new object[] { dictionary }); + var finder = new RecordPathFinder(); + + var found = finder.TryGetValueByPath(record, "person.name", out var value); + + found.Should().BeTrue(); + value.Should().Be("Bob"); + } + } +} diff --git a/Neo4j.Driver/Neo4j.Driver/Preview/Mapping/AsyncEnumerableExtensions.cs b/Neo4j.Driver/Neo4j.Driver/Preview/Mapping/AsyncEnumerableExtensions.cs new file mode 100644 index 000000000..ece451a93 --- /dev/null +++ b/Neo4j.Driver/Neo4j.Driver/Preview/Mapping/AsyncEnumerableExtensions.cs @@ -0,0 +1,53 @@ +// Copyright (c) "Neo4j" +// Neo4j Sweden AB [http://neo4j.com] +// +// This file is part of Neo4j. +// +// Licensed under the Apache License, Version 2.0 (the "License"). +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Neo4j.Driver.Preview.Mapping; + +/// +/// Contains extension methods for . +/// +public static class AsyncEnumerableExtensions +{ + /// + /// Materializes the into a list of objects of type + /// , by mapping each record in the enumerable to an object. + /// If no custom mapper is defined for type , the default + /// mapper will be used. + /// + /// + /// The asynchronous source of records. + /// A token to cancel the operation. + /// The type of object to map to. + /// The list of mapped objects. + public static async ValueTask> ToListAsync( + this IAsyncEnumerable asyncEnumerable, + CancellationToken cancellationToken = default) + where T : new() + { + var list = new List(); + await foreach (var item in asyncEnumerable.ConfigureAwait(false).WithCancellation(cancellationToken)) + { + list.Add(item.AsObject()); + } + + return list; + } +} diff --git a/Neo4j.Driver/Neo4j.Driver/Preview/Mapping/BuiltMapper.cs b/Neo4j.Driver/Neo4j.Driver/Preview/Mapping/BuiltMapper.cs new file mode 100644 index 000000000..521b1c8a3 --- /dev/null +++ b/Neo4j.Driver/Neo4j.Driver/Preview/Mapping/BuiltMapper.cs @@ -0,0 +1,163 @@ +// Copyright (c) "Neo4j" +// Neo4j Sweden AB [http://neo4j.com] +// +// This file is part of Neo4j. +// +// Licensed under the Apache License, Version 2.0 (the "License"). +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; + +namespace Neo4j.Driver.Preview.Mapping; + +internal class BuiltMapper : IRecordMapper where TObject : new() +{ + private readonly IMappingSourceDelegateBuilder _mappingSourceDelegateBuilder = new MappingSourceDelegateBuilder(); + + private Func _wholeObjectMapping; + private readonly List> _recordMappings = new(); + + private readonly MethodInfo _asGenericMethod = + typeof(ValueExtensions).GetMethod(nameof(ValueExtensions.As), new[] { typeof(object) }); + + public TObject Map(IRecord record) + { + // if there's a whole-object mapping, use it, otherwise create a new object + var obj = _wholeObjectMapping is not null + ? _wholeObjectMapping(record) + : new TObject(); + + // if there are individual mappings for the properties, apply them + foreach (var mapping in _recordMappings) + { + mapping(obj, record); + } + + return obj; + } + + public void AddWholeObjectMapping(Func mappingFunction) + { + _wholeObjectMapping = mappingFunction; + } + + public void AddMappingBySetter( + MethodInfo propertySetter, + MappingSource mappingSource, + Func converter = null) + { + // create the .As method we're going to use + var propertyType = propertySetter.GetParameters()[0].ParameterType; + var asMethod = _asGenericMethod.MakeGenericMethod(propertyType); + var getter = _mappingSourceDelegateBuilder.GetMappingDelegate(mappingSource); + AddMapping(propertySetter, GetValue); + + object GetValue(IRecord record) + { + var found = getter(record, out var value); + + return value switch + { + null => null, + + // prioritise a custom converter if there is one + _ when converter is not null => converter(value), + + // don't convert entities, just pass them through to be handled specially + IEntity entity => entity, + + // special case: if they want to map a list to a string, convert to comma-separated + IList list when propertyType == typeof(string) => string.Join(",", list.Cast()), + + // if it's a list, map the individual items in the list + IList list => CreateMappedList(list, propertyType, record), + + // otherwise, convert the value to the type of the property + _ => asMethod.Invoke(null, new[] { value }) + }; + } + } + + private IList CreateMappedList(IList list, Type desiredListType, IRecord record) + { + var newList = (IList)Activator.CreateInstance(desiredListType); + var desiredItemType = desiredListType.GetGenericArguments()[0]; + var asMethod = _asGenericMethod.MakeGenericMethod(desiredItemType); + + foreach (var item in list) + { + // entities and dictionaries can use the same logic, we can make them both into dictionaries + var dict = item switch + { + IEntity entity => entity.Properties, + IReadOnlyDictionary dictionary => dictionary, + _ => null + }; + + if (dict is not null) + { + // if the item is an entity or dictionary, we need to make it into a record and then map that + var subRecord = new DictAsRecord(dict, record); + var newItem = RecordObjectMapping.Map(subRecord, desiredItemType); + newList!.Add(newItem); + } + else + { + // otherwise, just convert the item to the type of the list + newList!.Add(asMethod.Invoke(null, new[] { item })); + } + } + + return newList; + } + + public void AddMapping( + MethodInfo propertySetter, + Func valueGetter) + { + _recordMappings.Add(MapFromRecord); + return; + + void MapFromRecord(TObject obj, IRecord record) + { + var value = valueGetter(record); + if (value is null && record is DictAsRecord { Record: var parentRecord }) + { + // try the path relative to the parent record + value = valueGetter(parentRecord); + } + + switch (value) + { + // if null is returned, leave the property as the default value: the record may not have the given field + case null: return; + + // if the value is an entity, make it into a fake record and map that (indirectly recursive) + case IEntity entity: + var destType = propertySetter.GetParameters()[0].ParameterType; + var newEntityDest = RecordObjectMapping.Map(new DictAsRecord(entity.Properties, record), destType); + + propertySetter.Invoke(obj, new[] { newEntityDest }); + return; + + // otherwise, just set the property to the value + default: + propertySetter.Invoke(obj, new object[] { value }); + return; + } + } + } +} diff --git a/Neo4j.Driver/Neo4j.Driver/Preview/Mapping/DefaultMapper.cs b/Neo4j.Driver/Neo4j.Driver/Preview/Mapping/DefaultMapper.cs new file mode 100644 index 000000000..94ce16998 --- /dev/null +++ b/Neo4j.Driver/Neo4j.Driver/Preview/Mapping/DefaultMapper.cs @@ -0,0 +1,71 @@ +// Copyright (c) "Neo4j" +// Neo4j Sweden AB [http://neo4j.com] +// +// This file is part of Neo4j. +// +// Licensed under the Apache License, Version 2.0 (the "License"). +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; +using System.Reflection; + +namespace Neo4j.Driver.Preview.Mapping; + +internal static class DefaultMapper +{ + private static readonly Dictionary Mappers = new(); + + public static IRecordMapper Get() where T : new() + { + var type = typeof(T); + if (Mappers.TryGetValue(type, out var mapper)) + { + return (IRecordMapper)mapper; + } + + var mappingBuilder = new MappingBuilder(); + var properties = type.GetProperties(BindingFlags.Public | BindingFlags.Instance); + foreach (var property in properties) + { + var setter = property.GetSetMethod(); + if (setter is null) + { + // ignore properties without setter + continue; + } + + // check if there is a MappingIgnoredAttribute; if there is, ignore this property + if (property.GetCustomAttribute() is not null) + { + continue; + } + + string path = property.Name; + + var mappingSource = new MappingSource(path, EntityMappingSource.Property); + + // check if there is a MappingSourceAttribute; if there is, use that path instead + var mappingSourceAttribute = property.GetCustomAttribute(); + if (mappingSourceAttribute is not null) + { + mappingSource = mappingSourceAttribute.MappingSource; + } + + mappingBuilder.Map(setter, mappingSource); + } + + mapper = mappingBuilder.Build(); + Mappers[type] = mapper; + return (IRecordMapper)mapper; + } +} diff --git a/Neo4j.Driver/Neo4j.Driver/Preview/Mapping/DictAsRecord.cs b/Neo4j.Driver/Neo4j.Driver/Preview/Mapping/DictAsRecord.cs new file mode 100644 index 000000000..85efd3cd3 --- /dev/null +++ b/Neo4j.Driver/Neo4j.Driver/Preview/Mapping/DictAsRecord.cs @@ -0,0 +1,40 @@ +// Copyright (c) "Neo4j" +// Neo4j Sweden AB [http://neo4j.com] +// +// This file is part of Neo4j. +// +// Licensed under the Apache License, Version 2.0 (the "License"). +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Neo4j.Driver.Preview.Mapping; + +internal class DictAsRecord : IRecord +{ + private readonly IReadOnlyDictionary _dict; + private readonly IRecord _record; + + public DictAsRecord(IReadOnlyDictionary dict, IRecord record) + { + _dict = dict; + _record = record; + } + + public IRecord Record => _record; + public object this[int index] => _dict.TryGetValue(_dict.Keys.ElementAt(index), out var obj) ? obj : null; + public object this[string key] => _dict.TryGetValue(key, out var obj) ? obj : null; + public IReadOnlyDictionary Values => _dict; + public IReadOnlyList Keys => _dict.Keys.ToList(); +} diff --git a/Neo4j.Driver/Neo4j.Driver/Preview/Mapping/ExecutableQueryMappingExtensions.cs b/Neo4j.Driver/Neo4j.Driver/Preview/Mapping/ExecutableQueryMappingExtensions.cs new file mode 100644 index 000000000..dd124e17e --- /dev/null +++ b/Neo4j.Driver/Neo4j.Driver/Preview/Mapping/ExecutableQueryMappingExtensions.cs @@ -0,0 +1,46 @@ +// Copyright (c) "Neo4j" +// Neo4j Sweden AB [http://neo4j.com] +// +// This file is part of Neo4j. +// +// Licensed under the Apache License, Version 2.0 (the "License"). +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace Neo4j.Driver.Preview.Mapping; + +/// +/// Contains extensions for using the global mapping system with the driver's +/// methods. +/// +public static class ExecutableQueryMappingExtensions +{ + /// + /// Add this method to an method chain to map the results to objects + /// as part of the query execution. + /// + /// + /// The task that will return the records. + /// The type to map to. + /// A task that will return the mapped objects. + public static async Task> AsObjectsAsync( + this Task>> recordsTask) + where T : new() + { + var records = await recordsTask.ConfigureAwait(false); + var mapper = RecordObjectMapping.GetMapper(); + return records.Result.Select(mapper.Map).ToList(); + } +} diff --git a/Neo4j.Driver/Neo4j.Driver/Preview/Mapping/IMappingBuilder.cs b/Neo4j.Driver/Neo4j.Driver/Preview/Mapping/IMappingBuilder.cs new file mode 100644 index 000000000..8d0553e93 --- /dev/null +++ b/Neo4j.Driver/Neo4j.Driver/Preview/Mapping/IMappingBuilder.cs @@ -0,0 +1,74 @@ +// Copyright (c) "Neo4j" +// Neo4j Sweden AB [http://neo4j.com] +// +// This file is part of Neo4j. +// +// Licensed under the Apache License, Version 2.0 (the "License"). +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Linq.Expressions; + +namespace Neo4j.Driver.Preview.Mapping; + +/// +/// Defines a builder for mapping objects from s. +/// +/// The type of object to be mapped. +public interface IMappingBuilder +{ + /// + /// Applies the default mapping for the object. Later calls to will override + /// the default mapping. + /// + /// This instance for method chaining. + IMappingBuilder UseDefaultMapping(); + + /// + /// Defines a mapping from a field in the record to a property on the object. + /// + /// The property to map to. + /// The key of the field in the record. + /// A value indicating the type of value to be mapped from the specified field. + /// + /// An optional converter function to convert the value from the field value + /// to the type of the property. + /// The type of the property being mapped. This type will be inferred from the + /// parameter. + /// This instance for method chaining. + IMappingBuilder Map( + Expression> destination, + string path, + EntityMappingSource entityMappingSource = EntityMappingSource.Property, + Func converter = null); + + /// + /// Defines a mapping directly from the record to a property on the object. + /// + /// The property to map to. + /// A function that accepts an and returns the value to be + /// stored in the property. + /// The type of the property being mapped. This type will be inferred from the + /// parameter. + /// This instance for method chaining. + IMappingBuilder Map( + Expression> destination, + Func valueGetter); + + /// + /// Defines a mapping from a the record directly to the entire object. + /// + /// A function that accepts an and returns the mapped + /// object. + /// This instance for method chaining. + IMappingBuilder MapWholeObject(Func mappingFunction); +} diff --git a/Neo4j.Driver/Neo4j.Driver/Preview/Mapping/IMappingProvider.cs b/Neo4j.Driver/Neo4j.Driver/Preview/Mapping/IMappingProvider.cs new file mode 100644 index 000000000..a7f234591 --- /dev/null +++ b/Neo4j.Driver/Neo4j.Driver/Preview/Mapping/IMappingProvider.cs @@ -0,0 +1,30 @@ +// Copyright (c) "Neo4j" +// Neo4j Sweden AB [http://neo4j.com] +// +// This file is part of Neo4j. +// +// Licensed under the Apache License, Version 2.0 (the "License"). +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +namespace Neo4j.Driver.Preview.Mapping; + +/// +/// Interface to be implemented by a class that provides mappers to the mapping system. +/// +public interface IMappingProvider +{ + /// + /// This method is called on mapping providers to allow them to register their mappers with the mapping system. + /// + /// The registry in which mappers should be registered. + void CreateMappers(IMappingRegistry registry); +} diff --git a/Neo4j.Driver/Neo4j.Driver/Preview/Mapping/IRecordMapper.cs b/Neo4j.Driver/Neo4j.Driver/Preview/Mapping/IRecordMapper.cs new file mode 100644 index 000000000..b15c9a7d2 --- /dev/null +++ b/Neo4j.Driver/Neo4j.Driver/Preview/Mapping/IRecordMapper.cs @@ -0,0 +1,32 @@ +// Copyright (c) "Neo4j" +// Neo4j Sweden AB [http://neo4j.com] +// +// This file is part of Neo4j. +// +// Licensed under the Apache License, Version 2.0 (the "License"). +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +namespace Neo4j.Driver.Preview.Mapping; + +/// +/// Interface to be implemented by a class that maps records to objects of type . +/// +/// The type of object to which records will be mapped. +public interface IRecordMapper +{ + /// + /// Maps the given record to an object of type . + /// + /// The record to map. + /// The mapped object. + T Map(IRecord record); +} diff --git a/Neo4j.Driver/Neo4j.Driver/Preview/Mapping/MappingBuilder.cs b/Neo4j.Driver/Neo4j.Driver/Preview/Mapping/MappingBuilder.cs new file mode 100644 index 000000000..40b61d07a --- /dev/null +++ b/Neo4j.Driver/Neo4j.Driver/Preview/Mapping/MappingBuilder.cs @@ -0,0 +1,99 @@ +// Copyright (c) "Neo4j" +// Neo4j Sweden AB [http://neo4j.com] +// +// This file is part of Neo4j. +// +// Licensed under the Apache License, Version 2.0 (the "License"). +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Linq.Expressions; +using System.Reflection; + +namespace Neo4j.Driver.Preview.Mapping; + +internal class MappingBuilder : IMappingBuilder where TObject : new() +{ + private readonly BuiltMapper _builtMapper = new(); + + internal void Map( + MethodInfo propertySetter, + MappingSource mappingSource) + { + _builtMapper.AddMappingBySetter(propertySetter, mappingSource); + } + + public IMappingBuilder Map( + Expression> destination, + string path, + EntityMappingSource entityMappingSource = EntityMappingSource.Property, + Func converter = null) + { + var propertySetter = GetPropertySetter(destination); + _builtMapper.AddMappingBySetter( + propertySetter, + new MappingSource(path, entityMappingSource), + converter is null ? null : o => converter.Invoke(o)); + + return this; + } + + /// + public IMappingBuilder Map( + Expression> destination, + Func valueGetter) + { + var propertySetter = GetPropertySetter(destination); + _builtMapper.AddMapping(propertySetter, valueGetter); + return this; + } + + public IMappingBuilder MapWholeObject(Func mappingFunction) + { + _builtMapper.AddWholeObjectMapping(mappingFunction); + return this; + } + + /// + public IMappingBuilder UseDefaultMapping() + { + _builtMapper.AddWholeObjectMapping(r => DefaultMapper.Get().Map(r)); + return this; + } + + internal IRecordMapper Build() + { + return _builtMapper; + } + + private static MethodInfo GetPropertySetter(Expression> destination) + { + var body = destination.Body.ToString(); + if (destination.Body is not MemberExpression member) + { + throw new ArgumentException("Expression is not a member expression", body); + } + + if (member.Member is not PropertyInfo prop) + { + throw new ArgumentException("Expression is not a property expression", body); + } + + var setter = prop.GetSetMethod(); + if (setter == null) + { + throw new ArgumentException("Property does not have a setter", body); + } + + return setter; + } +} diff --git a/Neo4j.Driver/Neo4j.Driver/Preview/Mapping/MappingIgnoredAttribute.cs b/Neo4j.Driver/Neo4j.Driver/Preview/Mapping/MappingIgnoredAttribute.cs new file mode 100644 index 000000000..806b5a7e1 --- /dev/null +++ b/Neo4j.Driver/Neo4j.Driver/Preview/Mapping/MappingIgnoredAttribute.cs @@ -0,0 +1,30 @@ +// Copyright (c) "Neo4j" +// Neo4j Sweden AB [http://neo4j.com] +// +// This file is part of Neo4j. +// +// Licensed under the Apache License, Version 2.0 (the "License"). +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; + +namespace Neo4j.Driver.Preview.Mapping; + +/// +/// Instructs the default object mapper not to attempt to map any value to this property. +/// This attribute does not affect custom-defined mappers. +/// +[AttributeUsage(AttributeTargets.Property)] +public class MappingIgnoredAttribute : Attribute +{ + +} diff --git a/Neo4j.Driver/Neo4j.Driver/Preview/Mapping/MappingSourceAttribute.cs b/Neo4j.Driver/Neo4j.Driver/Preview/Mapping/MappingSourceAttribute.cs new file mode 100644 index 000000000..5bef2e4ea --- /dev/null +++ b/Neo4j.Driver/Neo4j.Driver/Preview/Mapping/MappingSourceAttribute.cs @@ -0,0 +1,76 @@ +// Copyright (c) "Neo4j" +// Neo4j Sweden AB [http://neo4j.com] +// +// This file is part of Neo4j. +// +// Licensed under the Apache License, Version 2.0 (the "License"). +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; + +namespace Neo4j.Driver.Preview.Mapping; + +/// +/// Represents a mapping from an entity itself rather than any of its properties. +/// +public enum EntityMappingSource +{ + /// + /// The value of the specified property will be used as the value. + /// + Property, + + /// + /// If the value of the specified property is a relationship, then the relationship type will be used as the value. + /// Otherwise, the property will be ignored. + /// + RelationshipType, + + /// + /// If the value of the specified property is a node, then the labels will be used as the value. If the destination + /// property is a string, then the labels will be joined with a comma. If the destination property is a list, then + /// the labels will be added to the list. Otherwise, the property will be ignored. + /// + NodeLabel +} + +internal record MappingSource(string Path, EntityMappingSource EntityMappingSource); + +/// +/// Instructs the default mapper to use a different field than the property name when mapping a value to the +/// marked property. This attribute does not affect custom-defined mappers. A path may consist of the name of the +/// field to be mapped, or a dot-separated path to a nested field. +/// +[AttributeUsage(AttributeTargets.Property)] +public class MappingSourceAttribute : Attribute +{ + internal MappingSource MappingSource { get; } + + /// + /// Instructs the default mapper to use a different field than the property name when mapping a value to the + /// marked property. + /// + /// + /// Identifier for the value in the field in the record. If the path is a dot-separated path, then the + /// first part of the path is the key for the entity (or dictionary) field in the record, and the + /// last part is the key within that entity or dictionary. + /// + public MappingSourceAttribute(string path) + { + MappingSource = new MappingSource(path, EntityMappingSource.Property); + } + + public MappingSourceAttribute(string key, EntityMappingSource entityMappingSource) + { + MappingSource = new MappingSource(key, entityMappingSource); + } +} diff --git a/Neo4j.Driver/Neo4j.Driver/Preview/Mapping/MappingSourceDelegateBuilder.cs b/Neo4j.Driver/Neo4j.Driver/Preview/Mapping/MappingSourceDelegateBuilder.cs new file mode 100644 index 000000000..453f14ee5 --- /dev/null +++ b/Neo4j.Driver/Neo4j.Driver/Preview/Mapping/MappingSourceDelegateBuilder.cs @@ -0,0 +1,69 @@ +// Copyright (c) "Neo4j" +// Neo4j Sweden AB [http://neo4j.com] +// +// This file is part of Neo4j. +// +// Licensed under the Apache License, Version 2.0 (the "License"). +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +namespace Neo4j.Driver.Preview.Mapping +{ + internal delegate bool TryGetMapSourceValueDelegate( + IRecord record, + out object value); + + internal interface IMappingSourceDelegateBuilder + { + TryGetMapSourceValueDelegate GetMappingDelegate(MappingSource mappingSource); + } + + internal class MappingSourceDelegateBuilder : IMappingSourceDelegateBuilder + { + private IRecordPathFinder _pathFinder = new RecordPathFinder(); + + /// + public TryGetMapSourceValueDelegate GetMappingDelegate(MappingSource mappingSource) + { + bool TryGetValue(IRecord record, out object value) + { + if (!_pathFinder.TryGetValueByPath(record, mappingSource.Path, out var foundValue)) + { + value = null; + return false; + } + + switch (mappingSource) + { + case { EntityMappingSource: EntityMappingSource.NodeLabel } + when foundValue is INode node: + { + value = node.Labels; + return true; + } + + case { EntityMappingSource: EntityMappingSource.RelationshipType } + when foundValue is IRelationship relationship: + { + value = relationship.Type; + return true; + } + + default: + value = foundValue; + return true; + } + } + + return TryGetValue; + } + } +} diff --git a/Neo4j.Driver/Neo4j.Driver/Preview/Mapping/RecordEntityExtensions.cs b/Neo4j.Driver/Neo4j.Driver/Preview/Mapping/RecordEntityExtensions.cs new file mode 100644 index 000000000..a42e1c990 --- /dev/null +++ b/Neo4j.Driver/Neo4j.Driver/Preview/Mapping/RecordEntityExtensions.cs @@ -0,0 +1,167 @@ +// Copyright (c) "Neo4j" +// Neo4j Sweden AB [http://neo4j.com] +// +// This file is part of Neo4j. +// +// Licensed under the Apache License, Version 2.0 (the "License"). +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +namespace Neo4j.Driver.Preview.Mapping; + +/// +/// Contains extensions for accessing values simply from records and entities. +/// +public static class RecordEntityExtensions +{ + /// + /// Converts the record to an object of the given type according to the global mapping configuration. + /// + /// + /// The record to convert. + /// The type to map to. + /// The mapped object. + public static T AsObject(this IRecord record) where T : new() + { + return RecordObjectMapping.Map(record); + } + + /// + /// Gets the value of the given key from the record, converting it to the given type. + /// + /// The record to get the value from. + /// The key of the value. + /// The type to convert to. + /// The converted value. + public static T? GetValue(this IRecord record, string key) + { + return record.Values.TryGetValue(key, out var value) ? value.As() : default; + } + + /// + /// Gets the identified by the given key from the record. + /// + /// The record to get the entity from. + /// The key of the entity. + /// The entity. + public static IEntity GetEntity(this IRecord record, string key) + { + return record.GetValue(key); + } + + /// + /// Gets the value of the given key from the entity, converting it to the given type. + /// + /// The entity to get the value from. + /// The key of the value. + /// The type to convert to. + /// The converted value. + public static T? GetValue(this IEntity entity, string key) + { + return entity.Properties.TryGetValue(key, out var value) ? value.As() : default; + } + + /// + /// Gets the value of the given key from the entity, converting it to a string. + /// + /// The record to get the value from. + /// The key of the value. + /// The converted value. + public static string GetString(this IRecord record, string key) => record.GetValue(key); + + /// + /// Gets the value of the given key from the entity, converting it to an int. + /// + /// The record to get the value from. + /// The key of the value. + /// The converted value. + public static int GetInt(this IRecord record, string key) => record.GetValue(key); + + /// + /// Gets the value of the given key from the entity, converting it to a long. + /// + /// The record to get the value from. + /// The key of the value. + /// The converted value. + public static long GetLong(this IRecord record, string key) => record.GetValue(key); + + /// + /// Gets the value of the given key from the entity, converting it to a double. + /// + /// The record to get the value from. + /// The key of the value. + /// The converted value. + public static double GetDouble(this IRecord record, string key) => record.GetValue(key); + + /// + /// Gets the value of the given key from the entity, converting it to a float. + /// + /// The record to get the value from. + /// The key of the value. + /// The converted value. + public static float GetFloat(this IRecord record, string key) => record.GetValue(key); + + /// + /// Gets the value of the given key from the entity, converting it to a bool. + /// + /// The record to get the value from. + /// The key of the value. + /// The converted value. + public static bool GetBool(this IRecord record, string key) => record.GetValue(key); + + /// + /// Gets the value of the given key from the entity, converting it to a string. + /// + /// The entity to get the value from. + /// The key of the value. + /// The converted value. + public static string GetString(this IEntity entity, string key) => entity.GetValue(key); + + /// + /// Gets the value of the given key from the entity, converting it to an int. + /// + /// The entity to get the value from. + /// The key of the value. + /// The converted value. + public static int GetInt(this IEntity entity, string key) => entity.GetValue(key); + + /// + /// Gets the value of the given key from the entity, converting it to a long. + /// + /// The entity to get the value from. + /// The key of the value. + /// The converted value. + public static long GetLong(this IEntity entity, string key) => entity.GetValue(key); + + /// + /// Gets the value of the given key from the entity, converting it to a double. + /// + /// The entity to get the value from. + /// The key of the value. + /// The converted value. + public static double GetDouble(this IEntity entity, string key) => entity.GetValue(key); + + /// + /// Gets the value of the given key from the entity, converting it to a float. + /// + /// The entity to get the value from. + /// The key of the value. + /// The converted value. + public static float GetFloat(this IEntity entity, string key) => entity.GetValue(key); + + /// + /// Gets the value of the given key from the entity, converting it to a bool. + /// + /// The entity to get the value from. + /// The key of the value. + /// The converted value. + public static bool GetBool(this IEntity entity, string key) => entity.GetValue(key); +} diff --git a/Neo4j.Driver/Neo4j.Driver/Preview/Mapping/RecordObjectMapping.cs b/Neo4j.Driver/Neo4j.Driver/Preview/Mapping/RecordObjectMapping.cs new file mode 100644 index 000000000..8cb6e7c6d --- /dev/null +++ b/Neo4j.Driver/Neo4j.Driver/Preview/Mapping/RecordObjectMapping.cs @@ -0,0 +1,139 @@ +// Copyright (c) "Neo4j" +// Neo4j Sweden AB [http://neo4j.com] +// +// This file is part of Neo4j. +// +// Licensed under the Apache License, Version 2.0 (the "License"). +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; + +namespace Neo4j.Driver.Preview.Mapping; + +/// +/// Contains methods for registering a mapping with the global mapping configuration. +/// +public interface IMappingRegistry +{ + /// + /// Registers a mapping for the given type. + /// + /// This method will be called, passing a parameter that contains + /// a fluent API for defining the mapping. + /// The type to be mapped. + /// This instance for method chaining. + IMappingRegistry RegisterMapping(Action> mappingBuilder) where T : new(); +} + +/// +/// Controls global record mapping configuration. +/// +public class RecordObjectMapping : IMappingRegistry +{ + private static RecordObjectMapping Instance = new(); + + private readonly Dictionary _mappers = new(); + private readonly Dictionary _mapMethods = new(); + + private RecordObjectMapping() + { + } + + internal static void Reset() + { + Instance = new RecordObjectMapping(); + } + + /// + /// Registers a single record mapper. This will replace any existing mapper for the same type. + /// + /// The mapper. This must implement for the type + /// to be mapped. + /// The provided does not implement + /// IRecordMapper{T}. + public static void Register(IRecordMapper mapper) + { + Instance._mappers[typeof(T)] = mapper; + } + + internal static IRecordMapper GetMapper() where T : new() + { + return (IRecordMapper)GetMapperForType(typeof(T)); + } + + internal static object GetMapperForType(Type type) + { + if (Instance._mappers.TryGetValue(type, out var m)) + { + return m; + } + + // no mapper registered for this type, so use the default mapper + var getMethod = typeof(DefaultMapper).GetMethod(nameof(DefaultMapper.Get)); + var genericMethod = getMethod!.MakeGenericMethod(type); + return genericMethod.Invoke(null, null); + } + + /// + /// Maps a record to an object of the given type according to the global mapping configuration. + /// + /// The record to be mapped. + /// The type of object to be mapped. + /// The mapped object. + public static T Map(IRecord record) where T : new() + { + return GetMapper().Map(record); + } + + /// + /// Registers a mapping provider. This will call on the + /// provider, allowing it to register any mappers it wishes. + /// + /// The type of the mapping provider. + public static void RegisterProvider() where T : IMappingProvider, new() + { + var provider = new T(); + provider.CreateMappers(Instance); + } + + IMappingRegistry IMappingRegistry.RegisterMapping(Action> mappingBuilder) + { + var builder = new MappingBuilder(); + mappingBuilder(builder); + var mapper = builder.Build(); + Register(mapper); + return this; + } + + private MethodInfo GetMapMethodForType(Type type) + { + if (_mapMethods.TryGetValue(type, out var method)) + { + return method; + } + + var typedInterface = typeof(IRecordMapper<>).MakeGenericType(type); + var mapMethod = typedInterface.GetMethod(nameof(IRecordMapper.Map)); + _mapMethods[type] = mapMethod; + return mapMethod; + } + + public static object Map(IRecord record, Type type) + { + var mapMethod = Instance.GetMapMethodForType(type); + var mapperForType = GetMapperForType(type); + return mapMethod.Invoke(mapperForType, new[] { (object)record }); + } +} diff --git a/Neo4j.Driver/Neo4j.Driver/Preview/Mapping/RecordPathFinder.cs b/Neo4j.Driver/Neo4j.Driver/Preview/Mapping/RecordPathFinder.cs new file mode 100644 index 000000000..ff6b55df3 --- /dev/null +++ b/Neo4j.Driver/Neo4j.Driver/Preview/Mapping/RecordPathFinder.cs @@ -0,0 +1,75 @@ +// Copyright (c) "Neo4j" +// Neo4j Sweden AB [http://neo4j.com] +// +// This file is part of Neo4j. +// +// Licensed under the Apache License, Version 2.0 (the "License"). +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; + +namespace Neo4j.Driver.Preview.Mapping; + +internal interface IRecordPathFinder +{ + bool TryGetValueByPath(IRecord record, string path, out object value); +} + +internal class RecordPathFinder : IRecordPathFinder +{ + private bool PathCompare(string path, string field) + { + return string.Equals(path, field, StringComparison.InvariantCultureIgnoreCase); + } + + /// + public bool TryGetValueByPath(IRecord record, string path, out object value) + { + value = null; + + foreach (var field in record.Keys) + { + if (PathCompare(path, field)) + { + value = record[field]; + return true; + } + + IReadOnlyDictionary properties = null; + if(record[field] is IEntity entity) + { + properties = entity.Properties; + } + else if(record[field] is IReadOnlyDictionary dict) + { + properties = dict; + } + + if (properties is not null) + { + foreach (var property in properties) + { + if ( + PathCompare(path, property.Key) || + PathCompare(path, $"{field}.{property.Key}")) + { + value = property.Value; + return true; + } + } + } + } + + return false; + } +}