From 796dfe4f51525f9c0ff9c21da3d6747594a798fa Mon Sep 17 00:00:00 2001 From: Chris Watson Date: Fri, 23 Jan 2026 05:52:01 -0700 Subject: [PATCH] orm: add explicit JOIN support (INNER, LEFT, RIGHT, FULL OUTER) (fix #21635) (#26400) --- vlib/orm/orm.v | 35 +++++++++ vlib/orm/orm_join_test.v | 156 +++++++++++++++++++++++++++++++++++++++ vlib/v/ast/ast.v | 19 +++++ vlib/v/checker/orm.v | 151 +++++++++++++++++++++++++++++++++++++ vlib/v/fmt/fmt.v | 18 +++++ vlib/v/gen/c/orm.v | 74 +++++++++++++++++++ vlib/v/parser/orm.v | 69 +++++++++++++++++ 7 files changed, 522 insertions(+) create mode 100644 vlib/orm/orm_join_test.v diff --git a/vlib/orm/orm.v b/vlib/orm/orm.v index ee4612ea5..70fbda92a 100644 --- a/vlib/orm/orm.v +++ b/vlib/orm/orm.v @@ -89,6 +89,32 @@ pub enum OrderType { desc } +// JoinType represents the type of SQL JOIN operation +pub enum JoinType { + inner // INNER JOIN - returns only matching rows + left // LEFT JOIN - returns all left rows, NULL for non-matching right + right // RIGHT JOIN - returns all right rows, NULL for non-matching left + full_outer // FULL OUTER JOIN - returns all rows from both tables +} + +fn (jt JoinType) to_str() string { + return match jt { + .inner { 'INNER JOIN' } + .left { 'LEFT JOIN' } + .right { 'RIGHT JOIN' } + .full_outer { 'FULL OUTER JOIN' } + } +} + +// JoinConfig holds configuration for a JOIN clause in a SELECT query +pub struct JoinConfig { +pub mut: + kind JoinType + table Table + on_left_col string // Column from main table (e.g., 'user_id') + on_right_col string // Column from joined table (e.g., 'id') +} + pub enum SQLDialect { default mysql @@ -182,6 +208,7 @@ pub mut: // has_offset - Add an offset to the result // fields - Fields to select // types - Types to select +// joins - JOIN clauses for this query pub struct SelectConfig { pub mut: table Table @@ -196,6 +223,7 @@ pub mut: has_distinct bool fields []string types []int + joins []JoinConfig // JOIN clauses for this query } // Interfaces gets called from the backend and can be implemented @@ -367,6 +395,13 @@ pub fn orm_select_gen(cfg SelectConfig, q string, num bool, qm string, start_pos str += ' FROM ${q}${cfg.table.name}${q}' + // Generate JOIN clauses + for join in cfg.joins { + str += ' ${join.kind.to_str()} ${q}${join.table.name}${q}' + str += ' ON ${q}${cfg.table.name}${q}.${q}${join.on_left_col}${q}' + str += ' = ${q}${join.table.name}${q}.${q}${join.on_right_col}${q}' + } + mut c := start_pos if cfg.has_where { diff --git a/vlib/orm/orm_join_test.v b/vlib/orm/orm_join_test.v new file mode 100644 index 000000000..87fa85b91 --- /dev/null +++ b/vlib/orm/orm_join_test.v @@ -0,0 +1,156 @@ +// vtest retry: 3 +// vtest build: present_sqlite3? && !windows && !sanitize-memory-clang +import db.sqlite + +// Department table for testing JOINs - using dept_id to avoid column name conflicts +struct Department { + dept_id int @[primary; sql: serial] + dept_name string +} + +// User table with a foreign key reference to Department +struct User { + user_id int @[primary; sql: serial] + user_name string + department_id int +} + +fn test_inner_join() { + db := sqlite.connect(':memory:') or { panic(err) } + + // Create tables + sql db { + create table Department + create table User + }! + + // Insert departments + engineering := Department{ + dept_name: 'Engineering' + } + sales := Department{ + dept_name: 'Sales' + } + sql db { + insert engineering into Department + insert sales into Department + }! + + // Insert users + alice := User{ + user_name: 'Alice' + department_id: 1 + } + bob := User{ + user_name: 'Bob' + department_id: 2 + } + charlie := User{ + user_name: 'Charlie' + department_id: 1 + } + sql db { + insert alice into User + insert bob into User + insert charlie into User + }! + + // Test basic INNER JOIN + users := sql db { + select from User + join Department on User.department_id == Department.dept_id + }! + + assert users.len == 3 +} + +fn test_inner_join_with_where() { + db := sqlite.connect(':memory:') or { panic(err) } + + // Create tables + sql db { + create table Department + create table User + }! + + // Insert departments + engineering := Department{ + dept_name: 'Engineering' + } + sales := Department{ + dept_name: 'Sales' + } + sql db { + insert engineering into Department + insert sales into Department + }! + + // Insert users + alice := User{ + user_name: 'Alice' + department_id: 1 + } + bob := User{ + user_name: 'Bob' + department_id: 2 + } + charlie := User{ + user_name: 'Charlie' + department_id: 1 + } + sql db { + insert alice into User + insert bob into User + insert charlie into User + }! + + // Test INNER JOIN with WHERE clause - use simple field name (not Table.field) + engineering_users := sql db { + select from User + join Department on User.department_id == Department.dept_id where department_id == 1 + }! + + assert engineering_users.len == 2 + assert engineering_users[0].user_name == 'Alice' || engineering_users[0].user_name == 'Charlie' +} + +fn test_left_join() { + db := sqlite.connect(':memory:') or { panic(err) } + + // Create tables + sql db { + create table Department + create table User + }! + + // Insert departments + engineering := Department{ + dept_name: 'Engineering' + } + sql db { + insert engineering into Department + }! + + // Insert users - one with a department, one without (orphan) + alice := User{ + user_name: 'Alice' + department_id: 1 + } + bob := User{ + user_name: 'Bob' + department_id: 999 // No matching department + } + sql db { + insert alice into User + insert bob into User + }! + + // Test LEFT JOIN - should return all users, even those without matching department + users := sql db { + select from User + left join Department on User.department_id == Department.dept_id + }! + + // Both users should be returned since it's a LEFT JOIN + assert users.len == 2 +} diff --git a/vlib/v/ast/ast.v b/vlib/v/ast/ast.v index c375f9a75..56c277564 100644 --- a/vlib/v/ast/ast.v +++ b/vlib/v/ast/ast.v @@ -2288,6 +2288,24 @@ pub mut: end_comments []Comment } +// JoinKind represents the type of SQL JOIN operation +pub enum JoinKind { + inner // INNER JOIN - returns only matching rows + left // LEFT JOIN - returns all left rows, NULL for non-matching right + right // RIGHT JOIN - returns all right rows, NULL for non-matching left + full_outer // FULL OUTER JOIN - returns all rows from both tables +} + +// JoinClause represents a JOIN clause in an SQL SELECT query +pub struct JoinClause { +pub: + kind JoinKind + pos token.Pos +pub mut: + table_expr TypeNode // The table being joined (e.g., Department in `join Department`) + on_expr Expr // The ON condition (e.g., `User.dept_id == Department.id`) +} + pub struct SqlExpr { pub: is_count bool @@ -2315,6 +2333,7 @@ pub mut: fields []StructField sub_structs map[int]SqlExpr or_expr OrExpr + joins []JoinClause // JOIN clauses for this query } pub struct NodeError { diff --git a/vlib/v/checker/orm.v b/vlib/v/checker/orm.v index b82ff82cf..77206087f 100644 --- a/vlib/v/checker/orm.v +++ b/vlib/v/checker/orm.v @@ -161,6 +161,13 @@ fn (mut c Checker) sql_expr(mut node ast.SqlExpr) ast.Type { c.check_where_expr_has_no_pointless_exprs(table_sym, field_names, &node.where_expr) } + // Check JOIN clauses + for mut join in node.joins { + if !c.check_orm_join_clause(mut join, table_sym) { + return ast.void_type + } + } + if node.has_order { if mut node.order_expr is ast.Ident { order_ident_name := node.order_expr.name @@ -797,3 +804,147 @@ fn (mut c Checker) get_non_array_type(typ_ ast.Type) (ast.Type, &ast.TypeSymbol) } return typ, sym } + +// check_orm_join_clause validates a JOIN clause in an ORM query. +// It checks that the joined table type exists and is a struct, +// and validates the ON expression. +fn (mut c Checker) check_orm_join_clause(mut join ast.JoinClause, main_table_sym &ast.TypeSymbol) bool { + // Check that the joined table type exists + if !c.ensure_type_exists(join.table_expr.typ, join.pos) { + return false + } + + join_table_sym := c.table.sym(join.table_expr.typ) + + // Check that the joined table is a struct + if join_table_sym.info !is ast.Struct { + c.orm_error('JOIN table `${join_table_sym.name}` must be a struct type', join.pos) + return false + } + + // Validate the ON expression structure without running full expression checking + // (since Table.field syntax is special in ORM context) + return c.check_orm_join_on_expr(join.on_expr, main_table_sym, join_table_sym) +} + +// check_orm_join_on_expr validates the ON expression of a JOIN clause. +// It expects the form: TableA.fieldA == TableB.fieldB +// where TableA is either the main table or the joined table. +fn (mut c Checker) check_orm_join_on_expr(on_expr ast.Expr, main_table_sym &ast.TypeSymbol, join_table_sym &ast.TypeSymbol) bool { + // The ON expression should be an infix expression (e.g., Table1.field == Table2.field) + if on_expr is ast.InfixExpr { + // Check that the operator is a comparison operator + if on_expr.op !in [.eq, .ne, .lt, .gt, .le, .ge] { + c.orm_error('JOIN ON condition must use a comparison operator (==, !=, <, >, <=, >=)', + on_expr.pos) + return false + } + + // Check left side (should be Table.field format) + if !c.check_orm_join_field_ref(on_expr.left, main_table_sym, join_table_sym) { + return false + } + + // Check right side (should be Table.field format) + if !c.check_orm_join_field_ref(on_expr.right, main_table_sym, join_table_sym) { + return false + } + + return true + } else if on_expr !is ast.EmptyExpr { + c.orm_error('JOIN ON condition must be a comparison expression (e.g., Table1.field == Table2.field)', + on_expr.pos()) + return false + } + + return true +} + +// check_orm_join_field_ref validates that an expression is a valid Table.field reference +// for a JOIN ON condition. The table must be either the main table or the joined table. +fn (mut c Checker) check_orm_join_field_ref(expr ast.Expr, main_table_sym &ast.TypeSymbol, join_table_sym &ast.TypeSymbol) bool { + // Handle SelectorExpr (e.g., User.department_id) + if expr is ast.SelectorExpr { + // Get the table name from the selector's left side + mut table_name := '' + if expr.expr is ast.Ident { + table_name = expr.expr.name + } else if expr.expr is ast.TypeNode { + table_name = c.table.sym(expr.expr.typ).name + } + + // Check if the table name matches either the main table or the joined table + main_table_name := util.strip_mod_name(main_table_sym.name) + join_table_name := util.strip_mod_name(join_table_sym.name) + + // Determine which table to check the field against + is_main_table := table_name == main_table_name + is_join_table := table_name == join_table_name + + if !is_main_table && !is_join_table { + c.orm_error('table `${table_name}` in JOIN ON condition must be either `${main_table_name}` or `${join_table_name}`', + expr.pos) + return false + } + + // Check if the field exists in the target table + field_name := expr.field_name + if is_main_table { + if !main_table_sym.has_field(field_name) { + c.orm_error('field `${field_name}` does not exist in table `${main_table_name}`', + expr.pos) + return false + } + } else { + if !join_table_sym.has_field(field_name) { + c.orm_error('field `${field_name}` does not exist in table `${join_table_name}`', + expr.pos) + return false + } + } + + return true + } + + // Handle EnumVal - this happens when the parser sees Type.field as an enum access + // In ORM context, we need to reinterpret this as a table field reference + if expr is ast.EnumVal { + // The enum_name is the table name (e.g., "User") + // The val is the field name (e.g., "department_id") + table_name := util.strip_mod_name(expr.enum_name) + field_name := expr.val + + main_table_name := util.strip_mod_name(main_table_sym.name) + join_table_name := util.strip_mod_name(join_table_sym.name) + + is_main_table := table_name == main_table_name + is_join_table := table_name == join_table_name + + if !is_main_table && !is_join_table { + c.orm_error('table `${table_name}` in JOIN ON condition must be either `${main_table_name}` or `${join_table_name}`', + expr.pos) + return false + } + + // Check if the field exists in the target table + if is_main_table { + if !main_table_sym.has_field(field_name) { + c.orm_error('field `${field_name}` does not exist in table `${main_table_name}`', + expr.pos) + return false + } + } else { + if !join_table_sym.has_field(field_name) { + c.orm_error('field `${field_name}` does not exist in table `${join_table_name}`', + expr.pos) + return false + } + } + + return true + } + + c.orm_error('JOIN ON condition expects Table.field format (got ${typeof(expr).name})', + expr.pos()) + return false +} diff --git a/vlib/v/fmt/fmt.v b/vlib/v/fmt/fmt.v index b53aa4730..4e208bc40 100644 --- a/vlib/v/fmt/fmt.v +++ b/vlib/v/fmt/fmt.v @@ -3106,6 +3106,24 @@ pub fn (mut f Fmt) sql_expr(node ast.SqlExpr) { } else { f.write('from ${table_name}') } + // Format JOIN clauses + for join in node.joins { + f.writeln('') + f.write('\t') + match join.kind { + .inner { f.write('join ') } + .left { f.write('left join ') } + .right { f.write('right join ') } + .full_outer { f.write('full outer join ') } + } + join_sym := f.table.sym(join.table_expr.typ) + mut join_table_name := join_sym.name + if !join_table_name.starts_with('C.') && !join_table_name.starts_with('JS.') { + join_table_name = f.no_cur_mod(f.short_module(join_sym.name)) + } + f.write('${join_table_name} on ') + f.expr(join.on_expr) + } if node.has_where { f.write(' where ') f.expr(node.where_expr) diff --git a/vlib/v/gen/c/orm.v b/vlib/v/gen/c/orm.v index a616abc84..28de0fd09 100644 --- a/vlib/v/gen/c/orm.v +++ b/vlib/v/gen/c/orm.v @@ -1027,6 +1027,8 @@ fn (mut g Gen) write_orm_select(node ast.SqlExpr, connection_var_name string, re } g.indent-- g.writeln('),') + // Generate JOIN clauses array + g.write_orm_joins(node.joins) g.indent-- g.writeln('},') @@ -1389,3 +1391,75 @@ fn get_auto_field_idxs(fields []ast.StructField) []int { } return ret } + +// write_orm_joins writes C code for the joins array in SelectConfig +fn (mut g Gen) write_orm_joins(joins []ast.JoinClause) { + if joins.len == 0 { + g.writeln('.joins = builtin____new_array_with_default_noscan(0, 0, sizeof(orm__JoinConfig), 0),') + return + } + + g.writeln('.joins = builtin__new_array_from_c_array(${joins.len}, ${joins.len}, sizeof(orm__JoinConfig),') + g.indent++ + g.writeln('_MOV((orm__JoinConfig[${joins.len}]){') + g.indent++ + + for join in joins { + g.writeln('(orm__JoinConfig){') + g.indent++ + + // Write join kind + kind_str := match join.kind { + .inner { 'orm__JoinType__inner' } + .left { 'orm__JoinType__left' } + .right { 'orm__JoinType__right' } + .full_outer { 'orm__JoinType__full_outer' } + } + g.writeln('.kind = ${kind_str},') + + // Write joined table info + g.write('.table = ') + g.write_orm_table_struct(join.table_expr.typ) + g.writeln(',') + + // Extract column names from the ON expression (should be an InfixExpr) + left_col, right_col := g.extract_join_columns(join.on_expr) + g.writeln('.on_left_col = _S("${left_col}"),') + g.writeln('.on_right_col = _S("${right_col}"),') + + g.indent-- + g.writeln('},') + } + + g.indent-- + g.writeln('})') + g.indent-- + g.writeln('),') +} + +// extract_join_columns extracts the left and right column names from a JOIN ON expression. +// The ON expression is expected to be an InfixExpr like: User.dept_id == Department.id +// Returns (left_col, right_col) where left_col is from the main table, right_col is from the joined table. +fn (g &Gen) extract_join_columns(on_expr ast.Expr) (string, string) { + if on_expr is ast.InfixExpr { + left_col := g.extract_join_field_name(on_expr.left) + right_col := g.extract_join_field_name(on_expr.right) + return left_col, right_col + } + + // Fallback: return empty strings if the expression is not the expected format + return '', '' +} + +// extract_join_field_name extracts a field name from a JOIN ON expression operand. +// Handles both SelectorExpr (Table.field) and EnumVal (when parser interprets Type.field as enum). +fn (g &Gen) extract_join_field_name(expr ast.Expr) string { + if expr is ast.SelectorExpr { + return expr.field_name + } + if expr is ast.EnumVal { + // EnumVal.val contains the field name (e.g., "department_id" from "User.department_id") + return expr.val + } + return '' +} diff --git a/vlib/v/parser/orm.v b/vlib/v/parser/orm.v index d441be6df..e203ebee0 100644 --- a/vlib/v/parser/orm.v +++ b/vlib/v/parser/orm.v @@ -58,6 +58,15 @@ fn (mut p Parser) sql_expr() ast.Expr { table_pos := p.tok.pos() table_type := p.parse_type() // `User` + // Parse JOIN clauses (e.g., `join Department on User.dept_id == Department.id`) + mut joins := []ast.JoinClause{} + for p.tok.kind == .name && p.tok.lit in ['join', 'left', 'right', 'full', 'inner'] { + join_clause := p.parse_sql_join_clause() + if join_clause.table_expr.typ != ast.void_type { + joins << join_clause + } + } + mut where_expr := ast.empty_expr has_where := p.tok.kind == .name && p.tok.lit == 'where' @@ -145,6 +154,66 @@ fn (mut p Parser) sql_expr() ast.Expr { typ: table_type pos: table_pos } + joins: joins + } +} + +// parse_sql_join_clause parses a JOIN clause like: +// `join Department on User.dept_id == Department.id` +// `left join Department on User.dept_id == Department.id` +// `inner join Department on User.dept_id == Department.id` +fn (mut p Parser) parse_sql_join_clause() ast.JoinClause { + mut kind := ast.JoinKind.inner + join_pos := p.tok.pos() + + // Check for join type prefix (left, right, full, inner) + if p.tok.lit == 'left' { + kind = .left + p.next() + } else if p.tok.lit == 'right' { + kind = .right + p.next() + } else if p.tok.lit == 'full' { + kind = .full_outer + p.next() + // Handle optional 'outer' keyword + if p.tok.kind == .name && p.tok.lit == 'outer' { + p.next() + } + } else if p.tok.lit == 'inner' { + // 'inner' is optional, just skip it + p.next() + } + + // Now expect 'join' + if p.tok.kind != .name || p.tok.lit != 'join' { + p.error('expected `join` keyword after join type') + return ast.JoinClause{} + } + p.next() // consume 'join' + + // Parse the joined table type + table_pos := p.tok.pos() + table_type := p.parse_type() + + // Expect 'on' keyword + if p.tok.kind != .name || p.tok.lit != 'on' { + p.error('expected `on` keyword after table name in JOIN clause') + return ast.JoinClause{} + } + p.next() // consume 'on' + + // Parse the ON condition expression + on_expr := p.expr(0) + + return ast.JoinClause{ + kind: kind + pos: join_pos + table_expr: ast.TypeNode{ + typ: table_type + pos: table_pos + } + on_expr: on_expr } } -- 2.39.5