Browse Source

Query improvements.

pull/1293/head
Sebastian Stehle 7 months ago
parent
commit
8f607a680a
  1. 2
      backend/src/Squidex.Data.EntityFramework/Infrastructure/Extensions.cs
  2. 1
      backend/src/Squidex.Data.EntityFramework/Infrastructure/PooledDbNamedContextFactory.cs
  3. 7
      backend/src/Squidex.Data.EntityFramework/Infrastructure/Queries/SqlDialect.cs
  4. 26
      backend/src/Squidex.Data.EntityFramework/Infrastructure/Queries/SqlDialectInitializer.cs
  5. 5
      backend/src/Squidex.Data.EntityFramework/Providers/MySql/Content/JsonFunction.cs
  6. 14
      backend/src/Squidex.Data.EntityFramework/Providers/MySql/Extensions.cs
  7. 121
      backend/src/Squidex.Data.EntityFramework/Providers/MySql/JsonFunction.cs
  8. 72
      backend/src/Squidex.Data.EntityFramework/Providers/MySql/MySqlDialect.cs
  9. 409
      backend/src/Squidex.Data.EntityFramework/Providers/MySql/json_function.sql
  10. 111
      backend/src/Squidex.Data.EntityFramework/Providers/Postgres/JsonFunction.cs
  11. 61
      backend/src/Squidex.Data.EntityFramework/Providers/Postgres/PostgresDialect.cs
  12. 330
      backend/src/Squidex.Data.EntityFramework/Providers/Postgres/json_function.sql
  13. 14
      backend/src/Squidex.Data.EntityFramework/Providers/SqlServer/Extensions.cs
  14. 153
      backend/src/Squidex.Data.EntityFramework/Providers/SqlServer/JsonFunction.cs
  15. 69
      backend/src/Squidex.Data.EntityFramework/Providers/SqlServer/SqlServerDialect.cs
  16. 313
      backend/src/Squidex.Data.EntityFramework/Providers/SqlServer/json_function.sql
  17. 9
      backend/src/Squidex.Data.EntityFramework/Squidex.Data.EntityFramework.csproj
  18. 462
      backend/tests/Squidex.Data.Tests/EntityFramework/Infrastructure/Queries/EFQueryTests.cs
  19. 2
      backend/tests/Squidex.Data.Tests/EntityFramework/TestHelpers/MySqlFixture.cs
  20. 2
      backend/tests/Squidex.Data.Tests/EntityFramework/TestHelpers/PostgresFixture.cs
  21. 2
      backend/tests/Squidex.Data.Tests/EntityFramework/TestHelpers/SqlServerFixture.cs
  22. 35
      frontend/refactor.js

2
backend/src/Squidex.Data.EntityFramework/Infrastructure/Extensions.cs

@ -6,13 +6,11 @@
// ==========================================================================
using System.Linq.Expressions;
using Google.Protobuf;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Query;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using PhenX.EntityFrameworkCore.BulkInsert.Extensions;
using PhenX.EntityFrameworkCore.BulkInsert.Options;
using Squidex.Domain.Apps.Entities;

1
backend/src/Squidex.Data.EntityFramework/Infrastructure/PooledDbNamedContextFactory.cs

@ -7,7 +7,6 @@
using System.Collections.Concurrent;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Internal;
#pragma warning disable EF1001 // Internal EF Core API usage.

7
backend/src/Squidex.Data.EntityFramework/Infrastructure/Queries/SqlDialect.cs

@ -7,6 +7,7 @@
using System.Collections;
using System.Text;
using Microsoft.EntityFrameworkCore;
namespace Squidex.Infrastructure.Queries;
@ -19,6 +20,12 @@ public class SqlDialect
return false;
}
public virtual Task InitializeAsync(DbContext dbContext,
CancellationToken ct)
{
return Task.CompletedTask;
}
public virtual string BuildSelectStatement(SqlQuery request)
{
var sb = new StringBuilder("SELECT");

26
backend/src/Squidex.Data.EntityFramework/Infrastructure/Queries/SqlDialectInitializer.cs

@ -0,0 +1,26 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Microsoft.EntityFrameworkCore;
using Squidex.Hosting;
namespace Squidex.Infrastructure.Queries;
public sealed class SqlDialectInitializer<TContext>(IDbContextFactory<TContext> dbContextFactory)
: IInitializable where TContext : DbContext
{
public async Task InitializeAsync(CancellationToken ct)
{
await using var dbContext = await dbContextFactory.CreateDbContextAsync(ct);
if (dbContext is not IDbContextWithDialect withDialect)
{
return;
}
await withDialect.Dialect.InitializeAsync(dbContext, ct);
}
}

5
backend/src/Squidex.Data.EntityFramework/Providers/MySql/Content/JsonFunction.cs

@ -0,0 +1,5 @@
namespace Squidex.Providers.MySql.Content;
public static class JsonFunction
{
}

14
backend/src/Squidex.Data.EntityFramework/Providers/MySql/Extensions.cs

@ -17,7 +17,14 @@ internal static class Extensions
{
sb.Append('`');
sb.Append(path[0]);
sb.Append("`, \'$");
sb.Append("`, ");
sb.AppendJsonPropertyPath(path);
return sb;
}
public static StringBuilder AppendJsonPropertyPath(this StringBuilder sb, PropertyPath path)
{
sb.Append("\'$");
foreach (var property in path.Skip(1))
{
@ -38,6 +45,11 @@ internal static class Extensions
return sb;
}
public static string JsonSubPath(this PropertyPath path)
{
return new StringBuilder().AppendJsonPropertyPath(path).ToString();
}
public static string JsonPath(this PropertyPath path)
{
return new StringBuilder().AppendJsonPath(path).ToString();

121
backend/src/Squidex.Data.EntityFramework/Providers/MySql/JsonFunction.cs

@ -0,0 +1,121 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Microsoft.EntityFrameworkCore;
using Squidex.Infrastructure.Queries;
namespace Squidex.Providers.MySql;
public static class JsonFunction
{
private const int TypeAny = 0;
private const int TypeNull = 1;
private const int TypeText = 2;
private const int TypeBoolean = 3;
private const int TypeNumber = 4;
private static readonly Dictionary<(int Type, CompareOperator Operator), string> Functions = new()
{
[(TypeAny, CompareOperator.Empty)] = "json_empty",
[(TypeAny, CompareOperator.Exists)] = "json_exists",
[(TypeNull, CompareOperator.Equals)] = "json_null_equals",
[(TypeNull, CompareOperator.NotEquals)] = "json_null_notequals",
[(TypeText, CompareOperator.Contains)] = "json_text_contains",
[(TypeText, CompareOperator.EndsWith)] = "json_text_endswith",
[(TypeText, CompareOperator.Equals)] = "json_text_equals",
[(TypeText, CompareOperator.GreaterThan)] = "json_text_greaterthan",
[(TypeText, CompareOperator.GreaterThanOrEqual)] = "json_text_greaterthanorequal",
[(TypeText, CompareOperator.In)] = "json_text_in",
[(TypeText, CompareOperator.LessThan)] = "json_text_lessthan",
[(TypeText, CompareOperator.LessThanOrEqual)] = "json_text_lessthanorequal",
[(TypeText, CompareOperator.Matchs)] = "json_text_matchs",
[(TypeText, CompareOperator.NotEquals)] = "json_text_notequals",
[(TypeText, CompareOperator.StartsWith)] = "json_text_startswith",
[(TypeBoolean, CompareOperator.Equals)] = "json_boolean_equals",
[(TypeBoolean, CompareOperator.In)] = "json_boolean_in",
[(TypeBoolean, CompareOperator.NotEquals)] = "json_boolean_notequals",
[(TypeNumber, CompareOperator.Equals)] = "json_number_equals",
[(TypeNumber, CompareOperator.GreaterThan)] = "json_number_greaterthan",
[(TypeNumber, CompareOperator.GreaterThanOrEqual)] = "json_number_greaterthanorequal",
[(TypeNumber, CompareOperator.In)] = "json_number_in",
[(TypeNumber, CompareOperator.LessThan)] = "json_number_lessthan",
[(TypeNumber, CompareOperator.LessThanOrEqual)] = "json_number_lessthanorequal",
[(TypeNumber, CompareOperator.NotEquals)] = "json_number_notequals",
};
public static async Task InitializeAsync(DbContext dbContext,
CancellationToken ct)
{
var sqlStream = typeof(MySqlDialect).Assembly.GetManifestResourceStream("Squidex.Providers.MySql.json_function.sql");
var sqlText = await new StreamReader(sqlStream!).ReadToEndAsync(ct);
sqlText = sqlText.Replace("{", "{{", StringComparison.Ordinal);
sqlText = sqlText.Replace("}", "}}", StringComparison.Ordinal);
var statements = sqlText.Split(";;", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
// We want to filter out the drop statements and multiple function creations are not supported.
foreach (var statement in statements)
{
#if RELEASE
if (statement.StartsWith("DROP", StringComparison.Ordinal))
{
continue;
}
#endif
await dbContext.Database.ExecuteSqlRawAsync(statement, ct);
}
}
public static string Create(PropertyPath path, CompareOperator op, ClrValue value, string formattedValue)
{
var type = -1;
if (op is CompareOperator.Exists or CompareOperator.Empty)
{
type = TypeAny;
}
else
{
switch (value.ValueType)
{
case ClrValueType.Single:
case ClrValueType.Double:
case ClrValueType.Int32:
case ClrValueType.Int64:
type = TypeNumber;
break;
case ClrValueType.Instant:
case ClrValueType.Guid:
case ClrValueType.String:
type = TypeText;
break;
case ClrValueType.Boolean:
type = TypeBoolean;
break;
case ClrValueType.Null:
type = TypeNull;
break;
}
}
if (!Functions.TryGetValue((type, op), out var fn))
{
throw new NotSupportedException($"No jsonb function for type={value.ValueType}, operator={op}.");
}
if (type is TypeNull or TypeAny)
{
return $"{fn}(`{path[0]}`, {path.JsonSubPath()})";
}
if (value.IsList)
{
return $"{fn}(`{path[0]}`, {path.JsonSubPath()}, JSON_ARRAY({formattedValue}))";
}
return $"{fn}(`{path[0]}`, {path.JsonSubPath()}, {formattedValue})";
}
}

72
backend/src/Squidex.Data.EntityFramework/Providers/MySql/MySqlDialect.cs

@ -5,7 +5,7 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Text;
using Microsoft.EntityFrameworkCore;
using MySqlConnector;
using Squidex.Infrastructure.Queries;
@ -19,6 +19,12 @@ public sealed class MySqlDialect : SqlDialect
{
}
public override Task InitializeAsync(DbContext dbContext,
CancellationToken ct)
{
return JsonFunction.InitializeAsync(dbContext, ct);
}
public override bool IsDuplicateIndexException(Exception exception, string name)
{
return exception is MySqlException ex && ex.Number == 1061;
@ -107,69 +113,7 @@ public sealed class MySqlDialect : SqlDialect
{
if (isJson)
{
var sqlPath = path.JsonPath();
var sqlOp = FormatOperator(op, value);
var sqlRhs = FormatValues(op, value, queryParameters);
string BuildCondition(string path)
{
var isNumeric = value.ValueType is
ClrValueType.Single or
ClrValueType.Double or
ClrValueType.Int32 or
ClrValueType.Int64;
if (isNumeric)
{
return $"""
(CASE WHEN JSON_TYPE({path}) IN ('INTEGER', 'DOUBLE', 'DECIMAL')
THEN CAST(JSON_UNQUOTE({path}) AS DECIMAL(65,10)) {sqlOp} {sqlRhs}
ELSE FALSE
END)
""";
}
var isBoolean = value.ValueType is ClrValueType.Boolean;
if (isBoolean)
{
return $"""
(CASE WHEN JSON_TYPE({path}) = 'BOOLEAN'
THEN IF(JSON_UNQUOTE({path}) = 'true', TRUE, FALSE) {sqlOp} {sqlRhs}
ELSE FALSE
END)
""";
}
var isString = value.ValueType is
ClrValueType.Instant or
ClrValueType.Guid or
ClrValueType.String;
if (isString)
{
return $"JSON_UNQUOTE({path}) {sqlOp} {sqlRhs}";
}
var isNull = value.ValueType is ClrValueType.Null;
if (isNull)
{
var nullOp = FormatOperator(op, "null");
return $"COALESCE(JSON_TYPE({path}), 'NULL') {nullOp} 'NULL'";
}
return base.Where(path, op, value, queryParameters, false);
}
return $"""
CASE WHEN JSON_TYPE(JSON_EXTRACT({sqlPath})) = 'ARRAY'
THEN EXISTS (
SELECT 1
FROM JSON_TABLE(JSON_EXTRACT({sqlPath}), '$[*]' COLUMNS (
__element JSON PATH '$'
)) AS __jt
WHERE {BuildCondition("__element")}
)
ELSE {BuildCondition($"JSON_EXTRACT({sqlPath})")}
END
""";
return JsonFunction.Create(path, op, value, FormatValues(op, value, queryParameters, true));
}
return base.Where(path, op, value, queryParameters, isJson);

409
backend/src/Squidex.Data.EntityFramework/Providers/MySql/json_function.sql

@ -0,0 +1,409 @@
-- =============================================================================
-- TYPE-AGNOSTIC
-- =============================================================================
DROP FUNCTION IF EXISTS json_empty;;
CREATE FUNCTION json_empty(col JSON, path VARCHAR(500))
RETURNS TINYINT(1) DETERMINISTIC
BEGIN
DECLARE val JSON;
SET val = JSON_EXTRACT(col, path);
IF val IS NULL THEN RETURN 1; END IF;
IF JSON_TYPE(val) = 'NULL' THEN RETURN 1; END IF;
IF JSON_TYPE(val) = 'ARRAY' AND JSON_LENGTH(val) = 0 THEN RETURN 1; END IF;
IF JSON_TYPE(val) = 'STRING' AND JSON_UNQUOTE(val) = '' THEN RETURN 1; END IF;
RETURN 0;
END;;
DROP FUNCTION IF EXISTS json_exists;;
CREATE FUNCTION json_exists(col JSON, path VARCHAR(500))
RETURNS TINYINT(1) DETERMINISTIC
BEGIN
RETURN NOT json_empty(col, path);
END;;
-- =============================================================================
-- NULL
-- =============================================================================
DROP FUNCTION IF EXISTS json_null_equals;;
CREATE FUNCTION json_null_equals(col JSON, path VARCHAR(500))
RETURNS TINYINT(1) DETERMINISTIC
BEGIN
DECLARE val JSON;
SET val = JSON_EXTRACT(col, path);
IF JSON_TYPE(val) = 'ARRAY' THEN
RETURN EXISTS (
SELECT 1 FROM JSON_TABLE(val, '$[*]' COLUMNS (elem JSON PATH '$')) AS jt
WHERE COALESCE(JSON_TYPE(jt.elem), 'NULL') = 'NULL'
);
END IF;
RETURN COALESCE(JSON_TYPE(val), 'NULL') = 'NULL';
END;;
DROP FUNCTION IF EXISTS json_null_notequals;;
CREATE FUNCTION json_null_notequals(col JSON, path VARCHAR(500))
RETURNS TINYINT(1) DETERMINISTIC
BEGIN
RETURN NOT json_null_equals(col, path);
END;;
-- =============================================================================
-- TEXT
-- =============================================================================
DROP FUNCTION IF EXISTS json_text_equals;;
CREATE FUNCTION json_text_equals(col JSON, path VARCHAR(500), target VARCHAR(2000))
RETURNS TINYINT(1) DETERMINISTIC
BEGIN
DECLARE val JSON;
SET val = JSON_EXTRACT(col, path);
IF JSON_TYPE(val) = 'ARRAY' THEN
RETURN EXISTS (
SELECT 1 FROM JSON_TABLE(val, '$[*]' COLUMNS (elem JSON PATH '$')) AS jt
WHERE JSON_UNQUOTE(jt.elem) = target
);
END IF;
RETURN JSON_UNQUOTE(val) = target;
END;;
DROP FUNCTION IF EXISTS json_text_notequals;;
CREATE FUNCTION json_text_notequals(col JSON, path VARCHAR(500), target VARCHAR(2000))
RETURNS TINYINT(1) DETERMINISTIC
BEGIN
DECLARE val JSON;
SET val = JSON_EXTRACT(col, path);
IF JSON_TYPE(val) = 'ARRAY' THEN
RETURN NOT EXISTS (
SELECT 1 FROM JSON_TABLE(val, '$[*]' COLUMNS (elem JSON PATH '$')) AS jt
WHERE JSON_UNQUOTE(jt.elem) = target
);
END IF;
RETURN JSON_UNQUOTE(val) != target;
END;;
DROP FUNCTION IF EXISTS json_text_lessthan;;
CREATE FUNCTION json_text_lessthan(col JSON, path VARCHAR(500), target VARCHAR(2000))
RETURNS TINYINT(1) DETERMINISTIC
BEGIN
DECLARE val JSON;
SET val = JSON_EXTRACT(col, path);
IF JSON_TYPE(val) = 'ARRAY' THEN
RETURN EXISTS (
SELECT 1 FROM JSON_TABLE(val, '$[*]' COLUMNS (elem JSON PATH '$')) AS jt
WHERE JSON_UNQUOTE(jt.elem) < target
);
END IF;
RETURN JSON_UNQUOTE(val) < target;
END;;
DROP FUNCTION IF EXISTS json_text_lessthanorequal;;
CREATE FUNCTION json_text_lessthanorequal(col JSON, path VARCHAR(500), target VARCHAR(2000))
RETURNS TINYINT(1) DETERMINISTIC
BEGIN
DECLARE val JSON;
SET val = JSON_EXTRACT(col, path);
IF JSON_TYPE(val) = 'ARRAY' THEN
RETURN EXISTS (
SELECT 1 FROM JSON_TABLE(val, '$[*]' COLUMNS (elem JSON PATH '$')) AS jt
WHERE JSON_UNQUOTE(jt.elem) <= target
);
END IF;
RETURN JSON_UNQUOTE(val) <= target;
END;;
DROP FUNCTION IF EXISTS json_text_greaterthan;;
CREATE FUNCTION json_text_greaterthan(col JSON, path VARCHAR(500), target VARCHAR(2000))
RETURNS TINYINT(1) DETERMINISTIC
BEGIN
DECLARE val JSON;
SET val = JSON_EXTRACT(col, path);
IF JSON_TYPE(val) = 'ARRAY' THEN
RETURN EXISTS (
SELECT 1 FROM JSON_TABLE(val, '$[*]' COLUMNS (elem JSON PATH '$')) AS jt
WHERE JSON_UNQUOTE(jt.elem) > target
);
END IF;
RETURN JSON_UNQUOTE(val) > target;
END;;
DROP FUNCTION IF EXISTS json_text_greaterthanorequal;;
CREATE FUNCTION json_text_greaterthanorequal(col JSON, path VARCHAR(500), target VARCHAR(2000))
RETURNS TINYINT(1) DETERMINISTIC
BEGIN
DECLARE val JSON;
SET val = JSON_EXTRACT(col, path);
IF JSON_TYPE(val) = 'ARRAY' THEN
RETURN EXISTS (
SELECT 1 FROM JSON_TABLE(val, '$[*]' COLUMNS (elem JSON PATH '$')) AS jt
WHERE JSON_UNQUOTE(jt.elem) >= target
);
END IF;
RETURN JSON_UNQUOTE(val) >= target;
END;;
DROP FUNCTION IF EXISTS json_text_contains;;
CREATE FUNCTION json_text_contains(col JSON, path VARCHAR(500), target VARCHAR(2000))
RETURNS TINYINT(1) DETERMINISTIC
BEGIN
DECLARE val JSON;
SET val = JSON_EXTRACT(col, path);
IF JSON_TYPE(val) = 'ARRAY' THEN
RETURN EXISTS (
SELECT 1 FROM JSON_TABLE(val, '$[*]' COLUMNS (elem JSON PATH '$')) AS jt
WHERE JSON_UNQUOTE(jt.elem) LIKE CONCAT('%', target, '%')
);
END IF;
RETURN JSON_UNQUOTE(val) LIKE CONCAT('%', target, '%');
END;;
DROP FUNCTION IF EXISTS json_text_startswith;;
CREATE FUNCTION json_text_startswith(col JSON, path VARCHAR(500), target VARCHAR(2000))
RETURNS TINYINT(1) DETERMINISTIC
BEGIN
DECLARE val JSON;
SET val = JSON_EXTRACT(col, path);
IF JSON_TYPE(val) = 'ARRAY' THEN
RETURN EXISTS (
SELECT 1 FROM JSON_TABLE(val, '$[*]' COLUMNS (elem JSON PATH '$')) AS jt
WHERE JSON_UNQUOTE(jt.elem) LIKE CONCAT(target, '%')
);
END IF;
RETURN JSON_UNQUOTE(val) LIKE CONCAT(target, '%');
END;;
DROP FUNCTION IF EXISTS json_text_endswith;;
CREATE FUNCTION json_text_endswith(col JSON, path VARCHAR(500), target VARCHAR(2000))
RETURNS TINYINT(1) DETERMINISTIC
BEGIN
DECLARE val JSON;
SET val = JSON_EXTRACT(col, path);
IF JSON_TYPE(val) = 'ARRAY' THEN
RETURN EXISTS (
SELECT 1 FROM JSON_TABLE(val, '$[*]' COLUMNS (elem JSON PATH '$')) AS jt
WHERE JSON_UNQUOTE(jt.elem) LIKE CONCAT('%', target)
);
END IF;
RETURN JSON_UNQUOTE(val) LIKE CONCAT('%', target);
END;;
DROP FUNCTION IF EXISTS json_text_matchs;;
CREATE FUNCTION json_text_matchs(col JSON, path VARCHAR(500), target VARCHAR(2000))
RETURNS TINYINT(1) DETERMINISTIC
BEGIN
DECLARE val JSON;
SET val = JSON_EXTRACT(col, path);
IF JSON_TYPE(val) = 'ARRAY' THEN
RETURN EXISTS (
SELECT 1 FROM JSON_TABLE(val, '$[*]' COLUMNS (elem JSON PATH '$')) AS jt
WHERE JSON_UNQUOTE(jt.elem) REGEXP target
);
END IF;
RETURN JSON_UNQUOTE(val) REGEXP target;
END;;
DROP FUNCTION IF EXISTS json_text_in;;
CREATE FUNCTION json_text_in(col JSON, path VARCHAR(500), targets JSON)
RETURNS TINYINT(1) DETERMINISTIC
BEGIN
DECLARE val JSON;
SET val = JSON_EXTRACT(col, path);
IF JSON_TYPE(val) = 'ARRAY' THEN
RETURN EXISTS (
SELECT 1 FROM JSON_TABLE(val, '$[*]' COLUMNS (elem JSON PATH '$')) AS jt
WHERE JSON_CONTAINS(targets, jt.elem)
);
END IF;
RETURN JSON_CONTAINS(targets, val);
END;;
-- =============================================================================
-- NUMBER
-- =============================================================================
DROP FUNCTION IF EXISTS json_number_equals;;
CREATE FUNCTION json_number_equals(col JSON, path VARCHAR(500), target DECIMAL(65, 10))
RETURNS TINYINT(1) DETERMINISTIC
BEGIN
DECLARE val JSON;
SET val = JSON_EXTRACT(col, path);
IF JSON_TYPE(val) = 'ARRAY' THEN
RETURN EXISTS (
SELECT 1 FROM JSON_TABLE(val, '$[*]' COLUMNS (elem JSON PATH '$')) AS jt
WHERE CASE WHEN JSON_TYPE(jt.elem) IN ('INTEGER', 'DOUBLE', 'DECIMAL')
THEN CAST(JSON_UNQUOTE(jt.elem) AS DECIMAL(65, 10))
END = target
);
END IF;
IF JSON_TYPE(val) NOT IN ('INTEGER', 'DOUBLE', 'DECIMAL') THEN RETURN 0; END IF;
RETURN CAST(JSON_UNQUOTE(val) AS DECIMAL(65, 10)) = target;
END;;
DROP FUNCTION IF EXISTS json_number_notequals;;
CREATE FUNCTION json_number_notequals(col JSON, path VARCHAR(500), target DECIMAL(65, 10))
RETURNS TINYINT(1) DETERMINISTIC
BEGIN
DECLARE val JSON;
SET val = JSON_EXTRACT(col, path);
IF JSON_TYPE(val) = 'ARRAY' THEN
RETURN NOT EXISTS (
SELECT 1 FROM JSON_TABLE(val, '$[*]' COLUMNS (elem JSON PATH '$')) AS jt
WHERE CASE WHEN JSON_TYPE(jt.elem) IN ('INTEGER', 'DOUBLE', 'DECIMAL')
THEN CAST(JSON_UNQUOTE(jt.elem) AS DECIMAL(65, 10))
END = target
);
END IF;
IF JSON_TYPE(val) NOT IN ('INTEGER', 'DOUBLE', 'DECIMAL') THEN RETURN 0; END IF;
RETURN CAST(JSON_UNQUOTE(val) AS DECIMAL(65, 10)) != target;
END;;
DROP FUNCTION IF EXISTS json_number_lessthan;;
CREATE FUNCTION json_number_lessthan(col JSON, path VARCHAR(500), target DECIMAL(65, 10))
RETURNS TINYINT(1) DETERMINISTIC
BEGIN
DECLARE val JSON;
SET val = JSON_EXTRACT(col, path);
IF JSON_TYPE(val) = 'ARRAY' THEN
RETURN EXISTS (
SELECT 1 FROM JSON_TABLE(val, '$[*]' COLUMNS (elem JSON PATH '$')) AS jt
WHERE CASE WHEN JSON_TYPE(jt.elem) IN ('INTEGER', 'DOUBLE', 'DECIMAL')
THEN CAST(JSON_UNQUOTE(jt.elem) AS DECIMAL(65, 10))
END < target
);
END IF;
IF JSON_TYPE(val) NOT IN ('INTEGER', 'DOUBLE', 'DECIMAL') THEN RETURN 0; END IF;
RETURN CAST(JSON_UNQUOTE(val) AS DECIMAL(65, 10)) < target;
END;;
DROP FUNCTION IF EXISTS json_number_lessthanorequal;;
CREATE FUNCTION json_number_lessthanorequal(col JSON, path VARCHAR(500), target DECIMAL(65, 10))
RETURNS TINYINT(1) DETERMINISTIC
BEGIN
DECLARE val JSON;
SET val = JSON_EXTRACT(col, path);
IF JSON_TYPE(val) = 'ARRAY' THEN
RETURN EXISTS (
SELECT 1 FROM JSON_TABLE(val, '$[*]' COLUMNS (elem JSON PATH '$')) AS jt
WHERE CASE WHEN JSON_TYPE(jt.elem) IN ('INTEGER', 'DOUBLE', 'DECIMAL')
THEN CAST(JSON_UNQUOTE(jt.elem) AS DECIMAL(65, 10))
END <= target
);
END IF;
IF JSON_TYPE(val) NOT IN ('INTEGER', 'DOUBLE', 'DECIMAL') THEN RETURN 0; END IF;
RETURN CAST(JSON_UNQUOTE(val) AS DECIMAL(65, 10)) <= target;
END;;
DROP FUNCTION IF EXISTS json_number_greaterthan;;
CREATE FUNCTION json_number_greaterthan(col JSON, path VARCHAR(500), target DECIMAL(65, 10))
RETURNS TINYINT(1) DETERMINISTIC
BEGIN
DECLARE val JSON;
SET val = JSON_EXTRACT(col, path);
IF JSON_TYPE(val) = 'ARRAY' THEN
RETURN EXISTS (
SELECT 1 FROM JSON_TABLE(val, '$[*]' COLUMNS (elem JSON PATH '$')) AS jt
WHERE CASE WHEN JSON_TYPE(jt.elem) IN ('INTEGER', 'DOUBLE', 'DECIMAL')
THEN CAST(JSON_UNQUOTE(jt.elem) AS DECIMAL(65, 10))
END > target
);
END IF;
IF JSON_TYPE(val) NOT IN ('INTEGER', 'DOUBLE', 'DECIMAL') THEN RETURN 0; END IF;
RETURN CAST(JSON_UNQUOTE(val) AS DECIMAL(65, 10)) > target;
END;;
DROP FUNCTION IF EXISTS json_number_greaterthanorequal;;
CREATE FUNCTION json_number_greaterthanorequal(col JSON, path VARCHAR(500), target DECIMAL(65, 10))
RETURNS TINYINT(1) DETERMINISTIC
BEGIN
DECLARE val JSON;
SET val = JSON_EXTRACT(col, path);
IF JSON_TYPE(val) = 'ARRAY' THEN
RETURN EXISTS (
SELECT 1 FROM JSON_TABLE(val, '$[*]' COLUMNS (elem JSON PATH '$')) AS jt
WHERE CASE WHEN JSON_TYPE(jt.elem) IN ('INTEGER', 'DOUBLE', 'DECIMAL')
THEN CAST(JSON_UNQUOTE(jt.elem) AS DECIMAL(65, 10))
END >= target
);
END IF;
IF JSON_TYPE(val) NOT IN ('INTEGER', 'DOUBLE', 'DECIMAL') THEN RETURN 0; END IF;
RETURN CAST(JSON_UNQUOTE(val) AS DECIMAL(65, 10)) >= target;
END;;
DROP FUNCTION IF EXISTS json_number_in;;
CREATE FUNCTION json_number_in(col JSON, path VARCHAR(500), targets JSON)
RETURNS TINYINT(1) DETERMINISTIC
BEGIN
DECLARE val JSON;
SET val = JSON_EXTRACT(col, path);
IF JSON_TYPE(val) = 'ARRAY' THEN
RETURN EXISTS (
SELECT 1 FROM JSON_TABLE(val, '$[*]' COLUMNS (elem JSON PATH '$')) AS jt
WHERE JSON_TYPE(jt.elem) IN ('INTEGER', 'DOUBLE', 'DECIMAL')
AND JSON_CONTAINS(targets, jt.elem)
);
END IF;
IF JSON_TYPE(val) NOT IN ('INTEGER', 'DOUBLE', 'DECIMAL') THEN RETURN 0; END IF;
RETURN JSON_CONTAINS(targets, val);
END;;
-- =============================================================================
-- BOOLEAN
-- =============================================================================
DROP FUNCTION IF EXISTS json_boolean_equals;;
CREATE FUNCTION json_boolean_equals(col JSON, path VARCHAR(500), target TINYINT(1))
RETURNS TINYINT(1) DETERMINISTIC
BEGIN
DECLARE val JSON;
DECLARE target_json JSON;
SET val = JSON_EXTRACT(col, path);
SET target_json = IF(target, CAST('true' AS JSON), CAST('false' AS JSON));
IF JSON_TYPE(val) = 'ARRAY' THEN
RETURN EXISTS (
SELECT 1 FROM JSON_TABLE(val, '$[*]' COLUMNS (elem JSON PATH '$')) AS jt
WHERE CASE WHEN JSON_TYPE(jt.elem) = 'BOOLEAN' THEN jt.elem END = target_json
);
END IF;
IF JSON_TYPE(val) != 'BOOLEAN' THEN RETURN 0; END IF;
RETURN val = target_json;
END;;
DROP FUNCTION IF EXISTS json_boolean_notequals;;
CREATE FUNCTION json_boolean_notequals(col JSON, path VARCHAR(500), target TINYINT(1))
RETURNS TINYINT(1) DETERMINISTIC
BEGIN
DECLARE val JSON;
DECLARE target_json JSON;
SET val = JSON_EXTRACT(col, path);
SET target_json = IF(target, CAST('true' AS JSON), CAST('false' AS JSON));
IF JSON_TYPE(val) = 'ARRAY' THEN
RETURN NOT EXISTS (
SELECT 1 FROM JSON_TABLE(val, '$[*]' COLUMNS (elem JSON PATH '$')) AS jt
WHERE CASE WHEN JSON_TYPE(jt.elem) = 'BOOLEAN' THEN jt.elem END = target_json
);
END IF;
IF JSON_TYPE(val) != 'BOOLEAN' THEN RETURN 0; END IF;
RETURN val != target_json;
END;;
DROP FUNCTION IF EXISTS json_boolean_in;;
CREATE FUNCTION json_boolean_in(col JSON, path VARCHAR(500), targets JSON)
RETURNS TINYINT(1) DETERMINISTIC
BEGIN
DECLARE val JSON;
SET val = JSON_EXTRACT(col, path);
IF JSON_TYPE(val) = 'ARRAY' THEN
RETURN EXISTS (
SELECT 1 FROM JSON_TABLE(val, '$[*]' COLUMNS (elem JSON PATH '$')) AS jt
WHERE JSON_TYPE(jt.elem) = 'BOOLEAN'
AND EXISTS (
SELECT 1 FROM JSON_TABLE(targets, '$[*]' COLUMNS (t JSON PATH '$')) AS tt
WHERE IF(jt.elem = CAST('true' AS JSON), 1, 0) = CAST(JSON_UNQUOTE(tt.t) AS UNSIGNED)
)
);
END IF;
IF JSON_TYPE(val) != 'BOOLEAN' THEN RETURN 0; END IF;
RETURN EXISTS (
SELECT 1 FROM JSON_TABLE(targets, '$[*]' COLUMNS (t JSON PATH '$')) AS tt
WHERE IF(val = CAST('true' AS JSON), 1, 0) = CAST(JSON_UNQUOTE(tt.t) AS UNSIGNED)
);
END;;

111
backend/src/Squidex.Data.EntityFramework/Providers/Postgres/JsonFunction.cs

@ -0,0 +1,111 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Microsoft.EntityFrameworkCore;
using Squidex.Infrastructure.Queries;
using Squidex.Providers.Postgres.App;
namespace Squidex.Providers.Postgres;
public static class JsonFunction
{
private const int TypeAny = 0;
private const int TypeNull = 1;
private const int TypeText = 2;
private const int TypeBoolean = 3;
private const int TypeNumber = 4;
private static readonly Dictionary<(int Type, CompareOperator Operator), string> Functions = new()
{
[(TypeAny, CompareOperator.Empty)] = "jsonb_empty",
[(TypeAny, CompareOperator.Exists)] = "jsonb_exists",
[(TypeNull, CompareOperator.Equals)] = "jsonb_null_equals",
[(TypeNull, CompareOperator.NotEquals)] = "jsonb_null_notequals",
[(TypeText, CompareOperator.Contains)] = "jsonb_text_contains",
[(TypeText, CompareOperator.EndsWith)] = "jsonb_text_endswith",
[(TypeText, CompareOperator.Equals)] = "jsonb_text_equals",
[(TypeText, CompareOperator.GreaterThan)] = "jsonb_text_greaterthan",
[(TypeText, CompareOperator.GreaterThanOrEqual)] = "jsonb_text_greaterthanorequal",
[(TypeText, CompareOperator.In)] = "jsonb_text_in",
[(TypeText, CompareOperator.LessThan)] = "jsonb_text_lessthan",
[(TypeText, CompareOperator.LessThanOrEqual)] = "jsonb_text_lessthanorequal",
[(TypeText, CompareOperator.Matchs)] = "jsonb_text_matchs",
[(TypeText, CompareOperator.NotEquals)] = "jsonb_text_notequals",
[(TypeText, CompareOperator.StartsWith)] = "jsonb_text_startswith",
[(TypeBoolean, CompareOperator.Equals)] = "jsonb_boolean_equals",
[(TypeBoolean, CompareOperator.In)] = "jsonb_boolean_in",
[(TypeBoolean, CompareOperator.NotEquals)] = "jsonb_boolean_notequals",
[(TypeNumber, CompareOperator.Equals)] = "jsonb_number_equals",
[(TypeNumber, CompareOperator.GreaterThan)] = "jsonb_number_greaterthan",
[(TypeNumber, CompareOperator.GreaterThanOrEqual)] = "jsonb_number_greaterthanorequal",
[(TypeNumber, CompareOperator.In)] = "jsonb_number_in",
[(TypeNumber, CompareOperator.LessThan)] = "jsonb_number_lessthan",
[(TypeNumber, CompareOperator.LessThanOrEqual)] = "jsonb_number_lessthanorequal",
[(TypeNumber, CompareOperator.NotEquals)] = "jsonb_number_notequals",
};
public static async Task InitializeAsync(DbContext dbContext,
CancellationToken ct)
{
var sqlStream = typeof(PostgresDialect).Assembly.GetManifestResourceStream("Squidex.Providers.Postgres.json_function.sql");
var sqlText = await new StreamReader(sqlStream!).ReadToEndAsync(ct);
sqlText = sqlText.Replace("{", "{{", StringComparison.Ordinal);
sqlText = sqlText.Replace("}", "}}", StringComparison.Ordinal);
await dbContext.Database.ExecuteSqlRawAsync(sqlText, ct);
}
public static string Create(PropertyPath path, CompareOperator op, ClrValue value, string formattedValue)
{
var type = -1;
if (op is CompareOperator.Exists or CompareOperator.Empty)
{
type = TypeAny;
}
else
{
switch (value.ValueType)
{
case ClrValueType.Single:
case ClrValueType.Double:
case ClrValueType.Int32:
case ClrValueType.Int64:
type = TypeNumber;
break;
case ClrValueType.Instant:
case ClrValueType.Guid:
case ClrValueType.String:
type = TypeText;
break;
case ClrValueType.Boolean:
type = TypeBoolean;
break;
case ClrValueType.Null:
type = TypeNull;
break;
}
}
if (!Functions.TryGetValue((type, op), out var fn))
{
throw new NotSupportedException($"No jsonb function for type={value.ValueType}, operator={op}.");
}
if (type is TypeNull or TypeAny)
{
return $"{fn}({path.JsonPath(false)})";
}
if (value.IsList)
{
return $"{fn}({path.JsonPath(false)}, ARRAY[{formattedValue}])";
}
return $"{fn}({path.JsonPath(false)}, {formattedValue})";
}
}

61
backend/src/Squidex.Data.EntityFramework/Providers/Postgres/PostgresDialect.cs

@ -5,6 +5,7 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Microsoft.EntityFrameworkCore;
using Npgsql;
using Squidex.Infrastructure.Queries;
using Squidex.Providers.Postgres.App;
@ -19,6 +20,12 @@ public class PostgresDialect : SqlDialect
{
}
public override Task InitializeAsync(DbContext dbContext,
CancellationToken ct)
{
return JsonFunction.InitializeAsync(dbContext, ct);
}
public override bool IsDuplicateIndexException(Exception exception, string name)
{
return exception is PostgresException ex && ex.SqlState == "42P07";
@ -71,59 +78,7 @@ public class PostgresDialect : SqlDialect
{
if (isJson)
{
var sqlPathCast = path.JsonPath(true);
var sqlPath = path.JsonPath(false);
var sqlOp = FormatOperator(op, value);
var sqlRhs = FormatValues(op, value, queryParameters);
string BuildCondition(string path, string castPath)
{
var isNumeric = value.ValueType is
ClrValueType.Single or
ClrValueType.Double or
ClrValueType.Int32 or
ClrValueType.Int64;
if (isNumeric)
{
return $"(CASE WHEN jsonb_typeof({path}) = 'number' THEN ({castPath})::numeric {sqlOp} {sqlRhs} ELSE FALSE END)";
}
var isBoolean = value.ValueType is ClrValueType.Boolean;
if (isBoolean)
{
return $"(CASE WHEN jsonb_typeof({path}) = 'boolean' THEN ({castPath})::boolean {sqlOp} {sqlRhs} ELSE FALSE END)";
}
var isString = value.ValueType is
ClrValueType.Instant or
ClrValueType.Guid or
ClrValueType.String;
if (isString)
{
return $"{path} #>> '{{{{}}}}' {sqlOp} {sqlRhs}";
}
var isNull = value.ValueType is ClrValueType.Null;
if (isNull)
{
var nullOp = FormatOperator(op, "null");
return $"jsonb_typeof({path}) {nullOp} 'null'";
}
return base.Where(path, op, value, queryParameters, false);
}
return $"""
CASE WHEN jsonb_typeof({sqlPath}) = 'array'
THEN EXISTS (
SELECT 1
FROM jsonb_array_elements({sqlPath}) AS __element
WHERE {BuildCondition("__element", "__element")}
)
ELSE {BuildCondition(sqlPath, sqlPathCast)}
END
""";
return JsonFunction.Create(path, op, value, FormatValues(op, value, queryParameters, true));
}
return base.Where(path, op, value, queryParameters, isJson);

330
backend/src/Squidex.Data.EntityFramework/Providers/Postgres/json_function.sql

@ -0,0 +1,330 @@
-- =============================================================================
-- TYPE-AGNOSTIC
-- =============================================================================
CREATE OR REPLACE FUNCTION jsonb_empty(val jsonb)
RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$
BEGIN
RETURN val IS NULL
OR (jsonb_typeof(val) = 'null')
OR (jsonb_typeof(val) = 'array' AND jsonb_array_length(val) = 0)
OR (jsonb_typeof(val) = 'string' AND val #>> '{}' = '');
END;
$$;
CREATE OR REPLACE FUNCTION jsonb_exists(val jsonb)
RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$
BEGIN
RETURN NOT jsonb_empty(val);
END;
$$;
-- =============================================================================
-- TEXT
-- =============================================================================
CREATE OR REPLACE FUNCTION jsonb_text_equals(val jsonb, target text)
RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$
BEGIN
IF jsonb_typeof(val) = 'array' THEN
RETURN EXISTS (
SELECT 1 FROM jsonb_array_elements(val) AS e WHERE e #>> '{}' = target
);
END IF;
RETURN val #>> '{}' = target;
END;
$$;
CREATE OR REPLACE FUNCTION jsonb_text_notequals(val jsonb, target text)
RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$
BEGIN
IF jsonb_typeof(val) = 'array' THEN
RETURN NOT EXISTS (
SELECT 1 FROM jsonb_array_elements(val) AS e WHERE e #>> '{}' = target
);
END IF;
RETURN val #>> '{}' != target;
END;
$$;
CREATE OR REPLACE FUNCTION jsonb_text_lessthan(val jsonb, target text)
RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$
BEGIN
IF jsonb_typeof(val) = 'array' THEN
RETURN EXISTS (
SELECT 1 FROM jsonb_array_elements(val) AS e WHERE e #>> '{}' < target
);
END IF;
RETURN val #>> '{}' < target;
END;
$$;
CREATE OR REPLACE FUNCTION jsonb_text_lessthanorequal(val jsonb, target text)
RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$
BEGIN
IF jsonb_typeof(val) = 'array' THEN
RETURN EXISTS (
SELECT 1 FROM jsonb_array_elements(val) AS e WHERE e #>> '{}' <= target
);
END IF;
RETURN val #>> '{}' <= target;
END;
$$;
CREATE OR REPLACE FUNCTION jsonb_text_greaterthan(val jsonb, target text)
RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$
BEGIN
IF jsonb_typeof(val) = 'array' THEN
RETURN EXISTS (
SELECT 1 FROM jsonb_array_elements(val) AS e WHERE e #>> '{}' > target
);
END IF;
RETURN val #>> '{}' > target;
END;
$$;
CREATE OR REPLACE FUNCTION jsonb_text_greaterthanorequal(val jsonb, target text)
RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$
BEGIN
IF jsonb_typeof(val) = 'array' THEN
RETURN EXISTS (
SELECT 1 FROM jsonb_array_elements(val) AS e WHERE e #>> '{}' >= target
);
END IF;
RETURN val #>> '{}' >= target;
END;
$$;
CREATE OR REPLACE FUNCTION jsonb_text_contains(val jsonb, target text)
RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$
BEGIN
IF jsonb_typeof(val) = 'array' THEN
RETURN EXISTS (
SELECT 1 FROM jsonb_array_elements(val) AS e
WHERE e #>> '{}' LIKE '%' || target || '%'
);
END IF;
RETURN val #>> '{}' LIKE '%' || target || '%';
END;
$$;
CREATE OR REPLACE FUNCTION jsonb_text_startswith(val jsonb, target text)
RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$
BEGIN
IF jsonb_typeof(val) = 'array' THEN
RETURN EXISTS (
SELECT 1 FROM jsonb_array_elements(val) AS e
WHERE e #>> '{}' LIKE target || '%'
);
END IF;
RETURN val #>> '{}' LIKE target || '%';
END;
$$;
CREATE OR REPLACE FUNCTION jsonb_text_endswith(val jsonb, target text)
RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$
BEGIN
IF jsonb_typeof(val) = 'array' THEN
RETURN EXISTS (
SELECT 1 FROM jsonb_array_elements(val) AS e
WHERE e #>> '{}' LIKE '%' || target
);
END IF;
RETURN val #>> '{}' LIKE '%' || target;
END;
$$;
CREATE OR REPLACE FUNCTION jsonb_text_matchs(val jsonb, target text)
RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$
BEGIN
IF jsonb_typeof(val) = 'array' THEN
RETURN EXISTS (
SELECT 1 FROM jsonb_array_elements(val) AS e
WHERE e #>> '{}' ~ target
);
END IF;
RETURN val #>> '{}' ~ target;
END;
$$;
CREATE OR REPLACE FUNCTION jsonb_text_in(val jsonb, targets text[])
RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$
BEGIN
IF jsonb_typeof(val) = 'array' THEN
RETURN EXISTS (
SELECT 1 FROM jsonb_array_elements(val) AS e
WHERE e #>> '{}' = ANY(targets)
);
END IF;
RETURN val #>> '{}' = ANY(targets);
END;
$$;
-- =============================================================================
-- NUMBER
-- =============================================================================
CREATE OR REPLACE FUNCTION jsonb_number_equals(val jsonb, target numeric)
RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$
BEGIN
IF jsonb_typeof(val) = 'array' THEN
RETURN EXISTS (
SELECT 1 FROM jsonb_array_elements(val) AS e
WHERE jsonb_typeof(e) = 'number' AND (e #>> '{}')::numeric = target
);
END IF;
IF jsonb_typeof(val) != 'number' THEN RETURN FALSE; END IF;
RETURN (val #>> '{}')::numeric = target;
END;
$$;
CREATE OR REPLACE FUNCTION jsonb_number_notequals(val jsonb, target numeric)
RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$
BEGIN
IF jsonb_typeof(val) = 'array' THEN
RETURN NOT EXISTS (
SELECT 1 FROM jsonb_array_elements(val) AS e
WHERE jsonb_typeof(e) = 'number' AND (e #>> '{}')::numeric = target
);
END IF;
IF jsonb_typeof(val) != 'number' THEN RETURN FALSE; END IF;
RETURN (val #>> '{}')::numeric != target;
END;
$$;
CREATE OR REPLACE FUNCTION jsonb_number_lessthan(val jsonb, target numeric)
RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$
BEGIN
IF jsonb_typeof(val) = 'array' THEN
RETURN EXISTS (
SELECT 1 FROM jsonb_array_elements(val) AS e
WHERE jsonb_typeof(e) = 'number' AND (e #>> '{}')::numeric < target
);
END IF;
IF jsonb_typeof(val) != 'number' THEN RETURN FALSE; END IF;
RETURN (val #>> '{}')::numeric < target;
END;
$$;
CREATE OR REPLACE FUNCTION jsonb_number_lessthanorequal(val jsonb, target numeric)
RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$
BEGIN
IF jsonb_typeof(val) = 'array' THEN
RETURN EXISTS (
SELECT 1 FROM jsonb_array_elements(val) AS e
WHERE jsonb_typeof(e) = 'number' AND (e #>> '{}')::numeric <= target
);
END IF;
IF jsonb_typeof(val) != 'number' THEN RETURN FALSE; END IF;
RETURN (val #>> '{}')::numeric <= target;
END;
$$;
CREATE OR REPLACE FUNCTION jsonb_number_greaterthan(val jsonb, target numeric)
RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$
BEGIN
IF jsonb_typeof(val) = 'array' THEN
RETURN EXISTS (
SELECT 1 FROM jsonb_array_elements(val) AS e
WHERE jsonb_typeof(e) = 'number' AND (e #>> '{}')::numeric > target
);
END IF;
IF jsonb_typeof(val) != 'number' THEN RETURN FALSE; END IF;
RETURN (val #>> '{}')::numeric > target;
END;
$$;
CREATE OR REPLACE FUNCTION jsonb_number_greaterthanorequal(val jsonb, target numeric)
RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$
BEGIN
IF jsonb_typeof(val) = 'array' THEN
RETURN EXISTS (
SELECT 1 FROM jsonb_array_elements(val) AS e
WHERE jsonb_typeof(e) = 'number' AND (e #>> '{}')::numeric >= target
);
END IF;
IF jsonb_typeof(val) != 'number' THEN RETURN FALSE; END IF;
RETURN (val #>> '{}')::numeric >= target;
END;
$$;
CREATE OR REPLACE FUNCTION jsonb_number_in(val jsonb, targets numeric[])
RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$
BEGIN
IF jsonb_typeof(val) = 'array' THEN
RETURN EXISTS (
SELECT 1 FROM jsonb_array_elements(val) AS e
WHERE jsonb_typeof(e) = 'number' AND (e #>> '{}')::numeric = ANY(targets)
);
END IF;
IF jsonb_typeof(val) != 'number' THEN RETURN FALSE; END IF;
RETURN (val #>> '{}')::numeric = ANY(targets);
END;
$$;
-- =============================================================================
-- NULL
-- =============================================================================
CREATE OR REPLACE FUNCTION jsonb_null_equals(val jsonb)
RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$
BEGIN
IF jsonb_typeof(val) = 'array' THEN
RETURN EXISTS (
SELECT 1 FROM jsonb_array_elements(val) AS e
WHERE jsonb_typeof(e) = 'null'
);
END IF;
RETURN val IS NULL OR jsonb_typeof(val) = 'null';
END;
$$;
CREATE OR REPLACE FUNCTION jsonb_null_notequals(val jsonb)
RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$
BEGIN
RETURN NOT jsonb_null_equals(val);
END;
$$;
-- =============================================================================
-- BOOLEAN
-- =============================================================================
CREATE OR REPLACE FUNCTION jsonb_boolean_equals(val jsonb, target boolean)
RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$
BEGIN
IF jsonb_typeof(val) = 'array' THEN
RETURN EXISTS (
SELECT 1 FROM jsonb_array_elements(val) AS e
WHERE jsonb_typeof(e) = 'boolean' AND (e #>> '{}')::boolean = target
);
END IF;
IF jsonb_typeof(val) != 'boolean' THEN RETURN FALSE; END IF;
RETURN (val #>> '{}')::boolean = target;
END;
$$;
CREATE OR REPLACE FUNCTION jsonb_boolean_notequals(val jsonb, target boolean)
RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$
BEGIN
IF jsonb_typeof(val) = 'array' THEN
RETURN NOT EXISTS (
SELECT 1 FROM jsonb_array_elements(val) AS e
WHERE jsonb_typeof(e) = 'boolean' AND (e #>> '{}')::boolean = target
);
END IF;
IF jsonb_typeof(val) != 'boolean' THEN RETURN FALSE; END IF;
RETURN (val #>> '{}')::boolean != target;
END;
$$;
CREATE OR REPLACE FUNCTION jsonb_boolean_in(val jsonb, targets boolean[])
RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$
BEGIN
IF jsonb_typeof(val) = 'array' THEN
RETURN EXISTS (
SELECT 1 FROM jsonb_array_elements(val) AS e
WHERE jsonb_typeof(e) = 'boolean' AND (e #>> '{}')::boolean = ANY(targets)
);
END IF;
IF jsonb_typeof(val) != 'boolean' THEN RETURN FALSE; END IF;
RETURN (val #>> '{}')::boolean = ANY(targets);
END;
$$;

14
backend/src/Squidex.Data.EntityFramework/Providers/SqlServer/Extensions.cs

@ -17,7 +17,14 @@ internal static class Extensions
{
sb.Append('[');
sb.Append(path[0]);
sb.Append("], \'$");
sb.Append("], ");
sb.AppendJsonSubPath(path);
return sb;
}
public static StringBuilder AppendJsonSubPath(this StringBuilder sb, PropertyPath path)
{
sb.Append("\'$");
foreach (var property in path.Skip(1))
{
@ -38,6 +45,11 @@ internal static class Extensions
return sb;
}
public static string JsonSubPath(this PropertyPath path)
{
return new StringBuilder().AppendJsonSubPath(path).ToString();
}
public static string JsonPath(this PropertyPath path)
{
return new StringBuilder().AppendJsonPath(path).ToString();

153
backend/src/Squidex.Data.EntityFramework/Providers/SqlServer/JsonFunction.cs

@ -0,0 +1,153 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Microsoft.EntityFrameworkCore;
using Squidex.Infrastructure.Queries;
namespace Squidex.Providers.SqlServer;
public static class JsonFunction
{
private const int TypeAny = 0;
private const int TypeNull = 1;
private const int TypeText = 2;
private const int TypeBoolean = 3;
private const int TypeNumber = 4;
private static readonly Dictionary<(int Type, CompareOperator Operator), string> Functions = new ()
{
[(TypeAny, CompareOperator.Empty)] = "dbo.json_empty",
[(TypeAny, CompareOperator.Exists)] = "dbo.json_exists",
[(TypeNull, CompareOperator.Equals)] = "dbo.json_null_equals",
[(TypeNull, CompareOperator.NotEquals)] = "dbo.json_null_notequals",
[(TypeText, CompareOperator.Contains)] = "dbo.json_text_contains",
[(TypeText, CompareOperator.EndsWith)] = "dbo.json_text_endswith",
[(TypeText, CompareOperator.Equals)] = "dbo.json_text_equals",
[(TypeText, CompareOperator.GreaterThan)] = "dbo.json_text_greaterthan",
[(TypeText, CompareOperator.GreaterThanOrEqual)] = "dbo.json_text_greaterthanorequal",
[(TypeText, CompareOperator.LessThan)] = "dbo.json_text_lessthan",
[(TypeText, CompareOperator.LessThanOrEqual)] = "dbo.json_text_lessthanorequal",
[(TypeText, CompareOperator.Matchs)] = "dbo.json_text_matchs",
[(TypeText, CompareOperator.NotEquals)] = "dbo.json_text_notequals",
[(TypeText, CompareOperator.StartsWith)] = "dbo.json_text_startswith",
[(TypeBoolean, CompareOperator.Equals)] = "dbo.json_boolean_equals",
[(TypeBoolean, CompareOperator.NotEquals)] = "dbo.json_boolean_notequals",
[(TypeNumber, CompareOperator.Equals)] = "dbo.json_number_equals",
[(TypeNumber, CompareOperator.GreaterThan)] = "dbo.json_number_greaterthan",
[(TypeNumber, CompareOperator.GreaterThanOrEqual)] = "dbo.json_number_greaterthanorequal",
[(TypeNumber, CompareOperator.LessThan)] = "dbo.json_number_lessthan",
[(TypeNumber, CompareOperator.LessThanOrEqual)] = "dbo.json_number_lessthanorequal",
[(TypeNumber, CompareOperator.NotEquals)] = "dbo.json_number_notequals",
};
public static async Task InitializeAsync(DbContext dbContext,
CancellationToken ct)
{
var sqlStream = typeof(SqlServerDialect).Assembly.GetManifestResourceStream("Squidex.Providers.SqlServer.json_function.sql");
var sqlText = await new StreamReader(sqlStream!).ReadToEndAsync(ct);
sqlText = sqlText.Replace("{", "{{", StringComparison.Ordinal);
sqlText = sqlText.Replace("}", "}}", StringComparison.Ordinal);
var statements = sqlText.Split(";;", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
// We want to filter out the drop statements and multiple function creations are not supported.
foreach (var statement in statements)
{
#if RELEASE
if (statement.StartsWith("DROP", StringComparison.Ordinal))
{
continue;
}
#endif
await dbContext.Database.ExecuteSqlRawAsync(statement, ct);
}
}
public static string Create(PropertyPath path, CompareOperator op, ClrValue value, string formattedValue)
{
var type = -1;
if (op is CompareOperator.Exists or CompareOperator.Empty)
{
type = TypeAny;
}
else
{
switch (value.ValueType)
{
case ClrValueType.Single:
case ClrValueType.Double:
case ClrValueType.Int32:
case ClrValueType.Int64:
type = TypeNumber;
break;
case ClrValueType.Instant:
case ClrValueType.Guid:
case ClrValueType.String:
type = TypeText;
break;
case ClrValueType.Boolean:
type = TypeBoolean;
break;
case ClrValueType.Null:
type = TypeNull;
break;
}
}
var jsonPath = path.JsonPath();
// SQL Server scalar functions cannot accept parameterized arrays, embedding
// SQL parameter references (@p0, @p1) inside a JSON string literal
// produces invalid JSON at execution time. IN is therefore handled inline so
// parameters remain proper SQL parameters.
if (value.IsList)
{
return type switch
{
TypeText =>
$"""
(CASE WHEN JSON_QUERY({jsonPath}) IS NOT NULL
THEN CASE WHEN EXISTS (SELECT 1 FROM OPENJSON(JSON_QUERY({jsonPath})) AS j WHERE j.[type] = 1 AND j.[value] IN ({formattedValue})) THEN 1 ELSE 0 END
ELSE CASE WHEN JSON_VALUE({jsonPath}) IN ({formattedValue}) THEN 1 ELSE 0 END
END) = 1
""",
TypeNumber =>
$"""
(CASE WHEN JSON_QUERY({jsonPath}) IS NOT NULL
THEN CASE WHEN EXISTS (SELECT 1 FROM OPENJSON(JSON_QUERY({jsonPath})) AS j WHERE j.[type] = 2 AND TRY_CAST(j.[value] AS DECIMAL(38, 10)) IN ({formattedValue})) THEN 1 ELSE 0 END
ELSE CASE WHEN TRY_CAST(JSON_VALUE({jsonPath}) AS DECIMAL(38, 10)) IN ({formattedValue}) THEN 1 ELSE 0 END
END) = 1
""",
TypeBoolean =>
$"""
(CASE WHEN JSON_QUERY({jsonPath}) IS NOT NULL
THEN CASE WHEN EXISTS (SELECT 1 FROM OPENJSON(JSON_QUERY({jsonPath})) AS j WHERE j.[type] = 3 AND IIF(j.[value] = 'true', 1, IIF(j.[value] = 'false', 0, NULL)) IN ({formattedValue})) THEN 1 ELSE 0 END
ELSE CASE WHEN IIF(JSON_VALUE({jsonPath}) = 'true', 1, IIF(JSON_VALUE({jsonPath}) = 'false', 0, NULL)) IN ({formattedValue}) THEN 1 ELSE 0 END
END) = 1
""",
_ => throw new NotSupportedException($"No json function for type={type}, operator={op}."),
};
}
if (!Functions.TryGetValue((type, op), out var fn))
{
throw new NotSupportedException($"No json function for type={type}, operator={op}.");
}
if (type is TypeNull or TypeAny)
{
return $"{fn}({jsonPath}) = 1";
}
if (value.IsList)
{
return $"{fn}({jsonPath}, N'[{formattedValue}]') = 1";
}
return $"{fn}({jsonPath}, {formattedValue}) = 1";
}
}

69
backend/src/Squidex.Data.EntityFramework/Providers/SqlServer/SqlServerDialect.cs

@ -5,7 +5,7 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Text;
using Microsoft.EntityFrameworkCore;
using Squidex.Infrastructure.Queries;
using SqlException = Microsoft.Data.SqlClient.SqlException;
@ -19,6 +19,12 @@ public sealed class SqlServerDialect : SqlDialect
{
}
public override Task InitializeAsync(DbContext dbContext,
CancellationToken ct)
{
return JsonFunction.InitializeAsync(dbContext, ct);
}
public override bool IsDuplicateIndexException(Exception exception, string name)
{
return exception is SqlException ex && ex.Number is 1913 or 7642 or 7652;
@ -88,66 +94,7 @@ public sealed class SqlServerDialect : SqlDialect
{
if (isJson)
{
var sqlPath = path.JsonPath();
var sqlOp = FormatOperator(op, value);
var sqlRhs = FormatValues(op, value, queryParameters);
string BuildCondition(string path, string queryPath)
{
var isNumeric = value.ValueType is
ClrValueType.Single or
ClrValueType.Double or
ClrValueType.Int32 or
ClrValueType.Int64;
if (isNumeric)
{
return $"TRY_CAST({path} AS NUMERIC) {sqlOp} {sqlRhs}";
}
var isBoolean = value.ValueType is ClrValueType.Boolean;
if (isBoolean)
{
return $"IIF({path} = 'true', 1, IIF({path} = 'false', 0, NULL)) {sqlOp} {sqlRhs}";
}
var isString = value.ValueType is
ClrValueType.Instant or
ClrValueType.Guid or
ClrValueType.String;
if (isString)
{
return $"{path} {sqlOp} {sqlRhs}";
}
var isNull = value.ValueType is ClrValueType.Null;
if (isNull)
{
if (op == CompareOperator.Equals)
{
return $"{queryPath} IS NULL AND {path} IS NULL";
}
if (op == CompareOperator.NotEquals)
{
return $"{queryPath} IS NOT NULL OR {path} IS NOT NULL";
}
}
return base.Where(path, op, value, queryParameters, false);
}
return $"""
(
CASE WHEN LEFT(LTRIM(JSON_QUERY({sqlPath})), 1) = '['
THEN CASE WHEN EXISTS (
SELECT 1
FROM OPENJSON({sqlPath}) AS __element
WHERE {BuildCondition("__element.[value]", "__element.[value]")}
) THEN 1 ELSE 0 END
ELSE CASE WHEN {BuildCondition($"JSON_VALUE({sqlPath})", $"JSON_QUERY({sqlPath})")} THEN 1 ELSE 0 END
END
) = 1
""";
return JsonFunction.Create(path, op, value, FormatValues(op, value, queryParameters, true));
}
return base.Where(path, op, value, queryParameters, isJson);

313
backend/src/Squidex.Data.EntityFramework/Providers/SqlServer/json_function.sql

@ -0,0 +1,313 @@
-- =============================================================================
-- TYPE-AGNOSTIC
-- =============================================================================
DROP FUNCTION IF EXISTS dbo.json_empty;;
CREATE FUNCTION dbo.json_empty(@col NVARCHAR(MAX), @path NVARCHAR(500))
RETURNS BIT
AS
BEGIN
DECLARE @query NVARCHAR(MAX) = JSON_QUERY(@col, @path);
DECLARE @val NVARCHAR(MAX) = JSON_VALUE(@col, @path);
IF @query IS NOT NULL AND LEFT(LTRIM(@query), 1) = '['
RETURN CASE WHEN (SELECT COUNT(*) FROM OPENJSON(@query)) = 0 THEN 1 ELSE 0 END;
IF @query IS NOT NULL AND LEFT(LTRIM(@query), 1) = '{'
RETURN 0;
IF @val IS NULL RETURN 1;
IF @val = '' RETURN 1;
RETURN 0;
END;;
DROP FUNCTION IF EXISTS dbo.json_exists;;
CREATE FUNCTION dbo.json_exists(@col NVARCHAR(MAX), @path NVARCHAR(500))
RETURNS BIT
AS
BEGIN
RETURN 1 - dbo.json_empty(@col, @path);
END;;
-- =============================================================================
-- NULL
-- =============================================================================
DROP FUNCTION IF EXISTS dbo.json_null_equals;;
CREATE FUNCTION dbo.json_null_equals(@col NVARCHAR(MAX), @path NVARCHAR(500))
RETURNS BIT
AS
BEGIN
DECLARE @query NVARCHAR(MAX) = JSON_QUERY(@col, @path);
IF @query IS NOT NULL
RETURN CASE WHEN EXISTS (
SELECT 1 FROM OPENJSON(@query) AS j WHERE j.[type] = 0
) THEN 1 ELSE 0 END;
RETURN CASE WHEN JSON_VALUE(@col, @path) IS NULL THEN 1 ELSE 0 END;
END;;
DROP FUNCTION IF EXISTS dbo.json_null_notequals;;
CREATE FUNCTION dbo.json_null_notequals(@col NVARCHAR(MAX), @path NVARCHAR(500))
RETURNS BIT
AS
BEGIN
RETURN 1 - dbo.json_null_equals(@col, @path);
END;;
-- =============================================================================
-- TEXT
-- =============================================================================
DROP FUNCTION IF EXISTS dbo.json_text_equals;;
CREATE FUNCTION dbo.json_text_equals(@col NVARCHAR(MAX), @path NVARCHAR(500), @target NVARCHAR(2000))
RETURNS BIT
AS
BEGIN
DECLARE @query NVARCHAR(MAX) = JSON_QUERY(@col, @path);
IF @query IS NOT NULL
RETURN CASE WHEN EXISTS (
SELECT 1 FROM OPENJSON(@query) AS j WHERE j.[type] = 1 AND j.[value] = @target
) THEN 1 ELSE 0 END;
RETURN CASE WHEN JSON_VALUE(@col, @path) = @target THEN 1 ELSE 0 END;
END;;
DROP FUNCTION IF EXISTS dbo.json_text_notequals;;
CREATE FUNCTION dbo.json_text_notequals(@col NVARCHAR(MAX), @path NVARCHAR(500), @target NVARCHAR(2000))
RETURNS BIT
AS
BEGIN
DECLARE @query NVARCHAR(MAX) = JSON_QUERY(@col, @path);
IF @query IS NOT NULL
RETURN CASE WHEN NOT EXISTS (
SELECT 1 FROM OPENJSON(@query) AS j WHERE j.[type] = 1 AND j.[value] = @target
) THEN 1 ELSE 0 END;
RETURN CASE WHEN JSON_VALUE(@col, @path) != @target THEN 1 ELSE 0 END;
END;;
DROP FUNCTION IF EXISTS dbo.json_text_lessthan;;
CREATE FUNCTION dbo.json_text_lessthan(@col NVARCHAR(MAX), @path NVARCHAR(500), @target NVARCHAR(2000))
RETURNS BIT
AS
BEGIN
DECLARE @query NVARCHAR(MAX) = JSON_QUERY(@col, @path);
IF @query IS NOT NULL
RETURN CASE WHEN EXISTS (
SELECT 1 FROM OPENJSON(@query) AS j WHERE j.[type] = 1 AND j.[value] < @target
) THEN 1 ELSE 0 END;
RETURN CASE WHEN JSON_VALUE(@col, @path) < @target THEN 1 ELSE 0 END;
END;;
DROP FUNCTION IF EXISTS dbo.json_text_lessthanorequal;;
CREATE FUNCTION dbo.json_text_lessthanorequal(@col NVARCHAR(MAX), @path NVARCHAR(500), @target NVARCHAR(2000))
RETURNS BIT
AS
BEGIN
DECLARE @query NVARCHAR(MAX) = JSON_QUERY(@col, @path);
IF @query IS NOT NULL
RETURN CASE WHEN EXISTS (
SELECT 1 FROM OPENJSON(@query) AS j WHERE j.[type] = 1 AND j.[value] <= @target
) THEN 1 ELSE 0 END;
RETURN CASE WHEN JSON_VALUE(@col, @path) <= @target THEN 1 ELSE 0 END;
END;;
DROP FUNCTION IF EXISTS dbo.json_text_greaterthan;;
CREATE FUNCTION dbo.json_text_greaterthan(@col NVARCHAR(MAX), @path NVARCHAR(500), @target NVARCHAR(2000))
RETURNS BIT
AS
BEGIN
DECLARE @query NVARCHAR(MAX) = JSON_QUERY(@col, @path);
IF @query IS NOT NULL
RETURN CASE WHEN EXISTS (
SELECT 1 FROM OPENJSON(@query) AS j WHERE j.[type] = 1 AND j.[value] > @target
) THEN 1 ELSE 0 END;
RETURN CASE WHEN JSON_VALUE(@col, @path) > @target THEN 1 ELSE 0 END;
END;;
DROP FUNCTION IF EXISTS dbo.json_text_greaterthanorequal;;
CREATE FUNCTION dbo.json_text_greaterthanorequal(@col NVARCHAR(MAX), @path NVARCHAR(500), @target NVARCHAR(2000))
RETURNS BIT
AS
BEGIN
DECLARE @query NVARCHAR(MAX) = JSON_QUERY(@col, @path);
IF @query IS NOT NULL
RETURN CASE WHEN EXISTS (
SELECT 1 FROM OPENJSON(@query) AS j WHERE j.[type] = 1 AND j.[value] >= @target
) THEN 1 ELSE 0 END;
RETURN CASE WHEN JSON_VALUE(@col, @path) >= @target THEN 1 ELSE 0 END;
END;;
DROP FUNCTION IF EXISTS dbo.json_text_contains;;
CREATE FUNCTION dbo.json_text_contains(@col NVARCHAR(MAX), @path NVARCHAR(500), @target NVARCHAR(2000))
RETURNS BIT
AS
BEGIN
DECLARE @query NVARCHAR(MAX) = JSON_QUERY(@col, @path);
IF @query IS NOT NULL
RETURN CASE WHEN EXISTS (
SELECT 1 FROM OPENJSON(@query) AS j WHERE j.[type] = 1 AND j.[value] LIKE '%' + @target + '%'
) THEN 1 ELSE 0 END;
RETURN CASE WHEN JSON_VALUE(@col, @path) LIKE '%' + @target + '%' THEN 1 ELSE 0 END;
END;;
DROP FUNCTION IF EXISTS dbo.json_text_startswith;;
CREATE FUNCTION dbo.json_text_startswith(@col NVARCHAR(MAX), @path NVARCHAR(500), @target NVARCHAR(2000))
RETURNS BIT
AS
BEGIN
DECLARE @query NVARCHAR(MAX) = JSON_QUERY(@col, @path);
IF @query IS NOT NULL
RETURN CASE WHEN EXISTS (
SELECT 1 FROM OPENJSON(@query) AS j WHERE j.[type] = 1 AND j.[value] LIKE @target + '%'
) THEN 1 ELSE 0 END;
RETURN CASE WHEN JSON_VALUE(@col, @path) LIKE @target + '%' THEN 1 ELSE 0 END;
END;;
DROP FUNCTION IF EXISTS dbo.json_text_endswith;;
CREATE FUNCTION dbo.json_text_endswith(@col NVARCHAR(MAX), @path NVARCHAR(500), @target NVARCHAR(2000))
RETURNS BIT
AS
BEGIN
DECLARE @query NVARCHAR(MAX) = JSON_QUERY(@col, @path);
IF @query IS NOT NULL
RETURN CASE WHEN EXISTS (
SELECT 1 FROM OPENJSON(@query) AS j WHERE j.[type] = 1 AND j.[value] LIKE '%' + @target
) THEN 1 ELSE 0 END;
RETURN CASE WHEN JSON_VALUE(@col, @path) LIKE '%' + @target THEN 1 ELSE 0 END;
END;;
DROP FUNCTION IF EXISTS dbo.json_text_matchs;;
CREATE FUNCTION dbo.json_text_matchs(@col NVARCHAR(MAX), @path NVARCHAR(500), @target NVARCHAR(2000))
RETURNS BIT
AS
BEGIN
DECLARE @query NVARCHAR(MAX) = JSON_QUERY(@col, @path);
IF @query IS NOT NULL
RETURN CASE WHEN EXISTS (
SELECT 1 FROM OPENJSON(@query) AS j WHERE j.[type] = 1 AND j.[value] LIKE @target
) THEN 1 ELSE 0 END;
RETURN CASE WHEN JSON_VALUE(@col, @path) LIKE @target THEN 1 ELSE 0 END;
END;;
-- =============================================================================
-- NUMBER
-- =============================================================================
DROP FUNCTION IF EXISTS dbo.json_number_equals;;
CREATE FUNCTION dbo.json_number_equals(@col NVARCHAR(MAX), @path NVARCHAR(500), @target DECIMAL(38, 10))
RETURNS BIT
AS
BEGIN
DECLARE @query NVARCHAR(MAX) = JSON_QUERY(@col, @path);
IF @query IS NOT NULL
RETURN CASE WHEN EXISTS (
SELECT 1 FROM OPENJSON(@query) AS j
WHERE j.[type] = 2
AND TRY_CAST(j.[value] AS DECIMAL(38, 10)) = @target
) THEN 1 ELSE 0 END;
RETURN CASE WHEN TRY_CAST(JSON_VALUE(@col, @path) AS DECIMAL(38, 10)) = @target THEN 1 ELSE 0 END;
END;;
DROP FUNCTION IF EXISTS dbo.json_number_notequals;;
CREATE FUNCTION dbo.json_number_notequals(@col NVARCHAR(MAX), @path NVARCHAR(500), @target DECIMAL(38, 10))
RETURNS BIT
AS
BEGIN
DECLARE @query NVARCHAR(MAX) = JSON_QUERY(@col, @path);
IF @query IS NOT NULL
RETURN CASE WHEN NOT EXISTS (
SELECT 1 FROM OPENJSON(@query) AS j
WHERE j.[type] = 2
AND TRY_CAST(j.[value] AS DECIMAL(38, 10)) = @target
) THEN 1 ELSE 0 END;
RETURN CASE WHEN TRY_CAST(JSON_VALUE(@col, @path) AS DECIMAL(38, 10)) != @target THEN 1 ELSE 0 END;
END;;
DROP FUNCTION IF EXISTS dbo.json_number_lessthan;;
CREATE FUNCTION dbo.json_number_lessthan(@col NVARCHAR(MAX), @path NVARCHAR(500), @target DECIMAL(38, 10))
RETURNS BIT
AS
BEGIN
DECLARE @query NVARCHAR(MAX) = JSON_QUERY(@col, @path);
IF @query IS NOT NULL
RETURN CASE WHEN EXISTS (
SELECT 1 FROM OPENJSON(@query) AS j
WHERE j.[type] = 2
AND TRY_CAST(j.[value] AS DECIMAL(38, 10)) < @target
) THEN 1 ELSE 0 END;
RETURN CASE WHEN TRY_CAST(JSON_VALUE(@col, @path) AS DECIMAL(38, 10)) < @target THEN 1 ELSE 0 END;
END;;
DROP FUNCTION IF EXISTS dbo.json_number_lessthanorequal;;
CREATE FUNCTION dbo.json_number_lessthanorequal(@col NVARCHAR(MAX), @path NVARCHAR(500), @target DECIMAL(38, 10))
RETURNS BIT
AS
BEGIN
DECLARE @query NVARCHAR(MAX) = JSON_QUERY(@col, @path);
IF @query IS NOT NULL
RETURN CASE WHEN EXISTS (
SELECT 1 FROM OPENJSON(@query) AS j
WHERE j.[type] = 2
AND TRY_CAST(j.[value] AS DECIMAL(38, 10)) <= @target
) THEN 1 ELSE 0 END;
RETURN CASE WHEN TRY_CAST(JSON_VALUE(@col, @path) AS DECIMAL(38, 10)) <= @target THEN 1 ELSE 0 END;
END;;
DROP FUNCTION IF EXISTS dbo.json_number_greaterthan;;
CREATE FUNCTION dbo.json_number_greaterthan(@col NVARCHAR(MAX), @path NVARCHAR(500), @target DECIMAL(38, 10))
RETURNS BIT
AS
BEGIN
DECLARE @query NVARCHAR(MAX) = JSON_QUERY(@col, @path);
IF @query IS NOT NULL
RETURN CASE WHEN EXISTS (
SELECT 1 FROM OPENJSON(@query) AS j
WHERE j.[type] = 2
AND TRY_CAST(j.[value] AS DECIMAL(38, 10)) > @target
) THEN 1 ELSE 0 END;
RETURN CASE WHEN TRY_CAST(JSON_VALUE(@col, @path) AS DECIMAL(38, 10)) > @target THEN 1 ELSE 0 END;
END;;
DROP FUNCTION IF EXISTS dbo.json_number_greaterthanorequal;;
CREATE FUNCTION dbo.json_number_greaterthanorequal(@col NVARCHAR(MAX), @path NVARCHAR(500), @target DECIMAL(38, 10))
RETURNS BIT
AS
BEGIN
DECLARE @query NVARCHAR(MAX) = JSON_QUERY(@col, @path);
IF @query IS NOT NULL
RETURN CASE WHEN EXISTS (
SELECT 1 FROM OPENJSON(@query) AS j
WHERE j.[type] = 2
AND TRY_CAST(j.[value] AS DECIMAL(38, 10)) >= @target
) THEN 1 ELSE 0 END;
RETURN CASE WHEN TRY_CAST(JSON_VALUE(@col, @path) AS DECIMAL(38, 10)) >= @target THEN 1 ELSE 0 END;
END;;
-- =============================================================================
-- BOOLEAN
-- =============================================================================
DROP FUNCTION IF EXISTS dbo.json_boolean_equals;;
CREATE FUNCTION dbo.json_boolean_equals(@col NVARCHAR(MAX), @path NVARCHAR(500), @target BIT)
RETURNS BIT
AS
BEGIN
DECLARE @query NVARCHAR(MAX) = JSON_QUERY(@col, @path);
IF @query IS NOT NULL
RETURN CASE WHEN EXISTS (
SELECT 1 FROM OPENJSON(@query) AS j
WHERE j.[type] = 3
AND IIF(j.[value] = 'true', 1, IIF(j.[value] = 'false', 0, NULL)) = @target
) THEN 1 ELSE 0 END;
RETURN CASE WHEN IIF(JSON_VALUE(@col, @path) = 'true', 1, IIF(JSON_VALUE(@col, @path) = 'false', 0, NULL)) = @target THEN 1 ELSE 0 END;
END;;
DROP FUNCTION IF EXISTS dbo.json_boolean_notequals;;
CREATE FUNCTION dbo.json_boolean_notequals(@col NVARCHAR(MAX), @path NVARCHAR(500), @target BIT)
RETURNS BIT
AS
BEGIN
DECLARE @query NVARCHAR(MAX) = JSON_QUERY(@col, @path);
IF @query IS NOT NULL
RETURN CASE WHEN NOT EXISTS (
SELECT 1 FROM OPENJSON(@query) AS j
WHERE j.[type] = 3
AND IIF(j.[value] = 'true', 1, IIF(j.[value] = 'false', 0, NULL)) = @target
) THEN 1 ELSE 0 END;
RETURN CASE WHEN IIF(JSON_VALUE(@col, @path) = 'true', 1, IIF(JSON_VALUE(@col, @path) = 'false', 0, NULL)) != @target THEN 1 ELSE 0 END;
END;;

9
backend/src/Squidex.Data.EntityFramework/Squidex.Data.EntityFramework.csproj

@ -10,6 +10,10 @@
<DebugType>full</DebugType>
<DebugSymbols>True</DebugSymbols>
</PropertyGroup>
<ItemGroup>
<None Remove="Providers\Postgres\json_function.sql" />
<None Remove="Providers\SqlServer\json_function.sql" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Squidex.Domain.Apps.Entities\Squidex.Domain.Apps.Entities.csproj" />
<ProjectReference Include="..\Squidex.Domain.Users\Squidex.Domain.Users.csproj" />
@ -55,4 +59,9 @@
<ItemGroup>
<AdditionalFiles Include="..\..\stylecop.json" Link="stylecop.json" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Providers\MySql\json_function.sql" />
<EmbeddedResource Include="Providers\Postgres\json_function.sql" />
<EmbeddedResource Include="Providers\SqlServer\json_function.sql" />
</ItemGroup>
</Project>

462
backend/tests/Squidex.Data.Tests/EntityFramework/Infrastructure/Queries/EFQueryTests.cs

@ -6,7 +6,6 @@
// ==========================================================================
using Microsoft.EntityFrameworkCore;
using MongoDB.Driver;
using NetTopologySuite.Geometries;
using Squidex.EntityFramework.TestHelpers;
using Squidex.Infrastructure;
@ -94,6 +93,31 @@ public abstract class EFQueryTests<TContext>(ISqlFixture<TContext> fixture)
});
}
set.Add(new TestEntity
{
Boolean = false,
BooleanOrNull = null,
Number = 21,
NumberOrNull = null,
FullText = string.Empty,
Text = string.Empty,
Json = new TestJson
{
Boolean = false,
BooleanArray = [],
BooleanOrNull = null,
Mixed = null,
MixedArray = [],
Number = 21,
NumberArray = [],
NumberOrNull = null,
Text = string.Empty,
TextArray = [],
TextOrNull = null,
},
Point = new Point(0, 0) { SRID = 4326 },
});
await dbContext.SaveChangesAsync();
return dbContext;
}
@ -155,7 +179,7 @@ public abstract class EFQueryTests<TContext>(ISqlFixture<TContext> fixture)
Sort = [new SortNode("Number", SortOrder.Descending)],
});
Assert.Equal(Range(15, 11), actual);
Assert.Equal(Range(16, 12), actual);
}
[Fact]
@ -210,7 +234,7 @@ public abstract class EFQueryTests<TContext>(ISqlFixture<TContext> fixture)
Sort = [new SortNode("Text", SortOrder.Ascending)],
});
Assert.Equal([.. Range(10, 19), 1, 2, 20, .. Range(3, 9)], actual);
Assert.Equal([.. Range(10, 19), 1, 20, 2, .. Range(3, 9)], actual);
}
[Fact]
@ -232,7 +256,7 @@ public abstract class EFQueryTests<TContext>(ISqlFixture<TContext> fixture)
Sort = [new SortNode("Json.text", SortOrder.Ascending)],
});
Assert.Equal([.. Range(10, 19), 1, 2, 20, .. Range(3, 9)], actual);
Assert.Equal([.. Range(10, 19), 1, 20, 2, .. Range(3, 9)], actual);
}
[Fact]
@ -415,6 +439,17 @@ public abstract class EFQueryTests<TContext>(ISqlFixture<TContext> fixture)
Assert.Equal(Range(1, 5), actual.Order().ToArray());
}
[Fact]
public async Task Should_filter_by_number_in()
{
var actual = await QueryAsync(new ClrQuery
{
Filter = ClrFilter.In("Number", new List<int> { 3, 7, 15 }),
});
Assert.Equal([3, 7, 15], actual.Order().ToArray());
}
[Fact]
public async Task Should_filter_by_number_equal_in_json()
{
@ -481,6 +516,17 @@ public abstract class EFQueryTests<TContext>(ISqlFixture<TContext> fixture)
Assert.Equal(Range(1, 5), actual.Order().ToArray());
}
[Fact]
public async Task Should_filter_by_number_in_json()
{
var actual = await QueryAsync(new ClrQuery
{
Filter = ClrFilter.In("Json.number", new List<int> { 3, 7, 15 }),
});
Assert.Equal([3, 7, 15], actual.Order().ToArray());
}
[Fact]
public async Task Should_filter_by_number_null_in_nullable_json()
{
@ -536,6 +582,28 @@ public abstract class EFQueryTests<TContext>(ISqlFixture<TContext> fixture)
Assert.Equal([1, 2, 3], actual.Order().ToArray());
}
[Fact]
public async Task Should_filter_by_number_empty_in_nullable_json()
{
var actual = await QueryAsync(new ClrQuery
{
Filter = ClrFilter.Empty("Json.numberOrNull"),
});
Assert.Equal(Range(11, 20), actual.Order().ToArray());
}
[Fact]
public async Task Should_filter_by_number_exists_in_nullable_json()
{
var actual = await QueryAsync(new ClrQuery
{
Filter = ClrFilter.Exists("Json.numberOrNull"),
});
Assert.Equal(Range(1, 10), actual.Order().ToArray());
}
[Fact]
public async Task Should_filter_by_number_equal_in_mixed_json()
{
@ -580,6 +648,17 @@ public abstract class EFQueryTests<TContext>(ISqlFixture<TContext> fixture)
Assert.Equal([7], actual.Order().ToArray());
}
[Fact]
public async Task Should_filter_by_number_not_equal_in_json_number_array()
{
var actual = await QueryAsync(new ClrQuery
{
Filter = ClrFilter.Ne("Json.numberArray", 7),
});
Assert.Equal(AllExept(7), actual.Order().ToArray());
}
[Fact]
public async Task Should_filter_by_number_greater_than_in_json_number_array()
{
@ -602,6 +681,28 @@ public abstract class EFQueryTests<TContext>(ISqlFixture<TContext> fixture)
Assert.Equal([18, 19, 20], actual.Order().ToArray());
}
[Fact]
public async Task Should_filter_by_number_less_than_in_json_number_array()
{
var actual = await QueryAsync(new ClrQuery
{
Filter = ClrFilter.Lt("Json.numberArray", 0),
});
Assert.Empty(actual);
}
[Fact]
public async Task Should_filter_by_number_less_than_or_equal_in_json_number_array()
{
var actual = await QueryAsync(new ClrQuery
{
Filter = ClrFilter.Le("Json.numberArray", 0),
});
Assert.Equal(Range(1, 20), actual.Order().ToArray());
}
[Fact]
public async Task Should_filter_by_number_in_json_number_array()
{
@ -624,6 +725,28 @@ public abstract class EFQueryTests<TContext>(ISqlFixture<TContext> fixture)
Assert.Equal(Range(6, 15), actual.Order().ToArray());
}
[Fact]
public async Task Should_filter_by_number_empty_in_json_number_array()
{
var actual = await QueryAsync(new ClrQuery
{
Filter = ClrFilter.Empty("Json.numberArray"),
}, includeSpecialCase: true);
Assert.Equal([21], actual.Order().ToArray());
}
[Fact]
public async Task Should_filter_by_number_exists_in_json_number_array()
{
var actual = await QueryAsync(new ClrQuery
{
Filter = ClrFilter.Exists("Json.numberArray"),
});
Assert.Equal(Range(1, 20), actual.Order().ToArray());
}
[Fact]
public async Task Should_filter_by_number_equal_in_json_mixed_array()
{
@ -723,6 +846,17 @@ public abstract class EFQueryTests<TContext>(ISqlFixture<TContext> fixture)
Assert.Equal([5, 15], actual.Order().ToArray());
}
[Fact]
public async Task Should_filter_by_string_in()
{
var actual = await QueryAsync(new ClrQuery
{
Filter = ClrFilter.In("Text", new List<string> { "Prefix3Suffix", "Prefix7Suffix" }),
});
Assert.Equal([3, 7], actual.Order().ToArray());
}
[Fact]
public async Task Should_filter_by_string_equal_in_json()
{
@ -778,6 +912,39 @@ public abstract class EFQueryTests<TContext>(ISqlFixture<TContext> fixture)
Assert.Equal([5, 15], actual.Order().ToArray());
}
[Fact]
public async Task Should_filter_by_string_in_json()
{
var actual = await QueryAsync(new ClrQuery
{
Filter = ClrFilter.In("Json.text", new List<string> { "Prefix3Suffix", "Prefix7Suffix" }),
});
Assert.Equal([3, 7], actual.Order().ToArray());
}
[Fact]
public async Task Should_filter_by_string_empty_in_json()
{
var actual = await QueryAsync(new ClrQuery
{
Filter = ClrFilter.Empty("Json.text"),
}, includeSpecialCase: true);
Assert.Equal([21], actual.Order().ToArray());
}
[Fact]
public async Task Should_filter_by_string_exists_in_json()
{
var actual = await QueryAsync(new ClrQuery
{
Filter = ClrFilter.Exists("Json.text"),
});
Assert.Equal(Range(1, 20), actual.Order().ToArray());
}
[Fact]
public async Task Should_filter_by_string_null_in_nullable_json()
{
@ -833,6 +1000,28 @@ public abstract class EFQueryTests<TContext>(ISqlFixture<TContext> fixture)
Assert.Equal(Range(1, 10), actual.Order().ToArray());
}
[Fact]
public async Task Should_filter_by_string_empty_in_nullable_json()
{
var actual = await QueryAsync(new ClrQuery
{
Filter = ClrFilter.Empty("Json.textOrNull"),
});
Assert.Equal(Range(11, 20), actual.Order().ToArray());
}
[Fact]
public async Task Should_filter_by_string_exists_in_nullable_json()
{
var actual = await QueryAsync(new ClrQuery
{
Filter = ClrFilter.Exists("Json.textOrNull"),
});
Assert.Equal(Range(1, 10), actual.Order().ToArray());
}
[Fact]
public async Task Should_filter_by_string_contains_in_mixed_json()
{
@ -885,7 +1074,7 @@ public abstract class EFQueryTests<TContext>(ISqlFixture<TContext> fixture)
Filter = ClrFilter.Ne("Json.textArray", "X5"),
});
Assert.Equal(20, actual.Count);
Assert.Equal(19, actual.Count);
}
[Fact]
@ -921,6 +1110,39 @@ public abstract class EFQueryTests<TContext>(ISqlFixture<TContext> fixture)
Assert.Equal([5, 15], actual.Order().ToArray());
}
[Fact]
public async Task Should_filter_by_string_in_json_text_array()
{
var actual = await QueryAsync(new ClrQuery
{
Filter = ClrFilter.In("Json.textArray", new List<string> { "X5", "Y7" }),
});
Assert.Equal([5, 7], actual.Order().ToArray());
}
[Fact]
public async Task Should_filter_by_string_empty_in_json_text_array()
{
var actual = await QueryAsync(new ClrQuery
{
Filter = ClrFilter.Empty("Json.textArray"),
}, includeSpecialCase: true);
Assert.Equal([21], actual.Order().ToArray());
}
[Fact]
public async Task Should_filter_by_string_exists_in_json_text_array()
{
var actual = await QueryAsync(new ClrQuery
{
Filter = ClrFilter.Exists("Json.textArray"),
});
Assert.Equal(Range(1, 20), actual.Order().ToArray());
}
[Fact]
public async Task Should_filter_by_string_contains_in_json_mixed_array()
{
@ -943,6 +1165,17 @@ public abstract class EFQueryTests<TContext>(ISqlFixture<TContext> fixture)
Assert.Equal([5], actual.Order().ToArray());
}
[Fact]
public async Task Should_filter_by_string_in_json_mixed_array()
{
var actual = await QueryAsync(new ClrQuery
{
Filter = ClrFilter.In("Json.mixedArray", new List<string> { "T5", "T7" }),
});
Assert.Equal([5, 7], actual.Order().ToArray());
}
[Fact]
public async Task Should_filter_by_boolean_true()
{
@ -965,6 +1198,17 @@ public abstract class EFQueryTests<TContext>(ISqlFixture<TContext> fixture)
Assert.Equal(Range(1, 10), actual.Order().ToArray());
}
[Fact]
public async Task Should_filter_by_boolean_in()
{
var actual = await QueryAsync(new ClrQuery
{
Filter = ClrFilter.In("Boolean", new List<bool> { true }),
});
Assert.Equal(Range(11, 20), actual.Order().ToArray());
}
[Fact]
public async Task Should_filter_by_boolean_true_in_json()
{
@ -987,6 +1231,50 @@ public abstract class EFQueryTests<TContext>(ISqlFixture<TContext> fixture)
Assert.Equal(Range(1, 10), actual.Order().ToArray());
}
[Fact]
public async Task Should_filter_by_boolean_not_equal_true_in_json()
{
var actual = await QueryAsync(new ClrQuery
{
Filter = ClrFilter.Ne("Json.boolean", true),
});
Assert.Equal(Range(1, 10), actual.Order().ToArray());
}
[Fact]
public async Task Should_filter_by_boolean_not_equal_false_in_json()
{
var actual = await QueryAsync(new ClrQuery
{
Filter = ClrFilter.Ne("Json.boolean", false),
});
Assert.Equal(Range(11, 20), actual.Order().ToArray());
}
[Fact]
public async Task Should_filter_by_boolean_in_json()
{
var actual = await QueryAsync(new ClrQuery
{
Filter = ClrFilter.In("Json.boolean", new List<bool> { true }),
});
Assert.Equal(Range(11, 20), actual.Order().ToArray());
}
[Fact]
public async Task Should_filter_by_boolean_in_json_returns_all_when_both_values()
{
var actual = await QueryAsync(new ClrQuery
{
Filter = ClrFilter.In("Json.boolean", new List<bool> { true, false }),
});
Assert.Equal(Range(1, 20), actual.Order().ToArray());
}
[Fact]
public async Task Should_filter_by_boolean_null_in_nullable_json()
{
@ -1020,6 +1308,28 @@ public abstract class EFQueryTests<TContext>(ISqlFixture<TContext> fixture)
Assert.Equal(Range(11, 20), actual.Order().ToArray());
}
[Fact]
public async Task Should_filter_by_boolean_empty_in_nullable_json()
{
var actual = await QueryAsync(new ClrQuery
{
Filter = ClrFilter.Empty("Json.booleanOrNull"),
});
Assert.Equal(Range(1, 10), actual.Order().ToArray());
}
[Fact]
public async Task Should_filter_by_boolean_exists_in_nullable_json()
{
var actual = await QueryAsync(new ClrQuery
{
Filter = ClrFilter.Exists("Json.booleanOrNull"),
});
Assert.Equal(Range(11, 20), actual.Order().ToArray());
}
[Fact]
public async Task Should_filter_by_boolean_true_in_mixed_json()
{
@ -1064,6 +1374,72 @@ public abstract class EFQueryTests<TContext>(ISqlFixture<TContext> fixture)
Assert.Equal(Range(1, 15), actual.Order().ToArray());
}
[Fact]
public async Task Should_filter_by_boolean_not_equal_true_in_json_boolean_array()
{
var actual = await QueryAsync(new ClrQuery
{
Filter = ClrFilter.Ne("Json.booleanArray", true),
});
Assert.Equal(Range(1, 10), actual.Order().ToArray());
}
[Fact]
public async Task Should_filter_by_boolean_not_equal_false_in_json_boolean_array()
{
var actual = await QueryAsync(new ClrQuery
{
Filter = ClrFilter.Ne("Json.booleanArray", false),
});
Assert.Equal(Range(16, 20), actual.Order().ToArray());
}
[Fact]
public async Task Should_filter_by_boolean_in_true_in_json_boolean_array()
{
var actual = await QueryAsync(new ClrQuery
{
Filter = ClrFilter.In("Json.booleanArray", new List<bool> { true }),
});
Assert.Equal(Range(11, 20), actual.Order().ToArray());
}
[Fact]
public async Task Should_filter_by_boolean_in_false_in_json_boolean_array()
{
var actual = await QueryAsync(new ClrQuery
{
Filter = ClrFilter.In("Json.booleanArray", new List<bool> { false }),
});
Assert.Equal(Range(1, 15), actual.Order().ToArray());
}
[Fact]
public async Task Should_filter_by_boolean_empty_in_json_boolean_array()
{
var actual = await QueryAsync(new ClrQuery
{
Filter = ClrFilter.Empty("Json.booleanArray"),
}, includeSpecialCase: true);
Assert.Equal([21], actual.Order().ToArray());
}
[Fact]
public async Task Should_filter_by_boolean_exists_in_json_boolean_array()
{
var actual = await QueryAsync(new ClrQuery
{
Filter = ClrFilter.Exists("Json.booleanArray"),
});
Assert.Equal(Range(1, 20), actual.Order().ToArray());
}
[Fact]
public async Task Should_filter_by_boolean_true_in_json_mixed_array()
{
@ -1086,6 +1462,28 @@ public abstract class EFQueryTests<TContext>(ISqlFixture<TContext> fixture)
Assert.Equal(Range(1, 10), actual.Order().ToArray());
}
[Fact]
public async Task Should_filter_by_boolean_not_equal_true_in_json_mixed_array()
{
var actual = await QueryAsync(new ClrQuery
{
Filter = ClrFilter.Ne("Json.mixedArray", true),
});
Assert.Equal(Range(1, 10), actual.Order().ToArray());
}
[Fact]
public async Task Should_filter_by_boolean_in_json_mixed_array()
{
var actual = await QueryAsync(new ClrQuery
{
Filter = ClrFilter.In("Json.mixedArray", new List<bool> { true }),
});
Assert.Equal(Range(11, 20), actual.Order().ToArray());
}
[Fact]
public async Task Should_filter_by_null_on_number()
{
@ -1196,6 +1594,28 @@ public abstract class EFQueryTests<TContext>(ISqlFixture<TContext> fixture)
Assert.Equal(AllExept(6, 12, 18), actual.Order().ToArray());
}
[Fact]
public async Task Should_filter_by_empty_in_mixed_json()
{
var actual = await QueryAsync(new ClrQuery
{
Filter = ClrFilter.Empty("Json.mixed"),
}, includeSpecialCase: true);
Assert.Equal([6, 12, 18, 21], actual.Order().ToArray());
}
[Fact]
public async Task Should_filter_by_exists_in_mixed_json()
{
var actual = await QueryAsync(new ClrQuery
{
Filter = ClrFilter.Exists("Json.mixed"),
}, includeSpecialCase: true);
Assert.Equal(AllExept(6, 12, 18, 21), actual.Order().ToArray());
}
[Fact]
public async Task Should_filter_by_null_in_json_mixed_array()
{
@ -1215,6 +1635,28 @@ public abstract class EFQueryTests<TContext>(ISqlFixture<TContext> fixture)
Filter = ClrFilter.Ne("Json.mixedArray", ClrValue.Null),
});
Assert.Equal(14, actual.Count);
}
[Fact]
public async Task Should_filter_by_empty_in_json_mixed_array()
{
var actual = await QueryAsync(new ClrQuery
{
Filter = ClrFilter.Empty("Json.mixedArray"),
}, includeSpecialCase: true);
Assert.Equal([21], actual.Order().ToArray());
}
[Fact]
public async Task Should_filter_by_exists_in_json_mixed_array()
{
var actual = await QueryAsync(new ClrQuery
{
Filter = ClrFilter.Exists("Json.mixedArray"),
}, includeSpecialCase: true);
Assert.Equal(20, actual.Count);
}
@ -1230,7 +1672,7 @@ public abstract class EFQueryTests<TContext>(ISqlFixture<TContext> fixture)
var (sql, parameters) = builder.Compile();
var dbResult = await dbContext.Database.SqlQueryRaw<int>(sql, parameters).FirstOrDefaultAsync();
Assert.Equal(20, dbResult);
Assert.Equal(21, dbResult);
}
[Fact]
@ -1251,7 +1693,7 @@ public abstract class EFQueryTests<TContext>(ISqlFixture<TContext> fixture)
var (sql, parameters) = builder.Compile();
var dbResult = await dbContext.Database.SqlQueryRaw<int>(sql, parameters).FirstOrDefaultAsync();
Assert.Equal(10, dbResult);
Assert.Equal(11, dbResult);
}
[Fact]
@ -1266,6 +1708,7 @@ public abstract class EFQueryTests<TContext>(ISqlFixture<TContext> fixture)
}
[Fact]
[Trait("Category", "Slow")]
public async Task Should_query_full_text()
{
var dbContext = await CreateAndPrepareDbContextAsync();
@ -1281,6 +1724,7 @@ public abstract class EFQueryTests<TContext>(ISqlFixture<TContext> fixture)
}
[Fact]
[Trait("Category", "Slow")]
public async Task Should_query_full_text_with_space()
{
var dbContext = await CreateAndPrepareDbContextAsync();
@ -1321,7 +1765,7 @@ public abstract class EFQueryTests<TContext>(ISqlFixture<TContext> fixture)
return result.ToArray();
}
private async Task<List<long>> QueryAsync(ClrQuery query)
private async Task<List<long>> QueryAsync(ClrQuery query, bool includeSpecialCase = false)
{
var dbContext = await CreateAndPrepareDbContextAsync();
@ -1336,7 +1780,7 @@ public abstract class EFQueryTests<TContext>(ISqlFixture<TContext> fixture)
File.WriteAllText("D:\\last.json", sql);
var dbResult = await dbContext.Set<TestEntity>().FromSqlRaw(sql, parameters).ToListAsync();
return dbResult.Select(x => x.Number).ToList();
return dbResult.Select(x => x.Number).Where(x => includeSpecialCase || x != 21).ToList();
}
private static async Task<List<TestEntity>> PollAsync(TContext dbContext, string sql, object[] parameters, int expectedCount)

2
backend/tests/Squidex.Data.Tests/EntityFramework/TestHelpers/MySqlFixture.cs

@ -12,6 +12,7 @@ using Squidex.Domain.Apps.Core.TestHelpers;
using Squidex.Hosting;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Migrations;
using Squidex.Infrastructure.Queries;
using Squidex.Providers.MySql;
using Squidex.Providers.MySql.Content;
using Testcontainers.MySql;
@ -63,6 +64,7 @@ public class MySqlFixture(string? reuseId = null) : IAsyncLifetime, ISqlContentF
.AddSingleton<ConnectionStringParser, MySqlConnectionStringParser>()
.AddSingletonAs<DatabaseCreator<TestDbContextMySql>>().Done()
.AddSingleton(TestUtils.DefaultSerializer)
.AddSingleton<IInitializable, SqlDialectInitializer<TestDbContextMySql>>()
.BuildServiceProvider();
foreach (var service in services.GetRequiredService<IEnumerable<IInitializable>>())

2
backend/tests/Squidex.Data.Tests/EntityFramework/TestHelpers/PostgresFixture.cs

@ -12,6 +12,7 @@ using Squidex.Domain.Apps.Core.TestHelpers;
using Squidex.Hosting;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Migrations;
using Squidex.Infrastructure.Queries;
using Squidex.Providers.Postgres;
using Squidex.Providers.Postgres.Content;
using Testcontainers.PostgreSql;
@ -59,6 +60,7 @@ public class PostgresFixture(string? reuseId) : IAsyncLifetime, ISqlContentFixtu
.AddSingleton<ConnectionStringParser, PostgresConnectionStringParser>()
.AddSingletonAs<DatabaseCreator<TestDbContextPostgres>>().Done()
.AddSingleton(TestUtils.DefaultSerializer)
.AddSingleton<IInitializable, SqlDialectInitializer<TestDbContextPostgres>>()
.BuildServiceProvider();
foreach (var service in services.GetRequiredService<IEnumerable<IInitializable>>())

2
backend/tests/Squidex.Data.Tests/EntityFramework/TestHelpers/SqlServerFixture.cs

@ -13,6 +13,7 @@ using Squidex.Domain.Apps.Core.TestHelpers;
using Squidex.Hosting;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Migrations;
using Squidex.Infrastructure.Queries;
using Squidex.Providers.SqlServer;
using Squidex.Providers.SqlServer.Content;
using Testcontainers.MsSql;
@ -61,6 +62,7 @@ public class SqlServerFixture(string? reuseId = null) : IAsyncLifetime, ISqlCont
.AddSingleton<ConnectionStringParser, SqlServerConnectionStringParser>()
.AddSingletonAs<DatabaseCreator<TestDbContextSqlServer>>().Done()
.AddSingleton(TestUtils.DefaultSerializer)
.AddSingleton<IInitializable, SqlDialectInitializer<TestDbContextSqlServer>>()
.BuildServiceProvider();
foreach (var service in services.GetRequiredService<IEnumerable<IInitializable>>())

35
frontend/refactor.js

@ -0,0 +1,35 @@
#!/usr/bin/env node
const fs = require("fs");
const path = require("path");
const ROOT_DIR = ".";
const SKIP_DIRS = ["node_modules", ".git", "dist", "build", ".cache"];
const BUTTON_TAG_RE = /<button(\s[^>]*?)?>/gi;
function fixButtons(html) {
return html.replace(BUTTON_TAG_RE, (match, attrs) => {
if (attrs && /\btype\s*=/i.test(attrs)) return match;
return attrs ? `<button type="button"${attrs}>` : `<button type="button">`;
});
}
function walk(dir) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (!SKIP_DIRS.includes(entry.name)) walk(fullPath);
} else if (entry.isFile() && entry.name.endsWith(".component.html")) {
const original = fs.readFileSync(fullPath, "utf8");
const fixed = fixButtons(original);
if (fixed !== original) {
fs.writeFileSync(fullPath, fixed, "utf8");
console.log(`Fixed: ${fullPath}`);
}
}
}
}
walk(path.resolve(ROOT_DIR));
Loading…
Cancel
Save