Repository: ariga/atlas
Pull request #1935 (2023-08-03 15:03:02)

doc: add view to hcl schema


Patch:
diff --git a/cmd/atlas/doc.tmpl b/cmd/atlas/doc.tmpl
index cd95564e..6691d0b8 100644
--- a/cmd/atlas/doc.tmpl
+++ b/cmd/atlas/doc.tmpl
@@ -6,6 +6,7 @@ slug: cli-reference
 ---
 {{- end }}
 {{ define "body" }}
+<!-- This file is generated from cmd/atlas/doc.tmpl, do not edit manually -->
 ## Introduction
 
 This document serves as reference documentation for all available commands in the Atlas CLI.
@@ -17,15 +18,16 @@ For a more detailed introduction to the CLI capabilities, head over to the
 
 ## Distributed Binaries
 
-The binaries distributed in official releases are released under the  [Apache 2 License](https://github.com/ariga/atlas/blob/master/LICENSE).
-If you would like to build Atlas from source follow the instructions [here](https://atlasgo.io/cli-reference#building-from-source).
+The binaries and Docker images distributed in official releases are released in two flavors:
 
-### Building from Source
+* Atlas - A binary built from the OSS repository in addition to proprietary code maintained by
+  [Ariga](https://ariga.io), including access to the Atlas Cloud service and commercial driver support.
+  Atlas is distributed under the [Atlas EULA](https://ariga.io/legal/atlas/eula).
+* Atlas Community - A binary built from the OSS repository only. Atlas Community is distributed under
+  the [Apache 2 License](https://github.com/ariga/atlas/blob/master/LICENSE).
+
+For instructions on how to install Atlas Community, see this [guide](/community-edition).
 
-If you would like to build Atlas from source without the UI code run:
-```shell
-go get ariga.io/atlas/cmd/atlas
-```
 {{ range .Blocks }}
 {{ header .Depth }} {{ .CommandPath }}
 
diff --git a/cmd/atlas/go.mod b/cmd/atlas/go.mod
index 049cc343..65f3bbe9 100644
--- a/cmd/atlas/go.mod
+++ b/cmd/atlas/go.mod
@@ -3,7 +3,7 @@ module ariga.io/atlas/cmd/atlas
 go 1.20
 
 require (
-	ariga.io/atlas v0.12.1-0.20230726093600-73399935e167
+	ariga.io/atlas v0.12.2-0.20230806193313-117e03f96e45
 	entgo.io/ent v0.12.4-0.20230726082433-91c7fcc68504
 	github.com/1lann/promptui v0.8.1-0.20220708222609-81fad96dd5e1
 	github.com/antlr/antlr4/runtime/Go/antlr v0.0.0-20220816024939-bc8df83d7b9d
diff --git a/cmd/atlas/go.sum b/cmd/atlas/go.sum
index e70049b7..3e751e75 100644
--- a/cmd/atlas/go.sum
+++ b/cmd/atlas/go.sum
@@ -1,5 +1,7 @@
 ariga.io/atlas v0.12.1-0.20230726093600-73399935e167 h1:/R6gzexfeieMEQlHMVdSwmUsM469hamq6utBWNfhXT4=
 ariga.io/atlas v0.12.1-0.20230726093600-73399935e167/go.mod h1:+TR129FJZ5Lvzms6dvCeGWh1yR6hMvmXBhug4hrNIGk=
+ariga.io/atlas v0.12.2-0.20230806193313-117e03f96e45 h1:LwtsFmBfWKkJSzUoRmMt7XU83QBpTJTJwF94CmINP1Q=
+ariga.io/atlas v0.12.2-0.20230806193313-117e03f96e45/go.mod h1:+TR129FJZ5Lvzms6dvCeGWh1yR6hMvmXBhug4hrNIGk=
 bazil.org/fuse v0.0.0-20160811212531-371fbbdaa898/go.mod h1:Xbm+BRKSBEpa4q4hTSxohYNQpsxXPbPry4JJWOB3LB8=
 bazil.org/fuse v0.0.0-20200407214033-5883e5a4b512/go.mod h1:FbcW6z/2VytnFDhZfumh8Ss8zxHE6qpMP5sHTRe0EaM=
 cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
diff --git a/cmd/atlas/internal/cloudapi/client.go b/cmd/atlas/internal/cloudapi/client.go
index 219982cc..2c22578d 100644
--- a/cmd/atlas/internal/cloudapi/client.go
+++ b/cmd/atlas/internal/cloudapi/client.go
@@ -237,9 +237,7 @@ type roundTripper struct {
 
 // RoundTrip implements http.RoundTripper.
 func (r *roundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
-	req.Header.Set("Authorization", "Bearer "+r.token)
-	req.Header.Set("User-Agent", "atlas-cli")
-	req.Header.Set("Content-Type", "application/json")
+	SetHeader(req, r.token)
 	return http.DefaultTransport.RoundTrip(req)
 }
 
@@ -251,3 +249,10 @@ func RedactedURL(s string) (string, error) {
 	}
 	return u.Redacted(), nil
 }
+
+// SetHeader sets header fields for cloud requests.
+func SetHeader(req *http.Request, token string) {
+	req.Header.Set("Authorization", "Bearer "+token)
+	req.Header.Set("User-Agent", "atlas-cli")
+	req.Header.Set("Content-Type", "application/json")
+}
diff --git a/cmd/atlas/internal/cmdapi/schema.go b/cmd/atlas/internal/cmdapi/schema.go
index 63eaa0bb..be277cac 100644
--- a/cmd/atlas/internal/cmdapi/schema.go
+++ b/cmd/atlas/internal/cmdapi/schema.go
@@ -515,10 +515,12 @@ func schemaInspectRun(cmd *cobra.Command, _ []string, flags schemaInspectFlags)
 		}
 	}
 	s, err := r.ReadState(ctx)
+	if err != nil {
+		return err
+	}
 	return format.Execute(cmd.OutOrStdout(), &cmdlog.SchemaInspect{
 		Client: client,
 		Realm:  s,
-		Error:  err,
 	})
 }
 
diff --git a/cmd/atlas/internal/cmdapi/vercheck/vercheck.go b/cmd/atlas/internal/cmdapi/vercheck/vercheck.go
index 1acae18e..dc863a49 100644
--- a/cmd/atlas/internal/cmdapi/vercheck/vercheck.go
+++ b/cmd/atlas/internal/cmdapi/vercheck/vercheck.go
@@ -100,7 +100,7 @@ func (v *VerChecker) Check(ver string) (*Payload, error) {
 		if err := os.MkdirAll(filepath.Dir(v.statePath), os.ModePerm); err != nil {
 			return nil, err
 		}
-		if err := os.WriteFile(v.statePath, st, os.ModePerm); err != nil {
+		if err := os.WriteFile(v.statePath, st, 0666); err != nil {
 			return nil, err
 		}
 	}
diff --git a/cmd/atlas/internal/cmdlog/cmdlog.go b/cmd/atlas/internal/cmdlog/cmdlog.go
index d36355a6..cd7b56bf 100644
--- a/cmd/atlas/internal/cmdlog/cmdlog.go
+++ b/cmd/atlas/internal/cmdlog/cmdlog.go
@@ -524,7 +524,6 @@ func (c Changes) MarshalJSON() ([]byte, error) {
 type SchemaInspect struct {
 	*sqlclient.Client `json:"-"`
 	Realm             *schema.Realm `json:"Schema,omitempty"` // Inspected realm.
-	Error             error         `json:"Error,omitempty"`  // General error that occurred during inspection.
 }
 
 var (
@@ -537,7 +536,7 @@ var (
 	// SchemaInspectTemplate holds the default template of the 'schema inspect' command.
 	SchemaInspectTemplate = template.Must(template.New("inspect").
 				Funcs(InspectTemplateFuncs).
-				Parse(`{{ with .Error }}{{ .Error }}{{ else }}{{ $.MarshalHCL }}{{ end }}`))
+				Parse(`{{ $.MarshalHCL }}`))
 )
 
 // MarshalHCL returns the default HCL representation of the schema.
@@ -552,9 +551,6 @@ func (s *SchemaInspect) MarshalHCL() (string, error) {
 
 // MarshalJSON implements json.Marshaler.
 func (s *SchemaInspect) MarshalJSON() ([]byte, error) {
-	if s.Error != nil {
-		return json.Marshal(struct{ Error string }{s.Error.Error()})
-	}
 	type (
 		Column struct {
 			Name string `json:"name"`
@@ -657,9 +653,6 @@ func (s *SchemaInspect) MarshalSQL(indent ...string) (string, error) {
 }
 
 func sqlInspect(report *SchemaInspect, indent ...string) (string, error) {
-	if report.Error != nil {
-		return report.Error.Error(), nil
-	}
 	var changes schema.Changes
 	for _, s := range report.Realm.Schemas {
 		// Generate commands for creating the schemas on realm-mode.
diff --git a/doc/md/atlas-schema/hcl.mdx b/doc/md/atlas-schema/hcl.mdx
index 46230a70..089980b9 100644
--- a/doc/md/atlas-schema/hcl.mdx
+++ b/doc/md/atlas-schema/hcl.mdx
@@ -207,6 +207,43 @@ table "b" "users" {
 }
 ```
 
+## View
+
+A `view` is a virtual table in the database, defined by a statement that queries rows from one or more existing
+tables or views.
+
+```hcl
+view "clean_users" {
+  schema = schema.public
+  column "id" {
+    type = int
+  }
+  column "name" {
+    type = text
+  }
+  as         = <<-SQL
+  SELECT u.id, u.name
+    FROM ${table.users.name} AS u
+    JOIN ${view.active_users.name} AS au USING (id)
+  SQL
+  depends_on = [table.users, view.t1]
+  comment    = "A view to active users without sensitive data"
+}
+
+view "comedies" {
+  schema = schema.public
+  column "id" {
+    type = int
+  }
+  column "name" {
+    type = text
+  }
+  as           = "SELECT id, name FROM films WHERE kind = 'Comedy'"
+  depends_on   = [table.films]
+  check_option = CASCADED
+}
+```
+
 ## Column
 
 A `column` is a child resource of a `table`.
diff --git a/doc/md/atlas-schema/projects.mdx b/doc/md/atlas-schema/projects.mdx
index bfba3405..3e5f7ab8 100644
--- a/doc/md/atlas-schema/projects.mdx
+++ b/doc/md/atlas-schema/projects.mdx
@@ -758,7 +758,7 @@ set or map item. See the example [below](#multi-environment-example).
 
 - `lint` - A block defines the migration linting configuration of the env.
   - `format` - Override the `--format` flag by setting a custom logging for `migrate lint` (previously named `log`).
-  - `lastest` - A number configures the `--latest` option.
+  - `latest` - A number configures the `--latest` option.
   - `git.base` - A run analysis against the base Git branch.
   - `git.dir` - A path to the repository working directory.
 
@@ -900,4 +900,4 @@ env "local" {
     }
   }
 }
-```
\ No newline at end of file
+```
diff --git a/doc/md/cloud/getting-started.mdx b/doc/md/cloud/getting-started.mdx
index 728b2d73..a88d6736 100644
--- a/doc/md/cloud/getting-started.mdx
+++ b/doc/md/cloud/getting-started.mdx
@@ -41,4 +41,30 @@ Yes, head over to our [live demo account](https://gh.atlasgo.cloud) to see Atlas
 
 Atlas Cloud comes with a free "Community" plan which should be more than enough for most teams.
 
-To learn more see the [pricing page](https://atlasgo.cloud/pricing).
\ No newline at end of file
+To learn more see the [pricing page](https://atlasgo.cloud/pricing).
+
+### How do I get access to beta drivers?
+
+Atlas Cloud users can get early access to beta drivers by joining the Atlas Cloud Beta Program. To join the program,
+you first need to create an account on Atlas Cloud, enable it in your account settings, and then login to the CLI
+using your Atlas identity.
+
+1\. Sign up first on [Atlas Cloud](https://auth.atlasgo.cloud/signup).
+
+2\. Go to the account settings by clicking your avatar. Then, select the driver you want to get access to. For example,
+Microsoft SQL Server.
+
+<details>
+<summary>Screenshot example</summary>
+
+![](https://atlasgo.io/uploads/cloud/beta-driver.png)
+
+</details>
+
+3\. Last, run `atlas login` to login or refetch your account permissions:
+
+```text
+$ atlas login a8m
+//highlight-next-line-info
+You are now connected to "a8m" on Atlas Cloud.
+```
diff --git a/doc/md/community-edition.mdx b/doc/md/community-edition.mdx
new file mode 100644
index 00000000..da7f407d
--- /dev/null
+++ b/doc/md/community-edition.mdx
@@ -0,0 +1,80 @@
+---
+title: Community Edition
+id: community-edition
+slug: community-edition
+---
+import Tabs from '@theme/Tabs';
+import TabItem from '@theme/TabItem';
+
+## Obtaining Community Binaries
+
+Users that want to use the Community Edition of Atlas which is built directly from the
+[Atlas GitHub repository](https://github.com/ariga/atlas) and licensed under the
+Apache 2.0 license can do so by following the instructions below.
+
+<Tabs
+    defaultValue="apple-intel-plain+linux"
+    values={[
+        {label: 'macOS + Linux', value: 'apple-intel-plain+linux'},
+        {label: 'Docker', value: 'docker'},
+        {label: 'Windows', value: 'windows'},
+        {label: 'Manual Installation', value: 'manual'},
+    ]}>
+<TabItem value="apple-intel-plain+linux">
+
+To download and install the latest release of the Atlas CLI, simply run the following in your terminal:
+
+```shell
+curl -sSf https://atlasgo.sh | sh -s -- --community
+```
+
+</TabItem>
+<TabItem value="docker">
+
+To pull the Atlas image and run it as a Docker container:
+
+```shell
+docker pull arigaio/atlas:latest-community
+docker run --rm arigaio/atlas:latest-community --help
+```
+
+If the container needs access to the host network or a local directory, use the `--net=host` flag and mount the desired
+directory:
+
+```shell
+docker run --rm --net=host \
+-v $(pwd)/migrations:/migrations \
+arigaio/atlas:latest-community migrate apply
+--url "mysql://root:pass@:3306/test"
+```
+
+</TabItem>
+<TabItem value="windows">
+
+Download the [latest release](https://release.ariga.io/atlas/atlas-community-windows-amd64-latest.exe) and
+move the atlas binary to a file location on your system PATH.
+
+</TabItem>
+<TabItem value="manual">
+
+If you want to manually install the Atlas CLI, pick one of the below builds suitable for your system.
+
+* MacOS
+  * [amd64](https://release.ariga.io/atlas/atlas-community-darwin-amd64-latest) ([md5](https://release.ariga.io/atlas/atlas-community-darwin-amd64-latest.md5) / [sha256](https://release.ariga.io/atlas/atlas-community-darwin-amd64-latest.sha256))
+  * [arm64](https://release.ariga.io/atlas/atlas-community-darwin-arm64-latest) ([md5](https://release.ariga.io/atlas/atlas-community-darwin-arm64-latest.md5) / [sha256](https://release.ariga.io/atlas/atlas-community-darwin-arm64-latest.sha256))
+* Windows
+  * [amd64](https://release.ariga.io/atlas/atlas-community-windows-amd64-latest.exe) ([md5](https://release.ariga.io/atlas/atlas-community-windows-amd64-latest.exe.md5) / [sha256](https://release.ariga.io/atlas/atlas-community-windows-amd64-latest.exe.sha256))
+* Linux
+  * [amd64](https://release.ariga.io/atlas/atlas-community-linux-amd64-latest) ([md5](https://release.ariga.io/atlas/atlas-community-linux-amd64-latest.md5) / [sha256](https://release.ariga.io/atlas/atlas-community-linux-amd64-latest.sha256))
+  * [arm64](https://release.ariga.io/atlas/atlas-community-linux-arm64-latest) ([md5](https://release.ariga.io/atlas/atlas-community-linux-arm64-latest.md5) / [sha256](https://release.ariga.io/atlas/atlas-community-linux-arm64-latest.sha256))
+
+</TabItem>
+</Tabs>
+
+## Building from Source
+
+If you would like to build Atlas from source, run:
+
+```
+go get ariga.io/atlas/cmd/atlas
+```
\ No newline at end of file
diff --git a/doc/md/getting-started/getting-started.mdx b/doc/md/getting-started/getting-started.mdx
index 87281d3f..3328fcf7 100644
--- a/doc/md/getting-started/getting-started.mdx
+++ b/doc/md/getting-started/getting-started.mdx
@@ -25,9 +25,9 @@ and by utilizing Atlas, they can plan, lint, and apply the necessary migrations
 
 <InstallationInstructions />
 
-The binaries distributed in official releases are released under the
-[Apache 2 License](https://github.com/ariga/atlas/blob/master/LICENSE). If you would like to build Atlas from source
-follow the instructions [here](https://atlasgo.io/cli-reference#building-from-source).
+The default binaries distributed in official releases are released under the
+[Atlas EULA](https://ariga.io/legal/atlas/eila). If you would like obtain a copy of Atlas
+Community Edition (under an Apache 2 license) follow the instructions [here](/community-edition).
 
 ### Start a local database container
 
diff --git a/doc/md/integrations/kubernetes/operator.mdx b/doc/md/integrations/kubernetes/operator.mdx
index 9953ff52..bf0ad320 100644
--- a/doc/md/integrations/kubernetes/operator.mdx
+++ b/doc/md/integrations/kubernetes/operator.mdx
@@ -414,6 +414,28 @@ schemas:
 - schema2
 ```
 
+### `devURL`
+
+Atlas relies on a [Dev Database](/concepts/dev-database) to normalize schemas and make various
+calculations. By default, the operator will spin a new database pod to use as the Dev Database.
+However, if you have special requirements (such as have it preloaded with certain extensions or configuration),
+you can use the `devURL` field to specify a [URL](/concepts/url) to use as the Dev Database.
+
+```yaml
+devURL: mysql://root:password@dev-db-svc.default:3306/dev
+```
+
+### `devURLFrom`
+
+Alternatively, you can use the `devURLFrom` field to specify a key in a `Secret` that contains the Dev Database URL.
+
+```yaml
+devURLFrom:
+  secretKeyRef:
+    key: dev-url
+    name: myapp
+```
+
 
 ## Versioned migrations
 
diff --git a/doc/md/reference.md b/doc/md/reference.md
index fd570a33..dcf4ca3b 100644
--- a/doc/md/reference.md
+++ b/doc/md/reference.md
@@ -3,6 +3,7 @@ title: CLI Reference
 id: cli-reference
 slug: cli-reference
 ---
+<!-- This file is generated from cmd/atlas/doc.tmpl, do not edit manually -->
 ## Introduction
 
 This document serves as reference documentation for all available commands in the Atlas CLI.
@@ -14,15 +15,16 @@ For a more detailed introduction to the CLI capabilities, head over to the
 
 ## Distributed Binaries
 
-The binaries distributed in official releases are released under the  [Apache 2 License](https://github.com/ariga/atlas/blob/master/LICENSE).
-If you would like to build Atlas from source follow the instructions [here](https://atlasgo.io/cli-reference#building-from-source).
+The binaries and Docker images distributed in official releases are released in two flavors:
 
-### Building from Source
+* Atlas - A binary built from the OSS repository in addition to proprietary code maintained by
+  [Ariga](https://ariga.io), including access to the Atlas Cloud service and commercial driver support.
+  Atlas is distributed under the [Atlas EULA](https://ariga.io/legal/atlas/eula).
+* Atlas Community - A binary built from the OSS repository only. Atlas Community is distributed under
+  the [Apache 2 License](https://github.com/ariga/atlas/blob/master/LICENSE).
+
+For instructions on how to install Atlas Community, see this [guide](/community-edition).
 
-If you would like to build Atlas from source without the UI code run:
-```shell
-go get ariga.io/atlas/cmd/atlas
-```
 
 ## atlas license
 
diff --git a/doc/website/blog/2023-08-06-atlas-v-0-13.md b/doc/website/blog/2023-08-06-atlas-v-0-13.md
new file mode 100644
index 00000000..6903f978
--- /dev/null
+++ b/doc/website/blog/2023-08-06-atlas-v-0-13.md
@@ -0,0 +1,433 @@
+---
+title: "Announcing v0.13.0: New binary, support for SQL Views, MS-SQL Driver beta program and more"
+authors: rotemtam
+tags: [announcement, views, mssql]
+---
+
+It's been just over two months since our last version announcement and today I'm super excited to share with you our
+latest release, [v0.13](https://github.com/ariga/atlas/releases/tag/v0.13.0), which includes some 
+long-anticipated additions to Atlas:
+
+* **Atlas and Atlas Community Editions** - we are making a change in the way Atlas is distributed. Starting today, we are
+  making many of the features previously available only in the commercial version of Atlas available to everyone for free.
+  More on this below.
+
+* **Support for SQL Views** - SQL Views are virtual tables in a database created by a query and stored in the database.
+  Starting today, you can manage views in your database using the same Atlas workflows you use for tables, 
+  indexes, foreign keys, etc.
+
+* **Microsoft SQL Server Driver Beta** - Over the last few months, we have been working on a driver for MS-SQL with some of our 
+  enterprise design partners and are happy to make it available to you as a beta. 
+
+* **Built-in schema visualization** - Over the past year we released some tools to help developers visualize their database
+  schemas using Entity Relationship Diagrams (ERDs). Starting today, you can use the `atlas schema inspect --visualize` command
+  to create database ERDs directly from the command line.
+
+* **GitHub Action for Deployments** - We recently received a request from the community to add a GitHub Action to streamline 
+  applying migrations directly from GitHub workflows. We thought this was a great idea and are happy to introduce
+  [`ariga/atlas-deploy-action`](https://github.com/ariga/atlas-deploy-action) which you can use to deploy your migrations
+  today.
+
+* **Type checking for schema files** - As part of our continuous effort to make the experience of working with Atlas as
+  smooth as possible, we are excited to ship a new engine for running type checks on your schema files. 
+
+This release is extra-packed with new features and improvements, so let's dive in!  
+
+### Atlas and Atlas Community Editions
+
+In recent months, based on strategic discussions with our advisors, we have come to realize that in order to keep
+fueling Atlas's rapid growth and adoption, we should give free access to some features previously
+available only in the commercial version.  For this reason, we are making a change in the way Atlas is
+distributed. Here is a summary of how this is going to work:
+
+The Atlas codebase will continue to be actively developed in our open-source repository
+([`ariga/atlas`](https://github.com/ariga/atlas)) under the Apache 2 License. This version of Atlas will continue to be actively
+maintained and supported by us in community channels (GitHub, Discord, etc). We will continue to build and ship versions
+of this purely open-source version under the name "Atlas Community". For more information about obtaining
+Atlas Community binaries, see the [docs](/community-edition).
+
+In addition, we are starting to distribute for free (under the [Atlas EULA](https://ariga.io/legal/atlas/eula/)) the binaries for our
+commercial product which will include features previously unavailable to the public. We plan to release many of these
+features to the open-source repositories over time to keep supporting more open-source innovation in the database space.
+To keep things short and sweet, this distribution of the project will simply be called "Atlas".
+
+Starting today, our existing distribution channels (the `atlasgo.sh` installation script, Docker image, brew tap, etc.)
+will point to this edition, and we are adding parallel installation options for users that prefer to use the community
+edition.
+
+### Trying out the new features
+
+Before we dive into the new features, let's make sure you have the latest version of Atlas installed. To do so, run the
+following command:
+
+```shell
+curl -sSf https://atlasgo.sh | sh
+```
+For more installation options, see the [docs](https://atlasgo.io/getting-started#installation).
+
+In addition, two of the following features (MS-SQL driver, `schema inspect --visualize`) 
+require you to log in your CLI to Atlas Cloud. To do so, run the following command:
+
+```shell
+atlas login
+```
+
+The CLI will ask your permission to open a browser window to complete the login process.
+
+After completing the login process, you should see a message similar to the following:
+
+```text
+You are now connected to "acme-corp" on Atlas Cloud.
+```
+
+
+### Support for Views
+
+"Views" are virtual tables created by a query and stored in the database. Many teams use them to simplify
+complex queries, encapsulate logic, and present a consistent interface to users, abstracting the underlying data
+structures. Starting today, you can start managing views in your database using the same Atlas workflows you use for
+tables, indexes, foreign keys, etc. Views are currently supported for MySQL, MariaDB, PostgreSQL, MS-SQL, and SQLite.
+
+To demonstrate how this works, let's start by creating a new database. For this example, let's
+run a local MySQL database using Docker:
+
+```shell
+docker run --rm -d --name atlas-demo -p 3306:3306 -e MYSQL_ROOT_PASSWORD=pass -e MYSQL_DATABASE=default mysql:8
+```
+
+Next, create a new file named `schema.hcl` with the following content:
+
+```hcl
+schema "default" {}
+
+table "users" {
+  schema = schema.default
+  column "id" {
+    type = int
+  }
+  column "nickname" {
+    type = varchar(32)
+  }
+  column "phone_number" {
+    type = varchar(32)
+  }
+}
+
+view "users_clean" {
+  schema = schema.default
+  column "id" {
+    type = int
+  }
+  column "nickname" {
+    type = varchar(32)
+  }
+  as = <<-SQL
+   SELECT id, nickname
+    FROM ${table.users.name}
+  SQL
+  depends_on = [table.users]
+}
+```
+
+In this example, we first defined a table named `users` that contains some PII (the user's phone number). Next, we
+defined a view named `users_clean` that exposes only the user's id and nickname. This view can be used by other
+applications to access the user's data without exposing any personally identifiable information.
+
+Next, run the following command to apply the schema to the database:
+
+```shell
+atlas schema apply -u mysql://root:pass@localhost:3306/default -f schema.hcl
+```
+
+Atlas will connect to the database, inspect its current schema (which is empty) and produce the
+following output:
+
+```shell
+-- Planned Changes:
+-- Create "users" table
+CREATE TABLE `default`.`users` (`id` int NOT NULL, `nickname` varchar(32) NOT NULL, `phone_number` varchar(32) NOT NULL);
+-- Create "users_clean" view
+CREATE VIEW `default`.`users_clean` (`id`, `nickname`) AS SELECT id, nickname
+ FROM users;
+Use the arrow keys to navigate: ↓ ↑ → ←
+? Are you sure?:
+  ▸ Apply
+    Abort
+```
+After reviewing the changes, press enter to apply them to the database. Atlas will connect to the database and apply the
+changes. 
+
+Next, run the following command to inspect the database schema:
+
+```shell
+atlas schema inspect -u mysql://root:pass@/default --format "{{ sql . \" \" }}"
+```
+
+Notice that we used the `--format` flag to instruct Atlas to output the schema as SQL statements. 
+
+Atlas will connect to the database and produce the following output:
+
+```sql
+-- Create "users" table
+CREATE TABLE `users` (
+ `id` int NOT NULL,
+ `nickname` varchar(32) NOT NULL,
+ `phone_number` varchar(32) NOT NULL
+) CHARSET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
+-- Create "users_clean" view
+CREATE VIEW `users_clean` (
+ `id`,
+ `nickname`
+) AS select `users`.`id` AS `id`,`users`.`nickname` AS `nickname` from `users`;
+```
+
+As you can see, Atlas created the `users` table and the `users_clean` view in the database!
+
+### Microsoft SQL Server Driver
+
+Microsoft SQL Server, one of the longest-standing database engines in our business, was
+first released by Microsoft in 1989. MS-SQL is the go-to database for Windows environments in many industries. Over the
+last few months, we have been working on a driver for MS-SQL with some of our enterprise design partners and are happy
+to make it available to you as a beta feature.
+
+To get started with MS-SQL, you must first opt-in to its beta testing program. To do so:
+
+1. Login to your Atlas Cloud account using the [instructions above](#trying-out-the-new-features). 
+2. Head over to your [Atlas Cloud account](https://auth.atlasgo.cloud).
+3. Click on your profile picture in the bottom left corner to open the user menu and select the "Personal Settings" option.
+4. Select the "Microsoft SQL Server" checkbox from the driver list and click on the "Save" button to save your changes.
+
+![](https://atlasgo.io/uploads/v0.13.0/personal-settings.png)
+
+Next, run the following command to start a local MS-SQL server using Docker:
+
+```shell
+docker run --rm -e 'ACCEPT_EULA=Y' -e 'MSSQL_SA_PASSWORD=P@ssw0rd0995' -p 1433:1433 -d mcr.microsoft.com/mssql/server:latest
+```
+
+Notice that by providing the `ACCEPT_EULA` environment variable you confirm your acceptance of the Microsoft's
+[End-User Licensing Agreement](https://go.microsoft.com/fwlink/?linkid=857698).
+
+Create a new file named `schema.hcl` with the following content:
+
+```hcl
+schema "dbo" {
+}
+table "users" {
+  schema = schema.dbo
+  column "id" {
+    type = int
+  }
+  column "nickname" {
+    type = varchar(32)
+  }
+}
+```
+
+With this file ready, run the following command to apply the schema to the database:
+
+```shell
+atlas schema apply --url="sqlserver://sa:P@ssw0rd0995@localhost:1433?database=master" -f schema.hcl
+```
+
+Atlas will connect to the database, inspect its current schema (which is empty) and produce the
+following output:
+
+```text
+-- Planned Changes:
+-- Create "users" table
+CREATE TABLE [dbo].[users] ([id] int NOT NULL, [nickname] varchar(32) NOT NULL);
+Use the arrow keys to navigate: ↓ ↑ → ←
+? Are you sure?:
+  ▸ Apply
+    Abort
+```
+
+After reviewing the changes, press enter to apply them to the database. Atlas will connect to the database and apply the
+changes.
+
+Next, run the following command to inspect the database:
+
+```shell
+atlas schema inspect --url="sqlserver://sa:P@ssw0rd0995@localhost:1433?database=master"
+```
+
+Atlas will connect to the database and produce the following output:
+
+```text
+table "users" {
+  schema = schema.dbo
+  column "id" {
+    null = false
+    type = int
+  }
+  column "nickname" {
+    null = false
+    type = varchar(32)
+  }
+}
+schema "dbo" {
+}
+```
+
+Great! Atlas created the `users` table in the database!
+
+Do you use MS-SQL? We would love to hear feedback about your experience using this driver. Feel free
+to give it a try and reach out on our [Discord channel](https://discord.gg/5rCXNYMz2r) to let us know what you think.
+
+### Built-in Schema Visualization
+
+Over the past year we have released some tools to help developers visualize their database
+schemas using Entity Relationship Diagrams (ERDs). The success of these experiments (especially for
+[Ent](https://entgo.io/blog/2023/01/26/visualizing-with-entviz) and [Django](https://atlasgo.io/blog/2023/05/17/djangoviz))
+convinced us that we should provide this functionality natively in Atlas. So starting today, you can use
+the `atlas schema inspect --visualize` and `atlas schema diff --visualize` to create database ERDs directly from the command line.
+
+To get started, create a new schema file named `schema.hcl`:
+
+```hcl
+schema "default" {}
+
+table "t1" {
+  schema = schema.default
+  column "id" {
+      type = int
+  }
+  primary_key {
+      columns = [column.id]
+  }
+}
+
+table "t2" {
+  schema = schema.default
+  column "id" {
+    type = int
+  }
+  column "t1_id" {
+    type = int
+  }
+  foreign_key "t1_id" {
+    columns = [column.t1_id]
+    ref_columns = [table.t1.column.id]
+  }
+}
+```
+
+Next, run the following command to generate the ERD:
+
+```shell
+atlas schema inspect --dev-url docker://mysql/8/d --url file://schema.hcl --visualize
+```
+
+Atlas will prompt you to check where you would like to share the ERD:
+
+```text
+Use the arrow keys to navigate: ↓ ↑ → ←
+? Where would you like to share your schema visualization?:
+  ▸ Publicly (gh.atlasgo.cloud)
+    Privately (acme-corp.atlasgo.cloud)
+```
+
+After selecting the desired option, Atlas will generate the ERD and print out the URL where you can access it:
+
+```text
+https://gh.atlasgo.cloud/explore/cdd7bee5
+```
+
+![](https://atlasgo.io/uploads/v0.13.0/share-publicly.png)
+
+### `ariga/atlas-deploy` GitHub Action
+
+GitHub Actions is a powerful tool that allows developers to automate workflows around GitHub repositories. We have previously 
+published GitHub Actions for running [CI for schema changes](https://github.com/ariga/atlas-action) and
+for [syncing your migration directory](https://github.com/ariga/atlas-sync-action) with Atlas Cloud.
+
+Today, we are happy to announce the release of the `ariga/atlas-deploy-action` to the 
+[GitHub Actions Marketplace](https://github.com/marketplace/actions/atlas-deploy-action). This action allows you to
+run migrations directly from your GitHub Actions workflow. To get started, add the following step to your workflow:
+
+```yaml
+name: Deploy Database Migrations
+on:
+  push:
+    branches:
+      - master
+jobs:
+  deploy:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v3
+      - name: Deploy Atlas Migrations
+        uses: ariga/atlas-deploy-action@v0
+        with:
+          url: ${{ secrets.DATABASE_URL }}
+          dir: path/to/migrations
+```
+
+### Type checking for schema files
+
+I firmly believe that great developer experiences aren't created by any one single design decision
+but are rather a result of a relentless commitment to improving things little by little over long
+periods of time. As we work with Atlas as users every day, we constantly look for points of friction and
+frustration and try to find ways to make them better.
+
+Sometimes, these changes are small and simple, like improving the help text of a command or updating
+a documentation page. Other times, they are more complex, and require building more fundamental
+infrastructure. 
+
+I give all of this as an intro to a feature that I am very excited about: type checking for the HCL schema.
+Starting with this release, we are shipping a type checker engine that will give you more
+precise and actionable feedback when you make mistakes in your schema files.
+
+We have come as an industry to appreciate that a fast feedback loop is one of the most prominent factors
+in developer productivity, and it all starts in the local development environment. 
+
+Type checking validates the contents of your schema files and provides you with actionable feedback
+in case you make a mistake. For example, suppose you define a table like this (try spotting the errors
+yourself before reading on):
+
+```hcl title="schema.hcl"
+table "users" {
+  column "id" {
+    type = int
+  }
+  column "name" {
+    type = varchar(255)
+  }
+  column "manager_id" {
+    type = int
+  }
+  primray_key {
+    columns = [
+      column.id
+    ]
+  }
+  index "idx_name" {
+    coulmns = [
+      column.name
+    ]
+    unique = true
+  }
+  foreign_key {
+    columns = [column.manager_id]
+    ref_columns = [column.id]
+  }
+}
+```
+Trying to apply this schema will produce the following error:
+
+```text
+Error: failed parsing mysql schema files: schema.hcl
+schema.hcl:1,1-6: missing required attribute schema in block table
+schema.hcl:11,3-14: unknown block type primray_key in block of type table (Did you mean primary_key?)
+schema.hcl:17,5-19,6: unknown attribute coulmns in block of type index (Did you mean columns?)
+schema.hcl:22,3-14: foreign_key block must have exactly 1 label
+```
+This type checking engine is available on the Atlas CLI and will be integrated into our editor
+plugins for VSCode and (the upcoming) JetBrains IDEs.
+
+### Wrapping up
+
+This is it! I know this was a long post, but we are very excited about all of these new features
+and we hope you will find them useful. As always, we would love to hear your feedback and suggestions
+on our [Discord server](https://discord.gg/zZ6sWVg6NT).
diff --git a/doc/website/sidebars.js b/doc/website/sidebars.js
index 8a34bf32..c9bd4c92 100644
--- a/doc/website/sidebars.js
+++ b/doc/website/sidebars.js
@@ -115,9 +115,20 @@ module.exports = {
             id: 'contributing',
         },
         {
-            type: 'doc',
-            id: 'cli-reference'
-        }
+            type: 'category',
+            label: 'CLI Reference',
+            collapsed: false,
+            link: {
+              type: 'doc',
+              id: 'cli-reference'
+            },
+            items: [
+                {
+                    type: 'doc',
+                    id: 'community-edition'
+                },
+            ]
+        },
     ],
     guides: [
         {
diff --git a/internal/integration/go.mod b/internal/integration/go.mod
index 9b4ef6fe..c67a4952 100644
--- a/internal/integration/go.mod
+++ b/internal/integration/go.mod
@@ -7,8 +7,8 @@ replace ariga.io/atlas => ../../
 replace ariga.io/atlas/cmd/atlas => ../../cmd/atlas
 
 require (
-	ariga.io/atlas v0.12.1-0.20230726093600-73399935e167
-	ariga.io/atlas/cmd/atlas v0.12.2-0.20230803123152-64bd8681250f
+	ariga.io/atlas v0.12.2-0.20230806193313-117e03f96e45
+	ariga.io/atlas/cmd/atlas v0.12.2-0.20230806193313-117e03f96e45
 	github.com/go-sql-driver/mysql v1.7.0
 	github.com/hashicorp/hcl/v2 v2.13.0
 	github.com/lib/pq v1.10.7
diff --git a/sql/postgres/diff.go b/sql/postgres/diff.go
index 285b2a31..bcc1220a 100644
--- a/sql/postgres/diff.go
+++ b/sql/postgres/diff.go
@@ -224,6 +224,9 @@ func (*diff) IndexAttrChanged(from, to []schema.Attr) bool {
 	if t1.T != t2.T {
 		return true
 	}
+	if indexNullsDistinct(to) != indexNullsDistinct(from) {
+		return true
+	}
 	var p1, p2 IndexPredicate
 	if sqlx.Has(from, &p1) != sqlx.Has(to, &p2) || (p1.P != p2.P && p1.P != sqlx.MayWrap(p2.P)) {
 		return true
@@ -510,6 +513,16 @@ func indexIncludeChanged(from, to []schema.Attr) bool {
 	return false
 }
 
+// indexNullsDistinct returns the NULLS [NOT] DISTINCT value from the index attributes.
+func indexNullsDistinct(attrs []schema.Attr) bool {
+	if i := (IndexNullsDistinct{}); sqlx.Has(attrs, &i) {
+		return i.V
+	}
+	// The default PostgreSQL behavior. The inverse of
+	// "indnullsnotdistinct" in pg_index which is false.
+	return true
+}
+
 func trimCast(s string) string {
 	i := strings.LastIndex(s, "::")
 	if i == -1 {
diff --git a/sql/postgres/driver.go b/sql/postgres/driver.go
index 2b2886b1..f19e5bbb 100644
--- a/sql/postgres/driver.go
+++ b/sql/postgres/driver.go
@@ -317,6 +317,11 @@ func (c *conn) supportsIndexInclude() bool {
 	return c.version >= 11_00_00
 }
 
+// supportsIndexNullsDistinct reports if the server supports the NULLS [NOT] DISTINCT clause.
+func (c *conn) supportsIndexNullsDistinct() bool {
+	return c.version >= 15_00_00
+}
+
 type parser struct{}
 
 // ParseURL implements the sqlclient.URLParser interface.
diff --git a/sql/postgres/inspect.go b/sql/postgres/inspect.go
index ee7d9022..4f2ccbd8 100644
--- a/sql/postgres/inspect.go
+++ b/sql/postgres/inspect.go
@@ -336,12 +336,16 @@ func (i *inspect) inspectEnums(ctx context.Context, r *schema.Realm) error {
 
 // indexes queries and appends the indexes of the given table.
 func (i *inspect) indexes(ctx context.Context, s *schema.Schema) error {
-	query := indexesQuery
+	var query string
 	switch {
 	case i.conn.crdb:
 		return i.crdbIndexes(ctx, s)
-	case !i.conn.supportsIndexInclude():
-		query = indexesQueryNoInclude
+	case i.supportsIndexNullsDistinct():
+		query = indexesAbove15
+	case i.supportsIndexInclude():
+		query = indexesAbove11
+	default:
+		query = indexesBelow11
 	}
 	rows, err := i.querySchema(ctx, query, s)
 	if err != nil {
@@ -359,14 +363,14 @@ func (i *inspect) addIndexes(s *schema.Schema, rows *sql.Rows) error {
 	names := make(map[string]*schema.Index)
 	for rows.Next() {
 		var (
-			uniq, primary, included                                               bool
 			table, name, typ                                                      string
+			uniq, primary, included, nullsnotdistinct                             bool
 			desc, nullsfirst, nullslast, opcdefault                               sql.NullBool
 			column, constraints, pred, expr, comment, options, opcname, opcparams sql.NullString
 		)
 		if err := rows.Scan(
-			&table, &name, &typ, &column, &included, &primary, &uniq, &constraints, &pred, &expr,
-			&desc, &nullsfirst, &nullslast, &comment, &options, &opcname, &opcdefault, &opcparams,
+			&table, &name, &typ, &column, &included, &primary, &uniq, &constraints, &pred, &expr, &desc,
+			&nullsfirst, &nullslast, &comment, &options, &opcname, &opcdefault, &opcparams, &nullsnotdistinct,
 		); err != nil {
 			return fmt.Errorf("postgres: scanning indexes for schema %q: %w", s.Name, err)
 		}
@@ -393,18 +397,21 @@ func (i *inspect) addIndexes(s *schema.Schema, rows *sql.Rows) error {
 					return fmt.Errorf("postgres: unmarshaling index constraints: %w", err)
 				}
 				for n, t := range m {
-					idx.Attrs = append(idx.Attrs, &Constraint{N: n, T: t})
+					idx.AddAttrs(&Constraint{N: n, T: t})
 				}
 			}
 			if sqlx.ValidString(pred) {
-				idx.Attrs = append(idx.Attrs, &IndexPredicate{P: pred.String})
+				idx.AddAttrs(&IndexPredicate{P: pred.String})
 			}
 			if sqlx.ValidString(options) {
 				p, err := newIndexStorage(options.String)
 				if err != nil {
 					return err
 				}
-				idx.Attrs = append(idx.Attrs, p)
+				idx.AddAttrs(p)
+			}
+			if nullsnotdistinct {
+				idx.AddAttrs(&IndexNullsDistinct{V: false})
 			}
 			names[name] = idx
 			if primary {
@@ -873,6 +880,12 @@ type (
 		Params  []struct{ N, V string } // Optional parameters.
 	}
 
+	// IndexNullsDistinct describes the NULLS [NOT] DISTINCT clause.
+	IndexNullsDistinct struct {
+		schema.Attr
+		V bool // NULLS [NOT] DISTINCT. Defaults to true.
+	}
+
 	// Concurrently describes the CONCURRENTLY clause to instruct Postgres to
 	// build or drop the index concurrently without blocking the current table.
 	// https://www.postgresql.org/docs/current/sql-createindex.html#SQL-CREATEINDEX-CONCURRENTLY
@@ -1313,9 +1326,10 @@ ORDER BY
 )
 
 var (
-	indexesQuery          = fmt.Sprintf(indexesQueryTmpl, "(a.attname <> '' AND idx.indnatts > idx.indnkeyatts AND idx.ord > idx.indnkeyatts)", "%s")
-	indexesQueryNoInclude = fmt.Sprintf(indexesQueryTmpl, "false", "%s")
-	indexesQueryTmpl      = `
+	indexesBelow11   = fmt.Sprintf(indexesQueryTmpl, "false", "false", "%s")
+	indexesAbove11   = fmt.Sprintf(indexesQueryTmpl, "(a.attname <> '' AND idx.indnatts > idx.indnkeyatts AND idx.ord > idx.indnkeyatts)", "false", "%s")
+	indexesAbove15   = fmt.Sprintf(indexesQueryTmpl, "(a.attname <> '' AND idx.indnatts > idx.indnkeyatts AND idx.ord > idx.indnkeyatts)", "idx.indnullsnotdistinct", "%s")
+	indexesQueryTmpl = `
 SELECT
 	t.relname AS table_name,
 	i.relname AS index_name,
@@ -1334,7 +1348,8 @@ SELECT
 	i.reloptions AS options,
 	op.opcname AS opclass_name,
 	op.opcdefault AS opclass_default,
-	a2.attoptions AS opclass_params
+	a2.attoptions AS opclass_params,
+    %s AS indnullsnotdistinct
 FROM
 	(
 		select
diff --git a/sql/postgres/migrate.go b/sql/postgres/migrate.go
index 29b16f64..7bad687f 100644
--- a/sql/postgres/migrate.go
+++ b/sql/postgres/migrate.go
@@ -1009,6 +1009,10 @@ func (s *state) index(b *sqlx.Builder, idx *schema.Index) error {
 			})
 		})
 	}
+	// Avoid appending the default behavior, which NULL values are distinct.
+	if n := (IndexNullsDistinct{}); sqlx.Has(idx.Attrs, &n) && !n.V {
+		b.P("NULLS NOT DISTINCT")
+	}
 	if p, ok := indexStorageParams(idx.Attrs); ok {
 		b.P("WITH")
 		b.Wrap(func(b *sqlx.Builder) {
@@ -1027,7 +1031,7 @@ func (s *state) index(b *sqlx.Builder, idx *schema.Index) error {
 	}
 	for _, attr := range idx.Attrs {
 		switch attr.(type) {
-		case *schema.Comment, *IndexType, *IndexInclude, *Constraint, *IndexPredicate, *IndexStorageParams:
+		case *schema.Comment, *IndexType, *IndexInclude, *Constraint, *IndexPredicate, *IndexStorageParams, *IndexNullsDistinct:
 		default:
 			return fmt.Errorf("postgres: unexpected index attribute: %T", attr)
 		}
diff --git a/sql/postgres/sqlspec.go b/sql/postgres/sqlspec.go
index d6e4b0a3..938362ab 100644
--- a/sql/postgres/sqlspec.go
+++ b/sql/postgres/sqlspec.go
@@ -363,6 +363,13 @@ func convertIndex(spec *sqlspec.Index, t *schema.Table) (*schema.Index, error) {
 		}
 		idx.Attrs = append(idx.Attrs, &IndexPredicate{P: p})
 	}
+	if attr, ok := spec.Attr("nulls_distinct"); ok {
+		v, err := attr.Bool()
+		if err != nil {
+			return nil, err
+		}
+		idx.Attrs = append(idx.Attrs, &IndexNullsDistinct{V: v})
+	}
 	if err := convertIndexPK(spec, t, idx); err != nil {
 		return nil, err
 	}
@@ -605,6 +612,9 @@ func indexSpec(idx *schema.Index) (*sqlspec.Index, error) {
 	if i := (IndexPredicate{}); sqlx.Has(idx.Attrs, &i) && i.P != "" {
 		spec.Extra.Attrs = append(spec.Extra.Attrs, specutil.VarAttr("where", strconv.Quote(i.P)))
 	}
+	if i := (IndexNullsDistinct{}); sqlx.Has(idx.Attrs, &i) && !i.V {
+		spec.Extra.Attrs = append(spec.Extra.Attrs, schemahcl.BoolAttr("nulls_distinct", i.V))
+	}
 	spec.Extra.Attrs = indexPKSpec(idx, spec.Extra.Attrs)
 	return spec, nil
 }


Test patch:
diff --git a/cmd/atlas/internal/cmdapi/vercheck/vercheck_test.go b/cmd/atlas/internal/cmdapi/vercheck/vercheck_test.go
index cc6796bf..eaae605b 100644
--- a/cmd/atlas/internal/cmdapi/vercheck/vercheck_test.go
+++ b/cmd/atlas/internal/cmdapi/vercheck/vercheck_test.go
@@ -82,7 +82,7 @@ func TestState(t *testing.T) {
 			t.Cleanup(srv.Close)
 			path := filepath.Join(t.TempDir(), "release.json")
 			if tt.state != "" {
-				err := os.WriteFile(path, []byte(tt.state), os.ModePerm)
+				err := os.WriteFile(path, []byte(tt.state), 0666)
 				require.NoError(t, err)
 			}
 			vc := New(srv.URL, path)
diff --git a/cmd/atlas/internal/cmdlog/cmdlog_test.go b/cmd/atlas/internal/cmdlog/cmdlog_test.go
index f0e33a24..2cef48b8 100644
--- a/cmd/atlas/internal/cmdlog/cmdlog_test.go
+++ b/cmd/atlas/internal/cmdlog/cmdlog_test.go
@@ -8,8 +8,6 @@ import (
 	"bytes"
 	"context"
 	"encoding/json"
-	"errors"
-	"io"
 	"testing"
 	"text/template"
 
@@ -19,6 +17,7 @@ import (
 	"ariga.io/atlas/sql/sqlclient"
 	_ "ariga.io/atlas/sql/sqlite"
 
+	"github.com/fatih/color"
 	_ "github.com/mattn/go-sqlite3"
 	"github.com/stretchr/testify/require"
 )
@@ -96,11 +95,6 @@ func TestSchemaInspect_MarshalJSON(t *testing.T) {
     }
   ]
 }`, string(ident))
-
-	report = &cmdlog.SchemaInspect{Error: io.EOF}
-	b, err = report.MarshalJSON()
-	require.NoError(t, err)
-	require.Equal(t, `{"Error":"EOF"}`, string(b))
 }
 
 func TestSchemaInspect_MarshalSQL(t *testing.T) {
@@ -148,9 +142,6 @@ func TestSchemaInspect_EncodeSQL(t *testing.T) {
 	)
 	require.NoError(t, tmpl.Execute(&b, &cmdlog.SchemaInspect{Client: client, Realm: realm}))
 	require.Equal(t, "-- Create \"users\" table\nCREATE TABLE `users` (`id` int NOT NULL, `name` text NOT NULL);\n", b.String())
-	b.Reset()
-	require.NoError(t, tmpl.Execute(&b, &cmdlog.SchemaInspect{Error: errors.New("failure")}))
-	require.Equal(t, "failure", b.String())
 }
 
 func TestSchemaDiff_MarshalSQL(t *testing.T) {
@@ -178,6 +169,7 @@ func TestMigrateSet(t *testing.T) {
 		b   bytes.Buffer
 		log = &cmdlog.MigrateSet{}
 	)
+	color.NoColor = true
 	require.NoError(t, cmdlog.MigrateSetTemplate.Execute(&b, log))
 	require.Empty(t, b.String())
 
diff --git a/internal/integration/testdata/postgres/index-nulls-distinct.txt b/internal/integration/testdata/postgres/index-nulls-distinct.txt
new file mode 100644
index 00000000..cc50801b
--- /dev/null
+++ b/internal/integration/testdata/postgres/index-nulls-distinct.txt
@@ -0,0 +1,85 @@
+only postgres15
+
+apply 1.hcl
+cmphcl 1.inspect.hcl
+cmpshow users 1.sql
+
+# Change the nulls_distinct property of the index.
+apply 2.hcl
+cmphcl 2.inspect.hcl
+cmpshow users 2.sql
+
+-- 1.hcl --
+schema "script_index_nulls_distinct" {}
+
+table "users" {
+  schema = schema.script_index_nulls_distinct
+  column "c" {
+    type = int
+  }
+  index "nulls_not_distinct" {
+    unique         = true
+    columns        = [column.c]
+    nulls_distinct = false
+  }
+}
+
+-- 1.inspect.hcl --
+table "users" {
+  schema = schema.script_index_nulls_distinct
+  column "c" {
+    null = false
+    type = integer
+  }
+  index "nulls_not_distinct" {
+    unique         = true
+    columns        = [column.c]
+    nulls_distinct = false
+  }
+}
+schema "script_index_nulls_distinct" {
+}
+-- 1.sql --
+     Table "script_index_nulls_distinct.users"
+ Column |  Type   | Collation | Nullable | Default
+--------+---------+-----------+----------+---------
+ c      | integer |           | not null |
+Indexes:
+    "nulls_not_distinct" UNIQUE, btree (c) NULLS NOT DISTINCT
+
+-- 2.hcl --
+schema "script_index_nulls_distinct" {}
+
+table "users" {
+  schema = schema.script_index_nulls_distinct
+  column "c" {
+    type = int
+  }
+  index "nulls_not_distinct" {
+    unique  = true
+    columns = [column.c]
+  }
+}
+
+-- 2.inspect.hcl --
+table "users" {
+  schema = schema.script_index_nulls_distinct
+  column "c" {
+    null = false
+    type = integer
+  }
+  index "nulls_not_distinct" {
+    unique  = true
+    columns = [column.c]
+  }
+}
+schema "script_index_nulls_distinct" {
+}
+-- 2.sql --
+     Table "script_index_nulls_distinct.users"
+ Column |  Type   | Collation | Nullable | Default
+--------+---------+-----------+----------+---------
+ c      | integer |           | not null |
+Indexes:
+    "nulls_not_distinct" UNIQUE, btree (c)
+
diff --git a/sql/postgres/diff_test.go b/sql/postgres/diff_test.go
index cceda18f..02515d6b 100644
--- a/sql/postgres/diff_test.go
+++ b/sql/postgres/diff_test.go
@@ -328,6 +328,7 @@ func TestDiff_TableDiff(t *testing.T) {
 				{Name: "c5_include_no_change", Table: from, Parts: []*schema.IndexPart{{SeqNo: 1, C: from.Columns[1]}}, Attrs: []schema.Attr{&IndexInclude{Columns: from.Columns[:1]}}},
 				{Name: "c5_include_added", Table: from, Parts: []*schema.IndexPart{{SeqNo: 1, C: from.Columns[1]}}},
 				{Name: "c5_include_dropped", Table: from, Parts: []*schema.IndexPart{{SeqNo: 1, C: from.Columns[1]}}, Attrs: []schema.Attr{&IndexInclude{Columns: from.Columns[:1]}}},
+				{Name: "c6_nulls_not_distinct", Unique: true, Table: from, Parts: []*schema.IndexPart{{SeqNo: 1, C: from.Columns[1]}}, Attrs: []schema.Attr{&IndexNullsDistinct{V: true}}},
 			}
 			to.Indexes = []*schema.Index{
 				{Name: "c1_index", Table: from, Parts: []*schema.IndexPart{{SeqNo: 1, C: from.Columns[0]}}},
@@ -338,6 +339,7 @@ func TestDiff_TableDiff(t *testing.T) {
 				{Name: "c5_include_no_change", Table: from, Parts: []*schema.IndexPart{{SeqNo: 1, C: from.Columns[1]}}, Attrs: []schema.Attr{&IndexInclude{Columns: from.Columns[:1]}}},
 				{Name: "c5_include_added", Table: from, Parts: []*schema.IndexPart{{SeqNo: 1, C: from.Columns[1]}}, Attrs: []schema.Attr{&IndexInclude{Columns: from.Columns[:1]}}},
 				{Name: "c5_include_dropped", Table: from, Parts: []*schema.IndexPart{{SeqNo: 1, C: from.Columns[1]}}},
+				{Name: "c6_nulls_not_distinct", Unique: true, Table: from, Parts: []*schema.IndexPart{{SeqNo: 1, C: from.Columns[1]}}, Attrs: []schema.Attr{&IndexNullsDistinct{V: false}}},
 			}
 			return testcase{
 				name: "indexes",
@@ -350,6 +352,7 @@ func TestDiff_TableDiff(t *testing.T) {
 					&schema.ModifyIndex{From: from.Indexes[4], To: to.Indexes[4], Change: schema.ChangeAttr},
 					&schema.ModifyIndex{From: from.Indexes[6], To: to.Indexes[6], Change: schema.ChangeAttr},
 					&schema.ModifyIndex{From: from.Indexes[7], To: to.Indexes[7], Change: schema.ChangeAttr},
+					&schema.ModifyIndex{From: from.Indexes[8], To: to.Indexes[8], Change: schema.ChangeAttr},
 					&schema.AddIndex{I: to.Indexes[1]},
 				},
 			}
diff --git a/sql/postgres/inspect_test.go b/sql/postgres/inspect_test.go
index 2ce1d7b9..2631a186 100644
--- a/sql/postgres/inspect_test.go
+++ b/sql/postgres/inspect_test.go
@@ -26,7 +26,7 @@ var (
 	queryChecks      = sqltest.Escape(fmt.Sprintf(checksQuery, "$2"))
 	queryColumns     = sqltest.Escape(fmt.Sprintf(columnsQuery, "$2"))
 	queryCRDBColumns = sqltest.Escape(fmt.Sprintf(crdbColumnsQuery, "$2"))
-	queryIndexes     = sqltest.Escape(fmt.Sprintf(indexesQuery, "$2"))
+	queryIndexes     = sqltest.Escape(fmt.Sprintf(indexesAbove15, "$2"))
 	queryCRDBIndexes = sqltest.Escape(fmt.Sprintf(crdbIndexesQuery, "$2"))
 )
 
@@ -174,23 +174,23 @@ users      | ts          | tsvector            | tsvector  |  NO          |
 				m.ExpectQuery(queryIndexes).
 					WithArgs("public", "users").
 					WillReturnRows(sqltest.Rows(`
-   table_name   |    index_name   | index_type  | column_name | included | primary | unique |   constraints   | predicate             |   expression              | desc | nulls_first | nulls_last | comment   |                 options               |   opclass_name    | opclass_default | opclass_params 
-----------------+-----------------+-------------+-------------+----------+---------+--------+-----------------+-----------------------+---------------------------+------+-------------+------------+-----------+---------------------------------------+-------------------+-----------------+----------------
-users           | idx             | hash        |             | f        | f       | f      |                 |                       | "left"((c11)::text, 100)  | t    | t           | f          | boring    |                                       |     int4_ops      |        t        |
-users           | idx1            | btree       |             | f        | f       | f      |                 | (id <> NULL::integer) | "left"((c11)::text, 100)  | t    | t           | f          |           |                                       |     int4_ops      |        t        |
-users           | t1_c1_key       | btree       | c1          | f        | f       | t      | {"name": "u"}   |                       | c1                        | t    | t           | f          |           |                                       |     int4_ops      |        t        |
-users           | t1_pkey         | btree       | id          | f        | t       | t      | {"t_pkey": "p"} |                       | id                        | t    | f           | f          |           |                                       |     int4_ops      |        t        |
-users           | idx4            | btree       | c1          | f        | f       | t      |                 |                       | c1                        | f    | f           | f          |           |                                       |     int4_ops      |        t        |
-users           | idx4            | btree       | id          | f        | f       | t      |                 |                       | id                        | f    | f           | t          |           |                                       |     int4_ops      |        t        |
-users           | idx5            | btree       | c1          | f        | f       | t      |                 |                       | c1                        | f    | f           | f          |           |                                       |     int4_ops      |        t        |
-users           | idx5            | btree       |             | f        | f       | t      |                 |                       | coalesce(parent_id, 0)    | f    | f           | f          |           |                                       |     int4_ops      |        t        |
-users           | idx6            | brin        | c1          | f        | f       | t      |                 |                       |                           | f    | f           | f          |           | {autosummarize=true,pages_per_range=2}|     int4_ops      |        t        |
-users           | idx2            | btree       |             | f        | f       | f      |                 |                       | ((c * 2))                 | f    | f           | t          |           |                                       |     int4_ops      |        t        |
-users           | idx2            | btree       | c1          | f        | f       | f      |                 |                       | c                         | f    | f           | t          |           |                                       |     int4_ops      |        t        |
-users           | idx2            | btree       | id          | f        | f       | f      |                 |                       | d                         | f    | f           | t          |           |                                       |     int4_ops      |        t        |
-users           | idx2            | btree       | c1          | t        | f       | f      |                 |                       | c                         |      |             |            |           |                                       |     int4_ops      |        t        |
-users           | idx2            | btree       | parent_id   | t        | f       | f      |                 |                       | d                         |      |             |            |           |                                       |     int4_ops      |        t        |
-users           | tsx             | gist        | ts          | f        | f       | f      |                 |                       | ts                        |      |             |            |           |                                       |     tsvector_ops  |        f        | {siglen=1}
+   table_name   |    index_name   | index_type  | column_name | included | primary | unique |   constraints   | predicate             |   expression              | desc | nulls_first | nulls_last | comment   |                 options               |   opclass_name    | opclass_default | opclass_params | indnullsnotdistinct 
+----------------+-----------------+-------------+-------------+----------+---------+--------+-----------------+-----------------------+---------------------------+------+-------------+------------+-----------+---------------------------------------+-------------------+-----------------+----------------+---------------------
+users           | idx             | hash        |             | f        | f       | f      |                 |                       | "left"((c11)::text, 100)  | t    | t           | f          | boring    |                                       |     int4_ops      |        t        |                | f
+users           | idx1            | btree       |             | f        | f       | f      |                 | (id <> NULL::integer) | "left"((c11)::text, 100)  | t    | t           | f          |           |                                       |     int4_ops      |        t        |                | f
+users           | t1_c1_key       | btree       | c1          | f        | f       | t      | {"name": "u"}   |                       | c1                        | t    | t           | f          |           |                                       |     int4_ops      |        t        |                | f
+users           | t1_pkey         | btree       | id          | f        | t       | t      | {"t_pkey": "p"} |                       | id                        | t    | f           | f          |           |                                       |     int4_ops      |        t        |                | f
+users           | idx4            | btree       | c1          | f        | f       | t      |                 |                       | c1                        | f    | f           | f          |           |                                       |     int4_ops      |        t        |                | f
+users           | idx4            | btree       | id          | f        | f       | t      |                 |                       | id                        | f    | f           | t          |           |                                       |     int4_ops      |        t        |                | f
+users           | idx5            | btree       | c1          | f        | f       | t      |                 |                       | c1                        | f    | f           | f          |           |                                       |     int4_ops      |        t        |                | f
+users           | idx5            | btree       |             | f        | f       | t      |                 |                       | coalesce(parent_id, 0)    | f    | f           | f          |           |                                       |     int4_ops      |        t        |                | f
+users           | idx6            | brin        | c1          | f        | f       | t      |                 |                       |                           | f    | f           | f          |           | {autosummarize=true,pages_per_range=2}|     int4_ops      |        t        |                | f
+users           | idx2            | btree       |             | f        | f       | f      |                 |                       | ((c * 2))                 | f    | f           | t          |           |                                       |     int4_ops      |        t        |                | f
+users           | idx2            | btree       | c1          | f        | f       | f      |                 |                       | c                         | f    | f           | t          |           |                                       |     int4_ops      |        t        |                | f
+users           | idx2            | btree       | id          | f        | f       | f      |                 |                       | d                         | f    | f           | t          |           |                                       |     int4_ops      |        t        |                | f
+users           | idx2            | btree       | c1          | t        | f       | f      |                 |                       | c                         |      |             |            |           |                                       |     int4_ops      |        t        |                | f
+users           | idx2            | btree       | parent_id   | t        | f       | f      |                 |                       | d                         |      |             |            |           |                                       |     int4_ops      |        t        |                | f
+users           | tsx             | gist        | ts          | f        | f       | f      |                 |                       | ts                        |      |             |            |           |                                       |     tsvector_ops  |        f        | {siglen=1}     | f
 `))
 				m.noFKs()
 				m.noChecks()
@@ -333,7 +333,7 @@ users        | users_check1       | (((c2 + c1) + c3) > 10) | c3          | {2,1
 			db, m, err := sqlmock.New()
 			require.NoError(t, err)
 			mk := mock{m}
-			mk.version("130000")
+			mk.version("150000")
 			var drv migrate.Driver
 			drv, err = Open(db)
 			require.NoError(t, err)
@@ -358,7 +358,7 @@ func TestDriver_InspectPartitionedTable(t *testing.T) {
 	db, m, err := sqlmock.New()
 	require.NoError(t, err)
 	mk := mock{m}
-	mk.version("130000")
+	mk.version("150000")
 	drv, err := Open(db)
 	require.NoError(t, err)
 	mk.ExpectQuery(sqltest.Escape(fmt.Sprintf(schemasQueryArgs, "= CURRENT_SCHEMA()"))).
@@ -388,8 +388,8 @@ logs2      | c3         | integer   | integer   | NO          |                |
 logs3      | c4         | integer   | integer   | NO          |                |                          |                32 |                    |             0 |               |                    |                | NO          |                |                    |                  |                     |                       |         | b       |         |         |  23
 logs3      | c5         | integer   | integer   | NO          |                |                          |                32 |                    |             0 |               |                    |                | NO          |                |                    |                  |                     |                       |         | b       |         |         |  23
 `))
-	m.ExpectQuery(sqltest.Escape(fmt.Sprintf(indexesQuery, "$2, $3, $4"))).
-		WillReturnRows(sqlmock.NewRows([]string{"table_name", "index_name", "column_name", "primary", "unique", "constraint_type", "predicate", "expression"}))
+	m.ExpectQuery(sqltest.Escape(fmt.Sprintf(indexesAbove15, "$2, $3, $4"))).
+		WillReturnRows(sqlmock.NewRows([]string{"table_name", "index_name", "column_name", "primary", "unique", "constraint_type", "predicate", "expression", "options", "indnullsnotdistinct"}))
 	m.ExpectQuery(sqltest.Escape(fmt.Sprintf(fksQuery, "$2, $3, $4"))).
 		WillReturnRows(sqlmock.NewRows([]string{"constraint_name", "table_name", "column_name", "referenced_table_name", "referenced_column_name", "referenced_table_schema", "update_rule", "delete_rule"}))
 	m.ExpectQuery(sqltest.Escape(fmt.Sprintf(checksQuery, "$2, $3, $4"))).
@@ -754,7 +754,7 @@ func (m mock) tableExists(schema, table string, exists bool) {
 
 func (m mock) noIndexes() {
 	m.ExpectQuery(queryIndexes).
-		WillReturnRows(sqlmock.NewRows([]string{"table_name", "index_name", "column_name", "primary", "unique", "constraint_type", "predicate", "expression", "options"}))
+		WillReturnRows(sqlmock.NewRows([]string{"table_name", "index_name", "column_name", "primary", "unique", "constraint_type", "predicate", "expression", "options", "indnullsnotdistinct"}))
 }
 
 func (m mock) noFKs() {
diff --git a/sql/postgres/migrate_test.go b/sql/postgres/migrate_test.go
index 77f2fa92..eff59c10 100644
--- a/sql/postgres/migrate_test.go
+++ b/sql/postgres/migrate_test.go
@@ -617,6 +617,30 @@ func TestPlanChanges(t *testing.T) {
 									},
 								},
 							},
+							&schema.AddIndex{
+								I: &schema.Index{
+									Unique: true,
+									Name:   "nulls_distinct",
+									Parts: []*schema.IndexPart{
+										{C: users.Columns[0]},
+									},
+									Attrs: []schema.Attr{
+										&IndexNullsDistinct{V: true},
+									},
+								},
+							},
+							&schema.AddIndex{
+								I: &schema.Index{
+									Unique: true,
+									Name:   "nulls_not_distinct",
+									Parts: []*schema.IndexPart{
+										{C: users.Columns[0]},
+									},
+									Attrs: []schema.Attr{
+										&IndexNullsDistinct{V: false},
+									},
+								},
+							},
 						},
 					}
 				}(),
@@ -653,6 +677,14 @@ func TestPlanChanges(t *testing.T) {
 						Cmd:     `CREATE INDEX "operator_class" ON "users" USING BRIN ("id" int8_bloom_ops, "id", "id" int8_minmax_multi_ops(values_per_range=8))`,
 						Reverse: `DROP INDEX "operator_class"`,
 					},
+					{
+						Cmd:     `CREATE UNIQUE INDEX "nulls_distinct" ON "users" ("id")`,
+						Reverse: `DROP INDEX "nulls_distinct"`,
+					},
+					{
+						Cmd:     `CREATE UNIQUE INDEX "nulls_not_distinct" ON "users" ("id") NULLS NOT DISTINCT`,
+						Reverse: `DROP INDEX "nulls_not_distinct"`,
+					},
 					{
 						Cmd:     `COMMENT ON COLUMN "users" ."name" IS 'foo'`,
 						Reverse: `COMMENT ON COLUMN "users" ."name" IS ''`,
diff --git a/sql/postgres/sqlspec_test.go b/sql/postgres/sqlspec_test.go
index 56c115b8..56cb2408 100644
--- a/sql/postgres/sqlspec_test.go
+++ b/sql/postgres/sqlspec_test.go
@@ -827,6 +827,54 @@ schema "test" {
 	require.EqualValues(t, expected, string(buf))
 }
 
+func TestMarshalSpec_IndexNullsDistinct(t *testing.T) {
+	s := schema.New("public").
+		AddTables(
+			schema.NewTable("users").
+				AddColumns(
+					schema.NewIntColumn("c", "int"),
+				).
+				AddIndexes(
+					// Default behavior.
+					schema.NewUniqueIndex("without_attribute").
+						AddColumns(schema.NewColumn("c")),
+					schema.NewUniqueIndex("with_nulls_distinct").
+						AddColumns(schema.NewColumn("c")).
+						AddAttrs(&IndexNullsDistinct{V: true}),
+					// Explicitly disable (NULLS NOT DISTINCT).
+					schema.NewUniqueIndex("with_nulls_not_distinct").
+						AddColumns(schema.NewColumn("c")).
+						AddAttrs(&IndexNullsDistinct{V: false}),
+				),
+		)
+	buf, err := MarshalSpec(s, hclState)
+	require.NoError(t, err)
+	const expected = `table "users" {
+  schema = schema.public
+  column "c" {
+    null = false
+    type = int
+  }
+  index "without_attribute" {
+    unique  = true
+    columns = [column.c]
+  }
+  index "with_nulls_distinct" {
+    unique  = true
+    columns = [column.c]
+  }
+  index "with_nulls_not_distinct" {
+    unique         = true
+    columns        = [column.c]
+    nulls_distinct = false
+  }
+}
+schema "public" {
+}
+`
+	require.EqualValues(t, expected, string(buf))
+}
+
 func TestMarshalSpec_BRINIndex(t *testing.T) {
 	s := &schema.Schema{
 		Name: "test",