diff --git a/docs/README.md b/docs/README.md index a412d4e..1b86101 100644 --- a/docs/README.md +++ b/docs/README.md @@ -42,6 +42,7 @@ Welcome to the sqlgen documentation. This guide provides detailed information ab - [sqlgen::ForeignKey](foreign_key.md) - How to establish referential integrity between tables - [sqlgen::Pattern](pattern.md) - How to add regex pattern validation to avoid SQL injection - [sqlgen::Timestamp](timestamp.md) - How timestamps work in sqlgen +- [sqlgen::Unique](unique.md) - How to enforce uniqueness constraints on table columns - [sqlgen::Varchar](varchar.md) - How varchars work in sqlgen ## Other concepts diff --git a/docs/unique.md b/docs/unique.md new file mode 100644 index 0000000..b9f612a --- /dev/null +++ b/docs/unique.md @@ -0,0 +1,145 @@ +# `sqlgen::Unique` + +`sqlgen::Unique` is used to enforce uniqueness constraints on table columns. It ensures that no two rows in a table can have the same value for the specified column, providing data integrity at the database level. + +## Usage + +### Basic Definition + +Define a unique field in your struct by wrapping the type with `sqlgen::Unique`: + +```cpp +struct Person { + sqlgen::PrimaryKey id; + std::string first_name; + std::string last_name; + sqlgen::Unique age; +}; +``` + +This generates the following SQL schema: + +```sql +CREATE TABLE IF NOT EXISTS "Person"( + "id" INTEGER NOT NULL, + "first_name" TEXT NOT NULL, + "last_name" TEXT NOT NULL, + "age" NUMERIC NOT NULL UNIQUE, + PRIMARY KEY("id") +); +``` + +### Template Parameters + +The `sqlgen::Unique` template takes one parameter: + +1. **T**: The type of the unique field (any supported SQL data type) + +```cpp +sqlgen::Unique field_name; +``` + +### Type Safety and Validation + +`sqlgen::Unique` provides compile-time validation and runtime uniqueness enforcement: + +1. **Type Safety**: The wrapped type must be a supported SQL data type +2. **Database-Level Enforcement**: The database enforces uniqueness constraints +3. **Insert Validation**: You cannot insert duplicate values for unique fields + +```cpp +struct Person { + sqlgen::PrimaryKey id; + sqlgen::Unique first_name; // Each first_name must be unique + std::string last_name; + double age; +}; +``` + +### Assignment and Access + +Assign values to unique fields: + +```cpp +const auto person = Person{ + .id = 1, + .first_name = "Homer", // Must be unique across all Person records + .last_name = "Simpson", + .age = 45 +}; +``` + +Access the underlying value using any of these methods: + +```cpp +person.first_name(); +person.first_name.get(); +person.first_name.value(); +``` + +### Working with Unique Constraints + +Unique constraints are useful for ensuring data integrity in various scenarios: + +```cpp +struct User { + sqlgen::PrimaryKey id; + sqlgen::Unique email; // Each email must be unique + sqlgen::Unique username; // Each username must be unique + std::string full_name; + std::string password_hash; +}; + +// Insert user records +auto users = std::vector({ + User{.id = 1, .email = "homer@simpson.com", .username = "homer", .full_name = "Homer Simpson", .password_hash = "hash1"}, + User{.id = 2, .email = "marge@simpson.com", .username = "marge", .full_name = "Marge Simpson", .password_hash = "hash2"} +}); + +// Write to database - duplicate emails or usernames would cause an error +conn.and_then(create_table | if_not_exists) + .and_then(insert(std::ref(users))); +``` + +### Combining with Other Constraints + +You can combine `sqlgen::Unique` with other constraints and types: + +```cpp +struct Employee { + sqlgen::PrimaryKey id; + sqlgen::Unique employee_id; // Unique employee ID + sqlgen::Unique email; // Unique email address + std::string name; + sqlgen::Unique salary; // Unique salary (if applicable) +}; +``` + +### Database-Specific Behavior + +The generated SQL will be consistent across all supported databases: + +```sql +-- PostgreSQL +CREATE TABLE IF NOT EXISTS "Person" ("id" INTEGER NOT NULL, "first_name" TEXT NOT NULL UNIQUE, "last_name" TEXT NOT NULL, "age" NUMERIC NOT NULL, PRIMARY KEY ("id")); + +-- MySQL +CREATE TABLE IF NOT EXISTS `Person` (`id` BIGINT NOT NULL, `first_name` TEXT NOT NULL, `last_name` TEXT NOT NULL, `age` DECIMAL NOT NULL UNIQUE, PRIMARY KEY (`id`)); + +-- SQLite +CREATE TABLE IF NOT EXISTS "Person" ("id" INTEGER PRIMARY KEY NOT NULL, "first_name" TEXT NOT NULL UNIQUE, "last_name" TEXT NOT NULL, "age" REAL NOT NULL); +``` + +## Notes + +- The template parameter specifies the type of the unique field +- The class supports: + - Direct value assignment + - Multiple access methods for the underlying value + - Reflection for SQL operations + - Move and copy semantics + - Compile-time type validation +- Unique constraints can be used with any supported SQL data type +- Database-level enforcement ensures data integrity across concurrent operations +- Unique constraints are useful for business rules like unique emails, usernames, or SKU codes + diff --git a/include/sqlgen.hpp b/include/sqlgen.hpp index a35b45c..87fdbf3 100644 --- a/include/sqlgen.hpp +++ b/include/sqlgen.hpp @@ -14,6 +14,7 @@ #include "sqlgen/Result.hpp" #include "sqlgen/Session.hpp" #include "sqlgen/Timestamp.hpp" +#include "sqlgen/Unique.hpp" #include "sqlgen/Varchar.hpp" #include "sqlgen/aggregations.hpp" #include "sqlgen/as.hpp" diff --git a/include/sqlgen/Unique.hpp b/include/sqlgen/Unique.hpp new file mode 100644 index 0000000..1a1b205 --- /dev/null +++ b/include/sqlgen/Unique.hpp @@ -0,0 +1,91 @@ +#ifndef SQLGEN_UNIQUE_HPP_ +#define SQLGEN_UNIQUE_HPP_ + +#include +#include + +#include "Literal.hpp" +#include "transpilation/Col.hpp" +#include "transpilation/all_columns_exist.hpp" +#include "transpilation/is_primary_key.hpp" +#include "transpilation/remove_reflection_t.hpp" +#include "transpilation/underlying_t.hpp" + +namespace sqlgen { + +template +struct Unique { + using ReflectionType = T; + + Unique() : value_(T()) {} + + Unique(const T& _value) : value_(_value) {} + + Unique(Unique&& _other) noexcept = default; + + Unique(const Unique& _other) = default; + + template , + bool>::type = true> + Unique(const U& _value) : value_(_value) {} + + template , + bool>::type = true> + Unique(U&& _value) noexcept : value_(std::forward(_value)) {} + + ~Unique() = default; + + /// Returns the underlying object. + ReflectionType& get() { return value_; } + + /// Returns the underlying object. + const ReflectionType& get() const { return value_; } + + /// Returns the underlying object. + ReflectionType& operator()() { return value_; } + + /// Returns the underlying object. + const ReflectionType& operator()() const { return value_; } + + /// Assigns the underlying object. + auto& operator=(const ReflectionType& _value) { + value_ = _value; + return *this; + } + + /// Assigns the underlying object. + template , + bool>::type = true> + auto& operator=(const U& _value) { + value_ = _value; + return *this; + } + + /// Assigns the underlying object. + Unique& operator=(const Unique& _other) = default; + + /// Assigns the underlying object. + Unique& operator=(Unique&& _other) = default; + + /// Necessary for the automated transpilation to work. + const T& reflection() const { return value_; } + + /// Assigns the underlying object. + void set(const T& _value) { value_ = _value; } + + /// Returns the underlying object. + T& value() { return value_; } + + /// Returns the underlying object. + const T& value() const { return value_; } + + /// The underlying value. + T value_; +}; + +} // namespace sqlgen + +#endif diff --git a/include/sqlgen/dynamic/types.hpp b/include/sqlgen/dynamic/types.hpp index fd73e23..500faef 100644 --- a/include/sqlgen/dynamic/types.hpp +++ b/include/sqlgen/dynamic/types.hpp @@ -16,6 +16,7 @@ struct Properties { bool auto_incr = false; bool primary = false; bool nullable = false; + bool unique = false; std::optional foreign_key_reference = std::nullopt; }; diff --git a/include/sqlgen/parsing/Parser.hpp b/include/sqlgen/parsing/Parser.hpp index 40fe5dd..2179d93 100644 --- a/include/sqlgen/parsing/Parser.hpp +++ b/include/sqlgen/parsing/Parser.hpp @@ -9,6 +9,7 @@ #include "Parser_shared_ptr.hpp" #include "Parser_string.hpp" #include "Parser_timestamp.hpp" +#include "Parser_unique.hpp" #include "Parser_unique_ptr.hpp" #include "Parser_varchar.hpp" diff --git a/include/sqlgen/parsing/Parser_unique.hpp b/include/sqlgen/parsing/Parser_unique.hpp new file mode 100644 index 0000000..dd4200c --- /dev/null +++ b/include/sqlgen/parsing/Parser_unique.hpp @@ -0,0 +1,37 @@ +#ifndef SQLGEN_PARSING_PARSER_UNIQUE_HPP_ +#define SQLGEN_PARSING_PARSER_UNIQUE_HPP_ + +#include +#include + +#include "../Result.hpp" +#include "../Unique.hpp" +#include "../dynamic/Type.hpp" +#include "Parser_base.hpp" + +namespace sqlgen::parsing { + +template +struct Parser> { + static Result> read( + const std::optional& _str) noexcept { + return Parser>::read(_str).transform( + [](auto&& _t) { return Unique(std::move(_t)); }); + } + + static std::optional write(const Unique& _f) noexcept { + return Parser>::write(_f.value()); + } + + static dynamic::Type to_type() noexcept { + return Parser>::to_type().visit( + [](auto _t) -> dynamic::Type { + _t.properties.unique = true; + return _t; + }); + } +}; + +} // namespace sqlgen::parsing + +#endif diff --git a/src/sqlgen/mysql/to_sql.cpp b/src/sqlgen/mysql/to_sql.cpp index 6d6e935..e67730e 100644 --- a/src/sqlgen/mysql/to_sql.cpp +++ b/src/sqlgen/mysql/to_sql.cpp @@ -689,13 +689,15 @@ std::string operation_to_sql(const dynamic::Operation& _stmt) noexcept { } std::string properties_to_sql(const dynamic::types::Properties& _p) noexcept { - if (_p.auto_incr) { - return " AUTO_INCREMENT"; - } else if (!_p.nullable) { - return " NOT NULL"; - } else { - return ""; - } + return [&]() -> std::string { + if (_p.auto_incr) { + return " AUTO_INCREMENT"; + } else if (!_p.nullable) { + return " NOT NULL"; + } else { + return ""; + } + }() + [&]() -> std::string { return _p.unique ? " UNIQUE" : ""; }(); } std::string select_from_to_sql(const dynamic::SelectFrom& _stmt) noexcept { diff --git a/src/sqlgen/postgres/to_sql.cpp b/src/sqlgen/postgres/to_sql.cpp index 81fec8c..861cea0 100644 --- a/src/sqlgen/postgres/to_sql.cpp +++ b/src/sqlgen/postgres/to_sql.cpp @@ -573,13 +573,9 @@ std::string operation_to_sql(const dynamic::Operation& _stmt) noexcept { std::string properties_to_sql(const dynamic::types::Properties& _p) noexcept { return [&]() -> std::string { - if (_p.auto_incr) { - return " GENERATED ALWAYS AS IDENTITY"; - } else if (!_p.nullable) { - return " NOT NULL"; - } else { - return ""; - } + return std::string(_p.auto_incr ? " GENERATED ALWAYS AS IDENTITY" : "") + + std::string(_p.nullable ? "" : " NOT NULL") + + std::string(_p.unique ? " UNIQUE" : ""); }() + [&]() -> std::string { if (!_p.foreign_key_reference) { return ""; diff --git a/src/sqlgen/sqlite/to_sql.cpp b/src/sqlgen/sqlite/to_sql.cpp index 6f6373d..d7297c8 100644 --- a/src/sqlgen/sqlite/to_sql.cpp +++ b/src/sqlgen/sqlite/to_sql.cpp @@ -582,7 +582,8 @@ std::string properties_to_sql(const dynamic::types::Properties& _p) noexcept { return [&]() -> std::string { return std::string(_p.primary ? " PRIMARY KEY" : "") + std::string(_p.auto_incr ? " AUTOINCREMENT" : "") + - std::string(_p.nullable ? "" : " NOT NULL"); + std::string(_p.nullable ? "" : " NOT NULL") + + std::string(_p.unique ? " UNIQUE" : ""); }() + [&]() -> std::string { if (!_p.foreign_key_reference) { return ""; diff --git a/tests/mysql/test_unique.cpp b/tests/mysql/test_unique.cpp new file mode 100644 index 0000000..868179f --- /dev/null +++ b/tests/mysql/test_unique.cpp @@ -0,0 +1,54 @@ +#ifndef SQLGEN_BUILD_DRY_TESTS_ONLY + +#include + +#include +#include +#include +#include +#include +#include + +namespace test_unique { + +struct Person { + sqlgen::PrimaryKey id; + std::string first_name; + std::string last_name; + sqlgen::Unique age; +}; + +TEST(mysql, test_unique) { + const auto people = std::vector( + {Person{ + .id = 0, .first_name = "Homer", .last_name = "Simpson", .age = 45}, + Person{ + .id = 1, .first_name = "Marge", .last_name = "Simpson", .age = 40}, + Person{.id = 2, .first_name = "Bart", .last_name = "Simpson", .age = 10}, + Person{.id = 3, .first_name = "Lisa", .last_name = "Simpson", .age = 8}, + Person{ + .id = 4, .first_name = "Maggie", .last_name = "Simpson", .age = 0}}); + + const auto credentials = sqlgen::mysql::Credentials{.host = "localhost", + .user = "sqlgen", + .password = "password", + .dbname = "mysql"}; + + using namespace sqlgen; + using namespace sqlgen::literals; + + mysql::connect(credentials) + .and_then(drop | if_exists) + .and_then(create_table) + .and_then(insert(std::ref(people))) + .value(); + + const std::string expected_query = + R"(CREATE TABLE IF NOT EXISTS `Person` (`id` BIGINT NOT NULL, `first_name` TEXT NOT NULL, `last_name` TEXT NOT NULL, `age` DECIMAL NOT NULL UNIQUE, PRIMARY KEY (`id`));)"; + + EXPECT_EQ(mysql::to_sql(create_table), expected_query); +} + +} // namespace test_unique + +#endif diff --git a/tests/postgres/test_unique.cpp b/tests/postgres/test_unique.cpp new file mode 100644 index 0000000..9c0ae77 --- /dev/null +++ b/tests/postgres/test_unique.cpp @@ -0,0 +1,54 @@ +#ifndef SQLGEN_BUILD_DRY_TESTS_ONLY + +#include + +#include +#include +#include +#include +#include +#include + +namespace test_unique { + +struct Person { + sqlgen::PrimaryKey id; + sqlgen::Unique first_name; + std::string last_name; + double age; +}; + +TEST(postgres, test_unique) { + const auto people = std::vector( + {Person{ + .id = 0, .first_name = "Homer", .last_name = "Simpson", .age = 45}, + Person{ + .id = 1, .first_name = "Marge", .last_name = "Simpson", .age = 40}, + Person{.id = 2, .first_name = "Bart", .last_name = "Simpson", .age = 10}, + Person{.id = 3, .first_name = "Lisa", .last_name = "Simpson", .age = 8}, + Person{ + .id = 4, .first_name = "Maggie", .last_name = "Simpson", .age = 0}}); + + const auto credentials = sqlgen::postgres::Credentials{.user = "postgres", + .password = "password", + .host = "localhost", + .dbname = "postgres"}; + + using namespace sqlgen; + using namespace sqlgen::literals; + + postgres::connect(credentials) + .and_then(drop | if_exists) + .and_then(create_table) + .and_then(insert(std::ref(people))) + .value(); + + const std::string expected_query = + R"(CREATE TABLE IF NOT EXISTS "Person" ("id" INTEGER NOT NULL, "first_name" TEXT NOT NULL UNIQUE, "last_name" TEXT NOT NULL, "age" NUMERIC NOT NULL, PRIMARY KEY ("id"));)"; + + EXPECT_EQ(postgres::to_sql(create_table), expected_query); +} + +} // namespace test_unique + +#endif diff --git a/tests/sqlite/test_unique.cpp b/tests/sqlite/test_unique.cpp new file mode 100644 index 0000000..bf52f03 --- /dev/null +++ b/tests/sqlite/test_unique.cpp @@ -0,0 +1,46 @@ +#include + +#include +#include +#include +#include +#include +#include + +namespace test_unique { + +struct Person { + sqlgen::PrimaryKey id; + sqlgen::Unique first_name; + std::string last_name; + double age; +}; + +TEST(postgres, test_unique) { + const auto people = std::vector( + {Person{ + .id = 0, .first_name = "Homer", .last_name = "Simpson", .age = 45}, + Person{ + .id = 1, .first_name = "Marge", .last_name = "Simpson", .age = 40}, + Person{.id = 2, .first_name = "Bart", .last_name = "Simpson", .age = 10}, + Person{.id = 3, .first_name = "Lisa", .last_name = "Simpson", .age = 8}, + Person{ + .id = 4, .first_name = "Maggie", .last_name = "Simpson", .age = 0}}); + + using namespace sqlgen; + using namespace sqlgen::literals; + + sqlite::connect() + .and_then(drop | if_exists) + .and_then(create_table) + .and_then(insert(std::ref(people))) + .value(); + + const std::string expected_query = + R"(CREATE TABLE IF NOT EXISTS "Person" ("id" INTEGER PRIMARY KEY NOT NULL, "first_name" TEXT NOT NULL UNIQUE, "last_name" TEXT NOT NULL, "age" REAL NOT NULL);)"; + + EXPECT_EQ(sqlite::to_sql(create_table), expected_query); +} + +} // namespace test_unique +