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);