diff --git a/backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Contents/ContentSqlQueryBuilder.cs b/backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Contents/ContentSqlQueryBuilder.cs index ec33fed09..91825235d 100644 --- a/backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Contents/ContentSqlQueryBuilder.cs +++ b/backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Contents/ContentSqlQueryBuilder.cs @@ -10,12 +10,12 @@ using Squidex.Text; namespace Squidex.Domain.Apps.Entities.Contents; -public class ContentSqlQueryBuilder(SqlDialect dialect, string table, SqlParams? parameters = null) : SqlQueryBuilder(dialect, table, parameters) +public class ContentSqlQueryBuilder(SqlDialect dialect, string table, SqlParams? parameters = null) + : SqlQueryBuilder(dialect, table, parameters) { public override PropertyPath Visit(PropertyPath path) { var elements = path.ToList(); - elements[0] = elements[0].ToPascalCase(); return new PropertyPath(elements); diff --git a/backend/src/Squidex.Data.EntityFramework/Infrastructure/Extensions.cs b/backend/src/Squidex.Data.EntityFramework/Infrastructure/Extensions.cs index 3e432e3eb..b21a9dfba 100644 --- a/backend/src/Squidex.Data.EntityFramework/Infrastructure/Extensions.cs +++ b/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; diff --git a/backend/src/Squidex.Data.EntityFramework/Infrastructure/PooledDbNamedContextFactory.cs b/backend/src/Squidex.Data.EntityFramework/Infrastructure/PooledDbNamedContextFactory.cs index 63f856588..b5bff4d65 100644 --- a/backend/src/Squidex.Data.EntityFramework/Infrastructure/PooledDbNamedContextFactory.cs +++ b/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. diff --git a/backend/src/Squidex.Data.EntityFramework/Infrastructure/Queries/SqlDialect.cs b/backend/src/Squidex.Data.EntityFramework/Infrastructure/Queries/SqlDialect.cs index 9f08cd907..0e5795f5c 100644 --- a/backend/src/Squidex.Data.EntityFramework/Infrastructure/Queries/SqlDialect.cs +++ b/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"); @@ -144,7 +151,7 @@ public class SqlDialect throw new NotSupportedException(); } - protected virtual string FormatValues(CompareOperator op, ClrValue value, SqlParams queryParameters) + protected virtual string FormatValues(CompareOperator op, ClrValue value, SqlParams queryParameters, bool withoutBraces = false) { if (!value.IsList && value.ValueType == ClrValueType.Null) { @@ -170,6 +177,11 @@ public class SqlDialect if (op == CompareOperator.In) { + if (withoutBraces) + { + return string.Join(", ", parameters); + } + return $"({string.Join(", ", parameters)})"; } diff --git a/backend/src/Squidex.Data.EntityFramework/Infrastructure/Queries/SqlDialectInitializer.cs b/backend/src/Squidex.Data.EntityFramework/Infrastructure/Queries/SqlDialectInitializer.cs new file mode 100644 index 000000000..13a5efe71 --- /dev/null +++ b/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(IDbContextFactory 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); + } +} diff --git a/backend/src/Squidex.Data.EntityFramework/Providers/MySql/Content/JsonFunction.cs b/backend/src/Squidex.Data.EntityFramework/Providers/MySql/Content/JsonFunction.cs new file mode 100644 index 000000000..dbaf01ef4 --- /dev/null +++ b/backend/src/Squidex.Data.EntityFramework/Providers/MySql/Content/JsonFunction.cs @@ -0,0 +1,5 @@ +namespace Squidex.Providers.MySql.Content; + +public static class JsonFunction +{ +} diff --git a/backend/src/Squidex.Data.EntityFramework/Providers/MySql/Extensions.cs b/backend/src/Squidex.Data.EntityFramework/Providers/MySql/Extensions.cs index e028ca64f..b35787055 100644 --- a/backend/src/Squidex.Data.EntityFramework/Providers/MySql/Extensions.cs +++ b/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(); diff --git a/backend/src/Squidex.Data.EntityFramework/Providers/MySql/JsonFunction.cs b/backend/src/Squidex.Data.EntityFramework/Providers/MySql/JsonFunction.cs new file mode 100644 index 000000000..69f763db6 --- /dev/null +++ b/backend/src/Squidex.Data.EntityFramework/Providers/MySql/JsonFunction.cs @@ -0,0 +1,122 @@ +// ========================================================================== +// 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()})"; + } + + var arg = formattedValue; + if (value.IsList) + { + arg = $"JSON_ARRAY({formattedValue})"; + } + + return $"{fn}(`{path[0]}`, {path.JsonSubPath()}, {arg})"; + } +} diff --git a/backend/src/Squidex.Data.EntityFramework/Providers/MySql/MySqlDialect.cs b/backend/src/Squidex.Data.EntityFramework/Providers/MySql/MySqlDialect.cs index 69589b94c..f63936899 100644 --- a/backend/src/Squidex.Data.EntityFramework/Providers/MySql/MySqlDialect.cs +++ b/backend/src/Squidex.Data.EntityFramework/Providers/MySql/MySqlDialect.cs @@ -5,6 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using Microsoft.EntityFrameworkCore; using MySqlConnector; using Squidex.Infrastructure.Queries; @@ -18,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; @@ -85,7 +92,13 @@ public sealed class MySqlDialect : SqlDialect var sqlOrder = FormatOrder(order); var sqlPath = path.JsonPath(); - return $"IF(JSON_TYPE(JSON_EXTRACT({sqlPath})) IN ('INTEGER', 'DOUBLE', 'DECIMAL'), CAST(JSON_VALUE({sqlPath}) AS DOUBLE), NULL) {sqlOrder}, JSON_VALUE({sqlPath}) {sqlOrder}"; + return $""" + IF (JSON_TYPE(JSON_EXTRACT({sqlPath})) IN ('INTEGER', 'DOUBLE', 'DECIMAL'), + CAST(JSON_VALUE({sqlPath}) AS DOUBLE), + NULL + ) {sqlOrder}, + JSON_VALUE({sqlPath}) {sqlOrder} + """; } return base.OrderBy(path, order, isJson); @@ -100,15 +113,7 @@ public sealed class MySqlDialect : SqlDialect { if (isJson) { - var isBoolean = value.ValueType is ClrValueType.Boolean; - if (isBoolean) - { - var sqlPath = path.JsonPath(); - var sqlOp = FormatOperator(op, value); - var sqlRhs = FormatValues(op, value, queryParameters); - - return $"IF(JSON_VALUE({sqlPath}) = 'true', 1, 0) {sqlOp} {sqlRhs}"; - } + return JsonFunction.Create(path, op, value, FormatValues(op, value, queryParameters, true)); } return base.Where(path, op, value, queryParameters, isJson); diff --git a/backend/src/Squidex.Data.EntityFramework/Providers/MySql/json_function.sql b/backend/src/Squidex.Data.EntityFramework/Providers/MySql/json_function.sql new file mode 100644 index 000000000..39e89b7d7 --- /dev/null +++ b/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;; \ No newline at end of file diff --git a/backend/src/Squidex.Data.EntityFramework/Providers/Postgres/JsonFunction.cs b/backend/src/Squidex.Data.EntityFramework/Providers/Postgres/JsonFunction.cs new file mode 100644 index 000000000..168af3e56 --- /dev/null +++ b/backend/src/Squidex.Data.EntityFramework/Providers/Postgres/JsonFunction.cs @@ -0,0 +1,120 @@ +// ========================================================================== +// 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)})"; + } + + var arg = formattedValue; + if (value.IsList && type == TypeNumber) + { + arg = $"ARRAY[{formattedValue}]::numeric[]"; + } + else if (value.IsList) + { + arg = $"ARRAY[{formattedValue}]"; + } + else if (type == TypeNumber) + { + arg = $"{formattedValue}::numeric"; + } + + return $"{fn}({path.JsonPath(false)}, {arg})"; + } +} diff --git a/backend/src/Squidex.Data.EntityFramework/Providers/Postgres/PostgresDialect.cs b/backend/src/Squidex.Data.EntityFramework/Providers/Postgres/PostgresDialect.cs index 306f74a49..af4542a5f 100644 --- a/backend/src/Squidex.Data.EntityFramework/Providers/Postgres/PostgresDialect.cs +++ b/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,27 +78,7 @@ public class PostgresDialect : SqlDialect { if (isJson) { - var issNumeric = value.ValueType is - ClrValueType.Single or - ClrValueType.Double or - ClrValueType.Int32 or - ClrValueType.Int64; - if (issNumeric) - { - var sqlOp = FormatOperator(op, value); - var sqlRhs = FormatValues(op, value, queryParameters); - - return $"(CASE WHEN jsonb_typeof({path.JsonPath(false)}) = 'number' THEN ({path.JsonPath(true)})::numeric {sqlOp} {sqlRhs} ELSE FALSE END)"; - } - - var isBoolean = value.ValueType is ClrValueType.Boolean; - if (isBoolean) - { - var sqlOp = FormatOperator(op, value); - var sqlRhs = FormatValues(op, value, queryParameters); - - return $"(CASE WHEN jsonb_typeof({path.JsonPath(false)}) = 'boolean' THEN ({path.JsonPath(true)})::boolean {sqlOp} {sqlRhs} ELSE FALSE END)"; - } + return JsonFunction.Create(path, op, value, FormatValues(op, value, queryParameters, true)); } return base.Where(path, op, value, queryParameters, isJson); @@ -100,12 +87,16 @@ public class PostgresDialect : SqlDialect protected override string FormatField(PropertyPath path, bool isJson) { var baseField = path[0]; - if (isJson && path.Count > 1) { return path.JsonPath(true); } + if (baseField == "__element" || baseField.Contains("->", StringComparison.Ordinal)) + { + return baseField; + } + return $"\"{baseField}\""; } } diff --git a/backend/src/Squidex.Data.EntityFramework/Providers/Postgres/json_function.sql b/backend/src/Squidex.Data.EntityFramework/Providers/Postgres/json_function.sql new file mode 100644 index 000000000..df44e001c --- /dev/null +++ b/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; +$$; \ No newline at end of file diff --git a/backend/src/Squidex.Data.EntityFramework/Providers/SqlServer/Extensions.cs b/backend/src/Squidex.Data.EntityFramework/Providers/SqlServer/Extensions.cs index 87cfa1375..f2e4bdb8e 100644 --- a/backend/src/Squidex.Data.EntityFramework/Providers/SqlServer/Extensions.cs +++ b/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(); diff --git a/backend/src/Squidex.Data.EntityFramework/Providers/SqlServer/JsonFunction.cs b/backend/src/Squidex.Data.EntityFramework/Providers/SqlServer/JsonFunction.cs new file mode 100644 index 000000000..522f29ef6 --- /dev/null +++ b/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"; + } +} diff --git a/backend/src/Squidex.Data.EntityFramework/Providers/SqlServer/SqlServerDialect.cs b/backend/src/Squidex.Data.EntityFramework/Providers/SqlServer/SqlServerDialect.cs index bc14727bb..62899ea99 100644 --- a/backend/src/Squidex.Data.EntityFramework/Providers/SqlServer/SqlServerDialect.cs +++ b/backend/src/Squidex.Data.EntityFramework/Providers/SqlServer/SqlServerDialect.cs @@ -5,6 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using Microsoft.EntityFrameworkCore; using Squidex.Infrastructure.Queries; using SqlException = Microsoft.Data.SqlClient.SqlException; @@ -18,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; @@ -87,45 +94,7 @@ public sealed class SqlServerDialect : SqlDialect { if (isJson) { - var issNumeric = value.ValueType is - ClrValueType.Single or - ClrValueType.Double or - ClrValueType.Int32 or - ClrValueType.Int64; - if (issNumeric) - { - var sqlPath = path.JsonPath(); - var sqlOp = FormatOperator(op, value); - var sqlRhs = FormatValues(op, value, queryParameters); - - return $"TRY_CAST(JSON_VALUE({sqlPath}) AS NUMERIC) {sqlOp} {sqlRhs}"; - } - - var isBoolean = value.ValueType is ClrValueType.Boolean; - if (isBoolean) - { - var sqlPath = path.JsonPath(); - var sqlOp = FormatOperator(op, value); - var sqlRhs = FormatValues(op, value, queryParameters); - - return $"IIF(JSON_VALUE({sqlPath}) = 'true', 1, 0) {sqlOp} {sqlRhs}"; - } - - var isNull = value.ValueType is ClrValueType.Null; - if (isNull) - { - var sqlPath = path.JsonPath(); - - if (op == CompareOperator.Equals) - { - return $"JSON_QUERY({sqlPath}) IS NULL AND JSON_VALUE({sqlPath}) IS NULL"; - } - - if (op == CompareOperator.NotEquals) - { - return $"JSON_QUERY({sqlPath}) IS NOT NULL OR JSON_VALUE({sqlPath}) IS NOT NULL"; - } - } + return JsonFunction.Create(path, op, value, FormatValues(op, value, queryParameters, true)); } return base.Where(path, op, value, queryParameters, isJson); diff --git a/backend/src/Squidex.Data.EntityFramework/Providers/SqlServer/json_function.sql b/backend/src/Squidex.Data.EntityFramework/Providers/SqlServer/json_function.sql new file mode 100644 index 000000000..4f198081c --- /dev/null +++ b/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;; \ No newline at end of file diff --git a/backend/src/Squidex.Data.EntityFramework/ServiceExtensions.cs b/backend/src/Squidex.Data.EntityFramework/ServiceExtensions.cs index 0d130b2f7..037dedc09 100644 --- a/backend/src/Squidex.Data.EntityFramework/ServiceExtensions.cs +++ b/backend/src/Squidex.Data.EntityFramework/ServiceExtensions.cs @@ -45,6 +45,7 @@ using Squidex.Infrastructure; using Squidex.Infrastructure.Caching; using Squidex.Infrastructure.Log; using Squidex.Infrastructure.Migrations; +using Squidex.Infrastructure.Queries; using Squidex.Infrastructure.States; using Squidex.Infrastructure.UsageTracking; using Squidex.Messaging; @@ -260,6 +261,7 @@ public static class ServiceExtensions .AddEntityFrameworkStore(); services.AddEntityFrameworkAssetKeyValueStore(); + services.AddSingletonAs>(); } public static void AddSquidexEntityFrameworkEventStore(this IServiceCollection services, IConfiguration config) diff --git a/backend/src/Squidex.Data.EntityFramework/Squidex.Data.EntityFramework.csproj b/backend/src/Squidex.Data.EntityFramework/Squidex.Data.EntityFramework.csproj index dfc60795b..0945d8303 100644 --- a/backend/src/Squidex.Data.EntityFramework/Squidex.Data.EntityFramework.csproj +++ b/backend/src/Squidex.Data.EntityFramework/Squidex.Data.EntityFramework.csproj @@ -10,6 +10,9 @@ full True + + + @@ -55,4 +58,9 @@ + + + + + diff --git a/backend/src/Squidex.Infrastructure/Queries/QueryModel.cs b/backend/src/Squidex.Infrastructure/Queries/QueryModel.cs index 0daae1d6b..ab326f111 100644 --- a/backend/src/Squidex.Infrastructure/Queries/QueryModel.cs +++ b/backend/src/Squidex.Infrastructure/Queries/QueryModel.cs @@ -11,7 +11,7 @@ public sealed class QueryModel { public static readonly IReadOnlyDictionary> DefaultOperators = new Dictionary> { - [FilterSchemaType.Any] = Enum.GetValues(typeof(CompareOperator)).OfType().ToList(), + [FilterSchemaType.Any] = Enum.GetValues().ToList(), [FilterSchemaType.Boolean] = [ CompareOperator.Equals, @@ -117,14 +117,12 @@ public sealed class QueryModel public QueryModel Flatten(int maxDepth = 7, bool onlyWithOperators = true) { var predicate = (Predicate?)null; - if (onlyWithOperators) { predicate = x => Operators.TryGetValue(x.Type, out var operators) && operators.Count > 0; } var flatten = Schema.Flatten(maxDepth, predicate); - if (ReferenceEquals(flatten, Schema)) { return this; diff --git a/backend/src/Squidex.Web/Pipeline/SchemaResolver.cs b/backend/src/Squidex.Web/Pipeline/SchemaResolver.cs index 0d2f113f8..9a063843b 100644 --- a/backend/src/Squidex.Web/Pipeline/SchemaResolver.cs +++ b/backend/src/Squidex.Web/Pipeline/SchemaResolver.cs @@ -31,35 +31,29 @@ public sealed class SchemaResolver : IAsyncActionFilter public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) { var app = context.HttpContext.Features.Get(); - - if (app != null) + if (app != null && context.RouteData.Values.TryGetValue("schema", out var schemaValue)) { - if (context.RouteData.Values.TryGetValue("schema", out var schemaValue)) + var schemaIdOrName = schemaValue?.ToString(); + if (string.IsNullOrWhiteSpace(schemaIdOrName)) { - var schemaIdOrName = schemaValue?.ToString(); - - if (string.IsNullOrWhiteSpace(schemaIdOrName)) - { - context.Result = new NotFoundResult(); - return; - } - - var schema = await GetSchemaAsync(app.Id, schemaIdOrName, context.HttpContext.User); - - if (schema == null) - { - context.Result = new NotFoundResult(); - return; - } + context.Result = new NotFoundResult(); + return; + } - if (context.ActionDescriptor.EndpointMetadata.Any(x => x is SchemaMustBePublishedAttribute) && !schema.IsPublished) - { - context.Result = new NotFoundResult(); - return; - } + var schema = await GetSchemaAsync(app.Id, schemaIdOrName, context.HttpContext.User); + if (schema == null) + { + context.Result = new NotFoundResult(); + return; + } - context.HttpContext.Features.Set(schema); + if (context.ActionDescriptor.EndpointMetadata.Any(x => x is SchemaMustBePublishedAttribute) && !schema.IsPublished) + { + context.Result = new NotFoundResult(); + return; } + + context.HttpContext.Features.Set(schema); } await next(); diff --git a/backend/tests/Squidex.Data.Tests/EntityFramework/Infrastructure/Queries/EFQueryTests.cs b/backend/tests/Squidex.Data.Tests/EntityFramework/Infrastructure/Queries/EFQueryTests.cs index 84bac8cb1..e4dce705a 100644 --- a/backend/tests/Squidex.Data.Tests/EntityFramework/Infrastructure/Queries/EFQueryTests.cs +++ b/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; @@ -34,8 +33,8 @@ public abstract class EFQueryTests(ISqlFixture fixture) { var dbContext = await CreateDbContextAsync(); - await dbContext.CreateGeoIndexAsync("IDX_GEO", "TestEntity", "Point"); - await dbContext.CreateTextIndexAsync("IDX_Text", "TestEntity", "FullText"); + await dbContext.CreateGeoIndexAsync("IDX_GEO", nameof(TestEntity), "Point"); + await dbContext.CreateTextIndexAsync("IDX_Text", nameof(TestEntity), "FullText"); var set = dbContext.Set(); if (await set.AnyAsync()) @@ -79,17 +78,46 @@ public abstract class EFQueryTests(ISqlFixture fixture) Json = new TestJson { Boolean = i > 10, + BooleanArray = [i > 10, i > 15], BooleanOrNull = i > 10 ? true : null, Mixed = mixed, + MixedArray = [i, i % 3 == 0 ? null : $"T{i}", i > 10], Number = i, + NumberArray = [0, i], NumberOrNull = i > 10 ? null : i, - Array = [0, i], Text = $"Prefix{i}Suffix", + TextArray = [$"X{i}", $"Y{i}"], + TextOrNull = i > 10 ? null : $"Prefix{i}Suffix", }, Point = new Point(i * 2, i * 2) { SRID = 4326 }, }); } + 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; } @@ -142,29 +170,31 @@ public abstract class EFQueryTests(ISqlFixture fixture) } [Fact] - public async Task Should_sort_by_text() + public async Task Should_query_with_skip_and_take_sorted_descending() { var actual = await QueryAsync(new ClrQuery { - Sort = [new SortNode("Text", SortOrder.Descending)], + Take = 5, + Skip = 5, + Sort = [new SortNode("Number", SortOrder.Descending)], }); - Assert.Equal([.. Range(9, 3), 2, 20, 1, .. Range(19, 10)], actual); + Assert.Equal(Range(16, 12), actual); } [Fact] - public async Task Should_sort_by_text_in_json() + public async Task Should_sort_by_number_ascending() { var actual = await QueryAsync(new ClrQuery { - Sort = [new SortNode("Json.text", SortOrder.Descending)], + Sort = [new SortNode("Number", SortOrder.Ascending)], }); - Assert.Equal([.. Range(9, 3), 2, 20, 1, .. Range(19, 10)], actual); + Assert.Equal(Range(1, 20), actual); } [Fact] - public async Task Should_sort_by_number() + public async Task Should_sort_by_number_descending() { var actual = await QueryAsync(new ClrQuery { @@ -175,7 +205,18 @@ public abstract class EFQueryTests(ISqlFixture fixture) } [Fact] - public async Task Should_sort_by_number_in_json() + public async Task Should_sort_by_number_ascending_in_json() + { + var actual = await QueryAsync(new ClrQuery + { + Sort = [new SortNode("Json.number", SortOrder.Ascending)], + }); + + Assert.Equal(Range(1, 20), actual); + } + + [Fact] + public async Task Should_sort_by_number_descending_in_json() { var actual = await QueryAsync(new ClrQuery { @@ -186,7 +227,62 @@ public abstract class EFQueryTests(ISqlFixture fixture) } [Fact] - public async Task Should_sort_by_boolean_in_json() + public async Task Should_sort_by_text_ascending() + { + var actual = await QueryAsync(new ClrQuery + { + Sort = [new SortNode("Text", SortOrder.Ascending)], + }); + + Assert.Equal([.. Range(10, 19), 1, 20, 2, .. Range(3, 9)], actual); + } + + [Fact] + public async Task Should_sort_by_text_descending() + { + var actual = await QueryAsync(new ClrQuery + { + Sort = [new SortNode("Text", SortOrder.Descending)], + }); + + Assert.Equal([.. Range(9, 3), 2, 20, 1, .. Range(19, 10)], actual); + } + + [Fact] + public async Task Should_sort_by_text_ascending_in_json() + { + var actual = await QueryAsync(new ClrQuery + { + Sort = [new SortNode("Json.text", SortOrder.Ascending)], + }); + + Assert.Equal([.. Range(10, 19), 1, 20, 2, .. Range(3, 9)], actual); + } + + [Fact] + public async Task Should_sort_by_text_descending_in_json() + { + var actual = await QueryAsync(new ClrQuery + { + Sort = [new SortNode("Json.text", SortOrder.Descending)], + }); + + Assert.Equal([.. Range(9, 3), 2, 20, 1, .. Range(19, 10)], actual); + } + + [Fact] + public async Task Should_sort_by_boolean_ascending() + { + var actual = await QueryAsync(new ClrQuery + { + Sort = [new SortNode("Boolean", SortOrder.Ascending), new SortNode("Number", SortOrder.Ascending)], + }); + + Assert.Equal([.. Range(1, 10), .. Range(11, 20)], actual); + } + + [Fact] + public async Task Should_sort_by_boolean_descending() { var actual = await QueryAsync(new ClrQuery { @@ -197,7 +293,18 @@ public abstract class EFQueryTests(ISqlFixture fixture) } [Fact] - public async Task Should_sort_by_boolean() + public async Task Should_sort_by_boolean_ascending_in_json() + { + var actual = await QueryAsync(new ClrQuery + { + Sort = [new SortNode("Json.boolean", SortOrder.Ascending), new SortNode("Number", SortOrder.Ascending)], + }); + + Assert.Equal([.. Range(1, 10), .. Range(11, 20)], actual); + } + + [Fact] + public async Task Should_sort_by_boolean_descending_in_json() { var actual = await QueryAsync(new ClrQuery { @@ -208,7 +315,7 @@ public abstract class EFQueryTests(ISqlFixture fixture) } [Fact] - public async Task Should_sort_by_mixed_json() + public async Task Should_sort_by_mixed_json_descending() { var actual = await QueryAsync(new ClrQuery { @@ -218,6 +325,21 @@ public abstract class EFQueryTests(ISqlFixture fixture) Assert.Equal([20, 14, 8, 2], actual.Take(4)); } + [Fact] + public async Task Should_sort_by_multiple_fields() + { + var actual = await QueryAsync(new ClrQuery + { + Sort = + [ + new SortNode("Boolean", SortOrder.Ascending), + new SortNode("Number", SortOrder.Descending), + ], + }); + + Assert.Equal([.. Range(10, 1), .. Range(20, 11)], actual); + } + [Fact] public async Task Should_filter_with_or() { @@ -252,279 +374,1385 @@ public abstract class EFQueryTests(ISqlFixture fixture) } [Fact] - public async Task Should_filter_by_number() + public async Task Should_filter_by_number_equal() { var actual = await QueryAsync(new ClrQuery { - Filter = ClrFilter.Gt("Number", 5), + Filter = ClrFilter.Eq("Number", 7), }); - Assert.Equal(Range(6, 20), actual.Order().ToArray()); + Assert.Equal([7], actual.Order().ToArray()); } [Fact] - public async Task Should_filter_by_number_in_json() + public async Task Should_filter_by_number_equal_with_double() { var actual = await QueryAsync(new ClrQuery { - Filter = ClrFilter.Gt("Json.number", 5), + Filter = ClrFilter.Eq("Number", 7.0), }); - Assert.Equal(Range(6, 20), actual.Order().ToArray()); + Assert.Equal([7], actual.Order().ToArray()); } [Fact] - public async Task Should_filter_by_number_in_mixed_json() + public async Task Should_filter_by_number_not_equal() { var actual = await QueryAsync(new ClrQuery { - Filter = ClrFilter.Gt("Json.mixed", 5), + Filter = ClrFilter.Ne("Number", 7), }); - Assert.Equal([8, 14, 20], actual.Order().ToArray()); + Assert.Equal(AllExept(7), actual.Order().ToArray()); } [Fact] - public async Task Should_filter_by_null() + public async Task Should_filter_by_number_greater_than() { var actual = await QueryAsync(new ClrQuery { - Filter = ClrFilter.Eq("NumberOrNull", ClrValue.Null), + Filter = ClrFilter.Gt("Number", 15), }); - Assert.Equal(Range(11, 20), actual.Order().ToArray()); + Assert.Equal(Range(16, 20), actual.Order().ToArray()); } [Fact] - public async Task Should_filter_by_null_in_json() + public async Task Should_filter_by_number_greater_than_or_equal() { var actual = await QueryAsync(new ClrQuery { - Filter = ClrFilter.Eq("Json.numberOrNull", ClrValue.Null), + Filter = ClrFilter.Ge("Number", 15), }); - Assert.Equal(Range(11, 20), actual.Order().ToArray()); + Assert.Equal(Range(15, 20), actual.Order().ToArray()); } [Fact] - public async Task Should_filter_by_null_in_mixed_json() + public async Task Should_filter_by_number_less_than() { var actual = await QueryAsync(new ClrQuery { - Filter = ClrFilter.Eq("Json.mixed", ClrValue.Null), + Filter = ClrFilter.Lt("Number", 5), }); - Assert.Equal([6, 12, 18], actual.Order().ToArray()); + Assert.Equal(Range(1, 4), actual.Order().ToArray()); } [Fact] - public async Task Should_filter_with_many() + public async Task Should_filter_by_number_less_than_or_equal() { var actual = await QueryAsync(new ClrQuery { - Filter = ClrFilter.In("Number", new List { 3, 5, 7 }), + Filter = ClrFilter.Le("Number", 5), }); - Assert.Equal([3, 5, 7], actual.Order().ToArray()); + Assert.Equal(Range(1, 5), actual.Order().ToArray()); } [Fact] - public async Task Should_filter_with_many_in_json() + public async Task Should_filter_by_number_in() { var actual = await QueryAsync(new ClrQuery { - Filter = ClrFilter.In("Json.number", new List { 3, 5, 7 }), + Filter = ClrFilter.In("Number", new List { 3, 7, 15 }), }); - Assert.Equal([3, 5, 7], actual.Order().ToArray()); + Assert.Equal([3, 7, 15], actual.Order().ToArray()); } [Fact] - public async Task Should_filter_with_many_in_mixed_json() + public async Task Should_filter_by_number_equal_in_json() { var actual = await QueryAsync(new ClrQuery { - Filter = ClrFilter.In("Json.mixed", new List { 2, 8, 14 }), + Filter = ClrFilter.Eq("Json.number", 7), }); - Assert.Equal([2, 8, 14], actual.Order().ToArray()); + Assert.Equal([7], actual.Order().ToArray()); } [Fact] - public async Task Should_filter_in_json_array() + public async Task Should_filter_by_number_not_equal_in_json() { var actual = await QueryAsync(new ClrQuery { - Filter = ClrFilter.And(ClrFilter.Gt("Json.array.1", 5), ClrFilter.Lt("Json.array.1", 16)), + Filter = ClrFilter.Ne("Json.number", 7), }); - Assert.Equal(Range(6, 15), actual.Order().ToArray()); + Assert.Equal(AllExept(7), actual.Order().ToArray()); } [Fact] - public async Task Should_filter_in_mixed_json_array() + public async Task Should_filter_by_number_greater_than_in_json() { var actual = await QueryAsync(new ClrQuery { - Filter = ClrFilter.And(ClrFilter.Gt("Json.mixed.0", 5), ClrFilter.Lt("Json.mixed.0", 16)), + Filter = ClrFilter.Gt("Json.number", 15), }); - Assert.Contains(10, actual); + Assert.Equal(Range(16, 20), actual.Order().ToArray()); } [Fact] - public async Task Should_filter_by_boolean() + public async Task Should_filter_by_number_greater_than_or_equal_in_json() { var actual = await QueryAsync(new ClrQuery { - Filter = ClrFilter.Eq("Boolean", true), + Filter = ClrFilter.Ge("Json.number", 15), + }); + + Assert.Equal(Range(15, 20), actual.Order().ToArray()); + } + + [Fact] + public async Task Should_filter_by_number_less_than_in_json() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.Lt("Json.number", 5), + }); + + Assert.Equal(Range(1, 4), actual.Order().ToArray()); + } + + [Fact] + public async Task Should_filter_by_number_less_than_or_equal_in_json() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.Le("Json.number", 5), + }); + + 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 { 3, 7, 15 }), + }); + + Assert.Equal([3, 7, 15], actual.Order().ToArray()); + } + + [Fact] + public async Task Should_filter_by_number_null_in_nullable_json() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.Eq("Json.numberOrNull", ClrValue.Null), }); Assert.Equal(Range(11, 20), actual.Order().ToArray()); } [Fact] - public async Task Should_filter_by_boolean_in_json() + public async Task Should_filter_by_number_not_null_in_nullable_json() { var actual = await QueryAsync(new ClrQuery { - Filter = ClrFilter.Eq("Json.boolean", true), + Filter = ClrFilter.Ne("Json.numberOrNull", ClrValue.Null), + }); + + Assert.Equal(Range(1, 10), actual.Order().ToArray()); + } + + [Fact] + public async Task Should_filter_by_number_equal_in_nullable_json() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.Eq("Json.numberOrNull", 5), + }); + + Assert.Equal([5], actual.Order().ToArray()); + } + + [Fact] + public async Task Should_filter_by_number_greater_than_in_nullable_json() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.Gt("Json.numberOrNull", 7), + }); + + Assert.Equal([8, 9, 10], actual.Order().ToArray()); + } + + [Fact] + public async Task Should_filter_by_number_less_than_in_nullable_json() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.Lt("Json.numberOrNull", 4), + }); + + 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_boolean_in_mixed_json() + public async Task Should_filter_by_number_exists_in_nullable_json() { var actual = await QueryAsync(new ClrQuery { - Filter = ClrFilter.Eq("Json.mixed", true), + Filter = ClrFilter.Exists("Json.numberOrNull"), }); - Assert.Equal([3, 9, 15], actual.Order().ToArray()); + Assert.Equal(Range(1, 10), actual.Order().ToArray()); } [Fact] - public async Task Should_filter_by_string_contains() + public async Task Should_filter_by_number_equal_in_mixed_json() { var actual = await QueryAsync(new ClrQuery { - Filter = ClrFilter.Contains("Text", "7"), + Filter = ClrFilter.Eq("Json.mixed", 8), }); - Assert.Equal([7, 17], actual.Order().ToArray()); + Assert.Equal([8], actual.Order().ToArray()); } [Fact] - public async Task Should_filter_by_string_contains_in_json() + public async Task Should_filter_by_number_greater_than_in_mixed_json() { var actual = await QueryAsync(new ClrQuery { - Filter = ClrFilter.Contains("Json.text", "7"), + Filter = ClrFilter.Gt("Json.mixed", 5), }); - Assert.Equal([7, 17], actual.Order().ToArray()); + Assert.Equal([8, 10, 14, 16, 20], actual.Order().ToArray()); } [Fact] - public async Task Should_filter_by_string_startsWith() + public async Task Should_filter_by_number_in_mixed_json() { var actual = await QueryAsync(new ClrQuery { - Filter = ClrFilter.StartsWith("Text", "Prefix5"), + Filter = ClrFilter.In("Json.mixed", new List { 2, 8, 14 }), }); - Assert.Equal([5], actual.Order().ToArray()); + Assert.Equal([2, 8, 14], actual.Order().ToArray()); } [Fact] - public async Task Should_filter_by_string_startsWith_in_json() + public async Task Should_filter_by_number_equal_in_json_number_array() { var actual = await QueryAsync(new ClrQuery { - Filter = ClrFilter.StartsWith("Json.text", "Prefix5"), + Filter = ClrFilter.Eq("Json.numberArray", 7), }); - Assert.Equal([5], actual.Order().ToArray()); + Assert.Equal([7], actual.Order().ToArray()); } [Fact] - public async Task Should_filter_by_string_endWith() + public async Task Should_filter_by_number_not_equal_in_json_number_array() { var actual = await QueryAsync(new ClrQuery { - Filter = ClrFilter.EndsWith("Text", "5Suffix"), + Filter = ClrFilter.Ne("Json.numberArray", 7), }); - Assert.Equal([5, 15], actual.Order().ToArray()); + Assert.Equal(AllExept(7), actual.Order().ToArray()); } [Fact] - public async Task Should_filter_by_string_endWith_in_json() + public async Task Should_filter_by_number_greater_than_in_json_number_array() { var actual = await QueryAsync(new ClrQuery { - Filter = ClrFilter.EndsWith("Json.text", "5Suffix"), + Filter = ClrFilter.Gt("Json.numberArray", 18), }); - Assert.Equal([5, 15], actual.Order().ToArray()); + Assert.Equal([19, 20], actual.Order().ToArray()); } [Fact] - public async Task Should_query_count() + public async Task Should_filter_by_number_greater_than_or_equal_in_json_number_array() { - var dbContext = await CreateAndPrepareDbContextAsync(); + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.Ge("Json.numberArray", 18), + }); - var builder = - new TestSqlBuilder(dbContext.Dialect, "TestEntity") - .Count(); + Assert.Equal([18, 19, 20], actual.Order().ToArray()); + } - var (sql, parameters) = builder.Compile(); - var dbResult = await dbContext.Database.SqlQueryRaw(sql, parameters).FirstOrDefaultAsync(); + [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.Equal(20, dbResult); + Assert.Empty(actual); } [Fact] - public async Task Should_query_by_distance() + public async Task Should_filter_by_number_less_than_or_equal_in_json_number_array() { - var point = new Point(4, 4) { SRID = 4326 }; + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.Le("Json.numberArray", 0), + }); - var dbContext = await CreateAndPrepareDbContextAsync(); - var dbResult = await dbContext.Set().Where(x => x.Point.Distance(point) < 1).ToListAsync(); + Assert.Equal(Range(1, 20), actual.Order().ToArray()); + } - Assert.Single(dbResult); + [Fact] + public async Task Should_filter_by_number_in_json_number_array() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.In("Json.numberArray", new List { 3, 5, 7 }), + }); + + Assert.Equal([3, 5, 7], actual.Order().ToArray()); } [Fact] - public async Task Should_query_full_text() + public async Task Should_filter_by_number_with_index_in_json_number_array() { - var dbContext = await CreateAndPrepareDbContextAsync(); + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.And(ClrFilter.Gt("Json.numberArray.1", 5), ClrFilter.Lt("Json.numberArray.1", 16)), + }); - var queryBuilder = - new TestSqlBuilder(dbContext.Dialect, "TestEntity") - .WhereMatch("FullText", "hello"); + Assert.Equal(Range(6, 15), actual.Order().ToArray()); + } - var (sql, parameters) = queryBuilder.Compile(); - var dbResult = await PollAsync(dbContext, sql, parameters, 20); + [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(20, dbResult.Count); + Assert.Equal([21], actual.Order().ToArray()); } [Fact] - public async Task Should_query_full_text_with_space() + public async Task Should_filter_by_number_exists_in_json_number_array() { - var dbContext = await CreateAndPrepareDbContextAsync(); + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.Exists("Json.numberArray"), + }); - var queryBuilder = - new TestSqlBuilder(dbContext.Dialect, "TestEntity") - .WhereMatch("FullText", "hello world"); + Assert.Equal(Range(1, 20), actual.Order().ToArray()); + } - var (sql, parameters) = queryBuilder.Compile(); - var dbResult = await PollAsync(dbContext, sql, parameters, 0); + [Fact] + public async Task Should_filter_by_number_equal_in_json_mixed_array() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.Eq("Json.mixedArray", 7), + }); - Assert.Empty(dbResult); + Assert.Equal([7], actual.Order().ToArray()); + } + + [Fact] + public async Task Should_filter_by_number_greater_than_in_json_mixed_array() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.Gt("Json.mixedArray", 16), + }); + + Assert.Equal([17, 18, 19, 20], actual.Order().ToArray()); + } + + [Fact] + public async Task Should_filter_by_number_less_than_or_equal_in_json_mixed_array() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.Le("Json.mixedArray", 3), + }); + + Assert.Equal([1, 2, 3], actual.Order().ToArray()); + } + + [Fact] + public async Task Should_filter_by_number_with_index_in_json_mixed_array() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.And(ClrFilter.Gt("Json.mixedArray.0", 5), ClrFilter.Lt("Json.mixedArray.0", 16)), + }); + + Assert.Equal(Range(6, 15), actual.Order().ToArray()); + } + + [Fact] + public async Task Should_filter_by_string_equal() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.Eq("Text", "Prefix7Suffix"), + }); + + Assert.Equal([7], actual.Order().ToArray()); + } + + [Fact] + public async Task Should_filter_by_string_not_equal() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.Ne("Text", "Prefix7Suffix"), + }); + + Assert.Equal(AllExept(7), actual.Order().ToArray()); + } + + [Fact] + public async Task Should_filter_by_string_contains() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.Contains("Text", "7"), + }); + + Assert.Equal([7, 17], actual.Order().ToArray()); + } + + [Fact] + public async Task Should_filter_by_string_starts_with() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.StartsWith("Text", "Prefix5"), + }); + + Assert.Equal([5], actual.Order().ToArray()); + } + + [Fact] + public async Task Should_filter_by_string_ends_with() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.EndsWith("Text", "5Suffix"), + }); + + 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 { "Prefix3Suffix", "Prefix7Suffix" }), + }); + + Assert.Equal([3, 7], actual.Order().ToArray()); + } + + [Fact] + public async Task Should_filter_by_string_equal_in_json() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.Eq("Json.text", "Prefix7Suffix"), + }); + + Assert.Equal([7], actual.Order().ToArray()); + } + + [Fact] + public async Task Should_filter_by_string_not_equal_in_json() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.Ne("Json.text", "Prefix7Suffix"), + }); + + Assert.Equal(AllExept(7), actual.Order().ToArray()); + } + + [Fact] + public async Task Should_filter_by_string_contains_in_json() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.Contains("Json.text", "7"), + }); + + Assert.Equal([7, 17], actual.Order().ToArray()); + } + + [Fact] + public async Task Should_filter_by_string_starts_with_in_json() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.StartsWith("Json.text", "Prefix5"), + }); + + Assert.Equal([5], actual.Order().ToArray()); + } + + [Fact] + public async Task Should_filter_by_string_ends_with_in_json() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.EndsWith("Json.text", "5Suffix"), + }); + + 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 { "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() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.Eq("Json.textOrNull", ClrValue.Null), + }); + + Assert.Equal(Range(11, 20), actual.Order().ToArray()); + } + + [Fact] + public async Task Should_filter_by_string_not_null_in_nullable_json() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.Ne("Json.textOrNull", ClrValue.Null), + }); + + Assert.Equal(Range(1, 10), actual.Order().ToArray()); + } + + [Fact] + public async Task Should_filter_by_string_equal_in_nullable_json() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.Eq("Json.textOrNull", "Prefix5Suffix"), + }); + + Assert.Equal([5], actual.Order().ToArray()); + } + + [Fact] + public async Task Should_filter_by_string_contains_in_nullable_json() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.Contains("Json.textOrNull", "5"), + }); + + Assert.Equal([5], actual.Order().ToArray()); + } + + [Fact] + public async Task Should_filter_by_string_starts_with_in_nullable_json() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.StartsWith("Json.textOrNull", "Prefix"), + }); + + 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() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.Contains("Json.mixed", "7"), + }); + + Assert.Equal([7], actual.Order().ToArray()); + } + + [Fact] + public async Task Should_filter_by_string_starts_with_in_mixed_json() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.StartsWith("Json.mixed", "Prefix"), + }); + + Assert.Equal([1, 7, 13, 19], actual.Order().ToArray()); + } + + [Fact] + public async Task Should_filter_by_string_ends_with_in_mixed_json() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.EndsWith("Json.mixed", "Suffix"), + }); + + Assert.Equal([1, 7, 13, 19], actual.Order().ToArray()); + } + + [Fact] + public async Task Should_filter_by_string_equal_in_json_text_array() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.Eq("Json.textArray", "X5"), + }); + + Assert.Equal([5], actual.Order().ToArray()); + } + + [Fact] + public async Task Should_filter_by_string_not_equal_in_json_text_array() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.Ne("Json.textArray", "X5"), + }); + + Assert.Equal(19, actual.Count); + } + + [Fact] + public async Task Should_filter_by_string_contains_in_json_text_array() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.Contains("Json.textArray", "5"), + }); + + Assert.Equal([5, 15], actual.Order().ToArray()); + } + + [Fact] + public async Task Should_filter_by_string_starts_with_in_json_text_array() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.StartsWith("Json.textArray", "X5"), + }); + + Assert.Equal([5], actual.Order().ToArray()); + } + + [Fact] + public async Task Should_filter_by_string_ends_with_in_json_text_array() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.EndsWith("Json.textArray", "5"), + }); + + 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 { "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() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.Contains("Json.mixedArray", "7"), + }); + + Assert.Equal([7, 17], actual.Order().ToArray()); + } + + [Fact] + public async Task Should_filter_by_string_starts_with_in_json_mixed_array() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.StartsWith("Json.mixedArray", "T5"), + }); + + 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 { "T5", "T7" }), + }); + + Assert.Equal([5, 7], actual.Order().ToArray()); + } + + [Fact] + public async Task Should_filter_by_boolean_true() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.Eq("Boolean", true), + }); + + Assert.Equal(Range(11, 20), actual.Order().ToArray()); + } + + [Fact] + public async Task Should_filter_by_boolean_false() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.Eq("Boolean", false), + }); + + 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 { true }), + }); + + Assert.Equal(Range(11, 20), actual.Order().ToArray()); + } + + [Fact] + public async Task Should_filter_by_boolean_true_in_json() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.Eq("Json.boolean", true), + }); + + Assert.Equal(Range(11, 20), actual.Order().ToArray()); + } + + [Fact] + public async Task Should_filter_by_boolean_false_in_json() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.Eq("Json.boolean", false), + }); + + 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 { 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 { true, false }), + }); + + Assert.Equal(Range(1, 20), actual.Order().ToArray()); + } + + [Fact] + public async Task Should_filter_by_boolean_null_in_nullable_json() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.Eq("Json.booleanOrNull", ClrValue.Null), + }); + + Assert.Equal(Range(1, 10), actual.Order().ToArray()); + } + + [Fact] + public async Task Should_filter_by_boolean_not_null_in_nullable_json() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.Ne("Json.booleanOrNull", ClrValue.Null), + }); + + Assert.Equal(Range(11, 20), actual.Order().ToArray()); + } + + [Fact] + public async Task Should_filter_by_boolean_true_in_nullable_json() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.Eq("Json.booleanOrNull", true), + }); + + 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() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.Eq("Json.mixed", true), + }); + + Assert.Equal([3, 9, 15], actual.Order().ToArray()); + } + + [Fact] + public async Task Should_filter_by_boolean_false_in_mixed_json() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.Eq("Json.mixed", false), + }); + + Assert.Empty(actual); + } + + [Fact] + public async Task Should_filter_by_boolean_true_in_json_boolean_array() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.Eq("Json.booleanArray", true), + }); + + Assert.Equal(Range(11, 20), actual.Order().ToArray()); + } + + [Fact] + public async Task Should_filter_by_boolean_false_in_json_boolean_array() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.Eq("Json.booleanArray", false), + }); + + 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 { 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 { 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() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.Eq("Json.mixedArray", true), + }); + + Assert.Equal(Range(11, 20), actual.Order().ToArray()); + } + + [Fact] + public async Task Should_filter_by_boolean_false_in_json_mixed_array() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.Eq("Json.mixedArray", false), + }); + + 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 { true }), + }); + + Assert.Equal(Range(11, 20), actual.Order().ToArray()); + } + + [Fact] + public async Task Should_filter_by_null_on_number() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.Eq("NumberOrNull", ClrValue.Null), + }); + + Assert.Equal(Range(11, 20), actual.Order().ToArray()); + } + + [Fact] + public async Task Should_filter_by_not_null_on_number() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.Ne("NumberOrNull", ClrValue.Null), + }); + + Assert.Equal(Range(1, 10), actual.Order().ToArray()); + } + + [Fact] + public async Task Should_filter_by_null_on_boolean() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.Eq("BooleanOrNull", ClrValue.Null), + }); + + Assert.Equal(Range(1, 10), actual.Order().ToArray()); + } + + [Fact] + public async Task Should_filter_by_not_null_on_boolean() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.Ne("BooleanOrNull", ClrValue.Null), + }); + + Assert.Equal(Range(11, 20), actual.Order().ToArray()); + } + + [Fact] + public async Task Should_filter_by_null_in_json() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.Eq("Json.numberOrNull", ClrValue.Null), + }); + + Assert.Equal(Range(11, 20), actual.Order().ToArray()); + } + + [Fact] + public async Task Should_filter_by_not_null_in_json() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.Ne("Json.numberOrNull", ClrValue.Null), + }); + + Assert.Equal(Range(1, 10), actual.Order().ToArray()); + } + + [Fact] + public async Task Should_filter_by_null_on_text_in_nullable_json() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.Eq("Json.textOrNull", ClrValue.Null), + }); + + Assert.Equal(Range(11, 20), actual.Order().ToArray()); + } + + [Fact] + public async Task Should_filter_by_not_null_on_text_in_nullable_json() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.Ne("Json.textOrNull", ClrValue.Null), + }); + + Assert.Equal(Range(1, 10), actual.Order().ToArray()); + } + + [Fact] + public async Task Should_filter_by_null_in_mixed_json() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.Eq("Json.mixed", ClrValue.Null), + }); + + Assert.Equal([6, 12, 18], actual.Order().ToArray()); + } + + [Fact] + public async Task Should_filter_by_not_null_in_mixed_json() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.Ne("Json.mixed", ClrValue.Null), + }); + + 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() + { + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.Eq("Json.mixedArray", ClrValue.Null), + }); + + Assert.Equal([3, 6, 9, 12, 15, 18], actual.Order().ToArray()); + } + + [Fact] + public async Task Should_filter_by_not_null_in_json_mixed_array() + { + var actual = await QueryAsync(new ClrQuery + { + 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); + } + + [Fact] + public async Task Should_query_count() + { + var dbContext = await CreateAndPrepareDbContextAsync(); + + var builder = + new TestSqlBuilder(dbContext.Dialect, nameof(TestEntity)) + .Count(); + + var (sql, parameters) = builder.Compile(); + var dbResult = await dbContext.Database.SqlQueryRaw(sql, parameters).FirstOrDefaultAsync(); + + Assert.Equal(21, dbResult); + } + + [Fact] + public async Task Should_query_count_with_filter() + { + var dbContext = await CreateAndPrepareDbContextAsync(); + + var query = new ClrQuery + { + Filter = ClrFilter.Gt("Number", 10), + }; + + var builder = + new TestSqlBuilder(dbContext.Dialect, nameof(TestEntity)) + .Count() + .Where(query); + + var (sql, parameters) = builder.Compile(); + var dbResult = await dbContext.Database.SqlQueryRaw(sql, parameters).FirstOrDefaultAsync(); + + Assert.Equal(11, dbResult); + } + + [Fact] + public async Task Should_query_by_distance() + { + var point = new Point(4, 4) { SRID = 4326 }; + + var dbContext = await CreateAndPrepareDbContextAsync(); + var dbResult = await dbContext.Set().Where(x => x.Point.Distance(point) < 1).ToListAsync(); + + Assert.Single(dbResult); + } + + [Fact] + [Trait("Category", "Slow")] + public async Task Should_query_full_text() + { + var dbContext = await CreateAndPrepareDbContextAsync(); + + var queryBuilder = + new TestSqlBuilder(dbContext.Dialect, nameof(TestEntity)) + .WhereMatch("FullText", "hello"); + + var (sql, parameters) = queryBuilder.Compile(); + var dbResult = await PollAsync(dbContext, sql, parameters, 20); + + Assert.Equal(20, dbResult.Count); + } + + [Fact] + [Trait("Category", "Slow")] + public async Task Should_query_full_text_with_space() + { + var dbContext = await CreateAndPrepareDbContextAsync(); + + var queryBuilder = + new TestSqlBuilder(dbContext.Dialect, nameof(TestEntity)) + .WhereMatch("FullText", "hello world"); + + var (sql, parameters) = queryBuilder.Compile(); + var dbResult = await PollAsync(dbContext, sql, parameters, 0); + + Assert.Empty(dbResult); + } + + private static long[] AllExept(params long[] values) + { + return Range(1, 20).Except(values).ToArray(); } private static long[] Range(int from, int to) @@ -548,12 +1776,12 @@ public abstract class EFQueryTests(ISqlFixture fixture) return result.ToArray(); } - private async Task> QueryAsync(ClrQuery query) + private async Task> QueryAsync(ClrQuery query, bool includeSpecialCase = false) { var dbContext = await CreateAndPrepareDbContextAsync(); var queryBuilder = - new TestSqlBuilder(dbContext.Dialect, "TestEntity") + new TestSqlBuilder(dbContext.Dialect, nameof(TestEntity)) .Limit(query) .Offset(query) .Order(query) @@ -562,7 +1790,7 @@ public abstract class EFQueryTests(ISqlFixture fixture) var (sql, parameters) = queryBuilder.Compile(); var dbResult = await dbContext.Set().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> PollAsync(TContext dbContext, string sql, object[] parameters, int expectedCount) diff --git a/backend/tests/Squidex.Data.Tests/EntityFramework/TestHelpers/MySqlFixture.cs b/backend/tests/Squidex.Data.Tests/EntityFramework/TestHelpers/MySqlFixture.cs index 5d98aa4e9..74962a409 100644 --- a/backend/tests/Squidex.Data.Tests/EntityFramework/TestHelpers/MySqlFixture.cs +++ b/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() .AddSingletonAs>().Done() .AddSingleton(TestUtils.DefaultSerializer) + .AddSingleton>() .BuildServiceProvider(); foreach (var service in services.GetRequiredService>()) diff --git a/backend/tests/Squidex.Data.Tests/EntityFramework/TestHelpers/PostgresFixture.cs b/backend/tests/Squidex.Data.Tests/EntityFramework/TestHelpers/PostgresFixture.cs index 3ecbd2e99..d75322fcf 100644 --- a/backend/tests/Squidex.Data.Tests/EntityFramework/TestHelpers/PostgresFixture.cs +++ b/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() .AddSingletonAs>().Done() .AddSingleton(TestUtils.DefaultSerializer) + .AddSingleton>() .BuildServiceProvider(); foreach (var service in services.GetRequiredService>()) diff --git a/backend/tests/Squidex.Data.Tests/EntityFramework/TestHelpers/SqlServerFixture.cs b/backend/tests/Squidex.Data.Tests/EntityFramework/TestHelpers/SqlServerFixture.cs index 0d5c573c6..06d5d4fb0 100644 --- a/backend/tests/Squidex.Data.Tests/EntityFramework/TestHelpers/SqlServerFixture.cs +++ b/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() .AddSingletonAs>().Done() .AddSingleton(TestUtils.DefaultSerializer) + .AddSingleton>() .BuildServiceProvider(); foreach (var service in services.GetRequiredService>()) diff --git a/backend/tests/Squidex.Data.Tests/EntityFramework/TestHelpers/TestEntity.cs b/backend/tests/Squidex.Data.Tests/EntityFramework/TestHelpers/TestEntity.cs index dcfb0d9c4..6e96820ad 100644 --- a/backend/tests/Squidex.Data.Tests/EntityFramework/TestHelpers/TestEntity.cs +++ b/backend/tests/Squidex.Data.Tests/EntityFramework/TestHelpers/TestEntity.cs @@ -6,6 +6,7 @@ // ========================================================================== using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; using NetTopologySuite.Geometries; #pragma warning disable MA0048 // File name must match type name @@ -40,13 +41,21 @@ public class TestJson public long? NumberOrNull { get; set; } + public long[] NumberArray { get; set; } + public string Text { get; set; } + public string? TextOrNull { get; set; } + + public string[] TextArray { get; set; } + public bool Boolean { get; set; } public bool? BooleanOrNull { get; set; } + public bool[] BooleanArray { get; set; } + public object? Mixed { get; set; } - public long[] Array { get; set; } + public object?[] MixedArray { get; set; } } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0e7970371..9f23110b4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -41,7 +41,7 @@ "mousetrap": "1.6.5", "ng2-charts": "^8.0.0", "ngx-doc-viewer": "15.0.1", - "ngx-inline-filter": "^0.2.5", + "ngx-inline-filter": "^0.3.0", "ngx-scrollbar": "^19.1.4", "ngx-ui-tour-core": "16.0.0", "oidc-client-ts": "^3.4.1", @@ -439,6 +439,24 @@ } } }, + "node_modules/@angular-devkit/architect/node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@angular-devkit/architect/node_modules/picomatch": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", @@ -459,6 +477,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">= 20.19.0" }, @@ -714,6 +733,24 @@ } } }, + "node_modules/@angular-devkit/build-angular/node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@angular-devkit/build-angular/node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -792,6 +829,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">= 20.19.0" }, @@ -848,6 +886,7 @@ "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-18.0.2.tgz", "integrity": "sha512-QXcEdfmODc0rKblBerk30yw70fypIkFm6gQBLJgsshpwc+TMA+fuMLcPQebOTzKLtD2tNUkk/7SrWPQIGqeXaA==", "dev": true, + "peer": true, "dependencies": { "ajv": "8.13.0", "ajv-formats": "3.0.1", @@ -875,6 +914,7 @@ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", "dev": true, + "peer": true, "dependencies": { "ajv": "^8.0.0" }, @@ -891,13 +931,15 @@ "version": "3.2.1", "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.1.tgz", "integrity": "sha512-AilxAyFOAcK5wA1+LeaySVBrHsGQvUFCDWXKpZjzaL0PqW+xfBOttn8GNtWKFWqneyMZj41MWF9Kl6iPWLwgOA==", - "dev": true + "dev": true, + "peer": true }, "node_modules/@angular-devkit/core/node_modules/picomatch": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "dev": true, + "peer": true, "engines": { "node": ">=12" }, @@ -911,6 +953,7 @@ "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -919,6 +962,7 @@ "version": "0.7.4", "dev": true, "license": "BSD-3-Clause", + "peer": true, "engines": { "node": ">= 8" } @@ -1005,6 +1049,24 @@ } } }, + "node_modules/@angular-devkit/schematics/node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@angular-devkit/schematics/node_modules/picomatch": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", @@ -1025,6 +1087,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">= 20.19.0" }, @@ -1122,6 +1185,24 @@ } } }, + "node_modules/@angular-eslint/builder/node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@angular-eslint/builder/node_modules/picomatch": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", @@ -1142,6 +1223,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">= 20.19.0" }, @@ -1296,6 +1378,24 @@ } } }, + "node_modules/@angular-eslint/schematics/node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@angular-eslint/schematics/node_modules/ignore": { "version": "7.0.5", "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", @@ -1326,6 +1426,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">= 20.19.0" }, @@ -1363,7 +1464,6 @@ "integrity": "sha512-TCb3qYOC/uXKZCo56cJ6N9sHeWdFhyVqrbbYfFjTi09081T6jllgHDZL5Ms7gOMNY8KywWGGbhxwvzeA0RwTgA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@angular-eslint/bundled-angular-compiler": "21.2.0", "eslint-scope": "^9.0.0" @@ -1613,7 +1713,6 @@ "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-21.1.3.tgz", "integrity": "sha512-jMiEKCcZMIAnyx2jxrJHmw5c7JXAiN56ErZ4X+OuQ5yFvYRocRVEs25I0OMxntcXNdPTJQvpGwGlhWhS0yDorg==", "license": "MIT", - "peer": true, "dependencies": { "parse5": "^8.0.0", "tslib": "^2.3.0" @@ -1644,7 +1743,6 @@ "integrity": "sha512-UPtDcpKyrKZRPfym9gTovcibPzl2O/Woy7B8sm45sAnjDH+jDUCcCvuIak7GpH47shQkC2J4yvnHZbD4c6XxcQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@angular-devkit/architect": "0.2101.3", "@angular-devkit/core": "21.1.3", @@ -1738,6 +1836,24 @@ } } }, + "node_modules/@angular/cli/node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@angular/cli/node_modules/picomatch": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", @@ -1758,6 +1874,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">= 20.19.0" }, @@ -1794,7 +1911,6 @@ "resolved": "https://registry.npmjs.org/@angular/common/-/common-21.1.3.tgz", "integrity": "sha512-Wdbln/UqZM5oVnpfIydRdhhL8A9x3bKZ9Zy1/mM0q+qFSftPvmFZIXhEpFqbDwNYbGUhGzx7t8iULC4sVVp/zA==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -1811,7 +1927,6 @@ "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-21.1.3.tgz", "integrity": "sha512-gDNLh7MEf7Qf88ktZzS4LJQXCA5U8aQTfK9ak+0mi2ruZ0x4XSjQCro4H6OPKrrbq94+6GcnlSX5+oVIajEY3w==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -1824,7 +1939,6 @@ "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-21.1.3.tgz", "integrity": "sha512-nKxoQ89W2B1WdonNQ9kgRnvLNS6DAxDrRHBslsKTlV+kbdv7h59M9PjT4ZZ2sp1M/M8LiofnUfa/s2jd/xYj5w==", "license": "MIT", - "peer": true, "dependencies": { "@babel/core": "7.28.5", "@jridgewell/sourcemap-codec": "^1.4.14", @@ -1930,7 +2044,6 @@ "resolved": "https://registry.npmjs.org/@angular/core/-/core-21.1.3.tgz", "integrity": "sha512-TbhQxRC7Lb/3WBdm1n8KRsktmVEuGBBp0WRF5mq0Ze4s1YewIM6cULrSw9ACtcL5jdcq7c74ms+uKQsaP/gdcQ==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -2061,7 +2174,6 @@ "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-21.1.3.tgz", "integrity": "sha512-W+ZMXAioaP7CsACafBCHsIxiiKrRTPOlQ+hcC7XNBwy+bn5mjGONoCgLreQs76M8HNWLtr/OAUAr6h26OguOuA==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -2084,7 +2196,6 @@ "resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-21.1.3.tgz", "integrity": "sha512-wWEjrNtJfxzZmbDWdJSyRau7NWpQ6IFM9QAyn7xH3cQDGCj+Gy9lTU5sUIYQc+7sx3nKWztolc7h/M5meYCTAg==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -2103,7 +2214,6 @@ "resolved": "https://registry.npmjs.org/@angular/platform-server/-/platform-server-21.1.3.tgz", "integrity": "sha512-o6S9t52d00PKC9nAjN3DXkLiY41iQvpLLl2DnerNU23njA7lF3mwH2+IJxVPB0EkR/H9rAJr+7bo/S9LUoU7xw==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0", "xhr2": "^0.2.0" @@ -2124,7 +2234,6 @@ "resolved": "https://registry.npmjs.org/@angular/router/-/router-21.1.3.tgz", "integrity": "sha512-uAw4LAMHXAPCe4SywhlUEWjMYVbbLHwTxLyduSp1b+9aVwep0juy5O/Xttlxd/oigVe0NMnOyJG9y1Br/ubnrg==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -2230,7 +2339,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -2254,7 +2362,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -2308,7 +2415,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz", "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -3989,7 +4095,6 @@ "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@keyv/serialize": "^1.1.1" } @@ -4035,6 +4140,7 @@ "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.2.tgz", "integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==", "license": "MIT", + "peer": true, "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } @@ -4044,6 +4150,7 @@ "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.37.1.tgz", "integrity": "sha512-Qy4CAUwngy/VQkEz0XzMKVRcckQuqLYWKqVpDDDghBe5FSXSqfVrJn49nw3ePZHxRUz4nRmb05Lgi+9csWo4eg==", "license": "MIT", + "peer": true, "dependencies": { "@codemirror/state": "^6.5.0", "crelt": "^1.0.6", @@ -4056,6 +4163,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=0.1.90" } @@ -4363,7 +4471,6 @@ "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "readdirp": "^5.0.0" }, @@ -4766,7 +4873,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" }, @@ -4807,7 +4913,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" } @@ -4895,7 +5000,6 @@ "node_modules/@egjs/hammerjs": { "version": "2.0.17", "license": "MIT", - "peer": true, "dependencies": { "@types/hammerjs": "^2.0.36" }, @@ -6070,7 +6174,6 @@ "integrity": "sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@inquirer/checkbox": "^4.3.2", "@inquirer/confirm": "^5.1.21", @@ -6722,7 +6825,8 @@ "node_modules/@kurkle/color": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.1.tgz", - "integrity": "sha512-hW0GwZj06z/ZFUW2Espl7toVDjghJN+EKqyXzPSV8NV89d5BYp5rRMBJoc+aUN0x5OXDMeRQHazejr2Xmqj2tw==" + "integrity": "sha512-hW0GwZj06z/ZFUW2Espl7toVDjghJN+EKqyXzPSV8NV89d5BYp5rRMBJoc+aUN0x5OXDMeRQHazejr2Xmqj2tw==", + "peer": true }, "node_modules/@leichtgewicht/ip-codec": { "version": "2.0.5", @@ -6735,13 +6839,15 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.2.3.tgz", "integrity": "sha512-w7ojc8ejBqr2REPsWxJjrMFsA/ysDCFICn8zEOR9mrqzOu2amhITYuLD8ag6XZf0CFXDrhKqw7+tW8cX66NaDA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@lezer/highlight": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.1.tgz", "integrity": "sha512-Z5duk4RN/3zuVO7Jq0pGLJ3qynpxUVsh7IbUbGj88+uV2ApSAn6kWg2au3iJb+0Zi7kKtqffIESgNcRXWZWmSA==", "license": "MIT", + "peer": true, "dependencies": { "@lezer/common": "^1.0.0" } @@ -6751,6 +6857,7 @@ "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.2.tgz", "integrity": "sha512-pu0K1jCIdnQ12aWNaAVU5bzi7Bd1w54J3ECgANPmYLtQKP0HBj2cE/5coBD66MT10xbtIuUr7tg0Shbsvk0mDA==", "license": "MIT", + "peer": true, "dependencies": { "@lezer/common": "^1.0.0" } @@ -6888,7 +6995,8 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.26.0", @@ -9608,6 +9716,24 @@ } } }, + "node_modules/@schematics/angular/node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@schematics/angular/node_modules/picomatch": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", @@ -9628,6 +9754,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">= 20.19.0" }, @@ -9772,7 +9899,8 @@ "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "node_modules/@standard-schema/spec": { "version": "1.1.0", @@ -10001,7 +10129,6 @@ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.4.tgz", "integrity": "sha512-FlS4ZWlp97iiNWig0Muq8p+3rVDjRiYE+YKGbAqXOu9nwJFFOdL00kFpz42M+4huzYi86vAK1sOOfyOG45muIQ==", "dev": true, - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -10307,6 +10434,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@types/node": "*" } @@ -10443,7 +10571,6 @@ "integrity": "sha512-CPrnr8voK8vC6eEtyRzvMpgp3VyVRhgclonE7qYi6P9sXwYb59ucfrnmFBTaP0yUi8Gk4yZg/LlTJULGxvTNsg==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -10478,7 +10605,6 @@ "integrity": "sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -10489,7 +10615,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -10616,7 +10741,6 @@ "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/types": "8.54.0", @@ -10799,7 +10923,6 @@ "integrity": "sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -10906,7 +11029,6 @@ "integrity": "sha512-9Cnda8GS57AQakvRyG0PTejJNlA2xhvyNtEVIMlDWOOeEyBkYWhGPnfrIAnqxLMTSTo6q8g12XVjjev5l1NvMA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.54.0", @@ -11424,7 +11546,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -11509,7 +11630,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.13.0.tgz", "integrity": "sha512-PRA911Blj99jR5RMeTunVbNXMF6Lp4vZXnk5GQjcnUWUTsrXtekg/pnmFFI2u/I36Y/2bITGS30GZCXei6uNkA==", "dev": true, - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "json-schema-traverse": "^1.0.0", @@ -12039,6 +12159,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": "^4.5.0 || >= 5.9" } @@ -12389,7 +12510,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -13139,6 +13259,7 @@ "dev": true, "license": "ISC", "optional": true, + "peer": true, "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", @@ -13180,8 +13301,7 @@ "version": "5.65.19", "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.65.19.tgz", "integrity": "sha512-+aFkvqhaAVr1gferNMuN8vkTSrWIFvzlMV9I2KBLCWS2WpZ2+UAkZjlMZmEuT+gcXTi6RrGQCkWq1/bDtGqhIA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/codemirror-graphql": { "version": "2.2.2", @@ -13587,7 +13707,8 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/cropperjs": { "version": "1.6.2", @@ -13755,7 +13876,8 @@ "version": "1.0.1", "dev": true, "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/damerau-levenshtein": { "version": "1.0.8", @@ -13855,6 +13977,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=4.0" } @@ -14074,7 +14197,8 @@ "version": "0.0.1", "dev": true, "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/dns-packet": { "version": "5.6.1", @@ -14112,6 +14236,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "custom-event": "~1.0.0", "ent": "~2.2.0", @@ -14314,6 +14439,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@types/cors": "^2.8.12", "@types/node": ">=10.0.0", @@ -14336,6 +14462,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=10.0.0" } @@ -14358,7 +14485,8 @@ "version": "2.2.0", "dev": true, "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/entities": { "version": "2.2.0", @@ -14693,7 +14821,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -15549,7 +15676,8 @@ "version": "3.0.2", "dev": true, "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/fancy-log": { "version": "2.0.0", @@ -15811,7 +15939,6 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -16036,7 +16163,8 @@ "version": "1.0.0", "dev": true, "license": "ISC", - "optional": true + "optional": true, + "peer": true }, "node_modules/fsevents": { "version": "2.3.3", @@ -16198,6 +16326,7 @@ "dev": true, "license": "ISC", "optional": true, + "peer": true, "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -16399,7 +16528,6 @@ "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.12.0.tgz", "integrity": "sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ==", "license": "MIT", - "peer": true, "engines": { "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" } @@ -16426,7 +16554,6 @@ "resolved": "https://registry.npmjs.org/graphql-ws/-/graphql-ws-6.0.7.tgz", "integrity": "sha512-yoLRW+KRlDmnnROdAu7sX77VNLC0bsFoZyGQJLy1cF+X/SkLg/fWkRGrEEYQK8o2cafJ2wmEaMqMEZB3U3DYDg==", "license": "MIT", - "peer": true, "engines": { "node": ">=20" }, @@ -16589,7 +16716,6 @@ "integrity": "sha512-eVkB/CYCCei7K2WElZW9yYQFWssG0DhaDhVvr7wy5jJ22K+ck8fWW0EsLpB0sITUTvPnc97+rrbQqIr5iqiy9Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -16728,7 +16854,6 @@ "version": "5.5.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/html-minifier-terser": "^6.0.0", "html-minifier-terser": "^6.0.2", @@ -17120,6 +17245,7 @@ "dev": true, "license": "ISC", "optional": true, + "peer": true, "dependencies": { "once": "^1.3.0", "wrappy": "1" @@ -17801,6 +17927,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">= 8.0.0" }, @@ -18066,6 +18193,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@colors/colors": "1.5.0", "body-parser": "^1.19.0", @@ -18112,6 +18240,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "minimist": "^1.2.6" }, @@ -18124,6 +18253,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "cliui": "^7.0.2", "escalade": "^3.1.1", @@ -18142,6 +18272,7 @@ "dev": true, "license": "ISC", "optional": true, + "peer": true, "engines": { "node": ">=10" } @@ -18210,8 +18341,7 @@ "node_modules/leaflet": { "version": "1.9.4", "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", - "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", - "peer": true + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==" }, "node_modules/leaflet-control-geocoder": { "version": "3.3.1", @@ -18231,7 +18361,6 @@ "integrity": "sha512-j1n1IuTX1VQjIy3tT7cyGbX7nvQOsFLoIqobZv4ttI5axP923gA44zUj6miiA6R5Aoms4sEGVIIcucXUbRI14g==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "copy-anything": "^2.0.1", "parse-node-version": "^1.0.1", @@ -18390,7 +18519,6 @@ "integrity": "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cli-truncate": "^5.0.0", "colorette": "^2.0.20", @@ -18750,6 +18878,7 @@ "dev": true, "license": "Apache-2.0", "optional": true, + "peer": true, "dependencies": { "date-format": "^4.0.14", "debug": "^4.3.4", @@ -19095,6 +19224,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "bin": { "mime": "cli.js" }, @@ -19624,9 +19754,9 @@ } }, "node_modules/ngx-inline-filter": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/ngx-inline-filter/-/ngx-inline-filter-0.2.5.tgz", - "integrity": "sha512-lBucvPs+VbhA6SR9wb6akXUHBT6RyHHLAOa+Et1wJ6sb8CQzpy2xkE+bvSulQb+QQwFIFUS2bqusBPa2d7t+Nw==", + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/ngx-inline-filter/-/ngx-inline-filter-0.3.0.tgz", + "integrity": "sha512-Yb4VIt4+QrEXr5LYQSEx0nQlM/Aj0udGk1ncdGVKNYY6YwrjgCVk8UiJFk+b1WMWWJW2O3uayl/JTkBMeqsHwA==", "dependencies": { "tslib": "^2.3.0" }, @@ -19642,7 +19772,6 @@ "resolved": "https://registry.npmjs.org/ngx-scrollbar/-/ngx-scrollbar-19.1.4.tgz", "integrity": "sha512-VzmLli1Uq+tZ40s5BpBDVO5mxjeNL7L7JbVluuokKsrIfi1GmXmkx69iz0zruyVWmyzU5VeuiqhMobPYazGKOQ==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -20640,6 +20769,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=0.10.0" } @@ -20826,7 +20956,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -21005,7 +21134,6 @@ "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -21054,7 +21182,6 @@ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -21240,6 +21367,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=0.9" } @@ -21314,7 +21442,6 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -21335,7 +21462,6 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -21607,6 +21733,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=0.10.0" } @@ -21752,6 +21879,7 @@ "dev": true, "license": "ISC", "optional": true, + "peer": true, "dependencies": { "glob": "^7.1.3" }, @@ -21931,7 +22059,6 @@ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -22725,6 +22852,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "accepts": "~1.3.4", "base64id": "~2.0.0", @@ -22745,6 +22873,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "debug": "~4.3.4", "ws": "~8.17.1" @@ -22756,6 +22885,7 @@ "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==", "dev": true, "optional": true, + "peer": true, "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.3.1" @@ -23025,7 +23155,6 @@ "integrity": "sha512-LFKSuZyF6EW2/Kkl5d7CvqgwhXXfuWv+aLBuoc616boLKJ3mxXuea+GxIgfk02NEyTKctJ0QsnSh5pAomf6Qkg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@storybook/global": "^5.0.0", "@storybook/icons": "^2.0.1", @@ -23169,6 +23298,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "date-format": "^4.0.14", "debug": "^4.3.4", @@ -23183,6 +23313,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", @@ -23197,6 +23328,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "optionalDependencies": { "graceful-fs": "^4.1.6" } @@ -23206,6 +23338,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">= 4.0.0" } @@ -23400,7 +23533,8 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.2.tgz", "integrity": "sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/stylelint": { "version": "17.1.1", @@ -23418,7 +23552,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-syntax-patches-for-csstree": "^1.0.25", @@ -24028,7 +24161,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -24082,6 +24214,7 @@ "integrity": "sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==", "dev": true, "optional": true, + "peer": true, "engines": { "node": ">=14.14" } @@ -24295,8 +24428,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD", - "peer": true + "license": "0BSD" }, "node_modules/tuf-js": { "version": "4.1.0", @@ -24488,7 +24620,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -24518,6 +24649,7 @@ ], "license": "MIT", "optional": true, + "peer": true, "bin": { "ua-parser-js": "script/cli.js" }, @@ -24796,7 +24928,6 @@ "node_modules/uuid": { "version": "8.3.2", "license": "MIT", - "peer": true, "bin": { "uuid": "dist/bin/uuid" } @@ -24835,7 +24966,6 @@ "resolved": "https://registry.npmjs.org/video.js/-/video.js-8.23.6.tgz", "integrity": "sha512-qjS3HTDo7iapedJuso62scA303i+6CaCUnwyRr8GYd/BYAp7XGb7InUMw2Eu6zrN6IWooPOb78NzyMyjRbIN+Q==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@babel/runtime": "^7.12.5", "@videojs/http-streaming": "^3.17.2", @@ -24885,7 +25015,6 @@ "resolved": "https://registry.npmjs.org/vis-data/-/vis-data-8.0.3.tgz", "integrity": "sha512-jhnb6rJNqkKR1Qmlay0VuDXY9ZlvAnYN1udsrP4U+krgZEq7C0yNSKdZqmnCe13mdnf9AdVcdDGFOzy2mpPoqw==", "license": "(Apache-2.0 OR MIT)", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/visjs" @@ -24918,7 +25047,6 @@ "resolved": "https://registry.npmjs.org/vis-util/-/vis-util-6.0.0.tgz", "integrity": "sha512-qtpts3HRma0zPe4bO7t9A2uejkRNj8Z2Tb6do6lN85iPNWExFkUiVhdAq5uLGIUqBFduyYeqWJKv/jMkxX0R5g==", "license": "(Apache-2.0 OR MIT)", - "peer": true, "engines": { "node": ">=8" }, @@ -24937,7 +25065,6 @@ "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -25046,7 +25173,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -25060,7 +25186,6 @@ "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", @@ -25226,6 +25351,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=0.10.0" } @@ -25240,7 +25366,8 @@ "version": "2.2.8", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", @@ -25303,7 +25430,6 @@ "integrity": "sha512-gX/dMkRQc7QOMzgTe6KsYFM7DxeIONQSui1s0n/0xht36HvrgbxtM1xBlgx596NbpHuQU8P7QpKwrZYwUX48nw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -25453,7 +25579,6 @@ "integrity": "sha512-QcQ72gh8a+7JO63TAx/6XZf/CWhgMzu5m0QirvPfGvptOusAxG12w2+aua1Jkjr7hzaWDnJ2n6JFeexMHI+Zjg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/bonjour": "^3.5.13", "@types/connect-history-api-fallback": "^1.5.4", @@ -26278,6 +26403,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", @@ -26322,6 +26448,29 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/wsl-utils": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.3.1.tgz", @@ -26620,7 +26769,6 @@ "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -26639,8 +26787,7 @@ "version": "0.16.0", "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.16.0.tgz", "integrity": "sha512-LqLPpIQANebrlxY6jKcYKdgN5DTXyyHAKnnWWjE5pPfEQ4n7j5zn7mOEEpwNZVKGqx3kKKmvplEmoBrvpgROTA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/zustand": { "version": "5.0.5", diff --git a/frontend/package.json b/frontend/package.json index dd3232693..e7f48cce0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,7 +12,8 @@ "watch": "ng build --watch --configuration development", "storybook": "ng run squidex:storybook", "storybook-build": "ng run squidex:build-storybook", - "generate": "dotnet run --project generator/Generator/Generator.csproj" + "generate": "dotnet run --project generator/Generator/Generator.csproj", + "format": "prettier --write **/*.html" }, "private": true, "dependencies": { @@ -49,7 +50,7 @@ "mousetrap": "1.6.5", "ng2-charts": "^8.0.0", "ngx-doc-viewer": "15.0.1", - "ngx-inline-filter": "^0.2.5", + "ngx-inline-filter": "^0.3.0", "ngx-scrollbar": "^19.1.4", "ngx-ui-tour-core": "16.0.0", "oidc-client-ts": "^3.4.1", diff --git a/frontend/refactor.js b/frontend/refactor.js new file mode 100644 index 000000000..c7caa59c9 --- /dev/null +++ b/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 = /]*?)?>/gi; + +function fixButtons(html) { + return html.replace(BUTTON_TAG_RE, (match, attrs) => { + if (attrs && /\btype\s*=/i.test(attrs)) return match; + return attrs ? ` + } diff --git a/frontend/src/app/features/apps/pages/app.component.html b/frontend/src/app/features/apps/pages/app.component.html index 46e674e76..26ca6844d 100644 --- a/frontend/src/app/features/apps/pages/app.component.html +++ b/frontend/src/app/features/apps/pages/app.component.html @@ -51,7 +51,8 @@ confirmRememberKey="leaveApp" confirmText="i18n:apps.leaveConfirmText" confirmTitle="i18n:apps.leaveConfirmTitle" - (sqxConfirmClick)="leave.emit(app)"> + (sqxConfirmClick)="leave.emit(app)" + type="button"> {{ "apps.leave" | sqxTranslate }} diff --git a/frontend/src/app/features/apps/pages/onboarding-dialog.component.html b/frontend/src/app/features/apps/pages/onboarding-dialog.component.html index c528e2da2..9bfbf2f66 100644 --- a/frontend/src/app/features/apps/pages/onboarding-dialog.component.html +++ b/frontend/src/app/features/apps/pages/onboarding-dialog.component.html @@ -3,7 +3,7 @@
- + @if (step === 0) {
@@ -15,7 +15,7 @@
- +
@@ -78,8 +78,8 @@
- - +
diff --git a/frontend/src/app/features/apps/pages/team.component.html b/frontend/src/app/features/apps/pages/team.component.html index 4396b9a2c..ede3ab6eb 100644 --- a/frontend/src/app/features/apps/pages/team.component.html +++ b/frontend/src/app/features/apps/pages/team.component.html @@ -24,7 +24,8 @@ confirmRememberKey="leaveApp" confirmText="i18n:teams.leaveConfirmText" confirmTitle="i18n:teams.leaveConfirmTitle" - (sqxConfirmClick)="leave.emit(team)"> + (sqxConfirmClick)="leave.emit(team)" + type="button"> {{ "teams.leave" | sqxTranslate }} diff --git a/frontend/src/app/features/assets/pages/asset-tag-dialog.component.html b/frontend/src/app/features/assets/pages/asset-tag-dialog.component.html index fd82f2f77..f844fc657 100644 --- a/frontend/src/app/features/assets/pages/asset-tag-dialog.component.html +++ b/frontend/src/app/features/assets/pages/asset-tag-dialog.component.html @@ -3,7 +3,7 @@ {{ "common.renameTag" | sqxTranslate }} - + diff --git a/frontend/src/app/features/assets/pages/asset-tags.component.html b/frontend/src/app/features/assets/pages/asset-tags.component.html index 90a0edd30..9eacad81e 100644 --- a/frontend/src/app/features/assets/pages/asset-tags.component.html +++ b/frontend/src/app/features/assets/pages/asset-tags.component.html @@ -1,11 +1,11 @@ diff --git a/frontend/src/app/features/content/pages/content/content-history-page.component.html b/frontend/src/app/features/content/pages/content/content-history-page.component.html index 6ba23ec6d..214fe1119 100644 --- a/frontend/src/app/features/content/pages/content/content-history-page.component.html +++ b/frontend/src/app/features/content/pages/content/content-history-page.component.html @@ -18,7 +18,7 @@ @if (content.canDraftCreate || content.canDraftDelete) {
@if (!content.newStatus) { - + } @else { @@ -50,7 +50,8 @@ confirmRememberKey="deleteDraft" confirmText="i18n:contents.deleteVersionConfirmText" confirmTitle="i18n:contents.deleteConfirmTitle" - (sqxConfirmClick)="deleteDraft()"> + (sqxConfirmClick)="deleteDraft()" + type="button"> {{ "contents.versionDelete" | sqxTranslate }} @@ -60,7 +61,8 @@ confirmRememberKey="deleteContent" confirmText="i18n:contents.deleteConfirmText" confirmTitle="i18n:contents.deleteConfirmTitle" - (sqxConfirmClick)="delete()"> + (sqxConfirmClick)="delete()" + type="button"> {{ "common.delete" | sqxTranslate }} @@ -88,7 +90,7 @@ @if (content.statusUpdates.length > 0) { @for (info of content.statusUpdates; track info) { - @@ -102,7 +104,8 @@ confirmRememberKey="cancelStatus" confirmText="i18n:contents.cancelStatusConfirmText" confirmTitle="i18n:contents.cancelStatusConfirmTitle" - (sqxConfirmClick)="cancelStatus()"> + (sqxConfirmClick)="cancelStatus()" + type="button"> {{ "contents.cancelStatus" | sqxTranslate }} @@ -112,7 +115,8 @@ confirmRememberKey="deleteContent" confirmText="i18n:contents.deleteConfirmText" confirmTitle="i18n:contents.deleteConfirmTitle" - (sqxConfirmClick)="delete()"> + (sqxConfirmClick)="delete()" + type="button"> {{ "common.delete" | sqxTranslate }} diff --git a/frontend/src/app/features/content/pages/content/content-page.component.html b/frontend/src/app/features/content/pages/content/content-page.component.html index a54a71196..17213ded1 100644 --- a/frontend/src/app/features/content/pages/content/content-page.component.html +++ b/frontend/src/app/features/content/pages/content/content-page.component.html @@ -4,7 +4,7 @@
@if (schema.type !== "Singleton") { - } @@ -75,7 +75,7 @@ @if (content.canClone) { - } @@ -86,7 +86,8 @@ confirmRememberKey="deleteContent" confirmText="i18n:contents.deleteConfirmText" confirmTitle="i18n:contents.deleteConfirmTitle" - (sqxConfirmClick)="delete()"> + (sqxConfirmClick)="delete()" + type="button"> {{ "common.delete" | sqxTranslate }} } @@ -133,8 +134,8 @@ scrollY="true" [sqxAnchoredTo]="buttonSave" *sqxModal="saveOnlyDropdown; closeAlways: true"> - - + +
} @@ -157,10 +158,10 @@ scrollY="true" [sqxAnchoredTo]="buttonSave" *sqxModal="savePublishDropdown; closeAlways: true"> - - diff --git a/frontend/src/app/features/content/pages/content/editor/content-editor.component.html b/frontend/src/app/features/content/pages/content/editor/content-editor.component.html index 6e0225049..411c967d3 100644 --- a/frontend/src/app/features/content/pages/content/editor/content-editor.component.html +++ b/frontend/src/app/features/content/pages/content/editor/content-editor.component.html @@ -8,7 +8,7 @@ @if (contentVersion) {
- +
@if (isDeleted) { diff --git a/frontend/src/app/features/content/pages/content/inspecting/content-inspection.component.html b/frontend/src/app/features/content/pages/content/inspecting/content-inspection.component.html index ab8246fdd..e7083103d 100644 --- a/frontend/src/app/features/content/pages/content/inspecting/content-inspection.component.html +++ b/frontend/src/app/features/content/pages/content/inspecting/content-inspection.component.html @@ -3,19 +3,19 @@ @if (mode | async; as currentMode) {
- {{ 'common.or' | sqxTranslate }} + {{ "common.or" | sqxTranslate }}
ApiKey: {{ rolesState.appName }}:{{ apiKey | async }}
diff --git a/frontend/src/app/features/content/shared/list/content.component.html b/frontend/src/app/features/content/shared/list/content.component.html index 67e39e851..5667b66ba 100644 --- a/frontend/src/app/features/content/shared/list/content.component.html +++ b/frontend/src/app/features/content/shared/list/content.component.html @@ -22,14 +22,14 @@ {{ "common.editInNewTab" | sqxTranslate }} @for (info of content.statusUpdates; track info) { - } @if (cloneable) { - + } @@ -39,7 +39,8 @@ confirmRememberKey="deleteContent" confirmText="i18n:contents.deleteConfirmText" confirmTitle="i18n:contents.deleteConfirmTitle" - (sqxConfirmClick)="delete.emit()"> + (sqxConfirmClick)="delete.emit()" + type="button"> {{ "common.delete" | sqxTranslate }} diff --git a/frontend/src/app/features/content/shared/preview-button.component.html b/frontend/src/app/features/content/shared/preview-button.component.html index a1d913c5c..c794891d5 100644 --- a/frontend/src/app/features/content/shared/preview-button.component.html +++ b/frontend/src/app/features/content/shared/preview-button.component.html @@ -9,7 +9,7 @@ @for (name of snapshot.previewNamesMore; track name) { - + }
diff --git a/frontend/src/app/features/content/shared/references/reference-item.component.html b/frontend/src/app/features/content/shared/references/reference-item.component.html index d2a660479..64be1d378 100644 --- a/frontend/src/app/features/content/shared/references/reference-item.component.html +++ b/frontend/src/app/features/content/shared/references/reference-item.component.html @@ -44,7 +44,7 @@
- @if (canRemove) { diff --git a/frontend/src/app/features/content/shared/references/references-tags.component.ts b/frontend/src/app/features/content/shared/references/references-tags.component.ts index e497bdfc9..8e12b2a61 100644 --- a/frontend/src/app/features/content/shared/references/references-tags.component.ts +++ b/frontend/src/app/features/content/shared/references/references-tags.component.ts @@ -144,7 +144,6 @@ export class ReferencesTagsComponent extends StatefulControlComponent x.id === content.id); - if (index >= 0) { this.contents[index] = content; } else { @@ -153,12 +152,10 @@ export class ReferencesTagsComponent extends StatefulControlComponent { this.isLoadingFailed = true; - this.resetConverterState(false); }, }); @@ -171,7 +168,6 @@ export class ReferencesTagsComponent extends StatefulControlComponent
- - + + diff --git a/frontend/src/app/features/dashboard/pages/dashboard-page.component.html b/frontend/src/app/features/dashboard/pages/dashboard-page.component.html index f0d6bfe67..4260e0804 100644 --- a/frontend/src/app/features/dashboard/pages/dashboard-page.component.html +++ b/frontend/src/app/features/dashboard/pages/dashboard-page.component.html @@ -24,7 +24,8 @@ } - @case ("api-calls") {animate.enter="fade-in" animate.leave="fade-out" + @case ("api-calls") { + animate.enter="fade-in" animate.leave="fade-out" } diff --git a/frontend/src/app/features/rules/pages/events/rule-event.component.html b/frontend/src/app/features/rules/pages/events/rule-event.component.html index 1bb2b89f6..3d92c41d5 100644 --- a/frontend/src/app/features/rules/pages/events/rule-event.component.html +++ b/frontend/src/app/features/rules/pages/events/rule-event.component.html @@ -80,7 +80,7 @@
- +
diff --git a/frontend/src/app/features/rules/pages/rule/branch.component.html b/frontend/src/app/features/rules/pages/rule/branch.component.html index f4a7f19e8..ea690c6bd 100644 --- a/frontend/src/app/features/rules/pages/rule/branch.component.html +++ b/frontend/src/app/features/rules/pages/rule/branch.component.html @@ -14,7 +14,11 @@ @if (branchItems.length > 0) { @for (item of branchItems; track item.id; let last = $last; let i = $index) {
-
@@ -60,7 +64,11 @@ @if (last && item.step.step.stepType !== "If") {
-
@@ -68,7 +76,7 @@ } } @else {
-
diff --git a/frontend/src/app/features/rules/pages/rule/rule-page.component.html b/frontend/src/app/features/rules/pages/rule/rule-page.component.html index c7e68057d..8ff719c07 100644 --- a/frontend/src/app/features/rules/pages/rule/rule-page.component.html +++ b/frontend/src/app/features/rules/pages/rule/rule-page.component.html @@ -2,7 +2,7 @@
-

{{ "common.rule" | sqxTranslate }}

@@ -18,7 +18,8 @@ confirmText="i18n:rules.triggerConfirmText" confirmTitle="i18n:rules.triggerConfirmTitle" [disabled]="!rule?.canTrigger" - (sqxConfirmClick)="trigger()"> + (sqxConfirmClick)="trigger()" + type="button"> } @@ -67,7 +68,8 @@ } diff --git a/frontend/src/app/features/rules/pages/rules/rule.component.html b/frontend/src/app/features/rules/pages/rules/rule.component.html index 399b5919c..df33ee5a3 100644 --- a/frontend/src/app/features/rules/pages/rules/rule.component.html +++ b/frontend/src/app/features/rules/pages/rules/rule.component.html @@ -22,15 +22,15 @@ @if (rule.canUpdate) { - + } @if (rule.canEnable) { - + } @if (rule.canDisable) { - + } @@ -42,7 +42,8 @@ confirmRememberKey="runRule" confirmText="i18n:rules.runRuleConfirmText" confirmTitle="i18n:rules.runRuleConfirmTitle" - (sqxConfirmClick)="run()"> + (sqxConfirmClick)="run()" + type="button"> {{ "rules.run" | sqxTranslate }} } @@ -53,7 +54,8 @@ confirmRememberKey="runRuleFromSnapshots" confirmText="i18n:rules.runRuleConfirmText" confirmTitle="i18n:rules.runRuleConfirmTitle" - (sqxConfirmClick)="runFromSnapshots()"> + (sqxConfirmClick)="runFromSnapshots()" + type="button"> {{ "rules.runFromSnapshots" | sqxTranslate }} } @@ -67,7 +69,8 @@ confirmRememberKey="deleteRule" confirmText="i18n:rules.deleteConfirmText" confirmTitle="i18n:rules.deleteConfirmTitle" - (sqxConfirmClick)="delete()"> + (sqxConfirmClick)="delete()" + type="button"> {{ "common.delete" | sqxTranslate }} } @@ -118,7 +121,8 @@ confirmText="i18n:rules.triggerConfirmText" confirmTitle="i18n:rules.triggerConfirmTitle" [disabled]="!rule.canTrigger" - (sqxConfirmClick)="trigger()"> + (sqxConfirmClick)="trigger()" + type="button"> } @else { diff --git a/frontend/src/app/features/rules/pages/rules/rules-page.component.html b/frontend/src/app/features/rules/pages/rules/rules-page.component.html index cfc521ddc..bdd5add33 100644 --- a/frontend/src/app/features/rules/pages/rules/rules-page.component.html +++ b/frontend/src/app/features/rules/pages/rules/rules-page.component.html @@ -15,7 +15,7 @@ @if (rulesState.runningRule | async; as runningRule) {
{{ "rules.runningRule" | sqxTranslate: { name: runningRule.name || "Unnamed Rule" } }} - +
} diff --git a/frontend/src/app/features/rules/pages/simulator/rule-transition.component.html b/frontend/src/app/features/rules/pages/simulator/rule-transition.component.html index 37bed2fee..f28c47d81 100644 --- a/frontend/src/app/features/rules/pages/simulator/rule-transition.component.html +++ b/frontend/src/app/features/rules/pages/simulator/rule-transition.component.html @@ -1,6 +1,5 @@ - @if (text) { - +
{{ text | sqxTranslate }} @@ -10,7 +9,7 @@ } @for (error of filteredErrors; track error; let last = $last) { - +
{{ error | sqxTranslate }} diff --git a/frontend/src/app/features/rules/pages/simulator/simulated-rule-event.component.html b/frontend/src/app/features/rules/pages/simulator/simulated-rule-event.component.html index 950a6bccc..52d6a3887 100644 --- a/frontend/src/app/features/rules/pages/simulator/simulated-rule-event.component.html +++ b/frontend/src/app/features/rules/pages/simulator/simulated-rule-event.component.html @@ -71,7 +71,7 @@ text="i18n:rules.simulation.eventConditionEvaluated" /> @if (event.flowState) { - + } } } diff --git a/frontend/src/app/features/rules/shared/state-attempt.component.html b/frontend/src/app/features/rules/shared/state-attempt.component.html index be0676ddc..515e31ad6 100644 --- a/frontend/src/app/features/rules/shared/state-attempt.component.html +++ b/frontend/src/app/features/rules/shared/state-attempt.component.html @@ -2,19 +2,19 @@ @if (attempt) {
- {{ attempt.started.toISOString() || '-' }} + {{ attempt.started.toISOString() || "-" }}
- +
- {{ attempt.completed.toISOString() || '-' }} + {{ attempt.completed.toISOString() || "-" }}
- + @if (attempt.error) { } - + @if (output) { diff --git a/frontend/src/app/features/rules/shared/state-details.component.html b/frontend/src/app/features/rules/shared/state-details.component.html index 2a1d2d400..8ecb002d6 100644 --- a/frontend/src/app/features/rules/shared/state-details.component.html +++ b/frontend/src/app/features/rules/shared/state-details.component.html @@ -19,6 +19,6 @@ [isLast]="$last" [isNext]="state.nextStepId === item.id" [state]="state.steps[item.id]" - [stepInfo]="availableSteps[item.step.step.stepType]" - [stepDefinition]="state.definition.steps[item.id].step" /> + [stepDefinition]="state.definition.steps[item.id].step" + [stepInfo]="availableSteps[item.step.step.stepType]" /> } diff --git a/frontend/src/app/features/rules/shared/state-step.component.html b/frontend/src/app/features/rules/shared/state-step.component.html index f2f03e453..53c502f8d 100644 --- a/frontend/src/app/features/rules/shared/state-step.component.html +++ b/frontend/src/app/features/rules/shared/state-step.component.html @@ -21,7 +21,7 @@
diff --git a/frontend/src/app/features/schemas/pages/schema/fields/field-wizard.component.html b/frontend/src/app/features/schemas/pages/schema/fields/field-wizard.component.html index 8b33a126c..bc9c0bf7f 100644 --- a/frontend/src/app/features/schemas/pages/schema/fields/field-wizard.component.html +++ b/frontend/src/app/features/schemas/pages/schema/fields/field-wizard.component.html @@ -74,7 +74,7 @@ @if (!editForm) {
- - - + +
@@ -96,7 +96,7 @@ @if (editForm) {
- - +
diff --git a/frontend/src/app/features/schemas/pages/schema/fields/field.component.html b/frontend/src/app/features/schemas/pages/schema/fields/field.component.html index d0e82e64a..a527a4a01 100644 --- a/frontend/src/app/features/schemas/pages/schema/fields/field.component.html +++ b/frontend/src/app/features/schemas/pages/schema/fields/field.component.html @@ -56,19 +56,19 @@ @if (field.properties.isContentField) { @if (field.canEnable) { - + } @if (field.canDisable) { - + } @if (field.canHide) { - + } @if (field.canShow) { - + } } @@ -79,7 +79,8 @@ confirmRememberKey="lockField" confirmText="i18n:schemas.field.lockConfirmText" confirmTitle="i18n:schemas.field.lockConfirmTitle" - (sqxConfirmClick)="lockField()"> + (sqxConfirmClick)="lockField()" + type="button"> {{ "schemas.field.lock" | sqxTranslate }} } @@ -91,7 +92,8 @@ confirmRememberKey="deleteField" confirmText="i18n:schemas.field.deleteConfirmText" confirmTitle="i18n:schemas.field.deleteConfirmTitle" - (sqxConfirmClick)="deleteField()"> + (sqxConfirmClick)="deleteField()" + type="button"> {{ "common.delete" | sqxTranslate }}
diff --git a/frontend/src/app/features/schemas/pages/schema/fields/forms/field-form.component.html b/frontend/src/app/features/schemas/pages/schema/fields/forms/field-form.component.html index 8657f0e2f..1a50a4a64 100644 --- a/frontend/src/app/features/schemas/pages/schema/fields/forms/field-form.component.html +++ b/frontend/src/app/features/schemas/pages/schema/fields/forms/field-form.component.html @@ -1,25 +1,27 @@
diff --git a/frontend/src/app/features/schemas/pages/schema/fields/types/rich-text-ui.component.html b/frontend/src/app/features/schemas/pages/schema/fields/types/rich-text-ui.component.html index 4c88742ac..486db462f 100644 --- a/frontend/src/app/features/schemas/pages/schema/fields/types/rich-text-ui.component.html +++ b/frontend/src/app/features/schemas/pages/schema/fields/types/rich-text-ui.component.html @@ -1,5 +1,10 @@
- + diff --git a/frontend/src/app/features/schemas/pages/schema/schema-page.component.html b/frontend/src/app/features/schemas/pages/schema/schema-page.component.html index f3590541f..c20049d61 100644 --- a/frontend/src/app/features/schemas/pages/schema/schema-page.component.html +++ b/frontend/src/app/features/schemas/pages/schema/schema-page.component.html @@ -76,7 +76,7 @@ @if (schemasState.canCreate) { - + } @@ -86,7 +86,8 @@ confirmRememberKey="deleteSchema" confirmText="i18n:schemas.deleteConfirmText" confirmTitle="i18n:schemas.deleteConfirmTitle" - (sqxConfirmClick)="deleteSchema()"> + (sqxConfirmClick)="deleteSchema()" + type="button"> {{ "common.delete" | sqxTranslate }} diff --git a/frontend/src/app/features/schemas/pages/schema/scripts/schema-scripts-form.component.html b/frontend/src/app/features/schemas/pages/schema/scripts/schema-scripts-form.component.html index 6910a711f..349fd1d4c 100644 --- a/frontend/src/app/features/schemas/pages/schema/scripts/schema-scripts-form.component.html +++ b/frontend/src/app/features/schemas/pages/schema/scripts/schema-scripts-form.component.html @@ -3,7 +3,7 @@
@if (planInfo.isYearlySelected) { - + } @if (!planInfo.isYearlySelected) { @@ -57,7 +58,8 @@ [confirmText]="planInfo.plan.yearlyConfirmText!" confirmTitle="i18n:plans.changeConfirmTitle" [disabled]="(plansState.locked | async) !== 'None'" - (sqxConfirmClick)="changeYearly()"> + (sqxConfirmClick)="changeYearly()" + type="button"> {{ "plans.change" | sqxTranslate }} } diff --git a/frontend/src/app/features/settings/pages/workflows/workflow-step.component.html b/frontend/src/app/features/settings/pages/workflows/workflow-step.component.html index 2efc7222a..2a78bb7e6 100644 --- a/frontend/src/app/features/settings/pages/workflows/workflow-step.component.html +++ b/frontend/src/app/features/settings/pages/workflows/workflow-step.component.html @@ -6,7 +6,8 @@ [class.active]="step.name === workflow.dto.initial" [class.enabled]="step.name !== workflow.dto.initial && !step.isLocked" (click)="makeInitial()" - [disabled]="step.name === workflow.dto.initial || step.isLocked || disabled"> + [disabled]="step.name === workflow.dto.initial || step.isLocked || disabled" + type="button"> @if (!step.isLocked) { } @@ -69,7 +70,7 @@
- +
} diff --git a/frontend/src/app/features/settings/pages/workflows/workflow.component.html b/frontend/src/app/features/settings/pages/workflows/workflow.component.html index acc3683a0..0b55f2cef 100644 --- a/frontend/src/app/features/settings/pages/workflows/workflow.component.html +++ b/frontend/src/app/features/settings/pages/workflows/workflow.component.html @@ -37,13 +37,13 @@
diff --git a/frontend/src/app/features/teams/pages/plans/plan.component.html b/frontend/src/app/features/teams/pages/plans/plan.component.html index 7c95e8595..cc142be3e 100644 --- a/frontend/src/app/features/teams/pages/plans/plan.component.html +++ b/frontend/src/app/features/teams/pages/plans/plan.component.html @@ -22,7 +22,7 @@
@if (planInfo.isSelected) { - + } @if (!planInfo.isSelected) { @@ -33,7 +33,8 @@ [confirmText]="planInfo.plan.confirmText" confirmTitle="i18n:plans.changeConfirmTitle" [disabled]="(plansState.locked | async) !== 'None'" - (sqxConfirmClick)="changeMonthly()"> + (sqxConfirmClick)="changeMonthly()" + type="button"> {{ "plans.change" | sqxTranslate }} } @@ -47,7 +48,7 @@ @if (planInfo.isYearlySelected) { - + } @if (!planInfo.isYearlySelected) { @@ -57,7 +58,8 @@ [confirmText]="planInfo.plan.yearlyConfirmText!" confirmTitle="i18n:plans.changeConfirmTitle" [disabled]="(plansState.locked | async) !== 'None'" - (sqxConfirmClick)="changeYearly()"> + (sqxConfirmClick)="changeYearly()" + type="button"> {{ "plans.change" | sqxTranslate }} } diff --git a/frontend/src/app/framework/angular/layout.component.html b/frontend/src/app/framework/angular/layout.component.html index 6e0837cbc..16f171c0a 100644 --- a/frontend/src/app/framework/angular/layout.component.html +++ b/frontend/src/app/framework/angular/layout.component.html @@ -25,7 +25,7 @@ } @else { - + } } @@ -63,7 +63,12 @@ - @@ -21,7 +22,8 @@ [disabled]="disabled" (sqxConfirmClick)="action.emit()" [tabIndex]="tabIndex" - [title]="tooltip | sqxTranslate"> + [title]="tooltip | sqxTranslate" + type="button"> @if (icon) { } diff --git a/frontend/src/app/framework/angular/menu.component.html b/frontend/src/app/framework/angular/menu.component.html index 51375f2c2..7a224b210 100644 --- a/frontend/src/app/framework/angular/menu.component.html +++ b/frontend/src/app/framework/angular/menu.component.html @@ -23,7 +23,13 @@ } -
+
diff --git a/frontend/src/app/framework/angular/modals/dialog-renderer.component.html b/frontend/src/app/framework/angular/modals/dialog-renderer.component.html index 698512d9d..a73e8611b 100644 --- a/frontend/src/app/framework/angular/modals/dialog-renderer.component.html +++ b/frontend/src/app/framework/angular/modals/dialog-renderer.component.html @@ -22,7 +22,12 @@
@for (notification of snapshot.notifications; track notification) { -