mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-04-05 18:09:04 +00:00
Compare commits
1 Commits
feat/add-b
...
23e2199b6b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
23e2199b6b |
3
pipeline/.gitignore
vendored
Normal file
3
pipeline/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
dist
|
||||||
|
build
|
||||||
|
data
|
||||||
113
pipeline/CMakeLists.txt
Normal file
113
pipeline/CMakeLists.txt
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
cmake_minimum_required(VERSION 3.20)
|
||||||
|
project(biergarten-pipeline VERSION 0.1.0 LANGUAGES CXX)
|
||||||
|
|
||||||
|
cmake_policy(SET CMP0167 NEW)
|
||||||
|
|
||||||
|
set(CMAKE_CXX_STANDARD 23)
|
||||||
|
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||||
|
set(CMAKE_CXX_EXTENSIONS OFF)
|
||||||
|
|
||||||
|
find_package(CURL REQUIRED)
|
||||||
|
find_package(Boost REQUIRED COMPONENTS unit_test_framework)
|
||||||
|
find_package(SQLite3 REQUIRED)
|
||||||
|
|
||||||
|
include(FetchContent)
|
||||||
|
|
||||||
|
FetchContent_Declare(
|
||||||
|
nlohmann_json
|
||||||
|
GIT_REPOSITORY https://github.com/nlohmann/json.git
|
||||||
|
GIT_TAG v3.11.3
|
||||||
|
)
|
||||||
|
FetchContent_MakeAvailable(nlohmann_json)
|
||||||
|
|
||||||
|
# TODO: Integrate real llama.cpp when generator is ready to use actual models
|
||||||
|
# For now, using mocked brewery generation in generator.cpp
|
||||||
|
|
||||||
|
# SQLite for in-memory database
|
||||||
|
find_package(SQLite3 REQUIRED)
|
||||||
|
|
||||||
|
file(GLOB_RECURSE SOURCES CONFIGURE_DEPENDS
|
||||||
|
src/*.cpp
|
||||||
|
)
|
||||||
|
|
||||||
|
add_executable(biergarten-pipeline ${SOURCES})
|
||||||
|
|
||||||
|
target_include_directories(biergarten-pipeline
|
||||||
|
PRIVATE
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/includes
|
||||||
|
)
|
||||||
|
|
||||||
|
target_link_libraries(biergarten-pipeline
|
||||||
|
PRIVATE
|
||||||
|
CURL::libcurl
|
||||||
|
nlohmann_json::nlohmann_json
|
||||||
|
Boost::unit_test_framework
|
||||||
|
SQLite::SQLite3
|
||||||
|
)
|
||||||
|
|
||||||
|
target_compile_options(biergarten-pipeline PRIVATE
|
||||||
|
$<$<CXX_COMPILER_ID:GNU,Clang>:
|
||||||
|
-Wall
|
||||||
|
-Wextra
|
||||||
|
-Wpedantic
|
||||||
|
-Wshadow
|
||||||
|
-Wconversion
|
||||||
|
-Wsign-conversion
|
||||||
|
>
|
||||||
|
$<$<CXX_COMPILER_ID:MSVC>:
|
||||||
|
/W4
|
||||||
|
/WX
|
||||||
|
>
|
||||||
|
)
|
||||||
|
|
||||||
|
add_custom_command(TARGET biergarten-pipeline POST_BUILD
|
||||||
|
COMMAND ${CMAKE_COMMAND} -E make_directory
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/output
|
||||||
|
COMMENT "Creating output/ directory for seed SQL files"
|
||||||
|
)
|
||||||
|
|
||||||
|
find_program(VALGRIND valgrind)
|
||||||
|
if(VALGRIND)
|
||||||
|
add_custom_target(memcheck
|
||||||
|
COMMAND ${VALGRIND}
|
||||||
|
--leak-check=full
|
||||||
|
--error-exitcode=1
|
||||||
|
$<TARGET_FILE:biergarten-pipeline> --help
|
||||||
|
DEPENDS biergarten-pipeline
|
||||||
|
COMMENT "Running Valgrind memcheck"
|
||||||
|
)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
include(CTest)
|
||||||
|
|
||||||
|
if(BUILD_TESTING)
|
||||||
|
find_package(Boost REQUIRED COMPONENTS unit_test_framework)
|
||||||
|
|
||||||
|
file(GLOB_RECURSE TEST_SOURCES CONFIGURE_DEPENDS
|
||||||
|
tests/*.cpp
|
||||||
|
tests/*.cc
|
||||||
|
tests/*.cxx
|
||||||
|
)
|
||||||
|
|
||||||
|
if(TEST_SOURCES)
|
||||||
|
add_executable(biergarten-pipeline-tests ${TEST_SOURCES})
|
||||||
|
|
||||||
|
target_include_directories(biergarten-pipeline-tests
|
||||||
|
PRIVATE
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/include
|
||||||
|
)
|
||||||
|
|
||||||
|
target_link_libraries(biergarten-pipeline-tests
|
||||||
|
PRIVATE
|
||||||
|
Boost::unit_test_framework
|
||||||
|
CURL::libcurl
|
||||||
|
nlohmann_json::nlohmann_json
|
||||||
|
llama
|
||||||
|
)
|
||||||
|
|
||||||
|
add_test(
|
||||||
|
NAME biergarten-pipeline-tests
|
||||||
|
COMMAND biergarten-pipeline-tests
|
||||||
|
)
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
1
pipeline/README.md
Normal file
1
pipeline/README.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
111
pipeline/includes/data_downloader.h
Normal file
111
pipeline/includes/data_downloader.h
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
/**
|
||||||
|
* @file data_downloader.h
|
||||||
|
* @brief Download geographic data from GitHub repositories using libcurl.
|
||||||
|
*
|
||||||
|
* Provides functionality to fetch JSON data from GitHub using libcurl, with
|
||||||
|
* support for commit-based versioning to ensure reproducible builds. Downloads
|
||||||
|
* are cached to avoid repeated network requests.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* @code
|
||||||
|
* DataDownloader downloader;
|
||||||
|
* std::string jsonPath = downloader.DownloadCountriesDatabase(
|
||||||
|
* "/tmp/countries-data.json", // local cache path
|
||||||
|
* "c5eb7772" // optional commit hash or HEAD
|
||||||
|
* );
|
||||||
|
* // Now use jsonPath with JsonLoader::LoadWorldCities(jsonPath, db)
|
||||||
|
* @endcode
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef DATA_DOWNLOADER_H
|
||||||
|
#define DATA_DOWNLOADER_H
|
||||||
|
|
||||||
|
#include <stdexcept>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @class DataDownloader
|
||||||
|
* @brief Manages downloading and caching of geographic data from GitHub.
|
||||||
|
*
|
||||||
|
* This class encapsulates libcurl networking operations for reproducible
|
||||||
|
* data fetching. All methods are non-blocking and synchronous.
|
||||||
|
*
|
||||||
|
* @note Requires libcurl to be available at runtime.
|
||||||
|
* @note GitHub raw content CDN is used for efficient downloads.
|
||||||
|
*/
|
||||||
|
class DataDownloader {
|
||||||
|
public:
|
||||||
|
/**
|
||||||
|
* @brief Default constructor.
|
||||||
|
*
|
||||||
|
* Initializes the downloader without any specific state. The downloader
|
||||||
|
* is ready to use immediately.
|
||||||
|
*/
|
||||||
|
DataDownloader();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Destructor.
|
||||||
|
*
|
||||||
|
* Cleans up any resources. No explicit cleanup needed beyond destruction.
|
||||||
|
*/
|
||||||
|
~DataDownloader();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Download the countries+states+cities JSON database from GitHub.
|
||||||
|
*
|
||||||
|
* Downloads the geographic data from the
|
||||||
|
* dr5hn/countries-states-cities-database repository. If the file already
|
||||||
|
* exists at cachePath, it is used directly without downloading again.
|
||||||
|
*
|
||||||
|
* The download URL format is:
|
||||||
|
* @verbatim
|
||||||
|
* https://raw.githubusercontent.com/dr5hn/countries-states-cities-database/
|
||||||
|
* {commit}/json/countries+states+cities.json
|
||||||
|
* @endverbatim
|
||||||
|
*
|
||||||
|
* @param cachePath Local filesystem path where the JSON file should be
|
||||||
|
* stored. If the file already exists, download is skipped.
|
||||||
|
* @param commit Git commit hash or branch name (default: "c5eb7772").
|
||||||
|
* Examples: "HEAD", "main", "c5eb7772",
|
||||||
|
* "c5eb7772225f6b1802a54f39adb8c73464a85be1a"
|
||||||
|
*
|
||||||
|
* @return The file path where JSON was saved (same as cachePath).
|
||||||
|
*
|
||||||
|
* @throws std::runtime_error if:
|
||||||
|
* - Network download fails
|
||||||
|
* - File cannot be written to cachePath
|
||||||
|
* - Commit hash is invalid (404 on GitHub)
|
||||||
|
*
|
||||||
|
* Example with default commit (stable v2026-03-28):
|
||||||
|
* @code
|
||||||
|
* std::string path =
|
||||||
|
* downloader.DownloadCountriesDatabase("/tmp/data.json");
|
||||||
|
* @endcode
|
||||||
|
*
|
||||||
|
* Example with custom commit:
|
||||||
|
* @code
|
||||||
|
* std::string path = downloader.DownloadCountriesDatabase(
|
||||||
|
* "/tmp/data.json",
|
||||||
|
* "main" // Download latest from main branch
|
||||||
|
* );
|
||||||
|
* @endcode
|
||||||
|
*/
|
||||||
|
std::string DownloadCountriesDatabase(
|
||||||
|
const std::string &cachePath,
|
||||||
|
const std::string &commit = "c5eb7772" // Stable commit: 2026-03-28 export
|
||||||
|
);
|
||||||
|
|
||||||
|
private:
|
||||||
|
/**
|
||||||
|
* @brief Check if a file already exists at the given path.
|
||||||
|
*
|
||||||
|
* Used internally to implement cache-hit logic. No download occurs if
|
||||||
|
* the file already exists.
|
||||||
|
*
|
||||||
|
* @param filePath Path to check.
|
||||||
|
* @return True if file exists and is readable, false otherwise.
|
||||||
|
*/
|
||||||
|
bool FileExists(const std::string &filePath) const;
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // DATA_DOWNLOADER_H
|
||||||
102
pipeline/includes/database.h
Normal file
102
pipeline/includes/database.h
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <mutex>
|
||||||
|
#include <sqlite3.h>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
/// @struct Country
|
||||||
|
/// @brief Represents a country with geographic identifiers
|
||||||
|
struct Country {
|
||||||
|
int id;
|
||||||
|
std::string name;
|
||||||
|
std::string iso2; ///< 2-letter ISO code (e.g., "US", "CA")
|
||||||
|
std::string iso3; ///< 3-letter ISO code (e.g., "USA", "CAN")
|
||||||
|
};
|
||||||
|
|
||||||
|
/// @struct State
|
||||||
|
/// @brief Represents a state or province with geographic identifiers
|
||||||
|
struct State {
|
||||||
|
int id;
|
||||||
|
std::string name;
|
||||||
|
std::string iso2; ///< 2-letter state code (e.g., "CA", "ON")
|
||||||
|
int countryId;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @class SqliteDatabase
|
||||||
|
* @brief Thread-safe in-memory SQLite database wrapper for geographic data
|
||||||
|
*
|
||||||
|
* Manages a local in-memory SQLite database with countries, states, and cities.
|
||||||
|
* All write operations are serialized via mutex to enable safe concurrent
|
||||||
|
* access from multiple threads. Uses INSERT OR IGNORE for idempotent
|
||||||
|
* operations.
|
||||||
|
*
|
||||||
|
* Schema Relationships:
|
||||||
|
* countries (id, name, iso2, iso3)
|
||||||
|
* ↓ (one-to-many)
|
||||||
|
* states (id, country_id, name, iso2)
|
||||||
|
* ↓ (one-to-many)
|
||||||
|
* cities (id, state_id, country_id, name, latitude, longitude)
|
||||||
|
*/
|
||||||
|
class SqliteDatabase {
|
||||||
|
private:
|
||||||
|
sqlite3 *db = nullptr; ///< SQLite database connection handle
|
||||||
|
std::mutex dbMutex; ///< Protects all database operations from race conditions
|
||||||
|
|
||||||
|
/// @brief Creates the schema with three related tables and foreign keys
|
||||||
|
void InitializeSchema();
|
||||||
|
|
||||||
|
public:
|
||||||
|
/// @brief Destructor: safely closes the database connection
|
||||||
|
~SqliteDatabase();
|
||||||
|
|
||||||
|
/// @brief Opens an in-memory SQLite database and initializes the schema
|
||||||
|
void Initialize();
|
||||||
|
|
||||||
|
/// @brief Inserts a country record
|
||||||
|
/// @param id Unique country identifier
|
||||||
|
/// @param name Country name
|
||||||
|
/// @param iso2 2-letter ISO country code
|
||||||
|
/// @param iso3 3-letter ISO country code
|
||||||
|
/// @note Thread-safe: uses mutex lock. Idempotent: INSERT OR IGNORE prevents
|
||||||
|
/// duplicates
|
||||||
|
void InsertCountry(int id, const std::string &name, const std::string &iso2,
|
||||||
|
const std::string &iso3);
|
||||||
|
|
||||||
|
/// @brief Inserts a state/province record
|
||||||
|
/// @param id Unique state identifier
|
||||||
|
/// @param countryId Foreign key reference to parent country
|
||||||
|
/// @param name State/province name
|
||||||
|
/// @param iso2 2-letter state code (e.g., "CA", "ON")
|
||||||
|
/// @note Thread-safe and idempotent via mutex and INSERT OR IGNORE
|
||||||
|
void InsertState(int id, int countryId, const std::string &name,
|
||||||
|
const std::string &iso2);
|
||||||
|
|
||||||
|
/// @brief Inserts a city record with geographic coordinates
|
||||||
|
/// @param id Unique city identifier
|
||||||
|
/// @param stateId Foreign key reference to parent state
|
||||||
|
/// @param countryId Foreign key reference to parent country
|
||||||
|
/// @param name City name
|
||||||
|
/// @param latitude Geographic latitude coordinate (WGS84)
|
||||||
|
/// @param longitude Geographic longitude coordinate (WGS84)
|
||||||
|
/// @note Thread-safe and idempotent. Called by multithreaded JSON loader.
|
||||||
|
void InsertCity(int id, int stateId, int countryId, const std::string &name,
|
||||||
|
double latitude, double longitude);
|
||||||
|
|
||||||
|
/// @brief Queries all cities from the database
|
||||||
|
/// @return Vector of (city_id, city_name) pairs sorted alphabetically
|
||||||
|
std::vector<std::pair<int, std::string>> QueryCities();
|
||||||
|
|
||||||
|
/// @brief Queries all countries from the database with ISO codes
|
||||||
|
/// @param limit Maximum number of records to return (0 = all)
|
||||||
|
/// @return Vector of Country structs (includes id, name, iso2, iso3) sorted
|
||||||
|
/// alphabetically
|
||||||
|
std::vector<Country> QueryCountries(int limit = 0);
|
||||||
|
|
||||||
|
/// @brief Queries all states from the database with ISO codes
|
||||||
|
/// @param limit Maximum number of records to return (0 = all)
|
||||||
|
/// @return Vector of State structs (includes id, name, iso2, countryId)
|
||||||
|
/// sorted alphabetically
|
||||||
|
std::vector<State> QueryStates(int limit = 0);
|
||||||
|
};
|
||||||
59
pipeline/includes/generator.h
Normal file
59
pipeline/includes/generator.h
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @class LlamaBreweryGenerator
|
||||||
|
* @brief Generates brewery names and descriptions for cities
|
||||||
|
*
|
||||||
|
* Currently provides a deterministic mock implementation that generates
|
||||||
|
* brewery names and descriptions based on city name hashing.
|
||||||
|
*
|
||||||
|
* Design Pattern: Strategy pattern ready for swapping real llama.cpp
|
||||||
|
* implementation later. The LoadModel() and GenerateBrewery() interface
|
||||||
|
* will remain the same once actual LM inference is integrated.
|
||||||
|
*
|
||||||
|
* Mock Implementation: Uses std::hash to deterministically map city names
|
||||||
|
* to brewery templates, ensuring reproducible results for testing.
|
||||||
|
*/
|
||||||
|
class LlamaBreweryGenerator {
|
||||||
|
private:
|
||||||
|
/// Adjectives for brewery names (e.g., "Craft", "Heritage", etc.)
|
||||||
|
const std::vector<std::string> breweryAdjectives = {
|
||||||
|
"Craft", "Heritage", "Local", "Artisan",
|
||||||
|
"Pioneer", "Golden", "Modern", "Classic"};
|
||||||
|
|
||||||
|
/// Nouns for brewery names (e.g., "Brewing Co.", "Brewery", etc.)
|
||||||
|
const std::vector<std::string> breweryNouns = {
|
||||||
|
"Brewing Co.", "Brewery", "Bier Haus", "Taproom",
|
||||||
|
"Works", "House", "Fermentery", "Ale Co."};
|
||||||
|
|
||||||
|
/// Pre-written brewery descriptions (currently hand-crafted)
|
||||||
|
const std::vector<std::string> descriptions = {
|
||||||
|
"Handcrafted pale ales and seasonal IPAs with local ingredients.",
|
||||||
|
"Traditional lagers and experimental sours in small batches.",
|
||||||
|
"Award-winning stouts and wildly hoppy blonde ales.",
|
||||||
|
"Craft brewery specializing in Belgian-style triples and dark porters.",
|
||||||
|
"Modern brewery blending tradition with bold experimental flavors."};
|
||||||
|
|
||||||
|
public:
|
||||||
|
/// @struct Brewery
|
||||||
|
/// @brief Output structure for generated brewery data
|
||||||
|
struct Brewery {
|
||||||
|
std::string name; ///< Generated brewery name (e.g., "Craft Brewing Co.")
|
||||||
|
std::string description; ///< Short description of brewery style/offerings
|
||||||
|
};
|
||||||
|
|
||||||
|
/// @brief Loads a language model (currently mocked)
|
||||||
|
/// @param modelPath Path to GGUF model file (not used in mock)
|
||||||
|
/// @note In real implementation, loads llama.cpp model into memory
|
||||||
|
void LoadModel(const std::string &modelPath);
|
||||||
|
|
||||||
|
/// @brief Generates a brewery name and description for a city
|
||||||
|
/// @param cityName City name to generate brewery for
|
||||||
|
/// @param seed Integer seed (used for deterministic output in mock)
|
||||||
|
/// @return Brewery struct with name and description
|
||||||
|
/// @note Deterministic: same cityName+seed always produces same brewery
|
||||||
|
Brewery GenerateBrewery(const std::string &cityName, int seed);
|
||||||
|
};
|
||||||
85
pipeline/includes/json_loader.h
Normal file
85
pipeline/includes/json_loader.h
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "database.h"
|
||||||
|
#include <nlohmann/json.hpp>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
using json = nlohmann::json;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @class JsonLoader
|
||||||
|
* @brief Loads world geographic data from JSON file into SQLite database
|
||||||
|
*
|
||||||
|
* Handles parsing and population of world cities, states, and countries from
|
||||||
|
* a structured JSON source file. The loader uses parallel threads to chunk
|
||||||
|
* the city records and maximize database insertion throughput.
|
||||||
|
*
|
||||||
|
* Input Format (JSON Structure):
|
||||||
|
* @code
|
||||||
|
* {
|
||||||
|
* "countries": [
|
||||||
|
* {"id": 1, "name": "Canada", "iso2": "CA", "iso3": "CAN"},
|
||||||
|
* ...
|
||||||
|
* ],
|
||||||
|
* "states": [
|
||||||
|
* {"id": 1, "country_id": 1, "name": "Ontario", "iso2": "ON"},
|
||||||
|
* ...
|
||||||
|
* ],
|
||||||
|
* "cities": [
|
||||||
|
* {"id": 1, "state_id": 1, "country_id": 1, "name": "Toronto",
|
||||||
|
* "latitude": 43.6532, "longitude": -79.3832},
|
||||||
|
* ...
|
||||||
|
* ]
|
||||||
|
* }
|
||||||
|
* @endcode
|
||||||
|
*
|
||||||
|
* Performance Characteristics:
|
||||||
|
* - Reads entire JSON file into memory (nlohmann/json parser)
|
||||||
|
* - Iterates through countries: typically 200+ records
|
||||||
|
* - Iterates through states: typically 3000+ records
|
||||||
|
* - Iterates through cities: typically 50,000+ records (MAJOR DATASET)
|
||||||
|
* - Uses multithreading to chunk city insertion across threads
|
||||||
|
* - Thread pool size defaults to number of CPU cores
|
||||||
|
*
|
||||||
|
* Multithreading Strategy:
|
||||||
|
* - Divides cities into N chunks (N = CPU core count)
|
||||||
|
* - Each thread processes one chunk sequentially
|
||||||
|
* - Database has mutex protection for thread-safe concurrent access
|
||||||
|
* - Allows safe parallel writing to same SQLite database
|
||||||
|
*
|
||||||
|
* Example Usage:
|
||||||
|
* @code
|
||||||
|
* SqliteDatabase db;
|
||||||
|
* db.Initialize();
|
||||||
|
* JsonLoader::LoadWorldCities("../data/world_city_data.json", db);
|
||||||
|
* // Database now contains all countries, states, and cities
|
||||||
|
* @endcode
|
||||||
|
*/
|
||||||
|
class JsonLoader {
|
||||||
|
public:
|
||||||
|
/// @brief Loads world geographic data from JSON and populates database
|
||||||
|
///
|
||||||
|
/// Process:
|
||||||
|
/// 1. Reads and parses entire JSON file
|
||||||
|
/// 2. Inserts all countries into database (typically 200-250 records)
|
||||||
|
/// 3. Inserts all states/provinces (typically 3000+ records)
|
||||||
|
/// 4. Spawns worker threads to insert cities (typically 50,000+ records)
|
||||||
|
/// 5. Waits for all threads to complete
|
||||||
|
/// 6. Prints statistics about loaded data
|
||||||
|
///
|
||||||
|
/// @param jsonPath Filesystem path to world_city_data.json
|
||||||
|
/// @param db Reference to initialized SqliteDatabase to populate
|
||||||
|
///
|
||||||
|
/// @throws std::runtime_error if JSON file cannot be read or parsed
|
||||||
|
/// @throws std::runtime_error if database insertion fails
|
||||||
|
///
|
||||||
|
/// Output Examples:
|
||||||
|
/// @code
|
||||||
|
/// Loading JSON: ../data/world_city_data.json
|
||||||
|
/// Loaded countries: 250
|
||||||
|
/// Loaded states: 3500
|
||||||
|
/// Loaded cities: 52000
|
||||||
|
/// ✓ World city data loaded successfully
|
||||||
|
/// @endcode
|
||||||
|
static void LoadWorldCities(const std::string &jsonPath, SqliteDatabase &db);
|
||||||
|
};
|
||||||
163
pipeline/src/data_downloader.cpp
Normal file
163
pipeline/src/data_downloader.cpp
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
/**
|
||||||
|
* @file data_downloader.cpp
|
||||||
|
* @brief Implementation of DataDownloader using libcurl for HTTP downloads.
|
||||||
|
*
|
||||||
|
* Provides robust downloading with proper error handling, timeout management,
|
||||||
|
* and local caching to avoid repeated network calls. Uses GitHub's raw content
|
||||||
|
* CDN for reliable high-bandwidth downloads.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "data_downloader.h"
|
||||||
|
#include <cstdio>
|
||||||
|
#include <curl/curl.h>
|
||||||
|
#include <fstream>
|
||||||
|
#include <iostream>
|
||||||
|
#include <sstream>
|
||||||
|
#include <sys/stat.h>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Callback function for libcurl to write downloaded content to file.
|
||||||
|
*
|
||||||
|
* This callback is invoked repeatedly by curl as data arrives over the network.
|
||||||
|
* Each invocation contains a chunk of the response body. The function writes
|
||||||
|
* the content to the output file stream.
|
||||||
|
*
|
||||||
|
* @param contents Pointer to buffer containing data chunk.
|
||||||
|
* @param size Element size (always 1 for text).
|
||||||
|
* @param nmemb Number of elements in chunk.
|
||||||
|
* @param userp Opaque pointer to std::ofstream (FILE*).
|
||||||
|
*
|
||||||
|
* @return Total bytes written. Must match (size * nmemb) for success;
|
||||||
|
* returning less signals an error to curl.
|
||||||
|
*
|
||||||
|
* @note libcurl requires this signature: (char* ptr, size_t size, size_t nmemb,
|
||||||
|
* void* userp)
|
||||||
|
*/
|
||||||
|
static size_t WriteCallback(void *contents, size_t size, size_t nmemb,
|
||||||
|
void *userp) {
|
||||||
|
// Calculate total bytes in this chunk
|
||||||
|
size_t realsize = size * nmemb;
|
||||||
|
|
||||||
|
// Cast userp back to ofstream
|
||||||
|
std::ofstream *outFile = static_cast<std::ofstream *>(userp);
|
||||||
|
|
||||||
|
// Write to file
|
||||||
|
outFile->write(static_cast<char *>(contents), realsize);
|
||||||
|
|
||||||
|
// Return actual bytes written (success = requested amount)
|
||||||
|
return realsize;
|
||||||
|
}
|
||||||
|
|
||||||
|
DataDownloader::DataDownloader() {
|
||||||
|
// curl_global_init is called by user or external subsystem in a thread-safe
|
||||||
|
// manner. Not calling it here to avoid multiple initialization in
|
||||||
|
// multi-downloader scenarios.
|
||||||
|
}
|
||||||
|
|
||||||
|
DataDownloader::~DataDownloader() {
|
||||||
|
// No explicit cleanup needed; curl_global_cleanup managed externally.
|
||||||
|
}
|
||||||
|
|
||||||
|
bool DataDownloader::FileExists(const std::string &filePath) const {
|
||||||
|
// Use POSIX stat() to check file existence without opening it
|
||||||
|
struct stat buffer;
|
||||||
|
return (stat(filePath.c_str(), &buffer) == 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string
|
||||||
|
DataDownloader::DownloadCountriesDatabase(const std::string &cachePath,
|
||||||
|
const std::string &commit) {
|
||||||
|
// Check if file already cached locally
|
||||||
|
if (FileExists(cachePath)) {
|
||||||
|
std::cout << "[DataDownloader] Cache hit: " << cachePath << std::endl;
|
||||||
|
return cachePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct download URL
|
||||||
|
// Full commit hash is accepted, but only first 7 chars (short hash) are
|
||||||
|
// needed
|
||||||
|
std::string shortCommit = commit;
|
||||||
|
if (commit.length() > 7) {
|
||||||
|
shortCommit = commit.substr(0, 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string url = "https://raw.githubusercontent.com/dr5hn/"
|
||||||
|
"countries-states-cities-database/" +
|
||||||
|
shortCommit + "/json/countries+states+cities.json";
|
||||||
|
|
||||||
|
std::cout << "[DataDownloader] Downloading: " << url << std::endl;
|
||||||
|
|
||||||
|
// Initialize curl handle
|
||||||
|
CURL *curl = curl_easy_init();
|
||||||
|
if (!curl) {
|
||||||
|
throw std::runtime_error("[DataDownloader] Failed to initialize libcurl");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open output file for writing (binary mode to preserve exact bytes)
|
||||||
|
std::ofstream outFile(cachePath, std::ios::binary);
|
||||||
|
if (!outFile.is_open()) {
|
||||||
|
curl_easy_cleanup(curl);
|
||||||
|
throw std::runtime_error("[DataDownloader] Cannot open file for writing: " +
|
||||||
|
cachePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure curl for download
|
||||||
|
curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
|
||||||
|
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, WriteCallback);
|
||||||
|
curl_easy_setopt(curl, CURLOPT_WRITEDATA, static_cast<void *>(&outFile));
|
||||||
|
|
||||||
|
// Set reasonable timeout (30 seconds for initial connection, 300s for
|
||||||
|
// transfer)
|
||||||
|
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 30L);
|
||||||
|
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 300L);
|
||||||
|
|
||||||
|
// Follow redirects (CDN may redirect)
|
||||||
|
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
|
||||||
|
curl_easy_setopt(curl, CURLOPT_MAXREDIRS, 5L);
|
||||||
|
|
||||||
|
// Use gzip compression if server supports it
|
||||||
|
curl_easy_setopt(curl, CURLOPT_ACCEPT_ENCODING, "gzip");
|
||||||
|
|
||||||
|
// Set user agent to identify the application
|
||||||
|
curl_easy_setopt(curl, CURLOPT_USERAGENT, "biergarten-pipeline/0.1.0");
|
||||||
|
|
||||||
|
// Perform the download
|
||||||
|
CURLcode res = curl_easy_perform(curl);
|
||||||
|
outFile.close();
|
||||||
|
|
||||||
|
// Check for curl errors
|
||||||
|
if (res != CURLE_OK) {
|
||||||
|
curl_easy_cleanup(curl);
|
||||||
|
|
||||||
|
// Remove partially downloaded file
|
||||||
|
std::remove(cachePath.c_str());
|
||||||
|
|
||||||
|
std::string error = std::string("[DataDownloader] Download failed: ") +
|
||||||
|
curl_easy_strerror(res);
|
||||||
|
throw std::runtime_error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check HTTP response code
|
||||||
|
long httpCode = 0;
|
||||||
|
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &httpCode);
|
||||||
|
curl_easy_cleanup(curl);
|
||||||
|
|
||||||
|
if (httpCode != 200) {
|
||||||
|
// Remove partially downloaded or error file
|
||||||
|
std::remove(cachePath.c_str());
|
||||||
|
|
||||||
|
std::stringstream ss;
|
||||||
|
ss << "[DataDownloader] HTTP error " << httpCode
|
||||||
|
<< " (commit: " << shortCommit << ")";
|
||||||
|
throw std::runtime_error(ss.str());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get file size for diagnostics
|
||||||
|
std::ifstream fileCheck(cachePath, std::ios::binary | std::ios::ate);
|
||||||
|
std::streamsize size = fileCheck.tellg();
|
||||||
|
fileCheck.close();
|
||||||
|
|
||||||
|
std::cout << "[DataDownloader] ✓ Download complete: " << cachePath << " ("
|
||||||
|
<< (size / (1024.0 * 1024.0)) << " MB)" << std::endl;
|
||||||
|
return cachePath;
|
||||||
|
}
|
||||||
229
pipeline/src/database.cpp
Normal file
229
pipeline/src/database.cpp
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
#include "database.h"
|
||||||
|
#include <iostream>
|
||||||
|
#include <stdexcept>
|
||||||
|
|
||||||
|
void SqliteDatabase::InitializeSchema() {
|
||||||
|
std::lock_guard<std::mutex> lock(dbMutex);
|
||||||
|
|
||||||
|
const char *schema = R"(
|
||||||
|
CREATE TABLE IF NOT EXISTS countries (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
iso2 TEXT,
|
||||||
|
iso3 TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS states (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
country_id INTEGER NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
iso2 TEXT,
|
||||||
|
FOREIGN KEY(country_id) REFERENCES countries(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS cities (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
state_id INTEGER NOT NULL,
|
||||||
|
country_id INTEGER NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
latitude REAL,
|
||||||
|
longitude REAL,
|
||||||
|
FOREIGN KEY(state_id) REFERENCES states(id),
|
||||||
|
FOREIGN KEY(country_id) REFERENCES countries(id)
|
||||||
|
);
|
||||||
|
)";
|
||||||
|
|
||||||
|
char *errMsg = nullptr;
|
||||||
|
int rc = sqlite3_exec(db, schema, nullptr, nullptr, &errMsg);
|
||||||
|
if (rc != SQLITE_OK) {
|
||||||
|
std::string error = errMsg ? std::string(errMsg) : "Unknown error";
|
||||||
|
sqlite3_free(errMsg);
|
||||||
|
throw std::runtime_error("Failed to create schema: " + error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SqliteDatabase::~SqliteDatabase() {
|
||||||
|
if (db) {
|
||||||
|
sqlite3_close(db);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void SqliteDatabase::Initialize() {
|
||||||
|
int rc = sqlite3_open(":memory:", &db);
|
||||||
|
if (rc) {
|
||||||
|
throw std::runtime_error("Failed to create in-memory SQLite database");
|
||||||
|
}
|
||||||
|
std::cout << "✓ In-memory SQLite database created\n";
|
||||||
|
InitializeSchema();
|
||||||
|
}
|
||||||
|
|
||||||
|
void SqliteDatabase::InsertCountry(int id, const std::string &name,
|
||||||
|
const std::string &iso2,
|
||||||
|
const std::string &iso3) {
|
||||||
|
std::lock_guard<std::mutex> lock(dbMutex);
|
||||||
|
|
||||||
|
const char *query = R"(
|
||||||
|
INSERT OR IGNORE INTO countries (id, name, iso2, iso3)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
)";
|
||||||
|
|
||||||
|
sqlite3_stmt *stmt;
|
||||||
|
int rc = sqlite3_prepare_v2(db, query, -1, &stmt, nullptr);
|
||||||
|
if (rc != SQLITE_OK)
|
||||||
|
throw std::runtime_error("Failed to prepare country insert");
|
||||||
|
|
||||||
|
sqlite3_bind_int(stmt, 1, id);
|
||||||
|
sqlite3_bind_text(stmt, 2, name.c_str(), -1, SQLITE_STATIC);
|
||||||
|
sqlite3_bind_text(stmt, 3, iso2.c_str(), -1, SQLITE_STATIC);
|
||||||
|
sqlite3_bind_text(stmt, 4, iso3.c_str(), -1, SQLITE_STATIC);
|
||||||
|
|
||||||
|
if (sqlite3_step(stmt) != SQLITE_DONE) {
|
||||||
|
throw std::runtime_error("Failed to insert country");
|
||||||
|
}
|
||||||
|
sqlite3_finalize(stmt);
|
||||||
|
}
|
||||||
|
|
||||||
|
void SqliteDatabase::InsertState(int id, int countryId, const std::string &name,
|
||||||
|
const std::string &iso2) {
|
||||||
|
std::lock_guard<std::mutex> lock(dbMutex);
|
||||||
|
|
||||||
|
const char *query = R"(
|
||||||
|
INSERT OR IGNORE INTO states (id, country_id, name, iso2)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
)";
|
||||||
|
|
||||||
|
sqlite3_stmt *stmt;
|
||||||
|
int rc = sqlite3_prepare_v2(db, query, -1, &stmt, nullptr);
|
||||||
|
if (rc != SQLITE_OK)
|
||||||
|
throw std::runtime_error("Failed to prepare state insert");
|
||||||
|
|
||||||
|
sqlite3_bind_int(stmt, 1, id);
|
||||||
|
sqlite3_bind_int(stmt, 2, countryId);
|
||||||
|
sqlite3_bind_text(stmt, 3, name.c_str(), -1, SQLITE_STATIC);
|
||||||
|
sqlite3_bind_text(stmt, 4, iso2.c_str(), -1, SQLITE_STATIC);
|
||||||
|
|
||||||
|
if (sqlite3_step(stmt) != SQLITE_DONE) {
|
||||||
|
throw std::runtime_error("Failed to insert state");
|
||||||
|
}
|
||||||
|
sqlite3_finalize(stmt);
|
||||||
|
}
|
||||||
|
|
||||||
|
void SqliteDatabase::InsertCity(int id, int stateId, int countryId,
|
||||||
|
const std::string &name, double latitude,
|
||||||
|
double longitude) {
|
||||||
|
std::lock_guard<std::mutex> lock(dbMutex);
|
||||||
|
|
||||||
|
const char *query = R"(
|
||||||
|
INSERT OR IGNORE INTO cities (id, state_id, country_id, name, latitude, longitude)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
)";
|
||||||
|
|
||||||
|
sqlite3_stmt *stmt;
|
||||||
|
int rc = sqlite3_prepare_v2(db, query, -1, &stmt, nullptr);
|
||||||
|
if (rc != SQLITE_OK)
|
||||||
|
throw std::runtime_error("Failed to prepare city insert");
|
||||||
|
|
||||||
|
sqlite3_bind_int(stmt, 1, id);
|
||||||
|
sqlite3_bind_int(stmt, 2, stateId);
|
||||||
|
sqlite3_bind_int(stmt, 3, countryId);
|
||||||
|
sqlite3_bind_text(stmt, 4, name.c_str(), -1, SQLITE_STATIC);
|
||||||
|
sqlite3_bind_double(stmt, 5, latitude);
|
||||||
|
sqlite3_bind_double(stmt, 6, longitude);
|
||||||
|
|
||||||
|
if (sqlite3_step(stmt) != SQLITE_DONE) {
|
||||||
|
throw std::runtime_error("Failed to insert city");
|
||||||
|
}
|
||||||
|
sqlite3_finalize(stmt);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<std::pair<int, std::string>> SqliteDatabase::QueryCities() {
|
||||||
|
std::lock_guard<std::mutex> lock(dbMutex);
|
||||||
|
|
||||||
|
std::vector<std::pair<int, std::string>> cities;
|
||||||
|
sqlite3_stmt *stmt = nullptr;
|
||||||
|
|
||||||
|
const char *query = "SELECT id, name FROM cities ORDER BY name";
|
||||||
|
int rc = sqlite3_prepare_v2(db, query, -1, &stmt, nullptr);
|
||||||
|
|
||||||
|
if (rc != SQLITE_OK) {
|
||||||
|
throw std::runtime_error("Failed to prepare query");
|
||||||
|
}
|
||||||
|
|
||||||
|
while (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||||
|
int id = sqlite3_column_int(stmt, 0);
|
||||||
|
const char *name =
|
||||||
|
reinterpret_cast<const char *>(sqlite3_column_text(stmt, 1));
|
||||||
|
cities.push_back({id, name ? std::string(name) : ""});
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlite3_finalize(stmt);
|
||||||
|
return cities;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<Country> SqliteDatabase::QueryCountries(int limit) {
|
||||||
|
std::lock_guard<std::mutex> lock(dbMutex);
|
||||||
|
|
||||||
|
std::vector<Country> countries;
|
||||||
|
sqlite3_stmt *stmt = nullptr;
|
||||||
|
|
||||||
|
std::string query =
|
||||||
|
"SELECT id, name, iso2, iso3 FROM countries ORDER BY name";
|
||||||
|
if (limit > 0) {
|
||||||
|
query += " LIMIT " + std::to_string(limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
int rc = sqlite3_prepare_v2(db, query.c_str(), -1, &stmt, nullptr);
|
||||||
|
|
||||||
|
if (rc != SQLITE_OK) {
|
||||||
|
throw std::runtime_error("Failed to prepare countries query");
|
||||||
|
}
|
||||||
|
|
||||||
|
while (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||||
|
int id = sqlite3_column_int(stmt, 0);
|
||||||
|
const char *name =
|
||||||
|
reinterpret_cast<const char *>(sqlite3_column_text(stmt, 1));
|
||||||
|
const char *iso2 =
|
||||||
|
reinterpret_cast<const char *>(sqlite3_column_text(stmt, 2));
|
||||||
|
const char *iso3 =
|
||||||
|
reinterpret_cast<const char *>(sqlite3_column_text(stmt, 3));
|
||||||
|
countries.push_back({id, name ? std::string(name) : "",
|
||||||
|
iso2 ? std::string(iso2) : "",
|
||||||
|
iso3 ? std::string(iso3) : ""});
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlite3_finalize(stmt);
|
||||||
|
return countries;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<State> SqliteDatabase::QueryStates(int limit) {
|
||||||
|
std::lock_guard<std::mutex> lock(dbMutex);
|
||||||
|
|
||||||
|
std::vector<State> states;
|
||||||
|
sqlite3_stmt *stmt = nullptr;
|
||||||
|
|
||||||
|
std::string query =
|
||||||
|
"SELECT id, name, iso2, country_id FROM states ORDER BY name";
|
||||||
|
if (limit > 0) {
|
||||||
|
query += " LIMIT " + std::to_string(limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
int rc = sqlite3_prepare_v2(db, query.c_str(), -1, &stmt, nullptr);
|
||||||
|
|
||||||
|
if (rc != SQLITE_OK) {
|
||||||
|
throw std::runtime_error("Failed to prepare states query");
|
||||||
|
}
|
||||||
|
|
||||||
|
while (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||||
|
int id = sqlite3_column_int(stmt, 0);
|
||||||
|
const char *name =
|
||||||
|
reinterpret_cast<const char *>(sqlite3_column_text(stmt, 1));
|
||||||
|
const char *iso2 =
|
||||||
|
reinterpret_cast<const char *>(sqlite3_column_text(stmt, 2));
|
||||||
|
int countryId = sqlite3_column_int(stmt, 3);
|
||||||
|
states.push_back({id, name ? std::string(name) : "",
|
||||||
|
iso2 ? std::string(iso2) : "", countryId});
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlite3_finalize(stmt);
|
||||||
|
return states;
|
||||||
|
}
|
||||||
81
pipeline/src/generator.cpp
Normal file
81
pipeline/src/generator.cpp
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
#include "generator.h"
|
||||||
|
#include <functional>
|
||||||
|
#include <iostream>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Initializes the brewery generator by loading a language model
|
||||||
|
*
|
||||||
|
* Current Implementation (Mock):
|
||||||
|
* - Outputs informational messages about model initialization
|
||||||
|
* - Does not load actual llama.cpp model yet
|
||||||
|
* - Serves as interface definition for future real implementation
|
||||||
|
*
|
||||||
|
* Future Implementation:
|
||||||
|
* - Will load a GGUF-format LLM model file using llama.cpp
|
||||||
|
* - Will initialize CPU/GPU inference context
|
||||||
|
* - Will cache model weights for repeated brewery generation
|
||||||
|
*
|
||||||
|
* @param modelPath Path to GGUF model file (e.g., "models/llama-7b.gguf")
|
||||||
|
*
|
||||||
|
* Example output:
|
||||||
|
* @code
|
||||||
|
* [Mock] Initialized llama model: models/llama-7b.gguf
|
||||||
|
* ✓ Model ready
|
||||||
|
* @endcode
|
||||||
|
*/
|
||||||
|
void LlamaBreweryGenerator::LoadModel(const std::string &modelPath) {
|
||||||
|
std::cout << " [Mock] Initialized llama model: " << modelPath << "\n";
|
||||||
|
std::cout << " ✓ Model ready\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Generates a brewery name and description for a city using
|
||||||
|
* deterministic hashing
|
||||||
|
*
|
||||||
|
* Algorithm:
|
||||||
|
* 1. Combines city name with seed to create unique hash input
|
||||||
|
* 2. Uses std::hash<std::string> to compute deterministic hash value
|
||||||
|
* 3. Uses modulo arithmetic to map hash to template arrays:
|
||||||
|
* - name: adjective[hash % 8] + noun[(hash/7) % 8]
|
||||||
|
* - description: descriptions[(hash/13) % 5]
|
||||||
|
* 4. Returns Brewery struct with generated name and description
|
||||||
|
*
|
||||||
|
* Determinism:
|
||||||
|
* - Same cityName + seed ALWAYS produces same result
|
||||||
|
* - Enables reproducible testing and consistent brewery assignments
|
||||||
|
* - Hash distribution spreads city names across template combinations
|
||||||
|
*
|
||||||
|
* Example:
|
||||||
|
* @code
|
||||||
|
* auto gen = LlamaBreweryGenerator();
|
||||||
|
* auto brewery = gen.GenerateBrewery("Toronto", 1);
|
||||||
|
* // Always produces same brewery for same city/seed
|
||||||
|
* assert(gen.GenerateBrewery("Toronto", 1).name == brewery.name);
|
||||||
|
* @endcode
|
||||||
|
*
|
||||||
|
* @param cityName The city to generate a brewery for
|
||||||
|
* @param seed An integer seed for deterministic variation (usually 0 or row ID)
|
||||||
|
* @return Brewery struct containing:
|
||||||
|
* - name: Combined adjective + noun (e.g., "Craft Brewing Co.")
|
||||||
|
* - description: Pre-written description matching brewery style
|
||||||
|
*
|
||||||
|
* @note Future: Replace hashing with actual LLM inference
|
||||||
|
* Interface will remain identical for smooth migration
|
||||||
|
*/
|
||||||
|
LlamaBreweryGenerator::Brewery
|
||||||
|
LlamaBreweryGenerator::GenerateBrewery(const std::string &cityName, int seed) {
|
||||||
|
// Deterministic mock generation based on city name and seed
|
||||||
|
// Combines city name with seed to ensure different results for same city
|
||||||
|
// with different seed values (useful for generating multiple breweries per
|
||||||
|
// city)
|
||||||
|
size_t nameHash = std::hash<std::string>{}(cityName + std::to_string(seed));
|
||||||
|
|
||||||
|
Brewery result;
|
||||||
|
// Select adjective and noun using hash modulo
|
||||||
|
// Divided by 7 and 13 to ensure different modulo results from same hash
|
||||||
|
result.name = breweryAdjectives[nameHash % breweryAdjectives.size()] + " " +
|
||||||
|
breweryNouns[(nameHash / 7) % breweryNouns.size()];
|
||||||
|
result.description = descriptions[(nameHash / 13) % descriptions.size()];
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
222
pipeline/src/json_loader.cpp
Normal file
222
pipeline/src/json_loader.cpp
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
#include "json_loader.h"
|
||||||
|
#include <fstream>
|
||||||
|
#include <iostream>
|
||||||
|
#include <mutex>
|
||||||
|
#include <thread>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Loads world geographic data from JSON file into SQLite database
|
||||||
|
*
|
||||||
|
* This function implements a hierarchical multithreaded loading strategy:
|
||||||
|
*
|
||||||
|
* THREADING ARCHITECTURE:
|
||||||
|
* ┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
* │ Main Thread: Parse JSON (45 MB) │
|
||||||
|
* └────────────────────┬────────────────────────────────────────────┘
|
||||||
|
* │
|
||||||
|
* ┌─────────────┴──────────────┬──────────────┐
|
||||||
|
* ▼ ▼ ▼
|
||||||
|
* Country Thread 0 Country Thread 1 ... Thread N
|
||||||
|
* ├─ Insert Country ├─ Insert Country └─ Insert Country
|
||||||
|
* │
|
||||||
|
* ├─ State Thread A ├─ State Thread C
|
||||||
|
* │ ├─ Insert State │ ├─ Insert State
|
||||||
|
* │ ├─ Insert 100 cities │ └─ Insert 150 cities
|
||||||
|
* │ └─ +stats └─ +stats
|
||||||
|
* │
|
||||||
|
* └─ State Thread B
|
||||||
|
* ├─ Insert State
|
||||||
|
* ├─ Insert 200 cities
|
||||||
|
* └─ +stats
|
||||||
|
*
|
||||||
|
* THREADING DETAILS:
|
||||||
|
* - Countries loop: divided among CPU_CORE_COUNT threads
|
||||||
|
* - Each country: states processed in dedicated threads (nested parallelism)
|
||||||
|
* - Each state: cities inserted sequentially (within thread)
|
||||||
|
* - All writes protected by mutex in SqliteDatabase
|
||||||
|
* - Processing stats (city count) synchronized with mutex
|
||||||
|
*
|
||||||
|
* INPUT JSON STRUCTURE:
|
||||||
|
* The JSON file contains three main arrays:
|
||||||
|
*
|
||||||
|
* 1. Countries (~250 records):
|
||||||
|
* { id: int, name: string, iso2: string, iso3: string }
|
||||||
|
*
|
||||||
|
* 2. States/Provinces (~3500 records):
|
||||||
|
* { id: int, country_id: int, name: string, iso2: string }
|
||||||
|
*
|
||||||
|
* 3. Cities (~50000 records):
|
||||||
|
* { id: int, state_id: int, country_id: int, name: string,
|
||||||
|
* latitude: double, longitude: double }
|
||||||
|
*
|
||||||
|
* PERFORMANCE:
|
||||||
|
* - JSON parsing: Single-threaded, happens once at start
|
||||||
|
* - Country insertion: Parallelized across CPU cores
|
||||||
|
* - State insertion: Parallelized within each country via nested threads
|
||||||
|
* - City insertion: Sequential within each state (reduces serialization)
|
||||||
|
* - Total expected runtime: 2-5 seconds for 50k cities on modern CPU
|
||||||
|
*
|
||||||
|
* ERROR HANDLING:
|
||||||
|
* - Missing JSON file: throws std::runtime_error
|
||||||
|
* - Invalid JSON: throws nlohmann::json::parse_error
|
||||||
|
* - Bad city records: silently skipped (try-catch within loop)
|
||||||
|
* - Database errors: re-thrown from db.Insert*() calls
|
||||||
|
*
|
||||||
|
* STATISTICS:
|
||||||
|
* Prints progress messages showing:
|
||||||
|
* - Number of countries loaded
|
||||||
|
* - Number of worker threads created
|
||||||
|
* - Total cities inserted into database
|
||||||
|
*
|
||||||
|
* @param jsonPath Path to JSON file (typically: ../data/world_city_data.json)
|
||||||
|
* @param db Reference to initialized SqliteDatabase to populate
|
||||||
|
*/
|
||||||
|
void JsonLoader::LoadWorldCities(const std::string &jsonPath,
|
||||||
|
SqliteDatabase &db) {
|
||||||
|
std::cout << "\nLoading " << jsonPath << " (45 MB)...\n";
|
||||||
|
|
||||||
|
// Open and read JSON file from disk
|
||||||
|
std::ifstream jsonFile(jsonPath);
|
||||||
|
if (!jsonFile.is_open()) {
|
||||||
|
throw std::runtime_error("Failed to open JSON file: " + jsonPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse entire JSON into memory (nlohmann/json library)
|
||||||
|
json data;
|
||||||
|
try {
|
||||||
|
jsonFile >> data;
|
||||||
|
} catch (const std::exception &e) {
|
||||||
|
throw std::runtime_error("JSON parse error: " + std::string(e.what()));
|
||||||
|
}
|
||||||
|
jsonFile.close();
|
||||||
|
|
||||||
|
// DEBUG: Check JSON structure
|
||||||
|
if (!data.is_array()) {
|
||||||
|
std::cerr << "[DEBUG] JSON root is not an array. Type: " << data.type_name()
|
||||||
|
<< std::endl;
|
||||||
|
if (data.is_object()) {
|
||||||
|
std::cerr << "[DEBUG] JSON root is object with keys: ";
|
||||||
|
for (auto &[key, val] : data.items()) {
|
||||||
|
std::cerr << key << " ";
|
||||||
|
}
|
||||||
|
std::cerr << std::endl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::cout << "✓ Loaded " << data.size()
|
||||||
|
<< " records (expecting countries array)\n";
|
||||||
|
|
||||||
|
if (data.size() == 0) {
|
||||||
|
throw std::runtime_error("JSON file appears to be empty or malformed. "
|
||||||
|
"Check download succeeded.");
|
||||||
|
}
|
||||||
|
|
||||||
|
std::cout << "Processing countries with multithreading...\n";
|
||||||
|
|
||||||
|
// Determine optimal thread count based on CPU cores
|
||||||
|
unsigned int numThreads = std::thread::hardware_concurrency();
|
||||||
|
std::cout << " Using " << numThreads << " threads\n\n";
|
||||||
|
|
||||||
|
// Shared counter for statistics (protected by mutex)
|
||||||
|
int processedCities = 0;
|
||||||
|
std::mutex statsMutex;
|
||||||
|
|
||||||
|
// Spawn threads to process countries in parallel
|
||||||
|
std::vector<std::thread> countryThreads;
|
||||||
|
const size_t countriesPerThread = (data.size() + numThreads - 1) / numThreads;
|
||||||
|
|
||||||
|
for (size_t t = 0; t < numThreads; ++t) {
|
||||||
|
countryThreads.push_back(std::thread([&, t]() {
|
||||||
|
// Each thread processes a range of countries
|
||||||
|
size_t start = t * countriesPerThread;
|
||||||
|
size_t end = std::min((t + 1) * countriesPerThread, data.size());
|
||||||
|
|
||||||
|
for (size_t i = start; i < end; ++i) {
|
||||||
|
const auto &country = data[i];
|
||||||
|
int countryId = country["id"];
|
||||||
|
std::string countryName = country["name"];
|
||||||
|
std::string iso2 = country.value("iso2", "");
|
||||||
|
std::string iso3 = country.value("iso3", "");
|
||||||
|
|
||||||
|
// Insert country record
|
||||||
|
db.InsertCountry(countryId, countryName, iso2, iso3);
|
||||||
|
|
||||||
|
// Process states within this country
|
||||||
|
if (country.contains("states") && country["states"].is_array()) {
|
||||||
|
const auto &states = country["states"];
|
||||||
|
|
||||||
|
// Spawn threads to process states in parallel
|
||||||
|
// This creates nested parallelism: country threads spawn state
|
||||||
|
// threads
|
||||||
|
std::vector<std::thread> stateThreads;
|
||||||
|
|
||||||
|
for (size_t s = 0; s < states.size(); ++s) {
|
||||||
|
stateThreads.push_back(std::thread([&, s, countryId]() {
|
||||||
|
const auto &state = states[s];
|
||||||
|
int stateId = state["id"];
|
||||||
|
std::string stateName = state["name"];
|
||||||
|
std::string stateIso2 = state.value("iso2", "");
|
||||||
|
|
||||||
|
// Insert state record
|
||||||
|
db.InsertState(stateId, countryId, stateName, stateIso2);
|
||||||
|
|
||||||
|
// Process cities for this state
|
||||||
|
if (state.contains("cities") && state["cities"].is_array()) {
|
||||||
|
// Cities within a state are processed sequentially
|
||||||
|
// (within the state thread - reduces context switching)
|
||||||
|
for (const auto &city : state["cities"]) {
|
||||||
|
try {
|
||||||
|
int cityId = city["id"].get<int>();
|
||||||
|
std::string cityName = city["name"];
|
||||||
|
|
||||||
|
// Parse latitude and longitude as strings first (they're
|
||||||
|
// stored as strings in JSON), then convert to double
|
||||||
|
double lat = 0.0;
|
||||||
|
double lng = 0.0;
|
||||||
|
if (city.contains("latitude")) {
|
||||||
|
lat = std::stod(city["latitude"].get<std::string>());
|
||||||
|
}
|
||||||
|
if (city.contains("longitude")) {
|
||||||
|
lng = std::stod(city["longitude"].get<std::string>());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert city record to database
|
||||||
|
// Database has mutex protection for thread-safe access
|
||||||
|
db.InsertCity(cityId, stateId, countryId, cityName, lat,
|
||||||
|
lng);
|
||||||
|
|
||||||
|
// Update shared statistics counter (protected by mutex)
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(statsMutex);
|
||||||
|
processedCities++;
|
||||||
|
}
|
||||||
|
} catch (const std::exception &e) {
|
||||||
|
// Silently skip malformed city entries
|
||||||
|
// Example: missing required fields, invalid coordinates
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for all state threads to complete
|
||||||
|
// Important: don't proceed to next country until states are done
|
||||||
|
for (auto &t : stateThreads) {
|
||||||
|
if (t.joinable())
|
||||||
|
t.join();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for all country threads to complete
|
||||||
|
// This blocks until all nested state/city insertions are done
|
||||||
|
for (auto &t : countryThreads) {
|
||||||
|
if (t.joinable())
|
||||||
|
t.join();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::cout << "✓ Loaded " << processedCities << " cities into database\n\n";
|
||||||
|
}
|
||||||
154
pipeline/src/main.cpp
Normal file
154
pipeline/src/main.cpp
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
/**
|
||||||
|
* @file main.cpp
|
||||||
|
* @brief Entry point for the brewery data pipeline
|
||||||
|
*
|
||||||
|
* Pipeline Overview:
|
||||||
|
* This is the main data processing pipeline that:
|
||||||
|
* 1. Initializes an in-memory SQLite database
|
||||||
|
* 2. Loads world city data from a JSON file (50k+ cities)
|
||||||
|
* 3. Initializes the brewery generation system (currently mocked)
|
||||||
|
* 4. Demonstrates brewery generation for sample cities
|
||||||
|
*
|
||||||
|
* Architecture:
|
||||||
|
* ┌─────────────┐
|
||||||
|
* │ JSON File │ (world_city_data.json - 50k+ cities)
|
||||||
|
* └──────┬──────┘
|
||||||
|
* │
|
||||||
|
* ▼
|
||||||
|
* ┌─────────────────────┐
|
||||||
|
* │ JsonLoader::Load │ Parse and validate JSON
|
||||||
|
* └──────┬──────────────┘
|
||||||
|
* │
|
||||||
|
* ▼
|
||||||
|
* ┌─────────────────────┐
|
||||||
|
* │ SQLite Database │ Store cities in-memory
|
||||||
|
* └──────┬──────────────┘
|
||||||
|
* │
|
||||||
|
* ▼
|
||||||
|
* ┌─────────────────────┐
|
||||||
|
* │ BreweryGenerator │ Mock generation (hash-based)
|
||||||
|
* │ .GenerateBrewery() │ Future: LLM-based generation
|
||||||
|
* └─────────────────────┘
|
||||||
|
*
|
||||||
|
* Command Line Arguments:
|
||||||
|
* - argv[1]: Path to GGUF model file (default: ./model.gguf)
|
||||||
|
* - argv[2]: Path to cache directory for JSON downloads (default: /tmp)
|
||||||
|
* - argv[3]: Git commit hash for reproducible data version (default: c5eb7772)
|
||||||
|
*
|
||||||
|
* The pipeline automatically downloads the geographic data from GitHub on first
|
||||||
|
* run and caches it locally to avoid repeated network calls.
|
||||||
|
*
|
||||||
|
* Example Usage - Auto-download (stable 2026-03-28 build):
|
||||||
|
* @code
|
||||||
|
* ./brewery-pipeline ./llama-7b.gguf
|
||||||
|
* @endcode
|
||||||
|
*
|
||||||
|
* Example Usage - Custom commit:
|
||||||
|
* @code
|
||||||
|
* ./brewery-pipeline ./llama-7b.gguf /tmp main
|
||||||
|
* @endcode
|
||||||
|
*
|
||||||
|
* Exit Codes:
|
||||||
|
* - 0: Pipeline completed successfully
|
||||||
|
* - 1: Pipeline failed (exception caught)
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "data_downloader.h"
|
||||||
|
#include "database.h"
|
||||||
|
#include "generator.h"
|
||||||
|
#include "json_loader.h"
|
||||||
|
#include <curl/curl.h>
|
||||||
|
#include <iostream>
|
||||||
|
|
||||||
|
int main(int argc, char *argv[]) {
|
||||||
|
try {
|
||||||
|
// Initialize libcurl globally (thread-safe mode)
|
||||||
|
curl_global_init(CURL_GLOBAL_DEFAULT);
|
||||||
|
|
||||||
|
// Parse command-line arguments
|
||||||
|
std::string modelPath = argc > 1 ? argv[1] : "./model.gguf";
|
||||||
|
std::string cacheDir = argc > 2 ? argv[2] : "/tmp";
|
||||||
|
std::string commit =
|
||||||
|
argc > 3 ? argv[3] : "c5eb7772"; // Default: stable 2026-03-28
|
||||||
|
|
||||||
|
// Construct cache path for downloaded JSON
|
||||||
|
std::string jsonPath = cacheDir + "/countries+states+cities.json";
|
||||||
|
|
||||||
|
// Step 0: Download geographic data from GitHub (cached locally)
|
||||||
|
// On first run, downloads 45MB JSON. On subsequent runs, uses cached file.
|
||||||
|
// Commit hash allows pinning to specific data versions for reproducibility.
|
||||||
|
std::cout << "\n[Pipeline] Downloading geographic data from GitHub...\n";
|
||||||
|
DataDownloader downloader;
|
||||||
|
downloader.DownloadCountriesDatabase(jsonPath, commit);
|
||||||
|
|
||||||
|
SqliteDatabase db;
|
||||||
|
|
||||||
|
// Step 1: Initialize empty in-memory database
|
||||||
|
std::cout << "Initializing in-memory SQLite database...\n";
|
||||||
|
db.Initialize();
|
||||||
|
|
||||||
|
// Step 2: Load world city data from JSON file
|
||||||
|
// This populates the database with ~50k city records
|
||||||
|
// Each record includes: city name, country, latitude, longitude, population
|
||||||
|
JsonLoader::LoadWorldCities(jsonPath, db);
|
||||||
|
|
||||||
|
// Step 3: Initialize brewery generator
|
||||||
|
// Current: Mock implementation using deterministic hashing
|
||||||
|
// Future: LLM-based generation with llama.cpp
|
||||||
|
std::cout << "Initializing brewery generator...\n";
|
||||||
|
LlamaBreweryGenerator generator;
|
||||||
|
generator.LoadModel(modelPath);
|
||||||
|
|
||||||
|
// Step 4: Query geographic data from database
|
||||||
|
std::cout << "\n=== GEOGRAPHIC DATA OVERVIEW ===\n";
|
||||||
|
|
||||||
|
auto countries = db.QueryCountries(50);
|
||||||
|
auto states = db.QueryStates(50);
|
||||||
|
auto cities = db.QueryCities();
|
||||||
|
|
||||||
|
std::cout << "\nTotal records loaded:";
|
||||||
|
std::cout << "\n Countries: " << db.QueryCountries(0).size();
|
||||||
|
std::cout << "\n States: " << db.QueryStates(0).size();
|
||||||
|
std::cout << "\n Cities: " << cities.size() << "\n";
|
||||||
|
|
||||||
|
// Display 50 countries
|
||||||
|
std::cout << "\n--- 50 COUNTRIES ---\n";
|
||||||
|
for (size_t i = 0; i < countries.size(); i++) {
|
||||||
|
std::cout << (i + 1) << ". " << countries[i].iso2 << " ("
|
||||||
|
<< countries[i].iso3 << ") " << countries[i].name << "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display 50 states
|
||||||
|
std::cout << "\n--- 50 STATES ---\n";
|
||||||
|
for (size_t i = 0; i < states.size(); i++) {
|
||||||
|
std::cout << (i + 1) << ". " << states[i].iso2 << ": " << states[i].name
|
||||||
|
<< "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display 50 cities
|
||||||
|
std::cout << "\n--- 50 CITIES ---\n";
|
||||||
|
for (size_t i = 0; i < std::min(size_t(50), cities.size()); i++) {
|
||||||
|
std::cout << (i + 1) << ". " << cities[i].second << "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5: Demonstrate brewery generation on sample cities
|
||||||
|
std::cout << "\n=== SAMPLE BREWERY GENERATION ===\n\n";
|
||||||
|
for (size_t i = 0; i < std::min(size_t(5), cities.size()); i++) {
|
||||||
|
const auto &[cityId, cityName] = cities[i];
|
||||||
|
auto brewery = generator.GenerateBrewery(cityName, i);
|
||||||
|
std::cout << " " << cityName << ": " << brewery.name << "\n";
|
||||||
|
std::cout << " → " << brewery.description << "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
std::cout << "\n✓ Pipeline completed successfully\n";
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
curl_global_cleanup();
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
} catch (const std::exception &e) {
|
||||||
|
std::cerr << "✗ Pipeline failed: " << e.what() << "\n";
|
||||||
|
curl_global_cleanup();
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,7 +31,6 @@
|
|||||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
|
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
|
||||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Jwt\Infrastructure.Jwt.csproj" />
|
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Jwt\Infrastructure.Jwt.csproj" />
|
||||||
<ProjectReference Include="..\..\Service\Service.Auth\Service.Auth.csproj" />
|
<ProjectReference Include="..\..\Service\Service.Auth\Service.Auth.csproj" />
|
||||||
<ProjectReference Include="..\..\Service\Service.Breweries\Service.Breweries.csproj" />
|
|
||||||
<ProjectReference Include="..\..\Service\Service.UserManagement\Service.UserManagement.csproj" />
|
<ProjectReference Include="..\..\Service\Service.UserManagement\Service.UserManagement.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
using FluentValidation;
|
|
||||||
|
|
||||||
namespace API.Core.Contracts.Breweries;
|
|
||||||
|
|
||||||
public class BreweryCreateDtoValidator : AbstractValidator<BreweryCreateDto>
|
|
||||||
{
|
|
||||||
public BreweryCreateDtoValidator()
|
|
||||||
{
|
|
||||||
RuleFor(x => x.PostedById)
|
|
||||||
.NotEmpty()
|
|
||||||
.WithMessage("PostedById is required.");
|
|
||||||
|
|
||||||
RuleFor(x => x.BreweryName)
|
|
||||||
.NotEmpty()
|
|
||||||
.WithMessage("Brewery name is required.")
|
|
||||||
.MaximumLength(256)
|
|
||||||
.WithMessage("Brewery name cannot exceed 256 characters.");
|
|
||||||
|
|
||||||
RuleFor(x => x.Description)
|
|
||||||
.NotEmpty()
|
|
||||||
.WithMessage("Description is required.")
|
|
||||||
.MaximumLength(512)
|
|
||||||
.WithMessage("Description cannot exceed 512 characters.");
|
|
||||||
|
|
||||||
RuleFor(x => x.Location)
|
|
||||||
.NotNull()
|
|
||||||
.WithMessage("Location is required.");
|
|
||||||
|
|
||||||
RuleFor(x => x.Location.CityId)
|
|
||||||
.NotEmpty()
|
|
||||||
.When(x => x.Location is not null)
|
|
||||||
.WithMessage("CityId is required.");
|
|
||||||
|
|
||||||
RuleFor(x => x.Location.AddressLine1)
|
|
||||||
.NotEmpty()
|
|
||||||
.When(x => x.Location is not null)
|
|
||||||
.WithMessage("Address line 1 is required.")
|
|
||||||
.MaximumLength(256)
|
|
||||||
.When(x => x.Location is not null)
|
|
||||||
.WithMessage("Address line 1 cannot exceed 256 characters.");
|
|
||||||
|
|
||||||
RuleFor(x => x.Location.PostalCode)
|
|
||||||
.NotEmpty()
|
|
||||||
.When(x => x.Location is not null)
|
|
||||||
.WithMessage("Postal code is required.")
|
|
||||||
.MaximumLength(20)
|
|
||||||
.When(x => x.Location is not null)
|
|
||||||
.WithMessage("Postal code cannot exceed 20 characters.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
namespace API.Core.Contracts.Breweries;
|
|
||||||
|
|
||||||
public class BreweryLocationCreateDto
|
|
||||||
{
|
|
||||||
public Guid CityId { get; set; }
|
|
||||||
public string AddressLine1 { get; set; } = string.Empty;
|
|
||||||
public string? AddressLine2 { get; set; }
|
|
||||||
public string PostalCode { get; set; } = string.Empty;
|
|
||||||
public byte[]? Coordinates { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public class BreweryLocationDto
|
|
||||||
{
|
|
||||||
public Guid BreweryPostLocationId { get; set; }
|
|
||||||
public Guid BreweryPostId { get; set; }
|
|
||||||
public Guid CityId { get; set; }
|
|
||||||
public string AddressLine1 { get; set; } = string.Empty;
|
|
||||||
public string? AddressLine2 { get; set; }
|
|
||||||
public string PostalCode { get; set; } = string.Empty;
|
|
||||||
public byte[]? Coordinates { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public class BreweryCreateDto
|
|
||||||
{
|
|
||||||
public Guid PostedById { get; set; }
|
|
||||||
public string BreweryName { get; set; } = string.Empty;
|
|
||||||
public string Description { get; set; } = string.Empty;
|
|
||||||
public BreweryLocationCreateDto Location { get; set; } = null!;
|
|
||||||
}
|
|
||||||
|
|
||||||
public class BreweryDto
|
|
||||||
{
|
|
||||||
public Guid BreweryPostId { get; set; }
|
|
||||||
public Guid PostedById { get; set; }
|
|
||||||
public string BreweryName { get; set; } = string.Empty;
|
|
||||||
public string Description { get; set; } = string.Empty;
|
|
||||||
public DateTime CreatedAt { get; set; }
|
|
||||||
public DateTime? UpdatedAt { get; set; }
|
|
||||||
public byte[]? Timer { get; set; }
|
|
||||||
public BreweryLocationDto? Location { get; set; }
|
|
||||||
}
|
|
||||||
@@ -86,13 +86,6 @@ namespace API.Core.Controllers
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("confirm/resend")]
|
|
||||||
public async Task<ActionResult> ResendConfirmation([FromQuery] Guid userId)
|
|
||||||
{
|
|
||||||
await confirmationService.ResendConfirmationEmailAsync(userId);
|
|
||||||
return Ok(new ResponseBody { Message = "confirmation email has been resent" });
|
|
||||||
}
|
|
||||||
|
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
[HttpPost("refresh")]
|
[HttpPost("refresh")]
|
||||||
public async Task<ActionResult> Refresh(
|
public async Task<ActionResult> Refresh(
|
||||||
|
|||||||
@@ -1,129 +0,0 @@
|
|||||||
using API.Core.Contracts.Breweries;
|
|
||||||
using API.Core.Contracts.Common;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using Service.Breweries;
|
|
||||||
|
|
||||||
namespace API.Core.Controllers;
|
|
||||||
|
|
||||||
[ApiController]
|
|
||||||
[Route("api/[controller]")]
|
|
||||||
[Authorize(AuthenticationSchemes = "JWT")]
|
|
||||||
public class BreweryController(IBreweryService breweryService) : ControllerBase
|
|
||||||
{
|
|
||||||
[AllowAnonymous]
|
|
||||||
[HttpGet("{id:guid}")]
|
|
||||||
public async Task<ActionResult<ResponseBody<BreweryDto>>> GetById(Guid id)
|
|
||||||
{
|
|
||||||
var brewery = await breweryService.GetByIdAsync(id);
|
|
||||||
if (brewery is null)
|
|
||||||
return NotFound(new ResponseBody { Message = $"Brewery with ID {id} not found." });
|
|
||||||
|
|
||||||
return Ok(new ResponseBody<BreweryDto>
|
|
||||||
{
|
|
||||||
Message = "Brewery retrieved successfully.",
|
|
||||||
Payload = MapToDto(brewery),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
[AllowAnonymous]
|
|
||||||
[HttpGet]
|
|
||||||
public async Task<ActionResult<ResponseBody<IEnumerable<BreweryDto>>>> GetAll(
|
|
||||||
[FromQuery] int? limit,
|
|
||||||
[FromQuery] int? offset)
|
|
||||||
{
|
|
||||||
var breweries = await breweryService.GetAllAsync(limit, offset);
|
|
||||||
return Ok(new ResponseBody<IEnumerable<BreweryDto>>
|
|
||||||
{
|
|
||||||
Message = "Breweries retrieved successfully.",
|
|
||||||
Payload = breweries.Select(MapToDto),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPost]
|
|
||||||
public async Task<ActionResult<ResponseBody<BreweryDto>>> Create([FromBody] BreweryCreateDto dto)
|
|
||||||
{
|
|
||||||
var request = new BreweryCreateRequest(
|
|
||||||
dto.PostedById,
|
|
||||||
dto.BreweryName,
|
|
||||||
dto.Description,
|
|
||||||
new BreweryLocationCreateRequest(
|
|
||||||
dto.Location.CityId,
|
|
||||||
dto.Location.AddressLine1,
|
|
||||||
dto.Location.AddressLine2,
|
|
||||||
dto.Location.PostalCode,
|
|
||||||
dto.Location.Coordinates
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
var result = await breweryService.CreateAsync(request);
|
|
||||||
if (!result.Success)
|
|
||||||
return BadRequest(new ResponseBody { Message = result.Message });
|
|
||||||
|
|
||||||
return Created($"/api/brewery/{result.Brewery.BreweryPostId}", new ResponseBody<BreweryDto>
|
|
||||||
{
|
|
||||||
Message = "Brewery created successfully.",
|
|
||||||
Payload = MapToDto(result.Brewery),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPut("{id:guid}")]
|
|
||||||
public async Task<ActionResult<ResponseBody<BreweryDto>>> Update(Guid id, [FromBody] BreweryDto dto)
|
|
||||||
{
|
|
||||||
if (dto.BreweryPostId != id)
|
|
||||||
return BadRequest(new ResponseBody { Message = "Route ID does not match payload ID." });
|
|
||||||
|
|
||||||
var request = new BreweryUpdateRequest(
|
|
||||||
dto.BreweryPostId,
|
|
||||||
dto.PostedById,
|
|
||||||
dto.BreweryName,
|
|
||||||
dto.Description,
|
|
||||||
dto.Location is null ? null : new BreweryLocationUpdateRequest(
|
|
||||||
dto.Location.BreweryPostLocationId,
|
|
||||||
dto.Location.CityId,
|
|
||||||
dto.Location.AddressLine1,
|
|
||||||
dto.Location.AddressLine2,
|
|
||||||
dto.Location.PostalCode,
|
|
||||||
dto.Location.Coordinates
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
var result = await breweryService.UpdateAsync(request);
|
|
||||||
if (!result.Success)
|
|
||||||
return BadRequest(new ResponseBody { Message = result.Message });
|
|
||||||
|
|
||||||
return Ok(new ResponseBody<BreweryDto>
|
|
||||||
{
|
|
||||||
Message = "Brewery updated successfully.",
|
|
||||||
Payload = MapToDto(result.Brewery),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpDelete("{id:guid}")]
|
|
||||||
public async Task<ActionResult<ResponseBody>> Delete(Guid id)
|
|
||||||
{
|
|
||||||
await breweryService.DeleteAsync(id);
|
|
||||||
return Ok(new ResponseBody { Message = "Brewery deleted successfully." });
|
|
||||||
}
|
|
||||||
|
|
||||||
private static BreweryDto MapToDto(Domain.Entities.BreweryPost b) => new()
|
|
||||||
{
|
|
||||||
BreweryPostId = b.BreweryPostId,
|
|
||||||
PostedById = b.PostedById,
|
|
||||||
BreweryName = b.BreweryName,
|
|
||||||
Description = b.Description,
|
|
||||||
CreatedAt = b.CreatedAt,
|
|
||||||
UpdatedAt = b.UpdatedAt,
|
|
||||||
Timer = b.Timer,
|
|
||||||
Location = b.Location is null ? null : new BreweryLocationDto
|
|
||||||
{
|
|
||||||
BreweryPostLocationId = b.Location.BreweryPostLocationId,
|
|
||||||
BreweryPostId = b.Location.BreweryPostId,
|
|
||||||
CityId = b.Location.CityId,
|
|
||||||
AddressLine1 = b.Location.AddressLine1,
|
|
||||||
AddressLine2 = b.Location.AddressLine2,
|
|
||||||
PostalCode = b.Location.PostalCode,
|
|
||||||
Coordinates = b.Location.Coordinates,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,15 +1,20 @@
|
|||||||
using API.Core;
|
using API.Core;
|
||||||
using API.Core.Authentication;
|
using API.Core.Authentication;
|
||||||
|
using API.Core.Contracts.Common;
|
||||||
|
using Domain.Exceptions;
|
||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
using FluentValidation.AspNetCore;
|
using FluentValidation.AspNetCore;
|
||||||
using Infrastructure.Email;
|
using Infrastructure.Email;
|
||||||
|
using Infrastructure.Email.Templates;
|
||||||
using Infrastructure.Email.Templates.Rendering;
|
using Infrastructure.Email.Templates.Rendering;
|
||||||
using Infrastructure.Jwt;
|
using Infrastructure.Jwt;
|
||||||
using Infrastructure.PasswordHashing;
|
using Infrastructure.PasswordHashing;
|
||||||
using Infrastructure.Repository.Auth;
|
using Infrastructure.Repository.Auth;
|
||||||
using Infrastructure.Repository.Sql;
|
using Infrastructure.Repository.Sql;
|
||||||
using Infrastructure.Repository.UserAccount;
|
using Infrastructure.Repository.UserAccount;
|
||||||
using Infrastructure.Repository.Breweries;
|
using Microsoft.AspNetCore.Authentication;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Filters;
|
||||||
using Service.Auth;
|
using Service.Auth;
|
||||||
using Service.Emails;
|
using Service.Emails;
|
||||||
using Service.UserManagement.User;
|
using Service.UserManagement.User;
|
||||||
@@ -50,7 +55,6 @@ builder.Services.AddSingleton<
|
|||||||
|
|
||||||
builder.Services.AddScoped<IUserAccountRepository, UserAccountRepository>();
|
builder.Services.AddScoped<IUserAccountRepository, UserAccountRepository>();
|
||||||
builder.Services.AddScoped<IAuthRepository, AuthRepository>();
|
builder.Services.AddScoped<IAuthRepository, AuthRepository>();
|
||||||
builder.Services.AddScoped<IBreweryRepository, BreweryRepository>();
|
|
||||||
|
|
||||||
builder.Services.AddScoped<IUserService, UserService>();
|
builder.Services.AddScoped<IUserService, UserService>();
|
||||||
builder.Services.AddScoped<ILoginService, LoginService>();
|
builder.Services.AddScoped<ILoginService, LoginService>();
|
||||||
|
|||||||
@@ -26,7 +26,6 @@
|
|||||||
<Project Path="Service/Service.Auth.Tests/Service.Auth.Tests.csproj" />
|
<Project Path="Service/Service.Auth.Tests/Service.Auth.Tests.csproj" />
|
||||||
<Project Path="Service/Service.Emails/Service.Emails.csproj" />
|
<Project Path="Service/Service.Emails/Service.Emails.csproj" />
|
||||||
<Project Path="Service/Service.UserManagement/Service.UserManagement.csproj" />
|
<Project Path="Service/Service.UserManagement/Service.UserManagement.csproj" />
|
||||||
<Project Path="Service/Service.Auth/Service.Auth.csproj" />
|
<Project Path="Service\Service.Auth\Service.Auth.csproj" />
|
||||||
<Project Path="Service/Service.Breweries/Service.Breweries.csproj" />
|
|
||||||
</Folder>
|
</Folder>
|
||||||
</Solution>
|
</Solution>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ USE Biergarten;
|
|||||||
CREATE TABLE dbo.UserAccount
|
CREATE TABLE dbo.UserAccount
|
||||||
(
|
(
|
||||||
UserAccountID UNIQUEIDENTIFIER
|
UserAccountID UNIQUEIDENTIFIER
|
||||||
CONSTRAINT DF_UserAccountID DEFAULT NEWID(),
|
CONSTRAINT DF_UserAccountID DEFAULT NEWID(),
|
||||||
|
|
||||||
Username VARCHAR(64) NOT NULL,
|
Username VARCHAR(64) NOT NULL,
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ CREATE TABLE dbo.UserAccount
|
|||||||
|
|
||||||
UpdatedAt DATETIME,
|
UpdatedAt DATETIME,
|
||||||
|
|
||||||
DateOfBirth DATE NOT NULL,
|
DateOfBirth DATETIME NOT NULL,
|
||||||
|
|
||||||
Timer ROWVERSION,
|
Timer ROWVERSION,
|
||||||
|
|
||||||
@@ -49,6 +49,7 @@ CREATE TABLE dbo.UserAccount
|
|||||||
|
|
||||||
CONSTRAINT AK_Email
|
CONSTRAINT AK_Email
|
||||||
UNIQUE (Email)
|
UNIQUE (Email)
|
||||||
|
|
||||||
);
|
);
|
||||||
|
|
||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
@@ -108,7 +109,7 @@ CREATE TABLE UserAvatar -- delete avatar photo when user account is deleted
|
|||||||
|
|
||||||
CONSTRAINT AK_UserAvatar_UserAccountID
|
CONSTRAINT AK_UserAvatar_UserAccountID
|
||||||
UNIQUE (UserAccountID)
|
UNIQUE (UserAccountID)
|
||||||
);
|
)
|
||||||
|
|
||||||
CREATE NONCLUSTERED INDEX IX_UserAvatar_UserAccount
|
CREATE NONCLUSTERED INDEX IX_UserAvatar_UserAccount
|
||||||
ON UserAvatar(UserAccountID);
|
ON UserAvatar(UserAccountID);
|
||||||
@@ -124,7 +125,8 @@ CREATE TABLE UserVerification -- delete verification data when user account is d
|
|||||||
UserAccountID UNIQUEIDENTIFIER NOT NULL,
|
UserAccountID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
VerificationDateTime DATETIME NOT NULL
|
VerificationDateTime DATETIME NOT NULL
|
||||||
CONSTRAINT DF_VerificationDateTime DEFAULT GETDATE(),
|
CONSTRAINT DF_VerificationDateTime
|
||||||
|
DEFAULT GETDATE(),
|
||||||
|
|
||||||
Timer ROWVERSION,
|
Timer ROWVERSION,
|
||||||
|
|
||||||
@@ -153,13 +155,13 @@ CREATE TABLE UserCredential -- delete credentials when user account is deleted
|
|||||||
|
|
||||||
UserAccountID UNIQUEIDENTIFIER NOT NULL,
|
UserAccountID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
CreatedAt DATETIME NOT NULL
|
CreatedAt DATETIME
|
||||||
CONSTRAINT DF_UserCredential_CreatedAt DEFAULT GETDATE(),
|
CONSTRAINT DF_UserCredential_CreatedAt DEFAULT GETDATE() NOT NULL,
|
||||||
|
|
||||||
Expiry DATETIME NOT NULL
|
Expiry DATETIME
|
||||||
CONSTRAINT DF_UserCredential_Expiry DEFAULT DATEADD(DAY, 90, GETDATE()),
|
CONSTRAINT DF_UserCredential_Expiry DEFAULT DATEADD(DAY, 90, GETDATE()) NOT NULL,
|
||||||
|
|
||||||
Hash NVARCHAR(256) NOT NULL,
|
Hash NVARCHAR(MAX) NOT NULL,
|
||||||
-- uses argon2
|
-- uses argon2
|
||||||
|
|
||||||
IsRevoked BIT NOT NULL
|
IsRevoked BIT NOT NULL
|
||||||
@@ -175,16 +177,12 @@ CREATE TABLE UserCredential -- delete credentials when user account is deleted
|
|||||||
CONSTRAINT FK_UserCredential_UserAccount
|
CONSTRAINT FK_UserCredential_UserAccount
|
||||||
FOREIGN KEY (UserAccountID)
|
FOREIGN KEY (UserAccountID)
|
||||||
REFERENCES UserAccount(UserAccountID)
|
REFERENCES UserAccount(UserAccountID)
|
||||||
ON DELETE CASCADE
|
ON DELETE CASCADE,
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE NONCLUSTERED INDEX IX_UserCredential_UserAccount
|
CREATE NONCLUSTERED INDEX IX_UserCredential_UserAccount
|
||||||
ON UserCredential(UserAccountID);
|
ON UserCredential(UserAccountID);
|
||||||
|
|
||||||
CREATE NONCLUSTERED INDEX IX_UserCredential_Account_Active
|
|
||||||
ON UserCredential(UserAccountID, IsRevoked, Expiry)
|
|
||||||
INCLUDE (Hash);
|
|
||||||
|
|
||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
|
|
||||||
@@ -197,8 +195,8 @@ CREATE TABLE UserFollow
|
|||||||
|
|
||||||
FollowingID UNIQUEIDENTIFIER NOT NULL,
|
FollowingID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
CreatedAt DATETIME NOT NULL
|
CreatedAt DATETIME
|
||||||
CONSTRAINT DF_UserFollow_CreatedAt DEFAULT GETDATE(),
|
CONSTRAINT DF_UserFollow_CreatedAt DEFAULT GETDATE() NOT NULL,
|
||||||
|
|
||||||
Timer ROWVERSION,
|
Timer ROWVERSION,
|
||||||
|
|
||||||
@@ -207,13 +205,11 @@ CREATE TABLE UserFollow
|
|||||||
|
|
||||||
CONSTRAINT FK_UserFollow_UserAccount
|
CONSTRAINT FK_UserFollow_UserAccount
|
||||||
FOREIGN KEY (UserAccountID)
|
FOREIGN KEY (UserAccountID)
|
||||||
REFERENCES UserAccount(UserAccountID)
|
REFERENCES UserAccount(UserAccountID),
|
||||||
ON DELETE NO ACTION,
|
|
||||||
|
|
||||||
CONSTRAINT FK_UserFollow_UserAccountFollowing
|
CONSTRAINT FK_UserFollow_UserAccountFollowing
|
||||||
FOREIGN KEY (FollowingID)
|
FOREIGN KEY (FollowingID)
|
||||||
REFERENCES UserAccount(UserAccountID)
|
REFERENCES UserAccount(UserAccountID),
|
||||||
ON DELETE NO ACTION,
|
|
||||||
|
|
||||||
CONSTRAINT CK_CannotFollowOwnAccount
|
CONSTRAINT CK_CannotFollowOwnAccount
|
||||||
CHECK (UserAccountID != FollowingID)
|
CHECK (UserAccountID != FollowingID)
|
||||||
@@ -225,6 +221,7 @@ CREATE NONCLUSTERED INDEX IX_UserFollow_UserAccount_FollowingID
|
|||||||
CREATE NONCLUSTERED INDEX IX_UserFollow_FollowingID_UserAccount
|
CREATE NONCLUSTERED INDEX IX_UserFollow_FollowingID_UserAccount
|
||||||
ON UserFollow(FollowingID, UserAccountID);
|
ON UserFollow(FollowingID, UserAccountID);
|
||||||
|
|
||||||
|
|
||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
|
|
||||||
@@ -243,7 +240,7 @@ CREATE TABLE Country
|
|||||||
PRIMARY KEY (CountryID),
|
PRIMARY KEY (CountryID),
|
||||||
|
|
||||||
CONSTRAINT AK_Country_ISO3166_1
|
CONSTRAINT AK_Country_ISO3166_1
|
||||||
UNIQUE (ISO3166_1)
|
UNIQUE (ISO3166_1)
|
||||||
);
|
);
|
||||||
|
|
||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
@@ -302,6 +299,7 @@ CREATE TABLE City
|
|||||||
CREATE NONCLUSTERED INDEX IX_City_StateProvince
|
CREATE NONCLUSTERED INDEX IX_City_StateProvince
|
||||||
ON City(StateProvinceID);
|
ON City(StateProvinceID);
|
||||||
|
|
||||||
|
|
||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
|
|
||||||
@@ -310,8 +308,6 @@ CREATE TABLE BreweryPost -- A user cannot be deleted if they have a post
|
|||||||
BreweryPostID UNIQUEIDENTIFIER
|
BreweryPostID UNIQUEIDENTIFIER
|
||||||
CONSTRAINT DF_BreweryPostID DEFAULT NEWID(),
|
CONSTRAINT DF_BreweryPostID DEFAULT NEWID(),
|
||||||
|
|
||||||
BreweryName NVARCHAR(256) NOT NULL,
|
|
||||||
|
|
||||||
PostedByID UNIQUEIDENTIFIER NOT NULL,
|
PostedByID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
Description NVARCHAR(512) NOT NULL,
|
Description NVARCHAR(512) NOT NULL,
|
||||||
@@ -329,15 +325,15 @@ CREATE TABLE BreweryPost -- A user cannot be deleted if they have a post
|
|||||||
CONSTRAINT FK_BreweryPost_UserAccount
|
CONSTRAINT FK_BreweryPost_UserAccount
|
||||||
FOREIGN KEY (PostedByID)
|
FOREIGN KEY (PostedByID)
|
||||||
REFERENCES UserAccount(UserAccountID)
|
REFERENCES UserAccount(UserAccountID)
|
||||||
ON DELETE NO ACTION
|
ON DELETE NO ACTION,
|
||||||
);
|
|
||||||
|
)
|
||||||
|
|
||||||
CREATE NONCLUSTERED INDEX IX_BreweryPost_PostedByID
|
CREATE NONCLUSTERED INDEX IX_BreweryPost_PostedByID
|
||||||
ON BreweryPost(PostedByID);
|
ON BreweryPost(PostedByID);
|
||||||
|
|
||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
|
|
||||||
CREATE TABLE BreweryPostLocation
|
CREATE TABLE BreweryPostLocation
|
||||||
(
|
(
|
||||||
BreweryPostLocationID UNIQUEIDENTIFIER
|
BreweryPostLocationID UNIQUEIDENTIFIER
|
||||||
@@ -353,7 +349,7 @@ CREATE TABLE BreweryPostLocation
|
|||||||
|
|
||||||
CityID UNIQUEIDENTIFIER NOT NULL,
|
CityID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
Coordinates GEOGRAPHY NULL,
|
Coordinates GEOGRAPHY NOT NULL,
|
||||||
|
|
||||||
Timer ROWVERSION,
|
Timer ROWVERSION,
|
||||||
|
|
||||||
@@ -366,11 +362,7 @@ CREATE TABLE BreweryPostLocation
|
|||||||
CONSTRAINT FK_BreweryPostLocation_BreweryPost
|
CONSTRAINT FK_BreweryPostLocation_BreweryPost
|
||||||
FOREIGN KEY (BreweryPostID)
|
FOREIGN KEY (BreweryPostID)
|
||||||
REFERENCES BreweryPost(BreweryPostID)
|
REFERENCES BreweryPost(BreweryPostID)
|
||||||
ON DELETE CASCADE,
|
ON DELETE CASCADE
|
||||||
|
|
||||||
CONSTRAINT FK_BreweryPostLocation_City
|
|
||||||
FOREIGN KEY (CityID)
|
|
||||||
REFERENCES City(CityID)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE NONCLUSTERED INDEX IX_BreweryPostLocation_BreweryPost
|
CREATE NONCLUSTERED INDEX IX_BreweryPostLocation_BreweryPost
|
||||||
@@ -379,18 +371,6 @@ CREATE NONCLUSTERED INDEX IX_BreweryPostLocation_BreweryPost
|
|||||||
CREATE NONCLUSTERED INDEX IX_BreweryPostLocation_City
|
CREATE NONCLUSTERED INDEX IX_BreweryPostLocation_City
|
||||||
ON BreweryPostLocation(CityID);
|
ON BreweryPostLocation(CityID);
|
||||||
|
|
||||||
-- To assess when the time comes:
|
|
||||||
|
|
||||||
-- This would allow for efficient spatial queries to find breweries within a certain distance of a location, but it adds overhead to insert/update operations.
|
|
||||||
|
|
||||||
-- CREATE SPATIAL INDEX SIDX_BreweryPostLocation_Coordinates
|
|
||||||
-- ON BreweryPostLocation(Coordinates)
|
|
||||||
-- USING GEOGRAPHY_GRID
|
|
||||||
-- WITH (
|
|
||||||
-- GRIDS = (LEVEL_1 = MEDIUM, LEVEL_2 = MEDIUM, LEVEL_3 = MEDIUM, LEVEL_4 = MEDIUM),
|
|
||||||
-- CELLS_PER_OBJECT = 16
|
|
||||||
-- );
|
|
||||||
|
|
||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
|
|
||||||
@@ -423,14 +403,13 @@ CREATE TABLE BreweryPostPhoto -- All photos linked to a post are deleted if the
|
|||||||
);
|
);
|
||||||
|
|
||||||
CREATE NONCLUSTERED INDEX IX_BreweryPostPhoto_Photo_BreweryPost
|
CREATE NONCLUSTERED INDEX IX_BreweryPostPhoto_Photo_BreweryPost
|
||||||
ON BreweryPostPhoto(PhotoID, BreweryPostID);
|
ON BreweryPostPhoto(PhotoID, BreweryPostID);
|
||||||
|
|
||||||
CREATE NONCLUSTERED INDEX IX_BreweryPostPhoto_BreweryPost_Photo
|
CREATE NONCLUSTERED INDEX IX_BreweryPostPhoto_BreweryPost_Photo
|
||||||
ON BreweryPostPhoto(BreweryPostID, PhotoID);
|
ON BreweryPostPhoto(BreweryPostID, PhotoID);
|
||||||
|
|
||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
|
|
||||||
CREATE TABLE BeerStyle
|
CREATE TABLE BeerStyle
|
||||||
(
|
(
|
||||||
BeerStyleID UNIQUEIDENTIFIER
|
BeerStyleID UNIQUEIDENTIFIER
|
||||||
@@ -465,7 +444,7 @@ CREATE TABLE BeerPost
|
|||||||
-- Alcohol By Volume (typically 0-67%)
|
-- Alcohol By Volume (typically 0-67%)
|
||||||
|
|
||||||
IBU INT NOT NULL,
|
IBU INT NOT NULL,
|
||||||
-- International Bitterness Units (typically 0-120)
|
-- International Bitterness Units (typically 0-100)
|
||||||
|
|
||||||
PostedByID UNIQUEIDENTIFIER NOT NULL,
|
PostedByID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
@@ -485,8 +464,7 @@ CREATE TABLE BeerPost
|
|||||||
|
|
||||||
CONSTRAINT FK_BeerPost_PostedBy
|
CONSTRAINT FK_BeerPost_PostedBy
|
||||||
FOREIGN KEY (PostedByID)
|
FOREIGN KEY (PostedByID)
|
||||||
REFERENCES UserAccount(UserAccountID)
|
REFERENCES UserAccount(UserAccountID),
|
||||||
ON DELETE NO ACTION,
|
|
||||||
|
|
||||||
CONSTRAINT FK_BeerPost_BeerStyle
|
CONSTRAINT FK_BeerPost_BeerStyle
|
||||||
FOREIGN KEY (BeerStyleID)
|
FOREIGN KEY (BeerStyleID)
|
||||||
@@ -544,10 +522,10 @@ CREATE TABLE BeerPostPhoto -- All photos linked to a beer post are deleted if th
|
|||||||
);
|
);
|
||||||
|
|
||||||
CREATE NONCLUSTERED INDEX IX_BeerPostPhoto_Photo_BeerPost
|
CREATE NONCLUSTERED INDEX IX_BeerPostPhoto_Photo_BeerPost
|
||||||
ON BeerPostPhoto(PhotoID, BeerPostID);
|
ON BeerPostPhoto(PhotoID, BeerPostID);
|
||||||
|
|
||||||
CREATE NONCLUSTERED INDEX IX_BeerPostPhoto_BeerPost_Photo
|
CREATE NONCLUSTERED INDEX IX_BeerPostPhoto_BeerPost_Photo
|
||||||
ON BeerPostPhoto(BeerPostID, PhotoID);
|
ON BeerPostPhoto(BeerPostID, PhotoID);
|
||||||
|
|
||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
@@ -561,35 +539,17 @@ CREATE TABLE BeerPostComment
|
|||||||
|
|
||||||
BeerPostID UNIQUEIDENTIFIER NOT NULL,
|
BeerPostID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
CommentedByID UNIQUEIDENTIFIER NOT NULL,
|
|
||||||
|
|
||||||
Rating INT NOT NULL,
|
Rating INT NOT NULL,
|
||||||
|
|
||||||
CreatedAt DATETIME NOT NULL
|
|
||||||
CONSTRAINT DF_BeerPostComment_CreatedAt DEFAULT GETDATE(),
|
|
||||||
|
|
||||||
UpdatedAt DATETIME NULL,
|
|
||||||
|
|
||||||
Timer ROWVERSION,
|
Timer ROWVERSION,
|
||||||
|
|
||||||
CONSTRAINT PK_BeerPostComment
|
CONSTRAINT PK_BeerPostComment
|
||||||
PRIMARY KEY (BeerPostCommentID),
|
PRIMARY KEY (BeerPostCommentID),
|
||||||
|
|
||||||
CONSTRAINT FK_BeerPostComment_BeerPost
|
CONSTRAINT FK_BeerPostComment_BeerPost
|
||||||
FOREIGN KEY (BeerPostID)
|
FOREIGN KEY (BeerPostID) REFERENCES BeerPost(BeerPostID)
|
||||||
REFERENCES BeerPost(BeerPostID),
|
)
|
||||||
|
|
||||||
CONSTRAINT FK_BeerPostComment_UserAccount
|
|
||||||
FOREIGN KEY (CommentedByID)
|
|
||||||
REFERENCES UserAccount(UserAccountID)
|
|
||||||
ON DELETE NO ACTION,
|
|
||||||
|
|
||||||
CONSTRAINT CHK_BeerPostComment_Rating
|
|
||||||
CHECK (Rating BETWEEN 1 AND 5)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE NONCLUSTERED INDEX IX_BeerPostComment_BeerPost
|
CREATE NONCLUSTERED INDEX IX_BeerPostComment_BeerPost
|
||||||
ON BeerPostComment(BeerPostID);
|
ON BeerPostComment(BeerPostID)
|
||||||
|
|
||||||
CREATE NONCLUSTERED INDEX IX_BeerPostComment_CommentedBy
|
|
||||||
ON BeerPostComment(CommentedByID);
|
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
CREATE OR ALTER PROCEDURE dbo.USP_CreateBrewery(
|
|
||||||
@BreweryName NVARCHAR(256),
|
|
||||||
@Description NVARCHAR(512),
|
|
||||||
@PostedByID UNIQUEIDENTIFIER,
|
|
||||||
@CityID UNIQUEIDENTIFIER,
|
|
||||||
@AddressLine1 NVARCHAR(256),
|
|
||||||
@AddressLine2 NVARCHAR(256) = NULL,
|
|
||||||
@PostalCode NVARCHAR(20),
|
|
||||||
@Coordinates GEOGRAPHY = NULL
|
|
||||||
)
|
|
||||||
AS
|
|
||||||
BEGIN
|
|
||||||
SET NOCOUNT ON;
|
|
||||||
SET XACT_ABORT ON;
|
|
||||||
|
|
||||||
IF @BreweryName IS NULL
|
|
||||||
THROW 50001, 'Brewery name cannot be null.', 1;
|
|
||||||
|
|
||||||
IF @Description IS NULL
|
|
||||||
THROW 50002, 'Brewery description cannot be null.', 1;
|
|
||||||
|
|
||||||
IF NOT EXISTS (SELECT 1
|
|
||||||
FROM dbo.UserAccount
|
|
||||||
WHERE UserAccountID = @PostedByID)
|
|
||||||
THROW 50404, 'User not found.', 1;
|
|
||||||
|
|
||||||
IF NOT EXISTS (SELECT 1
|
|
||||||
FROM dbo.City
|
|
||||||
WHERE CityID = @CityID)
|
|
||||||
THROW 50404, 'City not found.', 1;
|
|
||||||
|
|
||||||
DECLARE @NewBreweryID UNIQUEIDENTIFIER = NEWID();
|
|
||||||
DECLARE @NewBrewerLocationID UNIQUEIDENTIFIER = NEWID();
|
|
||||||
|
|
||||||
BEGIN TRANSACTION;
|
|
||||||
|
|
||||||
INSERT INTO dbo.BreweryPost
|
|
||||||
(BreweryPostID, BreweryName, Description, PostedByID)
|
|
||||||
VALUES (@NewBreweryID, @BreweryName, @Description, @PostedByID);
|
|
||||||
|
|
||||||
INSERT INTO dbo.BreweryPostLocation
|
|
||||||
(BreweryPostLocationID, BreweryPostID, CityID, AddressLine1, AddressLine2, PostalCode, Coordinates)
|
|
||||||
VALUES (@NewBrewerLocationID, @NewBreweryID, @CityID, @AddressLine1, @AddressLine2, @PostalCode, @Coordinates);
|
|
||||||
|
|
||||||
COMMIT TRANSACTION;
|
|
||||||
|
|
||||||
SELECT @NewBreweryID AS BreweryPostID,
|
|
||||||
@NewBrewerLocationID AS BreweryPostLocationID;
|
|
||||||
|
|
||||||
END
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
CREATE OR ALTER PROCEDURE dbo.USP_GetBreweryById @BreweryPostID UNIQUEIDENTIFIER
|
|
||||||
AS
|
|
||||||
BEGIN
|
|
||||||
SELECT *
|
|
||||||
FROM BreweryPost bp
|
|
||||||
INNER JOIN BreweryPostLocation bpl
|
|
||||||
ON bp.BreweryPostID = bpl.BreweryPostID
|
|
||||||
WHERE bp.BreweryPostID = @BreweryPostID;
|
|
||||||
END
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
namespace Domain.Entities;
|
|
||||||
|
|
||||||
public class BreweryPost
|
|
||||||
{
|
|
||||||
public Guid BreweryPostId { get; set; }
|
|
||||||
public Guid PostedById { get; set; }
|
|
||||||
public string BreweryName { get; set; } = string.Empty;
|
|
||||||
public string Description { get; set; } = string.Empty;
|
|
||||||
public DateTime CreatedAt { get; set; }
|
|
||||||
public DateTime? UpdatedAt { get; set; }
|
|
||||||
public byte[]? Timer { get; set; }
|
|
||||||
public BreweryPostLocation? Location { get; set; }
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
namespace Domain.Entities;
|
|
||||||
|
|
||||||
public class BreweryPostLocation
|
|
||||||
{
|
|
||||||
public Guid BreweryPostLocationId { get; set; }
|
|
||||||
public Guid BreweryPostId { get; set; }
|
|
||||||
public string AddressLine1 { get; set; } = string.Empty;
|
|
||||||
public string? AddressLine2 { get; set; }
|
|
||||||
public string PostalCode { get; set; } = string.Empty;
|
|
||||||
public Guid CityId { get; set; }
|
|
||||||
public byte[]? Coordinates { get; set; }
|
|
||||||
public byte[]? Timer { get; set; }
|
|
||||||
}
|
|
||||||
@@ -7,6 +7,6 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="MailKit" Version="4.15.1" />
|
<PackageReference Include="MailKit" Version="4.9.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -17,34 +17,10 @@ public class AuthRepositoryTest
|
|||||||
var conn = new MockDbConnection();
|
var conn = new MockDbConnection();
|
||||||
|
|
||||||
conn.Mocks.When(cmd => cmd.CommandText == "USP_RegisterUser")
|
conn.Mocks.When(cmd => cmd.CommandText == "USP_RegisterUser")
|
||||||
.ReturnsScalar(expectedUserId);
|
|
||||||
|
|
||||||
// Mock the subsequent read for the newly created user by id
|
|
||||||
conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountById")
|
|
||||||
.ReturnsTable(
|
.ReturnsTable(
|
||||||
MockTable
|
MockTable
|
||||||
.WithColumns(
|
.WithColumns(("UserAccountId", typeof(Guid)))
|
||||||
("UserAccountId", typeof(Guid)),
|
.AddRow(expectedUserId)
|
||||||
("Username", typeof(string)),
|
|
||||||
("FirstName", typeof(string)),
|
|
||||||
("LastName", typeof(string)),
|
|
||||||
("Email", typeof(string)),
|
|
||||||
("CreatedAt", typeof(DateTime)),
|
|
||||||
("UpdatedAt", typeof(DateTime?)),
|
|
||||||
("DateOfBirth", typeof(DateTime)),
|
|
||||||
("Timer", typeof(byte[]))
|
|
||||||
)
|
|
||||||
.AddRow(
|
|
||||||
expectedUserId,
|
|
||||||
"testuser",
|
|
||||||
"Test",
|
|
||||||
"User",
|
|
||||||
"test@example.com",
|
|
||||||
DateTime.UtcNow,
|
|
||||||
null,
|
|
||||||
new DateTime(1990, 1, 1),
|
|
||||||
null
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
var repo = CreateRepo(conn);
|
var repo = CreateRepo(conn);
|
||||||
|
|||||||
@@ -1,108 +0,0 @@
|
|||||||
using Apps72.Dev.Data.DbMocker;
|
|
||||||
using FluentAssertions;
|
|
||||||
using Infrastructure.Repository.Breweries;
|
|
||||||
using Infrastructure.Repository.Tests.Database;
|
|
||||||
using Domain.Entities;
|
|
||||||
|
|
||||||
namespace Infrastructure.Repository.Tests.Breweries;
|
|
||||||
|
|
||||||
public class BreweryRepositoryTest
|
|
||||||
{
|
|
||||||
private static BreweryRepository CreateRepo(MockDbConnection conn) =>
|
|
||||||
new(new TestConnectionFactory(conn));
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task GetByIdAsync_ReturnsBrewery_WhenExists()
|
|
||||||
{
|
|
||||||
var breweryId = Guid.NewGuid();
|
|
||||||
var conn = new MockDbConnection();
|
|
||||||
|
|
||||||
// Repository calls the stored procedure
|
|
||||||
const string getByIdSql = "USP_GetBreweryById";
|
|
||||||
|
|
||||||
var locationId = Guid.NewGuid();
|
|
||||||
|
|
||||||
conn.Mocks.When(cmd => cmd.CommandText == getByIdSql)
|
|
||||||
.ReturnsTable(
|
|
||||||
MockTable
|
|
||||||
.WithColumns(
|
|
||||||
("BreweryPostId", typeof(Guid)),
|
|
||||||
("PostedById", typeof(Guid)),
|
|
||||||
("BreweryName", typeof(string)),
|
|
||||||
("Description", typeof(string)),
|
|
||||||
("CreatedAt", typeof(DateTime)),
|
|
||||||
("UpdatedAt", typeof(DateTime?)),
|
|
||||||
("Timer", typeof(byte[])),
|
|
||||||
("BreweryPostLocationId", typeof(Guid)),
|
|
||||||
("CityId", typeof(Guid)),
|
|
||||||
("AddressLine1", typeof(string)),
|
|
||||||
("AddressLine2", typeof(string)),
|
|
||||||
("PostalCode", typeof(string)),
|
|
||||||
("Coordinates", typeof(byte[]))
|
|
||||||
)
|
|
||||||
.AddRow(
|
|
||||||
breweryId,
|
|
||||||
Guid.NewGuid(),
|
|
||||||
"Test Brewery",
|
|
||||||
"A test brewery description",
|
|
||||||
DateTime.UtcNow,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
locationId,
|
|
||||||
Guid.NewGuid(),
|
|
||||||
"123 Main St",
|
|
||||||
null,
|
|
||||||
"12345",
|
|
||||||
null
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
var repo = CreateRepo(conn);
|
|
||||||
var result = await repo.GetByIdAsync(breweryId);
|
|
||||||
result.Should().NotBeNull();
|
|
||||||
result!.BreweryPostId.Should().Be(breweryId);
|
|
||||||
result.Location.Should().NotBeNull();
|
|
||||||
result.Location!.BreweryPostLocationId.Should().Be(locationId);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task GetByIdAsync_ReturnsNull_WhenNotExists()
|
|
||||||
{
|
|
||||||
var conn = new MockDbConnection();
|
|
||||||
conn.Mocks.When(cmd => cmd.CommandText == "USP_GetBreweryById")
|
|
||||||
.ReturnsTable(MockTable.Empty());
|
|
||||||
var repo = CreateRepo(conn);
|
|
||||||
var result = await repo.GetByIdAsync(Guid.NewGuid());
|
|
||||||
result.Should().BeNull();
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task CreateAsync_ExecutesSuccessfully()
|
|
||||||
{
|
|
||||||
var conn = new MockDbConnection();
|
|
||||||
conn.Mocks.When(cmd => cmd.CommandText == "USP_CreateBrewery")
|
|
||||||
.ReturnsScalar(1);
|
|
||||||
var repo = CreateRepo(conn);
|
|
||||||
var brewery = new BreweryPost
|
|
||||||
{
|
|
||||||
BreweryPostId = Guid.NewGuid(),
|
|
||||||
PostedById = Guid.NewGuid(),
|
|
||||||
BreweryName = "Test Brewery",
|
|
||||||
Description = "A test brewery description",
|
|
||||||
CreatedAt = DateTime.UtcNow,
|
|
||||||
Location = new BreweryPostLocation
|
|
||||||
{
|
|
||||||
BreweryPostLocationId = Guid.NewGuid(),
|
|
||||||
CityId = Guid.NewGuid(),
|
|
||||||
AddressLine1 = "123 Main St",
|
|
||||||
PostalCode = "12345",
|
|
||||||
Coordinates = [0x00, 0x01]
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Should not throw
|
|
||||||
var act = async () => await repo.CreateAsync(brewery);
|
|
||||||
await act.Should().NotThrowAsync();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -33,39 +33,18 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
AddParameter(command, "@Hash", passwordHash);
|
AddParameter(command, "@Hash", passwordHash);
|
||||||
|
|
||||||
var result = await command.ExecuteScalarAsync();
|
var result = await command.ExecuteScalarAsync();
|
||||||
|
var userAccountId = result != null ? (Guid)result : Guid.Empty;
|
||||||
|
|
||||||
Guid userAccountId = Guid.Empty;
|
return new Domain.Entities.UserAccount
|
||||||
if (result != null && result != DBNull.Value)
|
|
||||||
{
|
{
|
||||||
if (result is Guid g)
|
UserAccountId = userAccountId,
|
||||||
{
|
Username = username,
|
||||||
userAccountId = g;
|
FirstName = firstName,
|
||||||
}
|
LastName = lastName,
|
||||||
else if (result is string s && Guid.TryParse(s, out var parsed))
|
Email = email,
|
||||||
{
|
DateOfBirth = dateOfBirth,
|
||||||
userAccountId = parsed;
|
CreatedAt = DateTime.UtcNow,
|
||||||
}
|
};
|
||||||
else if (result is byte[] bytes && bytes.Length == 16)
|
|
||||||
{
|
|
||||||
userAccountId = new Guid(bytes);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Fallback: try to convert and parse string representation
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var str = result.ToString();
|
|
||||||
if (!string.IsNullOrEmpty(str) && Guid.TryParse(str, out var p))
|
|
||||||
userAccountId = p;
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
userAccountId = Guid.Empty;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return await GetUserByIdAsync(userAccountId) ?? throw new Exception("Failed to retrieve newly registered user.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Domain.Entities.UserAccount?> GetUserByEmailAsync(
|
public async Task<Domain.Entities.UserAccount?> GetUserByEmailAsync(
|
||||||
|
|||||||
@@ -1,147 +0,0 @@
|
|||||||
using System.Data.Common;
|
|
||||||
using Domain.Entities;
|
|
||||||
using Infrastructure.Repository.Sql;
|
|
||||||
|
|
||||||
namespace Infrastructure.Repository.Breweries;
|
|
||||||
|
|
||||||
public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
|
||||||
: Repository<BreweryPost>(connectionFactory), IBreweryRepository
|
|
||||||
{
|
|
||||||
private readonly ISqlConnectionFactory _connectionFactory = connectionFactory;
|
|
||||||
|
|
||||||
public async Task<BreweryPost?> GetByIdAsync(Guid id)
|
|
||||||
{
|
|
||||||
await using var connection = await CreateConnection();
|
|
||||||
await using var command = connection.CreateCommand();
|
|
||||||
command.CommandType = System.Data.CommandType.StoredProcedure;
|
|
||||||
|
|
||||||
command.CommandText = "USP_GetBreweryById";
|
|
||||||
AddParameter(command, "@BreweryPostID", id);
|
|
||||||
|
|
||||||
await using var reader = await command.ExecuteReaderAsync();
|
|
||||||
if (await reader.ReadAsync())
|
|
||||||
{
|
|
||||||
return MapToEntity(reader);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<IEnumerable<BreweryPost>> GetAllAsync(int? limit, int? offset)
|
|
||||||
{
|
|
||||||
throw new NotImplementedException();
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task UpdateAsync(BreweryPost brewery)
|
|
||||||
{
|
|
||||||
throw new NotImplementedException();
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task DeleteAsync(Guid id)
|
|
||||||
{
|
|
||||||
throw new NotImplementedException();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task CreateAsync(BreweryPost brewery)
|
|
||||||
{
|
|
||||||
await using var connection = await CreateConnection();
|
|
||||||
await using var command = connection.CreateCommand();
|
|
||||||
|
|
||||||
command.CommandText = "USP_CreateBrewery";
|
|
||||||
command.CommandType = System.Data.CommandType.StoredProcedure;
|
|
||||||
|
|
||||||
if (brewery.Location is null)
|
|
||||||
{
|
|
||||||
throw new ArgumentException("Location must be provided when creating a brewery.");
|
|
||||||
}
|
|
||||||
|
|
||||||
AddParameter(command, "@BreweryName", brewery.BreweryName);
|
|
||||||
AddParameter(command, "@Description", brewery.Description);
|
|
||||||
AddParameter(command, "@PostedByID", brewery.PostedById);
|
|
||||||
AddParameter(command, "@CityID", brewery.Location?.CityId);
|
|
||||||
AddParameter(command, "@AddressLine1", brewery.Location?.AddressLine1);
|
|
||||||
AddParameter(command, "@AddressLine2", brewery.Location?.AddressLine2);
|
|
||||||
AddParameter(command, "@PostalCode", brewery.Location?.PostalCode);
|
|
||||||
AddParameter(command, "@Coordinates", brewery.Location?.Coordinates);
|
|
||||||
await command.ExecuteNonQueryAsync();
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override BreweryPost MapToEntity(DbDataReader reader)
|
|
||||||
{
|
|
||||||
var brewery = new BreweryPost();
|
|
||||||
|
|
||||||
var ordBreweryPostId = reader.GetOrdinal("BreweryPostId");
|
|
||||||
var ordPostedById = reader.GetOrdinal("PostedById");
|
|
||||||
var ordBreweryName = reader.GetOrdinal("BreweryName");
|
|
||||||
var ordDescription = reader.GetOrdinal("Description");
|
|
||||||
var ordCreatedAt = reader.GetOrdinal("CreatedAt");
|
|
||||||
var ordUpdatedAt = reader.GetOrdinal("UpdatedAt");
|
|
||||||
var ordTimer = reader.GetOrdinal("Timer");
|
|
||||||
|
|
||||||
brewery.BreweryPostId = reader.GetGuid(ordBreweryPostId);
|
|
||||||
brewery.PostedById = reader.GetGuid(ordPostedById);
|
|
||||||
brewery.BreweryName = reader.GetString(ordBreweryName);
|
|
||||||
brewery.Description = reader.GetString(ordDescription);
|
|
||||||
brewery.CreatedAt = reader.GetDateTime(ordCreatedAt);
|
|
||||||
|
|
||||||
brewery.UpdatedAt = reader.IsDBNull(ordUpdatedAt) ? null : reader.GetDateTime(ordUpdatedAt);
|
|
||||||
|
|
||||||
// Read timer (varbinary/rowversion) robustly
|
|
||||||
if (reader.IsDBNull(ordTimer))
|
|
||||||
{
|
|
||||||
brewery.Timer = null;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
brewery.Timer = reader.GetFieldValue<byte[]>(ordTimer);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
var length = reader.GetBytes(ordTimer, 0, null, 0, 0);
|
|
||||||
var buffer = new byte[length];
|
|
||||||
reader.GetBytes(ordTimer, 0, buffer, 0, (int)length);
|
|
||||||
brewery.Timer = buffer;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Map BreweryPostLocation if columns are present
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var ordLocationId = reader.GetOrdinal("BreweryPostLocationId");
|
|
||||||
if (!reader.IsDBNull(ordLocationId))
|
|
||||||
{
|
|
||||||
var location = new BreweryPostLocation
|
|
||||||
{
|
|
||||||
BreweryPostLocationId = reader.GetGuid(ordLocationId),
|
|
||||||
BreweryPostId = reader.GetGuid(reader.GetOrdinal("BreweryPostId")),
|
|
||||||
CityId = reader.GetGuid(reader.GetOrdinal("CityId")),
|
|
||||||
AddressLine1 = reader.GetString(reader.GetOrdinal("AddressLine1")),
|
|
||||||
AddressLine2 = reader.IsDBNull(reader.GetOrdinal("AddressLine2")) ? null : reader.GetString(reader.GetOrdinal("AddressLine2")),
|
|
||||||
PostalCode = reader.GetString(reader.GetOrdinal("PostalCode")),
|
|
||||||
Coordinates = reader.IsDBNull(reader.GetOrdinal("Coordinates")) ? null : reader.GetFieldValue<byte[]>(reader.GetOrdinal("Coordinates"))
|
|
||||||
};
|
|
||||||
brewery.Location = location;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (IndexOutOfRangeException)
|
|
||||||
{
|
|
||||||
// Location columns not present, skip mapping location
|
|
||||||
}
|
|
||||||
|
|
||||||
return brewery;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void AddParameter(
|
|
||||||
DbCommand command,
|
|
||||||
string name,
|
|
||||||
object? value
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var p = command.CreateParameter();
|
|
||||||
p.ParameterName = name;
|
|
||||||
p.Value = value ?? DBNull.Value;
|
|
||||||
command.Parameters.Add(p);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
using Domain.Entities;
|
|
||||||
|
|
||||||
namespace Infrastructure.Repository.Breweries;
|
|
||||||
|
|
||||||
public interface IBreweryRepository
|
|
||||||
{
|
|
||||||
Task<BreweryPost?> GetByIdAsync(Guid id);
|
|
||||||
Task<IEnumerable<BreweryPost>> GetAllAsync(int? limit, int? offset);
|
|
||||||
Task UpdateAsync(BreweryPost brewery);
|
|
||||||
Task DeleteAsync(Guid id);
|
|
||||||
Task CreateAsync(BreweryPost brewery);
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
using FluentAssertions;
|
|
||||||
using Xunit;
|
|
||||||
using Service.Breweries;
|
|
||||||
using API.Core.Contracts.Breweries;
|
|
||||||
using Domain.Entities;
|
|
||||||
|
|
||||||
namespace Service.Breweries.Tests;
|
|
||||||
|
|
||||||
public class BreweryServiceTests
|
|
||||||
{
|
|
||||||
private class FakeRepo : IBreweryRepository
|
|
||||||
{
|
|
||||||
public BreweryPost? Created;
|
|
||||||
|
|
||||||
public Task<BreweryPost?> GetByIdAsync(Guid id) => Task.FromResult<BreweryPost?>(null);
|
|
||||||
public Task<IEnumerable<BreweryPost>> GetAllAsync(int? limit, int? offset) => Task.FromResult<IEnumerable<BreweryPost>>(Array.Empty<BreweryPost>());
|
|
||||||
public Task UpdateAsync(BreweryPost brewery) { Created = brewery; return Task.CompletedTask; }
|
|
||||||
public Task DeleteAsync(Guid id) => Task.CompletedTask;
|
|
||||||
public Task CreateAsync(BreweryPost brewery) { Created = brewery; return Task.CompletedTask; }
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task CreateAsync_ReturnsFailure_WhenLocationMissing()
|
|
||||||
{
|
|
||||||
var repo = new FakeRepo();
|
|
||||||
var svc = new BreweryService(repo);
|
|
||||||
|
|
||||||
var dto = new BreweryCreateDto
|
|
||||||
{
|
|
||||||
PostedById = Guid.NewGuid(),
|
|
||||||
BreweryName = "X",
|
|
||||||
Description = "Y",
|
|
||||||
Location = null!
|
|
||||||
};
|
|
||||||
|
|
||||||
var result = await svc.CreateAsync(dto);
|
|
||||||
result.Success.Should().BeFalse();
|
|
||||||
result.Message.Should().Contain("Location");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task CreateAsync_ReturnsSuccess_AndPersistsEntity()
|
|
||||||
{
|
|
||||||
var repo = new FakeRepo();
|
|
||||||
var svc = new BreweryService(repo);
|
|
||||||
|
|
||||||
var loc = new BreweryLocationCreateDto
|
|
||||||
{
|
|
||||||
CityId = Guid.NewGuid(),
|
|
||||||
AddressLine1 = "123 Main",
|
|
||||||
PostalCode = "12345"
|
|
||||||
};
|
|
||||||
|
|
||||||
var dto = new BreweryCreateDto
|
|
||||||
{
|
|
||||||
PostedById = Guid.NewGuid(),
|
|
||||||
BreweryName = "MyBrew",
|
|
||||||
Description = "Desc",
|
|
||||||
Location = loc
|
|
||||||
};
|
|
||||||
|
|
||||||
var result = await svc.CreateAsync(dto);
|
|
||||||
|
|
||||||
result.Success.Should().BeTrue();
|
|
||||||
repo.Created.Should().NotBeNull();
|
|
||||||
repo.Created!.BreweryName.Should().Be("MyBrew");
|
|
||||||
result.Brewery.BreweryName.Should().Be("MyBrew");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<IsPackable>false</IsPackable>
|
|
||||||
<RootNamespace>Service.Breweries.Tests</RootNamespace>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
|
|
||||||
<PackageReference Include="xunit" Version="2.9.2" />
|
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
|
||||||
<PackageReference Include="FluentAssertions" Version="6.9.0" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<Using Include="Xunit" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="..\Service.Breweries\Service.Breweries.csproj" />
|
|
||||||
<ProjectReference Include="..\Service.Auth\Service.Auth.csproj" />
|
|
||||||
<ProjectReference
|
|
||||||
Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
|
|
||||||
<ProjectReference Include="..\..\API\API.Core\API.Core.csproj" />
|
|
||||||
</ItemGroup>
|
|
||||||
</Project>
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
using Domain.Entities;
|
|
||||||
using Infrastructure.Repository.Breweries;
|
|
||||||
|
|
||||||
namespace Service.Breweries;
|
|
||||||
|
|
||||||
public class BreweryService(IBreweryRepository repository) : IBreweryService
|
|
||||||
{
|
|
||||||
public Task<BreweryPost?> GetByIdAsync(Guid id) =>
|
|
||||||
repository.GetByIdAsync(id);
|
|
||||||
|
|
||||||
public Task<IEnumerable<BreweryPost>> GetAllAsync(int? limit = null, int? offset = null) =>
|
|
||||||
repository.GetAllAsync(limit, offset);
|
|
||||||
|
|
||||||
public async Task<BreweryServiceReturn> CreateAsync(BreweryCreateRequest request)
|
|
||||||
{
|
|
||||||
var entity = new BreweryPost
|
|
||||||
{
|
|
||||||
BreweryPostId = Guid.NewGuid(),
|
|
||||||
PostedById = request.PostedById,
|
|
||||||
BreweryName = request.BreweryName,
|
|
||||||
Description = request.Description,
|
|
||||||
CreatedAt = DateTime.UtcNow,
|
|
||||||
Location = new BreweryPostLocation
|
|
||||||
{
|
|
||||||
BreweryPostLocationId = Guid.NewGuid(),
|
|
||||||
CityId = request.Location.CityId,
|
|
||||||
AddressLine1 = request.Location.AddressLine1,
|
|
||||||
AddressLine2 = request.Location.AddressLine2,
|
|
||||||
PostalCode = request.Location.PostalCode,
|
|
||||||
Coordinates = request.Location.Coordinates,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
await repository.CreateAsync(entity);
|
|
||||||
return new BreweryServiceReturn(entity);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<BreweryServiceReturn> UpdateAsync(BreweryUpdateRequest request)
|
|
||||||
{
|
|
||||||
var entity = new BreweryPost
|
|
||||||
{
|
|
||||||
BreweryPostId = request.BreweryPostId,
|
|
||||||
PostedById = request.PostedById,
|
|
||||||
BreweryName = request.BreweryName,
|
|
||||||
Description = request.Description,
|
|
||||||
UpdatedAt = DateTime.UtcNow,
|
|
||||||
Location = request.Location is null ? null : new BreweryPostLocation
|
|
||||||
{
|
|
||||||
BreweryPostLocationId = request.Location.BreweryPostLocationId,
|
|
||||||
BreweryPostId = request.BreweryPostId,
|
|
||||||
CityId = request.Location.CityId,
|
|
||||||
AddressLine1 = request.Location.AddressLine1,
|
|
||||||
AddressLine2 = request.Location.AddressLine2,
|
|
||||||
PostalCode = request.Location.PostalCode,
|
|
||||||
Coordinates = request.Location.Coordinates,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
await repository.UpdateAsync(entity);
|
|
||||||
return new BreweryServiceReturn(entity);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task DeleteAsync(Guid id) =>
|
|
||||||
repository.DeleteAsync(id);
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
using Domain.Entities;
|
|
||||||
|
|
||||||
namespace Service.Breweries;
|
|
||||||
|
|
||||||
public record BreweryCreateRequest(
|
|
||||||
Guid PostedById,
|
|
||||||
string BreweryName,
|
|
||||||
string Description,
|
|
||||||
BreweryLocationCreateRequest Location
|
|
||||||
);
|
|
||||||
|
|
||||||
public record BreweryLocationCreateRequest(
|
|
||||||
Guid CityId,
|
|
||||||
string AddressLine1,
|
|
||||||
string? AddressLine2,
|
|
||||||
string PostalCode,
|
|
||||||
byte[]? Coordinates
|
|
||||||
);
|
|
||||||
|
|
||||||
public record BreweryUpdateRequest(
|
|
||||||
Guid BreweryPostId,
|
|
||||||
Guid PostedById,
|
|
||||||
string BreweryName,
|
|
||||||
string Description,
|
|
||||||
BreweryLocationUpdateRequest? Location
|
|
||||||
);
|
|
||||||
|
|
||||||
public record BreweryLocationUpdateRequest(
|
|
||||||
Guid BreweryPostLocationId,
|
|
||||||
Guid CityId,
|
|
||||||
string AddressLine1,
|
|
||||||
string? AddressLine2,
|
|
||||||
string PostalCode,
|
|
||||||
byte[]? Coordinates
|
|
||||||
);
|
|
||||||
|
|
||||||
public record BreweryServiceReturn
|
|
||||||
{
|
|
||||||
public bool Success { get; init; }
|
|
||||||
public BreweryPost Brewery { get; init; }
|
|
||||||
public string Message { get; init; } = string.Empty;
|
|
||||||
|
|
||||||
public BreweryServiceReturn(BreweryPost brewery)
|
|
||||||
{
|
|
||||||
Success = true;
|
|
||||||
Brewery = brewery;
|
|
||||||
}
|
|
||||||
|
|
||||||
public BreweryServiceReturn(string message)
|
|
||||||
{
|
|
||||||
Success = false;
|
|
||||||
Brewery = default!;
|
|
||||||
Message = message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public interface IBreweryService
|
|
||||||
{
|
|
||||||
Task<BreweryPost?> GetByIdAsync(Guid id);
|
|
||||||
Task<IEnumerable<BreweryPost>> GetAllAsync(int? limit = null, int? offset = null);
|
|
||||||
Task<BreweryServiceReturn> CreateAsync(BreweryCreateRequest request);
|
|
||||||
Task<BreweryServiceReturn> UpdateAsync(BreweryUpdateRequest request);
|
|
||||||
Task DeleteAsync(Guid id);
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
|
|
||||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
|
|
||||||
</ItemGroup>
|
|
||||||
</Project>
|
|
||||||
Reference in New Issue
Block a user