Skip to content

Commit e33ca95

Browse files
Nested Connection Filtering
1 parent d1d0203 commit e33ca95

File tree

2 files changed

+181
-41
lines changed

2 files changed

+181
-41
lines changed

examples/StarWars/StarWarsClient.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,9 @@
109109
height
110110
eyeColor
111111
friends {
112-
name
112+
items {
113+
name
114+
}
113115
}
114116
}
115117
}"

src/SharpGraph.Core/GraphQL/GraphQLExecutor.cs

Lines changed: 178 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -617,61 +617,82 @@ public JsonDocument Execute(string query, JsonElement? variables = null)
617617
// Handle different relation types
618618
if (relationColumn.RelationType == RelationType.OneToMany || relationColumn.IsList)
619619
{
620-
// Many-to-many or one-to-many: return array of related records
621-
// The foreign key value should be an array of IDs
622-
var results = new List<Dictionary<string, object?>>();
620+
// Many-to-many or one-to-many: return Connection object
621+
// Check if query is asking for 'items' subfield (Connection pattern)
622+
var itemsField = field.SelectionSet?.Selections?.OfType<Field>()
623+
.FirstOrDefault(f => f.Name == "items");
623624

624-
if (foreignKeyValue.ValueKind == JsonValueKind.Array)
625+
if (itemsField != null)
625626
{
626-
// Many-to-many: foreign key is an array of IDs
627-
// Collect all IDs first for batch loading
628-
var ids = new List<string>();
629-
foreach (var idElement in foreignKeyValue.EnumerateArray())
627+
// Connection pattern: resolve the items with the nested field selections
628+
var results = new List<Dictionary<string, object?>>();
629+
630+
if (foreignKeyValue.ValueKind == JsonValueKind.Array)
630631
{
631-
var foreignId = idElement.GetString();
632-
if (!string.IsNullOrEmpty(foreignId))
632+
// Many-to-many: foreign key is an array of IDs
633+
var ids = new List<string>();
634+
foreach (var idElement in foreignKeyValue.EnumerateArray())
633635
{
634-
ids.Add(foreignId);
636+
var foreignId = idElement.GetString();
637+
if (!string.IsNullOrEmpty(foreignId))
638+
{
639+
ids.Add(foreignId);
640+
}
635641
}
636-
}
637-
638-
// Batch load all related records at once (fixes N+1 problem)
639-
foreach (var foreignId in ids)
640-
{
641-
var relatedRecord = relatedTable.Find(foreignId);
642-
if (relatedRecord != null)
642+
643+
// Batch load all related records at once (fixes N+1 problem)
644+
foreach (var foreignId in ids)
643645
{
644-
var recordData = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(relatedRecord);
645-
if (recordData != null)
646+
var relatedRecord = relatedTable.Find(foreignId);
647+
if (relatedRecord != null)
646648
{
647-
results.Add(ProjectFields(recordData, field, fragments, relationColumn.RelatedTable));
649+
var recordData = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(relatedRecord);
650+
if (recordData != null)
651+
{
652+
results.Add(ProjectFields(recordData, itemsField, fragments, relationColumn.RelatedTable));
653+
}
648654
}
649655
}
650656
}
651-
}
652-
else
653-
{
654-
// One-to-many: need to scan the related table for records that reference this record
655-
// This is the reverse direction (e.g., a Character's films where Film has characterId)
656-
var currentId = data.TryGetValue("id", out var idElement) ? idElement.GetString() : null;
657-
if (currentId != null)
657+
else
658658
{
659-
var allRecords = relatedTable.SelectAll();
660-
foreach (var (key, value) in allRecords)
659+
// One-to-many: scan related table for records that reference this record
660+
var currentId = data.TryGetValue("id", out var idElement) ? idElement.GetString() : null;
661+
if (currentId != null)
661662
{
662-
var recordData = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(value);
663-
if (recordData != null && recordData.TryGetValue(foreignKeyField, out var recordFk))
663+
var allRecords = relatedTable.SelectAll();
664+
foreach (var (key, value) in allRecords)
664665
{
665-
if (recordFk.GetString() == currentId)
666+
var recordData = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(value);
667+
if (recordData != null && recordData.TryGetValue(foreignKeyField, out var recordFk))
666668
{
667-
results.Add(ProjectFields(recordData, field, fragments, relationColumn.RelatedTable));
669+
if (recordFk.GetString() == currentId)
670+
{
671+
results.Add(ProjectFields(recordData, itemsField, fragments, relationColumn.RelatedTable));
672+
}
668673
}
669674
}
670675
}
671676
}
677+
678+
// Apply filtering, ordering, and pagination from items field arguments
679+
var filteredResults = ApplyFiltersAndPagination(results, itemsField, relationColumn.RelatedTable);
680+
681+
// Return Connection object
682+
return new Dictionary<string, object?>
683+
{
684+
["items"] = filteredResults
685+
};
686+
}
687+
else
688+
{
689+
// Legacy: No items field requested, return empty Connection
690+
// This handles the case where the query just asks for "friends" without "items { ... }"
691+
return new Dictionary<string, object?>
692+
{
693+
["items"] = new List<Dictionary<string, object?>>()
694+
};
672695
}
673-
674-
return results;
675696
}
676697
else
677698
{
@@ -691,6 +712,74 @@ public JsonDocument Execute(string query, JsonElement? variables = null)
691712
return null;
692713
}
693714

715+
private List<Dictionary<string, object?>> ApplyFiltersAndPagination(List<Dictionary<string, object?>> data, Field field, string tableName)
716+
{
717+
// Convert data to JsonElement-based records for filtering
718+
var jsonRecords = data.Select(item =>
719+
JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(
720+
JsonSerializer.Serialize(item)) ?? new Dictionary<string, JsonElement>()
721+
).ToList();
722+
723+
var filteredRecords = jsonRecords;
724+
725+
// Apply WHERE filtering using existing FilterEvaluator
726+
var whereArg = field.Arguments.FirstOrDefault(a => a.Name == "where");
727+
if (whereArg != null)
728+
{
729+
var whereValue = ResolveValue(whereArg.Value, null);
730+
if (whereValue is JsonElement whereElement)
731+
{
732+
// Use the existing FilterEvaluator for consistency
733+
filteredRecords = FilterEvaluator.ApplyFilters(filteredRecords, whereElement);
734+
}
735+
}
736+
737+
// Apply ORDER BY using existing FilterEvaluator
738+
var orderByArg = field.Arguments.FirstOrDefault(a => a.Name == "orderBy");
739+
if (orderByArg != null)
740+
{
741+
var orderByValue = ResolveValue(orderByArg.Value, null);
742+
if (orderByValue is JsonElement orderByElement)
743+
{
744+
filteredRecords = FilterEvaluator.ApplySorting(filteredRecords, orderByElement);
745+
}
746+
}
747+
748+
// Apply SKIP/TAKE pagination
749+
int? skip = null;
750+
int? take = null;
751+
752+
var skipArg = field.Arguments.FirstOrDefault(a => a.Name == "skip");
753+
if (skipArg != null)
754+
{
755+
var skipValue = ResolveValue(skipArg.Value, null);
756+
if (skipValue is JsonElement skipElement && skipElement.TryGetInt32(out var skipInt))
757+
{
758+
skip = skipInt;
759+
}
760+
}
761+
762+
var takeArg = field.Arguments.FirstOrDefault(a => a.Name == "take");
763+
if (takeArg != null)
764+
{
765+
var takeValue = ResolveValue(takeArg.Value, null);
766+
if (takeValue is JsonElement takeElement && takeElement.TryGetInt32(out var takeInt))
767+
{
768+
take = takeInt;
769+
}
770+
}
771+
772+
filteredRecords = FilterEvaluator.ApplyPagination(filteredRecords, skip, take);
773+
774+
// Convert back to Dictionary<string, object?>
775+
return filteredRecords.Select(record =>
776+
record.ToDictionary(
777+
kvp => kvp.Key,
778+
kvp => (object?)ConvertJsonElement(kvp.Value)
779+
)
780+
).ToList();
781+
}
782+
694783
private object? ConvertJsonElement(JsonElement element)
695784
{
696785
return element.ValueKind switch
@@ -1633,7 +1722,7 @@ private string GetBaseTypeName(TypeNode typeNode)
16331722
["kind"] = "OBJECT",
16341723
["name"] = name,
16351724
["description"] = $"{name} type",
1636-
["fields"] = BuildFields(typeDef, field),
1725+
["fields"] = BuildFields(name, typeDef, field),
16371726
["inputFields"] = null,
16381727
["interfaces"] = new List<object>(),
16391728
["enumValues"] = null,
@@ -1701,21 +1790,70 @@ private string GetBaseTypeName(TypeNode typeNode)
17011790
};
17021791
}
17031792

1704-
private List<Dictionary<string, object?>>? BuildFields(TypeDefinition typeDef, Field field)
1793+
private List<Dictionary<string, object?>>? BuildFields(string typeName, TypeDefinition typeDef, Field field)
17051794
{
17061795
if (typeDef.Fields.Count == 0)
17071796
return new List<Dictionary<string, object?>>();
17081797

17091798
var fields = new List<Dictionary<string, object?>>();
17101799

1800+
// Try to get table metadata for relationship checking
1801+
TableMetadata? tableMetadata = null;
1802+
if (_tables.TryGetValue(typeName, out var table))
1803+
{
1804+
tableMetadata = table.GetMetadata();
1805+
}
1806+
17111807
foreach (var fieldDef in typeDef.Fields)
17121808
{
1809+
// Check if this field is a relationship that returns a list
1810+
bool isListRelationship = false;
1811+
string? relatedTypeName = null;
1812+
1813+
if (tableMetadata != null)
1814+
{
1815+
var relationColumn = tableMetadata.Columns?.FirstOrDefault(c => c.Name == fieldDef.Name);
1816+
if (relationColumn != null && !string.IsNullOrEmpty(relationColumn.RelatedTable))
1817+
{
1818+
isListRelationship = relationColumn.IsList || relationColumn.RelationType == RelationType.OneToMany;
1819+
relatedTypeName = relationColumn.RelatedTable;
1820+
}
1821+
}
1822+
1823+
// If it's a list relationship, wrap it as a Connection type
1824+
Dictionary<string, object?> fieldType;
1825+
List<object> args = new List<object>();
1826+
1827+
if (isListRelationship && relatedTypeName != null)
1828+
{
1829+
// Return ConnectionType with filtering/pagination arguments
1830+
fieldType = new Dictionary<string, object?>
1831+
{
1832+
["kind"] = "NON_NULL",
1833+
["name"] = null,
1834+
["ofType"] = new Dictionary<string, object?>
1835+
{
1836+
["kind"] = "OBJECT",
1837+
["name"] = $"{relatedTypeName}Connection",
1838+
["ofType"] = null
1839+
}
1840+
};
1841+
1842+
// Add standard Connection arguments (these are handled by the Connection type's items field)
1843+
// No arguments needed at the Connection level
1844+
}
1845+
else
1846+
{
1847+
// Regular field - use its defined type
1848+
fieldType = BuildTypeRef(fieldDef.Type, field);
1849+
}
1850+
17131851
var fieldInfo = new Dictionary<string, object?>
17141852
{
17151853
["name"] = fieldDef.Name,
17161854
["description"] = null,
1717-
["args"] = new List<object>(),
1718-
["type"] = BuildTypeRef(fieldDef.Type, field),
1855+
["args"] = args,
1856+
["type"] = fieldType,
17191857
["isDeprecated"] = false,
17201858
["deprecationReason"] = null
17211859
};

0 commit comments

Comments
 (0)