Skip to content

Commit 5eb2c37

Browse files
winsmithclaude
andauthored
Move retention query generation into the compile look (#61)
* Refactor retention query generation to use compile-down pattern - Add retention case to CustomQuery.QueryType enum - Create CustomQuery+Retention.swift with precompiledRetentionQuery() method - Integrate retention query compilation into precompile() flow - Change CustomQuery.granularity from let to var to allow mutation - Update tests to verify both new and legacy approaches work This brings retention queries in line with funnel and experiment queries, using the same compile-down pattern for consistency. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]> * Remove legacy RetentionQueryGenerator - Delete RetentionQueryGenerator.swift as functionality moved to CustomQuery+Retention - Update tests to use new compile-down approach exclusively - Remove references to legacy generateRetentionQuery method The retention query generation now fully uses the compile-down pattern, consistent with funnel and experiment queries. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]> * Add granularity support to retention query generation - Retention period now determined by query's granularity property - Support day, week, month, quarter, and year retention periods - Add validation for minimum intervals based on granularity - Implement generic interval splitting for all supported granularities - Add comprehensive tests for different retention granularities The retention query now respects the granularity setting instead of always using monthly retention, providing more flexibility for different retention analysis periods. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]> --------- Co-authored-by: Claude <[email protected]>
1 parent c376bd4 commit 5eb2c37

File tree

5 files changed

+349
-181
lines changed

5 files changed

+349
-181
lines changed

Sources/DataTransferObjects/Query/CustomQuery+CompileDown.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ public extension CustomQuery {
4848
query = try namespace == nil ? precompiledFunnelQuery() : precompiledFunnelQuery(accuracy: 65536)
4949
} else if query.queryType == .experiment {
5050
query = try precompiledExperimentQuery()
51+
} else if query.queryType == .retention {
52+
query = try precompiledRetentionQuery()
5153
}
5254

5355
// Handle precompilable aggregators and post aggregators

Sources/DataTransferObjects/Query/CustomQuery.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ public struct CustomQuery: Codable, Hashable, Equatable, Sendable {
147147
// derived types
148148
case funnel
149149
case experiment
150-
// case retention
150+
case retention
151151
}
152152

153153
public enum Order: String, Codable, CaseIterable, Sendable {
@@ -183,7 +183,7 @@ public struct CustomQuery: Codable, Hashable, Equatable, Sendable {
183183

184184
/// If a relative intervals are set, their calculated output replaces the regular intervals
185185
public var relativeIntervals: [RelativeTimeInterval]?
186-
public let granularity: QueryGranularity?
186+
public var granularity: QueryGranularity?
187187
public var aggregations: [Aggregator]?
188188
public var postAggregations: [PostAggregator]?
189189
public var limit: Int?
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
import Foundation
2+
import DateOperations
3+
4+
extension CustomQuery {
5+
func precompiledRetentionQuery() throws -> CustomQuery {
6+
var query = self
7+
8+
// Get the query intervals - we need at least one interval
9+
guard let queryIntervals = intervals ?? relativeIntervals?.map({ QueryTimeInterval.from(relativeTimeInterval: $0) }),
10+
let firstInterval = queryIntervals.first else {
11+
throw QueryGenerationError.keyMissing(reason: "Missing intervals for retention query")
12+
}
13+
14+
let beginDate = firstInterval.beginningDate
15+
let endDate = firstInterval.endDate
16+
17+
// Use the query's granularity to determine retention period, defaulting to month if not specified
18+
let retentionGranularity = query.granularity ?? .month
19+
20+
// Validate minimum interval based on granularity
21+
try validateMinimumInterval(from: beginDate, to: endDate, granularity: retentionGranularity)
22+
23+
// Split into intervals based on the specified granularity
24+
let retentionIntervals = try splitIntoIntervals(from: beginDate, to: endDate, granularity: retentionGranularity)
25+
26+
// Generate Aggregators
27+
var aggregators = [Aggregator]()
28+
for interval in retentionIntervals {
29+
aggregators.append(aggregator(for: interval))
30+
}
31+
32+
// Generate Post-Aggregators
33+
var postAggregators = [PostAggregator]()
34+
for row in retentionIntervals {
35+
for column in retentionIntervals where column >= row {
36+
postAggregators.append(postAggregatorBetween(interval1: row, interval2: column))
37+
}
38+
}
39+
40+
// Set the query properties
41+
query.queryType = .groupBy
42+
query.granularity = .all
43+
query.aggregations = uniqued(aggregators)
44+
query.postAggregations = uniqued(postAggregators)
45+
46+
return query
47+
}
48+
49+
private func uniqued<T: Hashable>(_ array: [T]) -> [T] {
50+
var set = Set<T>()
51+
return array.filter { set.insert($0).inserted }
52+
}
53+
54+
// MARK: - Helper Methods
55+
56+
private func validateMinimumInterval(from beginDate: Date, to endDate: Date, granularity: QueryGranularity) throws {
57+
let calendar = Calendar.current
58+
59+
switch granularity {
60+
case .day:
61+
let components = calendar.dateComponents([.day], from: beginDate, to: endDate)
62+
if (components.day ?? 0) < 1 {
63+
throw QueryGenerationError.notImplemented(reason: "Daily retention queries require at least one day between begin and end dates")
64+
}
65+
case .week:
66+
let components = calendar.dateComponents([.weekOfYear], from: beginDate, to: endDate)
67+
if (components.weekOfYear ?? 0) < 1 {
68+
throw QueryGenerationError.notImplemented(reason: "Weekly retention queries require at least one week between begin and end dates")
69+
}
70+
case .month:
71+
let components = calendar.dateComponents([.month], from: beginDate, to: endDate)
72+
if (components.month ?? 0) < 1 {
73+
throw QueryGenerationError.notImplemented(reason: "Monthly retention queries require at least one month between begin and end dates")
74+
}
75+
case .quarter:
76+
let components = calendar.dateComponents([.quarter], from: beginDate, to: endDate)
77+
if (components.quarter ?? 0) < 1 {
78+
throw QueryGenerationError.notImplemented(reason: "Quarterly retention queries require at least one quarter between begin and end dates")
79+
}
80+
case .year:
81+
let components = calendar.dateComponents([.year], from: beginDate, to: endDate)
82+
if (components.year ?? 0) < 1 {
83+
throw QueryGenerationError.notImplemented(reason: "Yearly retention queries require at least one year between begin and end dates")
84+
}
85+
default:
86+
throw QueryGenerationError.notImplemented(reason: "Retention queries support day, week, month, quarter, or year granularity")
87+
}
88+
}
89+
90+
private func splitIntoIntervals(from fromDate: Date, to toDate: Date, granularity: QueryGranularity) throws -> [DateInterval] {
91+
let calendar = Calendar.current
92+
var intervals = [DateInterval]()
93+
94+
switch granularity {
95+
case .day:
96+
let numberOfDays = numberOfUnitsBetween(beginDate: fromDate, endDate: toDate, component: .day)
97+
for day in 0...numberOfDays {
98+
guard let date = calendar.date(byAdding: .day, value: day, to: fromDate) else { continue }
99+
let startOfDay = date.beginning(of: .day) ?? date
100+
let endOfDay = startOfDay.end(of: .day) ?? startOfDay
101+
intervals.append(DateInterval(start: startOfDay, end: endOfDay))
102+
}
103+
104+
case .week:
105+
let numberOfWeeks = numberOfUnitsBetween(beginDate: fromDate, endDate: toDate, component: .weekOfYear)
106+
for week in 0...numberOfWeeks {
107+
guard let date = calendar.date(byAdding: .weekOfYear, value: week, to: fromDate) else { continue }
108+
let startOfWeek = date.beginning(of: .weekOfYear) ?? date
109+
let endOfWeek = startOfWeek.end(of: .weekOfYear) ?? startOfWeek
110+
intervals.append(DateInterval(start: startOfWeek, end: endOfWeek))
111+
}
112+
113+
case .month:
114+
let numberOfMonths = numberOfUnitsBetween(beginDate: fromDate, endDate: toDate, component: .month)
115+
for month in 0...numberOfMonths {
116+
guard let date = calendar.date(byAdding: .month, value: month, to: fromDate) else { continue }
117+
let startOfMonth = date.beginning(of: .month) ?? date
118+
let endOfMonth = startOfMonth.end(of: .month) ?? startOfMonth
119+
intervals.append(DateInterval(start: startOfMonth, end: endOfMonth))
120+
}
121+
122+
case .quarter:
123+
let numberOfQuarters = numberOfUnitsBetween(beginDate: fromDate, endDate: toDate, component: .quarter)
124+
for quarter in 0...numberOfQuarters {
125+
guard let date = calendar.date(byAdding: .quarter, value: quarter, to: fromDate) else { continue }
126+
let startOfQuarter = date.beginning(of: .quarter) ?? date
127+
let endOfQuarter = startOfQuarter.end(of: .quarter) ?? startOfQuarter
128+
intervals.append(DateInterval(start: startOfQuarter, end: endOfQuarter))
129+
}
130+
131+
case .year:
132+
let numberOfYears = numberOfUnitsBetween(beginDate: fromDate, endDate: toDate, component: .year)
133+
for year in 0...numberOfYears {
134+
guard let date = calendar.date(byAdding: .year, value: year, to: fromDate) else { continue }
135+
let startOfYear = date.beginning(of: .year) ?? date
136+
let endOfYear = startOfYear.end(of: .year) ?? startOfYear
137+
intervals.append(DateInterval(start: startOfYear, end: endOfYear))
138+
}
139+
140+
default:
141+
throw QueryGenerationError.notImplemented(reason: "Retention queries support day, week, month, quarter, or year granularity")
142+
}
143+
144+
return intervals
145+
}
146+
147+
private func numberOfUnitsBetween(beginDate: Date, endDate: Date, component: Calendar.Component) -> Int {
148+
let calendar = Calendar.current
149+
let components = calendar.dateComponents([component], from: beginDate, to: endDate)
150+
151+
switch component {
152+
case .day:
153+
return components.day ?? 0
154+
case .weekOfYear:
155+
return components.weekOfYear ?? 0
156+
case .month:
157+
return components.month ?? 0
158+
case .quarter:
159+
return components.quarter ?? 0
160+
case .year:
161+
return components.year ?? 0
162+
default:
163+
return 0
164+
}
165+
}
166+
167+
private func title(for interval: DateInterval) -> String {
168+
let formatter = ISO8601DateFormatter()
169+
formatter.formatOptions = [.withFullDate]
170+
return "\(formatter.string(from: interval.start))_\(formatter.string(from: interval.end))"
171+
}
172+
173+
private func aggregator(for interval: DateInterval) -> Aggregator {
174+
.filtered(.init(
175+
filter: .interval(.init(
176+
dimension: "__time",
177+
intervals: [.init(dateInterval: interval)]
178+
)),
179+
aggregator: .thetaSketch(.init(
180+
name: "_\(title(for: interval))",
181+
fieldName: "clientUser"
182+
))
183+
))
184+
}
185+
186+
private func postAggregatorBetween(interval1: DateInterval, interval2: DateInterval) -> PostAggregator {
187+
.thetaSketchEstimate(.init(
188+
name: "retention_\(title(for: interval1))_\(title(for: interval2))",
189+
field: .thetaSketchSetOp(.init(
190+
func: .intersect,
191+
fields: [
192+
.fieldAccess(.init(type: .fieldAccess, fieldName: "_\(title(for: interval1))")),
193+
.fieldAccess(.init(type: .fieldAccess, fieldName: "_\(title(for: interval2))")),
194+
]
195+
))
196+
))
197+
}
198+
}

Sources/DataTransferObjects/QueryGeneration/RetentionQueryGenerator.swift

Lines changed: 0 additions & 132 deletions
This file was deleted.

0 commit comments

Comments
 (0)