diff --git a/application/Espo/Core/Htmlizer/Htmlizer.php b/application/Espo/Core/Htmlizer/Htmlizer.php index 61b9006ec1..d5c8bd224f 100644 --- a/application/Espo/Core/Htmlizer/Htmlizer.php +++ b/application/Espo/Core/Htmlizer/Htmlizer.php @@ -165,7 +165,7 @@ class Htmlizer if (!$skipInlineAttachmentHandling) { /** @var string $html */ $html = preg_replace_callback( - '/\?entryPoint=attachment&id=([A-Za-z0-9]*)/', + '/\?entryPoint=attachment&id=([A-Za-z0-9\-]*)/', function ($matches) { $id = $matches[1]; diff --git a/application/Espo/Modules/Crm/Tools/Calendar/Service.php b/application/Espo/Modules/Crm/Tools/Calendar/Service.php index 6c2b2707c8..90bfd8f052 100644 --- a/application/Espo/Modules/Crm/Tools/Calendar/Service.php +++ b/application/Espo/Modules/Crm/Tools/Calendar/Service.php @@ -298,7 +298,10 @@ class Service 'assignedUserId' => $userId, ]; - if ($seed->hasRelation('users')) { + // @todo Use a metadata parameter scope > usersLink. Populate in an upgrade script. + $hasUsers = $seed->hasRelation('users') && $seed->getRelationType('users') === Entity::MANY_MANY; + + if ($hasUsers) { $orGroup['usersMiddle.userId'] = $userId; } @@ -343,13 +346,15 @@ class Service throw new RuntimeException($e->getMessage()); } - if ($seed->hasRelation('users')) { + if ($hasUsers) { + // @todo Use sub-query. $queryBuilder ->distinct() ->leftJoin('users'); } if ($seed->hasRelation(Field::ASSIGNED_USERS)) { + // @todo Use sub-query. $queryBuilder ->distinct() ->leftJoin(Field::ASSIGNED_USERS); diff --git a/application/Espo/ORM/Query/Part/Expression.php b/application/Espo/ORM/Query/Part/Expression.php index 17fa0d8658..023e683299 100644 --- a/application/Espo/ORM/Query/Part/Expression.php +++ b/application/Espo/ORM/Query/Part/Expression.php @@ -763,6 +763,16 @@ class Expression implements WhereItem return self::composeFunction('LEAST', ...$arguments); } + /** + * 'ANY_VALUE' function. + * + * @since 9.1.6 + */ + public function anyValue(Expression $expression): self + { + return self::composeFunction('ANY_VALUE', $expression); + } + /** * 'AND' operator. Returns TRUE if all arguments are TRUE. */ diff --git a/application/Espo/ORM/Query/Part/Join.php b/application/Espo/ORM/Query/Part/Join.php index 282c181659..b18f413478 100644 --- a/application/Espo/ORM/Query/Part/Join.php +++ b/application/Espo/ORM/Query/Part/Join.php @@ -47,6 +47,7 @@ class Join private ?WhereItem $conditions = null; private bool $onlyMiddle = false; + private bool $isLateral = false; private function __construct( private string|Select $target, @@ -132,6 +133,16 @@ class Join return $this->onlyMiddle; } + /** + * Is LATERAL. + * + * @since 9.1.6 + */ + public function isLateral(): bool + { + return $this->isLateral; + } + /** * Create. * @@ -216,4 +227,21 @@ class Join return $obj; } + + /** + * With LATERAL. Only for a sub-query join. + * + * @since 9.1.6 + */ + public function withLateral(bool $isLateral = true): self + { + if (!$this->isSubQuery()) { + throw new LogicException("Lateral can be used only with sub-query joins."); + } + + $obj = clone $this; + $obj->isLateral = $isLateral; + + return $obj; + } } diff --git a/application/Espo/ORM/Query/SelectingBuilderTrait.php b/application/Espo/ORM/Query/SelectingBuilderTrait.php index cd1513e26a..e078c62ef8 100644 --- a/application/Espo/ORM/Query/SelectingBuilderTrait.php +++ b/application/Espo/ORM/Query/SelectingBuilderTrait.php @@ -201,6 +201,7 @@ trait SelectingBuilderTrait ): self { $onlyMiddle = false; + $isLateral = false; /** @var string|Join|array $target */ @@ -208,6 +209,8 @@ trait SelectingBuilderTrait $alias = $alias ?? $target->getAlias(); $conditions = $conditions ?? $target->getConditions(); $onlyMiddle = $target->isOnlyMiddle(); + $isLateral = $target->isLateral(); + $target = $target->getTarget(); } @@ -256,6 +259,10 @@ trait SelectingBuilderTrait $params['onlyMiddle'] = true; } + if ($isLateral) { + $params['isLateral'] = true; + } + if ($params !== []) { $this->params[$type][] = [$target, $alias, $conditions, $params]; diff --git a/application/Espo/ORM/QueryComposer/BaseQueryComposer.php b/application/Espo/ORM/QueryComposer/BaseQueryComposer.php index 5300531736..116a49318a 100644 --- a/application/Espo/ORM/QueryComposer/BaseQueryComposer.php +++ b/application/Espo/ORM/QueryComposer/BaseQueryComposer.php @@ -3140,6 +3140,18 @@ abstract class BaseQueryComposer implements QueryComposer $value = $right; + if ($isNotValue) { + if (is_null($value)) { + return $sql; + } + + $rightPart = $this->convertComplexExpression($entity, $value, false, $params); + + $sql .= " " . $operator . " " . $rightPart; + + return $sql; + } + if (is_null($value)) { if ($operator === '=') { $sql .= " IS NULL"; @@ -3150,14 +3162,6 @@ abstract class BaseQueryComposer implements QueryComposer return $sql; } - if ($isNotValue) { - $rightPart = $this->convertComplexExpression($entity, $value, false, $params); - - $sql .= " " . $operator . " " . $rightPart; - - return $sql; - } - $sql .= " " . $operator . " " . $this->quote($value); return $sql; @@ -3206,7 +3210,13 @@ abstract class BaseQueryComposer implements QueryComposer $aliasPart = $this->quoteIdentifier($alias); - $sql = $prefixPart . "JOIN $targetPart AS $aliasPart"; + $sql = $prefixPart . "JOIN "; + + if (!empty($joinParams['isLateral'])) { + $sql .= "LATERAL "; + } + + $sql .= "$targetPart AS $aliasPart"; if ($conditions === []) { return $sql; diff --git a/application/Espo/ORM/QueryComposer/Functions.php b/application/Espo/ORM/QueryComposer/Functions.php index 7181117d69..2addffb3cf 100644 --- a/application/Espo/ORM/QueryComposer/Functions.php +++ b/application/Espo/ORM/QueryComposer/Functions.php @@ -141,6 +141,7 @@ class Functions 'POSITION_IN_LIST', 'MATCH_BOOLEAN', 'MATCH_NATURAL_LANGUAGE', + 'ANY_VALUE', ]; public const COMPARISON_FUNCTION_LIST = [ diff --git a/application/Espo/Resources/metadata/entityDefs/EmailAccount.json b/application/Espo/Resources/metadata/entityDefs/EmailAccount.json index 6082817315..561b9cd0f9 100644 --- a/application/Espo/Resources/metadata/entityDefs/EmailAccount.json +++ b/application/Espo/Resources/metadata/entityDefs/EmailAccount.json @@ -15,6 +15,9 @@ "status": { "type": "enum", "options": ["Active", "Inactive"], + "style": { + "Inactive": "info" + }, "default": "Active" }, "host": { diff --git a/application/Espo/Resources/metadata/entityDefs/InboundEmail.json b/application/Espo/Resources/metadata/entityDefs/InboundEmail.json index f48635fd4e..0716b08ac3 100644 --- a/application/Espo/Resources/metadata/entityDefs/InboundEmail.json +++ b/application/Espo/Resources/metadata/entityDefs/InboundEmail.json @@ -15,6 +15,9 @@ "status": { "type": "enum", "options": ["Active", "Inactive"], + "style": { + "Inactive": "info" + }, "default": "Active" }, "host": { diff --git a/application/Espo/Resources/metadata/entityDefs/ScheduledJob.json b/application/Espo/Resources/metadata/entityDefs/ScheduledJob.json index 733556b08f..2a71ae6490 100644 --- a/application/Espo/Resources/metadata/entityDefs/ScheduledJob.json +++ b/application/Espo/Resources/metadata/entityDefs/ScheduledJob.json @@ -13,6 +13,9 @@ "type": "enum", "options": ["Active", "Inactive"], "default": "Active", + "style": { + "Inactive": "info" + }, "audited": true }, "scheduling": { diff --git a/application/Espo/Tools/Pdf/Dompdf/HtmlComposer.php b/application/Espo/Tools/Pdf/Dompdf/HtmlComposer.php index f40b29466c..fe7f0ccd43 100644 --- a/application/Espo/Tools/Pdf/Dompdf/HtmlComposer.php +++ b/application/Espo/Tools/Pdf/Dompdf/HtmlComposer.php @@ -196,7 +196,7 @@ class HtmlComposer ) ?? ''; return preg_replace_callback( - "/src=\"\?entryPoint=attachment&id=([A-Za-z0-9]*)\"/", + "/src=\"\?entryPoint=attachment&id=([A-Za-z0-9\-]*)\"/", function ($matches) { $id = $matches[1]; diff --git a/client/src/views/record/panels/relationship.js b/client/src/views/record/panels/relationship.js index aa58a7326f..93014d2d1c 100644 --- a/client/src/views/record/panels/relationship.js +++ b/client/src/views/record/panels/relationship.js @@ -390,6 +390,10 @@ class RelationshipPanelView extends BottomPanelView { this.once('show', () => collection.fetch()); }); + + if (this.defs.syncBackWithModel) { + this.listenTo(view, 'after:save after:delete', () => this.processSyncBack()); + } }); }); @@ -750,8 +754,11 @@ class RelationshipPanelView extends BottomPanelView { model: model, }) .then(view => { + // @todo Move to afterSave? this.listenTo(view, 'after:save', () => { this.collection.fetch(); + + this.processSyncBack(); }); }); } @@ -773,6 +780,8 @@ class RelationshipPanelView extends BottomPanelView { id: id, afterSave: () => { this.collection.fetch(); + + this.processSyncBack(); }, }); } @@ -801,6 +810,8 @@ class RelationshipPanelView extends BottomPanelView { this.model.trigger('after:unrelate'); this.model.trigger('after:unrelate:' + this.link); + + this.processSyncBack(); }); }); } @@ -831,6 +842,8 @@ class RelationshipPanelView extends BottomPanelView { this.model.trigger('after:unrelate'); this.model.trigger('after:unrelate:' + this.link); + + this.processSyncBack(); }); }); } @@ -857,6 +870,8 @@ class RelationshipPanelView extends BottomPanelView { this.model.trigger('after:unrelate'); this.model.trigger('after:unrelate:' + this.link); + + this.processSyncBack(); }); }); } @@ -869,7 +884,22 @@ class RelationshipPanelView extends BottomPanelView { actionCreateRelated() { const helper = new CreateRelatedHelper(this); - helper.process(this.model, this.link); + helper.process(this.model, this.link, { + afterSave: () => { + this.processSyncBack(); + }, + }); + } + + /** + * @protected + */ + processSyncBack() { + if (!this.defs.syncBackWithModel || this.getHelper().webSocketManager) { + return; + } + + this.model.fetch({highlight: true}); } // noinspection JSUnusedGlobalSymbols @@ -912,8 +942,6 @@ class RelationshipPanelView extends BottomPanelView { this.defs.create = false; } - - } export default RelationshipPanelView; diff --git a/schema/metadata/clientDefs.json b/schema/metadata/clientDefs.json index 7293d541dd..d69124d5f5 100644 --- a/schema/metadata/clientDefs.json +++ b/schema/metadata/clientDefs.json @@ -507,6 +507,10 @@ "type": "boolean", "description": "Re-fetch records when the parent model is saved or refreshed by WebSocket." }, + "syncBackWithModel": { + "type": "boolean", + "description": "Re-fetch the parent model when the relationship is updated. Ignored if WebSocked is enabled." + }, "massSelect": { "type": "boolean", "description": "Allow mass select." diff --git a/schema/metadata/entityDefs.json b/schema/metadata/entityDefs.json index 20f219a0a9..95c4509ecd 100644 --- a/schema/metadata/entityDefs.json +++ b/schema/metadata/entityDefs.json @@ -1184,6 +1184,11 @@ "description": "Apply DISTINCT to the query." } } + }, + "massUpdateActionList": { + "type": "array", + "items": {"type": "string"}, + "description": "Actions for mass update. Overrides the value from fields metadata." } } } diff --git a/schema/metadata/fields.json b/schema/metadata/fields.json index 75c6d40df5..bcb5d4dc1a 100644 --- a/schema/metadata/fields.json +++ b/schema/metadata/fields.json @@ -176,6 +176,11 @@ "type": "array", "items": {"type": "string"}, "description": "Sanitizers. Classes should implement Espo\\Core\\FieldSanitize\\Sanitizer. As of v8.2." + }, + "massUpdateActionList": { + "type": "array", + "items": {"type": "string"}, + "description": "Actions for mass update." } } } diff --git a/tests/unit/Espo/ORM/MysqlQueryComposerTest.php b/tests/unit/Espo/ORM/MysqlQueryComposerTest.php index 055f1e9939..3a6120115f 100644 --- a/tests/unit/Espo/ORM/MysqlQueryComposerTest.php +++ b/tests/unit/Espo/ORM/MysqlQueryComposerTest.php @@ -1057,6 +1057,39 @@ class MysqlQueryComposerTest extends \PHPUnit\Framework\TestCase ); } + public function testJoinSubQueryLateral(): void + { + $sql = + "SELECT post.id AS `id` FROM `post` " . + "JOIN LATERAL (SELECT post.id AS `id` FROM `post` LIMIT 0, 1) AS `a` ON TRUE"; + + $select = SelectBuilder::create() + ->select('id') + ->from('Post') + ->join( + Join::createWithSubQuery( + SelectBuilder::create() + ->select('id') + ->from('Post') + ->limit(0, 1) + ->withDeleted() + ->build(), + 'a', + ) + ->withLateral() + ->withConditions( + Expression::value(true) + ) + ) + ->withDeleted() + ->build(); + + $this->assertEquals( + $sql, + $this->query->composeSelect($select) + ); + } + public function testJoinSubQueryException1(): void { $this->expectException(LogicException::class);