Skip to content

Commit adc8a55

Browse files
authored
Add “in” filter (#64)
* Add “in” filter * Fix linter warnings
1 parent 604ab81 commit adc8a55

File tree

7 files changed

+102
-55
lines changed

7 files changed

+102
-55
lines changed

Sources/DataTransferObjects/Query/CustomQuery+CompileDown.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,8 @@ public extension CustomQuery {
162162
return filter
163163
case .null:
164164
return filter
165+
case .in:
166+
return filter
165167
case .and(let filterExpression):
166168
return Filter.and(.init(fields: filterExpression.fields.map { compileRelativeFilterInterval(filter: $0) }))
167169
case .or(let filterExpression):

Sources/DataTransferObjects/Query/Filter.swift

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -193,22 +193,34 @@ extension FilterEquals.MatchValue: Codable {
193193
var container = encoder.singleValueContainer()
194194

195195
switch self {
196-
case .string(let value):
196+
case let .string(value):
197197
try container.encode(value)
198-
case .int(let value):
198+
case let .int(value):
199199
try container.encode(value)
200-
case .double(let value):
200+
case let .double(value):
201201
try container.encode(value)
202-
case .arrayString(let value):
202+
case let .arrayString(value):
203203
try container.encode(value)
204-
case .arrayInt(let value):
204+
case let .arrayInt(value):
205205
try container.encode(value)
206-
case .arrayDouble(let value):
206+
case let .arrayDouble(value):
207207
try container.encode(value)
208208
}
209209
}
210210
}
211211

212+
/// The in filter can match input rows against a set of values, where a match occurs if the value is contained in the set.
213+
public struct FilterIn: Codable, Hashable, Equatable, Sendable {
214+
public init(dimension: String, values: [String]) {
215+
self.dimension = dimension
216+
self.values = values
217+
}
218+
219+
public let dimension: String
220+
public let values: [String]
221+
// extractionFn not supported atm
222+
}
223+
212224
/// The null filter matches rows where a column value is null.
213225
public struct FilterNull: Codable, Hashable, Equatable, Sendable {
214226
public init(column: String) {
@@ -223,7 +235,7 @@ public struct FilterNull: Codable, Hashable, Equatable, Sendable {
223235
public indirect enum Filter: Codable, Hashable, Equatable, Sendable {
224236
/// The selector filter will match a specific dimension with a specific value.
225237
/// Selector filters can be used as the base filters for more complex Boolean
226-
/// expressions of filters.
238+
/// expressions of filters (deprecated -- use equals instead)
227239
case selector(FilterSelector)
228240

229241
/// The column comparison filter is similar to the selector filter, but instead
@@ -252,6 +264,10 @@ public indirect enum Filter: Codable, Hashable, Equatable, Sendable {
252264
/// The null filter matches rows where a column value is null.
253265
case null(FilterNull)
254266

267+
/// The in filter can match input rows against a set of values, where a match occurs
268+
/// if the value is contained in the set.
269+
case `in`(FilterIn)
270+
255271
// logical expression filters
256272
case and(FilterExpression)
257273
case or(FilterExpression)
@@ -280,6 +296,8 @@ public indirect enum Filter: Codable, Hashable, Equatable, Sendable {
280296
self = try .equals(FilterEquals(from: decoder))
281297
case "null":
282298
self = try .null(FilterNull(from: decoder))
299+
case "in":
300+
self = try .in(FilterIn(from: decoder))
283301
case "and":
284302
self = try .and(FilterExpression(from: decoder))
285303
case "or":
@@ -321,6 +339,9 @@ public indirect enum Filter: Codable, Hashable, Equatable, Sendable {
321339
case let .null(null):
322340
try container.encode("null", forKey: .type)
323341
try null.encode(to: encoder)
342+
case let .in(inFilter):
343+
try container.encode("in", forKey: .type)
344+
try inFilter.self.encode(to: encoder)
324345
case let .and(and):
325346
try container.encode("and", forKey: .type)
326347
try and.encode(to: encoder)

Sources/DataTransferObjects/QueryGeneration/CustomQuery+Retention.swift

Lines changed: 27 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -4,58 +4,58 @@ import DateOperations
44
extension CustomQuery {
55
func precompiledRetentionQuery() throws -> CustomQuery {
66
var query = self
7-
7+
88
// Get the query intervals - we need at least one interval
99
guard let queryIntervals = intervals ?? relativeIntervals?.map({ QueryTimeInterval.from(relativeTimeInterval: $0) }),
1010
let firstInterval = queryIntervals.first else {
1111
throw QueryGenerationError.keyMissing(reason: "Missing intervals for retention query")
1212
}
13-
13+
1414
let beginDate = firstInterval.beginningDate
1515
let endDate = firstInterval.endDate
16-
16+
1717
// Use the query's granularity to determine retention period, defaulting to month if not specified
1818
let retentionGranularity = query.granularity ?? .month
19-
19+
2020
// Validate minimum interval based on granularity
2121
try validateMinimumInterval(from: beginDate, to: endDate, granularity: retentionGranularity)
22-
22+
2323
// Split into intervals based on the specified granularity
2424
let retentionIntervals = try splitIntoIntervals(from: beginDate, to: endDate, granularity: retentionGranularity)
25-
25+
2626
// Generate Aggregators
2727
var aggregators = [Aggregator]()
2828
for interval in retentionIntervals {
2929
aggregators.append(aggregator(for: interval))
3030
}
31-
31+
3232
// Generate Post-Aggregators
3333
var postAggregators = [PostAggregator]()
3434
for row in retentionIntervals {
3535
for column in retentionIntervals where column >= row {
3636
postAggregators.append(postAggregatorBetween(interval1: row, interval2: column))
3737
}
3838
}
39-
39+
4040
// Set the query properties
4141
query.queryType = .groupBy
4242
query.granularity = .all
4343
query.aggregations = uniqued(aggregators)
4444
query.postAggregations = uniqued(postAggregators)
45-
45+
4646
return query
4747
}
48-
48+
4949
private func uniqued<T: Hashable>(_ array: [T]) -> [T] {
5050
var set = Set<T>()
5151
return array.filter { set.insert($0).inserted }
5252
}
53-
53+
5454
// MARK: - Helper Methods
55-
55+
5656
private func validateMinimumInterval(from beginDate: Date, to endDate: Date, granularity: QueryGranularity) throws {
5757
let calendar = Calendar.current
58-
58+
5959
switch granularity {
6060
case .day:
6161
let components = calendar.dateComponents([.day], from: beginDate, to: endDate)
@@ -86,11 +86,11 @@ extension CustomQuery {
8686
throw QueryGenerationError.notImplemented(reason: "Retention queries support day, week, month, quarter, or year granularity")
8787
}
8888
}
89-
89+
9090
private func splitIntoIntervals(from fromDate: Date, to toDate: Date, granularity: QueryGranularity) throws -> [DateInterval] {
9191
let calendar = Calendar.current
9292
var intervals = [DateInterval]()
93-
93+
9494
switch granularity {
9595
case .day:
9696
let numberOfDays = numberOfUnitsBetween(beginDate: fromDate, endDate: toDate, component: .day)
@@ -100,7 +100,7 @@ extension CustomQuery {
100100
let endOfDay = startOfDay.end(of: .day) ?? startOfDay
101101
intervals.append(DateInterval(start: startOfDay, end: endOfDay))
102102
}
103-
103+
104104
case .week:
105105
let numberOfWeeks = numberOfUnitsBetween(beginDate: fromDate, endDate: toDate, component: .weekOfYear)
106106
for week in 0...numberOfWeeks {
@@ -109,7 +109,7 @@ extension CustomQuery {
109109
let endOfWeek = startOfWeek.end(of: .weekOfYear) ?? startOfWeek
110110
intervals.append(DateInterval(start: startOfWeek, end: endOfWeek))
111111
}
112-
112+
113113
case .month:
114114
let numberOfMonths = numberOfUnitsBetween(beginDate: fromDate, endDate: toDate, component: .month)
115115
for month in 0...numberOfMonths {
@@ -118,7 +118,7 @@ extension CustomQuery {
118118
let endOfMonth = startOfMonth.end(of: .month) ?? startOfMonth
119119
intervals.append(DateInterval(start: startOfMonth, end: endOfMonth))
120120
}
121-
121+
122122
case .quarter:
123123
let numberOfQuarters = numberOfUnitsBetween(beginDate: fromDate, endDate: toDate, component: .quarter)
124124
for quarter in 0...numberOfQuarters {
@@ -127,7 +127,7 @@ extension CustomQuery {
127127
let endOfQuarter = startOfQuarter.end(of: .quarter) ?? startOfQuarter
128128
intervals.append(DateInterval(start: startOfQuarter, end: endOfQuarter))
129129
}
130-
130+
131131
case .year:
132132
let numberOfYears = numberOfUnitsBetween(beginDate: fromDate, endDate: toDate, component: .year)
133133
for year in 0...numberOfYears {
@@ -136,18 +136,18 @@ extension CustomQuery {
136136
let endOfYear = startOfYear.end(of: .year) ?? startOfYear
137137
intervals.append(DateInterval(start: startOfYear, end: endOfYear))
138138
}
139-
139+
140140
default:
141141
throw QueryGenerationError.notImplemented(reason: "Retention queries support day, week, month, quarter, or year granularity")
142142
}
143-
143+
144144
return intervals
145145
}
146-
146+
147147
private func numberOfUnitsBetween(beginDate: Date, endDate: Date, component: Calendar.Component) -> Int {
148148
let calendar = Calendar.current
149149
let components = calendar.dateComponents([component], from: beginDate, to: endDate)
150-
150+
151151
switch component {
152152
case .day:
153153
return components.day ?? 0
@@ -163,13 +163,13 @@ extension CustomQuery {
163163
return 0
164164
}
165165
}
166-
166+
167167
private func title(for interval: DateInterval) -> String {
168168
let formatter = ISO8601DateFormatter()
169169
formatter.formatOptions = [.withFullDate]
170170
return "\(formatter.string(from: interval.start))_\(formatter.string(from: interval.end))"
171171
}
172-
172+
173173
private func aggregator(for interval: DateInterval) -> Aggregator {
174174
.filtered(.init(
175175
filter: .interval(.init(
@@ -182,7 +182,7 @@ extension CustomQuery {
182182
))
183183
))
184184
}
185-
185+
186186
private func postAggregatorBetween(interval1: DateInterval, interval2: DateInterval) -> PostAggregator {
187187
.thetaSketchEstimate(.init(
188188
name: "retention_\(title(for: interval1))_\(title(for: interval2))",
@@ -195,4 +195,4 @@ extension CustomQuery {
195195
))
196196
))
197197
}
198-
}
198+
}

Tests/QueryGenerationTests/RetentionQueryGenerationTests.swift

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -129,57 +129,57 @@ final class RetentionQueryGenerationTests: XCTestCase {
129129
granularity: .month
130130
)
131131
XCTAssertThrowsError(try monthQuery1.precompile(namespace: nil, useNamespace: false, organizationAppIDs: [UUID()], isSuperOrg: false))
132-
132+
133133
let monthQuery2 = CustomQuery(
134134
queryType: .retention,
135135
dataSource: "com.telemetrydeck.all",
136136
intervals: [QueryTimeInterval(beginningDate: begin_august, endDate: end_august)],
137137
granularity: .month
138138
)
139139
XCTAssertThrowsError(try monthQuery2.precompile(namespace: nil, useNamespace: false, organizationAppIDs: [UUID()], isSuperOrg: false))
140-
140+
141141
let monthQuery3 = CustomQuery(
142142
queryType: .retention,
143143
dataSource: "com.telemetrydeck.all",
144144
intervals: [QueryTimeInterval(beginningDate: begin_august, endDate: end_september)],
145145
granularity: .month
146146
)
147147
XCTAssertNoThrow(try monthQuery3.precompile(namespace: nil, useNamespace: false, organizationAppIDs: [UUID()], isSuperOrg: false))
148-
148+
149149
// Test daily retention
150150
let startDate = Date(iso8601String: "2022-08-01T00:00:00.000Z")!
151151
let sameDay = Date(iso8601String: "2022-08-01T12:00:00.000Z")!
152152
let nextDay = Date(iso8601String: "2022-08-02T00:00:00.000Z")!
153-
153+
154154
let dayQuery1 = CustomQuery(
155155
queryType: .retention,
156156
dataSource: "com.telemetrydeck.all",
157157
intervals: [QueryTimeInterval(beginningDate: startDate, endDate: sameDay)],
158158
granularity: .day
159159
)
160160
XCTAssertThrowsError(try dayQuery1.precompile(namespace: nil, useNamespace: false, organizationAppIDs: [UUID()], isSuperOrg: false))
161-
161+
162162
let dayQuery2 = CustomQuery(
163163
queryType: .retention,
164164
dataSource: "com.telemetrydeck.all",
165165
intervals: [QueryTimeInterval(beginningDate: startDate, endDate: nextDay)],
166166
granularity: .day
167167
)
168168
XCTAssertNoThrow(try dayQuery2.precompile(namespace: nil, useNamespace: false, organizationAppIDs: [UUID()], isSuperOrg: false))
169-
169+
170170
// Test weekly retention
171171
let weekStart = Date(iso8601String: "2022-08-01T00:00:00.000Z")!
172172
let weekMid = Date(iso8601String: "2022-08-05T00:00:00.000Z")!
173173
let weekEnd = Date(iso8601String: "2022-08-08T00:00:00.000Z")!
174-
174+
175175
let weekQuery1 = CustomQuery(
176176
queryType: .retention,
177177
dataSource: "com.telemetrydeck.all",
178178
intervals: [QueryTimeInterval(beginningDate: weekStart, endDate: weekMid)],
179179
granularity: .week
180180
)
181181
XCTAssertThrowsError(try weekQuery1.precompile(namespace: nil, useNamespace: false, organizationAppIDs: [UUID()], isSuperOrg: false))
182-
182+
183183
let weekQuery2 = CustomQuery(
184184
queryType: .retention,
185185
dataSource: "com.telemetrydeck.all",
@@ -204,22 +204,22 @@ final class RetentionQueryGenerationTests: XCTestCase {
204204
)],
205205
granularity: .month // Explicitly set to month
206206
)
207-
207+
208208
let compiledQuery = try query.precompile(namespace: nil, useNamespace: false, organizationAppIDs: [appID], isSuperOrg: true)
209-
209+
210210
// Verify the compiled query has the expected structure
211211
XCTAssertEqual(compiledQuery.queryType, .groupBy)
212212
XCTAssertEqual(compiledQuery.granularity, .all)
213213
XCTAssertNotNil(compiledQuery.aggregations)
214214
XCTAssertNotNil(compiledQuery.postAggregations)
215-
215+
216216
// The generated query should match the expected structure from tinyQuery
217217
// (though the exact aggregator names might differ due to date formatting)
218218
}
219-
219+
220220
func testRetentionWithDifferentGranularities() throws {
221221
let appID = UUID(uuidString: "79167A27-EBBF-4012-9974-160624E5D07B")!
222-
222+
223223
// Test daily retention - 7 days should generate 8 intervals (0-7 inclusive)
224224
let dailyQuery = CustomQuery(
225225
queryType: .retention,
@@ -233,12 +233,12 @@ final class RetentionQueryGenerationTests: XCTestCase {
233233
)],
234234
granularity: .day
235235
)
236-
236+
237237
let compiledDailyQuery = try dailyQuery.precompile(namespace: nil, useNamespace: false, organizationAppIDs: [appID], isSuperOrg: true)
238238
XCTAssertEqual(compiledDailyQuery.aggregations?.count, 7) // 7 days
239239
// Post-aggregations should be n*(n+1)/2 for n intervals
240240
XCTAssertEqual(compiledDailyQuery.postAggregations?.count, 28) // 7*8/2 = 28
241-
241+
242242
// Test weekly retention - 4 weeks
243243
let weeklyQuery = CustomQuery(
244244
queryType: .retention,
@@ -252,11 +252,11 @@ final class RetentionQueryGenerationTests: XCTestCase {
252252
)],
253253
granularity: .week
254254
)
255-
255+
256256
let compiledWeeklyQuery = try weeklyQuery.precompile(namespace: nil, useNamespace: false, organizationAppIDs: [appID], isSuperOrg: true)
257257
XCTAssertEqual(compiledWeeklyQuery.aggregations?.count, 5) // 5 weeks (spans into 5th week)
258258
XCTAssertEqual(compiledWeeklyQuery.postAggregations?.count, 15) // 5*6/2 = 15
259-
259+
260260
// Test monthly retention - 3 months
261261
let monthlyQuery = CustomQuery(
262262
queryType: .retention,
@@ -270,7 +270,7 @@ final class RetentionQueryGenerationTests: XCTestCase {
270270
)],
271271
granularity: .month
272272
)
273-
273+
274274
let compiledMonthlyQuery = try monthlyQuery.precompile(namespace: nil, useNamespace: false, organizationAppIDs: [appID], isSuperOrg: true)
275275
XCTAssertEqual(compiledMonthlyQuery.aggregations?.count, 3) // 3 months
276276
XCTAssertEqual(compiledMonthlyQuery.postAggregations?.count, 6) // 3*4/2 = 6

0 commit comments

Comments
 (0)