Repository: ariga/atlas
Pull request #2101 (2023-09-20 11:27:45)

doc/website/blog: post announcing external loader for sequelize


Patch:
diff --git a/.github/workflows/ci-go_oss.yaml b/.github/workflows/ci-go_oss.yaml
index b9a382fd..85410cc2 100644
--- a/.github/workflows/ci-go_oss.yaml
+++ b/.github/workflows/ci-go_oss.yaml
@@ -51,7 +51,7 @@ jobs:
     runs-on: ubuntu-latest
     strategy:
       matrix:
-        go: [ '1.20', '1.21' ]
+        go: [ "1.20", "1.21" ]
     steps:
       - uses: actions/checkout@v2
       - uses: actions/setup-go@v4
diff --git a/cmd/atlas/internal/cloudapi/client.go b/cmd/atlas/internal/cloudapi/client.go
index 153e6513..bbfa5567 100644
--- a/cmd/atlas/internal/cloudapi/client.go
+++ b/cmd/atlas/internal/cloudapi/client.go
@@ -12,12 +12,12 @@ import (
 	"fmt"
 	"io"
 	"net/http"
-	"net/url"
 	"runtime"
 	"strings"
 	"time"
 
 	"ariga.io/atlas/sql/migrate"
+	"ariga.io/atlas/sql/sqlclient"
 
 	"github.com/vektah/gqlparser/v2/gqlerror"
 )
@@ -288,7 +288,7 @@ func (r *roundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
 
 // RedactedURL returns a URL string with the userinfo redacted.
 func RedactedURL(s string) (string, error) {
-	u, err := url.Parse(s)
+	u, err := sqlclient.ParseURL(s)
 	if err != nil {
 		return "", err
 	}
diff --git a/cmd/atlas/internal/cmdapi/cmdapi.go b/cmd/atlas/internal/cmdapi/cmdapi.go
index 5a9d072f..3f6de54c 100644
--- a/cmd/atlas/internal/cmdapi/cmdapi.go
+++ b/cmd/atlas/internal/cmdapi/cmdapi.go
@@ -420,7 +420,7 @@ func (c *stateReaderConfig) Exported() (*cmdext.StateReaderConfig, error) {
 		parsed = make([]*url.URL, len(c.urls))
 	)
 	for i, u := range c.urls {
-		if parsed[i], err = url.Parse(u); err != nil {
+		if parsed[i], err = sqlclient.ParseURL(u); err != nil {
 			return nil, err
 		}
 	}
diff --git a/cmd/atlas/internal/cmdapi/migrate.go b/cmd/atlas/internal/cmdapi/migrate.go
index a36e617e..2179c91e 100644
--- a/cmd/atlas/internal/cmdapi/migrate.go
+++ b/cmd/atlas/internal/cmdapi/migrate.go
@@ -368,6 +368,11 @@ func (s *MigrateReportSet) ReportFor(flags migrateApplyFlags, e *Env) *MigrateRe
 // Note that reporting errors are logged, but not cause Atlas to fail.
 func (s *MigrateReportSet) Flush(cmd *cobra.Command, cmdErr error) {
 	if cmdErr != nil && s.Error == nil {
+		var uerr *url.Error
+		if errors.As(cmdErr, &uerr) {
+			uerr.URL = ""
+			cmdErr = uerr
+		}
 		s.StepLogError(cmdErr.Error())
 	}
 	var (
diff --git a/cmd/atlas/internal/cmdext/cmdext.go b/cmd/atlas/internal/cmdext/cmdext.go
index 558ddb64..a1ef39be 100644
--- a/cmd/atlas/internal/cmdext/cmdext.go
+++ b/cmd/atlas/internal/cmdext/cmdext.go
@@ -86,7 +86,7 @@ func RuntimeVar(c *hcl.EvalContext, block *hclsyntax.Block) (cty.Value, error) {
 	if diags := gohcl.DecodeBody(block.Body, c, &args); diags.HasErrors() {
 		return cty.NilVal, errorf("decoding body: %v", diags)
 	}
-	u, err := url.Parse(args.URL)
+	u, err := sqlclient.ParseURL(args.URL)
 	if err != nil {
 		return cty.NilVal, errorf("parsing url: %v", err)
 	}
@@ -348,6 +348,9 @@ func TemplateDir(ctx *hcl.EvalContext, block *hclsyntax.Block) (cty.Value, error
 	if len(attrs) > 0 {
 		return cty.NilVal, errorf("unexpected attributes: %v", attrs)
 	}
+	if d, err := os.Stat(args.Path); err != nil || !d.IsDir() {
+		return cty.NilVal, errorf("path %s is not a directory", args.Path)
+	}
 	dirname := path.Join(args.Path, block.Labels[1])
 	dir := migrate.OpenMemDir(dirname)
 	// Clear existing directories in case the config was called
@@ -407,8 +410,13 @@ func TemplateDir(ctx *hcl.EvalContext, block *hclsyntax.Block) (cty.Value, error
 		}
 		return migrate.WriteSumFile(l, sum)
 	})
+	u := fmt.Sprintf("mem://%s", dirname)
+	// Allow using reading the computed dir as a state source.
+	memLoader.states[u] = StateLoaderFunc(func(ctx context.Context, config *StateReaderConfig) (*StateReadCloser, error) {
+		return stateReaderSQL(ctx, config, dir)
+	})
 	return cty.ObjectVal(map[string]cty.Value{
-		"url": cty.StringVal(fmt.Sprintf("mem://%s", dirname)),
+		"url": cty.StringVal(u),
 	}), nil
 }
 
@@ -769,7 +777,7 @@ func (l EntLoader) MigrateDiff(ctx context.Context, opts *MigrateDiffOptions) er
 	if opts.Dev.URL.Schema == "" {
 		return errNotSchemaURL
 	}
-	u, err := url.Parse(opts.To[0])
+	u, err := sqlclient.ParseURL(opts.To[0])
 	if err != nil {
 		return nil
 	}
diff --git a/doc/md/guides/modern-database-ci-cd.mdx b/doc/md/guides/modern-database-ci-cd.mdx
new file mode 100644
index 00000000..24cb8b4b
--- /dev/null
+++ b/doc/md/guides/modern-database-ci-cd.mdx
@@ -0,0 +1,233 @@
+---
+title: Modern Database CI/CD with Atlas
+id: modern-database-ci-cd
+slug: modern-database-ci-cd
+---
+
+![](https://atlasgo.io/uploads/ci-cd-guide/database-ci-cd-workflow.png)
+
+Continuous integration and continuous delivery (CI/CD) are tenets of modern software development.
+CI/CD enables teams to deliver software faster, with higher quality, and with less risk. However,
+CI/CD is not just for application code.  Teams can also apply these principles to their databases,
+to extend their benefits to one of the most critical components of their stack.
+
+This document describes the recommended workflow for teams using Atlas to achieve continuous integration
+and continuous delivery for their databases.
+
+## Principles
+
+1. **Changes to the database are planned automatically**. Given the desired state of the database,
+   the system should automatically generate a plan for how to get from the current state to the desired
+   state.
+2. **Changes to the database schema are stored in a versioned migration directory.** All planned changes
+   to the database are checked in to a versioned migration directory. This directory contains SQL scripts,
+   which are executed in lexicographic order to apply the changes to the database.
+3. **Changes to the database are validated during CI.**  All changes to the database are tested and evaluated
+   against a set of governing policies.
+4. **Changes to the database are deployed via automation.**  No manual steps are required to deploy changes to the
+   database. All changes are deployed via a CI/CD pipeline.
+
+## Workflow
+
+This section follows a single change to your database schema from the time it is planned to the time it is deployed.
+The purpose of this section is not to provide extensive instructions for how to configure your CI/CD pipeline, but
+rather to provide a high-level overview of the recommended workflow.
+
+### 1. Create a new branch
+
+Create a new branch to track a change to the database.
+
+Example:
+
+```
+git checkout -b add-new-index
+```
+
+### 2. Change the desired state of the database
+
+Similar to modern Infrastructure-as-Code tools, Atlas uses a declarative approach to database management.
+This means that you do not need to write SQL scripts to apply changes to the database.  Instead, you simply
+declare the desired state of the database, and Atlas will automatically generate the SQL scripts required
+to apply the change.
+
+In Atlas, the desired state of the database can be provided in many forms. For example, you can provide
+it as a plain SQL file, using [Atlas HCL](/atlas-schema/hcl), as a reference to your [ORM](/atlas-schema/external),
+or even as a connection to an existing database.  For the purposes of this example, we will use a plain SQL file.
+
+Example:
+
+```diff {title="schema.sql"}
+CREATE TABLE users (
+  id INT AUTO_INCREMENT PRIMARY KEY,
+  name VARCHAR(255) NOT NULL,
+  email VARCHAR(255) NOT NULL,
+  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
++  INDEX idx_users_email (email)
+);
+```
+
+:::info Further reading
+
+Atlas supports many ways of declaring the desired state of the database. Here is a list of
+resources for you to learn more about them:
+* [SQL Schemas](/atlas-schema/sql) - Define the desired state as plain `.sql` files or using
+  [templates](/atlas-schema/sql#template-directory).
+* [Atlas HCL](/atlas-schema/hcl) - Define the desired state using a declarative language that
+  is designed specifically for database schemas.
+* [External Schemas](/atlas-schema/external) - Define the desired state using a reference to
+  your ORM or other external schema definition. For example, using [GORM](/guides/orms/gorm) (Go),
+  [Sequelize](/guides/orms/sequelize) (Node.js), or [Beego](/guides/orms/beego) (Go).
+
+:::
+
+### 3. Automatically plan the change
+
+Use the Atlas CLI to plan the change. This will generate a plan for how to get from the current state
+of the database to the desired state of the database.
+
+Example:
+
+```
+atlas migrate diff --env local add_email_index
+```
+
+Atlas will create a new SQL file in the migration directory, which contains the SQL statements required to
+apply the change to the database.
+
+```sql {title="migrations/20230919090302_add_email_index.sql"}
+ALTER TABLE `users` ADD INDEX `idx_users_email` (`email`);
+```
+
+:::info Further reading
+
+Atlas automates the planning process by generating SQL scripts that apply the desired changes to the database.
+To learn more about this process, review the [Automatic Migration Planning](/versioned/diff) documentation.
+
+:::
+
+### 4. Push your changes to trigger CI
+
+Once you are satisfied with your change and the plan, push your changes to trigger CI:
+```
+git add -A .
+git commit -m "Add email index"
+git push origin add-email-index
+```
+
+Your CI pipeline should be configured to run the following steps whenever a new commit is pushed to the branch.
+Teams commonly configure these steps to run only when a change is made to the desired state of the database or to
+the migration directory.
+
+```bash
+# login to Atlas to report migration analysis report
+atlas login --token $ATLAS_TOKEN
+
+# populate a JSON object with metadata about the Git repo, branch, commit and URL (usually the PR URL)
+ATLAS_CONTEXT=$(cat <<EOF
+{
+    "repo": "$CI_PROJECT_PATH",
+    "branch": "$CI_COMMIT_REF_NAME",
+    "commit": "$CI_COMMIT_SHA",
+    "url": "$URL"
+}
+EOF
+)
+
+# Run analysis against the latest revision of the database.
+atlas migrate lint --base atlas://dir-name --context $ATLAS_CONTEXT
+```
+
+This way, whenever a change is made to the desired state of the database, the CI pipeline will automatically
+simulate and analyze the changes, and verify that they are valid and safe.  Atlas will generate a detailed
+analysis report, an example of which you can see [here](https://gh.atlasgo.cloud/dirs/4294967383/ci-runs/8589934684).
+
+:::info Further reading
+
+It is possible to push your changes to Atlas Cloud directly with the CLI to
+your CI pipeline, but official integrations are also available:
+
+* The `atlas-action` [GitHub Action](/integrations/github-actions#arigaatlas-action-ci)
+* The [GitLab CI](/guides/ci-platforms/gitlab) Guide (includes instructions for running `migrate lint` in pipelines)
+
+:::
+
+### 5. Merge your changes to push the latest revision of the database
+
+Once CI is green and the change is approved, the changes should be merged into the main branch.
+This merge should trigger another workflow, which will push the most recent version of the migration
+directory to [Atlas Cloud](https://atlasgo.cloud). You can think of this step as similar to pushing
+a Docker image to a container registry when code updates are merged into the main branch.
+
+This is typically done by running the following steps in your workflow:
+
+```bash
+# login to Atlas to push the migration directory
+atlas login --token $ATLAS_TOKEN
+
+# push the migration directory to Atlas Cloud
+atlas migrate push dir-name:$GIT_COMMIT
+atlas migrate push dir-name:latest
+```
+
+Once the migration directory is pushed to Atlas Cloud, you can view a visual representation of the
+database schema and its revision history in the Atlas Cloud UI. You can see an example of this
+[here](https://gh.atlasgo.cloud/dirs/4294967383).
+
+:::info Further reading
+
+It is possible to use the Atlas CLI directly in your CI pipeline, but official integrations are also available:
+
+* The `atlas-sync-action` [GitHub Action](/integrations/github-actions#arigaatlas-sync-action)
+* The [GitLab CI](/guides/ci-platforms/gitlab) guide, includes instructions for running `migrate push` in pipelines.
+
+:::
+
+### 6. Deploy the latest revision of the database
+
+Once the latest revision of the database is pushed to Atlas Cloud, it can be deployed to any
+environment. This is typically done by running the following steps in your workflow:
+
+```bash
+# login to Atlas to deploy the latest revision of the database
+atlas login --token $ATLAS_TOKEN
+
+# deploy the latest revision of the database to the staging environment
+atlas migrate apply --url mysql://user:pass@host:port/dbname --dir atlas://project-id
+```
+
+When you run the `atlas migrate apply` command, Atlas will connect to the target database,
+and apply any pending migrations. If there are no pending migrations, Atlas will do nothing.
+When Atlas finishes running, it will send a report to your Atlas Cloud account, with a summary
+of the changes that were applied, and any errors that occurred. You can view an example of this
+report [here](https://gh.atlasgo.cloud/deployments/sets/94489280523).
+
+:::info Further reading
+
+Atlas offers integrations with many modern deployment tools. Here are some popular alternatives:
+
+* [Atlas CLI](/versioned/apply)
+* [GitHub Actions](/integrations/github-actions#arigaatlas-action-deploy)
+* [Terraform Provider](/integrations/terraform-provider#versioned-migrations)
+* [Kubernetes Operator](/integrations/kubernetes/operator#versioned-migrations)
+* And many more such as [Argo CD](/guides/deploying/k8s-argo), [Flux CD](/guides/deploying/k8s-flux),
+  [ECS Fargate](/guides/deploying/aws-ecs-fargate), [Helm](/guides/deploying/helm), and more!
+
+:::
+
+## Conclusion
+
+This document described the recommended workflow for teams using Atlas to achieve continuous integration
+and continuous delivery (CI/CD) for their databases.  As we've demonstrated, this process can generally be broken
+down into four phases:
+
+1. Develop - Create a new branch, change the desired state and plan the change.
+2. Review - Create a pull request, let Atlas analyze the change and report back.
+3. Deliver - Merge the change, push the latest revision of the database to Atlas Cloud.
+4. Deploy - Deploy the latest revision of the database to any environment.
+
+By implementing this workflow, teams can achieve the following benefits:
+* **Faster development** - Developers can focus on writing application code, and let Atlas be responsible for planning schema changes.
+* **Lower defect rate** - By simulating and validating changes to the database during CI, teams can catch errors
+  before they are deployed to production.
+* **Automation** - By integrating with your team's existing deployment tools, Atlas obviates the need for manual
+  steps to deploy changes to the database, making your system more reliable and less error-prone.
\ No newline at end of file
diff --git a/doc/website/blog/2023-09-25-sequelize-schema-loader.mdx b/doc/website/blog/2023-09-25-sequelize-schema-loader.mdx
new file mode 100644
index 00000000..30643d46
--- /dev/null
+++ b/doc/website/blog/2023-09-25-sequelize-schema-loader.mdx
@@ -0,0 +1,428 @@
+---
+title: "Announcing External Loader for Sequelize Schemas"
+authors: ronenlu
+tags: [sequelize, schema, migration]
+---
+
+import Tabs from '@theme/Tabs';
+import TabItem from '@theme/TabItem';
+
+:::info TL;DR
+
+You can now import the desired database schema from your Sequelize project into Atlas,
+and use it to automatically plan migrations for you.
+
+[See an example](#demo-time)
+
+:::
+
+## Introduction
+
+[Sequelize](https://sequelize.org/) is one of the most popular ORMs for Node.js. It supports a variety of databases, including MySQL,
+PostgreSQL, SQLite, and Microsoft SQL Server.
+
+[Atlas](https://atlasgo.io) is a modern tool for managing your database schema. It allows you to inspect, plan, lint and execute schema changes
+to your database. It is designed to be used by developers, DBAs and DevOps engineers alike.
+
+Atlas supports many ways to describe database schemas: Using [Schema Loaders](https://atlasgo.io/blog/2023/01/05/atlas-v090#schema-loaders),
+plain SQL, a connection to another database or using [Atlas HCL](https://atlasgo.io/atlas-schema/sql-resources).
+
+Today, I'm happy to announce that Atlas supports loading the desired schema from Sequelize projects.
+This means that Sequelize users can now use Atlas instead of the existing Sequelize CLI to manage
+their database schema.
+
+By using Atlas, Sequelize users can now enjoy these benefits:
+
+* A declarative migration flow - Atlas can operate like a "Terraform for databases", where
+  by running `atlas schema apply` the application schema is applied on a target database.
+* Automatic schema migration planning - Alternatively, Atlas can operate using a more traditional
+  versioned migration flow. However, contrary to most tools, Atlas will automatically analyze the diff between
+  the migration directory and the current application data model, and will produce correct and
+  safe SQL migration files.
+* CI for schema changes - Atlas can be used during CI to make sure you never merge a pull request
+  that will break your database schema.
+* Modern CD integrations - Atlas integrates seamlessly with modern deployment tools such as Kubernetes,
+  Terraform, Helm, Flux, and ArgoCD. This allows you to deploy changes to your database schema
+  as part of your existing deployment pipelines.
+* Visualization - Atlas users can create beautiful, shareable ERDs of their application data model
+  with a single command.
+* .. and much more (read more about [Atlas features](/))
+
+## Evolving beyond Sequelize's native migration support
+
+Sequelize allows users to manage their database schemas using its
+[sync](https://sequelize.org/docs/v6/core-concepts/model-basics/#model-synchronization/) feature,
+which is usually sufficient during development and in many simple cases:
+
+```js
+await sequelize.sync({ force: true });
+console.log("All models were synchronized successfully.");
+```
+
+However, at some point, teams need more control and decide to employ
+the [migrations](https://sequelize.org/master/manual/migrations.html)
+methodology, which is a more robust way to manage your database schema.
+The problem with [creating migrations](https://sequelize.org/docs/v6/other-topics/migrations/#creating-the-first-model-and-migration)
+in Sequelize is that they are usually written by hand in a very specific DSL, which is error-prone and
+time consuming:
+
+```js
+module.exports = {
+  up: (queryInterface, Sequelize) => {
+    return queryInterface.createTable('Person', {
+      name: Sequelize.DataTypes.STRING,
+      isBetaMember: {
+        type: Sequelize.DataTypes.BOOLEAN,
+        defaultValue: false,
+        allowNull: false
+      }
+    });
+  },
+  down: (queryInterface, Sequelize) => {
+    return queryInterface.dropTable('Person');
+  }
+};
+```
+
+Atlas can automatically plan database schema migrations for developers using Sequelize by
+calculating the diff between the **current** state of the migration directory and its
+**desired** state defined by the Sequelize schema.
+
+## Demo time
+
+Let's demonstrate how to set up Atlas to manage your Sequelize schema.
+
+### Installation
+
+If you haven't already, install Atlas from macOS or Linux by running:
+```bash
+curl -sSf https://atlasgo.sh | sh
+```
+See [the documentation](https://atlasgo.io/getting-started#installation) for more installation options.
+
+Install the [Atlas Sequelize Provider](https://github.com/ariga/atlas-provider-sequelize) by running:
+
+<Tabs>
+<TabItem value="js" label="JavaScript" default>
+
+```bash
+npm install @ariga/atlas-provider-sequelize
+```
+</TabItem>
+<TabItem value="ts" label="TypeScript">
+
+```bash
+npm install @ariga/ts-atlas-provider-sequelize
+```
+</TabItem>
+</Tabs>
+
+Make sure all your Node dependencies are installed by running:
+```bash
+npm install
+```
+
+### Standalone vs Script mode
+
+The provider can be used in two modes:
+* **Standalone** - If all of your Sequelize models exist in a single Node.js module,
+ you can use the provider directly to load your Sequelize schema into Atlas.
+* **Script** - In other cases, you can use the provider as an npm package to write a script
+ that loads your Sequelize schema into Atlas.
+
+### Standalone mode
+
+In your project directory, create a new file named `atlas.hcl` with the following contents:
+
+<Tabs>
+<TabItem value="js" label="JavaScript" default>
+
+```hcl
+data "external_schema" "sequelize" {
+    program = [
+        "npx",
+        "@ariga/atlas-provider-sequelize",
+        "load",
+        "--path", "./path/to/models",
+        "--dialect", "mysql", // mariadb | postgres | sqlite | mssql
+    ]
+}
+
+env "sequelize" {
+    src = data.external_schema.sequelize.url
+    dev = "docker://mysql/8/dev"
+    migration {
+        dir = "file://migrations"
+    }
+    format {
+        migrate {
+            diff = "{{ sql . \"  \" }}"
+        }
+    }
+}
+```
+</TabItem>
+<TabItem value="ts" label="TypeScript">
+
+```hcl
+data "external_schema" "sequelize" {
+    program = [
+        "npx",
+        "@ariga/ts-atlas-provider-sequelize",
+        "load",
+        "--path", "./path/to/models",
+        "--dialect", "mysql", // mariadb | postgres | sqlite | mssql
+    ]
+}
+
+env "sequelize" {
+    src = data.external_schema.sequelize.url
+    dev = "docker://mysql/8/dev"
+    migration {
+        dir = "file://migrations"
+    }
+    format {
+        migrate {
+            diff = "{{ sql . \"  \" }}"
+        }
+    }
+}
+```
+</TabItem>
+</Tabs>
+
+For the sake of brevity, we will not review the *Script mode* in this post, but you can find more information about
+it in the [Sequelize Guide](/guides/orms/sequelize).
+
+### Load Sequelize Schema in action
+
+Atlas supports a [versioned migrations](/concepts/declarative-vs-versioned#versioned-migrations)
+workflow, where each change to the database is versioned and recorded in a migration file. You can use the
+`atlas migrate diff` command to automatically generate a migration file that will migrate the database
+from its latest revision to the current Sequelize schema.
+
+Suppose we have the following Sequelize `models` directory, with two models `task` and `user`:
+
+<Tabs>
+<TabItem value="js" label="JavaScript" default>
+<Tabs>
+<TabItem value="task" label="task.js" default>
+
+```js
+'use strict';
+module.exports = (sequelize, DataTypes) => {
+  const Task = sequelize.define('Task', {
+    complete: {
+      type: DataTypes.BOOLEAN,
+      defaultValue: false,
+    }
+  });
+
+  Task.associate = (models) => {
+    Task.belongsTo(models.User, {
+      foreignKey: {
+        name: 'userID',
+        allowNull: false
+      },
+      as: 'tasks'
+    });
+  };
+
+  return Task;
+};
+```
+</TabItem>
+<TabItem value="user" label="user.js">
+
+```js
+'use strict';
+module.exports = function(sequelize, DataTypes) {
+  const User = sequelize.define('User', {
+    name: {
+      type: DataTypes.STRING,
+      allowNull: false
+    },
+    email: {
+      type: DataTypes.STRING,
+      allowNull: false,
+      validate: {
+        isEmail: true
+      },
+    }
+  });
+
+  User.associate = (models) => {
+    User.hasMany(models.Task, {
+      foreignKey: {
+        name: 'userID',
+        allowNull: false
+      },
+      as: 'tasks'
+    });
+  };
+
+  return User;
+};
+```
+</TabItem>
+</Tabs>
+
+</TabItem>
+<TabItem value="ts" label="TypeScript">
+<Tabs>
+<TabItem value="task" label="task.ts" default>
+
+```ts
+import { Table, Column, Model, PrimaryKey, ForeignKey, BelongsTo, AutoIncrement, DataType, AllowNull, Default } from "sequelize-typescript";
+
+import User from "./user";
+
+@Table({ tableName: "Tasks" })
+class Task extends Model {
+  @PrimaryKey
+  @AutoIncrement
+  @Column(DataType.INTEGER)
+  id!: number;
+
+ @Default(false)
+ @Column(DataType.BOOLEAN)
+ complete!: boolean;
+
+  @AllowNull(false)
+  @ForeignKey(() => User)
+  @Column(DataType.INTEGER)
+  userID!: number;
+
+  @BelongsTo(() => User)
+  user!: User;
+}
+
+export default Task;
+```
+</TabItem>
+<TabItem value="user" label="user.ts">
+
+```ts
+import { Table, Column, Model, PrimaryKey, AutoIncrement, DataType, AllowNull, HasMany} from "sequelize-typescript";
+import Task from "./task";
+
+@Table({ tableName: "Users" })
+class User extends Model {
+  @PrimaryKey
+  @AutoIncrement
+  @Column(DataType.INTEGER)
+  id!: number;
+
+  @AllowNull(false)
+  @Column
+  name!: string;
+
+  @AllowNull(false)
+  @Column
+  email!: string;
+
+  @HasMany(() => Task)
+  task!: Task[];
+}
+
+export default User;
+```
+</TabItem>
+</Tabs>
+</TabItem>
+</Tabs>
+
+We can now generate a migration file by running this command:
+
+```bash
+atlas migrate diff --env sequelize
+```
+
+Running this command will generate files similar to this in the `migrations` directory:
+
+```
+migrations
+|-- 20230918143104.sql
+`-- atlas.sum
+
+0 directories, 2 files
+```
+
+Examining the contents of `20230918143104.sql`:
+
+```sql
+-- Create "Users" table
+CREATE TABLE `Users` (
+  `id` int NOT NULL AUTO_INCREMENT,
+  `name` varchar(255) NOT NULL,
+  `email` varchar(255) NOT NULL,
+  `createdAt` datetime NOT NULL,
+  `updatedAt` datetime NOT NULL,
+  PRIMARY KEY (`id`)
+) CHARSET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
+-- Create "Tasks" table
+CREATE TABLE `Tasks` (
+  `id` int NOT NULL AUTO_INCREMENT,
+  `complete` bool NULL DEFAULT 0,
+  `createdAt` datetime NOT NULL,
+  `updatedAt` datetime NOT NULL,
+  `userID` int NOT NULL,
+  PRIMARY KEY (`id`),
+  INDEX `userID` (`userID`),
+  CONSTRAINT `Tasks_ibfk_1` FOREIGN KEY (`userID`) REFERENCES `Users` (`id`) ON UPDATE CASCADE ON DELETE NO ACTION
+) CHARSET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
+```
+
+Amazing! Atlas automatically generated a migration file that will create the `Users` and `Tasks` tables in our database!
+
+Next, alter the `User` model to add a new `age` field:
+
+<Tabs>
+<TabItem value="js" label="JavaScript" default>
+
+```diff
+    name: {
+      type: DataTypes.STRING,
+      allowNull: false
+    },
++   age: {
++     type: DataTypes.INTEGER,
++     allowNull: false
++   },
+```
+</TabItem>
+<TabItem value="ts" label="TypeScript">
+
+```diff
+    @AllowNull(false)
+    @Column
+    name!: string;
+
++   @AllowNull(false)
++   @Column
++   age!: number;
+```
+</TabItem>
+</Tabs>
+
+Re-run this command:
+
+```bash
+atlas migrate diff --env sequelize
+```
+
+Observe a new migration file is generated:
+
+```sql
+-- Modify "Users" table
+ALTER TABLE `Users` ADD COLUMN `age` int NOT NULL;
+```
+
+### Conclusion
+
+In this post, we have presented how Sequelize projects can use Atlas to automatically
+plan schema migrations based only on their data model.
+
+#### How can we make Atlas better?
+
+We would love to hear from you [on our Discord server](https://discord.gg/zZ6sWVg6NT) :heart:.
diff --git a/doc/website/blog/authors.yml b/doc/website/blog/authors.yml
index 4c613851..152ea615 100644
--- a/doc/website/blog/authors.yml
+++ b/doc/website/blog/authors.yml
@@ -38,3 +38,8 @@ datdao:
   name: Dat Dao
   url: "https://github.com/datdao"
   image_url: "https://avatars.githubusercontent.com/u/16095902?v=4"
+ronenlu:
+  name: Ronen Lubin
+  title: Software Engineer
+  url: "https://github.com/ronenlu"
+  image_url: "https://avatars.githubusercontent.com/u/63970571?v=4"
diff --git a/doc/website/sidebars.js b/doc/website/sidebars.js
index 120004a2..685e5530 100644
--- a/doc/website/sidebars.js
+++ b/doc/website/sidebars.js
@@ -19,6 +19,7 @@ module.exports = {
                 'getting-started/getting-started',
             ]
         },
+
         {
             type: "category",
             label: "Declarative Workflows",
@@ -137,6 +138,18 @@ module.exports = {
             type: 'doc',
             id: 'guides/guides'
         },
+        {
+          type: 'category',
+          label: 'For Platform Teams',
+          collapsed: false,
+            items: [
+                {
+                    type: 'doc',
+                    label: 'Modern Database CI/CD',
+                    id: 'guides/modern-database-ci-cd',
+                },
+            ]
+        },
         {
             type: 'category',
             label: 'Deploying schema migrations',
diff --git a/doc/website/src/css/custom.css b/doc/website/src/css/custom.css
index 584bf184..f310fdb9 100644
--- a/doc/website/src/css/custom.css
+++ b/doc/website/src/css/custom.css
@@ -250,3 +250,7 @@ div[class^='announcementBar_']  a.cta {
   color: white;
   text-decoration: none;
 }
+
+.theme-admonition a {
+  text-decoration: underline;
+}
\ No newline at end of file
diff --git a/internal/ci/ci_go.tmpl b/internal/ci/ci_go.tmpl
index ef65ac67..9e1205b4 100644
--- a/internal/ci/ci_go.tmpl
+++ b/internal/ci/ci_go.tmpl
@@ -51,7 +51,7 @@ jobs:
     runs-on: {{ $.Runner }}
     strategy:
       matrix:
-        go: [ '1.20', '1.21' ]
+        go: {{ with $.GoVersions }}{{ . }}{{ end }}
     steps:
       - uses: actions/checkout@v2
       - uses: actions/setup-go@v4
diff --git a/internal/ci/jobs_oss.go b/internal/ci/jobs_oss.go
index 4ea19a24..6ac727eb 100644
--- a/internal/ci/jobs_oss.go
+++ b/internal/ci/jobs_oss.go
@@ -9,7 +9,8 @@ package main
 //go:generate go run . -flavor Community -suffix oss
 
 func init() {
-	jobs = append(jobs,
+	data.GoVersions = goVersions{"1.20", "1.21"}
+	data.Jobs = append(jobs,
 		Job{
 			Version: "tidb5",
 			Image:   "pingcap/tidb:v5.4.0",
diff --git a/internal/ci/main.go b/internal/ci/main.go
index d01a0536..690b6e87 100644
--- a/internal/ci/main.go
+++ b/internal/ci/main.go
@@ -13,6 +13,8 @@ import (
 	"log"
 	"os"
 	"path/filepath"
+	"strconv"
+	"strings"
 	"text/template"
 )
 
@@ -164,19 +166,24 @@ var (
 	}
 )
 
+type goVersions []string
+
+var data struct {
+	Jobs                 []Job
+	Flavor, Tags, Runner string
+	GoVersions           goVersions
+}
+
 func main() {
-	var flavor, tags, suffix, runner string
-	flag.StringVar(&flavor, "flavor", "", "")
-	flag.StringVar(&tags, "tags", "", "")
+	var suffix string
+	flag.StringVar(&data.Flavor, "flavor", "", "")
+	flag.StringVar(&data.Tags, "tags", "", "")
+	flag.StringVar(&data.Runner, "runner", "ubuntu-latest", "")
 	flag.StringVar(&suffix, "suffix", "", "")
-	flag.StringVar(&runner, "runner", "ubuntu-latest", "")
 	flag.Parse()
 	for _, n := range []string{"dialect", "go", "revisions"} {
 		var buf bytes.Buffer
-		if err := tpl.ExecuteTemplate(&buf, fmt.Sprintf("ci_%s.tmpl", n), struct {
-			Jobs                 []Job
-			Flavor, Tags, Runner string
-		}{jobs, flavor, tags, runner}); err != nil {
+		if err := tpl.ExecuteTemplate(&buf, fmt.Sprintf("ci_%s.tmpl", n), data); err != nil {
 			log.Fatalln(err)
 		}
 		err := os.WriteFile(filepath.Clean(fmt.Sprintf("../../.github/workflows/ci-%s_%s.yaml", n, suffix)), buf.Bytes(), 0600)
@@ -185,3 +192,10 @@ func main() {
 		}
 	}
 }
+
+func (v goVersions) String() (s string) {
+	for i := range v {
+		v[i] = strconv.Quote(v[i])
+	}
+	return fmt.Sprintf("[ %s ]", strings.Join(v, ", "))
+}
diff --git a/sql/internal/specutil/convert.go b/sql/internal/specutil/convert.go
index b3acfc2a..0fcbd8c6 100644
--- a/sql/internal/specutil/convert.go
+++ b/sql/internal/specutil/convert.go
@@ -26,6 +26,7 @@ type (
 	ConvertTypeFunc        func(*sqlspec.Column) (schema.Type, error)
 	ConvertPrimaryKeyFunc  func(*sqlspec.PrimaryKey, *schema.Table) (*schema.Index, error)
 	ConvertIndexFunc       func(*sqlspec.Index, *schema.Table) (*schema.Index, error)
+	ConvertViewIndexFunc   func(*sqlspec.Index, *schema.View) (*schema.Index, error)
 	ConvertCheckFunc       func(*sqlspec.Check) (*schema.Check, error)
 	ColumnTypeSpecFunc     func(schema.Type) (*sqlspec.Column, error)
 	TableSpecFunc          func(*schema.Table) (*sqlspec.Table, error)
@@ -41,9 +42,10 @@ type (
 type (
 	// ScanDoc represents a scanned HCL document.
 	ScanDoc struct {
-		Schemas []*sqlspec.Schema
-		Tables  []*sqlspec.Table
-		Views   []*sqlspec.View
+		Schemas      []*sqlspec.Schema
+		Tables       []*sqlspec.Table
+		Views        []*sqlspec.View
+		Materialized []*sqlspec.View
 	}
 	// ScanFuncs represents a set of scan functions
 	// used to convert the HCL document to the Realm.
@@ -53,6 +55,14 @@ type (
 	}
 )
 
+const (
+	typeView         = "view"
+	typeTable        = "table"
+	typeColumn       = "column"
+	typeSchema       = "schema"
+	typeMaterialized = "materialized"
+)
+
 // Scan populates the Realm from the schemas and table specs.
 func Scan(r *schema.Realm, doc *ScanDoc, funcs *ScanFuncs) error {
 	byName := make(map[string]*schema.Schema)
@@ -110,36 +120,74 @@ func Scan(r *schema.Realm, doc *ScanDoc, funcs *ScanFuncs) error {
 			viewDeps[v] = refs
 		}
 	}
+	for _, m := range doc.Materialized {
+		name, err := SchemaName(m.Schema)
+		if err != nil {
+			return fmt.Errorf("specutil: cannot extract schema name for materialized %q: %w", m.Name, err)
+		}
+		s, ok := byName[name]
+		if !ok {
+			return fmt.Errorf("specutil: schema %q not found for materialized %q", name, m.Name)
+		}
+		v, err := funcs.View(m, s)
+		if err != nil {
+			return fmt.Errorf("specutil: cannot convert materialized %q: %w", m.Name, err)
+		}
+		s.AddViews(v.SetMaterialized(true))
+		if deps, ok := m.Attr("depends_on"); ok {
+			refs, err := deps.Refs()
+			if err != nil {
+				return fmt.Errorf("specutil: expect list of references for attribute materialized.%s.depends_on: %w", m.Name, err)
+			}
+			viewDeps[v] = refs
+		}
+	}
 	// Link views' dependencies.
 	for v, refs := range viewDeps {
+		srcT := typeView
+		if v.Materialized() {
+			srcT = typeMaterialized
+		}
 		for i, r := range refs {
 			switch p, err := r.Path(); {
 			case err != nil:
-				return fmt.Errorf("specutil: extract reference for view.%s: %w", v.Name, err)
+				return fmt.Errorf("specutil: extract reference for %s.%s: %w", srcT, v.Name, err)
 			case len(p) == 0:
-				return fmt.Errorf("specutil: empty reference for view.%s", v.Name)
-			case p[0].T == "view":
-				q, n, err := viewName(r)
+				return fmt.Errorf("specutil: empty reference for %s.%s", srcT, v.Name)
+			case p[0].T == typeView:
+				q, n, err := refName(r, typeView)
 				if err != nil {
-					return fmt.Errorf("specutil: extract view name from view.%s.depends_on[%d]: %w", v.Name, i, err)
+					return fmt.Errorf("specutil: extract view name from %s.%s.depends_on[%d]: %w", srcT, v.Name, i, err)
 				}
 				v1, err := findT(v.Schema, q, n, func(s *schema.Schema, name string) (*schema.View, bool) {
 					return s.View(name)
 				})
 				if err != nil {
-					return fmt.Errorf("specutil: find view refrence for view.%s.depends_on[%d]: %w", v.Name, i, err)
+					return fmt.Errorf("specutil: find view refrence for %s.%s.depends_on[%d]: %w", srcT, v.Name, i, err)
+				}
+				v.AddDeps(v1)
+			case p[0].T == typeMaterialized:
+				q, n, err := refName(r, typeMaterialized)
+				if err != nil {
+					return fmt.Errorf("specutil: extract materialized name from %s.%s.depends_on[%d]: %w", srcT, v.Name, i, err)
+				}
+				v1, err := findT(v.Schema, q, n, func(s *schema.Schema, name string) (*schema.View, bool) {
+					return s.Materialized(name)
+				})
+				if err != nil {
+					return fmt.Errorf("specutil: find materialized refrence for %s.%s.depends_on[%d]: %w", srcT, v.Name, i, err)
 				}
 				v.AddDeps(v1)
-			case p[0].T == "table":
-				q, n, err := tableName(r)
+			case p[0].T == typeTable:
+				q, n, err := refName(r, typeTable)
 				if err != nil {
-					return fmt.Errorf("specutil: extract view name from view.%s.depends_on[%d]: %w", v.Name, i, err)
+					return fmt.Errorf("specutil: extract table name from %s.%s.depends_on[%d]: %w", srcT, v.Name, i, err)
 				}
 				t1, err := findT(v.Schema, q, n, func(s *schema.Schema, name string) (*schema.Table, bool) {
 					return s.Table(name)
 				})
 				if err != nil {
-					return fmt.Errorf("specutil: find table refrence for view.%s.depends_on[%d]: %w", v.Name, i, err)
+					return fmt.Errorf("specutil: find table refrence for %s.%s.depends_on[%d]: %w", srcT, v.Name, i, err)
 				}
 				v.AddDeps(t1)
 			}
@@ -153,46 +201,46 @@ func Scan(r *schema.Realm, doc *ScanDoc, funcs *ScanFuncs) error {
 // at this point. Instead, the linking is done by the Schema function.
 func Table(spec *sqlspec.Table, parent *schema.Schema, convertColumn ConvertTableColumnFunc,
 	convertPK ConvertPrimaryKeyFunc, convertIndex ConvertIndexFunc, convertCheck ConvertCheckFunc) (*schema.Table, error) {
-	tbl := &schema.Table{
+	t := &schema.Table{
 		Name:   spec.Name,
 		Schema: parent,
 	}
 	for _, csp := range spec.Columns {
-		col, err := convertColumn(csp, tbl)
+		col, err := convertColumn(csp, t)
 		if err != nil {
 			return nil, err
 		}
-		tbl.Columns = append(tbl.Columns, col)
+		t.AddColumns(col)
 	}
 	if spec.PrimaryKey != nil {
-		pk, err := convertPK(spec.PrimaryKey, tbl)
+		pk, err := convertPK(spec.PrimaryKey, t)
 		if err != nil {
 			return nil, err
 		}
-		tbl.PrimaryKey = pk
+		t.SetPrimaryKey(pk)
 	}
 	for _, idx := range spec.Indexes {
-		i, err := convertIndex(idx, tbl)
+		i, err := convertIndex(idx, t)
 		if err != nil {
 			return nil, err
 		}
-		tbl.Indexes = append(tbl.Indexes, i)
+		t.AddIndexes(i)
 	}
 	for _, c := range spec.Checks {
 		c, err := convertCheck(c)
 		if err != nil {
 			return nil, err
 		}
-		tbl.AddChecks(c)
+		t.AddChecks(c)
 	}
-	if err := convertCommentFromSpec(spec, &tbl.Attrs); err != nil {
+	if err := convertCommentFromSpec(spec, &t.Attrs); err != nil {
 		return nil, err
 	}
-	return tbl, nil
+	return t, nil
 }
 
 // View converts a sqlspec.View to a schema.View.
-func View(spec *sqlspec.View, parent *schema.Schema, convertColumn ConvertViewColumnFunc) (*schema.View, error) {
+func View(spec *sqlspec.View, parent *schema.Schema, convertC ConvertViewColumnFunc, convertI ConvertViewIndexFunc) (*schema.View, error) {
 	as, ok := spec.Extra.Attr("as")
 	if !ok {
 		return nil, fmt.Errorf("specutil: missing 'as' definition for view %q", spec.Name)
@@ -203,12 +251,19 @@ func View(spec *sqlspec.View, parent *schema.Schema, convertColumn ConvertViewCo
 	}
 	v := schema.NewView(spec.Name, def).SetSchema(parent)
 	for _, c := range spec.Columns {
-		c, err := convertColumn(c, v)
+		c, err := convertC(c, v)
 		if err != nil {
 			return nil, err
 		}
 		v.AddColumns(c)
 	}
+	for _, idx := range spec.Indexes {
+		i, err := convertI(idx, v)
+		if err != nil {
+			return nil, err
+		}
+		v.AddIndexes(i)
+	}
 	if err := convertCommentFromSpec(spec, &v.Attrs); err != nil {
 		return nil, err
 	}
@@ -392,6 +447,7 @@ func FromSchema(s *schema.Schema, specT TableSpecFunc, specV ViewSpecFunc) (*Sch
 			Name: s.Name,
 		}
 		views  = make([]*sqlspec.View, 0, len(s.Views))
+		mviews = make([]*sqlspec.View, 0, len(s.Views))
 		tables = make([]*sqlspec.Table, 0, len(s.Tables))
 	)
 	for _, t := range s.Tables {
@@ -412,13 +468,18 @@ func FromSchema(s *schema.Schema, specT TableSpecFunc, specV ViewSpecFunc) (*Sch
 		if s.Name != "" {
 			view.Schema = SchemaRef(s.Name)
 		}
-		views = append(views, view)
+		if v.Materialized() {
+			mviews = append(mviews, view)
+		} else {
+			views = append(views, view)
+		}
 	}
 	convertCommentFromSchema(s.Attrs, &spec.Extra.Attrs)
 	return &SchemaSpec{
-		Schema: spec,
-		Tables: tables,
-		Views:  views,
+		Schema:       spec,
+		Tables:       tables,
+		Views:        views,
+		Materialized: mviews,
 	}, nil
 }
 
@@ -466,7 +527,7 @@ func FromTable(t *schema.Table, colFn TableColumnSpecFunc, pkFn PrimaryKeySpecFu
 }
 
 // FromView converts a schema.View to a sqlspec.View.
-func FromView(v *schema.View, colFn ViewColumnSpecFunc) (*sqlspec.View, error) {
+func FromView(v *schema.View, colFn ViewColumnSpecFunc, idxFn IndexSpecFunc) (*sqlspec.View, error) {
 	spec := &sqlspec.View{
 		Name: v.Name,
 	}
@@ -477,6 +538,13 @@ func FromView(v *schema.View, colFn ViewColumnSpecFunc) (*sqlspec.View, error) {
 		}
 		spec.Columns = append(spec.Columns, cs)
 	}
+	for _, idx := range v.Indexes {
+		i, err := idxFn(idx)
+		if err != nil {
+			return nil, err
+		}
+		spec.Indexes = append(spec.Indexes, i)
+	}
 	as := v.Def
 	// In case the view definition is multi-line,
 	// format it as indented heredoc with two spaces.
@@ -521,14 +589,18 @@ func FromView(v *schema.View, colFn ViewColumnSpecFunc) (*sqlspec.View, error) {
 				path = append(path, d.Schema.Name)
 			}
 			deps = append(deps, schemahcl.BuildRef([]schemahcl.PathIndex{
-				{T: "table", V: append(path, d.Name)},
+				{T: typeTable, V: append(path, d.Name)},
 			}))
 		case *schema.View:
+			vt := typeView
+			if d.Materialized() {
+				vt = typeMaterialized
+			}
 			if nameV[d.Name] > 1 {
 				path = append(path, d.Schema.Name)
 			}
 			deps = append(deps, schemahcl.BuildRef([]schemahcl.PathIndex{
-				{T: "view", V: append(path, d.Name)},
+				{T: vt, V: append(path, d.Name)},
 			}))
 		}
 	}
@@ -743,7 +815,7 @@ func FromCheck(s *schema.Check) *sqlspec.Check {
 
 // SchemaName returns the name from a ref to a schema.
 func SchemaName(ref *schemahcl.Ref) (string, error) {
-	vs, err := ref.ByType("schema")
+	vs, err := ref.ByType(typeSchema)
 	if err != nil {
 		return "", err
 	}
@@ -755,7 +827,7 @@ func SchemaName(ref *schemahcl.Ref) (string, error) {
 
 // ColumnByRef returns a column from the table by its reference.
 func ColumnByRef(t *schema.Table, ref *schemahcl.Ref) (*schema.Column, error) {
-	vs, err := ref.ByType("column")
+	vs, err := ref.ByType(typeColumn)
 	if err != nil {
 		return nil, err
 	}
@@ -822,24 +894,12 @@ func findT[T schema.View | schema.Table](sch *schema.Schema, qualifier, name str
 	}
 }
 
-func tableName(ref *schemahcl.Ref) (qualifier, name string, err error) {
-	vs, err := ref.ByType("table")
-	if err != nil {
-		return "", "", err
-	}
-	switch len(vs) {
-	case 1:
-		name = vs[0]
-	case 2:
-		qualifier, name = vs[0], vs[1]
-	default:
-		return "", "", fmt.Errorf("sqlspec: unexpected number of references in %q", vs)
-	}
-	return
+func tableName(ref *schemahcl.Ref) (string, string, error) {
+	return refName(ref, typeTable)
 }
 
-func viewName(ref *schemahcl.Ref) (qualifier, name string, err error) {
-	vs, err := ref.ByType("view")
+func refName(ref *schemahcl.Ref, typeName string) (qualifier, name string, err error) {
+	vs, err := ref.ByType(typeName)
 	if err != nil {
 		return "", "", err
 	}
@@ -861,28 +921,28 @@ func isLocalRef(r *schemahcl.Ref) bool {
 // ColumnRef returns the reference of a column by its name.
 func ColumnRef(cName string) *schemahcl.Ref {
 	return schemahcl.BuildRef([]schemahcl.PathIndex{
-		{T: "column", V: []string{cName}},
+		{T: typeColumn, V: []string{cName}},
 	})
 }
 
 func externalColRef(cName string, tName string) *schemahcl.Ref {
 	return schemahcl.BuildRef([]schemahcl.PathIndex{
-		{T: "table", V: []string{tName}},
-		{T: "column", V: []string{cName}},
+		{T: typeTable, V: []string{tName}},
+		{T: typeColumn, V: []string{cName}},
 	})
 }
 
 func qualifiedExternalColRef(cName, tName, sName string) *schemahcl.Ref {
 	return schemahcl.BuildRef([]schemahcl.PathIndex{
-		{T: "table", V: []string{sName, tName}},
-		{T: "column", V: []string{cName}},
+		{T: typeTable, V: []string{sName, tName}},
+		{T: typeColumn, V: []string{cName}},
 	})
 }
 
 // SchemaRef returns the schemahcl.Ref to the schema with the given name.
 func SchemaRef(name string) *schemahcl.Ref {
 	return schemahcl.BuildRef([]schemahcl.PathIndex{
-		{T: "schema", V: []string{name}},
+		{T: typeSchema, V: []string{name}},
 	})
 }
 
diff --git a/sql/internal/specutil/spec.go b/sql/internal/specutil/spec.go
index fdca87b7..8b5ccc3f 100644
--- a/sql/internal/specutil/spec.go
+++ b/sql/internal/specutil/spec.go
@@ -24,14 +24,16 @@ type (
 	// SchemaSpec is returned by driver convert functions to
 	// marshal a *schema.Schema into top-level spec objects.
 	SchemaSpec struct {
-		Schema *sqlspec.Schema
-		Tables []*sqlspec.Table
-		Views  []*sqlspec.View
+		Schema       *sqlspec.Schema
+		Tables       []*sqlspec.Table
+		Views        []*sqlspec.View
+		Materialized []*sqlspec.View
 	}
 	doc struct {
-		Tables  []*sqlspec.Table  `spec:"table"`
-		Views   []*sqlspec.View   `spec:"view"`
-		Schemas []*sqlspec.Schema `spec:"schema"`
+		Tables       []*sqlspec.Table  `spec:"table"`
+		Views        []*sqlspec.View   `spec:"view"`
+		Materialized []*sqlspec.View   `spec:"materialized"`
+		Schemas      []*sqlspec.Schema `spec:"schema"`
 	}
 )
 
@@ -47,6 +49,7 @@ func Marshal(v any, marshaler schemahcl.Marshaler, convertFunc func(*schema.Sche
 		}
 		d.Tables = spec.Tables
 		d.Views = spec.Views
+		d.Materialized = spec.Materialized
 		d.Schemas = []*sqlspec.Schema{spec.Schema}
 	case *schema.Realm:
 		for _, s := range s.Schemas {
@@ -56,6 +59,7 @@ func Marshal(v any, marshaler schemahcl.Marshaler, convertFunc func(*schema.Sche
 			}
 			d.Tables = append(d.Tables, spec.Tables...)
 			d.Views = append(d.Views, spec.Views...)
+			d.Materialized = spec.Materialized
 			d.Schemas = append(d.Schemas, spec.Schema)
 		}
 		if err := QualifyObjects(d.Tables); err != nil {
@@ -64,6 +68,9 @@ func Marshal(v any, marshaler schemahcl.Marshaler, convertFunc func(*schema.Sche
 		if err := QualifyObjects(d.Views); err != nil {
 			return nil, err
 		}
+		if err := QualifyObjects(d.Materialized); err != nil {
+			return nil, err
+		}
 		if err := QualifyReferences(d.Tables, s); err != nil {
 			return nil, err
 		}
diff --git a/sql/internal/sqlx/diff.go b/sql/internal/sqlx/diff.go
index 5a802ec0..392b0e72 100644
--- a/sql/internal/sqlx/diff.go
+++ b/sql/internal/sqlx/diff.go
@@ -185,18 +185,21 @@ func (d *Diff) schemaDiff(from, to *schema.Schema, opts *schema.DiffOptions) ([]
 	}
 	// Drop or modify views.
 	for _, v1 := range from.Views {
-		v2, ok := to.View(v1.Name)
+		v2, ok := findView(to, v1)
 		if !ok {
+			// Changing a view to materialized (and vice versa)
+			// generates a drop and add.
 			changes = opts.AddOrSkip(changes, &schema.DropView{V: v1})
 			continue
 		}
-		if TrimViewExtra(v1.Def) != TrimViewExtra(v2.Def) || d.ViewAttrChanged(v1, v2) {
-			changes = opts.AddOrSkip(changes, &schema.ModifyView{From: v1, To: v2})
+		change := d.indexDiffV(v1, v2, opts)
+		if ViewDefChanged(v1, v2) || d.ViewAttrChanged(v1, v2) || len(change) > 0 {
+			changes = opts.AddOrSkip(changes, &schema.ModifyView{From: v1, To: v2, Changes: change})
 		}
 	}
 	// Add views.
 	for _, v1 := range to.Views {
-		if _, ok := from.View(v1.Name); !ok {
+		if _, ok := findView(from, v1); !ok {
 			changes = opts.AddOrSkip(changes, &schema.AddView{V: v1})
 		}
 	}
@@ -269,7 +272,7 @@ func (d *Diff) tableDiff(from, to *schema.Table, opts *schema.DiffOptions) ([]sc
 
 	// Primary-key and index changes.
 	changes = append(changes, d.pkDiff(from, to, opts)...)
-	changes = append(changes, d.indexDiff(from, to, opts)...)
+	changes = append(changes, d.indexDiffT(from, to, opts)...)
 
 	// Drop or modify foreign-keys.
 	for _, fk1 := range from.ForeignKeys {
@@ -327,9 +330,9 @@ func (d *Diff) pkDiff(from, to *schema.Table, opts *schema.DiffOptions) (changes
 	return
 }
 
-// indexDiff returns the schema changes (if any) for migrating table
+// indexDiffT returns the schema changes (if any) for migrating table
 // indexes from current state to the desired state.
-func (d *Diff) indexDiff(from, to *schema.Table, opts *schema.DiffOptions) []schema.Change {
+func (d *Diff) indexDiffT(from, to *schema.Table, opts *schema.DiffOptions) []schema.Change {
 	var (
 		changes []schema.Change
 		exists  = make(map[*schema.Index]bool)
@@ -371,6 +374,42 @@ func (d *Diff) indexDiff(from, to *schema.Table, opts *schema.DiffOptions) []sch
 	return changes
 }
 
+// indexDiffV returns the schema changes (if any) for migrating view
+// indexes from current state to the desired state.
+func (d *Diff) indexDiffV(from, to *schema.View, opts *schema.DiffOptions) []schema.Change {
+	var (
+		changes []schema.Change
+		exists  = make(map[*schema.Index]bool)
+	)
+	// Drop or modify indexes.
+	for _, idx1 := range from.Indexes {
+		idx2, ok := to.Index(idx1.Name)
+		if ok {
+			if change := d.indexChange(idx1, idx2); change != schema.NoChange {
+				changes = opts.AddOrSkip(changes, &schema.ModifyIndex{
+					From:   idx1,
+					To:     idx2,
+					Change: change,
+				})
+			}
+			exists[idx2] = true
+			continue
+		}
+		// Not found.
+		changes = opts.AddOrSkip(changes, &schema.DropIndex{I: idx1})
+	}
+	// Add indexes.
+	for _, idx := range to.Indexes {
+		if exists[idx] {
+			continue
+		}
+		if _, ok := from.Index(idx.Name); !ok {
+			changes = opts.AddOrSkip(changes, &schema.AddIndex{I: idx})
+		}
+	}
+	return changes
+}
+
 // indexChange returns the schema changes (if any) for migrating one index to the other.
 func (d *Diff) indexChange(from, to *schema.Index) schema.ChangeKind {
 	var change schema.ChangeKind
@@ -679,3 +718,31 @@ func SingleQuote(s string) (string, error) {
 func TrimViewExtra(s string) string {
 	return strings.Trim(s, " \n\t;")
 }
+
+// ViewDefChanged returns true if the view definition has changed.
+// There is small work here that normalizes the indentation that
+// might be extra added on inspection or by the user.
+func ViewDefChanged(from, to *schema.View) bool {
+	if from.Def == to.Def || TrimViewExtra(from.Def) == TrimViewExtra(to.Def) {
+		return false
+	}
+	noident := func(v string) string {
+		var b strings.Builder
+		for i, s := range strings.Split(v, "\n") {
+			if s = TrimViewExtra(s); s != "" && i > 0 {
+				b.WriteByte(' ')
+			}
+			b.WriteString(s)
+		}
+		return b.String()
+	}
+	return noident(from.Def) != noident(to.Def)
+}
+
+// findView finds the view by its name and its type.
+func findView(s *schema.Schema, v1 *schema.View) (*schema.View, bool) {
+	if v1.Materialized() {
+		return s.Materialized(v1.Name)
+	}
+	return s.View(v1.Name)
+}
diff --git a/sql/mysql/sqlspec.go b/sql/mysql/sqlspec.go
index ab123812..9ee5dc95 100644
--- a/sql/mysql/sqlspec.go
+++ b/sql/mysql/sqlspec.go
@@ -141,9 +141,15 @@ func convertTable(spec *sqlspec.Table, parent *schema.Schema) (*schema.Table, er
 
 // convertView converts a sqlspec.View to a schema.View.
 func convertView(spec *sqlspec.View, parent *schema.Schema) (*schema.View, error) {
-	v, err := specutil.View(spec, parent, func(c *sqlspec.Column, _ *schema.View) (*schema.Column, error) {
-		return specutil.Column(c, convertColumnType)
-	})
+	v, err := specutil.View(
+		spec, parent,
+		func(c *sqlspec.Column, _ *schema.View) (*schema.Column, error) {
+			return specutil.Column(c, convertColumnType)
+		},
+		func(i *sqlspec.Index, v *schema.View) (*schema.Index, error) {
+			return nil, fmt.Errorf("unexpected view index %s.%s", v.Name, i.Name)
+		},
+	)
 	if err != nil {
 		return nil, err
 	}
@@ -318,9 +324,13 @@ func tableSpec(t *schema.Table) (*sqlspec.Table, error) {
 
 // viewSpec converts from a concrete MySQL schema.View to a sqlspec.View.
 func viewSpec(view *schema.View) (*sqlspec.View, error) {
-	spec, err := specutil.FromView(view, func(c *schema.Column, _ *schema.View) (*sqlspec.Column, error) {
-		return specutil.FromColumn(c, columnTypeSpec)
-	})
+	spec, err := specutil.FromView(
+		view,
+		func(c *schema.Column, _ *schema.View) (*sqlspec.Column, error) {
+			return specutil.FromColumn(c, columnTypeSpec)
+		},
+		indexSpec,
+	)
 	if err != nil {
 		return nil, err
 	}
diff --git a/sql/postgres/inspect.go b/sql/postgres/inspect.go
index 2c789f09..27f431ca 100644
--- a/sql/postgres/inspect.go
+++ b/sql/postgres/inspect.go
@@ -97,17 +97,7 @@ func (i *inspect) inspectTables(ctx context.Context, r *schema.Realm, opts *sche
 		if len(s.Tables) == 0 {
 			continue
 		}
-		if err := i.columns(ctx, s, queryScope{
-			exec: i.querySchema,
-			append: func(s *schema.Schema, table string, column *schema.Column) error {
-				t, ok := s.Table(table)
-				if !ok {
-					return fmt.Errorf("table %q was not found in schema", table)
-				}
-				t.AddColumns(column)
-				return nil
-			},
-		}); err != nil {
+		if err := i.columns(ctx, s); err != nil {
 			return err
 		}
 		if err := i.indexes(ctx, s); err != nil {
@@ -174,24 +164,19 @@ func (i *inspect) tables(ctx context.Context, realm *schema.Realm, opts *schema.
 	return rows.Close()
 }
 
-type queryScope struct {
-	exec   func(context.Context, string, *schema.Schema) (*sql.Rows, error)
-	append func(*schema.Schema, string, *schema.Column) error
-}
-
 // columns queries and appends the columns of the given table.
-func (i *inspect) columns(ctx context.Context, s *schema.Schema, scope queryScope) error {
+func (i *inspect) columns(ctx context.Context, s *schema.Schema) error {
 	query := columnsQuery
 	if i.crdb {
 		query = crdbColumnsQuery
 	}
-	rows, err := scope.exec(ctx, query, s)
+	rows, err := i.querySchema(ctx, query, s)
 	if err != nil {
 		return fmt.Errorf("postgres: querying schema %q columns: %w", s.Name, err)
 	}
 	defer rows.Close()
 	for rows.Next() {
-		if err := i.addColumn(s, rows, scope); err != nil {
+		if err := i.addColumn(s, rows); err != nil {
 			return fmt.Errorf("postgres: %w", err)
 		}
 	}
@@ -199,7 +184,7 @@ func (i *inspect) columns(ctx context.Context, s *schema.Schema, scope queryScop
 }
 
 // addColumn scans the current row and adds a new column from it to the scope (table or view).
-func (i *inspect) addColumn(s *schema.Schema, rows *sql.Rows, scope queryScope) (err error) {
+func (i *inspect) addColumn(s *schema.Schema, rows *sql.Rows) (err error) {
 	var (
 		typid, typelem, maxlen, precision, timeprecision, scale, seqstart, seqinc, seqlast                                                  sql.NullInt64
 		table, name, typ, fmtype, nullable, defaults, identity, genidentity, genexpr, charset, collate, comment, typtype, elemtyp, interval sql.NullString
@@ -210,6 +195,10 @@ func (i *inspect) addColumn(s *schema.Schema, rows *sql.Rows, scope queryScope)
 	); err != nil {
 		return err
 	}
+	t, ok := s.Table(table.String)
+	if !ok {
+		return fmt.Errorf("table %q was not found in schema", table.String)
+	}
 	c := &schema.Column{
 		Name: name.String,
 		Type: &schema.ColumnType{
@@ -257,7 +246,8 @@ func (i *inspect) addColumn(s *schema.Schema, rows *sql.Rows, scope queryScope)
 	if sqlx.ValidString(collate) {
 		c.SetCollation(collate.String)
 	}
-	return scope.append(s, table.String, c)
+	t.AddColumns(c)
+	return nil
 }
 
 // enumValues fills enum columns with their values from the database.
@@ -334,30 +324,66 @@ 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 {
-	var query string
-	switch {
-	case i.conn.crdb:
+	if i.crdb {
 		return i.crdbIndexes(ctx, s)
-	case i.supportsIndexNullsDistinct():
-		query = indexesAbove15
-	case i.supportsIndexInclude():
-		query = indexesAbove11
-	default:
-		query = indexesBelow11
 	}
-	rows, err := i.querySchema(ctx, query, s)
+	rows, err := i.querySchema(ctx, i.indexesQuery(), s)
 	if err != nil {
 		return fmt.Errorf("postgres: querying schema %q indexes: %w", s.Name, err)
 	}
 	defer rows.Close()
-	if err := i.addIndexes(s, rows); err != nil {
+	if err := i.addIndexes(s, rows, queryScope{
+		hasT: func(tv string) bool {
+			_, ok := s.Table(tv)
+			return ok
+		},
+		setPK: func(tv string, idx *schema.Index) error {
+			if t, ok := s.Table(tv); ok {
+				t.SetPrimaryKey(idx)
+				return nil
+			}
+			return fmt.Errorf("postgres: table %q for primary key was not found in schema", tv)
+		},
+		addIndex: func(tv string, idx *schema.Index) error {
+			if t, ok := s.Table(tv); ok {
+				t.AddIndexes(idx)
+				return nil
+			}
+			return fmt.Errorf("postgres: table %q for index was not found in schema", tv)
+		},
+		column: func(tv, name string) (*schema.Column, bool) {
+			if t, ok := s.Table(tv); ok {
+				return t.Column(name)
+			}
+			return nil, false
+		},
+	}); err != nil {
 		return err
 	}
 	return rows.Err()
 }
 
+func (i *inspect) indexesQuery() (q string) {
+	switch {
+	case i.supportsIndexNullsDistinct():
+		q = indexesAbove15
+	case i.supportsIndexInclude():
+		q = indexesAbove11
+	default:
+		q = indexesBelow11
+	}
+	return
+}
+
+type queryScope struct {
+	hasT     func(tv string) bool
+	setPK    func(tv string, idx *schema.Index) error
+	addIndex func(tv string, idx *schema.Index) error
+	column   func(tv, name string) (*schema.Column, bool)
+}
+
 // addIndexes scans the rows and adds the indexes to the table.
-func (i *inspect) addIndexes(s *schema.Schema, rows *sql.Rows) error {
+func (i *inspect) addIndexes(s *schema.Schema, rows *sql.Rows, scope queryScope) error {
 	names := make(map[string]*schema.Index)
 	for rows.Next() {
 		var (
@@ -372,8 +398,7 @@ func (i *inspect) addIndexes(s *schema.Schema, rows *sql.Rows) error {
 		); err != nil {
 			return fmt.Errorf("postgres: scanning indexes for schema %q: %w", s.Name, err)
 		}
-		t, ok := s.Table(table)
-		if !ok {
+		if !scope.hasT(table) {
 			return fmt.Errorf("table %q was not found in schema", table)
 		}
 		idx, ok := names[name]
@@ -381,7 +406,6 @@ func (i *inspect) addIndexes(s *schema.Schema, rows *sql.Rows) error {
 			idx = &schema.Index{
 				Name:   name,
 				Unique: uniq,
-				Table:  t,
 				Attrs: []schema.Attr{
 					&IndexType{T: typ},
 				},
@@ -412,10 +436,14 @@ func (i *inspect) addIndexes(s *schema.Schema, rows *sql.Rows) error {
 				idx.AddAttrs(&IndexNullsDistinct{V: false})
 			}
 			names[name] = idx
+			var err error
 			if primary {
-				t.PrimaryKey = idx
+				err = scope.setPK(table, idx)
 			} else {
-				t.Indexes = append(t.Indexes, idx)
+				err = scope.addIndex(table, idx)
+			}
+			if err != nil {
+				return err
 			}
 		}
 		part := &schema.IndexPart{SeqNo: len(idx.Parts) + 1, Desc: desc.Bool}
@@ -427,7 +455,7 @@ func (i *inspect) addIndexes(s *schema.Schema, rows *sql.Rows) error {
 		}
 		switch {
 		case included:
-			c, ok := t.Column(column.String)
+			c, ok := scope.column(table, column.String)
 			if !ok {
 				return fmt.Errorf("postgres: INCLUDE column %q was not found for index %q", column.String, idx.Name)
 			}
@@ -436,7 +464,7 @@ func (i *inspect) addIndexes(s *schema.Schema, rows *sql.Rows) error {
 			include.Columns = append(include.Columns, c)
 			schema.ReplaceOrAppend(&idx.Attrs, &include)
 		case sqlx.ValidString(column):
-			part.C, ok = t.Column(column.String)
+			part.C, ok = scope.column(table, column.String)
 			if !ok {
 				return fmt.Errorf("postgres: column %q was not found for index %q", column.String, idx.Name)
 			}
diff --git a/sql/postgres/migrate.go b/sql/postgres/migrate.go
index 7bad687f..eb3f3189 100644
--- a/sql/postgres/migrate.go
+++ b/sql/postgres/migrate.go
@@ -118,6 +118,9 @@ func (s *state) plan(changes []schema.Change) error {
 		case *schema.RenameView:
 			s.renameView(c)
 		}
+		if err != nil {
+			return err
+		}
 	}
 	for _, c := range dropT {
 		if err := s.dropTable(c); err != nil {
diff --git a/sql/postgres/sqlspec.go b/sql/postgres/sqlspec.go
index a57590f1..2cb7cf20 100644
--- a/sql/postgres/sqlspec.go
+++ b/sql/postgres/sqlspec.go
@@ -23,10 +23,11 @@ import (
 
 type (
 	doc struct {
-		Tables  []*sqlspec.Table  `spec:"table"`
-		Views   []*sqlspec.View   `spec:"view"`
-		Enums   []*Enum           `spec:"enum"`
-		Schemas []*sqlspec.Schema `spec:"schema"`
+		Tables       []*sqlspec.Table  `spec:"table"`
+		Views        []*sqlspec.View   `spec:"view"`
+		Materialized []*sqlspec.View   `spec:"materialized"`
+		Enums        []*Enum           `spec:"enum"`
+		Schemas      []*sqlspec.Schema `spec:"schema"`
 	}
 	// Enum holds a specification for an enum, that can be referenced as a column type.
 	Enum struct {
@@ -38,6 +39,15 @@ type (
 	}
 )
 
+// merge merges the doc d1 into d.
+func (d *doc) merge(d1 *doc) {
+	d.Tables = append(d.Tables, d1.Tables...)
+	d.Views = append(d.Views, d1.Views...)
+	d.Materialized = append(d.Materialized, d1.Materialized...)
+	d.Enums = append(d.Enums, d1.Enums...)
+	d.Schemas = append(d.Schemas, d1.Schemas...)
+}
+
 // Label returns the defaults label used for the enum resource.
 func (e *Enum) Label() string { return e.Name }
 
@@ -63,7 +73,7 @@ func evalSpec(p *hclparse.Parser, v any, input map[string]cty.Value) error {
 			return err
 		}
 		if err := specutil.Scan(v,
-			&specutil.ScanDoc{Schemas: d.Schemas, Tables: d.Tables, Views: d.Views},
+			&specutil.ScanDoc{Schemas: d.Schemas, Tables: d.Tables, Views: d.Views, Materialized: d.Materialized},
 			&specutil.ScanFuncs{Table: convertTable, View: convertView},
 		); err != nil {
 			return fmt.Errorf("specutil: failed converting to *schema.Realm: %w", err)
@@ -83,7 +93,7 @@ func evalSpec(p *hclparse.Parser, v any, input map[string]cty.Value) error {
 		}
 		r := &schema.Realm{}
 		if err := specutil.Scan(r,
-			&specutil.ScanDoc{Schemas: d.Schemas, Tables: d.Tables, Views: d.Views},
+			&specutil.ScanDoc{Schemas: d.Schemas, Tables: d.Tables, Views: d.Views, Materialized: d.Materialized},
 			&specutil.ScanFuncs{Table: convertTable, View: convertView},
 		); err != nil {
 			return err
@@ -105,25 +115,18 @@ func MarshalSpec(v any, marshaler schemahcl.Marshaler) ([]byte, error) {
 	var d doc
 	switch s := v.(type) {
 	case *schema.Schema:
-		var err error
-		doc, err := schemaSpec(s)
+		d1, err := schemaSpec(s)
 		if err != nil {
 			return nil, fmt.Errorf("specutil: failed converting schema to spec: %w", err)
 		}
-		d.Tables = doc.Tables
-		d.Views = doc.Views
-		d.Schemas = doc.Schemas
-		d.Enums = doc.Enums
+		d.merge(d1)
 	case *schema.Realm:
 		for _, s := range s.Schemas {
-			doc, err := schemaSpec(s)
+			d1, err := schemaSpec(s)
 			if err != nil {
 				return nil, fmt.Errorf("specutil: failed converting schema to spec: %w", err)
 			}
-			d.Tables = append(d.Tables, doc.Tables...)
-			d.Views = append(d.Views, doc.Views...)
-			d.Schemas = append(d.Schemas, doc.Schemas...)
-			d.Enums = append(d.Enums, doc.Enums...)
+			d.merge(d1)
 		}
 		if err := specutil.QualifyObjects(d.Tables); err != nil {
 			return nil, err
@@ -131,6 +134,9 @@ func MarshalSpec(v any, marshaler schemahcl.Marshaler) ([]byte, error) {
 		if err := specutil.QualifyObjects(d.Views); err != nil {
 			return nil, err
 		}
+		if err := specutil.QualifyObjects(d.Materialized); err != nil {
+			return nil, err
+		}
 		if err := specutil.QualifyObjects(d.Enums); err != nil {
 			return nil, err
 		}
@@ -147,6 +153,7 @@ var (
 	hclState = schemahcl.New(append(specOptions,
 		schemahcl.WithTypes("table.column.type", TypeRegistry.Specs()),
 		schemahcl.WithTypes("view.column.type", TypeRegistry.Specs()),
+		schemahcl.WithTypes("materialized.column.type", TypeRegistry.Specs()),
 		schemahcl.WithScopedEnums("view.check_option", schema.ViewCheckOptionLocal, schema.ViewCheckOptionCascaded),
 		schemahcl.WithScopedEnums("table.index.type", IndexTypeBTree, IndexTypeBRIN, IndexTypeHash, IndexTypeGIN, IndexTypeGiST, "GiST", IndexTypeSPGiST, "SPGiST"),
 		schemahcl.WithScopedEnums("table.partition.type", PartitionTypeRange, PartitionTypeList, PartitionTypeHash),
@@ -189,9 +196,20 @@ func convertTable(spec *sqlspec.Table, parent *schema.Schema) (*schema.Table, er
 
 // convertView converts a sqlspec.View to a schema.View.
 func convertView(spec *sqlspec.View, parent *schema.Schema) (*schema.View, error) {
-	v, err := specutil.View(spec, parent, func(c *sqlspec.Column, _ *schema.View) (*schema.Column, error) {
-		return specutil.Column(c, convertColumnType)
-	})
+	v, err := specutil.View(
+		spec, parent,
+		func(c *sqlspec.Column, _ *schema.View) (*schema.Column, error) {
+			return specutil.Column(c, convertColumnType)
+		},
+		func(i *sqlspec.Index, v *schema.View) (*schema.Index, error) {
+			idx, err := convertIndex(i, v.AsTable())
+			if err != nil {
+				return nil, err
+			}
+			idx.Table, idx.View = nil, v
+			return idx, nil
+		},
+	)
 	if err != nil {
 		return nil, err
 	}
@@ -560,10 +578,11 @@ func schemaSpec(s *schema.Schema) (*doc, error) {
 		return nil, err
 	}
 	d := &doc{
-		Tables:  spec.Tables,
-		Views:   spec.Views,
-		Schemas: []*sqlspec.Schema{spec.Schema},
-		Enums:   make([]*Enum, 0, len(s.Objects)),
+		Tables:       spec.Tables,
+		Views:        spec.Views,
+		Materialized: spec.Materialized,
+		Schemas:      []*sqlspec.Schema{spec.Schema},
+		Enums:        make([]*Enum, 0, len(s.Objects)),
 	}
 	for _, o := range s.Objects {
 		if e, ok := o.(*schema.EnumType); ok {
@@ -598,9 +617,13 @@ func tableSpec(table *schema.Table) (*sqlspec.Table, error) {
 
 // viewSpec converts from a concrete PostgreSQL schema.View to a sqlspec.View.
 func viewSpec(view *schema.View) (*sqlspec.View, error) {
-	spec, err := specutil.FromView(view, func(c *schema.Column, _ *schema.View) (*sqlspec.Column, error) {
-		return specutil.FromColumn(c, columnTypeSpec)
-	})
+	spec, err := specutil.FromView(
+		view,
+		func(c *schema.Column, _ *schema.View) (*sqlspec.Column, error) {
+			return specutil.FromColumn(c, columnTypeSpec)
+		},
+		indexSpec,
+	)
 	if err != nil {
 		return nil, err
 	}
diff --git a/sql/schema/dsl.go b/sql/schema/dsl.go
index 5178f900..b975866d 100644
--- a/sql/schema/dsl.go
+++ b/sql/schema/dsl.go
@@ -231,6 +231,12 @@ func NewView(name, def string) *View {
 	return &View{Name: name, Def: def}
 }
 
+// NewMaterializedView creates a new materialized View.
+func NewMaterializedView(name, def string) *View {
+	return NewView(name, def).
+		SetMaterialized(true)
+}
+
 // SetSchema sets the schema (named-database) of the view.
 func (v *View) SetSchema(s *Schema) *View {
 	v.Schema = s
@@ -262,6 +268,15 @@ func (v *View) AddDeps(objs ...Object) *View {
 	return v
 }
 
+// AddIndexes appends the given indexes to the table index list.
+func (v *View) AddIndexes(indexes ...*Index) *View {
+	for _, idx := range indexes {
+		idx.View = v
+	}
+	v.Indexes = append(v.Indexes, indexes...)
+	return v
+}
+
 // SetCheckOption sets the check option of the view.
 func (v *View) SetCheckOption(opt string) *View {
 	ReplaceOrAppend(&v.Attrs, &ViewCheckOption{V: opt})
diff --git a/sql/schema/migrate.go b/sql/schema/migrate.go
index d0bf21e9..cb4605f7 100644
--- a/sql/schema/migrate.go
+++ b/sql/schema/migrate.go
@@ -102,6 +102,9 @@ type (
 	// ModifyView describes a view modification change.
 	ModifyView struct {
 		From, To *View
+		// Changes that are extra to the view definition.
+		// For example, adding or dropping indexes.
+		Changes []Change
 	}
 
 	// RenameView describes a view rename change.
diff --git a/sql/schema/schema.go b/sql/schema/schema.go
index 0ca1cfaf..c8563e98 100644
--- a/sql/schema/schema.go
+++ b/sql/schema/schema.go
@@ -48,6 +48,7 @@ type (
 		Columns []*Column
 		Attrs   []Attr   // Attrs and options.
 		Deps    []Object // Tables and views used in view definition.
+		Indexes []*Index // Indexes on materialized view.
 	}
 
 	// A Column represents a column definition.
@@ -73,9 +74,11 @@ type (
 	Index struct {
 		Name   string
 		Unique bool
-		Table  *Table
-		Attrs  []Attr
-		Parts  []*IndexPart
+		// Table or View that this index belongs to.
+		Table *Table
+		View  *View
+		Attrs []Attr
+		Parts []*IndexPart
 	}
 
 	// An IndexPart represents an index part that
@@ -126,9 +129,19 @@ func (s *Schema) Table(name string) (*Table, bool) {
 
 // View returns the first view that matched the given name.
 func (s *Schema) View(name string) (*View, bool) {
-	for _, t := range s.Views {
-		if t.Name == name {
-			return t, true
+	for _, v := range s.Views {
+		if v.Name == name && !v.Materialized() {
+			return v, true
+		}
+	}
+	return nil, false
+}
+
+// Materialized returns the first materialized view that matched the given name.
+func (s *Schema) Materialized(name string) (*View, bool) {
+	for _, v := range s.Views {
+		if v.Name == name && v.Materialized() {
+			return v, true
 		}
 	}
 	return nil, false
@@ -174,6 +187,26 @@ func (t *Table) ForeignKey(symbol string) (*ForeignKey, bool) {
 	return nil, false
 }
 
+// Materialized reports if the view is materialized.
+func (v *View) Materialized() bool {
+	for _, a := range v.Attrs {
+		if _, ok := a.(*Materialized); ok {
+			return true
+		}
+	}
+	return false
+}
+
+// SetMaterialized reports if the view is materialized.
+func (v *View) SetMaterialized(b bool) *View {
+	if b {
+		ReplaceOrAppend(&v.Attrs, &Materialized{})
+	} else {
+		v.Attrs = RemoveAttr[*Materialized](v.Attrs)
+	}
+	return v
+}
+
 // Column returns the first column that matched the given name.
 func (v *View) Column(name string) (*Column, bool) {
 	for _, c := range v.Columns {
@@ -184,6 +217,23 @@ func (v *View) Column(name string) (*Column, bool) {
 	return nil, false
 }
 
+// Index returns the first index that matched the given name.
+func (v *View) Index(name string) (*Index, bool) {
+	for _, i := range v.Indexes {
+		if i.Name == name {
+			return i, true
+		}
+	}
+	return nil, false
+}
+
+// AsTable returns a table that represents the view.
+func (v *View) AsTable() *Table {
+	return NewTable(v.Name).
+		SetSchema(v.Schema).
+		AddColumns(v.Columns...)
+}
+
 // Column returns the first column that matches the given name.
 func (f *ForeignKey) Column(name string) (*Column, bool) {
 	for _, c := range f.Columns {
@@ -377,6 +427,12 @@ type (
 	ViewCheckOption struct {
 		V string // LOCAL, CASCADED, NONE, or driver specific.
 	}
+
+	// Materialized is a schema attribute that attached to views to indicates
+	// they are MATERIALIZED VIEWs.
+	Materialized struct {
+		Attr
+	}
 )
 
 // A list of known view check options.
diff --git a/sql/sqlclient/client.go b/sql/sqlclient/client.go
index 8f677ceb..a8c53bfa 100644
--- a/sql/sqlclient/client.go
+++ b/sql/sqlclient/client.go
@@ -167,6 +167,19 @@ func (f URLParserFunc) ParseURL(u *url.URL) *URL {
 	return f(u)
 }
 
+// ParseURL is similar to url.Parse but returns errors without
+// the raw URL attached to avoid printing userinfo in errors.
+func ParseURL(s string) (*url.URL, error) {
+	u, err := url.Parse(s)
+	if err != nil {
+		if err1, ok := err.(*url.Error); ok {
+			err = err1.Err
+		}
+		return nil, err
+	}
+	return u, nil
+}
+
 var drivers sync.Map
 
 type (
@@ -184,7 +197,7 @@ var ErrUnsupported = errors.New("sql/sqlclient: driver does not support changing
 
 // Open opens an Atlas client by its provided url string.
 func Open(ctx context.Context, s string, opts ...OpenOption) (*Client, error) {
-	u, err := url.Parse(s)
+	u, err := ParseURL(s)
 	if err != nil {
 		return nil, fmt.Errorf("sql/sqlclient: parse open url: %w", err)
 	}
diff --git a/sql/sqlite/sqlspec.go b/sql/sqlite/sqlspec.go
index c341964b..10df474c 100644
--- a/sql/sqlite/sqlspec.go
+++ b/sql/sqlite/sqlspec.go
@@ -99,9 +99,15 @@ func convertTable(spec *sqlspec.Table, parent *schema.Schema) (*schema.Table, er
 
 // convertView converts a sqlspec.View to a schema.View.
 func convertView(spec *sqlspec.View, parent *schema.Schema) (*schema.View, error) {
-	v, err := specutil.View(spec, parent, func(c *sqlspec.Column, _ *schema.View) (*schema.Column, error) {
-		return specutil.Column(c, convertColumnType)
-	})
+	v, err := specutil.View(
+		spec, parent,
+		func(c *sqlspec.Column, _ *schema.View) (*schema.Column, error) {
+			return specutil.Column(c, convertColumnType)
+		},
+		func(i *sqlspec.Index, v *schema.View) (*schema.Index, error) {
+			return nil, fmt.Errorf("unexpected view index %s.%s", v.Name, i.Name)
+		},
+	)
 	if err != nil {
 		return nil, err
 	}
@@ -183,9 +189,13 @@ func tableSpec(t *schema.Table) (*sqlspec.Table, error) {
 
 // viewSpec converts from a concrete SQLite schema.View to a sqlspec.View.
 func viewSpec(view *schema.View) (*sqlspec.View, error) {
-	spec, err := specutil.FromView(view, func(c *schema.Column, _ *schema.View) (*sqlspec.Column, error) {
-		return specutil.FromColumn(c, columnTypeSpec)
-	})
+	spec, err := specutil.FromView(
+		view,
+		func(c *schema.Column, _ *schema.View) (*sqlspec.Column, error) {
+			return specutil.FromColumn(c, columnTypeSpec)
+		},
+		indexSpec,
+	)
 	if err != nil {
 		return nil, err
 	}
diff --git a/sql/sqlspec/sqlspec.go b/sql/sqlspec/sqlspec.go
index 16ecc4a9..e163d279 100644
--- a/sql/sqlspec/sqlspec.go
+++ b/sql/sqlspec/sqlspec.go
@@ -36,6 +36,9 @@ type (
 		Qualifier string         `spec:",qualifier"`
 		Schema    *schemahcl.Ref `spec:"schema"`
 		Columns   []*Column      `spec:"column"`
+		// Indexes on (materialized) views are supported
+		// by some databases, like PostgreSQL.
+		Indexes []*Index `spec:"index"`
 		// The definition is appended as additional attribute
 		// by the spec creator to marshal it after the columns.
 		schemahcl.DefaultExtension
@@ -120,6 +123,7 @@ func (v *View) SchemaRef() *schemahcl.Ref { return v.Schema }
 
 func init() {
 	schemahcl.Register("view", &View{})
+	schemahcl.Register("materialized", &View{})
 	schemahcl.Register("table", &Table{})
 	schemahcl.Register("schema", &Schema{})
 }


Test patch:
diff --git a/cmd/atlas/internal/cloudapi/client_test.go b/cmd/atlas/internal/cloudapi/client_test.go
index 3bad2314..ff219d36 100644
--- a/cmd/atlas/internal/cloudapi/client_test.go
+++ b/cmd/atlas/internal/cloudapi/client_test.go
@@ -139,3 +139,12 @@ func TestClient_ReportMigrationSet(t *testing.T) {
 	require.NoError(t, err)
 	require.NotEmpty(t, link)
 }
+
+func TestRedactedURL(t *testing.T) {
+	u, err := RedactedURL("mysql://user:pass@:3306/db")
+	require.NoError(t, err)
+	require.Equal(t, "mysql://user:xxxxx@:3306/db", u)
+	u, err = RedactedURL("\\n mysql://user:pass@:3306/db")
+	require.EqualError(t, err, `first path segment in URL cannot contain colon`)
+	require.Empty(t, u)
+}
diff --git a/cmd/atlas/internal/cmdapi/migrate_test.go b/cmd/atlas/internal/cmdapi/migrate_test.go
index 31d9af86..b6ade08d 100644
--- a/cmd/atlas/internal/cmdapi/migrate_test.go
+++ b/cmd/atlas/internal/cmdapi/migrate_test.go
@@ -973,6 +973,21 @@ env {
 		require.Equal(t, "Error: openerror", *reports.Error)
 	})
 
+	t.Run("RedactedURL", func(t *testing.T) {
+		cmd := migrateCmd()
+		cmd.AddCommand(migrateApplyCmd())
+		_, err := runCmd(
+			cmd, "apply",
+			"-c", "file://"+path,
+			"--env", "local",
+			"--url", "    sqlite://my:pass@file.db",
+			"--var", "cloud_url="+srv.URL,
+		)
+		// Open errors are reported without URLs.
+		require.EqualError(t, err, `sql/sqlclient: parse open url: first path segment in URL cannot contain colon`)
+		require.Equal(t, `Error: sql/sqlclient: parse open url: first path segment in URL cannot contain colon`, *reports.Error)
+	})
+
 	t.Run("LocalDirectory", func(t *testing.T) {
 		report.DirName = "NotReported"
 		cmd := migrateCmd()
diff --git a/cmd/atlas/internal/cmdapi/schema_test.go b/cmd/atlas/internal/cmdapi/schema_test.go
index 83d06b67..07d3cdd5 100644
--- a/cmd/atlas/internal/cmdapi/schema_test.go
+++ b/cmd/atlas/internal/cmdapi/schema_test.go
@@ -366,6 +366,44 @@ func TestSchema_Apply(t *testing.T) {
 	)
 }
 
+func TestSchema_ApplyTemplateDir(t *testing.T) {
+	p := t.TempDir()
+	cfg := filepath.Join(p, "atlas.hcl")
+	src := filepath.Join(p, "schema.sql")
+	err := os.WriteFile(cfg, []byte(`
+variable "path" {
+  type        = string
+  description = "A path to the template directory"
+}
+
+data "template_dir" "schema" {
+  path = var.path
+  vars = {
+    table = "table_name"
+  }
+}
+
+env "dev" {
+  src = data.template_dir.schema.url
+  dev = "sqlite://dev?mode=memory"
+}`), 0600)
+	require.NoError(t, err)
+	require.NoError(t, os.WriteFile(src, []byte(`create table {{ .table }} (c text);`), 0600))
+
+	cmd := schemaCmd()
+	cmd.AddCommand(schemaApplyCmd())
+	s, err := runCmd(
+		cmd, "apply",
+		"--url", openSQLite(t, ""),
+		"-c", "file://"+cfg,
+		"--var", "path="+p,
+		"--env", "dev",
+		"--auto-approve",
+	)
+	require.NoError(t, err)
+	require.Equal(t, "-- Planned Changes:\n-- Create \"table_name\" table\nCREATE TABLE `table_name` (`c` text NULL);\n", s)
+}
+
 func TestSchema_ApplyMultiEnv(t *testing.T) {
 	p := t.TempDir()
 	cfg := filepath.Join(p, "atlas.hcl")
diff --git a/cmd/atlas/internal/cmdext/cmdext_test.go b/cmd/atlas/internal/cmdext/cmdext_test.go
index a4bbdf06..82b96aa0 100644
--- a/cmd/atlas/internal/cmdext/cmdext_test.go
+++ b/cmd/atlas/internal/cmdext/cmdext_test.go
@@ -281,11 +281,10 @@ func TestTemplateDir(t *testing.T) {
 			Dir string `spec:"dir"`
 		}
 		dir   = t.TempDir()
+		ctx   = context.Background()
 		state = schemahcl.New(cmdext.DataSources...)
-	)
-	err := os.WriteFile(filepath.Join(dir, "1.sql"), []byte("create table {{ .Schema }}.t(c int);"), 0644)
-	require.NoError(t, err)
-	err = state.EvalBytes([]byte(`
+		// language=hcl
+		cfg = `
 variable "path" {
   type = string
 }
@@ -293,12 +292,16 @@ variable "path" {
 data "template_dir" "tenant" {
   path = var.path
   vars = {
-    Schema = "a8m"
+    Schema = "main"
   }
 }
 
 dir = data.template_dir.tenant.url
-`), &v, map[string]cty.Value{
+`
+	)
+	err := os.WriteFile(filepath.Join(dir, "1.sql"), []byte("create table {{ .Schema }}.t(c int);"), 0644)
+	require.NoError(t, err)
+	err = state.EvalBytes([]byte(cfg), &v, map[string]cty.Value{
 		"path": cty.StringVal(dir),
 	})
 	require.NoError(t, err)
@@ -309,7 +312,30 @@ dir = data.template_dir.tenant.url
 	require.NoError(t, err)
 	require.Len(t, files, 1)
 	require.Equal(t, "1.sql", files[0].Name())
-	require.Equal(t, "create table a8m.t(c int);", string(files[0].Bytes()))
+	require.Equal(t, "create table main.t(c int);", string(files[0].Bytes()))
+
+	// Directory was loaded to memory as source for readers.
+	mem, ok := cmdext.States.Loader("mem")
+	require.True(t, ok)
+	u, err := url.Parse(v.Dir)
+	require.NoError(t, err)
+	dev, err := sqlclient.Open(ctx, "sqlite://test?mode=memory")
+	require.NoError(t, err)
+	sr, err := mem.LoadState(ctx, &cmdext.StateReaderConfig{
+		URLs: []*url.URL{u},
+		Dev:  dev,
+	})
+	require.NoError(t, err)
+	r, err := sr.ReadState(ctx)
+	require.NoError(t, err)
+	require.Len(t, r.Schemas[0].Tables, 1)
+
+	// Should not accept non-directories.
+	err = state.EvalBytes([]byte(cfg), &v, map[string]cty.Value{
+		"path": cty.StringVal(filepath.Join(dir, "1.sql")),
+	})
+	require.ErrorContains(t, err, "data.template_dir.tenant: path", "error prefix")
+	require.ErrorContains(t, err, "1.sql is not a directory", "error suffix")
 }
 
 func TestSchemaHCL(t *testing.T) {
diff --git a/sql/postgres/sqlspec_test.go b/sql/postgres/sqlspec_test.go
index d876eb5c..6bffdbfd 100644
--- a/sql/postgres/sqlspec_test.go
+++ b/sql/postgres/sqlspec_test.go
@@ -321,6 +321,7 @@ func TestMarshalViews(t *testing.T) {
 					schema.NewIntColumn("id", "id"),
 				).
 				SetComment("view comment"),
+			schema.NewMaterializedView("m1", "SELECT * FROM t1"),
 		)
 	s.AddViews(
 		schema.NewView("v4", "SELECT * FROM v2 JOIN t1 USING (id)").
@@ -328,6 +329,12 @@ func TestMarshalViews(t *testing.T) {
 				s.Views[1],
 				s.Tables[0],
 			),
+		schema.NewMaterializedView("m2", "SELECT * FROM t1").
+			AddDeps(
+				s.Views[1],
+				s.Views[3],
+				s.Tables[0],
+			),
 	)
 	buf, err := MarshalHCL(s)
 	require.NoError(t, err)
@@ -368,6 +375,15 @@ view "v4" {
   as         = "SELECT * FROM v2 JOIN t1 USING (id)"
   depends_on = [view.v2, table.t1]
 }
+materialized "m1" {
+  schema = schema.public
+  as     = "SELECT * FROM t1"
+}
+materialized "m2" {
+  schema     = schema.public
+  as         = "SELECT * FROM t1"
+  depends_on = [view.v2, materialized.m1, table.t1]
+}
 schema "public" {
 }
 `
@@ -386,6 +402,15 @@ view "v1" {
   schema = schema.public
   as     = "SELECT * FROM t2 WHERE id IS NOT NULL"
 }
+materialized "m1" {
+  schema = schema.public
+  as     = "SELECT * FROM t2 WHERE id IS NOT NULL"
+}
+materialized "m2" {
+  schema     = schema.public
+  as         = "SELECT * FROM multi"
+  depends_on = [view.v1, materialized.m1, table.t1]
+}
 view "v2" {
  schema = schema.public
  column "id" {
@@ -467,6 +492,12 @@ schema "other" {}
 			AddDeps(public.Views[0], public.Tables[0]),
 		schema.NewView("v4", "SELECT * FROM public.t2").
 			AddDeps(public.Tables[1]),
+		schema.NewMaterializedView("m1", "SELECT * FROM t2 WHERE id IS NOT NULL"),
+	)
+	m1, _ := public.Materialized("m1")
+	public.AddViews(
+		schema.NewMaterializedView("m2", "SELECT * FROM multi").
+			AddDeps(public.Views[0], m1, public.Tables[0]),
 	)
 	other.AddViews(
 		schema.NewView("v4", "SELECT * FROM other.t2").
@@ -2125,12 +2156,20 @@ func TestMarshalQualifiers(t *testing.T) {
 		s2 = schema.New("s2").
 			AddTables(
 				schema.NewTable("t1").AddColumns(schema.NewIntColumn("id", "int")),
+			).
+			AddViews(
+				schema.NewMaterializedView("m1", "SELECT id FROM s2.t1"),
 			)
 		s3 = schema.New("s3").
 			AddTables(
 				schema.NewTable("s1").AddColumns(schema.NewIntColumn("id", "int")),
+			).
+			AddViews(
+				schema.NewMaterializedView("m1", "SELECT id FROM s3.t1"),
 			)
 	)
+	s2.Views[0].AddDeps(s2.Tables[0])
+	s3.Views[0].AddDeps(s3.Tables[0])
 	buf, err := MarshalHCL.MarshalSpec(schema.NewRealm(s1, s2, s3))
 	require.NoError(t, err)
 	require.Equal(t, `table "s1" "t1" {
@@ -2154,6 +2193,16 @@ table "s3" "s1" {
     type = int
   }
 }
+materialized "s2" "m1" {
+  schema     = schema.s2
+  as         = "SELECT id FROM s2.t1"
+  depends_on = [table.s2.t1]
+}
+materialized "s3" "m1" {
+  schema     = schema.s3
+  as         = "SELECT id FROM s3.t1"
+  depends_on = [table.s1]
+}
 schema "s1" {
 }
 schema "s2" {
diff --git a/sql/sqlclient/client_test.go b/sql/sqlclient/client_test.go
index e5409654..c783532c 100644
--- a/sql/sqlclient/client_test.go
+++ b/sql/sqlclient/client_test.go
@@ -67,6 +67,21 @@ func TestOpen_Errors(t *testing.T) {
 	c, err = sqlclient.Open(context.Background(), "unknown://")
 	require.EqualError(t, err, `sql/sqlclient: unknown driver "unknown". See: https://atlasgo.io/url`)
 	require.Nil(t, c)
+
+	// URLs are not attached to errors.
+	_, err = sqlclient.Open(context.Background(), " postgres://user:pass:3306/")
+	require.EqualError(t, err, "sql/sqlclient: parse open url: first path segment in URL cannot contain colon")
+	_, err = sqlclient.Open(context.Background(), "scheme://hello world")
+	require.EqualError(t, err, `sql/sqlclient: parse open url: invalid character " " in host name`)
+}
+
+func TestParseURL(t *testing.T) {
+	_, err := sqlclient.ParseURL("boring ://")
+	require.EqualError(t, err, "first path segment in URL cannot contain colon")
+	_, err = sqlclient.ParseURL("\bboring://foo.com:3000")
+	require.EqualError(t, err, "net/url: invalid control character in URL")
+	_, err = sqlclient.ParseURL("boring:// : @foo.com:3000")
+	require.EqualError(t, err, "net/url: invalid userinfo")
 }
 
 func TestClient_AddClosers(t *testing.T) {