Skip to content

Commit

Permalink
Support for JSON owned entities (#2922)
Browse files Browse the repository at this point in the history
Closes #2548
Closes #2759
  • Loading branch information
roji authored Nov 6, 2023
1 parent f021d6f commit 4a7ef97
Show file tree
Hide file tree
Showing 72 changed files with 7,928 additions and 1,077 deletions.
2 changes: 1 addition & 1 deletion Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<PropertyGroup>
<EFCoreVersion>8.0.0-rc.2.23480.1</EFCoreVersion>
<MicrosoftExtensionsVersion>8.0.0-rc.2.23479.6</MicrosoftExtensionsVersion>
<NpgsqlVersion>8.0.0-rc.2</NpgsqlVersion>
<NpgsqlVersion>8.0.0-rtm-ci.20231103T214000</NpgsqlVersion>
</PropertyGroup>

<ItemGroup>
Expand Down
1 change: 1 addition & 0 deletions EFCore.PG.sln.DotSettings
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,7 @@ The .NET Foundation licenses this file to you under the MIT license.&#xD;
<s:Boolean x:Key="/Default/UserDictionary/Words/=Namer/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=navigations/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=niladic/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=OPENJSON/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=ordinality/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=plpgsql/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=pluralizer/@EntryIndexedValue">True</s:Boolean>
Expand Down
14 changes: 0 additions & 14 deletions src/EFCore.PG/Infrastructure/Internal/NpgsqlModelValidator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -193,20 +193,6 @@ static void ValidateSproc(IStoredProcedure sproc, IDiagnosticsLogger<DbLoggerCat
}
}

/// <inheritdoc />
protected override void ValidateJsonEntities(
IModel model,
IDiagnosticsLogger<DbLoggerCategory.Model.Validation> logger)
{
foreach (var entityType in model.GetEntityTypes())
{
if (entityType.IsMappedToJson())
{
throw new InvalidOperationException(NpgsqlStrings.Ef7JsonMappingNotSupported);
}
}
}

/// <inheritdoc />
protected override void ValidateCompatible(
IProperty property,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ public override ConventionSet CreateConventionSet()
conventionSet.ModelInitializedConventions.Add(valueGenerationStrategyConvention);
conventionSet.ModelInitializedConventions.Add(new RelationalMaxIdentifierLengthConvention(63, Dependencies, RelationalDependencies));

conventionSet.PropertyAddedConventions.Add(new NpgsqlJsonElementHackConvention());

ValueGenerationConvention valueGenerationConvention = new NpgsqlValueGenerationConvention(Dependencies, RelationalDependencies);
ReplaceConvention(conventionSet.EntityTypeBaseTypeChangedConventions, valueGenerationConvention);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Text.Json;
using Npgsql.EntityFrameworkCore.PostgreSQL.Storage.Internal.Mapping;

namespace Npgsql.EntityFrameworkCore.PostgreSQL.Metadata.Conventions;

/// <summary>
/// This convention is a hack around https://github.com/dotnet/efcore/issues/32192. To support the EF owned entity JSON support, EF requires
/// that a lookup of the CLR type <see cref="JsonElement" /> return the provider's special <see cref="JsonTypeMapping"/>. But Npgsql has
/// its own JSON DOM support, where actually mapping <see cref="JsonElement" /> is allowed as a weakly-typed mapping strategy. The two
/// JSON type mappings are incompatible notably because EF's <see cref="JsonTypeMapping" /> is expected to return UTF8 byte data which is
/// then parsed via <see cref="Utf8JsonWriter" /> (and not a string). So for properties actually typed as <see cref="JsonElement" />, we
/// hack here and set the type mapping rather than going through the regular type mapping process.
/// </summary>
public class NpgsqlJsonElementHackConvention : IPropertyAddedConvention
{
private NpgsqlJsonTypeMapping? _jsonTypeMapping;

/// <inheritdoc />
public void ProcessPropertyAdded(IConventionPropertyBuilder propertyBuilder, IConventionContext<IConventionPropertyBuilder> context)
{
var property = propertyBuilder.Metadata;

if (property.ClrType == typeof(JsonElement) && property.GetColumnType() is null)
{
property.SetTypeMapping(_jsonTypeMapping ??= new("jsonb", typeof(JsonElement)));
}
}
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using Microsoft.EntityFrameworkCore.Metadata.Internal;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata.Internal;

namespace Npgsql.EntityFrameworkCore.PostgreSQL.Metadata.Conventions;
Expand Down Expand Up @@ -60,6 +61,16 @@ public override void ProcessPropertyAnnotationChanged(
/// <returns>The store value generation strategy to set for the given property.</returns>
protected override ValueGenerated? GetValueGenerated(IConventionProperty property)
{
// TODO: move to relational?
if (property.DeclaringType.IsMappedToJson()
#pragma warning disable EF1001 // Internal EF Core API usage.
&& property.IsOrdinalKeyProperty()
#pragma warning restore EF1001 // Internal EF Core API usage.
&& (property.DeclaringType as IReadOnlyEntityType)?.FindOwnership()!.IsUnique == false)
{
return ValueGenerated.OnAdd;
}

var declaringTable = property.GetMappedStoreObjects(StoreObjectType.Table).FirstOrDefault();
if (declaringTable.Name == null)
{
Expand Down Expand Up @@ -91,4 +102,4 @@ public override void ProcessPropertyAnnotationChanged(
?? (property.GetValueGenerationStrategy(storeObject, typeMappingSource) != NpgsqlValueGenerationStrategy.None
? ValueGenerated.OnAdd
: null);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,23 +18,9 @@ public class NpgsqlLTreeTranslator : IMethodCallTranslator, IMemberTranslator
private readonly NpgsqlSqlExpressionFactory _sqlExpressionFactory;
private readonly RelationalTypeMapping _boolTypeMapping;
private readonly RelationalTypeMapping _ltreeTypeMapping;
private readonly RelationalTypeMapping _ltreeArrayTypeMapping;
private readonly RelationalTypeMapping _lqueryTypeMapping;
private readonly RelationalTypeMapping _lqueryArrayTypeMapping;
private readonly RelationalTypeMapping _ltxtqueryTypeMapping;

private static readonly MethodInfo IsAncestorOf =
typeof(LTree).GetRuntimeMethod(nameof(LTree.IsAncestorOf), new[] { typeof(LTree) })!;

private static readonly MethodInfo IsDescendantOf =
typeof(LTree).GetRuntimeMethod(nameof(LTree.IsDescendantOf), new[] { typeof(LTree) })!;

private static readonly MethodInfo MatchesLQuery =
typeof(LTree).GetRuntimeMethod(nameof(LTree.MatchesLQuery), new[] { typeof(string) })!;

private static readonly MethodInfo MatchesLTxtQuery =
typeof(LTree).GetRuntimeMethod(nameof(LTree.MatchesLTxtQuery), new[] { typeof(string) })!;

/// <summary>
/// This is an internal API that supports the Entity Framework Core infrastructure and not subject to
/// the same compatibility standards as public APIs. It may be changed or removed without notice in
Expand All @@ -49,9 +35,7 @@ public NpgsqlLTreeTranslator(
_sqlExpressionFactory = sqlExpressionFactory;
_boolTypeMapping = typeMappingSource.FindMapping(typeof(bool), model)!;
_ltreeTypeMapping = typeMappingSource.FindMapping(typeof(LTree), model)!;
_ltreeArrayTypeMapping = typeMappingSource.FindMapping(typeof(LTree[]), model)!;
_lqueryTypeMapping = typeMappingSource.FindMapping("lquery")!;
_lqueryArrayTypeMapping = typeMappingSource.FindMapping("lquery[]")!;
_ltxtqueryTypeMapping = typeMappingSource.FindMapping("ltxtquery")!;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@ public PgNewArrayExpression(
{
Check.NotNull(expressions, nameof(expressions));

if (!type.IsArrayOrGenericList())
if (type.TryGetElementType(typeof(IEnumerable<>)) is null)
{
throw new ArgumentException($"{nameof(PgNewArrayExpression)} must have an array type");
throw new ArgumentException($"{nameof(PgNewArrayExpression)} must have an IEnumerable<T> type");
}

Expressions = expressions;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
namespace Npgsql.EntityFrameworkCore.PostgreSQL.Query.Expressions.Internal;

/// <summary>
/// An expression that represents a PostgreSQL <c>unnest</c> function call in a SQL tree.
/// </summary>
/// <remarks>
/// <para>
/// This expression is just a <see cref="TableValuedFunctionExpression" />, adding the ability to provide an explicit column name
/// for its output (<c>SELECT * FROM unnest(array) AS f(foo)</c>). This is necessary since when the column name isn't explicitly
/// specified, it is automatically identical to the table alias (<c>f</c> above); since the table alias may get uniquified by
/// EF, this would break queries.
/// </para>
/// <para>
/// See <see href="https://www.postgresql.org/docs/current/functions-array.html#ARRAY-FUNCTIONS-TABLE">unnest</see> for more
/// information and examples.
/// </para>
/// <para>
/// This is an internal API that supports the Entity Framework Core infrastructure and not subject to
/// the same compatibility standards as public APIs. It may be changed or removed without notice in
/// any release. You should only use it directly in your code with extreme caution and knowing that
/// doing so can result in application failures when updating to a new Entity Framework Core release.
/// </para>
/// </remarks>
public class PgTableValuedFunctionExpression : TableValuedFunctionExpression, IEquatable<PgTableValuedFunctionExpression>
{
/// <summary>
/// The name of the column to be projected out from the <c>unnest</c> call.
/// </summary>
/// <remarks>
/// This is an internal API that supports the Entity Framework Core infrastructure and not subject to
/// the same compatibility standards as public APIs. It may be changed or removed without notice in
/// any release. You should only use it directly in your code with extreme caution and knowing that
/// doing so can result in application failures when updating to a new Entity Framework Core release.
/// </remarks>
public virtual IReadOnlyList<ColumnInfo>? ColumnInfos { get; }

/// <summary>
/// Whether to project an additional ordinality column containing the index of each element in the array.
/// </summary>
/// <remarks>
/// This is an internal API that supports the Entity Framework Core infrastructure and not subject to
/// the same compatibility standards as public APIs. It may be changed or removed without notice in
/// any release. You should only use it directly in your code with extreme caution and knowing that
/// doing so can result in application failures when updating to a new Entity Framework Core release.
/// </remarks>
public virtual bool WithOrdinality { get; }

/// <summary>
/// This is an internal API that supports the Entity Framework Core infrastructure and not subject to
/// the same compatibility standards as public APIs. It may be changed or removed without notice in
/// any release. You should only use it directly in your code with extreme caution and knowing that
/// doing so can result in application failures when updating to a new Entity Framework Core release.
/// </summary>
public PgTableValuedFunctionExpression(
string alias,
string name,
IReadOnlyList<SqlExpression> arguments,
IReadOnlyList<ColumnInfo>? columnInfos,
bool withOrdinality = true)
: base(alias, name, schema: null, builtIn: true, arguments)
{
ColumnInfos = columnInfos;
WithOrdinality = withOrdinality;
}

/// <summary>
/// This is an internal API that supports the Entity Framework Core infrastructure and not subject to
/// the same compatibility standards as public APIs. It may be changed or removed without notice in
/// any release. You should only use it directly in your code with extreme caution and knowing that
/// doing so can result in application failures when updating to a new Entity Framework Core release.
/// </summary>
public override PgTableValuedFunctionExpression Update(IReadOnlyList<SqlExpression> arguments)
=> !arguments.SequenceEqual(Arguments)
? new PgTableValuedFunctionExpression(Alias, Name, arguments, ColumnInfos, WithOrdinality)
: this;

/// <inheritdoc />
protected override void Print(ExpressionPrinter expressionPrinter)
{
expressionPrinter.Append(Name);
expressionPrinter.Append("(");
expressionPrinter.VisitCollection(Arguments);
expressionPrinter.Append(")");

if (WithOrdinality)
{
expressionPrinter.Append(" WITH ORDINALITY");
}

PrintAnnotations(expressionPrinter);

expressionPrinter.Append(" AS ").Append(Alias);

if (ColumnInfos is not null)
{
expressionPrinter.Append("(");

var isFirst = true;

foreach (var column in ColumnInfos)
{
if (isFirst)
{
isFirst = false;
}
else
{
expressionPrinter.Append(", ");
}

expressionPrinter.Append(column.Name);

if (column.TypeMapping is not null)
{
expressionPrinter.Append(" ").Append(column.TypeMapping.StoreType);
}
}

expressionPrinter.Append(")");
}
}

/// <inheritdoc />
public override bool Equals(object? obj)
=> ReferenceEquals(obj, this) || obj is PgTableValuedFunctionExpression e && Equals(e);

/// <inheritdoc />
public bool Equals(PgTableValuedFunctionExpression? expression)
=> base.Equals(expression)
&& (
expression.ColumnInfos is null && ColumnInfos is null
|| expression.ColumnInfos is not null && ColumnInfos is not null && expression.ColumnInfos.SequenceEqual(ColumnInfos))
&& WithOrdinality == expression.WithOrdinality;

/// <inheritdoc />
public override int GetHashCode()
=> base.GetHashCode();

/// <summary>
/// Defines the name of a column coming out of a <see cref="PgTableValuedFunctionExpression" /> and optionally its type.
/// </summary>
public readonly record struct ColumnInfo(string Name, RelationalTypeMapping? TypeMapping = null);
}
61 changes: 5 additions & 56 deletions src/EFCore.PG/Query/Expressions/Internal/PgUnnestExpression.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ namespace Npgsql.EntityFrameworkCore.PostgreSQL.Query.Expressions.Internal;
/// doing so can result in application failures when updating to a new Entity Framework Core release.
/// </para>
/// </remarks>
public class PgUnnestExpression : TableValuedFunctionExpression
public class PgUnnestExpression : PgTableValuedFunctionExpression
{
/// <summary>
/// The array to be un-nested into a table.
Expand All @@ -44,18 +44,8 @@ public virtual SqlExpression Array
/// any release. You should only use it directly in your code with extreme caution and knowing that
/// doing so can result in application failures when updating to a new Entity Framework Core release.
/// </remarks>
public virtual string ColumnName { get; }

/// <summary>
/// Whether to project an additional ordinality column containing the index of each element in the array.
/// </summary>
/// <remarks>
/// This is an internal API that supports the Entity Framework Core infrastructure and not subject to
/// the same compatibility standards as public APIs. It may be changed or removed without notice in
/// any release. You should only use it directly in your code with extreme caution and knowing that
/// doing so can result in application failures when updating to a new Entity Framework Core release.
/// </remarks>
public virtual bool WithOrdinality { get; }
public virtual string ColumnName
=> ColumnInfos![0].Name;

/// <summary>
/// This is an internal API that supports the Entity Framework Core infrastructure and not subject to
Expand All @@ -64,10 +54,8 @@ public virtual SqlExpression Array
/// doing so can result in application failures when updating to a new Entity Framework Core release.
/// </summary>
public PgUnnestExpression(string alias, SqlExpression array, string columnName, bool withOrdinality = true)
: base(alias, "unnest", schema: null, builtIn: true, new[] { array })
: base(alias, "unnest", new[] { array }, new[] { new ColumnInfo(columnName) }, withOrdinality)
{
ColumnName = columnName;
WithOrdinality = withOrdinality;
}

/// <summary>
Expand All @@ -76,7 +64,7 @@ public PgUnnestExpression(string alias, SqlExpression array, string columnName,
/// any release. You should only use it directly in your code with extreme caution and knowing that
/// doing so can result in application failures when updating to a new Entity Framework Core release.
/// </summary>
public override TableValuedFunctionExpression Update(IReadOnlyList<SqlExpression> arguments)
public override PgUnnestExpression Update(IReadOnlyList<SqlExpression> arguments)
=> arguments is [var singleArgument]
? Update(singleArgument)
: throw new ArgumentException();
Expand All @@ -91,43 +79,4 @@ public virtual PgUnnestExpression Update(SqlExpression array)
=> array == Array
? this
: new PgUnnestExpression(Alias, array, ColumnName, WithOrdinality);

/// <inheritdoc />
protected override void Print(ExpressionPrinter expressionPrinter)
{
expressionPrinter.Append(Name);
expressionPrinter.Append("(");
expressionPrinter.VisitCollection(Arguments);
expressionPrinter.Append(")");

if (WithOrdinality)
{
expressionPrinter.Append(" WITH ORDINALITY");
}

PrintAnnotations(expressionPrinter);

expressionPrinter
.Append(" AS ")
.Append(Alias)
.Append("(")
.Append(ColumnName)
.Append(")");
}

/// <inheritdoc />
public override bool Equals(object? obj)
=> obj != null
&& (ReferenceEquals(this, obj)
|| obj is PgUnnestExpression unnestExpression
&& Equals(unnestExpression));

private bool Equals(PgUnnestExpression unnestExpression)
=> base.Equals(unnestExpression)
&& ColumnName == unnestExpression.ColumnName
&& WithOrdinality == unnestExpression.WithOrdinality;

/// <inheritdoc />
public override int GetHashCode()
=> base.GetHashCode();
}
Loading

0 comments on commit 4a7ef97

Please sign in to comment.