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

Giorgi / DuckDB.NET / 22500794591

27 Feb 2026 07:31PM UTC coverage: 89.868% (+0.2%) from 89.658%
22500794591

push

github

Giorgi
Add `object` type support for simplified scalar functions

Extract `ReadValue<T>` helper that dispatches to non-generic
`GetValue` when T is `object`, enabling dynamic type resolution
at runtime.

1267 of 1467 branches covered (86.37%)

Branch coverage included in aggregate %.

5 of 7 new or added lines in 1 file covered. (71.43%)

9 existing lines in 3 files now uncovered.

2609 of 2846 relevant lines covered (91.67%)

462743.29 hits per line

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

96.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)
591✔
11
    {
12
        public bool IsNamed => NamedAs is not null;
249✔
13
    }
14

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

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

59
        public void RegisterTableFunction<T1, TData, TProjection>(
60
            string name,
61
            Func<T1, IEnumerable<TData>> dataFunc,
62
            Expression<Func<TData, TProjection>> projection)
63
        {
64
            var (columns, mapper) = ParseProjection(projection);
24✔
65
            var parameters = AnalyzeParameters(dataFunc.Method.GetParameters());
24✔
66
            var read1 = CompileValueReader<T1>(parameters[0], name);
24✔
67

68
            RegisterInternal(connection, name, parameters,
24✔
69
                (positional, named) => new TableFunction(columns, dataFunc(
27✔
70
                    read1(Resolve(parameters[0], positional, named)))),
27✔
71
                mapper);
24✔
72
        }
24✔
73

74
        public void RegisterTableFunction<T1, T2, TData, TProjection>(
75
            string name,
76
            Func<T1, T2, IEnumerable<TData>> dataFunc,
77
            Expression<Func<TData, TProjection>> projection)
78
        {
79
            var (columns, mapper) = ParseProjection(projection);
12✔
80
            var parameters = AnalyzeParameters(dataFunc.Method.GetParameters());
12✔
81
            
82
            var read1 = CompileValueReader<T1>(parameters[0], name);
12✔
83
            var read2 = CompileValueReader<T2>(parameters[1], name);
12✔
84

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

92
        public void RegisterTableFunction<T1, T2, T3, TData, TProjection>(
93
            string name,
94
            Func<T1, T2, T3, IEnumerable<TData>> dataFunc,
95
            Expression<Func<TData, TProjection>> projection)
96
        {
97
            var (columns, mapper) = ParseProjection(projection);
6✔
98
            var parameters = AnalyzeParameters(dataFunc.Method.GetParameters());
6✔
99
            
100
            var read1 = CompileValueReader<T1>(parameters[0], name);
6✔
101
            var read2 = CompileValueReader<T2>(parameters[1], name);
6✔
102
            var read3 = CompileValueReader<T3>(parameters[2], name);
6✔
103

104
            RegisterInternal(connection, name, parameters,
6✔
105
                (positional, named) => new TableFunction(columns, dataFunc(
9✔
106
                    read1(Resolve(parameters[0], positional, named)),
9✔
107
                    read2(Resolve(parameters[1], positional, named)),
9✔
108
                    read3(Resolve(parameters[2], positional, named)))),
9✔
109
                mapper);
6✔
110
        }
6✔
111

112
        public void RegisterTableFunction<T1, T2, T3, T4, TData, TProjection>(
113
            string name,
114
            Func<T1, T2, T3, T4, IEnumerable<TData>> dataFunc,
115
            Expression<Func<TData, TProjection>> projection)
116
        {
117
            var (columns, mapper) = ParseProjection(projection);
3✔
118
            var parameters = AnalyzeParameters(dataFunc.Method.GetParameters());
3✔
119
            
120
            var read1 = CompileValueReader<T1>(parameters[0], name);
3✔
121
            var read2 = CompileValueReader<T2>(parameters[1], name);
3✔
122
            var read3 = CompileValueReader<T3>(parameters[2], name);
3✔
123
            var read4 = CompileValueReader<T4>(parameters[3], name);
3✔
124

125
            RegisterInternal(connection, name, parameters,
3✔
126
                (positional, named) => new TableFunction(columns, dataFunc(
3✔
127
                    read1(Resolve(parameters[0], positional, named)),
3✔
128
                    read2(Resolve(parameters[1], positional, named)),
3✔
129
                    read3(Resolve(parameters[2], positional, named)),
3✔
130
                    read4(Resolve(parameters[3], positional, named)))),
3✔
131
                mapper);
3✔
132
        }
3✔
133
    }
134

135
    private static readonly MethodInfo GetValueMethod = typeof(IDuckDBValueReader).GetMethod(nameof(IDuckDBValueReader.GetValue))!;
3✔
136
    private static readonly MethodInfo WriteValueMethod = typeof(IDuckDBDataWriter).GetMethod(nameof(IDuckDBDataWriter.WriteValue))!;
3✔
137

138
    private static (ColumnInfo[] columns, Action<object?, IDuckDBDataWriter[], ulong> mapper) ParseProjection<TData, TProjection>(Expression<Func<TData, TProjection>> projection)
139
    {
140
        var (names, types, accessors) = projection.Body switch
51!
141
        {
51✔
142
            NewExpression newExpr => ParseNewExpression(newExpr),
48✔
143
            MemberInitExpression initExpr => ParseMemberInitExpression(initExpr),
3✔
UNCOV
144
            MemberExpression memberExpr => ([memberExpr.Member.Name], [memberExpr.Type], [memberExpr]),
×
UNCOV
145
            _ => throw new ArgumentException("Projection must be a new expression, object initializer, or single property access.")
×
146
        };
51✔
147

148
        var columns = new ColumnInfo[names.Length];
51✔
149
        for (int i = 0; i < names.Length; i++)
306✔
150
        {
151
            columns[i] = new ColumnInfo(names[i], types[i]);
102✔
152
        }
153

154
        var combinedWriter = CompileCombinedWriter<TData>(names.Length, types, accessors, projection.Parameters[0]);
51✔
155

156
        return (columns, Mapper);
51✔
157

158
        void Mapper(object? item, IDuckDBDataWriter[] writers, ulong rowIndex)
159
        {
160
            combinedWriter((TData)item!, writers, rowIndex);
15,120✔
161
        }
15,120✔
162
    }
163

164
    private static (string[] names, Type[] types, Expression[] accessors) ParseNewExpression(NewExpression newExpr)
165
    {
166
        var count = newExpr.Arguments.Count;
48✔
167
        var names = new string[count];
48✔
168
        var types = new Type[count];
48✔
169
        var accessors = new Expression[count];
48✔
170

171
        for (int i = 0; i < count; i++)
288✔
172
        {
173
            names[i] = newExpr.Members![i].Name;
96✔
174
            types[i] = newExpr.Arguments[i].Type;
96✔
175
            accessors[i] = newExpr.Arguments[i];
96✔
176
        }
177

178
        return (names, types, accessors);
48✔
179
    }
180

181
    private static (string[] names, Type[] types, Expression[] accessors) ParseMemberInitExpression(MemberInitExpression initExpr)
182
    {
183
        var count = initExpr.Bindings.Count;
3✔
184
        var names = new string[count];
3✔
185
        var types = new Type[count];
3✔
186
        var accessors = new Expression[count];
3✔
187

188
        for (int i = 0; i < count; i++)
18✔
189
        {
190
            var binding = (MemberAssignment)initExpr.Bindings[i];
6✔
191
            names[i] = binding.Member.Name;
6✔
192
            types[i] = binding.Expression.Type;
6✔
193
            accessors[i] = binding.Expression;
6✔
194
        }
195

196
        return (names, types, accessors);
3✔
197
    }
198

199
    private static Func<IDuckDBValueReader, T> CompileValueReader<T>(TableFunctionParameter param, string functionName)
200
    {
201
        var nullableUnderlyingType = Nullable.GetUnderlyingType(typeof(T));
78✔
202

203
        if (nullableUnderlyingType is not null)
78✔
204
        {
205
            var readNullable = CompileNullableReader<T>(nullableUnderlyingType);
9✔
206
            return reader => reader.IsNull() ? default! : readNullable(reader);
24✔
207
        }
208

209
        var errorContext = param.IsNamed
69✔
210
            ? $"named parameter '{param.NamedAs}'"
69✔
211
            : $"argument {param.PositionalIndex!.Value + 1}";
69✔
212

213
        return reader =>
69✔
214
        {
69✔
215
            if (reader.IsNull())
87✔
216
            {
69✔
217
                if (default(T) is null) return default!;
24✔
218
                throw new InvalidOperationException(
6✔
219
                    $"Table function '{functionName}' {errorContext} is NULL, "
6✔
220
                    + $"but parameter type '{typeof(T).Name}' is non-nullable.");
6✔
221
            }
69✔
222
            return reader.GetValue<T>();
72✔
223
        };
69✔
224
    }
225

226
    private static Func<IDuckDBValueReader, T> CompileNullableReader<T>(Type underlyingType)
227
    {
228
        var readerParam = Expression.Parameter(typeof(IDuckDBValueReader), "reader");
9✔
229

230
        var getValue = Expression.Call(readerParam, GetValueMethod.MakeGenericMethod(underlyingType));
9✔
231
        var convert = Expression.Convert(getValue, typeof(T));
9✔
232

233
        return Expression.Lambda<Func<IDuckDBValueReader, T>>(convert, readerParam).Compile();
9✔
234
    }
235

236
    private static IDuckDBValueReader Resolve(TableFunctionParameter param, IReadOnlyList<IDuckDBValueReader> positional, IReadOnlyDictionary<string, IDuckDBValueReader> named)
237
        => param.IsNamed ? named[param.NamedAs!] : positional[param.PositionalIndex!.Value];
102✔
238

239
    private static void RegisterInternal(DuckDBConnection connection, string name, TableFunctionParameter[] parameters,
240
                                         Func<IReadOnlyList<IDuckDBValueReader>, IReadOnlyDictionary<string, IDuckDBValueReader>, TableFunction> bind,
241
                                         Action<object?, IDuckDBDataWriter[], ulong> mapper)
242
    {
243
        var positionalTypes = new List<DuckDBLogicalType>();
45✔
244
        var namedDefinitions = new List<NamedParameterDefinition>();
45✔
245

246
        foreach (var param in parameters)
246✔
247
        {
248
            if (param.IsNamed)
78✔
249
                namedDefinitions.Add(new(param.NamedAs!, param.Type));
15✔
250
            else
251
                positionalTypes.Add(param.Type.GetLogicalType());
63✔
252
        }
253

254
        connection.RegisterTableFunctionInternal(name, bind, mapper, positionalTypes.ToArray(), namedDefinitions.ToArray());
45✔
255
    }
45✔
256

257
    private static TableFunctionParameter[] AnalyzeParameters(ParameterInfo[] methodParams)
258
    {
259
        var result = new TableFunctionParameter[methodParams.Length];
45✔
260
        int nextPositional = 0;
45✔
261

262
        for (int i = 0; i < methodParams.Length; i++)
246✔
263
        {
264
            var attr = methodParams[i].GetCustomAttribute<NamedAttribute>();
78✔
265
            result[i] = attr is not null
78✔
266
                ? new(attr.Name ?? methodParams[i].Name!, null, methodParams[i].ParameterType)
78✔
267
                : new(null, nextPositional++, methodParams[i].ParameterType);
78✔
268
        }
269

270
        return result;
45✔
271
    }
272

273
    private static Action<TData, IDuckDBDataWriter[], ulong> CompileCombinedWriter<TData>(int columnCount, Type[] types, Expression[] accessors, ParameterExpression originalParam)
274
    {
275
        var dataParam = Expression.Parameter(typeof(TData), "data");
51✔
276
        var writersParam = Expression.Parameter(typeof(IDuckDBDataWriter[]), "writers");
51✔
277
        var rowIndexParam = Expression.Parameter(typeof(ulong), "rowIndex");
51✔
278

279
        var replacer = new ParameterReplacer(originalParam, dataParam);
51✔
280
        var writeCalls = new Expression[columnCount];
51✔
281

282
        for (int i = 0; i < columnCount; i++)
306✔
283
        {
284
            var reboundAccessor = replacer.Visit(accessors[i]);
102✔
285
            var writerAccess = Expression.ArrayIndex(writersParam, Expression.Constant(i));
102✔
286

287
            writeCalls[i] = Expression.Call(
102✔
288
                writerAccess,
102✔
289
                WriteValueMethod.MakeGenericMethod(types[i]),
102✔
290
                reboundAccessor,
102✔
291
                rowIndexParam);
102✔
292
        }
293

294
        var body = Expression.Block(writeCalls);
51✔
295
        return Expression.Lambda<Action<TData, IDuckDBDataWriter[], ulong>>(body, dataParam, writersParam, rowIndexParam).Compile();
51✔
296
    }
297

298
    // Rebinds parameter references when embedding sub-expressions from the user's projection lambda
299
    // into a new lambda with a different parameter. E.g. given (Employee e) => new { e.Id }, the
300
    // accessor "e.Id" references the original parameter "e". When we compile the combined writer
301
    // (data, writers, rowIndex) => { writers[0].WriteValue(data.Id, rowIndex); ... }, we must
302
    // replace "e" with "data".
303
    private sealed class ParameterReplacer(ParameterExpression oldParam, ParameterExpression newParam) : ExpressionVisitor
51✔
304
    {
305
        protected override Expression VisitParameter(ParameterExpression node)
306
            => node == oldParam ? newParam : base.VisitParameter(node);
105!
307
    }
308
}
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