From a38099e9adbd3545b0f6d38de3093c284bf6a808 Mon Sep 17 00:00:00 2001 From: Gabriel Bolbotina Date: Tue, 12 May 2026 17:38:56 +0300 Subject: [PATCH 1/6] Check if the date time contains milisecond Modified logic to convert convert utc date time --- app/filter/filtercontroller.cpp | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/app/filter/filtercontroller.cpp b/app/filter/filtercontroller.cpp index ec679c839..59bd2f1c0 100644 --- a/app/filter/filtercontroller.cpp +++ b/app/filter/filtercontroller.cpp @@ -262,7 +262,13 @@ QString FilterController::buildFieldExpression( const FieldFilter &filter ) cons if ( value.typeId() == QMetaType::QDateTime ) if ( isDateFilterDateTime( filter.filterId ) ) - expressionCopy.replace( QStringLiteral( "@@value@@" ), QgsExpression::quotedString( value.toDateTime().toUTC().toString( TIMESTAMP_FORMAT ) ) ); + { + const QDateTime utcDt = value.toDateTime().toUTC(); + const QString dtFormat = utcDt.time().msec() != 0 + ? QStringLiteral( "yyyy-MM-ddTHH:mm:ss.zzzZ" ) + : QStringLiteral( "yyyy-MM-ddTHH:mm:ssZ" ); + expressionCopy.replace( QStringLiteral( "@@value@@" ), QgsExpression::quotedString( utcDt.toString( dtFormat ) ) ); + } else expressionCopy.replace( QStringLiteral( "@@value@@" ), QgsExpression::quotedString( value.toDate().toString( DATE_FORMAT ) ) ); else @@ -358,7 +364,13 @@ QString FilterController::buildFieldExpression( const FieldFilter &filter ) cons // QML always returns QDateTime so we can't differentiate based on type if ( value.typeId() == QMetaType::QDateTime ) if ( isDateFilterDateTime( filter.filterId ) ) - expressionTemplate.replace( QStringLiteral( "@@value@@" ), QgsExpression::quotedString( value.toDateTime().toUTC().toString( TIMESTAMP_FORMAT ) ) ); + { + const QDateTime utcDt = value.toDateTime().toUTC(); + const QString dtFormat = utcDt.time().msec() != 0 + ? QStringLiteral( "yyyy-MM-ddTHH:mm:ss.zzzZ" ) + : QStringLiteral( "yyyy-MM-ddTHH:mm:ssZ" ); + expressionTemplate.replace( QStringLiteral( "@@value@@" ), QgsExpression::quotedString( utcDt.toString( dtFormat ) ) ); + } else expressionTemplate.replace( QStringLiteral( "@@value@@" ), QgsExpression::quotedString( value.toDate().toString( DATE_FORMAT ) ) ); else From f1d64c6713eef1d8a86318d652e5de09f5945867 Mon Sep 17 00:00:00 2001 From: Gabriel Bolbotina Date: Thu, 14 May 2026 12:35:56 +0300 Subject: [PATCH 2/6] Modified variable names to be more readable --- app/filter/filtercontroller.cpp | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/filter/filtercontroller.cpp b/app/filter/filtercontroller.cpp index 59bd2f1c0..d738e1e53 100644 --- a/app/filter/filtercontroller.cpp +++ b/app/filter/filtercontroller.cpp @@ -263,11 +263,11 @@ QString FilterController::buildFieldExpression( const FieldFilter &filter ) cons if ( value.typeId() == QMetaType::QDateTime ) if ( isDateFilterDateTime( filter.filterId ) ) { - const QDateTime utcDt = value.toDateTime().toUTC(); - const QString dtFormat = utcDt.time().msec() != 0 - ? QStringLiteral( "yyyy-MM-ddTHH:mm:ss.zzzZ" ) - : QStringLiteral( "yyyy-MM-ddTHH:mm:ssZ" ); - expressionCopy.replace( QStringLiteral( "@@value@@" ), QgsExpression::quotedString( utcDt.toString( dtFormat ) ) ); + const QDateTime utcDateTime = value.toDateTime().toUTC(); + const QString dateTimeFormat = utcDateTime.time().msec() != 0 + ? QStringLiteral( "yyyy-MM-ddTHH:mm:ss.zzzZ" ) + : QStringLiteral( "yyyy-MM-ddTHH:mm:ssZ" ); + expressionCopy.replace( QStringLiteral( "@@value@@" ), QgsExpression::quotedString( utcDateTime.toString( dateTimeFormat ) ) ); } else expressionCopy.replace( QStringLiteral( "@@value@@" ), QgsExpression::quotedString( value.toDate().toString( DATE_FORMAT ) ) ); From 9535015c11da35aa9cc974b42d8c8d7b66dbad96 Mon Sep 17 00:00:00 2001 From: Gabriel Bolbotina Date: Thu, 14 May 2026 12:56:56 +0300 Subject: [PATCH 3/6] Added comment and renamed variables --- app/filter/filtercontroller.cpp | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/app/filter/filtercontroller.cpp b/app/filter/filtercontroller.cpp index d738e1e53..d9ee12edc 100644 --- a/app/filter/filtercontroller.cpp +++ b/app/filter/filtercontroller.cpp @@ -263,9 +263,10 @@ QString FilterController::buildFieldExpression( const FieldFilter &filter ) cons if ( value.typeId() == QMetaType::QDateTime ) if ( isDateFilterDateTime( filter.filterId ) ) { + // check if the DateTime contains milliseconds const QDateTime utcDateTime = value.toDateTime().toUTC(); const QString dateTimeFormat = utcDateTime.time().msec() != 0 - ? QStringLiteral( "yyyy-MM-ddTHH:mm:ss.zzzZ" ) + ? TIMESTAMP_FORMAT : QStringLiteral( "yyyy-MM-ddTHH:mm:ssZ" ); expressionCopy.replace( QStringLiteral( "@@value@@" ), QgsExpression::quotedString( utcDateTime.toString( dateTimeFormat ) ) ); } @@ -365,11 +366,12 @@ QString FilterController::buildFieldExpression( const FieldFilter &filter ) cons if ( value.typeId() == QMetaType::QDateTime ) if ( isDateFilterDateTime( filter.filterId ) ) { - const QDateTime utcDt = value.toDateTime().toUTC(); - const QString dtFormat = utcDt.time().msec() != 0 - ? QStringLiteral( "yyyy-MM-ddTHH:mm:ss.zzzZ" ) - : QStringLiteral( "yyyy-MM-ddTHH:mm:ssZ" ); - expressionTemplate.replace( QStringLiteral( "@@value@@" ), QgsExpression::quotedString( utcDt.toString( dtFormat ) ) ); + // check if the DateTime contains milliseconds + const QDateTime utcDateTime = value.toDateTime().toUTC(); + const QString dateTimeFormat = utcDateTime.time().msec() != 0 + ? TIMESTAMP_FORMAT + : QStringLiteral( "yyyy-MM-ddTHH:mm:ssZ" ); + expressionTemplate.replace( QStringLiteral( "@@value@@" ), QgsExpression::quotedString( utcDateTime.toString( dateTimeFormat ) ) ); } else expressionTemplate.replace( QStringLiteral( "@@value@@" ), QgsExpression::quotedString( value.toDate().toString( DATE_FORMAT ) ) ); From 429d49c6b431e21bf269e500a5b2f7edb5db48f8 Mon Sep 17 00:00:00 2001 From: Gabriel Bolbotina Date: Tue, 19 May 2026 21:07:19 +0300 Subject: [PATCH 4/6] Fixed edge case of 0 milliseconds Added constant for timestamp with no milliseconds --- app/filter/filtercontroller.cpp | 64 ++++++++++++++++++++++++++------- 1 file changed, 52 insertions(+), 12 deletions(-) diff --git a/app/filter/filtercontroller.cpp b/app/filter/filtercontroller.cpp index d9ee12edc..a83c56bfc 100644 --- a/app/filter/filtercontroller.cpp +++ b/app/filter/filtercontroller.cpp @@ -23,6 +23,7 @@ #include "coreutils.h" const QString TIMESTAMP_FORMAT = QStringLiteral( "yyyy-MM-ddTHH:mm:ss.zzzZ" ); +const QString TIMESTAMP_FORMAT_NO_MS = QStringLiteral( "yyyy-MM-ddTHH:mm:ssZ" ); const QString DATE_FORMAT = QStringLiteral( "yyyy-MM-dd" ); FilterController::FilterController( QObject *parent ) @@ -263,17 +264,38 @@ QString FilterController::buildFieldExpression( const FieldFilter &filter ) cons if ( value.typeId() == QMetaType::QDateTime ) if ( isDateFilterDateTime( filter.filterId ) ) { - // check if the DateTime contains milliseconds const QDateTime utcDateTime = value.toDateTime().toUTC(); - const QString dateTimeFormat = utcDateTime.time().msec() != 0 - ? TIMESTAMP_FORMAT - : QStringLiteral( "yyyy-MM-ddTHH:mm:ssZ" ); - expressionCopy.replace( QStringLiteral( "@@value@@" ), QgsExpression::quotedString( utcDateTime.toString( dateTimeFormat ) ) ); + if ( utcDateTime.time().msec() != 0 ) + { + expressionCopy.replace( QStringLiteral( "@@value@@" ), QgsExpression::quotedString( utcDateTime.toString( TIMESTAMP_FORMAT ) ) ); + } + else + { + // if the msec() is 0, it could mean that either there are 0 milliseconds or none at all + // in this case add both filter queries and use 'OR' between them + QStringList dateTimeExpressions; + + // filter expression with milliseconds + QString exprWithMs( expressionCopy ); + exprWithMs.replace( QStringLiteral( "@@value@@" ), QgsExpression::quotedString( utcDateTime.toString( TIMESTAMP_FORMAT ) ) ); + dateTimeExpressions << QStringLiteral( "(%1)" ).arg( exprWithMs ); + + // filter expression without milliseconds + QString exprNoMs( expressionCopy ); + exprNoMs.replace( QStringLiteral( "@@value@@" ), QgsExpression::quotedString( utcDateTime.toString( TIMESTAMP_FORMAT_NO_MS ) ) ); + dateTimeExpressions << QStringLiteral( "(%1)" ).arg( exprNoMs ); + + expressionCopy = dateTimeExpressions.join( QStringLiteral( " OR " ) ); + } } else + { expressionCopy.replace( QStringLiteral( "@@value@@" ), QgsExpression::quotedString( value.toDate().toString( DATE_FORMAT ) ) ); + } else + { expressionCopy.replace( QStringLiteral( "@@value@@" ), QgsExpression::quotedValue( value ) ); + } } break; } @@ -364,21 +386,39 @@ QString FilterController::buildFieldExpression( const FieldFilter &filter ) cons { // QML always returns QDateTime so we can't differentiate based on type if ( value.typeId() == QMetaType::QDateTime ) + { if ( isDateFilterDateTime( filter.filterId ) ) { - // check if the DateTime contains milliseconds const QDateTime utcDateTime = value.toDateTime().toUTC(); - const QString dateTimeFormat = utcDateTime.time().msec() != 0 - ? TIMESTAMP_FORMAT - : QStringLiteral( "yyyy-MM-ddTHH:mm:ssZ" ); - expressionTemplate.replace( QStringLiteral( "@@value@@" ), QgsExpression::quotedString( utcDateTime.toString( dateTimeFormat ) ) ); + if ( utcDateTime.time().msec() != 0 ) + { + expressionTemplate.replace( QStringLiteral( "@@value@@" ), QgsExpression::quotedString( utcDateTime.toString( TIMESTAMP_FORMAT ) ) ); + expressions << QStringLiteral( "(%1)" ).arg( expressionTemplate ); + } + else + { + // if the msec() is 0, it could mean that either there are 0 milliseconds or none at all + // in this case, we create two filter expressions which are already joined by OR for multi-select + QString exprWithMs( expressionTemplate ); + exprWithMs.replace( QStringLiteral( "@@value@@" ), QgsExpression::quotedString( utcDateTime.toString( TIMESTAMP_FORMAT ) ) ); + expressions << QStringLiteral( "(%1)" ).arg( exprWithMs ); + + QString exprNoMs( expressionTemplate ); + exprNoMs.replace( QStringLiteral( "@@value@@" ), QgsExpression::quotedString( utcDateTime.toString( TIMESTAMP_FORMAT_NO_MS ) ) ); + expressions << QStringLiteral( "(%1)" ).arg( exprNoMs ); + } } else + { expressionTemplate.replace( QStringLiteral( "@@value@@" ), QgsExpression::quotedString( value.toDate().toString( DATE_FORMAT ) ) ); + expressions << QStringLiteral( "(%1)" ).arg( expressionTemplate ); + } + } else + { expressionTemplate.replace( QStringLiteral( "@@value@@" ), QgsExpression::quotedValue( value ) ); - - expressions << QStringLiteral( "(%1)" ).arg( expressionTemplate ); + expressions << QStringLiteral( "(%1)" ).arg( expressionTemplate ); + } } } expressionCopy = expressions.join( QStringLiteral( " OR " ) ); From ba37eef8b8c16c9c88c58c4fb7f4b348e403e3a7 Mon Sep 17 00:00:00 2001 From: Gabriel Bolbotina Date: Mon, 15 Jun 2026 18:27:40 +0300 Subject: [PATCH 5/6] Created testfiltercontroller class for filtering unit tests Added helper methods to testUtils Changed single/ multi select filter to support IS NULL --- app/CMakeLists.txt | 2 + app/filter/filtercontroller.cpp | 2 + app/test/inputtests.cpp | 6 + app/test/testfiltercontroller.cpp | 476 ++++++++++++++++++++++++++++++ app/test/testfiltercontroller.h | 53 ++++ app/test/testutils.cpp | 67 ++++- app/test/testutils.h | 18 ++ test/CMakeLists.txt | 1 + 8 files changed, 624 insertions(+), 1 deletion(-) create mode 100644 app/test/testfiltercontroller.cpp create mode 100644 app/test/testfiltercontroller.h diff --git a/app/CMakeLists.txt b/app/CMakeLists.txt index b75d1b8e1..926b3b430 100644 --- a/app/CMakeLists.txt +++ b/app/CMakeLists.txt @@ -228,6 +228,7 @@ if (ENABLE_TESTS) test/testactiveproject.cpp test/testprojectchecksumcache.cpp test/testmultieditmanager.cpp + test/testfiltercontroller.cpp ) set(MM_HDRS @@ -254,6 +255,7 @@ if (ENABLE_TESTS) test/testactiveproject.h test/testprojectchecksumcache.h test/testmultieditmanager.h + test/testfiltercontroller.h ) if (NOT USE_MM_SERVER_API_KEY) diff --git a/app/filter/filtercontroller.cpp b/app/filter/filtercontroller.cpp index a83c56bfc..6bacf54b6 100644 --- a/app/filter/filtercontroller.cpp +++ b/app/filter/filtercontroller.cpp @@ -250,6 +250,7 @@ QString FilterController::buildFieldExpression( const FieldFilter &filter ) cons QStringList expressions; QString expressionTemplate( expressionCopy ); expressionTemplate.replace( QStringLiteral( "@@value@@" ), QStringLiteral( "NULL" ) ); + expressionTemplate.replace( QStringLiteral( "= NULL" ), QStringLiteral( "IS NULL" ) ); expressions << QStringLiteral( "(%1)" ).arg( expressionTemplate ); expressionTemplate = QString( expressionCopy ); expressionTemplate.replace( QStringLiteral( "@@value@@" ), QStringLiteral( "''" ) ); @@ -377,6 +378,7 @@ QString FilterController::buildFieldExpression( const FieldFilter &filter ) cons if ( QgsVariantUtils::isNull( value ) ) { expressionTemplate.replace( QStringLiteral( "@@value@@" ), QStringLiteral( "NULL" ) ); + expressionTemplate.replace( QStringLiteral( "= NULL" ), QStringLiteral( "IS NULL" ) ); expressions << QStringLiteral( "(%1)" ).arg( expressionTemplate ); expressionTemplate = QString( expressionCopy ); expressionTemplate.replace( QStringLiteral( "@@value@@" ), QStringLiteral( "''" ) ); diff --git a/app/test/inputtests.cpp b/app/test/inputtests.cpp index 282f9c99c..5fe25939e 100644 --- a/app/test/inputtests.cpp +++ b/app/test/inputtests.cpp @@ -30,6 +30,7 @@ #include "testactiveproject.h" #include "testprojectchecksumcache.h" #include "testmultieditmanager.h" +#include "testfiltercontroller.h" InputTests::InputTests() = default; @@ -205,6 +206,11 @@ int InputTests::runTest() const TestMultiEditManager multiEditManagerTest; nFailed = QTest::qExec( &multiEditManagerTest, mTestArgs ); } + else if ( mTestRequested == "--testFilterController" ) + { + TestFilterController filterControllerTest; + nFailed = QTest::qExec( &filterControllerTest, mTestArgs ); + } else { qDebug() << "invalid test requested" << mTestRequested; diff --git a/app/test/testfiltercontroller.cpp b/app/test/testfiltercontroller.cpp new file mode 100644 index 000000000..09f45ddef --- /dev/null +++ b/app/test/testfiltercontroller.cpp @@ -0,0 +1,476 @@ +/*************************************************************************** + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + ***************************************************************************/ + +#include "testfiltercontroller.h" +#include "testutils.h" +#include "filtercontroller.h" + +#include +#include + +#include +#include + +// Field name and SQL templates reused across all tests +static const QString FIELD_NAME = QStringLiteral( "ts_field" ); + +// Template for single/multi select: @@value@@ is replaced by a quoted value +static const QString SELECT_SQL = QStringLiteral( "\"ts_field\" = @@value@@" ); + +// Template for date range: @@value_from@@ and @@value_to@@ are replaced by quoted date strings +static const QString RANGE_SQL = QStringLiteral( "\"ts_field\" >= '@@value_from@@' AND \"ts_field\" <= '@@value_to@@'" ); + +void TestFilterController::init() +{ + QgsProject::instance()->clear(); + mController = std::make_unique(); +} + +void TestFilterController::cleanup() +{ + mController.reset(); +} + +// Date range +void TestFilterController::testDateRange_dateTime() +{ + QgsVectorLayer *layer = TestUtils::createFilterTestLayer( FIELD_NAME, QStringLiteral( "datetime" ) ); + QVERIFY( layer ); + + QVERIFY( TestUtils::addFeatureToLayer( layer, FIELD_NAME, QDateTime( QDate( 2024, 3, 14 ), QTime( 0, 0, 0 ), Qt::UTC ) ) ); // before range + QVERIFY( TestUtils::addFeatureToLayer( layer, FIELD_NAME, QDateTime( QDate( 2024, 3, 15 ), QTime( 12, 0, 0 ), Qt::UTC ) ) ); // inside range + QVERIFY( TestUtils::addFeatureToLayer( layer, FIELD_NAME, QDateTime( QDate( 2024, 3, 17 ), QTime( 0, 0, 0 ), Qt::UTC ) ) ); // after range + + const QString filterId = TestUtils::setupControllerWithFilter( + mController.get(), FieldFilter::DateFilter, layer->id(), FIELD_NAME, RANGE_SQL ); + QVERIFY( !filterId.isEmpty() ); + + const QDateTime from = QDateTime( QDate( 2024, 3, 15 ), QTime( 10, 0, 0, 0 ), Qt::UTC ); + const QDateTime to = QDateTime( QDate( 2024, 3, 16 ), QTime( 18, 30, 0, 0 ), Qt::UTC ); + + QVariantMap filterValues; + filterValues[filterId] = QVariantList{ from, to }; + mController->processFilters( filterValues ); + + // 'to' seconds are capped to 59 and ms to 999, hour and minute are preserved + const QString expected = QStringLiteral( + "(\"ts_field\" >= '2024-03-15T10:00:00.000Z' AND \"ts_field\" <= '2024-03-16T18:30:59.999Z')" ); + QCOMPARE( layer->subsetString(), expected ); + QCOMPARE( layer->featureCount(), ( long long ) 1 ); +} + +void TestFilterController::testDateRange_date() +{ + QgsVectorLayer *layer = TestUtils::createFilterTestLayer( FIELD_NAME, QStringLiteral( "date" ) ); + QVERIFY( layer ); + + QVERIFY( TestUtils::addFeatureToLayer( layer, FIELD_NAME, QDate( 2023, 12, 31 ) ) ); // before range + QVERIFY( TestUtils::addFeatureToLayer( layer, FIELD_NAME, QDate( 2024, 6, 15 ) ) ); // inside range + QVERIFY( TestUtils::addFeatureToLayer( layer, FIELD_NAME, QDate( 2025, 1, 1 ) ) ); // after range + + const QString filterId = TestUtils::setupControllerWithFilter( + mController.get(), FieldFilter::DateFilter, layer->id(), FIELD_NAME, RANGE_SQL ); + QVERIFY( !filterId.isEmpty() ); + + const QDateTime from = QDateTime( QDate( 2024, 1, 1 ), QTime( 0, 0, 0 ), Qt::UTC ); + const QDateTime to = QDateTime( QDate( 2024, 12, 31 ), QTime( 0, 0, 0 ), Qt::UTC ); + + QVariantMap filterValues; + filterValues[filterId] = QVariantList{ from, to }; + mController->processFilters( filterValues ); + + // Date-only fields produce yyyy-MM-dd format + const QString expected = QStringLiteral( + "(\"ts_field\" >= '2024-01-01' AND \"ts_field\" <= '2024-12-31')" ); + QCOMPARE( layer->subsetString(), expected ); + QCOMPARE( layer->featureCount(), ( long long ) 1 ); +} + +void TestFilterController::testDateRange_dateTime_null() +{ + QgsVectorLayer *layer = TestUtils::createFilterTestLayer( FIELD_NAME, QStringLiteral( "datetime" ) ); + QVERIFY( layer ); + + // Sentinel range covers everything, so both features must appear after filtering + QVERIFY( TestUtils::addFeatureToLayer( layer, FIELD_NAME, QDateTime( QDate( 2000, 1, 1 ), QTime( 0, 0, 0 ), Qt::UTC ) ) ); + QVERIFY( TestUtils::addFeatureToLayer( layer, FIELD_NAME, QDateTime( QDate( 2024, 6, 15 ), QTime( 12, 0, 0 ), Qt::UTC ) ) ); + + const QString filterId = TestUtils::setupControllerWithFilter( + mController.get(), FieldFilter::DateFilter, layer->id(), FIELD_NAME, RANGE_SQL ); + QVERIFY( !filterId.isEmpty() ); + + // Both bounds invalid — should fall back to the sentinel strings + QVariantMap filterValues; + filterValues[filterId] = QVariantList{ QVariant(), QVariant() }; + mController->processFilters( filterValues ); + + const QString expected = QStringLiteral( + "(\"ts_field\" >= '0001-01-01T00:00:00.000Z' AND \"ts_field\" <= '9999-12-31T23:59:59.999Z')" ); + QCOMPARE( layer->subsetString(), expected ); + QCOMPARE( layer->featureCount(), ( long long ) 2 ); +} + +void TestFilterController::testDateRange_date_null() +{ + QgsVectorLayer *layer = TestUtils::createFilterTestLayer( FIELD_NAME, QStringLiteral( "date" ) ); + QVERIFY( layer ); + + // Sentinel range covers everything, so both features must appear after filtering + QVERIFY( TestUtils::addFeatureToLayer( layer, FIELD_NAME, QDate( 2000, 1, 1 ) ) ); + QVERIFY( TestUtils::addFeatureToLayer( layer, FIELD_NAME, QDate( 2024, 6, 15 ) ) ); + + const QString filterId = TestUtils::setupControllerWithFilter( + mController.get(), FieldFilter::DateFilter, layer->id(), FIELD_NAME, RANGE_SQL ); + QVERIFY( !filterId.isEmpty() ); + + // Both bounds invalid — should fall back to the sentinel strings + QVariantMap filterValues; + filterValues[filterId] = QVariantList{ QVariant(), QVariant() }; + mController->processFilters( filterValues ); + + const QString expected = QStringLiteral( + "(\"ts_field\" >= '0001-01-01' AND \"ts_field\" <= '9999-12-31')" ); + QCOMPARE( layer->subsetString(), expected ); + QCOMPARE( layer->featureCount(), ( long long ) 2 ); +} + +void TestFilterController::testDateRange_dateTime_featureAtLowerBound() +{ + QgsVectorLayer *layer = TestUtils::createFilterTestLayer( FIELD_NAME, QStringLiteral( "datetime" ) ); + QVERIFY( layer ); + + // Feature exactly at the lower bound — >= is inclusive, so it must be counted. + // Feature just before the bound must be excluded. + QVERIFY( TestUtils::addFeatureToLayer( layer, FIELD_NAME, QDateTime( QDate( 2024, 3, 15 ), QTime( 10, 0, 0, 0 ), Qt::UTC ) ) ); // at lower bound + QVERIFY( TestUtils::addFeatureToLayer( layer, FIELD_NAME, QDateTime( QDate( 2024, 3, 15 ), QTime( 9, 59, 59, 999 ), Qt::UTC ) ) ); // just before + + const QString filterId = TestUtils::setupControllerWithFilter( + mController.get(), FieldFilter::DateFilter, layer->id(), FIELD_NAME, RANGE_SQL ); + QVERIFY( !filterId.isEmpty() ); + + const QDateTime from = QDateTime( QDate( 2024, 3, 15 ), QTime( 10, 0, 0, 0 ), Qt::UTC ); + const QDateTime to = QDateTime( QDate( 2024, 3, 15 ), QTime( 18, 0, 0, 0 ), Qt::UTC ); + + QVariantMap filterValues; + filterValues[filterId] = QVariantList{ from, to }; + mController->processFilters( filterValues ); + + const QString expected = QStringLiteral( + "(\"ts_field\" >= '2024-03-15T10:00:00.000Z' AND \"ts_field\" <= '2024-03-15T18:00:59.999Z')" ); + QCOMPARE( layer->subsetString(), expected ); + QCOMPARE( layer->featureCount(), ( long long ) 1 ); +} + +void TestFilterController::testDateRange_dateTime_midnightLowerBound() +{ + QgsVectorLayer *layer = TestUtils::createFilterTestLayer( FIELD_NAME, QStringLiteral( "datetime" ) ); + QVERIFY( layer ); + + // When the user picks a date without a time the picker passes midnight (00:00:00.000). + // A feature stored at midnight on that date must be included — it is at the + // start of the day, not before it. + QVERIFY( TestUtils::addFeatureToLayer( layer, FIELD_NAME, QDateTime( QDate( 2024, 3, 15 ), QTime( 0, 0, 0, 0 ), Qt::UTC ) ) ); // at midnight lower bound + QVERIFY( TestUtils::addFeatureToLayer( layer, FIELD_NAME, QDateTime( QDate( 2024, 3, 14 ), QTime( 23, 59, 59, 999 ), Qt::UTC ) ) ); // previous day, excluded + + const QString filterId = TestUtils::setupControllerWithFilter( + mController.get(), FieldFilter::DateFilter, layer->id(), FIELD_NAME, RANGE_SQL ); + QVERIFY( !filterId.isEmpty() ); + + const QDateTime from = QDateTime( QDate( 2024, 3, 15 ), QTime( 0, 0, 0, 0 ), Qt::UTC ); + const QDateTime to = QDateTime( QDate( 2024, 3, 15 ), QTime( 23, 0, 0, 0 ), Qt::UTC ); + + QVariantMap filterValues; + filterValues[filterId] = QVariantList{ from, to }; + mController->processFilters( filterValues ); + + const QString expected = QStringLiteral( + "(\"ts_field\" >= '2024-03-15T00:00:00.000Z' AND \"ts_field\" <= '2024-03-15T23:00:59.999Z')" ); + QCOMPARE( layer->subsetString(), expected ); + QCOMPARE( layer->featureCount(), ( long long ) 1 ); +} + +void TestFilterController::testDateRange_dateTime_zeroMsInsideRange() +{ + QgsVectorLayer *layer = TestUtils::createFilterTestLayer( FIELD_NAME, QStringLiteral( "datetime" ) ); + QVERIFY( layer ); + + // A feature with ms=0 is ambiguous for single/multi select (requires double OR'd expression), + // but range filters use >= / <=. QGIS does datetime-aware comparison, so the feature is + // counted regardless of whether it was stored as '.000Z' or without the ms suffix. + QVERIFY( TestUtils::addFeatureToLayer( layer, FIELD_NAME, QDateTime( QDate( 2024, 3, 15 ), QTime( 12, 0, 0, 0 ), Qt::UTC ) ) ); // inside range, 0 ms + QVERIFY( TestUtils::addFeatureToLayer( layer, FIELD_NAME, QDateTime( QDate( 2024, 3, 15 ), QTime( 9, 0, 0, 0 ), Qt::UTC ) ) ); // before range, excluded + + const QString filterId = TestUtils::setupControllerWithFilter( + mController.get(), FieldFilter::DateFilter, layer->id(), FIELD_NAME, RANGE_SQL ); + QVERIFY( !filterId.isEmpty() ); + + const QDateTime from = QDateTime( QDate( 2024, 3, 15 ), QTime( 10, 0, 0, 0 ), Qt::UTC ); + const QDateTime to = QDateTime( QDate( 2024, 3, 15 ), QTime( 18, 0, 0, 0 ), Qt::UTC ); + + QVariantMap filterValues; + filterValues[filterId] = QVariantList{ from, to }; + mController->processFilters( filterValues ); + + // Single expression — no double-expr trick needed for range filters + const QString expected = QStringLiteral( + "(\"ts_field\" >= '2024-03-15T10:00:00.000Z' AND \"ts_field\" <= '2024-03-15T18:00:59.999Z')" ); + QCOMPARE( layer->subsetString(), expected ); + QCOMPARE( layer->featureCount(), ( long long ) 1 ); +} + +// Single select +void TestFilterController::testSingleSelect_dateTime_nonZeroMs() +{ + QgsVectorLayer *layer = TestUtils::createFilterTestLayer( FIELD_NAME, QStringLiteral( "datetime" ) ); + QVERIFY( layer ); + + QVERIFY( TestUtils::addFeatureToLayer( layer, FIELD_NAME, QDateTime( QDate( 2024, 3, 15 ), QTime( 10, 30, 45, 123 ), Qt::UTC ) ) ); // matches + QVERIFY( TestUtils::addFeatureToLayer( layer, FIELD_NAME, QDateTime( QDate( 2024, 1, 1 ), QTime( 0, 0, 0, 0 ), Qt::UTC ) ) ); // no match + + const QString filterId = TestUtils::setupControllerWithFilter( + mController.get(), FieldFilter::SingleSelectFilter, layer->id(), FIELD_NAME, SELECT_SQL ); + QVERIFY( !filterId.isEmpty() ); + + const QDateTime dt = QDateTime( QDate( 2024, 3, 15 ), QTime( 10, 30, 45, 123 ), Qt::UTC ); + + QVariantMap filterValues; + filterValues[filterId] = QVariantList{ dt }; + mController->processFilters( filterValues ); + + // Non-zero ms → single expression with full timestamp format + const QString expected = QStringLiteral( "(\"ts_field\" = '2024-03-15T10:30:45.123Z')" ); + QCOMPARE( layer->subsetString(), expected ); + QCOMPARE( layer->featureCount(), ( long long ) 1 ); +} + +void TestFilterController::testSingleSelect_dateTime_zeroMs() +{ + QgsVectorLayer *layer = TestUtils::createFilterTestLayer( FIELD_NAME, QStringLiteral( "datetime" ) ); + QVERIFY( layer ); + + // Exactly 0 ms: ambiguous between "stored as .000Z" and "stored without ms" in the data. + // The filter must match both representations. + QVERIFY( TestUtils::addFeatureToLayer( layer, FIELD_NAME, QDateTime( QDate( 2024, 3, 15 ), QTime( 10, 30, 45, 0 ), Qt::UTC ) ) ); // matches (0ms) + QVERIFY( TestUtils::addFeatureToLayer( layer, FIELD_NAME, QDateTime( QDate( 2024, 1, 1 ), QTime( 0, 0, 0, 0 ), Qt::UTC ) ) ); // no match + + const QString filterId = TestUtils::setupControllerWithFilter( + mController.get(), FieldFilter::SingleSelectFilter, layer->id(), FIELD_NAME, SELECT_SQL ); + QVERIFY( !filterId.isEmpty() ); + + const QDateTime dt = QDateTime( QDate( 2024, 3, 15 ), QTime( 10, 30, 45, 0 ), Qt::UTC ); + + QVariantMap filterValues; + filterValues[filterId] = QVariantList{ dt }; + mController->processFilters( filterValues ); + + // Zero ms → two OR'd expressions: one with .000Z and one without ms suffix + const QString expected = QStringLiteral( + "((\"ts_field\" = '2024-03-15T10:30:45.000Z') OR (\"ts_field\" = '2024-03-15T10:30:45Z'))" ); + QCOMPARE( layer->subsetString(), expected ); + QCOMPARE( layer->featureCount(), ( long long ) 1 ); +} + +void TestFilterController::testSingleSelect_dateTime_null() +{ + QgsVectorLayer *layer = TestUtils::createFilterTestLayer( FIELD_NAME, QStringLiteral( "datetime" ) ); + QVERIFY( layer ); + + // Null value → two OR'd expressions matching both NULL and empty string + QVERIFY( TestUtils::addFeatureToLayer( layer, FIELD_NAME, QVariant() ) ); // matches (null) + QVERIFY( TestUtils::addFeatureToLayer( layer, FIELD_NAME, QDateTime( QDate( 2024, 1, 1 ), QTime( 0, 0, 0 ), Qt::UTC ) ) ); // no match + + const QString filterId = TestUtils::setupControllerWithFilter( + mController.get(), FieldFilter::SingleSelectFilter, layer->id(), FIELD_NAME, SELECT_SQL ); + QVERIFY( !filterId.isEmpty() ); + + QVariantMap filterValues; + filterValues[filterId] = QVariantList{ QVariant() }; + mController->processFilters( filterValues ); + + const QString expected = QStringLiteral( + "((\"ts_field\" IS NULL) OR (\"ts_field\" = ''))" ); + QCOMPARE( layer->subsetString(), expected ); + QCOMPARE( layer->featureCount(), ( long long ) 1 ); +} + +void TestFilterController::testSingleSelect_date() +{ + // QML always passes QDateTime even for date-only fields; the field type drives formatting + QgsVectorLayer *layer = TestUtils::createFilterTestLayer( FIELD_NAME, QStringLiteral( "date" ) ); + QVERIFY( layer ); + + QVERIFY( TestUtils::addFeatureToLayer( layer, FIELD_NAME, QDate( 2024, 6, 10 ) ) ); // matches + QVERIFY( TestUtils::addFeatureToLayer( layer, FIELD_NAME, QDate( 2024, 6, 11 ) ) ); // no match + + const QString filterId = TestUtils::setupControllerWithFilter( + mController.get(), FieldFilter::SingleSelectFilter, layer->id(), FIELD_NAME, SELECT_SQL ); + QVERIFY( !filterId.isEmpty() ); + + const QDateTime dt = QDateTime( QDate( 2024, 6, 10 ), QTime( 0, 0, 0 ), Qt::UTC ); + + QVariantMap filterValues; + filterValues[filterId] = QVariantList{ dt }; + mController->processFilters( filterValues ); + + // Date field → yyyy-MM-dd format only + const QString expected = QStringLiteral( "(\"ts_field\" = '2024-06-10')" ); + QCOMPARE( layer->subsetString(), expected ); + QCOMPARE( layer->featureCount(), ( long long ) 1 ); +} + +// Multi select +void TestFilterController::testMultiSelect_dateTime_nonZeroMs() +{ + QgsVectorLayer *layer = TestUtils::createFilterTestLayer( FIELD_NAME, QStringLiteral( "datetime" ) ); + QVERIFY( layer ); + + const QDateTime dt1 = QDateTime( QDate( 2024, 3, 15 ), QTime( 10, 30, 45, 100 ), Qt::UTC ); + const QDateTime dt2 = QDateTime( QDate( 2024, 3, 16 ), QTime( 11, 0, 0, 500 ), Qt::UTC ); + QVERIFY( TestUtils::addFeatureToLayer( layer, FIELD_NAME, dt1 ) ); // matches dt1 + QVERIFY( TestUtils::addFeatureToLayer( layer, FIELD_NAME, dt2 ) ); // matches dt2 + QVERIFY( TestUtils::addFeatureToLayer( layer, FIELD_NAME, QDateTime( QDate( 2024, 1, 1 ), QTime( 0, 0, 0, 0 ), Qt::UTC ) ) ); // no match + + const QString filterId = TestUtils::setupControllerWithFilter( + mController.get(), FieldFilter::MultiSelectFilter, layer->id(), FIELD_NAME, SELECT_SQL ); + QVERIFY( !filterId.isEmpty() ); + + QVariantMap filterValues; + filterValues[filterId] = QVariantList{ dt1, dt2 }; + mController->processFilters( filterValues ); + + // Both values have non-zero ms → one expression per value, joined by OR + const QString expected = QStringLiteral( + "((\"ts_field\" = '2024-03-15T10:30:45.100Z') OR (\"ts_field\" = '2024-03-16T11:00:00.500Z'))" ); + QCOMPARE( layer->subsetString(), expected ); + QCOMPARE( layer->featureCount(), ( long long ) 2 ); +} + +void TestFilterController::testMultiSelect_dateTime_zeroMs() +{ + QgsVectorLayer *layer = TestUtils::createFilterTestLayer( FIELD_NAME, QStringLiteral( "datetime" ) ); + QVERIFY( layer ); + + // Single value with 0 ms → produces two OR'd expressions for ambiguity coverage + const QDateTime dt = QDateTime( QDate( 2024, 5, 1 ), QTime( 8, 0, 0, 0 ), Qt::UTC ); + QVERIFY( TestUtils::addFeatureToLayer( layer, FIELD_NAME, dt ) ); // matches (0ms) + QVERIFY( TestUtils::addFeatureToLayer( layer, FIELD_NAME, QDateTime( QDate( 2024, 1, 1 ), QTime( 0, 0, 0, 0 ), Qt::UTC ) ) ); // no match + + const QString filterId = TestUtils::setupControllerWithFilter( + mController.get(), FieldFilter::MultiSelectFilter, layer->id(), FIELD_NAME, SELECT_SQL ); + QVERIFY( !filterId.isEmpty() ); + + QVariantMap filterValues; + filterValues[filterId] = QVariantList{ dt }; + mController->processFilters( filterValues ); + + const QString expected = QStringLiteral( + "((\"ts_field\" = '2024-05-01T08:00:00.000Z') OR (\"ts_field\" = '2024-05-01T08:00:00Z'))" ); + QCOMPARE( layer->subsetString(), expected ); + QCOMPARE( layer->featureCount(), ( long long ) 1 ); +} + +void TestFilterController::testMultiSelect_dateTime_mixed() +{ + QgsVectorLayer *layer = TestUtils::createFilterTestLayer( FIELD_NAME, QStringLiteral( "datetime" ) ); + QVERIFY( layer ); + + // First value has non-zero ms → one expression. + // Second value has 0 ms → expands to two expressions (with and without ms). + const QDateTime dt1 = QDateTime( QDate( 2024, 1, 10 ), QTime( 9, 15, 30, 250 ), Qt::UTC ); + const QDateTime dt2 = QDateTime( QDate( 2024, 2, 20 ), QTime( 14, 0, 0, 0 ), Qt::UTC ); + QVERIFY( TestUtils::addFeatureToLayer( layer, FIELD_NAME, dt1 ) ); // matches dt1 + QVERIFY( TestUtils::addFeatureToLayer( layer, FIELD_NAME, dt2 ) ); // matches dt2 (0ms) + QVERIFY( TestUtils::addFeatureToLayer( layer, FIELD_NAME, QDateTime( QDate( 2024, 6, 1 ), QTime( 0, 0, 0, 0 ), Qt::UTC ) ) ); // no match + + const QString filterId = TestUtils::setupControllerWithFilter( + mController.get(), FieldFilter::MultiSelectFilter, layer->id(), FIELD_NAME, SELECT_SQL ); + QVERIFY( !filterId.isEmpty() ); + + QVariantMap filterValues; + filterValues[filterId] = QVariantList{ dt1, dt2 }; + mController->processFilters( filterValues ); + + const QString expected = QStringLiteral( + "((\"ts_field\" = '2024-01-10T09:15:30.250Z')" + " OR (\"ts_field\" = '2024-02-20T14:00:00.000Z')" + " OR (\"ts_field\" = '2024-02-20T14:00:00Z'))" ); + QCOMPARE( layer->subsetString(), expected ); + QCOMPARE( layer->featureCount(), ( long long ) 2 ); +} + +void TestFilterController::testMultiSelect_dateTime_null() +{ + QgsVectorLayer *layer = TestUtils::createFilterTestLayer( FIELD_NAME, QStringLiteral( "datetime" ) ); + QVERIFY( layer ); + + // Null value → two OR'd expressions matching both NULL and empty string + QVERIFY( TestUtils::addFeatureToLayer( layer, FIELD_NAME, QVariant() ) ); // matches (null) + QVERIFY( TestUtils::addFeatureToLayer( layer, FIELD_NAME, QDateTime( QDate( 2024, 1, 1 ), QTime( 0, 0, 0 ), Qt::UTC ) ) ); // no match + + const QString filterId = TestUtils::setupControllerWithFilter( + mController.get(), FieldFilter::MultiSelectFilter, layer->id(), FIELD_NAME, SELECT_SQL ); + QVERIFY( !filterId.isEmpty() ); + + QVariantMap filterValues; + filterValues[filterId] = QVariantList{ QVariant() }; + mController->processFilters( filterValues ); + + const QString expected = QStringLiteral( + "((\"ts_field\" IS NULL) OR (\"ts_field\" = ''))" ); + QCOMPARE( layer->subsetString(), expected ); + QCOMPARE( layer->featureCount(), ( long long ) 1 ); +} + +void TestFilterController::testMultiSelect_dateTime_empty() +{ + QgsVectorLayer *layer = TestUtils::createFilterTestLayer( FIELD_NAME, QStringLiteral( "datetime" ) ); + QVERIFY( layer ); + + // Empty values list → short-circuits to empty expression → no subset string applied → all features visible + QVERIFY( TestUtils::addFeatureToLayer( layer, FIELD_NAME, QDateTime( QDate( 2024, 1, 1 ), QTime( 0, 0, 0 ), Qt::UTC ) ) ); + QVERIFY( TestUtils::addFeatureToLayer( layer, FIELD_NAME, QDateTime( QDate( 2024, 6, 1 ), QTime( 0, 0, 0 ), Qt::UTC ) ) ); + + const QString filterId = TestUtils::setupControllerWithFilter( + mController.get(), FieldFilter::MultiSelectFilter, layer->id(), FIELD_NAME, SELECT_SQL ); + QVERIFY( !filterId.isEmpty() ); + + QVariantMap filterValues; + filterValues[filterId] = QVariantList{}; + mController->processFilters( filterValues ); + + QVERIFY( layer->subsetString().isEmpty() ); + QCOMPARE( layer->featureCount(), ( long long ) 2 ); +} + +void TestFilterController::testMultiSelect_date() +{ + // Date-only field: all QDateTime values are formatted as yyyy-MM-dd + QgsVectorLayer *layer = TestUtils::createFilterTestLayer( FIELD_NAME, QStringLiteral( "date" ) ); + QVERIFY( layer ); + + QVERIFY( TestUtils::addFeatureToLayer( layer, FIELD_NAME, QDate( 2024, 6, 1 ) ) ); // matches dt1 + QVERIFY( TestUtils::addFeatureToLayer( layer, FIELD_NAME, QDate( 2024, 6, 15 ) ) ); // matches dt2 + QVERIFY( TestUtils::addFeatureToLayer( layer, FIELD_NAME, QDate( 2024, 7, 1 ) ) ); // no match + + const QString filterId = TestUtils::setupControllerWithFilter( + mController.get(), FieldFilter::MultiSelectFilter, layer->id(), FIELD_NAME, SELECT_SQL ); + QVERIFY( !filterId.isEmpty() ); + + const QDateTime dt1 = QDateTime( QDate( 2024, 6, 1 ), QTime( 0, 0, 0 ), Qt::UTC ); + const QDateTime dt2 = QDateTime( QDate( 2024, 6, 15 ), QTime( 0, 0, 0 ), Qt::UTC ); + + QVariantMap filterValues; + filterValues[filterId] = QVariantList{ dt1, dt2 }; + mController->processFilters( filterValues ); + + const QString expected = QStringLiteral( + "((\"ts_field\" = '2024-06-01') OR (\"ts_field\" = '2024-06-15'))" ); + QCOMPARE( layer->subsetString(), expected ); + QCOMPARE( layer->featureCount(), ( long long ) 2 ); +} diff --git a/app/test/testfiltercontroller.h b/app/test/testfiltercontroller.h new file mode 100644 index 000000000..008327b47 --- /dev/null +++ b/app/test/testfiltercontroller.h @@ -0,0 +1,53 @@ +/*************************************************************************** + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + ***************************************************************************/ + +#ifndef TESTFILTERCONTROLLER_H +#define TESTFILTERCONTROLLER_H + +#include +#include + +#include "filtercontroller.h" + +class TestFilterController : public QObject +{ + Q_OBJECT + + private slots: + void init(); // called before each test function + void cleanup(); // called after each test function + + // Date range + void testDateRange_dateTime(); + void testDateRange_date(); + void testDateRange_dateTime_null(); // invalid bounds fall back to min/max sentinels + void testDateRange_date_null(); // invalid bounds fall back to min/max sentinels + void testDateRange_dateTime_featureAtLowerBound(); // >= is inclusive: feature exactly at from-bound is counted + void testDateRange_dateTime_midnightLowerBound(); // from=midnight; midnight feature is at the inclusive lower bound + void testDateRange_dateTime_zeroMsInsideRange(); // 0 ms feature inside range: range uses >= / <=, no double-expr needed + + // Single select + void testSingleSelect_dateTime_nonZeroMs(); + void testSingleSelect_dateTime_zeroMs(); // edge case: 0 ms + void testSingleSelect_dateTime_null(); // null -> NULL OR '' + void testSingleSelect_date(); + + // Multi select + void testMultiSelect_dateTime_nonZeroMs(); + void testMultiSelect_dateTime_zeroMs(); // edge case: 0 ms + void testMultiSelect_dateTime_mixed(); // mix of 0 ms and non-zero ms values + void testMultiSelect_dateTime_null(); // null -> NULL OR '' + void testMultiSelect_dateTime_empty(); // empty list -> no subset string + void testMultiSelect_date(); + + private: + std::unique_ptr mController; +}; + +#endif // TESTFILTERCONTROLLER_H diff --git a/app/test/testutils.cpp b/app/test/testutils.cpp index 3c65c7ccb..fb0a1e7d5 100644 --- a/app/test/testutils.cpp +++ b/app/test/testutils.cpp @@ -8,8 +8,9 @@ ***************************************************************************/ #include "QtDebug" -#include #include +#include +#include #include #include @@ -379,6 +380,70 @@ void TestUtils::testIsValidUrl() QVERIFY( !InputUtils::isValidUrl( "" ) ); // empty url is considered valid by QUrl but not by us } +QgsVectorLayer *TestUtils::createFilterTestLayer( const QString &fieldName, const QString &fieldType, const QString &layerName ) +{ + const QString layerUri = QStringLiteral( "None?field=%1:%2" ).arg( fieldName, fieldType ); + + auto *layer = new QgsVectorLayer( layerUri, layerName, QStringLiteral( "memory" ) ); + if ( !layer->isValid() ) + { + delete layer; + return nullptr; + } + + QgsProject::instance()->addMapLayer( layer ); + return layer; +} + +bool TestUtils::addFeatureToLayer( QgsVectorLayer *layer, const QString &fieldName, const QVariant &value ) +{ + QgsFeature f( layer->fields() ); + f.setAttribute( fieldName, value ); + return layer->dataProvider()->addFeatures( QgsFeatureList() << f ); +} + +QString TestUtils::setupControllerWithFilter( FilterController *controller, + FieldFilter::FilterType filterType, + const QString &layerId, + const QString &fieldName, + const QString &sqlExpression ) +{ + QString filterTypeStr; + switch ( filterType ) + { + case FieldFilter::TextFilter: filterTypeStr = QStringLiteral( "Text" ); break; + case FieldFilter::NumberFilter: filterTypeStr = QStringLiteral( "Number" ); break; + case FieldFilter::DateFilter: filterTypeStr = QStringLiteral( "Date" ); break; + case FieldFilter::CheckboxFilter: filterTypeStr = QStringLiteral( "Checkbox" ); break; + case FieldFilter::SingleSelectFilter: filterTypeStr = QStringLiteral( "Single select" ); break; + case FieldFilter::MultiSelectFilter: filterTypeStr = QStringLiteral( "Multi select" ); break; + } + + QJsonObject filterObj; + filterObj[QStringLiteral( "filter_name" )] = QStringLiteral( "Test Filter" ); + filterObj[QStringLiteral( "filter_type" )] = filterTypeStr; + filterObj[QStringLiteral( "field_name" )] = fieldName; + filterObj[QStringLiteral( "sql_expression" )] = sqlExpression; + filterObj[QStringLiteral( "layer_id" )] = layerId; + filterObj[QStringLiteral( "provider" )] = QStringLiteral( "memory" ); + + QJsonArray filtersArray; + filtersArray.append( filterObj ); + + const QString filtersJson = QString::fromUtf8( + QJsonDocument( filtersArray ).toJson( QJsonDocument::Compact ) ); + + QgsProject::instance()->writeEntry( QStringLiteral( "Mergin" ), QStringLiteral( "Filtering/Enabled" ), true ); + QgsProject::instance()->writeEntry( QStringLiteral( "Mergin" ), QStringLiteral( "Filtering/Filters" ), filtersJson ); + + controller->loadFilterConfig( QgsProject::instance() ); + + const QVariantList filters = controller->getFilters(); + if ( filters.isEmpty() ) + return {}; + return filters.first().toMap().value( QStringLiteral( "filterId" ) ).toString(); +} + bool TestUtils::testExifPositionMetadataExists( const QString &imageSource ) { if ( !QFileInfo::exists( imageSource ) ) diff --git a/app/test/testutils.h b/app/test/testutils.h index 8643248fa..62526adb3 100644 --- a/app/test/testutils.h +++ b/app/test/testutils.h @@ -14,8 +14,10 @@ #include #include "qgsproject.h" +#include "filtercontroller.h" class MerginApi; +class QgsVectorLayer; namespace TestUtils { @@ -65,6 +67,22 @@ namespace TestUtils void testIsValidUrl(); bool testExifPositionMetadataExists( const QString &imageSource ); + + //! Creates an in-memory layer with a single field of the given type and registers it in QgsProject::instance() + //! fieldType is the QGIS memory-provider type string: "datetime", "date", "string", "integer", "double", "bool", etc. + QgsVectorLayer *createFilterTestLayer( const QString &fieldName, + const QString &fieldType, + const QString &layerName = QStringLiteral( "FilterTestLayer" ) ); + + //! Appends a single feature to layer via the data provider; value is stored in the named field. Returns false if addFeatures() fails. + bool addFeatureToLayer( QgsVectorLayer *layer, const QString &fieldName, const QVariant &value ); + + //! Writes a single-filter config into the project and loads it; returns the assigned filterId + QString setupControllerWithFilter( FilterController *controller, + FieldFilter::FilterType filterType, + const QString &layerId, + const QString &fieldName, + const QString &sqlExpression ); } #define COMPARENEAR(actual, expected, epsilon) \ diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 8e6cb6461..19c5cdbf2 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -27,6 +27,7 @@ set(MM_TESTS testVariablesManager testSketching testMultiEditManager + testFilterController ) foreach (test ${MM_TESTS}) From 9b29140cb237039016185fccf3cb664f9392f1f2 Mon Sep 17 00:00:00 2001 From: Gabriel Bolbotina Date: Wed, 17 Jun 2026 19:56:18 +0300 Subject: [PATCH 6/6] Changed the naming convention of test cases Removed the IS NULL change in filter controller Modified the null test cases for filter controller unit tests --- app/filter/filtercontroller.cpp | 2 -- app/test/testfiltercontroller.cpp | 52 ++++++++++++++++--------------- app/test/testfiltercontroller.h | 34 ++++++++++---------- 3 files changed, 44 insertions(+), 44 deletions(-) diff --git a/app/filter/filtercontroller.cpp b/app/filter/filtercontroller.cpp index 6bacf54b6..a83c56bfc 100644 --- a/app/filter/filtercontroller.cpp +++ b/app/filter/filtercontroller.cpp @@ -250,7 +250,6 @@ QString FilterController::buildFieldExpression( const FieldFilter &filter ) cons QStringList expressions; QString expressionTemplate( expressionCopy ); expressionTemplate.replace( QStringLiteral( "@@value@@" ), QStringLiteral( "NULL" ) ); - expressionTemplate.replace( QStringLiteral( "= NULL" ), QStringLiteral( "IS NULL" ) ); expressions << QStringLiteral( "(%1)" ).arg( expressionTemplate ); expressionTemplate = QString( expressionCopy ); expressionTemplate.replace( QStringLiteral( "@@value@@" ), QStringLiteral( "''" ) ); @@ -378,7 +377,6 @@ QString FilterController::buildFieldExpression( const FieldFilter &filter ) cons if ( QgsVariantUtils::isNull( value ) ) { expressionTemplate.replace( QStringLiteral( "@@value@@" ), QStringLiteral( "NULL" ) ); - expressionTemplate.replace( QStringLiteral( "= NULL" ), QStringLiteral( "IS NULL" ) ); expressions << QStringLiteral( "(%1)" ).arg( expressionTemplate ); expressionTemplate = QString( expressionCopy ); expressionTemplate.replace( QStringLiteral( "@@value@@" ), QStringLiteral( "''" ) ); diff --git a/app/test/testfiltercontroller.cpp b/app/test/testfiltercontroller.cpp index 09f45ddef..283fbadc0 100644 --- a/app/test/testfiltercontroller.cpp +++ b/app/test/testfiltercontroller.cpp @@ -38,7 +38,7 @@ void TestFilterController::cleanup() } // Date range -void TestFilterController::testDateRange_dateTime() +void TestFilterController::testDateRangeDateTime() { QgsVectorLayer *layer = TestUtils::createFilterTestLayer( FIELD_NAME, QStringLiteral( "datetime" ) ); QVERIFY( layer ); @@ -65,7 +65,7 @@ void TestFilterController::testDateRange_dateTime() QCOMPARE( layer->featureCount(), ( long long ) 1 ); } -void TestFilterController::testDateRange_date() +void TestFilterController::testDateRangeDate() { QgsVectorLayer *layer = TestUtils::createFilterTestLayer( FIELD_NAME, QStringLiteral( "date" ) ); QVERIFY( layer ); @@ -92,7 +92,7 @@ void TestFilterController::testDateRange_date() QCOMPARE( layer->featureCount(), ( long long ) 1 ); } -void TestFilterController::testDateRange_dateTime_null() +void TestFilterController::testDateRangeDateTimeNull() { QgsVectorLayer *layer = TestUtils::createFilterTestLayer( FIELD_NAME, QStringLiteral( "datetime" ) ); QVERIFY( layer ); @@ -116,7 +116,7 @@ void TestFilterController::testDateRange_dateTime_null() QCOMPARE( layer->featureCount(), ( long long ) 2 ); } -void TestFilterController::testDateRange_date_null() +void TestFilterController::testDateRangeDateNull() { QgsVectorLayer *layer = TestUtils::createFilterTestLayer( FIELD_NAME, QStringLiteral( "date" ) ); QVERIFY( layer ); @@ -140,7 +140,7 @@ void TestFilterController::testDateRange_date_null() QCOMPARE( layer->featureCount(), ( long long ) 2 ); } -void TestFilterController::testDateRange_dateTime_featureAtLowerBound() +void TestFilterController::testDateRangeDateTimeFeatureAtLowerBound() { QgsVectorLayer *layer = TestUtils::createFilterTestLayer( FIELD_NAME, QStringLiteral( "datetime" ) ); QVERIFY( layer ); @@ -167,7 +167,7 @@ void TestFilterController::testDateRange_dateTime_featureAtLowerBound() QCOMPARE( layer->featureCount(), ( long long ) 1 ); } -void TestFilterController::testDateRange_dateTime_midnightLowerBound() +void TestFilterController::testDateRangeDateTimeMidnightLowerBound() { QgsVectorLayer *layer = TestUtils::createFilterTestLayer( FIELD_NAME, QStringLiteral( "datetime" ) ); QVERIFY( layer ); @@ -195,7 +195,7 @@ void TestFilterController::testDateRange_dateTime_midnightLowerBound() QCOMPARE( layer->featureCount(), ( long long ) 1 ); } -void TestFilterController::testDateRange_dateTime_zeroMsInsideRange() +void TestFilterController::testDateRangeDateTimeZeroMsInsideRange() { QgsVectorLayer *layer = TestUtils::createFilterTestLayer( FIELD_NAME, QStringLiteral( "datetime" ) ); QVERIFY( layer ); @@ -225,7 +225,7 @@ void TestFilterController::testDateRange_dateTime_zeroMsInsideRange() } // Single select -void TestFilterController::testSingleSelect_dateTime_nonZeroMs() +void TestFilterController::testSingleSelectDateTimeNonZeroMs() { QgsVectorLayer *layer = TestUtils::createFilterTestLayer( FIELD_NAME, QStringLiteral( "datetime" ) ); QVERIFY( layer ); @@ -249,7 +249,7 @@ void TestFilterController::testSingleSelect_dateTime_nonZeroMs() QCOMPARE( layer->featureCount(), ( long long ) 1 ); } -void TestFilterController::testSingleSelect_dateTime_zeroMs() +void TestFilterController::testSingleSelectDateTimeZeroMs() { QgsVectorLayer *layer = TestUtils::createFilterTestLayer( FIELD_NAME, QStringLiteral( "datetime" ) ); QVERIFY( layer ); @@ -276,13 +276,14 @@ void TestFilterController::testSingleSelect_dateTime_zeroMs() QCOMPARE( layer->featureCount(), ( long long ) 1 ); } -void TestFilterController::testSingleSelect_dateTime_null() +void TestFilterController::testSingleSelectDateTimeNull() { QgsVectorLayer *layer = TestUtils::createFilterTestLayer( FIELD_NAME, QStringLiteral( "datetime" ) ); QVERIFY( layer ); - // Null value → two OR'd expressions matching both NULL and empty string - QVERIFY( TestUtils::addFeatureToLayer( layer, FIELD_NAME, QVariant() ) ); // matches (null) + // Null value → two OR'd expressions: one with NULL, one with empty string + // Note: "= NULL" is invalid SQL and never matches; proper IS NULL support is a separate feature + QVERIFY( TestUtils::addFeatureToLayer( layer, FIELD_NAME, QVariant() ) ); // not matched (= NULL is invalid SQL) QVERIFY( TestUtils::addFeatureToLayer( layer, FIELD_NAME, QDateTime( QDate( 2024, 1, 1 ), QTime( 0, 0, 0 ), Qt::UTC ) ) ); // no match const QString filterId = TestUtils::setupControllerWithFilter( @@ -294,12 +295,12 @@ void TestFilterController::testSingleSelect_dateTime_null() mController->processFilters( filterValues ); const QString expected = QStringLiteral( - "((\"ts_field\" IS NULL) OR (\"ts_field\" = ''))" ); + "((\"ts_field\" = NULL) OR (\"ts_field\" = ''))" ); QCOMPARE( layer->subsetString(), expected ); - QCOMPARE( layer->featureCount(), ( long long ) 1 ); + QCOMPARE( layer->featureCount(), ( long long ) 0 ); } -void TestFilterController::testSingleSelect_date() +void TestFilterController::testSingleSelectDate() { // QML always passes QDateTime even for date-only fields; the field type drives formatting QgsVectorLayer *layer = TestUtils::createFilterTestLayer( FIELD_NAME, QStringLiteral( "date" ) ); @@ -325,7 +326,7 @@ void TestFilterController::testSingleSelect_date() } // Multi select -void TestFilterController::testMultiSelect_dateTime_nonZeroMs() +void TestFilterController::testMultiSelectDateTimeNonZeroMs() { QgsVectorLayer *layer = TestUtils::createFilterTestLayer( FIELD_NAME, QStringLiteral( "datetime" ) ); QVERIFY( layer ); @@ -351,7 +352,7 @@ void TestFilterController::testMultiSelect_dateTime_nonZeroMs() QCOMPARE( layer->featureCount(), ( long long ) 2 ); } -void TestFilterController::testMultiSelect_dateTime_zeroMs() +void TestFilterController::testMultiSelectDateTimeZeroMs() { QgsVectorLayer *layer = TestUtils::createFilterTestLayer( FIELD_NAME, QStringLiteral( "datetime" ) ); QVERIFY( layer ); @@ -375,7 +376,7 @@ void TestFilterController::testMultiSelect_dateTime_zeroMs() QCOMPARE( layer->featureCount(), ( long long ) 1 ); } -void TestFilterController::testMultiSelect_dateTime_mixed() +void TestFilterController::testMultiSelectDateTimeMixed() { QgsVectorLayer *layer = TestUtils::createFilterTestLayer( FIELD_NAME, QStringLiteral( "datetime" ) ); QVERIFY( layer ); @@ -404,13 +405,14 @@ void TestFilterController::testMultiSelect_dateTime_mixed() QCOMPARE( layer->featureCount(), ( long long ) 2 ); } -void TestFilterController::testMultiSelect_dateTime_null() +void TestFilterController::testMultiSelectDateTimeNull() { QgsVectorLayer *layer = TestUtils::createFilterTestLayer( FIELD_NAME, QStringLiteral( "datetime" ) ); QVERIFY( layer ); - // Null value → two OR'd expressions matching both NULL and empty string - QVERIFY( TestUtils::addFeatureToLayer( layer, FIELD_NAME, QVariant() ) ); // matches (null) + // Null value → two OR'd expressions: one with NULL, one with empty string + // Note: "= NULL" is invalid SQL and never matches; proper IS NULL support is a separate feature + QVERIFY( TestUtils::addFeatureToLayer( layer, FIELD_NAME, QVariant() ) ); // not matched (= NULL is invalid SQL) QVERIFY( TestUtils::addFeatureToLayer( layer, FIELD_NAME, QDateTime( QDate( 2024, 1, 1 ), QTime( 0, 0, 0 ), Qt::UTC ) ) ); // no match const QString filterId = TestUtils::setupControllerWithFilter( @@ -422,12 +424,12 @@ void TestFilterController::testMultiSelect_dateTime_null() mController->processFilters( filterValues ); const QString expected = QStringLiteral( - "((\"ts_field\" IS NULL) OR (\"ts_field\" = ''))" ); + "((\"ts_field\" = NULL) OR (\"ts_field\" = ''))" ); QCOMPARE( layer->subsetString(), expected ); - QCOMPARE( layer->featureCount(), ( long long ) 1 ); + QCOMPARE( layer->featureCount(), ( long long ) 0 ); } -void TestFilterController::testMultiSelect_dateTime_empty() +void TestFilterController::testMultiSelectDateTimeEmpty() { QgsVectorLayer *layer = TestUtils::createFilterTestLayer( FIELD_NAME, QStringLiteral( "datetime" ) ); QVERIFY( layer ); @@ -448,7 +450,7 @@ void TestFilterController::testMultiSelect_dateTime_empty() QCOMPARE( layer->featureCount(), ( long long ) 2 ); } -void TestFilterController::testMultiSelect_date() +void TestFilterController::testMultiSelectDate() { // Date-only field: all QDateTime values are formatted as yyyy-MM-dd QgsVectorLayer *layer = TestUtils::createFilterTestLayer( FIELD_NAME, QStringLiteral( "date" ) ); diff --git a/app/test/testfiltercontroller.h b/app/test/testfiltercontroller.h index 008327b47..5e62590bf 100644 --- a/app/test/testfiltercontroller.h +++ b/app/test/testfiltercontroller.h @@ -24,27 +24,27 @@ class TestFilterController : public QObject void cleanup(); // called after each test function // Date range - void testDateRange_dateTime(); - void testDateRange_date(); - void testDateRange_dateTime_null(); // invalid bounds fall back to min/max sentinels - void testDateRange_date_null(); // invalid bounds fall back to min/max sentinels - void testDateRange_dateTime_featureAtLowerBound(); // >= is inclusive: feature exactly at from-bound is counted - void testDateRange_dateTime_midnightLowerBound(); // from=midnight; midnight feature is at the inclusive lower bound - void testDateRange_dateTime_zeroMsInsideRange(); // 0 ms feature inside range: range uses >= / <=, no double-expr needed + void testDateRangeDateTime(); + void testDateRangeDate(); + void testDateRangeDateTimeNull(); // invalid bounds fall back to min/max sentinels + void testDateRangeDateNull(); // invalid bounds fall back to min/max sentinels + void testDateRangeDateTimeFeatureAtLowerBound(); // >= is inclusive: feature exactly at from-bound is counted + void testDateRangeDateTimeMidnightLowerBound(); // from=midnight; midnight feature is at the inclusive lower bound + void testDateRangeDateTimeZeroMsInsideRange(); // 0 ms feature inside range: range uses >= / <=, no double-expr needed // Single select - void testSingleSelect_dateTime_nonZeroMs(); - void testSingleSelect_dateTime_zeroMs(); // edge case: 0 ms - void testSingleSelect_dateTime_null(); // null -> NULL OR '' - void testSingleSelect_date(); + void testSingleSelectDateTimeNonZeroMs(); + void testSingleSelectDateTimeZeroMs(); // edge case: 0 ms + void testSingleSelectDateTimeNull(); // null -> NULL OR '' + void testSingleSelectDate(); // Multi select - void testMultiSelect_dateTime_nonZeroMs(); - void testMultiSelect_dateTime_zeroMs(); // edge case: 0 ms - void testMultiSelect_dateTime_mixed(); // mix of 0 ms and non-zero ms values - void testMultiSelect_dateTime_null(); // null -> NULL OR '' - void testMultiSelect_dateTime_empty(); // empty list -> no subset string - void testMultiSelect_date(); + void testMultiSelectDateTimeNonZeroMs(); + void testMultiSelectDateTimeZeroMs(); // edge case: 0 ms + void testMultiSelectDateTimeMixed(); // mix of 0 ms and non-zero ms values + void testMultiSelectDateTimeNull(); // null -> NULL OR '' + void testMultiSelectDateTimeEmpty(); // empty list -> no subset string + void testMultiSelectDate(); private: std::unique_ptr mController;