Skip to content

Commit 63c666c

Browse files
committed
Improve bulk insert go driver
1 parent 4780d28 commit 63c666c

6 files changed

Lines changed: 357 additions & 198 deletions

File tree

go/bulk_ingest.go

Lines changed: 77 additions & 193 deletions
Original file line numberDiff line numberDiff line change
@@ -15,102 +15,64 @@
1515
package databricks
1616

1717
import (
18+
"bytes"
1819
"context"
19-
"database/sql/driver"
2020
"fmt"
2121
"strings"
2222

2323
"github.com/adbc-drivers/driverbase-go/driverbase"
2424
"github.com/apache/arrow-adbc/go/adbc"
2525
"github.com/apache/arrow-go/v18/arrow"
26-
"github.com/apache/arrow-go/v18/arrow/array"
2726
)
2827

29-
// executeIngest performs bulk insert using parameterized INSERT statements
30-
func (s *statementImpl) executeIngest(ctx context.Context) (int64, error) {
31-
if s.boundStream == nil {
32-
return -1, s.ErrorHelper.Errorf(adbc.StatusInvalidState, "no data bound for ingestion")
33-
}
34-
35-
defer func() {
36-
s.boundStream.Release()
37-
s.boundStream = nil
38-
}()
39-
40-
opts := &s.bulkIngestOptions
41-
42-
tableName := buildTableName(opts.CatalogName, opts.SchemaName, opts.TableName)
43-
44-
if err := s.createTableIfNeeded(ctx, tableName, s.boundStream.Schema(), opts); err != nil {
45-
return -1, err
46-
}
47-
48-
insertSQL, err := buildInsertSQL(tableName, s.boundStream.Schema())
49-
if err != nil {
50-
return -1, err
51-
}
52-
53-
totalRows := int64(0)
54-
params := make([]driver.NamedValue, s.boundStream.Schema().NumFields())
55-
56-
for s.boundStream.Next() {
57-
recordBatch := s.boundStream.RecordBatch()
58-
59-
for rowIdx := range int(recordBatch.NumRows()) {
60-
// Extract Go values from Arrow columns
61-
for colIdx := range int(recordBatch.NumCols()) {
62-
arr := recordBatch.Column(colIdx)
63-
val, err := extractGoValue(arr, rowIdx)
64-
if err != nil {
65-
return totalRows, s.ErrorHelper.Errorf(adbc.StatusInternal, "failed to extract go value: %v", err)
66-
}
67-
params[colIdx].Value = val
68-
}
69-
70-
// Use ExecContext directly instead of PrepareContext because Databricks doesn't do server-side statement preparation
71-
result, err := s.conn.conn.ExecContext(ctx, insertSQL, valuesToInterfaces(params)...)
72-
if err != nil {
73-
return totalRows, s.ErrorHelper.Errorf(adbc.StatusInternal, "failed to execute the query: %v", err)
74-
}
28+
// databricksBulkIngest implements driverbase.BulkIngestImpl for the
29+
// Databricks Staging + COPY INTO pattern. It uploads Parquet files to a
30+
// Unity Catalog Volume via the Databricks Files API, then uses COPY INTO
31+
// to load them into the target table.
32+
type databricksBulkIngest struct {
33+
conn *connectionImpl
34+
stagingClient *stagingClient
35+
errorHelper *driverbase.ErrorHelper
36+
options *driverbase.BulkIngestOptions
37+
}
7538

76-
rows, _ := result.RowsAffected()
77-
totalRows += rows
78-
}
79-
}
39+
// pendingCopy tracks a file uploaded to staging that is ready for COPY INTO.
40+
// It implements driverbase.BulkIngestPendingCopy.
41+
type pendingCopy struct {
42+
path string
43+
rows int64
44+
}
8045

81-
if err := s.boundStream.Err(); err != nil {
82-
return totalRows, s.ErrorHelper.Errorf(adbc.StatusInternal, "stream error: %v", err)
83-
}
46+
func (p *pendingCopy) String() string { return p.path }
47+
func (p *pendingCopy) Rows() int64 { return p.rows }
8448

85-
return totalRows, nil
49+
// CreateSink returns an in-memory buffer for Parquet data to be written to.
50+
func (bi *databricksBulkIngest) CreateSink(ctx context.Context, options *driverbase.BulkIngestOptions) (driverbase.BulkIngestSink, error) {
51+
return &driverbase.BufferBulkIngestSink{}, nil
8652
}
8753

88-
// createTableIfNeeded creates/drops table based on ingest mode
89-
func (s *statementImpl) createTableIfNeeded(ctx context.Context, tableName string, schema *arrow.Schema, opts *driverbase.BulkIngestOptions) error {
90-
switch opts.Mode {
91-
case adbc.OptionValueIngestModeCreate:
92-
return s.createTable(ctx, tableName, schema, false)
54+
// CreateTable creates or drops/recreates the target table based on the
55+
// specified table existence and missing behaviors.
56+
func (bi *databricksBulkIngest) CreateTable(ctx context.Context, schema *arrow.Schema, ifTableExists driverbase.BulkIngestTableExistsBehavior, ifTableMissing driverbase.BulkIngestTableMissingBehavior) error {
57+
tableName := buildTableName(bi.options.CatalogName, bi.options.SchemaName, bi.options.TableName)
9358

94-
case adbc.OptionValueIngestModeCreateAppend:
95-
return s.createTable(ctx, tableName, schema, true)
96-
97-
case adbc.OptionValueIngestModeReplace:
59+
if ifTableExists == driverbase.BulkIngestTableExistsDrop {
9860
dropSQL := fmt.Sprintf("DROP TABLE IF EXISTS %s", tableName)
99-
if _, err := s.conn.conn.ExecContext(ctx, dropSQL); err != nil {
100-
return s.ErrorHelper.Errorf(adbc.StatusInternal, "failed to drop the table: %v", err)
61+
if _, err := bi.conn.conn.ExecContext(ctx, dropSQL); err != nil {
62+
return bi.errorHelper.Errorf(adbc.StatusInternal, "failed to drop table: %v", err)
10163
}
102-
return s.createTable(ctx, tableName, schema, false)
103-
104-
case adbc.OptionValueIngestModeAppend:
105-
return nil
64+
}
10665

107-
default:
108-
return s.ErrorHelper.Errorf(adbc.StatusInvalidArgument, "invalid ingest mode: %s", opts.Mode)
66+
if ifTableMissing == driverbase.BulkIngestTableMissingCreate {
67+
ifNotExists := ifTableExists == driverbase.BulkIngestTableExistsIgnore
68+
return bi.createTableDDL(ctx, tableName, schema, ifNotExists)
10969
}
70+
71+
return nil
11072
}
11173

112-
// createTable generates and executes CREATE TABLE DDL
113-
func (s *statementImpl) createTable(ctx context.Context, tableName string, schema *arrow.Schema, ifNotExists bool) error {
74+
// createTableDDL generates and executes CREATE TABLE DDL from an Arrow schema.
75+
func (bi *databricksBulkIngest) createTableDDL(ctx context.Context, tableName string, schema *arrow.Schema, ifNotExists bool) error {
11476
var sql strings.Builder
11577
sql.WriteString("CREATE TABLE ")
11678
if ifNotExists {
@@ -132,48 +94,59 @@ func (s *statementImpl) createTable(ctx context.Context, tableName string, schem
13294
}
13395
sql.WriteString(")")
13496

135-
_, err := s.conn.conn.ExecContext(ctx, sql.String())
97+
_, err := bi.conn.conn.ExecContext(ctx, sql.String())
13698
if err != nil {
137-
return s.ErrorHelper.Errorf(adbc.StatusInternal, "failed to create table: %v", err)
99+
return bi.errorHelper.Errorf(adbc.StatusInternal, "failed to create table: %v", err)
138100
}
139101
return nil
140102
}
141103

142-
// buildInsertSQL generates parameterized INSERT statement
143-
func buildInsertSQL(tableName string, schema *arrow.Schema) (string, error) {
144-
var sql strings.Builder
104+
// Upload uploads the Parquet data from a buffer to the staging volume.
105+
func (bi *databricksBulkIngest) Upload(ctx context.Context, chunk driverbase.BulkIngestPendingUpload) (driverbase.BulkIngestPendingCopy, error) {
106+
buf, ok := chunk.Data.(*driverbase.BufferBulkIngestSink)
107+
if !ok {
108+
return nil, bi.errorHelper.Errorf(adbc.StatusInternal, "unexpected sink type: %T", chunk.Data)
109+
}
145110

146-
sql.WriteString("INSERT INTO ")
147-
sql.WriteString(tableName)
148-
sql.WriteString(" (")
111+
path, err := bi.stagingClient.generateFileName()
112+
if err != nil {
113+
return nil, bi.errorHelper.Errorf(adbc.StatusInternal, "failed to generate staging file name: %v", err)
114+
}
149115

150-
for i, field := range schema.Fields() {
151-
if i > 0 {
152-
sql.WriteString(", ")
153-
}
154-
sql.WriteString(quoteIdentifier(field.Name))
116+
if err := bi.stagingClient.Upload(ctx, path, bytes.NewReader(buf.Bytes())); err != nil {
117+
return nil, bi.errorHelper.Errorf(adbc.StatusIO, "failed to upload staging file: %v", err)
155118
}
156119

157-
sql.WriteString(") VALUES (")
120+
return &pendingCopy{path: path, rows: chunk.Rows}, nil
121+
}
158122

159-
for i, field := range schema.Fields() {
160-
if i > 0 {
161-
sql.WriteString(", ")
162-
}
123+
// Copy executes COPY INTO to load a staged Parquet file into the target table.
124+
func (bi *databricksBulkIngest) Copy(ctx context.Context, chunk driverbase.BulkIngestPendingCopy) error {
125+
tableName := buildTableName(bi.options.CatalogName, bi.options.SchemaName, bi.options.TableName)
163126

164-
if field.Type.ID() == arrow.FIXED_SIZE_BINARY {
165-
// Use UNHEX() to convert hex string to binary
166-
sql.WriteString("UNHEX(?)")
167-
} else {
168-
sql.WriteString("?")
169-
}
127+
// The path from pendingCopy has the form "Volumes/catalog/schema/volume/prefix/file.parquet".
128+
// COPY INTO expects the path with a leading slash: '/Volumes/...'
129+
copySQL := fmt.Sprintf(
130+
"COPY INTO %s FROM '/%s' FILEFORMAT = PARQUET",
131+
tableName, chunk.String(),
132+
)
133+
134+
_, err := bi.conn.conn.ExecContext(ctx, copySQL)
135+
if err != nil {
136+
return bi.errorHelper.Errorf(adbc.StatusInternal, "COPY INTO failed: %v", err)
170137
}
138+
return nil
139+
}
171140

172-
sql.WriteString(")")
173-
return sql.String(), nil
141+
// Delete removes the staging file after it has been copied into the target table.
142+
func (bi *databricksBulkIngest) Delete(ctx context.Context, chunk driverbase.BulkIngestPendingCopy) error {
143+
if err := bi.stagingClient.Delete(ctx, chunk.String()); err != nil {
144+
return bi.errorHelper.Errorf(adbc.StatusIO, "failed to delete staging file: %v", err)
145+
}
146+
return nil
174147
}
175148

176-
// buildTableName constructs catalog.schema.table name
149+
// buildTableName constructs a fully qualified catalog.schema.table name.
177150
func buildTableName(catalog, schema, table string) string {
178151
parts := []string{}
179152
if catalog != "" {
@@ -186,102 +159,13 @@ func buildTableName(catalog, schema, table string) string {
186159
return strings.Join(parts, ".")
187160
}
188161

189-
// quoteIdentifier quotes a Databricks identifier with backticks
162+
// quoteIdentifier quotes a Databricks identifier with backticks.
190163
func quoteIdentifier(id string) string {
191164
escaped := strings.ReplaceAll(id, "`", "``")
192165
return fmt.Sprintf("`%s`", escaped)
193166
}
194167

195-
// valuesToInterfaces converts driver.NamedValue slice to []any for ExecContext
196-
func valuesToInterfaces(params []driver.NamedValue) []any {
197-
result := make([]any, len(params))
198-
for i, p := range params {
199-
result[i] = p.Value
200-
}
201-
return result
202-
}
203-
204-
// extractGoValue extracts a Go value from an Arrow array at the given index
205-
func extractGoValue(arr arrow.Array, idx int) (any, error) {
206-
if arr.IsNull(idx) {
207-
return nil, nil
208-
}
209-
210-
switch arr.DataType().ID() {
211-
case arrow.BOOL:
212-
return arr.(*array.Boolean).Value(idx), nil
213-
214-
case arrow.INT8:
215-
return int64(arr.(*array.Int8).Value(idx)), nil
216-
case arrow.INT16:
217-
return int64(arr.(*array.Int16).Value(idx)), nil
218-
case arrow.INT32:
219-
return int64(arr.(*array.Int32).Value(idx)), nil
220-
case arrow.INT64:
221-
// https://github.com/databricks/databricks-sql-go/issues/315
222-
// databricks-sql-go incorrectly maps int64 to SqlInteger (INT) instead of SqlBigInt (BIGINT)
223-
// Pass as string to preserve full range
224-
return fmt.Sprintf("%d", arr.(*array.Int64).Value(idx)), nil
225-
226-
case arrow.UINT8:
227-
return int64(arr.(*array.Uint8).Value(idx)), nil
228-
case arrow.UINT16:
229-
return int64(arr.(*array.Uint16).Value(idx)), nil
230-
case arrow.UINT32:
231-
// https://github.com/databricks/databricks-sql-go/issues/315
232-
// databricks-sql-go incorrectly maps int64 to SqlInteger (INT)
233-
// Pass as string to preserve full uint32 range
234-
return fmt.Sprintf("%d", arr.(*array.Uint32).Value(idx)), nil
235-
case arrow.UINT64:
236-
// Pass as string to preserve full uint64 range (may still overflow if > int64 max)
237-
return fmt.Sprintf("%d", arr.(*array.Uint64).Value(idx)), nil
238-
239-
case arrow.FLOAT32:
240-
return float64(arr.(*array.Float32).Value(idx)), nil
241-
case arrow.FLOAT64:
242-
// https://github.com/databricks/databricks-sql-go/issues/314
243-
// databricks-sql-go has a bug where float64 is treated as SqlFloat instead of SqlDouble
244-
// causing precision loss. Pass as string to preserve full precision.
245-
val := arr.(*array.Float64).Value(idx)
246-
return fmt.Sprintf("%.17g", val), nil
247-
248-
case arrow.STRING:
249-
return arr.(*array.String).Value(idx), nil
250-
case arrow.LARGE_STRING:
251-
return arr.(*array.LargeString).Value(idx), nil
252-
case arrow.STRING_VIEW:
253-
return arr.(*array.StringView).Value(idx), nil
254-
255-
case arrow.BINARY:
256-
return arr.(*array.Binary).Value(idx), nil
257-
case arrow.LARGE_BINARY:
258-
return arr.(*array.LargeBinary).Value(idx), nil
259-
case arrow.BINARY_VIEW:
260-
return arr.(*array.BinaryView).Value(idx), nil
261-
case arrow.FIXED_SIZE_BINARY:
262-
// Convert to hex string for use with UNHEX() SQL function
263-
return fmt.Sprintf("%x", arr.(*array.FixedSizeBinary).Value(idx)), nil
264-
265-
case arrow.DATE32:
266-
return arr.(*array.Date32).Value(idx).ToTime(), nil
267-
case arrow.DATE64:
268-
return arr.(*array.Date64).Value(idx).ToTime(), nil
269-
270-
case arrow.TIMESTAMP:
271-
ts := arr.DataType().(*arrow.TimestampType)
272-
return arr.(*array.Timestamp).Value(idx).ToTime(ts.Unit), nil
273-
274-
case arrow.DECIMAL128:
275-
dec := arr.(*array.Decimal128)
276-
// Return as string, databricks-sql-go will infer DECIMAL type
277-
return dec.ValueStr(idx), nil
278-
279-
default:
280-
return nil, fmt.Errorf("unsupported Arrow type: %s", arr.DataType())
281-
}
282-
}
283-
284-
// arrowTypeToDatabricksType maps Arrow types to Databricks DDL types for CREATE TABLE
168+
// arrowTypeToDatabricksType maps Arrow types to Databricks DDL types for CREATE TABLE.
285169
func arrowTypeToDatabricksType(dt arrow.DataType) string {
286170
switch dt.ID() {
287171
case arrow.BOOL:

go/connection.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ type connectionImpl struct {
4545

4646
// Database connection
4747
conn *sql.Conn
48+
49+
// Staging client for bulk ingest file operations
50+
stagingClient *stagingClient
4851
}
4952

5053
func (c *connectionImpl) Close() error {

0 commit comments

Comments
 (0)