diff --git a/autotest/ogr/ogr_gpkg.py b/autotest/ogr/ogr_gpkg.py index 09d577ac9705..bf0a262e6cc2 100755 --- a/autotest/ogr/ogr_gpkg.py +++ b/autotest/ogr/ogr_gpkg.py @@ -6063,6 +6063,54 @@ def test_ogr_gpkg_field_domains_errors(): gdal.Unlink(filename) +############################################################################### +# Test gpkg_data_column_constraints of GPKG 1.0 + + +def test_ogr_gpkg_field_domain_gpkg_1_0(): + + filename = "/vsimem/test.gpkg" + + ds = gdal.GetDriverByName("GPKG").Create( + filename, 0, 0, 0, gdal.GDT_Unknown, options=["VERSION=1.0"] + ) + ds.CreateLayer("test") + assert ds.AddFieldDomain( + ogr.CreateRangeFieldDomain( + "range_domain_int", + "my desc", + ogr.OFTReal, + ogr.OFSTNone, + 1.5, + True, + 2.5, + False, + ) + ) + ds = None + + assert validate(filename) + + ds = gdal.OpenEx(filename, gdal.OF_VECTOR) + + gdal.ErrorReset() + domain = ds.GetFieldDomain("range_domain_int") + assert gdal.GetLastErrorMsg() == "" + assert domain is not None + assert domain.GetName() == "range_domain_int" + assert domain.GetDescription() == "my desc" + assert domain.GetDomainType() == ogr.OFDT_RANGE + assert domain.GetFieldType() == ogr.OFTReal + assert domain.GetMinAsDouble() == 1.5 + assert domain.IsMinInclusive() + assert domain.GetMaxAsDouble() == 2.5 + assert not domain.IsMaxInclusive() + + ds = None + + gdal.Unlink(filename) + + ############################################################################### # Test attribute and spatial views diff --git a/ogr/ogrsf_frmts/gpkg/ogr_geopackage.h b/ogr/ogrsf_frmts/gpkg/ogr_geopackage.h index c8e25b11399d..dd0543eeec11 100644 --- a/ogr/ogrsf_frmts/gpkg/ogr_geopackage.h +++ b/ogr/ogrsf_frmts/gpkg/ogr_geopackage.h @@ -335,6 +335,7 @@ class GDALGeoPackageDataset final : public OGRSQLiteBaseDataSource, public GDALG bool HasDataColumnsTable() const; bool HasDataColumnConstraintsTable() const; + bool HasDataColumnConstraintsTableGPKG_1_0() const; bool CreateColumnsTableAndColumnConstraintsTablesIfNecessary(); bool HasGpkgextRelationsTable() const; bool HasQGISLayerStyles() const; diff --git a/ogr/ogrsf_frmts/gpkg/ogrgeopackagedatasource.cpp b/ogr/ogrsf_frmts/gpkg/ogrgeopackagedatasource.cpp index 1d85279ca71d..ae373e7b2d64 100644 --- a/ogr/ogrsf_frmts/gpkg/ogrgeopackagedatasource.cpp +++ b/ogr/ogrsf_frmts/gpkg/ogrgeopackagedatasource.cpp @@ -3497,6 +3497,29 @@ bool GDALGeoPackageDataset::HasDataColumnConstraintsTable() const return nCount == 1; } +/************************************************************************/ +/* HasDataColumnConstraintsTableGPKG_1_0() */ +/************************************************************************/ + +bool GDALGeoPackageDataset::HasDataColumnConstraintsTableGPKG_1_0() const +{ + if( m_nApplicationId != GP10_APPLICATION_ID ) + return false; + // In GPKG 1.0, the columns were named minIsInclusive, maxIsInclusive + // They were changed in 1.1 to min_is_inclusive, max_is_inclusive + bool bRet = false; + sqlite3_stmt* hSQLStmt = nullptr; + int rc = sqlite3_prepare_v2( hDB, + "SELECT minIsInclusive, maxIsInclusive FROM gpkg_data_column_constraints", -1, + &hSQLStmt, nullptr ); + if( rc == SQLITE_OK ) + { + bRet = true; + sqlite3_finalize(hSQLStmt); + } + return bRet; +} + /************************************************************************/ /* CreateColumnsTableAndColumnConstraintsTablesIfNecessary() */ /************************************************************************/ @@ -3525,18 +3548,21 @@ bool GDALGeoPackageDataset::CreateColumnsTableAndColumnConstraintsTablesIfNecess } if( !HasDataColumnConstraintsTable() ) { - if( OGRERR_NONE != SQLCommand(GetDB(), - "CREATE TABLE gpkg_data_column_constraints (" + const char* min_is_inclusive = m_nApplicationId != GP10_APPLICATION_ID ? "min_is_inclusive": "minIsInclusive"; + const char* max_is_inclusive = m_nApplicationId != GP10_APPLICATION_ID ? "max_is_inclusive": "maxIsInclusive"; + + const std::string osSQL(CPLSPrintf("CREATE TABLE gpkg_data_column_constraints (" "constraint_name TEXT NOT NULL," "constraint_type TEXT NOT NULL," "value TEXT," "min NUMERIC," - "min_is_inclusive BOOLEAN," + "%s BOOLEAN," "max NUMERIC," - "max_is_inclusive BOOLEAN," + "%s BOOLEAN," "description TEXT," "CONSTRAINT gdcc_ntv UNIQUE (constraint_name, " - "constraint_type, value));") ) + "constraint_type, value));", min_is_inclusive, max_is_inclusive)); + if( OGRERR_NONE != SQLCommand(GetDB(), osSQL.c_str()) ) { return false; } @@ -8045,14 +8071,18 @@ const OGRFieldDomain* GDALGeoPackageDataset::GetFieldDomain(const std::string& n if( !HasDataColumnConstraintsTable() ) return nullptr; + const bool bIsGPKG10 = HasDataColumnConstraintsTableGPKG_1_0(); + const char* min_is_inclusive = bIsGPKG10 ? "minIsInclusive" : "min_is_inclusive"; + const char* max_is_inclusive = bIsGPKG10 ? "maxIsInclusive" : "max_is_inclusive"; + std::unique_ptr oResultTable; // Note: for coded domains, we use a little trick by using a dummy // _{domainname}_domain_description enum that has a single entry whose // description is the description of the main domain. { char* pszSQL = sqlite3_mprintf( - "SELECT constraint_type, value, min, min_is_inclusive, " - "max, max_is_inclusive, description, constraint_name " + "SELECT constraint_type, value, min, %s, " + "max, %s, description, constraint_name " "FROM gpkg_data_column_constraints " "WHERE constraint_name IN ('%q', '_%q_domain_description') " "AND length(constraint_type) < 100 " // to avoid denial of service @@ -8060,6 +8090,7 @@ const OGRFieldDomain* GDALGeoPackageDataset::GetFieldDomain(const std::string& n "AND (description IS NULL OR length(description) < 10000) " // to avoid denial of service "ORDER BY value " "LIMIT 10000", // to avoid denial of service + min_is_inclusive, max_is_inclusive, name.c_str(), name.c_str()); oResultTable = SQLQuery(hDB, pszSQL); sqlite3_free(pszSQL); @@ -8347,6 +8378,10 @@ bool GDALGeoPackageDataset::AddFieldDomain(std::unique_ptr&& dom if( !CreateColumnsTableAndColumnConstraintsTablesIfNecessary() ) return false; + const bool bIsGPKG10 = HasDataColumnConstraintsTableGPKG_1_0(); + const char* min_is_inclusive = bIsGPKG10 ? "minIsInclusive" : "min_is_inclusive"; + const char* max_is_inclusive = bIsGPKG10 ? "maxIsInclusive" : "max_is_inclusive"; + const auto& osDescription = domain->GetDescription(); switch( domain->GetDomainType() ) { @@ -8363,10 +8398,12 @@ bool GDALGeoPackageDataset::AddFieldDomain(std::unique_ptr&& dom char* pszSQL = sqlite3_mprintf( "INSERT INTO gpkg_data_column_constraints (" "constraint_name, constraint_type, value, " - "min, min_is_inclusive, max, max_is_inclusive, " + "min, %s, max, %s, " "description) VALUES (" "'_%q_domain_description', 'enum', '', NULL, NULL, NULL, " "NULL, %Q)", + min_is_inclusive, + max_is_inclusive, domainName.c_str(), osDescription.c_str()); CPL_IGNORE_RET_VAL(SQLCommand(hDB, pszSQL)); @@ -8378,9 +8415,11 @@ bool GDALGeoPackageDataset::AddFieldDomain(std::unique_ptr&& dom char* pszSQL = sqlite3_mprintf( "INSERT INTO gpkg_data_column_constraints (" "constraint_name, constraint_type, value, " - "min, min_is_inclusive, max, max_is_inclusive, " + "min, %s, max, %s, " "description) VALUES (" "'%q', 'enum', '%q', NULL, NULL, NULL, NULL, %Q)", + min_is_inclusive, + max_is_inclusive, domainName.c_str(), enumeration[i].pszCode, enumeration[i].pszValue); @@ -8435,11 +8474,11 @@ bool GDALGeoPackageDataset::AddFieldDomain(std::unique_ptr&& dom } sqlite3_stmt* hInsertStmt = nullptr; - const char* pszSQL = "INSERT INTO gpkg_data_column_constraints (" + const char* pszSQL = CPLSPrintf("INSERT INTO gpkg_data_column_constraints (" "constraint_name, constraint_type, value, " - "min, min_is_inclusive, max, max_is_inclusive, " + "min, %s, max, %s, " "description) VALUES (" - "?, 'range', NULL, ?, ?, ?, ?, ?)"; + "?, 'range', NULL, ?, ?, ?, ?, ?)", min_is_inclusive, max_is_inclusive); if ( sqlite3_prepare_v2(hDB, pszSQL, -1, &hInsertStmt, nullptr) != SQLITE_OK ) { @@ -8482,9 +8521,11 @@ bool GDALGeoPackageDataset::AddFieldDomain(std::unique_ptr&& dom char* pszSQL = sqlite3_mprintf( "INSERT INTO gpkg_data_column_constraints (" "constraint_name, constraint_type, value, " - "min, min_is_inclusive, max, max_is_inclusive, " + "min, %s, max, %s, " "description) VALUES (" "'%q', 'glob', '%q', NULL, NULL, NULL, NULL, %Q)", + min_is_inclusive, + max_is_inclusive, domainName.c_str(), poGlobDomain->GetGlob().c_str(), osDescription.empty() ? nullptr : osDescription.c_str()); diff --git a/swig/python/gdal-utils/osgeo_utils/samples/validate_gpkg.py b/swig/python/gdal-utils/osgeo_utils/samples/validate_gpkg.py index fb703c5c9c77..07e261229d49 100644 --- a/swig/python/gdal-utils/osgeo_utils/samples/validate_gpkg.py +++ b/swig/python/gdal-utils/osgeo_utils/samples/validate_gpkg.py @@ -764,6 +764,7 @@ def _check_vector_user_table(self, c, table_name): "SELECT %s FROM %s " % (_esc_id(geom_column_name), _esc_id(table_name)) ) found_geom_types = set() + warning_messages = set() for (blob,) in c.fetchall(): if blob is None: continue @@ -788,15 +789,14 @@ def _check_vector_user_table(self, c, table_name): ) endian_prefix = ">" if big_endian else "<" geom_srs_id = struct.unpack((endian_prefix + "I") * 1, blob[4:8])[0] - self._assert( - srs_id == geom_srs_id, - 33, - ( + if srs_id != geom_srs_id: + warning_msg = ( "table %s has geometries with SRID %d, " + "whereas only %d is expected" - ) - % (table_name, geom_srs_id, srs_id), - ) + ) % (table_name, geom_srs_id, srs_id) + if warning_msg not in warning_messages: + warning_messages.add(warning_msg) + self._assert(False, 33, warning_msg) self._assert( not (empty_flag and env_ind != 0), 152, "Invalid empty geometry" @@ -878,8 +878,8 @@ def _check_vector_user_table(self, c, table_name): self._assert( not found_geom_types or found_geom_types == set([geometry_type_name]), 32, - "in table %s, found geometry types %s" - % (table_name, str(found_geom_types)), + "in table %s, found geometry types %s whereas %s was expected" + % (table_name, str(found_geom_types), geometry_type_name), ) elif geometry_type_name == "GEOMETRYCOLLECTION": self._assert( @@ -2006,6 +2006,8 @@ def _check_gpkg_extensions(self, c): ] for geom_name in GPKGChecker.EXT_GEOM_TYPES: KNOWN_EXTENSIONS += ["gpkg_geom_" + geom_name] + if self.version < (1, 2): + KNOWN_EXTENSIONS += ["gpkg_geometry_type_trigger", "gpkg_srs_id_trigger"] for (extension_name,) in rows: @@ -2440,14 +2442,24 @@ def _check_schema(self, c): c.execute("PRAGMA table_info(gpkg_data_column_constraints)") columns = c.fetchall() + + # GPKG 1.1 uses min_is_inclusive/max_is_inclusive but GPKG 1.0 had + # minIsInclusive/maxIsInclusive + min_is_inclusive = ( + "min_is_inclusive" if self.version >= (1, 1) else "minIsInclusive" + ) + max_is_inclusive = ( + "max_is_inclusive" if self.version >= (1, 1) else "maxIsInclusive" + ) + expected_columns = [ (0, "constraint_name", "TEXT", 1, None, 0), (1, "constraint_type", "TEXT", 1, None, 0), (2, "value", "TEXT", 0, None, 0), (3, "min", "NUMERIC", 0, None, 0), - (4, "min_is_inclusive", "BOOLEAN", 0, None, 0), + (4, min_is_inclusive, "BOOLEAN", 0, None, 0), (5, "max", "NUMERIC", 0, None, 0), - (6, "max_is_inclusive", "BOOLEAN", 0, None, 0), + (6, max_is_inclusive, "BOOLEAN", 0, None, 0), (7, "description", "TEXT", 0, None, 0), ] self._check_structure( @@ -2531,7 +2543,7 @@ def _check_schema(self, c): c.execute( "SELECT 1 FROM gpkg_data_column_constraints WHERE " - + "constraint_type = 'range' AND min_is_inclusive NOT IN (0,1)" + + f"constraint_type = 'range' AND {min_is_inclusive} NOT IN (0,1)" ) if c.fetchone() is not None: self._assert( @@ -2544,7 +2556,7 @@ def _check_schema(self, c): c.execute( "SELECT 1 FROM gpkg_data_column_constraints WHERE " - + "constraint_type = 'range' AND max_is_inclusive NOT IN (0,1)" + + f"constraint_type = 'range' AND {max_is_inclusive} NOT IN (0,1)" ) if c.fetchone() is not None: self._assert( @@ -2555,7 +2567,7 @@ def _check_schema(self, c): + "not 0 or 1", ) - for col_name in ("min", "min_is_inclusive", "max", "max_is_inclusive"): + for col_name in ("min", min_is_inclusive, "max", max_is_inclusive): c.execute( "SELECT 1 FROM gpkg_data_column_constraints WHERE " + "constraint_type IN ('enum', 'glob') AND " @@ -2742,8 +2754,11 @@ def check(self): ("Wrong application_id: %s. " + "Expected one of GP10, GP11, GPKG") % str(application_id), ) - - if application_id == gpkg: + if application_id == gp10: + self.version = (1, 0) + elif application_id == gp11: + self.version = (1, 1) + elif application_id == gpkg: f.seek(60, 0) user_version = f.read(4) expected_version = 10200 @@ -2754,6 +2769,11 @@ def check(self): "Wrong user_version: %d. Expected >= %d" % (user_version, expected_version), ) + self.version = ( + expected_version // 10000, + (expected_version % 10000) // 100, + expected_version % 100, + ) conn = sqlite3.connect(":memory:") c = conn.cursor()