From 1106a8deac317e429b9aaa30423ca5491c588d8a Mon Sep 17 00:00:00 2001 From: Maciej Sopylo Date: Thu, 12 Mar 2026 22:06:05 +0100 Subject: [PATCH 1/5] Add support for NOT NULL columns --- README.md | 2 + src/orm/qormglobal.h | 3 +- src/orm/qormmetadatacache.cpp | 29 +++++++- src/orm/qormpropertymapping.cpp | 13 ++++ src/orm/qormpropertymapping.h | 2 + src/orm/qormsqliteprovider.cpp | 8 +++ src/orm/qormsqlitestatementgenerator_p.cpp | 4 ++ tests/auto/qormsession/CMakeLists.txt | 2 + tests/auto/qormsession/domain/withnotnull.cpp | 25 +++++++ tests/auto/qormsession/domain/withnotnull.h | 68 +++++++++++++++++++ tests/auto/qormsession/qormsession.pro | 2 + tests/auto/qormsession/qormsession.qbs | 1 + tests/auto/qormsession/tst_ormsession.cpp | 58 +++++++++++++++- .../domain/community.h | 14 ++++ .../tst_sqlitestatementgenerator.cpp | 6 +- 15 files changed, 232 insertions(+), 5 deletions(-) create mode 100644 tests/auto/qormsession/domain/withnotnull.cpp create mode 100644 tests/auto/qormsession/domain/withnotnull.h diff --git a/README.md b/README.md index 8507cc3..0b03835 100644 --- a/README.md +++ b/README.md @@ -241,6 +241,7 @@ Possible customizations: * `IDENTITY [true|false]`: mark the property as the identity * `AUTOGENERATED [true|false]`: mark the property as autogenerated by the database backend * `TRANSIENT [true|false]`: mark the property as transient + * `NOT_NULL [true|false]`: mark the property as not null Restrictions and requirements: @@ -248,6 +249,7 @@ Restrictions and requirements: * `IDENTITY` is required for `AUTOGENERATED` * `TRANSIENT` cannot be combined with `IDENTITY` * Renaming columns and tables to names containing QtOrm keywords (`IDENTITY`, `COLUMN`, `TRANSIENT`, etc.) is not supported. +* Schema `Append` mode will not set `NOT_NULL` on the new column(s) #### Relationships diff --git a/src/orm/qormglobal.h b/src/orm/qormglobal.h index 2ff6f98..35430df 100644 --- a/src/orm/qormglobal.h +++ b/src/orm/qormglobal.h @@ -155,7 +155,8 @@ namespace QOrm Autogenerated, Identity, Transient, - Schema + Schema, + NotNull }; inline auto qHash(Keyword value) { diff --git a/src/orm/qormmetadatacache.cpp b/src/orm/qormmetadatacache.cpp index efceb0e..2a7f408 100644 --- a/src/orm/qormmetadatacache.cpp +++ b/src/orm/qormmetadatacache.cpp @@ -52,7 +52,8 @@ namespace {QOrm::Keyword::Column, QLatin1String("COLUMN")}, {QOrm::Keyword::Identity, QLatin1String("IDENTITY")}, {QOrm::Keyword::Transient, QLatin1String("TRANSIENT")}, - {QOrm::Keyword::Autogenerated, QLatin1String("AUTOGENERATED")}}; + {QOrm::Keyword::Autogenerated, QLatin1String("AUTOGENERATED")}, + {QOrm::Keyword::NotNull, QLatin1String("NOT_NULL")}}; template KeywordPosition findNextKeyword(const QString& data, @@ -301,6 +302,22 @@ namespace ormPropertyInfo.insert(QOrm::Keyword::Autogenerated, isAutogenerated.value_or(true)); } + else if (keywordPosition.keyword->id == QOrm::Keyword::NotNull) + { + auto extractResult = extractBoolean(data, pos, PropertyKeywords); + + if (!extractResult.has_value()) + { + qFatal("QtOrm: syntax error in %s in Q_ORM_PROPERTY(%s ...) after NOT_NULL", + qMetaObject.className(), + qPrintable(propertyName)); + } + + std::optional isNotNull = extractResult->value; + keywordPosition = extractResult->nextKeyword; + + ormPropertyInfo.insert(QOrm::Keyword::NotNull, isNotNull.value_or(true)); + } } return ormPropertyInfo; @@ -320,6 +337,8 @@ class QOrmMetadataCachePrivate const QOrmMetadata* referencedEntity = nullptr; bool isTransient = false; bool isEnumeration{false}; + bool isNotNull = false; + QMetaType::Type dataType{QMetaType::UnknownType}; }; @@ -464,6 +483,7 @@ void QOrmMetadataCachePrivate::initialize(const QByteArray& className, descriptor.dataType, descriptor.referencedEntity, descriptor.isTransient, + descriptor.isNotNull, userPropertyMetadata); auto idx = static_cast(data->m_propertyMappings.size() - 1); @@ -496,6 +516,7 @@ QOrmMetadataCachePrivate::MappingDescriptor QOrmMetadataCachePrivate::mappingDes bool isObjectId = qstricmp(property.name(), "id") == 0; bool isAutogenerated = isObjectId; bool isTransient = !property.isStored(); + bool isNotNull = false; // Check if defaults are overridden by the user property metadata if (userPropertyMetadata.contains(QOrm::Keyword::Column)) @@ -519,11 +540,17 @@ QOrmMetadataCachePrivate::MappingDescriptor QOrmMetadataCachePrivate::mappingDes isTransient = userPropertyMetadata.value(QOrm::Keyword::Transient).toBool(); } + if (userPropertyMetadata.contains(QOrm::Keyword::NotNull)) + { + isNotNull = userPropertyMetadata.value(QOrm::Keyword::NotNull).toBool(); + } + descriptor.classPropertyName = QString::fromUtf8(property.name()); descriptor.tableFieldName = tableFieldName; descriptor.isObjectId = isObjectId; descriptor.isAutogenerated = isAutogenerated; descriptor.isTransient = isTransient; + descriptor.isNotNull = isNotNull; #if QT_VERSION < QT_VERSION_CHECK(6, 0, 0) descriptor.dataType = property.type() == QVariant::UserType ? static_cast(property.userType()) diff --git a/src/orm/qormpropertymapping.cpp b/src/orm/qormpropertymapping.cpp index 85efcd4..f9319e7 100644 --- a/src/orm/qormpropertymapping.cpp +++ b/src/orm/qormpropertymapping.cpp @@ -40,6 +40,9 @@ QDebug operator<<(QDebug dbg, const QOrmPropertyMapping& propertyMapping) if (propertyMapping.isTransient()) dbg << ", transient"; + if (propertyMapping.isNotNull()) + dbg << ", not null"; + dbg << ")"; return dbg; @@ -58,6 +61,7 @@ class QOrmPropertyMappingPrivate : public QSharedData QMetaType::Type dataType, const QOrmMetadata* referencedEntity, bool isTransient, + bool isNotNull, QOrmUserMetadata userMetadata) : m_enclosingEntity{enclosingEntity} , m_qMetaProperty{std::move(qMetaProperty)} @@ -68,6 +72,7 @@ class QOrmPropertyMappingPrivate : public QSharedData , m_dataType{dataType} , m_referencedEntity{referencedEntity} , m_isTransient{isTransient} + , m_isNotNull{isNotNull} , m_userMetadata{std::move(userMetadata)} { } @@ -81,6 +86,7 @@ class QOrmPropertyMappingPrivate : public QSharedData QMetaType::Type m_dataType{QMetaType::UnknownType}; const QOrmMetadata* m_referencedEntity{nullptr}; bool m_isTransient{false}; + bool m_isNotNull{false}; QOrmUserMetadata m_userMetadata; }; @@ -93,6 +99,7 @@ QOrmPropertyMapping::QOrmPropertyMapping(const QOrmMetadata& enclosingEntity, QMetaType::Type dataType, const QOrmMetadata* referencedEntity, bool isTransient, + bool isNotNull, QOrmUserMetadata userMetadata) : d{new QOrmPropertyMappingPrivate{enclosingEntity, std::move(qMetaProperty), @@ -103,6 +110,7 @@ QOrmPropertyMapping::QOrmPropertyMapping(const QOrmMetadata& enclosingEntity, dataType, referencedEntity, isTransient, + isNotNull, std::move(userMetadata)}} { } @@ -172,6 +180,11 @@ bool QOrmPropertyMapping::isTransient() const return d->m_isTransient; } +bool QOrmPropertyMapping::isNotNull() const +{ + return d->m_isNotNull; +} + const QOrmUserMetadata& QOrmPropertyMapping::userMetadata() const { return d->m_userMetadata; diff --git a/src/orm/qormpropertymapping.h b/src/orm/qormpropertymapping.h index f696f2d..f74cc52 100644 --- a/src/orm/qormpropertymapping.h +++ b/src/orm/qormpropertymapping.h @@ -45,6 +45,7 @@ class Q_ORM_EXPORT QOrmPropertyMapping QMetaType::Type dataType, const QOrmMetadata* referencedEntity, bool isTransient, + bool isNotNull, QOrmUserMetadata userMetadata); QOrmPropertyMapping(const QOrmPropertyMapping&); QOrmPropertyMapping(QOrmPropertyMapping&&); @@ -64,6 +65,7 @@ class Q_ORM_EXPORT QOrmPropertyMapping [[nodiscard]] bool isReference() const; [[nodiscard]] const QOrmMetadata* referencedEntity() const; [[nodiscard]] bool isTransient() const; + [[nodiscard]] bool isNotNull() const; [[nodiscard]] const QOrmUserMetadata& userMetadata() const; private: diff --git a/src/orm/qormsqliteprovider.cpp b/src/orm/qormsqliteprovider.cpp index 3403157..362dd6a 100644 --- a/src/orm/qormsqliteprovider.cpp +++ b/src/orm/qormsqliteprovider.cpp @@ -586,6 +586,14 @@ QOrmError QOrmSqliteProviderPrivate::updateSchema(const QOrmRelation& relation) << " are incompatible."; updateNeeded = true; } + else if ((field.requiredStatus() == QSqlField::Required) != mapping->isNotNull()) + { + qCDebug(qtorm).noquote().nospace() + << "updating table " << relation.mapping()->tableName() << ": field " + << field.name() << " nullability (" << field.requiredStatus() + << ") differs from the mapping (" << mapping->isNotNull() << ")"; + updateNeeded = true; + } } // Check if there are non-transient class properties that are not mapped in the database. diff --git a/src/orm/qormsqlitestatementgenerator_p.cpp b/src/orm/qormsqlitestatementgenerator_p.cpp index 04812a9..23f0bcb 100644 --- a/src/orm/qormsqlitestatementgenerator_p.cpp +++ b/src/orm/qormsqlitestatementgenerator_p.cpp @@ -526,6 +526,7 @@ QString QOrmSqliteStatementGenerator::generateCreateTableStatement( columnDefs += {escapeIdentifier(mapping.tableFieldName()), toSqliteType(mapping.referencedEntity()->objectIdMapping()->dataType())}; + } else { @@ -539,6 +540,9 @@ QString QOrmSqliteStatementGenerator::generateCreateTableStatement( columnDefs.push_back(QStringLiteral("AUTOINCREMENT")); } + if (mapping.isNotNull()) + columnDefs.push_back(QStringLiteral("NOT NULL")); + fields.push_back(columnDefs.join(' ')); } diff --git a/tests/auto/qormsession/CMakeLists.txt b/tests/auto/qormsession/CMakeLists.txt index 192a2d3..415c1da 100644 --- a/tests/auto/qormsession/CMakeLists.txt +++ b/tests/auto/qormsession/CMakeLists.txt @@ -4,10 +4,12 @@ qtorm_add_unit_test(NAME tst_ormsession SOURCES domain/person.cpp domain/province.cpp domain/town.cpp + domain/withnotnull.cpp domain/person.h domain/province.h domain/town.h + domain/withnotnull.h ormsession.qrc diff --git a/tests/auto/qormsession/domain/withnotnull.cpp b/tests/auto/qormsession/domain/withnotnull.cpp new file mode 100644 index 0000000..085b580 --- /dev/null +++ b/tests/auto/qormsession/domain/withnotnull.cpp @@ -0,0 +1,25 @@ +/* + * Copyright (C) 2026 Maciej Sopyło + * + * This file is part of QtOrm library. + * + * QtOrm is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * QtOrm is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with QtOrm. If not, see . + */ + +#include "withnotnull.h" + +WithNotNull::WithNotNull(QObject* parent) + : QObject{parent} +{ +} diff --git a/tests/auto/qormsession/domain/withnotnull.h b/tests/auto/qormsession/domain/withnotnull.h new file mode 100644 index 0000000..570f05b --- /dev/null +++ b/tests/auto/qormsession/domain/withnotnull.h @@ -0,0 +1,68 @@ +/* + * Copyright (C) 2019-2021 Dmitriy Purgin + * Copyright (C) 2019-2021 sequality software engineering e.U. + * Copyright (C) 2026 Maciej Sopyło + * + * This file is part of QtOrm library. + * + * QtOrm is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * QtOrm is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with QtOrm. If not, see . + */ + +#pragma once + +#include +#include + +#include + +class WithNotNull : public QObject +{ + Q_OBJECT + + Q_PROPERTY(int id READ id WRITE setId NOTIFY idChanged) + Q_PROPERTY(int data READ data WRITE setData NOTIFY dataChanged) + + Q_ORM_PROPERTY(data NOT_NULL) + +public: + Q_INVOKABLE WithNotNull(QObject* parent = nullptr); + + [[nodiscard]] int id() const { return m_id; } + void setId(int id) + { + if (m_id != id) + { + m_id = id; + emit idChanged(); + } + } + + [[nodiscard]] int data() const { return m_data; } + void setData(const int data) + { + if (m_data != data) + { + m_data = data; + emit dataChanged(); + } + } + +signals: + void idChanged(); + void dataChanged(); + +private: + int m_id{0}; + int m_data; +}; diff --git a/tests/auto/qormsession/qormsession.pro b/tests/auto/qormsession/qormsession.pro index e414b8b..6dac1e5 100644 --- a/tests/auto/qormsession/qormsession.pro +++ b/tests/auto/qormsession/qormsession.pro @@ -8,10 +8,12 @@ SOURCES += tst_ormsession.cpp \ domain/province.cpp \ domain/town.cpp \ domain/person.cpp \ + domain/withnotnull.cpp \ HEADERS += \ domain/province.h \ domain/town.h \ domain/person.h \ + domain/withnotnull.h \ RESOURCES += ormsession.qrc diff --git a/tests/auto/qormsession/qormsession.qbs b/tests/auto/qormsession/qormsession.qbs index d61bbad..a34541b 100644 --- a/tests/auto/qormsession/qormsession.qbs +++ b/tests/auto/qormsession/qormsession.qbs @@ -10,6 +10,7 @@ QtApplication { "domain/person.cpp", "domain/person.h", "domain/province.cpp", "domain/province.h", "domain/town.cpp", "domain/town.h", + "domain/withnotnull.cpp", "domain/withnotnull.h", "tst_ormsession.cpp", "ormsession.qrc"] } diff --git a/tests/auto/qormsession/tst_ormsession.cpp b/tests/auto/qormsession/tst_ormsession.cpp index f626a72..c2df7fa 100644 --- a/tests/auto/qormsession/tst_ormsession.cpp +++ b/tests/auto/qormsession/tst_ormsession.cpp @@ -31,10 +31,12 @@ #include #include #include +#include #include "domain/person.h" #include "domain/province.h" #include "domain/town.h" +#include "domain/withnotnull.h" #include "private/qormglobal_p.h" @@ -82,6 +84,7 @@ private slots: void testSchemaAppendCreatesTablesAndAddsColumns(); void testSchemaUpdateCreatesTablesAndAddsColumns(); void testSchemaUpdateRemovesColumns(); + void testSchemaUpdateUpdatesNotNull(); }; SqliteSessionTest::SqliteSessionTest() @@ -99,7 +102,7 @@ void SqliteSessionTest::init() if (db.exists()) QVERIFY(db.remove()); - qRegisterOrmEntity(); + qRegisterOrmEntity(); } void SqliteSessionTest::cleanup() @@ -976,6 +979,59 @@ void SqliteSessionTest::testSchemaUpdateRemovesColumns() } } +void SqliteSessionTest::testSchemaUpdateUpdatesNotNull() +{ + { + QSqlDatabase db = QSqlDatabase::addDatabase("QSQLITE"); + db.setDatabaseName("testdb.db"); + QVERIFY(db.open()); + + static const QStringList statements{ + "CREATE TABLE WithNotNull(id INTEGER PRIMARY KEY AUTOINCREMENT, data INTEGER)", + "INSERT INTO WithNotNull(id, data) VALUES(1, 2)", + "INSERT INTO WithNotNull(id, data) VALUES(2, 3)"}; + + for (const QString& statement : statements) + { + qDebug() << "Executing" << statement; + QSqlQuery query{db}; + QVERIFY(query.exec(statement)); + QCOMPARE(query.lastError().type(), QSqlError::NoError); + } + + db.close(); + QSqlDatabase::removeDatabase(QSqlDatabase::defaultConnection); + } + + { + QOrmSession session{QOrmSessionConfiguration::fromFile(":/qtorm_update_schema.json")}; + + auto result = session.from().select(); + QCOMPARE(result.error().type(), QOrm::ErrorType::None); + auto notNullData = result.toVector(); + QCOMPARE(notNullData.size(), 2); + QCOMPARE(notNullData[0]->id(), 1); + QCOMPARE(notNullData[1]->id(), 2); + } + + { + QSqlDatabase db = QSqlDatabase::addDatabase("QSQLITE"); + db.setDatabaseName("testdb.db"); + QVERIFY(db.open()); + + QVERIFY(db.tables().contains("WithNotNull")); + QSqlRecord record = db.record("WithNotNull"); + QCOMPARE(record.count(), 2); + QVERIFY(record.contains("id")); + QVERIFY(record.contains("data")); + QCOMPARE(record.field("data").requiredStatus(), QSqlField::Required); + + db.close(); + QSqlDatabase::removeDatabase(QSqlDatabase::defaultConnection); + } +} + + void SqliteSessionTest::testRemoveInstance() { QOrmSession session; diff --git a/tests/auto/qormsqlitestatementgenerator/domain/community.h b/tests/auto/qormsqlitestatementgenerator/domain/community.h index 0b06bcc..9893ab1 100644 --- a/tests/auto/qormsqlitestatementgenerator/domain/community.h +++ b/tests/auto/qormsqlitestatementgenerator/domain/community.h @@ -33,12 +33,14 @@ class Community : public QObject Q_PROPERTY(QString name READ name WRITE setName NOTIFY nameChanged) Q_PROPERTY(int population READ population WRITE setPopulation NOTIFY populationChanged) Q_PROPERTY(Province* province READ province WRITE setProvince NOTIFY provinceChanged) + Q_PROPERTY(int neverNull READ neverNull WRITE setNeverNull NOTIFY neverNullChanged) Q_PROPERTY(bool hasLargePopulation READ hasLargePopulation STORED false) Q_PROPERTY(bool hasSmallPopulation READ hasSmallPopulation) Q_ORM_CLASS(TABLE communities) Q_ORM_PROPERTY(communityId COLUMN community_id IDENTITY) Q_ORM_PROPERTY(hasSmallPopulation TRANSIENT) + Q_ORM_PROPERTY(neverNull NOT_NULL) public: Q_INVOKABLE Community(QObject* parent = nullptr); @@ -83,6 +85,16 @@ class Community : public QObject } } + int neverNull() const { return m_neverNull; } + void setNeverNull(int neverNull) + { + if (m_neverNull != neverNull) + { + m_neverNull = neverNull; + emit neverNullChanged(); + } + } + bool hasLargePopulation() const { return m_population > 5000; } bool hasSmallPopulation() const { return !hasLargePopulation(); } @@ -91,10 +103,12 @@ class Community : public QObject void nameChanged(); void populationChanged(); void provinceChanged(); + void neverNullChanged(); private: long m_communityId{0}; QString m_name; int m_population{0}; Province* m_province{nullptr}; + int m_neverNull{1}; }; diff --git a/tests/auto/qormsqlitestatementgenerator/tst_sqlitestatementgenerator.cpp b/tests/auto/qormsqlitestatementgenerator/tst_sqlitestatementgenerator.cpp index 14441b4..6d756d0 100644 --- a/tests/auto/qormsqlitestatementgenerator/tst_sqlitestatementgenerator.cpp +++ b/tests/auto/qormsqlitestatementgenerator/tst_sqlitestatementgenerator.cpp @@ -145,6 +145,7 @@ void SqliteStatementGenerator::testInsertForCustomizedEntity() hagenberg->setCommunityId(4232); hagenberg->setName("Hagenberg"); hagenberg->setPopulation(4000); + hagenberg->setNeverNull(5); QVariantMap boundParameters; QString statement = @@ -152,11 +153,12 @@ void SqliteStatementGenerator::testInsertForCustomizedEntity() QCOMPARE( statement, - R"(INSERT INTO "communities"("community_id","name","population","province_id") VALUES(:community_id,:name,:population,:province_id))"); + R"(INSERT INTO "communities"("community_id","name","population","province_id","nevernull") VALUES(:community_id,:name,:population,:province_id,:nevernull))"); QCOMPARE(boundParameters[":community_id"], 4232); QCOMPARE(boundParameters[":name"], "Hagenberg"); QCOMPARE(boundParameters[":population"], 4000); QCOMPARE(boundParameters[":province_id"], QVariant::fromValue(nullptr)); + QCOMPARE(boundParameters[":nevernull"], 5); } void SqliteStatementGenerator::testInsertWithNamespace() @@ -403,7 +405,7 @@ void SqliteStatementGenerator::testCreateTableForCustomizedEntity() QOrmMetadataCache cache; QCOMPARE( QOrmSqliteStatementGenerator{}.generateCreateTableStatement(cache.get()), - R"(CREATE TABLE "communities"("community_id" INTEGER PRIMARY KEY,"name" TEXT,"population" INTEGER,"province_id" INTEGER))"); + R"(CREATE TABLE "communities"("community_id" INTEGER PRIMARY KEY,"name" TEXT,"population" INTEGER,"province_id" INTEGER,"nevernull" INTEGER NOT NULL))"); } void SqliteStatementGenerator::testCreateTableWithQVariant() From ef9f33766fbb738f86d4f16938deb050359fcb38 Mon Sep 17 00:00:00 2001 From: Maciej Sopylo Date: Fri, 13 Mar 2026 22:41:32 +0100 Subject: [PATCH 2/5] Add support for foreign keys --- README.md | 1 + src/orm/qormglobal.h | 3 +- src/orm/qormmetadatacache.cpp | 28 ++++++- src/orm/qormpropertymapping.cpp | 13 ++++ src/orm/qormpropertymapping.h | 2 + src/orm/qormsessionconfiguration.cpp | 1 + src/orm/qormsqliteconfiguration.cpp | 10 +++ src/orm/qormsqliteconfiguration.h | 4 + src/orm/qormsqliteprovider.cpp | 25 +++++++ src/orm/qormsqlitestatementgenerator_p.cpp | 23 +++++- tests/auto/qormsession/CMakeLists.txt | 2 + .../qormsession/domain/withforeignkey.cpp | 25 +++++++ .../auto/qormsession/domain/withforeignkey.h | 69 +++++++++++++++++ tests/auto/qormsession/qormsession.pro | 2 + tests/auto/qormsession/qormsession.qbs | 1 + .../auto/qormsession/qtorm_update_schema.json | 1 + tests/auto/qormsession/tst_ormsession.cpp | 75 ++++++++++++++++++- .../domain/community.h | 1 + .../tst_sqlitestatementgenerator.cpp | 16 +++- 19 files changed, 294 insertions(+), 8 deletions(-) create mode 100644 tests/auto/qormsession/domain/withforeignkey.cpp create mode 100644 tests/auto/qormsession/domain/withforeignkey.h diff --git a/README.md b/README.md index 0b03835..7e219c8 100644 --- a/README.md +++ b/README.md @@ -242,6 +242,7 @@ Possible customizations: * `AUTOGENERATED [true|false]`: mark the property as autogenerated by the database backend * `TRANSIENT [true|false]`: mark the property as transient * `NOT_NULL [true|false]`: mark the property as not null + * `FOREIGN_KEY [true|false]`: mark the relation as having a foreign key in the generated schema Restrictions and requirements: diff --git a/src/orm/qormglobal.h b/src/orm/qormglobal.h index 35430df..85783ae 100644 --- a/src/orm/qormglobal.h +++ b/src/orm/qormglobal.h @@ -156,7 +156,8 @@ namespace QOrm Identity, Transient, Schema, - NotNull + NotNull, + ForeignKey }; inline auto qHash(Keyword value) { diff --git a/src/orm/qormmetadatacache.cpp b/src/orm/qormmetadatacache.cpp index 2a7f408..e1824c3 100644 --- a/src/orm/qormmetadatacache.cpp +++ b/src/orm/qormmetadatacache.cpp @@ -53,7 +53,8 @@ namespace {QOrm::Keyword::Identity, QLatin1String("IDENTITY")}, {QOrm::Keyword::Transient, QLatin1String("TRANSIENT")}, {QOrm::Keyword::Autogenerated, QLatin1String("AUTOGENERATED")}, - {QOrm::Keyword::NotNull, QLatin1String("NOT_NULL")}}; + {QOrm::Keyword::NotNull, QLatin1String("NOT_NULL")}, + {QOrm::Keyword::ForeignKey, QLatin1String("FOREIGN_KEY")}}; template KeywordPosition findNextKeyword(const QString& data, @@ -318,6 +319,22 @@ namespace ormPropertyInfo.insert(QOrm::Keyword::NotNull, isNotNull.value_or(true)); } + else if (keywordPosition.keyword->id == QOrm::Keyword::ForeignKey) + { + auto extractResult = extractBoolean(data, pos, PropertyKeywords); + + if (!extractResult.has_value()) + { + qFatal("QtOrm: syntax error in %s in Q_ORM_PROPERTY(%s ...) after FOREIGN_KEY", + qMetaObject.className(), + qPrintable(propertyName)); + } + + std::optional hasForeignKey = extractResult->value; + keywordPosition = extractResult->nextKeyword; + + ormPropertyInfo.insert(QOrm::Keyword::ForeignKey, hasForeignKey.value_or(true)); + } } return ormPropertyInfo; @@ -338,6 +355,7 @@ class QOrmMetadataCachePrivate bool isTransient = false; bool isEnumeration{false}; bool isNotNull = false; + bool hasForeignKey = false; QMetaType::Type dataType{QMetaType::UnknownType}; }; @@ -484,6 +502,7 @@ void QOrmMetadataCachePrivate::initialize(const QByteArray& className, descriptor.referencedEntity, descriptor.isTransient, descriptor.isNotNull, + descriptor.hasForeignKey, userPropertyMetadata); auto idx = static_cast(data->m_propertyMappings.size() - 1); @@ -517,6 +536,7 @@ QOrmMetadataCachePrivate::MappingDescriptor QOrmMetadataCachePrivate::mappingDes bool isAutogenerated = isObjectId; bool isTransient = !property.isStored(); bool isNotNull = false; + bool hasForeignKey = false; // Check if defaults are overridden by the user property metadata if (userPropertyMetadata.contains(QOrm::Keyword::Column)) @@ -545,12 +565,18 @@ QOrmMetadataCachePrivate::MappingDescriptor QOrmMetadataCachePrivate::mappingDes isNotNull = userPropertyMetadata.value(QOrm::Keyword::NotNull).toBool(); } + if (userPropertyMetadata.contains(QOrm::Keyword::ForeignKey)) + { + hasForeignKey = userPropertyMetadata.value(QOrm::Keyword::ForeignKey).toBool(); + } + descriptor.classPropertyName = QString::fromUtf8(property.name()); descriptor.tableFieldName = tableFieldName; descriptor.isObjectId = isObjectId; descriptor.isAutogenerated = isAutogenerated; descriptor.isTransient = isTransient; descriptor.isNotNull = isNotNull; + descriptor.hasForeignKey = hasForeignKey; #if QT_VERSION < QT_VERSION_CHECK(6, 0, 0) descriptor.dataType = property.type() == QVariant::UserType ? static_cast(property.userType()) diff --git a/src/orm/qormpropertymapping.cpp b/src/orm/qormpropertymapping.cpp index f9319e7..fc76099 100644 --- a/src/orm/qormpropertymapping.cpp +++ b/src/orm/qormpropertymapping.cpp @@ -43,6 +43,9 @@ QDebug operator<<(QDebug dbg, const QOrmPropertyMapping& propertyMapping) if (propertyMapping.isNotNull()) dbg << ", not null"; + if (propertyMapping.hasForeignKey()) + dbg << ", fk"; + dbg << ")"; return dbg; @@ -62,6 +65,7 @@ class QOrmPropertyMappingPrivate : public QSharedData const QOrmMetadata* referencedEntity, bool isTransient, bool isNotNull, + bool hasForeignKey, QOrmUserMetadata userMetadata) : m_enclosingEntity{enclosingEntity} , m_qMetaProperty{std::move(qMetaProperty)} @@ -73,6 +77,7 @@ class QOrmPropertyMappingPrivate : public QSharedData , m_referencedEntity{referencedEntity} , m_isTransient{isTransient} , m_isNotNull{isNotNull} + , m_hasForeignKey{hasForeignKey} , m_userMetadata{std::move(userMetadata)} { } @@ -87,6 +92,7 @@ class QOrmPropertyMappingPrivate : public QSharedData const QOrmMetadata* m_referencedEntity{nullptr}; bool m_isTransient{false}; bool m_isNotNull{false}; + bool m_hasForeignKey{false}; QOrmUserMetadata m_userMetadata; }; @@ -100,6 +106,7 @@ QOrmPropertyMapping::QOrmPropertyMapping(const QOrmMetadata& enclosingEntity, const QOrmMetadata* referencedEntity, bool isTransient, bool isNotNull, + bool hasForeignKey, QOrmUserMetadata userMetadata) : d{new QOrmPropertyMappingPrivate{enclosingEntity, std::move(qMetaProperty), @@ -111,6 +118,7 @@ QOrmPropertyMapping::QOrmPropertyMapping(const QOrmMetadata& enclosingEntity, referencedEntity, isTransient, isNotNull, + hasForeignKey, std::move(userMetadata)}} { } @@ -185,6 +193,11 @@ bool QOrmPropertyMapping::isNotNull() const return d->m_isNotNull; } +bool QOrmPropertyMapping::hasForeignKey() const +{ + return d->m_hasForeignKey; +} + const QOrmUserMetadata& QOrmPropertyMapping::userMetadata() const { return d->m_userMetadata; diff --git a/src/orm/qormpropertymapping.h b/src/orm/qormpropertymapping.h index f74cc52..cac1262 100644 --- a/src/orm/qormpropertymapping.h +++ b/src/orm/qormpropertymapping.h @@ -46,6 +46,7 @@ class Q_ORM_EXPORT QOrmPropertyMapping const QOrmMetadata* referencedEntity, bool isTransient, bool isNotNull, + bool hasForeignKey, QOrmUserMetadata userMetadata); QOrmPropertyMapping(const QOrmPropertyMapping&); QOrmPropertyMapping(QOrmPropertyMapping&&); @@ -66,6 +67,7 @@ class Q_ORM_EXPORT QOrmPropertyMapping [[nodiscard]] const QOrmMetadata* referencedEntity() const; [[nodiscard]] bool isTransient() const; [[nodiscard]] bool isNotNull() const; + [[nodiscard]] bool hasForeignKey() const; [[nodiscard]] const QOrmUserMetadata& userMetadata() const; private: diff --git a/src/orm/qormsessionconfiguration.cpp b/src/orm/qormsessionconfiguration.cpp index 303ed8f..abebad0 100644 --- a/src/orm/qormsessionconfiguration.cpp +++ b/src/orm/qormsessionconfiguration.cpp @@ -59,6 +59,7 @@ static QOrmSqliteConfiguration _build_json_sqlite_configuration(const QJsonObjec sqlConfiguration.setDatabaseName(object["databaseName"].toString()); sqlConfiguration.setVerbose(object["verbose"].toBool(false)); sqlConfiguration.setConnectOptions(object["connectOptions"].toString()); + sqlConfiguration.setForeignKeysEnabled(object["foreignKeysEnabled"].toBool(false)); QString schemaModeStr = object["schemaMode"].toString("validate").toLower(); diff --git a/src/orm/qormsqliteconfiguration.cpp b/src/orm/qormsqliteconfiguration.cpp index e59133f..89cafee 100644 --- a/src/orm/qormsqliteconfiguration.cpp +++ b/src/orm/qormsqliteconfiguration.cpp @@ -62,4 +62,14 @@ void QOrmSqliteConfiguration::setSchemaMode(SchemaMode schemaMode) m_schemaMode = schemaMode; } +bool QOrmSqliteConfiguration::foreignKeysEnabled() const +{ + return m_foreignKeysEnabled; +} + +void QOrmSqliteConfiguration::setForeignKeysEnabled(bool foreignKeysEnabled) +{ + m_foreignKeysEnabled = foreignKeysEnabled; +} + QT_END_NAMESPACE diff --git a/src/orm/qormsqliteconfiguration.h b/src/orm/qormsqliteconfiguration.h index 47c866f..9f9ab9e 100644 --- a/src/orm/qormsqliteconfiguration.h +++ b/src/orm/qormsqliteconfiguration.h @@ -55,11 +55,15 @@ class Q_ORM_EXPORT QOrmSqliteConfiguration SchemaMode schemaMode() const; void setSchemaMode(SchemaMode schemaMode); + [[nodiscard]] bool foreignKeysEnabled() const; + void setForeignKeysEnabled(bool foreignKeysEnabled); + private: QString m_connectOptions; QString m_databaseName; bool m_verbose{false}; SchemaMode m_schemaMode; + bool m_foreignKeysEnabled{false}; }; QT_END_NAMESPACE diff --git a/src/orm/qormsqliteprovider.cpp b/src/orm/qormsqliteprovider.cpp index 362dd6a..1fef02c 100644 --- a/src/orm/qormsqliteprovider.cpp +++ b/src/orm/qormsqliteprovider.cpp @@ -74,6 +74,7 @@ class QOrmSqliteProviderPrivate QString toSqlType(QMetaType::Type type); [[nodiscard]] bool canConvertFromSqliteToQProperty(QMetaType::Type fromSqlType, QMetaType::Type toQPropertyType); + [[nodiscard]] bool fieldHasForeignKey(const QSqlField& field); Q_REQUIRED_RESULT QOrmError lastDatabaseError() const; @@ -141,6 +142,17 @@ bool QOrmSqliteProviderPrivate::canConvertFromSqliteToQProperty(QMetaType::Type #endif } +bool QOrmSqliteProviderPrivate::fieldHasForeignKey(const QSqlField& field) +{ + QSqlQuery query = prepareAndExecute( + R"(SELECT 1 FROM pragma_foreign_key_list(:table_name) WHERE "from" = :column)", + {{":table_name", field.tableName()},{":column", field.name()}}); + + // query.size() is not supported for sqlite, but first() will confirm whether we got at least + // one result which is what we care about + return query.first(); +} + QOrmError QOrmSqliteProviderPrivate::lastDatabaseError() const { return QOrmError{QOrm::ErrorType::Provider, m_database.lastError().text()}; @@ -570,6 +582,14 @@ QOrmError QOrmSqliteProviderPrivate::updateSchema(const QOrmRelation& relation) << " mapping."; updateNeeded = true; } + else if (fieldHasForeignKey(field) != mapping->hasForeignKey()) + { + qCDebug(qtorm).noquote().nospace() + << "updating table " << relation.mapping()->tableName() << ": field " + << field.name() << " foreign key (" << !mapping->hasForeignKey() + << ") differs from the mapping (" << mapping->hasForeignKey() << ")"; + updateNeeded = true; + } } #if QT_VERSION < QT_VERSION_CHECK(6, 0, 0) else if (!canConvertFromSqliteToQProperty(static_cast(field.type()), @@ -1129,6 +1149,11 @@ QOrmError QOrmSqliteProvider::connectToBackend() { return d->lastDatabaseError(); } + + if (d->m_sqlConfiguration.foreignKeysEnabled()) + { + return d->setForeignKeysEnabled(true); + } } return QOrmError{QOrm::ErrorType::None, {}}; diff --git a/src/orm/qormsqlitestatementgenerator_p.cpp b/src/orm/qormsqlitestatementgenerator_p.cpp index 23f0bcb..bdd5c8c 100644 --- a/src/orm/qormsqlitestatementgenerator_p.cpp +++ b/src/orm/qormsqlitestatementgenerator_p.cpp @@ -527,6 +527,12 @@ QString QOrmSqliteStatementGenerator::generateCreateTableStatement( columnDefs += {escapeIdentifier(mapping.tableFieldName()), toSqliteType(mapping.referencedEntity()->objectIdMapping()->dataType())}; + if (mapping.hasForeignKey()) + columnDefs += {QStringLiteral("REFERENCES"), + escapeIdentifier(mapping.referencedEntity()->tableName()), + QStringLiteral("("), + escapeIdentifier(mapping.referencedEntity()->objectIdMapping()->tableFieldName()), + QStringLiteral(")")}; } else { @@ -572,10 +578,23 @@ QString QOrmSqliteStatementGenerator::generateAlterTableAddColumnStatement( dataType = toSqliteType(propertyMapping.dataType()); } - return QStringLiteral("ALTER TABLE %1 ADD COLUMN %2 %3") + QString possibleFk = ""; + + if (propertyMapping.isReference() && propertyMapping.hasForeignKey()) + { + QStringList fkDef = {QStringLiteral("REFERENCES"), + escapeIdentifier(propertyMapping.referencedEntity()->tableName()), + QStringLiteral("("), + escapeIdentifier(propertyMapping.referencedEntity()->objectIdMapping()->tableFieldName()), + QStringLiteral(")")}; + possibleFk = fkDef.join(' '); + } + + return QStringLiteral("ALTER TABLE %1 ADD COLUMN %2 %3 %4") .arg(escapeIdentifier(relation.tableName()), escapeIdentifier(propertyMapping.tableFieldName()), - dataType); + dataType, + possibleFk); } QString QOrmSqliteStatementGenerator::generateDropTableStatement(const QOrmMetadata& entity) diff --git a/tests/auto/qormsession/CMakeLists.txt b/tests/auto/qormsession/CMakeLists.txt index 415c1da..102fad8 100644 --- a/tests/auto/qormsession/CMakeLists.txt +++ b/tests/auto/qormsession/CMakeLists.txt @@ -5,11 +5,13 @@ qtorm_add_unit_test(NAME tst_ormsession SOURCES domain/province.cpp domain/town.cpp domain/withnotnull.cpp + domain/withforeignkey.cpp domain/person.h domain/province.h domain/town.h domain/withnotnull.h + domain/withforeignkey.h ormsession.qrc diff --git a/tests/auto/qormsession/domain/withforeignkey.cpp b/tests/auto/qormsession/domain/withforeignkey.cpp new file mode 100644 index 0000000..f501b34 --- /dev/null +++ b/tests/auto/qormsession/domain/withforeignkey.cpp @@ -0,0 +1,25 @@ +/* + * Copyright (C) 2026 Maciej Sopyło + * + * This file is part of QtOrm library. + * + * QtOrm is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * QtOrm is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with QtOrm. If not, see . + */ + +#include "withforeignkey.h" + +WithForeignKey::WithForeignKey(QObject* parent) + : QObject{parent} +{ +} diff --git a/tests/auto/qormsession/domain/withforeignkey.h b/tests/auto/qormsession/domain/withforeignkey.h new file mode 100644 index 0000000..41ce097 --- /dev/null +++ b/tests/auto/qormsession/domain/withforeignkey.h @@ -0,0 +1,69 @@ +/* + * Copyright (C) 2019-2021 Dmitriy Purgin + * Copyright (C) 2019-2021 sequality software engineering e.U. + * Copyright (C) 2026 Maciej Sopyło + * + * This file is part of QtOrm library. + * + * QtOrm is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * QtOrm is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with QtOrm. If not, see . + */ + +#pragma once + +#include +#include + +#include +#include "withnotnull.h" + +class WithForeignKey: public QObject +{ + Q_OBJECT + + Q_PROPERTY(int id READ id WRITE setId NOTIFY idChanged) + Q_PROPERTY(WithNotNull* data READ data WRITE setData NOTIFY dataChanged) + + Q_ORM_PROPERTY(data FOREIGN_KEY) + +public: + Q_INVOKABLE WithForeignKey(QObject* parent = nullptr); + + [[nodiscard]] int id() const { return m_id; } + void setId(int id) + { + if (m_id != id) + { + m_id = id; + emit idChanged(); + } + } + + [[nodiscard]] WithNotNull* data() const { return m_data; } + void setData(WithNotNull* data) + { + if (m_data != data) + { + m_data = data; + emit dataChanged(); + } + } + +signals: + void idChanged(); + void dataChanged(); + +private: + int m_id{0}; + WithNotNull* m_data{nullptr}; +}; diff --git a/tests/auto/qormsession/qormsession.pro b/tests/auto/qormsession/qormsession.pro index 6dac1e5..288e9b4 100644 --- a/tests/auto/qormsession/qormsession.pro +++ b/tests/auto/qormsession/qormsession.pro @@ -9,11 +9,13 @@ SOURCES += tst_ormsession.cpp \ domain/town.cpp \ domain/person.cpp \ domain/withnotnull.cpp \ + domain/withforeignkey.cpp \ HEADERS += \ domain/province.h \ domain/town.h \ domain/person.h \ domain/withnotnull.h \ + domain/withforeignkey.h \ RESOURCES += ormsession.qrc diff --git a/tests/auto/qormsession/qormsession.qbs b/tests/auto/qormsession/qormsession.qbs index a34541b..4f89b39 100644 --- a/tests/auto/qormsession/qormsession.qbs +++ b/tests/auto/qormsession/qormsession.qbs @@ -11,6 +11,7 @@ QtApplication { "domain/province.cpp", "domain/province.h", "domain/town.cpp", "domain/town.h", "domain/withnotnull.cpp", "domain/withnotnull.h", + "domain/withforeignkey.cpp", "domain/withforeignkey.h", "tst_ormsession.cpp", "ormsession.qrc"] } diff --git a/tests/auto/qormsession/qtorm_update_schema.json b/tests/auto/qormsession/qtorm_update_schema.json index f7f99d3..3ef72a1 100644 --- a/tests/auto/qormsession/qtorm_update_schema.json +++ b/tests/auto/qormsession/qtorm_update_schema.json @@ -4,6 +4,7 @@ "sqlite": { "databaseName": "testdb.db", "schemaMode": "update", + "foreignKeysEnabled": true, "verbose": true } } diff --git a/tests/auto/qormsession/tst_ormsession.cpp b/tests/auto/qormsession/tst_ormsession.cpp index c2df7fa..6aafab5 100644 --- a/tests/auto/qormsession/tst_ormsession.cpp +++ b/tests/auto/qormsession/tst_ormsession.cpp @@ -37,6 +37,7 @@ #include "domain/province.h" #include "domain/town.h" #include "domain/withnotnull.h" +#include "domain/withforeignkey.h" #include "private/qormglobal_p.h" @@ -85,6 +86,9 @@ private slots: void testSchemaUpdateCreatesTablesAndAddsColumns(); void testSchemaUpdateRemovesColumns(); void testSchemaUpdateUpdatesNotNull(); + void testSchemaUpdateUpdatesForeignKey(); + + void testForeignKeysEnabled(); }; SqliteSessionTest::SqliteSessionTest() @@ -102,7 +106,7 @@ void SqliteSessionTest::init() if (db.exists()) QVERIFY(db.remove()); - qRegisterOrmEntity(); + qRegisterOrmEntity(); } void SqliteSessionTest::cleanup() @@ -1031,6 +1035,57 @@ void SqliteSessionTest::testSchemaUpdateUpdatesNotNull() } } +void SqliteSessionTest::testSchemaUpdateUpdatesForeignKey() +{ + { + QSqlDatabase db = QSqlDatabase::addDatabase("QSQLITE"); + db.setDatabaseName("testdb.db"); + QVERIFY(db.open()); + + static const QStringList statements{ + "CREATE TABLE WithNotNull(id INTEGER PRIMARY KEY AUTOINCREMENT, data INTEGER NOT NULL)", + "INSERT INTO WithNotNull(id, data) VALUES(1, 2)", + "CREATE TABLE WithForeignKey(id INTEGER PRIMARY KEY AUTOINCREMENT, data_id INTEGER)", + "INSERT INTO WithForeignKey(id, data_id) VALUES(1, 1)", + "INSERT INTO WithForeignKey(id, data_id) VALUES(2, 1)"}; + + for (const QString& statement : statements) + { + qDebug() << "Executing" << statement; + QSqlQuery query{db}; + QVERIFY(query.exec(statement)); + QCOMPARE(query.lastError().type(), QSqlError::NoError); + } + + db.close(); + QSqlDatabase::removeDatabase(QSqlDatabase::defaultConnection); + } + + { + QOrmSession session{QOrmSessionConfiguration::fromFile(":/qtorm_update_schema.json")}; + + auto result = session.from().select(); + QCOMPARE(result.error().type(), QOrm::ErrorType::None); + auto fkData = result.toVector(); + QCOMPARE(fkData.size(), 2); + QCOMPARE(fkData[0]->id(), 1); + QCOMPARE(fkData[1]->id(), 2); + } + + { + QSqlDatabase db = QSqlDatabase::addDatabase("QSQLITE"); + db.setDatabaseName("testdb.db"); + QVERIFY(db.open()); + + QSqlQuery query{db}; + QVERIFY(query.exec(R"(SELECT 1 FROM pragma_foreign_key_list('WithForeignKey') WHERE "from" = 'data_id';)")); + QCOMPARE(query.lastError().type(), QSqlError::NoError); + QVERIFY(query.first()); + + db.close(); + QSqlDatabase::removeDatabase(QSqlDatabase::defaultConnection); + } +} void SqliteSessionTest::testRemoveInstance() { @@ -1106,6 +1161,24 @@ void SqliteSessionTest::testRemoveWithFilter() } } +void SqliteSessionTest::testForeignKeysEnabled() +{ + QOrmSqliteConfiguration sqliteConfiguration{}; + sqliteConfiguration.setForeignKeysEnabled(true); + sqliteConfiguration.setDatabaseName(":memory:"); + QOrmSqliteProvider* sqliteProvider = new QOrmSqliteProvider{sqliteConfiguration}; + QOrmSessionConfiguration sessionConfiguration{sqliteProvider, true}; + QOrmSession session{sessionConfiguration}; + + QOrmSqliteProvider* provider = + static_cast(session.configuration().provider()); + provider->connectToBackend(); + QSqlQuery query{provider->database()}; + + QVERIFY(query.exec("PRAGMA foreign_keys") && query.next()); + QCOMPARE(query.value("foreign_keys").toBool(), true); +} + QTEST_GUILESS_MAIN(SqliteSessionTest) #include "tst_ormsession.moc" diff --git a/tests/auto/qormsqlitestatementgenerator/domain/community.h b/tests/auto/qormsqlitestatementgenerator/domain/community.h index 9893ab1..f8e02fb 100644 --- a/tests/auto/qormsqlitestatementgenerator/domain/community.h +++ b/tests/auto/qormsqlitestatementgenerator/domain/community.h @@ -41,6 +41,7 @@ class Community : public QObject Q_ORM_PROPERTY(communityId COLUMN community_id IDENTITY) Q_ORM_PROPERTY(hasSmallPopulation TRANSIENT) Q_ORM_PROPERTY(neverNull NOT_NULL) + Q_ORM_PROPERTY(province FOREIGN_KEY) public: Q_INVOKABLE Community(QObject* parent = nullptr); diff --git a/tests/auto/qormsqlitestatementgenerator/tst_sqlitestatementgenerator.cpp b/tests/auto/qormsqlitestatementgenerator/tst_sqlitestatementgenerator.cpp index 6d756d0..9ce67d0 100644 --- a/tests/auto/qormsqlitestatementgenerator/tst_sqlitestatementgenerator.cpp +++ b/tests/auto/qormsqlitestatementgenerator/tst_sqlitestatementgenerator.cpp @@ -73,6 +73,7 @@ private slots: void testAlterTableAddColumn(); void testAlterTableAddColumnWithReference(); + void testAlterTableAddColumnWithForeignKey(); void testSelectWithLimitOffset(); void testSelectWithNamespace(); @@ -405,7 +406,7 @@ void SqliteStatementGenerator::testCreateTableForCustomizedEntity() QOrmMetadataCache cache; QCOMPARE( QOrmSqliteStatementGenerator{}.generateCreateTableStatement(cache.get()), - R"(CREATE TABLE "communities"("community_id" INTEGER PRIMARY KEY,"name" TEXT,"population" INTEGER,"province_id" INTEGER,"nevernull" INTEGER NOT NULL))"); + R"(CREATE TABLE "communities"("community_id" INTEGER PRIMARY KEY,"name" TEXT,"population" INTEGER,"province_id" INTEGER REFERENCES "Province" ( "id" ),"nevernull" INTEGER NOT NULL))"); } void SqliteStatementGenerator::testCreateTableWithQVariant() @@ -439,7 +440,7 @@ void SqliteStatementGenerator::testAlterTableAddColumn() QString actual = QOrmSqliteStatementGenerator{}.generateAlterTableAddColumnStatement( cache.get(), *cache.get().classPropertyMapping("name")); - QCOMPARE(actual, R"(ALTER TABLE "Person" ADD COLUMN "name" TEXT)"); + QCOMPARE(actual, R"(ALTER TABLE "Person" ADD COLUMN "name" TEXT )"); } void SqliteStatementGenerator::testAlterTableAddColumnWithReference() @@ -448,7 +449,16 @@ void SqliteStatementGenerator::testAlterTableAddColumnWithReference() QString actual = QOrmSqliteStatementGenerator{}.generateAlterTableAddColumnStatement( cache.get(), *cache.get().classPropertyMapping("province")); - QCOMPARE(actual, R"(ALTER TABLE "Town" ADD COLUMN "province_id" INTEGER)"); + QCOMPARE(actual, R"(ALTER TABLE "Town" ADD COLUMN "province_id" INTEGER )"); +} + +void SqliteStatementGenerator::testAlterTableAddColumnWithForeignKey() +{ + QOrmMetadataCache cache; + QString actual = QOrmSqliteStatementGenerator{}.generateAlterTableAddColumnStatement( + cache.get(), *cache.get().classPropertyMapping("province")); + + QCOMPARE(actual, R"(ALTER TABLE "communities" ADD COLUMN "province_id" INTEGER REFERENCES "Province" ( "id" ))"); } void SqliteStatementGenerator::testSelectWithLimitOffset() From 9e8f517d56bdd5996428c80b3da90e505833c82b Mon Sep 17 00:00:00 2001 From: Maciej Sopylo Date: Fri, 13 Mar 2026 22:44:13 +0100 Subject: [PATCH 3/5] Add support for UNIQUE constraints --- README.md | 5 +- src/orm/qormglobal.h | 3 +- src/orm/qormmetadatacache.cpp | 26 ++++- src/orm/qormpropertymapping.cpp | 28 +++++ src/orm/qormpropertymapping.h | 4 + src/orm/qormsqliteprovider.cpp | 74 +++++++++++++ src/orm/qormsqlitestatementgenerator_p.cpp | 20 ++++ tests/auto/qormsession/CMakeLists.txt | 2 + tests/auto/qormsession/domain/withunique.cpp | 26 +++++ tests/auto/qormsession/domain/withunique.h | 95 ++++++++++++++++ tests/auto/qormsession/qormsession.pro | 2 + tests/auto/qormsession/qormsession.qbs | 1 + tests/auto/qormsession/tst_ormsession.cpp | 103 +++++++++++++++++- .../domain/community.h | 17 ++- .../tst_sqlitestatementgenerator.cpp | 4 +- 15 files changed, 402 insertions(+), 8 deletions(-) create mode 100644 tests/auto/qormsession/domain/withunique.cpp create mode 100644 tests/auto/qormsession/domain/withunique.h diff --git a/README.md b/README.md index 7e219c8..cd1a8f0 100644 --- a/README.md +++ b/README.md @@ -243,14 +243,15 @@ Possible customizations: * `TRANSIENT [true|false]`: mark the property as transient * `NOT_NULL [true|false]`: mark the property as not null * `FOREIGN_KEY [true|false]`: mark the relation as having a foreign key in the generated schema + * `UNIQUE []`: mark the property as unique (properties with same `common_name` will create a compound unique index) Restrictions and requirements: * There can be only one `IDENTITY` * `IDENTITY` is required for `AUTOGENERATED` * `TRANSIENT` cannot be combined with `IDENTITY` -* Renaming columns and tables to names containing QtOrm keywords (`IDENTITY`, `COLUMN`, `TRANSIENT`, etc.) is not supported. -* Schema `Append` mode will not set `NOT_NULL` on the new column(s) +* Renaming columns and tables to names containing QtOrm keywords (`IDENTITY`, `COLUMN`, `TRANSIENT`, etc.) or the `!` and `,` characters is not supported. +* Schema `Append` mode will not set `NOT_NULL` on `UNIQUE` on the new column(s) #### Relationships diff --git a/src/orm/qormglobal.h b/src/orm/qormglobal.h index 85783ae..4b17977 100644 --- a/src/orm/qormglobal.h +++ b/src/orm/qormglobal.h @@ -157,7 +157,8 @@ namespace QOrm Transient, Schema, NotNull, - ForeignKey + ForeignKey, + Unique }; inline auto qHash(Keyword value) { diff --git a/src/orm/qormmetadatacache.cpp b/src/orm/qormmetadatacache.cpp index e1824c3..b87e98a 100644 --- a/src/orm/qormmetadatacache.cpp +++ b/src/orm/qormmetadatacache.cpp @@ -54,7 +54,8 @@ namespace {QOrm::Keyword::Transient, QLatin1String("TRANSIENT")}, {QOrm::Keyword::Autogenerated, QLatin1String("AUTOGENERATED")}, {QOrm::Keyword::NotNull, QLatin1String("NOT_NULL")}, - {QOrm::Keyword::ForeignKey, QLatin1String("FOREIGN_KEY")}}; + {QOrm::Keyword::ForeignKey, QLatin1String("FOREIGN_KEY")}, + {QOrm::Keyword::Unique, QLatin1String("UNIQUE")}}; template KeywordPosition findNextKeyword(const QString& data, @@ -335,6 +336,15 @@ namespace ormPropertyInfo.insert(QOrm::Keyword::ForeignKey, hasForeignKey.value_or(true)); } + else if (keywordPosition.keyword->id == QOrm::Keyword::Unique) + { + auto extractResult = extractString(data, pos, PropertyKeywords); + + QString uniqueGroup = extractResult.value; + keywordPosition = extractResult.nextKeyword; + + ormPropertyInfo.insert(QOrm::Keyword::Unique, uniqueGroup); + } } return ormPropertyInfo; @@ -356,6 +366,8 @@ class QOrmMetadataCachePrivate bool isEnumeration{false}; bool isNotNull = false; bool hasForeignKey = false; + bool isUnique = false; + QString uniqueGroup; QMetaType::Type dataType{QMetaType::UnknownType}; }; @@ -503,6 +515,8 @@ void QOrmMetadataCachePrivate::initialize(const QByteArray& className, descriptor.isTransient, descriptor.isNotNull, descriptor.hasForeignKey, + descriptor.isUnique, + descriptor.uniqueGroup, userPropertyMetadata); auto idx = static_cast(data->m_propertyMappings.size() - 1); @@ -537,6 +551,8 @@ QOrmMetadataCachePrivate::MappingDescriptor QOrmMetadataCachePrivate::mappingDes bool isTransient = !property.isStored(); bool isNotNull = false; bool hasForeignKey = false; + bool isUnique = false; + QString uniqueGroup; // Check if defaults are overridden by the user property metadata if (userPropertyMetadata.contains(QOrm::Keyword::Column)) @@ -570,6 +586,12 @@ QOrmMetadataCachePrivate::MappingDescriptor QOrmMetadataCachePrivate::mappingDes hasForeignKey = userPropertyMetadata.value(QOrm::Keyword::ForeignKey).toBool(); } + if (userPropertyMetadata.contains(QOrm::Keyword::Unique)) + { + isUnique = true; + uniqueGroup = userPropertyMetadata.value(QOrm::Keyword::Unique).toString(); + } + descriptor.classPropertyName = QString::fromUtf8(property.name()); descriptor.tableFieldName = tableFieldName; descriptor.isObjectId = isObjectId; @@ -577,6 +599,8 @@ QOrmMetadataCachePrivate::MappingDescriptor QOrmMetadataCachePrivate::mappingDes descriptor.isTransient = isTransient; descriptor.isNotNull = isNotNull; descriptor.hasForeignKey = hasForeignKey; + descriptor.isUnique = isUnique; + descriptor.uniqueGroup = uniqueGroup; #if QT_VERSION < QT_VERSION_CHECK(6, 0, 0) descriptor.dataType = property.type() == QVariant::UserType ? static_cast(property.userType()) diff --git a/src/orm/qormpropertymapping.cpp b/src/orm/qormpropertymapping.cpp index fc76099..c7c0b6b 100644 --- a/src/orm/qormpropertymapping.cpp +++ b/src/orm/qormpropertymapping.cpp @@ -46,6 +46,14 @@ QDebug operator<<(QDebug dbg, const QOrmPropertyMapping& propertyMapping) if (propertyMapping.hasForeignKey()) dbg << ", fk"; + if (propertyMapping.isUnique()) { + dbg << ", unique"; + + if (!propertyMapping.uniqueGroup().isEmpty()) { + dbg << "(" << propertyMapping.uniqueGroup() << ")"; + } + } + dbg << ")"; return dbg; @@ -66,6 +74,8 @@ class QOrmPropertyMappingPrivate : public QSharedData bool isTransient, bool isNotNull, bool hasForeignKey, + bool isUnique, + QString uniqueGroup, QOrmUserMetadata userMetadata) : m_enclosingEntity{enclosingEntity} , m_qMetaProperty{std::move(qMetaProperty)} @@ -78,6 +88,8 @@ class QOrmPropertyMappingPrivate : public QSharedData , m_isTransient{isTransient} , m_isNotNull{isNotNull} , m_hasForeignKey{hasForeignKey} + , m_isUnique{isUnique} + , m_uniqueGroup{uniqueGroup} , m_userMetadata{std::move(userMetadata)} { } @@ -93,6 +105,8 @@ class QOrmPropertyMappingPrivate : public QSharedData bool m_isTransient{false}; bool m_isNotNull{false}; bool m_hasForeignKey{false}; + bool m_isUnique{false}; + QString m_uniqueGroup; QOrmUserMetadata m_userMetadata; }; @@ -107,6 +121,8 @@ QOrmPropertyMapping::QOrmPropertyMapping(const QOrmMetadata& enclosingEntity, bool isTransient, bool isNotNull, bool hasForeignKey, + bool isUnique, + QString uniqueGroup, QOrmUserMetadata userMetadata) : d{new QOrmPropertyMappingPrivate{enclosingEntity, std::move(qMetaProperty), @@ -119,6 +135,8 @@ QOrmPropertyMapping::QOrmPropertyMapping(const QOrmMetadata& enclosingEntity, isTransient, isNotNull, hasForeignKey, + isUnique, + uniqueGroup, std::move(userMetadata)}} { } @@ -198,6 +216,16 @@ bool QOrmPropertyMapping::hasForeignKey() const return d->m_hasForeignKey; } +bool QOrmPropertyMapping::isUnique() const +{ + return d->m_isUnique; +} + +QString QOrmPropertyMapping::uniqueGroup() const +{ + return d->m_uniqueGroup; +} + const QOrmUserMetadata& QOrmPropertyMapping::userMetadata() const { return d->m_userMetadata; diff --git a/src/orm/qormpropertymapping.h b/src/orm/qormpropertymapping.h index cac1262..d5621fe 100644 --- a/src/orm/qormpropertymapping.h +++ b/src/orm/qormpropertymapping.h @@ -47,6 +47,8 @@ class Q_ORM_EXPORT QOrmPropertyMapping bool isTransient, bool isNotNull, bool hasForeignKey, + bool isUnique, + QString uniqueGroup, QOrmUserMetadata userMetadata); QOrmPropertyMapping(const QOrmPropertyMapping&); QOrmPropertyMapping(QOrmPropertyMapping&&); @@ -68,6 +70,8 @@ class Q_ORM_EXPORT QOrmPropertyMapping [[nodiscard]] bool isTransient() const; [[nodiscard]] bool isNotNull() const; [[nodiscard]] bool hasForeignKey() const; + [[nodiscard]] bool isUnique() const; + [[nodiscard]] QString uniqueGroup() const; [[nodiscard]] const QOrmUserMetadata& userMetadata() const; private: diff --git a/src/orm/qormsqliteprovider.cpp b/src/orm/qormsqliteprovider.cpp index 1fef02c..c73a1b4 100644 --- a/src/orm/qormsqliteprovider.cpp +++ b/src/orm/qormsqliteprovider.cpp @@ -75,6 +75,7 @@ class QOrmSqliteProviderPrivate [[nodiscard]] bool canConvertFromSqliteToQProperty(QMetaType::Type fromSqlType, QMetaType::Type toQPropertyType); [[nodiscard]] bool fieldHasForeignKey(const QSqlField& field); + [[nodiscard]] QString fieldGetUniqueConstraint(const QSqlField& field); Q_REQUIRED_RESULT QOrmError lastDatabaseError() const; @@ -153,6 +154,29 @@ bool QOrmSqliteProviderPrivate::fieldHasForeignKey(const QSqlField& field) return query.first(); } +QString QOrmSqliteProviderPrivate::fieldGetUniqueConstraint(const QSqlField& field) +{ + // This query returns a string representing the columns in a unique index for the field + // The column names are also wrapped between ! to avoid partial matches with LIKE + // This can then be used to check if the schema needs updating + QSqlQuery query = prepareAndExecute( + R"( + SELECT group_concat(printf('!%s!', index_info.name)) AS columns + FROM pragma_index_list(:table_name) AS index_list, + pragma_index_info(index_list.name) AS index_info + WHERE index_list.[unique] = 1 + GROUP BY index_list.name + HAVING columns LIKE printf('%%!%s!%%', :column))", + {{":table_name", field.tableName()},{":column", field.name()}}); + + if (!query.first()) + { + return ""; + } + + return query.record().field("columns").value().toString(); +} + QOrmError QOrmSqliteProviderPrivate::lastDatabaseError() const { return QOrmError{QOrm::ErrorType::Provider, m_database.lastError().text()}; @@ -614,6 +638,56 @@ QOrmError QOrmSqliteProviderPrivate::updateSchema(const QOrmRelation& relation) << ") differs from the mapping (" << mapping->isNotNull() << ")"; updateNeeded = true; } + else + { + QString fieldUniqueColumns = fieldGetUniqueConstraint(field); + + if (mapping->isUnique() != !fieldUniqueColumns.isEmpty() || + (mapping->isUnique() && mapping->uniqueGroup().isEmpty() && fieldUniqueColumns != QString("!%1!").arg(field.name()))) + { + qCDebug(qtorm).noquote().nospace() + << "updating table " << relation.mapping()->tableName() << ": field " + << field.name() << " unique constraint differs from the mapping (" + << mapping->isUnique() << "," << mapping->uniqueGroup() << ")"; + updateNeeded = true; + } + else if (mapping->isUnique() && !mapping->uniqueGroup().isEmpty()) + { + QStringList columnsInGroup = fieldUniqueColumns.split(","); + for (auto& column : columnsInGroup) + column.remove('!'); + + for (const auto& column : columnsInGroup) { + const QOrmPropertyMapping* groupColumnMapping = + relation.mapping()->tableFieldMapping(column); + + if (groupColumnMapping == nullptr || !groupColumnMapping->isUnique() || + groupColumnMapping->uniqueGroup() != mapping->uniqueGroup()) + { + qCDebug(qtorm).noquote().nospace() + << "updating table " << relation.mapping()->tableName() << ": field " + << column << " should be in the same unique constraint as " + << field.name(); + updateNeeded = true; + break; + } + } + + for (const auto& propertyMapping : relation.mapping()->propertyMappings()) + { + if (propertyMapping.isUnique() && propertyMapping.uniqueGroup() == mapping->uniqueGroup() && + !columnsInGroup.contains(propertyMapping.tableFieldName())) + { + qCDebug(qtorm).noquote().nospace() + << "updating table " << relation.mapping()->tableName() << ": field " + << propertyMapping.tableFieldName() << " should be in the same unique constraint as " + << field.name(); + updateNeeded = true; + break; + } + } + } + } } // Check if there are non-transient class properties that are not mapped in the database. diff --git a/src/orm/qormsqlitestatementgenerator_p.cpp b/src/orm/qormsqlitestatementgenerator_p.cpp index bdd5c8c..ad4265e 100644 --- a/src/orm/qormsqlitestatementgenerator_p.cpp +++ b/src/orm/qormsqlitestatementgenerator_p.cpp @@ -512,6 +512,7 @@ QString QOrmSqliteStatementGenerator::generateCreateTableStatement( std::optional overrideTableName) { QStringList fields; + QMap uniqueGroups; for (const QOrmPropertyMapping& mapping : entity.propertyMappings()) { @@ -549,9 +550,28 @@ QString QOrmSqliteStatementGenerator::generateCreateTableStatement( if (mapping.isNotNull()) columnDefs.push_back(QStringLiteral("NOT NULL")); + if (mapping.isUnique()) + { + if (mapping.uniqueGroup().isEmpty()) + { + columnDefs.push_back(QStringLiteral("UNIQUE")); + } + else + { + uniqueGroups[mapping.uniqueGroup()].push_back(escapeIdentifier(mapping.tableFieldName())); + } + } + fields.push_back(columnDefs.join(' ')); } + for (const QStringList& columns : std::as_const(uniqueGroups)) { + QStringList uniqueDef = {QStringLiteral("UNIQUE("), + columns.join(','), + QStringLiteral(")")}; + fields.push_back(uniqueDef.join(' ')); + } + QString fieldsStr = fields.join(','); Q_ASSERT(!overrideTableName.has_value() || !overrideTableName->isEmpty()); diff --git a/tests/auto/qormsession/CMakeLists.txt b/tests/auto/qormsession/CMakeLists.txt index 102fad8..6d827c9 100644 --- a/tests/auto/qormsession/CMakeLists.txt +++ b/tests/auto/qormsession/CMakeLists.txt @@ -6,12 +6,14 @@ qtorm_add_unit_test(NAME tst_ormsession SOURCES domain/town.cpp domain/withnotnull.cpp domain/withforeignkey.cpp + domain/withunique.cpp domain/person.h domain/province.h domain/town.h domain/withnotnull.h domain/withforeignkey.h + domain/withunique.h ormsession.qrc diff --git a/tests/auto/qormsession/domain/withunique.cpp b/tests/auto/qormsession/domain/withunique.cpp new file mode 100644 index 0000000..5ceccd3 --- /dev/null +++ b/tests/auto/qormsession/domain/withunique.cpp @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2019-2021 Dmitriy Purgin + * Copyright (C) 2026 Maciej Sopyło + * + * This file is part of QtOrm library. + * + * QtOrm is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * QtOrm is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with QtOrm. If not, see . + */ + +#include "withunique.h" + +WithUnique::WithUnique(QObject* parent) + : QObject{parent} +{ +} diff --git a/tests/auto/qormsession/domain/withunique.h b/tests/auto/qormsession/domain/withunique.h new file mode 100644 index 0000000..1c88b15 --- /dev/null +++ b/tests/auto/qormsession/domain/withunique.h @@ -0,0 +1,95 @@ +/* + * Copyright (C) 2019-2021 Dmitriy Purgin + * Copyright (C) 2019-2021 sequality software engineering e.U. + * Copyright (C) 2026 Maciej Sopyło + * + * This file is part of QtOrm library. + * + * QtOrm is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * QtOrm is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with QtOrm. If not, see . + */ + +#pragma once + +#include +#include + +#include + +class WithUnique: public QObject +{ + Q_OBJECT + + Q_PROPERTY(int id READ id WRITE setId NOTIFY idChanged) + Q_PROPERTY(QString single READ single WRITE setSingle NOTIFY singleChanged) + Q_PROPERTY(QString multiOne READ multiOne WRITE setMultiOne NOTIFY multiOneChanged) + Q_PROPERTY(QString multiTwo READ multiTwo WRITE setMultiTwo NOTIFY multiTwoChanged) + + Q_ORM_PROPERTY(single UNIQUE) + Q_ORM_PROPERTY(multiOne UNIQUE multi) + Q_ORM_PROPERTY(multiTwo UNIQUE multi) + +public: + Q_INVOKABLE WithUnique(QObject* parent = nullptr); + + [[nodiscard]] int id() const { return m_id; } + void setId(int id) + { + if (m_id != id) + { + m_id = id; + emit idChanged(); + } + } + + [[nodiscard]] QString single() const { return m_single; } + void setSingle(QString single) + { + if (m_single != single) + { + m_single = single; + emit singleChanged(); + } + } + + [[nodiscard]] QString multiOne() const { return m_multiOne; } + void setMultiOne(QString multiOne) + { + if (m_multiOne != multiOne) + { + m_multiOne = multiOne; + emit multiOneChanged(); + } + } + + [[nodiscard]] QString multiTwo() const { return m_multiTwo; } + void setMultiTwo(QString multiTwo) + { + if (m_multiTwo != multiTwo) + { + m_multiTwo = multiTwo; + emit multiTwoChanged(); + } + } +signals: + void idChanged(); + void singleChanged(); + void multiOneChanged(); + void multiTwoChanged(); + +private: + int m_id{0}; + QString m_single; + QString m_multiOne; + QString m_multiTwo; +}; diff --git a/tests/auto/qormsession/qormsession.pro b/tests/auto/qormsession/qormsession.pro index 288e9b4..0d98ae8 100644 --- a/tests/auto/qormsession/qormsession.pro +++ b/tests/auto/qormsession/qormsession.pro @@ -10,6 +10,7 @@ SOURCES += tst_ormsession.cpp \ domain/person.cpp \ domain/withnotnull.cpp \ domain/withforeignkey.cpp \ + domain/withunique.cpp \ HEADERS += \ domain/province.h \ @@ -17,5 +18,6 @@ HEADERS += \ domain/person.h \ domain/withnotnull.h \ domain/withforeignkey.h \ + domain/withunique.h \ RESOURCES += ormsession.qrc diff --git a/tests/auto/qormsession/qormsession.qbs b/tests/auto/qormsession/qormsession.qbs index 4f89b39..dd524c5 100644 --- a/tests/auto/qormsession/qormsession.qbs +++ b/tests/auto/qormsession/qormsession.qbs @@ -12,6 +12,7 @@ QtApplication { "domain/town.cpp", "domain/town.h", "domain/withnotnull.cpp", "domain/withnotnull.h", "domain/withforeignkey.cpp", "domain/withforeignkey.h", + "domain/withunique.cpp", "domain/withunique.h", "tst_ormsession.cpp", "ormsession.qrc"] } diff --git a/tests/auto/qormsession/tst_ormsession.cpp b/tests/auto/qormsession/tst_ormsession.cpp index 6aafab5..b84a355 100644 --- a/tests/auto/qormsession/tst_ormsession.cpp +++ b/tests/auto/qormsession/tst_ormsession.cpp @@ -38,6 +38,7 @@ #include "domain/town.h" #include "domain/withnotnull.h" #include "domain/withforeignkey.h" +#include "domain/withunique.h" #include "private/qormglobal_p.h" @@ -87,6 +88,8 @@ private slots: void testSchemaUpdateRemovesColumns(); void testSchemaUpdateUpdatesNotNull(); void testSchemaUpdateUpdatesForeignKey(); + void testSchemaUpdateUpdatesUnique(); + void testSchemaUpdateUpdatesUniqueMulti(); void testForeignKeysEnabled(); }; @@ -106,7 +109,7 @@ void SqliteSessionTest::init() if (db.exists()) QVERIFY(db.remove()); - qRegisterOrmEntity(); + qRegisterOrmEntity(); } void SqliteSessionTest::cleanup() @@ -1087,6 +1090,104 @@ void SqliteSessionTest::testSchemaUpdateUpdatesForeignKey() } } +void SqliteSessionTest::testSchemaUpdateUpdatesUnique() +{ + { + QSqlDatabase db = QSqlDatabase::addDatabase("QSQLITE"); + db.setDatabaseName("testdb.db"); + QVERIFY(db.open()); + + static const QStringList statements{ + "CREATE TABLE WithUnique(id INTEGER PRIMARY KEY AUTOINCREMENT, single TEXT, multione TEXT, multitwo TEXT, UNIQUE(multione, multitwo))", + "INSERT INTO WithUnique(id, single, multione, multitwo) VALUES(1, 'test', 'test', 'test')", + "INSERT INTO WithUnique(id, single, multione, multitwo) VALUES(2, 'test2', 'test', 'test2')"}; + + for (const QString& statement : statements) + { + qDebug() << "Executing" << statement; + QSqlQuery query{db}; + QVERIFY(query.exec(statement)); + QCOMPARE(query.lastError().type(), QSqlError::NoError); + } + + db.close(); + QSqlDatabase::removeDatabase(QSqlDatabase::defaultConnection); + } + + { + QOrmSession session{QOrmSessionConfiguration::fromFile(":/qtorm_update_schema.json")}; + + auto result = session.from().select(); + QCOMPARE(result.error().type(), QOrm::ErrorType::None); + auto fkData = result.toVector(); + QCOMPARE(fkData.size(), 2); + QCOMPARE(fkData[0]->id(), 1); + QCOMPARE(fkData[1]->id(), 2); + } + + { + QSqlDatabase db = QSqlDatabase::addDatabase("QSQLITE"); + db.setDatabaseName("testdb.db"); + QVERIFY(db.open()); + + QSqlQuery query{db}; + QVERIFY(!query.exec("INSERT INTO WithUnique(id, single, multione, multitwo) VALUES(3, 'test', 'test', 'test3')")); + QCOMPARE(query.lastError().type(), QSqlError::ConnectionError); + + db.close(); + QSqlDatabase::removeDatabase(QSqlDatabase::defaultConnection); + } +} + +void SqliteSessionTest::testSchemaUpdateUpdatesUniqueMulti() +{ + { + QSqlDatabase db = QSqlDatabase::addDatabase("QSQLITE"); + db.setDatabaseName("testdb.db"); + QVERIFY(db.open()); + + static const QStringList statements{ + "CREATE TABLE WithUnique(id INTEGER PRIMARY KEY AUTOINCREMENT, single TEXT UNIQUE, multione TEXT UNIQUE, multitwo TEXT)", + "INSERT INTO WithUnique(id, single, multione, multitwo) VALUES(1, 'test', 'test', 'test')", + "INSERT INTO WithUnique(id, single, multione, multitwo) VALUES(2, 'test2', 'test2', 'test')"}; + + for (const QString& statement : statements) + { + qDebug() << "Executing" << statement; + QSqlQuery query{db}; + QVERIFY(query.exec(statement)); + QCOMPARE(query.lastError().type(), QSqlError::NoError); + } + + db.close(); + QSqlDatabase::removeDatabase(QSqlDatabase::defaultConnection); + } + + { + QOrmSession session{QOrmSessionConfiguration::fromFile(":/qtorm_update_schema.json")}; + + auto result = session.from().select(); + QCOMPARE(result.error().type(), QOrm::ErrorType::None); + auto fkData = result.toVector(); + QCOMPARE(fkData.size(), 2); + QCOMPARE(fkData[0]->id(), 1); + QCOMPARE(fkData[1]->id(), 2); + } + + { + QSqlDatabase db = QSqlDatabase::addDatabase("QSQLITE"); + db.setDatabaseName("testdb.db"); + QVERIFY(db.open()); + + QSqlQuery query{db}; + QVERIFY(query.exec("INSERT INTO WithUnique(id, single, multione, multitwo) VALUES(3, 'test3', 'test', 'test3')")); + QCOMPARE(query.lastError().type(), QSqlError::NoError); + + db.close(); + QSqlDatabase::removeDatabase(QSqlDatabase::defaultConnection); + } +} + void SqliteSessionTest::testRemoveInstance() { QOrmSession session; diff --git a/tests/auto/qormsqlitestatementgenerator/domain/community.h b/tests/auto/qormsqlitestatementgenerator/domain/community.h index f8e02fb..3ad2de6 100644 --- a/tests/auto/qormsqlitestatementgenerator/domain/community.h +++ b/tests/auto/qormsqlitestatementgenerator/domain/community.h @@ -34,14 +34,17 @@ class Community : public QObject Q_PROPERTY(int population READ population WRITE setPopulation NOTIFY populationChanged) Q_PROPERTY(Province* province READ province WRITE setProvince NOTIFY provinceChanged) Q_PROPERTY(int neverNull READ neverNull WRITE setNeverNull NOTIFY neverNullChanged) + Q_PROPERTY(QString code READ code WRITE setCode NOTIFY codeChanged) Q_PROPERTY(bool hasLargePopulation READ hasLargePopulation STORED false) Q_PROPERTY(bool hasSmallPopulation READ hasSmallPopulation) Q_ORM_CLASS(TABLE communities) + Q_ORM_PROPERTY(name UNIQUE nameProvince) Q_ORM_PROPERTY(communityId COLUMN community_id IDENTITY) Q_ORM_PROPERTY(hasSmallPopulation TRANSIENT) Q_ORM_PROPERTY(neverNull NOT_NULL) - Q_ORM_PROPERTY(province FOREIGN_KEY) + Q_ORM_PROPERTY(province FOREIGN_KEY UNIQUE nameProvince) + Q_ORM_PROPERTY(code UNIQUE) public: Q_INVOKABLE Community(QObject* parent = nullptr); @@ -96,6 +99,16 @@ class Community : public QObject } } + QString code() const { return m_code; } + void setCode(QString code) + { + if (m_code != code) + { + m_code = code; + emit codeChanged(); + } + } + bool hasLargePopulation() const { return m_population > 5000; } bool hasSmallPopulation() const { return !hasLargePopulation(); } @@ -105,6 +118,7 @@ class Community : public QObject void populationChanged(); void provinceChanged(); void neverNullChanged(); + void codeChanged(); private: long m_communityId{0}; @@ -112,4 +126,5 @@ class Community : public QObject int m_population{0}; Province* m_province{nullptr}; int m_neverNull{1}; + QString m_code; }; diff --git a/tests/auto/qormsqlitestatementgenerator/tst_sqlitestatementgenerator.cpp b/tests/auto/qormsqlitestatementgenerator/tst_sqlitestatementgenerator.cpp index 9ce67d0..4a5f139 100644 --- a/tests/auto/qormsqlitestatementgenerator/tst_sqlitestatementgenerator.cpp +++ b/tests/auto/qormsqlitestatementgenerator/tst_sqlitestatementgenerator.cpp @@ -154,7 +154,7 @@ void SqliteStatementGenerator::testInsertForCustomizedEntity() QCOMPARE( statement, - R"(INSERT INTO "communities"("community_id","name","population","province_id","nevernull") VALUES(:community_id,:name,:population,:province_id,:nevernull))"); + R"(INSERT INTO "communities"("community_id","name","population","province_id","nevernull","code") VALUES(:community_id,:name,:population,:province_id,:nevernull,:code))"); QCOMPARE(boundParameters[":community_id"], 4232); QCOMPARE(boundParameters[":name"], "Hagenberg"); QCOMPARE(boundParameters[":population"], 4000); @@ -406,7 +406,7 @@ void SqliteStatementGenerator::testCreateTableForCustomizedEntity() QOrmMetadataCache cache; QCOMPARE( QOrmSqliteStatementGenerator{}.generateCreateTableStatement(cache.get()), - R"(CREATE TABLE "communities"("community_id" INTEGER PRIMARY KEY,"name" TEXT,"population" INTEGER,"province_id" INTEGER REFERENCES "Province" ( "id" ),"nevernull" INTEGER NOT NULL))"); + R"(CREATE TABLE "communities"("community_id" INTEGER PRIMARY KEY,"name" TEXT,"population" INTEGER,"province_id" INTEGER REFERENCES "Province" ( "id" ),"nevernull" INTEGER NOT NULL,"code" TEXT UNIQUE,UNIQUE( "name","province_id" )))"); } void SqliteStatementGenerator::testCreateTableWithQVariant() From 5952e73e4ab8258fe1e1d8b345cf39c6a5ea18e2 Mon Sep 17 00:00:00 2001 From: Maciej Sopylo Date: Thu, 12 Mar 2026 22:24:51 +0100 Subject: [PATCH 4/5] Escape table name in UPDATE --- src/orm/qormsqlitestatementgenerator_p.cpp | 2 +- .../tst_sqlitestatementgenerator.cpp | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/orm/qormsqlitestatementgenerator_p.cpp b/src/orm/qormsqlitestatementgenerator_p.cpp index ad4265e..af91c2d 100644 --- a/src/orm/qormsqlitestatementgenerator_p.cpp +++ b/src/orm/qormsqlitestatementgenerator_p.cpp @@ -204,7 +204,7 @@ QString QOrmSqliteStatementGenerator::generateUpdateStatement(const QOrmMetadata QString whereClause = generateWhereClause(QOrmFilter{*relation.objectIdMapping() == objectId}, boundParameters); - QStringList parts = {"UPDATE", relation.tableName(), "SET", setList.join(','), whereClause}; + QStringList parts = {"UPDATE", escapeIdentifier(relation.tableName()), "SET", setList.join(','), whereClause}; return parts.join(QChar(' ')); } diff --git a/tests/auto/qormsqlitestatementgenerator/tst_sqlitestatementgenerator.cpp b/tests/auto/qormsqlitestatementgenerator/tst_sqlitestatementgenerator.cpp index 4a5f139..90641c3 100644 --- a/tests/auto/qormsqlitestatementgenerator/tst_sqlitestatementgenerator.cpp +++ b/tests/auto/qormsqlitestatementgenerator/tst_sqlitestatementgenerator.cpp @@ -301,7 +301,7 @@ void SqliteStatementGenerator::testUpdateWithManyToOne() upperAustria.get(), boundParameters); - QCOMPARE(statement, R"(UPDATE Province SET name = :name WHERE "id" = :id)"); + QCOMPARE(statement, R"(UPDATE "Province" SET name = :name WHERE "id" = :id)"); QCOMPARE(boundParameters[":name"], QString::fromUtf8("Oberösterreich")); QCOMPARE(boundParameters[":id"], 1); } @@ -319,7 +319,7 @@ void SqliteStatementGenerator::testUpdateWithOneToMany() generator.generateUpdateStatement(cache.get(), hagenberg.get(), boundParameters); QCOMPARE(statement, - R"(UPDATE Town SET name = :name,province_id = :province_id WHERE "id" = :id)"); + R"(UPDATE "Town" SET name = :name,province_id = :province_id WHERE "id" = :id)"); QCOMPARE(boundParameters[":name"], QString::fromUtf8("Hagenberg")); QCOMPARE(boundParameters[":province_id"], 1); QCOMPARE(boundParameters[":id"], 2); @@ -337,7 +337,7 @@ void SqliteStatementGenerator::testUpdateWithOneToManyNullReference() generator.generateUpdateStatement(cache.get(), hagenberg.get(), boundParameters); QCOMPARE(statement, - R"(UPDATE Town SET name = :name,province_id = :province_id WHERE "id" = :id)"); + R"(UPDATE "Town" SET name = :name,province_id = :province_id WHERE "id" = :id)"); QCOMPARE(boundParameters[":name"], QString::fromUtf8("Hagenberg")); QCOMPARE(boundParameters[":province_id"], QVariant::fromValue(nullptr)); QCOMPARE(boundParameters[":id"], 2); From 095bca72990c0fee5c9f654f3dbb18afafe69683 Mon Sep 17 00:00:00 2001 From: Maciej Sopylo Date: Thu, 12 Mar 2026 20:01:06 +0100 Subject: [PATCH 5/5] Escape table name in CREATE TABLE even if overwritten --- src/orm/qormsqlitestatementgenerator_p.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/orm/qormsqlitestatementgenerator_p.cpp b/src/orm/qormsqlitestatementgenerator_p.cpp index af91c2d..6c7dc44 100644 --- a/src/orm/qormsqlitestatementgenerator_p.cpp +++ b/src/orm/qormsqlitestatementgenerator_p.cpp @@ -575,9 +575,9 @@ QString QOrmSqliteStatementGenerator::generateCreateTableStatement( QString fieldsStr = fields.join(','); Q_ASSERT(!overrideTableName.has_value() || !overrideTableName->isEmpty()); - QString effectiveTableName{overrideTableName.value_or(escapeIdentifier(entity.tableName()))}; + QString effectiveTableName{overrideTableName.value_or(entity.tableName())}; - return QStringLiteral("CREATE TABLE %1(%2)").arg(effectiveTableName, fieldsStr); + return QStringLiteral("CREATE TABLE %1(%2)").arg(escapeIdentifier(effectiveTableName), fieldsStr); } QString QOrmSqliteStatementGenerator::generateAlterTableAddColumnStatement(