diff --git a/.all-contributorsrc b/.all-contributorsrc index c77780ee7d..2bfc1055b4 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -753,6 +753,24 @@ "contributions": [ "code" ] + }, + { + "login": "imhuytq", + "name": "Huy TQ", + "avatar_url": "https://avatars.githubusercontent.com/u/5723282?v=4", + "profile": "https://huytq.com", + "contributions": [ + "code" + ] + }, + { + "login": "maorlipchuk", + "name": "maorlipchuk", + "avatar_url": "https://avatars.githubusercontent.com/u/7034637?v=4", + "profile": "https://github.com/maorlipchuk", + "contributions": [ + "code" + ] } ], "contributorsPerLine": 7, diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index e519b22764..01c089a62b 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -13,8 +13,8 @@ jobs: name: docs runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2.4.0 - - uses: actions/setup-node@v2.5.1 + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 with: node-version: 14 - name: Install Dependencies diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b3444c48b4..0a22b20a2b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2.4.0 + - uses: actions/checkout@v3 - name: Run linters uses: golangci/golangci-lint-action@v2.5.2 with: @@ -25,7 +25,7 @@ jobs: matrix: go: ['1.17', '1.16'] steps: - - uses: actions/checkout@v2.4.0 + - uses: actions/checkout@v3 - uses: actions/setup-go@v2 with: go-version: ${{ matrix.go }} @@ -54,7 +54,7 @@ jobs: generate: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2.4.0 + - uses: actions/checkout@v3 - uses: actions/setup-go@v2 with: go-version: '1.17' @@ -226,7 +226,7 @@ jobs: --health-timeout 5s --health-retries 5 steps: - - uses: actions/checkout@v2.4.0 + - uses: actions/checkout@v3 - uses: actions/setup-go@v2 with: go-version: '1.17' @@ -392,7 +392,7 @@ jobs: --health-timeout 5s --health-retries 5 steps: - - uses: actions/checkout@v2.4.0 + - uses: actions/checkout@v3 with: fetch-depth: 0 - uses: actions/setup-go@v2 diff --git a/dialect/sql/schema/atlas.go b/dialect/sql/schema/atlas.go index 24a45dfe8b..fdff9a8e48 100644 --- a/dialect/sql/schema/atlas.go +++ b/dialect/sql/schema/atlas.go @@ -291,18 +291,18 @@ func (m *Migrate) setupAtlas() error { if m.withFixture { return errors.New("sql/schema: WithFixture(true) does not work in Atlas migration") } - k := DropIndex | DropColumn + skip := DropIndex | DropColumn if m.atlas.skip != NoChange { - k = m.atlas.skip + skip = m.atlas.skip } if m.dropIndexes { - k |= ^DropIndex + skip &= ^DropIndex } if m.dropColumns { - k |= ^DropColumn + skip &= ^DropColumn } - if k == NoChange { - m.atlas.diff = append(m.atlas.diff, filterChanges(k)) + if skip != NoChange { + m.atlas.diff = append(m.atlas.diff, filterChanges(skip)) } if !m.withForeignKeys { m.atlas.diff = append(m.atlas.diff, withoutForeignKeys) @@ -329,7 +329,7 @@ func (m *Migrate) atCreate(ctx context.Context, tables ...*Table) error { return err } } - plan, err := m.atDiff(ctx, tx, tables...) + plan, err := m.atDiff(ctx, tx, "", tables...) if err != nil { return err } @@ -355,7 +355,7 @@ func (m *Migrate) atCreate(ctx context.Context, tables ...*Table) error { return tx.Commit() } -func (m *Migrate) atDiff(ctx context.Context, conn dialect.ExecQuerier, tables ...*Table) (*migrate.Plan, error) { +func (m *Migrate) atDiff(ctx context.Context, conn dialect.ExecQuerier, name string, tables ...*Table) (*migrate.Plan, error) { drv, err := m.atOpen(conn) if err != nil { return nil, err @@ -385,7 +385,7 @@ func (m *Migrate) atDiff(ctx context.Context, conn dialect.ExecQuerier, tables . return nil, err } // Plan changes. - return drv.PlanChanges(ctx, "", changes) + return drv.PlanChanges(ctx, name, changes) } type db struct{ dialect.ExecQuerier } diff --git a/dialect/sql/schema/migrate.go b/dialect/sql/schema/migrate.go index 5be835bbf7..3484ff7991 100644 --- a/dialect/sql/schema/migrate.go +++ b/dialect/sql/schema/migrate.go @@ -165,14 +165,24 @@ func (m *Migrate) Create(ctx context.Context, tables ...*Table) error { // Diff compares the state read from the StateReader with the state defined by Ent. // Changes will be written to migration files by the configures Planner. func (m *Migrate) Diff(ctx context.Context, tables ...*Table) error { + return m.NamedDiff(ctx, "changes", tables...) +} + +// NamedDiff compares the state read from the StateReader with the state defined by Ent. +// Changes will be written to migration files by the configures Planner. +func (m *Migrate) NamedDiff(ctx context.Context, name string, tables ...*Table) error { if m.atlas.dir == nil { return errors.New("no migration directory given") } - plan, err := m.atDiff(ctx, m, tables...) + plan, err := m.atDiff(ctx, m, name, tables...) if err != nil { return err } - return migrate.New(nil, m.atlas.dir, m.atlas.fmt).WritePlan(plan) + // Skip if the plan has no changes. + if len(plan.Changes) == 0 { + return nil + } + return migrate.NewPlanner(nil, m.atlas.dir, migrate.WithFormatter(m.atlas.fmt)).WritePlan(plan) } func (m *Migrate) create(ctx context.Context, tables ...*Table) error { diff --git a/doc/md/contributors.md b/doc/md/contributors.md index a086fcc5ac..72fed9f512 100644 --- a/doc/md/contributors.md +++ b/doc/md/contributors.md @@ -114,6 +114,8 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
Pedro Henrique

💻
MrParano1d

💻
Thomas Prebble

💻 +
Huy TQ

💻 +
maorlipchuk

💻 diff --git a/doc/md/versioned-migrations.md b/doc/md/versioned-migrations.md index a8e44cc715..b8a47e4866 100644 --- a/doc/md/versioned-migrations.md +++ b/doc/md/versioned-migrations.md @@ -10,10 +10,13 @@ tool you like (like golang-migrate, Flyway, liquibase). ![atlas-versioned-migration-process](https://entgo.io/images/assets/migrate-atlas-versioned.png) -## Configuration +## Generating Versioned Migration Files + +### From Client -In order to have Ent make the necessary changes to your code, you have to enable this feature with one of the two -options: +If you want to use an instantiated Ent client to create new migration files, you have to enable the versioned +migrations feature flag in order to have Ent make the necessary changes to the generated code. Depending on how you +execute the Ent code generator, you have to use one of the two options: 1. If you are using the default go generate configuration, simply add the `--feature sql/versioned-migration` to the `ent/generate.go` file as follows: @@ -47,12 +50,8 @@ func main() { } ``` -## Generating Versioned Migration Files - -### From Client - After regenerating the project, there will be an extra `Diff` method on the Ent client that you can use to inspect the -connected database, compare it with the schema definitions and create sql statements needed to migrate the database to +connected database, compare it with the schema definitions and create SQL statements needed to migrate the database to the graph. ```go @@ -82,6 +81,8 @@ func main() { } // Write migration diff. err = client.Schema.Diff(ctx, schema.WithDir(dir)) + // You can use the following method to give the migration files a name. + // err = client.Schema.NamedDiff(ctx, "migration_name", schema.WithDir(dir)) if err != nil { log.Fatalf("failed creating schema resources: %v", err) } @@ -93,7 +94,7 @@ You can then create a new set of migration files by simply calling `go run -mod= ### From Graph You can also generate new migration files without an instantiated Ent client. This can be useful if you want to make the -migration file creation part of a go generate workflow. +migration file creation part of a go generate workflow. Note, that in this case enabling the feature flag is optional. ```go package main @@ -137,6 +138,10 @@ func main() { if err := m.Diff(context.Background(), tbls...); err != nil { log.Fatalln(err) } + // You can use the following method to give the migration files a name. + // if err := m.NamedDiff(context.Background(), "migration_name", tbls...); err != nil { + // log.Fatalln(err) + // } } ``` diff --git a/doc/website/blog/2022-03-14-announcing-versioned-migrations.md b/doc/website/blog/2022-03-14-announcing-versioned-migrations.md new file mode 100644 index 0000000000..98b5bac561 --- /dev/null +++ b/doc/website/blog/2022-03-14-announcing-versioned-migrations.md @@ -0,0 +1,364 @@ +--- +title: Announcing Versioned Migrations Authoring +author: MasseElch +authorURL: "https://github.com/masseelch" +authorImageURL: "https://avatars.githubusercontent.com/u/12862103?v=4" +image: "https://entgo.io/images/assets/migrate/versioned-share.png" +--- + +When [Ariel](https://github.com/a8m) released Ent v0.10.0 at the end of January, +he [introduced](2022-01-20-announcing-new-migration-engine.md) a new migration engine for Ent based on another +open-source project called [Atlas](https://github.com/ariga/atlas). + +Initially, Atlas supported a style of managing database schemas that we call "declarative migrations". With declarative +migrations, the desired state of the database schema is given as input to the migration engine, which plans and executes +a set of actions to change the database to its desired state. This approach has been popularized in the field of +cloud native infrastructure by projects such as Kubernetes and Terraform. It works great in many cases, in +fact it has served the Ent framework very well in the past few years. However, database migrations are a very sensitive +topic, and many projects require a more controlled approach. + +For this reason, most industry standard solutions, like [Flyway](https://flywaydb.org/) +, [Liquibase](https://liquibase.org/), or [golang-migrate/migrate](https://github.com/golang-migrate/migrate) (which is +common in the Go ecosystem), support a workflow that they call "versioned migrations". + +With versioned migrations (sometimes called "change base migrations") instead of describing the desired state ("what the +database should look like"), you describe the changes itself ("how to reach the state"). Most of the time this is done +by creating a set of SQL files containing the statements needed. Each of the files is assigned a unique version and a +description of the changes. Tools like the ones mentioned earlier are then able to interpret the migration files and to +apply (some of) them in the correct order to transition to the desired database structure. + +In this post, I want to showcase a new kind of migration workflow that has recently been added to Atlas and Ent. We call +it "versioned migration authoring" and it's an attempt to combine the simplicity and expressiveness of the declarative +approach with the safety and explicitness of versioned migrations. With versioned migration authoring, users still +declare their desired state and use the Atlas engine to plan a safe migration from the existing to the new state. +However, instead of coupling the planning and execution, it is instead written into a file which can be checked into +source control, fine-tuned manually and reviewed in normal code review processes. + +As an example, I will demonstrate the workflow with `golang-migrate/migrate`. + +### Getting Started + +The very first thing to do, is to make sure you have an up-to-date Ent version: + +```shell +go get -u entgo.io/ent@master +``` + +There are two ways to have Ent generate migration files for schema changes. The first one is to use an instantiated Ent +client and the second one to generate the changes from a parsed schema graph. This post will take the second approach, +if you want to learn how to use the first one you can have a look at +the [documentation](./docs/versioned-migrations#from-client). + +### Generating Versioned Migration Files + +Since we have enabled the versioned migrations feature now, let's create a small schema and generate the initial set of +migration files. Consider the following schema for a fresh Ent project: + +```go title="ent/schema/user.go" +package schema + +import ( + "entgo.io/ent" + "entgo.io/ent/schema/field" + "entgo.io/ent/schema/index" +) + +// User holds the schema definition for the User entity. +type User struct { + ent.Schema +} + +// Fields of the User. +func (User) Fields() []ent.Field { + return []ent.Field{ + field.String("username"), + } +} + +// Indexes of the User. +func (User) Indexes() []ent.Index { + return []ent.Index{ + index.Fields("username").Unique(), + } +} + +``` + +As I stated before, we want to use the parsed schema graph to compute the difference between our schema and the +connected database. Here is an example of a (semi-)persistent MySQL docker container to use if you want to follow along: + +```shell +docker run --rm --name ent-versioned-migrations --detach --env MYSQL_ROOT_PASSWORD=pass --env MYSQL_DATABASE=ent -p 3306:3306 mysql +``` + +Once you are done, you can shut down the container and remove all resources with `docker stop ent-versioned-migrations`. + +Now, let's create a small function that loads the schema graph and generates the migration files. Create a new Go file +named `main.go` and copy the following contents: + +```go title="main.go" +package main + +import ( + "context" + "log" + "os" + + "ariga.io/atlas/sql/migrate" + "entgo.io/ent/dialect/sql" + "entgo.io/ent/dialect/sql/schema" + "entgo.io/ent/entc" + "entgo.io/ent/entc/gen" + _ "github.com/go-sql-driver/mysql" +) + +func main() { + // We need a name for the new migration file. + if len(os.Args) < 2 { + log.Fatalln("no name given") + } + // Create a local migration directory. + dir, err := migrate.NewLocalDir("migrations") + if err != nil { + log.Fatalln(err) + } + // Load the graph. + graph, err := entc.LoadGraph("./ent/schema", &gen.Config{}) + if err != nil { + log.Fatalln(err) + } + tbls, err := graph.Tables() + if err != nil { + log.Fatalln(err) + } + // Open connection to the database. + drv, err := sql.Open("mysql", "root:pass@tcp(localhost:3306)/ent") + if err != nil { + log.Fatalln(err) + } + // Inspect the current database state and compare it with the graph. + m, err := schema.NewMigrate(drv, schema.WithDir(dir)) + if err != nil { + log.Fatalln(err) + } + if err := m.NamedDiff(context.Background(), os.Args[1], tbls...); err != nil { + log.Fatalln(err) + } +} +``` + +All we have to do now is create the migration directory and execute the above Go file: + +```shell +mkdir migrations +go run -mod=mod main.go initial +``` + +You will now see two new files in the `migrations` directory: `_initial.down.sql` +and `_initial.up.sql`. The `x.up.sql` files are used to create the database version `x` and `x.down.sql` to +roll back to the previous version. + +```sql title="_initial.up.sql" +CREATE TABLE `users` (`id` bigint NOT NULL AUTO_INCREMENT, `username` varchar(191) NOT NULL, PRIMARY KEY (`id`), UNIQUE INDEX `user_username` (`username`)) CHARSET utf8mb4 COLLATE utf8mb4_bin; +``` + +```sql title="_initial.down.sql" +DROP TABLE `users`; +``` + +### Applying Migrations + +To apply these migrations on your database, install the `golang-migrate/migrate` tool as described in +their [README](https://github.com/golang-migrate/migrate/blob/master/cmd/migrate/README.md). Then run the following +command to check if everything went as it should. + +```shell +migrate -help +``` +```text +Usage: migrate OPTIONS COMMAND [arg...] + migrate [ -version | -help ] + +Options: + -source Location of the migrations (driver://url) + -path Shorthand for -source=file://path + -database Run migrations against this database (driver://url) + -prefetch N Number of migrations to load in advance before executing (default 10) + -lock-timeout N Allow N seconds to acquire database lock (default 15) + -verbose Print verbose logging + -version Print version + -help Print usage + +Commands: + create [-ext E] [-dir D] [-seq] [-digits N] [-format] NAME + Create a set of timestamped up/down migrations titled NAME, in directory D with extension E. + Use -seq option to generate sequential up/down migrations with N digits. + Use -format option to specify a Go time format string. + goto V Migrate to version V + up [N] Apply all or N up migrations + down [N] Apply all or N down migrations + drop Drop everything inside database + force V Set version V but don't run migration (ignores dirty state) + version Print current migration version +``` + +Now we can execute our initial migration and sync the database with our schema: + +```shell +migrate -source 'file://migrations' -database 'mysql://root:pass@tcp(localhost:3306)/ent' up +``` +```text +/u initial (349.256951ms) +``` + +### Workflow + +To demonstrate the usual workflow when using versioned migrations we will both edit our schema graph and generate the +migration changes for it, and manually create a set of migration files to seed the database with some data. First, we +will add a Group schema and a many-to-many relation to the existing User schema, next create an admin Group with an +admin User in it. Go ahead and make the following changes: + +```go title="ent/schema/user.go" {22-28} +package schema + +import ( + "entgo.io/ent" + "entgo.io/ent/schema/edge" + "entgo.io/ent/schema/field" + "entgo.io/ent/schema/index" +) + +// User holds the schema definition for the User entity. +type User struct { + ent.Schema +} + +// Fields of the User. +func (User) Fields() []ent.Field { + return []ent.Field{ + field.String("username"), + } +} + +// Edges of the User. +func (User) Edges() []ent.Edge { + return []ent.Edge{ + edge.From("groups", Group.Type). + Ref("users"), + } +} + +// Indexes of the User. +func (User) Indexes() []ent.Index { + return []ent.Index{ + index.Fields("username").Unique(), + } +} +``` + +```go title="ent/schema/group.go" +package schema + +import ( + "entgo.io/ent" + "entgo.io/ent/schema/edge" + "entgo.io/ent/schema/field" + "entgo.io/ent/schema/index" +) + +// Group holds the schema definition for the Group entity. +type Group struct { + ent.Schema +} + +// Fields of the Group. +func (Group) Fields() []ent.Field { + return []ent.Field{ + field.String("name"), + } +} + +// Edges of the Group. +func (Group) Edges() []ent.Edge { + return []ent.Edge{ + edge.To("users", User.Type), + } +} + +// Indexes of the Group. +func (Group) Indexes() []ent.Index { + return []ent.Index{ + index.Fields("name").Unique(), + } +} +``` +Once the schema is updated, create a new set of migration files. + +```shell +go run -mod=mod main.go add_group_schema +``` + +Once again there will be two new files in the `migrations` directory: `_add_group_schema.down.sql` +and `_add_group_schema.up.sql`. + +```sql title="_add_group_schema.up.sql" +CREATE TABLE `groups` (`id` bigint NOT NULL AUTO_INCREMENT, `name` varchar(191) NOT NULL, PRIMARY KEY (`id`), UNIQUE INDEX `group_name` (`name`)) CHARSET utf8mb4 COLLATE utf8mb4_bin; +CREATE TABLE `group_users` (`group_id` bigint NOT NULL, `user_id` bigint NOT NULL, PRIMARY KEY (`group_id`, `user_id`), CONSTRAINT `group_users_group_id` FOREIGN KEY (`group_id`) REFERENCES `groups` (`id`) ON DELETE CASCADE, CONSTRAINT `group_users_user_id` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE) CHARSET utf8mb4 COLLATE utf8mb4_bin; +``` + +```sql title="_add_group_schema.down.sql" +DROP TABLE `group_users`; +DROP TABLE `groups`; +``` + +Now you can either edit the generated files to add the seed data or create new files for it. I chose the latter: + +```shell +migrate create -format unix -ext sql -dir migrations seed_admin +``` +```text +[...]/ent-versioned-migrations/migrations/_seed_admin.up.sql +[...]/ent-versioned-migrations/migrations/_seed_admin.down.sql +``` + +You can now edit those files and add statements to create an admin Group and User. + +```sql title="migrations/_seed_admin.up.sql" +INSERT INTO `groups` (`id`, `name`) VALUES (1, 'Admins'); +INSERT INTO `users` (`id`, `username`) VALUES (1, 'admin'); +INSERT INTO `group_users` (`group_id`, `user_id`) VALUES (1, 1); +``` + +```sql title="migrations/_seed_admin.down.sql" +DELETE FROM `group_users` where `group_id` = 1 and `user_id` = 1; +DELETE FROM `groups` where id = 1; +DELETE FROM `users` where id = 1; +``` + +Apply the migrations once more, and you are done: + +```shell +migrate -source file://migrations -database 'mysql://root:pass@tcp(localhost:3306)/ent' up +``` + +```text +/u add_group_schema (417.434415ms) +/u seed_admin (674.189872ms) +``` + +### Wrapping Up + +In this post, we demonstrated the general workflow when using Ent Versioned Migrations with `golang-migate/migrate`. We +created a small example schema, generated the migration files for it and learned how to apply them. We now know the +workflow and how to add custom migration files. + +Have questions? Need help with getting started? Feel free to [join our Slack channel](https://entgo.io/docs/slack/). + +:::note For more Ent news and updates: + +- Subscribe to our [Newsletter](https://www.getrevue.co/profile/ent) +- Follow us on [Twitter](https://twitter.com/entgo_io) +- Join us on #ent on the [Gophers Slack](https://entgo.io/docs/slack) +- Join us on the [Ent Discord Server](https://discord.gg/qZmPgTE6RX) + +::: diff --git a/entc/gen/graph.go b/entc/gen/graph.go index 8293851102..e20e683a72 100644 --- a/entc/gen/graph.go +++ b/entc/gen/graph.go @@ -310,7 +310,7 @@ func (g *Graph) addEdges(schema *load.Schema) { ref := e.Ref expect(e.RefName == "", "reference name is derived from the assoc name: %s.%s <-> %s.%s", t.Name, ref.Name, t.Name, e.Name) expect(ref.Type == t.Name, "assoc-inverse edge allowed only as o2o relation of the same type") - t.Edges = append(t.Edges, &Edge{ + from := &Edge{ def: e, Type: typ, Name: e.Name, @@ -320,8 +320,10 @@ func (g *Graph) addEdges(schema *load.Schema) { Optional: !e.Required, StructTag: structTag(e.Name, e.Tag), Annotations: e.Annotations, - }, &Edge{ + } + to := &Edge{ def: ref, + Ref: from, Type: typ, Owner: t, Name: ref.Name, @@ -329,7 +331,9 @@ func (g *Graph) addEdges(schema *load.Schema) { Optional: !ref.Required, StructTag: structTag(ref.Name, ref.Tag), Annotations: ref.Annotations, - }) + } + from.Ref = to + t.Edges = append(t.Edges, from, to) default: panic(graphError{"edge must be either an assoc or inverse edge"}) } diff --git a/entc/gen/graph_test.go b/entc/gen/graph_test.go index 2d82f24c8d..61bea6c4a3 100644 --- a/entc/gen/graph_test.go +++ b/entc/gen/graph_test.go @@ -123,7 +123,11 @@ func TestNewGraph(t *testing.T) { require.Equal(graph.Nodes[0], e1.Type) require.Equal("t2_m2m_from", t2.Edges[5].Name) + require.Equal("t2_m2m_to", t2.Edges[5].Inverse) require.Equal("t2_m2m_to", t2.Edges[6].Name) + require.Empty(t2.Edges[6].Inverse) + require.Equal(t2.Edges[6], t2.Edges[5].Ref) + require.Equal(t2.Edges[5], t2.Edges[6].Ref) require.Equal(map[string]string{"Name": "From"}, t2.Edges[5].Annotations["GQL"]) require.Equal(map[string]string{"Name": "To"}, t2.Edges[6].Annotations["GQL"]) } diff --git a/entc/gen/template/dialect/sql/feature/migrate_diff.tmpl b/entc/gen/template/dialect/sql/feature/migrate_diff.tmpl index df879f873b..9fa3a732ea 100644 --- a/entc/gen/template/dialect/sql/feature/migrate_diff.tmpl +++ b/entc/gen/template/dialect/sql/feature/migrate_diff.tmpl @@ -16,4 +16,14 @@ func (s *Schema) Diff(ctx context.Context, opts ...schema.MigrateOption) error { } return migrate.Diff(ctx, Tables...) } + +// NamedDiff creates a named migration file containing the statements to resolve the diff +// between the Ent schema and the connected database. +func (s *Schema) NamedDiff(ctx context.Context, name string, opts ...schema.MigrateOption) error { + migrate, err := schema.NewMigrate(s.drv, opts...) + if err != nil { + return fmt.Errorf("ent/migrate: %w", err) + } + return migrate.NamedDiff(ctx, name, Tables...) +} {{ end }} \ No newline at end of file diff --git a/entc/integration/migrate/entv1/car/car.go b/entc/integration/migrate/entv1/car/car.go index 3e601f7dbd..e2dcfe8cbc 100644 --- a/entc/integration/migrate/entv1/car/car.go +++ b/entc/integration/migrate/entv1/car/car.go @@ -16,9 +16,9 @@ const ( // UserFieldID holds the string denoting the ID field of the User. UserFieldID = "oid" // Table holds the table name of the car in the database. - Table = "cars" + Table = "Car" // OwnerTable is the table that holds the owner relation/edge. - OwnerTable = "cars" + OwnerTable = "Car" // OwnerInverseTable is the table name for the User entity. // It exists in this package in order to avoid circular dependency with the "user" package. OwnerInverseTable = "users" @@ -31,7 +31,7 @@ var Columns = []string{ FieldID, } -// ForeignKeys holds the SQL foreign-keys that are owned by the "cars" +// ForeignKeys holds the SQL foreign-keys that are owned by the "Car" // table and are not defined as standalone fields in the schema. var ForeignKeys = []string{ "user_car", diff --git a/entc/integration/migrate/entv1/migrate/schema.go b/entc/integration/migrate/entv1/migrate/schema.go index aafe9c888b..2bb54ab2e0 100644 --- a/entc/integration/migrate/entv1/migrate/schema.go +++ b/entc/integration/migrate/entv1/migrate/schema.go @@ -13,20 +13,20 @@ import ( ) var ( - // CarsColumns holds the columns for the "cars" table. - CarsColumns = []*schema.Column{ + // CarColumns holds the columns for the "Car" table. + CarColumns = []*schema.Column{ {Name: "id", Type: field.TypeInt, Increment: true}, {Name: "user_car", Type: field.TypeInt, Unique: true, Nullable: true}, } - // CarsTable holds the schema information for the "cars" table. - CarsTable = &schema.Table{ - Name: "cars", - Columns: CarsColumns, - PrimaryKey: []*schema.Column{CarsColumns[0]}, + // CarTable holds the schema information for the "Car" table. + CarTable = &schema.Table{ + Name: "Car", + Columns: CarColumns, + PrimaryKey: []*schema.Column{CarColumns[0]}, ForeignKeys: []*schema.ForeignKey{ { - Symbol: "cars_users_car", - Columns: []*schema.Column{CarsColumns[1]}, + Symbol: "Car_users_car", + Columns: []*schema.Column{CarColumns[1]}, RefColumns: []*schema.Column{UsersColumns[0]}, OnDelete: schema.SetNull, }, @@ -115,7 +115,7 @@ var ( } // Tables holds all the tables in the schema. Tables = []*schema.Table{ - CarsTable, + CarTable, ConversionsTable, CustomTypesTable, UsersTable, @@ -123,7 +123,10 @@ var ( ) func init() { - CarsTable.ForeignKeys[0].RefTable = UsersTable + CarTable.ForeignKeys[0].RefTable = UsersTable + CarTable.Annotation = &entsql.Annotation{ + Table: "Car", + } UsersTable.ForeignKeys[0].RefTable = UsersTable UsersTable.ForeignKeys[1].RefTable = UsersTable } diff --git a/entc/integration/migrate/entv1/schema/user.go b/entc/integration/migrate/entv1/schema/user.go index 1e37776795..3aa4b2bed1 100644 --- a/entc/integration/migrate/entv1/schema/user.go +++ b/entc/integration/migrate/entv1/schema/user.go @@ -7,6 +7,7 @@ package schema import ( "entgo.io/ent" "entgo.io/ent/dialect/entsql" + "entgo.io/ent/schema" "entgo.io/ent/schema/edge" "entgo.io/ent/schema/field" "entgo.io/ent/schema/index" @@ -72,6 +73,13 @@ type Car struct { ent.Schema } +// Annotations of the Car. +func (Car) Annotations() []schema.Annotation { + return []schema.Annotation{ + entsql.Annotation{Table: "Car"}, + } +} + func (Car) Edges() []ent.Edge { return []ent.Edge{ edge.From("owner", User.Type). diff --git a/entc/integration/migrate/entv1/user/user.go b/entc/integration/migrate/entv1/user/user.go index a614853f3a..b515e20dda 100644 --- a/entc/integration/migrate/entv1/user/user.go +++ b/entc/integration/migrate/entv1/user/user.go @@ -60,10 +60,10 @@ const ( // SpouseColumn is the table column denoting the spouse relation/edge. SpouseColumn = "user_spouse" // CarTable is the table that holds the car relation/edge. - CarTable = "cars" + CarTable = "Car" // CarInverseTable is the table name for the Car entity. // It exists in this package in order to avoid circular dependency with the "car" package. - CarInverseTable = "cars" + CarInverseTable = "Car" // CarColumn is the table column denoting the car relation/edge. CarColumn = "user_car" ) diff --git a/entc/integration/migrate/entv2/car/car.go b/entc/integration/migrate/entv2/car/car.go index 3e601f7dbd..e2dcfe8cbc 100644 --- a/entc/integration/migrate/entv2/car/car.go +++ b/entc/integration/migrate/entv2/car/car.go @@ -16,9 +16,9 @@ const ( // UserFieldID holds the string denoting the ID field of the User. UserFieldID = "oid" // Table holds the table name of the car in the database. - Table = "cars" + Table = "Car" // OwnerTable is the table that holds the owner relation/edge. - OwnerTable = "cars" + OwnerTable = "Car" // OwnerInverseTable is the table name for the User entity. // It exists in this package in order to avoid circular dependency with the "user" package. OwnerInverseTable = "users" @@ -31,7 +31,7 @@ var Columns = []string{ FieldID, } -// ForeignKeys holds the SQL foreign-keys that are owned by the "cars" +// ForeignKeys holds the SQL foreign-keys that are owned by the "Car" // table and are not defined as standalone fields in the schema. var ForeignKeys = []string{ "user_car", diff --git a/entc/integration/migrate/entv2/migrate/schema.go b/entc/integration/migrate/entv2/migrate/schema.go index ec95e68751..14086c452c 100644 --- a/entc/integration/migrate/entv2/migrate/schema.go +++ b/entc/integration/migrate/entv2/migrate/schema.go @@ -13,20 +13,20 @@ import ( ) var ( - // CarsColumns holds the columns for the "cars" table. - CarsColumns = []*schema.Column{ + // CarColumns holds the columns for the "Car" table. + CarColumns = []*schema.Column{ {Name: "id", Type: field.TypeInt, Increment: true}, {Name: "user_car", Type: field.TypeInt}, } - // CarsTable holds the schema information for the "cars" table. - CarsTable = &schema.Table{ - Name: "cars", - Columns: CarsColumns, - PrimaryKey: []*schema.Column{CarsColumns[0]}, + // CarTable holds the schema information for the "Car" table. + CarTable = &schema.Table{ + Name: "Car", + Columns: CarColumns, + PrimaryKey: []*schema.Column{CarColumns[0]}, ForeignKeys: []*schema.ForeignKey{ { - Symbol: "cars_users_car", - Columns: []*schema.Column{CarsColumns[1]}, + Symbol: "Car_users_car", + Columns: []*schema.Column{CarColumns[1]}, RefColumns: []*schema.Column{UsersColumns[0]}, OnDelete: schema.NoAction, }, @@ -209,7 +209,7 @@ var ( } // Tables holds all the tables in the schema. Tables = []*schema.Table{ - CarsTable, + CarTable, ConversionsTable, CustomTypesTable, GroupsTable, @@ -221,7 +221,10 @@ var ( ) func init() { - CarsTable.ForeignKeys[0].RefTable = UsersTable + CarTable.ForeignKeys[0].RefTable = UsersTable + CarTable.Annotation = &entsql.Annotation{ + Table: "Car", + } MediaTable.Annotation = &entsql.Annotation{ Check: "text <> 'boring'", } diff --git a/entc/integration/migrate/entv2/schema/user.go b/entc/integration/migrate/entv2/schema/user.go index 4362e432bf..03de9c8cd7 100644 --- a/entc/integration/migrate/entv2/schema/user.go +++ b/entc/integration/migrate/entv2/schema/user.go @@ -7,6 +7,8 @@ package schema import ( "time" + "entgo.io/ent/schema" + "entgo.io/ent" "entgo.io/ent/dialect" "entgo.io/ent/dialect/entsql" @@ -142,6 +144,13 @@ type Car struct { ent.Schema } +// Annotations of the Car. +func (Car) Annotations() []schema.Annotation { + return []schema.Annotation{ + entsql.Annotation{Table: "Car"}, + } +} + func (Car) Edges() []ent.Edge { return []ent.Edge{ edge.From("owner", User.Type). diff --git a/entc/integration/migrate/entv2/user/user.go b/entc/integration/migrate/entv2/user/user.go index dc383dabf0..4fc21fa058 100644 --- a/entc/integration/migrate/entv2/user/user.go +++ b/entc/integration/migrate/entv2/user/user.go @@ -59,10 +59,10 @@ const ( // Table holds the table name of the user in the database. Table = "users" // CarTable is the table that holds the car relation/edge. - CarTable = "cars" + CarTable = "Car" // CarInverseTable is the table name for the Car entity. // It exists in this package in order to avoid circular dependency with the "car" package. - CarInverseTable = "cars" + CarInverseTable = "Car" // CarColumn is the table column denoting the car relation/edge. CarColumn = "user_car" // PetsTable is the table that holds the pets relation/edge. diff --git a/entc/integration/migrate/migrate_test.go b/entc/integration/migrate/migrate_test.go index a365e87cbf..c3e6b8a9fa 100644 --- a/entc/integration/migrate/migrate_test.go +++ b/entc/integration/migrate/migrate_test.go @@ -99,6 +99,7 @@ func TestSQLite(t *testing.T) { ctx, migratev2.WithGlobalUniqueID(true), migratev2.WithDropIndex(true), + migratev2.WithDropColumn(true), schema.WithDiffHook(func(next schema.Differ) schema.Differ { return schema.DiffFunc(func(current, desired *atlas.Schema) ([]atlas.Change, error) { // Example to hook into the diff process. @@ -163,7 +164,7 @@ func V1ToV2(t *testing.T, dialect string, clientv1 *entv1.Client, clientv2 *entv // Run migration and execute queries on v2. require.NoError(t, clientv2.Schema.Create(ctx, migratev2.WithGlobalUniqueID(true), migratev2.WithDropIndex(true), migratev2.WithDropColumn(true), schema.WithAtlas(true))) - require.NoError(t, clientv2.Schema.Create(ctx, migratev2.WithGlobalUniqueID(true), schema.WithAtlas(true)), "should not create additional resources on multiple runs") + require.NoError(t, clientv2.Schema.Create(ctx, migratev2.WithGlobalUniqueID(true), migratev2.WithDropIndex(true), migratev2.WithDropColumn(true), schema.WithAtlas(true)), "should not create additional resources on multiple runs") SanityV2(t, dialect, clientv2) u := clientv2.User.Create().SetAge(1).SetName("foo").SetNickname("nick_foo").SetPhone("phone").SaveX(ctx) diff --git a/entc/integration/migrate/versioned/migrate/migrate.go b/entc/integration/migrate/versioned/migrate/migrate.go index 9641fc2616..0a227e7534 100644 --- a/entc/integration/migrate/versioned/migrate/migrate.go +++ b/entc/integration/migrate/versioned/migrate/migrate.go @@ -66,6 +66,16 @@ func (s *Schema) Diff(ctx context.Context, opts ...schema.MigrateOption) error { return migrate.Diff(ctx, Tables...) } +// NamedDiff creates a named migration file containing the statements to resolve the diff +// between the Ent schema and the connected database. +func (s *Schema) NamedDiff(ctx context.Context, name string, opts ...schema.MigrateOption) error { + migrate, err := schema.NewMigrate(s.drv, opts...) + if err != nil { + return fmt.Errorf("ent/migrate: %w", err) + } + return migrate.NamedDiff(ctx, name, Tables...) +} + // WriteTo writes the schema changes to w instead of running them against the database. // // if err := client.Schema.WriteTo(context.Background(), os.Stdout); err != nil { diff --git a/go.mod b/go.mod index e9250a2093..08aa0c4283 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module entgo.io/ent go 1.17 require ( - ariga.io/atlas v0.3.5-0.20220215131223-8043663b4223 + ariga.io/atlas v0.3.8-0.20220313134928-770640fc02bf github.com/DATA-DOG/go-sqlmock v1.5.0 github.com/go-openapi/inflect v0.19.0 github.com/go-sql-driver/mysql v1.6.0 @@ -16,7 +16,7 @@ require ( github.com/mitchellh/mapstructure v1.4.3 github.com/modern-go/reflect2 v1.0.2 github.com/olekukonko/tablewriter v0.0.5 - github.com/spf13/cobra v1.3.0 + github.com/spf13/cobra v1.4.0 github.com/stretchr/objx v0.2.0 // indirect github.com/stretchr/testify v1.7.1-0.20210427113832-6241f9ab9942 go.opencensus.io v0.23.0 diff --git a/go.sum b/go.sum index 8057272693..04664d0dbf 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ -ariga.io/atlas v0.3.5-0.20220215131223-8043663b4223 h1:kthcdfUZLRcoVeZetK8gJ0FGaw2D5zrr3h2nP/TuXCw= -ariga.io/atlas v0.3.5-0.20220215131223-8043663b4223/go.mod h1:XcLUpQX7Cq4qtagEHIleq3MJaBeeJ76BS8doc4gkOJk= +ariga.io/atlas v0.3.7-0.20220303204946-787354f533c3 h1:fjG4oFCQEfGrRi0QoxWcH2OO28CE6VYa6DkIr3yDySU= +ariga.io/atlas v0.3.7-0.20220303204946-787354f533c3/go.mod h1:yWGf4VPiD4SW83+kAqzD624txN9VKoJC+bpVXr2pKJA= +ariga.io/atlas v0.3.8-0.20220313134928-770640fc02bf h1:bAt5AUvr91QI8yXHME6qTsMTNM4BtfSB3M9o1cmt51E= +ariga.io/atlas v0.3.8-0.20220313134928-770640fc02bf/go.mod h1:ipw7dUlFanAylr9nvs8lCvOUC8hFG6PGd/gtr+uJMvk= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= @@ -356,8 +358,9 @@ github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasO github.com/spf13/afero v1.3.3/go.mod h1:5KUK8ByomD5Ti5Artl0RtHeI5pTF7MIDuXL3yY520V4= github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I= github.com/spf13/cast v1.4.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= -github.com/spf13/cobra v1.3.0 h1:R7cSvGu+Vv+qX0gW5R/85dx2kmmJT5z5NM8ifdYjdn0= github.com/spf13/cobra v1.3.0/go.mod h1:BrRVncBjOJa/eUcVVm9CE+oC6as8k+VYr4NY7WCi9V4= +github.com/spf13/cobra v1.4.0 h1:y+wJpx64xcgO1V+RcnwW0LEHxTKRi2ZDPSBjWnrg88Q= +github.com/spf13/cobra v1.4.0/go.mod h1:Wo4iy3BUC+X2Fybo0PDqwJIv3dNRiZLHQymsfxlB84g= github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= github.com/spf13/pflag v1.0.2/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= diff --git a/schema/field/field.go b/schema/field/field.go index 0c20eda2b3..c895b304e9 100644 --- a/schema/field/field.go +++ b/schema/field/field.go @@ -1165,37 +1165,43 @@ func (d *Descriptor) goType(typ interface{}, expectType reflect.Type) { Methods: make(map[string]struct{ In, Out []*RType }, t.NumMethod()), }, } + methods(t, info.RType) switch t.Kind() { case reflect.Slice, reflect.Ptr, reflect.Map: info.Nillable = true } switch pt := reflect.PtrTo(t); { - case pt.Implements(valueScannerType): - t = pt - fallthrough - case t.Implements(valueScannerType): - n := t.NumMethod() - for i := 0; i < n; i++ { - m := t.Method(i) - in := make([]*RType, m.Type.NumIn()-1) - for j := range in { - arg := m.Type.In(j + 1) - in[j] = &RType{Name: arg.Name(), Ident: arg.String(), Kind: arg.Kind(), PkgPath: arg.PkgPath()} - } - out := make([]*RType, m.Type.NumOut()) - for j := range out { - ret := m.Type.Out(j) - out[j] = &RType{Name: ret.Name(), Ident: ret.String(), Kind: ret.Kind(), PkgPath: ret.PkgPath()} - } - info.RType.Methods[m.Name] = struct{ In, Out []*RType }{in, out} - } - case t.Kind() == expectType.Kind() && t.ConvertibleTo(expectType): + case pt.Implements(valueScannerType), t.Implements(valueScannerType), + t.Kind() == expectType.Kind() && t.ConvertibleTo(expectType): default: d.Err = fmt.Errorf("GoType must be a %q type or ValueScanner", expectType) } d.Info = info } +func methods(t reflect.Type, rtype *RType) { + // For type T, add methods with + // pointer receiver as well (*T). + if t.Kind() != reflect.Ptr { + t = reflect.PtrTo(t) + } + n := t.NumMethod() + for i := 0; i < n; i++ { + m := t.Method(i) + in := make([]*RType, m.Type.NumIn()-1) + for j := range in { + arg := m.Type.In(j + 1) + in[j] = &RType{Name: arg.Name(), Ident: arg.String(), Kind: arg.Kind(), PkgPath: arg.PkgPath()} + } + out := make([]*RType, m.Type.NumOut()) + for j := range out { + ret := m.Type.Out(j) + out[j] = &RType{Name: ret.Name(), Ident: ret.String(), Kind: ret.Kind(), PkgPath: ret.PkgPath()} + } + rtype.Methods[m.Name] = struct{ In, Out []*RType }{in, out} + } +} + func (d *Descriptor) checkDefaultFunc(expectType reflect.Type) { for _, typ := range []reflect.Type{reflect.TypeOf(d.Default), reflect.TypeOf(d.UpdateDefault)} { if typ == nil || typ.Kind() != reflect.Func || d.Err != nil { diff --git a/schema/field/field_test.go b/schema/field/field_test.go index 84740d6fb2..88bd8b0d1a 100644 --- a/schema/field/field_test.go +++ b/schema/field/field_test.go @@ -8,14 +8,18 @@ import ( "database/sql" "database/sql/driver" "errors" + "fmt" + "io" "net" "net/http" "net/url" "reflect" "regexp" + "strconv" "testing" "time" + "entgo.io/ent" "entgo.io/ent/dialect" "entgo.io/ent/schema/field" @@ -680,6 +684,77 @@ func TestField_Other(t *testing.T) { assert.Error(t, fd.Err, "invalid default value") } +type UserRole string + +const ( + Admin UserRole = "ADMIN" + User UserRole = "USER" + Unknown UserRole = "UNKNOWN" +) + +func (UserRole) Values() (roles []string) { + for _, r := range []UserRole{Admin, User, Unknown} { + roles = append(roles, string(r)) + } + return +} + +func (e UserRole) String() string { + return string(e) +} + +// MarshalGQL implements graphql.Marshaler interface. +func (e UserRole) MarshalGQL(w io.Writer) { + _, _ = io.WriteString(w, strconv.Quote(e.String())) +} + +// UnmarshalGQL implements graphql.Unmarshaler interface. +func (e *UserRole) UnmarshalGQL(val interface{}) error { + str, ok := val.(string) + if !ok { + return fmt.Errorf("enum %T must be a string", val) + } + *e = UserRole(str) + switch *e { + case Admin, User, Unknown: + return nil + default: + return fmt.Errorf("%s is not a valid Role", str) + } +} + +type Scalar struct{} + +func (Scalar) MarshalGQL(io.Writer) {} +func (*Scalar) UnmarshalGQL(interface{}) error { return nil } +func (Scalar) Value() (driver.Value, error) { return nil, nil } + +func TestRType_Implements(t *testing.T) { + type ( + marshaler interface{ MarshalGQL(w io.Writer) } + unmarshaler interface{ UnmarshalGQL(v interface{}) error } + codec interface { + marshaler + unmarshaler + } + ) + var ( + codecType = reflect.TypeOf((*codec)(nil)).Elem() + marshalType = reflect.TypeOf((*marshaler)(nil)).Elem() + unmarshalType = reflect.TypeOf((*unmarshaler)(nil)).Elem() + ) + for _, f := range []ent.Field{ + field.Enum("role").GoType(Admin), + field.Other("scalar", &Scalar{}), + field.Other("scalar", Scalar{}), + } { + fd := f.Descriptor() + assert.True(t, fd.Info.RType.Implements(codecType)) + assert.True(t, fd.Info.RType.Implements(marshalType)) + assert.True(t, fd.Info.RType.Implements(unmarshalType)) + } +} + func TestTypeString(t *testing.T) { typ := field.TypeBool assert.Equal(t, "bool", typ.String()) diff --git a/schema/field/type.go b/schema/field/type.go index addbffc699..15e1964b12 100644 --- a/schema/field/type.go +++ b/schema/field/type.go @@ -120,12 +120,12 @@ func (t TypeInfo) ConstName() string { // ValueScanner indicates if this type implements the ValueScanner interface. func (t TypeInfo) ValueScanner() bool { - return t.RType.implements(valueScannerType) + return t.RType.Implements(valueScannerType) } // Valuer indicates if this type implements the driver.Valuer interface. func (t TypeInfo) Valuer() bool { - return t.RType.implements(valuerType) + return t.RType.Implements(valuerType) } // Comparable reports whether values of this type are comparable. @@ -147,7 +147,7 @@ var stringerType = reflect.TypeOf((*fmt.Stringer)(nil)).Elem() // Stringer indicates if this type implements the Stringer interface. func (t TypeInfo) Stringer() bool { - return t.RType.implements(stringerType) + return t.RType.Implements(stringerType) } var ( @@ -215,7 +215,8 @@ func (r *RType) IsPtr() bool { return r != nil && r.Kind == reflect.Ptr } -func (r *RType) implements(typ reflect.Type) bool { +// Implements reports whether the RType ~implements the given interface type. +func (r *RType) Implements(typ reflect.Type) bool { if r == nil { return false }