feat(StatusQ): General purpose js function-based aggregator

Closes: #14617
This commit is contained in:
Michał Cieślak 2024-05-07 16:18:49 +02:00 committed by Michał
parent ae636ef5a7
commit 587594f3b9
8 changed files with 455 additions and 0 deletions

View File

@ -0,0 +1,155 @@
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
import StatusQ 0.1
import StatusQ.Core.Utils 0.1
import Storybook 1.0
Control {
id: root
font.pixelSize: 16
padding: 10
ListModel {
id: sourceModel
ListElement {
symbol: "SNT"
balance: "4"
}
ListElement {
symbol: "ETH"
balance: "14"
}
ListElement {
symbol: "ZRX"
balance: "24"
}
ListElement {
symbol: "DAI"
balance: "43"
}
ListElement {
symbol: "UNI"
balance: "2"
}
ListElement {
symbol: "PEPE"
balance: "1"
}
}
FunctionAggregator {
id: totalBalanceAggregator
model: sourceModel
initialValue: "0"
roleName: "balance"
aggregateFunction: (aggr, value) => AmountsArithmetic.sum(
AmountsArithmetic.fromString(aggr),
AmountsArithmetic.fromString(value)).toString()
}
FunctionAggregator {
id: maxBalanceAggregator
model: sourceModel
initialValue: "0"
roleName: "balance"
aggregateFunction: (aggr, value) => AmountsArithmetic.cmp(
AmountsArithmetic.fromString(aggr),
AmountsArithmetic.fromString(value)) > 0
? aggr : value
}
FunctionAggregator {
id: tokensListAggregator
model: sourceModel
initialValue: []
roleName: "symbol"
aggregateFunction: (aggr, value) => [...aggr, value]
}
contentItem: ColumnLayout {
Label {
text: "SUMMARY"
font.bold: true
}
Label {
text: "Total balance: " + totalBalanceAggregator.value
}
Label {
text: "Max balance: " + maxBalanceAggregator.value
}
Label {
text: "Tokens list: " + tokensListAggregator.value
}
Item {
Layout.preferredHeight: 20
}
Label {
text: "MODEL (click rows to change)"
font.bold: true
}
GenericListView {
Layout.fillWidth: true
Layout.fillHeight: true
model: sourceModel
onRowClicked: {
if (role === "balance") {
const balance = sourceModel.get(index).balance
sourceModel.setProperty(index, "balance",
(Number(balance) + 1).toString())
} else {
const symbol = sourceModel.get(index).symbol
sourceModel.setProperty(index, "symbol", symbol + "_")
}
}
insetComponent: Button {
height: 20
font.pixelSize: 11
text: "remove"
onClicked: {
sourceModel.remove(model.index)
}
}
}
Button {
text: "Add token"
property int counter: 1
onClicked: {
sourceModel.append({
symbol: "NEW_" + counter,
balance: "" + counter * 2
})
counter++
}
}
}
}
// category: Models

View File

@ -37,6 +37,7 @@ ListView {
bottomMargin: margin
signal moveRequested(int from, int to)
signal rowClicked(int index, string role)
ListModel {
id: rowModel
@ -154,6 +155,14 @@ ListView {
readonly property string separator: last ? "" : ","
text: `${roleName}: ${valueSanitized}${separator}`
MouseArea {
anchors.fill: parent
onClicked: root.rowClicked(
delegateRoot.topModel.index,
roleName)
}
}
}
}

View File

@ -99,6 +99,7 @@ add_library(StatusQ SHARED
include/StatusQ/fastexpressionrole.h
include/StatusQ/fastexpressionsorter.h
include/StatusQ/formatteddoubleproperty.h
include/StatusQ/functionaggregator.h
include/StatusQ/leftjoinmodel.h
include/StatusQ/modelutilsinternal.h
include/StatusQ/movablemodel.h
@ -120,6 +121,7 @@ add_library(StatusQ SHARED
src/fastexpressionrole.cpp
src/fastexpressionsorter.cpp
src/formatteddoubleproperty.cpp
src/functionaggregator.cpp
src/leftjoinmodel.cpp
src/modelutilsinternal.cpp
src/movablemodel.cpp

View File

@ -0,0 +1,38 @@
#pragma once
#include <QJSValue>
#include <QObject>
#include <QVariant>
#include "StatusQ/singleroleaggregator.h"
class FunctionAggregator : public SingleRoleAggregator
{
Q_OBJECT
Q_PROPERTY(QVariant initialValue READ initialValue WRITE setInitialValue
NOTIFY initialValueChanged)
Q_PROPERTY(QJSValue aggregateFunction READ aggregateFunction
WRITE setAggregateFunction NOTIFY aggregateFunctionChanged)
public:
explicit FunctionAggregator(QObject* parent = nullptr);
const QVariant& initialValue() const;
void setInitialValue(const QVariant& initialValue);
const QJSValue& aggregateFunction() const;
void setAggregateFunction(const QJSValue& aggregateFunction);
signals:
void initialValueChanged();
void aggregateFunctionChanged();
protected slots:
QVariant calculateAggregation() override;
private:
QVariant m_initialValue;
QJSValue m_aggregateFunction;
};

View File

@ -0,0 +1,98 @@
#include "StatusQ/functionaggregator.h"
#include <QDebug>
#include <QJSEngine>
FunctionAggregator::FunctionAggregator(QObject* parent)
: SingleRoleAggregator(parent)
{
recalculate();
}
const QVariant& FunctionAggregator::initialValue() const
{
return m_initialValue;
}
void FunctionAggregator::setInitialValue(const QVariant& initialValue)
{
if (m_initialValue == initialValue)
return;
m_initialValue = initialValue;
emit initialValueChanged();
recalculate();
}
const QJSValue& FunctionAggregator::aggregateFunction() const
{
return m_aggregateFunction;
}
void FunctionAggregator::setAggregateFunction(const QJSValue& aggregateFunction)
{
if (m_aggregateFunction.strictlyEquals(aggregateFunction))
return;
if (!aggregateFunction.isCallable() && !aggregateFunction.isUndefined()) {
qWarning() << "FunctionAggregator::aggregateFunction must be a "
"callable object.";
return;
}
m_aggregateFunction = aggregateFunction;
emit aggregateFunctionChanged();
recalculate();
}
QVariant FunctionAggregator::calculateAggregation()
{
// Check if m_model exists and role name is initialized
if (!model() || roleName().isEmpty())
return m_initialValue;
// Check if m_roleName is part of the roles of the model
QHash<int, QByteArray> roles = model()->roleNames();
if (!roleExists() && model()->rowCount()) {
qWarning() << "Provided role name does not exist in the current model.";
return m_initialValue;
}
if (!m_initialValue.isValid())
return m_initialValue;
if (m_aggregateFunction.isUndefined())
return m_initialValue;
QJSEngine* engine = qjsEngine(this);
if (engine == nullptr) {
qWarning() << "FunctionAggregator is intended to be used in JS "
"environment. QJSEngine must be available.";
return m_initialValue;
}
QJSValue aggregation = engine->toScriptValue(m_initialValue);
auto rows = model()->rowCount();
auto role = roles.key(roleName());
for (int i = 0; i < rows; ++i) {
QModelIndex index = model()->index(i, 0);
QVariant value = model()->data(index, role);
QJSValue valueJs = engine->toScriptValue(value);
aggregation = m_aggregateFunction.call({aggregation, valueJs});
if (aggregation.isError()) {
qWarning() << "Aggregation calculation failed. Error type:"
<< aggregation.errorType();
return m_initialValue;
}
}
return aggregation.toVariant();
}

View File

@ -9,6 +9,7 @@
#include "StatusQ/fastexpressionrole.h"
#include "StatusQ/fastexpressionsorter.h"
#include "StatusQ/formatteddoubleproperty.h"
#include "StatusQ/functionaggregator.h"
#include "StatusQ/leftjoinmodel.h"
#include "StatusQ/modelutilsinternal.h"
#include "StatusQ/movablemodel.h"
@ -56,6 +57,7 @@ public:
qmlRegisterType<RoleRename>("StatusQ", 0, 1, "RoleRename");
qmlRegisterType<RolesRenamingModel>("StatusQ", 0, 1, "RolesRenamingModel");
qmlRegisterType<SumAggregator>("StatusQ", 0, 1, "SumAggregator");
qmlRegisterType<FunctionAggregator>("StatusQ", 0, 1, "FunctionAggregator");
qmlRegisterType<WritableProxyModel>("StatusQ", 0, 1, "WritableProxyModel");
qmlRegisterType<FormattedDoubleProperty>("StatusQ", 0, 1, "FormattedDoubleProperty");

View File

@ -82,6 +82,10 @@ add_executable(SumAggregatorTest tst_SumAggregator.cpp)
target_link_libraries(SumAggregatorTest PRIVATE StatusQ StatusQTestLib)
add_test(NAME SumAggregatorTest COMMAND SumAggregatorTest)
add_executable(FunctionAggregatorTest tst_FunctionAggregator.cpp)
target_link_libraries(FunctionAggregatorTest PRIVATE StatusQ StatusQTestLib)
add_test(NAME FunctionAggregatorTest COMMAND FunctionAggregatorTest)
add_executable(ConcatModelTest tst_ConcatModel.cpp)
target_link_libraries(ConcatModelTest PRIVATE StatusQ StatusQTestLib SortFilterProxyModel)
add_test(NAME ConcatModelTest COMMAND ConcatModelTest)

View File

@ -0,0 +1,147 @@
#include <QtTest>
#include <QQmlEngine>
#include <StatusQ/functionaggregator.h>
#include <TestHelpers/testmodel.h>
class TestFunctionAggregator : public QObject
{
Q_OBJECT
private:
void makeQmlEngineAvailable(QQmlEngine& engine, QObject& obj)
{
auto jsObj = engine.newQObject(&obj);
engine.setObjectOwnership(&obj, QQmlEngine::CppOwnership);
Q_UNUSED(jsObj);
}
private slots:
void basicTest() {
QQmlEngine engine;
FunctionAggregator aggregator;
makeQmlEngineAvailable(engine, aggregator);
auto jsLambda = engine.evaluate("(aggr, val) => [...aggr, val]");
QCOMPARE(jsLambda.isError(), false);
QCOMPARE(jsLambda.isCallable(), true);
TestModel sourceModel({
{ "chainId", { "12", "13", "1", "321" }},
{ "balance", { "4", "3", "5", "5" }}
});
aggregator.setModel(&sourceModel);
aggregator.setRoleName("balance");
aggregator.setInitialValue(QVariantList());
aggregator.setAggregateFunction(jsLambda);
QVariantList expected{"4", "3", "5", "5"};
QCOMPARE(aggregator.value(), expected);
}
void typeMismatchTest() {
QQmlEngine engine;
FunctionAggregator aggregator;
makeQmlEngineAvailable(engine, aggregator);
auto jsLambda = engine.evaluate("(aggr, val) => [...aggr, val]");
QCOMPARE(jsLambda.isError(), false);
QCOMPARE(jsLambda.isCallable(), true);
TestModel sourceModel({{ "balance", { "4", "3", "5", "5" }}});
aggregator.setModel(&sourceModel);
aggregator.setRoleName("balance");
aggregator.setInitialValue(0);
QTest::ignoreMessage(QtWarningMsg,
"Aggregation calculation failed. Error type: 6");
aggregator.setAggregateFunction(jsLambda);
QCOMPARE(aggregator.value(), 0);
}
void roleNameNotFoundTest() {
QQmlEngine engine;
FunctionAggregator aggregator;
makeQmlEngineAvailable(engine, aggregator);
TestModel sourceModel({{ "balance", { "4", "3", "5", "5" }}});
aggregator.setModel(&sourceModel);
aggregator.setInitialValue(0);
QTest::ignoreMessage(QtWarningMsg,
"Provided role name does not exist in the current model.");
aggregator.setRoleName("notExisiting");
QCOMPARE(aggregator.value(), 0);
}
void invalidFunctionTest() {
FunctionAggregator aggregator;
QTest::ignoreMessage(QtWarningMsg,
"FunctionAggregator::aggregateFunction must be a "
"callable object.");
aggregator.setAggregateFunction(5);
}
void noJsEngineTest() {
QQmlEngine engine;
FunctionAggregator aggregator;
auto jsLambda = engine.evaluate("(aggr) => aggr");
QCOMPARE(jsLambda.isError(), false);
QCOMPARE(jsLambda.isCallable(), true);
TestModel sourceModel({
{ "balance", { "4", "3", "5", "5" }}
});
aggregator.setModel(&sourceModel);
aggregator.setRoleName("balance");
aggregator.setInitialValue(0);
QTest::ignoreMessage(QtWarningMsg,
"FunctionAggregator is intended to be used in JS "
"environment. QJSEngine must be available.");
aggregator.setAggregateFunction(jsLambda);
QCOMPARE(aggregator.value(), 0);
}
void providingInitialValueIfNotReadyTest() {
QQmlEngine engine;
FunctionAggregator aggregator;
makeQmlEngineAvailable(engine, aggregator);
auto jsLambda = engine.evaluate("(aggr, val) => aggr + val");
QCOMPARE(jsLambda.isError(), false);
QCOMPARE(jsLambda.isCallable(), true);
TestModel sourceModel({
{ "chainId", { "12", "13", "1", "321" }},
{ "balance", { "4", "3", "5", "5" }}
});
QCOMPARE(aggregator.value(), {});
aggregator.setInitialValue("-");
QCOMPARE(aggregator.value(), "-");
aggregator.setModel(&sourceModel);
QCOMPARE(aggregator.value(), "-");
aggregator.setRoleName("balance");
QCOMPARE(aggregator.value(), "-");
aggregator.setAggregateFunction(jsLambda);
QCOMPARE(aggregator.value(), "-4355");
}
};
QTEST_MAIN(TestFunctionAggregator)
#include "tst_FunctionAggregator.moc"