What this skill tests
Four objective groups: data security and compliance, database performance, CI/CD with SQL Database Projects and integration with Azure services. Most items are "which feature meets this requirement" — and the distractors are features that sound right but protect a different thing (masking is not encryption; Change Tracking does not keep old values).
Data security and compliance
flowchart TB CALLER["Caller<br/>app · user · agent"] --> NET["Network<br/>firewall · private endpoint"] NET --> AUTHN["Authentication<br/>Entra ID · managed identity · passwordless"] AUTHN --> AUTHZ["Authorization<br/>roles · GRANT / DENY · RLS"] AUTHZ --> DATA["Data protection<br/>Always Encrypted · DDM · column encryption · TDE"] DATA --> AUDIT["Auditing<br/>who did what, when"]
Encryption: Always Encrypted, column-level and TDE
| Always Encrypted | Column-level encryption | Transparent data encryption (TDE) | |
|---|---|---|---|
| Where encryption happens | In the client driver | In the engine, by your T-SQL | In the engine, at the storage layer |
| Who can see plaintext | Only clients holding the column master key — not DBAs or the cloud operator | Anyone who can open the key | Anyone who can query the table |
| Protects against | Privileged insiders, compromised servers | Casual access to selected columns | Stolen files and backups (data at rest) |
| Application changes | Connection string setting, parameterized queries | Queries must call ENCRYPTBYKEY/DECRYPTBYKEY | None |
| Platforms | SQL Server, Azure SQL DB, MI — not Fabric | All four | On by default in Azure SQL; Fabric encrypts storage with service-managed or workspace keys |
-- Always Encrypted: the driver encrypts on the client; the engine never sees plaintext.
-- Keys: a column master key (in Azure Key Vault) protects column encryption keys.
CREATE TABLE dbo.Patient
(
PatientID int IDENTITY PRIMARY KEY,
NationalId char(11) COLLATE Latin1_General_BIN2
ENCRYPTED WITH (COLUMN_ENCRYPTION_KEY = CEK_Patient,
ENCRYPTION_TYPE = DETERMINISTIC, -- equality lookups, joins, grouping
ALGORITHM = 'AEAD_AES_256_CBC_HMAC_SHA_256') NOT NULL,
Diagnosis nvarchar(400)
ENCRYPTED WITH (COLUMN_ENCRYPTION_KEY = CEK_Patient,
ENCRYPTION_TYPE = RANDOMIZED, -- stronger; no operations without an enclave
ALGORITHM = 'AEAD_AES_256_CBC_HMAC_SHA_256') NULL
);
-- Client connection string: Column Encryption Setting=Enabled
-- Queries must be parameterized so the driver can encrypt the parameter values. - Deterministic encryption allows equality, joins,
GROUP BYand indexes, but leaks repeating values; needs aBIN2collation on strings. - Randomized is stronger but allows no operations — unless you use Always Encrypted with secure enclaves, which lets the server evaluate comparisons,
LIKEand range queries inside a protected enclave. - Keys: the column master key (CMK) lives outside the database, typically in Azure Key Vault; it encrypts the column encryption keys (CEKs) stored in the database.
-- Column-level (cell-level) encryption: the engine encrypts with a symmetric key.
CREATE MASTER KEY ENCRYPTION BY PASSWORD = N'<strong password>';
CREATE CERTIFICATE CertPayroll WITH SUBJECT = 'Payroll column key protector';
CREATE SYMMETRIC KEY SymPayroll WITH ALGORITHM = AES_256 ENCRYPTION BY CERTIFICATE CertPayroll;
OPEN SYMMETRIC KEY SymPayroll DECRYPTION BY CERTIFICATE CertPayroll;
UPDATE dbo.Employee SET SalaryEnc = ENCRYPTBYKEY(KEY_GUID('SymPayroll'), CAST(Salary AS varchar(20)));
SELECT EmployeeID, CAST(DECRYPTBYKEY(SalaryEnc) AS varchar(20)) AS Salary FROM dbo.Employee;
CLOSE SYMMETRIC KEY SymPayroll; Dynamic data masking
Masking changes what query results show to users without the UNMASK permission; the
stored data is unchanged. It reduces exposure in applications and support tools but is not a security
boundary — a user with ad hoc query access can infer values with predicates such as
WHERE CreditLimit > 10000.
-- Dynamic data masking: obfuscates results for users without UNMASK. Data is unchanged.
ALTER TABLE dbo.Customer ALTER COLUMN Email
ADD MASKED WITH (FUNCTION = 'email()'); -- aXXX@XXXX.com
ALTER TABLE dbo.Customer ALTER COLUMN Phone
ADD MASKED WITH (FUNCTION = 'partial(0, "XXX-XXX-", 4)');
ALTER TABLE dbo.Customer ALTER COLUMN CreditLimit
ADD MASKED WITH (FUNCTION = 'default()'); -- 0 for numbers, XXXX for strings
ALTER TABLE dbo.Customer ALTER COLUMN BirthDate
ADD MASKED WITH (FUNCTION = 'datetime("Y")'); -- masks the year
-- Granular UNMASK (SQL Server 2022+, Azure SQL, Fabric): database, schema, table or column
GRANT UNMASK ON dbo.Customer(Email) TO SupportAgent; Row-level security
RLS filters rows by the caller's context — a user name, a role, or a value the app puts in
SESSION_CONTEXT. A filter predicate silently hides rows from
SELECT, UPDATE and DELETE; a block predicate stops writes that
would violate the rule (AFTER INSERT, AFTER UPDATE, BEFORE UPDATE,
BEFORE DELETE).
-- Row-level security: an inline TVF predicate bound to a security policy
CREATE SCHEMA Security;
GO
CREATE FUNCTION Security.fn_TenantPredicate (@TenantID int)
RETURNS TABLE
WITH SCHEMABINDING
AS
RETURN SELECT 1 AS allowed
WHERE @TenantID = CAST(SESSION_CONTEXT(N'TenantID') AS int)
OR IS_MEMBER(N'db_owner') = 1;
GO
CREATE SECURITY POLICY Security.TenantPolicy
ADD FILTER PREDICATE Security.fn_TenantPredicate(TenantID) ON dbo.Orders, -- hides rows from reads
ADD BLOCK PREDICATE Security.fn_TenantPredicate(TenantID) ON dbo.Orders AFTER INSERT -- stops writing other tenants' rows
WITH (STATE = ON);
GO
-- The application sets the tenant once per connection
EXECUTE sys.sp_set_session_context @key = N'TenantID', @value = 42, @read_only = 1; RLS traps
- The predicate is an inline table-valued function created
WITH SCHEMABINDING. - Without a block predicate, a user can insert rows they can then no longer see.
- With a shared connection pool, set
SESSION_CONTEXTwith@read_only = 1on every connection open, or tenants can impersonate each other. - Index the predicate column — the filter is added to every query.
Object-level permissions
Grant to roles, at the schema level where you can, and prefer
EXECUTE on procedures over table permissions. DENY overrides any GRANT;
REVOKE removes a grant or a deny. Ownership chaining lets a procedure read tables its
caller has no rights on when both have the same owner, and EXECUTE AS runs a module under another
principal (not available in Fabric).
-- Least privilege through roles and schemas, not individual users and objects
CREATE ROLE app_reader;
GRANT SELECT ON SCHEMA::Sales TO app_reader; -- every current and future object in the schema
CREATE ROLE app_writer;
GRANT EXECUTE ON SCHEMA::Api TO app_writer; -- procedures only; no direct table access
DENY SELECT ON dbo.Salary TO app_reader; -- DENY wins over any GRANT
-- Passwordless: a contained user for an app's managed identity (Entra ID)
CREATE USER [contoso-orders-api] FROM EXTERNAL PROVIDER;
ALTER ROLE app_writer ADD MEMBER [contoso-orders-api]; Secure, passwordless database access
- Microsoft Entra authentication for people and workloads; Entra-only authentication turns SQL authentication off at the server or instance (and is the only option in Fabric).
- Managed identities for Azure-hosted apps (App Service, Container Apps, Functions): create a
contained user
FROM EXTERNAL PROVIDERand connect withAuthentication=Active Directory Managed IdentityorActive Directory Default— no secret in the connection string or in Key Vault. - Network: server firewall rules, private endpoints, and "deny public network access" when only private traffic should reach the database.
Auditing
| Platform | Feature | Destinations |
|---|---|---|
| SQL Server 2025 | SQL Server Audit — server audit plus server or database audit specifications | File, Windows Application or Security log |
| Azure SQL MI | SQL Server Audit | Azure Blob storage, Log Analytics, Event Hubs |
| Azure SQL Database | Azure SQL auditing, at server level (all databases) or database level | Azure Storage, Log Analytics, Event Hubs |
| SQL database in Fabric | SQL auditing, configured in Security → Manage SQL auditing | The item's read-only OneLake folder; read with sys.fn_get_audit_file_v2 |
-- SQL Server / Azure SQL MI: server audit + database audit specification
CREATE SERVER AUDIT AuditToFile TO FILE (FILEPATH = N'D:\Audit\');
ALTER SERVER AUDIT AuditToFile WITH (STATE = ON);
CREATE DATABASE AUDIT SPECIFICATION PatientAccess
FOR SERVER AUDIT AuditToFile
ADD (SELECT, UPDATE ON dbo.Patient BY public),
ADD (DATABASE_ROLE_MEMBER_CHANGE_GROUP)
WITH (STATE = ON);
-- Read the logs (SQL Server, MI, Azure SQL Database and Fabric all expose this function family)
SELECT event_time, action_id, server_principal_name, statement
FROM sys.fn_get_audit_file(N'D:\Audit\*.sqlaudit', DEFAULT, DEFAULT); Secure model endpoints with managed identity
An external model call leaves the database over HTTPS, so its credential matters. Prefer a database scoped
credential with IDENTITY = 'Managed Identity' over an API key in
HTTPEndpointHeaders; grant the database's identity an RBAC role on the model resource; and grant
EXECUTE on the external model only to the roles that need it. The credential name must be the
endpoint URL (or a prefix of it).
-- Secure a model endpoint with the database's managed identity (no API key in the database).
-- Grant the identity the "Cognitive Services OpenAI User" role on the Azure OpenAI / Foundry resource first.
-- Azure SQL Database and Fabric: the logical server's / item's identity.
-- SQL Server 2025: connect the server to Azure Arc and enable its primary managed identity, then
-- EXECUTE sp_configure 'allow server scoped db credentials', 1; RECONFIGURE;
CREATE DATABASE SCOPED CREDENTIAL [https://contoso-ai.openai.azure.com/]
WITH IDENTITY = 'Managed Identity',
SECRET = '{"resourceid":"https://cognitiveservices.azure.com"}';
CREATE EXTERNAL MODEL EmbeddingModel
WITH (
LOCATION = 'https://contoso-ai.openai.azure.com/openai/deployments/text-embedding-3-small/embeddings?api-version=2024-02-01',
API_FORMAT = 'Azure OpenAI',
MODEL_TYPE = EMBEDDINGS,
MODEL = 'text-embedding-3-small',
CREDENTIAL = [https://contoso-ai.openai.azure.com/]
);
GRANT EXECUTE ON EXTERNAL MODEL::EmbeddingModel TO app_writer; Secure GraphQL, REST and MCP endpoints
Data API builder serves all three surfaces from one configuration, so the same controls protect all three:
- Authentication provider (
runtime.host.authentication.provider) -
EntraId(formerlyAzureAd) orCustomvalidate JWT bearer tokens;AppServicetrusts EasyAuth headers;Unauthenticated(the default in DAB 2.x) treats every call asanonymous;Simulatorworks only in development mode. - Roles and permissions
-
No role has permissions by default. Each entity lists roles (
anonymous,authenticatedor a custom role chosen with theX-MS-API-ROLEheader) and their actions —create,read,update,delete,execute— with field-levelinclude/exclude. - Database policies
- Row filters such as
@item.OwnerId eq @claims.oidappended to the query for a role and action. - Identity to the database
- DAB connects with a managed identity; with on-behalf-of (OBO) it passes the user's identity so RLS and auditing see the real user.
- MCP specifically
- Disable DML tools you do not need (
runtime.mcp.dml-tools.delete-record: false) and opt sensitive entities out with"mcp": false.
Samples for each are in the Configuration reference.
Optimize database performance
Recommend database configurations
| Setting | Recommendation | Why |
|---|---|---|
| Compatibility level | Latest (170) after testing | Unlocks new functions and intelligent query processing; use Query Store to catch regressions |
| Query Store | On, READ_WRITE | History of plans and runtime stats; needed for plan forcing and hints |
READ_COMMITTED_SNAPSHOT | On for OLTP | Readers stop blocking writers (default in Azure SQL Database) |
| Accelerated database recovery + optimized locking | On | Fast rollback; fewer locks held, less blocking and no lock escalation (on by default in Azure SQL Database) |
MAXDOP (database scoped) | 8 or fewer, by workload | Caps parallelism per query |
| Automatic tuning | FORCE_LAST_GOOD_PLAN on | Reverts plan-choice regressions automatically (Azure SQL; SQL Server via Query Store) |
| Statistics | Auto create and auto update on, async update for latency-sensitive OLTP | Fresh cardinality estimates |
| Azure SQL service tier | Serverless for intermittent use, Hyperscale for large or fast-growing data, Business Critical for low-latency I/O and In-Memory OLTP | Capacity matches the workload |
Isolation levels and concurrency controls
| Isolation level | Dirty reads | Non-repeatable reads | Phantoms | How it works |
|---|---|---|---|---|
READ UNCOMMITTED | Yes | Yes | Yes | No shared locks (NOLOCK) |
READ COMMITTED (locking) | No | Yes | Yes | Short shared locks; readers wait for writers |
READ COMMITTED with RCSI | No | Yes | Yes | Statement-level row versions; readers don't block |
REPEATABLE READ | No | No | Yes | Shared locks held to the end of the transaction |
SNAPSHOT | No | No | No | Transaction-level row versions; update conflicts raise error 3960 |
SERIALIZABLE | No | No | No | Key-range locks (HOLDLOCK); most blocking |
-- Reader sees the last committed version instead of waiting for writers
ALTER DATABASE CURRENT SET READ_COMMITTED_SNAPSHOT ON; -- on by default in Azure SQL Database
-- Transaction-level SNAPSHOT: one consistent view for the whole transaction
ALTER DATABASE CURRENT SET ALLOW_SNAPSHOT_ISOLATION ON;
SET TRANSACTION ISOLATION LEVEL SNAPSHOT;
BEGIN TRANSACTION;
SELECT SUM(Balance) FROM dbo.Account; -- stable for the whole transaction
COMMIT;
-- Pessimistic read-then-update without a lost update: lock the row you read
BEGIN TRANSACTION;
SELECT Quantity FROM dbo.Stock WITH (UPDLOCK, HOLDLOCK) WHERE ProductID = 42;
UPDATE dbo.Stock SET Quantity -= 1 WHERE ProductID = 42;
COMMIT;
-- Optimistic concurrency with rowversion
UPDATE dbo.Product SET ListPrice = @NewPrice
WHERE ProductID = @ID AND RowVer = @RowVerReadEarlier;
IF @@ROWCOUNT = 0 THROW 50001, 'The row was changed by someone else.', 1; Optimized locking
Optimized locking (requires accelerated database recovery) replaces row and page locks held to the end of a transaction with a single transaction ID lock, and with RCSI adds lock after qualification — rows are evaluated against the latest committed version before any lock is taken. The result is less blocking, lower lock memory and no lock escalation.
Evaluate query performance
- Execution plans: the estimated plan shows the optimizer's choice; the actual plan
adds row counts, warnings and spills. Look for estimate-versus-actual gaps, key lookups, scans where seeks were
expected, implicit conversions (a
CONVERT_IMPLICITon a column defeats an index), and tempdb spills. - Statement statistics:
SET STATISTICS IO, TIME ONfor logical reads and CPU; Live Query Statistics for a running query. - DMVs for what is happening now and since the last restart:
sys.dm_exec_requests,sys.dm_exec_sessions,sys.dm_exec_query_stats,sys.dm_os_wait_stats(orsys.dm_db_wait_statsin Azure SQL Database),sys.dm_tran_locks,sys.dm_db_index_usage_statsand the missing-index DMVs. - Query Store for history that survives restarts: regressed queries, top resource consumers, plan forcing and Query Store hints.
- Query Performance Insight — Azure SQL Database only — is a portal view over Query Store: top queries by CPU, duration and execution count. In Fabric the equivalent is the Performance Dashboard.
-- What is running now, and who is blocking whom?
SELECT r.session_id, r.blocking_session_id, r.wait_type, r.wait_time, r.cpu_time,
t.text AS sql_text
FROM sys.dm_exec_requests AS r
CROSS APPLY sys.dm_exec_sql_text(r.sql_handle) AS t
WHERE r.session_id <> @@SPID;
-- Most expensive cached statements by CPU
SELECT TOP (10) qs.total_worker_time / qs.execution_count AS avg_cpu, qs.execution_count,
SUBSTRING(t.text, qs.statement_start_offset / 2 + 1, 200) AS stmt, qp.query_plan
FROM sys.dm_exec_query_stats AS qs
CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) AS t
CROSS APPLY sys.dm_exec_query_plan(qs.plan_handle) AS qp
ORDER BY avg_cpu DESC;
-- Azure SQL Database: resource use in 15-second intervals for the last hour
SELECT end_time, avg_cpu_percent, avg_data_io_percent, avg_log_write_percent
FROM sys.dm_db_resource_stats ORDER BY end_time DESC; -- Query Store: on by default in Azure SQL Database and new SQL Server 2022+ databases
ALTER DATABASE CURRENT SET QUERY_STORE = ON (OPERATION_MODE = READ_WRITE);
-- Find a regressed query and its plans
SELECT q.query_id, p.plan_id, rs.avg_duration, rs.last_execution_time
FROM sys.query_store_query AS q
JOIN sys.query_store_plan AS p ON p.query_id = q.query_id
JOIN sys.query_store_runtime_stats AS rs ON rs.plan_id = p.plan_id
WHERE q.query_id = 1234
ORDER BY rs.avg_duration;
-- Fix it without touching the application
EXECUTE sp_query_store_force_plan @query_id = 1234, @plan_id = 17; -- pin the good plan
EXECUTE sp_query_store_set_hints @query_id = 1234, @query_hints = N'OPTION (RECOMPILE)'; -- or add a hint Blocking and deadlocks
Blocking is one session waiting for a lock another holds — find the head blocker through
blocking_session_id chains. A deadlock is a cycle; the engine kills the cheapest victim
with error 1205. Capture deadlock graphs from the system_health Extended Events session
(SQL Server, MI), a database-scoped XE session or the Deadlocks diagnostic setting (Azure SQL Database).
- Keep transactions short and never wait for user input inside one.
- Access objects in the same order in every code path.
- Index to turn scans into seeks, so fewer rows are locked.
- Turn on RCSI (and optimized locking) so readers stop taking shared locks.
- Retry on 1205 in the application, and use
SET DEADLOCK_PRIORITYorSET LOCK_TIMEOUTwhere one side should always yield.
CI/CD with SQL Database Projects
A SQL database project is the declarative source of truth: one CREATE script per object,
built into a .dacpac model. Deployment compares the model with the target and generates the
ALTER script — you never hand-write migrations for schema.
flowchart TB PR["Pull request<br/>feature branch → main"] --> BUILD["Build job<br/>dotnet build · code analysis"] BUILD --> TEST["Test job<br/>SQL Server container · unit and integration tests"] TEST --> ART["Artifact<br/>.dacpac"] ART --> DEVENV["Deploy to test<br/>environment: test"] DEVENV --> REPORT["DeployReport and script<br/>review the plan"] REPORT --> GATE["Environment gate<br/>required reviewers · branch rule"] GATE --> PROD["Publish to production<br/>azure/sql-action · OIDC login"]
Create, build and validate SDK-style projects
<!-- An SDK-style SQL project: every .sql file under the folder is included by default -->
<Project DefaultTargets="Build">
<Sdk Name="Microsoft.Build.Sql" Version="2.3.0" />
<PropertyGroup>
<Name>Contoso.Database</Name>
<DSP>Microsoft.Data.Tools.Schema.Sql.SqlAzureV12DatabaseSchemaProvider</DSP>
<RunSqlCodeAnalysis>True</RunSqlCodeAnalysis>
</PropertyGroup>
<ItemGroup>
<PostDeploy Include="Scripts\PostDeployment.sql" />
</ItemGroup>
</Project> Microsoft.Build.Sql(2.3.0 at the time of writing) is the SDK-style format; it builds cross-platform withdotnet buildand uses NuGet package references for database references.- The target platform is the
DSPproperty:Sql170DatabaseSchemaProvider(SQL Server 2025),SqlAzureV12DatabaseSchemaProvider(Azure SQL Database),SqlDbFabricDatabaseSchemaProvider(SQL database in Fabric, preview). Publishing to a different platform fails unless you pass/p:AllowIncompatiblePlatform=true. - Build validates every reference and syntax error;
RunSqlCodeAnalysisadds static analysis rules. - VS Code (SQL Database Projects in the MSSQL extension) supports SDK-style projects GA; Visual Studio 2022 has them in preview, and Visual Studio 2026 only supports the original format.
- SQL database in Fabric's built-in Git integration stores the database as an SDK-style project, one file per object under
schema/ObjectType/.
Testing strategy
| Test level | What it proves | How |
|---|---|---|
| Build and static analysis | The model compiles; no broken references or rule violations | dotnet build with code analysis, on every PR |
| Unit tests | One procedure, function or trigger behaves correctly in isolation | tSQLt: fake tables, act, assert, inside a rolled-back transaction |
| Integration tests | The deployed schema works with the app and real data shapes | Publish the .dacpac to an ephemeral database (a SQL Server container service in the workflow), then run the app's tests |
| Deployment tests | The upgrade path is safe for existing data | DeployReport/Script against a copy of production; BlockOnPossibleDataLoss |
-- A tSQLt unit test: arrange with a fake table, act, assert
EXEC tSQLt.NewTestClass 'OrderTests';
GO
CREATE PROCEDURE OrderTests.[test usp_PlaceOrder inserts one header]
AS
BEGIN
EXEC tSQLt.FakeTable 'dbo.OrderHeader';
DECLARE @no bigint;
EXEC dbo.usp_PlaceOrder @CustomerID = 7, @OrderNo = @no OUTPUT;
DECLARE @rows int = (SELECT COUNT(*) FROM dbo.OrderHeader WHERE CustomerID = 7);
EXEC tSQLt.AssertEquals @Expected = 1, @Actual = @rows;
END;
GO
EXEC tSQLt.Run 'OrderTests'; Reference and static data in source control
Lookup values (status codes, countries) belong in the project as an idempotent post-deployment
script — usually a MERGE from a VALUES list — so every environment converges on the
same rows. A pre-deployment script handles data that must move before schema changes (splitting a
column, for example). Transactional data never belongs in the project.
-- Scripts/PostDeployment.sql — idempotent reference data, runs after every publish
MERGE dbo.OrderStatus AS target
USING (VALUES (1, N'Placed'), (2, N'Shipped'), (3, N'Delivered'), (4, N'Cancelled'))
AS source (StatusID, Name)
ON target.StatusID = source.StatusID
WHEN MATCHED AND target.Name <> source.Name THEN UPDATE SET Name = source.Name
WHEN NOT MATCHED BY TARGET THEN INSERT (StatusID, Name) VALUES (source.StatusID, source.Name)
WHEN NOT MATCHED BY SOURCE THEN DELETE; Source control, branching and pull requests
- Commit the
.sqlproj, the object scripts, pre/post-deployment scripts and publish profiles without secrets; ignorebin/andobj/. - Branch per feature, merge to
mainthrough a pull request that must pass the build and tests. - Conflicts are rare because each object is its own file; when two branches change the same object, resolve the script, rebuild and re-run tests. Renames go through the project's refactor so a
.refactorlogtells deployment tosp_renameinstead of drop-and-create.
Secrets management
- Authenticate the workflow to Azure with OpenID Connect (
azure/loginwith a federated credential) — no client secret stored at all. - Keep any remaining values in GitHub secrets, preferably environment secrets so only the gated job can read them; or in Azure Key Vault.
- Deploy with Entra authentication to the database; never put a password in a publish profile or
.sqlproj, and use SQLCMD variables for per-environment values.
Detect schema drift
Drift is a target that no longer matches what the pipeline last deployed — a hotfix applied by hand, an index added in production.
sqlpackage /Action:DriftReport- Reports changes made to a registered database since it was last registered (publish with
/p:RegisterDataTierApplication=true). /p:BlockWhenDriftDetected=true(default)- Blocks a publish to a registered database whose schema no longer matches its registration.
DeployReportand schema compare- Compare the project with any target — the usual drift check in a scheduled workflow for unregistered databases.
Update a project and deploy changes
Change the object scripts (or apply a schema compare from a database into the project), build, then publish. In
GitHub Actions azure/sql-action@v2 builds a .sqlproj or takes a .dacpac and runs
SqlPackage for you. The full workflow is in the
Configuration reference.
# Build the model and validate it
dotnet build Contoso.Database.sqlproj -c Release # → bin/Release/Contoso.Database.dacpac
# What would a publish change? (XML report and a reviewable script)
sqlpackage /Action:DeployReport /SourceFile:bin/Release/Contoso.Database.dacpac \
/TargetConnectionString:"$TARGET" /OutputPath:deployreport.xml
sqlpackage /Action:Script /SourceFile:bin/Release/Contoso.Database.dacpac \
/TargetConnectionString:"$TARGET" /OutputPath:deploy.sql
# Has anyone changed a registered database outside the pipeline?
sqlpackage /Action:DriftReport /TargetConnectionString:"$TARGET" /OutputPath:drift.xml
# Publish, refusing anything that could lose data
sqlpackage /Action:Publish /SourceFile:bin/Release/Contoso.Database.dacpac \
/TargetConnectionString:"$TARGET" /p:BlockOnPossibleDataLoss=true Controls for deployment pipelines
| Control | Where | What it enforces |
|---|---|---|
| Branch protection rules or rulesets | Repository settings | Changes reach main only through a PR with required status checks and approvals; no force-push |
CODEOWNERS | .github/CODEOWNERS | The DBA team must review any change under, say, /database/ |
| Triggers | on: in the workflow | pull_request to validate, push to main to deploy, paths filters, workflow_dispatch for manual runs |
| Environments with approvals | Settings → Environments | Required reviewers, wait timer, deployment branch rules and environment-scoped secrets before a job targeting production runs |
| Authentication | permissions: id-token: write + azure/login | Short-lived OIDC tokens scoped to the environment |
An odd phrase in the study guide
The objective lists "branching policies, triggers in approvals, authentication tables, and code owners". There is no GitHub feature called an authentication table; read it as the authentication used by the pipeline (OIDC federated credentials, environment secrets) and you will match what the course teaches.
Integrate SQL solutions with Azure services
Data API builder
Data API builder (DAB) is an open-source, configuration-driven engine that exposes tables, views and stored
procedures as REST (/api), GraphQL (/graphql) and
MCP (/mcp) endpoints — no API code. Everything lives in dab-config.json,
created and edited with the dab CLI.
| Objective | What to configure |
|---|---|
| Create the configuration file | dab init --database-type mssql --connection-string "@env('SQL_CONN')"; never a literal connection string |
| Entities for REST and GraphQL | dab add Product --source dbo.Product --permissions "anonymous:read"; rest.path, graphql.type singular/plural, mappings to rename columns |
| Caching | runtime.cache plus per-entity cache.enabled and ttl-seconds for read results |
| Pagination | $first/$after (REST) and first/after (GraphQL); runtime.pagination.default-page-size (100) and max-page-size (100,000); -1 asks for the maximum |
| Searching and filtering | REST $filter (eq, ne, gt, ge, lt, le, and, or, not), $orderby, $select; GraphQL filter with operators such as contains, startsWith, isNull |
| Stored procedures and views | Views need key-fields; procedures set rest.methods and graphql.operation (query or mutation) |
| GraphQL relationships | relationships with cardinality one/many, target.entity, source/target fields, and a linking.object for many-to-many |
Deploy Data API builder
- Azure Container Apps (the common choice) or App Service, running the DAB container image with the config file mounted or baked in.
- Set
runtime.host.modetoproduction(no Simulator, no GraphQL introspection UI by default), configure CORS and the authentication provider. - Give the container a managed identity and connect to SQL with it; read any remaining values from environment variables or Key Vault references.
- Turn on Application Insights or OpenTelemetry in
runtime.telemetry.
Azure Monitor configurations
| Need | Recommend |
|---|---|
| Database metrics and alerts (CPU, DTU/vCore %, storage, deadlocks, availability) | Azure Monitor metrics with metric alert rules and action groups |
| Query and wait diagnostics retained and queryable with KQL | Diagnostic settings sending SQLInsights, QueryStoreRuntimeStatistics, QueryStoreWaitStatistics, Errors, Timeouts, Blocks and Deadlocks to a Log Analytics workspace |
| Application-side telemetry (API latency, failures, dependency calls to SQL) | Application Insights on the app or on Data API builder |
| Fleet-wide, near-real-time SQL monitoring | Database watcher (Azure SQL Database and MI), storing data in Azure Data Explorer or a Fabric Eventhouse |
| Long-term or compliance archive | Diagnostic settings to a Storage account; streaming to Event Hubs for a SIEM |
Handle changes
flowchart TB
Q["Something must react to row changes"] --> A{"Push each change as an<br/>event to a stream?"}
A -->|"yes"| CES["Change event streaming<br/>Event Hubs · Fabric Eventstream"]
A -->|"no"| B{"Need the old and new<br/>values of every change?"}
B -->|"yes"| CDC["Change data capture<br/>change tables · LSN ranges"]
B -->|"no, just which rows"| C{"Run code when<br/>rows change?"}
C -->|"serverless function"| FN["Azure Functions<br/>SQL trigger binding"]
C -->|"low-code workflow"| LA["Azure Logic Apps<br/>SQL connector trigger"]
C -->|"sync on my own schedule"| CT["Change Tracking<br/>CHANGETABLE · versions"] | Mechanism | Captures | Delivery | Platform line |
|---|---|---|---|
| Change event streaming (CES) | Each row change with old and new values, as a CloudEvent (JSON or Avro) | Pushed in near real time to Azure Event Hubs or Fabric Eventstream (Kafka protocol; AMQP deprecated from 15 August 2026) | Preview on all four |
| Change data capture (CDC) | Every change, before and after images, in change tables | Pulled by LSN range (cdc.fn_cdc_get_all_changes_…); capture and cleanup jobs | SQL Server, MI, Azure SQL DB S3+; not Fabric |
| Change Tracking | Which rows changed (key and operation), not old values | Pulled with CHANGETABLE(CHANGES …) since a saved version | All |
| Azure Functions SQL trigger binding | Row changes detected through Change Tracking | Function invoked with batches of changed rows; leases kept in an az_func schema | Needs Change Tracking on the database and table |
| Azure Logic Apps SQL connector | New or modified rows, by polling | Workflow trigger "When an item is created" or "modified" | Needs an identity column (created) or a rowversion column (modified) |
| Table trigger | Synchronous, inside the transaction | Runs T-SQL immediately | All — but adds latency to every write |
-- Change Tracking: which rows changed since version N (no old values)
ALTER DATABASE CURRENT SET CHANGE_TRACKING = ON (CHANGE_RETENTION = 2 DAYS, AUTO_CLEANUP = ON);
ALTER TABLE dbo.Product ENABLE CHANGE_TRACKING WITH (TRACK_COLUMNS_UPDATED = ON);
DECLARE @last bigint = 1520; -- stored by the consumer after its last sync
SELECT ct.ProductID, ct.SYS_CHANGE_OPERATION, ct.SYS_CHANGE_VERSION, p.Name, p.ListPrice
FROM CHANGETABLE(CHANGES dbo.Product, @last) AS ct
LEFT JOIN dbo.Product AS p ON p.ProductID = ct.ProductID; -- current values; deleted rows return NULL
SELECT CHANGE_TRACKING_CURRENT_VERSION(); -- save as the next @last
-- Change data capture: every change with before and after images
EXEC sys.sp_cdc_enable_db;
EXEC sys.sp_cdc_enable_table @source_schema = N'dbo', @source_name = N'Product', @role_name = NULL;
SELECT * FROM cdc.fn_cdc_get_all_changes_dbo_Product(
sys.fn_cdc_get_min_lsn('dbo_Product'), sys.fn_cdc_get_max_lsn(), N'all update old'); -- Change event streaming (preview): publish row changes as CloudEvents
-- SQL Server 2025 needs PREVIEW_FEATURES and the FULL recovery model; Azure SQL is always FULL.
EXEC sys.sp_enable_event_stream;
EXEC sys.sp_create_event_stream_group
@stream_group_name = N'ordersStream',
@destination_type = N'AzureEventHubsApacheKafka', -- SQL Server 2025 / MI; 'AzureEventHubs' on Azure SQL Database and Fabric
@destination_location = N'contoso-ns.servicebus.windows.net:9093/orders',
@destination_credential = CesEventHubsCredential, -- a database scoped credential
@partition_key_scheme = N'Table',
@encoding = N'JSON';
EXEC sys.sp_add_object_to_event_stream_group
@stream_group_name = N'ordersStream', @object_name = N'dbo.Orders';
SELECT * FROM sys.dm_change_feed_errors; -- delivery errors The classic distractor
"Downstream needs the previous value of the price" rules out Change Tracking and the Functions SQL trigger (both report that a row changed and give you its current values). Choose CDC to pull history, or CES to push each change as an event.