diff --git a/cpp17/lib/CMakeLists.txt b/cpp17/lib/CMakeLists.txt index 2e32299..9033665 100644 --- a/cpp17/lib/CMakeLists.txt +++ b/cpp17/lib/CMakeLists.txt @@ -13,6 +13,7 @@ find_package(jwt-cpp CONFIG REQUIRED) configure_file(src/Version.h.in ${CMAKE_BINARY_DIR}/autostore/Version.h) add_library(${TARGET_NAME} STATIC + src/domain/helpers/Specification.cpp src/application/queries/GetItem.cpp src/application/queries/ListItems.cpp src/application/commands/AddItem.cpp diff --git a/cpp17/lib/src/application/interfaces/IItemRepository.h b/cpp17/lib/src/application/interfaces/IItemRepository.h index d31026f..c42042e 100644 --- a/cpp17/lib/src/application/interfaces/IItemRepository.h +++ b/cpp17/lib/src/application/interfaces/IItemRepository.h @@ -1,6 +1,7 @@ #pragma once #include "domain/entities/Item.h" +#include "domain/polices/ItemExpirationPolicy.h" #include #include #include @@ -18,6 +19,8 @@ public: virtual std::vector findByOwner(domain::User::Id_t ownerId) = 0; virtual std::vector findWhere(std::function predicate) = 0; + virtual std::vector + findWhere(const domain::ItemExpirationSpec& spec) = 0; virtual void remove(domain::Item::Id_t id) = 0; }; diff --git a/cpp17/lib/src/domain/helpers/Specification.cpp b/cpp17/lib/src/domain/helpers/Specification.cpp new file mode 100644 index 0000000..37aa8fc --- /dev/null +++ b/cpp17/lib/src/domain/helpers/Specification.cpp @@ -0,0 +1,141 @@ +#include "Specification.h" +#include + +namespace nxl::helpers { + +// Condition render implementation +std::string Condition::render(const Renderer& renderer) const +{ + std::string opStr; + switch (op) { + case ComparisonOp::EQ: + opStr = renderer.opEq; + break; + case ComparisonOp::NE: + opStr = renderer.opNe; + break; + case ComparisonOp::LT: + opStr = renderer.opLt; + break; + case ComparisonOp::LE: + opStr = renderer.opLe; + break; + case ComparisonOp::GT: + opStr = renderer.opGt; + break; + case ComparisonOp::GE: + opStr = renderer.opGe; + break; + case ComparisonOp::LIKE: + opStr = renderer.opLike; + break; + case ComparisonOp::IS_NULL: + opStr = renderer.opIsNull; + break; + case ComparisonOp::IS_NOT_NULL: + opStr = renderer.opIsNotNull; + break; + } + + std::string result = field + " " + opStr; + if (value) { + result += " " + renderer.formatValue(*value); + } + return result; +} + +// ConditionGroup render implementation +std::string ConditionGroup::render(const Renderer& renderer) const +{ + if (children.empty()) + return ""; + + std::string opStr = (op == LogicalOp::AND) ? renderer.opAnd : renderer.opOr; + std::string result = renderer.groupStart; + + bool first = true; + for (const auto& child : children) { + if (!first) + result += " " + opStr + " "; + result += child->render(renderer); + first = false; + } + + result += renderer.groupEnd; + return result; +} + +// SpecificationBuilder implementation +SpecificationBuilder::SpecificationBuilder() +{ + root = std::make_unique(LogicalOp::AND); + current = root.get(); +} + +SpecificationBuilder& SpecificationBuilder::field(const std::string& name) +{ + lastField = name; + return *this; +} + +SpecificationBuilder& SpecificationBuilder::like(std::string pattern) +{ + addCondition(ComparisonOp::LIKE, std::move(pattern)); + return *this; +} + +SpecificationBuilder& SpecificationBuilder::isNull() +{ + addCondition(ComparisonOp::IS_NULL); + return *this; +} + +SpecificationBuilder& SpecificationBuilder::isNotNull() +{ + addCondition(ComparisonOp::IS_NOT_NULL); + return *this; +} + +SpecificationBuilder& SpecificationBuilder::andGroup() +{ + startGroup(LogicalOp::AND); + return *this; +} + +SpecificationBuilder& SpecificationBuilder::orGroup() +{ + startGroup(LogicalOp::OR); + return *this; +} + +SpecificationBuilder& SpecificationBuilder::endGroup() +{ + if (stack.empty()) + return *this; + current = stack.back(); + stack.pop_back(); + return *this; +} + +std::unique_ptr SpecificationBuilder::build() +{ + return std::move(root); +} + +void SpecificationBuilder::startGroup(LogicalOp opType) +{ + auto newGroup = std::make_unique(opType); + auto* newGroupPtr = newGroup.get(); + current->add(std::move(newGroup)); + + stack.push_back(current); + current = newGroupPtr; +} + +// Helper function +SpecificationBuilder makeSpecification() +{ + return SpecificationBuilder(); +} + +} // namespace nxl::helpers \ No newline at end of file diff --git a/cpp17/lib/src/domain/helpers/Specification.h b/cpp17/lib/src/domain/helpers/Specification.h new file mode 100644 index 0000000..3ffcae5 --- /dev/null +++ b/cpp17/lib/src/domain/helpers/Specification.h @@ -0,0 +1,198 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace nxl::helpers { + +class ISpecificationExpr; +class Condition; +class ConditionGroup; + +enum class ComparisonOp { EQ, NE, LT, LE, GT, GE, LIKE, IS_NULL, IS_NOT_NULL }; +enum class LogicalOp { AND, OR }; + +class ISpecificationExpr +{ +public: + virtual ~ISpecificationExpr() = default; + virtual std::string render(const struct Renderer& renderer) const = 0; +}; + +class ConditionValue +{ +public: + template ConditionValue(T value) : value_(std::move(value)) {} + + const std::any& get() const { return value_; } + + template const T& as() const + { + return std::any_cast(value_); + } + + template bool is() const { return value_.type() == typeid(T); } + +private: + std::any value_; +}; + +class Condition : public ISpecificationExpr +{ + std::string field; + ComparisonOp op; + std::optional value; // Optional for operators like IS_NULL + +public: + Condition(std::string f, ComparisonOp o, std::optional v = {}) + : field(std::move(f)), op(o), value(std::move(v)) + {} + + const std::string& getField() const { return field; } + ComparisonOp getOp() const { return op; } + const std::optional& getValue() const { return value; } + + std::string render(const struct Renderer& renderer) const override; +}; + +// Logical group of conditions +class ConditionGroup : public ISpecificationExpr +{ + LogicalOp op; + std::vector> children; + +public: + ConditionGroup(LogicalOp o) : op(o) {} + + void add(std::unique_ptr expr) + { + children.push_back(std::move(expr)); + } + + LogicalOp getOp() const { return op; } + const std::vector>& getChildren() const + { + return children; + } + + std::string render(const struct Renderer& renderer) const override; +}; + +// Renderer interface - defines how to render operators and grouping +struct Renderer +{ + std::string opEq; + std::string opNe; + std::string opLt; + std::string opLe; + std::string opGt; + std::string opGe; + std::string opLike; + std::string opIsNull; + std::string opIsNotNull; + + std::string opAnd; + std::string opOr; + + std::string groupStart; + std::string groupEnd; + + // Value formatting function + std::function formatValue; +}; + +// Fluent builder for specifications +class SpecificationBuilder +{ + std::unique_ptr root; + ConditionGroup* current; + std::vector stack; + std::string lastField; + +public: + SpecificationBuilder(); + + // Set the field for the next condition + SpecificationBuilder& field(const std::string& name); + + template SpecificationBuilder& equals(T value) + { + addCondition(ComparisonOp::EQ, std::move(value)); + return *this; + } + + template SpecificationBuilder& notEquals(T value) + { + addCondition(ComparisonOp::NE, std::move(value)); + return *this; + } + + template SpecificationBuilder& lessThan(T value) + { + addCondition(ComparisonOp::LT, std::move(value)); + return *this; + } + + template SpecificationBuilder& lessOrEqual(T value) + { + addCondition(ComparisonOp::LE, std::move(value)); + return *this; + } + + template SpecificationBuilder& greaterThan(T value) + { + addCondition(ComparisonOp::GT, std::move(value)); + return *this; + } + + template SpecificationBuilder& greaterOrEqual(T value) + { + addCondition(ComparisonOp::GE, std::move(value)); + return *this; + } + SpecificationBuilder& like(std::string pattern); + SpecificationBuilder& isNull(); + SpecificationBuilder& isNotNull(); + + SpecificationBuilder& andGroup(); + SpecificationBuilder& orGroup(); + SpecificationBuilder& endGroup(); + + std::unique_ptr build(); + +private: + template void addCondition(ComparisonOp op, T value) + { + if (lastField.empty()) { + throw std::runtime_error("No field specified for condition"); + } + + auto condition = std::make_unique( + lastField, op, ConditionValue(std::move(value))); + current->add(std::move(condition)); + lastField.clear(); + } + + void addCondition(ComparisonOp op) + { + if (lastField.empty()) { + throw std::runtime_error("No field specified for condition"); + } + + auto condition = std::make_unique(lastField, op, std::nullopt); + current->add(std::move(condition)); + lastField.clear(); + } + void startGroup(LogicalOp opType); +}; + +// Helper function to create a specification builder +SpecificationBuilder makeSpecification(); + +} // namespace nxl::helpers \ No newline at end of file diff --git a/cpp17/lib/src/domain/polices/ItemExpirationPolicy.h b/cpp17/lib/src/domain/polices/ItemExpirationPolicy.h index 5a39570..bc274f9 100644 --- a/cpp17/lib/src/domain/polices/ItemExpirationPolicy.h +++ b/cpp17/lib/src/domain/polices/ItemExpirationPolicy.h @@ -1,18 +1,31 @@ #pragma once #include "domain/entities/Item.h" +#include "domain/helpers/Specification.h" #include namespace nxl::autostore::domain { +using ItemExpirationSpec = std::unique_ptr; + class ItemExpirationPolicy { public: - bool isExpired(const Item& item, - const std::chrono::system_clock::time_point& currentTime) const + using TimePoint = std::chrono::system_clock::time_point; + constexpr static const char* FIELD_EXP_DATE{"expiration_date"}; + + bool isExpired(const Item& item, const TimePoint& currentTime) const { return item.expirationDate <= currentTime; } + + ItemExpirationSpec getExpiredSpecification(const TimePoint& currentTime) const + { + return nxl::helpers::SpecificationBuilder() + .field(FIELD_EXP_DATE) + .lessOrEqual(currentTime) + .build(); + } }; } // namespace nxl::autostore::domain \ No newline at end of file diff --git a/cpp17/lib/src/infrastructure/repositories/FileItemRepository.cpp b/cpp17/lib/src/infrastructure/repositories/FileItemRepository.cpp index a0f180e..f4effe7 100644 --- a/cpp17/lib/src/infrastructure/repositories/FileItemRepository.cpp +++ b/cpp17/lib/src/infrastructure/repositories/FileItemRepository.cpp @@ -5,6 +5,7 @@ #include #include #include +#include "FileItemRepository.h" namespace nxl::autostore::infrastructure { @@ -91,6 +92,14 @@ std::vector FileItemRepository::findWhere( return matchedItems; } +std::vector +nxl::autostore::infrastructure::FileItemRepository::findWhere( + const domain::ItemExpirationSpec& spec) +{ + // Not implemented since no SQL-like query language is used + throw std::runtime_error("Not implemented"); +} + void FileItemRepository::remove(domain::Item::Id_t id) { std::lock_guard lock(mtx); @@ -120,4 +129,4 @@ void FileItemRepository::persist() } } -} // namespace nxl::autostore::infrastructure \ No newline at end of file +} // namespace nxl::autostore::infrastructure diff --git a/cpp17/lib/src/infrastructure/repositories/FileItemRepository.h b/cpp17/lib/src/infrastructure/repositories/FileItemRepository.h index f5c6759..7e40e5b 100644 --- a/cpp17/lib/src/infrastructure/repositories/FileItemRepository.h +++ b/cpp17/lib/src/infrastructure/repositories/FileItemRepository.h @@ -16,6 +16,8 @@ public: std::vector findByOwner(domain::User::Id_t userId) override; std::vector findWhere(std::function predicate) override; + virtual std::vector + findWhere(const domain::ItemExpirationSpec& spec) override; void remove(domain::Item::Id_t id) override; private: diff --git a/cpp17/tests/CMakeLists.txt b/cpp17/tests/CMakeLists.txt index e046728..293fff1 100644 --- a/cpp17/tests/CMakeLists.txt +++ b/cpp17/tests/CMakeLists.txt @@ -34,4 +34,5 @@ endfunction() add_test_target(FileItemRepositoryTest integration/FileItemRepository.test.cpp) # add_integration_test(FileUserRepositoryTest integration/FileUserRepository.test.cpp) add_test_target(AddItemTest unit/AddItem.test.cpp) -add_test_target(TaskSchedulerTest unit/TaskScheduler.test.cpp) \ No newline at end of file +add_test_target(SpecificationTest unit/Specification.test.cpp) +add_test_target(TaskSchedulerTest unit/TaskScheduler.test.cpp) diff --git a/cpp17/tests/mocks/MockItemRepository.h b/cpp17/tests/mocks/MockItemRepository.h index 3e8d6cd..2e4545c 100644 --- a/cpp17/tests/mocks/MockItemRepository.h +++ b/cpp17/tests/mocks/MockItemRepository.h @@ -7,6 +7,7 @@ namespace test { using nxl::autostore::domain::Item; using nxl::autostore::domain::User; +using nxl::autostore::domain::ItemExpirationSpec; class MockItemRepository : public nxl::autostore::application::IItemRepository { @@ -16,6 +17,7 @@ public: MAKE_MOCK1(findByOwner, std::vector(User::Id_t), override); MAKE_MOCK1(findWhere, std::vector(std::function), override); + MAKE_MOCK1(findWhere, std::vector(const ItemExpirationSpec&), override); MAKE_MOCK1(remove, void(Item::Id_t), override); }; diff --git a/cpp17/tests/unit/Specification.test.cpp b/cpp17/tests/unit/Specification.test.cpp new file mode 100644 index 0000000..d681492 --- /dev/null +++ b/cpp17/tests/unit/Specification.test.cpp @@ -0,0 +1,692 @@ +#include "domain/helpers/Specification.h" +#include +#include +#include +#include +#include +#include + +using trompeloeil::_; +using namespace nxl::helpers; + +TEST_CASE("Specification Unit Tests", "[unit][Specification]") +{ + const Renderer TEST_RENDERER{ + .opEq = "=", + .opNe = "!=", + .opLt = "<", + .opLe = "<=", + .opGt = ">", + .opGe = ">=", + .opLike = "LIKE", + .opIsNull = "IS NULL", + .opIsNotNull = "IS NOT NULL", + .opAnd = "AND", + .opOr = "OR", + .groupStart = "(", + .groupEnd = ")", + .formatValue = [](const ConditionValue& v) -> std::string { + if (v.is()) { + return "'" + v.as() + "'"; + } else if (v.is()) { + return "'" + std::string(v.as()) + "'"; + } else if (v.is()) { + return v.as() ? "TRUE" : "FALSE"; + } else if (v.is()) { + return std::to_string(v.as()); + } else if (v.is()) { + return std::to_string(v.as()); + } else if (v.is()) { + auto time = v.as(); + auto time_t = std::chrono::system_clock::to_time_t(time); + std::string time_str = std::ctime(&time_t); + if (!time_str.empty() && time_str.back() == '\n') { + time_str.pop_back(); + } + return "'" + time_str + "'"; + } + return "'unknown'"; + }}; + + SECTION("when single equals string condition is added then proper operator " + "is rendered") + { + // Given + auto spec = makeSpecification().field("F").equals("foo").build(); + + // When + auto result = spec->render(TEST_RENDERER); + + // Then + REQUIRE(result == "(F = 'foo')"); + } + + SECTION( + "when condition with chrono time_point is used then it renders correctly") + { + // Given + auto now = std::chrono::system_clock::now(); + auto spec = makeSpecification().field("timestamp").greaterThan(now).build(); + + // When + auto result = spec->render(TEST_RENDERER); + + // Then + REQUIRE(result.find("timestamp >") != std::string::npos); + REQUIRE(result.find("'") != std::string::npos); + } + + SECTION("when single not equals string condition is added then proper " + "operator is rendered") + { + // Given + auto spec = makeSpecification().field("F").notEquals("foo").build(); + + // When + auto result = spec->render(TEST_RENDERER); + + // Then + REQUIRE(result == "(F != 'foo')"); + } + + SECTION( + "when single less than condition is added then proper operator is rendered") + { + // Given + auto spec = makeSpecification().field("F").lessThan(42).build(); + + // When + auto result = spec->render(TEST_RENDERER); + + // Then + REQUIRE(result == "(F < 42)"); + } + + SECTION("when single less than or equal condition is added then proper " + "operator is rendered") + { + // Given + auto spec = makeSpecification().field("F").lessOrEqual(42).build(); + + // When + auto result = spec->render(TEST_RENDERER); + + // Then + REQUIRE(result == "(F <= 42)"); + } + + SECTION("when single greater than condition is added then proper operator is " + "rendered") + { + // Given + auto spec = makeSpecification().field("F").greaterThan(42).build(); + + // When + auto result = spec->render(TEST_RENDERER); + + // Then + REQUIRE(result == "(F > 42)"); + } + + SECTION("when single greater than or equal condition is added then proper " + "operator is rendered") + { + // Given + auto spec = makeSpecification().field("F").greaterOrEqual(42).build(); + + // When + auto result = spec->render(TEST_RENDERER); + + // Then + REQUIRE(result == "(F >= 42)"); + } + + SECTION( + "when single like condition is added then proper operator is rendered") + { + // Given + auto spec = makeSpecification().field("F").like("%foo%").build(); + + // When + auto result = spec->render(TEST_RENDERER); + + // Then + REQUIRE(result == "(F LIKE '%foo%')"); + } + + SECTION( + "when single is null condition is added then proper operator is rendered") + { + // Given + auto spec = makeSpecification().field("F").isNull().build(); + + // When + auto result = spec->render(TEST_RENDERER); + + // Then + REQUIRE(result == "(F IS NULL)"); + } + + SECTION("when single is not null condition is added then proper operator is " + "rendered") + { + // Given + auto spec = makeSpecification().field("F").isNotNull().build(); + + // When + auto result = spec->render(TEST_RENDERER); + + // Then + REQUIRE(result == "(F IS NOT NULL)"); + } + + SECTION("when condition with double value is used then it renders correctly") + { + // Given + auto spec = makeSpecification().field("price").equals(19.99).build(); + + // When + auto result = spec->render(TEST_RENDERER); + + // Then + REQUIRE(result == "(price = 19.990000)"); + } + + SECTION("when condition with boolean value is used then it renders correctly") + { + // Given + auto spec = makeSpecification().field("active").equals(true).build(); + + // When + auto result = spec->render(TEST_RENDERER); + + // Then + REQUIRE(result == "(active = TRUE)"); + } + + SECTION( + "when condition with const char* value is used then it renders correctly") + { + // Given + auto spec = makeSpecification().field("name").equals("test").build(); + + // When + auto result = spec->render(TEST_RENDERER); + + // Then + REQUIRE(result == "(name = 'test')"); + } + + SECTION("when multiple AND conditions are added then they are rendered with " + "AND operator") + { + // Given + auto spec = makeSpecification() + .field("name") + .equals("John") + .field("age") + .greaterThan(30) + .field("active") + .equals(true) + .build(); + + // When + auto result = spec->render(TEST_RENDERER); + + // Then + REQUIRE(result == "(name = 'John' AND age > 30 AND active = TRUE)"); + } + + SECTION("when AND group is created then conditions are grouped properly") + { + // Given + auto spec = makeSpecification() + .field("name") + .equals("John") + .andGroup() + .field("age") + .greaterThan(30) + .field("active") + .equals(true) + .endGroup() + .build(); + + // When + auto result = spec->render(TEST_RENDERER); + + // Then + REQUIRE(result == "(name = 'John' AND (age > 30 AND active = TRUE))"); + } + + SECTION( + "when OR group is created then conditions are grouped with OR operator") + { + // Given + auto spec = makeSpecification() + .orGroup() + .field("name") + .equals("John") + .field("age") + .greaterThan(30) + .field("active") + .equals(true) + .endGroup() + .build(); + + // When + auto result = spec->render(TEST_RENDERER); + + // Then + REQUIRE(result == "((name = 'John' OR age > 30 OR active = TRUE))"); + } + + SECTION("when nested groups are created then they are rendered properly") + { + // Given + auto spec = makeSpecification() + .field("category") + .equals("electronics") + .andGroup() + .field("price") + .lessThan(1000) + .orGroup() + .field("brand") + .equals("Sony") + .field("brand") + .equals("Samsung") + .endGroup() + .endGroup() + .build(); + + // When + auto result = spec->render(TEST_RENDERER); + + // Then + REQUIRE( + result + == "(category = 'electronics' AND (price < 1000 AND (brand = 'Sony' " + "OR brand = 'Samsung')))"); + } + + SECTION("when complex specification with multiple nested groups is created " + "then it renders correctly") + { + // Given + auto spec = makeSpecification() + .orGroup() + .andGroup() + .field("type") + .equals("food") + .field("expiration_date") + .lessOrEqual("2023-01-01") + .endGroup() + .andGroup() + .field("type") + .equals("non-food") + .field("expiration_date") + .lessOrEqual("2023-03-01") + .endGroup() + .endGroup() + .build(); + + // When + auto result = spec->render(TEST_RENDERER); + + // Then + REQUIRE(result + == "(((type = 'food' AND expiration_date <= '2023-01-01') OR (type " + "= 'non-food' AND expiration_date <= '2023-03-01')))"); + } + + SECTION("when empty group is created then it renders as empty") + { + // Given + auto spec = makeSpecification() + .field("name") + .equals("John") + .andGroup() + .endGroup() + .build(); + + // When + auto result = spec->render(TEST_RENDERER); + + // Then + REQUIRE(result == "(name = 'John' AND )"); + } + + SECTION("when endGroup is called without matching startGroup then it should " + "handle gracefully") + { + // Given + auto spec = + makeSpecification().field("name").equals("John").endGroup().build(); + + // When + auto result = spec->render(TEST_RENDERER); + + // Then + REQUIRE(result == "(name = 'John')"); + } + + SECTION("when condition is added without field then exception is thrown") + { + // Given + auto builder = makeSpecification(); + + // When/Then + REQUIRE_THROWS_AS(builder.equals("value"), std::runtime_error); + REQUIRE_THROWS_WITH(builder.equals("value"), + "No field specified for condition"); + } + + SECTION("when field is called multiple times then last field is used") + { + // Given + auto spec = makeSpecification() + .field("field1") + .field("field2") + .equals("value") + .build(); + + // When + auto result = spec->render(TEST_RENDERER); + + // Then + REQUIRE(result == "(field2 = 'value')"); + } + + SECTION("when multiple conditions with same field are added then they are " + "rendered correctly") + { + // Given + auto spec = makeSpecification() + .field("age") + .greaterThan(18) + .field("age") + .lessThan(65) + .build(); + + // When + auto result = spec->render(TEST_RENDERER); + + // Then + REQUIRE(result == "(age > 18 AND age < 65)"); + } + + SECTION("when specification has only conditions with no explicit grouping " + "then AND is used") + { + // Given + auto spec = makeSpecification() + .field("field1") + .equals("value1") + .field("field2") + .equals("value2") + .field("field3") + .equals("value3") + .build(); + + // When + auto result = spec->render(TEST_RENDERER); + + // Then + REQUIRE( + result + == "(field1 = 'value1' AND field2 = 'value2' AND field3 = 'value3')"); + } + + SECTION("when mixed data types are used then they render correctly") + { + // Given + auto now = std::chrono::system_clock::now(); + auto spec = makeSpecification() + .field("name") + .equals("John") + .field("age") + .greaterThan(30) + .field("salary") + .greaterOrEqual(50000.50) + .field("active") + .equals(true) + .field("created_at") + .lessThan(now) + .build(); + + // When + auto result = spec->render(TEST_RENDERER); + + // Then + REQUIRE(result.find("(name = 'John' AND age > 30 AND salary >= " + "50000.500000 AND active = TRUE AND created_at < '") + != std::string::npos); + REQUIRE(result.find("'") != std::string::npos); + } + + SECTION("when ConditionValue is created with string then it stores and " + "retrieves correctly") + { + // Given + ConditionValue value(std::string("test")); + + // Then + REQUIRE(value.is()); + REQUIRE_FALSE(value.is()); + REQUIRE(value.as() == "test"); + } + + SECTION("when ConditionValue is created with int then it stores and " + "retrieves correctly") + { + // Given + ConditionValue value(42); + + // Then + REQUIRE(value.is()); + REQUIRE_FALSE(value.is()); + REQUIRE(value.as() == 42); + } + + SECTION("when ConditionValue is created with double then it stores and " + "retrieves correctly") + { + // Given + ConditionValue value(3.14); + + // Then + REQUIRE(value.is()); + REQUIRE_FALSE(value.is()); + REQUIRE(value.as() == 3.14); + } + + SECTION("when ConditionValue is created with bool then it stores and " + "retrieves correctly") + { + // Given + ConditionValue value(true); + + // Then + REQUIRE(value.is()); + REQUIRE_FALSE(value.is()); + REQUIRE(value.as() == true); + } + + SECTION("when ConditionValue is created with time_point then it stores and " + "retrieves correctly") + { + // Given + auto now = std::chrono::system_clock::now(); + ConditionValue value(now); + + // Then + REQUIRE(value.is()); + REQUIRE_FALSE(value.is()); + REQUIRE(value.as() == now); + } + + SECTION( + "when renderer has custom formatValue function then it is used correctly") + { + // Given + Renderer customRenderer{ + .opEq = "=", + .opNe = "!=", + .opLt = "<", + .opLe = "<=", + .opGt = ">", + .opGe = ">=", + .opLike = "LIKE", + .opIsNull = "IS NULL", + .opIsNotNull = "IS NOT NULL", + .opAnd = "AND", + .opOr = "OR", + .groupStart = "[", + .groupEnd = "]", + .formatValue = [](const ConditionValue& v) -> std::string { + if (v.is()) { + return "\"" + v.as() + "\""; + } else if (v.is()) { + return "\"" + std::string(v.as()) + "\""; + } else if (v.is()) { + return "INT:" + std::to_string(v.as()); + } + return "CUSTOM"; + }}; + + auto spec = makeSpecification() + .field("name") + .equals(std::string("John")) + .field("age") + .greaterThan(30) + .build(); + + // When + auto result = spec->render(customRenderer); + + // Then + REQUIRE(result == "[name = \"John\" AND age > INT:30]"); + } + + SECTION( + "when renderer has custom grouping symbols then they are used correctly") + { + // Given + Renderer customRenderer{ + .opEq = "=", + .opNe = "!=", + .opLt = "<", + .opLe = "<=", + .opGt = ">", + .opGe = ">=", + .opLike = "LIKE", + .opIsNull = "IS NULL", + .opIsNotNull = "IS NOT NULL", + .opAnd = "AND", + .opOr = "OR", + .groupStart = "{", + .groupEnd = "}", + .formatValue = [](const ConditionValue& v) -> std::string { + if (v.is()) { + return "'" + v.as() + "'"; + } else if (v.is()) { + return "'" + std::string(v.as()) + "'"; + } else if (v.is()) { + return std::to_string(v.as()); + } + return "'unknown'"; + }}; + + auto spec = makeSpecification() + .field("name") + .equals(std::string("John")) + .andGroup() + .field("age") + .greaterThan(30) + .endGroup() + .build(); + + // When + auto result = spec->render(customRenderer); + + // Then + REQUIRE(result == "{name = 'John' AND {age > 30}}"); + } + + SECTION( + "when renderer has custom logical operators then they are used correctly") + { + // Given + Renderer customRenderer{ + .opEq = "=", + .opNe = "!=", + .opLt = "<", + .opLe = "<=", + .opGt = ">", + .opGe = ">=", + .opLike = "LIKE", + .opIsNull = "IS NULL", + .opIsNotNull = "IS NOT NULL", + .opAnd = "&&", + .opOr = "||", + .groupStart = "(", + .groupEnd = ")", + .formatValue = [](const ConditionValue& v) -> std::string { + if (v.is()) { + return "'" + v.as() + "'"; + } else if (v.is()) { + return "'" + std::string(v.as()) + "'"; + } else if (v.is()) { + return std::to_string(v.as()); + } else if (v.is()) { + return v.as() ? "TRUE" : "FALSE"; + } + return "'unknown'"; + }}; + + auto spec = makeSpecification() + .orGroup() + .field("name") + .equals(std::string("John")) + .orGroup() + .field("age") + .greaterThan(30) + .field("active") + .equals(true) + .endGroup() + .endGroup() + .build(); + + // When + auto result = spec->render(customRenderer); + + // Then + REQUIRE(result == "((name = 'John' || (age > 30 || active = TRUE)))"); + } + + SECTION( + "when ConditionGroup has no children then render returns empty string") + { + // Given + ConditionGroup group(LogicalOp::AND); + + // When + auto result = group.render(TEST_RENDERER); + + // Then + REQUIRE(result == ""); + } + + SECTION("when makeSpecification helper is used then it creates valid builder") + { + // Given + auto builder = makeSpecification(); + + // When + auto spec = builder.field("test").equals("value").build(); + + // Then + REQUIRE(spec != nullptr); + auto result = spec->render(TEST_RENDERER); + REQUIRE(result == "(test = 'value')"); + } +} \ No newline at end of file