Skip to content

Commit

Permalink
Map JsonDocument and JsonElement
Browse files Browse the repository at this point in the history
Issue #981 enabled mapping arbitrary user POCOs to PG JON.
This adds support for mapping the System.Text.Json DOM types
JsonDocument and JsonElement, which are more relevant for
unstructured scenarios. Traversal is implemented as well
as some of the PG JSON operators.

Closes #1002
  • Loading branch information
roji committed Sep 2, 2019
1 parent d11cbcb commit 210f94c
Show file tree
Hide file tree
Showing 18 changed files with 1,023 additions and 84 deletions.
2 changes: 1 addition & 1 deletion Directory.Build.props
Expand Up @@ -8,7 +8,7 @@

<PropertyGroup Label="Package Versions">
<VersionPrefix>3.0.0</VersionPrefix>
<NpgsqlVersion>4.1.0-preview1</NpgsqlVersion>
<NpgsqlVersion>4.1.0-ci.2201</NpgsqlVersion>
<EFCoreVersion>3.0.0-preview9.19421.11</EFCoreVersion>
<MicrosoftExtensionsVersion>3.0.0-preview9.19421.6</MicrosoftExtensionsVersion>
</PropertyGroup>
Expand Down
1 change: 1 addition & 0 deletions EFCore.PG.sln.DotSettings
Expand Up @@ -125,6 +125,7 @@
<s:Boolean x:Key="/Default/UserDictionary/Words/=ownerns/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=pgcrypto/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=plpgsql/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Poco/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=postgis/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=postgre/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=postgresql/@EntryIndexedValue">True</s:Boolean>
Expand Down
59 changes: 59 additions & 0 deletions src/EFCore.PG/Extensions/NpgsqlJsonDbFunctionsExtensions.cs
@@ -0,0 +1,59 @@
using System;
using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;

namespace Npgsql.EntityFrameworkCore.PostgreSQL.Extensions
{
/// <summary>
/// Provides methods for <see cref="JsonElement"/> supporting translation to PostgreSQL JSON operators and functions.
/// </summary>
public static class NpgsqlJsonDbFunctionsExtensions
{
/// <summary>
/// Checks if <paramref name="left"/> contains <paramref name="right"/> as top-level entries.
/// </summary>
public static bool JsonContains(this DbFunctions _, JsonElement left, JsonElement right)
=> throw ClientEvaluationNotSupportedException();

/// <summary>
/// Checks if <paramref name="left"/> contains <paramref name="right"/> as top-level entries.
/// </summary>
public static bool JsonContains(this DbFunctions _, JsonElement left, string right)
=> throw ClientEvaluationNotSupportedException();

/// <summary>
/// Checks if <paramref name="left"/> is contained in <paramref name="right"/> as top-level entries.
/// </summary>
public static bool JsonContained(this DbFunctions _, JsonElement left, JsonElement right)
=> throw ClientEvaluationNotSupportedException();

/// <summary>
/// Checks if <paramref name="left"/> is contained in <paramref name="right"/> as top-level entries.
/// </summary>
public static bool JsonContained(this DbFunctions _, string left, JsonElement right)
=> throw ClientEvaluationNotSupportedException();

/// <summary>
/// Checks if <paramref name="key"/> exists as a top-level key within <paramref name="element"/>.
/// </summary>
public static bool JsonExists(this DbFunctions _, JsonElement element, string key)
=> throw ClientEvaluationNotSupportedException();

/// <summary>
/// Checks if any of the given <paramref name="keys"/> exist as top-level keys within <paramref name="element"/>.
/// </summary>
public static bool JsonExistAny(this DbFunctions _, JsonElement element, string[] keys)
=> throw ClientEvaluationNotSupportedException();

/// <summary>
/// Checks if all of the given <paramref name="keys"/> exist as top-level keys within <paramref name="element"/>.
/// </summary>
public static bool JsonExistAll(this DbFunctions _, JsonElement element, string[] keys)
=> throw ClientEvaluationNotSupportedException();

static NotSupportedException ClientEvaluationNotSupportedException([CallerMemberName] string method = default)
=> new NotSupportedException($"{method} is only intended for use via SQL translation as part of an EF Core LINQ query.");
}
}
2 changes: 1 addition & 1 deletion src/EFCore.PG/Extensions/NpgsqlNetworkExtensions.cs
@@ -1,4 +1,5 @@
using System;
using System.Diagnostics.CodeAnalysis;
using System.Net;
using System.Net.NetworkInformation;
using System.Runtime.CompilerServices;
Expand Down Expand Up @@ -1056,7 +1057,6 @@ public static PhysicalAddress Set7BitMac8([CanBeNull] this DbFunctions _, Physic
/// <returns>
/// A <see cref="NotSupportedException"/>.
/// </returns>
[NotNull]
static NotSupportedException ClientEvaluationNotSupportedException([CallerMemberName] string method = default)
=> new NotSupportedException($"{method} is only intended for use via SQL translation as part of an EF Core LINQ query.");

Expand Down
Expand Up @@ -33,12 +33,12 @@ public class NpgsqlArrayMethodTranslator : IMethodCallTranslator
[NotNull]
readonly NpgsqlSqlExpressionFactory _sqlExpressionFactory;
[NotNull]
readonly NpgsqlJsonTranslator _jsonTranslator;
readonly NpgsqlJsonPocoTranslator _jsonPocoTranslator;

public NpgsqlArrayMethodTranslator(NpgsqlSqlExpressionFactory sqlExpressionFactory, NpgsqlJsonTranslator jsonTranslator)
public NpgsqlArrayMethodTranslator(NpgsqlSqlExpressionFactory sqlExpressionFactory, NpgsqlJsonPocoTranslator jsonPocoTranslator)
{
_sqlExpressionFactory = sqlExpressionFactory;
_jsonTranslator = jsonTranslator;
_jsonPocoTranslator = jsonPocoTranslator;
}

[CanBeNull]
Expand All @@ -60,7 +60,7 @@ public SqlExpression Translate(SqlExpression instance, MethodInfo method, IReadO
{
return
_sqlExpressionFactory.GreaterThan(
_jsonTranslator.TranslateArrayLength(arrayOperand) ??
_jsonPocoTranslator.TranslateArrayLength(arrayOperand) ??
_sqlExpressionFactory.Function("cardinality", arguments, typeof(int?)),
_sqlExpressionFactory.Constant(0));
}
Expand Down
@@ -0,0 +1,192 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Text.Json;
using Microsoft.EntityFrameworkCore.Query;
using Microsoft.EntityFrameworkCore.Query.SqlExpressions;
using Microsoft.EntityFrameworkCore.Storage;
using Npgsql.EntityFrameworkCore.PostgreSQL.Extensions;
using Npgsql.EntityFrameworkCore.PostgreSQL.Query.Expressions.Internal;
using Npgsql.EntityFrameworkCore.PostgreSQL.Query.Internal;
using Npgsql.EntityFrameworkCore.PostgreSQL.Storage.Internal.Mapping;

namespace Npgsql.EntityFrameworkCore.PostgreSQL.Query.ExpressionTranslators.Internal
{
public class NpgsqlJsonDomTranslator : IMemberTranslator, IMethodCallTranslator
{
static readonly MemberInfo RootElement = typeof(JsonDocument).GetProperty(nameof(JsonDocument.RootElement));
static readonly MethodInfo GetProperty = typeof(JsonElement).GetRuntimeMethod(nameof(JsonElement.GetProperty), new[] { typeof(string) });
static readonly MethodInfo GetArrayLength = typeof(JsonElement).GetRuntimeMethod(nameof(JsonElement.GetArrayLength), Type.EmptyTypes);

static readonly MethodInfo ArrayIndexer = typeof(JsonElement).GetProperties()
.Single(p => p.GetIndexParameters().Length == 1 && p.GetIndexParameters()[0].ParameterType == typeof(int))
.GetMethod;

static readonly string[] GetMethods =
{
nameof(JsonElement.GetBoolean),
nameof(JsonElement.GetDateTime),
nameof(JsonElement.GetDateTimeOffset),
nameof(JsonElement.GetDecimal),
nameof(JsonElement.GetDouble),
nameof(JsonElement.GetGuid),
nameof(JsonElement.GetInt16),
nameof(JsonElement.GetInt32),
nameof(JsonElement.GetInt64),
nameof(JsonElement.GetSingle),
nameof(JsonElement.GetString)
};

readonly NpgsqlSqlExpressionFactory _sqlExpressionFactory;
readonly RelationalTypeMapping _stringTypeMapping;
readonly RelationalTypeMapping _boolTypeMapping;
readonly RelationalTypeMapping _jsonbTypeMapping;

public NpgsqlJsonDomTranslator(NpgsqlSqlExpressionFactory sqlExpressionFactory, IRelationalTypeMappingSource typeMappingSource)
{
_sqlExpressionFactory = sqlExpressionFactory;
_stringTypeMapping = typeMappingSource.FindMapping(typeof(string));
_boolTypeMapping = typeMappingSource.FindMapping(typeof(bool));
_jsonbTypeMapping = typeMappingSource.FindMapping("jsonb");
}

public SqlExpression Translate(SqlExpression instance, MemberInfo member, Type returnType)
{
if (member.DeclaringType != typeof(JsonDocument))
return null;

if (member == RootElement &&
instance is ColumnExpression column &&
column.TypeMapping is NpgsqlJsonTypeMapping)
{
// Simply get rid of the RootElement member access
return column;
}

return null;
}

public SqlExpression Translate(SqlExpression instance, MethodInfo method, IReadOnlyList<SqlExpression> arguments)
=> TranslateJsonElementMethod(instance, method, arguments) ??
TranslateDbFunctionsMethod(method, arguments);

public SqlExpression TranslateJsonElementMethod(SqlExpression instance, MethodInfo method, IReadOnlyList<SqlExpression> arguments)
{
if (method.DeclaringType != typeof(JsonElement) ||
!(instance.TypeMapping is NpgsqlJsonTypeMapping mapping))
{
return null;
}

if (method == GetProperty || method == ArrayIndexer)
{
// The first time we see a JSON traversal it's on a column - create a JsonTraversalExpression.
// Traversals on top of that get appended into the same expression.
return instance is ColumnExpression columnExpression
? _sqlExpressionFactory.JsonTraversal(columnExpression, arguments, false, typeof(string), mapping)
: instance is JsonTraversalExpression prevPathTraversal
? prevPathTraversal.Append(_sqlExpressionFactory.ApplyDefaultTypeMapping(arguments[0]))
: null;
}

if (GetMethods.Contains(method.Name) &&
arguments.Count == 0 &&
instance is JsonTraversalExpression traversal)
{
var traversalToText = new JsonTraversalExpression(
traversal.Expression,
traversal.Path,
returnsText: true,
typeof(string),
_stringTypeMapping);

return method.Name == nameof(JsonElement.GetString)
? traversalToText
: ConvertFromText(traversalToText, method.ReturnType);
}

if (method == GetArrayLength)
{
return _sqlExpressionFactory.Function(
mapping.IsJsonb ? "jsonb_array_length" : "json_array_length",
new[] { instance }, typeof(int));
}

if (method.Name.StartsWith("TryGet") && arguments.Count == 0)
throw new InvalidOperationException($"The TryGet* methods on {nameof(JsonElement)} aren't translated yet, use Get* instead.'");

return null;
}

public SqlExpression TranslateDbFunctionsMethod(MethodInfo method, IReadOnlyList<SqlExpression> arguments)
{
if (arguments.Any(a => a.TypeMapping is NpgsqlJsonTypeMapping jsonMapping && !jsonMapping.IsJsonb))
throw new InvalidOperationException("JSON methods on EF.Functions only support the jsonb type, not json.");

return method.Name switch
{
nameof(NpgsqlJsonDbFunctionsExtensions.JsonContains) => new SqlCustomBinaryExpression(
_sqlExpressionFactory.ApplyDefaultTypeMapping(arguments[1]),
_sqlExpressionFactory.ApplyDefaultTypeMapping(arguments[2]),
"@>",
typeof(bool),
_boolTypeMapping),

nameof(NpgsqlJsonDbFunctionsExtensions.JsonContained) => new SqlCustomBinaryExpression(
_sqlExpressionFactory.ApplyDefaultTypeMapping(arguments[1]),
_sqlExpressionFactory.ApplyDefaultTypeMapping(arguments[2]),
"<@",
typeof(bool),
_boolTypeMapping),

nameof(NpgsqlJsonDbFunctionsExtensions.JsonExists) => new SqlCustomBinaryExpression(
_sqlExpressionFactory.ApplyDefaultTypeMapping(arguments[1]),
_sqlExpressionFactory.ApplyDefaultTypeMapping(arguments[2]),
"?",
typeof(bool),
_boolTypeMapping),

nameof(NpgsqlJsonDbFunctionsExtensions.JsonExistAny) => new SqlCustomBinaryExpression(
_sqlExpressionFactory.ApplyDefaultTypeMapping(arguments[1]),
_sqlExpressionFactory.ApplyDefaultTypeMapping(arguments[2]),
"?|",
typeof(bool),
_boolTypeMapping),

nameof(NpgsqlJsonDbFunctionsExtensions.JsonExistAll) => new SqlCustomBinaryExpression(
_sqlExpressionFactory.ApplyDefaultTypeMapping(arguments[1]),
_sqlExpressionFactory.ApplyDefaultTypeMapping(arguments[2]),
"?&",
typeof(bool),
_boolTypeMapping),

_ => null
};
}

// The PostgreSQL traversal operator always returns text, so we need to convert to int, bool, etc.
SqlExpression ConvertFromText(SqlExpression expression, Type returnType)
{
switch (Type.GetTypeCode(returnType))
{
case TypeCode.Boolean:
case TypeCode.Byte:
case TypeCode.DateTime:
case TypeCode.Decimal:
case TypeCode.Double:
case TypeCode.Int16:
case TypeCode.Int32:
case TypeCode.Int64:
case TypeCode.SByte:
case TypeCode.Single:
case TypeCode.UInt16:
case TypeCode.UInt32:
case TypeCode.UInt64:
return _sqlExpressionFactory.Convert(expression, returnType, _sqlExpressionFactory.FindMapping(returnType));
default:
return expression;
}
}
}
}

0 comments on commit 210f94c

Please sign in to comment.