Skip to content

Commit b48d79e

Browse files
committed
Implemented COLUMNPROPERTY, INDEXPROPERTY, TYPEPROPERTY, OBJECTPROPERTYEX.
1 parent 5e1a836 commit b48d79e

10 files changed

Lines changed: 1179 additions & 8 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ Per-feature deep-dives live under `docs/claude/`. Each entry below is a trigger:
149149
- **CTE shapes / recursive-CTE error handling**[`ctes.md`](docs/claude/ctes.md).
150150
- **JSON_VALUE / JSON_QUERY / JSON_MODIFY / JSON_OBJECT / JSON_ARRAY / JSON_PATH_EXISTS / ISJSON / OPENJSON**[`json.md`](docs/claude/json.md).
151151
- **Name resolution, schema lookup, CREATE / DROP / ALTER SCHEMA TRANSFER, `OBJECT_ID` / `OBJECT_NAME` / `OBJECT_SCHEMA_NAME` / `SCHEMA_ID` / `SCHEMA_NAME` / `DB_ID` / `DB_NAME`, cross-DB read routing**[`schemas.md`](docs/claude/schemas.md).
152-
- **System metadata surfaces** (sys.* / INFORMATION_SCHEMA.*, function-form lookups: `OBJECTPROPERTY` / `SERVERPROPERTY` / `COL_LENGTH` / `COL_NAME` / `TYPE_NAME` / `TYPE_ID` / `PARSENAME`) → [`catalog-views.md`](docs/claude/catalog-views.md).
152+
- **System metadata surfaces** (sys.* / INFORMATION_SCHEMA.*, function-form lookups: `OBJECTPROPERTY` / `OBJECTPROPERTYEX` / `COLUMNPROPERTY` / `INDEXPROPERTY` / `TYPEPROPERTY` / `SERVERPROPERTY` / `COL_LENGTH` / `COL_NAME` / `TYPE_NAME` / `TYPE_ID` / `PARSENAME`) → [`catalog-views.md`](docs/claude/catalog-views.md).
153153
- **Scalar UDFs / TVFs / views / stored procs / dynamic SQL / `@@NESTLEVEL` / `@@PROCID`**[`programmable.md`](docs/claude/programmable.md).
154154
- **`#foo` / `##foo` routing, DROP TABLE, TRUNCATE TABLE**[`temp-tables.md`](docs/claude/temp-tables.md).
155155
- **`DECLARE @t TABLE`, table-variable DML, `OUTPUT … INTO`**[`table-variables.md`](docs/claude/table-variables.md).

SqlServerSimulator.Tests/PropertyFunctionsTests.cs

Lines changed: 447 additions & 0 deletions
Large diffs are not rendered by default.

SqlServerSimulator/Parser/Expression.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -722,20 +722,23 @@ private static Expression ResolveBuiltIn(string name, ParserContext context)
722722
"ERROR_NUMBER" => new ErrorNumberFunction(context),
723723
"ROWCOUNT_BIG" => new RowCountBig(context),
724724
"SWITCHOFFSET" => new SwitchOffset(context),
725+
"TYPEPROPERTY" => new TypeProperty(context),
725726
_ => null
726727
},
727728
13 => uppercaseName switch
728729
{
729730
"DATEFROMPARTS" => new DatePartsBuilder(context, DatePartsBuilderKind.DateFromParts),
730731
"ERROR_MESSAGE" => new ErrorMessageFunction(context),
731732
"IDENT_CURRENT" => new IdentCurrent(context),
733+
"INDEXPROPERTY" => new IndexProperty(context),
732734
"IS_ROLEMEMBER" => new RoleMemberCheck(context),
733735
"STRING_ESCAPE" => new StringEscape(context),
734736
"TIMEFROMPARTS" => new DatePartsBuilder(context, DatePartsBuilderKind.TimeFromParts),
735737
_ => null
736738
},
737739
14 => uppercaseName switch
738740
{
741+
"COLUMNPROPERTY" => new ColumnProperty(context),
739742
"ERROR_SEVERITY" => new ErrorSeverityFunction(context),
740743
"OBJECTPROPERTY" => new ObjectProperty(context),
741744
"ORIGINAL_LOGIN" => new OriginalLogin(context),
@@ -755,6 +758,7 @@ private static Expression ResolveBuiltIn(string name, ParserContext context)
755758
{
756759
"IS_SRVROLEMEMBER" => new RoleMemberCheck(context),
757760
"JSON_PATH_EXISTS" => new JsonPathExists(context),
761+
"OBJECTPROPERTYEX" => new ObjectPropertyEx(context),
758762
"ORIGINAL_DB_NAME" => new OriginalDbName(context),
759763
"TODATETIMEOFFSET" => new ToDateTimeOffset(context),
760764
_ => null
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
using SqlServerSimulator.Storage;
2+
3+
namespace SqlServerSimulator.Parser.Expressions;
4+
5+
/// <summary>
6+
/// SQL <c>COLUMNPROPERTY(table_or_proc_id, 'column_or_param_name', 'property')</c>:
7+
/// per-column metadata flags / counts for a table column. Returns <c>int</c>;
8+
/// unknown property / column / id / NULL on any arg → NULL (matches real
9+
/// SQL Server, probe-confirmed 2026-05-23). Property names and column names
10+
/// are both case-insensitive.
11+
/// </summary>
12+
/// <remarks>
13+
/// <para>
14+
/// Shipped properties (all probe-confirmed against SQL Server 2025):
15+
/// <list type="bullet">
16+
/// <item><description><c>AllowsNull</c> — 1 / 0 from <see cref="HeapColumn.Nullable"/>.</description></item>
17+
/// <item><description><c>IsIdentity</c> — 1 / 0 from <see cref="HeapColumn.Identity"/>.</description></item>
18+
/// <item><description><c>IsComputed</c> — 1 / 0 from <see cref="HeapColumn.Computed"/>.</description></item>
19+
/// <item><description><c>IsRowGuidCol</c> — always 0 (ROWGUIDCOL not modeled).</description></item>
20+
/// <item><description><c>Precision</c> — type-dependent: integer family yields
21+
/// (decimal-equivalent precision), <c>varchar(N)</c> / <c>nvarchar(N)</c> yield
22+
/// <c>N</c>, money family yields 19 / 10.</description></item>
23+
/// <item><description><c>Scale</c> — type-dependent: integer family yields 0,
24+
/// money yields 4, decimal yields the declared scale.</description></item>
25+
/// <item><description><c>CharMaxLen</c> — <c>N</c> for <c>varchar(N)</c> /
26+
/// <c>nvarchar(N)</c>; NULL for non-character types (matches real, which
27+
/// returns -1 on the catalog view but NULL through COLUMNPROPERTY).</description></item>
28+
/// <item><description><c>ColumnId</c> — 1-based declaration ordinal in
29+
/// <see cref="HeapTable.Columns"/>.</description></item>
30+
/// <item><description><c>UsesAnsiTrim</c> — 1 for character types (the
31+
/// simulator's ANSI-trim behavior is always on), 0 otherwise.</description></item>
32+
/// </list>
33+
/// Unsupported / physical-storage properties (<c>IsDeterministic</c>,
34+
/// <c>IsIndexable</c>, <c>IsPrecise</c>, <c>IsSparse</c>, <c>IsColumnSet</c>,
35+
/// <c>StatisticalSemantics</c>, <c>GeneratedAlwaysType</c>) return NULL —
36+
/// callers reading them on a real server with no stats / column-set also get
37+
/// NULL, so this matches the common case.
38+
/// </para>
39+
/// </remarks>
40+
internal sealed class ColumnProperty : Expression
41+
{
42+
private readonly Expression idArg;
43+
private readonly Expression columnArg;
44+
private readonly Expression propertyArg;
45+
46+
public ColumnProperty(ParserContext context)
47+
{
48+
this.idArg = Parse(context);
49+
if (context.Token is not Tokens.Operator { Character: ',' })
50+
throw SimulatedSqlException.SyntaxErrorNear(context);
51+
this.columnArg = Parse(context.MoveNextRequiredReturnSelf());
52+
if (context.Token is not Tokens.Operator { Character: ',' })
53+
throw SimulatedSqlException.SyntaxErrorNear(context);
54+
this.propertyArg = Parse(context.MoveNextRequiredReturnSelf());
55+
if (context.Token is not Tokens.Operator { Character: ')' })
56+
throw SimulatedSqlException.SyntaxErrorNear(context);
57+
}
58+
59+
public override SqlValue Run(RuntimeContext runtime)
60+
{
61+
var idValue = this.idArg.Run(runtime);
62+
var columnValue = this.columnArg.Run(runtime);
63+
var propValue = this.propertyArg.Run(runtime);
64+
if (idValue.IsNull || columnValue.IsNull || propValue.IsNull)
65+
return SqlValue.Null(SqlType.Int32);
66+
var id = idValue.CoerceTo(SqlType.Int32).AsInt32;
67+
var columnName = columnValue.CoerceTo(SqlType.NVarchar).AsString;
68+
var prop = propValue.CoerceTo(SqlType.NVarchar).AsString;
69+
70+
if (ObjectProperty.FindObject(runtime.Batch.CurrentDatabase, id) is not HeapTable table)
71+
return SqlValue.Null(SqlType.Int32);
72+
73+
var (column, ordinal) = FindColumn(table, columnName);
74+
return column is null
75+
? SqlValue.Null(SqlType.Int32)
76+
: EvaluateColumnProperty(column, ordinal, prop) is int result
77+
? SqlValue.FromInt32(result)
78+
: SqlValue.Null(SqlType.Int32);
79+
}
80+
81+
private static (HeapColumn? Column, int Ordinal) FindColumn(HeapTable table, string columnName)
82+
{
83+
for (var i = 0; i < table.Columns.Length; i++)
84+
{
85+
if (Collation.Baseline.Equals(table.Columns[i].Name, columnName))
86+
return (table.Columns[i], i + 1);
87+
}
88+
return (null, 0);
89+
}
90+
91+
private static int? EvaluateColumnProperty(HeapColumn column, int ordinal, string property)
92+
{
93+
Span<char> upper = stackalloc char[property.Length];
94+
return property.AsSpan().ToUpperInvariant(upper) switch
95+
{
96+
5 => upper switch { "SCALE" => GetScale(column.Type), _ => null },
97+
8 => upper switch
98+
{
99+
"COLUMNID" => ordinal,
100+
_ => null,
101+
},
102+
9 => upper switch
103+
{
104+
"PRECISION" => GetPrecision(column),
105+
_ => null,
106+
},
107+
10 => upper switch
108+
{
109+
"ALLOWSNULL" => column.Nullable ? 1 : 0,
110+
"ISCOMPUTED" => column.Computed is null ? 0 : 1,
111+
"ISIDENTITY" => column.Identity is null ? 0 : 1,
112+
"CHARMAXLEN" => GetCharMaxLen(column),
113+
_ => null,
114+
},
115+
12 => upper switch
116+
{
117+
"ISROWGUIDCOL" => 0,
118+
"USESANSITRIM" => SqlType.IsStringCategory(column.Type) ? 1 : 0,
119+
_ => null,
120+
},
121+
_ => null,
122+
};
123+
}
124+
125+
private static int GetPrecision(HeapColumn column) => column.Type switch
126+
{
127+
{ Category: SqlTypeCategory.Integer } t => SqlType.IntegerAsDecimal(t).Precision,
128+
{ Category: SqlTypeCategory.Money } t => SqlType.MoneyAsDecimal(t).Precision,
129+
DecimalSqlType d => d.precision,
130+
_ when SqlType.IsStringCategory(column.Type)
131+
&& column.MaxLength is int n
132+
&& n != SqlType.MaxLengthSentinel => n,
133+
_ => 0,
134+
};
135+
136+
private static int GetScale(SqlType type) => type switch
137+
{
138+
{ Category: SqlTypeCategory.Money } t => SqlType.MoneyAsDecimal(t).Scale,
139+
DecimalSqlType d => d.scale,
140+
_ => 0,
141+
};
142+
143+
private static int? GetCharMaxLen(HeapColumn column) =>
144+
SqlType.IsStringCategory(column.Type) && column.MaxLength is int n && n != SqlType.MaxLengthSentinel
145+
? n
146+
: null;
147+
148+
public override SqlType GetSqlType(BatchContext batch, Func<MultiPartName, SqlType> resolveColumnType) => SqlType.Int32;
149+
150+
internal override string DebugDisplay() =>
151+
$"COLUMNPROPERTY({this.idArg.DebugDisplay()}, {this.columnArg.DebugDisplay()}, {this.propertyArg.DebugDisplay()})";
152+
}
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
using SqlServerSimulator.Storage;
2+
using Index = SqlServerSimulator.Storage.Index;
3+
4+
namespace SqlServerSimulator.Parser.Expressions;
5+
6+
/// <summary>
7+
/// SQL <c>INDEXPROPERTY(object_id, 'index_name', 'property')</c>: per-index
8+
/// metadata flags. Returns <c>int</c>; NULL on any NULL arg, unknown index
9+
/// name (within the resolved table), unknown property, or unknown
10+
/// <c>object_id</c>. Property names and index names are
11+
/// case-insensitive.
12+
/// </summary>
13+
/// <remarks>
14+
/// <para>
15+
/// Index lookup combines two sources on the resolved <see cref="HeapTable"/>:
16+
/// <see cref="HeapTable.Indexes"/> (named indexes from CREATE INDEX) and
17+
/// <see cref="HeapTable.KeyConstraints"/> (PK + UNIQUE constraints — these
18+
/// surface in <c>sys.indexes</c> by constraint name).
19+
/// </para>
20+
/// <para>
21+
/// Shipped properties:
22+
/// <list type="bullet">
23+
/// <item><description><c>IsClustered</c> / <c>IsUnique</c> — flags from
24+
/// <see cref="Index.IsClustered"/> / <see cref="Index.IsUnique"/> (or
25+
/// <see cref="KeyConstraint.Kind"/> for PK / UNIQUE constraints).</description></item>
26+
/// <item><description><c>IsAutoStatistics</c>, <c>IndexDepth</c>,
27+
/// <c>IndexFillFactor</c>, <c>IsHypothetical</c>, <c>IsPadIndex</c>,
28+
/// <c>IsStatistics</c> — always 0 (no B-tree storage; matches probed
29+
/// real-server behavior on a freshly-created index with no stats).</description></item>
30+
/// </list>
31+
/// </para>
32+
/// </remarks>
33+
internal sealed class IndexProperty : Expression
34+
{
35+
private readonly Expression idArg;
36+
private readonly Expression indexNameArg;
37+
private readonly Expression propertyArg;
38+
39+
public IndexProperty(ParserContext context)
40+
{
41+
this.idArg = Parse(context);
42+
if (context.Token is not Tokens.Operator { Character: ',' })
43+
throw SimulatedSqlException.SyntaxErrorNear(context);
44+
this.indexNameArg = Parse(context.MoveNextRequiredReturnSelf());
45+
if (context.Token is not Tokens.Operator { Character: ',' })
46+
throw SimulatedSqlException.SyntaxErrorNear(context);
47+
this.propertyArg = Parse(context.MoveNextRequiredReturnSelf());
48+
if (context.Token is not Tokens.Operator { Character: ')' })
49+
throw SimulatedSqlException.SyntaxErrorNear(context);
50+
}
51+
52+
public override SqlValue Run(RuntimeContext runtime)
53+
{
54+
var idValue = this.idArg.Run(runtime);
55+
var indexValue = this.indexNameArg.Run(runtime);
56+
var propValue = this.propertyArg.Run(runtime);
57+
if (idValue.IsNull || indexValue.IsNull || propValue.IsNull)
58+
return SqlValue.Null(SqlType.Int32);
59+
var id = idValue.CoerceTo(SqlType.Int32).AsInt32;
60+
var indexName = indexValue.CoerceTo(SqlType.NVarchar).AsString;
61+
var prop = propValue.CoerceTo(SqlType.NVarchar).AsString;
62+
63+
if (ObjectProperty.FindObject(runtime.Batch.CurrentDatabase, id) is not HeapTable table)
64+
return SqlValue.Null(SqlType.Int32);
65+
66+
// Resolve as either a CREATE INDEX-declared index or a PK / UNIQUE
67+
// constraint-backed index. KeyConstraint also surfaces in sys.indexes
68+
// under the constraint's auto-generated name (e.g. PK__<table8>__<hex>).
69+
bool isUnique, isClustered;
70+
if (FindIndex(table, indexName) is Index idx)
71+
{
72+
isUnique = idx.IsUnique;
73+
isClustered = idx.IsClustered;
74+
}
75+
else if (FindKeyConstraint(table, indexName) is KeyConstraint kc)
76+
{
77+
isUnique = true;
78+
isClustered = kc.Kind == KeyConstraintKind.PrimaryKey;
79+
}
80+
else
81+
{
82+
return SqlValue.Null(SqlType.Int32);
83+
}
84+
85+
return EvaluateIndexProperty(isUnique, isClustered, prop) is int result
86+
? SqlValue.FromInt32(result)
87+
: SqlValue.Null(SqlType.Int32);
88+
}
89+
90+
private static Index? FindIndex(HeapTable table, string name)
91+
{
92+
foreach (var idx in table.Indexes)
93+
{
94+
if (Collation.Baseline.Equals(idx.Name, name))
95+
return idx;
96+
}
97+
return null;
98+
}
99+
100+
private static KeyConstraint? FindKeyConstraint(HeapTable table, string name)
101+
{
102+
foreach (var kc in table.KeyConstraints)
103+
{
104+
if (Collation.Baseline.Equals(kc.Name, name))
105+
return kc;
106+
}
107+
return null;
108+
}
109+
110+
private static int? EvaluateIndexProperty(bool isUnique, bool isClustered, string property)
111+
{
112+
Span<char> upper = stackalloc char[property.Length];
113+
return property.AsSpan().ToUpperInvariant(upper) switch
114+
{
115+
8 => upper switch { "ISUNIQUE" => isUnique ? 1 : 0, _ => null },
116+
10 => upper switch
117+
{
118+
"INDEXDEPTH" => 0,
119+
"ISPADINDEX" => 0,
120+
_ => null,
121+
},
122+
11 => upper switch
123+
{
124+
"ISCLUSTERED" => isClustered ? 1 : 0,
125+
_ => null,
126+
},
127+
12 => upper switch
128+
{
129+
"ISSTATISTICS" => 0,
130+
_ => null,
131+
},
132+
14 => upper switch
133+
{
134+
"ISHYPOTHETICAL" => 0,
135+
_ => null,
136+
},
137+
15 => upper switch
138+
{
139+
"INDEXFILLFACTOR" => 0,
140+
_ => null,
141+
},
142+
16 => upper switch
143+
{
144+
"ISAUTOSTATISTICS" => 0,
145+
_ => null,
146+
},
147+
_ => null,
148+
};
149+
}
150+
151+
public override SqlType GetSqlType(BatchContext batch, Func<MultiPartName, SqlType> resolveColumnType) => SqlType.Int32;
152+
153+
internal override string DebugDisplay() =>
154+
$"INDEXPROPERTY({this.idArg.DebugDisplay()}, {this.indexNameArg.DebugDisplay()}, {this.propertyArg.DebugDisplay()})";
155+
}

SqlServerSimulator/Parser/Expressions/ObjectProperty.cs

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ public override SqlValue Run(RuntimeContext runtime)
4242
: SqlValue.Null(SqlType.Int32);
4343
}
4444

45-
private static SchemaObject? FindObject(Database database, int id)
45+
internal static SchemaObject? FindObject(Database database, int id)
4646
{
4747
foreach (var schema in database.Schemas.Values)
4848
{
@@ -62,7 +62,36 @@ public override SqlValue Run(RuntimeContext runtime)
6262
return null;
6363
}
6464

65-
private static int? EvaluateProperty(SchemaObject obj, string property)
65+
/// <summary>
66+
/// Finds the schema that owns <paramref name="obj"/>, or returns
67+
/// <c>null</c> if the object isn't reachable through the database's
68+
/// per-schema dictionaries. Used by OBJECTPROPERTYEX's <c>SchemaId</c>
69+
/// property; the lookup is linear (no back-pointer on <see cref="SchemaObject"/>).
70+
/// </summary>
71+
internal static Schema? FindOwningSchema(Database database, SchemaObject obj)
72+
{
73+
foreach (var schema in database.Schemas.Values)
74+
{
75+
if (schema.HeapTables.Values.Contains(obj)
76+
|| schema.Views.Values.Contains(obj)
77+
|| schema.Procedures.Values.Contains(obj)
78+
|| schema.Functions.Values.Contains(obj)
79+
|| schema.Triggers.Values.Contains(obj)
80+
|| schema.Sequences.Values.Contains(obj))
81+
{
82+
return schema;
83+
}
84+
}
85+
return null;
86+
}
87+
88+
/// <summary>
89+
/// Boolean Is-X property dispatch shared with <c>OBJECTPROPERTYEX</c>.
90+
/// Returns <c>1</c> / <c>0</c> for the recognized properties, <c>null</c>
91+
/// for unknown names. Property-name comparison is case-insensitive via
92+
/// the SSS003-friendly <see cref="ReadOnlySpan{T}"/> overload.
93+
/// </summary>
94+
internal static int? EvaluateProperty(SchemaObject obj, string property)
6695
{
6796
// Boolean Is-X checks based on concrete type. Returns 1 if true,
6897
// 0 if false, NULL for unknown property names (matching real

0 commit comments

Comments
 (0)