• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

Giorgi / DuckDB.NET / 28017109290

23 Jun 2026 09:41AM UTC coverage: 89.361% (-0.4%) from 89.738%
28017109290

push

github

Giorgi
Merge branch 'main' into nightly-builds

1330 of 1545 branches covered (86.08%)

Branch coverage included in aggregate %.

362 of 413 new or added lines in 32 files covered. (87.65%)

4 existing lines in 3 files now uncovered.

2769 of 3042 relevant lines covered (91.03%)

434285.19 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

91.67
/DuckDB.NET.Data/DuckDBConnection.TableFunction.Extensions.cs
1
using DuckDB.NET.Data.Connection;
2
using DuckDB.NET.Data.DataChunk.Writer;
3
using System.Linq.Expressions;
4
using System.Reflection;
5

6
namespace DuckDB.NET.Data;
7

8
public static class DuckDBConnectionTableFunctionExtensions
9
{
10
    private record TableFunctionParameter(string? NamedAs, int? PositionalIndex, Type Type, bool IsProjection = false)
891✔
11
    {
12
        public bool IsNamed => NamedAs is not null;
297✔
13
    }
14

15
    private static bool IsProjectionParameter(Type type) => type == typeof(IReadOnlyList<ProjectedColumn>);
117✔
16

17
    extension(DuckDBConnection connection)
18
    {
19
        public void RegisterTableFunction<TData, TProjection>(
20
            string name,
21
            Func<IEnumerable<TData>> dataFunc,
22
            Expression<Func<TData, TProjection>> projection)
23
        {
24
            var (columns, mapper) = ParseProjection(projection);
6✔
25
            connection.RegisterTableFunction(name,
6✔
26
                () => new TableFunction(columns, dataFunc()),
6✔
27
                mapper);
6✔
28
        }
6✔
29

30
        // High-level table function registration flow (1–4 param overloads):
31
        //
32
        // Example: connection.RegisterTableFunction("my_func",
33
        //     (int count, [Named] string? prefix) => GetEmployees(count).Select(e => ...),
34
        //     e => new { e.Id, e.Name });
35
        //
36
        // 1. AnalyzeParameters inspects the lambda's parameters:
37
        //      parameters[0] = (NamedAs: null,     PositionalIndex: 0, Type: int)    — positional
38
        //      parameters[1] = (NamedAs: "prefix", PositionalIndex: null, Type: string) — named
39
        //
40
        // 2. CompileValueReader builds a Func<IDuckDBValueReader, T> per parameter —
41
        //    handles null-checking and type conversion (what to do with the value):
42
        //      read1: reader → reader.GetValue<int>()         (throws if NULL)
43
        //      read2: reader → reader.IsNull() ? null : reader.GetValue<string>()
44
        //
45
        // 3. Resolve picks the right IDuckDBValueReader from the positional list or
46
        //    named dictionary (where to find the value):
47
        //      Resolve(p[0], positional, named) → positional[0]
48
        //      Resolve(p[1], positional, named) → named["prefix"]
49
        //
50
        // 4. The bind lambda wires them together (called once per query):
51
        //      (positional, named) => {
52
        //          int count      = read1(Resolve(p[0], positional, named));
53
        //          string? prefix = read2(Resolve(p[1], positional, named));
54
        //          return new TableFunction(columns, dataFunc(count, prefix));
55
        //      }
56
        //
57
        // 5. RegisterInternal registers with DuckDB:
58
        //      1 positional parameter (INTEGER)
59
        //      1 named parameter ("prefix", VARCHAR)
60
        //
61
        // Projection-aware variant: when parameters[0].Type is IReadOnlyList<ProjectedColumn>,
62
        // step 2 is skipped for that slot, step 4 returns the DataFactory form of TableFunction
63
        // (deferring dataFunc invocation to init time with the projected column list supplied),
64
        // and step 5 omits the slot from DuckDB's parameter list entirely.
65

66
        public void RegisterTableFunction<T1, TData, TProjection>(
67
            string name,
68
            Func<T1, IEnumerable<TData>> dataFunc,
69
            Expression<Func<TData, TProjection>> projection)
70
        {
71
            var (columns, mapper) = ParseProjection(projection);
27✔
72
            var parameters = AnalyzeParameters(dataFunc.Method.GetParameters());
27✔
73

74
            if (parameters[0].IsProjection)
27✔
75
            {
76
                RegisterInternal(connection, name, parameters,
3✔
77
                    (positional, named) => new TableFunction(columns, projected =>
3✔
78
                        dataFunc((T1)(object)projected)),
6✔
79
                    mapper);
3✔
80
                return;
3✔
81
            }
82

83
            var read1 = CompileValueReader<T1>(parameters[0], name);
24✔
84

85
            RegisterInternal(connection, name, parameters,
24✔
86
                (positional, named) => new TableFunction(columns, dataFunc(
27✔
87
                    read1(Resolve(parameters[0], positional, named)))),
27✔
88
                mapper);
24✔
89
        }
24✔
90

91
        public void RegisterTableFunction<T1, T2, TData, TProjection>(
92
            string name,
93
            Func<T1, T2, IEnumerable<TData>> dataFunc,
94
            Expression<Func<TData, TProjection>> projection)
95
        {
96
            var (columns, mapper) = ParseProjection(projection);
27✔
97
            var parameters = AnalyzeParameters(dataFunc.Method.GetParameters());
27✔
98

99
            if (parameters[0].IsProjection)
21✔
100
            {
101
                var read2 = CompileValueReader<T2>(parameters[1], name);
9✔
102
                RegisterInternal(connection, name, parameters,
9✔
103
                    (positional, named) =>
9✔
104
                    {
9✔
105
                        var arg2 = read2(Resolve(parameters[1], positional, named));
15✔
106
                        return new TableFunction(columns, projected =>
15✔
107
                            dataFunc((T1)(object)projected, arg2));
30✔
108
                    },
9✔
109
                    mapper);
9✔
110
                return;
9✔
111
            }
112

113
            var readA = CompileValueReader<T1>(parameters[0], name);
12✔
114
            var readB = CompileValueReader<T2>(parameters[1], name);
12✔
115

116
            RegisterInternal(connection, name, parameters,
12✔
117
                (positional, named) => new TableFunction(columns, dataFunc(
18✔
118
                    readA(Resolve(parameters[0], positional, named)),
18✔
119
                    readB(Resolve(parameters[1], positional, named)))),
18✔
120
                mapper);
12✔
121
        }
12✔
122

123
        public void RegisterTableFunction<T1, T2, T3, TData, TProjection>(
124
            string name,
125
            Func<T1, T2, T3, IEnumerable<TData>> dataFunc,
126
            Expression<Func<TData, TProjection>> projection)
127
        {
128
            var (columns, mapper) = ParseProjection(projection);
9✔
129
            var parameters = AnalyzeParameters(dataFunc.Method.GetParameters());
9✔
130

131
            if (parameters[0].IsProjection)
9✔
132
            {
133
                var read2 = CompileValueReader<T2>(parameters[1], name);
3✔
134
                var read3 = CompileValueReader<T3>(parameters[2], name);
3✔
135
                RegisterInternal(connection, name, parameters,
3✔
136
                    (positional, named) =>
3✔
137
                    {
3✔
138
                        var arg2 = read2(Resolve(parameters[1], positional, named));
3✔
139
                        var arg3 = read3(Resolve(parameters[2], positional, named));
3✔
140
                        return new TableFunction(columns, projected =>
3✔
141
                            dataFunc((T1)(object)projected, arg2, arg3));
6✔
142
                    },
3✔
143
                    mapper);
3✔
144
                return;
3✔
145
            }
146

147
            var readA = CompileValueReader<T1>(parameters[0], name);
6✔
148
            var readB = CompileValueReader<T2>(parameters[1], name);
6✔
149
            var readC = CompileValueReader<T3>(parameters[2], name);
6✔
150

151
            RegisterInternal(connection, name, parameters,
6✔
152
                (positional, named) => new TableFunction(columns, dataFunc(
9✔
153
                    readA(Resolve(parameters[0], positional, named)),
9✔
154
                    readB(Resolve(parameters[1], positional, named)),
9✔
155
                    readC(Resolve(parameters[2], positional, named)))),
9✔
156
                mapper);
6✔
157
        }
6✔
158

159
        public void RegisterTableFunction<T1, T2, T3, T4, TData, TProjection>(
160
            string name,
161
            Func<T1, T2, T3, T4, IEnumerable<TData>> dataFunc,
162
            Expression<Func<TData, TProjection>> projection)
163
        {
164
            var (columns, mapper) = ParseProjection(projection);
3✔
165
            var parameters = AnalyzeParameters(dataFunc.Method.GetParameters());
3✔
166

167
            if (parameters[0].IsProjection)
3!
168
            {
NEW
169
                var read2 = CompileValueReader<T2>(parameters[1], name);
×
NEW
170
                var read3 = CompileValueReader<T3>(parameters[2], name);
×
NEW
171
                var read4 = CompileValueReader<T4>(parameters[3], name);
×
NEW
172
                RegisterInternal(connection, name, parameters,
×
NEW
173
                    (positional, named) =>
×
NEW
174
                    {
×
NEW
175
                        var arg2 = read2(Resolve(parameters[1], positional, named));
×
NEW
176
                        var arg3 = read3(Resolve(parameters[2], positional, named));
×
NEW
177
                        var arg4 = read4(Resolve(parameters[3], positional, named));
×
NEW
178
                        return new TableFunction(columns, projected =>
×
NEW
179
                            dataFunc((T1)(object)projected, arg2, arg3, arg4));
×
NEW
180
                    },
×
NEW
181
                    mapper);
×
NEW
182
                return;
×
183
            }
184

185
            var readA = CompileValueReader<T1>(parameters[0], name);
3✔
186
            var readB = CompileValueReader<T2>(parameters[1], name);
3✔
187
            var readC = CompileValueReader<T3>(parameters[2], name);
3✔
188
            var readD = CompileValueReader<T4>(parameters[3], name);
3✔
189

190
            RegisterInternal(connection, name, parameters,
3✔
191
                (positional, named) => new TableFunction(columns, dataFunc(
3✔
192
                    readA(Resolve(parameters[0], positional, named)),
3✔
193
                    readB(Resolve(parameters[1], positional, named)),
3✔
194
                    readC(Resolve(parameters[2], positional, named)),
3✔
195
                    readD(Resolve(parameters[3], positional, named)))),
3✔
196
                mapper);
3✔
197
        }
3✔
198
    }
199

200
    private static readonly MethodInfo GetValueMethod = typeof(IDuckDBValueReader).GetMethod(nameof(IDuckDBValueReader.GetValue))!;
3✔
201
    private static readonly MethodInfo WriteValueMethod = typeof(IDuckDBDataWriter).GetMethod(nameof(IDuckDBDataWriter.WriteValue))!;
3✔
202

203
    private static (ColumnInfo[] columns, Action<object?, IDuckDBDataWriter[], ulong> mapper) ParseProjection<TData, TProjection>(Expression<Func<TData, TProjection>> projection)
204
    {
205
        var (names, types, accessors) = projection.Body switch
72!
206
        {
72✔
207
            NewExpression newExpr => ParseNewExpression(newExpr),
69✔
208
            MemberInitExpression initExpr => ParseMemberInitExpression(initExpr),
3✔
209
            MemberExpression memberExpr => ([memberExpr.Member.Name], [memberExpr.Type], [memberExpr]),
×
210
            _ => throw new ArgumentException("Projection must be a new expression, object initializer, or single property access.")
×
211
        };
72✔
212

213
        var columns = new ColumnInfo[names.Length];
72✔
214
        for (int i = 0; i < names.Length; i++)
456✔
215
        {
216
            columns[i] = new ColumnInfo(names[i], types[i]);
156✔
217
        }
218

219
        var combinedWriter = CompileCombinedWriter<TData>(names.Length, types, accessors, projection.Parameters[0]);
72✔
220

221
        return (columns, Mapper);
72✔
222

223
        void Mapper(object? item, IDuckDBDataWriter[] writers, ulong rowIndex)
224
        {
225
            combinedWriter((TData)item!, writers, rowIndex);
15,177✔
226
        }
15,177✔
227
    }
228

229
    private static (string[] names, Type[] types, Expression[] accessors) ParseNewExpression(NewExpression newExpr)
230
    {
231
        var count = newExpr.Arguments.Count;
69✔
232
        var names = new string[count];
69✔
233
        var types = new Type[count];
69✔
234
        var accessors = new Expression[count];
69✔
235

236
        for (int i = 0; i < count; i++)
438✔
237
        {
238
            names[i] = newExpr.Members![i].Name;
150✔
239
            types[i] = newExpr.Arguments[i].Type;
150✔
240
            accessors[i] = newExpr.Arguments[i];
150✔
241
        }
242

243
        return (names, types, accessors);
69✔
244
    }
245

246
    private static (string[] names, Type[] types, Expression[] accessors) ParseMemberInitExpression(MemberInitExpression initExpr)
247
    {
248
        var count = initExpr.Bindings.Count;
3✔
249
        var names = new string[count];
3✔
250
        var types = new Type[count];
3✔
251
        var accessors = new Expression[count];
3✔
252

253
        for (int i = 0; i < count; i++)
18✔
254
        {
255
            var binding = (MemberAssignment)initExpr.Bindings[i];
6✔
256
            names[i] = binding.Member.Name;
6✔
257
            types[i] = binding.Expression.Type;
6✔
258
            accessors[i] = binding.Expression;
6✔
259
        }
260

261
        return (names, types, accessors);
3✔
262
    }
263

264
    private static Func<IDuckDBValueReader, T> CompileValueReader<T>(TableFunctionParameter param, string functionName)
265
    {
266
        var nullableUnderlyingType = Nullable.GetUnderlyingType(typeof(T));
93✔
267

268
        if (nullableUnderlyingType is not null)
93✔
269
        {
270
            var readNullable = CompileNullableReader<T>(nullableUnderlyingType);
12✔
271
            return reader => reader.IsNull() ? default! : readNullable(reader);
30✔
272
        }
273

274
        var errorContext = param.IsNamed
81✔
275
            ? $"named parameter '{param.NamedAs}'"
81✔
276
            : $"argument {param.PositionalIndex!.Value + 1}";
81✔
277

278
        return reader =>
81✔
279
        {
81✔
280
            if (reader.IsNull())
105✔
281
            {
81✔
282
                if (default(T) is null) return default!;
24✔
283
                throw new InvalidOperationException(
6✔
284
                    $"Table function '{functionName}' {errorContext} is NULL, "
6✔
285
                    + $"but parameter type '{typeof(T).Name}' is non-nullable.");
6✔
286
            }
81✔
287
            return reader.GetValue<T>();
90✔
288
        };
81✔
289
    }
290

291
    private static Func<IDuckDBValueReader, T> CompileNullableReader<T>(Type underlyingType)
292
    {
293
        var readerParam = Expression.Parameter(typeof(IDuckDBValueReader), "reader");
12✔
294

295
        var getValue = Expression.Call(readerParam, GetValueMethod.MakeGenericMethod(underlyingType));
12✔
296
        var convert = Expression.Convert(getValue, typeof(T));
12✔
297

298
        return Expression.Lambda<Func<IDuckDBValueReader, T>>(convert, readerParam).Compile();
12✔
299
    }
300

301
    private static IDuckDBValueReader Resolve(TableFunctionParameter param, IReadOnlyList<IDuckDBValueReader> positional, IReadOnlyDictionary<string, IDuckDBValueReader> named)
302
        => param.IsNamed ? named[param.NamedAs!] : positional[param.PositionalIndex!.Value];
123✔
303

304
    private static void RegisterInternal(DuckDBConnection connection, string name, TableFunctionParameter[] parameters,
305
                                         Func<IReadOnlyList<IDuckDBValueReader>, IReadOnlyDictionary<string, IDuckDBValueReader>, TableFunction> bind,
306
                                         Action<object?, IDuckDBDataWriter[], ulong> mapper)
307
    {
308
        var positionalTypes = new List<DuckDBLogicalType>();
60✔
309
        var namedDefinitions = new List<NamedParameterDefinition>();
60✔
310

311
        foreach (var param in parameters)
336✔
312
        {
313
            if (param.IsProjection)
108✔
314
                continue;
315
            if (param.IsNamed)
93✔
316
                namedDefinitions.Add(new(param.NamedAs!, param.Type));
18✔
317
            else
318
                positionalTypes.Add(param.Type.GetLogicalType());
75✔
319
        }
320

321
        connection.RegisterTableFunctionInternal(name, bind, mapper, positionalTypes.ToArray(), namedDefinitions.ToArray());
60✔
322
    }
60✔
323

324
    private static TableFunctionParameter[] AnalyzeParameters(ParameterInfo[] methodParams)
325
    {
326
        var result = new TableFunctionParameter[methodParams.Length];
66✔
327
        int nextPositional = 0;
66✔
328

329
        for (int parameterIndex = 0; parameterIndex < methodParams.Length; parameterIndex++)
354✔
330
        {
331
            if (IsProjectionParameter(methodParams[parameterIndex].ParameterType))
117✔
332
            {
333
                if (parameterIndex != 0)
21✔
334
                {
335
                    throw new InvalidOperationException("ProjectedColumn parameter must be the first parameter of the data callback.");
3✔
336
                }
337
                
338
                if (methodParams[parameterIndex].GetCustomAttribute<NamedAttribute>() is not null)
18✔
339
                {
340
                    throw new InvalidOperationException("[Named] cannot be applied to a ProjectedColumn parameter.");
3✔
341
                }
342
                result[0] = new TableFunctionParameter(null, null, methodParams[0].ParameterType, IsProjection: true);
15✔
343
                continue;
15✔
344
            }
345

346
            var attr = methodParams[parameterIndex].GetCustomAttribute<NamedAttribute>();
96✔
347
            result[parameterIndex] = attr is not null
96✔
348
                ? new(attr.Name ?? methodParams[parameterIndex].Name!, null, methodParams[parameterIndex].ParameterType)
96✔
349
                : new(null, nextPositional++, methodParams[parameterIndex].ParameterType);
96✔
350
        }
351

352
        return result;
60✔
353
    }
354

355
    private static Action<TData, IDuckDBDataWriter[], ulong> CompileCombinedWriter<TData>(int columnCount, Type[] types, Expression[] accessors, ParameterExpression originalParam)
356
    {
357
        var dataParam = Expression.Parameter(typeof(TData), "data");
72✔
358
        var writersParam = Expression.Parameter(typeof(IDuckDBDataWriter[]), "writers");
72✔
359
        var rowIndexParam = Expression.Parameter(typeof(ulong), "rowIndex");
72✔
360

361
        var replacer = new ParameterReplacer(originalParam, dataParam);
72✔
362
        var writeCalls = new Expression[columnCount];
72✔
363

364
        for (int i = 0; i < columnCount; i++)
456✔
365
        {
366
            var reboundAccessor = replacer.Visit(accessors[i]);
156✔
367
            var writerAccess = Expression.ArrayIndex(writersParam, Expression.Constant(i));
156✔
368

369
            writeCalls[i] = Expression.Call(
156✔
370
                writerAccess,
156✔
371
                WriteValueMethod.MakeGenericMethod(types[i]),
156✔
372
                reboundAccessor,
156✔
373
                rowIndexParam);
156✔
374
        }
375

376
        var body = Expression.Block(writeCalls);
72✔
377
        return Expression.Lambda<Action<TData, IDuckDBDataWriter[], ulong>>(body, dataParam, writersParam, rowIndexParam).Compile();
72✔
378
    }
379

380
    // Rebinds parameter references when embedding sub-expressions from the user's projection lambda
381
    // into a new lambda with a different parameter. E.g. given (Employee e) => new { e.Id }, the
382
    // accessor "e.Id" references the original parameter "e". When we compile the combined writer
383
    // (data, writers, rowIndex) => { writers[0].WriteValue(data.Id, rowIndex); ... }, we must
384
    // replace "e" with "data".
385
    private sealed class ParameterReplacer(ParameterExpression oldParam, ParameterExpression newParam) : ExpressionVisitor
72✔
386
    {
387
        protected override Expression VisitParameter(ParameterExpression node)
388
            => node == oldParam ? newParam : base.VisitParameter(node);
159!
389
    }
390
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc