Sometimes there’s a need to create database documentation - an old fashioned list of tables, columns, views etc. - as part of delivering a project, or for analysts, project managers and end users.
In these situations I tend to automate. SQL Server is very “meta” in that it contains tables and records that describe, well, other tables and records. Coupled with the MS_Description
extended property, it’s a powerful way to keep up-to-date documentation in close proximity to the code itself.
The script below will generate Markdown for database tables, views, stored procedures and functions. Column names and data types are listed for tables. View definitions are output. For stored procedures and functions, only the MS_Description
extended property will be output.
Additionally, the Markdown contains hyperlinked table names, useful for referencing from other documents.
I built and adapted the script from sources on the internat. Feel free to build and adapt as you need!
Thomas’s “but it worked for me” disclaimer: before using any code you find on the internet, especially on this blog, take time to understand what the code does and test, test, test. I’m not responsible for damage caused by code from this blog, and don’t offer any support or warranty.
--database documentation queries for current database, outputs markdown format
--outputs:
-- * for each database table, a table with column name, type, description
-- * for each view, the first 3,000 characters of the view definition
-- * for functions and stored procedures, the name and the description
--tested with SQL Server 2019
--run this query with "Results to Text"
--can be copy/pasted to app that supports markdown e.g. static site/blog, GitHub, Confluence etc.
--adapted from https://gist.github.com/mwinckler/2577364, https://www.red-gate.com/hub/product-learning/flyway/managing-database-documentation-during-flyway-based-development, https://gist.github.com/aplocher/fa86d16d3dd94ab4e42cc22b6b2fafa0
SET NOCOUNT, XACT_ABORT ON
SET CONCAT_NULL_YIELDS_NULL OFF
--temp table for lines to output, returned at end of process
DECLARE @temp_lines TABLE (
--internal identifier used for ordering
[id] INT IDENTITY (1, 1) PRIMARY KEY,
--line of SQL code
[line] NVARCHAR(4000)
)
--temp table for list of tables from INFORMATION_SCHEMA.TABLES
DECLARE @temp_all_tables TABLE (
[id] INT IDENTITY (1, 1) PRIMARY KEY,
[TABLE_SCHEMA] SYSNAME NOT NULL,
[TABLE_NAME] SYSNAME NOT NULL
)
--temp table for just primary keys
DECLARE @temp_primary_keys TABLE (
[TABLE_SCHEMA] SYSNAME NOT NULL,
[TABLE_NAME] SYSNAME NOT NULL,
[COLUMN_NAME] SYSNAME NOT NULL
)
--insert disclaimer into output in italics
--date in unambiguous month format d/MMM/YYYY (because, Aussie here)
INSERT INTO @temp_lines ([line])
SELECT N'*This page was generated from a script at ' + FORMAT(GETDATE(), 'hh:mmtt d/MMM/yyyy') + N'*'
--get tables into temp table (will not include system tables)
INSERT INTO @temp_all_tables
SELECT [TABLE_SCHEMA], [TABLE_NAME]
FROM INFORMATION_SCHEMA.TABLES
WHERE TABLE_TYPE = 'BASE TABLE'
ORDER BY 1, 2
--get just primary keys into temp table for easier retrieval & comparison later
--need table schema, table name, primary key column(s)
INSERT INTO @temp_primary_keys
SELECT [TABLE_SCHEMA] = CONVERT(SYSNAME, SCHEMA_NAME(o.[schema_id])),
[TABLE_NAME] = o.[name],
--column name
[COLUMN_NAME] = c.[name]
FROM sys.objects o INNER JOIN
--only objects with indexes
sys.indexes i ON
o.object_id = i.object_id INNER JOIN
sys.index_columns ic ON
i.object_id = ic.object_id AND
i.index_id = ic.index_id INNER JOIN
sys.columns c ON
ic.object_id = c.object_id AND
ic.column_id = c.column_id
WHERE --user (not system) tables only
o.[type] = 'U' AND
--ignore system tables
o.[is_ms_shipped] = 0 AND
--ignore heaps
i.[index_id] > 0 AND
--ignore indexes that "...cannot be used directly as a data access path. Hypothetical indexes hold column-level statistics..."
i.[is_hypothetical] = 0 AND
--primary keys only
i.[is_primary_key] = 1
--current table loop index (start at 1, unless there's no tables)
DECLARE @current_loop_table_index INT = (SELECT MIN([id]) FROM @temp_all_tables)
--output section heading for tables
INSERT INTO @temp_lines ([line])
SELECT N'## Tables'
--loop through each table
WHILE @current_loop_table_index IS NOT NULL BEGIN
--current loop schema and table name
DECLARE @current_loop_table_schema SYSNAME = (SELECT MIN([TABLE_SCHEMA]) FROM @temp_all_tables WHERE [id] = @current_loop_table_index)
DECLARE @current_loop_table_name SYSNAME = (SELECT MIN([TABLE_NAME]) FROM @temp_all_tables WHERE [id] = @current_loop_table_index)
--output schema & table name as heading 3, make into an anchor
INSERT INTO @temp_lines ([line])
SELECT N'### [' + @current_loop_table_schema + N'.' + @current_loop_table_name + N'](#' + @current_loop_table_schema + N'.' + @current_loop_table_name + N')'
--output extended property for table, allow up to 2,000 characters
--adapted from https://gist.github.com/mwinckler/2577364
INSERT INTO @temp_lines ([line])
SELECT --if there's no trailing full stop, add one
LTRIM(RTRIM(CONVERT(NVARCHAR(2000), [value]))) +
CASE
WHEN RIGHT(LTRIM(RTRIM(CONVERT(NVARCHAR(2000), [value]))), 1) != N'.' THEN N'.'
ELSE N''
END
FROM sys.extended_properties
WHERE --MS_Description metadata only
[name] = N'MS_Description' AND
--should be zero for table and column metadata
[minor_id] = 0 AND
--match object ID of current loop table
[major_id] = OBJECT_ID(QUOTENAME(@current_loop_table_schema) + N'.' + QUOTENAME(@current_loop_table_name))
--output markdown table header for columns
INSERT INTO @temp_lines ([line])
SELECT N'| Column name | Description | Data type | Allow NULLs |'
UNION ALL
SELECT N'| ------- | ------- | ------- | ------- |'
--output columns from INFORMATION_SCHEMA.COLUMNS
--as markdown table rows
--roughly match SSMS designers
--data types in uppercase, because that's the way I like 'em
INSERT INTO @temp_lines ([line])
SELECT --column name as bold text
N'| **' + COLUMNS.[COLUMN_NAME] + N'**' +
--if this column is a primary key, add star symbol (ideally could use "key" emoji)
CASE
WHEN EXISTS (
SELECT 1
FROM @temp_primary_keys p
WHERE COLUMNS.[TABLE_SCHEMA] = p.[TABLE_SCHEMA] AND
COLUMNS.[TABLE_NAME] = p.[TABLE_NAME] AND
COLUMNS.[COLUMN_NAME] = p.[COLUMN_NAME]
) THEN N' ★'
ELSE N''
END +
--get description for extended properties
--if there's no trailing full stop, add one
N' | ' + LTRIM(RTRIM(CONVERT(NVARCHAR(2000), ep.[value]))) +
CASE
WHEN RIGHT(LTRIM(RTRIM(CONVERT(NVARCHAR(2000), ep.[value]))), 1) != N'.' THEN N'.'
ELSE N''
END +
--put together data type
N' | ' + UPPER(COLUMNS.[DATA_TYPE]) +
--append precision in brackets, for certain data types only
CASE
--add precision for datetime offset
WHEN UPPER(COLUMNS.[DATA_TYPE]) IN (N'DATETIMEOFFSET') THEN N'(' + CONVERT(NVARCHAR(25), COLUMNS.[DATETIME_PRECISION]) + N')'
--for numeric columns, add precision
WHEN UPPER(COLUMNS.[DATA_TYPE]) IN (N'NUMERIC') THEN N'(' + CONVERT(NVARCHAR(25), COLUMNS.[NUMERIC_PRECISION]) + N',' + CONVERT(NVARCHAR(25), COLUMNS.[NUMERIC_SCALE]) + N')'
--for character columns, add length (replace length of -1 with MAX)
WHEN UPPER(COLUMNS.[DATA_TYPE]) IN (N'CHAR', N'NCHAR', N'VARCHAR', N'NVARCHAR') THEN
REPLACE(N'(' + CONVERT(NVARCHAR(25), COLUMNS.CHARACTER_MAXIMUM_LENGTH) + N')', N'(-1)', N'(MAX)')
ELSE N''
END +
--special case - if an IDENTITY column, append word "identity"
CASE
WHEN c.[is_identity] = 1 THEN N' IDENTITY'
ELSE N''
END +
--custom NULL column, ticked check box if is nullable
N' | ' + CASE
WHEN COLUMNS.[IS_NULLABLE] = N'YES' THEN N'☑'
--empty check box if not nullable
ELSE N'☐'
END +
--close table row
N' |'
FROM INFORMATION_SCHEMA.COLUMNS LEFT OUTER JOIN
sys.extended_properties ep ON
ep.[name] = N'MS_Description' AND
--table is "major_id"
ep.[major_id] = OBJECT_ID(QUOTENAME(@current_loop_table_schema) + N'.' + QUOTENAME(@current_loop_table_name)) AND
--column number is "minor_id"
ep.[minor_id] = COLUMNS.[ORDINAL_POSITION] LEFT OUTER JOIN
--sys.columns, needed for identity columns
sys.columns C ON
C.[object_id] = OBJECT_ID(QUOTENAME(@current_loop_table_schema) + N'.' + QUOTENAME(@current_loop_table_name)) AND
C.[name] = COLUMNS.[COLUMN_NAME]
WHERE COLUMNS.[TABLE_SCHEMA] = @current_loop_table_schema AND
COLUMNS.[TABLE_NAME] = @current_loop_table_name
ORDER BY
--order the same as SSMS designers
COLUMNS.[ORDINAL_POSITION]
--increment table loop index
SET @current_loop_table_index = (SELECT MIN([id]) FROM @temp_all_tables WHERE [id] > @current_loop_table_index)
END
--are there any views?
IF EXISTS(SELECT * FROM INFORMATION_SCHEMA.VIEWS) BEGIN
--output section heading for views
INSERT INTO @temp_lines ([line])
SELECT N'## Views'
--for views, output name as heading, and definition as code block
INSERT INTO @temp_lines ([line])
SELECT N'### ' + [TABLE_SCHEMA] + N'.' + [TABLE_NAME] + CHAR(13) +
--if definition is longer than 4,000 characters, will be NULL
CASE
WHEN [VIEW_DEFINITION] IS NULL THEN N'*Text too large to display*'
ELSE
--start code block
N'````' + CHAR(13) +
--first 3,000 characters of definition
--need to remove leading and trailing linebreaks
TRIM(CONVERT(NVARCHAR(3000), TRIM(CHAR(13) FROM TRIM(CHAR(10) FROM TRIM(CHAR(13) FROM [VIEW_DEFINITION]))))) +
--if definition is longer than 3,000 characters, add ellipses
(CASE WHEN LEN([VIEW_DEFINITION]) > 3000 THEN N'...' ELSE N'' END) + CHAR(13) +
--end of code block
N'````' + CHAR(13)
END
FROM INFORMATION_SCHEMA.VIEWS
ORDER BY [TABLE_SCHEMA], [TABLE_NAME]
END
--are there any (non-system) stored procedures?
IF EXISTS(SELECT * FROM sys.procedures WHERE [is_ms_shipped] = 0) BEGIN
--output section heading for stored procedures
INSERT INTO @temp_lines ([line])
SELECT N'## Stored procedures'
--stored procedures, output name and description separated by line break
INSERT INTO @temp_lines ([line])
SELECT N'**' + procs.[name] + N'**' + CHAR(13) + LTRIM(RTRIM(CONVERT(NVARCHAR(2000), ep.[value])))
FROM sys.procedures procs LEFT OUTER JOIN
sys.extended_properties ep ON
ep.[name] = N'MS_Description' AND
ep.[major_id] = procs.[object_id] AND
--stored procedure comments (not parameters)
ep.[minor_id] = 0
WHERE --not system stored procs
procs.[is_ms_shipped] = 0
ORDER BY procs.[name]
END
--are there any functions?
IF EXISTS(SELECT * FROM sys.objects WHERE [type] = 'FN' AND [is_ms_shipped] = 0) BEGIN
--output section heading for functions
INSERT INTO @temp_lines ([line])
SELECT N'## Functions'
--functions, output name and metadata separated by line break
INSERT INTO @temp_lines ([line])
SELECT N'**' + o.[name] + N'**' + CHAR(13) + LTRIM(RTRIM(CONVERT(NVARCHAR(2000), ep.[value])))
FROM sys.objects o LEFT OUTER JOIN
sys.extended_properties ep ON
ep.[name] = N'MS_Description' AND
ep.[major_id] = o.[object_id] AND
ep.[minor_id] = 0
WHERE --functions
o.[type] = 'FN' AND
--not system functions
o.[is_ms_shipped] = 0
ORDER BY o.[name]
END
SET NOCOUNT OFF
--return output in order of line number, but without line number in output
SELECT [line]
FROM @temp_lines