Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion src/EFCore.PG/Metadata/Internal/NpgsqlAnnotationProvider.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore.Metadata.Internal;
using Npgsql.EntityFrameworkCore.PostgreSQL.Internal;

namespace Npgsql.EntityFrameworkCore.PostgreSQL.Metadata.Internal;

Expand Down Expand Up @@ -112,7 +113,13 @@ public override IEnumerable<IAnnotation> For(IColumn column, bool designTime)
yield return new Annotation(
NpgsqlAnnotationNames.TsVectorProperties,
valueGeneratedProperty.GetTsVectorProperties()!
.Select(p2 => valueGeneratedProperty.DeclaringType.FindProperty(p2)!.GetColumnName(tableIdentifier))
.Select(p2 => valueGeneratedProperty.DeclaringType.FindProperty(p2)?.GetColumnName(tableIdentifier)
?? (valueGeneratedProperty.DeclaringType as IReadOnlyEntityType)?.FindNavigation(p2)?.TargetEntityType
.GetContainerColumnName(tableIdentifier)
?? valueGeneratedProperty.DeclaringType.FindComplexProperty(p2)?.ComplexType.GetContainerColumnName(tableIdentifier)
?? throw new InvalidOperationException(
NpgsqlStrings.TsVectorIncludedPropertyNotFound(valueGeneratedProperty.DeclaringType.DisplayName(), p2))
Comment on lines +116 to +121

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't actually an issue. TargetEntityType and ComplexType are non-nullable properties. Due to how the null-conditional operator (?.) works, if FindNavigation(p2) or FindComplexProperty(p2) returns null, the entire expression short-circuits to null.

)
.ToArray());
}

Expand Down
8 changes: 8 additions & 0 deletions src/EFCore.PG/Properties/NpgsqlStrings.Designer.cs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions src/EFCore.PG/Properties/NpgsqlStrings.resx
Original file line number Diff line number Diff line change
Expand Up @@ -268,4 +268,7 @@
<data name="WithoutOverlapsRequiresRangeType" xml:space="preserve">
<value>WITHOUT OVERLAPS on {keyOrIndexName} in entity type '{entityType}' requires the last column to be a PostgreSQL range type (e.g. daterange, tsrange, tstzrange), but property '{property}' has type '{propertyType}'.</value>
</data>
<data name="TsVectorIncludedPropertyNotFound" xml:space="preserve">
<value>Could not find property, navigation or complex property '{property}' on entity type '{entityType}' for generated tsvector column</value>
</data>
</root>
22 changes: 22 additions & 0 deletions test/EFCore.PG.FunctionalTests/Migrations/MigrationsNpgsqlTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3193,6 +3193,28 @@ await Test(
"""ALTER TABLE "People" ADD "SearchColumn" tsvector GENERATED ALWAYS AS (to_tsvector('english', "RequiredTextColumn" || ' ' || coalesce("OptionalTextColumn", '')) || jsonb_to_tsvector('english', "RequiredJsonbColumn", '"all"') || json_to_tsvector('english', coalesce("OptionalJsonColumn", '{}'), '"all"')) STORED;""");
}

[Fact]
public virtual async Task Add_column_generated_tsvector_over_json_owned_navigation()
{
await Test(
builder =>
{
builder.Entity("People").OwnsOne("JsonOwned", "JsonOwned").ToJson();
},
_ => { },
builder => builder.Entity("People").Property<NpgsqlTsVector>("SearchColumn")
.IsGeneratedTsVectorColumn("english", "JsonOwned"),
model =>
{
var table = Assert.Single(model.Tables);
var column = Assert.Single(table.Columns, c => c.Name == "SearchColumn");
Assert.Equal("tsvector", column.StoreType);
});

AssertSql(
"""ALTER TABLE "People" ADD "SearchColumn" tsvector GENERATED ALWAYS AS (jsonb_to_tsvector('english', coalesce("JsonOwned", '{}'), '"all"')) STORED;""");
}

[Fact]
public virtual async Task Alter_column_generated_tsvector_change_config()
{
Expand Down
178 changes: 178 additions & 0 deletions test/EFCore.PG.Tests/Migrations/NpgsqlAnnotationProviderTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata.Internal;

namespace Npgsql.EntityFrameworkCore.PostgreSQL.Migrations;

public class NpgsqlAnnotationProviderTest
{
[Fact]
public void ForColumn_resolves_tsvector_properties_over_json_navigation()
{
var modelBuilder = NpgsqlTestHelpers.Instance.CreateConventionBuilder();

modelBuilder.Entity<Blog>(
eb =>
{
eb.OwnsOne(b => b.Owned).ToJson();
eb.Property(b => b.SearchVector)
.IsGeneratedTsVectorColumn("english", nameof(Blog.Owned));
});

CheckTsVectorProperties(modelBuilder, ["Owned"]);
}

[Fact]
public void ForColumn_resolves_tsvector_properties_over_renamed_json_navigation()
{
var modelBuilder = NpgsqlTestHelpers.Instance.CreateConventionBuilder();

modelBuilder.Entity<Blog>(
eb =>
{
eb.OwnsOne(b => b.Owned).ToJson("owned_renamed");
eb.Property(b => b.SearchVector)
.IsGeneratedTsVectorColumn("english", nameof(Blog.Owned));
});

CheckTsVectorProperties(modelBuilder, ["owned_renamed"]);
}

[Fact]
public void ForColumn_resolves_tsvector_properties_over_json_complex_property()
{
var modelBuilder = NpgsqlTestHelpers.Instance.CreateConventionBuilder();

modelBuilder.Entity<Blog>().ComplexProperty(b => b.Owned).ToJson();
modelBuilder.Entity<Blog>().Property(b => b.SearchVector).IsGeneratedTsVectorColumn("english", nameof(Blog.Owned));

CheckTsVectorProperties(modelBuilder, ["Owned"]);
}

[Fact]
public void ForColumn_resolves_tsvector_properties_over_renamed_json_complex_property()
{
var modelBuilder = NpgsqlTestHelpers.Instance.CreateConventionBuilder();

modelBuilder.Entity<Blog>().ComplexProperty(b => b.Owned).ToJson("owned_renamed");
modelBuilder.Entity<Blog>().Property(b => b.SearchVector).IsGeneratedTsVectorColumn("english", nameof(Blog.Owned));

CheckTsVectorProperties(modelBuilder, ["owned_renamed"]);
}

[Fact]
public void ForColumn_resolves_tsvector_properties_over_mixed_text_jsonb_and_json_navigation()
{
var modelBuilder = NpgsqlTestHelpers.Instance.CreateConventionBuilder();

modelBuilder.Entity(
"Blogs", eb =>
{
eb.Property<int>("Id");
eb.Property<string>("Text");
eb.Property<string>("Jsonb").HasColumnType("jsonb");
eb.OwnsOne("Owned", "Owned").ToJson();
eb.Property<NpgsqlTsVector>("SearchVector")
.IsGeneratedTsVectorColumn("english", "Text", "Jsonb", "Owned");
});

CheckTsVectorProperties(modelBuilder, ["Text", "Jsonb", "Owned"]);
}

[Fact]
public void ForColumn_resolves_tsvector_properties_when_declared_on_complex_type()
{
var modelBuilder = NpgsqlTestHelpers.Instance.CreateConventionBuilder();

modelBuilder.Entity<BlogWithComplexProperty>(
eb =>
{
eb.ComplexProperty(b => b.ComplexProperty, cb =>
{
cb.Property(cp => cp.Something).IsRequired();
// using [nameof(ComplexProperty.Something)] leads to an exception, see #3892
cb.Property(cp => cp.SearchVector).Metadata.SetTsVectorProperties(new string[] { nameof(ComplexProperty.Something) });
});
});

CheckTsVectorProperties(modelBuilder, ["ComplexProperty_Something"], searchVectorName: "ComplexProperty_SearchVector");
}

[Fact]
public void ForColumn_throws_when_included_navigation_is_not_mapped_to_json()
{
var modelBuilder = NpgsqlTestHelpers.Instance.CreateConventionBuilder();

modelBuilder.Entity(
"Blogs", eb =>
{
eb.Property<int>("Id");
eb.OwnsOne("Owned", "Owned");
eb.Property<NpgsqlTsVector>("SearchVector")
.IsGeneratedTsVectorColumn("english", "Owned");
});

var exception = Assert.Throws<InvalidOperationException>(() =>
{
var model = modelBuilder.FinalizeModel(designTime: true);
model.GetRelationalModel();
});
Assert.Equal(
"Could not find property, navigation or complex property 'Owned' on entity type 'Blogs (Dictionary<string, object>)' for generated tsvector column",
exception.Message);
}

[Fact]
public void ForColumn_throws_when_included_name_does_not_resolve()
{
var modelBuilder = NpgsqlTestHelpers.Instance.CreateConventionBuilder();

modelBuilder.Entity(
"Blogs", eb =>
{
eb.Property<int>("Id");
eb.OwnsOne("Owned", "Owned");
eb.Property<NpgsqlTsVector>("SearchVector")
.IsGeneratedTsVectorColumn("english", "Owne");
});

var exception = Assert.Throws<InvalidOperationException>(() =>
{
var model = modelBuilder.FinalizeModel(designTime: true);
model.GetRelationalModel();
});
Assert.Equal(
"Could not find property, navigation or complex property 'Owne' on entity type 'Blogs (Dictionary<string, object>)' for generated tsvector column",
exception.Message);
}

private class Blog
{
public int Id { get; set; }
public Owned Owned { get; set; }
public NpgsqlTsVector SearchVector { get; set; }
}

private class Owned { public string Something { get; set; } }

private class BlogWithComplexProperty
{
public int Id { get; set; }
public ComplexProperty ComplexProperty { get; set; }
}

private class ComplexProperty
{
public string Something { get; set; }
public NpgsqlTsVector SearchVector { get; set; }
}

private void CheckTsVectorProperties(TestHelpers.TestModelBuilder modelBuilder, IReadOnlyCollection<string> expectedProperties,
string searchVectorName = "SearchVector")
{
var model = modelBuilder.FinalizeModel(designTime: true);
var relationalModel = model.GetRelationalModel();

var column = Assert.Single(relationalModel.Tables.Single().Columns, c => c.Name == searchVectorName);
var tsVectorProperties = (IReadOnlyCollection<string>)column.FindAnnotation(NpgsqlAnnotationNames.TsVectorProperties)!.Value!;
Assert.Equal(expectedProperties, tsVectorProperties);
}
}