@@ -11,7 +11,9 @@ import (
1111
1212 "github.com/apache/arrow-go/v18/arrow"
1313 "github.com/apache/arrow-go/v18/arrow/array"
14+ "github.com/apache/arrow-go/v18/arrow/compute"
1415 "github.com/apache/arrow-go/v18/arrow/ipc"
16+ "github.com/apache/arrow-go/v18/arrow/memory"
1517 "github.com/basekick-labs/arc/internal/metrics"
1618 "github.com/gofiber/fiber/v2"
1719)
@@ -113,6 +115,14 @@ func (h *QueryHandler) executeQueryArrow(c *fiber.Ctx) error {
113115
114116 schema := reader .Schema ()
115117
118+ // Normalize decimal columns in the schema — DuckDB returns SUM(integer) as
119+ // decimal(38,0) which many Arrow clients (e.g. Grafana) cannot handle.
120+ // castInfo is nil when there are no decimal columns (zero overhead on hot path).
121+ castInfo := normalizeDecimalSchema (schema )
122+ if castInfo != nil {
123+ schema = castInfo .schema
124+ }
125+
116126 c .Set ("Content-Type" , "application/vnd.apache.arrow.stream" )
117127
118128 c .Context ().SetBodyStreamWriter (func (w * bufio.Writer ) {
@@ -126,6 +136,17 @@ func (h *QueryHandler) executeQueryArrow(c *fiber.Ctx) error {
126136 }
127137 totalRows += batch .NumRows ()
128138
139+ if castInfo != nil {
140+ var castErr error
141+ batch , castErr = castDecimalBatch (batch , castInfo )
142+ if castErr != nil {
143+ h .logger .Error ().Err (castErr ).Msg ("Failed to cast decimal columns in Arrow batch" )
144+ batch .Release ()
145+ break
146+ }
147+ defer batch .Release ()
148+ }
149+
129150 if err := ipcWriter .Write (batch ); err != nil {
130151 h .logger .Error ().Err (err ).Msg ("Failed to write Arrow batch" )
131152 break
@@ -155,6 +176,87 @@ func (h *QueryHandler) executeQueryArrow(c *fiber.Ctx) error {
155176 return nil
156177}
157178
179+ // decimalCastInfo holds the modified schema and per-column cast targets for
180+ // queries that return decimal columns (e.g. SUM/AVG on integer columns).
181+ type decimalCastInfo struct {
182+ schema * arrow.Schema
183+ targets []arrow.DataType // nil entry = no cast needed for that column index
184+ }
185+
186+ // normalizeDecimalSchema inspects the schema for decimal columns and returns
187+ // a decimalCastInfo with a substituted schema if any are found, or nil if
188+ // there are no decimal columns (zero overhead on the common path).
189+ //
190+ // - decimal(x, 0) → int64 (SUM/COUNT of integers)
191+ // - decimal(x, y) → float64 (AVG or user-configured decimals)
192+ func normalizeDecimalSchema (schema * arrow.Schema ) * decimalCastInfo {
193+ hasDecimal := false
194+ for i := 0 ; i < schema .NumFields (); i ++ {
195+ if _ , ok := schema .Field (i ).Type .(* arrow.Decimal128Type ); ok {
196+ hasDecimal = true
197+ break
198+ }
199+ }
200+ if ! hasDecimal {
201+ return nil
202+ }
203+
204+ targets := make ([]arrow.DataType , schema .NumFields ())
205+ fields := make ([]arrow.Field , schema .NumFields ())
206+ for i := 0 ; i < schema .NumFields (); i ++ {
207+ f := schema .Field (i )
208+ if dt , ok := f .Type .(* arrow.Decimal128Type ); ok {
209+ if dt .Scale == 0 {
210+ targets [i ] = arrow .PrimitiveTypes .Int64
211+ } else {
212+ targets [i ] = arrow .PrimitiveTypes .Float64
213+ }
214+ fields [i ] = arrow.Field {Name : f .Name , Type : targets [i ], Nullable : f .Nullable , Metadata : f .Metadata }
215+ } else {
216+ fields [i ] = f
217+ }
218+ }
219+
220+ md := schema .Metadata ()
221+ return & decimalCastInfo {
222+ schema : arrow .NewSchema (fields , & md ),
223+ targets : targets ,
224+ }
225+ }
226+
227+ // castDecimalBatch replaces decimal columns in the batch with int64 or float64
228+ // using arrow-go's compute.CastArray (SIMD-optimized, handles nulls via bitmap).
229+ // The returned record must be Released by the caller.
230+ func castDecimalBatch (batch arrow.Record , info * decimalCastInfo ) (arrow.Record , error ) {
231+ cols := make ([]arrow.Array , batch .NumCols ())
232+ toRelease := make ([]arrow.Array , 0 , batch .NumCols ())
233+
234+ ctx := compute .WithAllocator (context .Background (), memory .DefaultAllocator )
235+
236+ for i , target := range info .targets {
237+ if target == nil {
238+ cols [i ] = batch .Column (i )
239+ continue
240+ }
241+ casted , err := compute .CastArray (ctx , batch .Column (i ), compute .SafeCastOptions (target ))
242+ if err != nil {
243+ // Release any arrays we already allocated
244+ for _ , a := range toRelease {
245+ a .Release ()
246+ }
247+ return nil , err
248+ }
249+ cols [i ] = casted
250+ toRelease = append (toRelease , casted )
251+ }
252+
253+ rec := array .NewRecord (info .schema , cols , batch .NumRows ())
254+ for _ , a := range toRelease {
255+ a .Release ()
256+ }
257+ return rec , nil
258+ }
259+
158260// sqlTypeToArrowType converts SQL type names to Arrow types
159261func sqlTypeToArrowType (sqlType string ) arrow.DataType {
160262 sqlType = strings .ToUpper (sqlType )
0 commit comments