@an5/orm 1.3.5 → 1.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -723,6 +723,14 @@ namespace An5Orm
723
723
  return FindFirst("Id = @id", new Dictionary<string, object> { { "id", id } });
724
724
  }
725
725
 
726
+ private static bool IsScalarColumn(PropertyInfo prop)
727
+ {
728
+ var t = Nullable.GetUnderlyingType(prop.PropertyType) ?? prop.PropertyType;
729
+ if (t.IsPrimitive || t.IsEnum || t.IsValueType) return true;
730
+ if (t == typeof(string) || t == typeof(byte[]) || t == typeof(float[]) || t == typeof(double[])) return true;
731
+ return false;
732
+ }
733
+
726
734
  public T Create(T entity)
727
735
  {
728
736
  var properties = typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance);
@@ -734,6 +742,7 @@ namespace An5Orm
734
742
 
735
743
  foreach (var prop in properties)
736
744
  {
745
+ if (!IsScalarColumn(prop)) continue;
737
746
  var val = prop.GetValue(entity);
738
747
  if (val != null)
739
748
  {
@@ -775,6 +784,7 @@ namespace An5Orm
775
784
 
776
785
  foreach (var prop in properties)
777
786
  {
787
+ if (!IsScalarColumn(prop)) continue;
778
788
  var val = prop.GetValue(entity);
779
789
  if (prop.Name.Equals("Id", StringComparison.OrdinalIgnoreCase))
780
790
  {
@@ -1053,6 +1063,98 @@ namespace An5Orm
1053
1063
  return output;
1054
1064
  }
1055
1065
 
1066
+ public Dictionary<string, object> Aggregate(
1067
+ string whereClause = null,
1068
+ Dictionary<string, object> parameters = null,
1069
+ bool count = false,
1070
+ List<string> sum = null,
1071
+ List<string> avg = null,
1072
+ List<string> min = null,
1073
+ List<string> max = null)
1074
+ {
1075
+ var selectParts = new List<string>();
1076
+ if (count) selectParts.Add("COUNT(*) AS _count");
1077
+ if (sum != null) foreach (var col in sum) selectParts.Add($"SUM({An5Provider.Quote(col, Dialect)}) AS _sum_{col}");
1078
+ if (avg != null) foreach (var col in avg) selectParts.Add($"AVG({An5Provider.Quote(col, Dialect)}) AS _avg_{col}");
1079
+ if (min != null) foreach (var col in min) selectParts.Add($"MIN({An5Provider.Quote(col, Dialect)}) AS _min_{col}");
1080
+ if (max != null) foreach (var col in max) selectParts.Add($"MAX({An5Provider.Quote(col, Dialect)}) AS _max_{col}");
1081
+ if (selectParts.Count == 0) selectParts.Add("COUNT(*) AS _count");
1082
+
1083
+ var query = $"SELECT {string.Join(", ", selectParts)} FROM {TableName}";
1084
+ if (!string.IsNullOrEmpty(whereClause)) query += $" WHERE {whereClause}";
1085
+
1086
+ var conn = An5DbContext.GetActiveConnection(ConnectionString, out bool isTx);
1087
+ try
1088
+ {
1089
+ using var cmd = CreateCommand(conn, query);
1090
+ if (parameters != null)
1091
+ {
1092
+ foreach (var kvp in parameters) An5Provider.Bind(cmd, kvp.Key, kvp.Value);
1093
+ }
1094
+ using var reader = cmd.ExecuteReader();
1095
+ var result = new Dictionary<string, object>();
1096
+ if (reader.Read())
1097
+ {
1098
+ for (int i = 0; i < reader.FieldCount; i++)
1099
+ {
1100
+ var name = reader.GetName(i);
1101
+ var val = reader[i];
1102
+ result[name] = val != DBNull.Value ? val : null;
1103
+ }
1104
+ }
1105
+ return result;
1106
+ }
1107
+ finally { if (!isTx) conn.Dispose(); }
1108
+ }
1109
+
1110
+ public List<Dictionary<string, object>> GroupBy(
1111
+ List<string> by,
1112
+ string whereClause = null,
1113
+ Dictionary<string, object> parameters = null,
1114
+ List<string> sum = null,
1115
+ List<string> avg = null,
1116
+ List<string> min = null,
1117
+ List<string> max = null)
1118
+ {
1119
+ if (by == null || by.Count == 0) throw new ArgumentException("GroupBy requires at least one column in 'by'");
1120
+ var byCols = by.Select(c => An5Provider.Quote(c, Dialect)).ToList();
1121
+ var selectParts = new List<string>(byCols);
1122
+ selectParts.Add("COUNT(*) AS _count");
1123
+ if (sum != null) foreach (var col in sum) selectParts.Add($"SUM({An5Provider.Quote(col, Dialect)}) AS _sum_{col}");
1124
+ if (avg != null) foreach (var col in avg) selectParts.Add($"AVG({An5Provider.Quote(col, Dialect)}) AS _avg_{col}");
1125
+ if (min != null) foreach (var col in min) selectParts.Add($"MIN({An5Provider.Quote(col, Dialect)}) AS _min_{col}");
1126
+ if (max != null) foreach (var col in max) selectParts.Add($"MAX({An5Provider.Quote(col, Dialect)}) AS _max_{col}");
1127
+
1128
+ var query = $"SELECT {string.Join(", ", selectParts)} FROM {TableName}";
1129
+ if (!string.IsNullOrEmpty(whereClause)) query += $" WHERE {whereClause}";
1130
+ query += $" GROUP BY {string.Join(", ", byCols)}";
1131
+
1132
+ var conn = An5DbContext.GetActiveConnection(ConnectionString, out bool isTx);
1133
+ try
1134
+ {
1135
+ using var cmd = CreateCommand(conn, query);
1136
+ if (parameters != null)
1137
+ {
1138
+ foreach (var kvp in parameters) An5Provider.Bind(cmd, kvp.Key, kvp.Value);
1139
+ }
1140
+ using var reader = cmd.ExecuteReader();
1141
+ var rows = new List<Dictionary<string, object>>();
1142
+ while (reader.Read())
1143
+ {
1144
+ var row = new Dictionary<string, object>();
1145
+ for (int i = 0; i < reader.FieldCount; i++)
1146
+ {
1147
+ var name = reader.GetName(i);
1148
+ var val = reader[i];
1149
+ row[name] = val != DBNull.Value ? val : null;
1150
+ }
1151
+ rows.Add(row);
1152
+ }
1153
+ return rows;
1154
+ }
1155
+ finally { if (!isTx) conn.Dispose(); }
1156
+ }
1157
+
1056
1158
  private static List<double> ParseDoubleArray(string json)
1057
1159
  {
1058
1160
  var clean = json.Trim('[', ']');
@@ -1253,6 +1355,26 @@ namespace An5Orm
1253
1355
 
1254
1356
  public List<T> VectorSearch(List<double> vector, int take = 10, string whereClause = null, Dictionary<string, object> parameters = null, string vectorField = "Embedding", string distanceMetric = "cosine")
1255
1357
  => _table.VectorSearch(vector, take, whereClause, parameters, vectorField, distanceMetric);
1358
+
1359
+ public Dictionary<string, object> Aggregate(
1360
+ string whereClause = null,
1361
+ Dictionary<string, object> parameters = null,
1362
+ bool count = false,
1363
+ List<string> sum = null,
1364
+ List<string> avg = null,
1365
+ List<string> min = null,
1366
+ List<string> max = null)
1367
+ => _table.Aggregate(whereClause, parameters, count, sum, avg, min, max);
1368
+
1369
+ public List<Dictionary<string, object>> GroupBy(
1370
+ List<string> by,
1371
+ string whereClause = null,
1372
+ Dictionary<string, object> parameters = null,
1373
+ List<string> sum = null,
1374
+ List<string> avg = null,
1375
+ List<string> min = null,
1376
+ List<string> max = null)
1377
+ => _table.GroupBy(by, whereClause, parameters, sum, avg, min, max);
1256
1378
  }
1257
1379
  }
1258
1380
  `;
@@ -266,14 +266,34 @@ type BoolFilter struct {
266
266
 
267
267
  // DateTimeFilter provides type-safe datetime field filtering options.
268
268
  type DateTimeFilter struct {
269
- \tEquals *time.Time
270
- \tNot *time.Time
271
- \tIn []time.Time
272
- \tNotIn []time.Time
273
- \tGt *time.Time
274
- \tGte *time.Time
275
- \tLt *time.Time
276
- \tLte *time.Time
269
+ Equals *time.Time
270
+ Not *time.Time
271
+ In []time.Time
272
+ NotIn []time.Time
273
+ Gt *time.Time
274
+ Gte *time.Time
275
+ Lt *time.Time
276
+ Lte *time.Time
277
+ }
278
+
279
+ // AggregateArgs configures an aggregate query.
280
+ type AggregateArgs struct {
281
+ Where interface{}
282
+ Count bool
283
+ Sum []string
284
+ Avg []string
285
+ Min []string
286
+ Max []string
287
+ }
288
+
289
+ // GroupByArgs configures a GroupBy query.
290
+ type GroupByArgs struct {
291
+ By []string
292
+ Where interface{}
293
+ Sum []string
294
+ Avg []string
295
+ Min []string
296
+ Max []string
277
297
  }
278
298
 
279
299
  // Dialect identifies the database engine in use.
@@ -442,6 +462,16 @@ func (c *ViewClient[T]) VectorSearch(ctx context.Context, vector []float64, opts
442
462
  \treturn c.table.VectorSearch(ctx, vector, opts...)
443
463
  }
444
464
 
465
+ // Aggregate returns aggregate calculations on the view.
466
+ func (c *ViewClient[T]) Aggregate(ctx context.Context, args AggregateArgs) (map[string]interface{}, error) {
467
+ \treturn c.table.Aggregate(ctx, args)
468
+ }
469
+
470
+ // GroupBy groups view rows by specified columns and computes aggregates.
471
+ func (c *ViewClient[T]) GroupBy(ctx context.Context, args GroupByArgs) ([]map[string]interface{}, error) {
472
+ \treturn c.table.GroupBy(ctx, args)
473
+ }
474
+
445
475
  `;
446
476
  }
447
477
  // ─── queryAndScan ──────────────────────────────────────────────────────────
@@ -953,7 +983,7 @@ func (c *TableClient[T]) UpdateMany(ctx context.Context, whereArgs interface{},
953
983
  \t\tsets = append(sets, c.quoteName(col)+" = "+c.ph(len(execArgs)))
954
984
  \t}
955
985
 
956
- \tclause, _, _, _, _, whereQueryArgs := c.parseArgs(whereArgs)
986
+ \tclause, _, _, _, _, whereQueryArgs := c.parseArgsWithOffset(whereArgs, len(execArgs)+1)
957
987
  \texecArgs = append(execArgs, whereQueryArgs...)
958
988
  \tquery := fmt.Sprintf("UPDATE %s SET %s", c.quoteTable(), strings.Join(sets, ", "))
959
989
  \tif clause != "" {
@@ -1038,9 +1068,129 @@ func (c *TableClient[T]) Transaction(ctx context.Context, fn func(tx *sql.Tx) er
1038
1068
  \treturn tx.Commit()
1039
1069
  }
1040
1070
 
1071
+ // Aggregate returns aggregate calculations (count, sum, avg, min, max).
1072
+ func (c *TableClient[T]) Aggregate(ctx context.Context, args AggregateArgs) (map[string]interface{}, error) {
1073
+ \tclause, _, _, _, _, queryArgs := c.parseArgs(args.Where)
1074
+ \tvar selectParts []string
1075
+ \tif args.Count || (len(args.Sum) == 0 && len(args.Avg) == 0 && len(args.Min) == 0 && len(args.Max) == 0) {
1076
+ \t\tselectParts = append(selectParts, "COUNT(*) AS _count")
1077
+ \t}
1078
+ \tfor _, col := range args.Sum {
1079
+ \t\tselectParts = append(selectParts, fmt.Sprintf("SUM(%s) AS _sum_%s", c.quoteName(col), col))
1080
+ \t}
1081
+ \tfor _, col := range args.Avg {
1082
+ \t\tselectParts = append(selectParts, fmt.Sprintf("AVG(%s) AS _avg_%s", c.quoteName(col), col))
1083
+ \t}
1084
+ \tfor _, col := range args.Min {
1085
+ \t\tselectParts = append(selectParts, fmt.Sprintf("MIN(%s) AS _min_%s", c.quoteName(col), col))
1086
+ \t}
1087
+ \tfor _, col := range args.Max {
1088
+ \t\tselectParts = append(selectParts, fmt.Sprintf("MAX(%s) AS _max_%s", c.quoteName(col), col))
1089
+ \t}
1090
+
1091
+ \tquery := fmt.Sprintf("SELECT %s FROM %s", strings.Join(selectParts, ", "), c.quoteTable())
1092
+ \tif clause != "" {
1093
+ \t\tquery += " WHERE " + clause
1094
+ \t}
1095
+
1096
+ \trows, err := c.DB.QueryContext(ctx, query, queryArgs...)
1097
+ \tif err != nil {
1098
+ \t\treturn nil, err
1099
+ \t}
1100
+ \tdefer rows.Close()
1101
+
1102
+ \tcols, err := rows.Columns()
1103
+ \tif err != nil {
1104
+ \t\treturn nil, err
1105
+ \t}
1106
+
1107
+ \tres := make(map[string]interface{})
1108
+ \tif rows.Next() {
1109
+ \t\tcolVals := make([]interface{}, len(cols))
1110
+ \t\tcolPtrs := make([]interface{}, len(cols))
1111
+ \t\tfor i := range colVals {
1112
+ \t\t\tcolPtrs[i] = &colVals[i]
1113
+ \t\t}
1114
+ \t\tif err := rows.Scan(colPtrs...); err != nil {
1115
+ \t\t\treturn nil, err
1116
+ \t\t}
1117
+ \t\tfor i, name := range cols {
1118
+ \t\t\tres[name] = colVals[i]
1119
+ \t\t}
1120
+ \t}
1121
+ \treturn res, nil
1122
+ }
1123
+
1124
+ // GroupBy groups records by specified columns and computes aggregates.
1125
+ func (c *TableClient[T]) GroupBy(ctx context.Context, args GroupByArgs) ([]map[string]interface{}, error) {
1126
+ \tif len(args.By) == 0 {
1127
+ \t\treturn nil, fmt.Errorf("GroupBy requires at least one column in 'By'")
1128
+ \t}
1129
+ \tclause, _, _, _, _, queryArgs := c.parseArgs(args.Where)
1130
+ \tbyCols := make([]string, len(args.By))
1131
+ \tfor i, col := range args.By {
1132
+ \t\tbyCols[i] = c.quoteName(col)
1133
+ \t}
1134
+
1135
+ \tselectParts := make([]string, len(byCols))
1136
+ \tcopy(selectParts, byCols)
1137
+ \tselectParts = append(selectParts, "COUNT(*) AS _count")
1138
+ \tfor _, col := range args.Sum {
1139
+ \t\tselectParts = append(selectParts, fmt.Sprintf("SUM(%s) AS _sum_%s", c.quoteName(col), col))
1140
+ \t}
1141
+ \tfor _, col := range args.Avg {
1142
+ \t\tselectParts = append(selectParts, fmt.Sprintf("AVG(%s) AS _avg_%s", c.quoteName(col), col))
1143
+ \t}
1144
+ \tfor _, col := range args.Min {
1145
+ \t\tselectParts = append(selectParts, fmt.Sprintf("MIN(%s) AS _min_%s", c.quoteName(col), col))
1146
+ \t}
1147
+ \tfor _, col := range args.Max {
1148
+ \t\tselectParts = append(selectParts, fmt.Sprintf("MAX(%s) AS _max_%s", c.quoteName(col), col))
1149
+ \t}
1150
+
1151
+ \tquery := fmt.Sprintf("SELECT %s FROM %s", strings.Join(selectParts, ", "), c.quoteTable())
1152
+ \tif clause != "" {
1153
+ \t\tquery += " WHERE " + clause
1154
+ \t}
1155
+ \tquery += " GROUP BY " + strings.Join(byCols, ", ")
1156
+
1157
+ \trows, err := c.DB.QueryContext(ctx, query, queryArgs...)
1158
+ \tif err != nil {
1159
+ \t\treturn nil, err
1160
+ \t}
1161
+ \tdefer rows.Close()
1162
+
1163
+ \tcols, err := rows.Columns()
1164
+ \tif err != nil {
1165
+ \t\treturn nil, err
1166
+ \t}
1167
+
1168
+ \tvar results []map[string]interface{}
1169
+ \tfor rows.Next() {
1170
+ \t\tcolVals := make([]interface{}, len(cols))
1171
+ \t\tcolPtrs := make([]interface{}, len(cols))
1172
+ \t\tfor i := range colVals {
1173
+ \t\t\tcolPtrs[i] = &colVals[i]
1174
+ \t\t}
1175
+ \t\tif err := rows.Scan(colPtrs...); err != nil {
1176
+ \t\t\treturn nil, err
1177
+ \t\t}
1178
+ \t\trowMap := make(map[string]interface{})
1179
+ \t\tfor i, name := range cols {
1180
+ \t\t\trowMap[name] = colVals[i]
1181
+ \t\t}
1182
+ \t\tresults = append(results, rowMap)
1183
+ \t}
1184
+ \treturn results, nil
1185
+ }
1186
+
1041
1187
  // parseArgs is the generic where/orderBy/take/skip extractor.
1042
1188
  // It uses reflection to read WhereClause, OrderBy, Take, Skip, Select from any args struct.
1043
1189
  func (c *TableClient[T]) parseArgs(args interface{}) (whereClause string, orderBy string, take int, skip int, selectCols []string, queryArgs []interface{}) {
1190
+ \treturn c.parseArgsWithOffset(args, 1)
1191
+ }
1192
+
1193
+ func (c *TableClient[T]) parseArgsWithOffset(args interface{}, offset int) (whereClause string, orderBy string, take int, skip int, selectCols []string, queryArgs []interface{}) {
1044
1194
  \tif args == nil {
1045
1195
  \t\treturn
1046
1196
  \t}
@@ -1057,10 +1207,18 @@ func (c *TableClient[T]) parseArgs(args interface{}) (whereClause string, orderB
1057
1207
 
1058
1208
  \t// Where
1059
1209
  \twhereField := v.FieldByName("Where")
1060
- \tif whereField.IsValid() && !whereField.IsNil() {
1061
- \t\t// where is a pointer to a model-specific WhereInput struct
1062
- \t\t// Delegate to buildWhereFromStruct
1063
- \t\twc, wa := c.buildWhereFromStruct(whereField.Elem(), 1)
1210
+ \tif whereField.IsValid() {
1211
+ \t\tif !whereField.IsNil() {
1212
+ \t\t\telem := whereField
1213
+ \t\t\tif elem.Kind() == reflect.Ptr {
1214
+ \t\t\t\telem = elem.Elem()
1215
+ \t\t\t}
1216
+ \t\t\twc, wa := c.buildWhereFromStruct(elem, offset)
1217
+ \t\t\twhereClause = wc
1218
+ \t\t\tqueryArgs = wa
1219
+ \t\t}
1220
+ \t} else {
1221
+ \t\twc, wa := c.buildWhereFromStruct(v, offset)
1064
1222
  \t\twhereClause = wc
1065
1223
  \t\tqueryArgs = wa
1066
1224
  \t}
@@ -238,27 +238,24 @@ class PythonGenerator {
238
238
  content += '\n';
239
239
  // TypedDict row shape: mirrors what the runtime actually returns
240
240
  // (plain dicts), so `AdapterTableClient["<Model>Row"]` delegates
241
- // type-check. Required keys stay required via a total=True base;
242
- // everything else is optional (total=False), keeping partial
243
- // selects valid without extra dependencies.
244
- const rowRequired = model.fields.filter(f => !f.isOptional && !f.hasDefault);
245
- const rowOptional = model.fields.filter(f => f.isOptional || f.hasDefault);
241
+ // type-check. All table columns are guaranteed keys in fetched rows
242
+ // (nullable columns take Optional[...] values); relations stay
243
+ // optional keys (total=False) because they are only present when
244
+ // joined/included.
246
245
  content += `class _${model.name}Required(TypedDict):\n`;
247
246
  content += ` """Required keys of a ${model.name} row."""\n`;
248
- if (rowRequired.length === 0) {
247
+ if (model.fields.length === 0) {
249
248
  content += ' pass\n';
250
249
  }
251
- for (const f of rowRequired) {
252
- content += ` ${this.toSnakeCase(f.name)}: ${this.mapPyType(f)}\n`;
250
+ for (const f of model.fields) {
251
+ const pyType = f.isOptional ? `Optional[${this.mapPyType(f)}]` : this.mapPyType(f);
252
+ content += ` ${this.toSnakeCase(f.name)}: ${pyType}\n`;
253
253
  }
254
254
  content += `\nclass ${model.name}Row(_${model.name}Required, total=False):\n`;
255
255
  content += ` """Row shape returned for ${model.name} queries."""\n`;
256
- if (rowOptional.length === 0 && model.relations.length === 0) {
256
+ if (model.relations.length === 0) {
257
257
  content += ' pass\n';
258
258
  }
259
- for (const f of rowOptional) {
260
- content += ` ${this.toSnakeCase(f.name)}: ${this.mapPyType(f)}\n`;
261
- }
262
259
  for (const rel of model.relations) {
263
260
  const relName = this.toSnakeCase(rel.name);
264
261
  content += rel.isArray ? ` ${relName}: List[Any]\n` : ` ${relName}: Any\n`;
@@ -305,9 +302,9 @@ class PythonGenerator {
305
302
  content += `from an5_adapter import An5Adapter, AdapterTableClient${hasViews ? ', ViewClient' : ''}, create_an5_adapter, set_adapter_metadata\n\n`;
306
303
  const metadataModule = this.metadataModule();
307
304
  content += 'try:\n';
308
- content += ` from .${metadataModule} import MODEL_TO_TABLE, MODEL_FIELDS\n`;
305
+ content += ` from .${metadataModule} import MODEL_TO_TABLE, MODEL_FIELDS, RELATION_MAP\n`;
309
306
  content += 'except ImportError:\n';
310
- content += ` from ${metadataModule} import MODEL_TO_TABLE, MODEL_FIELDS\n\n`;
307
+ content += ` from ${metadataModule} import MODEL_TO_TABLE, MODEL_FIELDS, RELATION_MAP\n\n`;
311
308
  if (models.length > 0) {
312
309
  content += 'if TYPE_CHECKING:\n';
313
310
  for (const m of models) {
@@ -332,7 +329,7 @@ class PythonGenerator {
332
329
  content += ' conn_str = (connection_string or \n';
333
330
  content += ' os.getenv("DATABASE_URL") or \n';
334
331
  content += ' "Server=localhost;Database=master;Trusted_Connection=True;TrustServerCertificate=True;")\n';
335
- content += ' set_adapter_metadata({"model_to_table": MODEL_TO_TABLE, "model_fields": MODEL_FIELDS})\n';
332
+ content += ' set_adapter_metadata({"model_to_table": MODEL_TO_TABLE, "model_fields": MODEL_FIELDS, "relation_map": RELATION_MAP})\n';
336
333
  content += ' self.adapter: An5Adapter = create_an5_adapter(conn_str)\n\n';
337
334
  for (const model of models) {
338
335
  const propName = this.toSnakeCase(model.name) + 's';
@@ -741,6 +741,14 @@ namespace An5Orm
741
741
  return FindFirst("Id = @id", new Dictionary<string, object> { { "id", id } });
742
742
  }
743
743
 
744
+ private static bool IsScalarColumn(PropertyInfo prop)
745
+ {
746
+ var t = Nullable.GetUnderlyingType(prop.PropertyType) ?? prop.PropertyType;
747
+ if (t.IsPrimitive || t.IsEnum || t.IsValueType) return true;
748
+ if (t == typeof(string) || t == typeof(byte[]) || t == typeof(float[]) || t == typeof(double[])) return true;
749
+ return false;
750
+ }
751
+
744
752
  public T Create(T entity)
745
753
  {
746
754
  var properties = typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance);
@@ -752,6 +760,7 @@ namespace An5Orm
752
760
 
753
761
  foreach (var prop in properties)
754
762
  {
763
+ if (!IsScalarColumn(prop)) continue;
755
764
  var val = prop.GetValue(entity);
756
765
  if (val != null)
757
766
  {
@@ -793,6 +802,7 @@ namespace An5Orm
793
802
 
794
803
  foreach (var prop in properties)
795
804
  {
805
+ if (!IsScalarColumn(prop)) continue;
796
806
  var val = prop.GetValue(entity);
797
807
  if (prop.Name.Equals("Id", StringComparison.OrdinalIgnoreCase))
798
808
  {
@@ -1071,6 +1081,98 @@ namespace An5Orm
1071
1081
  return output;
1072
1082
  }
1073
1083
 
1084
+ public Dictionary<string, object> Aggregate(
1085
+ string whereClause = null,
1086
+ Dictionary<string, object> parameters = null,
1087
+ bool count = false,
1088
+ List<string> sum = null,
1089
+ List<string> avg = null,
1090
+ List<string> min = null,
1091
+ List<string> max = null)
1092
+ {
1093
+ var selectParts = new List<string>();
1094
+ if (count) selectParts.Add("COUNT(*) AS _count");
1095
+ if (sum != null) foreach (var col in sum) selectParts.Add($"SUM({An5Provider.Quote(col, Dialect)}) AS _sum_{col}");
1096
+ if (avg != null) foreach (var col in avg) selectParts.Add($"AVG({An5Provider.Quote(col, Dialect)}) AS _avg_{col}");
1097
+ if (min != null) foreach (var col in min) selectParts.Add($"MIN({An5Provider.Quote(col, Dialect)}) AS _min_{col}");
1098
+ if (max != null) foreach (var col in max) selectParts.Add($"MAX({An5Provider.Quote(col, Dialect)}) AS _max_{col}");
1099
+ if (selectParts.Count == 0) selectParts.Add("COUNT(*) AS _count");
1100
+
1101
+ var query = $"SELECT {string.Join(", ", selectParts)} FROM {TableName}";
1102
+ if (!string.IsNullOrEmpty(whereClause)) query += $" WHERE {whereClause}";
1103
+
1104
+ var conn = An5DbContext.GetActiveConnection(ConnectionString, out bool isTx);
1105
+ try
1106
+ {
1107
+ using var cmd = CreateCommand(conn, query);
1108
+ if (parameters != null)
1109
+ {
1110
+ foreach (var kvp in parameters) An5Provider.Bind(cmd, kvp.Key, kvp.Value);
1111
+ }
1112
+ using var reader = cmd.ExecuteReader();
1113
+ var result = new Dictionary<string, object>();
1114
+ if (reader.Read())
1115
+ {
1116
+ for (int i = 0; i < reader.FieldCount; i++)
1117
+ {
1118
+ var name = reader.GetName(i);
1119
+ var val = reader[i];
1120
+ result[name] = val != DBNull.Value ? val : null;
1121
+ }
1122
+ }
1123
+ return result;
1124
+ }
1125
+ finally { if (!isTx) conn.Dispose(); }
1126
+ }
1127
+
1128
+ public List<Dictionary<string, object>> GroupBy(
1129
+ List<string> by,
1130
+ string whereClause = null,
1131
+ Dictionary<string, object> parameters = null,
1132
+ List<string> sum = null,
1133
+ List<string> avg = null,
1134
+ List<string> min = null,
1135
+ List<string> max = null)
1136
+ {
1137
+ if (by == null || by.Count == 0) throw new ArgumentException("GroupBy requires at least one column in 'by'");
1138
+ var byCols = by.Select(c => An5Provider.Quote(c, Dialect)).ToList();
1139
+ var selectParts = new List<string>(byCols);
1140
+ selectParts.Add("COUNT(*) AS _count");
1141
+ if (sum != null) foreach (var col in sum) selectParts.Add($"SUM({An5Provider.Quote(col, Dialect)}) AS _sum_{col}");
1142
+ if (avg != null) foreach (var col in avg) selectParts.Add($"AVG({An5Provider.Quote(col, Dialect)}) AS _avg_{col}");
1143
+ if (min != null) foreach (var col in min) selectParts.Add($"MIN({An5Provider.Quote(col, Dialect)}) AS _min_{col}");
1144
+ if (max != null) foreach (var col in max) selectParts.Add($"MAX({An5Provider.Quote(col, Dialect)}) AS _max_{col}");
1145
+
1146
+ var query = $"SELECT {string.Join(", ", selectParts)} FROM {TableName}";
1147
+ if (!string.IsNullOrEmpty(whereClause)) query += $" WHERE {whereClause}";
1148
+ query += $" GROUP BY {string.Join(", ", byCols)}";
1149
+
1150
+ var conn = An5DbContext.GetActiveConnection(ConnectionString, out bool isTx);
1151
+ try
1152
+ {
1153
+ using var cmd = CreateCommand(conn, query);
1154
+ if (parameters != null)
1155
+ {
1156
+ foreach (var kvp in parameters) An5Provider.Bind(cmd, kvp.Key, kvp.Value);
1157
+ }
1158
+ using var reader = cmd.ExecuteReader();
1159
+ var rows = new List<Dictionary<string, object>>();
1160
+ while (reader.Read())
1161
+ {
1162
+ var row = new Dictionary<string, object>();
1163
+ for (int i = 0; i < reader.FieldCount; i++)
1164
+ {
1165
+ var name = reader.GetName(i);
1166
+ var val = reader[i];
1167
+ row[name] = val != DBNull.Value ? val : null;
1168
+ }
1169
+ rows.Add(row);
1170
+ }
1171
+ return rows;
1172
+ }
1173
+ finally { if (!isTx) conn.Dispose(); }
1174
+ }
1175
+
1074
1176
  private static List<double> ParseDoubleArray(string json)
1075
1177
  {
1076
1178
  var clean = json.Trim('[', ']');
@@ -1271,6 +1373,26 @@ namespace An5Orm
1271
1373
 
1272
1374
  public List<T> VectorSearch(List<double> vector, int take = 10, string whereClause = null, Dictionary<string, object> parameters = null, string vectorField = "Embedding", string distanceMetric = "cosine")
1273
1375
  => _table.VectorSearch(vector, take, whereClause, parameters, vectorField, distanceMetric);
1376
+
1377
+ public Dictionary<string, object> Aggregate(
1378
+ string whereClause = null,
1379
+ Dictionary<string, object> parameters = null,
1380
+ bool count = false,
1381
+ List<string> sum = null,
1382
+ List<string> avg = null,
1383
+ List<string> min = null,
1384
+ List<string> max = null)
1385
+ => _table.Aggregate(whereClause, parameters, count, sum, avg, min, max);
1386
+
1387
+ public List<Dictionary<string, object>> GroupBy(
1388
+ List<string> by,
1389
+ string whereClause = null,
1390
+ Dictionary<string, object> parameters = null,
1391
+ List<string> sum = null,
1392
+ List<string> avg = null,
1393
+ List<string> min = null,
1394
+ List<string> max = null)
1395
+ => _table.GroupBy(by, whereClause, parameters, sum, avg, min, max);
1274
1396
  }
1275
1397
  }
1276
1398
  `;
@@ -273,14 +273,34 @@ type BoolFilter struct {
273
273
 
274
274
  // DateTimeFilter provides type-safe datetime field filtering options.
275
275
  type DateTimeFilter struct {
276
- \tEquals *time.Time
277
- \tNot *time.Time
278
- \tIn []time.Time
279
- \tNotIn []time.Time
280
- \tGt *time.Time
281
- \tGte *time.Time
282
- \tLt *time.Time
283
- \tLte *time.Time
276
+ Equals *time.Time
277
+ Not *time.Time
278
+ In []time.Time
279
+ NotIn []time.Time
280
+ Gt *time.Time
281
+ Gte *time.Time
282
+ Lt *time.Time
283
+ Lte *time.Time
284
+ }
285
+
286
+ // AggregateArgs configures an aggregate query.
287
+ type AggregateArgs struct {
288
+ Where interface{}
289
+ Count bool
290
+ Sum []string
291
+ Avg []string
292
+ Min []string
293
+ Max []string
294
+ }
295
+
296
+ // GroupByArgs configures a GroupBy query.
297
+ type GroupByArgs struct {
298
+ By []string
299
+ Where interface{}
300
+ Sum []string
301
+ Avg []string
302
+ Min []string
303
+ Max []string
284
304
  }
285
305
 
286
306
  // Dialect identifies the database engine in use.
@@ -458,6 +478,16 @@ func (c *ViewClient[T]) VectorSearch(ctx context.Context, vector []float64, opts
458
478
  \treturn c.table.VectorSearch(ctx, vector, opts...)
459
479
  }
460
480
 
481
+ // Aggregate returns aggregate calculations on the view.
482
+ func (c *ViewClient[T]) Aggregate(ctx context.Context, args AggregateArgs) (map[string]interface{}, error) {
483
+ \treturn c.table.Aggregate(ctx, args)
484
+ }
485
+
486
+ // GroupBy groups view rows by specified columns and computes aggregates.
487
+ func (c *ViewClient[T]) GroupBy(ctx context.Context, args GroupByArgs) ([]map[string]interface{}, error) {
488
+ \treturn c.table.GroupBy(ctx, args)
489
+ }
490
+
461
491
  `;
462
492
  }
463
493
 
@@ -976,7 +1006,7 @@ func (c *TableClient[T]) UpdateMany(ctx context.Context, whereArgs interface{},
976
1006
  \t\tsets = append(sets, c.quoteName(col)+" = "+c.ph(len(execArgs)))
977
1007
  \t}
978
1008
 
979
- \tclause, _, _, _, _, whereQueryArgs := c.parseArgs(whereArgs)
1009
+ \tclause, _, _, _, _, whereQueryArgs := c.parseArgsWithOffset(whereArgs, len(execArgs)+1)
980
1010
  \texecArgs = append(execArgs, whereQueryArgs...)
981
1011
  \tquery := fmt.Sprintf("UPDATE %s SET %s", c.quoteTable(), strings.Join(sets, ", "))
982
1012
  \tif clause != "" {
@@ -1061,9 +1091,129 @@ func (c *TableClient[T]) Transaction(ctx context.Context, fn func(tx *sql.Tx) er
1061
1091
  \treturn tx.Commit()
1062
1092
  }
1063
1093
 
1094
+ // Aggregate returns aggregate calculations (count, sum, avg, min, max).
1095
+ func (c *TableClient[T]) Aggregate(ctx context.Context, args AggregateArgs) (map[string]interface{}, error) {
1096
+ \tclause, _, _, _, _, queryArgs := c.parseArgs(args.Where)
1097
+ \tvar selectParts []string
1098
+ \tif args.Count || (len(args.Sum) == 0 && len(args.Avg) == 0 && len(args.Min) == 0 && len(args.Max) == 0) {
1099
+ \t\tselectParts = append(selectParts, "COUNT(*) AS _count")
1100
+ \t}
1101
+ \tfor _, col := range args.Sum {
1102
+ \t\tselectParts = append(selectParts, fmt.Sprintf("SUM(%s) AS _sum_%s", c.quoteName(col), col))
1103
+ \t}
1104
+ \tfor _, col := range args.Avg {
1105
+ \t\tselectParts = append(selectParts, fmt.Sprintf("AVG(%s) AS _avg_%s", c.quoteName(col), col))
1106
+ \t}
1107
+ \tfor _, col := range args.Min {
1108
+ \t\tselectParts = append(selectParts, fmt.Sprintf("MIN(%s) AS _min_%s", c.quoteName(col), col))
1109
+ \t}
1110
+ \tfor _, col := range args.Max {
1111
+ \t\tselectParts = append(selectParts, fmt.Sprintf("MAX(%s) AS _max_%s", c.quoteName(col), col))
1112
+ \t}
1113
+
1114
+ \tquery := fmt.Sprintf("SELECT %s FROM %s", strings.Join(selectParts, ", "), c.quoteTable())
1115
+ \tif clause != "" {
1116
+ \t\tquery += " WHERE " + clause
1117
+ \t}
1118
+
1119
+ \trows, err := c.DB.QueryContext(ctx, query, queryArgs...)
1120
+ \tif err != nil {
1121
+ \t\treturn nil, err
1122
+ \t}
1123
+ \tdefer rows.Close()
1124
+
1125
+ \tcols, err := rows.Columns()
1126
+ \tif err != nil {
1127
+ \t\treturn nil, err
1128
+ \t}
1129
+
1130
+ \tres := make(map[string]interface{})
1131
+ \tif rows.Next() {
1132
+ \t\tcolVals := make([]interface{}, len(cols))
1133
+ \t\tcolPtrs := make([]interface{}, len(cols))
1134
+ \t\tfor i := range colVals {
1135
+ \t\t\tcolPtrs[i] = &colVals[i]
1136
+ \t\t}
1137
+ \t\tif err := rows.Scan(colPtrs...); err != nil {
1138
+ \t\t\treturn nil, err
1139
+ \t\t}
1140
+ \t\tfor i, name := range cols {
1141
+ \t\t\tres[name] = colVals[i]
1142
+ \t\t}
1143
+ \t}
1144
+ \treturn res, nil
1145
+ }
1146
+
1147
+ // GroupBy groups records by specified columns and computes aggregates.
1148
+ func (c *TableClient[T]) GroupBy(ctx context.Context, args GroupByArgs) ([]map[string]interface{}, error) {
1149
+ \tif len(args.By) == 0 {
1150
+ \t\treturn nil, fmt.Errorf("GroupBy requires at least one column in 'By'")
1151
+ \t}
1152
+ \tclause, _, _, _, _, queryArgs := c.parseArgs(args.Where)
1153
+ \tbyCols := make([]string, len(args.By))
1154
+ \tfor i, col := range args.By {
1155
+ \t\tbyCols[i] = c.quoteName(col)
1156
+ \t}
1157
+
1158
+ \tselectParts := make([]string, len(byCols))
1159
+ \tcopy(selectParts, byCols)
1160
+ \tselectParts = append(selectParts, "COUNT(*) AS _count")
1161
+ \tfor _, col := range args.Sum {
1162
+ \t\tselectParts = append(selectParts, fmt.Sprintf("SUM(%s) AS _sum_%s", c.quoteName(col), col))
1163
+ \t}
1164
+ \tfor _, col := range args.Avg {
1165
+ \t\tselectParts = append(selectParts, fmt.Sprintf("AVG(%s) AS _avg_%s", c.quoteName(col), col))
1166
+ \t}
1167
+ \tfor _, col := range args.Min {
1168
+ \t\tselectParts = append(selectParts, fmt.Sprintf("MIN(%s) AS _min_%s", c.quoteName(col), col))
1169
+ \t}
1170
+ \tfor _, col := range args.Max {
1171
+ \t\tselectParts = append(selectParts, fmt.Sprintf("MAX(%s) AS _max_%s", c.quoteName(col), col))
1172
+ \t}
1173
+
1174
+ \tquery := fmt.Sprintf("SELECT %s FROM %s", strings.Join(selectParts, ", "), c.quoteTable())
1175
+ \tif clause != "" {
1176
+ \t\tquery += " WHERE " + clause
1177
+ \t}
1178
+ \tquery += " GROUP BY " + strings.Join(byCols, ", ")
1179
+
1180
+ \trows, err := c.DB.QueryContext(ctx, query, queryArgs...)
1181
+ \tif err != nil {
1182
+ \t\treturn nil, err
1183
+ \t}
1184
+ \tdefer rows.Close()
1185
+
1186
+ \tcols, err := rows.Columns()
1187
+ \tif err != nil {
1188
+ \t\treturn nil, err
1189
+ \t}
1190
+
1191
+ \tvar results []map[string]interface{}
1192
+ \tfor rows.Next() {
1193
+ \t\tcolVals := make([]interface{}, len(cols))
1194
+ \t\tcolPtrs := make([]interface{}, len(cols))
1195
+ \t\tfor i := range colVals {
1196
+ \t\t\tcolPtrs[i] = &colVals[i]
1197
+ \t\t}
1198
+ \t\tif err := rows.Scan(colPtrs...); err != nil {
1199
+ \t\t\treturn nil, err
1200
+ \t\t}
1201
+ \t\trowMap := make(map[string]interface{})
1202
+ \t\tfor i, name := range cols {
1203
+ \t\t\trowMap[name] = colVals[i]
1204
+ \t\t}
1205
+ \t\tresults = append(results, rowMap)
1206
+ \t}
1207
+ \treturn results, nil
1208
+ }
1209
+
1064
1210
  // parseArgs is the generic where/orderBy/take/skip extractor.
1065
1211
  // It uses reflection to read WhereClause, OrderBy, Take, Skip, Select from any args struct.
1066
1212
  func (c *TableClient[T]) parseArgs(args interface{}) (whereClause string, orderBy string, take int, skip int, selectCols []string, queryArgs []interface{}) {
1213
+ \treturn c.parseArgsWithOffset(args, 1)
1214
+ }
1215
+
1216
+ func (c *TableClient[T]) parseArgsWithOffset(args interface{}, offset int) (whereClause string, orderBy string, take int, skip int, selectCols []string, queryArgs []interface{}) {
1067
1217
  \tif args == nil {
1068
1218
  \t\treturn
1069
1219
  \t}
@@ -1080,10 +1230,18 @@ func (c *TableClient[T]) parseArgs(args interface{}) (whereClause string, orderB
1080
1230
 
1081
1231
  \t// Where
1082
1232
  \twhereField := v.FieldByName("Where")
1083
- \tif whereField.IsValid() && !whereField.IsNil() {
1084
- \t\t// where is a pointer to a model-specific WhereInput struct
1085
- \t\t// Delegate to buildWhereFromStruct
1086
- \t\twc, wa := c.buildWhereFromStruct(whereField.Elem(), 1)
1233
+ \tif whereField.IsValid() {
1234
+ \t\tif !whereField.IsNil() {
1235
+ \t\t\telem := whereField
1236
+ \t\t\tif elem.Kind() == reflect.Ptr {
1237
+ \t\t\t\telem = elem.Elem()
1238
+ \t\t\t}
1239
+ \t\t\twc, wa := c.buildWhereFromStruct(elem, offset)
1240
+ \t\t\twhereClause = wc
1241
+ \t\t\tqueryArgs = wa
1242
+ \t\t}
1243
+ \t} else {
1244
+ \t\twc, wa := c.buildWhereFromStruct(v, offset)
1087
1245
  \t\twhereClause = wc
1088
1246
  \t\tqueryArgs = wa
1089
1247
  \t}
@@ -271,27 +271,24 @@ export class PythonGenerator {
271
271
 
272
272
  // TypedDict row shape: mirrors what the runtime actually returns
273
273
  // (plain dicts), so `AdapterTableClient["<Model>Row"]` delegates
274
- // type-check. Required keys stay required via a total=True base;
275
- // everything else is optional (total=False), keeping partial
276
- // selects valid without extra dependencies.
277
- const rowRequired = model.fields.filter(f => !f.isOptional && !f.hasDefault);
278
- const rowOptional = model.fields.filter(f => f.isOptional || f.hasDefault);
274
+ // type-check. All table columns are guaranteed keys in fetched rows
275
+ // (nullable columns take Optional[...] values); relations stay
276
+ // optional keys (total=False) because they are only present when
277
+ // joined/included.
279
278
  content += `class _${model.name}Required(TypedDict):\n`;
280
279
  content += ` """Required keys of a ${model.name} row."""\n`;
281
- if (rowRequired.length === 0) {
280
+ if (model.fields.length === 0) {
282
281
  content += ' pass\n';
283
282
  }
284
- for (const f of rowRequired) {
285
- content += ` ${this.toSnakeCase(f.name)}: ${this.mapPyType(f)}\n`;
283
+ for (const f of model.fields) {
284
+ const pyType = f.isOptional ? `Optional[${this.mapPyType(f)}]` : this.mapPyType(f);
285
+ content += ` ${this.toSnakeCase(f.name)}: ${pyType}\n`;
286
286
  }
287
287
  content += `\nclass ${model.name}Row(_${model.name}Required, total=False):\n`;
288
288
  content += ` """Row shape returned for ${model.name} queries."""\n`;
289
- if (rowOptional.length === 0 && model.relations.length === 0) {
289
+ if (model.relations.length === 0) {
290
290
  content += ' pass\n';
291
291
  }
292
- for (const f of rowOptional) {
293
- content += ` ${this.toSnakeCase(f.name)}: ${this.mapPyType(f)}\n`;
294
- }
295
292
  for (const rel of model.relations) {
296
293
  const relName = this.toSnakeCase(rel.name);
297
294
  content += rel.isArray ? ` ${relName}: List[Any]\n` : ` ${relName}: Any\n`;
@@ -343,9 +340,9 @@ export class PythonGenerator {
343
340
 
344
341
  const metadataModule = this.metadataModule();
345
342
  content += 'try:\n';
346
- content += ` from .${metadataModule} import MODEL_TO_TABLE, MODEL_FIELDS\n`;
343
+ content += ` from .${metadataModule} import MODEL_TO_TABLE, MODEL_FIELDS, RELATION_MAP\n`;
347
344
  content += 'except ImportError:\n';
348
- content += ` from ${metadataModule} import MODEL_TO_TABLE, MODEL_FIELDS\n\n`;
345
+ content += ` from ${metadataModule} import MODEL_TO_TABLE, MODEL_FIELDS, RELATION_MAP\n\n`;
349
346
 
350
347
  if (models.length > 0) {
351
348
  content += 'if TYPE_CHECKING:\n';
@@ -372,7 +369,7 @@ export class PythonGenerator {
372
369
  content += ' conn_str = (connection_string or \n';
373
370
  content += ' os.getenv("DATABASE_URL") or \n';
374
371
  content += ' "Server=localhost;Database=master;Trusted_Connection=True;TrustServerCertificate=True;")\n';
375
- content += ' set_adapter_metadata({"model_to_table": MODEL_TO_TABLE, "model_fields": MODEL_FIELDS})\n';
372
+ content += ' set_adapter_metadata({"model_to_table": MODEL_TO_TABLE, "model_fields": MODEL_FIELDS, "relation_map": RELATION_MAP})\n';
376
373
  content += ' self.adapter: An5Adapter = create_an5_adapter(conn_str)\n\n';
377
374
 
378
375
  for (const model of models) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@an5/orm",
3
- "version": "1.3.5",
3
+ "version": "1.3.6",
4
4
  "description": "The lightweight ORM for SQL Server with support for TypeScript, Python, .NET (C#), Go, Rust, Java, Kotlin, and Swift.",
5
5
  "license": "MIT",
6
6
  "bin": {