Skip to content

Commit 563033e

Browse files
committed
Remove for match stage and fix for pointer constraint conversion
1 parent 2bd90f0 commit 563033e

3 files changed

Lines changed: 251 additions & 36 deletions

File tree

lib/parse/query.rb

Lines changed: 27 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1111,8 +1111,9 @@ def aggregate(pipeline, verbose: nil)
11111111
unless @where.empty?
11121112
where_clause = Parse::Query.compile_where(@where)
11131113
if where_clause.any?
1114-
# Convert dates and other Parse-specific types for MongoDB aggregation
1115-
match_stage = convert_for_aggregation(where_clause)
1114+
# Convert field names for aggregation context and handle dates/pointers
1115+
aggregation_where = convert_constraints_for_aggregation(where_clause)
1116+
match_stage = convert_dates_for_aggregation(aggregation_where)
11161117
complete_pipeline << { "$match" => match_stage }
11171118
end
11181119
end
@@ -1233,6 +1234,14 @@ def convert_for_aggregation(constraints)
12331234
return constraints[:iso]
12341235
end
12351236

1237+
# Check if this is a Parse Pointer hash and convert to MongoDB format
1238+
if constraints.keys.sort == [:__type, :className, :objectId].sort && constraints[:__type] == "Pointer"
1239+
return "#{constraints[:className]}$#{constraints[:objectId]}"
1240+
end
1241+
if constraints.keys.sort == ["__type", "className", "objectId"].sort && constraints["__type"] == "Pointer"
1242+
return "#{constraints["className"]}$#{constraints["objectId"]}"
1243+
end
1244+
12361245
result = {}
12371246
constraints.each do |key, value|
12381247
result[key] = convert_for_aggregation(value)
@@ -1250,8 +1259,9 @@ def convert_for_aggregation(constraints)
12501259
# Convert Ruby DateTime objects to raw ISO string for aggregation (Parse Server expects raw ISO strings in aggregation pipelines)
12511260
constraints.utc.iso8601(3)
12521261
when Parse::Object, Parse::Pointer
1253-
# Convert Parse objects/pointers to MongoDB pointer format
1254-
constraints.as_json
1262+
# Convert Parse objects/pointers to MongoDB pointer format for aggregation
1263+
# Parse Server expects "ClassName$objectId" format in aggregation pipelines, not Parse API format
1264+
"#{constraints.parse_class}$#{constraints.id}"
12551265
else
12561266
constraints
12571267
end
@@ -2447,50 +2457,33 @@ def convert_constraints_for_aggregation(constraints)
24472457
result
24482458
end
24492459

2450-
# Convert Ruby Date/Time objects for aggregation pipelines
2460+
# Convert Ruby Date/Time objects for aggregation pipelines to raw ISO strings.
2461+
# Parse Server expects dates in raw ISO string format in aggregation pipelines, not the Parse Date object format.
24512462
# @param obj [Object] the object to convert (Hash, Array, or value)
2452-
# @param for_match_stage [Boolean] if true, converts to Parse Date format; if false (default), converts to raw ISO strings which Parse Server expects in aggregation pipelines
2453-
# @return [Object] the converted object with dates in the appropriate format
2454-
def convert_dates_for_aggregation(obj, for_match_stage: false)
2463+
# @return [Object] the converted object with dates converted to raw ISO strings
2464+
def convert_dates_for_aggregation(obj)
24552465
case obj
24562466
when Hash
24572467
# Handle Parse's JSON date format: {"__type": "Date", "iso": "..."} or {:__type => "Date", :iso => "..."}
24582468
if (obj["__type"] == "Date" || obj[:__type] == "Date") && (obj["iso"] || obj[:iso])
2459-
if for_match_stage
2460-
# For Parse Server aggregation match stages, keep the Parse Date format
2461-
{ "__type" => "Date", "iso" => (obj["iso"] || obj[:iso]) }
2462-
else
2463-
# For other stages, use raw ISO string
2464-
obj["iso"] || obj[:iso]
2465-
end
2469+
# Convert Parse Date format to raw ISO string
2470+
obj["iso"] || obj[:iso]
24662471
else
2467-
# Also handle field name mapping for built-in Parse fields
2472+
# Recursively convert nested hashes
24682473
converted_hash = {}
24692474
obj.each do |key, value|
2470-
# For Parse Server aggregation, keep standard Parse field names
2471-
mapped_key = key
2472-
converted_hash[mapped_key] = convert_dates_for_aggregation(value, for_match_stage: for_match_stage)
2475+
converted_hash[key] = convert_dates_for_aggregation(value)
24732476
end
24742477
converted_hash
24752478
end
24762479
when Array
2477-
obj.map { |v| convert_dates_for_aggregation(v, for_match_stage: for_match_stage) }
2480+
obj.map { |v| convert_dates_for_aggregation(v) }
24782481
when Time, DateTime
2479-
if for_match_stage
2480-
# Convert Ruby Time/DateTime objects to Parse Server's JSON date format for match stages
2481-
{ "__type" => "Date", "iso" => obj.utc.iso8601(3) }
2482-
else
2483-
# For other stages, use raw ISO string
2484-
obj.utc.iso8601(3)
2485-
end
2482+
# Convert Ruby Time/DateTime objects to raw ISO string
2483+
obj.utc.iso8601(3)
24862484
when Date
2487-
if for_match_stage
2488-
# Convert Ruby Date objects to Parse Server's JSON date format for match stages
2489-
{ "__type" => "Date", "iso" => obj.to_time.utc.iso8601(3) }
2490-
else
2491-
# For other stages, use raw ISO string
2492-
obj.to_time.utc.iso8601(3)
2493-
end
2485+
# Convert Ruby Date objects to raw ISO string
2486+
obj.to_time.utc.iso8601(3)
24942487
else
24952488
obj
24962489
end

test/lib/parse/query/aggregation_features_test.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -454,7 +454,7 @@ def test_convert_dates_for_aggregation_with_parse_date
454454
"iso" => "2025-08-15T07:00:00.000Z"
455455
}
456456

457-
result = @query.send(:convert_dates_for_aggregation, parse_date_obj, for_match_stage: false)
457+
result = @query.send(:convert_dates_for_aggregation, parse_date_obj)
458458

459459
# Should convert to raw ISO string
460460
assert_equal "2025-08-15T07:00:00.000Z", result
@@ -474,7 +474,7 @@ def test_convert_dates_for_aggregation_with_nested_dates
474474
}
475475
}
476476

477-
result = @query.send(:convert_dates_for_aggregation, constraint_with_dates, for_match_stage: false)
477+
result = @query.send(:convert_dates_for_aggregation, constraint_with_dates)
478478

479479
# Should convert nested date objects to ISO strings
480480
assert_equal "2025-08-15T07:00:00.000Z", result["createdAt"]["$gte"]

test/lib/parse/query_aggregate_integration_test.rb

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1702,4 +1702,226 @@ def test_date_filtering_with_group_by_count
17021702
end
17031703
end
17041704
end
1705+
1706+
def test_pointer_constraint_aggregation
1707+
skip "Docker integration tests require PARSE_TEST_USE_DOCKER=true" unless ENV['PARSE_TEST_USE_DOCKER'] == 'true'
1708+
1709+
with_parse_server do
1710+
with_timeout(25, "pointer constraint aggregation test") do
1711+
puts "\n=== Testing Pointer Constraint Aggregation ==="
1712+
1713+
# Create test data for pointer constraint testing
1714+
user1 = AggregateTestUser.new(name: "Pointer User 1", age: 28, city: "Boston", active: true)
1715+
user2 = AggregateTestUser.new(name: "Pointer User 2", age: 32, city: "Seattle", active: true)
1716+
user3 = AggregateTestUser.new(name: "Pointer User 3", age: 25, city: "Denver", active: false)
1717+
1718+
assert user1.save, "User 1 should save successfully"
1719+
assert user2.save, "User 2 should save successfully"
1720+
assert user3.save, "User 3 should save successfully"
1721+
1722+
# Create posts with different authors
1723+
post1 = AggregateTestPost.new(title: "Post by User 1", author: user1, category: "tech", likes: 100)
1724+
post2 = AggregateTestPost.new(title: "Another Post by User 1", author: user1, category: "design", likes: 75)
1725+
post3 = AggregateTestPost.new(title: "Post by User 2", author: user2, category: "tech", likes: 120)
1726+
post4 = AggregateTestPost.new(title: "Post by User 3", author: user3, category: "writing", likes: 50)
1727+
1728+
assert post1.save, "Post 1 should save successfully"
1729+
assert post2.save, "Post 2 should save successfully"
1730+
assert post3.save, "Post 3 should save successfully"
1731+
assert post4.save, "Post 4 should save successfully"
1732+
1733+
puts "Created test data: 3 users, 4 posts with pointer relationships"
1734+
1735+
# Test 1: Filter by specific user pointer, then group by category
1736+
puts "\n--- Test 1: where(author: user).group_by(:category).count ---"
1737+
puts "Target user ID: #{user1.id}"
1738+
1739+
# First verify basic where query works
1740+
posts_by_user1 = AggregateTestPost.where(author: user1).all
1741+
puts "Direct where query found: #{posts_by_user1.length} posts by user1"
1742+
posts_by_user1.each do |post|
1743+
puts " - #{post.title} (#{post.category})"
1744+
end
1745+
1746+
# Show the aggregation pipeline that will be generated
1747+
puts "\n--- Debugging: Pipeline generation ---"
1748+
pipeline = AggregateTestPost.where(author: user1).group_by(:category).pipeline
1749+
puts "Generated pipeline:"
1750+
puts JSON.pretty_generate(pipeline)
1751+
1752+
# Check the exact format of the pointer constraint in the match stage
1753+
match_stage = pipeline.find { |stage| stage.key?("$match") }
1754+
if match_stage
1755+
match_conditions = match_stage["$match"]
1756+
puts "\nMatch stage conditions:"
1757+
match_conditions.each do |field, condition|
1758+
puts " #{field}: #{condition.inspect} (#{condition.class})"
1759+
end
1760+
1761+
# Look for author constraint specifically
1762+
author_constraint = match_conditions["author"] || match_conditions["_p_author"]
1763+
if author_constraint
1764+
puts "Author constraint found: #{author_constraint.inspect} (#{author_constraint.class})"
1765+
else
1766+
puts "WARNING: No author constraint found in match stage"
1767+
end
1768+
end
1769+
1770+
begin
1771+
result = AggregateTestPost.where(author: user1).group_by(:category).count
1772+
1773+
puts "\nPointer constraint aggregation executed successfully!"
1774+
puts "Result type: #{result.class}"
1775+
puts "Result: #{result.inspect}"
1776+
1777+
if result.is_a?(Hash)
1778+
assert !result.empty?, "Should find posts by user1"
1779+
1780+
# Verify we get the expected categories
1781+
expected_categories = ["tech", "design"] # user1 has posts in these categories
1782+
result.keys.each do |category|
1783+
assert expected_categories.include?(category), "Found unexpected category: #{category}"
1784+
end
1785+
1786+
# Total should match direct query results
1787+
total_count = result.values.sum
1788+
assert total_count == posts_by_user1.length, "Aggregation count should match direct query: expected #{posts_by_user1.length}, got #{total_count}"
1789+
1790+
puts "✅ Pointer constraint aggregation works correctly"
1791+
else
1792+
flunk "Expected Hash result, got #{result.class}: #{result.inspect}"
1793+
end
1794+
1795+
rescue => e
1796+
puts "\n❌ Pointer constraint aggregation failed: #{e.class}: #{e.message}"
1797+
puts "This confirms the issue with pointer constraints in aggregation pipelines"
1798+
1799+
# Let's also test with the raw pipeline to see if Parse Server accepts it
1800+
puts "\n--- Testing raw pipeline execution ---"
1801+
begin
1802+
raw_result = AggregateTestPost.new.client.aggregate_pipeline("AggregateTestPost", pipeline)
1803+
puts "Raw pipeline result: #{raw_result.results&.inspect || raw_result.inspect}"
1804+
1805+
if raw_result.results.is_a?(Array) && raw_result.results.empty?
1806+
puts "Raw pipeline returned empty results - pointer constraint format issue confirmed"
1807+
end
1808+
rescue => raw_e
1809+
puts "Raw pipeline also failed: #{raw_e.class}: #{raw_e.message}"
1810+
end
1811+
1812+
flunk "Pointer constraint aggregation should work: #{e.class}: #{e.message}"
1813+
end
1814+
1815+
# Test 2: Multiple pointer constraints
1816+
puts "\n--- Test 2: Multiple constraints including pointer ---"
1817+
1818+
begin
1819+
result2 = AggregateTestPost.where(author: user1, :likes.gte => 80).group_by(:category).count
1820+
1821+
puts "Multiple constraint result: #{result2.inspect}"
1822+
1823+
# Should only include posts by user1 with likes >= 80
1824+
if result2.is_a?(Hash)
1825+
total_count = result2.values.sum
1826+
expected_posts = posts_by_user1.select { |p| p.likes >= 80 }
1827+
assert total_count == expected_posts.length, "Should match posts with likes >= 80"
1828+
1829+
puts "✅ Multiple constraints including pointer work correctly"
1830+
end
1831+
1832+
rescue => e
1833+
puts "Multiple constraints failed: #{e.class}: #{e.message}"
1834+
end
1835+
1836+
# Test 3: Test the exact failing pattern from user's example
1837+
puts "\n--- Test 3: Test exact failing patterns ---"
1838+
1839+
# Pattern 1: Membership.where(role: x, active: true).group_by(:project).count
1840+
# We'll simulate with Post.where(author: x, category: y).group_by(:author).count
1841+
begin
1842+
simulated_result = AggregateTestPost.where(author: user1, category: "tech").group_by(:author).count
1843+
puts "Simulated membership pattern result: #{simulated_result.inspect}"
1844+
1845+
if simulated_result.is_a?(Hash) && !simulated_result.empty?
1846+
puts "✅ Simulated membership pattern works"
1847+
elsif simulated_result.is_a?(Hash) && simulated_result.empty?
1848+
puts "❌ Simulated membership pattern returned empty results"
1849+
end
1850+
rescue => e
1851+
puts "Simulated membership pattern failed: #{e.class}: #{e.message}"
1852+
end
1853+
1854+
# Test 4: Debug the internal pointer format vs expected format
1855+
puts "\n--- Test 4: Pointer format debugging ---"
1856+
1857+
# Check what format Parse Server expects vs what we're sending
1858+
manual_pipeline = [
1859+
{
1860+
"$match" => {
1861+
"_p_author" => "_AggregateTestUser$#{user1.id}" # MongoDB internal format
1862+
}
1863+
},
1864+
{
1865+
"$group" => {
1866+
"_id" => "$category",
1867+
"count" => { "$sum" => 1 }
1868+
}
1869+
}
1870+
]
1871+
1872+
puts "Manual pipeline with _p_author:"
1873+
puts JSON.pretty_generate(manual_pipeline)
1874+
1875+
begin
1876+
manual_result = AggregateTestPost.new.client.aggregate_pipeline("AggregateTestPost", manual_pipeline)
1877+
puts "Manual _p_author result: #{manual_result.results&.inspect || 'nil'}"
1878+
1879+
if manual_result.results&.any?
1880+
puts "✅ _p_author format works in aggregation"
1881+
else
1882+
puts "❌ _p_author format also fails"
1883+
end
1884+
rescue => e
1885+
puts "Manual _p_author pipeline failed: #{e.class}: #{e.message}"
1886+
end
1887+
1888+
# Try with Parse API format
1889+
manual_pipeline2 = [
1890+
{
1891+
"$match" => {
1892+
"author" => {
1893+
"__type" => "Pointer",
1894+
"className" => "AggregateTestUser",
1895+
"objectId" => user1.id
1896+
}
1897+
}
1898+
},
1899+
{
1900+
"$group" => {
1901+
"_id" => "$category",
1902+
"count" => { "$sum" => 1 }
1903+
}
1904+
}
1905+
]
1906+
1907+
puts "\nManual pipeline with Parse Pointer format:"
1908+
puts JSON.pretty_generate(manual_pipeline2)
1909+
1910+
begin
1911+
manual_result2 = AggregateTestPost.new.client.aggregate_pipeline("AggregateTestPost", manual_pipeline2)
1912+
puts "Manual Parse Pointer result: #{manual_result2.results&.inspect || 'nil'}"
1913+
1914+
if manual_result2.results&.any?
1915+
puts "✅ Parse Pointer format works in aggregation"
1916+
else
1917+
puts "❌ Parse Pointer format also fails"
1918+
end
1919+
rescue => e
1920+
puts "Manual Parse Pointer pipeline failed: #{e.class}: #{e.message}"
1921+
end
1922+
1923+
puts "\n✅ Pointer constraint aggregation test completed (debugging results above)"
1924+
end
1925+
end
1926+
end
17051927
end

0 commit comments

Comments
 (0)