diff --git a/python/PyQt6/core/__init__.py.in b/python/PyQt6/core/__init__.py.in index b57fc3eea27c..f8a6aa9474f4 100644 --- a/python/PyQt6/core/__init__.py.in +++ b/python/PyQt6/core/__init__.py.in @@ -70,9 +70,9 @@ def _date_range_repr(self): QgsDateTimeRange.__repr__ = _datetime_range_repr QgsDateRange.__repr__ = _date_range_repr - QgsProperty.__bool__ = lambda self: self.propertyType() != Qgis.PropertyType.Invalid QgsOptionalExpression.__bool__ = lambda self: self.enabled() +QgsUnsetAttributeValue.__hash__ = lambda self: 2178310 # add some __repr__ methods to processing classes def _processing_source_repr(self): diff --git a/python/core/__init__.py.in b/python/core/__init__.py.in index 3a2be42fff18..50d4f624393a 100644 --- a/python/core/__init__.py.in +++ b/python/core/__init__.py.in @@ -73,7 +73,7 @@ QgsDateRange.__repr__ = _date_range_repr QgsProperty.__bool__ = lambda self: self.propertyType() != Qgis.PropertyType.Invalid QgsOptionalExpression.__bool__ = lambda self: self.enabled() - +QgsUnsetAttributeValue.__hash__ = lambda self: 2178310 # add some __repr__ methods to processing classes def _processing_source_repr(self): diff --git a/src/core/providers/memory/qgsmemoryprovider.cpp b/src/core/providers/memory/qgsmemoryprovider.cpp index d9cff37c84b1..f4814ffee70a 100644 --- a/src/core/providers/memory/qgsmemoryprovider.cpp +++ b/src/core/providers/memory/qgsmemoryprovider.cpp @@ -682,6 +682,9 @@ bool QgsMemoryProvider::changeAttributeValues( const QgsChangedAttributesMap &at continue; QVariant attrValue = it2.value(); + if ( attrValue.userType() == qMetaTypeId< QgsUnsetAttributeValue >() ) + continue; + // Check attribute conversion const bool conversionError { ! QgsVariantUtils::isNull( attrValue ) && ! mFields.at( it2.key() ).convertCompatible( attrValue, &errorMessage ) }; diff --git a/src/core/providers/ogr/qgsogrprovider.cpp b/src/core/providers/ogr/qgsogrprovider.cpp index 5cbbfe351fed..bef28b60b4b8 100644 --- a/src/core/providers/ogr/qgsogrprovider.cpp +++ b/src/core/providers/ogr/qgsogrprovider.cpp @@ -2763,7 +2763,7 @@ bool QgsOgrProvider::changeAttributeValues( const QgsChangedAttributesMap &attr_ { useUpdate = false; } - if ( useUpdate ) + if ( useUpdate && val.userType() != qMetaTypeId() ) { QString sql = QStringLiteral( "UPDATE %1 SET %2 = %3" ) .arg( QString::fromUtf8( QgsOgrProviderUtils::quotedIdentifier( mOgrLayer->name(), mGDALDriverName ) ) ) @@ -2838,6 +2838,9 @@ bool QgsOgrProvider::changeAttributeValues( const QgsChangedAttributesMap &attr_ for ( QgsAttributeMap::const_iterator it2 = attr.begin(); it2 != attr.end(); ++it2 ) { int f = it2.key(); + if ( it2->userType() == qMetaTypeId< QgsUnsetAttributeValue >() ) + continue; + if ( mFirstFieldIsFid ) { if ( f == 0 ) diff --git a/src/core/qgsfield.cpp b/src/core/qgsfield.cpp index 056bd9b4afc3..1a64d9565070 100644 --- a/src/core/qgsfield.cpp +++ b/src/core/qgsfield.cpp @@ -20,6 +20,7 @@ #include "qgsapplication.h" #include "qgsreferencedgeometry.h" #include "qgsvariantutils.h" +#include "qgsunsetattributevalue.h" #include #include @@ -482,6 +483,11 @@ bool QgsField::convertCompatible( QVariant &v, QString *errorMessage ) const return true; } + if ( v.userType() == qMetaTypeId< QgsUnsetAttributeValue >() ) + { + return true; + } + if ( d->type == QMetaType::Type::Int && v.toInt() != v.toLongLong() ) { v = QgsVariantUtils::createNullVariant( d->type ); diff --git a/src/providers/hana/qgshanaprovider.cpp b/src/providers/hana/qgshanaprovider.cpp index aab0cc94c574..ad1bb335aa5b 100644 --- a/src/providers/hana/qgshanaprovider.cpp +++ b/src/providers/hana/qgshanaprovider.cpp @@ -748,6 +748,11 @@ bool QgsHanaProvider::addFeatures( QgsFeatureList &flist, Flags flags ) const int fieldIndex = fieldIds[i]; const AttributeField &field = mAttributeFields.at( fieldIndex ); QVariant attrValue = fieldIndex < attrs.length() ? attrs.at( fieldIndex ) : QgsVariantUtils::createNullVariant( QMetaType::Type::LongLong ); + + // no default value clause handling supported in this provider, best we can do for now is set to NULL + if ( attrValue.userType() == qMetaTypeId< QgsUnsetAttributeValue >() ) + attrValue = QVariant(); + if ( pkFields[i] ) { hasIdValue = hasIdValue || !attrValue.isNull(); @@ -1175,6 +1180,9 @@ bool QgsHanaProvider::changeAttributeValues( const QgsChangedAttributesMap &attr if ( field.name.isEmpty() || field.isAutoIncrement ) continue; + if ( it2->userType() == qMetaTypeId< QgsUnsetAttributeValue >() ) + continue; + pkChanged = pkChanged || mPrimaryKeyAttrs.contains( fieldIndex ); auto qType = mFields.at( fieldIndex ).type(); if ( field.type == QgsHanaDataType::Geometry && qType == QMetaType::Type::QString ) @@ -1202,7 +1210,11 @@ bool QgsHanaProvider::changeAttributeValues( const QgsChangedAttributesMap &attr if ( field.name.isEmpty() || field.isAutoIncrement ) continue; - setStatementValue( stmtUpdate, paramIndex, field, *attrIt ); + const QVariant attrValue = *attrIt; + if ( attrValue.userType() == qMetaTypeId< QgsUnsetAttributeValue >() ) + continue; + + setStatementValue( stmtUpdate, paramIndex, field, attrValue ); ++paramIndex; } diff --git a/src/providers/mssql/qgsmssqlprovider.cpp b/src/providers/mssql/qgsmssqlprovider.cpp index 5396c7bec5a2..19f0417dc646 100644 --- a/src/providers/mssql/qgsmssqlprovider.cpp +++ b/src/providers/mssql/qgsmssqlprovider.cpp @@ -1202,6 +1202,9 @@ bool QgsMssqlProvider::addFeatures( QgsFeatureList &flist, Flags flags ) if ( fld.name().isEmpty() ) continue; // invalid + if ( attrs.at( i ).userType() == qMetaTypeId< QgsUnsetAttributeValue >() ) + continue; + if ( mDefaultValues.contains( i ) && mDefaultValues.value( i ) == attrs.at( i ).toString() ) continue; // skip fields having default values @@ -1300,6 +1303,9 @@ bool QgsMssqlProvider::addFeatures( QgsFeatureList &flist, Flags flags ) if ( fld.name().isEmpty() ) continue; // invalid + if ( attrs.at( i ).userType() == qMetaTypeId< QgsUnsetAttributeValue >() ) + continue; + if ( mDefaultValues.contains( i ) && mDefaultValues.value( i ) == attrs.at( i ).toString() ) continue; // skip fields having default values @@ -1554,6 +1560,9 @@ bool QgsMssqlProvider::changeAttributeValues( const QgsChangedAttributesMap &att if ( fld.name().isEmpty() ) continue; // invalid + if ( it2.value().userType() == qMetaTypeId< QgsUnsetAttributeValue >() ) + continue; + if ( mComputedColumns.contains( fld.name() ) ) continue; // skip computed columns because they are done server side. @@ -1593,6 +1602,9 @@ bool QgsMssqlProvider::changeAttributeValues( const QgsChangedAttributesMap &att if ( fld.name().isEmpty() ) continue; // invalid + if ( it2.value().userType() == qMetaTypeId< QgsUnsetAttributeValue >() ) + continue; + if ( mComputedColumns.contains( fld.name() ) ) continue; // skip computed columns because they are done server side. diff --git a/src/providers/oracle/qgsoracleprovider.cpp b/src/providers/oracle/qgsoracleprovider.cpp index f154bbfc8f00..5fc066723dd1 100644 --- a/src/providers/oracle/qgsoracleprovider.cpp +++ b/src/providers/oracle/qgsoracleprovider.cpp @@ -1394,7 +1394,9 @@ bool QgsOracleProvider::addFeatures( QgsFeatureList &flist, QgsFeatureSink::Flag QVariant value = attributevec.value( fieldId[i], QVariant() ); QgsField fld = field( fieldId[i] ); - if ( ( QgsVariantUtils::isNull( value ) && mPrimaryKeyAttrs.contains( i ) && !defaultValues.at( i ).isEmpty() ) || ( value.toString() == defaultValues[i] ) ) + if ( ( QgsVariantUtils::isNull( value ) && mPrimaryKeyAttrs.contains( i ) && !defaultValues.at( i ).isEmpty() ) + || ( value.toString() == defaultValues[i] ) + || value.userType() == qMetaTypeId< QgsUnsetAttributeValue >() ) { value = evaluateDefaultExpression( defaultValues[i], fld.type() ); } @@ -1860,6 +1862,10 @@ bool QgsOracleProvider::changeAttributeValues( const QgsChangedAttributesMap &at QgsLogger::warning( tr( "Changing the value of GENERATED field %1 is not allowed." ).arg( fld.name() ) ); continue; } + if ( siter.value().userType() == qMetaTypeId< QgsUnsetAttributeValue >() ) + { + continue; + } pkChanged = pkChanged || mPrimaryKeyAttrs.contains( siter.key() ); diff --git a/src/providers/postgres/qgspostgresprovider.cpp b/src/providers/postgres/qgspostgresprovider.cpp index 9971e91cba45..189972c4f02c 100644 --- a/src/providers/postgres/qgspostgresprovider.cpp +++ b/src/providers/postgres/qgspostgresprovider.cpp @@ -2287,7 +2287,7 @@ bool QgsPostgresProvider::addFeatures( QgsFeatureList &flist, Flags flags ) QVariant v2 = attrs2.value( idx, QgsVariantUtils::createNullVariant( QMetaType::Type::Int ) ); // a PK field with a sequence val is auto populate by QGIS with this default // we are only interested in non default values - if ( !QgsVariantUtils::isNull( v2 ) && v2.toString() != defaultValue ) + if ( !QgsVariantUtils::isNull( v2 ) && v2.toString() != defaultValue && v2.userType() != qMetaTypeId< QgsUnsetAttributeValue >() ) { foundNonEmptyPK = true; break; @@ -2298,7 +2298,7 @@ bool QgsPostgresProvider::addFeatures( QgsFeatureList &flist, Flags flags ) if ( !skipSinglePKField ) { - for ( int idx : mPrimaryKeyAttrs ) + for ( int idx : std::as_const( mPrimaryKeyAttrs ) ) { if ( mIdentityFields[idx] == 'a' ) overrideIdentity = true; @@ -2327,7 +2327,6 @@ bool QgsPostgresProvider::addFeatures( QgsFeatureList &flist, Flags flags ) continue; QString fieldname = mAttributeFields.at( idx ).name(); - if ( !mGeneratedValues.value( idx, QString() ).isEmpty() ) { QgsDebugMsgLevel( QStringLiteral( "Skipping field %1 (idx %2) which is GENERATED." ).arg( fieldname, QString::number( idx ) ), 2 ); @@ -2469,7 +2468,7 @@ bool QgsPostgresProvider::addFeatures( QgsFeatureList &flist, Flags flags ) QVariant value = attrIdx < attrs.length() ? attrs.at( attrIdx ) : QgsVariantUtils::createNullVariant( QMetaType::Type::Int ); QString v; - if ( QgsVariantUtils::isNull( value ) ) + if ( QgsVariantUtils::isNull( value ) || value.userType() == qMetaTypeId< QgsUnsetAttributeValue >() ) { QgsField fld = field( attrIdx ); v = paramValue( defaultValues[i], defaultValues[i] ); @@ -2949,6 +2948,10 @@ bool QgsPostgresProvider::changeAttributeValues( const QgsChangedAttributesMap & { try { + const QVariant attributeValue = siter.value(); + if ( attributeValue.userType() == qMetaTypeId< QgsUnsetAttributeValue >() ) + continue; + QgsField fld = field( siter.key() ); pkChanged = pkChanged || mPrimaryKeyAttrs.contains( siter.key() ); @@ -2965,13 +2968,13 @@ bool QgsPostgresProvider::changeAttributeValues( const QgsChangedAttributesMap & delim = ','; QString defVal = defaultValueClause( siter.key() ); - if ( qgsVariantEqual( *siter, defVal ) ) + if ( qgsVariantEqual( attributeValue, defVal ) ) { sql += defVal.isNull() ? "NULL" : defVal; } else if ( fld.typeName() == QLatin1String( "geometry" ) ) { - QString val = geomAttrToString( siter.value(), connectionRO() ); + QString val = geomAttrToString( attributeValue, connectionRO() ); sql += QStringLiteral( "%1(%2)" ) .arg( connectionRO()->majorVersion() < 2 ? "geomfromewkt" : "st_geomfromewkt", quotedValue( val ) ); @@ -2979,25 +2982,25 @@ bool QgsPostgresProvider::changeAttributeValues( const QgsChangedAttributesMap & else if ( fld.typeName() == QLatin1String( "geography" ) ) { sql += QStringLiteral( "st_geographyfromtext(%1)" ) - .arg( quotedValue( siter->toString() ) ); + .arg( quotedValue( attributeValue.toString() ) ); } else if ( fld.typeName() == QLatin1String( "jsonb" ) ) { sql += QStringLiteral( "%1::jsonb" ) - .arg( quotedJsonValue( siter.value() ) ); + .arg( quotedJsonValue( attributeValue ) ); } else if ( fld.typeName() == QLatin1String( "json" ) ) { sql += QStringLiteral( "%1::json" ) - .arg( quotedJsonValue( siter.value() ) ); + .arg( quotedJsonValue( attributeValue ) ); } else if ( fld.typeName() == QLatin1String( "bytea" ) ) { - sql += quotedByteaValue( siter.value() ); + sql += quotedByteaValue( attributeValue ); } else { - sql += quotedValue( *siter ); + sql += quotedValue( attributeValue ); } } catch ( PGFieldNotFound ) @@ -3328,6 +3331,12 @@ bool QgsPostgresProvider::changeFeatures( const QgsChangedAttributesMap &attr_ma continue; } + const QVariant value = siter.value(); + if ( value.userType() == qMetaTypeId< QgsUnsetAttributeValue >() ) + { + continue; + } + numChangedFields++; sql += delim + QStringLiteral( "%1=" ).arg( quotedIdentifier( fld.name() ) ); @@ -3335,32 +3344,32 @@ bool QgsPostgresProvider::changeFeatures( const QgsChangedAttributesMap &attr_ma if ( fld.typeName() == QLatin1String( "geometry" ) ) { - QString val = geomAttrToString( siter.value(), connectionRO() ); + QString val = geomAttrToString( value, connectionRO() ); sql += QStringLiteral( "%1(%2)" ) .arg( connectionRO()->majorVersion() < 2 ? "geomfromewkt" : "st_geomfromewkt", quotedValue( val ) ); } else if ( fld.typeName() == QLatin1String( "geography" ) ) { sql += QStringLiteral( "st_geographyfromtext(%1)" ) - .arg( quotedValue( siter->toString() ) ); + .arg( quotedValue( value.toString() ) ); } else if ( fld.typeName() == QLatin1String( "jsonb" ) ) { sql += QStringLiteral( "%1::jsonb" ) - .arg( quotedJsonValue( siter.value() ) ); + .arg( quotedJsonValue( value ) ); } else if ( fld.typeName() == QLatin1String( "json" ) ) { sql += QStringLiteral( "%1::json" ) - .arg( quotedJsonValue( siter.value() ) ); + .arg( quotedJsonValue( value ) ); } else if ( fld.typeName() == QLatin1String( "bytea" ) ) { - sql += quotedByteaValue( siter.value() ); + sql += quotedByteaValue( value ); } else { - sql += quotedValue( *siter ); + sql += quotedValue( value ); } } catch ( PGFieldNotFound ) diff --git a/src/providers/spatialite/qgsspatialiteprovider.cpp b/src/providers/spatialite/qgsspatialiteprovider.cpp index acd60202ab40..9189a73d9781 100644 --- a/src/providers/spatialite/qgsspatialiteprovider.cpp +++ b/src/providers/spatialite/qgsspatialiteprovider.cpp @@ -4131,7 +4131,10 @@ bool QgsSpatiaLiteProvider::addFeatures( QgsFeatureList &flist, Flags flags ) for ( int i = 0; i < attributevec.count(); ++i ) { - if ( mDefaultValues.contains( i ) && ( mDefaultValues.value( i ) == attributevec.at( i ).toString() || !attributevec.at( i ).isValid() ) ) + if ( + ( mDefaultValues.contains( i ) && ( mDefaultValues.value( i ) == attributevec.at( i ).toString() || !attributevec.at( i ).isValid() ) ) + || ( attributevec.at( i ).userType() == qMetaTypeId< QgsUnsetAttributeValue >() ) + ) { defaultIndexes.push_back( i ); continue; @@ -4597,6 +4600,8 @@ bool QgsSpatiaLiteProvider::changeAttributeValues( const QgsChangedAttributesMap { QgsField fld = field( siter.key() ); const QVariant &val = siter.value(); + if ( val.userType() == qMetaTypeId< QgsUnsetAttributeValue >() ) + continue; if ( !first ) sql += ','; diff --git a/tests/src/core/testqgsfield.cpp b/tests/src/core/testqgsfield.cpp index b88ac69bb074..d9c703305a7c 100644 --- a/tests/src/core/testqgsfield.cpp +++ b/tests/src/core/testqgsfield.cpp @@ -26,6 +26,7 @@ #include "qgsapplication.h" #include "qgstest.h" #include "qgsreferencedgeometry.h" +#include "qgsunsetattributevalue.h" class TestQgsField : public QObject { @@ -603,6 +604,9 @@ void TestQgsField::convertCompatible() QVERIFY( stringField.convertCompatible( nullDouble ) ); QCOMPARE( static_cast( nullDouble.userType() ), QMetaType::Type::QString ); QVERIFY( nullDouble.isNull() ); + QVariant unsetValue = QgsUnsetAttributeValue( QStringLiteral( "Autonumber" ) ); + QVERIFY( stringField.convertCompatible( unsetValue ) ); + QCOMPARE( static_cast( unsetValue.userType() ), qMetaTypeId< QgsUnsetAttributeValue >() ); //test double const QgsField doubleField( QStringLiteral( "double" ), QMetaType::Type::Double, QStringLiteral( "double" ) ); @@ -636,6 +640,9 @@ void TestQgsField::convertCompatible() QVERIFY( doubleField.convertCompatible( nullDouble ) ); QCOMPARE( static_cast( nullDouble.userType() ), QMetaType::Type::Double ); QVERIFY( nullDouble.isNull() ); + unsetValue = QgsUnsetAttributeValue( QStringLiteral( "Autonumber" ) ); + QVERIFY( doubleField.convertCompatible( unsetValue ) ); + QCOMPARE( static_cast( unsetValue.userType() ), qMetaTypeId< QgsUnsetAttributeValue >() ); //test special rules diff --git a/tests/src/python/provider_python.py b/tests/src/python/provider_python.py index ad6ab99e0f2a..c0a8dfb0cbb4 100644 --- a/tests/src/python/provider_python.py +++ b/tests/src/python/provider_python.py @@ -14,6 +14,7 @@ __copyright__ = "Copyright 2018, The QGIS Project" from qgis.PyQt.QtCore import QVariant +from qgis._core import QgsUnsetAttributeValue from qgis.core import ( Qgis, QgsAbstractFeatureIterator, @@ -34,6 +35,7 @@ QgsSpatialIndex, QgsVectorDataProvider, QgsVectorLayer, + QgsUnsetAttributeValue, ) @@ -421,6 +423,8 @@ def changeAttributeValues(self, attr_map): except KeyError: continue for k, v in attrs.items(): + if isinstance(v, QgsUnsetAttributeValue): + continue f.setAttribute(k, v) self.clearMinMaxCache() return True diff --git a/tests/src/python/providertestbase.py b/tests/src/python/providertestbase.py index 6355499210a0..bf154164af67 100644 --- a/tests/src/python/providertestbase.py +++ b/tests/src/python/providertestbase.py @@ -31,6 +31,7 @@ QgsVectorDataProvider, QgsVectorLayerFeatureSource, QgsVectorLayerUtils, + QgsUnsetAttributeValue, ) from featuresourcetestbase import FeatureSourceTestCase @@ -1003,6 +1004,86 @@ def testAddFeatureFastInsert(self): ) self.assertEqual(l.dataProvider().featureCount(), 7) + def testAddFeatureUnsetAttributes(self): + if not getattr(self, "getEditableLayer", None): + return + + l = self.getEditableLayer() + self.assertTrue(l.isValid()) + + f1 = QgsFeature() + f1.setAttributes( + [ + 6, + -220, + QgsUnsetAttributeValue(), + "String", + "15", + NULL, + NULL, + NULL, + ] + ) + f1.setGeometry(QgsGeometry.fromWkt("Point (-72.345 71.987)")) + + f2 = QgsFeature() + f2.setAttributes( + [ + 7, + QgsUnsetAttributeValue(), + "Coconut", + "CoCoNut", + "13", + NULL, + NULL, + NULL, + ] + ) + + if ( + l.dataProvider().capabilities() + & QgsVectorDataProvider.Capability.AddFeatures + ): + # expect success + result, added = l.dataProvider().addFeatures( + [f1, f2], QgsFeatureSink.Flag.FastInsert + ) + self.assertTrue( + result, + "Provider reported AddFeatures capability, but returned False to addFeatures using QgsUnsetAttributeValues", + ) + self.assertEqual(l.dataProvider().featureCount(), 7) + + features = [f for f in l.dataProvider().getFeatures()] + self.assertEqual(len(features), 7) + f6 = [f for f in features if f[0] == 6][0] + f7 = [f for f in features if f[0] == 7][0] + self.assertEqual(f6[1], -220) + self.assertTrue( + isinstance(f6[2], QgsUnsetAttributeValue) + or f6[2] == NULL + or str(f6[2]) == "", + f"Expected null/unset value, got {f6[2]}", + ) + self.assertEqual(f6[3], "String") + self.assertEqual(f6[4], "15") + self.assertEqual(f6[5], NULL) + self.assertEqual(f6[6], NULL) + self.assertEqual(f6[7], NULL) + + self.assertTrue( + isinstance(f7[1], QgsUnsetAttributeValue) + or f7[1] == NULL + or str(f7[1]) == "", + f"Expected null/unset value, got {f7[1]}", + ) + self.assertEqual(f7[2], "Coconut") + self.assertEqual(f7[3], "CoCoNut") + self.assertEqual(f7[4], "13") + self.assertEqual(f7[5], NULL) + self.assertEqual(f7[6], NULL) + self.assertEqual(f7[7], NULL) + def testAddFeatureMissingAttributes(self): if not getattr(self, "getEditableLayer", None): return @@ -1386,6 +1467,80 @@ def testChangeAttributes(self): "Provider reported no ChangeAttributeValues capability, but returned true to changeAttributeValues", ) + def testChangeAttributesUnsetValue(self): + if not getattr(self, "getEditableLayer", None): + return + + l = self.getEditableLayer() + self.assertTrue(l.isValid()) + + # find 2 features to change + features = [f for f in l.dataProvider().getFeatures()] + # need to keep order here + to_change = [f for f in features if f.attributes()[0] == 1] + to_change.extend([f for f in features if f.attributes()[0] == 3]) + # changes by feature id, for changeAttributeValues call + changes = { + to_change[0].id(): {1: QgsUnsetAttributeValue(), 3: "new string"}, + to_change[1].id(): {1: 502, 4: QgsUnsetAttributeValue()}, + } + # changes by pk, for testing after retrieving changed features + new_attr_map = {1: {3: "new string"}, 3: {1: 502}} + + if ( + l.dataProvider().capabilities() + & QgsVectorDataProvider.Capability.ChangeAttributeValues + ): + # expect success + result = l.dataProvider().changeAttributeValues(changes) + self.assertTrue( + result, + "Provider reported ChangeAttributeValues capability, but returned False to changeAttributeValues", + ) + + # check result + self.testGetFeatures(l.dataProvider(), changed_attributes=new_attr_map) + + else: + # expect fail + self.assertFalse( + l.dataProvider().changeAttributeValues(changes), + "Provider reported no ChangeAttributeValues capability, but returned true to changeAttributeValues", + ) + + def testChangeAttributesOnlyUnsetValue(self): + if not getattr(self, "getEditableLayer", None): + return + + l = self.getEditableLayer() + self.assertTrue(l.isValid()) + + # find 2 features to change + features = [f for f in l.dataProvider().getFeatures()] + # need to keep order here + to_change = [f for f in features if f.attributes()[0] == 1] + to_change.extend([f for f in features if f.attributes()[0] == 3]) + # changes by feature id, for changeAttributeValues call + changes = { + to_change[0].id(): {1: QgsUnsetAttributeValue()}, + to_change[1].id(): {4: QgsUnsetAttributeValue()}, + } + if ( + l.dataProvider().capabilities() + & QgsVectorDataProvider.Capability.ChangeAttributeValues + ): + l.dataProvider().changeAttributeValues(changes) + + # check result + self.testGetFeatures(l.dataProvider()) + + else: + # expect fail + self.assertFalse( + l.dataProvider().changeAttributeValues(changes), + "Provider reported no ChangeAttributeValues capability, but returned true to changeAttributeValues", + ) + def testChangeAttributesConstraintViolation(self): """Checks that changing attributes violating a DB-level CHECK constraint returns false the provider test case must provide an editable layer with a text field @@ -1591,6 +1746,83 @@ def testChangeFeatures(self): "Provider reported no ChangeAttributeValues capability, but returned true to changeFeatures", ) + def testChangeFeaturesUnsetAttribute(self): + if not getattr(self, "getEditableLayer", None): + return + + l = self.getEditableLayer() + self.assertTrue(l.isValid()) + + features = [f for f in l.dataProvider().getFeatures()] + + # find 2 features to change attributes for + features = [f for f in l.dataProvider().getFeatures()] + # need to keep order here + to_change = [f for f in features if f.attributes()[0] == 1] + to_change.extend([f for f in features if f.attributes()[0] == 2]) + # changes by feature id, for changeAttributeValues call + attribute_changes = { + to_change[0].id(): {1: QgsUnsetAttributeValue(), 3: "new string"}, + to_change[1].id(): {1: 502, 4: QgsUnsetAttributeValue()}, + } + # changes by pk, for testing after retrieving changed features + new_attr_map = {1: {3: "new string"}, 2: {1: 502}} + + # find 2 features to change geometries for + to_change = [f for f in features if f.attributes()[0] == 1] + to_change.extend([f for f in features if f.attributes()[0] == 3]) + # changes by feature id, for changeGeometryValues call + geometry_changes = { + to_change[0].id(): QgsGeometry.fromWkt("Point (10 20)"), + to_change[1].id(): QgsGeometry(), + } + # changes by pk, for testing after retrieving changed features + new_geom_map = {1: QgsGeometry.fromWkt("Point ( 10 20 )"), 3: QgsGeometry()} + + if ( + l.dataProvider().capabilities() + & QgsVectorDataProvider.Capability.ChangeGeometries + and l.dataProvider().capabilities() + & QgsVectorDataProvider.Capability.ChangeAttributeValues + ): + # expect success + result = l.dataProvider().changeFeatures( + attribute_changes, geometry_changes + ) + self.assertTrue( + result, + "Provider reported ChangeGeometries and ChangeAttributeValues capability, but returned False to changeFeatures", + ) + + # check result + self.testGetFeatures( + l.dataProvider(), + changed_attributes=new_attr_map, + changed_geometries=new_geom_map, + ) + + # change empty list, should return true for consistency + self.assertTrue(l.dataProvider().changeFeatures({}, {})) + + elif ( + not l.dataProvider().capabilities() + & QgsVectorDataProvider.Capability.ChangeGeometries + ): + # expect fail + self.assertFalse( + l.dataProvider().changeFeatures(attribute_changes, geometry_changes), + "Provider reported no ChangeGeometries capability, but returned true to changeFeatures", + ) + elif ( + not l.dataProvider().capabilities() + & QgsVectorDataProvider.Capability.ChangeAttributeValues + ): + # expect fail + self.assertFalse( + l.dataProvider().changeFeatures(attribute_changes, geometry_changes), + "Provider reported no ChangeAttributeValues capability, but returned true to changeFeatures", + ) + def testMinMaxAfterChanges(self): """ Tests retrieving field min and max value after making changes to the provider's features diff --git a/tests/src/python/test_provider_postgres.py b/tests/src/python/test_provider_postgres.py index c6cd57c785f7..818bf957139e 100644 --- a/tests/src/python/test_provider_postgres.py +++ b/tests/src/python/test_provider_postgres.py @@ -67,6 +67,8 @@ QgsVectorLayerUtils, QgsWkbTypes, QgsSettingsTree, + QgsUnsetAttributeValue, + QgsFeatureSink, ) from qgis.gui import QgsAttributeForm, QgsGui import unittest @@ -4921,6 +4923,85 @@ def testAddFeatureExtraAttributes(self): "The PostgreSQL provider doesn't truncate extra attributes.", ) + def testAddFeatureUnsetAttributes(self): + # changed from ProviderTestBase.testAddFeatureMissingAttributes: this + # layer differs from the standard source definition because + # of the 'qgis' default value set on the text fields + + if not getattr(self, "getEditableLayer", None): + return + + l = self.getEditableLayer() + self.assertTrue(l.isValid()) + + f1 = QgsFeature() + f1.setAttributes( + [ + 6, + -220, + QgsUnsetAttributeValue(), + "String", + "15", + NULL, + NULL, + NULL, + ] + ) + f1.setGeometry(QgsGeometry.fromWkt("Point (-72.345 71.987)")) + + f2 = QgsFeature() + f2.setAttributes( + [ + 7, + QgsUnsetAttributeValue(), + "Coconut", + "CoCoNut", + "13", + NULL, + NULL, + NULL, + ] + ) + + if ( + l.dataProvider().capabilities() + & QgsVectorDataProvider.Capability.AddFeatures + ): + # expect success + result, added = l.dataProvider().addFeatures( + [f1, f2], QgsFeatureSink.Flag.FastInsert + ) + self.assertTrue( + result, + "Provider reported AddFeatures capability, but returned False to addFeatures using QgsUnsetAttributeValues", + ) + self.assertEqual(l.dataProvider().featureCount(), 7) + + features = [f for f in l.dataProvider().getFeatures()] + self.assertEqual(len(features), 7) + f6 = [f for f in features if f[0] == 6][0] + f7 = [f for f in features if f[0] == 7][0] + self.assertEqual(f6[1], -220) + self.assertEqual(f6[2], "qgis") + self.assertEqual(f6[3], "String") + self.assertEqual(f6[4], "15") + self.assertEqual(f6[5], NULL) + self.assertEqual(f6[6], NULL) + self.assertEqual(f6[7], NULL) + + self.assertTrue( + isinstance(f7[1], QgsUnsetAttributeValue) + or f7[1] == NULL + or str(f7[1]) == "", + f"Expected null/unset value, got {f7[1]}", + ) + self.assertEqual(f7[2], "Coconut") + self.assertEqual(f7[3], "CoCoNut") + self.assertEqual(f7[4], "13") + self.assertEqual(f7[5], NULL) + self.assertEqual(f7[6], NULL) + self.assertEqual(f7[7], NULL) + def testAddFeatureMissingAttributes(self): if not getattr(self, "getEditableLayer", None): return diff --git a/tests/src/python/test_qgsprocessinginplace.py b/tests/src/python/test_qgsprocessinginplace.py index 366ac9d5552b..cf8c2d74c04f 100644 --- a/tests/src/python/test_qgsprocessinginplace.py +++ b/tests/src/python/test_qgsprocessinginplace.py @@ -1132,10 +1132,6 @@ def test_unique_constraints(self): alg, parameters, context=context, feedback=feedback, raise_exceptions=True ) - pks = set() - for f in gpkg_layer.getFeatures(): - pks.add(f.attribute(0)) - self.assertTrue(gpkg_layer.commitChanges()) def test_regenerate_fid(self):