Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
264 changes: 245 additions & 19 deletions docs/contributing/code-style/sql.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,12 @@ These standards should be applied across any T-SQL scripts that you write.
- **Blank lines**: Separate sections of code with at least one blank line
- **Commas**: Commas should be placed at the right end of the line
- **Parentheses**: Parentheses should be vertically aligned with spanning multiple lines
- **Data type modifiers**: When a data type has a size or precision modifier, the preferred style is
to omit the space between the type name and the opening parenthesis (e.g., `NVARCHAR(50)` not
`NVARCHAR (50)`, `DATETIME2(7)` not `DATETIME2 (7)`)
- **ID generation**: Do not use `NEWID()` to generate IDs in the database. New IDs are generated in
application code using `CoreHelpers.GenerateComb()`. See
[GUID generation](./csharp#guid-generation) for more information.

### `SELECT` statements

Expand Down Expand Up @@ -114,6 +120,145 @@ WHERE
U.[Enabled] = 1
```

#### `WHERE` clause conditions

- `AND`/`OR` keywords go at the **start** of the next line, indented to align with the condition
above it
- Wrap grouped `OR` conditions in parentheses, with the opening `(` on the **same line** as `AND`
and the closing `)` on its own line aligned with `AND`
- Use inline comments to explain non-obvious conditions, such as status code meanings

```sql
WHERE
O.[Enabled] = 1
AND O.[UsePolicies] = 1
AND (
-- Active users linked by UserId
(OU.[Status] != 0 AND OU.[UserId] = @UserId)
-- Invited users matched by email (Status = 0)
OR EXISTS (
SELECT
1
FROM
[dbo].[UserView] U
WHERE
U.[Id] = @UserId
AND OU.[Email] = U.[Email]
AND OU.[Status] = 0
)
)
```

#### `IN` clauses

- For bulk operations, prefer table-valued parameters (TVPs) using the `IN (SELECT [Id] FROM @Ids)`
pattern
- For direct value lists, use no spaces after commas: `IN (0,1,2)`

```sql
WHERE
[Id] IN (SELECT [Id] FROM @Ids)
```

#### Subqueries

- Use `EXISTS` (not `IN`) for correlated subqueries β€” `EXISTS` short-circuits on the first match,
whereas `IN` evaluates all matching values first
- Use `IN` for non-correlated subqueries (where the inner query does not reference the outer query)
β€” the optimizer typically produces equivalent plans, and `IN` reads more naturally in this context
- Use `SELECT 1` inside `EXISTS` checks, not `SELECT *`
- Indent the subquery body 4 spaces within the parentheses; align the closing `)` with the opening
context

Correlated subquery (references outer query β€” use `EXISTS`):

```sql
CASE WHEN EXISTS (
SELECT
1
FROM
[dbo].[ProviderUserView] PU
INNER JOIN
[dbo].[ProviderOrganizationView] PO ON PO.[ProviderId] = PU.[ProviderId]
WHERE
PU.[UserId] = OU.[UserId]
AND PO.[OrganizationId] = P.[OrganizationId]
) THEN 1 ELSE 0 END AS [IsProvider]
```

Non-correlated subquery (self-contained β€” use `IN`):

```sql
SELECT
[Id],
[Name]
FROM
[dbo].[OrganizationView]
WHERE
[Id] IN (
SELECT
[OrganizationId]
FROM
[dbo].[CollectionView]
WHERE
[ExternalId] IS NOT NULL
)
```

#### Common table expressions (CTEs)

- Prefix the `WITH` keyword with a semicolon: `;WITH`
- Place the CTE name and `AS` followed by the opening `(` on the same line
- Put the closing `)` on its own line; follow it with a comma and the next CTE name when chaining
- Each CTE `SELECT` follows the same formatting rules as a regular `SELECT` statement
- Put `UNION ALL` on its own line, with a blank line above and below it

```sql
;WITH OrgUsers AS
(
-- Active users: direct UserId match
SELECT
OU.[Id],
OU.[OrganizationId],
OU.[Status]
FROM
[dbo].[OrganizationUserView] OU
WHERE
OU.[Status] <> 0
AND OU.[UserId] = @UserId

UNION ALL

-- Invited users: matched by email
SELECT
OU.[Id],
OU.[OrganizationId],
OU.[Status]
FROM
[dbo].[OrganizationUserView] OU
WHERE
OU.[Status] = 0
AND OU.[Email] = @UserEmail
),
Providers AS
(
SELECT
[OrganizationId]
FROM
[dbo].[UserProviderAccessView]
WHERE
[UserId] = @UserId
)
SELECT
OU.[Id],
OU.[OrganizationId],
CASE WHEN PR.[OrganizationId] IS NULL THEN 0 ELSE 1 END AS [IsProvider]
FROM
OrgUsers OU
LEFT JOIN
Providers PR ON PR.[OrganizationId] = OU.[OrganizationId]
```

### Stored procedures

- **Stored Procedure Name**: `{EntityName}_{Action}` format (e.g., `[dbo].[User_ReadById]`)
Expand All @@ -132,8 +277,19 @@ WHERE

:::

:::warning Avoid using `Get` in procedure names

Some procedures in the codebase use `Get` instead of `Read` in the name (e.g.,
`CipherOrganizationPermissions_GetManyByOrganizationId`, `OrganizationReport_GetSummaryDataById`).
These are incorrect and should not be used as a reference. Always use `Read` or `ReadMany` for
`SELECT` operations.

:::

#### Basic structure

- Wrap the entire procedure body in `BEGIN`/`END` statements

```sql
CREATE PROCEDURE [dbo].[EntityName_Action]
@Parameter1 DATATYPE,
Expand All @@ -148,6 +304,65 @@ BEGIN
END
```

#### Common examples

**Read by ID** β€” select a single record from a view:

```sql
CREATE PROCEDURE [dbo].[EntityName_ReadById]
@Id UNIQUEIDENTIFIER
AS
BEGIN
SET NOCOUNT ON

SELECT
*
FROM
[dbo].[EntityNameView]
WHERE
[Id] = @Id
END
```

**Read many by IDs** β€” bulk read using a table-valued parameter:

```sql
CREATE PROCEDURE [dbo].[EntityName_ReadManyByIds]
@Ids AS [dbo].[GuidIdArray] READONLY
AS
BEGIN
SET NOCOUNT ON

SELECT
*
FROM
[dbo].[EntityNameView]
WHERE
[Id] IN (SELECT [Id] FROM @Ids)
END
```

**Read many with filter** β€” multiple `AND` conditions with an inline status code comment:

```sql
CREATE PROCEDURE [dbo].[EntityName_ReadManyByOrganizationIdAndRole]
@OrganizationId UNIQUEIDENTIFIER,
@Role TINYINT
AS
BEGIN
SET NOCOUNT ON

SELECT
*
FROM
[dbo].[EntityNameDetailsView]
WHERE
[OrganizationId] = @OrganizationId
AND [Status] = 2 -- 2 = Confirmed
AND [Type] = @Role
END
```

#### Parameter declaration

- One parameter per line
Expand Down Expand Up @@ -204,7 +419,8 @@ WHERE

### Tables

- **Table Name**: PascalCase (e.g., [dbo].[User], [dbo].[AuthRequest])
- **Table Name**: Singular form of the object name, PascalCase (e.g., `[dbo].[User]` not
`[dbo].[Users]`, `[dbo].[AuthRequest]` not `[dbo].[AuthRequests]`)
- **Column Names**: PascalCase (e.g., [Id], [CreationDate], [MasterPasswordHash])
- **Primary Key**: `PK_{TableName}` (e.g., [PK_User], [PK_Organization])
- **Foreign Keys**: `FK_{TableName}_{ReferencedTable}` (e.g., FK_Device_User)
Expand All @@ -220,6 +436,8 @@ WHERE
- `VARCHAR(n)` for ASCII text
- `BIT` for boolean values
- `TINYINT`, `SMALLINT`, `INT`, `BIGINT` for integers
- **Data type modifiers**: No space between the type name and its size or precision modifier (e.g.,
`NVARCHAR(50)` not `NVARCHAR (50)`, `DATETIME2(7)` not `DATETIME2 (7)`)
- **Nullability**: Explicitly specify `NOT NULL` or `NULL`
- **Standard Columns**: Most tables include:
- `[Id] UNIQUEIDENTIFIER NOT NULL` - Primary key
Expand All @@ -229,12 +447,13 @@ WHERE
```sql
CREATE TABLE [dbo].[TableName]
(
[Column1] INT IDENTITY(1,1) NOT NULL,
[Column2] NVARCHAR(100) NOT NULL,
[Column3] NVARCHAR(255) NULL,
[Column4] BIT NOT NULL CONSTRAINT [DF_TableName_Column4] DEFAULT (1),

CONSTRAINT [PK_TableName] PRIMARY KEY CLUSTERED ([Column1] ASC)
[Id] UNIQUEIDENTIFIER NOT NULL,
[Column2] NVARCHAR(100) NOT NULL,
[Column3] NVARCHAR(255) NULL,
[CreationDate] DATETIME2(7) NOT NULL,
[RevisionDate] DATETIME2(7) NOT NULL,
[Column6] BIT NOT NULL CONSTRAINT [DF_TableName_Column6] DEFAULT (1),
CONSTRAINT [PK_TableName] PRIMARY KEY CLUSTERED ([Id] ASC)
);
```

Expand Down Expand Up @@ -295,8 +514,8 @@ CREATE FUNCTION [dbo].[FunctionName](@Parameter DATATYPE)
RETURNS TABLE
AS RETURN
SELECT
Column1,
Column2,
[Column1],
[Column2],
CASE
WHEN Condition
THEN Value1
Expand Down Expand Up @@ -503,11 +722,15 @@ script will not modify the data type again.

```sql
IF EXISTS (
SELECT *
FROM INFORMATION_SCHEMA.COLUMNS
WHERE COLUMN_NAME = '{column_name}' AND
DATA_TYPE = '{datatype}' AND
TABLE_NAME = '{table_name}')
SELECT
*
FROM
INFORMATION_SCHEMA.COLUMNS
WHERE
COLUMN_NAME = '{column_name}'
AND DATA_TYPE = '{datatype}'
AND TABLE_NAME = '{table_name}'
)
BEGIN
ALTER TABLE [dbo].[{table_name}]
ALTER COLUMN [{column_name}] {NEW_TYPE} {NULL|NOT NULL}
Expand Down Expand Up @@ -554,8 +777,7 @@ GO
#### Adjusting metadata

When altering views, you may also need to refresh modules (stored procedures or functions) that
reference that view or function so that SQL Server to update its statistics and compiled references
to it.
reference that view so that SQL Server can update its cached metadata and compiled references to it.

```sql
IF OBJECT_ID('[dbo].[{procedure_or_function}]') IS NOT NULL
Expand Down Expand Up @@ -615,9 +837,13 @@ old index available for queries during the rebuild.

```sql
IF EXISTS (
SELECT * FROM sys.indexes
WHERE name = 'IX_Organization_Enabled'
AND object_id = OBJECT_ID('[dbo].[Organization]')
SELECT
*
FROM
sys.indexes
WHERE
name = 'IX_Organization_Enabled'
AND object_id = OBJECT_ID('[dbo].[Organization]')
)
BEGIN
CREATE NONCLUSTERED INDEX [IX_Organization_Enabled]
Expand Down
15 changes: 15 additions & 0 deletions docs/contributing/database-migrations/mssql.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,18 @@ will call `dbo_finalization`.

Upon execution any finalization scripts will be [automatically moved](./edd.mdx#online-environments)
for proper history.

### Data migrations

Data migrations are sometimes needed during deployment β€” for example, seeding a new column or
inserting rows required by a new feature.

However, large or active tables can make this infeasible, as migrations may take hours or days.
Migrations also run before code releases, so data can become stale by the time the app deploys.

Therefore, new features must handle both old and new data states. This decouples migrations from
code releases and allows natural migration over time (e.g. write the expected value on first access,
then use the new value going forward).

Once the bulk of data has been naturally migrated, a cleanup script can bring remaining data to the
desired state, at which point the old code path can be removed.
Loading