Skip to content

Commit 1ce6511

Browse files
committed
Add SELECT * projection: bare * expands to all columns from all FROM sources in order, t.* filters to one source. StarProjection placeholder lands in the projection list at parse time and ExpandStars replaces it with per-column Reference expressions (qualified by each source's alias) after FROM is parsed, so multi-source * keeps duplicates without tripping Msg 209. Unbound qualifier raises Msg 4104; the * multiplication operator and count(*) paths are untouched.
1 parent 73f58c1 commit 1ce6511

7 files changed

Lines changed: 216 additions & 2 deletions

File tree

CLAUDE.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,9 @@ APPLY is the lateral form: the right side is a derived-table SELECT re-executed
165165

166166
`JoinDriver`'s lateral branch handles both APPLY (CrossApply / OuterApply, no ON predicate) and ordinary derived tables in INNER / LEFT / CROSS join slots — the latter apply their `ON` predicate after the inner row materializes, and LEFT joins null-fill on no-match the same way OUTER APPLY does. Leftmost-source lateral plans run from the surrounding `outerResolver` directly (no per-tuple feedback at level 0).
167167

168+
### `SELECT *` projection
169+
Bare `*` and qualified `<source>.*` both work. `*` expands to every column of every FROM source in source order; `t.*` filters to one source. Multi-source `*` keeps duplicate column names (e.g. a join on a same-named key emits the key twice). Expansion happens after FROM is parsed via `ExpandStars` in `Selection.cs`, replacing the placeholder `StarProjection` in the projection list with per-column `Reference` expressions qualified by each source's alias / table name — qualifying is what lets duplicates land without tripping Msg 209's ambiguous-column check. Unbound `<qualifier>.*` raises Msg 4104. The `*` *operator* (multiplication) is unchanged: it's only intercepted as star when it appears at projection-element-start position, so `select 5 * 3` still parses as multiplication and `count(*)` continues through its own AggregateExpression path.
170+
168171
### CASE
169172
Searched (`CASE WHEN cond THEN ... [ELSE ...] END`) and simple (`CASE input WHEN val ...`). Branches evaluate in order; first true predicate wins. UNKNOWN excludes (matches WHERE); simple-form `CASE NULL WHEN NULL` falls through. Result type computed via `SqlType.Promote` across all THEN/ELSE, cached on first `GetSqlType`; `Run` coerces matched values to the common type. No-match-no-ELSE → typed NULL.
170173

SqlServerSimulator.Tests.EFCore/EFCoreFromSql.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ public void FromSqlInterpolated_StringParameterAgainstIntColumn_PromotesAndMatch
3939
using var context = SeededContext();
4040
var idAsString = "2";
4141
var customer = context.Customers
42-
.FromSqlInterpolated($"select Id, Name from Customers where Id = {idAsString}")
42+
.FromSqlInterpolated($"select * from Customers where Id = {idAsString}")
4343
.Single();
4444
Assert.AreEqual(2, customer.Id);
4545
Assert.AreEqual("beta", customer.Name);

SqlServerSimulator.Tests/SelectTests.cs

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,4 +364,97 @@ public void UnaryMinusInProjection_PreservesAlias()
364364
Assert.AreEqual(-1, reader[0]);
365365
Assert.AreEqual("n", reader.GetName(0));
366366
}
367+
368+
// ─── SELECT * projection ───────────────────────────────────────────────
369+
370+
[TestMethod]
371+
public void SelectStar_SingleSource_ProjectsAllColumnsInDeclaredOrder()
372+
{
373+
using var connection = new Simulation().CreateOpenConnection();
374+
_ = connection.CreateCommand("create table t (a int, b int, c int)").ExecuteNonQuery();
375+
_ = connection.CreateCommand("insert t values (1, 2, 3)").ExecuteNonQuery();
376+
using var reader = connection.CreateCommand("select * from t").ExecuteReader();
377+
Assert.IsTrue(reader.Read());
378+
Assert.AreEqual(3, reader.FieldCount);
379+
Assert.AreEqual("a", reader.GetName(0));
380+
Assert.AreEqual("b", reader.GetName(1));
381+
Assert.AreEqual("c", reader.GetName(2));
382+
Assert.AreEqual(1, reader[0]);
383+
Assert.AreEqual(2, reader[1]);
384+
Assert.AreEqual(3, reader[2]);
385+
}
386+
387+
[TestMethod]
388+
public void SelectStar_WithWhereClause_FiltersRows()
389+
{
390+
using var connection = new Simulation().CreateOpenConnection();
391+
_ = connection.CreateCommand("create table t (a int, b int)").ExecuteNonQuery();
392+
_ = connection.CreateCommand("insert t values (1, 10), (2, 20), (3, 30)").ExecuteNonQuery();
393+
using var reader = connection.CreateCommand("select * from t where a >= 2").ExecuteReader();
394+
var values = new List<int>();
395+
while (reader.Read())
396+
values.Add(reader.GetInt32(1));
397+
CollectionAssert.AreEqual(new[] { 20, 30 }, values);
398+
}
399+
400+
[TestMethod]
401+
public void SelectStar_TwoSourceJoin_IncludesDuplicateColumnNames()
402+
{
403+
// Multi-source `*` includes same-named columns from each source —
404+
// SQL Server keeps the duplicate (no auto-disambiguation), and the
405+
// qualified per-source References emitted by star-expansion let
406+
// the resolver bind each duplicate without raising Msg 209.
407+
using var connection = new Simulation().CreateOpenConnection();
408+
_ = connection.CreateCommand("create table t (a int, b int)").ExecuteNonQuery();
409+
_ = connection.CreateCommand("create table u (a int, c int)").ExecuteNonQuery();
410+
_ = connection.CreateCommand("insert t values (1, 2)").ExecuteNonQuery();
411+
_ = connection.CreateCommand("insert u values (1, 9)").ExecuteNonQuery();
412+
using var reader = connection.CreateCommand("select * from t inner join u on t.a = u.a").ExecuteReader();
413+
Assert.IsTrue(reader.Read());
414+
Assert.AreEqual(4, reader.FieldCount);
415+
Assert.AreEqual("a", reader.GetName(0));
416+
Assert.AreEqual("b", reader.GetName(1));
417+
Assert.AreEqual("a", reader.GetName(2));
418+
Assert.AreEqual("c", reader.GetName(3));
419+
}
420+
421+
[TestMethod]
422+
public void SelectQualifiedStar_RestrictsToNamedSource()
423+
{
424+
using var connection = new Simulation().CreateOpenConnection();
425+
_ = connection.CreateCommand("create table t (a int, b int)").ExecuteNonQuery();
426+
_ = connection.CreateCommand("create table u (a int, c int)").ExecuteNonQuery();
427+
_ = connection.CreateCommand("insert t values (1, 2)").ExecuteNonQuery();
428+
_ = connection.CreateCommand("insert u values (1, 9)").ExecuteNonQuery();
429+
using var reader = connection.CreateCommand("select t.* from t inner join u on t.a = u.a").ExecuteReader();
430+
Assert.IsTrue(reader.Read());
431+
Assert.AreEqual(2, reader.FieldCount);
432+
Assert.AreEqual("a", reader.GetName(0));
433+
Assert.AreEqual("b", reader.GetName(1));
434+
}
435+
436+
[TestMethod]
437+
public void SelectStar_InsideDerivedTable_OuterReferencesProjectedColumns()
438+
{
439+
// The inner `select *` expands against the inner FROM's columns,
440+
// and the outer query then references those columns through the
441+
// derived-table alias — the FromSqlInterpolated wrap shape EF Core
442+
// emits.
443+
using var connection = new Simulation().CreateOpenConnection();
444+
_ = connection.CreateCommand("create table t (a int, b int)").ExecuteNonQuery();
445+
_ = connection.CreateCommand("insert t values (1, 2), (3, 4)").ExecuteNonQuery();
446+
using var reader = connection.CreateCommand("select x.a from (select * from t) as x where x.a = 3").ExecuteReader();
447+
Assert.IsTrue(reader.Read());
448+
Assert.AreEqual(3, reader[0]);
449+
Assert.IsFalse(reader.Read());
450+
}
451+
452+
[TestMethod]
453+
public void SelectQualifiedStar_UnboundQualifier_RaisesMsg4104()
454+
{
455+
using var connection = new Simulation().CreateOpenConnection();
456+
_ = connection.CreateCommand("create table t (a int)").ExecuteNonQuery();
457+
var ex = Assert.Throws<DbException>(() => connection.CreateCommand("select notbound.* from t").ExecuteReader().Read());
458+
Assert.AreEqual("The multi-part identifier \"notbound.*\" could not be bound.", ex.Message);
459+
}
367460
}

SqlServerSimulator/Parser/Expression.cs

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,25 @@ public static Expression Parse(ParserContext context)
113113
if (expression is not Reference reference)
114114
throw SimulatedSqlException.SyntaxErrorNear(context);
115115

116-
reference.AddMultiPartComponent(context.GetNextRequired<Name>());
116+
var afterDot = context.GetNextRequired();
117+
if (afterDot is Operator { Character: '*' })
118+
{
119+
// <qualifier>.* — convert the Reference into a
120+
// StarProjection placeholder. Selection.ParseInner
121+
// expands it once the FROM sources are known; if
122+
// it survives into a non-projection context, the
123+
// placeholder's Run / GetSqlType raise the
124+
// surface-not-supported error.
125+
expression = new StarProjection(reference.Name);
126+
}
127+
else if (afterDot is Name name)
128+
{
129+
reference.AddMultiPartComponent(name);
130+
}
131+
else
132+
{
133+
throw SimulatedSqlException.SyntaxErrorNear(context);
134+
}
117135
}
118136
continue;
119137
case Operator { Character: ')' }:

SqlServerSimulator/Parser/Expressions/Reference.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,18 @@ public Reference(string name)
2222
this.name = new MultiPartName(name);
2323
}
2424

25+
/// <summary>
26+
/// Two-part reference (<c>qualifier.column</c>). Used by star-expansion
27+
/// in <see cref="Selection"/> to emit per-column references qualified by
28+
/// the FROM source's alias / table name, so multi-source <c>SELECT *</c>
29+
/// includes same-named columns from different sources without triggering
30+
/// Msg 209.
31+
/// </summary>
32+
public Reference(string qualifier, string column)
33+
{
34+
this.name = new MultiPartName(qualifier).WithAddedPart(column);
35+
}
36+
2537
public override string Name => this.name.Leaf;
2638

2739
public void AddMultiPartComponent(Name next) => this.name = this.name.WithAddedPart(next.Value);
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
using SqlServerSimulator.Storage;
2+
3+
namespace SqlServerSimulator.Parser.Expressions;
4+
5+
/// <summary>
6+
/// Placeholder expression for <c>*</c> (or <c>&lt;qualifier&gt;.*</c>) in a
7+
/// SELECT projection list. Replaced by per-column <see cref="Reference"/>
8+
/// expressions in <see cref="Selection"/> after the FROM clause is parsed and
9+
/// the source columns are known. The placeholder can't participate in any
10+
/// other expression context — <see cref="Run"/> / <see cref="GetSqlType"/>
11+
/// throw if it ever survives into evaluation (e.g. <c>WHERE *</c>,
12+
/// <c>SELECT * + 1</c>, or <c>SELECT *</c> with no FROM).
13+
/// </summary>
14+
internal sealed class StarProjection(string? qualifier) : Expression
15+
{
16+
public readonly string? Qualifier = qualifier;
17+
18+
public override SqlValue Run(Func<MultiPartName, SqlValue> getColumnValue) =>
19+
throw new NotSupportedException("'*' is only valid as a top-level SELECT projection element.");
20+
21+
public override SqlType GetSqlType(Func<MultiPartName, SqlType> resolveColumnType) =>
22+
throw new NotSupportedException("'*' is only valid as a top-level SELECT projection element.");
23+
24+
internal override string DebugDisplay() => this.Qualifier is { } q ? $"{q}.*" : "*";
25+
}

SqlServerSimulator/Parser/Selection.cs

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,15 @@ private static Selection ParseInner(ParserContext context, uint depth, List<Aggr
315315
throw SimulatedSqlException.SyntaxErrorNear(context);
316316
goto ExitWhileTokenLoop;
317317

318+
// Bare `*` as a projection element (the first thing after
319+
// SELECT, or the first thing after a comma). Within a
320+
// projected expression, `*` is the multiplication operator and
321+
// is handled by Expression.Parse's binary loop instead.
322+
case Operator { Character: '*' }:
323+
expressions.Add(new StarProjection(null));
324+
context.MoveNextOptional();
325+
break;
326+
318327
default:
319328
expressions.Add(Expression.Parse(context));
320329
break;
@@ -359,6 +368,7 @@ private static Selection ParseInner(ParserContext context, uint depth, List<Aggr
359368
ParseFromSourceAndJoins(context, depth, sources, joins, fromClause, outerTypeResolver, allowOrderBy);
360369
if (topCount is not null && fromClause.OffsetCount is not null)
361370
throw SimulatedSqlException.TopAndOffsetMutuallyExclusive();
371+
ExpandStars(expressions, sources);
362372
return BuildSqlProjection([.. sources], [.. joins], expressions, fromClause, distinct, topCount, aggregates, windows, outerTypeResolver);
363373

364374
case ReservedKeyword { Keyword: Keyword.Where }:
@@ -955,6 +965,59 @@ outerResolver is not null
955965
return [RowEncoder.EncodeRow(schema, values)];
956966
});
957967
}
968+
969+
/// <summary>
970+
/// Expands any <see cref="StarProjection"/> markers in the projection
971+
/// list into per-column <see cref="Reference"/> expressions, using each
972+
/// FROM source's <see cref="FromSource.Qualifier"/> to disambiguate
973+
/// same-named columns across sources (so multi-source <c>SELECT *</c>
974+
/// doesn't trip Msg 209). Bare <c>*</c> emits every column from every
975+
/// source in source order; <c>&lt;qualifier&gt;.*</c> filters to the
976+
/// named source. An unbound qualifier raises Msg 4104.
977+
/// </summary>
978+
private static void ExpandStars(List<Expression> expressions, List<FromSource> sources)
979+
{
980+
for (var i = expressions.Count - 1; i >= 0; i--)
981+
{
982+
if (expressions[i] is not StarProjection star)
983+
continue;
984+
985+
var expanded = new List<Expression>();
986+
if (star.Qualifier is null)
987+
{
988+
foreach (var source in sources)
989+
AppendSourceColumns(expanded, source);
990+
}
991+
else
992+
{
993+
FromSource? matched = null;
994+
foreach (var source in sources)
995+
{
996+
if (source.Qualifier is { } q && Collation.Default.Equals(q, star.Qualifier))
997+
{
998+
matched = source;
999+
break;
1000+
}
1001+
}
1002+
if (matched is null)
1003+
throw SimulatedSqlException.MultiPartIdentifierCouldNotBeBound($"{star.Qualifier}.*");
1004+
AppendSourceColumns(expanded, matched);
1005+
}
1006+
1007+
expressions.RemoveAt(i);
1008+
expressions.InsertRange(i, expanded);
1009+
}
1010+
1011+
static void AppendSourceColumns(List<Expression> destination, FromSource source)
1012+
{
1013+
foreach (var col in source.ColumnNames)
1014+
{
1015+
destination.Add(source.Qualifier is { } q
1016+
? new Reference(q, col)
1017+
: new Reference(col));
1018+
}
1019+
}
1020+
}
9581021
}
9591022

9601023
/// <summary>

0 commit comments

Comments
 (0)