Skip to content

Commit

Permalink
Refactor query compilation (#22)
Browse files Browse the repository at this point in the history
* Refactor query compilation

* Fix failing tests

* Some Tests

* Finish test for compiledown methods

* Remove a ton of whitespace

* Use isEmpty instead of count == 0

* More Linter Cleanup

* add compilationstatus property

* Whitespace

* Throw error when intervals not set

* QueryGenerationError Should be string based

* Revert "QueryGenerationError Should be string based"

This reverts commit e7e0ced.

* Move funnel creation into CustomQuery, keeping the original query

* Remove nil checks for properties. Instead, we use the compilation status property

* Remove an unused swiftlint rule
  • Loading branch information
winsmith authored Jan 18, 2023
1 parent 06b8f39 commit 4cb1a6a
Show file tree
Hide file tree
Showing 22 changed files with 528 additions and 211 deletions.
4 changes: 2 additions & 2 deletions Sources/DataTransferObjects/DTOs/DTOv2.swift
Original file line number Diff line number Diff line change
Expand Up @@ -173,14 +173,14 @@ public enum DTOv2 {
case customQuery
case funnel
}

public var id: UUID
public var groupID: UUID

/// order in which insights appear in the apps (if not expanded)
public var order: Double?
public var title: String

/// What kind of insight is this?
public var type: InsightType

Expand Down
14 changes: 12 additions & 2 deletions Sources/DataTransferObjects/DTOs/SignalDTO.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,17 @@ public extension DTOv1 {
}

struct Signal: Codable, Hashable {
public init(appID: UUID? = nil, count: Int? = nil, receivedAt: Date, clientUser: String, sessionID: String? = nil, type: String, payload: [String: String]? = nil, floatValue: Double? = nil, isTestMode: Bool) {
public init(
appID: UUID? = nil,
count: Int? = nil,
receivedAt: Date,
clientUser: String,
sessionID: String? = nil,
type: String,
payload: [String: String]? = nil,
floatValue: Double? = nil,
isTestMode: Bool
) {
self.appID = appID
self.count = count
self.receivedAt = receivedAt
Expand Down Expand Up @@ -65,7 +75,7 @@ public extension DTOv1 {
public var sessionID: String?
public var type: String
public var payload: String
public var floatValue: Double? = nil
public var floatValue: Double?
public var isTestMode: String

public func toSignal() -> Signal {
Expand Down
6 changes: 3 additions & 3 deletions Sources/DataTransferObjects/Query/BaseFilters.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@ import Foundation
public enum BaseFilters: String, Codable, Hashable, Equatable {
/// Attach test mode filter and filter for all apps of the executing user's organization
case thisOrganization

/// Attach test mode filter and filter for the app the insight lives in
///
/// This fails if the query does not belong to an insight.
case thisApp

/// Attach test mode filter and filter for the example app's data
///
/// The server will execute this query as if the owner of the Example App
Expand All @@ -21,7 +21,7 @@ public enum BaseFilters: String, Codable, Hashable, Equatable {
///
/// This is great for showing a demo of the environment.
case exampleData

/// Only available for super org, do not specify any filters
///
/// This is used internally for admin dashboards. The server will
Expand Down
134 changes: 134 additions & 0 deletions Sources/DataTransferObjects/Query/CustomQuery+CompileDown.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import Foundation

public extension CustomQuery {
enum QueryGenerationError: Error {
case notAllowed(reason: String)
case notImplemented(reason: String)
case keyMissing(reason: String)
case compilationStatusError
}

/// Compiles almost all TelemetryDeck-properties down into a regular query that can be enqueued in the Query Task Runner.
///
/// Will not compile the relativeTimeIntervals property into intervals. These need to be calculated directly before running the query.
///
/// @warn Both precompile AND compileToRunnableQuery need to be run before a query can safely be handed to Druid!
///
/// @see compileToRunnableQuery
func precompile(organizationAppIDs: [UUID], isSuperOrg: Bool) throws -> CustomQuery {
guard (self.compilationStatus ?? .notCompiled) == .notCompiled else {
throw QueryGenerationError.compilationStatusError
}

// Make an editable copy of self
var query = self

// Make sure either intervals or relative intervals are set
guard query.intervals != nil || query.relativeIntervals != nil else {
throw QueryGenerationError.keyMissing(reason: "Either 'relativeIntervals' or 'intervals' need to be set")
}

// Custom Query Types
if query.queryType == .funnel {
query = try self.precompiledFunnelQuery()
}

// Apply base filters and data source
query = try Self.applyBaseFilters(query: query, organizationAppIDs: organizationAppIDs, isSuperOrg: isSuperOrg)

// Update compilationStatus so the next steps in the pipeline are sure the query has been precompiled
query.compilationStatus = .precompiled

return query
}

/// Compiles all TelemetryDeck additions down into a regular query that can be run on Apache Druid.
///
/// Since this includes the `relativeTimeIntervals` property, this should only be called directly before actually running the query.
///
/// @warn Both precompile AND compileToRunnableQuery need to be run before a query can safely be handed to Druid!
///
/// @see precompile
func compileToRunnableQuery() throws -> CustomQuery {
guard self.compilationStatus == .precompiled else {
throw QueryGenerationError.compilationStatusError
}

// Make an editable copy of self
var query = self

// Compile relative Time intervals
if let relativeIntervals = query.relativeIntervals {
query.intervals = relativeIntervals.map { QueryTimeInterval.from(relativeTimeInterval: $0) }
}

guard query.intervals != nil && !query.intervals!.isEmpty else {
throw QueryGenerationError.keyMissing(reason: "Either 'relativeIntervals' or 'intervals' need to be set")
}

// Update compilationStatus so the next steps in the pipeline are sure the query has been compiled
query.compilationStatus = .compiled

return query
}
}
extension CustomQuery {
static func applyBaseFilters(query: CustomQuery, organizationAppIDs: [UUID]?, isSuperOrg: Bool) throws -> CustomQuery {
// make an editable copy of the query
var query = query

// Throw if noFilter is requested by an ord that is not super
let baseFilters = query.baseFilters ?? .thisOrganization
if baseFilters == .noFilter {
guard isSuperOrg else {
throw QueryGenerationError.notAllowed(reason: "The noFilter base filter is not implemented.")
}
} else {
query.dataSource = .init("telemetry-signals")
query.context = QueryContext(timeout: "200000", skipEmptyBuckets: false)
}

// Apply filters according to the basefilters property
switch baseFilters {
case .thisOrganization:
guard let organizationAppIDs = organizationAppIDs else { throw QueryGenerationError.keyMissing(reason: "Missing organization app IDs") }
query.filter = query.filter && (try appIDFilter(for: organizationAppIDs)) && testModeFilter(for: query)
return query

case .thisApp:
guard let appID = query.appID else { throw QueryGenerationError.keyMissing(reason: "Missing key 'appID'") }
query.filter = query.filter && (try appIDFilter(for: [appID])) && testModeFilter(for: query)
return query

case .exampleData:
let appIDFilter = Filter.selector(.init(dimension: "appID", value: "B97579B6-FFB8-4AC5-AAA7-DA5796CC5DCE"))
query.filter = query.filter && appIDFilter && testModeFilter(for: query)
return query

case .noFilter:
return query
}
}

/// Returns a filter according to the query objects `testMode` property.
static func testModeFilter(for query: CustomQuery) -> Filter {
return Filter.selector(.init(dimension: "isTestMode", value: "\(query.testMode ?? false ? "true" : "false")"))
}

// Given a list of app UUIDs, generates a Filter object that restricts a query to only apps with either of the given IDs
static func appIDFilter(for organizationAppIDs: [UUID]) throws -> Filter {
guard !organizationAppIDs.isEmpty else {
throw QueryGenerationError.keyMissing(reason: "Missing organization app IDs")
}

guard organizationAppIDs.count != 1 else {
return Filter.selector(.init(dimension: "appID", value: organizationAppIDs.first!.uuidString))
}

let filters = organizationAppIDs.compactMap {
Filter.selector(.init(dimension: "appID", value: $0.uuidString))
}

return Filter.or(.init(fields: filters))
}
}
71 changes: 54 additions & 17 deletions Sources/DataTransferObjects/Query/CustomQuery.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,14 @@ import Foundation

/// Custom JSON based query
public struct CustomQuery: Codable, Hashable, Equatable {
public init(queryType: CustomQuery.QueryType, dataSource: String = "telemetry-signals",
public init(queryType: CustomQuery.QueryType,
compilationStatus: CompilationStatus? = nil,
dataSource: String? = "telemetry-signals",
descending: Bool? = nil,
filter: Filter? = nil,
baseFilters: BaseFilters? = nil,
appID: UUID? = nil,
baseFilters: BaseFilters? = nil,
testMode: Bool? = nil,
intervals: [QueryTimeInterval]? = nil,
relativeIntervals: [RelativeTimeInterval]? = nil, granularity: QueryGranularity,
aggregations: [Aggregator]? = nil, postAggregations: [PostAggregator]? = nil,
Expand All @@ -15,10 +19,17 @@ public struct CustomQuery: Codable, Hashable, Equatable {
steps: [Filter]? = nil, stepNames: [String]? = nil)
{
self.queryType = queryType
self.dataSource = DataSource(type: .table, name: dataSource)
self.compilationStatus = compilationStatus

if let dataSource = dataSource {
self.dataSource = DataSource(type: .table, name: dataSource)
}

self.descending = descending
self.baseFilters = baseFilters
self.testMode = testMode
self.filter = filter
self.appID = appID
self.intervals = intervals
self.relativeIntervals = relativeIntervals
self.granularity = granularity
Expand All @@ -33,11 +44,15 @@ public struct CustomQuery: Codable, Hashable, Equatable {
self.steps = steps
self.stepNames = stepNames
}

public init(queryType: CustomQuery.QueryType, dataSource: DataSource,

public init(queryType: CustomQuery.QueryType,
compilationStatus: CompilationStatus? = nil,
dataSource: DataSource?,
descending: Bool? = nil,
filter: Filter? = nil,
appID: UUID? = nil,
baseFilters: BaseFilters? = nil,
testMode: Bool? = nil,
intervals: [QueryTimeInterval]? = nil,
relativeIntervals: [RelativeTimeInterval]? = nil, granularity: QueryGranularity,
aggregations: [Aggregator]? = nil, postAggregations: [PostAggregator]? = nil,
Expand All @@ -47,10 +62,13 @@ public struct CustomQuery: Codable, Hashable, Equatable {
steps: [Filter]? = nil, stepNames: [String]? = nil)
{
self.queryType = queryType
self.compilationStatus = compilationStatus
self.dataSource = dataSource
self.descending = descending
self.baseFilters = baseFilters
self.testMode = testMode
self.filter = filter
self.appID = appID
self.intervals = intervals
self.relativeIntervals = relativeIntervals
self.granularity = granularity
Expand All @@ -72,16 +90,31 @@ public struct CustomQuery: Codable, Hashable, Equatable {
case timeseries
case groupBy
case topN

// derived types
case funnel
// case retention
}

public enum CompilationStatus: String, Codable, CaseIterable, Identifiable {
public var id: String { rawValue }

case notCompiled
case precompiled
case compiled
}

public var queryType: QueryType
public var dataSource: DataSource = .init(type: .table, name: "telemetry-signals")
public var compilationStatus: CompilationStatus?
public var dataSource: DataSource? = .init(type: .table, name: "telemetry-signals")
public var descending: Bool?
public var baseFilters: BaseFilters?
public var testMode: Bool?
public var filter: Filter?

/// Used by baseFilter.thisApp, the appID to use for the appID filter
public var appID: UUID?

public var intervals: [QueryTimeInterval]?

/// If a relative intervals are set, their calculated output replaces the regular intervals
Expand All @@ -103,23 +136,27 @@ public struct CustomQuery: Codable, Hashable, Equatable {

/// Only for groupBy Queries: A list of dimensions to do the groupBy over, if queryType is groupBy
public var dimensions: [DimensionSpec]?

/// Only for funnel Queries: A list of filters that form the steps of the funnel
public var steps: [Filter]?

/// Only for funnel Queries: An optional List of names for the funnel steps
public var stepNames: [String]?

public func hash(into hasher: inout Hasher) {
hasher.combine(queryType)
hasher.combine(compilationStatus)
hasher.combine(dataSource)
hasher.combine(descending)
hasher.combine(baseFilters)
hasher.combine(testMode)
hasher.combine(filter)
hasher.combine(appID)
hasher.combine(intervals)
hasher.combine(relativeIntervals)
hasher.combine(granularity)
hasher.combine(aggregations)
hasher.combine(postAggregations)
hasher.combine(limit)
hasher.combine(context)
hasher.combine(threshold)
Expand All @@ -133,15 +170,18 @@ public struct CustomQuery: Codable, Hashable, Equatable {
public static func == (lhs: CustomQuery, rhs: CustomQuery) -> Bool {
lhs.hashValue == rhs.hashValue
}

public init(from decoder: Decoder) throws {
let container: KeyedDecodingContainer<CustomQuery.CodingKeys> = try decoder.container(keyedBy: CustomQuery.CodingKeys.self)

self.queryType = try container.decode(CustomQuery.QueryType.self, forKey: CustomQuery.CodingKeys.queryType)
self.dataSource = try container.decode(DataSource.self, forKey: CustomQuery.CodingKeys.dataSource)
self.compilationStatus = try container.decodeIfPresent(CompilationStatus.self, forKey: CustomQuery.CodingKeys.compilationStatus)
self.dataSource = try container.decodeIfPresent(DataSource.self, forKey: CustomQuery.CodingKeys.dataSource)
self.descending = try container.decodeIfPresent(Bool.self, forKey: CustomQuery.CodingKeys.descending)
self.baseFilters = try container.decodeIfPresent(BaseFilters.self, forKey: CustomQuery.CodingKeys.baseFilters)
self.testMode = try container.decodeIfPresent(Bool.self, forKey: CustomQuery.CodingKeys.testMode)
self.filter = try container.decodeIfPresent(Filter.self, forKey: CustomQuery.CodingKeys.filter)
self.appID = try container.decodeIfPresent(UUID.self, forKey: CustomQuery.CodingKeys.appID)
self.relativeIntervals = try container.decodeIfPresent([RelativeTimeInterval].self, forKey: CustomQuery.CodingKeys.relativeIntervals)
self.granularity = try container.decode(QueryGranularity.self, forKey: CustomQuery.CodingKeys.granularity)
self.aggregations = try container.decodeIfPresent([Aggregator].self, forKey: CustomQuery.CodingKeys.aggregations)
Expand All @@ -154,14 +194,11 @@ public struct CustomQuery: Codable, Hashable, Equatable {
self.dimensions = try container.decodeIfPresent([DimensionSpec].self, forKey: CustomQuery.CodingKeys.dimensions)
self.steps = try container.decodeIfPresent([Filter].self, forKey: CustomQuery.CodingKeys.steps)
self.stepNames = try container.decodeIfPresent([String].self, forKey: CustomQuery.CodingKeys.stepNames)

if let intervals = try? container.decode(QueryTimeIntervalsContainer.self, forKey: CustomQuery.CodingKeys.intervals) {
self.intervals = intervals.intervals
}

else {
} else {
self.intervals = try container.decodeIfPresent([QueryTimeInterval].self, forKey: CustomQuery.CodingKeys.intervals)
}

}
}
Loading

0 comments on commit 4cb1a6a

Please sign in to comment.