diff --git a/bindings/dart/lib/src/database.dart b/bindings/dart/lib/src/database.dart index ee86325..cc51138 100644 --- a/bindings/dart/lib/src/database.dart +++ b/bindings/dart/lib/src/database.dart @@ -83,6 +83,73 @@ class Database { } } + /// Sets a scalar relation (foreign key) between two elements by their labels. + void setScalarRelation(String collection, String attribute, + String fromLabel, String toLabel) { + _ensureNotClosed(); + + final arena = Arena(); + try { + final err = bindings.psr_database_set_scalar_relation( + _ptr, + collection.toNativeUtf8(allocator: arena).cast(), + attribute.toNativeUtf8(allocator: arena).cast(), + fromLabel.toNativeUtf8(allocator: arena).cast(), + toLabel.toNativeUtf8(allocator: arena).cast(), + ); + + if (err != psr_error_t.PSR_OK) { + throw DatabaseException.fromError(err, "Failed to set scalar relation '$attribute' in '$collection'"); + } + } finally { + arena.releaseAll(); + } + } + + /// Reads scalar relation values (target labels) for a FK attribute. + /// Returns null for elements with no relation set. + List readScalarRelation(String collection, String attribute) { + _ensureNotClosed(); + + final arena = Arena(); + try { + final outValues = arena>>(); + final outCount = arena(); + + final err = bindings.psr_database_read_scalar_relation( + _ptr, + collection.toNativeUtf8(allocator: arena).cast(), + attribute.toNativeUtf8(allocator: arena).cast(), + outValues, + outCount, + ); + + if (err != psr_error_t.PSR_OK) { + throw DatabaseException.fromError(err, "Failed to read scalar relation '$attribute' from '$collection'"); + } + + final count = outCount.value; + if (count == 0 || outValues.value == nullptr) { + return []; + } + + final result = []; + for (var i = 0; i < count; i++) { + final ptr = outValues.value[i]; + if (ptr == nullptr) { + result.add(null); + } else { + final s = ptr.cast().toDartString(); + result.add(s.isEmpty ? null : s); + } + } + bindings.psr_free_string_array(outValues.value, count); + return result; + } finally { + arena.releaseAll(); + } + } + /// Reads all integer values for a scalar attribute from a collection. List readScalarIntegers(String collection, String attribute) { _ensureNotClosed(); diff --git a/bindings/dart/lib/src/ffi/bindings.dart b/bindings/dart/lib/src/ffi/bindings.dart index 016edd0..5b483d6 100644 --- a/bindings/dart/lib/src/ffi/bindings.dart +++ b/bindings/dart/lib/src/ffi/bindings.dart @@ -197,6 +197,72 @@ class PsrDatabaseBindings { int Function(ffi.Pointer, ffi.Pointer, ffi.Pointer)>(); + int psr_database_set_scalar_relation( + ffi.Pointer db, + ffi.Pointer collection, + ffi.Pointer attribute, + ffi.Pointer from_label, + ffi.Pointer to_label, + ) { + return _psr_database_set_scalar_relation( + db, + collection, + attribute, + from_label, + to_label, + ); + } + + late final _psr_database_set_scalar_relationPtr = _lookup< + ffi.NativeFunction< + ffi.Int32 Function( + ffi.Pointer, + ffi.Pointer, + ffi.Pointer, + ffi.Pointer, + ffi.Pointer)>>('psr_database_set_scalar_relation'); + late final _psr_database_set_scalar_relation = + _psr_database_set_scalar_relationPtr.asFunction< + int Function( + ffi.Pointer, + ffi.Pointer, + ffi.Pointer, + ffi.Pointer, + ffi.Pointer)>(); + + int psr_database_read_scalar_relation( + ffi.Pointer db, + ffi.Pointer collection, + ffi.Pointer attribute, + ffi.Pointer>> out_values, + ffi.Pointer out_count, + ) { + return _psr_database_read_scalar_relation( + db, + collection, + attribute, + out_values, + out_count, + ); + } + + late final _psr_database_read_scalar_relationPtr = _lookup< + ffi.NativeFunction< + ffi.Int32 Function( + ffi.Pointer, + ffi.Pointer, + ffi.Pointer, + ffi.Pointer>>, + ffi.Pointer)>>('psr_database_read_scalar_relation'); + late final _psr_database_read_scalar_relation = + _psr_database_read_scalar_relationPtr.asFunction< + int Function( + ffi.Pointer, + ffi.Pointer, + ffi.Pointer, + ffi.Pointer>>, + ffi.Pointer)>(); + int psr_database_read_scalar_integers( ffi.Pointer db, ffi.Pointer collection, diff --git a/bindings/dart/test/read_test.dart b/bindings/dart/test/read_test.dart index a1c91ea..64be0a7 100644 --- a/bindings/dart/test/read_test.dart +++ b/bindings/dart/test/read_test.dart @@ -324,4 +324,79 @@ void main() { } }); }); + + group('Scalar Relations', () { + test('set and read scalar relation', () { + final db = Database.fromSchema( + ':memory:', + path.join(testsPath, 'schemas', 'valid', 'relations.sql'), + ); + try { + db.createElement('Configuration', {'label': 'Test Config'}); + + // Create parents + db.createElement('Parent', {'label': 'Parent 1'}); + db.createElement('Parent', {'label': 'Parent 2'}); + + // Create children + db.createElement('Child', {'label': 'Child 1'}); + db.createElement('Child', {'label': 'Child 2'}); + db.createElement('Child', {'label': 'Child 3'}); + + // Set relations + db.setScalarRelation('Child', 'parent_id', 'Child 1', 'Parent 1'); + db.setScalarRelation('Child', 'parent_id', 'Child 3', 'Parent 2'); + + // Read relations + final labels = db.readScalarRelation('Child', 'parent_id'); + expect(labels.length, equals(3)); + expect(labels[0], equals('Parent 1')); + expect(labels[1], isNull); // Child 2 has no parent + expect(labels[2], equals('Parent 2')); + } finally { + db.close(); + } + }); + + test('read scalar relation self-reference', () { + final db = Database.fromSchema( + ':memory:', + path.join(testsPath, 'schemas', 'valid', 'relations.sql'), + ); + try { + db.createElement('Configuration', {'label': 'Test Config'}); + + // Create children (Child references itself via sibling_id) + db.createElement('Child', {'label': 'Child 1'}); + db.createElement('Child', {'label': 'Child 2'}); + + // Set sibling relation (self-reference) + db.setScalarRelation('Child', 'sibling_id', 'Child 1', 'Child 2'); + + // Read sibling relations + final labels = db.readScalarRelation('Child', 'sibling_id'); + expect(labels.length, equals(2)); + expect(labels[0], equals('Child 2')); // Child 1's sibling is Child 2 + expect(labels[1], isNull); // Child 2 has no sibling set + } finally { + db.close(); + } + }); + + test('read scalar relation empty result', () { + final db = Database.fromSchema( + ':memory:', + path.join(testsPath, 'schemas', 'valid', 'relations.sql'), + ); + try { + db.createElement('Configuration', {'label': 'Test Config'}); + + // No Child elements created + final labels = db.readScalarRelation('Child', 'parent_id'); + expect(labels, isEmpty); + } finally { + db.close(); + } + }); + }); } diff --git a/bindings/julia/src/c_api.jl b/bindings/julia/src/c_api.jl index 8f6735b..f37eeeb 100644 --- a/bindings/julia/src/c_api.jl +++ b/bindings/julia/src/c_api.jl @@ -102,6 +102,14 @@ function psr_database_create_element(db, collection, element) @ccall libpsr_database_c.psr_database_create_element(db::Ptr{psr_database_t}, collection::Ptr{Cchar}, element::Ptr{psr_element_t})::Int64 end +function psr_database_set_scalar_relation(db, collection, attribute, from_label, to_label) + @ccall libpsr_database_c.psr_database_set_scalar_relation(db::Ptr{psr_database_t}, collection::Ptr{Cchar}, attribute::Ptr{Cchar}, from_label::Ptr{Cchar}, to_label::Ptr{Cchar})::psr_error_t +end + +function psr_database_read_scalar_relation(db, collection, attribute, out_values, out_count) + @ccall libpsr_database_c.psr_database_read_scalar_relation(db::Ptr{psr_database_t}, collection::Ptr{Cchar}, attribute::Ptr{Cchar}, out_values::Ptr{Ptr{Ptr{Cchar}}}, out_count::Ptr{Csize_t})::psr_error_t +end + function psr_database_read_scalar_integers(db, collection, attribute, out_values, out_count) @ccall libpsr_database_c.psr_database_read_scalar_integers(db::Ptr{psr_database_t}, collection::Ptr{Cchar}, attribute::Ptr{Cchar}, out_values::Ptr{Ptr{Int64}}, out_count::Ptr{Csize_t})::psr_error_t end diff --git a/bindings/julia/src/database.jl b/bindings/julia/src/database.jl index abfc666..aac4d26 100644 --- a/bindings/julia/src/database.jl +++ b/bindings/julia/src/database.jl @@ -41,6 +41,48 @@ function close!(db::Database) return nothing end +function set_scalar_relation!( + db::Database, + collection::String, + attribute::String, + from_label::String, + to_label::String, +) + err = C.psr_database_set_scalar_relation(db.ptr, collection, attribute, from_label, to_label) + if err != C.PSR_OK + throw(DatabaseException("Failed to set scalar relation '$attribute' in '$collection'")) + end + return nothing +end + +function read_scalar_relation(db::Database, collection::String, attribute::String) + out_values = Ref{Ptr{Ptr{Cchar}}}(C_NULL) + out_count = Ref{Csize_t}(0) + + err = C.psr_database_read_scalar_relation(db.ptr, collection, attribute, out_values, out_count) + if err != C.PSR_OK + throw(DatabaseException("Failed to read scalar relation '$attribute' from '$collection'")) + end + + count = out_count[] + if count == 0 || out_values[] == C_NULL + return Union{String, Nothing}[] + end + + ptrs = unsafe_wrap(Array, out_values[], count) + result = Union{String, Nothing}[] + for ptr in ptrs + if ptr == C_NULL + push!(result, nothing) + else + s = unsafe_string(ptr) + push!(result, isempty(s) ? nothing : s) + end + end + C.psr_free_string_array(out_values[], count) + return result +end + function read_scalar_integers(db::Database, collection::String, attribute::String) out_values = Ref{Ptr{Int64}}(C_NULL) out_count = Ref{Csize_t}(0) diff --git a/bindings/julia/test/test_read.jl b/bindings/julia/test/test_read.jl index 4903def..3042990 100644 --- a/bindings/julia/test/test_read.jl +++ b/bindings/julia/test/test_read.jl @@ -198,4 +198,68 @@ end PSRDatabase.close!(db) end +@testset "Set and Read Scalar Relations" begin + path_schema = joinpath(tests_path(), "schemas", "valid", "relations.sql") + db = PSRDatabase.from_schema(":memory:", path_schema; force = true) + + PSRDatabase.create_element!(db, "Configuration"; label = "Test Config") + + # Create parents + PSRDatabase.create_element!(db, "Parent"; label = "Parent 1") + PSRDatabase.create_element!(db, "Parent"; label = "Parent 2") + + # Create children + PSRDatabase.create_element!(db, "Child"; label = "Child 1") + PSRDatabase.create_element!(db, "Child"; label = "Child 2") + PSRDatabase.create_element!(db, "Child"; label = "Child 3") + + # Set relations + PSRDatabase.set_scalar_relation!(db, "Child", "parent_id", "Child 1", "Parent 1") + PSRDatabase.set_scalar_relation!(db, "Child", "parent_id", "Child 3", "Parent 2") + + # Read relations + labels = PSRDatabase.read_scalar_relation(db, "Child", "parent_id") + @test length(labels) == 3 + @test labels[1] == "Parent 1" + @test labels[2] === nothing # Child 2 has no parent + @test labels[3] == "Parent 2" + + PSRDatabase.close!(db) +end + +@testset "Read Scalar Relations Self-Reference" begin + path_schema = joinpath(tests_path(), "schemas", "valid", "relations.sql") + db = PSRDatabase.from_schema(":memory:", path_schema; force = true) + + PSRDatabase.create_element!(db, "Configuration"; label = "Test Config") + + # Create children (Child references itself via sibling_id) + PSRDatabase.create_element!(db, "Child"; label = "Child 1") + PSRDatabase.create_element!(db, "Child"; label = "Child 2") + + # Set sibling relation (self-reference) + PSRDatabase.set_scalar_relation!(db, "Child", "sibling_id", "Child 1", "Child 2") + + # Read sibling relations + labels = PSRDatabase.read_scalar_relation(db, "Child", "sibling_id") + @test length(labels) == 2 + @test labels[1] == "Child 2" # Child 1's sibling is Child 2 + @test labels[2] === nothing # Child 2 has no sibling set + + PSRDatabase.close!(db) +end + +@testset "Read Scalar Relations Empty Result" begin + path_schema = joinpath(tests_path(), "schemas", "valid", "relations.sql") + db = PSRDatabase.from_schema(":memory:", path_schema; force = true) + + PSRDatabase.create_element!(db, "Configuration"; label = "Test Config") + + # No Child elements created + labels = PSRDatabase.read_scalar_relation(db, "Child", "parent_id") + @test labels == Union{String, Nothing}[] + + PSRDatabase.close!(db) +end + end diff --git a/include/psr/c/database.h b/include/psr/c/database.h index 1e4773b..a5c1c2d 100644 --- a/include/psr/c/database.h +++ b/include/psr/c/database.h @@ -45,6 +45,19 @@ PSR_C_API int64_t psr_database_current_version(psr_database_t* db); typedef struct psr_element psr_element_t; PSR_C_API int64_t psr_database_create_element(psr_database_t* db, const char* collection, psr_element_t* element); +// Relation operations +PSR_C_API psr_error_t psr_database_set_scalar_relation(psr_database_t* db, + const char* collection, + const char* attribute, + const char* from_label, + const char* to_label); + +PSR_C_API psr_error_t psr_database_read_scalar_relation(psr_database_t* db, + const char* collection, + const char* attribute, + char*** out_values, + size_t* out_count); + // Read scalar attributes PSR_C_API psr_error_t psr_database_read_scalar_integers(psr_database_t* db, const char* collection, diff --git a/include/psr/database.h b/include/psr/database.h index b7ccbc5..60791e4 100644 --- a/include/psr/database.h +++ b/include/psr/database.h @@ -46,6 +46,14 @@ class PSR_API Database { // Element operations int64_t create_element(const std::string& collection, const Element& element); + // Relation operations + void set_scalar_relation(const std::string& collection, + const std::string& attribute, + const std::string& from_label, + const std::string& to_label); + + std::vector read_scalar_relation(const std::string& collection, const std::string& attribute); + // Read scalar attributes std::vector read_scalar_integers(const std::string& collection, const std::string& attribute); std::vector read_scalar_doubles(const std::string& collection, const std::string& attribute); diff --git a/src/c_api_database.cpp b/src/c_api_database.cpp index 0696413..05acc0d 100644 --- a/src/c_api_database.cpp +++ b/src/c_api_database.cpp @@ -177,6 +177,49 @@ PSR_C_API int64_t psr_database_create_element(psr_database_t* db, const char* co } } +PSR_C_API psr_error_t psr_database_set_scalar_relation(psr_database_t* db, + const char* collection, + const char* attribute, + const char* from_label, + const char* to_label) { + if (!db || !collection || !attribute || !from_label || !to_label) { + return PSR_ERROR_INVALID_ARGUMENT; + } + try { + db->db.set_scalar_relation(collection, attribute, from_label, to_label); + return PSR_OK; + } catch (const std::exception&) { + return PSR_ERROR_DATABASE; + } +} + +PSR_C_API psr_error_t psr_database_read_scalar_relation(psr_database_t* db, + const char* collection, + const char* attribute, + char*** out_values, + size_t* out_count) { + if (!db || !collection || !attribute || !out_values || !out_count) { + return PSR_ERROR_INVALID_ARGUMENT; + } + try { + auto values = db->db.read_scalar_relation(collection, attribute); + *out_count = values.size(); + if (values.empty()) { + *out_values = nullptr; + return PSR_OK; + } + *out_values = new char*[values.size()]; + for (size_t i = 0; i < values.size(); ++i) { + (*out_values)[i] = new char[values[i].size() + 1]; + std::copy(values[i].begin(), values[i].end(), (*out_values)[i]); + (*out_values)[i][values[i].size()] = '\0'; + } + return PSR_OK; + } catch (const std::exception&) { + return PSR_ERROR_DATABASE; + } +} + PSR_C_API psr_database_t* psr_database_from_schema(const char* db_path, const char* schema_path, const psr_database_options_t* options) { if (!db_path || !schema_path) { diff --git a/src/database.cpp b/src/database.cpp index ca44949..7eaa0ac 100644 --- a/src/database.cpp +++ b/src/database.cpp @@ -615,6 +615,88 @@ int64_t Database::create_element(const std::string& collection, const Element& e return element_id; } +void Database::set_scalar_relation(const std::string& collection, + const std::string& attribute, + const std::string& from_label, + const std::string& to_label) { + impl_->logger->debug("Setting relation {}.{} from '{}' to '{}'", collection, attribute, from_label, to_label); + + if (!impl_->schema) { + throw std::runtime_error("Cannot set relation: no schema loaded"); + } + + const auto* table_def = impl_->schema->get_table(collection); + if (!table_def) { + throw std::runtime_error("Collection not found in schema: " + collection); + } + + // Find the foreign key with the given attribute name + std::string to_table; + for (const auto& fk : table_def->foreign_keys) { + if (fk.from_column == attribute) { + to_table = fk.to_table; + break; + } + } + + if (to_table.empty()) { + throw std::runtime_error("Attribute '" + attribute + "' is not a foreign key in collection '" + collection + + "'"); + } + + // Look up the target ID by label + auto lookup_sql = "SELECT id FROM " + to_table + " WHERE label = ?"; + auto lookup_result = execute(lookup_sql, {to_label}); + if (lookup_result.empty() || !lookup_result[0].get_int(0)) { + throw std::runtime_error("Target element with label '" + to_label + "' not found in '" + to_table + "'"); + } + auto to_id = lookup_result[0].get_int(0).value(); + + // Update the source element + auto update_sql = "UPDATE " + collection + " SET " + attribute + " = ? WHERE label = ?"; + execute(update_sql, {to_id, from_label}); + + impl_->logger->info( + "Set relation {}.{} for '{}' to '{}' (id: {})", collection, attribute, from_label, to_label, to_id); +} + +std::vector Database::read_scalar_relation(const std::string& collection, const std::string& attribute) { + if (!impl_->schema) { + throw std::runtime_error("Cannot read relation: no schema loaded"); + } + + const auto* table_def = impl_->schema->get_table(collection); + if (!table_def) { + throw std::runtime_error("Collection not found in schema: " + collection); + } + + // Find the foreign key with the given attribute name + std::string to_table; + for (const auto& fk : table_def->foreign_keys) { + if (fk.from_column == attribute) { + to_table = fk.to_table; + break; + } + } + + if (to_table.empty()) { + throw std::runtime_error("Attribute '" + attribute + "' is not a foreign key in collection '" + collection + + "'"); + } + + // LEFT JOIN to get target labels (NULL for unset relations) + auto sql = "SELECT t.label FROM " + collection + " c LEFT JOIN " + to_table + " t ON c." + attribute + " = t.id"; + auto result = execute(sql); + + std::vector labels; + labels.reserve(result.row_count()); + for (size_t i = 0; i < result.row_count(); ++i) { + auto val = result[i].get_string(0); + labels.push_back(val.value_or("")); + } + return labels; +} + std::vector Database::read_scalar_integers(const std::string& collection, const std::string& attribute) { auto sql = "SELECT " + attribute + " FROM " + collection; auto result = execute(sql); diff --git a/tests/test_database.cpp b/tests/test_database.cpp index 2971b9c..0d4b4cd 100644 --- a/tests/test_database.cpp +++ b/tests/test_database.cpp @@ -450,3 +450,52 @@ TEST_F(DatabaseFixture, ReadSetOnlyReturnsElementsWithData) { auto sets = db.read_set_strings("Collection", "tag"); EXPECT_EQ(sets.size(), 2); } + +// ============================================================================ +// set_scalar_relation tests +// ============================================================================ + +TEST_F(DatabaseFixture, SetScalarRelation) { + auto db = psr::Database::from_schema( + ":memory:", schema_path("schemas/valid/relations.sql"), {.console_level = psr::LogLevel::off}); + + // Create parent + psr::Element parent; + parent.set("label", std::string("Parent 1")); + db.create_element("Parent", parent); + + // Create child without relation + psr::Element child; + child.set("label", std::string("Child 1")); + db.create_element("Child", child); + + // Set the relation + db.set_scalar_relation("Child", "parent_id", "Child 1", "Parent 1"); + + // Verify the relation was set + auto result = db.execute("SELECT parent_id FROM Child WHERE label = ?", {std::string("Child 1")}); + EXPECT_EQ(result.row_count(), 1); + EXPECT_EQ(result[0].get_int(0).value(), 1); // Parent 1 has id = 1 +} + +TEST_F(DatabaseFixture, SetScalarRelationSelfReference) { + auto db = psr::Database::from_schema( + ":memory:", schema_path("schemas/valid/relations.sql"), {.console_level = psr::LogLevel::off}); + + // Create two children + psr::Element child1; + child1.set("label", std::string("Child 1")); + db.create_element("Child", child1); + + psr::Element child2; + child2.set("label", std::string("Child 2")); + db.create_element("Child", child2); + + // Set self-referential relation (sibling) + db.set_scalar_relation("Child", "sibling_id", "Child 1", "Child 2"); + + // Verify the relation was set + auto result = db.execute("SELECT sibling_id FROM Child WHERE label = ?", {std::string("Child 1")}); + EXPECT_EQ(result.row_count(), 1); + EXPECT_EQ(result[0].get_int(0).value(), 2); // Child 2 has id = 2 +}