Skip to content

Commit a755dd2

Browse files
committed
Add OrderByColumn
1 parent ed75d54 commit a755dd2

12 files changed

Lines changed: 447 additions & 47 deletions

api/v1/dataflow_types.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,11 @@ type PostgreSQLSourceSpec struct {
291291
// +kubebuilder:default="updated_at"
292292
ChangeTrackingColumn string `json:"changeTrackingColumn,omitempty"`
293293

294+
// OrderByColumn is the secondary sort key for stable pagination (default: id).
295+
// Used in ORDER BY together with changeTrackingColumn.
296+
// +optional
297+
OrderByColumn string `json:"orderByColumn,omitempty"`
298+
294299
// AutoCreateTable creates the table if it doesn't exist before reading
295300
// +optional
296301
AutoCreateTable *bool `json:"autoCreateTable,omitempty"`
@@ -337,6 +342,10 @@ type TrinoSourceSpec struct {
337342
// TableSecretRef references a Kubernetes secret for table name
338343
// +optional
339344
TableSecretRef *SecretRef `json:"tableSecretRef,omitempty"`
345+
346+
// OrderByColumn is the column used for incremental pagination and stable ORDER BY (default: id).
347+
// +optional
348+
OrderByColumn string `json:"orderByColumn,omitempty"`
340349
}
341350

342351
// ClickHouseSourceSpec defines ClickHouse source configuration
@@ -362,6 +371,10 @@ type ClickHouseSourceSpec struct {
362371
// TableSecretRef references a Kubernetes secret for table name
363372
// +optional
364373
TableSecretRef *SecretRef `json:"tableSecretRef,omitempty"`
374+
375+
// OrderByColumn is the column used for incremental pagination and stable ORDER BY (default: id).
376+
// +optional
377+
OrderByColumn string `json:"orderByColumn,omitempty"`
365378
}
366379

367380
// NessieSourceSpec defines Nessie (Iceberg REST catalog) source configuration.

api/v1/dataflow_validation.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ package v1
1919
import (
2020
"encoding/json"
2121
"fmt"
22+
"regexp"
2223
"strconv"
2324
"strings"
2425

@@ -197,9 +198,25 @@ func validatePostgreSQLSource(p *PostgreSQLSourceSpec, f *field.Path) field.Erro
197198
if p.TableSecretRef != nil {
198199
all = append(all, validateSecretRef(p.TableSecretRef, f.Child("tableSecretRef"))...)
199200
}
201+
if err := validateOrderByColumn(p.OrderByColumn, f.Child("orderByColumn")); err != nil {
202+
all = append(all, err)
203+
}
200204
return all
201205
}
202206

207+
var sqlIdentifierRe = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`)
208+
209+
func validateOrderByColumn(col string, f *field.Path) *field.Error {
210+
if col == "" {
211+
return nil
212+
}
213+
if !sqlIdentifierRe.MatchString(col) {
214+
return field.Invalid(f, col,
215+
"must be a valid SQL identifier (letters, digits, underscore; must not start with a digit)")
216+
}
217+
return nil
218+
}
219+
203220
func validateTrinoSource(t *TrinoSourceSpec, f *field.Path) field.ErrorList {
204221
var all field.ErrorList
205222
hasURL := t.ServerURL != "" || t.ServerURLSecretRef != nil
@@ -230,6 +247,9 @@ func validateTrinoSource(t *TrinoSourceSpec, f *field.Path) field.ErrorList {
230247
if t.TableSecretRef != nil {
231248
all = append(all, validateSecretRef(t.TableSecretRef, f.Child("tableSecretRef"))...)
232249
}
250+
if err := validateOrderByColumn(t.OrderByColumn, f.Child("orderByColumn")); err != nil {
251+
all = append(all, err)
252+
}
233253
return all
234254
}
235255

@@ -503,6 +523,9 @@ func validateClickHouseSource(c *ClickHouseSourceSpec, f *field.Path) field.Erro
503523
if c.TableSecretRef != nil {
504524
all = append(all, validateSecretRef(c.TableSecretRef, f.Child("tableSecretRef"))...)
505525
}
526+
if err := validateOrderByColumn(c.OrderByColumn, f.Child("orderByColumn")); err != nil {
527+
all = append(all, err)
528+
}
506529
return all
507530
}
508531

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/*
2+
Copyright 2024.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package v1
18+
19+
import (
20+
"testing"
21+
22+
"k8s.io/apimachinery/pkg/util/validation/field"
23+
)
24+
25+
func TestValidateOrderByColumn_sources(t *testing.T) {
26+
path := field.NewPath("spec").Child("source").Child("config")
27+
28+
t.Run("postgresql valid", func(t *testing.T) {
29+
spec := &PostgreSQLSourceSpec{
30+
ConnectionString: "postgres://x",
31+
Table: "t",
32+
OrderByColumn: "price_id",
33+
}
34+
if errs := validatePostgreSQLSource(spec, path); len(errs) != 0 {
35+
t.Fatalf("expected no errors, got %v", errs)
36+
}
37+
})
38+
39+
t.Run("trino invalid", func(t *testing.T) {
40+
spec := &TrinoSourceSpec{
41+
ServerURL: "http://trino:8080",
42+
Catalog: "c",
43+
Schema: "s",
44+
Table: "t",
45+
OrderByColumn: "bad-col",
46+
}
47+
errs := validateTrinoSource(spec, path)
48+
if len(errs) == 0 {
49+
t.Fatal("expected validation error")
50+
}
51+
})
52+
53+
t.Run("clickhouse valid", func(t *testing.T) {
54+
spec := &ClickHouseSourceSpec{
55+
ConnectionString: "clickhouse://x",
56+
Table: "t",
57+
OrderByColumn: "price_id",
58+
}
59+
if errs := validateClickHouseSource(spec, path); len(errs) != 0 {
60+
t.Fatalf("expected no errors, got %v", errs)
61+
}
62+
})
63+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/*
2+
Copyright 2024.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package v1
18+
19+
import (
20+
"testing"
21+
22+
"k8s.io/apimachinery/pkg/util/validation/field"
23+
)
24+
25+
func TestValidatePostgreSQLSource_orderByColumn(t *testing.T) {
26+
path := field.NewPath("spec").Child("source").Child("config")
27+
28+
t.Run("valid identifier", func(t *testing.T) {
29+
spec := &PostgreSQLSourceSpec{
30+
ConnectionString: "postgres://x",
31+
Table: "t",
32+
OrderByColumn: "price_id",
33+
}
34+
if errs := validatePostgreSQLSource(spec, path); len(errs) != 0 {
35+
t.Fatalf("expected no errors, got %v", errs)
36+
}
37+
})
38+
39+
t.Run("invalid identifier", func(t *testing.T) {
40+
spec := &PostgreSQLSourceSpec{
41+
ConnectionString: "postgres://x",
42+
Table: "t",
43+
OrderByColumn: "price-id",
44+
}
45+
errs := validatePostgreSQLSource(spec, path)
46+
if len(errs) == 0 {
47+
t.Fatal("expected validation error for invalid orderByColumn")
48+
}
49+
})
50+
}

internal/connectors/clickhouse.go

Lines changed: 24 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -178,16 +178,9 @@ func (c *ClickHouseSourceConnector) readRows(ctx context.Context, msgChan chan *
178178

179179
var query string
180180
if c.config.Query != "" {
181-
query = c.config.Query
181+
query = c.wrapQueryWithStableOrder(c.config.Query)
182182
} else {
183-
if lastReadID > 0 {
184-
query = fmt.Sprintf("SELECT * FROM %s WHERE id > %d ORDER BY id", c.config.Table, lastReadID)
185-
} else if lastReadTime != nil {
186-
query = fmt.Sprintf("SELECT * FROM %s WHERE created_at > '%s' ORDER BY created_at",
187-
c.config.Table, lastReadTime.Format("2006-01-02 15:04:05"))
188-
} else {
189-
query = fmt.Sprintf("SELECT * FROM %s", c.config.Table)
190-
}
183+
query = c.buildTableReadQuery(lastReadID, lastReadTime)
191184
}
192185

193186
c.logger.V(1).Info("Executing ClickHouse query", "query", query, "table", c.config.Table)
@@ -209,16 +202,9 @@ func (c *ClickHouseSourceConnector) readRows(ctx context.Context, msgChan chan *
209202
return fmt.Errorf("clickhouse columns: %w", err)
210203
}
211204

212-
var idIndex = -1
213-
var createdAtIndex = -1
214-
for i, col := range columns {
215-
if col == "id" {
216-
idIndex = i
217-
}
218-
if col == "created_at" {
219-
createdAtIndex = i
220-
}
221-
}
205+
orderByCol := ResolveOrderByColumn(c.config.OrderByColumn)
206+
idIndex := ColumnIndex(columns, orderByCol)
207+
createdAtIndex := ColumnIndex(columns, "created_at")
222208

223209
var maxReadID int64 = lastReadID
224210
var maxReadTime *time.Time
@@ -278,7 +264,7 @@ func (c *ClickHouseSourceConnector) readRows(ctx context.Context, msgChan chan *
278264
msg := types.NewMessage(jsonData)
279265
msg.Metadata["table"] = c.config.Table
280266
if idIndex >= 0 && len(values) > idIndex {
281-
msg.Metadata["id"] = values[idIndex]
267+
SetSourceRowIDMetadata(msg, values[idIndex])
282268
}
283269
// Ack advances checkpoint only after sink successfully writes; prevents data loss on crash
284270
rowID, rowTime := c.extractRowCheckpoint(values, idIndex, createdAtIndex)
@@ -299,6 +285,24 @@ func (c *ClickHouseSourceConnector) readRows(ctx context.Context, msgChan chan *
299285
return nil
300286
}
301287

288+
func (c *ClickHouseSourceConnector) buildTableReadQuery(lastReadID int64, lastReadTime *time.Time) string {
289+
table := c.config.Table
290+
col := ResolveOrderByColumn(c.config.OrderByColumn)
291+
if lastReadID > 0 {
292+
return fmt.Sprintf("SELECT * FROM %s WHERE %s > %d ORDER BY %s", table, col, lastReadID, col)
293+
}
294+
if lastReadTime != nil {
295+
return fmt.Sprintf("SELECT * FROM %s WHERE created_at > '%s' ORDER BY created_at, %s",
296+
table, lastReadTime.Format("2006-01-02 15:04:05"), col)
297+
}
298+
return fmt.Sprintf("SELECT * FROM %s ORDER BY created_at, %s", table, col)
299+
}
300+
301+
func (c *ClickHouseSourceConnector) wrapQueryWithStableOrder(userQuery string) string {
302+
col := ResolveOrderByColumn(c.config.OrderByColumn)
303+
return WrapQueryStableOrder(userQuery, col)
304+
}
305+
302306
// extractRowCheckpoint returns (id, created_at) for checkpoint advancement.
303307
func (c *ClickHouseSourceConnector) extractRowCheckpoint(values []interface{}, idIndex, createdAtIndex int) (int64, *time.Time) {
304308
var rowID int64

internal/connectors/clickhouse_test.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,3 +254,50 @@ func TestBuildInsertValues_preservesCreatedAtFromSource(t *testing.T) {
254254
})
255255
}
256256
}
257+
258+
func TestClickHouseSourceConnector_buildTableReadQuery(t *testing.T) {
259+
spec := &v1.ClickHouseSourceSpec{
260+
ConnectionString: "clickhouse://localhost:9000",
261+
Table: "events",
262+
}
263+
c := NewClickHouseSourceConnector(spec)
264+
265+
t.Run("first read", func(t *testing.T) {
266+
got := c.buildTableReadQuery(0, nil)
267+
assert.Contains(t, got, "ORDER BY created_at, id")
268+
assert.NotContains(t, got, "WHERE")
269+
})
270+
t.Run("incremental by id", func(t *testing.T) {
271+
got := c.buildTableReadQuery(100, nil)
272+
assert.Contains(t, got, "WHERE id > 100")
273+
assert.Contains(t, got, "ORDER BY id")
274+
})
275+
t.Run("incremental by created_at", func(t *testing.T) {
276+
ts := time.Date(2024, 6, 1, 12, 0, 0, 0, time.UTC)
277+
got := c.buildTableReadQuery(0, &ts)
278+
assert.Contains(t, got, "WHERE created_at > '2024-06-01 12:00:00'")
279+
assert.Contains(t, got, "ORDER BY created_at, id")
280+
})
281+
t.Run("custom orderByColumn", func(t *testing.T) {
282+
spec := &v1.ClickHouseSourceSpec{
283+
ConnectionString: "clickhouse://localhost:9000",
284+
Table: "prices",
285+
OrderByColumn: "price_id",
286+
}
287+
c := NewClickHouseSourceConnector(spec)
288+
got := c.buildTableReadQuery(50, nil)
289+
assert.Contains(t, got, "WHERE price_id > 50")
290+
assert.Contains(t, got, "ORDER BY price_id")
291+
})
292+
}
293+
294+
func TestClickHouseSourceConnector_wrapQueryWithStableOrder(t *testing.T) {
295+
c := NewClickHouseSourceConnector(&v1.ClickHouseSourceSpec{
296+
ConnectionString: "clickhouse://localhost:9000",
297+
Table: "t",
298+
OrderByColumn: "price_id",
299+
})
300+
got := c.wrapQueryWithStableOrder("SELECT * FROM prices")
301+
assert.Contains(t, got, "__dataflow_src")
302+
assert.Contains(t, got, "ORDER BY price_id")
303+
}

0 commit comments

Comments
 (0)