Skip to content

Commit d9279e2

Browse files
committed
TEXTPTR fabricates a 16-byte signature+FNV-1a-64-column-hash pointer (varbinary(16), NULL for NULL cells, that TEXTVALID validates against its name's column segment with row identity deliberately unencoded since READTEXT/WRITETEXT/UPDATETEXT are still unmodeled, FULLTEXTCATALOGPROPERTY reads stored catalog metadata, CERTENCODED/CERTPRIVATEKEY return the no-such-certificate NULL (varbinary(max), and GET_FILESTREAM_TRANSACTION_CONTEXT returns the FILESTREAM-idle NULL.
1 parent c522b6a commit d9279e2

15 files changed

Lines changed: 619 additions & 7 deletions

CLAUDE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,7 @@ Comparison (Msg 402), ORDER BY/DISTINCT (Msg 306), and aggregates (Msg 8117 from
225225
Per-feature deep-dives live under `docs/claude/`.
226226
Each entry below is a trigger: read the linked file on demand when working in the matching area.
227227

228-
- **Built-in scalars** — math; date (DATETRUNC / DATE_BUCKET / SWITCHOFFSET / TODATETIMEOFFSET / `*FROMPARTS` / AT TIME ZONE / current-time); string (CONCAT / SOUNDEX / TRANSLATE / STRING_ESCAPE / DIFFERENCE); CHOOSE / IIF; bit (BIT_COUNT / GET_BIT / SET_BIT / shifts); CHECKSUM / BINARY_CHECKSUM; FORMAT / FORMATMESSAGE; RAND; STRING_SPLIT / GENERATE_SERIES; COMPRESS / DECOMPRESS; PWDENCRYPT / PWDCOMPARE / LOGINPROPERTY; sys.fn_varbintohexsubstring / sys.fn_varbintohexstr (varbinary→hex, sys-qualified system functions); `@@`-constants (incl. the system statistical counters + `sys.fn_virtualfilestats`) + HOST_NAME / APP_NAME / GETANSINULL / ORIGINAL_DB_NAME; session-state (SESSION_CONTEXT / sp_set_session_context / CONTEXT_INFO / CONNECTIONPROPERTY / SESSIONPROPERTY); SQL_VARIANT_PROPERTY → [`scalars.md`](docs/claude/scalars.md).
228+
- **Built-in scalars** — math; date (DATETRUNC / DATE_BUCKET / SWITCHOFFSET / TODATETIMEOFFSET / `*FROMPARTS` / AT TIME ZONE / current-time); string (CONCAT / SOUNDEX / TRANSLATE / STRING_ESCAPE / DIFFERENCE); CHOOSE / IIF; bit (BIT_COUNT / GET_BIT / SET_BIT / shifts); CHECKSUM / BINARY_CHECKSUM; FORMAT / FORMATMESSAGE; RAND; STRING_SPLIT / GENERATE_SERIES; COMPRESS / DECOMPRESS; PWDENCRYPT / PWDCOMPARE / LOGINPROPERTY; sys.fn_varbintohexsubstring / sys.fn_varbintohexstr (varbinary→hex, sys-qualified system functions); `@@`-constants (incl. the system statistical counters + `sys.fn_virtualfilestats`) + HOST_NAME / APP_NAME / GETANSINULL / ORIGINAL_DB_NAME; session-state (SESSION_CONTEXT / sp_set_session_context / CONTEXT_INFO / CONNECTIONPROPERTY / SESSIONPROPERTY); SQL_VARIANT_PROPERTY; legacy text-pointer (TEXTPTR / TEXTVALID — column-identity pointer, no READTEXT/WRITETEXT/UPDATETEXT consumer); NULL placeholders (CERTENCODED / CERTPRIVATEKEY / GET_FILESTREAM_TRANSACTION_CONTEXT) → [`scalars.md`](docs/claude/scalars.md).
229229
- **`SqlType.Promote` / `PromoteForArithmetic` / decimal precision-scale / int↔string promotion / string+binary literal value-width typing + width algebra (concat sum-cap, CASE/COALESCE/set-op max, per-function widths)**[`arithmetic.md`](docs/claude/arithmetic.md).
230230
- **`Cast` / coercion error paths** (CAST/CONVERT narrow targets, TRY_CAST/TRY_CONVERT swallow set, PARSE/TRY_PARSE culture-aware parsing) → [`casting.md`](docs/claude/casting.md).
231231
- **`SimulatedDbDataReader` client surface** (typed accessors, `datetime` client-millisecond rounding, `GetOrdinal` precedence, GetBytes/GetChars materialization divergence) → [`data-reader.md`](docs/claude/data-reader.md).
@@ -262,7 +262,7 @@ Each entry below is a trigger: read the linked file on demand when working in th
262262
- **Application locks** (`sp_getapplock` / `sp_releaseapplock` / `APPLOCK_MODE` / `APPLOCK_TEST`, return-code-vs-raised-error asymmetry, EF 9/10 `Database.Migrate()`'s `__EFMigrationsLock`) → [`app-locks.md`](docs/claude/app-locks.md).
263263
- **`hierarchyid` type** (OrdPath storage, byte-identical CAST/wire/DATALENGTH, tier table + opaque passthrough) → [`hierarchyid.md`](docs/claude/hierarchyid.md).
264264
- **`GRANT` / `REVOKE` / `DENY`, principal DDL (incl. server logins: `CREATE/ALTER/DROP LOGIN`), fixed-principal seed, principal scalars** (`USER_NAME` / `SUSER_SNAME` / `DATABASE_PRINCIPAL_ID` / `CURRENT_USER` / `SESSION_USER` / `ORIGINAL_LOGIN` / `HAS_PERMS_BY_NAME` / `IS_MEMBER`) → [`permissions.md`](docs/claude/permissions.md).
265-
- **`CREATE FULLTEXT CATALOG`/`INDEX`, `CONTAINS`/`FREETEXT` rejection**[`full-text.md`](docs/claude/full-text.md).
265+
- **`CREATE FULLTEXT CATALOG`/`INDEX`, `CONTAINS`/`FREETEXT` rejection, FULLTEXTCATALOGPROPERTY / FULLTEXTSERVICEPROPERTY**[`full-text.md`](docs/claude/full-text.md).
266266
- **`xml` type, XML schema collections, XML methods (`.value()` / `.nodes()` / `.query()` / `.exist()` via an XQuery-subset evaluator; `.modify()` XML-DML skip-with-diagnostic), XML indexes**[`xml.md`](docs/claude/xml.md).
267267
- **`geography` / `geometry` types, spatial methods, spatial indexes**[`spatial.md`](docs/claude/spatial.md).
268268
- **`ALTER DATABASE SET <option>` accept-list + database-level `COLLATE` clause**[`database-options.md`](docs/claude/database-options.md).

docs/claude/backlog.md

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,14 +59,10 @@ Blocked on a larger unmodeled parent feature (shipping a function here implies t
5959

6060
Low priority / niche — simulatable (as placeholder constants or a small model) but rarely hit, so not worth attention yet:
6161

62-
- **Legacy text/image** — TEXTPTR / TEXTVALID (the `text` / `ntext` / `image` types ship; these navigate the deprecated READTEXT / WRITETEXT / UPDATETEXT pointer path).
63-
- **Certificates** — CERTENCODED / CERTPRIVATEKEY (needs a small certificate-name → bytes model, or NULL placeholders).
64-
- **Full-text properties** — FULLTEXTCATALOGPROPERTY (the `CREATE FULLTEXT CATALOG` / `INDEX` DDL already ships; this reads its metadata).
6562
- **`sql_variant` minor quirk** (cross-type family ordering and one-side-variant comparison both shipped 2026-07-19 — see [`scalars.md`](scalars.md#sql_variant-expression-semantics)): a decimal-declared inner reports BaseType `numeric` rather than real's `decimal`.
6663
Probed 2026-07-19: real preserves the declared keyword *distinctly*`decimal` and `numeric` never collapse — through literals, table columns, variant columns, and variant variables assigned from typed variables.
6764
The faithful fix splits the per-`(p, s)` `DecimalSqlType` singleton by declared keyword, forking the reference-identity space the row encoder, promote paths, and catalog surfaces key on — a medium refactor whose blast radius far exceeds the one metadata string it corrects, so it's deliberately deferred.
6865
Deliberate exclusion, don't re-pitch: `msdb.dbo.syspolicy_configuration.current_value` stays `nvarchar` — it's a *view-body* projection (not a resource column) mixing `int` rows with a `binary` GUID row, every consumer reads a single named row and CASTs it, so a variant migration there would only touch the view SQL text for no observable gain.
69-
- **FILESTREAM** — GET_FILESTREAM_TRANSACTION_CONTEXT (needs a FILESTREAM storage binding; NULL is a faithful "no FILESTREAM context" placeholder).
7066

7167
## Fidelity gaps in shipped behavior
7268

docs/claude/full-text.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,14 @@ The simulator reports Full-Text as installed (`SERVERPROPERTY('IsFullTextInstall
8181
The reference box returns `0` only because Full-Text isn't installed there — and while uninstalled it returns NULL for the resource-tuning properties too, so their installed values can't be probed.
8282
The simulator reports the installed value of `0` for each: `ConnectTimeout`, `LoadOSResources`, `ResourceUsage`, `VerifyResourceUsage`.
8383
An unrecognized property name returns NULL `int` (probe-confirmed convention); names are case-insensitive.
84-
`FULLTEXTCATALOGPROPERTY` is not modeled.
84+
85+
## `FULLTEXTCATALOGPROPERTY('catalog_name', 'property')`
86+
87+
`Parser/Expressions/FullTextCatalogProperty.cs`.
88+
Returns an `int` property of a full-text catalog resolved by name against `Database.FullTextCatalogs` (probe-confirmed return type; 2026-07-20).
89+
The simulator has no indexing engine, so the population / size / status properties report the idle-empty answers a freshly created catalog gives — `ItemCount`, `IndexSize`, `PopulateStatus`, `PopulateCompletionAge`, `MergeStatus`, `ImportStatus`, `UniqueKeyCount`, `LogSize` all `0`.
90+
`AccentSensitivity` reflects the catalog's DDL-captured `ACCENT_SENSITIVITY` option (`FullTextCatalog.IsAccentSensitive`, defaulting `1` / accent-sensitive) — the one property that varies with catalog state.
91+
An unknown catalog name or unrecognized property returns NULL; property names are case-insensitive.
8592

8693
## Known gaps
8794

docs/claude/scalars.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -591,3 +591,33 @@ Probe-confirmed against SQL Server 2025 (2026-05-23).
591591
- **`SID_BINARY(name)`** is constant NULL — probe-confirmed against SQL Server 2025: it resolves only Windows / Entra-ID directory principals and returns NULL even for existing SQL-auth logins, so NULL is faithful for every input the simulator can host.
592592
The argument still parses and evaluates.
593593
Surfaced by SSMS's Select-Top-1000 server-properties batch (`suser_sname(sid_binary(@SqlGroup))`).
594+
595+
## Legacy text-pointer scalars: `TEXTPTR` / `TEXTVALID`
596+
597+
`Parser/Expressions/TextPointer.cs` (`LegacyTextPointer` helper + `TextPointer`), `Parser/Expressions/TextValid.cs`.
598+
Probe-confirmed against SQL Server 2025 (2026-07-20).
599+
600+
- **`TEXTPTR(column)`** returns the 16-byte `varbinary` text pointer of a base-table `text` / `ntext` / `image` column, or NULL when the cell is NULL.
601+
The argument must be a base-table column reference: a literal, CAST, or computed expression raises **Msg 280** (`Only base table columns are allowed in the TEXTPTR function.`), and a column of any other type (including `varchar(max)`) raises **Msg 8116** (`Argument data type <t> is invalid for argument 1 of textptr function.`).
602+
Real varies the pointer per row; the simulator fabricates a shape carrying only column identity — an 8-byte signature plus an 8-byte FNV-1a-64 hash of the case-folded column name — since the only sanctioned consumers (`READTEXT` / `WRITETEXT` / `UPDATETEXT`) stay unmodeled.
603+
Two non-NULL cells of one column therefore share a pointer (a divergence with no observable consumer).
604+
- **`TEXTVALID('table.column', text_ptr)`** returns `int` `1` when the pointer is valid for the named column, else `0`.
605+
A NULL pointer / NULL name, a pointer that isn't a simulator-fabricated text pointer (e.g. arbitrary bytes), and a name whose final (column) segment doesn't match the pointer's source column all return `0`.
606+
The name must have at least two dotted parts (`table.column`) — a bare single-part name returns `0`, matching real — but only its column segment is matched against the pointer's embedded column-identity hash; the table portion is not resolved against the catalog.
607+
A syntactically valid name whose column segment matches the pointer's source column therefore returns `1` even if its table portion names a different table (real cross-checks the exact column object); this is unobservable through the sanctioned `TEXTVALID('t.c', TEXTPTR(c))` idiom, where the two column names always agree.
608+
609+
## Placeholder security / FILESTREAM scalars: `CERTENCODED` / `CERTPRIVATEKEY` / `GET_FILESTREAM_TRANSACTION_CONTEXT`
610+
611+
`Parser/Expressions/CertificateFunctions.cs` (`CertificateFunction`, `isPrivateKey` flag), `Parser/Expressions/GetFilestreamTransactionContext.cs`.
612+
The simulator models no certificate store or FILESTREAM storage, so each returns a NULL `varbinary(max)` — the faithful answer for the state the simulator is always in.
613+
Probe-confirmed against SQL Server 2025 (2026-07-20).
614+
615+
- **`CERTENCODED(cert_id)`** → NULL (the answer real gives for a nonexistent certificate id).
616+
Exactly one argument; any other count raises **Msg 174** (`The CertEncoded function requires 1 argument(s).` — PascalCase function name, unlike the lowercase-rendered `PI` / `ISNULL` form).
617+
- **`CERTPRIVATEKEY(cert_id, N'encryption_password' [, N'decryption_password'])`** → NULL.
618+
Two or three arguments; any other count raises **Msg 189** (`The CertPrivateKey function requires 2 to 3 arguments.`, via `SimulatedSqlException.FunctionArgumentCountRange`).
619+
- **`GET_FILESTREAM_TRANSACTION_CONTEXT()`** → NULL, the faithful "no active FILESTREAM transaction" answer a FILESTREAM-enabled server gives outside such a transaction.
620+
(A reference instance with FILESTREAM file-system access disabled at the instance level instead raises Msg 5592; the simulator returns the enabled-but-idle answer.)
621+
Zero arguments; any argument raises **Msg 174** (`The get_filestream_transaction_context function requires 0 argument(s).` — lowercase function name).
622+
623+
The untyped-NULL-literal argument diagnostic (real raises Msg 8116 for `CERTENCODED(NULL)` because an untyped `NULL` literal has no type) is not modeled — the simulator's untyped `NULL` literal carries `Type=Int32`, so it flows through as a valid int argument and returns NULL.

src/SqlServerSimulator/Errors/SimulatedSqlException.QueryErrors.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,15 @@ internal static SimulatedSqlException FetchMustBeGreaterThanZero() =>
147147
internal static SimulatedSqlException FunctionRequiresNArguments(string functionLowerName, int argumentCount) =>
148148
new($"The {functionLowerName} function requires {argumentCount} argument(s).", 174, 15, 1);
149149

150+
/// <summary>
151+
/// Mimics SQL Server error 280 — <c>TEXTPTR</c> was applied to something
152+
/// other than a base-table <c>text</c> / <c>ntext</c> / <c>image</c> column
153+
/// (a literal, a CAST, or any computed expression). Probe-confirmed against
154+
/// SQL Server 2025 (2026-07-20).
155+
/// </summary>
156+
internal static SimulatedSqlException OnlyBaseTableColumnsInTextPtr() =>
157+
new("Only base table columns are allowed in the TEXTPTR function.", 280, 16, 1);
158+
150159
/// <summary>
151160
/// Mimics SQL Server error 6607: the password-encryption machinery
152161
/// rejected an input — fired by <c>PWDENCRYPT</c> for a clear text over

src/SqlServerSimulator/Errors/SimulatedSqlException.SyntaxErrors.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,17 @@ internal static SimulatedSqlException MustBeFirstStatementInBatch(string stateme
150150
internal static SimulatedSqlException FunctionArgumentCount(string lowercaseFunctionName, int min) =>
151151
new($"The {lowercaseFunctionName} function requires {min} to 254 arguments.", 189, 15, 1);
152152

153+
/// <summary>
154+
/// Mimics SQL Server error 189 with an explicit upper bound: a built-in
155+
/// function received the wrong number of arguments where the accepted count
156+
/// is a small range (e.g. <c>"The CertPrivateKey function requires 2 to 3
157+
/// arguments."</c>). Probe-confirmed against SQL Server 2025 (2026-07-20) —
158+
/// the function name is rendered with its documented PascalCase spelling,
159+
/// unlike the lowercase-rendered <see cref="FunctionArgumentCount"/> family.
160+
/// </summary>
161+
internal static SimulatedSqlException FunctionArgumentCountRange(string functionName, int min, int max) =>
162+
new($"The {functionName} function requires {min} to {max} arguments.", 189, 15, 1);
163+
153164
/// <summary>
154165
/// Mimics SQL Server error 3902: a <c>COMMIT</c> was issued with no
155166
/// active transaction. Probe-confirmed against SQL Server 2025

src/SqlServerSimulator/Parser/Expression.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -882,6 +882,7 @@ private static Expression ResolveBuiltIn(string name, ParserContext context)
882882
"REVERSE" => new Reverse(context),
883883
"SET_BIT" => new SetBit(context),
884884
"SOUNDEX" => new Soundex(context),
885+
"TEXTPTR" => new TextPointer(context),
885886
"TYPE_ID" => new TypeId(context),
886887
"UNICODE" => new UnicodeCodepoint(context),
887888
"USER_ID" => new PrincipalIdLookup(context, PrincipalIdKind.UserId),
@@ -924,6 +925,7 @@ private static Expression ResolveBuiltIn(string name, ParserContext context)
924925
"SCHEMA_ID" => new SchemaId(context),
925926
"SUBSTRING" => new Substring(context),
926927
"SUSER_SID" => new SUserSid(context),
928+
"TEXTVALID" => new TextValid(context),
927929
"TRANSLATE" => new Translate(context),
928930
"TRY_PARSE" => new ParseFunction(context, tryMode: true),
929931
"TYPE_NAME" => new TypeName(context),
@@ -958,6 +960,7 @@ private static Expression ResolveBuiltIn(string name, ParserContext context)
958960
},
959961
11 => uppercaseName switch
960962
{
963+
"CERTENCODED" => new CertificateFunction(context, isPrivateKey: false),
961964
"DATE_BUCKET" => new DateBucket(context),
962965
"ERROR_STATE" => new ErrorStateFunction(context),
963966
"FIRST_VALUE" => WindowExpression.ParseFirstValue(context),
@@ -1008,6 +1011,7 @@ private static Expression ResolveBuiltIn(string name, ParserContext context)
10081011
},
10091012
14 => uppercaseName switch
10101013
{
1014+
"CERTPRIVATEKEY" => new CertificateFunction(context, isPrivateKey: true),
10111015
"COLUMNPROPERTY" => new ColumnProperty(context),
10121016
"ERROR_SEVERITY" => new ErrorSeverityFunction(context),
10131017
"FILEGROUP_NAME" => new FilegroupName(context),
@@ -1082,9 +1086,15 @@ private static Expression ResolveBuiltIn(string name, ParserContext context)
10821086
23 => uppercaseName switch
10831087
{
10841088
"DATETIMEOFFSETFROMPARTS" => new DatePartsBuilder(context, DatePartsBuilderKind.DateTimeOffsetFromParts),
1089+
"FULLTEXTCATALOGPROPERTY" => new FullTextCatalogProperty(context),
10851090
"FULLTEXTSERVICEPROPERTY" => new FullTextServiceProperty(context),
10861091
_ => null
10871092
},
1093+
34 => uppercaseName switch
1094+
{
1095+
"GET_FILESTREAM_TRANSACTION_CONTEXT" => new GetFilestreamTransactionContext(context),
1096+
_ => null
1097+
},
10881098
_ => (Expression?)null
10891099
} ?? throw SimulatedSqlException.UnrecognizedBuiltInFunction(name);
10901100
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
using SqlServerSimulator.Storage;
2+
3+
namespace SqlServerSimulator.Parser.Expressions;
4+
5+
/// <summary>
6+
/// SQL <c>CERTENCODED(cert_id)</c> and
7+
/// <c>CERTPRIVATEKEY(cert_id, N'encryption_password' [, N'decryption_password'])</c>:
8+
/// return the public certificate / private key bytes as <c>varbinary(max)</c>.
9+
/// The simulator models no certificate store, so both return NULL — the exact
10+
/// answer real SQL Server gives for a certificate id that doesn't exist
11+
/// (probe-confirmed against SQL Server 2025). Argument-count violations raise
12+
/// the same Msg 174 (<c>CERTENCODED</c>) / Msg 189 (<c>CERTPRIVATEKEY</c>) real
13+
/// does. References:
14+
/// https://learn.microsoft.com/en-us/sql/t-sql/functions/certencoded-transact-sql,
15+
/// https://learn.microsoft.com/en-us/sql/t-sql/functions/certprivatekey-transact-sql
16+
/// </summary>
17+
internal sealed class CertificateFunction : Expression
18+
{
19+
private readonly Expression certId;
20+
private readonly Expression? password;
21+
private readonly Expression? decryptionPassword;
22+
23+
public CertificateFunction(ParserContext context, bool isPrivateKey)
24+
{
25+
if (isPrivateKey)
26+
{
27+
if (context.Token is Tokens.Operator { Character: ')' })
28+
throw SimulatedSqlException.FunctionArgumentCountRange("CertPrivateKey", 2, 3);
29+
this.certId = Parse(context);
30+
if (context.Token is not Tokens.Operator { Character: ',' })
31+
throw SimulatedSqlException.FunctionArgumentCountRange("CertPrivateKey", 2, 3);
32+
this.password = Parse(context.MoveNextRequiredReturnSelf());
33+
if (context.Token is Tokens.Operator { Character: ',' })
34+
this.decryptionPassword = Parse(context.MoveNextRequiredReturnSelf());
35+
if (context.Token is not Tokens.Operator { Character: ')' })
36+
throw SimulatedSqlException.FunctionArgumentCountRange("CertPrivateKey", 2, 3);
37+
}
38+
else
39+
{
40+
if (context.Token is Tokens.Operator { Character: ')' })
41+
throw SimulatedSqlException.FunctionRequiresNArguments("CertEncoded", 1);
42+
this.certId = Parse(context);
43+
if (context.Token is not Tokens.Operator { Character: ')' })
44+
throw SimulatedSqlException.FunctionRequiresNArguments("CertEncoded", 1);
45+
}
46+
}
47+
48+
public override SqlValue Run(RuntimeContext runtime) => SqlValue.Null(SqlType.VarbinaryMax);
49+
50+
public override SqlType GetSqlType(BatchContext batch, Func<MultiPartName, SqlType> resolveColumnType) => SqlType.VarbinaryMax;
51+
52+
internal override string DebugDisplay() => this.password is null
53+
? $"CERTENCODED({this.certId.DebugDisplay()})"
54+
: $"CERTPRIVATEKEY({this.certId.DebugDisplay()}, …)";
55+
56+
internal override void VisitColumnReferences(Action<MultiPartName> visit)
57+
{
58+
this.certId.VisitColumnReferences(visit);
59+
this.password?.VisitColumnReferences(visit);
60+
this.decryptionPassword?.VisitColumnReferences(visit);
61+
}
62+
}

0 commit comments

Comments
 (0)