Testing AI-Generated DDL Against the Rules It Was Given

A developer pastes a paragraph of business rules into an AI coding agent and gets back three CREATE TABLE statements and a trigger. The script runs without an error. The reviewer now has to work out which of those rules the database enforces and which ones exist only in the comments and the notes that came with it.

A building inspector tests the doors and windows of a house a robot built from a blueprint, marking an unlocked door, a missing latch and two overlapping rooms with red flags.

So I tried it. I wrote a short schema description, had an AI coding agent turn it into DDL, deployed the unedited output to SQL Server 2019 and SQL Server 2025, and tried to break the rules in the description with INSERT, UPDATE and DELETE statements.

The Description and the Prompt

The schema is a booking system for a small physiotherapy clinic. This is the whole prompt, as given to the agent:

I didn’t ask for naming or formatting conventions, and the prompt says nothing about collation, indexes or concurrency.

What the Agent Produced

The DDL came from a Claude model running as a sub-agent in GitHub Copilot CLI. I didn’t record the exact model ID. That session wasn’t blind. It already had my test brief in context, including the list of defects I expected to find, and it had my own T-SQL style instructions loaded. A fresh session with no brief, or a different model, could do better or worse. This is one sample from one session, and here’s its output, unedited, including its -- comments:

The agent also listed which rule each object covers. It said that the inactive-practitioner rule isn’t enforced and should be checked in the application. It also said that CreatedAt is a UTC audit column. CreatedAt and Notes weren’t in the description.

That’s a reasonable first draft, and nothing in it looks wrong on a read-through.

How I Tested It

I deployed the script unchanged into a scratch database on SQL Server 2019 (15.0.4500.2) and SQL Server 2025 (17.0.5015.4), both using the instance default collation SQL_Latin1_General_CP1_CI_AS. I deployed it a third time on 2025 into a database created with Latin1_General_100_CS_AS, a case-sensitive collation. Then I ran one batch per test, each with a value chosen to break a rule. I tested on one workstation, and I sent the prompt once.

Results, Rule by Rule

SQL Server 2019 and 2025 gave the same result for every test in the case-insensitive databases. The case-sensitive database differed in two tests, noted in the table.

Rule from the description Test Result
Every patient has an email Insert '' Accepted
Patient email is unique Same address, same case Rejected
Patient email is unique Ana.Silva@Contoso.example after ana.silva@contoso.example Rejected (CI), accepted (CS)
Patient email is unique Same address with a trailing space Rejected
Patient email is unique Same address with a leading space Accepted
End after start End 30 minutes before start Rejected
At least 15 minutes 10 minutes Rejected
At least 15 minutes 08:00:59 to 08:15:00 (14 min 1 s) Accepted
At most 2 hours 12:00:00 to 14:00:59 (2 h 0 min 59 s) Accepted
Status list Pending Rejected
Status list no-show, as the description spells it Rejected
Status list cancelled, lower case Accepted (CI), rejected (CS)
No overlap Overlapping single insert Rejected
No overlap Two overlapping rows in one INSERT Rejected
No overlap Un-cancel a booking whose slot was rebooked Rejected
Cancelled don’t block the slot Book over a cancelled slot Accepted
Inactive can’t be booked Book the inactive practitioner Accepted
Cancelled are kept DELETE a cancelled appointment Accepted
Patient with appointments can’t be deleted DELETE the patient Rejected

Most of the tests went the way the rules say: 12 of the 19 in the case-insensitive databases.

Rules with no enforcement. Nothing stops a booking with an inactive practitioner, and the agent pointed me to the application for that one. For “kept for our records”, the Status column records the cancellation, but nothing stops a DELETE of the row. The agent didn’t mention that one.

The email rules depend on the database collation. The DDL has no COLLATE clause, so each column takes the database’s default collation.[1][2] A case-sensitive collation distinguishes upper case from lower case.[3] In the case-sensitive database, the mixed-case copy of an existing address went in as a second patient. The same DDL in the other database rejected it. A leading space created a second patient in both, and an empty string met the NOT NULL “must have an email” rule.

The duration CHECK doesn’t measure elapsed time. DATEDIFF counts datepart boundaries crossed between the two values, not elapsed time.[4] From 08:00:59 to 08:15:00 crosses 15 minute boundaries, so a 14-minute-1-second booking passed. A booking 59 seconds over two hours also passed. Comparing EndTime to DATEADD(minute, 15, StartTime) would test the actual length.

The status list doesn’t match the description. The description says “no-show”. The CHECK allows NoShow. Any code that sends the value as written in the requirements gets error 547. The description also writes all four statuses in lower case, and the CHECK lists them in mixed case. The case-insensitive databases accepted lower-case cancelled, but the case-sensitive database rejected it. I ran only cancelled and no-show there, but lower-case booked and completed go through the same IN list under the same collation, so all four statuses as the description writes them fail in that database.

The Overlap Trigger

The trigger rejected every overlap I tried from one session, including a multi-row insert and an UPDATE that changed a booking back from cancelled. Two things it does are still a problem.

It depends on the isolation level. I opened a transaction in one session, inserted a 14:00 to 15:00 booking, and waited eight seconds before committing. Two seconds in, a second session inserted 14:30 to 15:30 for the same practitioner. With the READ_COMMITTED_SNAPSHOT database option off, the second session waited about six seconds for the first to commit, then the trigger rejected its row. With it on, the second insert finished at once, both rows committed, and the practitioner had two overlapping bookings. Both instances behaved the same way, and I ran each case once. Under read committed snapshot isolation (RCSI), a statement reads the last committed version of each row, so the trigger couldn’t see the first session’s uncommitted booking.[5] With RCSI off, the trigger held for this one staggered timing. I didn’t test two inserts arriving at the same moment with RCSI off. Sessions that use SNAPSHOT isolation also read row versions, so they may have the same gap, but I didn’t test that either. RCSI is set per database, so the same DDL can behave differently from one database to the next. The same guide notes that RCSI is on by default in Azure SQL Database, which I didn’t test.

Its ROLLBACK ends the caller’s transaction. I opened a transaction, inserted a valid booking, then inserted an overlapping one. SQL Server returned error 3609, The transaction ended in the trigger. The batch has been aborted. The valid booking was gone, @@TRANCOUNT was 0, and the COMMIT never ran. Code that wraps several bookings in one transaction and expects to handle one failure loses all of them.

Things the Rules Didn’t Mention

Ten constraints have system-generated names. The primary keys, the two UNIQUE constraints, and the five defaults got names like UQ__Patients__A9D1053409E07876. The primary key and unique constraint suffixes were different in each of the three databases, including the two on the same instance. A later migration that drops or alters one of those constraints by name may work in one environment and fail in the next.

No index supports the patient foreign key. A foreign key constraint doesn’t create an index.[6] The agent’s only nonclustered index leads on PractitionerId. With 50,009 appointments loaded on SQL Server 2025, deleting one patient who had no appointments cost 470 logical reads on Appointments, because SQL Server scanned the table to check the foreign key. With an index on PatientId, the same delete cost 2 logical reads.

The trigger’s cost grows faster than the row count. The trigger compares every inserted row with every other appointment for the same practitioner. I timed single INSERT statements for one practitioner on SQL Server 2025, each rolled back afterward. Each timing is a single run:

Rows in one INSERT Elapsed ms CPU ms
1,000 409 398
2,000 1,246 1,224
4,000 3,457 3,454
8,000 14,185 13,995

My first data load put 50,000 appointments for one practitioner in a single statement. I stopped it after more than four minutes of CPU, spent inside the trigger according to sys.dm_exec_requests. Spreading the same number of rows across 500 practitioners took under two seconds. A clinic doesn’t book 8,000 appointments in one statement, but a data migration or an import from an old system might.

The data types were fine. Every column was either declared NULL or NOT NULL, or is one of the three IDENTITY primary key columns, and a primary key forces NOT NULL.[6] Session defaults didn’t matter. The time columns are datetime2 with no precision given, which is datetime2(7). Seven decimal places for appointment times is more than the clinic needs. It isn’t a correctness problem. The CreatedAt defaults use SYSUTCDATETIME(), while the description says all times are local. The agent disclosed that one.

What I’d Change Before Using It

These are ordinary DDL review items, and each one came from a test or a catalogue query. I haven’t deployed or tested any of these changes. I’d name every constraint, set a collation on the email columns or store a normalized copy with its own unique constraint, add a CHECK that rejects empty and padded addresses, and replace the duration check with a DATEADD comparison. I’d add an index on Appointments.PatientId, and decide what “kept for our records” means: a trigger that blocks deletes, or a permission that does. I’d make the status list match the description, or get the description changed to match the list, and settle on one spelling and case. For inactive practitioners, I’d check IsActive in the booking procedure or a trigger, or confirm that the application does it, as the agent suggested.

For the overlap rule, I’d decide which isolation level the application uses before I trusted the trigger, and I’d test it with two sessions, not one. I’d also test what a caller’s multi-statement transaction gets back when the trigger fires, because error 3609 rolls back the caller’s whole transaction.

Writing T-SQL with an AI Partner covers the review loop for code like this. Proving the Contract Holds: Testing Database Code covers turning tests like these into something that runs on every build. If a nullable column is part of your rules, The CHECK Constraint That Lets NULL Through covers a CHECK case this schema didn’t reach. And the naming and style rules I’d apply here are in Why I Keep a T-SQL Style Guide, and What Testing It Cost Me.

If you’ve tested generated DDL against its own requirements, I’d like to know which rules failed for you. Find me on Bluesky or LinkedIn.

References

  1. CREATE TABLE (Transact-SQL) - Microsoft Learn. States that a character column with no COLLATE clause is assigned the collation of its user-defined data type, if it has one, or the default collation of the database. ↩
  2. Set or Change the Column Collation - Microsoft Learn. States that a column uses the database collation unless a COLLATE clause in CREATE TABLE or ALTER TABLE overrides it. ↩
  3. Collation and Unicode support - Microsoft Learn. Defines the case-sensitive (_CS) collation option as one that distinguishes between uppercase and lowercase letters. ↩
  4. DATEDIFF (Transact-SQL) - Microsoft Learn. States that DATEDIFF returns the count of datepart boundaries crossed between the start and end values. ↩
  5. Transaction locking and row versioning guide - Microsoft Learn. States that with READ_COMMITTED_SNAPSHOT on, READ COMMITTED uses row versioning for statement-level read consistency, and that the option is on by default in Azure SQL Database. ↩
  6. Primary and foreign key constraints - Microsoft Learn. States that, unlike a primary key, a foreign key constraint doesn’t automatically create a corresponding index, and that columns in a primary key are set to NOT NULL when nullability isn’t specified. ↩