From dae87efff23032fddef7a10585e78a6244098dac Mon Sep 17 00:00:00 2001 From: Konstantin Kostov Date: Thu, 27 Nov 2025 11:05:21 +0100 Subject: [PATCH 1/3] feat: update CLAUDE.md to match the current implementation --- CLAUDE.md | 64 +++++++++++++++++++++++++++++-------------------------- 1 file changed, 34 insertions(+), 30 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index dcec5c3..51af568 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,43 +42,50 @@ swift package generate-xcodeproj ## Architecture Overview -### Core Data Models -- **DTOv1/DTOv2**: Main data transfer objects with versioning - - `DTOv1`: Legacy models (InsightGroup, LexiconPayloadKey, OrganizationJoinRequest) - - `DTOv2`: Current models (Organization, User, App, Insight, Badge, etc.) -- **Models.swift**: Additional DTOs for API requests, authentication, and UI state - -### Query System +### Query System (`Query/`) - **CustomQuery**: Main query builder for Apache Druid integration - - Supports multiple query types: timeseries, groupBy, topN, scan, timeBoundary, funnel, experiment + - Query types: `timeseries`, `groupBy`, `topN`, `scan`, `timeBoundary`, `funnel`, `experiment`, `retention` - Handles filters, aggregations, post-aggregations, and time intervals - **Query Components**: - - `Aggregator`: Define aggregation functions (sum, count, etc.) + - `Aggregator`: Aggregation functions (sum, count, etc.) - `Filter`: Query filtering logic - `DimensionSpec`: Dimension specifications for grouping - `QueryGranularity`: Time granularity (day, week, month) - `VirtualColumn`: Computed columns - -### Druid Integration -- **Druid/**: Complete Apache Druid configuration DTOs - - `configuration/`: Tuning configs, compaction configs - - `data/input/`: Input formats, sources, and dimension specs - - `indexing/`: Parallel indexing, batch processing - - `ingestion/`: Native batch ingestion specs - - `segment/`: Data schema and transformation specs - - `Supervisor/`: Kafka streaming supervision - -### Chart Configuration -- **ChartConfiguration**: Display settings for analytics charts -- **ChartDefinitionDTO**: Chart metadata and configuration -- **InsightDisplayMode**: Chart types (lineChart, barChart, pieChart, etc.) - -### Query Results -- **QueryResult**: Polymorphic result handling for different query types + - `PostAggregator`: Post-aggregation calculations + - `Datasource`: Data source configuration + +### Query Generation (`QueryGeneration/`) +- **CustomQuery+Funnel**: Funnel analysis query generation +- **CustomQuery+Experiment**: A/B experiment queries +- **CustomQuery+Retention**: Retention analysis queries +- **Precompilable**: Query precompilation protocol +- **SQLQueryConversion**: SQL conversion utilities + +### Query Results (`QueryResult/`) +- **QueryResult**: Polymorphic enum for different result types - **TimeSeriesQueryResult**: Time-based query results - **TopNQueryResult**: Top-N dimension results - **GroupByQueryResult**: Grouped aggregation results - **ScanQueryResult**: Raw data scanning results +- **TimeBoundaryResult**: Time boundary query results +- Helper types: `StringWrapper`, `DoubleWrapper`, `DoublePlusInfinity` + +### Druid Configuration (`Druid/`) +- `configuration/`: TuningConfig, AutoCompactionConfig +- `data/input/`: Input formats and dimension specs +- `indexer/`: Granularity specs +- `indexing/`: Kinesis streaming, parallel batch indexing +- `ingestion/`: Task specs, native batch, ingestion specs +- `segment/`: Data schema and transform specs + +### Supervisor (`Supervisor/`) +- Kafka/Kinesis streaming supervision DTOs + +### Chart Configuration (`Chart Configuration/`) +- **ChartConfiguration**: Display settings for analytics charts +- **ChartAggregationConfiguration**: Aggregation configuration +- **ChartConfigurationOptions**: Chart options ## Key Dependencies @@ -87,9 +94,6 @@ swift package generate-xcodeproj ## Development Notes -### DTO Versioning -The library uses a versioning strategy with `DTOv1` and `DTOv2` namespaces. `DTOv2.Insight` is deprecated in favor of V3InsightsController patterns. - ### Query Hashing CustomQuery implements stable hashing using SHA256 for caching and query deduplication. The `stableHashValue` property provides consistent query identification. @@ -98,7 +102,7 @@ Tests are organized by functionality: - **DataTransferObjectsTests**: Basic DTO serialization/deserialization - **QueryTests**: Query building and validation - **QueryResultTests**: Result parsing and handling -- **QueryGenerationTests**: Advanced query generation (funnels, experiments) +- **QueryGenerationTests**: Advanced query generation (funnels, experiments, retention) - **SupervisorTests**: Druid supervisor configuration - **DataSchemaTests**: Data ingestion schema validation From 9e4dbe88dbb117c4d3831b44c0a1aaddd64b114a Mon Sep 17 00:00:00 2001 From: Konstantin Kostov Date: Thu, 27 Nov 2025 11:24:03 +0100 Subject: [PATCH 2/3] feat: add support for null and equals query types --- .../Query/CustomQuery+CompileDown.swift | 4 + .../DataTransferObjects/Query/Filter.swift | 111 +++++++++- Tests/QueryTests/FilterEqualsNullTests.swift | 208 ++++++++++++++++++ 3 files changed, 320 insertions(+), 3 deletions(-) create mode 100644 Tests/QueryTests/FilterEqualsNullTests.swift diff --git a/Sources/DataTransferObjects/Query/CustomQuery+CompileDown.swift b/Sources/DataTransferObjects/Query/CustomQuery+CompileDown.swift index 9f13262..b6b5bbb 100644 --- a/Sources/DataTransferObjects/Query/CustomQuery+CompileDown.swift +++ b/Sources/DataTransferObjects/Query/CustomQuery+CompileDown.swift @@ -158,6 +158,10 @@ public extension CustomQuery { return filter case .range: return filter + case .equals: + return filter + case .null: + return filter case .and(let filterExpression): return Filter.and(.init(fields: filterExpression.fields.map { compileRelativeFilterInterval(filter: $0) })) case .or(let filterExpression): diff --git a/Sources/DataTransferObjects/Query/Filter.swift b/Sources/DataTransferObjects/Query/Filter.swift index 151646f..7d8a7ba 100644 --- a/Sources/DataTransferObjects/Query/Filter.swift +++ b/Sources/DataTransferObjects/Query/Filter.swift @@ -129,8 +129,97 @@ public struct FilterNot: Codable, Hashable, Equatable, Sendable { public let field: Filter } +/// The equality filter matches rows where a column value equals a specific value. +public struct FilterEquals: Codable, Hashable, Equatable, Sendable { + public init(column: String, matchValueType: MatchValueType, matchValue: MatchValue) { + self.column = column + self.matchValueType = matchValueType + self.matchValue = matchValue + } + + public enum MatchValueType: String, Codable, Hashable, Equatable, Sendable { + case string = "STRING" + case long = "LONG" + case double = "DOUBLE" + case float = "FLOAT" + case arrayString = "ARRAY" + case arrayLong = "ARRAY" + case arrayDouble = "ARRAY" + case arrayFloat = "ARRAY" + } + + public enum MatchValue: Hashable, Equatable, Sendable { + case string(String) + case int(Int) + case double(Double) + case arrayString([String]) + case arrayInt([Int]) + case arrayDouble([Double]) + } + + public let column: String + public let matchValueType: MatchValueType + public let matchValue: MatchValue +} + +extension FilterEquals.MatchValue: Codable { + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + if let arrayString = try? container.decode([String].self) { + self = .arrayString(arrayString) + } else if let arrayInt = try? container.decode([Int].self) { + self = .arrayInt(arrayInt) + } else if let arrayDouble = try? container.decode([Double].self) { + self = .arrayDouble(arrayDouble) + } else if let string = try? container.decode(String.self) { + self = .string(string) + } else if let int = try? container.decode(Int.self) { + self = .int(int) + } else if let double = try? container.decode(Double.self) { + self = .double(double) + } else { + throw DecodingError.typeMismatch( + FilterEquals.MatchValue.self, + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Expected String, Int, Double, or array types" + ) + ) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + + switch self { + case .string(let value): + try container.encode(value) + case .int(let value): + try container.encode(value) + case .double(let value): + try container.encode(value) + case .arrayString(let value): + try container.encode(value) + case .arrayInt(let value): + try container.encode(value) + case .arrayDouble(let value): + try container.encode(value) + } + } +} + +/// The null filter matches rows where a column value is null. +public struct FilterNull: Codable, Hashable, Equatable, Sendable { + public init(column: String) { + self.column = column + } + + public let column: String +} + /// A filter is a JSON object indicating which rows of data should be included in the computation -/// for a query. It’s essentially the equivalent of the WHERE clause in SQL. +/// for a query. It's essentially the equivalent of the WHERE clause in SQL. public indirect enum Filter: Codable, Hashable, Equatable, Sendable { /// The selector filter will match a specific dimension with a specific value. /// Selector filters can be used as the base filters for more complex Boolean @@ -157,6 +246,12 @@ public indirect enum Filter: Codable, Hashable, Equatable, Sendable { // to, less than or equal to, and "between" case range(FilterRange) + /// The equality filter matches rows where a column value equals a specific value. + case equals(FilterEquals) + + /// The null filter matches rows where a column value is null. + case null(FilterNull) + // logical expression filters case and(FilterExpression) case or(FilterExpression) @@ -179,14 +274,18 @@ public indirect enum Filter: Codable, Hashable, Equatable, Sendable { self = try .interval(FilterInterval(from: decoder)) case "regex": self = try .regex(FilterRegex(from: decoder)) + case "range": + self = try .range(FilterRange(from: decoder)) + case "equals": + self = try .equals(FilterEquals(from: decoder)) + case "null": + self = try .null(FilterNull(from: decoder)) case "and": self = try .and(FilterExpression(from: decoder)) case "or": self = try .or(FilterExpression(from: decoder)) case "not": self = try .not(FilterNot(from: decoder)) - case "range": - self = try .range(FilterRange(from: decoder)) default: throw EncodingError.invalidValue("Invalid type", .init(codingPath: [CodingKeys.type], debugDescription: "Invalid Type", underlyingError: nil)) } @@ -216,6 +315,12 @@ public indirect enum Filter: Codable, Hashable, Equatable, Sendable { case let .range(range): try container.encode("range", forKey: .type) try range.encode(to: encoder) + case let .equals(equals): + try container.encode("equals", forKey: .type) + try equals.encode(to: encoder) + case let .null(null): + try container.encode("null", forKey: .type) + try null.encode(to: encoder) case let .and(and): try container.encode("and", forKey: .type) try and.encode(to: encoder) diff --git a/Tests/QueryTests/FilterEqualsNullTests.swift b/Tests/QueryTests/FilterEqualsNullTests.swift new file mode 100644 index 0000000..7e45e40 --- /dev/null +++ b/Tests/QueryTests/FilterEqualsNullTests.swift @@ -0,0 +1,208 @@ +import DataTransferObjects +import XCTest + +class FilterEqualsNullTests: XCTestCase { + func testFilterEqualsStringValue() throws { + let filterJSON = """ + { + "column": "name", + "matchValue": "John", + "matchValueType": "STRING", + "type": "equals" + } + """ + .filter { !$0.isWhitespace } + + let filterEquals = Filter.equals( + FilterEquals( + column: "name", + matchValueType: .string, + matchValue: .string("John") + ) + ) + + let decodedFilter = try JSONDecoder.telemetryDecoder.decode( + Filter.self, + from: filterJSON.data(using: .utf8)! + ) + + let encodedFilter = try JSONEncoder.telemetryEncoder.encode(filterEquals) + + XCTAssertEqual(filterEquals, decodedFilter) + XCTAssertEqual(filterJSON, String(data: encodedFilter, encoding: .utf8)) + } + + func testFilterEqualsIntValue() throws { + let filterJSON = """ + { + "column": "age", + "matchValue": 42, + "matchValueType": "LONG", + "type": "equals" + } + """ + .filter { !$0.isWhitespace } + + let filterEquals = Filter.equals( + FilterEquals( + column: "age", + matchValueType: .long, + matchValue: .int(42) + ) + ) + + let decodedFilter = try JSONDecoder.telemetryDecoder.decode( + Filter.self, + from: filterJSON.data(using: .utf8)! + ) + + let encodedFilter = try JSONEncoder.telemetryEncoder.encode(filterEquals) + + XCTAssertEqual(filterEquals, decodedFilter) + XCTAssertEqual(filterJSON, String(data: encodedFilter, encoding: .utf8)) + } + + func testFilterEqualsDoubleValue() throws { + let filterJSON = """ + { + "column": "score", + "matchValue": 3.14, + "matchValueType": "DOUBLE", + "type": "equals" + } + """ + .filter { !$0.isWhitespace } + + let filterEquals = Filter.equals( + FilterEquals( + column: "score", + matchValueType: .double, + matchValue: .double(3.14) + ) + ) + + let decodedFilter = try JSONDecoder.telemetryDecoder.decode( + Filter.self, + from: filterJSON.data(using: .utf8)! + ) + + let encodedFilter = try JSONEncoder.telemetryEncoder.encode(filterEquals) + + XCTAssertEqual(filterEquals, decodedFilter) + XCTAssertEqual(filterJSON, String(data: encodedFilter, encoding: .utf8)) + } + + func testFilterEqualsArrayStringValue() throws { + let filterJSON = """ + { + "column": "tags", + "matchValue": ["swift", "ios", "macos"], + "matchValueType": "ARRAY", + "type": "equals" + } + """ + .filter { !$0.isWhitespace } + + let filterEquals = Filter.equals( + FilterEquals( + column: "tags", + matchValueType: .arrayString, + matchValue: .arrayString(["swift", "ios", "macos"]) + ) + ) + + let decodedFilter = try JSONDecoder.telemetryDecoder.decode( + Filter.self, + from: filterJSON.data(using: .utf8)! + ) + + let encodedFilter = try JSONEncoder.telemetryEncoder.encode(filterEquals) + + XCTAssertEqual(filterEquals, decodedFilter) + XCTAssertEqual(filterJSON, String(data: encodedFilter, encoding: .utf8)) + } + + func testFilterEqualsArrayIntValue() throws { + let filterJSON = """ + { + "column": "numbers", + "matchValue": [1, 2, 3], + "matchValueType": "ARRAY", + "type": "equals" + } + """ + .filter { !$0.isWhitespace } + + let filterEquals = Filter.equals( + FilterEquals( + column: "numbers", + matchValueType: .arrayLong, + matchValue: .arrayInt([1, 2, 3]) + ) + ) + + let decodedFilter = try JSONDecoder.telemetryDecoder.decode( + Filter.self, + from: filterJSON.data(using: .utf8)! + ) + + let encodedFilter = try JSONEncoder.telemetryEncoder.encode(filterEquals) + + XCTAssertEqual(filterEquals, decodedFilter) + XCTAssertEqual(filterJSON, String(data: encodedFilter, encoding: .utf8)) + } + + func testFilterEqualsArrayDoubleValue() throws { + let filterJSON = """ + { + "column": "values", + "matchValue": [1.1, 2.2, 3.3], + "matchValueType": "ARRAY", + "type": "equals" + } + """ + .filter { !$0.isWhitespace } + + let filterEquals = Filter.equals( + FilterEquals( + column: "values", + matchValueType: .arrayDouble, + matchValue: .arrayDouble([1.1, 2.2, 3.3]) + ) + ) + + let decodedFilter = try JSONDecoder.telemetryDecoder.decode( + Filter.self, + from: filterJSON.data(using: .utf8)! + ) + + let encodedFilter = try JSONEncoder.telemetryEncoder.encode(filterEquals) + + XCTAssertEqual(filterEquals, decodedFilter) + XCTAssertEqual(filterJSON, String(data: encodedFilter, encoding: .utf8)) + } + + func testFilterNull() throws { + let filterJSON = """ + { + "column": "description", + "type": "null" + } + """ + .filter { !$0.isWhitespace } + + let filterNull = Filter.null( + FilterNull(column: "description") + ) + + let decodedFilter = try JSONDecoder.telemetryDecoder.decode( + Filter.self, + from: filterJSON.data(using: .utf8)! + ) + + let encodedFilter = try JSONEncoder.telemetryEncoder.encode(filterNull) + + XCTAssertEqual(filterNull, decodedFilter) + XCTAssertEqual(filterJSON, String(data: encodedFilter, encoding: .utf8)) + } +} From 536e695a062167c53d8615bb75ec9ec4c624a135 Mon Sep 17 00:00:00 2001 From: Konstantin Kostov Date: Thu, 27 Nov 2025 11:29:27 +0100 Subject: [PATCH 3/3] ci: linux test image should use swift 6.2 --- .github/workflows/testlinux.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/testlinux.yml b/.github/workflows/testlinux.yml index b7872a1..1ea47b6 100644 --- a/.github/workflows/testlinux.yml +++ b/.github/workflows/testlinux.yml @@ -9,7 +9,7 @@ on: jobs: build: runs-on: ubuntu-latest - container: swift:5.9-jammy + container: swift:6.2 steps: - name: Check out Source