From eed19e05ac08b534f0cade2f6b55d9f358a0bc64 Mon Sep 17 00:00:00 2001 From: Chris Watson Date: Sat, 7 Mar 2026 11:06:16 -0700 Subject: [PATCH] orm: add aggregate function support (#26697) --- cmd/tools/vast/vast.v | 3 +- vlib/db/mysql/mysql_orm_test.v | 28 +++ vlib/db/mysql/orm.c.v | 70 +++++- vlib/db/pg/orm.v | 3 - vlib/db/pg/pg_orm_test.v | 22 +- vlib/db/sqlite/orm.v | 11 - vlib/orm/README.md | 43 +++- vlib/orm/orm.v | 57 +++-- vlib/orm/orm_aggregate_test.v | 156 +++++++++++++ vlib/orm/orm_fn_test.v | 45 ++++ vlib/orm/orm_func.v | 212 +++++++++++++++++- vlib/v/ast/ast.v | 39 ++-- vlib/v/checker/orm.v | 95 +++++++- .../tests/orm_aggregate_avg_time_error.out | 7 + .../tests/orm_aggregate_avg_time_error.vv | 14 ++ .../tests/orm_aggregate_expr_error.out | 7 + .../checker/tests/orm_aggregate_expr_error.vv | 13 ++ .../tests/orm_aggregate_sum_string_error.out | 7 + .../tests/orm_aggregate_sum_string_error.vv | 13 ++ vlib/v/fmt/fmt.v | 12 +- vlib/v/fmt/tests/orm_keep.vv | 16 ++ vlib/v/gen/c/cgen.v | 4 +- vlib/v/gen/c/orm.v | 94 ++++++-- vlib/v/gen/golang/golang.v | 12 +- vlib/v/parser/orm.v | 84 ++++--- 25 files changed, 938 insertions(+), 129 deletions(-) create mode 100644 vlib/orm/orm_aggregate_test.v create mode 100644 vlib/v/checker/tests/orm_aggregate_avg_time_error.out create mode 100644 vlib/v/checker/tests/orm_aggregate_avg_time_error.vv create mode 100644 vlib/v/checker/tests/orm_aggregate_expr_error.out create mode 100644 vlib/v/checker/tests/orm_aggregate_expr_error.vv create mode 100644 vlib/v/checker/tests/orm_aggregate_sum_string_error.out create mode 100644 vlib/v/checker/tests/orm_aggregate_sum_string_error.vv diff --git a/cmd/tools/vast/vast.v b/cmd/tools/vast/vast.v index 9b791fea5..ca8a66335 100644 --- a/cmd/tools/vast/vast.v +++ b/cmd/tools/vast/vast.v @@ -1834,7 +1834,8 @@ fn (t Tree) sql_expr(node ast.SqlExpr) &Node { mut obj := create_object() obj.add_terse('ast_type', t.string_node('SqlExpr')) obj.add_terse('type', t.type_node(node.typ)) - obj.add_terse('is_count', t.bool_node(node.is_count)) + obj.add_terse('aggregate_kind', t.string_node('${node.aggregate_kind}')) + obj.add_terse('aggregate_field', t.string_node(node.aggregate_field)) obj.add_terse('db_expr', t.expr(node.db_expr)) obj.add_terse('table_expr', t.type_expr(node.table_expr)) obj.add_terse('has_where', t.bool_node(node.has_where)) diff --git a/vlib/db/mysql/mysql_orm_test.v b/vlib/db/mysql/mysql_orm_test.v index 968a5113f..d391aa05f 100644 --- a/vlib/db/mysql/mysql_orm_test.v +++ b/vlib/db/mysql/mysql_orm_test.v @@ -128,6 +128,34 @@ fn test_mysql_orm() { assert age == 101 } + sum_res := db.select(orm.SelectConfig{ + table: table + aggregate_kind: .sum + aggregate_field: 'age' + fields: ['age'] + types: [typeof[int]().idx] + }, orm.QueryData{}, orm.QueryData{}) or { panic(err) } + assert sum_res.len == 1 + assert sum_res[0].len == 1 + assert sum_res[0][0] is int + if sum_res[0][0] is int { + assert sum_res[0][0] == 101 + } + + avg_res := db.select(orm.SelectConfig{ + table: table + aggregate_kind: .avg + aggregate_field: 'age' + fields: ['age'] + types: [typeof[f64]().idx] + }, orm.QueryData{}, orm.QueryData{}) or { panic(err) } + assert avg_res.len == 1 + assert avg_res[0].len == 1 + assert avg_res[0][0] is f64 + if avg_res[0][0] is f64 { + assert avg_res[0][0] == 101.0 + } + /** test orm sql type * - verify if all type create by attribute sql_type has created */ diff --git a/vlib/db/mysql/orm.c.v b/vlib/db/mysql/orm.c.v index 3b779eb21..247f9d8f8 100644 --- a/vlib/db/mysql/orm.c.v +++ b/vlib/db/mysql/orm.c.v @@ -1,6 +1,7 @@ module mysql import orm +import strconv import time // select is used internally by V's ORM for processing `SELECT ` queries. @@ -42,8 +43,8 @@ pub fn (db DB) select(config orm.SelectConfig, data orm.QueryData, where orm.Que .type_time, .type_date, .type_datetime, .type_time2, .type_datetime2, .type_timestamp { data_pointers << unsafe { malloc(sizeof(C.MYSQL_TIME)) } } - .type_string, .type_var_string, .type_blob, .type_tiny_blob, .type_medium_blob, - .type_long_blob { + .type_decimal, .type_newdecimal, .type_string, .type_var_string, .type_blob, + .type_tiny_blob, .type_medium_blob, .type_long_blob { // Memory will be allocated later dynamically depending on the length of the value. data_pointers << &u8(unsafe { nil }) } @@ -59,7 +60,7 @@ pub fn (db DB) select(config orm.SelectConfig, data orm.QueryData, where orm.Que mut types := config.types.clone() mut field_types := []FieldType{} - if config.is_count { + if config.aggregate_kind == .count { types = [orm.type_idx['u64']] } @@ -73,8 +74,11 @@ pub fn (db DB) select(config orm.SelectConfig, data orm.QueryData, where orm.Que field_types << field_type match field_type { - .type_string, .type_var_string, .type_blob, .type_tiny_blob, .type_medium_blob, - .type_long_blob { + .type_decimal, .type_newdecimal, .type_string, .type_var_string, .type_blob, + .type_tiny_blob, .type_medium_blob, .type_long_blob { + if field_type in [.type_decimal, .type_newdecimal] { + mysql_bind.buffer_type = C.MYSQL_TYPE_STRING + } string_binds_map[i] = mysql_bind } .type_long { @@ -113,7 +117,17 @@ pub fn (db DB) select(config orm.SelectConfig, data orm.QueryData, where orm.Que stmt.fetch_column(bind, index)! } - result << data_pointers_to_primitives(is_null, data_pointers, types, field_types)! + mut row := data_pointers_to_primitives(is_null, data_pointers, types, field_types)! + if config.aggregate_kind == .count && row.len > 0 { + count_value := row[0] + row[0] = match count_value { + u64 { orm.Primitive(int(count_value)) } + i64 { orm.Primitive(int(count_value)) } + int { count_value } + else { count_value } + } + } + result << row } stmt.close()! @@ -262,6 +276,12 @@ fn data_pointers_to_primitives(is_null []bool, data_pointers []&u8, types []int, for i, data in data_pointers { mut primitive := orm.Primitive(0) if !is_null[i] { + if field_types[i] in [.type_decimal, .type_newdecimal] { + decimal_value := unsafe { cstring_to_vstring(&char(data)) } + primitive = decimal_string_to_primitive(decimal_value, types[i])! + result << primitive + continue + } match types[i] { orm.type_idx['i8'] { primitive = *(unsafe { &i8(data) }) @@ -327,6 +347,44 @@ fn data_pointers_to_primitives(is_null []bool, data_pointers []&u8, types []int, return result } +fn decimal_string_to_primitive(value string, typ int) !orm.Primitive { + return match typ { + orm.type_idx['i8'] { + orm.Primitive(strconv.atoi8(value)!) + } + orm.type_idx['i16'] { + orm.Primitive(strconv.atoi16(value)!) + } + orm.type_idx['int'], orm.serial { + orm.Primitive(strconv.atoi(value)!) + } + orm.type_idx['i64'], orm.enum_ { + orm.Primitive(strconv.atoi64(value)!) + } + orm.type_idx['u8'] { + orm.Primitive(strconv.atou8(value)!) + } + orm.type_idx['u16'] { + orm.Primitive(strconv.atou16(value)!) + } + orm.type_idx['u32'] { + orm.Primitive(strconv.atou32(value)!) + } + orm.type_idx['u64'] { + orm.Primitive(strconv.atou64(value)!) + } + orm.type_idx['f32'] { + orm.Primitive(f32(strconv.atof64(value)!)) + } + orm.type_idx['f64'] { + orm.Primitive(strconv.atof64(value)!) + } + else { + return error('Unknown decimal target type ${typ}') + } + } +} + // mysql_type_from_v converts the V type to the corresponding MySQL type. fn mysql_type_from_v(typ int) !string { sql_type := match typ { diff --git a/vlib/db/pg/orm.v b/vlib/db/pg/orm.v index 4d6a29bbd..c060e1710 100644 --- a/vlib/db/pg/orm.v +++ b/vlib/db/pg/orm.v @@ -14,9 +14,6 @@ pub fn (db DB) select(config orm.SelectConfig, data orm.QueryData, where orm.Que mut ret := [][]orm.Primitive{} - if config.is_count { - } - for row in rows { mut row_data := []orm.Primitive{} for i, val in row.vals { diff --git a/vlib/db/pg/pg_orm_test.v b/vlib/db/pg/pg_orm_test.v index 8eece80dd..456f7b0eb 100644 --- a/vlib/db/pg/pg_orm_test.v +++ b/vlib/db/pg/pg_orm_test.v @@ -110,17 +110,17 @@ fn test_pg_orm() { }) or { panic(err) } res := db.select(orm.SelectConfig{ - table: table - is_count: false - has_where: true - has_order: false - order: '' - order_type: .asc - has_limit: false - primary: 'id' - has_offset: false - fields: ['id', 'name', 'age'] - types: [typeof[int]().idx, typeof[string]().idx, typeof[i64]().idx] + table: table + aggregate_kind: .none + has_where: true + has_order: false + order: '' + order_type: .asc + has_limit: false + primary: 'id' + has_offset: false + fields: ['id', 'name', 'age'] + types: [typeof[int]().idx, typeof[string]().idx, typeof[i64]().idx] }, orm.QueryData{}, orm.QueryData{ fields: ['name', 'age'] data: [orm.Primitive('Louis'), orm.Primitive(101)] diff --git a/vlib/db/sqlite/orm.v b/vlib/db/sqlite/orm.v index cf325c654..f6652414b 100644 --- a/vlib/db/sqlite/orm.v +++ b/vlib/db/sqlite/orm.v @@ -23,17 +23,6 @@ pub fn (db DB) select(config orm.SelectConfig, data orm.QueryData, where orm.Que sqlite_stmt_binder(stmt, data, query, mut c)! mut ret := [][]orm.Primitive{} - - if config.is_count { - // 2. Get count of returned values & add it to ret array - step := stmt.step() - if step !in [sqlite_row, sqlite_ok, sqlite_done] { - return db.error_message(step, query) - } - count := stmt.sqlite_select_column(0, 8)! - ret << [count] - return ret - } for { // 2. Parse returned values step := stmt.step() diff --git a/vlib/orm/README.md b/vlib/orm/README.md index c43e16830..21dd95f50 100644 --- a/vlib/orm/README.md +++ b/vlib/orm/README.md @@ -175,6 +175,29 @@ result := sql db { }! ``` +ORM select expressions also support built-in aggregate functions. `count` keeps +its legacy syntax, while the other aggregates use SQL-like function calls. + +```v ignore +total_age := sql db { + select sum(age) from Foo +}! + +average_age := sql db { + select avg(age) from Foo where id > 1 +}! + +lowest_name := sql db { + select min(name) from Foo +}! + +highest_created_at := sql db { + select max(created_at) from Foo +}! +``` + +`sum`, `avg`, `min`, and `max` return options so empty result sets can surface +SQL `NULL` as `none`. `count` continues to return `int`. ### Update @@ -326,6 +349,25 @@ struct User { qb.set('age = ?, title = ?', 71, 'boss')!.where('name = ?','John')!.update()! ``` +9. Query aggregate values​​: + +```v ignore + total_age := qb.sum('age')! + average_score := qb.avg('score')! + first_name := qb.min('name')! + latest_created_at := qb.max('created_at')! + count := qb.count()! + + assert total_age.as_int()? == 42 + assert average_score.as_f64()? == 9.5 + assert first_name.as_string()? == 'Alice' + assert latest_created_at.as_time()? == created_at +``` + +`sum`, `avg`, `min`, and `max` return an `AggregateValue`. Use +`as_int()`, `as_f64()`, `as_string()`, or `as_time()` to unwrap the typed +value, or check `has_value` for empty result sets. `count` returns `int`. + 9. Drop the table​​: ```v ignore @@ -355,4 +397,3 @@ The API includes a built-in parser to handle intricate `WHERE` clause conditions Note the use of placeholders `?`. The conditional expressions support logical operators including `AND`, `OR`, `||`, and `&&`. - diff --git a/vlib/orm/orm.v b/vlib/orm/orm.v index 84fa8adb6..774b983c7 100644 --- a/vlib/orm/orm.v +++ b/vlib/orm/orm.v @@ -89,6 +89,15 @@ pub enum OrderType { desc } +pub enum AggregateKind { + none + count + sum + avg + min + max +} + // JoinType represents the type of SQL JOIN operation pub enum JoinType { inner // INNER JOIN - returns only matching rows @@ -157,6 +166,17 @@ fn (kind OrderType) to_str() string { } } +fn (kind AggregateKind) to_str() string { + return match kind { + .none { '' } + .count { 'COUNT(*)' } + .sum { 'SUM' } + .avg { 'AVG' } + .min { 'MIN' } + .max { 'MAX' } + } +} + // Examples for QueryData in SQL: abc == 3 && b == 'test' // => fields[abc, b]; data[3, 'test']; types[index of int, index of string]; kinds[.eq, .eq]; is_and[true]; // Every field, data, type & kind of operation in the expr share the same index in the arrays @@ -198,7 +218,7 @@ pub mut: } // table - Table struct -// is_count - Either the data will be returned or an integer with the count +// aggregate_kind - Select rows or return a single aggregate value // has_where - Select all or use a where expr // has_order - Order the results // order - Name of the column which will be ordered @@ -211,19 +231,20 @@ pub mut: // joins - JOIN clauses for this query pub struct SelectConfig { pub mut: - table Table - is_count bool - has_where bool - has_order bool - order string - order_type OrderType - has_limit bool - primary string = 'id' // should be set if primary is different than 'id' and 'has_limit' is false - has_offset bool - has_distinct bool - fields []string - types []int - joins []JoinConfig // JOIN clauses for this query + table Table + aggregate_kind AggregateKind + aggregate_field string + has_where bool + has_order bool + order string + order_type OrderType + has_limit bool + primary string = 'id' // should be set if primary is different than 'id' and 'has_limit' is false + has_offset bool + has_distinct bool + fields []string + types []int + joins []JoinConfig // JOIN clauses for this query } // Interfaces gets called from the backend and can be implemented @@ -382,8 +403,12 @@ pub fn orm_select_gen(cfg SelectConfig, q string, num bool, qm string, start_pos str += 'DISTINCT ' } - if cfg.is_count { - str += 'COUNT(*)' + if cfg.aggregate_kind != .none { + if cfg.aggregate_kind == .count { + str += cfg.aggregate_kind.to_str() + } else { + str += '${cfg.aggregate_kind.to_str()}(${q}${cfg.aggregate_field}${q})' + } } else { for i, field in cfg.fields { str += '${q}${field}${q}' diff --git a/vlib/orm/orm_aggregate_test.v b/vlib/orm/orm_aggregate_test.v new file mode 100644 index 000000000..612d8aeb4 --- /dev/null +++ b/vlib/orm/orm_aggregate_test.v @@ -0,0 +1,156 @@ +// vtest retry: 3 +// vtest build: present_sqlite3? && !windows && !sanitize-memory-clang +import orm +import time +import db.sqlite + +struct AggregateEntry { + id int @[primary; sql: serial] + age int + score f64 + label string + created time.Time +} + +fn test_sql_orm_aggregates() { + mut db := sqlite.connect(':memory:') or { panic(err) } + defer { + db.close() or {} + } + + sql db { + create table AggregateEntry + }! + + first_created := time.unix(1_700_000_000) + second_created := time.unix(1_700_000_100) + third_created := time.unix(1_700_000_200) + entries := [ + AggregateEntry{ + age: 20 + score: 7.5 + label: 'bravo' + created: second_created + }, + AggregateEntry{ + age: 30 + score: 8.25 + label: 'alpha' + created: first_created + }, + AggregateEntry{ + age: 40 + score: 9.75 + label: 'charlie' + created: third_created + }, + ] + + for entry in entries { + sql db { + insert entry into AggregateEntry + }! + } + + total_age := sql db { + select sum(age) from AggregateEntry + }! + if value := total_age { + assert value == 90 + } else { + assert false + } + + average_age := sql db { + select avg(age) from AggregateEntry where age >= 20 + }! + if value := average_age { + assert value == 30.0 + } else { + assert false + } + + min_label := sql db { + select min(label) from AggregateEntry + }! + if value := min_label { + assert value == 'alpha' + } else { + assert false + } + + max_created := sql db { + select max(created) from AggregateEntry + }! + if value := max_created { + assert value == third_created + } else { + assert false + } + + empty_sum := sql db { + select sum(age) from AggregateEntry where age > 100 + }! + assert empty_sum == none + + empty_avg := sql db { + select avg(score) from AggregateEntry where age > 100 + }! + assert empty_avg == none +} + +fn test_query_builder_aggregates() { + mut db := sqlite.connect(':memory:') or { panic(err) } + defer { + db.close() or {} + } + + mut qb := orm.new_query[AggregateEntry](db) + qb.create()! + + first_created := time.unix(1_800_000_000) + second_created := time.unix(1_800_000_100) + entries := [ + AggregateEntry{ + age: 10 + score: 1.5 + label: 'delta' + created: second_created + }, + AggregateEntry{ + age: 25 + score: 2.5 + label: 'beta' + created: first_created + }, + ] + + qb.insert_many(entries)! + + assert qb.count()! == 2 + + sum_age := qb.sum('age')! + assert sum_age.has_value + assert sum_age.as_int()? == 35 + + avg_score := qb.avg('score')! + assert avg_score.has_value + assert avg_score.as_f64()? == 2.0 + + min_label := qb.min('label')! + assert min_label.has_value + assert min_label.as_string()? == 'beta' + + max_created := qb.max('created')! + assert max_created.has_value + assert max_created.as_time()? == second_created + + empty_max := qb.where('age > ?', 100)!.max('age')! + assert !empty_max.has_value + + if _ := qb.sum('label') { + assert false + } else { + assert err.msg().contains('requires a numeric field') + } +} diff --git a/vlib/orm/orm_fn_test.v b/vlib/orm/orm_fn_test.v index 7685d2dc4..3811a6d52 100644 --- a/vlib/orm/orm_fn_test.v +++ b/vlib/orm/orm_fn_test.v @@ -197,6 +197,51 @@ fn test_orm_select_gen_with_distinct_and_where() { assert query == "SELECT DISTINCT 'id', 'test', 'abc' FROM 'test_table' WHERE 'abc' = ?0;" } +fn test_orm_select_gen_with_sum() { + query := orm.orm_select_gen(orm.SelectConfig{ + table: orm.Table{ + name: 'test_table' + } + aggregate_kind: .sum + aggregate_field: 'age' + fields: ['age'] + types: [orm.type_idx['int']] + }, "'", true, '?', 0, orm.QueryData{}) + + assert query == "SELECT SUM('age') FROM 'test_table';" +} + +fn test_orm_select_gen_with_avg_and_where() { + query := orm.orm_select_gen(orm.SelectConfig{ + table: orm.Table{ + name: 'test_table' + } + aggregate_kind: .avg + aggregate_field: 'score' + fields: ['score'] + types: [orm.type_idx['f64']] + has_where: true + }, "'", true, '?', 0, orm.QueryData{ + fields: ['abc'] + kinds: [.eq] + is_and: [] + }) + + assert query == "SELECT AVG('score') FROM 'test_table' WHERE 'abc' = ?0;" +} + +fn test_orm_select_gen_with_count() { + query := orm.orm_select_gen(orm.SelectConfig{ + table: orm.Table{ + name: 'test_table' + } + aggregate_kind: .count + types: [orm.type_idx['int']] + }, "'", true, '?', 0, orm.QueryData{}) + + assert query == "SELECT COUNT(*) FROM 'test_table';" +} + fn test_orm_table_gen() { table := orm.Table{ name: 'test_table' diff --git a/vlib/orm/orm_func.v b/vlib/orm/orm_func.v index f2d44f1e0..661c5f17e 100644 --- a/vlib/orm/orm_func.v +++ b/vlib/orm/orm_func.v @@ -6,6 +6,12 @@ import strings.textscanner const operators = ['=', '!=', '<>', '>=', '<=', '>', '<', 'LIKE', 'ILIKE', 'IS NULL', 'IS NOT NULL', 'IN', 'NOT IN']! +pub struct AggregateValue { +pub: + has_value bool + value Primitive = Null{} +} + @[heap] pub struct QueryBuilder[T] { pub mut: @@ -690,6 +696,144 @@ fn (qb_ &QueryBuilder[T]) prepare() ! { } } +fn (qb &QueryBuilder[T]) get_meta_field_by_sql_name(field string) ?TableField { + for meta_field in qb.meta { + if sql_field_name(meta_field) == field { + return meta_field + } + } + return none +} + +fn is_numeric_type_idx(typ int) bool { + return typ in nums || typ in num64 || typ in float +} + +fn is_min_max_supported_type_idx(typ int) bool { + return is_numeric_type_idx(typ) || typ == type_string || typ == time_ +} + +fn (qb &QueryBuilder[T]) validate_aggregate_field(kind AggregateKind, field string) !TableField { + meta_field := qb.get_meta_field_by_sql_name(field) or { + return error("${@FN}(): table `${qb.config.table}` has no field's name: `${field}`") + } + match kind { + .sum, .avg { + if !is_numeric_type_idx(meta_field.typ) { + msg := match kind { + .sum { '${@FN}(): `sum` requires a numeric field' } + .avg { '${@FN}(): `avg` requires a numeric field' } + else { '${@FN}(): aggregate requires a numeric field' } + } + return error(msg) + } + } + .min, .max { + if !is_min_max_supported_type_idx(meta_field.typ) { + msg := match kind { + .min { '${@FN}(): `min` requires a numeric, string, or time.Time field' } + .max { '${@FN}(): `max` requires a numeric, string, or time.Time field' } + else { '${@FN}(): aggregate requires a numeric, string, or time.Time field' } + } + return error(msg) + } + } + else {} + } + return meta_field +} + +fn (qb &QueryBuilder[T]) build_aggregate_config(kind AggregateKind, field string) !SelectConfig { + mut cfg := qb.config + cfg.aggregate_kind = kind + cfg.aggregate_field = '' + cfg.fields = [] + cfg.types = [] + if kind == .count { + cfg.types = [type_idx['int']] + return cfg + } + + meta_field := qb.validate_aggregate_field(kind, field)! + cfg.aggregate_field = field + cfg.fields = [field] + cfg.types = [if kind == .avg { type_idx['f64'] } else { meta_field.typ }] + return cfg +} + +fn primitive_to_aggregate_value(value Primitive) AggregateValue { + return if value == Primitive(Null{}) { + AggregateValue{} + } else { + AggregateValue{ + has_value: true + value: value + } + } +} + +// as_int returns the aggregate value as `int`, or `none` when it is null or not numeric. +pub fn (value AggregateValue) as_int() ?int { + if !value.has_value { + return none + } + return match value.value { + i8 { int(value.value) } + i16 { int(value.value) } + int { value.value } + i64 { int(value.value) } + u8 { int(value.value) } + u16 { int(value.value) } + u32 { int(value.value) } + u64 { int(value.value) } + f32 { int(value.value) } + f64 { int(value.value) } + else { return none } + } +} + +// as_f64 returns the aggregate value as `f64`, or `none` when it is null or not numeric. +pub fn (value AggregateValue) as_f64() ?f64 { + if !value.has_value { + return none + } + return match value.value { + i8 { f64(value.value) } + i16 { f64(value.value) } + int { f64(value.value) } + i64 { f64(value.value) } + u8 { f64(value.value) } + u16 { f64(value.value) } + u32 { f64(value.value) } + u64 { f64(value.value) } + f32 { f64(value.value) } + f64 { value.value } + else { return none } + } +} + +// as_string returns the aggregate value as `string`, or `none` when it is null or not a string. +pub fn (value AggregateValue) as_string() ?string { + if !value.has_value { + return none + } + return match value.value { + string { value.value } + else { return none } + } +} + +// as_time returns the aggregate value as `time.Time`, or `none` when it is null or not a time. +pub fn (value AggregateValue) as_time() ?time.Time { + if !value.has_value { + return none + } + return match value.value { + time.Time { value.value } + else { return none } + } +} + // query start a query and return result in struct `T` pub fn (qb_ &QueryBuilder[T]) query() ![]T { mut qb := unsafe { qb_ } @@ -711,10 +855,8 @@ pub fn (qb_ &QueryBuilder[T]) count() !int { defer { qb.reset() } - mut count_config := qb.config - count_config.is_count = true - count_config.fields = [] qb.prepare()! + count_config := qb.build_aggregate_config(.count, '')! result := qb.conn.select(count_config, qb.data, qb.where)! if result.len == 0 || result[0].len == 0 { @@ -729,6 +871,70 @@ pub fn (qb_ &QueryBuilder[T]) count() !int { } } +// sum returns the sum of the field values as an `AggregateValue`. +pub fn (qb_ &QueryBuilder[T]) sum(field string) !AggregateValue { + mut qb := unsafe { qb_ } + defer { + qb.reset() + } + qb.prepare()! + qb.validate_aggregate_field(.sum, field)! + cfg := qb.build_aggregate_config(.sum, field)! + result := qb.conn.select(cfg, qb.data, qb.where)! + if result.len == 0 || result[0].len == 0 { + return AggregateValue{} + } + return primitive_to_aggregate_value(result[0][0]) +} + +// min returns the smallest field value as an `AggregateValue`. +pub fn (qb_ &QueryBuilder[T]) min(field string) !AggregateValue { + mut qb := unsafe { qb_ } + defer { + qb.reset() + } + qb.prepare()! + qb.validate_aggregate_field(.min, field)! + cfg := qb.build_aggregate_config(.min, field)! + result := qb.conn.select(cfg, qb.data, qb.where)! + if result.len == 0 || result[0].len == 0 { + return AggregateValue{} + } + return primitive_to_aggregate_value(result[0][0]) +} + +// max returns the largest field value as an `AggregateValue`. +pub fn (qb_ &QueryBuilder[T]) max(field string) !AggregateValue { + mut qb := unsafe { qb_ } + defer { + qb.reset() + } + qb.prepare()! + qb.validate_aggregate_field(.max, field)! + cfg := qb.build_aggregate_config(.max, field)! + result := qb.conn.select(cfg, qb.data, qb.where)! + if result.len == 0 || result[0].len == 0 { + return AggregateValue{} + } + return primitive_to_aggregate_value(result[0][0]) +} + +// avg returns the average field value as an `AggregateValue`. +pub fn (qb_ &QueryBuilder[T]) avg(field string) !AggregateValue { + mut qb := unsafe { qb_ } + defer { + qb.reset() + } + qb.prepare()! + qb.validate_aggregate_field(.avg, field)! + cfg := qb.build_aggregate_config(.avg, field)! + result := qb.conn.select(cfg, qb.data, qb.where)! + if result.len == 0 || result[0].len == 0 { + return AggregateValue{} + } + return primitive_to_aggregate_value(result[0][0]) +} + // insert insert a record into the database pub fn (qb_ &QueryBuilder[T]) insert[T](value T) !&QueryBuilder[T] { mut qb := unsafe { qb_ } diff --git a/vlib/v/ast/ast.v b/vlib/v/ast/ast.v index a7f1217ab..bcdc55407 100644 --- a/vlib/v/ast/ast.v +++ b/vlib/v/ast/ast.v @@ -2315,11 +2315,21 @@ pub mut: on_expr Expr // The ON condition (e.g., `User.dept_id == Department.id`) } +pub enum SqlAggregateKind { + none + count + sum + avg + min + max +} + pub struct SqlExpr { pub: - is_count bool - is_insert bool // for insert expressions - inserted_var string + aggregate_kind SqlAggregateKind + aggregate_field string + is_insert bool // for insert expressions + inserted_var string has_where bool has_order bool @@ -2332,17 +2342,18 @@ pub: is_generated bool pos token.Pos pub mut: - typ Type - db_expr Expr // `db` in `sql db {` - where_expr Expr - order_expr Expr - limit_expr Expr - offset_expr Expr - table_expr TypeNode - fields []StructField - sub_structs map[int]SqlExpr - or_expr OrExpr - joins []JoinClause // JOIN clauses for this query + typ Type + db_expr Expr // `db` in `sql db {` + where_expr Expr + order_expr Expr + limit_expr Expr + offset_expr Expr + table_expr TypeNode + fields []StructField + sub_structs map[int]SqlExpr + or_expr OrExpr + joins []JoinClause // JOIN clauses for this query + aggregate_field_type Type } pub struct NodeError { diff --git a/vlib/v/checker/orm.v b/vlib/v/checker/orm.v index 56607ebca..d63497967 100644 --- a/vlib/v/checker/orm.v +++ b/vlib/v/checker/orm.v @@ -176,17 +176,30 @@ fn (mut c Checker) sql_expr(mut node ast.SqlExpr) ast.Type { sub_structs[int(field.typ)] = subquery_expr } - if node.is_count { - fields = [ - ast.StructField{ - typ: ast.int_type - }, - ] - } - - node.fields = fields - node.sub_structs = sub_structs.move() field_names := fields.map(it.name) + if node.aggregate_kind != .none { + node.sub_structs = map[int]ast.SqlExpr{} + if node.aggregate_kind == .count { + node.fields = [ + ast.StructField{ + typ: ast.int_type + }, + ] + node.aggregate_field_type = ast.int_type + node.typ = ast.int_type.set_flag(.result) + } else { + aggregate_field := c.check_orm_aggregate_field(node.aggregate_kind, node.aggregate_field, + fields, table_sym.name, node.pos) or { return ast.void_type } + node.aggregate_field_type = aggregate_field.typ + node.fields = [ + aggregate_field, + ] + node.typ = c.orm_aggregate_return_type(node.aggregate_kind, aggregate_field.typ).set_flag(.result) + } + } else { + node.fields = fields + node.sub_structs = sub_structs.move() + } if node.has_where { c.expr(mut node.where_expr) @@ -588,6 +601,68 @@ fn (mut c Checker) check_sql_value_expr_is_comptime_with_natural_number_or_expr_ } } +fn (mut c Checker) check_orm_aggregate_field(kind ast.SqlAggregateKind, field_name string, + fields []ast.StructField, table_name string, pos token.Pos) ?ast.StructField { + field := fields.filter(it.name == field_name) + if field.len == 0 { + mut field_names := []string{cap: fields.len} + for item in fields { + field_names << item.name + } + c.orm_error(util.new_suggestion(field_name, field_names).say('`${table_name}` structure has no field with name `${field_name}`'), + pos) + return none + } + resolved_field := field[0] + field_type := c.table.final_type(resolved_field.typ.clear_flag(.option)) + field_sym := c.table.sym(field_type) + is_time := field_sym.name == 'time.Time' + is_numeric := field_type.is_number() + is_string := field_type.is_string() + + if field_sym.kind in [.array, .struct] && !is_time { + c.orm_error('ORM aggregate functions do not support array or sub-struct fields', + pos) + return none + } + + match kind { + .sum, .avg { + if !is_numeric { + msg := match kind { + .sum { '`sum` aggregate requires a numeric field' } + .avg { '`avg` aggregate requires a numeric field' } + else { 'aggregate requires a numeric field' } + } + c.orm_error(msg, pos) + return none + } + } + .min, .max { + if !(is_numeric || is_string || is_time) { + msg := match kind { + .min { '`min` aggregate requires a numeric, string, or time.Time field' } + .max { '`max` aggregate requires a numeric, string, or time.Time field' } + else { 'aggregate requires a numeric, string, or time.Time field' } + } + c.orm_error(msg, pos) + return none + } + } + else {} + } + return resolved_field +} + +fn (_ &Checker) orm_aggregate_return_type(kind ast.SqlAggregateKind, field_type ast.Type) ast.Type { + return match kind { + .count { ast.int_type } + .avg { ast.f64_type.set_flag(.option) } + .sum, .min, .max { field_type.clear_flag(.option).set_flag(.option) } + .none { ast.void_type } + } +} + fn (mut c Checker) check_sql_expr_type_is_int(expr &ast.Expr, sql_keyword string) { if expr is ast.Ident { if expr.obj.typ.is_int() { diff --git a/vlib/v/checker/tests/orm_aggregate_avg_time_error.out b/vlib/v/checker/tests/orm_aggregate_avg_time_error.out new file mode 100644 index 000000000..7630ee9d7 --- /dev/null +++ b/vlib/v/checker/tests/orm_aggregate_avg_time_error.out @@ -0,0 +1,7 @@ +vlib/v/checker/tests/orm_aggregate_avg_time_error.vv:11:7: error: ORM: `avg` aggregate requires a numeric field + 9 | fn main() { + 10 | db := sqlite.connect(':memory:')! + 11 | _ := sql db { + | ~~~~~~~~ + 12 | select avg(created) from Event + 13 | }! diff --git a/vlib/v/checker/tests/orm_aggregate_avg_time_error.vv b/vlib/v/checker/tests/orm_aggregate_avg_time_error.vv new file mode 100644 index 000000000..b184090f6 --- /dev/null +++ b/vlib/v/checker/tests/orm_aggregate_avg_time_error.vv @@ -0,0 +1,14 @@ +import db.sqlite +import time + +struct Event { + id int @[primary] + created time.Time +} + +fn main() { + db := sqlite.connect(':memory:')! + _ := sql db { + select avg(created) from Event + }! +} diff --git a/vlib/v/checker/tests/orm_aggregate_expr_error.out b/vlib/v/checker/tests/orm_aggregate_expr_error.out new file mode 100644 index 000000000..f14468686 --- /dev/null +++ b/vlib/v/checker/tests/orm_aggregate_expr_error.out @@ -0,0 +1,7 @@ +vlib/v/checker/tests/orm_aggregate_expr_error.vv:11:18: error: ORM aggregate functions only support a single field name argument + 9 | db := sqlite.connect(':memory:')! + 10 | _ := sql db { + 11 | select sum(age + 1) from User + | ^ + 12 | }! + 13 | } diff --git a/vlib/v/checker/tests/orm_aggregate_expr_error.vv b/vlib/v/checker/tests/orm_aggregate_expr_error.vv new file mode 100644 index 000000000..6e1f1ef5e --- /dev/null +++ b/vlib/v/checker/tests/orm_aggregate_expr_error.vv @@ -0,0 +1,13 @@ +import db.sqlite + +struct User { + id int @[primary] + age int +} + +fn main() { + db := sqlite.connect(':memory:')! + _ := sql db { + select sum(age + 1) from User + }! +} diff --git a/vlib/v/checker/tests/orm_aggregate_sum_string_error.out b/vlib/v/checker/tests/orm_aggregate_sum_string_error.out new file mode 100644 index 000000000..7204de94e --- /dev/null +++ b/vlib/v/checker/tests/orm_aggregate_sum_string_error.out @@ -0,0 +1,7 @@ +vlib/v/checker/tests/orm_aggregate_sum_string_error.vv:10:7: error: ORM: `sum` aggregate requires a numeric field + 8 | fn main() { + 9 | db := sqlite.connect(':memory:')! + 10 | _ := sql db { + | ~~~~~~~~ + 11 | select sum(name) from User + 12 | }! diff --git a/vlib/v/checker/tests/orm_aggregate_sum_string_error.vv b/vlib/v/checker/tests/orm_aggregate_sum_string_error.vv new file mode 100644 index 000000000..ee8f329aa --- /dev/null +++ b/vlib/v/checker/tests/orm_aggregate_sum_string_error.vv @@ -0,0 +1,13 @@ +import db.sqlite + +struct User { + id int @[primary] + name string +} + +fn main() { + db := sqlite.connect(':memory:')! + _ := sql db { + select sum(name) from User + }! +} diff --git a/vlib/v/fmt/fmt.v b/vlib/v/fmt/fmt.v index 1638ce87d..9364f493d 100644 --- a/vlib/v/fmt/fmt.v +++ b/vlib/v/fmt/fmt.v @@ -3091,8 +3091,16 @@ pub fn (mut f Fmt) sql_expr(node ast.SqlExpr) { if !table_name.starts_with('C.') && !table_name.starts_with('JS.') { table_name = f.no_cur_mod(f.short_module(sym.name)) // TODO: f.type_to_str? } - if node.is_count { - f.write('count ') + if node.aggregate_kind != .none { + match node.aggregate_kind { + .count { + f.write('count ') + } + .sum, .avg, .min, .max { + f.write('${node.aggregate_kind}(${node.aggregate_field}) ') + } + .none {} + } } else { for i, fd in node.fields { f.write(fd.name) diff --git a/vlib/v/fmt/tests/orm_keep.vv b/vlib/v/fmt/tests/orm_keep.vv index 6b22a1648..016b1aecd 100644 --- a/vlib/v/fmt/tests/orm_keep.vv +++ b/vlib/v/fmt/tests/orm_keep.vv @@ -20,7 +20,23 @@ fn main() { nr_customers := sql db { select count from Customer } + total_orders := sql db { + select sum(nr_orders) from Customer + } + avg_orders := sql db { + select avg(nr_orders) from Customer + } + first_country := sql db { + select min(country) from Customer + } + last_country := sql db { + select max(country) from Customer + } println('number of all customers: ${nr_customers}') + println(total_orders) + println(avg_orders) + println(first_country) + println(last_country) // V syntax can be used to build queries // db.select returns an array uk_customers := sql db { diff --git a/vlib/v/gen/c/cgen.v b/vlib/v/gen/c/cgen.v index 4fd5ec7c6..bef245581 100644 --- a/vlib/v/gen/c/cgen.v +++ b/vlib/v/gen/c/cgen.v @@ -1274,11 +1274,13 @@ pub fn (mut g Gen) write_typeof_functions() { fn (mut g Gen) styp(t ast.Type) string { if !t.has_option_or_result() { return g.base_type(t) + } else if t.has_flag(.result) { + return g.register_result(t) } else if t.has_flag(.option) { // Register an optional if it's not registered yet return g.register_option(t) } else { - return g.register_result(t) + return g.base_type(t) } } diff --git a/vlib/v/gen/c/orm.v b/vlib/v/gen/c/orm.v index 3742c1618..af13b0dec 100644 --- a/vlib/v/gen/c/orm.v +++ b/vlib/v/gen/c/orm.v @@ -22,6 +22,15 @@ fn orm_field_access_name(field_name string) string { return c_name(field_name) } +fn (g &Gen) orm_primitive_field_name(typ ast.Type) string { + final_typ := g.table.final_type(typ.clear_flag(.option)) + sym := g.table.sym(final_typ) + if sym.kind == .enum { + return 'i64' + } + return vint2int(sym.cname) +} + enum SqlExprSide { left right @@ -983,7 +992,8 @@ fn (mut g Gen) write_orm_select(node ast.SqlExpr, connection_var_name string, re g.writeln('.table = ') g.write_orm_table_struct(node.table_expr.typ) g.writeln(',') - g.writeln('.is_count = ${node.is_count},') + g.writeln('.aggregate_kind = orm__AggregateKind__${node.aggregate_kind},') + g.writeln('.aggregate_field = _S("${node.aggregate_field}"),') g.writeln('.has_where = ${node.has_where},') g.writeln('.has_order = ${node.has_order},') @@ -1013,7 +1023,10 @@ fn (mut g Gen) write_orm_select(node ast.SqlExpr, connection_var_name string, re g.writeln('.primary = _S("${primary_field.name}"),') } - select_fields := fields.filter(g.table.sym(it.typ).kind != .array) + mut select_fields := fields.filter(g.table.sym(it.typ).kind != .array) + if node.aggregate_kind != .none { + select_fields = fields.clone() + } g.writeln('.fields = builtin__new_array_from_c_array(${select_fields.len}, ${select_fields.len}, sizeof(string),') g.indent++ mut types := []string{} @@ -1022,7 +1035,12 @@ fn (mut g Gen) write_orm_select(node ast.SqlExpr, connection_var_name string, re g.indent++ for field in select_fields { g.writeln('_S("${g.get_orm_column_name_from_struct_field(field)}"),') - final_field_typ := g.table.final_type(field.typ) + mut final_field_typ := g.table.final_type(field.typ.clear_flag(.option)) + if node.aggregate_kind == .avg { + final_field_typ = ast.f64_type + } else if node.aggregate_kind == .count { + final_field_typ = ast.int_type + } sym := g.table.sym(final_field_typ) if sym.name == 'time.Time' { types << '_const_orm__time_' @@ -1119,8 +1137,38 @@ fn (mut g Gen) write_orm_select(node ast.SqlExpr, connection_var_name string, re g.writeln('Array_Array_orm__Primitive ${select_unwrapped_result_var_name} = (*(Array_Array_orm__Primitive*)${select_result_var_name}.data);') - if node.is_count { - g.writeln('*(${unwrapped_c_typ}*) ${result_var}.data = *((*(orm__Primitive*) builtin__array_get((*(Array_orm__Primitive*) builtin__array_get(${select_unwrapped_result_var_name}, 0)), 0))._${ast.int_type_name});') + if node.aggregate_kind != .none { + prim_var := g.new_tmp_var() + aggregate_type := if node.aggregate_kind == .avg { + ast.f64_type + } else { + node.aggregate_field_type + } + primitive_field_name := g.orm_primitive_field_name(aggregate_type) + aggregate_value_styp := g.styp(aggregate_type.clear_flag(.option)) + if node.aggregate_kind == .count { + g.writeln('*(${unwrapped_c_typ}*) ${result_var}.data = 0;') + g.writeln('if (${select_unwrapped_result_var_name}.len > 0 && (*(Array_orm__Primitive*) builtin__array_get(${select_unwrapped_result_var_name}, 0)).len > 0) {') + g.indent++ + g.writeln('orm__Primitive *${prim_var} = &(*(orm__Primitive*) builtin__array_get((*(Array_orm__Primitive*) builtin__array_get(${select_unwrapped_result_var_name}, 0)), 0));') + g.writeln('*(${unwrapped_c_typ}*) ${result_var}.data = *(${prim_var}->_${primitive_field_name});') + g.indent-- + g.writeln('}') + } else { + aggregate_result_var := g.new_tmp_var() + g.writeln('${unwrapped_c_typ} ${aggregate_result_var} = (${unwrapped_c_typ}){ .state = 2, .err = _const_none__, .data = {E_STRUCT} };') + g.writeln('if (${select_unwrapped_result_var_name}.len > 0 && (*(Array_orm__Primitive*) builtin__array_get(${select_unwrapped_result_var_name}, 0)).len > 0) {') + g.indent++ + g.writeln('orm__Primitive *${prim_var} = &(*(orm__Primitive*) builtin__array_get((*(Array_orm__Primitive*) builtin__array_get(${select_unwrapped_result_var_name}, 0)), 0));') + g.writeln('if (${prim_var}->_typ != ${g.table.find_type_idx('orm.Null')}) {') + g.indent++ + g.writeln('builtin___option_ok(${prim_var}->_${primitive_field_name}, (_option *)&${aggregate_result_var}, sizeof(${aggregate_value_styp}));') + g.indent-- + g.writeln('}') + g.indent-- + g.writeln('}') + g.writeln('*(${unwrapped_c_typ}*) ${result_var}.data = ${aggregate_result_var};') + } } else { tmp := g.new_tmp_var() idx := g.new_tmp_var() @@ -1226,23 +1274,25 @@ fn (mut g Gen) write_orm_select(node ast.SqlExpr, connection_var_name string, re } mut sql_expr_select_array := ast.SqlExpr{ - typ: final_field_typ.set_flag(.result) - is_count: sub.is_count - db_expr: sub.db_expr - has_where: sub.has_where - has_offset: sub.has_offset - offset_expr: sub.offset_expr - has_order: sub.has_order - order_expr: sub.order_expr - has_desc: sub.has_desc - is_array: true - is_generated: true - pos: sub.pos - has_limit: sub.has_limit - limit_expr: sub.limit_expr - table_expr: sub.table_expr - fields: sub.fields - where_expr: where_expr + typ: final_field_typ.set_flag(.result) + aggregate_kind: sub.aggregate_kind + aggregate_field: sub.aggregate_field + db_expr: sub.db_expr + has_where: sub.has_where + has_offset: sub.has_offset + offset_expr: sub.offset_expr + has_order: sub.has_order + order_expr: sub.order_expr + has_desc: sub.has_desc + is_array: true + is_generated: true + pos: sub.pos + has_limit: sub.has_limit + limit_expr: sub.limit_expr + table_expr: sub.table_expr + fields: sub.fields + where_expr: where_expr + aggregate_field_type: sub.aggregate_field_type } sub_result_var := g.new_tmp_var() diff --git a/vlib/v/gen/golang/golang.v b/vlib/v/gen/golang/golang.v index f94af27fa..8fa355875 100644 --- a/vlib/v/gen/golang/golang.v +++ b/vlib/v/gen/golang/golang.v @@ -2175,8 +2175,16 @@ pub fn (mut f Gen) sql_expr(node ast.SqlExpr) { f.writeln(' {') f.write('\tselect ') table_name := util.strip_mod_name(f.table.sym(node.table_expr.typ).name) - if node.is_count { - f.write('count ') + if node.aggregate_kind != .none { + match node.aggregate_kind { + .count { + f.write('count ') + } + .sum, .avg, .min, .max { + f.write('${node.aggregate_kind}(${node.aggregate_field}) ') + } + .none {} + } } else { for i, fd in node.fields { f.write(fd.name) diff --git a/vlib/v/parser/orm.v b/vlib/v/parser/orm.v index e203ebee0..fa563803b 100644 --- a/vlib/v/parser/orm.v +++ b/vlib/v/parser/orm.v @@ -5,6 +5,17 @@ module parser import v.ast +fn sql_aggregate_kind_from_name(name string) ast.SqlAggregateKind { + return match name { + 'count' { .count } + 'sum' { .sum } + 'avg' { .avg } + 'min' { .min } + 'max' { .max } + else { .none } + } +} + // select from User // insert user into User returning id fn (mut p Parser) sql_expr() ast.Expr { @@ -27,7 +38,8 @@ fn (mut p Parser) sql_expr() ast.Expr { p.next() // kind := if is_select { ast.SqlExprKind.select_ } else { ast.SqlExprKind.insert } mut inserted_var := '' - mut is_count := false + mut aggregate_kind := ast.SqlAggregateKind.none + mut aggregate_field := '' mut has_distinct := false if is_insert { inserted_var = p.check_name() @@ -41,18 +53,35 @@ fn (mut p Parser) sql_expr() ast.Expr { if n == 'distinct' { has_distinct = true n2 := p.check_name() - is_count = n2 == 'count' + aggregate_kind = sql_aggregate_kind_from_name(n2) } else { - is_count = n == 'count' + aggregate_kind = sql_aggregate_kind_from_name(n) } } mut typ := ast.void_type - if is_count { + if aggregate_kind == .count { n := p.check_name() // from if n != 'from' { p.error('expecting "from" in a "select count" ORM statement') } + } else if aggregate_kind != .none { + if p.tok.kind != .lpar { + p.error('expecting `(` after aggregate function in ORM select') + } + p.next() + if p.tok.kind != .name { + p.error('ORM aggregate functions only support a single field name argument') + } + aggregate_field = p.check_name() + if p.tok.kind != .rpar { + p.error('ORM aggregate functions only support a single field name argument') + } + p.next() + n := p.check_name() // from + if n != 'from' { + p.error('expecting `from` after ORM aggregate function') + } } table_pos := p.tok.pos() @@ -116,8 +145,10 @@ fn (mut p Parser) sql_expr() ast.Expr { offset_expr = p.expr(0) } - if is_count { + if aggregate_kind == .count { typ = ast.int_type + } else if aggregate_kind != .none { + typ = ast.void_type } else if table_type.has_flag(.generic) { typ = ast.new_type(p.table.find_or_register_array(table_type)).set_flag(.generic) } else { @@ -131,30 +162,31 @@ fn (mut p Parser) sql_expr() ast.Expr { p.inside_match = tmp_inside_match return ast.SqlExpr{ - is_count: is_count - is_insert: is_insert - typ: typ.set_flag(.result) - or_expr: or_expr - db_expr: db_expr - where_expr: where_expr - has_where: has_where - has_limit: has_limit - limit_expr: limit_expr - has_offset: has_offset - offset_expr: offset_expr - has_order: has_order - order_expr: order_expr - has_desc: has_desc - has_distinct: has_distinct - is_array: if is_count { false } else { true } - is_generated: false - inserted_var: inserted_var - pos: pos.extend(p.prev_tok.pos()) - table_expr: ast.TypeNode{ + aggregate_kind: aggregate_kind + aggregate_field: aggregate_field + is_insert: is_insert + typ: typ.set_flag(.result) + or_expr: or_expr + db_expr: db_expr + where_expr: where_expr + has_where: has_where + has_limit: has_limit + limit_expr: limit_expr + has_offset: has_offset + offset_expr: offset_expr + has_order: has_order + order_expr: order_expr + has_desc: has_desc + has_distinct: has_distinct + is_array: aggregate_kind == .none + is_generated: false + inserted_var: inserted_var + pos: pos.extend(p.prev_tok.pos()) + table_expr: ast.TypeNode{ typ: table_type pos: table_pos } - joins: joins + joins: joins } } -- 2.39.5