From 4b3ad0b611835716950f58a59cf25b749c4c561a Mon Sep 17 00:00:00 2001 From: upstream-mirror Date: Tue, 15 Sep 2026 22:12:36 +0500 Subject: [PATCH] snapshot of https://github.com/ent/ent.git (master, 2026-09-15; blobs > 131072B omitted) --- .all-contributorsrc | 855 +++ .devcontainer/devcontainer-lock.json | 19 + .devcontainer/devcontainer.json | 37 + .github/ISSUE_TEMPLATE/1.bug.md | 58 + .github/ISSUE_TEMPLATE/2.feature.md | 27 + .github/ISSUE_TEMPLATE/3.support.md | 5 + .github/ISSUE_TEMPLATE/config.yml | 5 + .github/dependabot.yml | 10 + .github/workflows/atlas-ci-public.yaml | 57 + .github/workflows/ci.yml | 501 ++ .gitignore | 1 + .golangci.yml | 72 + CODE_OF_CONDUCT.md | 77 + CONTRIBUTING.md | 57 + LICENSE | 202 + README.md | 56 + README_jp.md | 54 + README_kr.md | 52 + README_zh.md | 44 + blob.go | 483 ++ cmd/ent/ent.go | 26 + cmd/ent/ent_test.go | 39 + cmd/entc/entc.go | 41 + cmd/entc/entc_test.go | 35 + cmd/entfix/entfix.go | 107 + cmd/entfix/entfix_test.go | 89 + cmd/entfix/go.mod | 32 + cmd/entfix/go.sum | 67 + cmd/internal/base/base.go | 360 ++ cmd/internal/base/packages.go | 55 + cmd/internal/base/packages_test.go | 49 + cmd/internal/printer/printer.go | 141 + cmd/internal/printer/printer_test.go | 177 + dialect/dialect.go | 208 + dialect/entsql/annotation.go | 857 +++ dialect/gremlin/client.go | 77 + dialect/gremlin/client_test.go | 93 + dialect/gremlin/config.go | 82 + dialect/gremlin/config_test.go | 154 + dialect/gremlin/driver.go | 58 + .../gremlin/encoding/graphson/bench_test.go | 99 + .../gremlin/encoding/graphson/common_test.go | 54 + dialect/gremlin/encoding/graphson/decode.go | 109 + .../gremlin/encoding/graphson/decode_test.go | 5 + dialect/gremlin/encoding/graphson/encode.go | 98 + .../gremlin/encoding/graphson/encode_test.go | 16 + dialect/gremlin/encoding/graphson/error.go | 45 + .../gremlin/encoding/graphson/error_test.go | 30 + .../gremlin/encoding/graphson/extension.go | 88 + dialect/gremlin/encoding/graphson/init.go | 16 + .../gremlin/encoding/graphson/interface.go | 153 + .../encoding/graphson/interface_test.go | 352 ++ dialect/gremlin/encoding/graphson/lazy.go | 74 + .../gremlin/encoding/graphson/lazy_test.go | 46 + dialect/gremlin/encoding/graphson/map.go | 116 + dialect/gremlin/encoding/graphson/map_test.go | 355 ++ .../gremlin/encoding/graphson/marshaler.go | 133 + .../encoding/graphson/marshaler_test.go | 101 + dialect/gremlin/encoding/graphson/native.go | 137 + .../gremlin/encoding/graphson/native_test.go | 299 ++ dialect/gremlin/encoding/graphson/raw.go | 35 + dialect/gremlin/encoding/graphson/raw_test.go | 32 + dialect/gremlin/encoding/graphson/slice.go | 142 + .../gremlin/encoding/graphson/slice_test.go | 219 + dialect/gremlin/encoding/graphson/struct.go | 39 + .../gremlin/encoding/graphson/struct_test.go | 205 + dialect/gremlin/encoding/graphson/tags.go | 39 + .../gremlin/encoding/graphson/tags_test.go | 60 + dialect/gremlin/encoding/graphson/time.go | 34 + .../gremlin/encoding/graphson/time_test.go | 36 + dialect/gremlin/encoding/graphson/type.go | 155 + .../gremlin/encoding/graphson/type_test.go | 95 + dialect/gremlin/encoding/graphson/util.go | 114 + .../gremlin/encoding/graphson/util_test.go | 129 + dialect/gremlin/encoding/mime.go | 30 + dialect/gremlin/encoding/mime_test.go | 21 + dialect/gremlin/example_test.go | 46 + dialect/gremlin/expand.go | 47 + dialect/gremlin/expand_test.go | 71 + dialect/gremlin/graph/dsl/__/dsl.go | 75 + dialect/gremlin/graph/dsl/dsl.go | 214 + dialect/gremlin/graph/dsl/dsl_test.go | 238 + dialect/gremlin/graph/dsl/g/g.go | 19 + dialect/gremlin/graph/dsl/p/p.go | 93 + dialect/gremlin/graph/dsl/traversal.go | 426 ++ dialect/gremlin/graph/edge.go | 94 + dialect/gremlin/graph/edge_test.go | 108 + dialect/gremlin/graph/element.go | 16 + dialect/gremlin/graph/valuemap.go | 57 + dialect/gremlin/graph/valuemap_test.go | 74 + dialect/gremlin/graph/vertex.go | 62 + dialect/gremlin/graph/vertex_test.go | 86 + dialect/gremlin/http.go | 85 + dialect/gremlin/http_test.go | 130 + dialect/gremlin/internal/ws/conn.go | 315 ++ dialect/gremlin/internal/ws/conn_test.go | 390 ++ dialect/gremlin/ocgremlin/client.go | 64 + dialect/gremlin/ocgremlin/client_test.go | 74 + dialect/gremlin/ocgremlin/stats.go | 117 + dialect/gremlin/ocgremlin/stats_test.go | 94 + dialect/gremlin/ocgremlin/trace.go | 133 + dialect/gremlin/ocgremlin/trace_test.go | 254 + dialect/gremlin/request.go | 110 + dialect/gremlin/request_test.go | 135 + dialect/gremlin/response.go | 127 + dialect/gremlin/response_test.go | 564 ++ dialect/gremlin/status.go | 69 + dialect/gremlin/status_test.go | 16 + dialect/gremlin/tokens.go | 69 + dialect/sql/bench_test.go | 37 + dialect/sql/builder.go | 3603 +++++++++++++ dialect/sql/builder_test.go | 2407 +++++++++ dialect/sql/driver.go | 305 ++ dialect/sql/driver_test.go | 93 + dialect/sql/scan.go | 425 ++ dialect/sql/scan_test.go | 367 ++ dialect/sql/schema/atlas.go | 1226 +++++ dialect/sql/schema/migrate.go | 160 + dialect/sql/schema/migrate_test.go | 464 ++ dialect/sql/schema/mysql.go | 333 ++ dialect/sql/schema/postgres.go | 260 + dialect/sql/schema/schema.go | 695 +++ dialect/sql/schema/schema_test.go | 390 ++ dialect/sql/schema/sqlite.go | 227 + dialect/sql/schema/writer.go | 365 ++ dialect/sql/schema/writer_test.go | 189 + dialect/sql/sql.go | 428 ++ dialect/sql/sql_test.go | 471 ++ dialect/sql/sqlgraph/blob.go | 155 + dialect/sql/sqlgraph/blob_test.go | 182 + dialect/sql/sqlgraph/entql.go | 334 ++ dialect/sql/sqlgraph/entql_test.go | 171 + dialect/sql/sqlgraph/errors.go | 74 + dialect/sql/sqlgraph/graph.go | 2076 ++++++++ dialect/sql/sqlgraph/graph_test.go | 2755 ++++++++++ dialect/sql/sqljson/dialect.go | 222 + dialect/sql/sqljson/sqljson.go | 727 +++ dialect/sql/sqljson/sqljson_test.go | 535 ++ doc/.gitignore | 12 + doc/md/aggregate.md | 172 + doc/md/blob-storage.md | 386 ++ doc/md/ci.mdx | 293 ++ doc/md/code-gen.md | 296 ++ doc/md/community.md | 17 + doc/md/components/_atlas_migrate_apply.mdx | 48 + doc/md/components/_atlas_migrate_diff.mdx | 52 + .../components/_installation_instructions.mdx | 53 + doc/md/contributors.md | 144 + doc/md/crud.mdx | 637 +++ doc/md/data-migrations.mdx | 316 ++ doc/md/dialects.md | 41 + doc/md/eager-load.mdx | 192 + doc/md/extension.md | 232 + doc/md/faq.md | 849 +++ doc/md/features.md | 436 ++ doc/md/generating-ent-schemas.md | 225 + doc/md/getting-started.mdx | 701 +++ doc/md/globalid.mdx | 170 + doc/md/graphql.md | 373 ++ doc/md/hooks.md | 300 ++ doc/md/interceptors.mdx | 416 ++ doc/md/migrate.md | 446 ++ doc/md/migration/composite.mdx | 241 + doc/md/migration/domain.mdx | 208 + doc/md/migration/enum.mdx | 202 + doc/md/migration/extension.mdx | 223 + doc/md/migration/functional-indexes.mdx | 200 + doc/md/migration/rls.mdx | 227 + doc/md/migration/trigger.mdx | 277 + doc/md/multischema-migrations.mdx | 180 + doc/md/paging.mdx | 269 + doc/md/predicates.md | 349 ++ doc/md/privacy.mdx | 682 +++ doc/md/schema-annotations.md | 137 + doc/md/schema-def.md | 73 + doc/md/schema-edges.mdx | 1687 ++++++ doc/md/schema-fields.mdx | 1038 ++++ doc/md/schema-indexes.md | 300 ++ doc/md/schema-mixin.md | 152 + doc/md/schema-view.mdx | 427 ++ doc/md/sql-integration.md | 152 + doc/md/templates.md | 268 + doc/md/testing.md | 39 + doc/md/transactions.md | 184 + doc/md/translations.md | 33 + doc/md/traversals.md | 216 + doc/md/tutorial-grpc-edges.md | 280 + doc/md/tutorial-grpc-ext-service.md | 161 + doc/md/tutorial-grpc-generating-a-service.md | 101 + doc/md/tutorial-grpc-generating-proto.md | 139 + doc/md/tutorial-grpc-intro.md | 25 + doc/md/tutorial-grpc-optional-fields.md | 93 + doc/md/tutorial-grpc-server-and-client.md | 158 + ...utorial-grpc-service-generation-options.md | 58 + doc/md/tutorial-grpc-setting-up.md | 88 + doc/md/tutorial-setup.md | 130 + doc/md/tutorial-todo-crud.md | 217 + doc/md/tutorial-todo-gql-field-collection.md | 196 + doc/md/tutorial-todo-gql-filter-input.md | 343 ++ doc/md/tutorial-todo-gql-mutation-input.md | 304 ++ doc/md/tutorial-todo-gql-node.md | 133 + doc/md/tutorial-todo-gql-paginate.md | 298 ++ doc/md/tutorial-todo-gql-schema-generator.md | 298 ++ doc/md/tutorial-todo-gql-tx-mutation.md | 99 + doc/md/tutorial-todo-gql.mdx | 507 ++ doc/md/versioned-migrations.mdx | 904 ++++ doc/md/versioned/01-intro.md | 58 + doc/md/versioned/02-auto-plan.mdx | 43 + doc/md/versioned/03-upgrade-prod.mdx | 78 + doc/md/versioned/04-new-migration.mdx | 129 + doc/md/versioned/05-custom-migrations.md | 91 + doc/md/versioned/06-verifying-safety.mdx | 264 + doc/md/versioned/07-programmatically.mdx | 220 + doc/md/writing-docs.md | 79 + doc/website/.gitignore | 20 + .../blog/2019-10-03-introducing-ent.md | 50 + ...021-03-12-announcing-edge-field-support.md | 215 + ...03-18-generating-a-grpc-server-with-ent.md | 475 ++ .../2021-05-04-announcing-schema-imports.md | 103 + .../blog/2021-06-28-gprc-ready-for-use.md | 85 + ...-01-automatic-graphql-filter-generation.md | 349 ++ ...22-database-locking-techniques-with-ent.md | 323 ++ ...fully-working-go-crud-http-api-with-ent.md | 533 ++ .../blog/2021-08-05-announcing-upsert-api.md | 283 + ...ervable-ent-application-with-prometheus.md | 371 ++ ...isualizing-your-data-graph-using-entviz.md | 180 + ...21-09-01-ent-joins-the-linux-foundation.md | 45 + .../blog/2021-09-02-ent-extension-api.md | 281 + .../blog/2021-09-10-openapi-generator.md | 387 ++ ...ent-schemas-from-existing-sql-databases.md | 460 ++ .../blog/2021-10-14-introducing-entcache.md | 183 + .../2021-10-19-sqlcomment-support-for-ent.md | 130 + ...nc-to-external-data-systems-using-hooks.md | 306 ++ .../blog/2021-11-15-announcing-entoas.md | 319 ++ ...ing-my-first-feature-to-ent-grpc-plugin.md | 253 + ...2022-01-04-serverless-graphql-using-aws.md | 616 +++ ...2-01-20-announcing-new-migration-engine.md | 235 + ...15-generate-rest-crud-with-ent-and-ogen.md | 574 ++ ...2-03-14-announcing-versioned-migrations.md | 364 ++ ...-17-announcing-preview-support-for-tidb.md | 96 + .../2022-04-07-how-twitter-can-implement.md | 128 + ...022-05-09-versioned-migrations-sum-file.md | 247 + doc/website/blog/2022-09-06-ci-for-ent.mdx | 307 ++ doc/website/blog/2022-10-10-json-append.mdx | 297 ++ ...hanging-column-types-with-zero-downtime.md | 247 + .../2023-01-26-visualizing-with-entviz.md | 106 + .../blog/2023-02-23-simple-cms-with-ent.mdx | 849 +++ .../blog/2023-08-13-visualize-with-atlas.md | 99 + ...2025-02-12-rag-with-ent-atlas-pgvector.mdx | 831 +++ doc/website/crowdin.yaml | 34 + doc/website/docusaurus.config.js | 246 + doc/website/package.json | 35 + doc/website/sidebars.js | 167 + doc/website/src/css/code-block-buttons.css | 39 + doc/website/src/css/custom.css | 819 +++ doc/website/src/img/favicon.ico | Bin 0 -> 806 bytes doc/website/src/img/logo.png | Bin 0 -> 1370 bytes doc/website/src/js/code-block-buttons.js | 47 + doc/website/src/js/custom.js | 17 + doc/website/src/pages/index.js | 201 + doc/website/src/static/contrib/index.html | 12 + doc/website/src/static/ent/cmd/ent/index.html | 12 + .../src/static/ent/cmd/entfix/index.html | 12 + .../src/static/ent/entc/gen/index.html | 12 + doc/website/src/static/ent/entc/index.html | 12 + doc/website/src/static/ent/index.html | 12 + .../src/theme/prism-include-languages.js | 83 + doc/website/static/contrib/index.html | 12 + doc/website/static/ent/cmd/ent/index.html | 12 + doc/website/static/ent/cmd/entfix/index.html | 12 + doc/website/static/ent/entc/gen/index.html | 12 + doc/website/static/ent/entc/index.html | 12 + doc/website/static/ent/index.html | 12 + doc/website/static/font/CalibreBold.woff | Bin 0 -> 30568 bytes doc/website/static/font/CalibreLight.woff | Bin 0 -> 28544 bytes .../static/font/CalibreLightItalic.woff | Bin 0 -> 29096 bytes doc/website/static/font/CalibreMedium.woff | Bin 0 -> 26536 bytes .../static/font/CalibreMediumItalic.woff | Bin 0 -> 26400 bytes doc/website/static/font/CalibreRegular.woff | Bin 0 -> 28528 bytes .../static/font/CalibreRegularItalic.woff | Bin 0 -> 29036 bytes doc/website/static/font/CalibreSemibold.woff | Bin 0 -> 29712 bytes doc/website/static/font/CalibreThin.woff | Bin 0 -> 25288 bytes .../static/font/CalibreThinItalic.woff | Bin 0 -> 25660 bytes doc/website/static/img/favicon.ico | Bin 0 -> 806 bytes doc/website/static/img/logo.png | Bin 0 -> 1370 bytes doc/website/static/img/oss_logo.png | Bin 0 -> 4370 bytes doc/website/static/js/code-block-buttons.js | 47 + doc/website/static/js/custom.js | 17 + ent.go | 580 +++ entc/entc.go | 428 ++ entc/gen/bench_test.go | 41 + entc/gen/feature.go | 249 + entc/gen/func.go | 553 ++ entc/gen/globalid.go | 208 + entc/gen/globalid_test.go | 128 + entc/gen/graph.go | 1258 +++++ entc/gen/graph_test.go | 770 +++ entc/gen/predicate.go | 72 + entc/gen/storage.go | 200 + entc/gen/template.go | 466 ++ entc/gen/template/base.tmpl | 429 ++ entc/gen/template/builder/create.tmpl | 173 + entc/gen/template/builder/delete.tmpl | 97 + entc/gen/template/builder/entql.tmpl | 14 + entc/gen/template/builder/mutation_graph.tmpl | 213 + entc/gen/template/builder/query.tmpl | 500 ++ entc/gen/template/builder/setter.tmpl | 150 + entc/gen/template/builder/update.tmpl | 277 + entc/gen/template/client.tmpl | 561 ++ entc/gen/template/dialect/gremlin/by.tmpl | 57 + entc/gen/template/dialect/gremlin/create.tmpl | 95 + entc/gen/template/dialect/gremlin/decode.tmpl | 87 + entc/gen/template/dialect/gremlin/delete.tmpl | 32 + entc/gen/template/dialect/gremlin/errors.tmpl | 54 + .../gen/template/dialect/gremlin/globals.tmpl | 14 + entc/gen/template/dialect/gremlin/group.tmpl | 46 + entc/gen/template/dialect/gremlin/meta.tmpl | 19 + entc/gen/template/dialect/gremlin/open.tmpl | 24 + .../template/dialect/gremlin/predicate.tmpl | 135 + entc/gen/template/dialect/gremlin/query.tmpl | 107 + entc/gen/template/dialect/gremlin/select.tmpl | 44 + entc/gen/template/dialect/gremlin/update.tmpl | 198 + entc/gen/template/dialect/sql/by.tmpl | 67 + entc/gen/template/dialect/sql/create.tmpl | 492 ++ entc/gen/template/dialect/sql/decode.tmpl | 181 + entc/gen/template/dialect/sql/delete.tmpl | 84 + entc/gen/template/dialect/sql/ent.tmpl | 25 + entc/gen/template/dialect/sql/entql.tmpl | 177 + entc/gen/template/dialect/sql/errors.tmpl | 11 + .../dialect/sql/feature/execquery.tmpl | 71 + .../template/dialect/sql/feature/lock.tmpl | 46 + .../dialect/sql/feature/migratediff.tmpl | 40 + .../dialect/sql/feature/modifier.tmpl | 92 + .../dialect/sql/feature/namedges.tmpl | 121 + .../dialect/sql/feature/schemaconfig.tmpl | 220 + .../template/dialect/sql/feature/upsert.tmpl | 493 ++ entc/gen/template/dialect/sql/globals.tmpl | 14 + entc/gen/template/dialect/sql/group.tmpl | 40 + entc/gen/template/dialect/sql/meta.tmpl | 194 + entc/gen/template/dialect/sql/open.tmpl | 15 + entc/gen/template/dialect/sql/predicate.tmpl | 99 + entc/gen/template/dialect/sql/query.tmpl | 601 +++ entc/gen/template/dialect/sql/select.tmpl | 42 + entc/gen/template/dialect/sql/tx.tmpl | 31 + entc/gen/template/dialect/sql/update.tmpl | 395 ++ entc/gen/template/ent.tmpl | 291 ++ entc/gen/template/enttest.tmpl | 111 + entc/gen/template/header.tmpl | 17 + entc/gen/template/hook.tmpl | 192 + entc/gen/template/import.tmpl | 87 + entc/gen/template/intercept.tmpl | 171 + entc/gen/template/internal.tmpl | 28 + entc/gen/template/meta.tmpl | 189 + entc/gen/template/migrate/migrate.tmpl | 77 + entc/gen/template/migrate/schema.tmpl | 245 + entc/gen/template/mutation.tmpl | 654 +++ entc/gen/template/predicate.tmpl | 39 + entc/gen/template/privacy/filter.tmpl | 69 + entc/gen/template/privacy/privacy.tmpl | 160 + entc/gen/template/runtime.tmpl | 250 + entc/gen/template/tx.tmpl | 182 + entc/gen/template/where.tmpl | 237 + entc/gen/type.go | 2502 +++++++++ entc/gen/type_test.go | 466 ++ entc/integration/README.md | 34 + entc/integration/blob/blob.go | 85 + entc/integration/blob/encrypt.go | 145 + entc/integration/blob_test.go | 2433 +++++++++ .../cascadelete/cascadelete_test.go | 41 + entc/integration/cascadelete/ent/client.go | 695 +++ entc/integration/cascadelete/ent/comment.go | 147 + .../cascadelete/ent/comment/comment.go | 83 + .../cascadelete/ent/comment/mutation.go | 325 ++ .../cascadelete/ent/comment/where.go | 191 + .../cascadelete/ent/comment_create.go | 222 + .../cascadelete/ent/comment_delete.go | 92 + .../cascadelete/ent/comment_query.go | 610 +++ .../cascadelete/ent/comment_update.go | 344 ++ entc/integration/cascadelete/ent/ent.go | 616 +++ .../cascadelete/ent/enttest/enttest.go | 88 + entc/integration/cascadelete/ent/generate.go | 7 + entc/integration/cascadelete/ent/hook/hook.go | 227 + .../cascadelete/ent/migrate/migrate.go | 68 + .../cascadelete/ent/migrate/schema.go | 77 + entc/integration/cascadelete/ent/mutation.go | 470 ++ entc/integration/cascadelete/ent/post.go | 163 + .../cascadelete/ent/post/mutation.go | 432 ++ entc/integration/cascadelete/ent/post/post.go | 118 + .../integration/cascadelete/ent/post/where.go | 224 + .../cascadelete/ent/post_create.go | 274 + .../cascadelete/ent/post_delete.go | 92 + .../integration/cascadelete/ent/post_query.go | 685 +++ .../cascadelete/ent/post_update.go | 497 ++ .../cascadelete/ent/predicate/predicate.go | 20 + entc/integration/cascadelete/ent/runtime.go | 31 + .../cascadelete/ent/runtime/runtime.go | 13 + .../cascadelete/ent/schema/comment.go | 35 + .../cascadelete/ent/schema/post.go | 41 + .../cascadelete/ent/schema/user.go | 35 + entc/integration/cascadelete/ent/tx.go | 220 + entc/integration/cascadelete/ent/user.go | 133 + .../cascadelete/ent/user/mutation.go | 326 ++ entc/integration/cascadelete/ent/user/user.go | 87 + .../integration/cascadelete/ent/user/where.go | 166 + .../cascadelete/ent/user_create.go | 237 + .../cascadelete/ent/user_delete.go | 92 + .../integration/cascadelete/ent/user_query.go | 610 +++ .../cascadelete/ent/user_update.go | 376 ++ .../compose/gremlin-server/Dockerfile | 11 + .../gremlin-server/gremlin-server.yaml | 40 + .../tinkergraph-empty.properties | 19 + entc/integration/config/config_test.go | 94 + entc/integration/config/ent/client.go | 344 ++ entc/integration/config/ent/ent.go | 612 +++ .../integration/config/ent/enttest/enttest.go | 88 + entc/integration/config/ent/generate.go | 7 + entc/integration/config/ent/hook/hook.go | 203 + .../integration/config/ent/migrate/migrate.go | 68 + entc/integration/config/ent/migrate/schema.go | 41 + entc/integration/config/ent/mutation.go | 187 + .../config/ent/predicate/predicate.go | 14 + entc/integration/config/ent/runtime.go | 13 + .../integration/config/ent/runtime/runtime.go | 13 + entc/integration/config/ent/schema/user.go | 71 + entc/integration/config/ent/tx.go | 214 + entc/integration/config/ent/user.go | 120 + entc/integration/config/ent/user/mutation.go | 309 ++ entc/integration/config/ent/user/user.go | 59 + entc/integration/config/ent/user/where.go | 227 + entc/integration/config/ent/user_create.go | 221 + entc/integration/config/ent/user_delete.go | 92 + entc/integration/config/ent/user_query.go | 531 ++ entc/integration/config/ent/user_update.go | 283 + entc/integration/customid/customid_test.go | 350 ++ entc/integration/customid/ent/account.go | 134 + .../customid/ent/account/account.go | 90 + .../customid/ent/account/mutation.go | 327 ++ .../integration/customid/ent/account/where.go | 167 + .../customid/ent/account_create.go | 549 ++ .../customid/ent/account_delete.go | 92 + .../integration/customid/ent/account_query.go | 612 +++ .../customid/ent/account_update.go | 403 ++ entc/integration/customid/ent/blob.go | 189 + entc/integration/customid/ent/blob/blob.go | 162 + .../integration/customid/ent/blob/mutation.go | 457 ++ entc/integration/customid/ent/blob/where.go | 233 + entc/integration/customid/ent/blob_create.go | 680 +++ entc/integration/customid/ent/blob_delete.go | 92 + entc/integration/customid/ent/blob_query.go | 797 +++ entc/integration/customid/ent/blob_update.go | 562 ++ entc/integration/customid/ent/bloblink.go | 169 + .../customid/ent/bloblink/bloblink.go | 115 + .../customid/ent/bloblink/mutation.go | 406 ++ .../customid/ent/bloblink/where.go | 172 + .../customid/ent/bloblink_create.go | 600 +++ .../customid/ent/bloblink_delete.go | 91 + .../customid/ent/bloblink_query.go | 607 +++ .../customid/ent/bloblink_update.go | 462 ++ entc/integration/customid/ent/car.go | 170 + entc/integration/customid/ent/car/car.go | 111 + entc/integration/customid/ent/car/mutation.go | 482 ++ entc/integration/customid/ent/car/where.go | 276 + entc/integration/customid/ent/car_create.go | 736 +++ entc/integration/customid/ent/car_delete.go | 92 + entc/integration/customid/ent/car_query.go | 618 +++ entc/integration/customid/ent/car_update.go | 502 ++ entc/integration/customid/ent/client.go | 3143 +++++++++++ entc/integration/customid/ent/device.go | 151 + .../integration/customid/ent/device/device.go | 116 + .../customid/ent/device/mutation.go | 345 ++ entc/integration/customid/ent/device/where.go | 120 + .../integration/customid/ent/device_create.go | 521 ++ .../integration/customid/ent/device_delete.go | 92 + entc/integration/customid/ent/device_query.go | 672 +++ .../integration/customid/ent/device_update.go | 451 ++ entc/integration/customid/ent/doc.go | 178 + entc/integration/customid/ent/doc/doc.go | 149 + entc/integration/customid/ent/doc/mutation.go | 491 ++ entc/integration/customid/ent/doc/where.go | 223 + entc/integration/customid/ent/doc_create.go | 640 +++ entc/integration/customid/ent/doc_delete.go | 92 + entc/integration/customid/ent/doc_query.go | 797 +++ entc/integration/customid/ent/doc_update.go | 664 +++ entc/integration/customid/ent/ent.go | 646 +++ entc/integration/customid/ent/entql.go | 1791 +++++++ .../customid/ent/enttest/enttest.go | 88 + entc/integration/customid/ent/generate.go | 7 + entc/integration/customid/ent/group.go | 121 + entc/integration/customid/ent/group/group.go | 80 + .../customid/ent/group/mutation.go | 285 + entc/integration/customid/ent/group/where.go | 96 + entc/integration/customid/ent/group_create.go | 456 ++ entc/integration/customid/ent/group_delete.go | 92 + entc/integration/customid/ent/group_query.go | 619 +++ entc/integration/customid/ent/group_update.go | 342 ++ entc/integration/customid/ent/hook/hook.go | 407 ++ entc/integration/customid/ent/intsid.go | 150 + .../integration/customid/ent/intsid/intsid.go | 102 + .../customid/ent/intsid/mutation.go | 345 ++ entc/integration/customid/ent/intsid/where.go | 120 + .../integration/customid/ent/intsid_create.go | 496 ++ .../integration/customid/ent/intsid_delete.go | 92 + entc/integration/customid/ent/intsid_query.go | 671 +++ .../integration/customid/ent/intsid_update.go | 450 ++ entc/integration/customid/ent/link.go | 112 + entc/integration/customid/ent/link/link.go | 55 + .../integration/customid/ent/link/mutation.go | 234 + entc/integration/customid/ent/link/where.go | 73 + entc/integration/customid/ent/link_create.go | 517 ++ entc/integration/customid/ent/link_delete.go | 92 + entc/integration/customid/ent/link_query.go | 532 ++ entc/integration/customid/ent/link_update.go | 198 + .../customid/ent/migrate/migrate.go | 68 + .../customid/ent/migrate/schema.go | 464 ++ entc/integration/customid/ent/mixinid.go | 119 + .../customid/ent/mixinid/mixinid.go | 65 + .../customid/ent/mixinid/mutation.go | 268 + .../integration/customid/ent/mixinid/where.go | 213 + .../customid/ent/mixinid_create.go | 565 ++ .../customid/ent/mixinid_delete.go | 92 + .../integration/customid/ent/mixinid_query.go | 532 ++ .../customid/ent/mixinid_update.go | 247 + entc/integration/customid/ent/mutation.go | 2389 +++++++++ entc/integration/customid/ent/note.go | 160 + .../integration/customid/ent/note/mutation.go | 408 ++ entc/integration/customid/ent/note/note.go | 118 + entc/integration/customid/ent/note/where.go | 200 + entc/integration/customid/ent/note_create.go | 609 +++ entc/integration/customid/ent/note_delete.go | 92 + entc/integration/customid/ent/note_query.go | 693 +++ entc/integration/customid/ent/note_update.go | 502 ++ entc/integration/customid/ent/other.go | 96 + .../customid/ent/other/mutation.go | 192 + entc/integration/customid/ent/other/other.go | 49 + entc/integration/customid/ent/other/where.go | 73 + entc/integration/customid/ent/other_create.go | 448 ++ entc/integration/customid/ent/other_delete.go | 92 + entc/integration/customid/ent/other_query.go | 510 ++ entc/integration/customid/ent/other_update.go | 179 + entc/integration/customid/ent/pet.go | 194 + entc/integration/customid/ent/pet/mutation.go | 486 ++ entc/integration/customid/ent/pet/pet.go | 169 + entc/integration/customid/ent/pet/where.go | 175 + entc/integration/customid/ent/pet_create.go | 588 +++ entc/integration/customid/ent/pet_delete.go | 92 + entc/integration/customid/ent/pet_query.go | 850 +++ entc/integration/customid/ent/pet_update.go | 721 +++ .../customid/ent/predicate/predicate.go | 76 + .../customid/ent/privacy/privacy.go | 667 +++ entc/integration/customid/ent/revision.go | 95 + .../customid/ent/revision/mutation.go | 192 + .../customid/ent/revision/revision.go | 43 + .../customid/ent/revision/where.go | 82 + .../customid/ent/revision_create.go | 429 ++ .../customid/ent/revision_delete.go | 92 + .../customid/ent/revision_query.go | 509 ++ .../customid/ent/revision_update.go | 179 + entc/integration/customid/ent/runtime.go | 207 + .../customid/ent/runtime/runtime.go | 13 + .../customid/ent/schema/account.go | 40 + entc/integration/customid/ent/schema/blob.go | 86 + entc/integration/customid/ent/schema/car.go | 58 + entc/integration/customid/ent/schema/doc.go | 74 + entc/integration/customid/ent/schema/group.go | 31 + .../integration/customid/ent/schema/intsid.go | 37 + entc/integration/customid/ent/schema/link.go | 35 + .../customid/ent/schema/mixinid.go | 55 + entc/integration/customid/ent/schema/note.go | 45 + entc/integration/customid/ent/schema/other.go | 31 + entc/integration/customid/ent/schema/pet.go | 42 + .../customid/ent/schema/revision.go | 23 + .../customid/ent/schema/session.go | 92 + entc/integration/customid/ent/schema/token.go | 40 + entc/integration/customid/ent/schema/user.go | 36 + .../customid/ent/schema/valuescan.go | 43 + entc/integration/customid/ent/session.go | 135 + .../customid/ent/session/mutation.go | 260 + .../customid/ent/session/session.go | 86 + .../integration/customid/ent/session/where.go | 97 + .../customid/ent/session_create.go | 490 ++ .../customid/ent/session_delete.go | 92 + .../integration/customid/ent/session_query.go | 597 +++ .../customid/ent/session_update.go | 289 ++ entc/integration/customid/ent/token.go | 147 + .../customid/ent/token/mutation.go | 301 ++ entc/integration/customid/ent/token/token.go | 94 + entc/integration/customid/ent/token/where.go | 167 + entc/integration/customid/ent/token_create.go | 549 ++ entc/integration/customid/ent/token_delete.go | 92 + entc/integration/customid/ent/token_query.go | 619 +++ entc/integration/customid/ent/token_update.go | 339 ++ entc/integration/customid/ent/tx.go | 265 + entc/integration/customid/ent/user.go | 181 + .../integration/customid/ent/user/mutation.go | 510 ++ entc/integration/customid/ent/user/user.go | 170 + entc/integration/customid/ent/user/where.go | 165 + entc/integration/customid/ent/user_create.go | 555 ++ entc/integration/customid/ent/user_delete.go | 92 + entc/integration/customid/ent/user_query.go | 850 +++ entc/integration/customid/ent/user_update.go | 775 +++ entc/integration/customid/ent/valuescan.go | 108 + .../customid/ent/valuescan/mutation.go | 233 + .../customid/ent/valuescan/valuescan.go | 60 + .../customid/ent/valuescan/where.go | 104 + .../customid/ent/valuescan_create.go | 519 ++ .../customid/ent/valuescan_delete.go | 92 + .../customid/ent/valuescan_query.go | 537 ++ .../customid/ent/valuescan_update.go | 217 + entc/integration/customid/sid/sid.go | 53 + .../customid/uuidcompatible/uuidc.go | 29 + entc/integration/docker-compose.yaml | 169 + entc/integration/edgefield/edgefield_test.go | 145 + entc/integration/edgefield/ent/car.go | 159 + entc/integration/edgefield/ent/car/car.go | 88 + .../integration/edgefield/ent/car/mutation.go | 348 ++ entc/integration/edgefield/ent/car/where.go | 177 + entc/integration/edgefield/ent/car_create.go | 253 + entc/integration/edgefield/ent/car_delete.go | 92 + entc/integration/edgefield/ent/car_query.go | 633 +++ entc/integration/edgefield/ent/car_update.go | 394 ++ entc/integration/edgefield/ent/card.go | 147 + entc/integration/edgefield/ent/card/card.go | 83 + .../edgefield/ent/card/mutation.go | 366 ++ entc/integration/edgefield/ent/card/where.go | 211 + entc/integration/edgefield/ent/card_create.go | 228 + entc/integration/edgefield/ent/card_delete.go | 92 + entc/integration/edgefield/ent/card_query.go | 610 +++ entc/integration/edgefield/ent/card_update.go | 352 ++ entc/integration/edgefield/ent/client.go | 1802 +++++++ entc/integration/edgefield/ent/ent.go | 628 +++ .../edgefield/ent/enttest/enttest.go | 88 + entc/integration/edgefield/ent/generate.go | 7 + entc/integration/edgefield/ent/hook/hook.go | 299 ++ entc/integration/edgefield/ent/info.go | 139 + entc/integration/edgefield/ent/info/info.go | 70 + .../edgefield/ent/info/mutation.go | 317 ++ entc/integration/edgefield/ent/info/where.go | 96 + entc/integration/edgefield/ent/info_create.go | 237 + entc/integration/edgefield/ent/info_delete.go | 92 + entc/integration/edgefield/ent/info_query.go | 607 +++ entc/integration/edgefield/ent/info_update.go | 330 ++ entc/integration/edgefield/ent/metadata.go | 204 + .../edgefield/ent/metadata/metadata.go | 135 + .../edgefield/ent/metadata/mutation.go | 524 ++ .../edgefield/ent/metadata/where.go | 222 + .../edgefield/ent/metadata_create.go | 321 ++ .../edgefield/ent/metadata_delete.go | 92 + .../edgefield/ent/metadata_query.go | 777 +++ .../edgefield/ent/metadata_update.go | 624 +++ .../edgefield/ent/migrate/migrate.go | 68 + .../edgefield/ent/migrate/schema.go | 250 + entc/integration/edgefield/ent/mutation.go | 1402 +++++ entc/integration/edgefield/ent/node.go | 162 + .../edgefield/ent/node/mutation.go | 439 ++ entc/integration/edgefield/ent/node/node.go | 105 + entc/integration/edgefield/ent/node/where.go | 199 + entc/integration/edgefield/ent/node_create.go | 276 + entc/integration/edgefield/ent/node_delete.go | 92 + entc/integration/edgefield/ent/node_query.go | 679 +++ entc/integration/edgefield/ent/node_update.go | 461 ++ entc/integration/edgefield/ent/pet.go | 134 + .../integration/edgefield/ent/pet/mutation.go | 312 ++ entc/integration/edgefield/ent/pet/pet.go | 75 + entc/integration/edgefield/ent/pet/where.go | 131 + entc/integration/edgefield/ent/pet_create.go | 210 + entc/integration/edgefield/ent/pet_delete.go | 92 + entc/integration/edgefield/ent/pet_query.go | 610 +++ entc/integration/edgefield/ent/pet_update.go | 300 ++ entc/integration/edgefield/ent/post.go | 150 + .../edgefield/ent/post/mutation.go | 347 ++ entc/integration/edgefield/ent/post/post.go | 83 + entc/integration/edgefield/ent/post/where.go | 201 + entc/integration/edgefield/ent/post_create.go | 224 + entc/integration/edgefield/ent/post_delete.go | 92 + entc/integration/edgefield/ent/post_query.go | 613 +++ entc/integration/edgefield/ent/post_update.go | 334 ++ .../edgefield/ent/predicate/predicate.go | 38 + entc/integration/edgefield/ent/rental.go | 181 + .../edgefield/ent/rental/mutation.go | 408 ++ .../edgefield/ent/rental/rental.go | 121 + .../integration/edgefield/ent/rental/where.go | 217 + .../edgefield/ent/rental_create.go | 277 + .../edgefield/ent/rental_delete.go | 92 + .../integration/edgefield/ent/rental_query.go | 686 +++ .../edgefield/ent/rental_update.go | 242 + entc/integration/edgefield/ent/runtime.go | 48 + .../edgefield/ent/runtime/runtime.go | 13 + entc/integration/edgefield/ent/schema/car.go | 34 + entc/integration/edgefield/ent/schema/card.go | 44 + entc/integration/edgefield/ent/schema/info.go | 33 + .../edgefield/ent/schema/metadata.go | 37 + entc/integration/edgefield/ent/schema/node.go | 37 + entc/integration/edgefield/ent/schema/pet.go | 34 + entc/integration/edgefield/ent/schema/post.go | 43 + .../edgefield/ent/schema/rental.go | 59 + entc/integration/edgefield/ent/schema/user.go | 57 + entc/integration/edgefield/ent/tx.go | 238 + entc/integration/edgefield/ent/user.go | 364 ++ .../edgefield/ent/user/mutation.go | 864 ++++ entc/integration/edgefield/ent/user/user.go | 263 + entc/integration/edgefield/ent/user/where.go | 327 ++ entc/integration/edgefield/ent/user_create.go | 456 ++ entc/integration/edgefield/ent/user_delete.go | 92 + entc/integration/edgefield/ent/user_query.go | 1201 +++++ entc/integration/edgefield/ent/user_update.go | 1168 +++++ .../integration/edgeschema/edgeschema_test.go | 410 ++ .../edgeschema/ent/attachedfile.go | 178 + .../ent/attachedfile/attachedfile.go | 121 + .../edgeschema/ent/attachedfile/mutation.go | 420 ++ .../edgeschema/ent/attachedfile/where.go | 216 + .../edgeschema/ent/attachedfile_create.go | 633 +++ .../edgeschema/ent/attachedfile_delete.go | 92 + .../edgeschema/ent/attachedfile_query.go | 685 +++ .../edgeschema/ent/attachedfile_update.go | 472 ++ entc/integration/edgeschema/ent/client.go | 3265 ++++++++++++ entc/integration/edgeschema/ent/ent.go | 644 +++ entc/integration/edgeschema/ent/entc.go | 36 + entc/integration/edgeschema/ent/entql.go | 2464 +++++++++ .../edgeschema/ent/enttest/enttest.go | 88 + entc/integration/edgeschema/ent/file.go | 133 + entc/integration/edgeschema/ent/file/file.go | 86 + .../edgeschema/ent/file/mutation.go | 326 ++ entc/integration/edgeschema/ent/file/where.go | 166 + .../integration/edgeschema/ent/file_create.go | 490 ++ .../integration/edgeschema/ent/file_delete.go | 92 + entc/integration/edgeschema/ent/file_query.go | 641 +++ .../integration/edgeschema/ent/file_update.go | 376 ++ entc/integration/edgeschema/ent/friendship.go | 188 + .../edgeschema/ent/friendship/friendship.go | 131 + .../edgeschema/ent/friendship/mutation.go | 475 ++ .../edgeschema/ent/friendship/where.go | 261 + .../edgeschema/ent/friendship_create.go | 649 +++ .../edgeschema/ent/friendship_delete.go | 92 + .../edgeschema/ent/friendship_query.go | 684 +++ .../edgeschema/ent/friendship_update.go | 296 ++ entc/integration/edgeschema/ent/generate.go | 7 + entc/integration/edgeschema/ent/group.go | 181 + .../integration/edgeschema/ent/group/group.go | 182 + .../edgeschema/ent/group/mutation.go | 575 +++ .../integration/edgeschema/ent/group/where.go | 235 + .../edgeschema/ent/group_create.go | 608 +++ .../edgeschema/ent/group_delete.go | 92 + .../integration/edgeschema/ent/group_query.go | 894 ++++ .../edgeschema/ent/group_update.go | 889 ++++ entc/integration/edgeschema/ent/grouptag.go | 164 + .../edgeschema/ent/grouptag/grouptag.go | 106 + .../edgeschema/ent/grouptag/mutation.go | 371 ++ .../edgeschema/ent/grouptag/where.go | 169 + .../edgeschema/ent/grouptag_create.go | 555 ++ .../edgeschema/ent/grouptag_delete.go | 92 + .../edgeschema/ent/grouptag_query.go | 685 +++ .../edgeschema/ent/grouptag_update.go | 425 ++ entc/integration/edgeschema/ent/hook/hook.go | 395 ++ .../edgeschema/ent/internal/schema.go | 13 + .../edgeschema/ent/migrate/migrate.go | 68 + .../edgeschema/ent/migrate/schema.go | 456 ++ entc/integration/edgeschema/ent/mutation.go | 2310 +++++++++ .../edgeschema/ent/predicate/predicate.go | 62 + .../edgeschema/ent/privacy/privacy.go | 639 +++ entc/integration/edgeschema/ent/process.go | 137 + .../edgeschema/ent/process/mutation.go | 368 ++ .../edgeschema/ent/process/process.go | 108 + .../edgeschema/ent/process/where.go | 119 + .../edgeschema/ent/process_create.go | 462 ++ .../edgeschema/ent/process_delete.go | 92 + .../edgeschema/ent/process_query.go | 693 +++ .../edgeschema/ent/process_update.go | 529 ++ .../edgeschema/ent/relationship.go | 195 + .../edgeschema/ent/relationship/mutation.go | 543 ++ .../ent/relationship/relationship.go | 154 + .../edgeschema/ent/relationship/where.go | 227 + .../edgeschema/ent/relationship_create.go | 718 +++ .../edgeschema/ent/relationship_delete.go | 91 + .../edgeschema/ent/relationship_query.go | 688 +++ .../edgeschema/ent/relationship_update.go | 601 +++ .../edgeschema/ent/relationshipinfo.go | 107 + .../ent/relationshipinfo/mutation.go | 233 + .../ent/relationshipinfo/relationshipinfo.go | 51 + .../edgeschema/ent/relationshipinfo/where.go | 142 + .../edgeschema/ent/relationshipinfo_create.go | 458 ++ .../edgeschema/ent/relationshipinfo_delete.go | 92 + .../edgeschema/ent/relationshipinfo_query.go | 531 ++ .../edgeschema/ent/relationshipinfo_update.go | 213 + entc/integration/edgeschema/ent/role.go | 163 + .../edgeschema/ent/role/mutation.go | 362 ++ entc/integration/edgeschema/ent/role/role.go | 131 + entc/integration/edgeschema/ent/role/where.go | 236 + .../integration/edgeschema/ent/role_create.go | 566 ++ .../integration/edgeschema/ent/role_delete.go | 92 + entc/integration/edgeschema/ent/role_query.go | 715 +++ .../integration/edgeschema/ent/role_update.go | 435 ++ entc/integration/edgeschema/ent/roleuser.go | 169 + .../edgeschema/ent/roleuser/mutation.go | 408 ++ .../edgeschema/ent/roleuser/roleuser.go | 117 + .../edgeschema/ent/roleuser/where.go | 171 + .../edgeschema/ent/roleuser_create.go | 600 +++ .../edgeschema/ent/roleuser_delete.go | 91 + .../edgeschema/ent/roleuser_query.go | 607 +++ .../edgeschema/ent/roleuser_update.go | 462 ++ entc/integration/edgeschema/ent/runtime.go | 9 + .../edgeschema/ent/runtime/runtime.go | 140 + .../edgeschema/ent/schema/friendship.go | 63 + .../edgeschema/ent/schema/group.go | 36 + .../edgeschema/ent/schema/grouptag.go | 40 + .../edgeschema/ent/schema/process.go | 81 + .../edgeschema/ent/schema/relationship.go | 87 + .../edgeschema/ent/schema/relationshipinfo.go | 22 + .../integration/edgeschema/ent/schema/role.go | 37 + .../edgeschema/ent/schema/roleuser.go | 50 + entc/integration/edgeschema/ent/schema/tag.go | 33 + .../edgeschema/ent/schema/tweet.go | 39 + .../edgeschema/ent/schema/tweetlike.go | 62 + .../edgeschema/ent/schema/tweettag.go | 45 + .../integration/edgeschema/ent/schema/user.go | 55 + .../edgeschema/ent/schema/usergroup.go | 42 + .../edgeschema/ent/schema/usertweet.go | 51 + entc/integration/edgeschema/ent/tag.go | 181 + .../edgeschema/ent/tag/mutation.go | 576 +++ entc/integration/edgeschema/ent/tag/tag.go | 177 + entc/integration/edgeschema/ent/tag/where.go | 235 + entc/integration/edgeschema/ent/tag_create.go | 594 +++ entc/integration/edgeschema/ent/tag_delete.go | 92 + entc/integration/edgeschema/ent/tag_query.go | 894 ++++ entc/integration/edgeschema/ent/tag_update.go | 908 ++++ entc/integration/edgeschema/ent/tweet.go | 213 + .../edgeschema/ent/tweet/mutation.go | 659 +++ .../integration/edgeschema/ent/tweet/tweet.go | 238 + .../integration/edgeschema/ent/tweet/where.go | 281 + .../edgeschema/ent/tweet_create.go | 633 +++ .../edgeschema/ent/tweet_delete.go | 92 + .../integration/edgeschema/ent/tweet_query.go | 1072 ++++ .../edgeschema/ent/tweet_update.go | 1118 ++++ entc/integration/edgeschema/ent/tweetlike.go | 169 + .../edgeschema/ent/tweetlike/mutation.go | 408 ++ .../edgeschema/ent/tweetlike/tweetlike.go | 125 + .../edgeschema/ent/tweetlike/where.go | 171 + .../edgeschema/ent/tweetlike_create.go | 606 +++ .../edgeschema/ent/tweetlike_delete.go | 91 + .../edgeschema/ent/tweetlike_query.go | 614 +++ .../edgeschema/ent/tweetlike_update.go | 462 ++ entc/integration/edgeschema/ent/tweettag.go | 181 + .../edgeschema/ent/tweettag/mutation.go | 407 ++ .../edgeschema/ent/tweettag/tweettag.go | 124 + .../edgeschema/ent/tweettag/where.go | 217 + .../edgeschema/ent/tweettag_create.go | 675 +++ .../edgeschema/ent/tweettag_delete.go | 92 + .../edgeschema/ent/tweettag_query.go | 686 +++ .../edgeschema/ent/tweettag_update.go | 460 ++ entc/integration/edgeschema/ent/tx.go | 262 + entc/integration/edgeschema/ent/user.go | 309 ++ .../edgeschema/ent/user/mutation.go | 990 ++++ entc/integration/edgeschema/ent/user/user.go | 428 ++ entc/integration/edgeschema/ent/user/where.go | 419 ++ .../integration/edgeschema/ent/user_create.go | 788 +++ .../integration/edgeschema/ent/user_delete.go | 92 + entc/integration/edgeschema/ent/user_query.go | 1614 ++++++ .../integration/edgeschema/ent/user_update.go | 1821 +++++++ entc/integration/edgeschema/ent/usergroup.go | 178 + .../edgeschema/ent/usergroup/mutation.go | 407 ++ .../edgeschema/ent/usergroup/usergroup.go | 121 + .../edgeschema/ent/usergroup/where.go | 216 + .../edgeschema/ent/usergroup_create.go | 627 +++ .../edgeschema/ent/usergroup_delete.go | 92 + .../edgeschema/ent/usergroup_query.go | 685 +++ .../edgeschema/ent/usergroup_update.go | 460 ++ entc/integration/edgeschema/ent/usertweet.go | 178 + .../edgeschema/ent/usertweet/mutation.go | 407 ++ .../edgeschema/ent/usertweet/usertweet.go | 121 + .../edgeschema/ent/usertweet/where.go | 216 + .../edgeschema/ent/usertweet_create.go | 627 +++ .../edgeschema/ent/usertweet_delete.go | 92 + .../edgeschema/ent/usertweet_query.go | 685 +++ .../edgeschema/ent/usertweet_update.go | 460 ++ entc/integration/ent/api.go | 95 + entc/integration/ent/api/api.go | 45 + entc/integration/ent/api/mutation.go | 192 + entc/integration/ent/api/where.go | 72 + entc/integration/ent/api_create.go | 394 ++ entc/integration/ent/api_delete.go | 92 + entc/integration/ent/api_query.go | 559 ++ entc/integration/ent/api_update.go | 195 + entc/integration/ent/builder.go | 95 + entc/integration/ent/builder/builder.go | 45 + entc/integration/ent/builder/mutation.go | 192 + entc/integration/ent/builder/where.go | 72 + entc/integration/ent/builder_create.go | 394 ++ entc/integration/ent/builder_delete.go | 92 + entc/integration/ent/builder_query.go | 559 ++ entc/integration/ent/builder_update.go | 195 + entc/integration/ent/card.go | 239 + entc/integration/ent/card/card.go | 171 + entc/integration/ent/card/mutation.go | 584 +++ entc/integration/ent/card/where.go | 406 ++ entc/integration/ent/card_create.go | 775 +++ entc/integration/ent/card_delete.go | 92 + entc/integration/ent/card_query.go | 801 +++ entc/integration/ent/card_update.go | 636 +++ entc/integration/ent/client.go | 3534 +++++++++++++ entc/integration/ent/comment.go | 173 + entc/integration/ent/comment/comment.go | 88 + entc/integration/ent/comment/mutation.go | 591 +++ entc/integration/ent/comment/where.go | 382 ++ entc/integration/ent/comment_create.go | 884 ++++ entc/integration/ent/comment_delete.go | 92 + entc/integration/ent/comment_query.go | 581 +++ entc/integration/ent/comment_update.go | 532 ++ entc/integration/ent/document.go | 321 ++ entc/integration/ent/document/document.go | 104 + entc/integration/ent/document/mutation.go | 523 ++ entc/integration/ent/document/where.go | 197 + entc/integration/ent/document_create.go | 931 ++++ entc/integration/ent/document_delete.go | 138 + entc/integration/ent/document_query.go | 636 +++ entc/integration/ent/document_update.go | 528 ++ entc/integration/ent/ent.go | 651 +++ entc/integration/ent/entql.go | 2621 ++++++++++ entc/integration/ent/enttest/enttest.go | 88 + entc/integration/ent/extension.go | 14 + entc/integration/ent/exvaluescan.go | 200 + .../ent/exvaluescan/exvaluescan.go | 122 + entc/integration/ent/exvaluescan/mutation.go | 540 ++ entc/integration/ent/exvaluescan/where.go | 946 ++++ entc/integration/ent/exvaluescan_create.go | 928 ++++ entc/integration/ent/exvaluescan_delete.go | 92 + entc/integration/ent/exvaluescan_query.go | 581 +++ entc/integration/ent/exvaluescan_update.go | 507 ++ entc/integration/ent/fieldtype.go | 898 ++++ entc/integration/ent/fieldtype/fieldtype.go | 644 +++ entc/integration/ent/fieldtype/mutation.go | 4596 ++++++++++++++++ entc/integration/ent/fieldtype_delete.go | 92 + entc/integration/ent/fieldtype_query.go | 586 +++ entc/integration/ent/fieldtype_update.go | 3864 ++++++++++++++ entc/integration/ent/file.go | 311 ++ entc/integration/ent/file/file.go | 208 + entc/integration/ent/file/mutation.go | 911 ++++ entc/integration/ent/file/where.go | 604 +++ entc/integration/ent/file_create.go | 1174 +++++ entc/integration/ent/file_delete.go | 92 + entc/integration/ent/file_query.go | 841 +++ entc/integration/ent/file_update.go | 1053 ++++ entc/integration/ent/filetype.go | 180 + entc/integration/ent/filetype/filetype.go | 165 + entc/integration/ent/filetype/mutation.go | 396 ++ entc/integration/ent/filetype/where.go | 206 + entc/integration/ent/filetype_create.go | 636 +++ entc/integration/ent/filetype_delete.go | 92 + entc/integration/ent/filetype_query.go | 693 +++ entc/integration/ent/filetype_update.go | 496 ++ entc/integration/ent/generate.go | 7 + entc/integration/ent/goods.go | 95 + entc/integration/ent/goods/goods.go | 45 + entc/integration/ent/goods/mutation.go | 192 + entc/integration/ent/goods/where.go | 72 + entc/integration/ent/goods_create.go | 394 ++ entc/integration/ent/goods_delete.go | 92 + entc/integration/ent/goods_query.go | 559 ++ entc/integration/ent/goods_update.go | 195 + entc/integration/ent/group.go | 321 ++ entc/integration/ent/group/group.go | 227 + entc/integration/ent/group/mutation.go | 770 +++ entc/integration/ent/group/where.go | 432 ++ entc/integration/ent/group_create.go | 905 ++++ entc/integration/ent/group_delete.go | 92 + entc/integration/ent/group_query.go | 989 ++++ entc/integration/ent/group_update.go | 1055 ++++ entc/integration/ent/groupinfo.go | 169 + entc/integration/ent/groupinfo/groupinfo.go | 97 + entc/integration/ent/groupinfo/mutation.go | 397 ++ entc/integration/ent/groupinfo/where.go | 211 + entc/integration/ent/groupinfo_create.go | 581 +++ entc/integration/ent/groupinfo_delete.go | 92 + entc/integration/ent/groupinfo_query.go | 693 +++ entc/integration/ent/groupinfo_update.go | 446 ++ entc/integration/ent/hook/hook.go | 431 ++ entc/integration/ent/internal/globalid.go | 9 + entc/integration/ent/item.go | 105 + entc/integration/ent/item/item.go | 62 + entc/integration/ent/item/mutation.go | 255 + entc/integration/ent/item/where.go | 162 + entc/integration/ent/item_create.go | 546 ++ entc/integration/ent/item_delete.go | 92 + entc/integration/ent/item_query.go | 581 +++ entc/integration/ent/item_update.go | 273 + entc/integration/ent/license.go | 119 + entc/integration/ent/license/license.go | 72 + entc/integration/ent/license/mutation.go | 269 + entc/integration/ent/license/where.go | 164 + entc/integration/ent/license_create.go | 538 ++ entc/integration/ent/license_delete.go | 92 + entc/integration/ent/license_query.go | 581 +++ entc/integration/ent/license_update.go | 232 + entc/integration/ent/migrate/migrate.go | 68 + entc/integration/ent/migrate/schema.go | 731 +++ entc/integration/ent/node.go | 178 + entc/integration/ent/node/mutation.go | 473 ++ entc/integration/ent/node/node.go | 120 + entc/integration/ent/node/where.go | 231 + entc/integration/ent/node_create.go | 653 +++ entc/integration/ent/node_delete.go | 92 + entc/integration/ent/node_query.go | 748 +++ entc/integration/ent/node_update.go | 538 ++ entc/integration/ent/pc.go | 95 + entc/integration/ent/pc/mutation.go | 192 + entc/integration/ent/pc/pc.go | 45 + entc/integration/ent/pc/where.go | 72 + entc/integration/ent/pc_create.go | 394 ++ entc/integration/ent/pc_delete.go | 92 + entc/integration/ent/pc_query.go | 559 ++ entc/integration/ent/pc_update.go | 195 + entc/integration/ent/pet.go | 239 + entc/integration/ent/pet/mutation.go | 632 +++ entc/integration/ent/pet/pet.go | 159 + entc/integration/ent/pet/where.go | 442 ++ entc/integration/ent/pet_create.go | 923 ++++ entc/integration/ent/pet_delete.go | 92 + entc/integration/ent/pet_query.go | 747 +++ entc/integration/ent/pet_update.go | 692 +++ entc/integration/ent/predicate/predicate.go | 93 + entc/integration/ent/role/role.go | 78 + entc/integration/ent/runtime.go | 335 ++ entc/integration/ent/runtime/runtime.go | 13 + entc/integration/ent/schema/api.go | 13 + entc/integration/ent/schema/builder.go | 22 + entc/integration/ent/schema/card.go | 101 + entc/integration/ent/schema/comment.go | 35 + entc/integration/ent/schema/dir/dir.go | 13 + entc/integration/ent/schema/document.go | 79 + entc/integration/ent/schema/exvaluescan.go | 103 + entc/integration/ent/schema/fieldtype.go | 545 ++ entc/integration/ent/schema/file.go | 90 + entc/integration/ent/schema/filetype.go | 44 + entc/integration/ent/schema/goods.go | 22 + entc/integration/ent/schema/group.go | 65 + entc/integration/ent/schema/groupinfo.go | 33 + entc/integration/ent/schema/item.go | 30 + entc/integration/ent/schema/license.go | 35 + entc/integration/ent/schema/node.go | 42 + entc/integration/ent/schema/pc.go | 18 + entc/integration/ent/schema/pet.go | 66 + entc/integration/ent/schema/spec.go | 21 + entc/integration/ent/schema/task.go | 55 + entc/integration/ent/schema/task/priority.go | 37 + entc/integration/ent/schema/user.go | 97 + entc/integration/ent/spec.go | 146 + entc/integration/ent/spec/mutation.go | 285 + entc/integration/ent/spec/spec.go | 80 + entc/integration/ent/spec/where.go | 96 + entc/integration/ent/spec_create.go | 426 ++ entc/integration/ent/spec_delete.go | 92 + entc/integration/ent/spec_query.go | 691 +++ entc/integration/ent/spec_update.go | 358 ++ entc/integration/ent/task.go | 198 + entc/integration/ent/task/mutation.go | 682 +++ entc/integration/ent/task/task.go | 122 + entc/integration/ent/task/where.go | 525 ++ entc/integration/ent/task_create.go | 1032 ++++ entc/integration/ent/task_delete.go | 93 + entc/integration/ent/task_query.go | 581 +++ entc/integration/ent/task_update.go | 604 +++ entc/integration/ent/template/client.tmpl | 18 + entc/integration/ent/template/ext.go | 14 + entc/integration/ent/template/ext.tmpl | 33 + entc/integration/ent/template/fields.tmpl | 12 + entc/integration/ent/template/meta.tmpl | 25 + entc/integration/ent/tx.go | 297 ++ entc/integration/ent/user.go | 628 +++ entc/integration/ent/user/mutation.go | 1685 ++++++ entc/integration/ent/user/user.go | 522 ++ entc/integration/ent/user/where.go | 1061 ++++ entc/integration/ent/user_create.go | 1700 ++++++ entc/integration/ent/user_delete.go | 92 + entc/integration/ent/user_query.go | 1718 ++++++ entc/integration/ent/user_update.go | 2405 +++++++++ entc/integration/entql_test.go | 97 + entc/integration/go.mod | 57 + entc/integration/go.sum | 218 + entc/integration/gremlin/ent/api.go | 86 + entc/integration/gremlin/ent/api/api.go | 23 + entc/integration/gremlin/ent/api/mutation.go | 192 + entc/integration/gremlin/ent/api/where.go | 120 + entc/integration/gremlin/ent/api_create.go | 93 + entc/integration/gremlin/ent/api_delete.go | 94 + entc/integration/gremlin/ent/api_query.go | 480 ++ entc/integration/gremlin/ent/api_update.go | 182 + entc/integration/gremlin/ent/builder.go | 86 + .../gremlin/ent/builder/builder.go | 23 + .../gremlin/ent/builder/mutation.go | 192 + entc/integration/gremlin/ent/builder/where.go | 120 + .../integration/gremlin/ent/builder_create.go | 93 + .../integration/gremlin/ent/builder_delete.go | 94 + entc/integration/gremlin/ent/builder_query.go | 480 ++ .../integration/gremlin/ent/builder_update.go | 182 + entc/integration/gremlin/ent/card.go | 178 + entc/integration/gremlin/ent/card/card.go | 58 + entc/integration/gremlin/ent/card/mutation.go | 584 +++ entc/integration/gremlin/ent/card/where.go | 529 ++ entc/integration/gremlin/ent/card_create.go | 272 + entc/integration/gremlin/ent/card_delete.go | 94 + entc/integration/gremlin/ent/card_query.go | 558 ++ entc/integration/gremlin/ent/card_update.go | 554 ++ entc/integration/gremlin/ent/client.go | 3315 ++++++++++++ entc/integration/gremlin/ent/comment.go | 142 + .../gremlin/ent/comment/comment.go | 35 + .../gremlin/ent/comment/mutation.go | 591 +++ entc/integration/gremlin/ent/comment/where.go | 526 ++ .../integration/gremlin/ent/comment_create.go | 209 + .../integration/gremlin/ent/comment_delete.go | 94 + entc/integration/gremlin/ent/comment_query.go | 502 ++ .../integration/gremlin/ent/comment_update.go | 598 +++ entc/integration/gremlin/ent/document.go | 115 + .../gremlin/ent/document/document.go | 48 + .../gremlin/ent/document/mutation.go | 437 ++ .../integration/gremlin/ent/document/where.go | 281 + .../gremlin/ent/document_create.go | 168 + .../gremlin/ent/document_delete.go | 94 + .../integration/gremlin/ent/document_query.go | 502 ++ .../gremlin/ent/document_update.go | 333 ++ entc/integration/gremlin/ent/ent.go | 665 +++ .../gremlin/ent/enttest/enttest.go | 62 + entc/integration/gremlin/ent/extension.go | 14 + entc/integration/gremlin/ent/exvaluescan.go | 159 + .../gremlin/ent/exvaluescan/exvaluescan.go | 57 + .../gremlin/ent/exvaluescan/mutation.go | 540 ++ .../gremlin/ent/exvaluescan/where.go | 1034 ++++ .../gremlin/ent/exvaluescan_create.go | 191 + .../gremlin/ent/exvaluescan_delete.go | 94 + .../gremlin/ent/exvaluescan_query.go | 502 ++ .../gremlin/ent/exvaluescan_update.go | 439 ++ entc/integration/gremlin/ent/fieldtype.go | 701 +++ .../gremlin/ent/fieldtype/fieldtype.go | 252 + .../gremlin/ent/fieldtype/mutation.go | 4597 +++++++++++++++++ .../gremlin/ent/fieldtype_create.go | 1179 +++++ .../gremlin/ent/fieldtype_delete.go | 94 + .../gremlin/ent/fieldtype_query.go | 502 ++ .../gremlin/ent/fieldtype_update.go | 3851 ++++++++++++++ entc/integration/gremlin/ent/file.go | 224 + entc/integration/gremlin/ent/file/file.go | 60 + entc/integration/gremlin/ent/file/mutation.go | 911 ++++ entc/integration/gremlin/ent/file/where.go | 778 +++ entc/integration/gremlin/ent/file_create.go | 335 ++ entc/integration/gremlin/ent/file_delete.go | 94 + entc/integration/gremlin/ent/file_query.go | 585 +++ entc/integration/gremlin/ent/file_update.go | 934 ++++ entc/integration/gremlin/ent/filetype.go | 139 + .../gremlin/ent/filetype/filetype.go | 98 + .../gremlin/ent/filetype/mutation.go | 396 ++ .../integration/gremlin/ent/filetype/where.go | 278 + .../gremlin/ent/filetype_create.go | 210 + .../gremlin/ent/filetype_delete.go | 94 + .../integration/gremlin/ent/filetype_query.go | 529 ++ .../gremlin/ent/filetype_update.go | 457 ++ entc/integration/gremlin/ent/generate.go | 7 + entc/integration/gremlin/ent/goods.go | 86 + entc/integration/gremlin/ent/goods/goods.go | 23 + .../integration/gremlin/ent/goods/mutation.go | 192 + entc/integration/gremlin/ent/goods/where.go | 120 + entc/integration/gremlin/ent/goods_create.go | 93 + entc/integration/gremlin/ent/goods_delete.go | 94 + entc/integration/gremlin/ent/goods_query.go | 480 ++ entc/integration/gremlin/ent/goods_update.go | 182 + entc/integration/gremlin/ent/group.go | 210 + entc/integration/gremlin/ent/group/group.go | 62 + .../integration/gremlin/ent/group/mutation.go | 770 +++ entc/integration/gremlin/ent/group/where.go | 537 ++ entc/integration/gremlin/ent/group_create.go | 296 ++ entc/integration/gremlin/ent/group_delete.go | 94 + entc/integration/gremlin/ent/group_query.go | 611 +++ entc/integration/gremlin/ent/group_update.go | 827 +++ entc/integration/gremlin/ent/groupinfo.go | 129 + .../gremlin/ent/groupinfo/groupinfo.go | 36 + .../gremlin/ent/groupinfo/mutation.go | 397 ++ .../gremlin/ent/groupinfo/where.go | 285 + .../gremlin/ent/groupinfo_create.go | 172 + .../gremlin/ent/groupinfo_delete.go | 94 + .../gremlin/ent/groupinfo_query.go | 530 ++ .../gremlin/ent/groupinfo_update.go | 399 ++ entc/integration/gremlin/ent/hook/hook.go | 431 ++ entc/integration/gremlin/ent/item.go | 94 + entc/integration/gremlin/ent/item/item.go | 34 + entc/integration/gremlin/ent/item/mutation.go | 255 + entc/integration/gremlin/ent/item/where.go | 218 + entc/integration/gremlin/ent/item_create.go | 165 + entc/integration/gremlin/ent/item_delete.go | 94 + entc/integration/gremlin/ent/item_query.go | 502 ++ entc/integration/gremlin/ent/item_update.go | 311 ++ entc/integration/gremlin/ent/license.go | 104 + .../gremlin/ent/license/license.go | 38 + .../gremlin/ent/license/mutation.go | 269 + entc/integration/gremlin/ent/license/where.go | 248 + .../integration/gremlin/ent/license_create.go | 157 + .../integration/gremlin/ent/license_delete.go | 94 + entc/integration/gremlin/ent/license_query.go | 502 ++ .../integration/gremlin/ent/license_update.go | 219 + entc/integration/gremlin/ent/node.go | 155 + entc/integration/gremlin/ent/node/mutation.go | 473 ++ entc/integration/gremlin/ent/node/node.go | 42 + entc/integration/gremlin/ent/node/where.go | 312 ++ entc/integration/gremlin/ent/node_create.go | 194 + entc/integration/gremlin/ent/node_delete.go | 94 + entc/integration/gremlin/ent/node_query.go | 556 ++ entc/integration/gremlin/ent/node_update.go | 495 ++ entc/integration/gremlin/ent/pc.go | 86 + entc/integration/gremlin/ent/pc/mutation.go | 192 + entc/integration/gremlin/ent/pc/pc.go | 23 + entc/integration/gremlin/ent/pc/where.go | 120 + entc/integration/gremlin/ent/pc_create.go | 93 + entc/integration/gremlin/ent/pc_delete.go | 94 + entc/integration/gremlin/ent/pc_query.go | 480 ++ entc/integration/gremlin/ent/pc_update.go | 182 + entc/integration/gremlin/ent/pet.go | 188 + entc/integration/gremlin/ent/pet/mutation.go | 632 +++ entc/integration/gremlin/ent/pet/pet.go | 50 + entc/integration/gremlin/ent/pet/where.go | 579 +++ entc/integration/gremlin/ent/pet_create.go | 275 + entc/integration/gremlin/ent/pet_delete.go | 94 + entc/integration/gremlin/ent/pet_query.go | 557 ++ entc/integration/gremlin/ent/pet_update.go | 641 +++ .../gremlin/ent/predicate/predicate.go | 93 + entc/integration/gremlin/ent/runtime.go | 330 ++ .../gremlin/ent/runtime/runtime.go | 13 + entc/integration/gremlin/ent/spec.go | 112 + entc/integration/gremlin/ent/spec/mutation.go | 285 + entc/integration/gremlin/ent/spec/spec.go | 27 + entc/integration/gremlin/ent/spec/where.go | 138 + entc/integration/gremlin/ent/spec_create.go | 111 + entc/integration/gremlin/ent/spec_delete.go | 94 + entc/integration/gremlin/ent/spec_query.go | 507 ++ entc/integration/gremlin/ent/spec_update.go | 275 + entc/integration/gremlin/ent/task.go | 165 + entc/integration/gremlin/ent/task/mutation.go | 682 +++ entc/integration/gremlin/ent/task/task.go | 53 + entc/integration/gremlin/ent/task/where.go | 705 +++ entc/integration/gremlin/ent/task_create.go | 261 + entc/integration/gremlin/ent/task_delete.go | 95 + entc/integration/gremlin/ent/task_query.go | 502 ++ entc/integration/gremlin/ent/task_update.go | 601 +++ entc/integration/gremlin/ent/tx.go | 271 + entc/integration/gremlin/ent/user.go | 389 ++ entc/integration/gremlin/ent/user/mutation.go | 1685 ++++++ entc/integration/gremlin/ent/user/user.go | 167 + entc/integration/gremlin/ent/user/where.go | 1261 +++++ entc/integration/gremlin/ent/user_create.go | 594 +++ entc/integration/gremlin/ent/user_delete.go | 94 + entc/integration/gremlin/ent/user_query.go | 799 +++ entc/integration/gremlin/ent/user_update.go | 1795 +++++++ entc/integration/gremlin/gremlin_test.go | 2077 ++++++++ entc/integration/hooks/ent/card.go | 193 + entc/integration/hooks/ent/card/card.go | 137 + entc/integration/hooks/ent/card/mutation.go | 482 ++ entc/integration/hooks/ent/card/where.go | 418 ++ entc/integration/hooks/ent/card_create.go | 328 ++ entc/integration/hooks/ent/card_delete.go | 92 + entc/integration/hooks/ent/card_query.go | 618 +++ entc/integration/hooks/ent/card_update.go | 461 ++ entc/integration/hooks/ent/client.go | 732 +++ entc/integration/hooks/ent/ent.go | 616 +++ entc/integration/hooks/ent/enttest/enttest.go | 88 + entc/integration/hooks/ent/generate.go | 7 + entc/integration/hooks/ent/hook/hook.go | 227 + .../hooks/ent/intercept/intercept.go | 213 + entc/integration/hooks/ent/internal/schema.go | 13 + entc/integration/hooks/ent/migrate/migrate.go | 68 + entc/integration/hooks/ent/migrate/schema.go | 124 + entc/integration/hooks/ent/mutation.go | 604 +++ entc/integration/hooks/ent/pet.go | 160 + entc/integration/hooks/ent/pet/mutation.go | 377 ++ entc/integration/hooks/ent/pet/pet.go | 105 + entc/integration/hooks/ent/pet/where.go | 233 + entc/integration/hooks/ent/pet_create.go | 247 + entc/integration/hooks/ent/pet_delete.go | 92 + entc/integration/hooks/ent/pet_query.go | 618 +++ entc/integration/hooks/ent/pet_update.go | 393 ++ .../hooks/ent/predicate/predicate.go | 20 + entc/integration/hooks/ent/runtime.go | 9 + entc/integration/hooks/ent/runtime/runtime.go | 68 + entc/integration/hooks/ent/schema/card.go | 112 + entc/integration/hooks/ent/schema/mixin.go | 85 + entc/integration/hooks/ent/schema/pet.go | 40 + entc/integration/hooks/ent/schema/user.go | 122 + entc/integration/hooks/ent/tx.go | 220 + entc/integration/hooks/ent/user.go | 238 + entc/integration/hooks/ent/user/mutation.go | 802 +++ entc/integration/hooks/ent/user/user.go | 220 + entc/integration/hooks/ent/user/where.go | 430 ++ entc/integration/hooks/ent/user_create.go | 413 ++ entc/integration/hooks/ent/user_delete.go | 92 + entc/integration/hooks/ent/user_query.go | 872 ++++ entc/integration/hooks/ent/user_update.go | 1021 ++++ entc/integration/hooks/hooks_test.go | 827 +++ entc/integration/idtype/ent/client.go | 393 ++ entc/integration/idtype/ent/ent.go | 612 +++ .../integration/idtype/ent/enttest/enttest.go | 88 + entc/integration/idtype/ent/generate.go | 7 + entc/integration/idtype/ent/hook/hook.go | 203 + .../integration/idtype/ent/migrate/migrate.go | 68 + entc/integration/idtype/ent/migrate/schema.go | 71 + entc/integration/idtype/ent/mutation.go | 162 + .../idtype/ent/predicate/predicate.go | 14 + entc/integration/idtype/ent/runtime.go | 13 + .../integration/idtype/ent/runtime/runtime.go | 13 + entc/integration/idtype/ent/schema/user.go | 33 + entc/integration/idtype/ent/tx.go | 214 + entc/integration/idtype/ent/user.go | 177 + entc/integration/idtype/ent/user/mutation.go | 468 ++ entc/integration/idtype/ent/user/user.go | 142 + entc/integration/idtype/ent/user/where.go | 212 + entc/integration/idtype/ent/user_create.go | 285 + entc/integration/idtype/ent/user_delete.go | 92 + entc/integration/idtype/ent/user_query.go | 826 +++ entc/integration/idtype/ent/user_update.go | 645 +++ entc/integration/idtype/idtype_test.go | 31 + entc/integration/index_test.go | 73 + entc/integration/integration_test.go | 2944 +++++++++++ entc/integration/json/ent/client.go | 344 ++ entc/integration/json/ent/ent.go | 612 +++ entc/integration/json/ent/enttest/enttest.go | 88 + entc/integration/json/ent/generate.go | 7 + entc/integration/json/ent/hook/hook.go | 203 + entc/integration/json/ent/migrate/migrate.go | 68 + entc/integration/json/ent/migrate/schema.go | 45 + entc/integration/json/ent/mutation.go | 394 ++ .../json/ent/predicate/predicate.go | 14 + entc/integration/json/ent/runtime.go | 42 + entc/integration/json/ent/runtime/runtime.go | 13 + entc/integration/json/ent/schema/user.go | 128 + entc/integration/json/ent/tx.go | 214 + entc/integration/json/ent/user.go | 268 + entc/integration/json/ent/user/mutation.go | 1040 ++++ entc/integration/json/ent/user/user.go | 97 + entc/integration/json/ent/user/where.go | 192 + entc/integration/json/ent/user_create.go | 348 ++ entc/integration/json/ent/user_delete.go | 92 + entc/integration/json/ent/user_query.go | 554 ++ entc/integration/json/ent/user_update.go | 910 ++++ entc/integration/json/json_test.go | 738 +++ entc/integration/migrate/entv1/car.go | 134 + entc/integration/migrate/entv1/car/car.go | 80 + .../integration/migrate/entv1/car/mutation.go | 259 + entc/integration/migrate/entv1/car/where.go | 96 + entc/integration/migrate/entv1/car_create.go | 210 + entc/integration/migrate/entv1/car_delete.go | 92 + entc/integration/migrate/entv1/car_query.go | 596 +++ entc/integration/migrate/entv1/car_update.go | 288 ++ entc/integration/migrate/entv1/client.go | 854 +++ entc/integration/migrate/entv1/conversion.go | 195 + .../migrate/entv1/conversion/conversion.go | 115 + .../migrate/entv1/conversion/mutation.go | 962 ++++ .../migrate/entv1/conversion/where.go | 592 +++ .../migrate/entv1/conversion_create.go | 335 ++ .../migrate/entv1/conversion_delete.go | 92 + .../migrate/entv1/conversion_query.go | 531 ++ .../migrate/entv1/conversion_update.go | 807 +++ entc/integration/migrate/entv1/customtype.go | 107 + .../migrate/entv1/customtype/customtype.go | 51 + .../migrate/entv1/customtype/mutation.go | 255 + .../migrate/entv1/customtype/where.go | 152 + .../migrate/entv1/customtype_create.go | 191 + .../migrate/entv1/customtype_delete.go | 92 + .../migrate/entv1/customtype_query.go | 531 ++ .../migrate/entv1/customtype_update.go | 231 + entc/integration/migrate/entv1/ent.go | 618 +++ .../migrate/entv1/enttest/enttest.go | 88 + entc/integration/migrate/entv1/generate.go | 7 + entc/integration/migrate/entv1/hook/hook.go | 239 + .../migrate/entv1/migrate/migrate.go | 68 + .../migrate/entv1/migrate/schema.go | 134 + entc/integration/migrate/entv1/mutation.go | 913 ++++ .../migrate/entv1/predicate/predicate.go | 23 + entc/integration/migrate/entv1/runtime.go | 36 + .../migrate/entv1/runtime/runtime.go | 13 + .../migrate/entv1/schema/conversion.go | 39 + .../migrate/entv1/schema/customtype.go | 27 + entc/integration/migrate/entv1/schema/user.go | 95 + entc/integration/migrate/entv1/tx.go | 223 + entc/integration/migrate/entv1/user.go | 331 ++ .../migrate/entv1/user/mutation.go | 1079 ++++ entc/integration/migrate/entv1/user/user.go | 278 + entc/integration/migrate/entv1/user/where.go | 985 ++++ entc/integration/migrate/entv1/user_create.go | 555 ++ entc/integration/migrate/entv1/user_delete.go | 92 + entc/integration/migrate/entv1/user_query.go | 837 +++ entc/integration/migrate/entv1/user_update.go | 1278 +++++ entc/integration/migrate/entv2/blog.go | 131 + entc/integration/migrate/entv2/blog/blog.go | 84 + .../migrate/entv2/blog/mutation.go | 362 ++ entc/integration/migrate/entv2/blog/where.go | 141 + entc/integration/migrate/entv2/blog_create.go | 235 + entc/integration/migrate/entv2/blog_delete.go | 92 + entc/integration/migrate/entv2/blog_query.go | 611 +++ entc/integration/migrate/entv2/blog_update.go | 396 ++ entc/integration/migrate/entv2/car.go | 146 + entc/integration/migrate/entv2/car/car.go | 88 + .../integration/migrate/entv2/car/mutation.go | 322 ++ entc/integration/migrate/entv2/car/where.go | 176 + entc/integration/migrate/entv2/car_create.go | 224 + entc/integration/migrate/entv2/car_delete.go | 92 + entc/integration/migrate/entv2/car_query.go | 618 +++ entc/integration/migrate/entv2/car_update.go | 346 ++ entc/integration/migrate/entv2/client.go | 1580 ++++++ entc/integration/migrate/entv2/conversion.go | 195 + .../migrate/entv2/conversion/conversion.go | 115 + .../migrate/entv2/conversion/mutation.go | 687 +++ .../migrate/entv2/conversion/where.go | 792 +++ .../migrate/entv2/conversion_create.go | 335 ++ .../migrate/entv2/conversion_delete.go | 92 + .../migrate/entv2/conversion_query.go | 531 ++ .../migrate/entv2/conversion_update.go | 647 +++ entc/integration/migrate/entv2/customtype.go | 132 + .../migrate/entv2/customtype/customtype.go | 67 + .../migrate/entv2/customtype/mutation.go | 364 ++ .../migrate/entv2/customtype/where.go | 264 + .../migrate/entv2/customtype_create.go | 228 + .../migrate/entv2/customtype_delete.go | 92 + .../migrate/entv2/customtype_query.go | 531 ++ .../migrate/entv2/customtype_update.go | 336 ++ entc/integration/migrate/entv2/ent.go | 628 +++ .../migrate/entv2/enttest/enttest.go | 88 + entc/integration/migrate/entv2/generate.go | 7 + entc/integration/migrate/entv2/group.go | 95 + entc/integration/migrate/entv2/group/group.go | 43 + .../migrate/entv2/group/mutation.go | 192 + entc/integration/migrate/entv2/group/where.go | 72 + .../integration/migrate/entv2/group_create.go | 173 + .../integration/migrate/entv2/group_delete.go | 92 + entc/integration/migrate/entv2/group_query.go | 509 ++ .../integration/migrate/entv2/group_update.go | 179 + entc/integration/migrate/entv2/hook/hook.go | 299 ++ entc/integration/migrate/entv2/media.go | 129 + entc/integration/migrate/entv2/media/media.go | 67 + .../migrate/entv2/media/mutation.go | 363 ++ entc/integration/migrate/entv2/media/where.go | 312 ++ .../integration/migrate/entv2/media_create.go | 227 + .../integration/migrate/entv2/media_delete.go | 92 + entc/integration/migrate/entv2/media_query.go | 531 ++ .../integration/migrate/entv2/media_update.go | 335 ++ .../migrate/entv2/migrate/migrate.go | 100 + .../migrate/entv2/migrate/schema.go | 309 ++ entc/integration/migrate/entv2/mutation.go | 1827 +++++++ entc/integration/migrate/entv2/pet.go | 146 + .../integration/migrate/entv2/pet/mutation.go | 322 ++ entc/integration/migrate/entv2/pet/pet.go | 88 + entc/integration/migrate/entv2/pet/where.go | 176 + entc/integration/migrate/entv2/pet_create.go | 228 + entc/integration/migrate/entv2/pet_delete.go | 92 + entc/integration/migrate/entv2/pet_query.go | 618 +++ entc/integration/migrate/entv2/pet_update.go | 340 ++ .../migrate/entv2/predicate/predicate.go | 38 + entc/integration/migrate/entv2/runtime.go | 65 + .../migrate/entv2/runtime/runtime.go | 13 + .../migrate/entv2/schema/conversion.go | 51 + .../migrate/entv2/schema/customtype.go | 39 + .../integration/migrate/entv2/schema/media.go | 57 + entc/integration/migrate/entv2/schema/user.go | 260 + entc/integration/migrate/entv2/schema/zoo.go | 31 + entc/integration/migrate/entv2/tx.go | 238 + entc/integration/migrate/entv2/user.go | 408 ++ .../migrate/entv2/user/mutation.go | 1415 +++++ entc/integration/migrate/entv2/user/user.go | 398 ++ entc/integration/migrate/entv2/user/where.go | 1339 +++++ entc/integration/migrate/entv2/user_create.go | 720 +++ entc/integration/migrate/entv2/user_delete.go | 92 + entc/integration/migrate/entv2/user_query.go | 791 +++ entc/integration/migrate/entv2/user_update.go | 1569 ++++++ entc/integration/migrate/entv2/zoo.go | 95 + .../integration/migrate/entv2/zoo/mutation.go | 192 + entc/integration/migrate/entv2/zoo/where.go | 72 + entc/integration/migrate/entv2/zoo/zoo.go | 43 + entc/integration/migrate/entv2/zoo_create.go | 185 + entc/integration/migrate/entv2/zoo_delete.go | 92 + entc/integration/migrate/entv2/zoo_query.go | 509 ++ entc/integration/migrate/entv2/zoo_update.go | 179 + entc/integration/migrate/migrate_test.go | 871 ++++ entc/integration/migrate/versioned/client.go | 487 ++ entc/integration/migrate/versioned/ent.go | 614 +++ .../migrate/versioned/enttest/enttest.go | 88 + .../integration/migrate/versioned/generate.go | 7 + entc/integration/migrate/versioned/group.go | 107 + .../migrate/versioned/group/group.go | 51 + .../migrate/versioned/group/mutation.go | 233 + .../migrate/versioned/group/where.go | 142 + .../migrate/versioned/group_create.go | 187 + .../migrate/versioned/group_delete.go | 92 + .../migrate/versioned/group_query.go | 531 ++ .../migrate/versioned/group_update.go | 213 + .../migrate/versioned/hook/hook.go | 215 + .../migrate/versioned/migrate/migrate.go | 100 + .../migrate/versioned/migrate/schema.go | 56 + .../integration/migrate/versioned/mutation.go | 335 ++ .../migrate/versioned/predicate/predicate.go | 17 + entc/integration/migrate/versioned/runtime.go | 24 + .../migrate/versioned/runtime/runtime.go | 13 + .../migrate/versioned/schema/group.go | 35 + .../migrate/versioned/schema/user.go | 35 + entc/integration/migrate/versioned/tx.go | 217 + entc/integration/migrate/versioned/user.go | 129 + .../migrate/versioned/user/mutation.go | 361 ++ .../migrate/versioned/user/user.go | 72 + .../migrate/versioned/user/where.go | 267 + .../migrate/versioned/user_create.go | 223 + .../migrate/versioned/user_delete.go | 92 + .../migrate/versioned/user_query.go | 531 ++ .../migrate/versioned/user_update.go | 345 ++ entc/integration/multischema/README.md | 32 + entc/integration/multischema/ent/cleanuser.go | 89 + .../multischema/ent/cleanuser/cleanuser.go | 43 + .../multischema/ent/cleanuser/where.go | 97 + .../multischema/ent/cleanuser_query.go | 486 ++ entc/integration/multischema/ent/client.go | 1236 +++++ entc/integration/multischema/ent/ent.go | 622 +++ .../multischema/ent/enttest/enttest.go | 88 + .../integration/multischema/ent/friendship.go | 188 + .../multischema/ent/friendship/friendship.go | 131 + .../multischema/ent/friendship/mutation.go | 475 ++ .../multischema/ent/friendship/where.go | 274 + .../multischema/ent/friendship_create.go | 303 ++ .../multischema/ent/friendship_delete.go | 95 + .../multischema/ent/friendship_query.go | 721 +++ .../multischema/ent/friendship_update.go | 317 ++ entc/integration/multischema/ent/generate.go | 7 + entc/integration/multischema/ent/group.go | 133 + .../multischema/ent/group/group.go | 91 + .../multischema/ent/group/mutation.go | 326 ++ .../multischema/ent/group/where.go | 173 + .../multischema/ent/group_create.go | 239 + .../multischema/ent/group_delete.go | 95 + .../multischema/ent/group_query.go | 676 +++ .../multischema/ent/group_update.go | 403 ++ entc/integration/multischema/ent/hook/hook.go | 251 + .../multischema/ent/internal/schemaconfig.go | 36 + .../multischema/ent/migrate/migrate.go | 68 + .../multischema/ent/migrate/schema.go | 175 + entc/integration/multischema/ent/mutation.go | 818 +++ entc/integration/multischema/ent/parent.go | 176 + .../multischema/ent/parent/mutation.go | 419 ++ .../multischema/ent/parent/parent.go | 119 + .../multischema/ent/parent/where.go | 197 + .../multischema/ent/parent_create.go | 283 + .../multischema/ent/parent_delete.go | 95 + .../multischema/ent/parent_query.go | 721 +++ .../multischema/ent/parent_update.go | 262 + entc/integration/multischema/ent/pet.go | 147 + .../multischema/ent/pet/mutation.go | 347 ++ entc/integration/multischema/ent/pet/pet.go | 88 + entc/integration/multischema/ent/pet/where.go | 208 + .../integration/multischema/ent/pet_create.go | 244 + .../integration/multischema/ent/pet_delete.go | 95 + entc/integration/multischema/ent/pet_query.go | 644 +++ .../integration/multischema/ent/pet_update.go | 359 ++ .../multischema/ent/predicate/predicate.go | 29 + entc/integration/multischema/ent/runtime.go | 58 + .../multischema/ent/runtime/runtime.go | 13 + .../multischema/ent/schema/base.go | 19 + .../multischema/ent/schema/friendship.go | 65 + .../multischema/ent/schema/group.go | 40 + .../multischema/ent/schema/parent.go | 44 + .../integration/multischema/ent/schema/pet.go | 36 + .../multischema/ent/schema/user.go | 60 + .../multischema/ent/template/config.tmpl | 16 + entc/integration/multischema/ent/tx.go | 229 + entc/integration/multischema/ent/user.go | 229 + .../multischema/ent/user/mutation.go | 824 +++ entc/integration/multischema/ent/user/user.go | 265 + .../integration/multischema/ent/user/where.go | 347 ++ .../multischema/ent/user_create.go | 442 ++ .../multischema/ent/user_delete.go | 95 + .../integration/multischema/ent/user_query.go | 1231 +++++ .../multischema/ent/user_update.go | 1462 ++++++ .../multischema/migrations/20231112192027.sql | 59 + .../multischema/migrations/atlas.sum | 2 + .../multischema/multischema_test.go | 287 + .../multischema/versioned/client.go | 994 ++++ entc/integration/multischema/versioned/ent.go | 618 +++ .../integration/multischema/versioned/entc.go | 30 + .../multischema/versioned/enttest/enttest.go | 88 + .../multischema/versioned/friendship.go | 188 + .../versioned/friendship/friendship.go | 131 + .../versioned/friendship/mutation.go | 475 ++ .../multischema/versioned/friendship/where.go | 274 + .../versioned/friendship_create.go | 303 ++ .../versioned/friendship_delete.go | 95 + .../multischema/versioned/friendship_query.go | 721 +++ .../versioned/friendship_update.go | 317 ++ .../multischema/versioned/generate.go | 7 + .../multischema/versioned/group.go | 133 + .../multischema/versioned/group/group.go | 91 + .../multischema/versioned/group/mutation.go | 326 ++ .../multischema/versioned/group/where.go | 173 + .../multischema/versioned/group_create.go | 239 + .../multischema/versioned/group_delete.go | 95 + .../multischema/versioned/group_query.go | 676 +++ .../multischema/versioned/group_update.go | 403 ++ .../multischema/versioned/hook/hook.go | 239 + .../versioned/internal/schemaconfig.go | 34 + .../multischema/versioned/migrate/migrate.go | 68 + .../multischema/versioned/migrate/schema.go | 166 + .../multischema/versioned/mutation.go | 644 +++ entc/integration/multischema/versioned/pet.go | 147 + .../multischema/versioned/pet/mutation.go | 347 ++ .../multischema/versioned/pet/pet.go | 88 + .../multischema/versioned/pet/where.go | 208 + .../multischema/versioned/pet_create.go | 244 + .../multischema/versioned/pet_delete.go | 95 + .../multischema/versioned/pet_query.go | 644 +++ .../multischema/versioned/pet_update.go | 359 ++ .../versioned/predicate/predicate.go | 23 + .../multischema/versioned/runtime.go | 51 + .../multischema/versioned/runtime/runtime.go | 13 + .../versioned/schema/friendship.go | 65 + .../multischema/versioned/schema/group.go | 34 + .../multischema/versioned/schema/mixin.go | 44 + .../multischema/versioned/schema/pet.go | 45 + .../multischema/versioned/schema/user.go | 50 + entc/integration/multischema/versioned/tx.go | 223 + .../integration/multischema/versioned/user.go | 213 + .../multischema/versioned/user/mutation.go | 741 +++ .../multischema/versioned/user/user.go | 235 + .../multischema/versioned/user/where.go | 318 ++ .../multischema/versioned/user_create.go | 405 ++ .../multischema/versioned/user_delete.go | 95 + .../multischema/versioned/user_query.go | 1154 +++++ .../multischema/versioned/user_update.go | 1269 +++++ entc/integration/privacy/ent/client.go | 740 +++ entc/integration/privacy/ent/ent.go | 616 +++ entc/integration/privacy/ent/entc.go | 43 + entc/integration/privacy/ent/entql.go | 388 ++ .../privacy/ent/enttest/enttest.go | 88 + entc/integration/privacy/ent/generate.go | 7 + entc/integration/privacy/ent/hook/hook.go | 227 + .../privacy/ent/internal/schema.go | 13 + .../privacy/ent/migrate/migrate.go | 68 + .../integration/privacy/ent/migrate/schema.go | 132 + entc/integration/privacy/ent/mutation.go | 509 ++ .../privacy/ent/predicate/predicate.go | 20 + .../privacy/ent/privacy/privacy.go | 247 + entc/integration/privacy/ent/runtime.go | 9 + .../privacy/ent/runtime/runtime.go | 80 + entc/integration/privacy/ent/schema/base.go | 31 + entc/integration/privacy/ent/schema/task.go | 73 + entc/integration/privacy/ent/schema/team.go | 75 + entc/integration/privacy/ent/schema/user.go | 69 + entc/integration/privacy/ent/task.go | 198 + entc/integration/privacy/ent/task/mutation.go | 532 ++ entc/integration/privacy/ent/task/task.go | 186 + entc/integration/privacy/ent/task/where.go | 345 ++ entc/integration/privacy/ent/task_create.go | 337 ++ entc/integration/privacy/ent/task_delete.go | 92 + entc/integration/privacy/ent/task_query.go | 731 +++ entc/integration/privacy/ent/task_update.go | 660 +++ entc/integration/privacy/ent/team.go | 149 + entc/integration/privacy/ent/team/mutation.go | 409 ++ entc/integration/privacy/ent/team/team.go | 130 + entc/integration/privacy/ent/team/where.go | 189 + entc/integration/privacy/ent/team_create.go | 256 + entc/integration/privacy/ent/team_delete.go | 92 + entc/integration/privacy/ent/team_query.go | 753 +++ entc/integration/privacy/ent/team_update.go | 565 ++ entc/integration/privacy/ent/tx.go | 220 + entc/integration/privacy/ent/user.go | 160 + entc/integration/privacy/ent/user/mutation.go | 503 ++ entc/integration/privacy/ent/user/user.go | 137 + entc/integration/privacy/ent/user/where.go | 244 + entc/integration/privacy/ent/user_create.go | 274 + entc/integration/privacy/ent/user_delete.go | 92 + entc/integration/privacy/ent/user_query.go | 723 +++ entc/integration/privacy/ent/user_update.go | 577 +++ entc/integration/privacy/privacy_test.go | 82 + entc/integration/privacy/rule/rule.go | 211 + entc/integration/privacy/viewer/viewer.go | 84 + entc/integration/relation_test.go | 1183 +++++ entc/integration/template/ent/client.go | 703 +++ entc/integration/template/ent/ent.go | 616 +++ .../template/ent/enttest/enttest.go | 88 + entc/integration/template/ent/generate.go | 7 + entc/integration/template/ent/group.go | 105 + entc/integration/template/ent/group/group.go | 51 + .../template/ent/group/mutation.go | 269 + entc/integration/template/ent/group/where.go | 117 + entc/integration/template/ent/group_create.go | 187 + entc/integration/template/ent/group_delete.go | 92 + entc/integration/template/ent/group_query.go | 548 ++ entc/integration/template/ent/group_update.go | 233 + entc/integration/template/ent/hook/hook.go | 227 + .../template/ent/migrate/migrate.go | 68 + .../template/ent/migrate/schema.go | 96 + entc/integration/template/ent/mutation.go | 452 ++ entc/integration/template/ent/node.go | 240 + entc/integration/template/ent/pet.go | 146 + entc/integration/template/ent/pet/mutation.go | 394 ++ entc/integration/template/ent/pet/pet.go | 94 + entc/integration/template/ent/pet/where.go | 198 + entc/integration/template/ent/pet_create.go | 243 + entc/integration/template/ent/pet_delete.go | 92 + entc/integration/template/ent/pet_query.go | 635 +++ entc/integration/template/ent/pet_update.go | 395 ++ .../template/ent/predicate/predicate.go | 20 + entc/integration/template/ent/runtime.go | 13 + .../template/ent/runtime/runtime.go | 13 + entc/integration/template/ent/schema/group.go | 27 + entc/integration/template/ent/schema/pet.go | 33 + entc/integration/template/ent/schema/user.go | 39 + .../template/ent/template/client.tmpl | 60 + .../template/ent/template/config.tmpl | 17 + .../template/ent/template/helper.tmpl | 21 + .../template/ent/template/modifier.tmpl | 30 + .../template/ent/template/node.tmpl | 191 + .../template/ent/template/query.tmpl | 17 + .../template/ent/template/stringer.tmpl | 46 + .../template/ent/template/where.tmpl | 21 + entc/integration/template/ent/tx.go | 220 + entc/integration/template/ent/user.go | 149 + .../integration/template/ent/user/mutation.go | 409 ++ entc/integration/template/ent/user/user.go | 113 + entc/integration/template/ent/user/where.go | 200 + entc/integration/template/ent/user_create.go | 250 + entc/integration/template/ent/user_delete.go | 92 + entc/integration/template/ent/user_query.go | 732 +++ entc/integration/template/ent/user_update.go | 538 ++ entc/integration/template/template_test.go | 96 + entc/integration/type_test.go | 230 + entc/internal/snapshot.go | 247 + entc/internal/snapshot_test.go | 70 + entc/internal/vcs.go | 46 + entc/internal/vcs_test.go | 37 + entc/load/load.go | 357 ++ entc/load/load_test.go | 99 + entc/load/schema.go | 521 ++ entc/load/schema_test.go | 572 ++ entc/load/template/main.tmpl | 44 + entc/load/testdata/base/schema.go | 33 + entc/load/testdata/buildflags/group.go | 24 + entc/load/testdata/buildflags/user.go | 22 + entc/load/testdata/cycle/fakent/fakent.go | 15 + entc/load/testdata/cycle/schema.go | 36 + entc/load/testdata/failure/user.go | 20 + entc/load/testdata/invalid/schema.go | 8 + entc/load/testdata/valid/schema.go | 45 + entql/entql.go | 464 ++ entql/entql_test.go | 81 + entql/internal/gen.go | 96 + entql/internal/types.tmpl | 108 + entql/types.go | 1980 +++++++ entql/types_test.go | 84 + examples/README.md | 1023 ++++ examples/compositetypes/README.md | 3 + examples/compositetypes/atlas.hcl | 15 + examples/compositetypes/ent/client.go | 340 ++ examples/compositetypes/ent/ent.go | 608 +++ .../compositetypes/ent/enttest/enttest.go | 84 + examples/compositetypes/ent/generate.go | 3 + examples/compositetypes/ent/hook/hook.go | 199 + .../compositetypes/ent/migrate/migrate.go | 64 + examples/compositetypes/ent/migrate/schema.go | 29 + examples/compositetypes/ent/mutation.go | 159 + .../compositetypes/ent/predicate/predicate.go | 10 + examples/compositetypes/ent/runtime.go | 9 + .../compositetypes/ent/runtime/runtime.go | 9 + examples/compositetypes/ent/schema/user.go | 53 + examples/compositetypes/ent/tx.go | 210 + examples/compositetypes/ent/user.go | 104 + examples/compositetypes/ent/user/mutation.go | 230 + examples/compositetypes/ent/user/user.go | 47 + examples/compositetypes/ent/user/where.go | 114 + examples/compositetypes/ent/user_create.go | 184 + examples/compositetypes/ent/user_delete.go | 88 + examples/compositetypes/ent/user_query.go | 527 ++ examples/compositetypes/ent/user_update.go | 194 + examples/compositetypes/example_test.go | 50 + .../migrations/20240713195943.sql | 4 + examples/compositetypes/migrations/atlas.sum | 2 + examples/compositetypes/schema.sql | 4 + examples/domaintypes/README.md | 3 + examples/domaintypes/atlas.hcl | 15 + examples/domaintypes/ent/client.go | 340 ++ examples/domaintypes/ent/ent.go | 608 +++ examples/domaintypes/ent/enttest/enttest.go | 84 + examples/domaintypes/ent/generate.go | 3 + examples/domaintypes/ent/hook/hook.go | 199 + examples/domaintypes/ent/migrate/migrate.go | 64 + examples/domaintypes/ent/migrate/schema.go | 29 + examples/domaintypes/ent/mutation.go | 158 + .../domaintypes/ent/predicate/predicate.go | 10 + examples/domaintypes/ent/runtime.go | 9 + examples/domaintypes/ent/runtime/runtime.go | 9 + examples/domaintypes/ent/schema/user.go | 29 + examples/domaintypes/ent/tx.go | 210 + examples/domaintypes/ent/user.go | 103 + examples/domaintypes/ent/user/mutation.go | 229 + examples/domaintypes/ent/user/user.go | 47 + examples/domaintypes/ent/user/where.go | 138 + examples/domaintypes/ent/user_create.go | 183 + examples/domaintypes/ent/user_delete.go | 88 + examples/domaintypes/ent/user_query.go | 527 ++ examples/domaintypes/ent/user_update.go | 209 + examples/domaintypes/example_test.go | 48 + .../domaintypes/migrations/20240712090543.sql | 4 + examples/domaintypes/migrations/atlas.sum | 2 + examples/domaintypes/schema.sql | 5 + examples/edgeindex/README.md | 16 + examples/edgeindex/ent/city.go | 133 + examples/edgeindex/ent/city/city.go | 82 + examples/edgeindex/ent/city/mutation.go | 326 ++ examples/edgeindex/ent/city/where.go | 166 + examples/edgeindex/ent/city_create.go | 219 + examples/edgeindex/ent/city_delete.go | 92 + examples/edgeindex/ent/city_query.go | 611 +++ examples/edgeindex/ent/city_update.go | 376 ++ examples/edgeindex/ent/client.go | 520 ++ examples/edgeindex/ent/ent.go | 614 +++ examples/edgeindex/ent/enttest/enttest.go | 88 + examples/edgeindex/ent/generate.go | 7 + examples/edgeindex/ent/hook/hook.go | 215 + examples/edgeindex/ent/migrate/migrate.go | 68 + examples/edgeindex/ent/migrate/schema.go | 62 + examples/edgeindex/ent/mutation.go | 297 ++ examples/edgeindex/ent/predicate/predicate.go | 17 + examples/edgeindex/ent/runtime.go | 13 + examples/edgeindex/ent/runtime/runtime.go | 13 + examples/edgeindex/ent/schema/city.go | 30 + examples/edgeindex/ent/schema/street.go | 42 + examples/edgeindex/ent/street.go | 146 + examples/edgeindex/ent/street/mutation.go | 300 ++ examples/edgeindex/ent/street/street.go | 86 + examples/edgeindex/ent/street/where.go | 166 + examples/edgeindex/ent/street_create.go | 224 + examples/edgeindex/ent/street_delete.go | 92 + examples/edgeindex/ent/street_query.go | 618 +++ examples/edgeindex/ent/street_update.go | 322 ++ examples/edgeindex/ent/tx.go | 217 + examples/edgeindex/example_test.go | 66 + examples/encryptfield/README.md | 35 + examples/encryptfield/ent/client.go | 355 ++ examples/encryptfield/ent/ent.go | 612 +++ examples/encryptfield/ent/entc.go | 36 + examples/encryptfield/ent/enttest/enttest.go | 88 + examples/encryptfield/ent/generate.go | 7 + examples/encryptfield/ent/hook/hook.go | 203 + .../encryptfield/ent/intercept/intercept.go | 153 + examples/encryptfield/ent/migrate/migrate.go | 68 + examples/encryptfield/ent/migrate/schema.go | 34 + examples/encryptfield/ent/mutation.go | 181 + .../encryptfield/ent/predicate/predicate.go | 14 + examples/encryptfield/ent/runtime.go | 9 + examples/encryptfield/ent/runtime/runtime.go | 26 + examples/encryptfield/ent/schema/user.go | 104 + examples/encryptfield/ent/tx.go | 214 + examples/encryptfield/ent/user.go | 118 + examples/encryptfield/ent/user/mutation.go | 290 ++ examples/encryptfield/ent/user/user.go | 70 + examples/encryptfield/ent/user/where.go | 222 + examples/encryptfield/ent/user_create.go | 205 + examples/encryptfield/ent/user_delete.go | 92 + examples/encryptfield/ent/user_query.go | 531 ++ examples/encryptfield/ent/user_update.go | 265 + examples/encryptfield/example_test.go | 69 + examples/entcpkg/README.md | 63 + examples/entcpkg/ent/client.go | 363 ++ examples/entcpkg/ent/debug.go | 25 + examples/entcpkg/ent/ent.go | 612 +++ examples/entcpkg/ent/entc.go | 165 + examples/entcpkg/ent/enttest/enttest.go | 88 + examples/entcpkg/ent/generate.go | 7 + examples/entcpkg/ent/hook/hook.go | 203 + examples/entcpkg/ent/migrate/migrate.go | 68 + examples/entcpkg/ent/migrate/schema.go | 34 + examples/entcpkg/ent/mutation.go | 181 + examples/entcpkg/ent/predicate/predicate.go | 14 + examples/entcpkg/ent/runtime.go | 13 + examples/entcpkg/ent/runtime/runtime.go | 13 + examples/entcpkg/ent/schema/user.go | 30 + examples/entcpkg/ent/template/debug.tmpl | 37 + examples/entcpkg/ent/template/entinit.tmpl | 25 + examples/entcpkg/ent/template/static.tmpl | 12 + examples/entcpkg/ent/template/stringer.tmpl | 16 + examples/entcpkg/ent/tx.go | 214 + examples/entcpkg/ent/user.go | 130 + examples/entcpkg/ent/user/mutation.go | 346 ++ examples/entcpkg/ent/user/user.go | 59 + examples/entcpkg/ent/user/where.go | 207 + examples/entcpkg/ent/user_create.go | 209 + examples/entcpkg/ent/user_delete.go | 92 + examples/entcpkg/ent/user_query.go | 531 ++ examples/entcpkg/ent/user_update.go | 303 ++ examples/entcpkg/example_test.go | 48 + examples/enumtypes/README.md | 3 + examples/enumtypes/atlas.hcl | 15 + examples/enumtypes/ent/client.go | 340 ++ examples/enumtypes/ent/ent.go | 608 +++ examples/enumtypes/ent/enttest/enttest.go | 84 + examples/enumtypes/ent/generate.go | 3 + examples/enumtypes/ent/hook/hook.go | 199 + examples/enumtypes/ent/migrate/migrate.go | 64 + examples/enumtypes/ent/migrate/schema.go | 29 + examples/enumtypes/ent/mutation.go | 158 + examples/enumtypes/ent/predicate/predicate.go | 10 + examples/enumtypes/ent/runtime.go | 9 + examples/enumtypes/ent/runtime/runtime.go | 9 + examples/enumtypes/ent/schema/user.go | 27 + examples/enumtypes/ent/tx.go | 210 + examples/enumtypes/ent/user.go | 103 + examples/enumtypes/ent/user/mutation.go | 229 + examples/enumtypes/ent/user/user.go | 73 + examples/enumtypes/ent/user/where.go | 88 + examples/enumtypes/ent/user_create.go | 188 + examples/enumtypes/ent/user_delete.go | 88 + examples/enumtypes/ent/user_query.go | 527 ++ examples/enumtypes/ent/user_update.go | 235 + examples/enumtypes/example_test.go | 47 + .../enumtypes/migrations/20240712145529.sql | 4 + examples/enumtypes/migrations/atlas.sum | 2 + examples/enumtypes/schema.sql | 1 + examples/extensions/README.md | 3 + examples/extensions/atlas.hcl | 20 + examples/extensions/ent/client.go | 340 ++ examples/extensions/ent/ent.go | 608 +++ examples/extensions/ent/enttest/enttest.go | 84 + examples/extensions/ent/generate.go | 3 + examples/extensions/ent/hook/hook.go | 199 + examples/extensions/ent/migrate/migrate.go | 64 + examples/extensions/ent/migrate/schema.go | 29 + examples/extensions/ent/mutation.go | 158 + .../extensions/ent/predicate/predicate.go | 10 + examples/extensions/ent/runtime.go | 9 + examples/extensions/ent/runtime/runtime.go | 9 + examples/extensions/ent/schema/user.go | 28 + examples/extensions/ent/tx.go | 210 + examples/extensions/ent/user.go | 103 + examples/extensions/ent/user/mutation.go | 229 + examples/extensions/ent/user/user.go | 42 + examples/extensions/ent/user/where.go | 113 + examples/extensions/ent/user_create.go | 183 + examples/extensions/ent/user_delete.go | 88 + examples/extensions/ent/user_query.go | 527 ++ examples/extensions/ent/user_update.go | 193 + .../extensions/migrations/20240713193406.sql | 8 + examples/extensions/migrations/atlas.sum | 2 + examples/extensions/schema.sql | 2 + examples/fs/README.md | 44 + examples/fs/ent/client.go | 377 ++ examples/fs/ent/ent.go | 612 +++ examples/fs/ent/enttest/enttest.go | 88 + examples/fs/ent/file.go | 175 + examples/fs/ent/file/file.go | 120 + examples/fs/ent/file/mutation.go | 467 ++ examples/fs/ent/file/where.go | 239 + examples/fs/ent/file_create.go | 285 + examples/fs/ent/file_delete.go | 92 + examples/fs/ent/file_query.go | 683 +++ examples/fs/ent/file_update.go | 529 ++ examples/fs/ent/generate.go | 7 + examples/fs/ent/hook/hook.go | 203 + examples/fs/ent/migrate/migrate.go | 68 + examples/fs/ent/migrate/schema.go | 44 + examples/fs/ent/mutation.go | 200 + examples/fs/ent/predicate/predicate.go | 14 + examples/fs/ent/runtime.go | 24 + examples/fs/ent/runtime/runtime.go | 13 + examples/fs/ent/schema/file.go | 37 + examples/fs/ent/tx.go | 214 + examples/fs/example_test.go | 97 + examples/functionalidx/README.md | 3 + examples/functionalidx/atlas.hcl | 15 + examples/functionalidx/ent/client.go | 340 ++ examples/functionalidx/ent/ent.go | 608 +++ examples/functionalidx/ent/enttest/enttest.go | 84 + examples/functionalidx/ent/generate.go | 3 + examples/functionalidx/ent/hook/hook.go | 199 + examples/functionalidx/ent/migrate/migrate.go | 64 + examples/functionalidx/ent/migrate/schema.go | 29 + examples/functionalidx/ent/mutation.go | 158 + .../functionalidx/ent/predicate/predicate.go | 10 + examples/functionalidx/ent/runtime.go | 9 + examples/functionalidx/ent/runtime/runtime.go | 9 + examples/functionalidx/ent/schema/user.go | 23 + examples/functionalidx/ent/tx.go | 210 + examples/functionalidx/ent/user.go | 103 + examples/functionalidx/ent/user/mutation.go | 229 + examples/functionalidx/ent/user/user.go | 47 + examples/functionalidx/ent/user/where.go | 138 + examples/functionalidx/ent/user_create.go | 183 + examples/functionalidx/ent/user_delete.go | 88 + examples/functionalidx/ent/user_query.go | 527 ++ examples/functionalidx/ent/user_update.go | 209 + examples/functionalidx/example_test.go | 61 + .../migrations/20240720083105.sql | 4 + examples/functionalidx/migrations/atlas.sum | 2 + examples/functionalidx/schema.sql | 2 + examples/go.mod | 47 + examples/jsonencode/README.md | 16 + examples/jsonencode/ent/card.go | 107 + examples/jsonencode/ent/card/card.go | 51 + examples/jsonencode/ent/card/mutation.go | 233 + examples/jsonencode/ent/card/where.go | 142 + examples/jsonencode/ent/card_create.go | 187 + examples/jsonencode/ent/card_delete.go | 92 + examples/jsonencode/ent/card_query.go | 531 ++ examples/jsonencode/ent/card_update.go | 213 + examples/jsonencode/ent/client.go | 663 +++ examples/jsonencode/ent/ent.go | 616 +++ examples/jsonencode/ent/entc.go | 81 + examples/jsonencode/ent/enttest/enttest.go | 88 + examples/jsonencode/ent/generate.go | 7 + examples/jsonencode/ent/hook/hook.go | 227 + examples/jsonencode/ent/migrate/migrate.go | 68 + examples/jsonencode/ent/migrate/schema.go | 69 + examples/jsonencode/ent/mutation.go | 489 ++ examples/jsonencode/ent/pet.go | 171 + examples/jsonencode/ent/pet/mutation.go | 393 ++ examples/jsonencode/ent/pet/pet.go | 91 + examples/jsonencode/ent/pet/where.go | 236 + examples/jsonencode/ent/pet_create.go | 235 + examples/jsonencode/ent/pet_delete.go | 92 + examples/jsonencode/ent/pet_query.go | 610 +++ examples/jsonencode/ent/pet_update.go | 398 ++ .../jsonencode/ent/predicate/predicate.go | 20 + examples/jsonencode/ent/runtime.go | 13 + examples/jsonencode/ent/runtime/runtime.go | 13 + examples/jsonencode/ent/schema/card.go | 22 + examples/jsonencode/ent/schema/pet.go | 36 + examples/jsonencode/ent/schema/user.go | 31 + examples/jsonencode/ent/tx.go | 220 + examples/jsonencode/ent/user.go | 157 + examples/jsonencode/ent/user/mutation.go | 397 ++ examples/jsonencode/ent/user/user.go | 90 + examples/jsonencode/ent/user/where.go | 211 + examples/jsonencode/ent/user_create.go | 232 + examples/jsonencode/ent/user_delete.go | 92 + examples/jsonencode/ent/user_query.go | 610 +++ examples/jsonencode/ent/user_update.go | 430 ++ examples/jsonencode/example_test.go | 62 + examples/m2m2types/README.md | 16 + examples/m2m2types/ent/client.go | 520 ++ examples/m2m2types/ent/ent.go | 614 +++ examples/m2m2types/ent/enttest/enttest.go | 88 + examples/m2m2types/ent/generate.go | 7 + examples/m2m2types/ent/group.go | 133 + examples/m2m2types/ent/group/group.go | 86 + examples/m2m2types/ent/group/mutation.go | 326 ++ examples/m2m2types/ent/group/where.go | 166 + examples/m2m2types/ent/group_create.go | 219 + examples/m2m2types/ent/group_delete.go | 92 + examples/m2m2types/ent/group_query.go | 641 +++ examples/m2m2types/ent/group_update.go | 376 ++ examples/m2m2types/ent/hook/hook.go | 215 + examples/m2m2types/ent/migrate/migrate.go | 68 + examples/m2m2types/ent/migrate/schema.go | 74 + examples/m2m2types/ent/mutation.go | 316 ++ examples/m2m2types/ent/predicate/predicate.go | 17 + examples/m2m2types/ent/runtime.go | 13 + examples/m2m2types/ent/runtime/runtime.go | 13 + examples/m2m2types/ent/schema/group.go | 30 + examples/m2m2types/ent/schema/user.go | 32 + examples/m2m2types/ent/tx.go | 217 + examples/m2m2types/ent/user.go | 144 + examples/m2m2types/ent/user/mutation.go | 397 ++ examples/m2m2types/ent/user/user.go | 94 + examples/m2m2types/ent/user/where.go | 211 + examples/m2m2types/ent/user_create.go | 232 + examples/m2m2types/ent/user_delete.go | 92 + examples/m2m2types/ent/user_query.go | 641 +++ examples/m2m2types/ent/user_update.go | 430 ++ examples/m2m2types/example_test.go | 102 + examples/m2mbidi/README.md | 16 + examples/m2mbidi/ent/client.go | 361 ++ examples/m2mbidi/ent/ent.go | 612 +++ examples/m2mbidi/ent/enttest/enttest.go | 88 + examples/m2mbidi/ent/generate.go | 7 + examples/m2mbidi/ent/hook/hook.go | 203 + examples/m2mbidi/ent/migrate/migrate.go | 68 + examples/m2mbidi/ent/migrate/schema.go | 62 + examples/m2mbidi/ent/mutation.go | 181 + examples/m2mbidi/ent/predicate/predicate.go | 14 + examples/m2mbidi/ent/runtime.go | 13 + examples/m2mbidi/ent/runtime/runtime.go | 13 + examples/m2mbidi/ent/schema/user.go | 31 + examples/m2mbidi/ent/tx.go | 214 + examples/m2mbidi/ent/user.go | 144 + examples/m2mbidi/ent/user/mutation.go | 397 ++ examples/m2mbidi/ent/user/user.go | 91 + examples/m2mbidi/ent/user/where.go | 211 + examples/m2mbidi/ent/user_create.go | 231 + examples/m2mbidi/ent/user_delete.go | 92 + examples/m2mbidi/ent/user_query.go | 640 +++ examples/m2mbidi/ent/user_update.go | 429 ++ examples/m2mbidi/example_test.go | 73 + examples/m2mrecur/README.md | 16 + examples/m2mrecur/ent/client.go | 377 ++ examples/m2mrecur/ent/ent.go | 612 +++ examples/m2mrecur/ent/enttest/enttest.go | 88 + examples/m2mrecur/ent/generate.go | 7 + examples/m2mrecur/ent/hook/hook.go | 203 + examples/m2mrecur/ent/migrate/migrate.go | 68 + examples/m2mrecur/ent/migrate/schema.go | 62 + examples/m2mrecur/ent/mutation.go | 181 + examples/m2mrecur/ent/predicate/predicate.go | 14 + examples/m2mrecur/ent/runtime.go | 13 + examples/m2mrecur/ent/runtime/runtime.go | 13 + examples/m2mrecur/ent/schema/user.go | 32 + examples/m2mrecur/ent/tx.go | 214 + examples/m2mrecur/ent/user.go | 160 + examples/m2mrecur/ent/user/mutation.go | 480 ++ examples/m2mrecur/ent/user/user.go | 119 + examples/m2mrecur/ent/user/where.go | 234 + examples/m2mrecur/ent/user_create.go | 262 + examples/m2mrecur/ent/user_delete.go | 92 + examples/m2mrecur/ent/user_query.go | 744 +++ examples/m2mrecur/ent/user_update.go | 591 +++ examples/m2mrecur/example_test.go | 92 + examples/migration/README.md | 31 + examples/migration/ent/card.go | 199 + examples/migration/ent/card/card.go | 144 + examples/migration/ent/card/mutation.go | 538 ++ examples/migration/ent/card/where.go | 411 ++ examples/migration/ent/card_create.go | 329 ++ examples/migration/ent/card_delete.go | 92 + examples/migration/ent/card_query.go | 685 +++ examples/migration/ent/card_update.go | 628 +++ examples/migration/ent/client.go | 1187 +++++ examples/migration/ent/ent.go | 622 +++ examples/migration/ent/enttest/enttest.go | 88 + examples/migration/ent/generate.go | 3 + examples/migration/ent/hook/hook.go | 263 + examples/migration/ent/migrate/main.go | 42 + examples/migration/ent/migrate/migrate.go | 100 + .../ent/migrate/migratedata/migratedata.go | 120 + .../20221114082343_create_users.sql | 2 + .../migrations/20221114090322_add_age.sql | 2 + .../migrations/20221114101516_add_name.sql | 2 + .../20221126173531_add_user_tags.sql | 2 + .../20221126185750_backfill_user_tags.sql | 4 + .../20230101214930_default_values.sql | 4 + .../20231231083716_add_card_number.sql | 2 + .../20231231091550_add_pet_name.sql | 2 + .../migrations/20231231101555_add_payment.sql | 2 + .../20231231102849_add_currency.sql | 2 + .../migrations/20231231120006_add_session.sql | 6 + .../migrations/20231231123114_hash_card.sql | 4 + .../20240229200634_add_card_type.sql | 2 + .../20240317101809_add_payment_vnd.sql | 2 + .../ent/migrate/migrations/atlas.sum | 15 + examples/migration/ent/migrate/schema.go | 213 + examples/migration/ent/mutation.go | 1332 +++++ examples/migration/ent/payment.go | 196 + examples/migration/ent/payment/mutation.go | 499 ++ examples/migration/ent/payment/payment.go | 171 + examples/migration/ent/payment/where.go | 323 ++ examples/migration/ent/payment_create.go | 290 ++ examples/migration/ent/payment_delete.go | 92 + examples/migration/ent/payment_query.go | 610 +++ examples/migration/ent/payment_update.go | 531 ++ examples/migration/ent/pet.go | 203 + examples/migration/ent/pet/mutation.go | 543 ++ examples/migration/ent/pet/pet.go | 135 + examples/migration/ent/pet/where.go | 330 ++ examples/migration/ent/pet_create.go | 324 ++ examples/migration/ent/pet_delete.go | 92 + examples/migration/ent/pet_query.go | 685 +++ examples/migration/ent/pet_update.go | 567 ++ examples/migration/ent/predicate/predicate.go | 29 + examples/migration/ent/runtime.go | 77 + examples/migration/ent/runtime/runtime.go | 13 + examples/migration/ent/schema/card.go | 55 + examples/migration/ent/schema/payment.go | 63 + examples/migration/ent/schema/pet.go | 58 + examples/migration/ent/schema/session.go | 100 + examples/migration/ent/schema/user.go | 52 + examples/migration/ent/session.go | 202 + examples/migration/ent/session/mutation.go | 543 ++ examples/migration/ent/session/session.go | 118 + examples/migration/ent/session/where.go | 339 ++ examples/migration/ent/session_create.go | 326 ++ examples/migration/ent/session_delete.go | 92 + examples/migration/ent/session_query.go | 611 +++ examples/migration/ent/session_update.go | 510 ++ examples/migration/ent/sessiondevice.go | 181 + .../migration/ent/sessiondevice/mutation.go | 490 ++ .../ent/sessiondevice/sessiondevice.go | 126 + examples/migration/ent/sessiondevice/where.go | 409 ++ .../migration/ent/sessiondevice_create.go | 322 ++ .../migration/ent/sessiondevice_delete.go | 92 + examples/migration/ent/sessiondevice_query.go | 611 +++ .../migration/ent/sessiondevice_update.go | 578 +++ examples/migration/ent/tx.go | 229 + examples/migration/ent/user.go | 173 + examples/migration/ent/user/mutation.go | 506 ++ examples/migration/ent/user/user.go | 101 + examples/migration/ent/user/where.go | 291 ++ examples/migration/ent/user_create.go | 255 + examples/migration/ent/user_delete.go | 92 + examples/migration/ent/user_query.go | 610 +++ examples/migration/ent/user_update.go | 523 ++ examples/o2m2types/README.md | 18 + examples/o2m2types/ent/client.go | 520 ++ examples/o2m2types/ent/ent.go | 614 +++ examples/o2m2types/ent/enttest/enttest.go | 88 + examples/o2m2types/ent/generate.go | 7 + examples/o2m2types/ent/hook/hook.go | 215 + examples/o2m2types/ent/migrate/migrate.go | 68 + examples/o2m2types/ent/migrate/schema.go | 56 + examples/o2m2types/ent/mutation.go | 316 ++ examples/o2m2types/ent/pet.go | 146 + examples/o2m2types/ent/pet/mutation.go | 300 ++ examples/o2m2types/ent/pet/pet.go | 86 + examples/o2m2types/ent/pet/where.go | 166 + examples/o2m2types/ent/pet_create.go | 224 + examples/o2m2types/ent/pet_delete.go | 92 + examples/o2m2types/ent/pet_query.go | 618 +++ examples/o2m2types/ent/pet_update.go | 322 ++ examples/o2m2types/ent/predicate/predicate.go | 17 + examples/o2m2types/ent/runtime.go | 13 + examples/o2m2types/ent/runtime/runtime.go | 13 + examples/o2m2types/ent/schema/pet.go | 32 + examples/o2m2types/ent/schema/user.go | 31 + examples/o2m2types/ent/tx.go | 217 + examples/o2m2types/ent/user.go | 144 + examples/o2m2types/ent/user/mutation.go | 397 ++ examples/o2m2types/ent/user/user.go | 90 + examples/o2m2types/ent/user/where.go | 211 + examples/o2m2types/ent/user_create.go | 232 + examples/o2m2types/ent/user_delete.go | 92 + examples/o2m2types/ent/user_query.go | 611 +++ examples/o2m2types/ent/user_update.go | 430 ++ examples/o2m2types/example_test.go | 79 + examples/o2mrecur/README.md | 17 + examples/o2mrecur/ent/client.go | 377 ++ examples/o2mrecur/ent/ent.go | 612 +++ examples/o2mrecur/ent/enttest/enttest.go | 88 + examples/o2mrecur/ent/generate.go | 7 + examples/o2mrecur/ent/hook/hook.go | 203 + examples/o2mrecur/ent/migrate/migrate.go | 68 + examples/o2mrecur/ent/migrate/schema.go | 43 + examples/o2mrecur/ent/mutation.go | 181 + examples/o2mrecur/ent/node.go | 160 + examples/o2mrecur/ent/node/mutation.go | 465 ++ examples/o2mrecur/ent/node/node.go | 107 + examples/o2mrecur/ent/node/where.go | 199 + examples/o2mrecur/ent/node_create.go | 254 + examples/o2mrecur/ent/node_delete.go | 92 + examples/o2mrecur/ent/node_query.go | 683 +++ examples/o2mrecur/ent/node_update.go | 515 ++ examples/o2mrecur/ent/predicate/predicate.go | 14 + examples/o2mrecur/ent/runtime.go | 13 + examples/o2mrecur/ent/runtime/runtime.go | 13 + examples/o2mrecur/ent/schema/node.go | 35 + examples/o2mrecur/ent/tx.go | 214 + examples/o2mrecur/example_test.go | 102 + examples/o2o2types/README.md | 15 + examples/o2o2types/ent/card.go | 160 + examples/o2o2types/ent/card/card.go | 94 + examples/o2o2types/ent/card/mutation.go | 336 ++ examples/o2o2types/ent/card/where.go | 213 + examples/o2o2types/ent/card_create.go | 233 + examples/o2o2types/ent/card_delete.go | 92 + examples/o2o2types/ent/card_query.go | 618 +++ examples/o2o2types/ent/card_update.go | 363 ++ examples/o2o2types/ent/client.go | 520 ++ examples/o2o2types/ent/ent.go | 614 +++ examples/o2o2types/ent/enttest/enttest.go | 88 + examples/o2o2types/ent/generate.go | 7 + examples/o2o2types/ent/hook/hook.go | 215 + examples/o2o2types/ent/migrate/migrate.go | 68 + examples/o2o2types/ent/migrate/schema.go | 57 + examples/o2o2types/ent/mutation.go | 336 ++ examples/o2o2types/ent/predicate/predicate.go | 17 + examples/o2o2types/ent/runtime.go | 13 + examples/o2o2types/ent/runtime/runtime.go | 13 + examples/o2o2types/ent/schema/card.go | 37 + examples/o2o2types/ent/schema/user.go | 32 + examples/o2o2types/ent/tx.go | 217 + examples/o2o2types/ent/user.go | 147 + examples/o2o2types/ent/user/mutation.go | 371 ++ examples/o2o2types/ent/user/user.go | 83 + examples/o2o2types/ent/user/where.go | 211 + examples/o2o2types/ent/user_create.go | 236 + examples/o2o2types/ent/user_delete.go | 92 + examples/o2o2types/ent/user_query.go | 607 +++ examples/o2o2types/ent/user_update.go | 376 ++ examples/o2o2types/example_test.go | 78 + examples/o2obidi/README.md | 17 + examples/o2obidi/ent/client.go | 361 ++ examples/o2obidi/ent/ent.go | 612 +++ examples/o2obidi/ent/enttest/enttest.go | 88 + examples/o2obidi/ent/generate.go | 7 + examples/o2obidi/ent/hook/hook.go | 203 + examples/o2obidi/ent/migrate/migrate.go | 68 + examples/o2obidi/ent/migrate/schema.go | 44 + examples/o2obidi/ent/mutation.go | 181 + examples/o2obidi/ent/predicate/predicate.go | 14 + examples/o2obidi/ent/runtime.go | 13 + examples/o2obidi/ent/runtime/runtime.go | 13 + examples/o2obidi/ent/schema/user.go | 32 + examples/o2obidi/ent/tx.go | 214 + examples/o2obidi/ent/user.go | 156 + examples/o2obidi/ent/user/mutation.go | 371 ++ examples/o2obidi/ent/user/user.go | 91 + examples/o2obidi/ent/user/where.go | 211 + examples/o2obidi/ent/user_create.go | 236 + examples/o2obidi/ent/user_delete.go | 92 + examples/o2obidi/ent/user_query.go | 617 +++ examples/o2obidi/ent/user_update.go | 375 ++ examples/o2obidi/example_test.go | 85 + examples/o2orecur/README.md | 17 + examples/o2orecur/ent/client.go | 377 ++ examples/o2orecur/ent/ent.go | 612 +++ examples/o2orecur/ent/enttest/enttest.go | 88 + examples/o2orecur/ent/generate.go | 7 + examples/o2orecur/ent/hook/hook.go | 203 + examples/o2orecur/ent/migrate/migrate.go | 68 + examples/o2orecur/ent/migrate/schema.go | 43 + examples/o2orecur/ent/mutation.go | 181 + examples/o2orecur/ent/node.go | 162 + examples/o2orecur/ent/node/mutation.go | 439 ++ examples/o2orecur/ent/node/node.go | 100 + examples/o2orecur/ent/node/where.go | 199 + examples/o2orecur/ent/node_create.go | 258 + examples/o2orecur/ent/node_delete.go | 92 + examples/o2orecur/ent/node_query.go | 679 +++ examples/o2orecur/ent/node_update.go | 461 ++ examples/o2orecur/ent/predicate/predicate.go | 14 + examples/o2orecur/ent/runtime.go | 13 + examples/o2orecur/ent/runtime/runtime.go | 13 + examples/o2orecur/ent/schema/node.go | 36 + examples/o2orecur/ent/tx.go | 214 + examples/o2orecur/example_test.go | 86 + examples/privacyadmin/README.md | 16 + examples/privacyadmin/ent/client.go | 345 ++ examples/privacyadmin/ent/ent.go | 612 +++ examples/privacyadmin/ent/enttest/enttest.go | 88 + examples/privacyadmin/ent/generate.go | 7 + examples/privacyadmin/ent/hook/hook.go | 203 + examples/privacyadmin/ent/internal/schema.go | 13 + examples/privacyadmin/ent/migrate/migrate.go | 68 + examples/privacyadmin/ent/migrate/schema.go | 33 + examples/privacyadmin/ent/mutation.go | 162 + .../privacyadmin/ent/predicate/predicate.go | 14 + examples/privacyadmin/ent/privacy/privacy.go | 139 + examples/privacyadmin/ent/runtime.go | 9 + examples/privacyadmin/ent/runtime/runtime.go | 42 + examples/privacyadmin/ent/schema/user.go | 39 + examples/privacyadmin/ent/tx.go | 214 + examples/privacyadmin/ent/user.go | 107 + examples/privacyadmin/ent/user/mutation.go | 233 + examples/privacyadmin/ent/user/user.go | 64 + examples/privacyadmin/ent/user/where.go | 142 + examples/privacyadmin/ent/user_create.go | 208 + examples/privacyadmin/ent/user_delete.go | 92 + examples/privacyadmin/ent/user_query.go | 538 ++ examples/privacyadmin/ent/user_update.go | 213 + examples/privacyadmin/example_test.go | 70 + examples/privacyadmin/rule/rule.go | 36 + examples/privacyadmin/viewer/viewer.go | 46 + examples/privacytenant/README.md | 15 + examples/privacytenant/ent/client.go | 698 +++ examples/privacytenant/ent/ent.go | 616 +++ examples/privacytenant/ent/entql.go | 330 ++ examples/privacytenant/ent/enttest/enttest.go | 88 + examples/privacytenant/ent/generate.go | 7 + examples/privacytenant/ent/group.go | 163 + examples/privacytenant/ent/group/group.go | 130 + examples/privacytenant/ent/group/mutation.go | 410 ++ examples/privacytenant/ent/group/where.go | 214 + examples/privacytenant/ent/group_create.go | 275 + examples/privacytenant/ent/group_delete.go | 92 + examples/privacytenant/ent/group_query.go | 723 +++ examples/privacytenant/ent/group_update.go | 398 ++ examples/privacytenant/ent/hook/hook.go | 227 + examples/privacytenant/ent/internal/schema.go | 13 + examples/privacytenant/ent/migrate/migrate.go | 68 + examples/privacytenant/ent/migrate/schema.go | 106 + examples/privacytenant/ent/mutation.go | 489 ++ .../privacytenant/ent/predicate/predicate.go | 20 + examples/privacytenant/ent/privacy/privacy.go | 247 + examples/privacytenant/ent/runtime.go | 9 + examples/privacytenant/ent/runtime/runtime.go | 77 + examples/privacytenant/ent/schema/group.go | 56 + examples/privacytenant/ent/schema/mixin.go | 64 + examples/privacytenant/ent/schema/tenant.go | 44 + examples/privacytenant/ent/schema/user.go | 47 + .../privacytenant/ent/template/privacy.tmpl | 29 + examples/privacytenant/ent/tenant.go | 107 + examples/privacytenant/ent/tenant/mutation.go | 233 + examples/privacytenant/ent/tenant/tenant.go | 64 + examples/privacytenant/ent/tenant/where.go | 142 + examples/privacytenant/ent/tenant_create.go | 192 + examples/privacytenant/ent/tenant_delete.go | 92 + examples/privacytenant/ent/tenant_query.go | 538 ++ examples/privacytenant/ent/tenant_update.go | 239 + examples/privacytenant/ent/tx.go | 220 + examples/privacytenant/ent/user.go | 179 + examples/privacytenant/ent/user/mutation.go | 484 ++ examples/privacytenant/ent/user/user.go | 133 + examples/privacytenant/ent/user/where.go | 224 + examples/privacytenant/ent/user_create.go | 285 + examples/privacytenant/ent/user_delete.go | 92 + examples/privacytenant/ent/user_query.go | 723 +++ examples/privacytenant/ent/user_update.go | 457 ++ examples/privacytenant/example_test.go | 211 + examples/privacytenant/rule/rule.go | 95 + examples/privacytenant/viewer/viewer.go | 57 + examples/rls/README.md | 3 + examples/rls/atlas.hcl | 15 + examples/rls/ent/client.go | 483 ++ examples/rls/ent/ent.go | 610 +++ examples/rls/ent/enttest/enttest.go | 84 + examples/rls/ent/generate.go | 3 + examples/rls/ent/hook/hook.go | 211 + examples/rls/ent/migrate/migrate.go | 64 + examples/rls/ent/migrate/schema.go | 42 + examples/rls/ent/mutation.go | 312 ++ examples/rls/ent/predicate/predicate.go | 13 + examples/rls/ent/runtime.go | 9 + examples/rls/ent/runtime/runtime.go | 9 + examples/rls/ent/schema/tenant.go | 35 + examples/rls/ent/tenant.go | 103 + examples/rls/ent/tenant/mutation.go | 229 + examples/rls/ent/tenant/tenant.go | 47 + examples/rls/ent/tenant/where.go | 138 + examples/rls/ent/tenant_create.go | 183 + examples/rls/ent/tenant_delete.go | 88 + examples/rls/ent/tenant_query.go | 527 ++ examples/rls/ent/tenant_update.go | 209 + examples/rls/ent/tx.go | 213 + examples/rls/ent/user.go | 114 + examples/rls/ent/user/mutation.go | 300 ++ examples/rls/ent/user/user.go | 55 + examples/rls/ent/user/where.go | 183 + examples/rls/ent/user_create.go | 196 + examples/rls/ent/user_delete.go | 88 + examples/rls/ent/user_query.go | 527 ++ examples/rls/ent/user_update.go | 263 + examples/rls/example_test.go | 63 + examples/rls/migrations/20240714101101.sql | 8 + examples/rls/migrations/atlas.sum | 2 + examples/rls/schema.sql | 6 + examples/start/README.md | 18 + examples/start/ent/car.go | 160 + examples/start/ent/car/car.go | 94 + examples/start/ent/car/mutation.go | 336 ++ examples/start/ent/car/where.go | 213 + examples/start/ent/car_create.go | 238 + examples/start/ent/car_delete.go | 92 + examples/start/ent/car_query.go | 618 +++ examples/start/ent/car_update.go | 357 ++ examples/start/ent/client.go | 695 +++ examples/start/ent/ent.go | 616 +++ examples/start/ent/enttest/enttest.go | 88 + examples/start/ent/generate.go | 7 + examples/start/ent/group.go | 133 + examples/start/ent/group/group.go | 91 + examples/start/ent/group/mutation.go | 326 ++ examples/start/ent/group/where.go | 166 + examples/start/ent/group_create.go | 224 + examples/start/ent/group_delete.go | 92 + examples/start/ent/group_query.go | 641 +++ examples/start/ent/group_update.go | 402 ++ examples/start/ent/hook/hook.go | 227 + examples/start/ent/migrate/migrate.go | 68 + .../migrations/20230219130733_initial.sql | 8 + .../start/ent/migrate/migrations/atlas.sum | 2 + examples/start/ent/migrate/schema.go | 97 + examples/start/ent/mutation.go | 471 ++ examples/start/ent/predicate/predicate.go | 20 + examples/start/ent/runtime.go | 35 + examples/start/ent/runtime/runtime.go | 13 + examples/start/ent/schema/car.go | 38 + examples/start/ent/schema/group.go | 34 + examples/start/ent/schema/user.go | 38 + examples/start/ent/tx.go | 220 + examples/start/ent/user.go | 160 + examples/start/ent/user/mutation.go | 480 ++ examples/start/ent/user/user.go | 131 + examples/start/ent/user/where.go | 234 + examples/start/ent/user_create.go | 287 + examples/start/ent/user_delete.go | 92 + examples/start/ent/user_query.go | 716 +++ examples/start/ent/user_update.go | 619 +++ examples/start/start.go | 282 + examples/traversal/README.md | 15 + examples/traversal/ent/client.go | 759 +++ examples/traversal/ent/ent.go | 616 +++ examples/traversal/ent/enttest/enttest.go | 88 + examples/traversal/ent/generate.go | 7 + examples/traversal/ent/group.go | 162 + examples/traversal/ent/group/group.go | 120 + examples/traversal/ent/group/mutation.go | 385 ++ examples/traversal/ent/group/where.go | 189 + examples/traversal/ent/group_create.go | 255 + examples/traversal/ent/group_delete.go | 92 + examples/traversal/ent/group_query.go | 723 +++ examples/traversal/ent/group_update.go | 484 ++ examples/traversal/ent/hook/hook.go | 227 + examples/traversal/ent/migrate/migrate.go | 68 + examples/traversal/ent/migrate/schema.go | 162 + examples/traversal/ent/mutation.go | 451 ++ examples/traversal/ent/pet.go | 162 + examples/traversal/ent/pet/mutation.go | 385 ++ examples/traversal/ent/pet/pet.go | 117 + examples/traversal/ent/pet/where.go | 189 + examples/traversal/ent/pet_create.go | 255 + examples/traversal/ent/pet_delete.go | 92 + examples/traversal/ent/pet_query.go | 723 +++ examples/traversal/ent/pet_update.go | 484 ++ examples/traversal/ent/predicate/predicate.go | 20 + examples/traversal/ent/runtime.go | 13 + examples/traversal/ent/runtime/runtime.go | 13 + examples/traversal/ent/schema/group.go | 32 + examples/traversal/ent/schema/pet.go | 33 + examples/traversal/ent/schema/user.go | 36 + examples/traversal/ent/tx.go | 220 + examples/traversal/ent/user.go | 192 + examples/traversal/ent/user/mutation.go | 646 +++ examples/traversal/ent/user/user.go | 182 + examples/traversal/ent/user/where.go | 280 + examples/traversal/ent/user_create.go | 326 ++ examples/traversal/ent/user_delete.go | 92 + examples/traversal/ent/user_query.go | 894 ++++ examples/traversal/ent/user_update.go | 917 ++++ examples/traversal/example_test.go | 242 + examples/triggers/README.md | 3 + examples/triggers/atlas.hcl | 15 + examples/triggers/ent/client.go | 483 ++ examples/triggers/ent/ent.go | 610 +++ examples/triggers/ent/enttest/enttest.go | 84 + examples/triggers/ent/generate.go | 3 + examples/triggers/ent/hook/hook.go | 211 + examples/triggers/ent/migrate/migrate.go | 64 + examples/triggers/ent/migrate/schema.go | 44 + examples/triggers/ent/mutation.go | 350 ++ examples/triggers/ent/predicate/predicate.go | 13 + examples/triggers/ent/runtime.go | 9 + examples/triggers/ent/runtime/runtime.go | 9 + examples/triggers/ent/schema/user.go | 39 + examples/triggers/ent/tx.go | 213 + examples/triggers/ent/user.go | 103 + examples/triggers/ent/user/mutation.go | 229 + examples/triggers/ent/user/user.go | 47 + examples/triggers/ent/user/where.go | 138 + examples/triggers/ent/user_create.go | 183 + examples/triggers/ent/user_delete.go | 88 + examples/triggers/ent/user_query.go | 527 ++ examples/triggers/ent/user_update.go | 209 + examples/triggers/ent/userauditlog.go | 136 + .../triggers/ent/userauditlog/mutation.go | 375 ++ .../triggers/ent/userauditlog/userauditlog.go | 71 + examples/triggers/ent/userauditlog/where.go | 368 ++ examples/triggers/ent/userauditlog_create.go | 232 + examples/triggers/ent/userauditlog_delete.go | 88 + examples/triggers/ent/userauditlog_query.go | 527 ++ examples/triggers/ent/userauditlog_update.go | 347 ++ examples/triggers/example_test.go | 62 + .../triggers/migrations/20240713061538.sql | 29 + examples/triggers/migrations/atlas.sum | 2 + examples/triggers/schema.sql | 29 + examples/version/README.md | 22 + examples/version/ent/client.go | 344 ++ examples/version/ent/ent.go | 612 +++ examples/version/ent/enttest/enttest.go | 88 + examples/version/ent/generate.go | 3 + examples/version/ent/hook/hook.go | 203 + examples/version/ent/internal/schema.go | 13 + examples/version/ent/migrate/migrate.go | 68 + examples/version/ent/migrate/schema.go | 34 + examples/version/ent/mutation.go | 181 + examples/version/ent/predicate/predicate.go | 14 + examples/version/ent/runtime.go | 27 + examples/version/ent/runtime/runtime.go | 13 + examples/version/ent/schema/user.go | 50 + examples/version/ent/tx.go | 214 + examples/version/ent/user.go | 118 + examples/version/ent/user/mutation.go | 304 ++ examples/version/ent/user/user.go | 89 + examples/version/ent/user/where.go | 137 + examples/version/ent/user_create.go | 223 + examples/version/ent/user_delete.go | 92 + examples/version/ent/user_query.go | 531 ++ examples/version/ent/user_update.go | 293 ++ examples/version/example_test.go | 55 + examples/viewcomposite/README.md | 14 + examples/viewcomposite/atlas.hcl | 15 + examples/viewcomposite/ent/cleanuser.go | 109 + .../viewcomposite/ent/cleanuser/cleanuser.go | 55 + examples/viewcomposite/ent/cleanuser/where.go | 208 + examples/viewcomposite/ent/cleanuser_query.go | 451 ++ examples/viewcomposite/ent/client.go | 555 ++ examples/viewcomposite/ent/ent.go | 614 +++ examples/viewcomposite/ent/enttest/enttest.go | 84 + examples/viewcomposite/ent/generate.go | 3 + examples/viewcomposite/ent/hook/hook.go | 211 + examples/viewcomposite/ent/migrate/migrate.go | 64 + examples/viewcomposite/ent/migrate/schema.go | 43 + examples/viewcomposite/ent/mutation.go | 333 ++ examples/viewcomposite/ent/pet.go | 103 + examples/viewcomposite/ent/pet/mutation.go | 229 + examples/viewcomposite/ent/pet/pet.go | 47 + examples/viewcomposite/ent/pet/where.go | 138 + examples/viewcomposite/ent/pet_create.go | 183 + examples/viewcomposite/ent/pet_delete.go | 88 + examples/viewcomposite/ent/pet_query.go | 527 ++ examples/viewcomposite/ent/pet_update.go | 209 + examples/viewcomposite/ent/petusername.go | 85 + .../ent/petusername/petusername.go | 39 + .../viewcomposite/ent/petusername/where.go | 93 + .../viewcomposite/ent/petusername_query.go | 451 ++ .../viewcomposite/ent/predicate/predicate.go | 19 + examples/viewcomposite/ent/runtime.go | 9 + examples/viewcomposite/ent/runtime/runtime.go | 9 + examples/viewcomposite/ent/schema/pet.go | 36 + examples/viewcomposite/ent/schema/user.go | 40 + examples/viewcomposite/ent/tx.go | 219 + examples/viewcomposite/ent/user.go | 125 + examples/viewcomposite/ent/user/mutation.go | 299 ++ examples/viewcomposite/ent/user/user.go | 63 + examples/viewcomposite/ent/user/where.go | 278 + examples/viewcomposite/ent/user_create.go | 209 + examples/viewcomposite/ent/user_delete.go | 88 + examples/viewcomposite/ent/user_query.go | 527 ++ examples/viewcomposite/ent/user_update.go | 277 + examples/viewcomposite/example_test.go | 60 + examples/viewcomposite/schema.sql | 13 + examples/viewschema/README.md | 14 + examples/viewschema/atlas.hcl | 4 + examples/viewschema/ent/cleanuser.go | 109 + .../viewschema/ent/cleanuser/cleanuser.go | 55 + examples/viewschema/ent/cleanuser/where.go | 208 + examples/viewschema/ent/cleanuser_query.go | 451 ++ examples/viewschema/ent/client.go | 555 ++ examples/viewschema/ent/ent.go | 614 +++ examples/viewschema/ent/enttest/enttest.go | 84 + examples/viewschema/ent/generate.go | 3 + examples/viewschema/ent/hook/hook.go | 211 + examples/viewschema/ent/migrate/migrate.go | 64 + examples/viewschema/ent/migrate/schema.go | 43 + examples/viewschema/ent/mutation.go | 333 ++ examples/viewschema/ent/pet.go | 103 + examples/viewschema/ent/pet/mutation.go | 229 + examples/viewschema/ent/pet/pet.go | 47 + examples/viewschema/ent/pet/where.go | 138 + examples/viewschema/ent/pet_create.go | 183 + examples/viewschema/ent/pet_delete.go | 88 + examples/viewschema/ent/pet_query.go | 527 ++ examples/viewschema/ent/pet_update.go | 209 + examples/viewschema/ent/petusername.go | 85 + .../viewschema/ent/petusername/petusername.go | 39 + examples/viewschema/ent/petusername/where.go | 93 + examples/viewschema/ent/petusername_query.go | 451 ++ .../viewschema/ent/predicate/predicate.go | 19 + examples/viewschema/ent/runtime.go | 9 + examples/viewschema/ent/runtime/runtime.go | 9 + examples/viewschema/ent/schema/pet.go | 70 + examples/viewschema/ent/schema/user.go | 53 + examples/viewschema/ent/tx.go | 219 + examples/viewschema/ent/user.go | 125 + examples/viewschema/ent/user/mutation.go | 299 ++ examples/viewschema/ent/user/user.go | 63 + examples/viewschema/ent/user/where.go | 278 + examples/viewschema/ent/user_create.go | 209 + examples/viewschema/ent/user_delete.go | 88 + examples/viewschema/ent/user_query.go | 527 ++ examples/viewschema/ent/user_update.go | 277 + examples/viewschema/example_test.go | 60 + go.mod | 60 + go.sum | 209 + op_string.go | 43 + privacy/privacy.go | 250 + privacy/privacy_test.go | 71 + schema/edge/annotation.go | 49 + schema/edge/edge.go | 276 + schema/edge/edge_test.go | 129 + schema/field/annotation.go | 81 + schema/field/annotation_test.go | 26 + schema/field/field.go | 1909 +++++++ schema/field/field_test.go | 1017 ++++ schema/field/internal/gen.go | 56 + schema/field/internal/numeric.tmpl | 470 ++ schema/field/numeric.go | 2449 +++++++++ schema/field/type.go | 252 + schema/index/index.go | 121 + schema/index/index_test.go | 39 + schema/mixin/mixin.go | 127 + schema/mixin/mixin_test.go | 100 + schema/schema.go | 43 + 2680 files changed, 719472 insertions(+) create mode 100644 .all-contributorsrc create mode 100644 .devcontainer/devcontainer-lock.json create mode 100644 .devcontainer/devcontainer.json create mode 100644 .github/ISSUE_TEMPLATE/1.bug.md create mode 100644 .github/ISSUE_TEMPLATE/2.feature.md create mode 100644 .github/ISSUE_TEMPLATE/3.support.md create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/atlas-ci-public.yaml create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 .golangci.yml create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 README_jp.md create mode 100644 README_kr.md create mode 100644 README_zh.md create mode 100644 blob.go create mode 100644 cmd/ent/ent.go create mode 100644 cmd/ent/ent_test.go create mode 100644 cmd/entc/entc.go create mode 100644 cmd/entc/entc_test.go create mode 100644 cmd/entfix/entfix.go create mode 100644 cmd/entfix/entfix_test.go create mode 100644 cmd/entfix/go.mod create mode 100644 cmd/entfix/go.sum create mode 100644 cmd/internal/base/base.go create mode 100644 cmd/internal/base/packages.go create mode 100644 cmd/internal/base/packages_test.go create mode 100644 cmd/internal/printer/printer.go create mode 100644 cmd/internal/printer/printer_test.go create mode 100644 dialect/dialect.go create mode 100644 dialect/entsql/annotation.go create mode 100644 dialect/gremlin/client.go create mode 100644 dialect/gremlin/client_test.go create mode 100644 dialect/gremlin/config.go create mode 100644 dialect/gremlin/config_test.go create mode 100644 dialect/gremlin/driver.go create mode 100644 dialect/gremlin/encoding/graphson/bench_test.go create mode 100644 dialect/gremlin/encoding/graphson/common_test.go create mode 100644 dialect/gremlin/encoding/graphson/decode.go create mode 100644 dialect/gremlin/encoding/graphson/decode_test.go create mode 100644 dialect/gremlin/encoding/graphson/encode.go create mode 100644 dialect/gremlin/encoding/graphson/encode_test.go create mode 100644 dialect/gremlin/encoding/graphson/error.go create mode 100644 dialect/gremlin/encoding/graphson/error_test.go create mode 100644 dialect/gremlin/encoding/graphson/extension.go create mode 100644 dialect/gremlin/encoding/graphson/init.go create mode 100644 dialect/gremlin/encoding/graphson/interface.go create mode 100644 dialect/gremlin/encoding/graphson/interface_test.go create mode 100644 dialect/gremlin/encoding/graphson/lazy.go create mode 100644 dialect/gremlin/encoding/graphson/lazy_test.go create mode 100644 dialect/gremlin/encoding/graphson/map.go create mode 100644 dialect/gremlin/encoding/graphson/map_test.go create mode 100644 dialect/gremlin/encoding/graphson/marshaler.go create mode 100644 dialect/gremlin/encoding/graphson/marshaler_test.go create mode 100644 dialect/gremlin/encoding/graphson/native.go create mode 100644 dialect/gremlin/encoding/graphson/native_test.go create mode 100644 dialect/gremlin/encoding/graphson/raw.go create mode 100644 dialect/gremlin/encoding/graphson/raw_test.go create mode 100644 dialect/gremlin/encoding/graphson/slice.go create mode 100644 dialect/gremlin/encoding/graphson/slice_test.go create mode 100644 dialect/gremlin/encoding/graphson/struct.go create mode 100644 dialect/gremlin/encoding/graphson/struct_test.go create mode 100644 dialect/gremlin/encoding/graphson/tags.go create mode 100644 dialect/gremlin/encoding/graphson/tags_test.go create mode 100644 dialect/gremlin/encoding/graphson/time.go create mode 100644 dialect/gremlin/encoding/graphson/time_test.go create mode 100644 dialect/gremlin/encoding/graphson/type.go create mode 100644 dialect/gremlin/encoding/graphson/type_test.go create mode 100644 dialect/gremlin/encoding/graphson/util.go create mode 100644 dialect/gremlin/encoding/graphson/util_test.go create mode 100644 dialect/gremlin/encoding/mime.go create mode 100644 dialect/gremlin/encoding/mime_test.go create mode 100644 dialect/gremlin/example_test.go create mode 100644 dialect/gremlin/expand.go create mode 100644 dialect/gremlin/expand_test.go create mode 100644 dialect/gremlin/graph/dsl/__/dsl.go create mode 100644 dialect/gremlin/graph/dsl/dsl.go create mode 100644 dialect/gremlin/graph/dsl/dsl_test.go create mode 100644 dialect/gremlin/graph/dsl/g/g.go create mode 100644 dialect/gremlin/graph/dsl/p/p.go create mode 100644 dialect/gremlin/graph/dsl/traversal.go create mode 100644 dialect/gremlin/graph/edge.go create mode 100644 dialect/gremlin/graph/edge_test.go create mode 100644 dialect/gremlin/graph/element.go create mode 100644 dialect/gremlin/graph/valuemap.go create mode 100644 dialect/gremlin/graph/valuemap_test.go create mode 100644 dialect/gremlin/graph/vertex.go create mode 100644 dialect/gremlin/graph/vertex_test.go create mode 100644 dialect/gremlin/http.go create mode 100644 dialect/gremlin/http_test.go create mode 100644 dialect/gremlin/internal/ws/conn.go create mode 100644 dialect/gremlin/internal/ws/conn_test.go create mode 100644 dialect/gremlin/ocgremlin/client.go create mode 100644 dialect/gremlin/ocgremlin/client_test.go create mode 100644 dialect/gremlin/ocgremlin/stats.go create mode 100644 dialect/gremlin/ocgremlin/stats_test.go create mode 100644 dialect/gremlin/ocgremlin/trace.go create mode 100644 dialect/gremlin/ocgremlin/trace_test.go create mode 100644 dialect/gremlin/request.go create mode 100644 dialect/gremlin/request_test.go create mode 100644 dialect/gremlin/response.go create mode 100644 dialect/gremlin/response_test.go create mode 100644 dialect/gremlin/status.go create mode 100644 dialect/gremlin/status_test.go create mode 100644 dialect/gremlin/tokens.go create mode 100644 dialect/sql/bench_test.go create mode 100644 dialect/sql/builder.go create mode 100644 dialect/sql/builder_test.go create mode 100644 dialect/sql/driver.go create mode 100644 dialect/sql/driver_test.go create mode 100644 dialect/sql/scan.go create mode 100644 dialect/sql/scan_test.go create mode 100644 dialect/sql/schema/atlas.go create mode 100644 dialect/sql/schema/migrate.go create mode 100644 dialect/sql/schema/migrate_test.go create mode 100644 dialect/sql/schema/mysql.go create mode 100644 dialect/sql/schema/postgres.go create mode 100644 dialect/sql/schema/schema.go create mode 100644 dialect/sql/schema/schema_test.go create mode 100644 dialect/sql/schema/sqlite.go create mode 100644 dialect/sql/schema/writer.go create mode 100644 dialect/sql/schema/writer_test.go create mode 100644 dialect/sql/sql.go create mode 100644 dialect/sql/sql_test.go create mode 100644 dialect/sql/sqlgraph/blob.go create mode 100644 dialect/sql/sqlgraph/blob_test.go create mode 100644 dialect/sql/sqlgraph/entql.go create mode 100644 dialect/sql/sqlgraph/entql_test.go create mode 100644 dialect/sql/sqlgraph/errors.go create mode 100644 dialect/sql/sqlgraph/graph.go create mode 100644 dialect/sql/sqlgraph/graph_test.go create mode 100644 dialect/sql/sqljson/dialect.go create mode 100644 dialect/sql/sqljson/sqljson.go create mode 100644 dialect/sql/sqljson/sqljson_test.go create mode 100644 doc/.gitignore create mode 100644 doc/md/aggregate.md create mode 100644 doc/md/blob-storage.md create mode 100644 doc/md/ci.mdx create mode 100644 doc/md/code-gen.md create mode 100644 doc/md/community.md create mode 100644 doc/md/components/_atlas_migrate_apply.mdx create mode 100644 doc/md/components/_atlas_migrate_diff.mdx create mode 100644 doc/md/components/_installation_instructions.mdx create mode 100644 doc/md/contributors.md create mode 100644 doc/md/crud.mdx create mode 100644 doc/md/data-migrations.mdx create mode 100644 doc/md/dialects.md create mode 100644 doc/md/eager-load.mdx create mode 100644 doc/md/extension.md create mode 100644 doc/md/faq.md create mode 100644 doc/md/features.md create mode 100644 doc/md/generating-ent-schemas.md create mode 100644 doc/md/getting-started.mdx create mode 100644 doc/md/globalid.mdx create mode 100644 doc/md/graphql.md create mode 100644 doc/md/hooks.md create mode 100644 doc/md/interceptors.mdx create mode 100644 doc/md/migrate.md create mode 100644 doc/md/migration/composite.mdx create mode 100644 doc/md/migration/domain.mdx create mode 100644 doc/md/migration/enum.mdx create mode 100644 doc/md/migration/extension.mdx create mode 100644 doc/md/migration/functional-indexes.mdx create mode 100644 doc/md/migration/rls.mdx create mode 100644 doc/md/migration/trigger.mdx create mode 100644 doc/md/multischema-migrations.mdx create mode 100644 doc/md/paging.mdx create mode 100644 doc/md/predicates.md create mode 100644 doc/md/privacy.mdx create mode 100644 doc/md/schema-annotations.md create mode 100644 doc/md/schema-def.md create mode 100644 doc/md/schema-edges.mdx create mode 100644 doc/md/schema-fields.mdx create mode 100644 doc/md/schema-indexes.md create mode 100644 doc/md/schema-mixin.md create mode 100644 doc/md/schema-view.mdx create mode 100644 doc/md/sql-integration.md create mode 100644 doc/md/templates.md create mode 100644 doc/md/testing.md create mode 100644 doc/md/transactions.md create mode 100644 doc/md/translations.md create mode 100644 doc/md/traversals.md create mode 100644 doc/md/tutorial-grpc-edges.md create mode 100644 doc/md/tutorial-grpc-ext-service.md create mode 100644 doc/md/tutorial-grpc-generating-a-service.md create mode 100644 doc/md/tutorial-grpc-generating-proto.md create mode 100644 doc/md/tutorial-grpc-intro.md create mode 100644 doc/md/tutorial-grpc-optional-fields.md create mode 100644 doc/md/tutorial-grpc-server-and-client.md create mode 100644 doc/md/tutorial-grpc-service-generation-options.md create mode 100644 doc/md/tutorial-grpc-setting-up.md create mode 100644 doc/md/tutorial-setup.md create mode 100644 doc/md/tutorial-todo-crud.md create mode 100644 doc/md/tutorial-todo-gql-field-collection.md create mode 100644 doc/md/tutorial-todo-gql-filter-input.md create mode 100644 doc/md/tutorial-todo-gql-mutation-input.md create mode 100644 doc/md/tutorial-todo-gql-node.md create mode 100644 doc/md/tutorial-todo-gql-paginate.md create mode 100644 doc/md/tutorial-todo-gql-schema-generator.md create mode 100644 doc/md/tutorial-todo-gql-tx-mutation.md create mode 100644 doc/md/tutorial-todo-gql.mdx create mode 100644 doc/md/versioned-migrations.mdx create mode 100644 doc/md/versioned/01-intro.md create mode 100644 doc/md/versioned/02-auto-plan.mdx create mode 100644 doc/md/versioned/03-upgrade-prod.mdx create mode 100644 doc/md/versioned/04-new-migration.mdx create mode 100644 doc/md/versioned/05-custom-migrations.md create mode 100644 doc/md/versioned/06-verifying-safety.mdx create mode 100644 doc/md/versioned/07-programmatically.mdx create mode 100644 doc/md/writing-docs.md create mode 100644 doc/website/.gitignore create mode 100644 doc/website/blog/2019-10-03-introducing-ent.md create mode 100644 doc/website/blog/2021-03-12-announcing-edge-field-support.md create mode 100644 doc/website/blog/2021-03-18-generating-a-grpc-server-with-ent.md create mode 100644 doc/website/blog/2021-05-04-announcing-schema-imports.md create mode 100644 doc/website/blog/2021-06-28-gprc-ready-for-use.md create mode 100644 doc/website/blog/2021-07-01-automatic-graphql-filter-generation.md create mode 100644 doc/website/blog/2021-07-22-database-locking-techniques-with-ent.md create mode 100644 doc/website/blog/2021-07-29-generate-a-fully-working-go-crud-http-api-with-ent.md create mode 100644 doc/website/blog/2021-08-05-announcing-upsert-api.md create mode 100644 doc/website/blog/2021-08-12-building-observable-ent-application-with-prometheus.md create mode 100644 doc/website/blog/2021-08-26-visualizing-your-data-graph-using-entviz.md create mode 100644 doc/website/blog/2021-09-01-ent-joins-the-linux-foundation.md create mode 100644 doc/website/blog/2021-09-02-ent-extension-api.md create mode 100644 doc/website/blog/2021-09-10-openapi-generator.md create mode 100644 doc/website/blog/2021-10-11-generating-ent-schemas-from-existing-sql-databases.md create mode 100644 doc/website/blog/2021-10-14-introducing-entcache.md create mode 100644 doc/website/blog/2021-10-19-sqlcomment-support-for-ent.md create mode 100644 doc/website/blog/2021-11-1-sync-to-external-data-systems-using-hooks.md create mode 100644 doc/website/blog/2021-11-15-announcing-entoas.md create mode 100644 doc/website/blog/2021-12-09-contributing-my-first-feature-to-ent-grpc-plugin.md create mode 100644 doc/website/blog/2022-01-04-serverless-graphql-using-aws.md create mode 100644 doc/website/blog/2022-01-20-announcing-new-migration-engine.md create mode 100644 doc/website/blog/2022-02-15-generate-rest-crud-with-ent-and-ogen.md create mode 100644 doc/website/blog/2022-03-14-announcing-versioned-migrations.md create mode 100644 doc/website/blog/2022-03-17-announcing-preview-support-for-tidb.md create mode 100644 doc/website/blog/2022-04-07-how-twitter-can-implement.md create mode 100644 doc/website/blog/2022-05-09-versioned-migrations-sum-file.md create mode 100644 doc/website/blog/2022-09-06-ci-for-ent.mdx create mode 100644 doc/website/blog/2022-10-10-json-append.mdx create mode 100644 doc/website/blog/2022-12-01-changing-column-types-with-zero-downtime.md create mode 100644 doc/website/blog/2023-01-26-visualizing-with-entviz.md create mode 100644 doc/website/blog/2023-02-23-simple-cms-with-ent.mdx create mode 100644 doc/website/blog/2023-08-13-visualize-with-atlas.md create mode 100644 doc/website/blog/2025-02-12-rag-with-ent-atlas-pgvector.mdx create mode 100644 doc/website/crowdin.yaml create mode 100644 doc/website/docusaurus.config.js create mode 100644 doc/website/package.json create mode 100644 doc/website/sidebars.js create mode 100644 doc/website/src/css/code-block-buttons.css create mode 100644 doc/website/src/css/custom.css create mode 100644 doc/website/src/img/favicon.ico create mode 100644 doc/website/src/img/logo.png create mode 100644 doc/website/src/js/code-block-buttons.js create mode 100644 doc/website/src/js/custom.js create mode 100644 doc/website/src/pages/index.js create mode 100644 doc/website/src/static/contrib/index.html create mode 100644 doc/website/src/static/ent/cmd/ent/index.html create mode 100644 doc/website/src/static/ent/cmd/entfix/index.html create mode 100644 doc/website/src/static/ent/entc/gen/index.html create mode 100644 doc/website/src/static/ent/entc/index.html create mode 100644 doc/website/src/static/ent/index.html create mode 100644 doc/website/src/theme/prism-include-languages.js create mode 100644 doc/website/static/contrib/index.html create mode 100644 doc/website/static/ent/cmd/ent/index.html create mode 100644 doc/website/static/ent/cmd/entfix/index.html create mode 100644 doc/website/static/ent/entc/gen/index.html create mode 100644 doc/website/static/ent/entc/index.html create mode 100644 doc/website/static/ent/index.html create mode 100644 doc/website/static/font/CalibreBold.woff create mode 100644 doc/website/static/font/CalibreLight.woff create mode 100644 doc/website/static/font/CalibreLightItalic.woff create mode 100644 doc/website/static/font/CalibreMedium.woff create mode 100644 doc/website/static/font/CalibreMediumItalic.woff create mode 100644 doc/website/static/font/CalibreRegular.woff create mode 100644 doc/website/static/font/CalibreRegularItalic.woff create mode 100644 doc/website/static/font/CalibreSemibold.woff create mode 100644 doc/website/static/font/CalibreThin.woff create mode 100644 doc/website/static/font/CalibreThinItalic.woff create mode 100644 doc/website/static/img/favicon.ico create mode 100644 doc/website/static/img/logo.png create mode 100644 doc/website/static/img/oss_logo.png create mode 100644 doc/website/static/js/code-block-buttons.js create mode 100644 doc/website/static/js/custom.js create mode 100644 ent.go create mode 100644 entc/entc.go create mode 100644 entc/gen/bench_test.go create mode 100644 entc/gen/feature.go create mode 100644 entc/gen/func.go create mode 100644 entc/gen/globalid.go create mode 100644 entc/gen/globalid_test.go create mode 100644 entc/gen/graph.go create mode 100644 entc/gen/graph_test.go create mode 100644 entc/gen/predicate.go create mode 100644 entc/gen/storage.go create mode 100644 entc/gen/template.go create mode 100644 entc/gen/template/base.tmpl create mode 100644 entc/gen/template/builder/create.tmpl create mode 100644 entc/gen/template/builder/delete.tmpl create mode 100644 entc/gen/template/builder/entql.tmpl create mode 100644 entc/gen/template/builder/mutation_graph.tmpl create mode 100644 entc/gen/template/builder/query.tmpl create mode 100644 entc/gen/template/builder/setter.tmpl create mode 100644 entc/gen/template/builder/update.tmpl create mode 100644 entc/gen/template/client.tmpl create mode 100644 entc/gen/template/dialect/gremlin/by.tmpl create mode 100644 entc/gen/template/dialect/gremlin/create.tmpl create mode 100644 entc/gen/template/dialect/gremlin/decode.tmpl create mode 100644 entc/gen/template/dialect/gremlin/delete.tmpl create mode 100644 entc/gen/template/dialect/gremlin/errors.tmpl create mode 100644 entc/gen/template/dialect/gremlin/globals.tmpl create mode 100644 entc/gen/template/dialect/gremlin/group.tmpl create mode 100644 entc/gen/template/dialect/gremlin/meta.tmpl create mode 100644 entc/gen/template/dialect/gremlin/open.tmpl create mode 100644 entc/gen/template/dialect/gremlin/predicate.tmpl create mode 100644 entc/gen/template/dialect/gremlin/query.tmpl create mode 100644 entc/gen/template/dialect/gremlin/select.tmpl create mode 100644 entc/gen/template/dialect/gremlin/update.tmpl create mode 100644 entc/gen/template/dialect/sql/by.tmpl create mode 100644 entc/gen/template/dialect/sql/create.tmpl create mode 100644 entc/gen/template/dialect/sql/decode.tmpl create mode 100644 entc/gen/template/dialect/sql/delete.tmpl create mode 100644 entc/gen/template/dialect/sql/ent.tmpl create mode 100644 entc/gen/template/dialect/sql/entql.tmpl create mode 100644 entc/gen/template/dialect/sql/errors.tmpl create mode 100644 entc/gen/template/dialect/sql/feature/execquery.tmpl create mode 100644 entc/gen/template/dialect/sql/feature/lock.tmpl create mode 100644 entc/gen/template/dialect/sql/feature/migratediff.tmpl create mode 100644 entc/gen/template/dialect/sql/feature/modifier.tmpl create mode 100644 entc/gen/template/dialect/sql/feature/namedges.tmpl create mode 100644 entc/gen/template/dialect/sql/feature/schemaconfig.tmpl create mode 100644 entc/gen/template/dialect/sql/feature/upsert.tmpl create mode 100644 entc/gen/template/dialect/sql/globals.tmpl create mode 100644 entc/gen/template/dialect/sql/group.tmpl create mode 100644 entc/gen/template/dialect/sql/meta.tmpl create mode 100644 entc/gen/template/dialect/sql/open.tmpl create mode 100644 entc/gen/template/dialect/sql/predicate.tmpl create mode 100644 entc/gen/template/dialect/sql/query.tmpl create mode 100644 entc/gen/template/dialect/sql/select.tmpl create mode 100644 entc/gen/template/dialect/sql/tx.tmpl create mode 100644 entc/gen/template/dialect/sql/update.tmpl create mode 100644 entc/gen/template/ent.tmpl create mode 100644 entc/gen/template/enttest.tmpl create mode 100644 entc/gen/template/header.tmpl create mode 100644 entc/gen/template/hook.tmpl create mode 100644 entc/gen/template/import.tmpl create mode 100644 entc/gen/template/intercept.tmpl create mode 100644 entc/gen/template/internal.tmpl create mode 100644 entc/gen/template/meta.tmpl create mode 100644 entc/gen/template/migrate/migrate.tmpl create mode 100644 entc/gen/template/migrate/schema.tmpl create mode 100644 entc/gen/template/mutation.tmpl create mode 100644 entc/gen/template/predicate.tmpl create mode 100644 entc/gen/template/privacy/filter.tmpl create mode 100644 entc/gen/template/privacy/privacy.tmpl create mode 100644 entc/gen/template/runtime.tmpl create mode 100644 entc/gen/template/tx.tmpl create mode 100644 entc/gen/template/where.tmpl create mode 100644 entc/gen/type.go create mode 100644 entc/gen/type_test.go create mode 100644 entc/integration/README.md create mode 100644 entc/integration/blob/blob.go create mode 100644 entc/integration/blob/encrypt.go create mode 100644 entc/integration/blob_test.go create mode 100644 entc/integration/cascadelete/cascadelete_test.go create mode 100644 entc/integration/cascadelete/ent/client.go create mode 100644 entc/integration/cascadelete/ent/comment.go create mode 100644 entc/integration/cascadelete/ent/comment/comment.go create mode 100644 entc/integration/cascadelete/ent/comment/mutation.go create mode 100644 entc/integration/cascadelete/ent/comment/where.go create mode 100644 entc/integration/cascadelete/ent/comment_create.go create mode 100644 entc/integration/cascadelete/ent/comment_delete.go create mode 100644 entc/integration/cascadelete/ent/comment_query.go create mode 100644 entc/integration/cascadelete/ent/comment_update.go create mode 100644 entc/integration/cascadelete/ent/ent.go create mode 100644 entc/integration/cascadelete/ent/enttest/enttest.go create mode 100644 entc/integration/cascadelete/ent/generate.go create mode 100644 entc/integration/cascadelete/ent/hook/hook.go create mode 100644 entc/integration/cascadelete/ent/migrate/migrate.go create mode 100644 entc/integration/cascadelete/ent/migrate/schema.go create mode 100644 entc/integration/cascadelete/ent/mutation.go create mode 100644 entc/integration/cascadelete/ent/post.go create mode 100644 entc/integration/cascadelete/ent/post/mutation.go create mode 100644 entc/integration/cascadelete/ent/post/post.go create mode 100644 entc/integration/cascadelete/ent/post/where.go create mode 100644 entc/integration/cascadelete/ent/post_create.go create mode 100644 entc/integration/cascadelete/ent/post_delete.go create mode 100644 entc/integration/cascadelete/ent/post_query.go create mode 100644 entc/integration/cascadelete/ent/post_update.go create mode 100644 entc/integration/cascadelete/ent/predicate/predicate.go create mode 100644 entc/integration/cascadelete/ent/runtime.go create mode 100644 entc/integration/cascadelete/ent/runtime/runtime.go create mode 100644 entc/integration/cascadelete/ent/schema/comment.go create mode 100644 entc/integration/cascadelete/ent/schema/post.go create mode 100644 entc/integration/cascadelete/ent/schema/user.go create mode 100644 entc/integration/cascadelete/ent/tx.go create mode 100644 entc/integration/cascadelete/ent/user.go create mode 100644 entc/integration/cascadelete/ent/user/mutation.go create mode 100644 entc/integration/cascadelete/ent/user/user.go create mode 100644 entc/integration/cascadelete/ent/user/where.go create mode 100644 entc/integration/cascadelete/ent/user_create.go create mode 100644 entc/integration/cascadelete/ent/user_delete.go create mode 100644 entc/integration/cascadelete/ent/user_query.go create mode 100644 entc/integration/cascadelete/ent/user_update.go create mode 100644 entc/integration/compose/gremlin-server/Dockerfile create mode 100644 entc/integration/compose/gremlin-server/gremlin-server.yaml create mode 100644 entc/integration/compose/gremlin-server/tinkergraph-empty.properties create mode 100644 entc/integration/config/config_test.go create mode 100644 entc/integration/config/ent/client.go create mode 100644 entc/integration/config/ent/ent.go create mode 100644 entc/integration/config/ent/enttest/enttest.go create mode 100644 entc/integration/config/ent/generate.go create mode 100644 entc/integration/config/ent/hook/hook.go create mode 100644 entc/integration/config/ent/migrate/migrate.go create mode 100644 entc/integration/config/ent/migrate/schema.go create mode 100644 entc/integration/config/ent/mutation.go create mode 100644 entc/integration/config/ent/predicate/predicate.go create mode 100644 entc/integration/config/ent/runtime.go create mode 100644 entc/integration/config/ent/runtime/runtime.go create mode 100644 entc/integration/config/ent/schema/user.go create mode 100644 entc/integration/config/ent/tx.go create mode 100644 entc/integration/config/ent/user.go create mode 100644 entc/integration/config/ent/user/mutation.go create mode 100644 entc/integration/config/ent/user/user.go create mode 100644 entc/integration/config/ent/user/where.go create mode 100644 entc/integration/config/ent/user_create.go create mode 100644 entc/integration/config/ent/user_delete.go create mode 100644 entc/integration/config/ent/user_query.go create mode 100644 entc/integration/config/ent/user_update.go create mode 100644 entc/integration/customid/customid_test.go create mode 100644 entc/integration/customid/ent/account.go create mode 100644 entc/integration/customid/ent/account/account.go create mode 100644 entc/integration/customid/ent/account/mutation.go create mode 100644 entc/integration/customid/ent/account/where.go create mode 100644 entc/integration/customid/ent/account_create.go create mode 100644 entc/integration/customid/ent/account_delete.go create mode 100644 entc/integration/customid/ent/account_query.go create mode 100644 entc/integration/customid/ent/account_update.go create mode 100644 entc/integration/customid/ent/blob.go create mode 100644 entc/integration/customid/ent/blob/blob.go create mode 100644 entc/integration/customid/ent/blob/mutation.go create mode 100644 entc/integration/customid/ent/blob/where.go create mode 100644 entc/integration/customid/ent/blob_create.go create mode 100644 entc/integration/customid/ent/blob_delete.go create mode 100644 entc/integration/customid/ent/blob_query.go create mode 100644 entc/integration/customid/ent/blob_update.go create mode 100644 entc/integration/customid/ent/bloblink.go create mode 100644 entc/integration/customid/ent/bloblink/bloblink.go create mode 100644 entc/integration/customid/ent/bloblink/mutation.go create mode 100644 entc/integration/customid/ent/bloblink/where.go create mode 100644 entc/integration/customid/ent/bloblink_create.go create mode 100644 entc/integration/customid/ent/bloblink_delete.go create mode 100644 entc/integration/customid/ent/bloblink_query.go create mode 100644 entc/integration/customid/ent/bloblink_update.go create mode 100644 entc/integration/customid/ent/car.go create mode 100644 entc/integration/customid/ent/car/car.go create mode 100644 entc/integration/customid/ent/car/mutation.go create mode 100644 entc/integration/customid/ent/car/where.go create mode 100644 entc/integration/customid/ent/car_create.go create mode 100644 entc/integration/customid/ent/car_delete.go create mode 100644 entc/integration/customid/ent/car_query.go create mode 100644 entc/integration/customid/ent/car_update.go create mode 100644 entc/integration/customid/ent/client.go create mode 100644 entc/integration/customid/ent/device.go create mode 100644 entc/integration/customid/ent/device/device.go create mode 100644 entc/integration/customid/ent/device/mutation.go create mode 100644 entc/integration/customid/ent/device/where.go create mode 100644 entc/integration/customid/ent/device_create.go create mode 100644 entc/integration/customid/ent/device_delete.go create mode 100644 entc/integration/customid/ent/device_query.go create mode 100644 entc/integration/customid/ent/device_update.go create mode 100644 entc/integration/customid/ent/doc.go create mode 100644 entc/integration/customid/ent/doc/doc.go create mode 100644 entc/integration/customid/ent/doc/mutation.go create mode 100644 entc/integration/customid/ent/doc/where.go create mode 100644 entc/integration/customid/ent/doc_create.go create mode 100644 entc/integration/customid/ent/doc_delete.go create mode 100644 entc/integration/customid/ent/doc_query.go create mode 100644 entc/integration/customid/ent/doc_update.go create mode 100644 entc/integration/customid/ent/ent.go create mode 100644 entc/integration/customid/ent/entql.go create mode 100644 entc/integration/customid/ent/enttest/enttest.go create mode 100644 entc/integration/customid/ent/generate.go create mode 100644 entc/integration/customid/ent/group.go create mode 100644 entc/integration/customid/ent/group/group.go create mode 100644 entc/integration/customid/ent/group/mutation.go create mode 100644 entc/integration/customid/ent/group/where.go create mode 100644 entc/integration/customid/ent/group_create.go create mode 100644 entc/integration/customid/ent/group_delete.go create mode 100644 entc/integration/customid/ent/group_query.go create mode 100644 entc/integration/customid/ent/group_update.go create mode 100644 entc/integration/customid/ent/hook/hook.go create mode 100644 entc/integration/customid/ent/intsid.go create mode 100644 entc/integration/customid/ent/intsid/intsid.go create mode 100644 entc/integration/customid/ent/intsid/mutation.go create mode 100644 entc/integration/customid/ent/intsid/where.go create mode 100644 entc/integration/customid/ent/intsid_create.go create mode 100644 entc/integration/customid/ent/intsid_delete.go create mode 100644 entc/integration/customid/ent/intsid_query.go create mode 100644 entc/integration/customid/ent/intsid_update.go create mode 100644 entc/integration/customid/ent/link.go create mode 100644 entc/integration/customid/ent/link/link.go create mode 100644 entc/integration/customid/ent/link/mutation.go create mode 100644 entc/integration/customid/ent/link/where.go create mode 100644 entc/integration/customid/ent/link_create.go create mode 100644 entc/integration/customid/ent/link_delete.go create mode 100644 entc/integration/customid/ent/link_query.go create mode 100644 entc/integration/customid/ent/link_update.go create mode 100644 entc/integration/customid/ent/migrate/migrate.go create mode 100644 entc/integration/customid/ent/migrate/schema.go create mode 100644 entc/integration/customid/ent/mixinid.go create mode 100644 entc/integration/customid/ent/mixinid/mixinid.go create mode 100644 entc/integration/customid/ent/mixinid/mutation.go create mode 100644 entc/integration/customid/ent/mixinid/where.go create mode 100644 entc/integration/customid/ent/mixinid_create.go create mode 100644 entc/integration/customid/ent/mixinid_delete.go create mode 100644 entc/integration/customid/ent/mixinid_query.go create mode 100644 entc/integration/customid/ent/mixinid_update.go create mode 100644 entc/integration/customid/ent/mutation.go create mode 100644 entc/integration/customid/ent/note.go create mode 100644 entc/integration/customid/ent/note/mutation.go create mode 100644 entc/integration/customid/ent/note/note.go create mode 100644 entc/integration/customid/ent/note/where.go create mode 100644 entc/integration/customid/ent/note_create.go create mode 100644 entc/integration/customid/ent/note_delete.go create mode 100644 entc/integration/customid/ent/note_query.go create mode 100644 entc/integration/customid/ent/note_update.go create mode 100644 entc/integration/customid/ent/other.go create mode 100644 entc/integration/customid/ent/other/mutation.go create mode 100644 entc/integration/customid/ent/other/other.go create mode 100644 entc/integration/customid/ent/other/where.go create mode 100644 entc/integration/customid/ent/other_create.go create mode 100644 entc/integration/customid/ent/other_delete.go create mode 100644 entc/integration/customid/ent/other_query.go create mode 100644 entc/integration/customid/ent/other_update.go create mode 100644 entc/integration/customid/ent/pet.go create mode 100644 entc/integration/customid/ent/pet/mutation.go create mode 100644 entc/integration/customid/ent/pet/pet.go create mode 100644 entc/integration/customid/ent/pet/where.go create mode 100644 entc/integration/customid/ent/pet_create.go create mode 100644 entc/integration/customid/ent/pet_delete.go create mode 100644 entc/integration/customid/ent/pet_query.go create mode 100644 entc/integration/customid/ent/pet_update.go create mode 100644 entc/integration/customid/ent/predicate/predicate.go create mode 100644 entc/integration/customid/ent/privacy/privacy.go create mode 100644 entc/integration/customid/ent/revision.go create mode 100644 entc/integration/customid/ent/revision/mutation.go create mode 100644 entc/integration/customid/ent/revision/revision.go create mode 100644 entc/integration/customid/ent/revision/where.go create mode 100644 entc/integration/customid/ent/revision_create.go create mode 100644 entc/integration/customid/ent/revision_delete.go create mode 100644 entc/integration/customid/ent/revision_query.go create mode 100644 entc/integration/customid/ent/revision_update.go create mode 100644 entc/integration/customid/ent/runtime.go create mode 100644 entc/integration/customid/ent/runtime/runtime.go create mode 100644 entc/integration/customid/ent/schema/account.go create mode 100644 entc/integration/customid/ent/schema/blob.go create mode 100644 entc/integration/customid/ent/schema/car.go create mode 100644 entc/integration/customid/ent/schema/doc.go create mode 100644 entc/integration/customid/ent/schema/group.go create mode 100644 entc/integration/customid/ent/schema/intsid.go create mode 100644 entc/integration/customid/ent/schema/link.go create mode 100644 entc/integration/customid/ent/schema/mixinid.go create mode 100644 entc/integration/customid/ent/schema/note.go create mode 100644 entc/integration/customid/ent/schema/other.go create mode 100644 entc/integration/customid/ent/schema/pet.go create mode 100644 entc/integration/customid/ent/schema/revision.go create mode 100644 entc/integration/customid/ent/schema/session.go create mode 100644 entc/integration/customid/ent/schema/token.go create mode 100644 entc/integration/customid/ent/schema/user.go create mode 100644 entc/integration/customid/ent/schema/valuescan.go create mode 100644 entc/integration/customid/ent/session.go create mode 100644 entc/integration/customid/ent/session/mutation.go create mode 100644 entc/integration/customid/ent/session/session.go create mode 100644 entc/integration/customid/ent/session/where.go create mode 100644 entc/integration/customid/ent/session_create.go create mode 100644 entc/integration/customid/ent/session_delete.go create mode 100644 entc/integration/customid/ent/session_query.go create mode 100644 entc/integration/customid/ent/session_update.go create mode 100644 entc/integration/customid/ent/token.go create mode 100644 entc/integration/customid/ent/token/mutation.go create mode 100644 entc/integration/customid/ent/token/token.go create mode 100644 entc/integration/customid/ent/token/where.go create mode 100644 entc/integration/customid/ent/token_create.go create mode 100644 entc/integration/customid/ent/token_delete.go create mode 100644 entc/integration/customid/ent/token_query.go create mode 100644 entc/integration/customid/ent/token_update.go create mode 100644 entc/integration/customid/ent/tx.go create mode 100644 entc/integration/customid/ent/user.go create mode 100644 entc/integration/customid/ent/user/mutation.go create mode 100644 entc/integration/customid/ent/user/user.go create mode 100644 entc/integration/customid/ent/user/where.go create mode 100644 entc/integration/customid/ent/user_create.go create mode 100644 entc/integration/customid/ent/user_delete.go create mode 100644 entc/integration/customid/ent/user_query.go create mode 100644 entc/integration/customid/ent/user_update.go create mode 100644 entc/integration/customid/ent/valuescan.go create mode 100644 entc/integration/customid/ent/valuescan/mutation.go create mode 100644 entc/integration/customid/ent/valuescan/valuescan.go create mode 100644 entc/integration/customid/ent/valuescan/where.go create mode 100644 entc/integration/customid/ent/valuescan_create.go create mode 100644 entc/integration/customid/ent/valuescan_delete.go create mode 100644 entc/integration/customid/ent/valuescan_query.go create mode 100644 entc/integration/customid/ent/valuescan_update.go create mode 100644 entc/integration/customid/sid/sid.go create mode 100644 entc/integration/customid/uuidcompatible/uuidc.go create mode 100644 entc/integration/docker-compose.yaml create mode 100644 entc/integration/edgefield/edgefield_test.go create mode 100644 entc/integration/edgefield/ent/car.go create mode 100644 entc/integration/edgefield/ent/car/car.go create mode 100644 entc/integration/edgefield/ent/car/mutation.go create mode 100644 entc/integration/edgefield/ent/car/where.go create mode 100644 entc/integration/edgefield/ent/car_create.go create mode 100644 entc/integration/edgefield/ent/car_delete.go create mode 100644 entc/integration/edgefield/ent/car_query.go create mode 100644 entc/integration/edgefield/ent/car_update.go create mode 100644 entc/integration/edgefield/ent/card.go create mode 100644 entc/integration/edgefield/ent/card/card.go create mode 100644 entc/integration/edgefield/ent/card/mutation.go create mode 100644 entc/integration/edgefield/ent/card/where.go create mode 100644 entc/integration/edgefield/ent/card_create.go create mode 100644 entc/integration/edgefield/ent/card_delete.go create mode 100644 entc/integration/edgefield/ent/card_query.go create mode 100644 entc/integration/edgefield/ent/card_update.go create mode 100644 entc/integration/edgefield/ent/client.go create mode 100644 entc/integration/edgefield/ent/ent.go create mode 100644 entc/integration/edgefield/ent/enttest/enttest.go create mode 100644 entc/integration/edgefield/ent/generate.go create mode 100644 entc/integration/edgefield/ent/hook/hook.go create mode 100644 entc/integration/edgefield/ent/info.go create mode 100644 entc/integration/edgefield/ent/info/info.go create mode 100644 entc/integration/edgefield/ent/info/mutation.go create mode 100644 entc/integration/edgefield/ent/info/where.go create mode 100644 entc/integration/edgefield/ent/info_create.go create mode 100644 entc/integration/edgefield/ent/info_delete.go create mode 100644 entc/integration/edgefield/ent/info_query.go create mode 100644 entc/integration/edgefield/ent/info_update.go create mode 100644 entc/integration/edgefield/ent/metadata.go create mode 100644 entc/integration/edgefield/ent/metadata/metadata.go create mode 100644 entc/integration/edgefield/ent/metadata/mutation.go create mode 100644 entc/integration/edgefield/ent/metadata/where.go create mode 100644 entc/integration/edgefield/ent/metadata_create.go create mode 100644 entc/integration/edgefield/ent/metadata_delete.go create mode 100644 entc/integration/edgefield/ent/metadata_query.go create mode 100644 entc/integration/edgefield/ent/metadata_update.go create mode 100644 entc/integration/edgefield/ent/migrate/migrate.go create mode 100644 entc/integration/edgefield/ent/migrate/schema.go create mode 100644 entc/integration/edgefield/ent/mutation.go create mode 100644 entc/integration/edgefield/ent/node.go create mode 100644 entc/integration/edgefield/ent/node/mutation.go create mode 100644 entc/integration/edgefield/ent/node/node.go create mode 100644 entc/integration/edgefield/ent/node/where.go create mode 100644 entc/integration/edgefield/ent/node_create.go create mode 100644 entc/integration/edgefield/ent/node_delete.go create mode 100644 entc/integration/edgefield/ent/node_query.go create mode 100644 entc/integration/edgefield/ent/node_update.go create mode 100644 entc/integration/edgefield/ent/pet.go create mode 100644 entc/integration/edgefield/ent/pet/mutation.go create mode 100644 entc/integration/edgefield/ent/pet/pet.go create mode 100644 entc/integration/edgefield/ent/pet/where.go create mode 100644 entc/integration/edgefield/ent/pet_create.go create mode 100644 entc/integration/edgefield/ent/pet_delete.go create mode 100644 entc/integration/edgefield/ent/pet_query.go create mode 100644 entc/integration/edgefield/ent/pet_update.go create mode 100644 entc/integration/edgefield/ent/post.go create mode 100644 entc/integration/edgefield/ent/post/mutation.go create mode 100644 entc/integration/edgefield/ent/post/post.go create mode 100644 entc/integration/edgefield/ent/post/where.go create mode 100644 entc/integration/edgefield/ent/post_create.go create mode 100644 entc/integration/edgefield/ent/post_delete.go create mode 100644 entc/integration/edgefield/ent/post_query.go create mode 100644 entc/integration/edgefield/ent/post_update.go create mode 100644 entc/integration/edgefield/ent/predicate/predicate.go create mode 100644 entc/integration/edgefield/ent/rental.go create mode 100644 entc/integration/edgefield/ent/rental/mutation.go create mode 100644 entc/integration/edgefield/ent/rental/rental.go create mode 100644 entc/integration/edgefield/ent/rental/where.go create mode 100644 entc/integration/edgefield/ent/rental_create.go create mode 100644 entc/integration/edgefield/ent/rental_delete.go create mode 100644 entc/integration/edgefield/ent/rental_query.go create mode 100644 entc/integration/edgefield/ent/rental_update.go create mode 100644 entc/integration/edgefield/ent/runtime.go create mode 100644 entc/integration/edgefield/ent/runtime/runtime.go create mode 100644 entc/integration/edgefield/ent/schema/car.go create mode 100644 entc/integration/edgefield/ent/schema/card.go create mode 100644 entc/integration/edgefield/ent/schema/info.go create mode 100644 entc/integration/edgefield/ent/schema/metadata.go create mode 100644 entc/integration/edgefield/ent/schema/node.go create mode 100644 entc/integration/edgefield/ent/schema/pet.go create mode 100644 entc/integration/edgefield/ent/schema/post.go create mode 100644 entc/integration/edgefield/ent/schema/rental.go create mode 100644 entc/integration/edgefield/ent/schema/user.go create mode 100644 entc/integration/edgefield/ent/tx.go create mode 100644 entc/integration/edgefield/ent/user.go create mode 100644 entc/integration/edgefield/ent/user/mutation.go create mode 100644 entc/integration/edgefield/ent/user/user.go create mode 100644 entc/integration/edgefield/ent/user/where.go create mode 100644 entc/integration/edgefield/ent/user_create.go create mode 100644 entc/integration/edgefield/ent/user_delete.go create mode 100644 entc/integration/edgefield/ent/user_query.go create mode 100644 entc/integration/edgefield/ent/user_update.go create mode 100644 entc/integration/edgeschema/edgeschema_test.go create mode 100644 entc/integration/edgeschema/ent/attachedfile.go create mode 100644 entc/integration/edgeschema/ent/attachedfile/attachedfile.go create mode 100644 entc/integration/edgeschema/ent/attachedfile/mutation.go create mode 100644 entc/integration/edgeschema/ent/attachedfile/where.go create mode 100644 entc/integration/edgeschema/ent/attachedfile_create.go create mode 100644 entc/integration/edgeschema/ent/attachedfile_delete.go create mode 100644 entc/integration/edgeschema/ent/attachedfile_query.go create mode 100644 entc/integration/edgeschema/ent/attachedfile_update.go create mode 100644 entc/integration/edgeschema/ent/client.go create mode 100644 entc/integration/edgeschema/ent/ent.go create mode 100644 entc/integration/edgeschema/ent/entc.go create mode 100644 entc/integration/edgeschema/ent/entql.go create mode 100644 entc/integration/edgeschema/ent/enttest/enttest.go create mode 100644 entc/integration/edgeschema/ent/file.go create mode 100644 entc/integration/edgeschema/ent/file/file.go create mode 100644 entc/integration/edgeschema/ent/file/mutation.go create mode 100644 entc/integration/edgeschema/ent/file/where.go create mode 100644 entc/integration/edgeschema/ent/file_create.go create mode 100644 entc/integration/edgeschema/ent/file_delete.go create mode 100644 entc/integration/edgeschema/ent/file_query.go create mode 100644 entc/integration/edgeschema/ent/file_update.go create mode 100644 entc/integration/edgeschema/ent/friendship.go create mode 100644 entc/integration/edgeschema/ent/friendship/friendship.go create mode 100644 entc/integration/edgeschema/ent/friendship/mutation.go create mode 100644 entc/integration/edgeschema/ent/friendship/where.go create mode 100644 entc/integration/edgeschema/ent/friendship_create.go create mode 100644 entc/integration/edgeschema/ent/friendship_delete.go create mode 100644 entc/integration/edgeschema/ent/friendship_query.go create mode 100644 entc/integration/edgeschema/ent/friendship_update.go create mode 100644 entc/integration/edgeschema/ent/generate.go create mode 100644 entc/integration/edgeschema/ent/group.go create mode 100644 entc/integration/edgeschema/ent/group/group.go create mode 100644 entc/integration/edgeschema/ent/group/mutation.go create mode 100644 entc/integration/edgeschema/ent/group/where.go create mode 100644 entc/integration/edgeschema/ent/group_create.go create mode 100644 entc/integration/edgeschema/ent/group_delete.go create mode 100644 entc/integration/edgeschema/ent/group_query.go create mode 100644 entc/integration/edgeschema/ent/group_update.go create mode 100644 entc/integration/edgeschema/ent/grouptag.go create mode 100644 entc/integration/edgeschema/ent/grouptag/grouptag.go create mode 100644 entc/integration/edgeschema/ent/grouptag/mutation.go create mode 100644 entc/integration/edgeschema/ent/grouptag/where.go create mode 100644 entc/integration/edgeschema/ent/grouptag_create.go create mode 100644 entc/integration/edgeschema/ent/grouptag_delete.go create mode 100644 entc/integration/edgeschema/ent/grouptag_query.go create mode 100644 entc/integration/edgeschema/ent/grouptag_update.go create mode 100644 entc/integration/edgeschema/ent/hook/hook.go create mode 100644 entc/integration/edgeschema/ent/internal/schema.go create mode 100644 entc/integration/edgeschema/ent/migrate/migrate.go create mode 100644 entc/integration/edgeschema/ent/migrate/schema.go create mode 100644 entc/integration/edgeschema/ent/mutation.go create mode 100644 entc/integration/edgeschema/ent/predicate/predicate.go create mode 100644 entc/integration/edgeschema/ent/privacy/privacy.go create mode 100644 entc/integration/edgeschema/ent/process.go create mode 100644 entc/integration/edgeschema/ent/process/mutation.go create mode 100644 entc/integration/edgeschema/ent/process/process.go create mode 100644 entc/integration/edgeschema/ent/process/where.go create mode 100644 entc/integration/edgeschema/ent/process_create.go create mode 100644 entc/integration/edgeschema/ent/process_delete.go create mode 100644 entc/integration/edgeschema/ent/process_query.go create mode 100644 entc/integration/edgeschema/ent/process_update.go create mode 100644 entc/integration/edgeschema/ent/relationship.go create mode 100644 entc/integration/edgeschema/ent/relationship/mutation.go create mode 100644 entc/integration/edgeschema/ent/relationship/relationship.go create mode 100644 entc/integration/edgeschema/ent/relationship/where.go create mode 100644 entc/integration/edgeschema/ent/relationship_create.go create mode 100644 entc/integration/edgeschema/ent/relationship_delete.go create mode 100644 entc/integration/edgeschema/ent/relationship_query.go create mode 100644 entc/integration/edgeschema/ent/relationship_update.go create mode 100644 entc/integration/edgeschema/ent/relationshipinfo.go create mode 100644 entc/integration/edgeschema/ent/relationshipinfo/mutation.go create mode 100644 entc/integration/edgeschema/ent/relationshipinfo/relationshipinfo.go create mode 100644 entc/integration/edgeschema/ent/relationshipinfo/where.go create mode 100644 entc/integration/edgeschema/ent/relationshipinfo_create.go create mode 100644 entc/integration/edgeschema/ent/relationshipinfo_delete.go create mode 100644 entc/integration/edgeschema/ent/relationshipinfo_query.go create mode 100644 entc/integration/edgeschema/ent/relationshipinfo_update.go create mode 100644 entc/integration/edgeschema/ent/role.go create mode 100644 entc/integration/edgeschema/ent/role/mutation.go create mode 100644 entc/integration/edgeschema/ent/role/role.go create mode 100644 entc/integration/edgeschema/ent/role/where.go create mode 100644 entc/integration/edgeschema/ent/role_create.go create mode 100644 entc/integration/edgeschema/ent/role_delete.go create mode 100644 entc/integration/edgeschema/ent/role_query.go create mode 100644 entc/integration/edgeschema/ent/role_update.go create mode 100644 entc/integration/edgeschema/ent/roleuser.go create mode 100644 entc/integration/edgeschema/ent/roleuser/mutation.go create mode 100644 entc/integration/edgeschema/ent/roleuser/roleuser.go create mode 100644 entc/integration/edgeschema/ent/roleuser/where.go create mode 100644 entc/integration/edgeschema/ent/roleuser_create.go create mode 100644 entc/integration/edgeschema/ent/roleuser_delete.go create mode 100644 entc/integration/edgeschema/ent/roleuser_query.go create mode 100644 entc/integration/edgeschema/ent/roleuser_update.go create mode 100644 entc/integration/edgeschema/ent/runtime.go create mode 100644 entc/integration/edgeschema/ent/runtime/runtime.go create mode 100644 entc/integration/edgeschema/ent/schema/friendship.go create mode 100644 entc/integration/edgeschema/ent/schema/group.go create mode 100644 entc/integration/edgeschema/ent/schema/grouptag.go create mode 100644 entc/integration/edgeschema/ent/schema/process.go create mode 100644 entc/integration/edgeschema/ent/schema/relationship.go create mode 100644 entc/integration/edgeschema/ent/schema/relationshipinfo.go create mode 100644 entc/integration/edgeschema/ent/schema/role.go create mode 100644 entc/integration/edgeschema/ent/schema/roleuser.go create mode 100644 entc/integration/edgeschema/ent/schema/tag.go create mode 100644 entc/integration/edgeschema/ent/schema/tweet.go create mode 100644 entc/integration/edgeschema/ent/schema/tweetlike.go create mode 100644 entc/integration/edgeschema/ent/schema/tweettag.go create mode 100644 entc/integration/edgeschema/ent/schema/user.go create mode 100644 entc/integration/edgeschema/ent/schema/usergroup.go create mode 100644 entc/integration/edgeschema/ent/schema/usertweet.go create mode 100644 entc/integration/edgeschema/ent/tag.go create mode 100644 entc/integration/edgeschema/ent/tag/mutation.go create mode 100644 entc/integration/edgeschema/ent/tag/tag.go create mode 100644 entc/integration/edgeschema/ent/tag/where.go create mode 100644 entc/integration/edgeschema/ent/tag_create.go create mode 100644 entc/integration/edgeschema/ent/tag_delete.go create mode 100644 entc/integration/edgeschema/ent/tag_query.go create mode 100644 entc/integration/edgeschema/ent/tag_update.go create mode 100644 entc/integration/edgeschema/ent/tweet.go create mode 100644 entc/integration/edgeschema/ent/tweet/mutation.go create mode 100644 entc/integration/edgeschema/ent/tweet/tweet.go create mode 100644 entc/integration/edgeschema/ent/tweet/where.go create mode 100644 entc/integration/edgeschema/ent/tweet_create.go create mode 100644 entc/integration/edgeschema/ent/tweet_delete.go create mode 100644 entc/integration/edgeschema/ent/tweet_query.go create mode 100644 entc/integration/edgeschema/ent/tweet_update.go create mode 100644 entc/integration/edgeschema/ent/tweetlike.go create mode 100644 entc/integration/edgeschema/ent/tweetlike/mutation.go create mode 100644 entc/integration/edgeschema/ent/tweetlike/tweetlike.go create mode 100644 entc/integration/edgeschema/ent/tweetlike/where.go create mode 100644 entc/integration/edgeschema/ent/tweetlike_create.go create mode 100644 entc/integration/edgeschema/ent/tweetlike_delete.go create mode 100644 entc/integration/edgeschema/ent/tweetlike_query.go create mode 100644 entc/integration/edgeschema/ent/tweetlike_update.go create mode 100644 entc/integration/edgeschema/ent/tweettag.go create mode 100644 entc/integration/edgeschema/ent/tweettag/mutation.go create mode 100644 entc/integration/edgeschema/ent/tweettag/tweettag.go create mode 100644 entc/integration/edgeschema/ent/tweettag/where.go create mode 100644 entc/integration/edgeschema/ent/tweettag_create.go create mode 100644 entc/integration/edgeschema/ent/tweettag_delete.go create mode 100644 entc/integration/edgeschema/ent/tweettag_query.go create mode 100644 entc/integration/edgeschema/ent/tweettag_update.go create mode 100644 entc/integration/edgeschema/ent/tx.go create mode 100644 entc/integration/edgeschema/ent/user.go create mode 100644 entc/integration/edgeschema/ent/user/mutation.go create mode 100644 entc/integration/edgeschema/ent/user/user.go create mode 100644 entc/integration/edgeschema/ent/user/where.go create mode 100644 entc/integration/edgeschema/ent/user_create.go create mode 100644 entc/integration/edgeschema/ent/user_delete.go create mode 100644 entc/integration/edgeschema/ent/user_query.go create mode 100644 entc/integration/edgeschema/ent/user_update.go create mode 100644 entc/integration/edgeschema/ent/usergroup.go create mode 100644 entc/integration/edgeschema/ent/usergroup/mutation.go create mode 100644 entc/integration/edgeschema/ent/usergroup/usergroup.go create mode 100644 entc/integration/edgeschema/ent/usergroup/where.go create mode 100644 entc/integration/edgeschema/ent/usergroup_create.go create mode 100644 entc/integration/edgeschema/ent/usergroup_delete.go create mode 100644 entc/integration/edgeschema/ent/usergroup_query.go create mode 100644 entc/integration/edgeschema/ent/usergroup_update.go create mode 100644 entc/integration/edgeschema/ent/usertweet.go create mode 100644 entc/integration/edgeschema/ent/usertweet/mutation.go create mode 100644 entc/integration/edgeschema/ent/usertweet/usertweet.go create mode 100644 entc/integration/edgeschema/ent/usertweet/where.go create mode 100644 entc/integration/edgeschema/ent/usertweet_create.go create mode 100644 entc/integration/edgeschema/ent/usertweet_delete.go create mode 100644 entc/integration/edgeschema/ent/usertweet_query.go create mode 100644 entc/integration/edgeschema/ent/usertweet_update.go create mode 100644 entc/integration/ent/api.go create mode 100644 entc/integration/ent/api/api.go create mode 100644 entc/integration/ent/api/mutation.go create mode 100644 entc/integration/ent/api/where.go create mode 100644 entc/integration/ent/api_create.go create mode 100644 entc/integration/ent/api_delete.go create mode 100644 entc/integration/ent/api_query.go create mode 100644 entc/integration/ent/api_update.go create mode 100644 entc/integration/ent/builder.go create mode 100644 entc/integration/ent/builder/builder.go create mode 100644 entc/integration/ent/builder/mutation.go create mode 100644 entc/integration/ent/builder/where.go create mode 100644 entc/integration/ent/builder_create.go create mode 100644 entc/integration/ent/builder_delete.go create mode 100644 entc/integration/ent/builder_query.go create mode 100644 entc/integration/ent/builder_update.go create mode 100644 entc/integration/ent/card.go create mode 100644 entc/integration/ent/card/card.go create mode 100644 entc/integration/ent/card/mutation.go create mode 100644 entc/integration/ent/card/where.go create mode 100644 entc/integration/ent/card_create.go create mode 100644 entc/integration/ent/card_delete.go create mode 100644 entc/integration/ent/card_query.go create mode 100644 entc/integration/ent/card_update.go create mode 100644 entc/integration/ent/client.go create mode 100644 entc/integration/ent/comment.go create mode 100644 entc/integration/ent/comment/comment.go create mode 100644 entc/integration/ent/comment/mutation.go create mode 100644 entc/integration/ent/comment/where.go create mode 100644 entc/integration/ent/comment_create.go create mode 100644 entc/integration/ent/comment_delete.go create mode 100644 entc/integration/ent/comment_query.go create mode 100644 entc/integration/ent/comment_update.go create mode 100644 entc/integration/ent/document.go create mode 100644 entc/integration/ent/document/document.go create mode 100644 entc/integration/ent/document/mutation.go create mode 100644 entc/integration/ent/document/where.go create mode 100644 entc/integration/ent/document_create.go create mode 100644 entc/integration/ent/document_delete.go create mode 100644 entc/integration/ent/document_query.go create mode 100644 entc/integration/ent/document_update.go create mode 100644 entc/integration/ent/ent.go create mode 100644 entc/integration/ent/entql.go create mode 100644 entc/integration/ent/enttest/enttest.go create mode 100644 entc/integration/ent/extension.go create mode 100644 entc/integration/ent/exvaluescan.go create mode 100644 entc/integration/ent/exvaluescan/exvaluescan.go create mode 100644 entc/integration/ent/exvaluescan/mutation.go create mode 100644 entc/integration/ent/exvaluescan/where.go create mode 100644 entc/integration/ent/exvaluescan_create.go create mode 100644 entc/integration/ent/exvaluescan_delete.go create mode 100644 entc/integration/ent/exvaluescan_query.go create mode 100644 entc/integration/ent/exvaluescan_update.go create mode 100644 entc/integration/ent/fieldtype.go create mode 100644 entc/integration/ent/fieldtype/fieldtype.go create mode 100644 entc/integration/ent/fieldtype/mutation.go create mode 100644 entc/integration/ent/fieldtype_delete.go create mode 100644 entc/integration/ent/fieldtype_query.go create mode 100644 entc/integration/ent/fieldtype_update.go create mode 100644 entc/integration/ent/file.go create mode 100644 entc/integration/ent/file/file.go create mode 100644 entc/integration/ent/file/mutation.go create mode 100644 entc/integration/ent/file/where.go create mode 100644 entc/integration/ent/file_create.go create mode 100644 entc/integration/ent/file_delete.go create mode 100644 entc/integration/ent/file_query.go create mode 100644 entc/integration/ent/file_update.go create mode 100644 entc/integration/ent/filetype.go create mode 100644 entc/integration/ent/filetype/filetype.go create mode 100644 entc/integration/ent/filetype/mutation.go create mode 100644 entc/integration/ent/filetype/where.go create mode 100644 entc/integration/ent/filetype_create.go create mode 100644 entc/integration/ent/filetype_delete.go create mode 100644 entc/integration/ent/filetype_query.go create mode 100644 entc/integration/ent/filetype_update.go create mode 100644 entc/integration/ent/generate.go create mode 100644 entc/integration/ent/goods.go create mode 100644 entc/integration/ent/goods/goods.go create mode 100644 entc/integration/ent/goods/mutation.go create mode 100644 entc/integration/ent/goods/where.go create mode 100644 entc/integration/ent/goods_create.go create mode 100644 entc/integration/ent/goods_delete.go create mode 100644 entc/integration/ent/goods_query.go create mode 100644 entc/integration/ent/goods_update.go create mode 100644 entc/integration/ent/group.go create mode 100644 entc/integration/ent/group/group.go create mode 100644 entc/integration/ent/group/mutation.go create mode 100644 entc/integration/ent/group/where.go create mode 100644 entc/integration/ent/group_create.go create mode 100644 entc/integration/ent/group_delete.go create mode 100644 entc/integration/ent/group_query.go create mode 100644 entc/integration/ent/group_update.go create mode 100644 entc/integration/ent/groupinfo.go create mode 100644 entc/integration/ent/groupinfo/groupinfo.go create mode 100644 entc/integration/ent/groupinfo/mutation.go create mode 100644 entc/integration/ent/groupinfo/where.go create mode 100644 entc/integration/ent/groupinfo_create.go create mode 100644 entc/integration/ent/groupinfo_delete.go create mode 100644 entc/integration/ent/groupinfo_query.go create mode 100644 entc/integration/ent/groupinfo_update.go create mode 100644 entc/integration/ent/hook/hook.go create mode 100644 entc/integration/ent/internal/globalid.go create mode 100644 entc/integration/ent/item.go create mode 100644 entc/integration/ent/item/item.go create mode 100644 entc/integration/ent/item/mutation.go create mode 100644 entc/integration/ent/item/where.go create mode 100644 entc/integration/ent/item_create.go create mode 100644 entc/integration/ent/item_delete.go create mode 100644 entc/integration/ent/item_query.go create mode 100644 entc/integration/ent/item_update.go create mode 100644 entc/integration/ent/license.go create mode 100644 entc/integration/ent/license/license.go create mode 100644 entc/integration/ent/license/mutation.go create mode 100644 entc/integration/ent/license/where.go create mode 100644 entc/integration/ent/license_create.go create mode 100644 entc/integration/ent/license_delete.go create mode 100644 entc/integration/ent/license_query.go create mode 100644 entc/integration/ent/license_update.go create mode 100644 entc/integration/ent/migrate/migrate.go create mode 100644 entc/integration/ent/migrate/schema.go create mode 100644 entc/integration/ent/node.go create mode 100644 entc/integration/ent/node/mutation.go create mode 100644 entc/integration/ent/node/node.go create mode 100644 entc/integration/ent/node/where.go create mode 100644 entc/integration/ent/node_create.go create mode 100644 entc/integration/ent/node_delete.go create mode 100644 entc/integration/ent/node_query.go create mode 100644 entc/integration/ent/node_update.go create mode 100644 entc/integration/ent/pc.go create mode 100644 entc/integration/ent/pc/mutation.go create mode 100644 entc/integration/ent/pc/pc.go create mode 100644 entc/integration/ent/pc/where.go create mode 100644 entc/integration/ent/pc_create.go create mode 100644 entc/integration/ent/pc_delete.go create mode 100644 entc/integration/ent/pc_query.go create mode 100644 entc/integration/ent/pc_update.go create mode 100644 entc/integration/ent/pet.go create mode 100644 entc/integration/ent/pet/mutation.go create mode 100644 entc/integration/ent/pet/pet.go create mode 100644 entc/integration/ent/pet/where.go create mode 100644 entc/integration/ent/pet_create.go create mode 100644 entc/integration/ent/pet_delete.go create mode 100644 entc/integration/ent/pet_query.go create mode 100644 entc/integration/ent/pet_update.go create mode 100644 entc/integration/ent/predicate/predicate.go create mode 100644 entc/integration/ent/role/role.go create mode 100644 entc/integration/ent/runtime.go create mode 100644 entc/integration/ent/runtime/runtime.go create mode 100644 entc/integration/ent/schema/api.go create mode 100644 entc/integration/ent/schema/builder.go create mode 100644 entc/integration/ent/schema/card.go create mode 100644 entc/integration/ent/schema/comment.go create mode 100644 entc/integration/ent/schema/dir/dir.go create mode 100644 entc/integration/ent/schema/document.go create mode 100644 entc/integration/ent/schema/exvaluescan.go create mode 100644 entc/integration/ent/schema/fieldtype.go create mode 100644 entc/integration/ent/schema/file.go create mode 100644 entc/integration/ent/schema/filetype.go create mode 100644 entc/integration/ent/schema/goods.go create mode 100644 entc/integration/ent/schema/group.go create mode 100644 entc/integration/ent/schema/groupinfo.go create mode 100644 entc/integration/ent/schema/item.go create mode 100644 entc/integration/ent/schema/license.go create mode 100644 entc/integration/ent/schema/node.go create mode 100644 entc/integration/ent/schema/pc.go create mode 100644 entc/integration/ent/schema/pet.go create mode 100644 entc/integration/ent/schema/spec.go create mode 100644 entc/integration/ent/schema/task.go create mode 100644 entc/integration/ent/schema/task/priority.go create mode 100644 entc/integration/ent/schema/user.go create mode 100644 entc/integration/ent/spec.go create mode 100644 entc/integration/ent/spec/mutation.go create mode 100644 entc/integration/ent/spec/spec.go create mode 100644 entc/integration/ent/spec/where.go create mode 100644 entc/integration/ent/spec_create.go create mode 100644 entc/integration/ent/spec_delete.go create mode 100644 entc/integration/ent/spec_query.go create mode 100644 entc/integration/ent/spec_update.go create mode 100644 entc/integration/ent/task.go create mode 100644 entc/integration/ent/task/mutation.go create mode 100644 entc/integration/ent/task/task.go create mode 100644 entc/integration/ent/task/where.go create mode 100644 entc/integration/ent/task_create.go create mode 100644 entc/integration/ent/task_delete.go create mode 100644 entc/integration/ent/task_query.go create mode 100644 entc/integration/ent/task_update.go create mode 100644 entc/integration/ent/template/client.tmpl create mode 100644 entc/integration/ent/template/ext.go create mode 100644 entc/integration/ent/template/ext.tmpl create mode 100644 entc/integration/ent/template/fields.tmpl create mode 100644 entc/integration/ent/template/meta.tmpl create mode 100644 entc/integration/ent/tx.go create mode 100644 entc/integration/ent/user.go create mode 100644 entc/integration/ent/user/mutation.go create mode 100644 entc/integration/ent/user/user.go create mode 100644 entc/integration/ent/user/where.go create mode 100644 entc/integration/ent/user_create.go create mode 100644 entc/integration/ent/user_delete.go create mode 100644 entc/integration/ent/user_query.go create mode 100644 entc/integration/ent/user_update.go create mode 100644 entc/integration/entql_test.go create mode 100644 entc/integration/go.mod create mode 100644 entc/integration/go.sum create mode 100644 entc/integration/gremlin/ent/api.go create mode 100644 entc/integration/gremlin/ent/api/api.go create mode 100644 entc/integration/gremlin/ent/api/mutation.go create mode 100644 entc/integration/gremlin/ent/api/where.go create mode 100644 entc/integration/gremlin/ent/api_create.go create mode 100644 entc/integration/gremlin/ent/api_delete.go create mode 100644 entc/integration/gremlin/ent/api_query.go create mode 100644 entc/integration/gremlin/ent/api_update.go create mode 100644 entc/integration/gremlin/ent/builder.go create mode 100644 entc/integration/gremlin/ent/builder/builder.go create mode 100644 entc/integration/gremlin/ent/builder/mutation.go create mode 100644 entc/integration/gremlin/ent/builder/where.go create mode 100644 entc/integration/gremlin/ent/builder_create.go create mode 100644 entc/integration/gremlin/ent/builder_delete.go create mode 100644 entc/integration/gremlin/ent/builder_query.go create mode 100644 entc/integration/gremlin/ent/builder_update.go create mode 100644 entc/integration/gremlin/ent/card.go create mode 100644 entc/integration/gremlin/ent/card/card.go create mode 100644 entc/integration/gremlin/ent/card/mutation.go create mode 100644 entc/integration/gremlin/ent/card/where.go create mode 100644 entc/integration/gremlin/ent/card_create.go create mode 100644 entc/integration/gremlin/ent/card_delete.go create mode 100644 entc/integration/gremlin/ent/card_query.go create mode 100644 entc/integration/gremlin/ent/card_update.go create mode 100644 entc/integration/gremlin/ent/client.go create mode 100644 entc/integration/gremlin/ent/comment.go create mode 100644 entc/integration/gremlin/ent/comment/comment.go create mode 100644 entc/integration/gremlin/ent/comment/mutation.go create mode 100644 entc/integration/gremlin/ent/comment/where.go create mode 100644 entc/integration/gremlin/ent/comment_create.go create mode 100644 entc/integration/gremlin/ent/comment_delete.go create mode 100644 entc/integration/gremlin/ent/comment_query.go create mode 100644 entc/integration/gremlin/ent/comment_update.go create mode 100644 entc/integration/gremlin/ent/document.go create mode 100644 entc/integration/gremlin/ent/document/document.go create mode 100644 entc/integration/gremlin/ent/document/mutation.go create mode 100644 entc/integration/gremlin/ent/document/where.go create mode 100644 entc/integration/gremlin/ent/document_create.go create mode 100644 entc/integration/gremlin/ent/document_delete.go create mode 100644 entc/integration/gremlin/ent/document_query.go create mode 100644 entc/integration/gremlin/ent/document_update.go create mode 100644 entc/integration/gremlin/ent/ent.go create mode 100644 entc/integration/gremlin/ent/enttest/enttest.go create mode 100644 entc/integration/gremlin/ent/extension.go create mode 100644 entc/integration/gremlin/ent/exvaluescan.go create mode 100644 entc/integration/gremlin/ent/exvaluescan/exvaluescan.go create mode 100644 entc/integration/gremlin/ent/exvaluescan/mutation.go create mode 100644 entc/integration/gremlin/ent/exvaluescan/where.go create mode 100644 entc/integration/gremlin/ent/exvaluescan_create.go create mode 100644 entc/integration/gremlin/ent/exvaluescan_delete.go create mode 100644 entc/integration/gremlin/ent/exvaluescan_query.go create mode 100644 entc/integration/gremlin/ent/exvaluescan_update.go create mode 100644 entc/integration/gremlin/ent/fieldtype.go create mode 100644 entc/integration/gremlin/ent/fieldtype/fieldtype.go create mode 100644 entc/integration/gremlin/ent/fieldtype/mutation.go create mode 100644 entc/integration/gremlin/ent/fieldtype_create.go create mode 100644 entc/integration/gremlin/ent/fieldtype_delete.go create mode 100644 entc/integration/gremlin/ent/fieldtype_query.go create mode 100644 entc/integration/gremlin/ent/fieldtype_update.go create mode 100644 entc/integration/gremlin/ent/file.go create mode 100644 entc/integration/gremlin/ent/file/file.go create mode 100644 entc/integration/gremlin/ent/file/mutation.go create mode 100644 entc/integration/gremlin/ent/file/where.go create mode 100644 entc/integration/gremlin/ent/file_create.go create mode 100644 entc/integration/gremlin/ent/file_delete.go create mode 100644 entc/integration/gremlin/ent/file_query.go create mode 100644 entc/integration/gremlin/ent/file_update.go create mode 100644 entc/integration/gremlin/ent/filetype.go create mode 100644 entc/integration/gremlin/ent/filetype/filetype.go create mode 100644 entc/integration/gremlin/ent/filetype/mutation.go create mode 100644 entc/integration/gremlin/ent/filetype/where.go create mode 100644 entc/integration/gremlin/ent/filetype_create.go create mode 100644 entc/integration/gremlin/ent/filetype_delete.go create mode 100644 entc/integration/gremlin/ent/filetype_query.go create mode 100644 entc/integration/gremlin/ent/filetype_update.go create mode 100644 entc/integration/gremlin/ent/generate.go create mode 100644 entc/integration/gremlin/ent/goods.go create mode 100644 entc/integration/gremlin/ent/goods/goods.go create mode 100644 entc/integration/gremlin/ent/goods/mutation.go create mode 100644 entc/integration/gremlin/ent/goods/where.go create mode 100644 entc/integration/gremlin/ent/goods_create.go create mode 100644 entc/integration/gremlin/ent/goods_delete.go create mode 100644 entc/integration/gremlin/ent/goods_query.go create mode 100644 entc/integration/gremlin/ent/goods_update.go create mode 100644 entc/integration/gremlin/ent/group.go create mode 100644 entc/integration/gremlin/ent/group/group.go create mode 100644 entc/integration/gremlin/ent/group/mutation.go create mode 100644 entc/integration/gremlin/ent/group/where.go create mode 100644 entc/integration/gremlin/ent/group_create.go create mode 100644 entc/integration/gremlin/ent/group_delete.go create mode 100644 entc/integration/gremlin/ent/group_query.go create mode 100644 entc/integration/gremlin/ent/group_update.go create mode 100644 entc/integration/gremlin/ent/groupinfo.go create mode 100644 entc/integration/gremlin/ent/groupinfo/groupinfo.go create mode 100644 entc/integration/gremlin/ent/groupinfo/mutation.go create mode 100644 entc/integration/gremlin/ent/groupinfo/where.go create mode 100644 entc/integration/gremlin/ent/groupinfo_create.go create mode 100644 entc/integration/gremlin/ent/groupinfo_delete.go create mode 100644 entc/integration/gremlin/ent/groupinfo_query.go create mode 100644 entc/integration/gremlin/ent/groupinfo_update.go create mode 100644 entc/integration/gremlin/ent/hook/hook.go create mode 100644 entc/integration/gremlin/ent/item.go create mode 100644 entc/integration/gremlin/ent/item/item.go create mode 100644 entc/integration/gremlin/ent/item/mutation.go create mode 100644 entc/integration/gremlin/ent/item/where.go create mode 100644 entc/integration/gremlin/ent/item_create.go create mode 100644 entc/integration/gremlin/ent/item_delete.go create mode 100644 entc/integration/gremlin/ent/item_query.go create mode 100644 entc/integration/gremlin/ent/item_update.go create mode 100644 entc/integration/gremlin/ent/license.go create mode 100644 entc/integration/gremlin/ent/license/license.go create mode 100644 entc/integration/gremlin/ent/license/mutation.go create mode 100644 entc/integration/gremlin/ent/license/where.go create mode 100644 entc/integration/gremlin/ent/license_create.go create mode 100644 entc/integration/gremlin/ent/license_delete.go create mode 100644 entc/integration/gremlin/ent/license_query.go create mode 100644 entc/integration/gremlin/ent/license_update.go create mode 100644 entc/integration/gremlin/ent/node.go create mode 100644 entc/integration/gremlin/ent/node/mutation.go create mode 100644 entc/integration/gremlin/ent/node/node.go create mode 100644 entc/integration/gremlin/ent/node/where.go create mode 100644 entc/integration/gremlin/ent/node_create.go create mode 100644 entc/integration/gremlin/ent/node_delete.go create mode 100644 entc/integration/gremlin/ent/node_query.go create mode 100644 entc/integration/gremlin/ent/node_update.go create mode 100644 entc/integration/gremlin/ent/pc.go create mode 100644 entc/integration/gremlin/ent/pc/mutation.go create mode 100644 entc/integration/gremlin/ent/pc/pc.go create mode 100644 entc/integration/gremlin/ent/pc/where.go create mode 100644 entc/integration/gremlin/ent/pc_create.go create mode 100644 entc/integration/gremlin/ent/pc_delete.go create mode 100644 entc/integration/gremlin/ent/pc_query.go create mode 100644 entc/integration/gremlin/ent/pc_update.go create mode 100644 entc/integration/gremlin/ent/pet.go create mode 100644 entc/integration/gremlin/ent/pet/mutation.go create mode 100644 entc/integration/gremlin/ent/pet/pet.go create mode 100644 entc/integration/gremlin/ent/pet/where.go create mode 100644 entc/integration/gremlin/ent/pet_create.go create mode 100644 entc/integration/gremlin/ent/pet_delete.go create mode 100644 entc/integration/gremlin/ent/pet_query.go create mode 100644 entc/integration/gremlin/ent/pet_update.go create mode 100644 entc/integration/gremlin/ent/predicate/predicate.go create mode 100644 entc/integration/gremlin/ent/runtime.go create mode 100644 entc/integration/gremlin/ent/runtime/runtime.go create mode 100644 entc/integration/gremlin/ent/spec.go create mode 100644 entc/integration/gremlin/ent/spec/mutation.go create mode 100644 entc/integration/gremlin/ent/spec/spec.go create mode 100644 entc/integration/gremlin/ent/spec/where.go create mode 100644 entc/integration/gremlin/ent/spec_create.go create mode 100644 entc/integration/gremlin/ent/spec_delete.go create mode 100644 entc/integration/gremlin/ent/spec_query.go create mode 100644 entc/integration/gremlin/ent/spec_update.go create mode 100644 entc/integration/gremlin/ent/task.go create mode 100644 entc/integration/gremlin/ent/task/mutation.go create mode 100644 entc/integration/gremlin/ent/task/task.go create mode 100644 entc/integration/gremlin/ent/task/where.go create mode 100644 entc/integration/gremlin/ent/task_create.go create mode 100644 entc/integration/gremlin/ent/task_delete.go create mode 100644 entc/integration/gremlin/ent/task_query.go create mode 100644 entc/integration/gremlin/ent/task_update.go create mode 100644 entc/integration/gremlin/ent/tx.go create mode 100644 entc/integration/gremlin/ent/user.go create mode 100644 entc/integration/gremlin/ent/user/mutation.go create mode 100644 entc/integration/gremlin/ent/user/user.go create mode 100644 entc/integration/gremlin/ent/user/where.go create mode 100644 entc/integration/gremlin/ent/user_create.go create mode 100644 entc/integration/gremlin/ent/user_delete.go create mode 100644 entc/integration/gremlin/ent/user_query.go create mode 100644 entc/integration/gremlin/ent/user_update.go create mode 100644 entc/integration/gremlin/gremlin_test.go create mode 100644 entc/integration/hooks/ent/card.go create mode 100644 entc/integration/hooks/ent/card/card.go create mode 100644 entc/integration/hooks/ent/card/mutation.go create mode 100644 entc/integration/hooks/ent/card/where.go create mode 100644 entc/integration/hooks/ent/card_create.go create mode 100644 entc/integration/hooks/ent/card_delete.go create mode 100644 entc/integration/hooks/ent/card_query.go create mode 100644 entc/integration/hooks/ent/card_update.go create mode 100644 entc/integration/hooks/ent/client.go create mode 100644 entc/integration/hooks/ent/ent.go create mode 100644 entc/integration/hooks/ent/enttest/enttest.go create mode 100644 entc/integration/hooks/ent/generate.go create mode 100644 entc/integration/hooks/ent/hook/hook.go create mode 100644 entc/integration/hooks/ent/intercept/intercept.go create mode 100644 entc/integration/hooks/ent/internal/schema.go create mode 100644 entc/integration/hooks/ent/migrate/migrate.go create mode 100644 entc/integration/hooks/ent/migrate/schema.go create mode 100644 entc/integration/hooks/ent/mutation.go create mode 100644 entc/integration/hooks/ent/pet.go create mode 100644 entc/integration/hooks/ent/pet/mutation.go create mode 100644 entc/integration/hooks/ent/pet/pet.go create mode 100644 entc/integration/hooks/ent/pet/where.go create mode 100644 entc/integration/hooks/ent/pet_create.go create mode 100644 entc/integration/hooks/ent/pet_delete.go create mode 100644 entc/integration/hooks/ent/pet_query.go create mode 100644 entc/integration/hooks/ent/pet_update.go create mode 100644 entc/integration/hooks/ent/predicate/predicate.go create mode 100644 entc/integration/hooks/ent/runtime.go create mode 100644 entc/integration/hooks/ent/runtime/runtime.go create mode 100644 entc/integration/hooks/ent/schema/card.go create mode 100644 entc/integration/hooks/ent/schema/mixin.go create mode 100644 entc/integration/hooks/ent/schema/pet.go create mode 100644 entc/integration/hooks/ent/schema/user.go create mode 100644 entc/integration/hooks/ent/tx.go create mode 100644 entc/integration/hooks/ent/user.go create mode 100644 entc/integration/hooks/ent/user/mutation.go create mode 100644 entc/integration/hooks/ent/user/user.go create mode 100644 entc/integration/hooks/ent/user/where.go create mode 100644 entc/integration/hooks/ent/user_create.go create mode 100644 entc/integration/hooks/ent/user_delete.go create mode 100644 entc/integration/hooks/ent/user_query.go create mode 100644 entc/integration/hooks/ent/user_update.go create mode 100644 entc/integration/hooks/hooks_test.go create mode 100644 entc/integration/idtype/ent/client.go create mode 100644 entc/integration/idtype/ent/ent.go create mode 100644 entc/integration/idtype/ent/enttest/enttest.go create mode 100644 entc/integration/idtype/ent/generate.go create mode 100644 entc/integration/idtype/ent/hook/hook.go create mode 100644 entc/integration/idtype/ent/migrate/migrate.go create mode 100644 entc/integration/idtype/ent/migrate/schema.go create mode 100644 entc/integration/idtype/ent/mutation.go create mode 100644 entc/integration/idtype/ent/predicate/predicate.go create mode 100644 entc/integration/idtype/ent/runtime.go create mode 100644 entc/integration/idtype/ent/runtime/runtime.go create mode 100644 entc/integration/idtype/ent/schema/user.go create mode 100644 entc/integration/idtype/ent/tx.go create mode 100644 entc/integration/idtype/ent/user.go create mode 100644 entc/integration/idtype/ent/user/mutation.go create mode 100644 entc/integration/idtype/ent/user/user.go create mode 100644 entc/integration/idtype/ent/user/where.go create mode 100644 entc/integration/idtype/ent/user_create.go create mode 100644 entc/integration/idtype/ent/user_delete.go create mode 100644 entc/integration/idtype/ent/user_query.go create mode 100644 entc/integration/idtype/ent/user_update.go create mode 100644 entc/integration/idtype/idtype_test.go create mode 100644 entc/integration/index_test.go create mode 100644 entc/integration/integration_test.go create mode 100644 entc/integration/json/ent/client.go create mode 100644 entc/integration/json/ent/ent.go create mode 100644 entc/integration/json/ent/enttest/enttest.go create mode 100644 entc/integration/json/ent/generate.go create mode 100644 entc/integration/json/ent/hook/hook.go create mode 100644 entc/integration/json/ent/migrate/migrate.go create mode 100644 entc/integration/json/ent/migrate/schema.go create mode 100644 entc/integration/json/ent/mutation.go create mode 100644 entc/integration/json/ent/predicate/predicate.go create mode 100644 entc/integration/json/ent/runtime.go create mode 100644 entc/integration/json/ent/runtime/runtime.go create mode 100644 entc/integration/json/ent/schema/user.go create mode 100644 entc/integration/json/ent/tx.go create mode 100644 entc/integration/json/ent/user.go create mode 100644 entc/integration/json/ent/user/mutation.go create mode 100644 entc/integration/json/ent/user/user.go create mode 100644 entc/integration/json/ent/user/where.go create mode 100644 entc/integration/json/ent/user_create.go create mode 100644 entc/integration/json/ent/user_delete.go create mode 100644 entc/integration/json/ent/user_query.go create mode 100644 entc/integration/json/ent/user_update.go create mode 100644 entc/integration/json/json_test.go create mode 100644 entc/integration/migrate/entv1/car.go create mode 100644 entc/integration/migrate/entv1/car/car.go create mode 100644 entc/integration/migrate/entv1/car/mutation.go create mode 100644 entc/integration/migrate/entv1/car/where.go create mode 100644 entc/integration/migrate/entv1/car_create.go create mode 100644 entc/integration/migrate/entv1/car_delete.go create mode 100644 entc/integration/migrate/entv1/car_query.go create mode 100644 entc/integration/migrate/entv1/car_update.go create mode 100644 entc/integration/migrate/entv1/client.go create mode 100644 entc/integration/migrate/entv1/conversion.go create mode 100644 entc/integration/migrate/entv1/conversion/conversion.go create mode 100644 entc/integration/migrate/entv1/conversion/mutation.go create mode 100644 entc/integration/migrate/entv1/conversion/where.go create mode 100644 entc/integration/migrate/entv1/conversion_create.go create mode 100644 entc/integration/migrate/entv1/conversion_delete.go create mode 100644 entc/integration/migrate/entv1/conversion_query.go create mode 100644 entc/integration/migrate/entv1/conversion_update.go create mode 100644 entc/integration/migrate/entv1/customtype.go create mode 100644 entc/integration/migrate/entv1/customtype/customtype.go create mode 100644 entc/integration/migrate/entv1/customtype/mutation.go create mode 100644 entc/integration/migrate/entv1/customtype/where.go create mode 100644 entc/integration/migrate/entv1/customtype_create.go create mode 100644 entc/integration/migrate/entv1/customtype_delete.go create mode 100644 entc/integration/migrate/entv1/customtype_query.go create mode 100644 entc/integration/migrate/entv1/customtype_update.go create mode 100644 entc/integration/migrate/entv1/ent.go create mode 100644 entc/integration/migrate/entv1/enttest/enttest.go create mode 100644 entc/integration/migrate/entv1/generate.go create mode 100644 entc/integration/migrate/entv1/hook/hook.go create mode 100644 entc/integration/migrate/entv1/migrate/migrate.go create mode 100644 entc/integration/migrate/entv1/migrate/schema.go create mode 100644 entc/integration/migrate/entv1/mutation.go create mode 100644 entc/integration/migrate/entv1/predicate/predicate.go create mode 100644 entc/integration/migrate/entv1/runtime.go create mode 100644 entc/integration/migrate/entv1/runtime/runtime.go create mode 100644 entc/integration/migrate/entv1/schema/conversion.go create mode 100644 entc/integration/migrate/entv1/schema/customtype.go create mode 100644 entc/integration/migrate/entv1/schema/user.go create mode 100644 entc/integration/migrate/entv1/tx.go create mode 100644 entc/integration/migrate/entv1/user.go create mode 100644 entc/integration/migrate/entv1/user/mutation.go create mode 100644 entc/integration/migrate/entv1/user/user.go create mode 100644 entc/integration/migrate/entv1/user/where.go create mode 100644 entc/integration/migrate/entv1/user_create.go create mode 100644 entc/integration/migrate/entv1/user_delete.go create mode 100644 entc/integration/migrate/entv1/user_query.go create mode 100644 entc/integration/migrate/entv1/user_update.go create mode 100644 entc/integration/migrate/entv2/blog.go create mode 100644 entc/integration/migrate/entv2/blog/blog.go create mode 100644 entc/integration/migrate/entv2/blog/mutation.go create mode 100644 entc/integration/migrate/entv2/blog/where.go create mode 100644 entc/integration/migrate/entv2/blog_create.go create mode 100644 entc/integration/migrate/entv2/blog_delete.go create mode 100644 entc/integration/migrate/entv2/blog_query.go create mode 100644 entc/integration/migrate/entv2/blog_update.go create mode 100644 entc/integration/migrate/entv2/car.go create mode 100644 entc/integration/migrate/entv2/car/car.go create mode 100644 entc/integration/migrate/entv2/car/mutation.go create mode 100644 entc/integration/migrate/entv2/car/where.go create mode 100644 entc/integration/migrate/entv2/car_create.go create mode 100644 entc/integration/migrate/entv2/car_delete.go create mode 100644 entc/integration/migrate/entv2/car_query.go create mode 100644 entc/integration/migrate/entv2/car_update.go create mode 100644 entc/integration/migrate/entv2/client.go create mode 100644 entc/integration/migrate/entv2/conversion.go create mode 100644 entc/integration/migrate/entv2/conversion/conversion.go create mode 100644 entc/integration/migrate/entv2/conversion/mutation.go create mode 100644 entc/integration/migrate/entv2/conversion/where.go create mode 100644 entc/integration/migrate/entv2/conversion_create.go create mode 100644 entc/integration/migrate/entv2/conversion_delete.go create mode 100644 entc/integration/migrate/entv2/conversion_query.go create mode 100644 entc/integration/migrate/entv2/conversion_update.go create mode 100644 entc/integration/migrate/entv2/customtype.go create mode 100644 entc/integration/migrate/entv2/customtype/customtype.go create mode 100644 entc/integration/migrate/entv2/customtype/mutation.go create mode 100644 entc/integration/migrate/entv2/customtype/where.go create mode 100644 entc/integration/migrate/entv2/customtype_create.go create mode 100644 entc/integration/migrate/entv2/customtype_delete.go create mode 100644 entc/integration/migrate/entv2/customtype_query.go create mode 100644 entc/integration/migrate/entv2/customtype_update.go create mode 100644 entc/integration/migrate/entv2/ent.go create mode 100644 entc/integration/migrate/entv2/enttest/enttest.go create mode 100644 entc/integration/migrate/entv2/generate.go create mode 100644 entc/integration/migrate/entv2/group.go create mode 100644 entc/integration/migrate/entv2/group/group.go create mode 100644 entc/integration/migrate/entv2/group/mutation.go create mode 100644 entc/integration/migrate/entv2/group/where.go create mode 100644 entc/integration/migrate/entv2/group_create.go create mode 100644 entc/integration/migrate/entv2/group_delete.go create mode 100644 entc/integration/migrate/entv2/group_query.go create mode 100644 entc/integration/migrate/entv2/group_update.go create mode 100644 entc/integration/migrate/entv2/hook/hook.go create mode 100644 entc/integration/migrate/entv2/media.go create mode 100644 entc/integration/migrate/entv2/media/media.go create mode 100644 entc/integration/migrate/entv2/media/mutation.go create mode 100644 entc/integration/migrate/entv2/media/where.go create mode 100644 entc/integration/migrate/entv2/media_create.go create mode 100644 entc/integration/migrate/entv2/media_delete.go create mode 100644 entc/integration/migrate/entv2/media_query.go create mode 100644 entc/integration/migrate/entv2/media_update.go create mode 100644 entc/integration/migrate/entv2/migrate/migrate.go create mode 100644 entc/integration/migrate/entv2/migrate/schema.go create mode 100644 entc/integration/migrate/entv2/mutation.go create mode 100644 entc/integration/migrate/entv2/pet.go create mode 100644 entc/integration/migrate/entv2/pet/mutation.go create mode 100644 entc/integration/migrate/entv2/pet/pet.go create mode 100644 entc/integration/migrate/entv2/pet/where.go create mode 100644 entc/integration/migrate/entv2/pet_create.go create mode 100644 entc/integration/migrate/entv2/pet_delete.go create mode 100644 entc/integration/migrate/entv2/pet_query.go create mode 100644 entc/integration/migrate/entv2/pet_update.go create mode 100644 entc/integration/migrate/entv2/predicate/predicate.go create mode 100644 entc/integration/migrate/entv2/runtime.go create mode 100644 entc/integration/migrate/entv2/runtime/runtime.go create mode 100644 entc/integration/migrate/entv2/schema/conversion.go create mode 100644 entc/integration/migrate/entv2/schema/customtype.go create mode 100644 entc/integration/migrate/entv2/schema/media.go create mode 100644 entc/integration/migrate/entv2/schema/user.go create mode 100644 entc/integration/migrate/entv2/schema/zoo.go create mode 100644 entc/integration/migrate/entv2/tx.go create mode 100644 entc/integration/migrate/entv2/user.go create mode 100644 entc/integration/migrate/entv2/user/mutation.go create mode 100644 entc/integration/migrate/entv2/user/user.go create mode 100644 entc/integration/migrate/entv2/user/where.go create mode 100644 entc/integration/migrate/entv2/user_create.go create mode 100644 entc/integration/migrate/entv2/user_delete.go create mode 100644 entc/integration/migrate/entv2/user_query.go create mode 100644 entc/integration/migrate/entv2/user_update.go create mode 100644 entc/integration/migrate/entv2/zoo.go create mode 100644 entc/integration/migrate/entv2/zoo/mutation.go create mode 100644 entc/integration/migrate/entv2/zoo/where.go create mode 100644 entc/integration/migrate/entv2/zoo/zoo.go create mode 100644 entc/integration/migrate/entv2/zoo_create.go create mode 100644 entc/integration/migrate/entv2/zoo_delete.go create mode 100644 entc/integration/migrate/entv2/zoo_query.go create mode 100644 entc/integration/migrate/entv2/zoo_update.go create mode 100644 entc/integration/migrate/migrate_test.go create mode 100644 entc/integration/migrate/versioned/client.go create mode 100644 entc/integration/migrate/versioned/ent.go create mode 100644 entc/integration/migrate/versioned/enttest/enttest.go create mode 100644 entc/integration/migrate/versioned/generate.go create mode 100644 entc/integration/migrate/versioned/group.go create mode 100644 entc/integration/migrate/versioned/group/group.go create mode 100644 entc/integration/migrate/versioned/group/mutation.go create mode 100644 entc/integration/migrate/versioned/group/where.go create mode 100644 entc/integration/migrate/versioned/group_create.go create mode 100644 entc/integration/migrate/versioned/group_delete.go create mode 100644 entc/integration/migrate/versioned/group_query.go create mode 100644 entc/integration/migrate/versioned/group_update.go create mode 100644 entc/integration/migrate/versioned/hook/hook.go create mode 100644 entc/integration/migrate/versioned/migrate/migrate.go create mode 100644 entc/integration/migrate/versioned/migrate/schema.go create mode 100644 entc/integration/migrate/versioned/mutation.go create mode 100644 entc/integration/migrate/versioned/predicate/predicate.go create mode 100644 entc/integration/migrate/versioned/runtime.go create mode 100644 entc/integration/migrate/versioned/runtime/runtime.go create mode 100644 entc/integration/migrate/versioned/schema/group.go create mode 100644 entc/integration/migrate/versioned/schema/user.go create mode 100644 entc/integration/migrate/versioned/tx.go create mode 100644 entc/integration/migrate/versioned/user.go create mode 100644 entc/integration/migrate/versioned/user/mutation.go create mode 100644 entc/integration/migrate/versioned/user/user.go create mode 100644 entc/integration/migrate/versioned/user/where.go create mode 100644 entc/integration/migrate/versioned/user_create.go create mode 100644 entc/integration/migrate/versioned/user_delete.go create mode 100644 entc/integration/migrate/versioned/user_query.go create mode 100644 entc/integration/migrate/versioned/user_update.go create mode 100644 entc/integration/multischema/README.md create mode 100644 entc/integration/multischema/ent/cleanuser.go create mode 100644 entc/integration/multischema/ent/cleanuser/cleanuser.go create mode 100644 entc/integration/multischema/ent/cleanuser/where.go create mode 100644 entc/integration/multischema/ent/cleanuser_query.go create mode 100644 entc/integration/multischema/ent/client.go create mode 100644 entc/integration/multischema/ent/ent.go create mode 100644 entc/integration/multischema/ent/enttest/enttest.go create mode 100644 entc/integration/multischema/ent/friendship.go create mode 100644 entc/integration/multischema/ent/friendship/friendship.go create mode 100644 entc/integration/multischema/ent/friendship/mutation.go create mode 100644 entc/integration/multischema/ent/friendship/where.go create mode 100644 entc/integration/multischema/ent/friendship_create.go create mode 100644 entc/integration/multischema/ent/friendship_delete.go create mode 100644 entc/integration/multischema/ent/friendship_query.go create mode 100644 entc/integration/multischema/ent/friendship_update.go create mode 100644 entc/integration/multischema/ent/generate.go create mode 100644 entc/integration/multischema/ent/group.go create mode 100644 entc/integration/multischema/ent/group/group.go create mode 100644 entc/integration/multischema/ent/group/mutation.go create mode 100644 entc/integration/multischema/ent/group/where.go create mode 100644 entc/integration/multischema/ent/group_create.go create mode 100644 entc/integration/multischema/ent/group_delete.go create mode 100644 entc/integration/multischema/ent/group_query.go create mode 100644 entc/integration/multischema/ent/group_update.go create mode 100644 entc/integration/multischema/ent/hook/hook.go create mode 100644 entc/integration/multischema/ent/internal/schemaconfig.go create mode 100644 entc/integration/multischema/ent/migrate/migrate.go create mode 100644 entc/integration/multischema/ent/migrate/schema.go create mode 100644 entc/integration/multischema/ent/mutation.go create mode 100644 entc/integration/multischema/ent/parent.go create mode 100644 entc/integration/multischema/ent/parent/mutation.go create mode 100644 entc/integration/multischema/ent/parent/parent.go create mode 100644 entc/integration/multischema/ent/parent/where.go create mode 100644 entc/integration/multischema/ent/parent_create.go create mode 100644 entc/integration/multischema/ent/parent_delete.go create mode 100644 entc/integration/multischema/ent/parent_query.go create mode 100644 entc/integration/multischema/ent/parent_update.go create mode 100644 entc/integration/multischema/ent/pet.go create mode 100644 entc/integration/multischema/ent/pet/mutation.go create mode 100644 entc/integration/multischema/ent/pet/pet.go create mode 100644 entc/integration/multischema/ent/pet/where.go create mode 100644 entc/integration/multischema/ent/pet_create.go create mode 100644 entc/integration/multischema/ent/pet_delete.go create mode 100644 entc/integration/multischema/ent/pet_query.go create mode 100644 entc/integration/multischema/ent/pet_update.go create mode 100644 entc/integration/multischema/ent/predicate/predicate.go create mode 100644 entc/integration/multischema/ent/runtime.go create mode 100644 entc/integration/multischema/ent/runtime/runtime.go create mode 100644 entc/integration/multischema/ent/schema/base.go create mode 100644 entc/integration/multischema/ent/schema/friendship.go create mode 100644 entc/integration/multischema/ent/schema/group.go create mode 100644 entc/integration/multischema/ent/schema/parent.go create mode 100644 entc/integration/multischema/ent/schema/pet.go create mode 100644 entc/integration/multischema/ent/schema/user.go create mode 100644 entc/integration/multischema/ent/template/config.tmpl create mode 100644 entc/integration/multischema/ent/tx.go create mode 100644 entc/integration/multischema/ent/user.go create mode 100644 entc/integration/multischema/ent/user/mutation.go create mode 100644 entc/integration/multischema/ent/user/user.go create mode 100644 entc/integration/multischema/ent/user/where.go create mode 100644 entc/integration/multischema/ent/user_create.go create mode 100644 entc/integration/multischema/ent/user_delete.go create mode 100644 entc/integration/multischema/ent/user_query.go create mode 100644 entc/integration/multischema/ent/user_update.go create mode 100644 entc/integration/multischema/migrations/20231112192027.sql create mode 100644 entc/integration/multischema/migrations/atlas.sum create mode 100644 entc/integration/multischema/multischema_test.go create mode 100644 entc/integration/multischema/versioned/client.go create mode 100644 entc/integration/multischema/versioned/ent.go create mode 100644 entc/integration/multischema/versioned/entc.go create mode 100644 entc/integration/multischema/versioned/enttest/enttest.go create mode 100644 entc/integration/multischema/versioned/friendship.go create mode 100644 entc/integration/multischema/versioned/friendship/friendship.go create mode 100644 entc/integration/multischema/versioned/friendship/mutation.go create mode 100644 entc/integration/multischema/versioned/friendship/where.go create mode 100644 entc/integration/multischema/versioned/friendship_create.go create mode 100644 entc/integration/multischema/versioned/friendship_delete.go create mode 100644 entc/integration/multischema/versioned/friendship_query.go create mode 100644 entc/integration/multischema/versioned/friendship_update.go create mode 100644 entc/integration/multischema/versioned/generate.go create mode 100644 entc/integration/multischema/versioned/group.go create mode 100644 entc/integration/multischema/versioned/group/group.go create mode 100644 entc/integration/multischema/versioned/group/mutation.go create mode 100644 entc/integration/multischema/versioned/group/where.go create mode 100644 entc/integration/multischema/versioned/group_create.go create mode 100644 entc/integration/multischema/versioned/group_delete.go create mode 100644 entc/integration/multischema/versioned/group_query.go create mode 100644 entc/integration/multischema/versioned/group_update.go create mode 100644 entc/integration/multischema/versioned/hook/hook.go create mode 100644 entc/integration/multischema/versioned/internal/schemaconfig.go create mode 100644 entc/integration/multischema/versioned/migrate/migrate.go create mode 100644 entc/integration/multischema/versioned/migrate/schema.go create mode 100644 entc/integration/multischema/versioned/mutation.go create mode 100644 entc/integration/multischema/versioned/pet.go create mode 100644 entc/integration/multischema/versioned/pet/mutation.go create mode 100644 entc/integration/multischema/versioned/pet/pet.go create mode 100644 entc/integration/multischema/versioned/pet/where.go create mode 100644 entc/integration/multischema/versioned/pet_create.go create mode 100644 entc/integration/multischema/versioned/pet_delete.go create mode 100644 entc/integration/multischema/versioned/pet_query.go create mode 100644 entc/integration/multischema/versioned/pet_update.go create mode 100644 entc/integration/multischema/versioned/predicate/predicate.go create mode 100644 entc/integration/multischema/versioned/runtime.go create mode 100644 entc/integration/multischema/versioned/runtime/runtime.go create mode 100644 entc/integration/multischema/versioned/schema/friendship.go create mode 100644 entc/integration/multischema/versioned/schema/group.go create mode 100644 entc/integration/multischema/versioned/schema/mixin.go create mode 100644 entc/integration/multischema/versioned/schema/pet.go create mode 100644 entc/integration/multischema/versioned/schema/user.go create mode 100644 entc/integration/multischema/versioned/tx.go create mode 100644 entc/integration/multischema/versioned/user.go create mode 100644 entc/integration/multischema/versioned/user/mutation.go create mode 100644 entc/integration/multischema/versioned/user/user.go create mode 100644 entc/integration/multischema/versioned/user/where.go create mode 100644 entc/integration/multischema/versioned/user_create.go create mode 100644 entc/integration/multischema/versioned/user_delete.go create mode 100644 entc/integration/multischema/versioned/user_query.go create mode 100644 entc/integration/multischema/versioned/user_update.go create mode 100644 entc/integration/privacy/ent/client.go create mode 100644 entc/integration/privacy/ent/ent.go create mode 100644 entc/integration/privacy/ent/entc.go create mode 100644 entc/integration/privacy/ent/entql.go create mode 100644 entc/integration/privacy/ent/enttest/enttest.go create mode 100644 entc/integration/privacy/ent/generate.go create mode 100644 entc/integration/privacy/ent/hook/hook.go create mode 100644 entc/integration/privacy/ent/internal/schema.go create mode 100644 entc/integration/privacy/ent/migrate/migrate.go create mode 100644 entc/integration/privacy/ent/migrate/schema.go create mode 100644 entc/integration/privacy/ent/mutation.go create mode 100644 entc/integration/privacy/ent/predicate/predicate.go create mode 100644 entc/integration/privacy/ent/privacy/privacy.go create mode 100644 entc/integration/privacy/ent/runtime.go create mode 100644 entc/integration/privacy/ent/runtime/runtime.go create mode 100644 entc/integration/privacy/ent/schema/base.go create mode 100644 entc/integration/privacy/ent/schema/task.go create mode 100644 entc/integration/privacy/ent/schema/team.go create mode 100644 entc/integration/privacy/ent/schema/user.go create mode 100644 entc/integration/privacy/ent/task.go create mode 100644 entc/integration/privacy/ent/task/mutation.go create mode 100644 entc/integration/privacy/ent/task/task.go create mode 100644 entc/integration/privacy/ent/task/where.go create mode 100644 entc/integration/privacy/ent/task_create.go create mode 100644 entc/integration/privacy/ent/task_delete.go create mode 100644 entc/integration/privacy/ent/task_query.go create mode 100644 entc/integration/privacy/ent/task_update.go create mode 100644 entc/integration/privacy/ent/team.go create mode 100644 entc/integration/privacy/ent/team/mutation.go create mode 100644 entc/integration/privacy/ent/team/team.go create mode 100644 entc/integration/privacy/ent/team/where.go create mode 100644 entc/integration/privacy/ent/team_create.go create mode 100644 entc/integration/privacy/ent/team_delete.go create mode 100644 entc/integration/privacy/ent/team_query.go create mode 100644 entc/integration/privacy/ent/team_update.go create mode 100644 entc/integration/privacy/ent/tx.go create mode 100644 entc/integration/privacy/ent/user.go create mode 100644 entc/integration/privacy/ent/user/mutation.go create mode 100644 entc/integration/privacy/ent/user/user.go create mode 100644 entc/integration/privacy/ent/user/where.go create mode 100644 entc/integration/privacy/ent/user_create.go create mode 100644 entc/integration/privacy/ent/user_delete.go create mode 100644 entc/integration/privacy/ent/user_query.go create mode 100644 entc/integration/privacy/ent/user_update.go create mode 100644 entc/integration/privacy/privacy_test.go create mode 100644 entc/integration/privacy/rule/rule.go create mode 100644 entc/integration/privacy/viewer/viewer.go create mode 100644 entc/integration/relation_test.go create mode 100644 entc/integration/template/ent/client.go create mode 100644 entc/integration/template/ent/ent.go create mode 100644 entc/integration/template/ent/enttest/enttest.go create mode 100644 entc/integration/template/ent/generate.go create mode 100644 entc/integration/template/ent/group.go create mode 100644 entc/integration/template/ent/group/group.go create mode 100644 entc/integration/template/ent/group/mutation.go create mode 100644 entc/integration/template/ent/group/where.go create mode 100644 entc/integration/template/ent/group_create.go create mode 100644 entc/integration/template/ent/group_delete.go create mode 100644 entc/integration/template/ent/group_query.go create mode 100644 entc/integration/template/ent/group_update.go create mode 100644 entc/integration/template/ent/hook/hook.go create mode 100644 entc/integration/template/ent/migrate/migrate.go create mode 100644 entc/integration/template/ent/migrate/schema.go create mode 100644 entc/integration/template/ent/mutation.go create mode 100644 entc/integration/template/ent/node.go create mode 100644 entc/integration/template/ent/pet.go create mode 100644 entc/integration/template/ent/pet/mutation.go create mode 100644 entc/integration/template/ent/pet/pet.go create mode 100644 entc/integration/template/ent/pet/where.go create mode 100644 entc/integration/template/ent/pet_create.go create mode 100644 entc/integration/template/ent/pet_delete.go create mode 100644 entc/integration/template/ent/pet_query.go create mode 100644 entc/integration/template/ent/pet_update.go create mode 100644 entc/integration/template/ent/predicate/predicate.go create mode 100644 entc/integration/template/ent/runtime.go create mode 100644 entc/integration/template/ent/runtime/runtime.go create mode 100644 entc/integration/template/ent/schema/group.go create mode 100644 entc/integration/template/ent/schema/pet.go create mode 100644 entc/integration/template/ent/schema/user.go create mode 100644 entc/integration/template/ent/template/client.tmpl create mode 100644 entc/integration/template/ent/template/config.tmpl create mode 100644 entc/integration/template/ent/template/helper.tmpl create mode 100644 entc/integration/template/ent/template/modifier.tmpl create mode 100644 entc/integration/template/ent/template/node.tmpl create mode 100644 entc/integration/template/ent/template/query.tmpl create mode 100644 entc/integration/template/ent/template/stringer.tmpl create mode 100644 entc/integration/template/ent/template/where.tmpl create mode 100644 entc/integration/template/ent/tx.go create mode 100644 entc/integration/template/ent/user.go create mode 100644 entc/integration/template/ent/user/mutation.go create mode 100644 entc/integration/template/ent/user/user.go create mode 100644 entc/integration/template/ent/user/where.go create mode 100644 entc/integration/template/ent/user_create.go create mode 100644 entc/integration/template/ent/user_delete.go create mode 100644 entc/integration/template/ent/user_query.go create mode 100644 entc/integration/template/ent/user_update.go create mode 100644 entc/integration/template/template_test.go create mode 100644 entc/integration/type_test.go create mode 100644 entc/internal/snapshot.go create mode 100644 entc/internal/snapshot_test.go create mode 100644 entc/internal/vcs.go create mode 100644 entc/internal/vcs_test.go create mode 100644 entc/load/load.go create mode 100644 entc/load/load_test.go create mode 100644 entc/load/schema.go create mode 100644 entc/load/schema_test.go create mode 100644 entc/load/template/main.tmpl create mode 100644 entc/load/testdata/base/schema.go create mode 100644 entc/load/testdata/buildflags/group.go create mode 100644 entc/load/testdata/buildflags/user.go create mode 100644 entc/load/testdata/cycle/fakent/fakent.go create mode 100644 entc/load/testdata/cycle/schema.go create mode 100644 entc/load/testdata/failure/user.go create mode 100644 entc/load/testdata/invalid/schema.go create mode 100644 entc/load/testdata/valid/schema.go create mode 100644 entql/entql.go create mode 100644 entql/entql_test.go create mode 100644 entql/internal/gen.go create mode 100644 entql/internal/types.tmpl create mode 100644 entql/types.go create mode 100644 entql/types_test.go create mode 100644 examples/README.md create mode 100644 examples/compositetypes/README.md create mode 100644 examples/compositetypes/atlas.hcl create mode 100644 examples/compositetypes/ent/client.go create mode 100644 examples/compositetypes/ent/ent.go create mode 100644 examples/compositetypes/ent/enttest/enttest.go create mode 100644 examples/compositetypes/ent/generate.go create mode 100644 examples/compositetypes/ent/hook/hook.go create mode 100644 examples/compositetypes/ent/migrate/migrate.go create mode 100644 examples/compositetypes/ent/migrate/schema.go create mode 100644 examples/compositetypes/ent/mutation.go create mode 100644 examples/compositetypes/ent/predicate/predicate.go create mode 100644 examples/compositetypes/ent/runtime.go create mode 100644 examples/compositetypes/ent/runtime/runtime.go create mode 100644 examples/compositetypes/ent/schema/user.go create mode 100644 examples/compositetypes/ent/tx.go create mode 100644 examples/compositetypes/ent/user.go create mode 100644 examples/compositetypes/ent/user/mutation.go create mode 100644 examples/compositetypes/ent/user/user.go create mode 100644 examples/compositetypes/ent/user/where.go create mode 100644 examples/compositetypes/ent/user_create.go create mode 100644 examples/compositetypes/ent/user_delete.go create mode 100644 examples/compositetypes/ent/user_query.go create mode 100644 examples/compositetypes/ent/user_update.go create mode 100644 examples/compositetypes/example_test.go create mode 100644 examples/compositetypes/migrations/20240713195943.sql create mode 100644 examples/compositetypes/migrations/atlas.sum create mode 100644 examples/compositetypes/schema.sql create mode 100644 examples/domaintypes/README.md create mode 100644 examples/domaintypes/atlas.hcl create mode 100644 examples/domaintypes/ent/client.go create mode 100644 examples/domaintypes/ent/ent.go create mode 100644 examples/domaintypes/ent/enttest/enttest.go create mode 100644 examples/domaintypes/ent/generate.go create mode 100644 examples/domaintypes/ent/hook/hook.go create mode 100644 examples/domaintypes/ent/migrate/migrate.go create mode 100644 examples/domaintypes/ent/migrate/schema.go create mode 100644 examples/domaintypes/ent/mutation.go create mode 100644 examples/domaintypes/ent/predicate/predicate.go create mode 100644 examples/domaintypes/ent/runtime.go create mode 100644 examples/domaintypes/ent/runtime/runtime.go create mode 100644 examples/domaintypes/ent/schema/user.go create mode 100644 examples/domaintypes/ent/tx.go create mode 100644 examples/domaintypes/ent/user.go create mode 100644 examples/domaintypes/ent/user/mutation.go create mode 100644 examples/domaintypes/ent/user/user.go create mode 100644 examples/domaintypes/ent/user/where.go create mode 100644 examples/domaintypes/ent/user_create.go create mode 100644 examples/domaintypes/ent/user_delete.go create mode 100644 examples/domaintypes/ent/user_query.go create mode 100644 examples/domaintypes/ent/user_update.go create mode 100644 examples/domaintypes/example_test.go create mode 100644 examples/domaintypes/migrations/20240712090543.sql create mode 100644 examples/domaintypes/migrations/atlas.sum create mode 100644 examples/domaintypes/schema.sql create mode 100644 examples/edgeindex/README.md create mode 100644 examples/edgeindex/ent/city.go create mode 100644 examples/edgeindex/ent/city/city.go create mode 100644 examples/edgeindex/ent/city/mutation.go create mode 100644 examples/edgeindex/ent/city/where.go create mode 100644 examples/edgeindex/ent/city_create.go create mode 100644 examples/edgeindex/ent/city_delete.go create mode 100644 examples/edgeindex/ent/city_query.go create mode 100644 examples/edgeindex/ent/city_update.go create mode 100644 examples/edgeindex/ent/client.go create mode 100644 examples/edgeindex/ent/ent.go create mode 100644 examples/edgeindex/ent/enttest/enttest.go create mode 100644 examples/edgeindex/ent/generate.go create mode 100644 examples/edgeindex/ent/hook/hook.go create mode 100644 examples/edgeindex/ent/migrate/migrate.go create mode 100644 examples/edgeindex/ent/migrate/schema.go create mode 100644 examples/edgeindex/ent/mutation.go create mode 100644 examples/edgeindex/ent/predicate/predicate.go create mode 100644 examples/edgeindex/ent/runtime.go create mode 100644 examples/edgeindex/ent/runtime/runtime.go create mode 100644 examples/edgeindex/ent/schema/city.go create mode 100644 examples/edgeindex/ent/schema/street.go create mode 100644 examples/edgeindex/ent/street.go create mode 100644 examples/edgeindex/ent/street/mutation.go create mode 100644 examples/edgeindex/ent/street/street.go create mode 100644 examples/edgeindex/ent/street/where.go create mode 100644 examples/edgeindex/ent/street_create.go create mode 100644 examples/edgeindex/ent/street_delete.go create mode 100644 examples/edgeindex/ent/street_query.go create mode 100644 examples/edgeindex/ent/street_update.go create mode 100644 examples/edgeindex/ent/tx.go create mode 100644 examples/edgeindex/example_test.go create mode 100644 examples/encryptfield/README.md create mode 100644 examples/encryptfield/ent/client.go create mode 100644 examples/encryptfield/ent/ent.go create mode 100644 examples/encryptfield/ent/entc.go create mode 100644 examples/encryptfield/ent/enttest/enttest.go create mode 100644 examples/encryptfield/ent/generate.go create mode 100644 examples/encryptfield/ent/hook/hook.go create mode 100644 examples/encryptfield/ent/intercept/intercept.go create mode 100644 examples/encryptfield/ent/migrate/migrate.go create mode 100644 examples/encryptfield/ent/migrate/schema.go create mode 100644 examples/encryptfield/ent/mutation.go create mode 100644 examples/encryptfield/ent/predicate/predicate.go create mode 100644 examples/encryptfield/ent/runtime.go create mode 100644 examples/encryptfield/ent/runtime/runtime.go create mode 100644 examples/encryptfield/ent/schema/user.go create mode 100644 examples/encryptfield/ent/tx.go create mode 100644 examples/encryptfield/ent/user.go create mode 100644 examples/encryptfield/ent/user/mutation.go create mode 100644 examples/encryptfield/ent/user/user.go create mode 100644 examples/encryptfield/ent/user/where.go create mode 100644 examples/encryptfield/ent/user_create.go create mode 100644 examples/encryptfield/ent/user_delete.go create mode 100644 examples/encryptfield/ent/user_query.go create mode 100644 examples/encryptfield/ent/user_update.go create mode 100644 examples/encryptfield/example_test.go create mode 100644 examples/entcpkg/README.md create mode 100644 examples/entcpkg/ent/client.go create mode 100644 examples/entcpkg/ent/debug.go create mode 100644 examples/entcpkg/ent/ent.go create mode 100644 examples/entcpkg/ent/entc.go create mode 100644 examples/entcpkg/ent/enttest/enttest.go create mode 100644 examples/entcpkg/ent/generate.go create mode 100644 examples/entcpkg/ent/hook/hook.go create mode 100644 examples/entcpkg/ent/migrate/migrate.go create mode 100644 examples/entcpkg/ent/migrate/schema.go create mode 100644 examples/entcpkg/ent/mutation.go create mode 100644 examples/entcpkg/ent/predicate/predicate.go create mode 100644 examples/entcpkg/ent/runtime.go create mode 100644 examples/entcpkg/ent/runtime/runtime.go create mode 100644 examples/entcpkg/ent/schema/user.go create mode 100644 examples/entcpkg/ent/template/debug.tmpl create mode 100644 examples/entcpkg/ent/template/entinit.tmpl create mode 100644 examples/entcpkg/ent/template/static.tmpl create mode 100644 examples/entcpkg/ent/template/stringer.tmpl create mode 100644 examples/entcpkg/ent/tx.go create mode 100644 examples/entcpkg/ent/user.go create mode 100644 examples/entcpkg/ent/user/mutation.go create mode 100644 examples/entcpkg/ent/user/user.go create mode 100644 examples/entcpkg/ent/user/where.go create mode 100644 examples/entcpkg/ent/user_create.go create mode 100644 examples/entcpkg/ent/user_delete.go create mode 100644 examples/entcpkg/ent/user_query.go create mode 100644 examples/entcpkg/ent/user_update.go create mode 100644 examples/entcpkg/example_test.go create mode 100644 examples/enumtypes/README.md create mode 100644 examples/enumtypes/atlas.hcl create mode 100644 examples/enumtypes/ent/client.go create mode 100644 examples/enumtypes/ent/ent.go create mode 100644 examples/enumtypes/ent/enttest/enttest.go create mode 100644 examples/enumtypes/ent/generate.go create mode 100644 examples/enumtypes/ent/hook/hook.go create mode 100644 examples/enumtypes/ent/migrate/migrate.go create mode 100644 examples/enumtypes/ent/migrate/schema.go create mode 100644 examples/enumtypes/ent/mutation.go create mode 100644 examples/enumtypes/ent/predicate/predicate.go create mode 100644 examples/enumtypes/ent/runtime.go create mode 100644 examples/enumtypes/ent/runtime/runtime.go create mode 100644 examples/enumtypes/ent/schema/user.go create mode 100644 examples/enumtypes/ent/tx.go create mode 100644 examples/enumtypes/ent/user.go create mode 100644 examples/enumtypes/ent/user/mutation.go create mode 100644 examples/enumtypes/ent/user/user.go create mode 100644 examples/enumtypes/ent/user/where.go create mode 100644 examples/enumtypes/ent/user_create.go create mode 100644 examples/enumtypes/ent/user_delete.go create mode 100644 examples/enumtypes/ent/user_query.go create mode 100644 examples/enumtypes/ent/user_update.go create mode 100644 examples/enumtypes/example_test.go create mode 100644 examples/enumtypes/migrations/20240712145529.sql create mode 100644 examples/enumtypes/migrations/atlas.sum create mode 100644 examples/enumtypes/schema.sql create mode 100644 examples/extensions/README.md create mode 100644 examples/extensions/atlas.hcl create mode 100644 examples/extensions/ent/client.go create mode 100644 examples/extensions/ent/ent.go create mode 100644 examples/extensions/ent/enttest/enttest.go create mode 100644 examples/extensions/ent/generate.go create mode 100644 examples/extensions/ent/hook/hook.go create mode 100644 examples/extensions/ent/migrate/migrate.go create mode 100644 examples/extensions/ent/migrate/schema.go create mode 100644 examples/extensions/ent/mutation.go create mode 100644 examples/extensions/ent/predicate/predicate.go create mode 100644 examples/extensions/ent/runtime.go create mode 100644 examples/extensions/ent/runtime/runtime.go create mode 100644 examples/extensions/ent/schema/user.go create mode 100644 examples/extensions/ent/tx.go create mode 100644 examples/extensions/ent/user.go create mode 100644 examples/extensions/ent/user/mutation.go create mode 100644 examples/extensions/ent/user/user.go create mode 100644 examples/extensions/ent/user/where.go create mode 100644 examples/extensions/ent/user_create.go create mode 100644 examples/extensions/ent/user_delete.go create mode 100644 examples/extensions/ent/user_query.go create mode 100644 examples/extensions/ent/user_update.go create mode 100644 examples/extensions/migrations/20240713193406.sql create mode 100644 examples/extensions/migrations/atlas.sum create mode 100644 examples/extensions/schema.sql create mode 100644 examples/fs/README.md create mode 100644 examples/fs/ent/client.go create mode 100644 examples/fs/ent/ent.go create mode 100644 examples/fs/ent/enttest/enttest.go create mode 100644 examples/fs/ent/file.go create mode 100644 examples/fs/ent/file/file.go create mode 100644 examples/fs/ent/file/mutation.go create mode 100644 examples/fs/ent/file/where.go create mode 100644 examples/fs/ent/file_create.go create mode 100644 examples/fs/ent/file_delete.go create mode 100644 examples/fs/ent/file_query.go create mode 100644 examples/fs/ent/file_update.go create mode 100644 examples/fs/ent/generate.go create mode 100644 examples/fs/ent/hook/hook.go create mode 100644 examples/fs/ent/migrate/migrate.go create mode 100644 examples/fs/ent/migrate/schema.go create mode 100644 examples/fs/ent/mutation.go create mode 100644 examples/fs/ent/predicate/predicate.go create mode 100644 examples/fs/ent/runtime.go create mode 100644 examples/fs/ent/runtime/runtime.go create mode 100644 examples/fs/ent/schema/file.go create mode 100644 examples/fs/ent/tx.go create mode 100644 examples/fs/example_test.go create mode 100644 examples/functionalidx/README.md create mode 100644 examples/functionalidx/atlas.hcl create mode 100644 examples/functionalidx/ent/client.go create mode 100644 examples/functionalidx/ent/ent.go create mode 100644 examples/functionalidx/ent/enttest/enttest.go create mode 100644 examples/functionalidx/ent/generate.go create mode 100644 examples/functionalidx/ent/hook/hook.go create mode 100644 examples/functionalidx/ent/migrate/migrate.go create mode 100644 examples/functionalidx/ent/migrate/schema.go create mode 100644 examples/functionalidx/ent/mutation.go create mode 100644 examples/functionalidx/ent/predicate/predicate.go create mode 100644 examples/functionalidx/ent/runtime.go create mode 100644 examples/functionalidx/ent/runtime/runtime.go create mode 100644 examples/functionalidx/ent/schema/user.go create mode 100644 examples/functionalidx/ent/tx.go create mode 100644 examples/functionalidx/ent/user.go create mode 100644 examples/functionalidx/ent/user/mutation.go create mode 100644 examples/functionalidx/ent/user/user.go create mode 100644 examples/functionalidx/ent/user/where.go create mode 100644 examples/functionalidx/ent/user_create.go create mode 100644 examples/functionalidx/ent/user_delete.go create mode 100644 examples/functionalidx/ent/user_query.go create mode 100644 examples/functionalidx/ent/user_update.go create mode 100644 examples/functionalidx/example_test.go create mode 100644 examples/functionalidx/migrations/20240720083105.sql create mode 100644 examples/functionalidx/migrations/atlas.sum create mode 100644 examples/functionalidx/schema.sql create mode 100644 examples/go.mod create mode 100644 examples/jsonencode/README.md create mode 100644 examples/jsonencode/ent/card.go create mode 100644 examples/jsonencode/ent/card/card.go create mode 100644 examples/jsonencode/ent/card/mutation.go create mode 100644 examples/jsonencode/ent/card/where.go create mode 100644 examples/jsonencode/ent/card_create.go create mode 100644 examples/jsonencode/ent/card_delete.go create mode 100644 examples/jsonencode/ent/card_query.go create mode 100644 examples/jsonencode/ent/card_update.go create mode 100644 examples/jsonencode/ent/client.go create mode 100644 examples/jsonencode/ent/ent.go create mode 100644 examples/jsonencode/ent/entc.go create mode 100644 examples/jsonencode/ent/enttest/enttest.go create mode 100644 examples/jsonencode/ent/generate.go create mode 100644 examples/jsonencode/ent/hook/hook.go create mode 100644 examples/jsonencode/ent/migrate/migrate.go create mode 100644 examples/jsonencode/ent/migrate/schema.go create mode 100644 examples/jsonencode/ent/mutation.go create mode 100644 examples/jsonencode/ent/pet.go create mode 100644 examples/jsonencode/ent/pet/mutation.go create mode 100644 examples/jsonencode/ent/pet/pet.go create mode 100644 examples/jsonencode/ent/pet/where.go create mode 100644 examples/jsonencode/ent/pet_create.go create mode 100644 examples/jsonencode/ent/pet_delete.go create mode 100644 examples/jsonencode/ent/pet_query.go create mode 100644 examples/jsonencode/ent/pet_update.go create mode 100644 examples/jsonencode/ent/predicate/predicate.go create mode 100644 examples/jsonencode/ent/runtime.go create mode 100644 examples/jsonencode/ent/runtime/runtime.go create mode 100644 examples/jsonencode/ent/schema/card.go create mode 100644 examples/jsonencode/ent/schema/pet.go create mode 100644 examples/jsonencode/ent/schema/user.go create mode 100644 examples/jsonencode/ent/tx.go create mode 100644 examples/jsonencode/ent/user.go create mode 100644 examples/jsonencode/ent/user/mutation.go create mode 100644 examples/jsonencode/ent/user/user.go create mode 100644 examples/jsonencode/ent/user/where.go create mode 100644 examples/jsonencode/ent/user_create.go create mode 100644 examples/jsonencode/ent/user_delete.go create mode 100644 examples/jsonencode/ent/user_query.go create mode 100644 examples/jsonencode/ent/user_update.go create mode 100644 examples/jsonencode/example_test.go create mode 100644 examples/m2m2types/README.md create mode 100644 examples/m2m2types/ent/client.go create mode 100644 examples/m2m2types/ent/ent.go create mode 100644 examples/m2m2types/ent/enttest/enttest.go create mode 100644 examples/m2m2types/ent/generate.go create mode 100644 examples/m2m2types/ent/group.go create mode 100644 examples/m2m2types/ent/group/group.go create mode 100644 examples/m2m2types/ent/group/mutation.go create mode 100644 examples/m2m2types/ent/group/where.go create mode 100644 examples/m2m2types/ent/group_create.go create mode 100644 examples/m2m2types/ent/group_delete.go create mode 100644 examples/m2m2types/ent/group_query.go create mode 100644 examples/m2m2types/ent/group_update.go create mode 100644 examples/m2m2types/ent/hook/hook.go create mode 100644 examples/m2m2types/ent/migrate/migrate.go create mode 100644 examples/m2m2types/ent/migrate/schema.go create mode 100644 examples/m2m2types/ent/mutation.go create mode 100644 examples/m2m2types/ent/predicate/predicate.go create mode 100644 examples/m2m2types/ent/runtime.go create mode 100644 examples/m2m2types/ent/runtime/runtime.go create mode 100644 examples/m2m2types/ent/schema/group.go create mode 100644 examples/m2m2types/ent/schema/user.go create mode 100644 examples/m2m2types/ent/tx.go create mode 100644 examples/m2m2types/ent/user.go create mode 100644 examples/m2m2types/ent/user/mutation.go create mode 100644 examples/m2m2types/ent/user/user.go create mode 100644 examples/m2m2types/ent/user/where.go create mode 100644 examples/m2m2types/ent/user_create.go create mode 100644 examples/m2m2types/ent/user_delete.go create mode 100644 examples/m2m2types/ent/user_query.go create mode 100644 examples/m2m2types/ent/user_update.go create mode 100644 examples/m2m2types/example_test.go create mode 100644 examples/m2mbidi/README.md create mode 100644 examples/m2mbidi/ent/client.go create mode 100644 examples/m2mbidi/ent/ent.go create mode 100644 examples/m2mbidi/ent/enttest/enttest.go create mode 100644 examples/m2mbidi/ent/generate.go create mode 100644 examples/m2mbidi/ent/hook/hook.go create mode 100644 examples/m2mbidi/ent/migrate/migrate.go create mode 100644 examples/m2mbidi/ent/migrate/schema.go create mode 100644 examples/m2mbidi/ent/mutation.go create mode 100644 examples/m2mbidi/ent/predicate/predicate.go create mode 100644 examples/m2mbidi/ent/runtime.go create mode 100644 examples/m2mbidi/ent/runtime/runtime.go create mode 100644 examples/m2mbidi/ent/schema/user.go create mode 100644 examples/m2mbidi/ent/tx.go create mode 100644 examples/m2mbidi/ent/user.go create mode 100644 examples/m2mbidi/ent/user/mutation.go create mode 100644 examples/m2mbidi/ent/user/user.go create mode 100644 examples/m2mbidi/ent/user/where.go create mode 100644 examples/m2mbidi/ent/user_create.go create mode 100644 examples/m2mbidi/ent/user_delete.go create mode 100644 examples/m2mbidi/ent/user_query.go create mode 100644 examples/m2mbidi/ent/user_update.go create mode 100644 examples/m2mbidi/example_test.go create mode 100644 examples/m2mrecur/README.md create mode 100644 examples/m2mrecur/ent/client.go create mode 100644 examples/m2mrecur/ent/ent.go create mode 100644 examples/m2mrecur/ent/enttest/enttest.go create mode 100644 examples/m2mrecur/ent/generate.go create mode 100644 examples/m2mrecur/ent/hook/hook.go create mode 100644 examples/m2mrecur/ent/migrate/migrate.go create mode 100644 examples/m2mrecur/ent/migrate/schema.go create mode 100644 examples/m2mrecur/ent/mutation.go create mode 100644 examples/m2mrecur/ent/predicate/predicate.go create mode 100644 examples/m2mrecur/ent/runtime.go create mode 100644 examples/m2mrecur/ent/runtime/runtime.go create mode 100644 examples/m2mrecur/ent/schema/user.go create mode 100644 examples/m2mrecur/ent/tx.go create mode 100644 examples/m2mrecur/ent/user.go create mode 100644 examples/m2mrecur/ent/user/mutation.go create mode 100644 examples/m2mrecur/ent/user/user.go create mode 100644 examples/m2mrecur/ent/user/where.go create mode 100644 examples/m2mrecur/ent/user_create.go create mode 100644 examples/m2mrecur/ent/user_delete.go create mode 100644 examples/m2mrecur/ent/user_query.go create mode 100644 examples/m2mrecur/ent/user_update.go create mode 100644 examples/m2mrecur/example_test.go create mode 100644 examples/migration/README.md create mode 100644 examples/migration/ent/card.go create mode 100644 examples/migration/ent/card/card.go create mode 100644 examples/migration/ent/card/mutation.go create mode 100644 examples/migration/ent/card/where.go create mode 100644 examples/migration/ent/card_create.go create mode 100644 examples/migration/ent/card_delete.go create mode 100644 examples/migration/ent/card_query.go create mode 100644 examples/migration/ent/card_update.go create mode 100644 examples/migration/ent/client.go create mode 100644 examples/migration/ent/ent.go create mode 100644 examples/migration/ent/enttest/enttest.go create mode 100644 examples/migration/ent/generate.go create mode 100644 examples/migration/ent/hook/hook.go create mode 100644 examples/migration/ent/migrate/main.go create mode 100644 examples/migration/ent/migrate/migrate.go create mode 100644 examples/migration/ent/migrate/migratedata/migratedata.go create mode 100644 examples/migration/ent/migrate/migrations/20221114082343_create_users.sql create mode 100644 examples/migration/ent/migrate/migrations/20221114090322_add_age.sql create mode 100644 examples/migration/ent/migrate/migrations/20221114101516_add_name.sql create mode 100644 examples/migration/ent/migrate/migrations/20221126173531_add_user_tags.sql create mode 100644 examples/migration/ent/migrate/migrations/20221126185750_backfill_user_tags.sql create mode 100644 examples/migration/ent/migrate/migrations/20230101214930_default_values.sql create mode 100644 examples/migration/ent/migrate/migrations/20231231083716_add_card_number.sql create mode 100644 examples/migration/ent/migrate/migrations/20231231091550_add_pet_name.sql create mode 100644 examples/migration/ent/migrate/migrations/20231231101555_add_payment.sql create mode 100644 examples/migration/ent/migrate/migrations/20231231102849_add_currency.sql create mode 100644 examples/migration/ent/migrate/migrations/20231231120006_add_session.sql create mode 100644 examples/migration/ent/migrate/migrations/20231231123114_hash_card.sql create mode 100644 examples/migration/ent/migrate/migrations/20240229200634_add_card_type.sql create mode 100644 examples/migration/ent/migrate/migrations/20240317101809_add_payment_vnd.sql create mode 100644 examples/migration/ent/migrate/migrations/atlas.sum create mode 100644 examples/migration/ent/migrate/schema.go create mode 100644 examples/migration/ent/mutation.go create mode 100644 examples/migration/ent/payment.go create mode 100644 examples/migration/ent/payment/mutation.go create mode 100644 examples/migration/ent/payment/payment.go create mode 100644 examples/migration/ent/payment/where.go create mode 100644 examples/migration/ent/payment_create.go create mode 100644 examples/migration/ent/payment_delete.go create mode 100644 examples/migration/ent/payment_query.go create mode 100644 examples/migration/ent/payment_update.go create mode 100644 examples/migration/ent/pet.go create mode 100644 examples/migration/ent/pet/mutation.go create mode 100644 examples/migration/ent/pet/pet.go create mode 100644 examples/migration/ent/pet/where.go create mode 100644 examples/migration/ent/pet_create.go create mode 100644 examples/migration/ent/pet_delete.go create mode 100644 examples/migration/ent/pet_query.go create mode 100644 examples/migration/ent/pet_update.go create mode 100644 examples/migration/ent/predicate/predicate.go create mode 100644 examples/migration/ent/runtime.go create mode 100644 examples/migration/ent/runtime/runtime.go create mode 100644 examples/migration/ent/schema/card.go create mode 100644 examples/migration/ent/schema/payment.go create mode 100644 examples/migration/ent/schema/pet.go create mode 100644 examples/migration/ent/schema/session.go create mode 100644 examples/migration/ent/schema/user.go create mode 100644 examples/migration/ent/session.go create mode 100644 examples/migration/ent/session/mutation.go create mode 100644 examples/migration/ent/session/session.go create mode 100644 examples/migration/ent/session/where.go create mode 100644 examples/migration/ent/session_create.go create mode 100644 examples/migration/ent/session_delete.go create mode 100644 examples/migration/ent/session_query.go create mode 100644 examples/migration/ent/session_update.go create mode 100644 examples/migration/ent/sessiondevice.go create mode 100644 examples/migration/ent/sessiondevice/mutation.go create mode 100644 examples/migration/ent/sessiondevice/sessiondevice.go create mode 100644 examples/migration/ent/sessiondevice/where.go create mode 100644 examples/migration/ent/sessiondevice_create.go create mode 100644 examples/migration/ent/sessiondevice_delete.go create mode 100644 examples/migration/ent/sessiondevice_query.go create mode 100644 examples/migration/ent/sessiondevice_update.go create mode 100644 examples/migration/ent/tx.go create mode 100644 examples/migration/ent/user.go create mode 100644 examples/migration/ent/user/mutation.go create mode 100644 examples/migration/ent/user/user.go create mode 100644 examples/migration/ent/user/where.go create mode 100644 examples/migration/ent/user_create.go create mode 100644 examples/migration/ent/user_delete.go create mode 100644 examples/migration/ent/user_query.go create mode 100644 examples/migration/ent/user_update.go create mode 100644 examples/o2m2types/README.md create mode 100644 examples/o2m2types/ent/client.go create mode 100644 examples/o2m2types/ent/ent.go create mode 100644 examples/o2m2types/ent/enttest/enttest.go create mode 100644 examples/o2m2types/ent/generate.go create mode 100644 examples/o2m2types/ent/hook/hook.go create mode 100644 examples/o2m2types/ent/migrate/migrate.go create mode 100644 examples/o2m2types/ent/migrate/schema.go create mode 100644 examples/o2m2types/ent/mutation.go create mode 100644 examples/o2m2types/ent/pet.go create mode 100644 examples/o2m2types/ent/pet/mutation.go create mode 100644 examples/o2m2types/ent/pet/pet.go create mode 100644 examples/o2m2types/ent/pet/where.go create mode 100644 examples/o2m2types/ent/pet_create.go create mode 100644 examples/o2m2types/ent/pet_delete.go create mode 100644 examples/o2m2types/ent/pet_query.go create mode 100644 examples/o2m2types/ent/pet_update.go create mode 100644 examples/o2m2types/ent/predicate/predicate.go create mode 100644 examples/o2m2types/ent/runtime.go create mode 100644 examples/o2m2types/ent/runtime/runtime.go create mode 100644 examples/o2m2types/ent/schema/pet.go create mode 100644 examples/o2m2types/ent/schema/user.go create mode 100644 examples/o2m2types/ent/tx.go create mode 100644 examples/o2m2types/ent/user.go create mode 100644 examples/o2m2types/ent/user/mutation.go create mode 100644 examples/o2m2types/ent/user/user.go create mode 100644 examples/o2m2types/ent/user/where.go create mode 100644 examples/o2m2types/ent/user_create.go create mode 100644 examples/o2m2types/ent/user_delete.go create mode 100644 examples/o2m2types/ent/user_query.go create mode 100644 examples/o2m2types/ent/user_update.go create mode 100644 examples/o2m2types/example_test.go create mode 100644 examples/o2mrecur/README.md create mode 100644 examples/o2mrecur/ent/client.go create mode 100644 examples/o2mrecur/ent/ent.go create mode 100644 examples/o2mrecur/ent/enttest/enttest.go create mode 100644 examples/o2mrecur/ent/generate.go create mode 100644 examples/o2mrecur/ent/hook/hook.go create mode 100644 examples/o2mrecur/ent/migrate/migrate.go create mode 100644 examples/o2mrecur/ent/migrate/schema.go create mode 100644 examples/o2mrecur/ent/mutation.go create mode 100644 examples/o2mrecur/ent/node.go create mode 100644 examples/o2mrecur/ent/node/mutation.go create mode 100644 examples/o2mrecur/ent/node/node.go create mode 100644 examples/o2mrecur/ent/node/where.go create mode 100644 examples/o2mrecur/ent/node_create.go create mode 100644 examples/o2mrecur/ent/node_delete.go create mode 100644 examples/o2mrecur/ent/node_query.go create mode 100644 examples/o2mrecur/ent/node_update.go create mode 100644 examples/o2mrecur/ent/predicate/predicate.go create mode 100644 examples/o2mrecur/ent/runtime.go create mode 100644 examples/o2mrecur/ent/runtime/runtime.go create mode 100644 examples/o2mrecur/ent/schema/node.go create mode 100644 examples/o2mrecur/ent/tx.go create mode 100644 examples/o2mrecur/example_test.go create mode 100644 examples/o2o2types/README.md create mode 100644 examples/o2o2types/ent/card.go create mode 100644 examples/o2o2types/ent/card/card.go create mode 100644 examples/o2o2types/ent/card/mutation.go create mode 100644 examples/o2o2types/ent/card/where.go create mode 100644 examples/o2o2types/ent/card_create.go create mode 100644 examples/o2o2types/ent/card_delete.go create mode 100644 examples/o2o2types/ent/card_query.go create mode 100644 examples/o2o2types/ent/card_update.go create mode 100644 examples/o2o2types/ent/client.go create mode 100644 examples/o2o2types/ent/ent.go create mode 100644 examples/o2o2types/ent/enttest/enttest.go create mode 100644 examples/o2o2types/ent/generate.go create mode 100644 examples/o2o2types/ent/hook/hook.go create mode 100644 examples/o2o2types/ent/migrate/migrate.go create mode 100644 examples/o2o2types/ent/migrate/schema.go create mode 100644 examples/o2o2types/ent/mutation.go create mode 100644 examples/o2o2types/ent/predicate/predicate.go create mode 100644 examples/o2o2types/ent/runtime.go create mode 100644 examples/o2o2types/ent/runtime/runtime.go create mode 100644 examples/o2o2types/ent/schema/card.go create mode 100644 examples/o2o2types/ent/schema/user.go create mode 100644 examples/o2o2types/ent/tx.go create mode 100644 examples/o2o2types/ent/user.go create mode 100644 examples/o2o2types/ent/user/mutation.go create mode 100644 examples/o2o2types/ent/user/user.go create mode 100644 examples/o2o2types/ent/user/where.go create mode 100644 examples/o2o2types/ent/user_create.go create mode 100644 examples/o2o2types/ent/user_delete.go create mode 100644 examples/o2o2types/ent/user_query.go create mode 100644 examples/o2o2types/ent/user_update.go create mode 100644 examples/o2o2types/example_test.go create mode 100644 examples/o2obidi/README.md create mode 100644 examples/o2obidi/ent/client.go create mode 100644 examples/o2obidi/ent/ent.go create mode 100644 examples/o2obidi/ent/enttest/enttest.go create mode 100644 examples/o2obidi/ent/generate.go create mode 100644 examples/o2obidi/ent/hook/hook.go create mode 100644 examples/o2obidi/ent/migrate/migrate.go create mode 100644 examples/o2obidi/ent/migrate/schema.go create mode 100644 examples/o2obidi/ent/mutation.go create mode 100644 examples/o2obidi/ent/predicate/predicate.go create mode 100644 examples/o2obidi/ent/runtime.go create mode 100644 examples/o2obidi/ent/runtime/runtime.go create mode 100644 examples/o2obidi/ent/schema/user.go create mode 100644 examples/o2obidi/ent/tx.go create mode 100644 examples/o2obidi/ent/user.go create mode 100644 examples/o2obidi/ent/user/mutation.go create mode 100644 examples/o2obidi/ent/user/user.go create mode 100644 examples/o2obidi/ent/user/where.go create mode 100644 examples/o2obidi/ent/user_create.go create mode 100644 examples/o2obidi/ent/user_delete.go create mode 100644 examples/o2obidi/ent/user_query.go create mode 100644 examples/o2obidi/ent/user_update.go create mode 100644 examples/o2obidi/example_test.go create mode 100644 examples/o2orecur/README.md create mode 100644 examples/o2orecur/ent/client.go create mode 100644 examples/o2orecur/ent/ent.go create mode 100644 examples/o2orecur/ent/enttest/enttest.go create mode 100644 examples/o2orecur/ent/generate.go create mode 100644 examples/o2orecur/ent/hook/hook.go create mode 100644 examples/o2orecur/ent/migrate/migrate.go create mode 100644 examples/o2orecur/ent/migrate/schema.go create mode 100644 examples/o2orecur/ent/mutation.go create mode 100644 examples/o2orecur/ent/node.go create mode 100644 examples/o2orecur/ent/node/mutation.go create mode 100644 examples/o2orecur/ent/node/node.go create mode 100644 examples/o2orecur/ent/node/where.go create mode 100644 examples/o2orecur/ent/node_create.go create mode 100644 examples/o2orecur/ent/node_delete.go create mode 100644 examples/o2orecur/ent/node_query.go create mode 100644 examples/o2orecur/ent/node_update.go create mode 100644 examples/o2orecur/ent/predicate/predicate.go create mode 100644 examples/o2orecur/ent/runtime.go create mode 100644 examples/o2orecur/ent/runtime/runtime.go create mode 100644 examples/o2orecur/ent/schema/node.go create mode 100644 examples/o2orecur/ent/tx.go create mode 100644 examples/o2orecur/example_test.go create mode 100644 examples/privacyadmin/README.md create mode 100644 examples/privacyadmin/ent/client.go create mode 100644 examples/privacyadmin/ent/ent.go create mode 100644 examples/privacyadmin/ent/enttest/enttest.go create mode 100644 examples/privacyadmin/ent/generate.go create mode 100644 examples/privacyadmin/ent/hook/hook.go create mode 100644 examples/privacyadmin/ent/internal/schema.go create mode 100644 examples/privacyadmin/ent/migrate/migrate.go create mode 100644 examples/privacyadmin/ent/migrate/schema.go create mode 100644 examples/privacyadmin/ent/mutation.go create mode 100644 examples/privacyadmin/ent/predicate/predicate.go create mode 100644 examples/privacyadmin/ent/privacy/privacy.go create mode 100644 examples/privacyadmin/ent/runtime.go create mode 100644 examples/privacyadmin/ent/runtime/runtime.go create mode 100644 examples/privacyadmin/ent/schema/user.go create mode 100644 examples/privacyadmin/ent/tx.go create mode 100644 examples/privacyadmin/ent/user.go create mode 100644 examples/privacyadmin/ent/user/mutation.go create mode 100644 examples/privacyadmin/ent/user/user.go create mode 100644 examples/privacyadmin/ent/user/where.go create mode 100644 examples/privacyadmin/ent/user_create.go create mode 100644 examples/privacyadmin/ent/user_delete.go create mode 100644 examples/privacyadmin/ent/user_query.go create mode 100644 examples/privacyadmin/ent/user_update.go create mode 100644 examples/privacyadmin/example_test.go create mode 100644 examples/privacyadmin/rule/rule.go create mode 100644 examples/privacyadmin/viewer/viewer.go create mode 100644 examples/privacytenant/README.md create mode 100644 examples/privacytenant/ent/client.go create mode 100644 examples/privacytenant/ent/ent.go create mode 100644 examples/privacytenant/ent/entql.go create mode 100644 examples/privacytenant/ent/enttest/enttest.go create mode 100644 examples/privacytenant/ent/generate.go create mode 100644 examples/privacytenant/ent/group.go create mode 100644 examples/privacytenant/ent/group/group.go create mode 100644 examples/privacytenant/ent/group/mutation.go create mode 100644 examples/privacytenant/ent/group/where.go create mode 100644 examples/privacytenant/ent/group_create.go create mode 100644 examples/privacytenant/ent/group_delete.go create mode 100644 examples/privacytenant/ent/group_query.go create mode 100644 examples/privacytenant/ent/group_update.go create mode 100644 examples/privacytenant/ent/hook/hook.go create mode 100644 examples/privacytenant/ent/internal/schema.go create mode 100644 examples/privacytenant/ent/migrate/migrate.go create mode 100644 examples/privacytenant/ent/migrate/schema.go create mode 100644 examples/privacytenant/ent/mutation.go create mode 100644 examples/privacytenant/ent/predicate/predicate.go create mode 100644 examples/privacytenant/ent/privacy/privacy.go create mode 100644 examples/privacytenant/ent/runtime.go create mode 100644 examples/privacytenant/ent/runtime/runtime.go create mode 100644 examples/privacytenant/ent/schema/group.go create mode 100644 examples/privacytenant/ent/schema/mixin.go create mode 100644 examples/privacytenant/ent/schema/tenant.go create mode 100644 examples/privacytenant/ent/schema/user.go create mode 100644 examples/privacytenant/ent/template/privacy.tmpl create mode 100644 examples/privacytenant/ent/tenant.go create mode 100644 examples/privacytenant/ent/tenant/mutation.go create mode 100644 examples/privacytenant/ent/tenant/tenant.go create mode 100644 examples/privacytenant/ent/tenant/where.go create mode 100644 examples/privacytenant/ent/tenant_create.go create mode 100644 examples/privacytenant/ent/tenant_delete.go create mode 100644 examples/privacytenant/ent/tenant_query.go create mode 100644 examples/privacytenant/ent/tenant_update.go create mode 100644 examples/privacytenant/ent/tx.go create mode 100644 examples/privacytenant/ent/user.go create mode 100644 examples/privacytenant/ent/user/mutation.go create mode 100644 examples/privacytenant/ent/user/user.go create mode 100644 examples/privacytenant/ent/user/where.go create mode 100644 examples/privacytenant/ent/user_create.go create mode 100644 examples/privacytenant/ent/user_delete.go create mode 100644 examples/privacytenant/ent/user_query.go create mode 100644 examples/privacytenant/ent/user_update.go create mode 100644 examples/privacytenant/example_test.go create mode 100644 examples/privacytenant/rule/rule.go create mode 100644 examples/privacytenant/viewer/viewer.go create mode 100644 examples/rls/README.md create mode 100644 examples/rls/atlas.hcl create mode 100644 examples/rls/ent/client.go create mode 100644 examples/rls/ent/ent.go create mode 100644 examples/rls/ent/enttest/enttest.go create mode 100644 examples/rls/ent/generate.go create mode 100644 examples/rls/ent/hook/hook.go create mode 100644 examples/rls/ent/migrate/migrate.go create mode 100644 examples/rls/ent/migrate/schema.go create mode 100644 examples/rls/ent/mutation.go create mode 100644 examples/rls/ent/predicate/predicate.go create mode 100644 examples/rls/ent/runtime.go create mode 100644 examples/rls/ent/runtime/runtime.go create mode 100644 examples/rls/ent/schema/tenant.go create mode 100644 examples/rls/ent/tenant.go create mode 100644 examples/rls/ent/tenant/mutation.go create mode 100644 examples/rls/ent/tenant/tenant.go create mode 100644 examples/rls/ent/tenant/where.go create mode 100644 examples/rls/ent/tenant_create.go create mode 100644 examples/rls/ent/tenant_delete.go create mode 100644 examples/rls/ent/tenant_query.go create mode 100644 examples/rls/ent/tenant_update.go create mode 100644 examples/rls/ent/tx.go create mode 100644 examples/rls/ent/user.go create mode 100644 examples/rls/ent/user/mutation.go create mode 100644 examples/rls/ent/user/user.go create mode 100644 examples/rls/ent/user/where.go create mode 100644 examples/rls/ent/user_create.go create mode 100644 examples/rls/ent/user_delete.go create mode 100644 examples/rls/ent/user_query.go create mode 100644 examples/rls/ent/user_update.go create mode 100644 examples/rls/example_test.go create mode 100644 examples/rls/migrations/20240714101101.sql create mode 100644 examples/rls/migrations/atlas.sum create mode 100644 examples/rls/schema.sql create mode 100644 examples/start/README.md create mode 100644 examples/start/ent/car.go create mode 100644 examples/start/ent/car/car.go create mode 100644 examples/start/ent/car/mutation.go create mode 100644 examples/start/ent/car/where.go create mode 100644 examples/start/ent/car_create.go create mode 100644 examples/start/ent/car_delete.go create mode 100644 examples/start/ent/car_query.go create mode 100644 examples/start/ent/car_update.go create mode 100644 examples/start/ent/client.go create mode 100644 examples/start/ent/ent.go create mode 100644 examples/start/ent/enttest/enttest.go create mode 100644 examples/start/ent/generate.go create mode 100644 examples/start/ent/group.go create mode 100644 examples/start/ent/group/group.go create mode 100644 examples/start/ent/group/mutation.go create mode 100644 examples/start/ent/group/where.go create mode 100644 examples/start/ent/group_create.go create mode 100644 examples/start/ent/group_delete.go create mode 100644 examples/start/ent/group_query.go create mode 100644 examples/start/ent/group_update.go create mode 100644 examples/start/ent/hook/hook.go create mode 100644 examples/start/ent/migrate/migrate.go create mode 100644 examples/start/ent/migrate/migrations/20230219130733_initial.sql create mode 100644 examples/start/ent/migrate/migrations/atlas.sum create mode 100644 examples/start/ent/migrate/schema.go create mode 100644 examples/start/ent/mutation.go create mode 100644 examples/start/ent/predicate/predicate.go create mode 100644 examples/start/ent/runtime.go create mode 100644 examples/start/ent/runtime/runtime.go create mode 100644 examples/start/ent/schema/car.go create mode 100644 examples/start/ent/schema/group.go create mode 100644 examples/start/ent/schema/user.go create mode 100644 examples/start/ent/tx.go create mode 100644 examples/start/ent/user.go create mode 100644 examples/start/ent/user/mutation.go create mode 100644 examples/start/ent/user/user.go create mode 100644 examples/start/ent/user/where.go create mode 100644 examples/start/ent/user_create.go create mode 100644 examples/start/ent/user_delete.go create mode 100644 examples/start/ent/user_query.go create mode 100644 examples/start/ent/user_update.go create mode 100644 examples/start/start.go create mode 100644 examples/traversal/README.md create mode 100644 examples/traversal/ent/client.go create mode 100644 examples/traversal/ent/ent.go create mode 100644 examples/traversal/ent/enttest/enttest.go create mode 100644 examples/traversal/ent/generate.go create mode 100644 examples/traversal/ent/group.go create mode 100644 examples/traversal/ent/group/group.go create mode 100644 examples/traversal/ent/group/mutation.go create mode 100644 examples/traversal/ent/group/where.go create mode 100644 examples/traversal/ent/group_create.go create mode 100644 examples/traversal/ent/group_delete.go create mode 100644 examples/traversal/ent/group_query.go create mode 100644 examples/traversal/ent/group_update.go create mode 100644 examples/traversal/ent/hook/hook.go create mode 100644 examples/traversal/ent/migrate/migrate.go create mode 100644 examples/traversal/ent/migrate/schema.go create mode 100644 examples/traversal/ent/mutation.go create mode 100644 examples/traversal/ent/pet.go create mode 100644 examples/traversal/ent/pet/mutation.go create mode 100644 examples/traversal/ent/pet/pet.go create mode 100644 examples/traversal/ent/pet/where.go create mode 100644 examples/traversal/ent/pet_create.go create mode 100644 examples/traversal/ent/pet_delete.go create mode 100644 examples/traversal/ent/pet_query.go create mode 100644 examples/traversal/ent/pet_update.go create mode 100644 examples/traversal/ent/predicate/predicate.go create mode 100644 examples/traversal/ent/runtime.go create mode 100644 examples/traversal/ent/runtime/runtime.go create mode 100644 examples/traversal/ent/schema/group.go create mode 100644 examples/traversal/ent/schema/pet.go create mode 100644 examples/traversal/ent/schema/user.go create mode 100644 examples/traversal/ent/tx.go create mode 100644 examples/traversal/ent/user.go create mode 100644 examples/traversal/ent/user/mutation.go create mode 100644 examples/traversal/ent/user/user.go create mode 100644 examples/traversal/ent/user/where.go create mode 100644 examples/traversal/ent/user_create.go create mode 100644 examples/traversal/ent/user_delete.go create mode 100644 examples/traversal/ent/user_query.go create mode 100644 examples/traversal/ent/user_update.go create mode 100644 examples/traversal/example_test.go create mode 100644 examples/triggers/README.md create mode 100644 examples/triggers/atlas.hcl create mode 100644 examples/triggers/ent/client.go create mode 100644 examples/triggers/ent/ent.go create mode 100644 examples/triggers/ent/enttest/enttest.go create mode 100644 examples/triggers/ent/generate.go create mode 100644 examples/triggers/ent/hook/hook.go create mode 100644 examples/triggers/ent/migrate/migrate.go create mode 100644 examples/triggers/ent/migrate/schema.go create mode 100644 examples/triggers/ent/mutation.go create mode 100644 examples/triggers/ent/predicate/predicate.go create mode 100644 examples/triggers/ent/runtime.go create mode 100644 examples/triggers/ent/runtime/runtime.go create mode 100644 examples/triggers/ent/schema/user.go create mode 100644 examples/triggers/ent/tx.go create mode 100644 examples/triggers/ent/user.go create mode 100644 examples/triggers/ent/user/mutation.go create mode 100644 examples/triggers/ent/user/user.go create mode 100644 examples/triggers/ent/user/where.go create mode 100644 examples/triggers/ent/user_create.go create mode 100644 examples/triggers/ent/user_delete.go create mode 100644 examples/triggers/ent/user_query.go create mode 100644 examples/triggers/ent/user_update.go create mode 100644 examples/triggers/ent/userauditlog.go create mode 100644 examples/triggers/ent/userauditlog/mutation.go create mode 100644 examples/triggers/ent/userauditlog/userauditlog.go create mode 100644 examples/triggers/ent/userauditlog/where.go create mode 100644 examples/triggers/ent/userauditlog_create.go create mode 100644 examples/triggers/ent/userauditlog_delete.go create mode 100644 examples/triggers/ent/userauditlog_query.go create mode 100644 examples/triggers/ent/userauditlog_update.go create mode 100644 examples/triggers/example_test.go create mode 100644 examples/triggers/migrations/20240713061538.sql create mode 100644 examples/triggers/migrations/atlas.sum create mode 100644 examples/triggers/schema.sql create mode 100644 examples/version/README.md create mode 100644 examples/version/ent/client.go create mode 100644 examples/version/ent/ent.go create mode 100644 examples/version/ent/enttest/enttest.go create mode 100644 examples/version/ent/generate.go create mode 100644 examples/version/ent/hook/hook.go create mode 100644 examples/version/ent/internal/schema.go create mode 100644 examples/version/ent/migrate/migrate.go create mode 100644 examples/version/ent/migrate/schema.go create mode 100644 examples/version/ent/mutation.go create mode 100644 examples/version/ent/predicate/predicate.go create mode 100644 examples/version/ent/runtime.go create mode 100644 examples/version/ent/runtime/runtime.go create mode 100644 examples/version/ent/schema/user.go create mode 100644 examples/version/ent/tx.go create mode 100644 examples/version/ent/user.go create mode 100644 examples/version/ent/user/mutation.go create mode 100644 examples/version/ent/user/user.go create mode 100644 examples/version/ent/user/where.go create mode 100644 examples/version/ent/user_create.go create mode 100644 examples/version/ent/user_delete.go create mode 100644 examples/version/ent/user_query.go create mode 100644 examples/version/ent/user_update.go create mode 100644 examples/version/example_test.go create mode 100644 examples/viewcomposite/README.md create mode 100644 examples/viewcomposite/atlas.hcl create mode 100644 examples/viewcomposite/ent/cleanuser.go create mode 100644 examples/viewcomposite/ent/cleanuser/cleanuser.go create mode 100644 examples/viewcomposite/ent/cleanuser/where.go create mode 100644 examples/viewcomposite/ent/cleanuser_query.go create mode 100644 examples/viewcomposite/ent/client.go create mode 100644 examples/viewcomposite/ent/ent.go create mode 100644 examples/viewcomposite/ent/enttest/enttest.go create mode 100644 examples/viewcomposite/ent/generate.go create mode 100644 examples/viewcomposite/ent/hook/hook.go create mode 100644 examples/viewcomposite/ent/migrate/migrate.go create mode 100644 examples/viewcomposite/ent/migrate/schema.go create mode 100644 examples/viewcomposite/ent/mutation.go create mode 100644 examples/viewcomposite/ent/pet.go create mode 100644 examples/viewcomposite/ent/pet/mutation.go create mode 100644 examples/viewcomposite/ent/pet/pet.go create mode 100644 examples/viewcomposite/ent/pet/where.go create mode 100644 examples/viewcomposite/ent/pet_create.go create mode 100644 examples/viewcomposite/ent/pet_delete.go create mode 100644 examples/viewcomposite/ent/pet_query.go create mode 100644 examples/viewcomposite/ent/pet_update.go create mode 100644 examples/viewcomposite/ent/petusername.go create mode 100644 examples/viewcomposite/ent/petusername/petusername.go create mode 100644 examples/viewcomposite/ent/petusername/where.go create mode 100644 examples/viewcomposite/ent/petusername_query.go create mode 100644 examples/viewcomposite/ent/predicate/predicate.go create mode 100644 examples/viewcomposite/ent/runtime.go create mode 100644 examples/viewcomposite/ent/runtime/runtime.go create mode 100644 examples/viewcomposite/ent/schema/pet.go create mode 100644 examples/viewcomposite/ent/schema/user.go create mode 100644 examples/viewcomposite/ent/tx.go create mode 100644 examples/viewcomposite/ent/user.go create mode 100644 examples/viewcomposite/ent/user/mutation.go create mode 100644 examples/viewcomposite/ent/user/user.go create mode 100644 examples/viewcomposite/ent/user/where.go create mode 100644 examples/viewcomposite/ent/user_create.go create mode 100644 examples/viewcomposite/ent/user_delete.go create mode 100644 examples/viewcomposite/ent/user_query.go create mode 100644 examples/viewcomposite/ent/user_update.go create mode 100644 examples/viewcomposite/example_test.go create mode 100644 examples/viewcomposite/schema.sql create mode 100644 examples/viewschema/README.md create mode 100644 examples/viewschema/atlas.hcl create mode 100644 examples/viewschema/ent/cleanuser.go create mode 100644 examples/viewschema/ent/cleanuser/cleanuser.go create mode 100644 examples/viewschema/ent/cleanuser/where.go create mode 100644 examples/viewschema/ent/cleanuser_query.go create mode 100644 examples/viewschema/ent/client.go create mode 100644 examples/viewschema/ent/ent.go create mode 100644 examples/viewschema/ent/enttest/enttest.go create mode 100644 examples/viewschema/ent/generate.go create mode 100644 examples/viewschema/ent/hook/hook.go create mode 100644 examples/viewschema/ent/migrate/migrate.go create mode 100644 examples/viewschema/ent/migrate/schema.go create mode 100644 examples/viewschema/ent/mutation.go create mode 100644 examples/viewschema/ent/pet.go create mode 100644 examples/viewschema/ent/pet/mutation.go create mode 100644 examples/viewschema/ent/pet/pet.go create mode 100644 examples/viewschema/ent/pet/where.go create mode 100644 examples/viewschema/ent/pet_create.go create mode 100644 examples/viewschema/ent/pet_delete.go create mode 100644 examples/viewschema/ent/pet_query.go create mode 100644 examples/viewschema/ent/pet_update.go create mode 100644 examples/viewschema/ent/petusername.go create mode 100644 examples/viewschema/ent/petusername/petusername.go create mode 100644 examples/viewschema/ent/petusername/where.go create mode 100644 examples/viewschema/ent/petusername_query.go create mode 100644 examples/viewschema/ent/predicate/predicate.go create mode 100644 examples/viewschema/ent/runtime.go create mode 100644 examples/viewschema/ent/runtime/runtime.go create mode 100644 examples/viewschema/ent/schema/pet.go create mode 100644 examples/viewschema/ent/schema/user.go create mode 100644 examples/viewschema/ent/tx.go create mode 100644 examples/viewschema/ent/user.go create mode 100644 examples/viewschema/ent/user/mutation.go create mode 100644 examples/viewschema/ent/user/user.go create mode 100644 examples/viewschema/ent/user/where.go create mode 100644 examples/viewschema/ent/user_create.go create mode 100644 examples/viewschema/ent/user_delete.go create mode 100644 examples/viewschema/ent/user_query.go create mode 100644 examples/viewschema/ent/user_update.go create mode 100644 examples/viewschema/example_test.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 op_string.go create mode 100644 privacy/privacy.go create mode 100644 privacy/privacy_test.go create mode 100644 schema/edge/annotation.go create mode 100644 schema/edge/edge.go create mode 100644 schema/edge/edge_test.go create mode 100644 schema/field/annotation.go create mode 100644 schema/field/annotation_test.go create mode 100644 schema/field/field.go create mode 100644 schema/field/field_test.go create mode 100644 schema/field/internal/gen.go create mode 100644 schema/field/internal/numeric.tmpl create mode 100644 schema/field/numeric.go create mode 100644 schema/field/type.go create mode 100644 schema/index/index.go create mode 100644 schema/index/index_test.go create mode 100644 schema/mixin/mixin.go create mode 100644 schema/mixin/mixin_test.go create mode 100644 schema/schema.go diff --git a/.all-contributorsrc b/.all-contributorsrc new file mode 100644 index 0000000..66692e1 --- /dev/null +++ b/.all-contributorsrc @@ -0,0 +1,855 @@ +{ + "files": [ + "doc/md/contributors.md" + ], + "imageSize": 100, + "commit": false, + "contributors": [ + { + "login": "a8m", + "name": "Ariel Mashraki", + "avatar_url": "https://avatars.githubusercontent.com/u/7413593?v=4", + "profile": "https://github.com/a8m", + "contributions": [ + "maintenance", + "doc", + "code" + ] + }, + { + "login": "alexsn", + "name": "Alex Snast", + "avatar_url": "https://avatars.githubusercontent.com/u/987019?v=4", + "profile": "https://github.com/alexsn", + "contributions": [ + "code" + ] + }, + { + "login": "rotemtam", + "name": "Rotem Tamir", + "avatar_url": "https://avatars.githubusercontent.com/u/1522681?v=4", + "profile": "https://rotemtam.com/", + "contributions": [ + "maintenance", + "doc", + "code" + ] + }, + { + "login": "cliedeman", + "name": "Ciaran Liedeman", + "avatar_url": "https://avatars.githubusercontent.com/u/3578740?v=4", + "profile": "https://github.com/cliedeman", + "contributions": [ + "code" + ] + }, + { + "login": "marwan-at-work", + "name": "Marwan Sulaiman", + "avatar_url": "https://avatars.githubusercontent.com/u/16294261?v=4", + "profile": "https://www.marwan.io/", + "contributions": [ + "code" + ] + }, + { + "login": "napei", + "name": "Nathaniel Peiffer", + "avatar_url": "https://avatars.githubusercontent.com/u/8946502?v=4", + "profile": "https://nathaniel.peiffer.com.au/", + "contributions": [ + "code" + ] + }, + { + "login": "tmc", + "name": "Travis Cline", + "avatar_url": "https://avatars.githubusercontent.com/u/3977?v=4", + "profile": "https://github.com/tmc", + "contributions": [ + "code" + ] + }, + { + "login": "hantmac", + "name": "Jeremy", + "avatar_url": "https://avatars.githubusercontent.com/u/7600925?v=4", + "profile": "https://cloudsjhan.github.io/", + "contributions": [ + "code" + ] + }, + { + "login": "aca", + "name": "aca", + "avatar_url": "https://avatars.githubusercontent.com/u/50316549?v=4", + "profile": "https://github.com/aca", + "contributions": [ + "code" + ] + }, + { + "login": "BrentChesny", + "name": "BrentChesny", + "avatar_url": "https://avatars.githubusercontent.com/u/1449435?v=4", + "profile": "https://github.com/BrentChesny", + "contributions": [ + "code", + "doc" + ] + }, + { + "login": "giautm", + "name": "Giau. Tran Minh", + "avatar_url": "https://avatars.githubusercontent.com/u/12751435?v=4", + "profile": "https://github.com/giautm", + "contributions": [ + "code", + "review" + ] + }, + { + "login": "htdvisser", + "name": "Hylke Visser", + "avatar_url": "https://avatars.githubusercontent.com/u/181308?v=4", + "profile": "https://htdvisser.dev/", + "contributions": [ + "code" + ] + }, + { + "login": "kerbelp", + "name": "Pavel Kerbel", + "avatar_url": "https://avatars.githubusercontent.com/u/3934990?v=4", + "profile": "https://github.com/kerbelp", + "contributions": [ + "code" + ] + }, + { + "login": "day-dreams", + "name": "zhangnan", + "avatar_url": "https://avatars.githubusercontent.com/u/24593904?v=4", + "profile": "https://github.com/day-dreams", + "contributions": [ + "code" + ] + }, + { + "login": "uta-mori", + "name": "mori yuta", + "avatar_url": "https://avatars.githubusercontent.com/u/59682979?v=4", + "profile": "https://github.com/uta-mori", + "contributions": [ + "code", + "translation", + "review" + ] + }, + { + "login": "chris-rock", + "name": "Christoph Hartmann", + "avatar_url": "https://avatars.githubusercontent.com/u/1178413?v=4", + "profile": "http://lollyrock.com/", + "contributions": [ + "code" + ] + }, + { + "login": "rubensayshi", + "name": "Ruben de Vries", + "avatar_url": "https://avatars.githubusercontent.com/u/649160?v=4", + "profile": "https://github.com/rubensayshi", + "contributions": [ + "code" + ] + }, + { + "login": "ernado", + "name": "Aleksandr Razumov", + "avatar_url": "https://avatars.githubusercontent.com/u/866677?v=4", + "profile": "https://keybase.io/ernado", + "contributions": [ + "code" + ] + }, + { + "login": "apbuteau", + "name": "apbuteau", + "avatar_url": "https://avatars.githubusercontent.com/u/6796073?v=4", + "profile": "https://github.com/apbuteau", + "contributions": [ + "code" + ] + }, + { + "login": "ichord", + "name": "Harold.Luo", + "avatar_url": "https://avatars.githubusercontent.com/u/1324791?v=4", + "profile": "https://github.com/ichord", + "contributions": [ + "code" + ] + }, + { + "login": "idoshveki", + "name": "ido shveki", + "avatar_url": "https://avatars.githubusercontent.com/u/11615669?v=4", + "profile": "https://github.com/idoshveki", + "contributions": [ + "code" + ] + }, + { + "login": "masseelch", + "name": "MasseElch", + "avatar_url": "https://avatars.githubusercontent.com/u/12862103?v=4", + "profile": "https://github.com/masseelch", + "contributions": [ + "code" + ] + }, + { + "login": "kidlj", + "name": "Jian Li", + "avatar_url": "https://avatars.githubusercontent.com/u/300616?v=4", + "profile": "https://github.com/kidlj", + "contributions": [ + "code" + ] + }, + { + "login": "nolotz", + "name": "Noah-Jerome Lotzer", + "avatar_url": "https://avatars.githubusercontent.com/u/5778728?v=4", + "profile": "https://noah.je/", + "contributions": [ + "code" + ] + }, + { + "login": "danf0rth", + "name": "danforth", + "avatar_url": "https://avatars.githubusercontent.com/u/14220891?v=4", + "profile": "https://github.com/danf0rth", + "contributions": [ + "code" + ] + }, + { + "login": "maxiloEmmmm", + "name": "maxilozoz", + "avatar_url": "https://avatars.githubusercontent.com/u/16779121?v=4", + "profile": "https://github.com/maxiloEmmmm", + "contributions": [ + "code" + ] + }, + { + "login": "zzwx", + "name": "zzwx", + "avatar_url": "https://avatars.githubusercontent.com/u/8169082?v=4", + "profile": "https://gist.github.com/zzwx", + "contributions": [ + "code" + ] + }, + { + "login": "ix64", + "name": "MengYX", + "avatar_url": "https://avatars.githubusercontent.com/u/13902388?v=4", + "profile": "https://github.com/ix64", + "contributions": [ + "translation" + ] + }, + { + "login": "mattn", + "name": "mattn", + "avatar_url": "https://avatars.githubusercontent.com/u/10111?v=4", + "profile": "https://mattn.kaoriya.net/", + "contributions": [ + "translation" + ] + }, + { + "login": "Bladrak", + "name": "Hugo Briand", + "avatar_url": "https://avatars.githubusercontent.com/u/1321977?v=4", + "profile": "https://github.com/Bladrak", + "contributions": [ + "code" + ] + }, + { + "login": "enmand", + "name": "Dan Enman", + "avatar_url": "https://avatars.githubusercontent.com/u/432487?v=4", + "profile": "https://danielenman.com/", + "contributions": [ + "code" + ] + }, + { + "login": "UnAfraid", + "name": "Rumen Nikiforov", + "avatar_url": "https://avatars.githubusercontent.com/u/2185291?v=4", + "profile": "http://www.l2junity.org/", + "contributions": [ + "code" + ] + }, + { + "login": "wenerme", + "name": "陈杨文", + "avatar_url": "https://avatars.githubusercontent.com/u/1777211?v=4", + "profile": "https://wener.me", + "contributions": [ + "code" + ] + }, + { + "login": "joesonw", + "name": "Qiaosen (Joeson) Huang", + "avatar_url": "https://avatars.githubusercontent.com/u/1635441?v=4", + "profile": "https://djwong.net", + "contributions": [ + "bug" + ] + }, + { + "login": "davebehr1", + "name": "AlonDavidBehr", + "avatar_url": "https://avatars.githubusercontent.com/u/16716239?v=4", + "profile": "https://github.com/davebehr1", + "contributions": [ + "code", + "review" + ] + }, + { + "login": "DuGlaser", + "name": "DuGlaser", + "avatar_url": "https://avatars.githubusercontent.com/u/50506482?v=4", + "profile": "http://duglaser.dev", + "contributions": [ + "doc" + ] + }, + { + "login": "shanna", + "name": "Shane Hanna", + "avatar_url": "https://avatars.githubusercontent.com/u/28489?v=4", + "profile": "https://github.com/shanna", + "contributions": [ + "doc" + ] + }, + { + "login": "mahmud2011", + "name": "Mahmudul Haque", + "avatar_url": "https://avatars.githubusercontent.com/u/5278142?v=4", + "profile": "https://www.linkedin.com/in/mahmud2011", + "contributions": [ + "code" + ] + }, + { + "login": "sywesk", + "name": "Benjamin Bourgeais", + "avatar_url": "https://avatars.githubusercontent.com/u/862607?v=4", + "profile": "http://blog.scaleprocess.net", + "contributions": [ + "code" + ] + }, + { + "login": "8ayac", + "name": "8ayac(Yoshinori Hayashi)", + "avatar_url": "https://avatars.githubusercontent.com/u/29266382?v=4", + "profile": "https://about.8ay.ac/", + "contributions": [ + "doc" + ] + }, + { + "login": "y-yagi", + "name": "y-yagi", + "avatar_url": "https://avatars.githubusercontent.com/u/987638?v=4", + "profile": "https://github.com/y-yagi", + "contributions": [ + "doc" + ] + }, + { + "login": "Sacro", + "name": "Ben Woodward", + "avatar_url": "https://avatars.githubusercontent.com/u/2659869?v=4", + "profile": "https://github.com/Sacro", + "contributions": [ + "code" + ] + }, + { + "login": "wzyjerry", + "name": "WzyJerry", + "avatar_url": "https://avatars.githubusercontent.com/u/11435169?v=4", + "profile": "https://github.com/wzyjerry", + "contributions": [ + "code" + ] + }, + { + "login": "tarrencev", + "name": "Tarrence van As", + "avatar_url": "https://avatars.githubusercontent.com/u/4740651?v=4", + "profile": "https://github.com/tarrencev", + "contributions": [ + "doc", + "code" + ] + }, + { + "login": "MONAKA0721", + "name": "Yuya Sumie", + "avatar_url": "https://avatars.githubusercontent.com/u/32859963?v=4", + "profile": "https://mo7ka.com", + "contributions": [ + "doc" + ] + }, + { + "login": "akfaew", + "name": "Michal Mazurek", + "avatar_url": "https://avatars.githubusercontent.com/u/7853732?v=4", + "profile": "http://jasminek.net", + "contributions": [ + "code" + ] + }, + { + "login": "nmemoto", + "name": "Takafumi Umemoto", + "avatar_url": "https://avatars.githubusercontent.com/u/1522332?v=4", + "profile": "https://github.com/nmemoto", + "contributions": [ + "doc" + ] + }, + { + "login": "squarebat", + "name": "Khadija Sidhpuri", + "avatar_url": "https://avatars.githubusercontent.com/u/59063821?v=4", + "profile": "http://www.linkedin.com/in/khadija-sidhpuri-87709316a", + "contributions": [ + "code" + ] + }, + { + "login": "neel229", + "name": "Neel Modi", + "avatar_url": "https://avatars.githubusercontent.com/u/53475167?v=4", + "profile": "https://github.com/neel229", + "contributions": [ + "code" + ] + }, + { + "login": "shomodj", + "name": "Boris Shomodjvarac", + "avatar_url": "https://avatars.githubusercontent.com/u/304768?v=4", + "profile": "https://ie.linkedin.com/in/boris-shomodjvarac-51970879", + "contributions": [ + "doc" + ] + }, + { + "login": "sadmansakib", + "name": "Sadman Sakib", + "avatar_url": "https://avatars.githubusercontent.com/u/17023844?v=4", + "profile": "https://github.com/sadmansakib", + "contributions": [ + "doc" + ] + }, + { + "login": "dakimura", + "name": "dakimura", + "avatar_url": "https://avatars.githubusercontent.com/u/34202807?v=4", + "profile": "https://github.com/dakimura", + "contributions": [ + "code" + ] + }, + { + "login": "RiskyFeryansyahP", + "name": "Risky Feryansyah", + "avatar_url": "https://avatars.githubusercontent.com/u/36788585?v=4", + "profile": "https://github.com/RiskyFeryansyahP", + "contributions": [ + "code" + ] + }, + { + "login": "seiichi1101", + "name": "seiichi ", + "avatar_url": "https://avatars.githubusercontent.com/u/20941952?v=4", + "profile": "https://github.com/seiichi1101", + "contributions": [ + "code" + ] + }, + { + "login": "odeke-em", + "name": "Emmanuel T Odeke", + "avatar_url": "https://avatars.githubusercontent.com/u/4898263?v=4", + "profile": "https://orijtech.com/", + "contributions": [ + "code" + ] + }, + { + "login": "isoppp", + "name": "Hiroki Isogai", + "avatar_url": "https://avatars.githubusercontent.com/u/16318727?v=4", + "profile": "https://isoppp.com", + "contributions": [ + "doc" + ] + }, + { + "login": "tsingsun", + "name": "李清山", + "avatar_url": "https://avatars.githubusercontent.com/u/5848549?v=4", + "profile": "https://github.com/tsingsun", + "contributions": [ + "code" + ] + }, + { + "login": "s-takehana", + "name": "s-takehana", + "avatar_url": "https://avatars.githubusercontent.com/u/3423547?v=4", + "profile": "https://github.com/s-takehana", + "contributions": [ + "doc" + ] + }, + { + "login": "EndlessIdea", + "name": "Kuiba", + "avatar_url": "https://avatars.githubusercontent.com/u/1527796?v=4", + "profile": "https://github.com/EndlessIdea", + "contributions": [ + "code" + ] + }, + { + "login": "storyicon", + "name": "storyicon", + "avatar_url": "https://avatars.githubusercontent.com/u/29772821?v=4", + "profile": "https://github.com/storyicon", + "contributions": [ + "code" + ] + }, + { + "login": "evanlurvey", + "name": "Evan Lurvey", + "avatar_url": "https://avatars.githubusercontent.com/u/54965655?v=4", + "profile": "https://github.com/evanlurvey", + "contributions": [ + "code" + ] + }, + { + "login": "attackordie", + "name": "Brian", + "avatar_url": "https://avatars.githubusercontent.com/u/20145334?v=4", + "profile": "https://github.com/attackordie", + "contributions": [ + "doc" + ] + }, + { + "login": "ThinkontrolSY", + "name": "Shen Yang", + "avatar_url": "https://avatars.githubusercontent.com/u/11331554?v=4", + "profile": "http://www.thinkontrol.com", + "contributions": [ + "code" + ] + }, + { + "login": "sivchari", + "name": "sivchari", + "avatar_url": "https://avatars.githubusercontent.com/u/55221074?v=4", + "profile": "https://twitter.com/sivchari", + "contributions": [ + "code" + ] + }, + { + "login": "mookjp", + "name": "mook", + "avatar_url": "https://avatars.githubusercontent.com/u/1519309?v=4", + "profile": "https://blog.mookjp.io", + "contributions": [ + "code" + ] + }, + { + "login": "heliumbrain", + "name": "heliumbrain", + "avatar_url": "https://avatars.githubusercontent.com/u/1607668?v=4", + "profile": "http://www.entiros.se", + "contributions": [ + "doc" + ] + }, + { + "login": "JeremyV2014", + "name": "Jeremy Maxey-Vesperman", + "avatar_url": "https://avatars.githubusercontent.com/u/9276415?v=4", + "profile": "https://github.com/JeremyV2014", + "contributions": [ + "code", + "doc" + ] + }, + { + "login": "tankbusta", + "name": "Christopher Schmitt", + "avatar_url": "https://avatars.githubusercontent.com/u/592749?v=4", + "profile": "https://github.com/tankbusta", + "contributions": [ + "doc" + ] + }, + { + "login": "grevych", + "name": "Gerardo Reyes", + "avatar_url": "https://avatars.githubusercontent.com/u/3792003?v=4", + "profile": "https://github.com/grevych", + "contributions": [ + "code" + ] + }, + { + "login": "naormatania", + "name": "Naor Matania", + "avatar_url": "https://avatars.githubusercontent.com/u/6978437?v=4", + "profile": "https://github.com/naormatania", + "contributions": [ + "code" + ] + }, + { + "login": "idc77", + "name": "idc77", + "avatar_url": "https://avatars.githubusercontent.com/u/87644834?v=4", + "profile": "https://github.com/idc77", + "contributions": [ + "doc" + ] + }, + { + "login": "HurSungYun", + "name": "Sungyun Hur", + "avatar_url": "https://avatars.githubusercontent.com/u/8033896?v=4", + "profile": "http://ethanhur.me", + "contributions": [ + "doc" + ] + }, + { + "login": "peanut-cc", + "name": "peanut-pg", + "avatar_url": "https://avatars.githubusercontent.com/u/55480838?v=4", + "profile": "https://github.com/peanut-cc", + "contributions": [ + "doc" + ] + }, + { + "login": "m3hm3t", + "name": "Mehmet Yılmaz", + "avatar_url": "https://avatars.githubusercontent.com/u/22320354?v=4", + "profile": "https://github.com/m3hm3t", + "contributions": [ + "code" + ] + }, + { + "login": "Laconty", + "name": "Roman Maklakov", + "avatar_url": "https://avatars.githubusercontent.com/u/17760166?v=4", + "profile": "https://github.com/Laconty", + "contributions": [ + "code" + ] + }, + { + "login": "genevieve", + "name": "Genevieve", + "avatar_url": "https://avatars.githubusercontent.com/u/12158641?v=4", + "profile": "https://github.com/genevieve", + "contributions": [ + "code" + ] + }, + { + "login": "cjraa", + "name": "Clarence", + "avatar_url": "https://avatars.githubusercontent.com/u/62199269?v=4", + "profile": "https://github.com/cjraa", + "contributions": [ + "code" + ] + }, + { + "login": "iamnande", + "name": "Nicholas Anderson", + "avatar_url": "https://avatars.githubusercontent.com/u/7806510?v=4", + "profile": "https://www.linkedin.com/in/iamnande/", + "contributions": [ + "code" + ] + }, + { + "login": "hezhizhen", + "name": "Zhizhen He", + "avatar_url": "https://avatars.githubusercontent.com/u/7611700?v=4", + "profile": "https://github.com/hezhizhen", + "contributions": [ + "code" + ] + }, + { + "login": "crossworth", + "name": "Pedro Henrique", + "avatar_url": "https://avatars.githubusercontent.com/u/1251151?v=4", + "profile": "https://pedro.dev.br", + "contributions": [ + "code" + ] + }, + { + "login": "MrParano1d", + "name": "MrParano1d", + "avatar_url": "https://avatars.githubusercontent.com/u/7414374?v=4", + "profile": "https://2jp.de", + "contributions": [ + "code" + ] + }, + { + "login": "tprebs", + "name": "Thomas Prebble", + "avatar_url": "https://avatars.githubusercontent.com/u/6523587?v=4", + "profile": "https://github.com/tprebs", + "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" + ] + }, + { + "login": "iwata", + "name": "Motonori Iwata", + "avatar_url": "https://avatars.githubusercontent.com/u/121048?v=4", + "profile": "https://mobcov.hatenadiary.org/", + "contributions": [ + "doc" + ] + }, + { + "login": "CharlesGe129", + "name": "Charles Ge", + "avatar_url": "https://avatars.githubusercontent.com/u/20162173?v=4", + "profile": "https://github.com/CharlesGe129", + "contributions": [ + "code" + ] + }, + { + "login": "thmeitz", + "name": "Thomas Meitz", + "avatar_url": "https://avatars.githubusercontent.com/u/92851940?v=4", + "profile": "https://github.com/thmeitz", + "contributions": [ + "code", + "doc" + ] + }, + { + "login": "booleangate", + "name": "Justin Johnson", + "avatar_url": "https://avatars.githubusercontent.com/u/181567?v=4", + "profile": "http://justinjohnson.org", + "contributions": [ + "code" + ] + }, + { + "login": "hax10", + "name": "hax10", + "avatar_url": "https://avatars.githubusercontent.com/u/85743468?v=4", + "profile": "https://github.com/hax10", + "contributions": [ + "code" + ] + }, + { + "login": "water-a", + "name": "water-a", + "avatar_url": "https://avatars.githubusercontent.com/u/38114545?v=4", + "profile": "https://github.com/water-a", + "contributions": [ + "bug" + ] + }, + { + "login": "jhwz", + "name": "jhwz", + "avatar_url": "https://avatars.githubusercontent.com/u/52683873?v=4", + "profile": "https://github.com/jhwz", + "contributions": [ + "doc" + ] + }, + { + "login": "kortschak", + "name": "Dan Kortschak", + "avatar_url": "https://avatars.githubusercontent.com/u/275221?v=4", + "profile": "https://kortschak.io/", + "contributions": [ + "doc" + ] + } + ], + "contributorsPerLine": 7, + "projectName": "ent", + "projectOwner": "ent", + "repoType": "github", + "repoHost": "https://github.com", + "skipCi": true +} diff --git a/.devcontainer/devcontainer-lock.json b/.devcontainer/devcontainer-lock.json new file mode 100644 index 0000000..4744a2d --- /dev/null +++ b/.devcontainer/devcontainer-lock.json @@ -0,0 +1,19 @@ +{ + "features": { + "ghcr.io/devcontainers/features/copilot-cli:1": { + "version": "1.1.2", + "resolved": "ghcr.io/devcontainers/features/copilot-cli@sha256:757c6c2899dc902c44a9f6164c1f7392832ced13c6bb632d8d60a880f2e92456", + "integrity": "sha256:757c6c2899dc902c44a9f6164c1f7392832ced13c6bb632d8d60a880f2e92456" + }, + "ghcr.io/devcontainers/features/docker-in-docker:2": { + "version": "2.17.0", + "resolved": "ghcr.io/devcontainers/features/docker-in-docker@sha256:25b9f05705ffba7dbe503230ac76081419306f8c8bc88e0ce78c4ecd99a0c78c", + "integrity": "sha256:25b9f05705ffba7dbe503230ac76081419306f8c8bc88e0ce78c4ecd99a0c78c" + }, + "ghcr.io/devcontainers/features/github-cli:1": { + "version": "1.1.0", + "resolved": "ghcr.io/devcontainers/features/github-cli@sha256:d22f50b70ed75339b4eed1ba9ecde3a1791f90e88d37936517e3bace0bbad671", + "integrity": "sha256:d22f50b70ed75339b4eed1ba9ecde3a1791f90e88d37936517e3bace0bbad671" + } + } +} diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..399e91a --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,37 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the +// README at: https://github.com/devcontainers/templates/tree/main/src/go . +{ + "name": "Go", + // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile + "image": "mcr.microsoft.com/devcontainers/go:2-1.26", + "features": { + "ghcr.io/devcontainers/features/copilot-cli:1": { + "version": "latest" + }, + "ghcr.io/devcontainers/features/docker-in-docker:2": { + "dockerDashComposeVersion": "latest", + "installDockerBuildx": true, + "moby": false, + "version": "latest" + }, + "ghcr.io/devcontainers/features/github-cli:1": { + "installDirectlyFromGitHubRelease": true, + "version": "latest" + } + } + + // Features to add to the dev container. More info: https://containers.dev/features. + // "features": {}, + + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // "forwardPorts": [], + + // Use 'postCreateCommand' to run commands after the container is created. + // "postCreateCommand": "go version", + + // Configure tool-specific properties. + // "customizations": {}, + + // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. + // "remoteUser": "root" +} diff --git a/.github/ISSUE_TEMPLATE/1.bug.md b/.github/ISSUE_TEMPLATE/1.bug.md new file mode 100644 index 0000000..4330424 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/1.bug.md @@ -0,0 +1,58 @@ +--- +name: Bug report 🐛 +about: Create a bug report. +labels: 'status: needs triage' +--- + + + + + + + +- [ ] The issue is present in the latest release. +- [ ] I have searched the [issues](https://github.com/ent/ent/issues) of this repository and believe that this is not a duplicate. + +## Current Behavior 😯 + + + +## Expected Behavior 🤔 + + + +## Steps to Reproduce 🕹 + + + + + + +Steps: + +1. +2. +3. +4. + +## Your Environment 🌎 + + + +| Tech | Version | +| ----------- | ------- | +| Go | 1.17.? | +| Ent | 0.9.? | +| Database | MySQL | +| Driver | https://github.com/go-sql-driver/mysql | diff --git a/.github/ISSUE_TEMPLATE/2.feature.md b/.github/ISSUE_TEMPLATE/2.feature.md new file mode 100644 index 0000000..2ddf480 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/2.feature.md @@ -0,0 +1,27 @@ +--- +name: Feature request 🎉 +about: Suggest a new idea for the project. +labels: 'status: needs triage' +--- + + + + + + + +- [ ] I have searched the [issues](https://github.com/ent/ent/issues) of this repository and believe that this is not a duplicate. + +## Summary 💡 + + + +## Motivation 🔦 + + diff --git a/.github/ISSUE_TEMPLATE/3.support.md b/.github/ISSUE_TEMPLATE/3.support.md new file mode 100644 index 0000000..391c556 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/3.support.md @@ -0,0 +1,5 @@ +--- +name: Question +about: General support +labels: 'status: needs triage' +--- diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..8c4cb03 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false # force the usage of a template +contact_links: + - name: Something Else ❔ + url: https://gophers.slack.com/archives/C01FMSQDT53 + about: Come chat to us in the gophers slack \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..5d219e8 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: +- package-ecosystem: github-actions + directory: / + schedule: + interval: daily +- package-ecosystem: gomod + directory: / + schedule: + interval: daily diff --git a/.github/workflows/atlas-ci-public.yaml b/.github/workflows/atlas-ci-public.yaml new file mode 100644 index 0000000..8653a1d --- /dev/null +++ b/.github/workflows/atlas-ci-public.yaml @@ -0,0 +1,57 @@ +name: Atlas CI Public +on: + # Run whenever code is changed in the master branch, + # change this to your root branch. + push: + branches: + - master + paths: + - 'examples/migration/ent/migrate/migrations/*' + pull_request: + paths: + - 'examples/migration/ent/migrate/migrations/*' +jobs: + sync: + permissions: + contents: read + id-token: write + needs: lint + if: github.ref == 'refs/heads/master' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ariga/atlas-sync-action@v0 + with: + dir: 'examples/migration/ent/migrate/migrations' + driver: mysql # or: postgres | sqlite + cloud-public: true + lint: + permissions: + contents: read + id-token: write + pull-requests: write + services: + # Spin up a mysql:8.0.29 container to be used as the dev-database for analysis. + mysql: + image: mysql:8.0.29 + env: + MYSQL_ROOT_PASSWORD: pass + MYSQL_DATABASE: dev + ports: + - "3306:3306" + options: >- + --health-cmd "mysqladmin ping -ppass" + --health-interval 10s + --health-start-period 10s + --health-timeout 5s + --health-retries 10 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # Mandatory unless "latest" is set below. + - uses: ariga/atlas-action@v0 + with: + dir: 'examples/migration/ent/migrate/migrations' + dev-url: mysql://root:pass@localhost:3306/dev + cloud-public: true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5d317d0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,501 @@ +name: Continuous Integration +on: + push: + paths-ignore: + - 'doc/**' + tags-ignore: + - '*.*' + pull_request: + paths-ignore: + - 'doc/**' + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: './go.mod' + - name: Run linters + uses: golangci/golangci-lint-action@v5 + with: + args: --verbose + + unit: + runs-on: ubuntu-latest + strategy: + matrix: + go: ['1.23', '1.24'] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go }} + - uses: actions/cache@v4 + with: + path: ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + - name: Run cmd tests + run: go test -race ./... + working-directory: cmd + - name: Run dialect tests + run: go test -race ./... + working-directory: dialect + - name: Run schema tests + run: go test -race ./... + working-directory: schema + - name: Run loader tests + run: go test -race ./... + working-directory: entc/load + - name: Run codegen tests + run: go test -race ./... + working-directory: entc/gen + - name: Run example tests + working-directory: examples + run: go test -race ./... + + generate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: './go.mod' + - uses: actions/cache@v4 + with: + path: ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + - name: Run go generate + run: go generate ./... && go mod tidy + - name: Run go generate on examples directory + working-directory: examples + run: go generate ./... && go mod tidy + - name: Run go generate on integration directory + working-directory: entc/integration + run: go generate ./... && go mod tidy + - name: Check generated files + run: | + status=$(git status --porcelain) + if [ -n "$status" ]; then + echo "you need to run 'go generate ./...' in root and 'examples' dirs and commit the changes" + echo "$status" + git --no-pager diff + exit 1 + fi + + integration: + runs-on: ubuntu-latest + services: + mysql56: + image: mysql:5.6.35 + env: + MYSQL_DATABASE: test + MYSQL_ROOT_PASSWORD: pass + ports: + - 3306:3306 + options: >- + --health-cmd "mysqladmin ping -ppass" + --health-interval 10s + --health-start-period 10s + --health-timeout 5s + --health-retries 10 + mysql57: + image: mysql:5.7.26 + env: + MYSQL_DATABASE: test + MYSQL_ROOT_PASSWORD: pass + ports: + - 3307:3306 + options: >- + --health-cmd "mysqladmin ping -ppass" + --health-interval 10s + --health-start-period 10s + --health-timeout 5s + --health-retries 10 + mysql8: + image: mysql:8 + env: + MYSQL_DATABASE: test + MYSQL_ROOT_PASSWORD: pass + ports: + - 3308:3306 + options: >- + --health-cmd "mysqladmin ping -ppass" + --health-interval 10s + --health-start-period 10s + --health-timeout 5s + --health-retries 10 + maria: + image: mariadb:10.4 # Temporary to unblock PRs from failing. + env: + MYSQL_DATABASE: test + MYSQL_ROOT_PASSWORD: pass + ports: + - 4306:3306 + options: >- + --health-cmd "mysqladmin ping -ppass" + --health-interval 10s + --health-start-period 10s + --health-timeout 5s + --health-retries 10 + maria102: + image: mariadb:10.2.32 + env: + MYSQL_DATABASE: test + MYSQL_ROOT_PASSWORD: pass + ports: + - 4307:3306 + options: >- + --health-cmd "mysqladmin ping -ppass" + --health-interval 10s + --health-start-period 10s + --health-timeout 5s + --health-retries 10 + maria103: + image: mariadb:10.3.13 + env: + MYSQL_DATABASE: test + MYSQL_ROOT_PASSWORD: pass + ports: + - 4308:3306 + options: >- + --health-cmd "mysqladmin ping -ppass" + --health-interval 10s + --health-start-period 10s + --health-timeout 5s + --health-retries 10 + postgres10: + image: postgres:10 + env: + POSTGRES_DB: test + POSTGRES_PASSWORD: pass + ports: + - 5430:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + postgres11: + image: postgres:11 + env: + POSTGRES_DB: test + POSTGRES_PASSWORD: pass + ports: + - 5431:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + postgres12: + image: postgres:12.3 + env: + POSTGRES_DB: test + POSTGRES_PASSWORD: pass + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + postgres13: + image: postgres:13.1 + env: + POSTGRES_DB: test + POSTGRES_PASSWORD: pass + ports: + - 5433:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + postgres14: + image: postgres:14 + env: + POSTGRES_DB: test + POSTGRES_PASSWORD: pass + ports: + - 5434:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + postgres15: + image: postgres:15 + env: + POSTGRES_DB: test + POSTGRES_PASSWORD: pass + ports: + - 5435:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + postgres16: + image: postgres:16 + env: + POSTGRES_DB: test + POSTGRES_PASSWORD: pass + ports: + - 5436:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + postgres17: + image: postgres:17 + env: + POSTGRES_DB: test + POSTGRES_PASSWORD: pass + ports: + - 5437:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + gremlin-server: + image: entgo/gremlin-server + ports: + - 8182:8182 + options: >- + --health-cmd "netstat -an | grep -q 8182" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: './go.mod' + - uses: actions/cache@v4 + with: + path: ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + - name: Run integration tests + working-directory: entc/integration + run: go test -race -count=2 ./... + + migration: + runs-on: ubuntu-latest + if: ${{ github.ref != 'refs/heads/master' }} + services: + mysql56: + image: mysql:5.6.35 + env: + MYSQL_DATABASE: test + MYSQL_ROOT_PASSWORD: pass + ports: + - 3306:3306 + options: >- + --health-cmd "mysqladmin ping -ppass" + --health-interval 10s + --health-start-period 10s + --health-timeout 5s + --health-retries 10 + mysql57: + image: mysql:5.7.26 + env: + MYSQL_DATABASE: test + MYSQL_ROOT_PASSWORD: pass + ports: + - 3307:3306 + options: >- + --health-cmd "mysqladmin ping -ppass" + --health-interval 10s + --health-start-period 10s + --health-timeout 5s + --health-retries 10 + mysql8: + image: mysql:8 + env: + MYSQL_DATABASE: test + MYSQL_ROOT_PASSWORD: pass + ports: + - 3308:3306 + options: >- + --health-cmd "mysqladmin ping -ppass" + --health-interval 10s + --health-start-period 10s + --health-timeout 5s + --health-retries 10 + maria: + image: mariadb:10.4 # Temporary to unblock PRs from failing. + env: + MYSQL_DATABASE: test + MYSQL_ROOT_PASSWORD: pass + ports: + - 4306:3306 + options: >- + --health-cmd "mysqladmin ping -ppass" + --health-interval 10s + --health-start-period 10s + --health-timeout 5s + --health-retries 10 + maria102: + image: mariadb:10.2.32 + env: + MYSQL_DATABASE: test + MYSQL_ROOT_PASSWORD: pass + ports: + - 4307:3306 + options: >- + --health-cmd "mysqladmin ping -ppass" + --health-interval 10s + --health-start-period 10s + --health-timeout 5s + --health-retries 10 + maria103: + image: mariadb:10.3.13 + env: + MYSQL_DATABASE: test + MYSQL_ROOT_PASSWORD: pass + ports: + - 4308:3306 + options: >- + --health-cmd "mysqladmin ping -ppass" + --health-interval 10s + --health-start-period 10s + --health-timeout 5s + --health-retries 10 + postgres10: + image: postgres:10 + env: + POSTGRES_DB: test + POSTGRES_PASSWORD: pass + ports: + - 5430:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + postgres11: + image: postgres:11 + env: + POSTGRES_DB: test + POSTGRES_PASSWORD: pass + ports: + - 5431:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + postgres12: + image: postgres:12.3 + env: + POSTGRES_DB: test + POSTGRES_PASSWORD: pass + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + postgres13: + image: postgres:13.1 + env: + POSTGRES_DB: test + POSTGRES_PASSWORD: pass + ports: + - 5433:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + postgres14: + image: postgres:14 + env: + POSTGRES_DB: test + POSTGRES_PASSWORD: pass + ports: + - 5434:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + postgres15: + image: postgres:15 + env: + POSTGRES_DB: test + POSTGRES_PASSWORD: pass + ports: + - 5435:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + postgres16: + image: postgres:16 + env: + POSTGRES_DB: test + POSTGRES_PASSWORD: pass + ports: + - 5436:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + postgres17: + image: postgres:17 + env: + POSTGRES_DB: test + POSTGRES_PASSWORD: pass + ports: + - 5437:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + gremlin-server: + image: entgo/gremlin-server + ports: + - 8182:8182 + options: >- + --health-cmd "netstat -an | grep -q 8182" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-go@v5 + with: + go-version-file: './go.mod' + - uses: actions/cache@v4 + with: + path: ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + - name: Checkout origin/master + run: git checkout origin/master + - name: Run integration on origin/master + working-directory: entc/integration + run: go test -race -count=2 ./... + - name: Checkout previous HEAD + run: git checkout - + - name: Run integration on HEAD + working-directory: entc/integration + run: go test -race -count=2 ./... diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e43b0f9 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.DS_Store diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..ceb8f6c --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,72 @@ +run: + go: '1.19' + timeout: 5m + +linters-settings: + errcheck: + ignore: fmt:.*,Read|Write|Close|Exec,io:Copy + dupl: + threshold: 100 + funlen: + lines: 200 + statements: 200 + goheader: + template: |- + Copyright 2019-present Facebook Inc. All rights reserved. + This source code is licensed under the Apache 2.0 license found + in the LICENSE file in the root directory of this source tree. +linters: + disable-all: true + enable: + - asciicheck + - bodyclose + - dogsled + - dupl + - errcheck + - funlen + - gocritic + # - gofmt; Enable back when upgrading CI to Go 1.20. + - goheader + - gosec + - gosimple + - govet + - ineffassign + - misspell + - staticcheck + - stylecheck + - typecheck + - unconvert + - unused + - whitespace + +issues: + exclude-rules: + - path: _test\.go + linters: + - dupl + - funlen + - gosec + - gocritic + - linters: + - unused + source: ent.Schema + - path: dialect/sql/schema + linters: + - dupl + - gosec + - text: "Expect WriteFile permissions to be 0600 or less" + linters: + - gosec + - path: privacy/privacy.go + linters: + - stylecheck + - path: entc/load/schema.go + linters: + - staticcheck + - path: entc/gen/graph.go + linters: + - gocritic + - path: \.go + linters: + - staticcheck + text: SA1019 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..d1abc70 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,77 @@ +# Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to make participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, sex characteristics, gender identity and expression, +level of experience, education, socio-economic status, nationality, personal +appearance, race, religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or + advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic + address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct, or to ban temporarily or +permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies within all project spaces, and it also applies when +an individual is representing the project or its community in public spaces. +Examples of representing a project or community include using an official +project e-mail address, posting via an official social media account, or acting +as an appointed representative at an online or offline event. Representation of +a project may be further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting the project team at . All +complaints will be reviewed and investigated and will result in a response that +is deemed necessary and appropriate to the circumstances. The project team is +obligated to maintain confidentiality with regard to the reporter of an incident. +Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, +available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see +https://www.contributor-covenant.org/faq + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..e2b65d1 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,57 @@ +# Contributing to ent +We want to make contributing to this project as easy and transparent as +possible. + +# Project structure + +- `dialect` - Contains SQL and Gremlin code used by the generated code. + - `dialect/sql/schema` - Auto migration logic resides there. + - `dialect/sql/sqljson` - JSON extension for SQL. + +- `schema` - User schema API. + - `schema/{field, edge, index, mixin}` - provides schema builders API. + - `schema/field/gen` - Templates and codegen for numeric builders. + +- `entc` - Codegen of `ent`. + - `entc/load` - `entc` loader API for loading user schemas into a Go objects at runtime. + - `entc/gen` - The actual code generation logic resides in this package (and its `templates` package). + - `integration` - Integration tests for `entc`. + +- `privacy` - Runtime code for [privacy layer](https://entgo.io/docs/privacy/). + +- `doc` - Documentation code for `entgo.io` (uses [Docusaurus](https://docusaurus.io)). + - `doc/md` - Markdown files for documentation. + - `doc/website` - Website code and assets. + + In order to test your documentation changes, run `npm start` from the `doc/website` directory, and open [localhost:3000](http://localhost:3000/). + +# Run integration tests +If you touch any file in `entc`, run the following commands in `entc/integration` and 'examples' dirs: + +``` +go generate ./... +go mod tidy +``` + +Then, in `entc/integration` run `docker-compose` in order to spin-up all database containers: + +``` +docker-compose -f docker-compose.yaml up -d +``` + +Then, run `go test ./...` to run all integration tests. + + +## Pull Requests +We actively welcome your pull requests. + +1. Fork the repo and create your branch from `master`. +2. If you've added code that should be tested, add tests. +3. If you've changed APIs, update the documentation. +4. Ensure the test suite passes. +5. Make sure your code lints. +6. If you haven't already, complete the Contributor License Agreement ("CLA"). + +## License +By contributing to ent, you agree that your contributions will be licensed +under the LICENSE file in the root directory of this source tree. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7a4a3ea --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..c849ff8 --- /dev/null +++ b/README.md @@ -0,0 +1,56 @@ +## ent - An Entity Framework For Go + +[![Twitter](https://img.shields.io/twitter/url/https/twitter.com/entgo_io.svg?style=social&label=Follow%20%40entgo_io)](https://twitter.com/entgo_io) +[![Discord](https://img.shields.io/discord/885059418646003782?label=discord&logo=discord&style=flat-square&logoColor=white)](https://discord.gg/qZmPgTE6RX) + +[English](README.md) | [中文](README_zh.md) | [日本語](README_jp.md) | [한국어](README_kr.md) + + + +Simple, yet powerful entity framework for Go, that makes it easy to build and maintain applications +with large data-models. + +- **Schema As Code** - model any database schema as Go objects. +- **Easily Traverse Any Graph** - run queries, aggregations and traverse any graph structure easily. +- **Statically Typed And Explicit API** - 100% statically typed and explicit API using code generation. +- **Multi Storage Driver** - supports MySQL, MariaDB, TiDB, PostgreSQL, CockroachDB, SQLite and Gremlin. +- **Extendable** - simple to extend and customize using Go templates. + +## Quick Installation +```console +go install entgo.io/ent/cmd/ent@latest +``` + +For proper installation using [Go modules], visit [entgo.io website][entgo install]. + +## Docs and Support +The documentation for developing and using ent is available at: https://entgo.io + +For discussion and support, [open an issue](https://github.com/ent/ent/issues/new/choose) or join our [Discord server](https://discord.gg/qZmPgTE6RX). + +## Developed and Sponsored by the [Atlas](https://atlasgo.io) Team + +[![image](https://github.com/user-attachments/assets/2a35b1b7-325f-4844-a2d7-d450c60de0dc)](https://atlasgo.io?utm_source=ent-readme) + +## About the Project +The `ent` project was inspired by Ent, an entity framework used internally at Meta (Facebook). It was created by [a8m](https://github.com/a8m) and [alexsn](https://github.com/alexsn) from the [Facebook Connectivity][fbc] team. These days, it is developed and maintained by the [Atlas](https://github.com/ariga/atlas) team, and the roadmap for its v1 release is described [here](https://github.com/ent/ent/issues/46). + +## Join the ent Community +Building `ent` would not have been possible without the collective work of our entire community. We maintain a [contributors page](doc/md/contributors.md) +which lists the contributors to this `ent`. + +In order to contribute to `ent`, see the [CONTRIBUTING](CONTRIBUTING.md) file for how to go get started. +If your company or your product is using `ent`, please let us know by adding yourself to the [ent users page](https://github.com/ent/ent/wiki/ent-users). + +For updates, follow us on Twitter at https://twitter.com/entgo_io + +## License +ent is licensed under Apache 2.0 as found in the [LICENSE file](LICENSE). + + +[entgo install]: https://entgo.io/docs/code-gen/#version-compatibility-between-entc-and-ent +[Go modules]: https://github.com/golang/go/wiki/Modules#quick-start +[fbc]: https://connectivity.fb.com diff --git a/README_jp.md b/README_jp.md new file mode 100644 index 0000000..c1c1c11 --- /dev/null +++ b/README_jp.md @@ -0,0 +1,54 @@ +## ent - Goのエンティティーフレームワーク + +[![Twitter](https://img.shields.io/twitter/url/https/twitter.com/entgo_io.svg?style=social&label=Follow%20%40entgo_io)](https://twitter.com/entgo_io) + +[English](README.md) | [中文](README_zh.md) | [日本語](README_jp.md) + + + +シンプルながらもパワフルなGoのエンティティフレームワークであり、大規模なデータモデルを持つアプリケーションを容易に構築・保守できるようにします。 + +- **Schema As Code(コードとしてのスキーマ)** - あらゆるデータベーススキーマをGoオブジェクトとしてモデル化します。 +- **任意のグラフを簡単にトラバースできます** - クエリや集約の実行、任意のグラフ構造の走査を容易に実行できます。 +- **100%静的に型付けされた明示的なAPI** - コード生成により、100%静的に型付けされた曖昧さのないAPIを提供します。 +- **マルチストレージドライバ** - MySQL、MariaDB、 TiDB、PostgreSQL、CockroachDB、SQLite、Gremlinをサポートしています。 +- **拡張性** - Goテンプレートを使用して簡単に拡張、カスタマイズできます。 + +## クイックインストール +```console +go install entgo.io/ent/cmd/ent@latest +``` + +[Go modules]を使ったインストールについては、[entgo.ioのWebサイト](https://entgo.io/ja/docs/code-gen/#entc-%E3%81%A8-ent-%E3%81%AE%E3%83%90%E3%83%BC%E3%82%B8%E3%83%A7%E3%83%B3%E3%82%92%E4%B8%80%E8%87%B4%E3%81%95%E3%81%9B%E3%82%8B)をご覧ください。 + +## ドキュメントとサポート +entを開発・使用するためのドキュメントは、こちら: https://entgo.io + +議論やサポートについては、[Issueを開く](https://github.com/ent/ent/issues/new/choose)か、gophers Slackの[チャンネル](https://gophers.slack.com/archives/C01FMSQDT53)に参加してください。 + +## entコミュニティへの参加 +`ent`の構築は、コミュニティ全体の協力なしには実現できませんでした。 私たちは、この`ent`の貢献者をリストアップした[contributorsページ](doc/md/contributors.md)を管理しています。 + +`ent`に貢献するときは、まず[CONTRIBUTING](CONTRIBUTING.md)を参照してください。 +もし、あなたの会社や製品で`ent`を利用している場合は、[ent usersページ](https://github.com/ent/ent/wiki/ent-users)に追記する形で、そのことをぜひ教えて下さい。 + +最新情報については、Twitter()をフォローしてください。 + + + +## プロジェクトについて +`ent`プロジェクトは、私たちが社内で使用しているエンティティフレームワークであるEntからインスピレーションを得ています。 +entは、[Facebook Connectivity][fbc]チームの[a8m](https://github.com/a8m)と[alexsn](https://github.com/alexsn)が開発・保守しています。 +本番環境では複数のチームやプロジェクトで使用されており、v1リリースまでのロードマップは[こちら](https://github.com/ent/ent/issues/46)に記載されています。 +このプロジェクトの動機については[こちら](https://entgo.io/blog/2019/10/03/introducing-ent)をご覧ください。 + +## ライセンス +entは、[LICENSEファイル](LICENSE)にもある通り、Apache 2.0でライセンスされています。 + + +[entgo instal]: https://entgo.io/docs/code-gen/#version-compatibility-between-entc-and-ent +[Go modules]: https://github.com/golang/go/wiki/Modules#quick-start +[fbc]: https://connectivity.fb.com diff --git a/README_kr.md b/README_kr.md new file mode 100644 index 0000000..12b0906 --- /dev/null +++ b/README_kr.md @@ -0,0 +1,52 @@ +## ent - An Entity Framework For Go + +[English](README.md) | [中文](README_zh.md) | [日本語](README_jp.md) | [한국어](README_kr.md) + + + +간단하지만 강력한 Go용 엔터티 프레임워크로, 대규모 데이터 모델이 포함된 애플리케이션을 쉽게 만들고 유지할 수 있습니다. + +- **스키마를 코드로 관리** - 모든 데이터베이스 스키마와 모델을 Go Object로 구현 가능. +- **어떤 그래프든 쉽게 탐색가능** - 쿼리실행, 집계, 그래프구조를 쉽게 탐색 가능. +- **정적 타입 그리고 명시적인 API** - 100% 생성된 코드로, 정적타입과 명시적인 API를 제공. +- **다양한 스토리지 드라이버** - MySQL, MariaDB, TiDB, PostgreSQL, CockroachDB, SQLite and Gremlin 를 지원 +- **확장성** - Go 템플릿을 이용하여 간단하게 확장, 커스터마이징 가능. + +## 빠른 설치 + +```console +go install entgo.io/ent/cmd/ent@latest +``` + +[Go modules]을 사용하여 바르게 설치하려면, [entgo.io 웹페이지][entgo install]를 방문해주시길 바랍니다. + +## 문서 및 지원 + +Ent 개발 및 사용에 관한 문서는 여기서 확인할 수 있습니다. : https://entgo.io + +토론, 지원을 위해서 [open an issue](https://github.com/ent/ent/issues/new/choose)깃허브 이슈 또는 gophers Slack [채널](https://gophers.slack.com/archives/C01FMSQDT53)에 가입해주세요. + +## ent 커뮤니티 가입 + +ent 커뮤니티의 공동작업이 없었다면, ent를 만들 수 없었을 것입니다. 우리는 기여한 사람들을 [contributors 페이지](doc/md/contributors.md)에 올리고 유지합니다. + +ent에 기여하려면 [CONTRIBUTING](CONTRIBUTING.md)에서 시작 방법을 확인해보세요. +프로젝트나 회사에서 ent를 사용중이면, [ent 유저 페이지](https://github.com/ent/ent/wiki/ent-users)에 추가하여 알려주세요. + +트위터계정을 팔로우하여 업데이트 소식을 확인하세요. https://twitter.com/entgo_io + +## 프로젝트에 관하여 + +ent프로젝트는 내부적으로 사용하는 엔터티 프레임워크 "Ent"에서 영감을 받았습니다. 개발 및 유지보수는 [a8m](https://github.com/a8m) 및 [alexsn](https://github.com/alexsn)[Facebook Connectivity][fbc] 팀에서 담당합니다. 여러 팀이 프로덕션 환경에서 사용하고 있습니다. v1 릴리즈 로드맵에 대한 설명은 [여기](https://github.com/ent/ent/issues/46)를 클릭해주세요. +프로젝트 동기에 대해 더 궁금하시다면 [여기](https://entgo.io/blog/2019/10/03/introducing-ent)를 클릭해주세요. + +## 라이센스 + +ent 라이센스는 Apache 2.0입니다. [LICENSE file](LICENSE)파일에서도 확인 가능합니다. + +[entgo install]: https://entgo.io/docs/code-gen/#version-compatibility-between-entc-and-ent +[go modules]: https://github.com/golang/go/wiki/Modules#quick-start +[fbc]: https://connectivity.fb.com diff --git a/README_zh.md b/README_zh.md new file mode 100644 index 0000000..05034b0 --- /dev/null +++ b/README_zh.md @@ -0,0 +1,44 @@ +## ent - 一个强大的Go语言实体框架 + +[English](README.md) | [中文](README_zh.md) | [日本語](README_jp.md) + + + +ent是一个简单而又功能强大的Go语言实体框架,ent易于构建和维护应用程序与大数据模型。 + +- **图就是代码** - 将任何数据库表建模为Go对象。 +- **轻松地遍历任何图形** - 可以轻松地运行查询、聚合和遍历任何图形结构。 +- **静态类型和显式API** - 使用代码生成静态类型和显式API,查询数据更加便捷。 +- **多存储驱动程序** - 支持MySQL, PostgreSQL, SQLite 和 Gremlin。 +- **可扩展** - 简单地扩展和使用Go模板自定义。 + +## 快速安装 +```console +go install entgo.io/ent/cmd/ent@latest +``` + +请访问[entgo.io website][entgo instal]以使用[Go modules]进行正确安装。 + +## 文档和支持 +开发和使用ent的文档请参照: https://entgo.io + +如要讨论问题和支持, [创建一个issue](https://github.com/ent/ent/issues/new/choose) 或者加入我们的Gopher Slack(Slack软件,类似于论坛)[讨论组](https://gophers.slack.com/archives/C01FMSQDT53) + +## 加入 ent 社区 +如果你想为`ent`做出贡献, [贡献代码](CONTRIBUTING.md) 中写了如何做出自己的贡献 +如果你的公司或者产品在使用`ent`,请让我们知道你已经加入 [ent 用户](https://github.com/ent/ent/wiki/ent-users) + +## 关于项目 +`ent` 项目灵感来自于Ent,Ent是一个facebook内部使用的一个实体框架项目。 它由 [Facebook Connectivity][fbc] 团队通过 [a8m](https://github.com/a8m) 和 [alexsn](https://github.com/alexsn) 开发和维护 +, 它被生产中的多个团队和项目使用。它的v1版本的路线图为 [版本的路线图](https://github.com/ent/ent/issues/46). +关于项目更多的信息 [ent介绍](https://entgo.io/blog/2019/10/03/introducing-ent)。 + +## 声明 +ent使用Apache 2.0协议授权,可以在[LICENSE文件](LICENSE)中找到。 + +[entgo instal]: https://entgo.io/docs/code-gen/#version-compatibility-between-entc-and-ent +[Go modules]: https://github.com/golang/go/wiki/Modules#quick-start +[fbc]: https://connectivity.fb.com diff --git a/blob.go b/blob.go new file mode 100644 index 0000000..66d3d02 --- /dev/null +++ b/blob.go @@ -0,0 +1,483 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package ent + +import ( + "context" + "errors" + "fmt" + "io" + "io/fs" +) + +// Blob defines the interface for blob storage operations. +// Implementations should return [io/fs.ErrNotExist] (or an error wrapping it) +// from NewReader when the requested key does not exist. +// +// Single-row SQL create builders write blob data to external storage before +// inserting the database row. If the row insertion fails (for example, due to +// a constraint violation), generated code attempts to delete the just-written +// blobs — but only those no database row references. See [BlobRefChecker]. +type Blob interface { + // NewReader opens a reader for the given key. + NewReader(ctx context.Context, key string) (io.ReadCloser, error) + // NewWriter opens a writer for the given key. + NewWriter(ctx context.Context, key string) (io.WriteCloser, error) + // Delete removes the blob at the given key. + // Implementations should return nil (not an error) if the key does not exist. + Delete(ctx context.Context, key string) error + // Close releases any resources held by the bucket. + Close() error +} + +// BlobOpener is a function that opens a [Blob] bucket for the given field name. +type BlobOpener func(context.Context, string) (Blob, error) + +// BlobKey identifies a blob in storage by field name and key. +type BlobKey struct { + Field string + Key string +} + +// BlobRefChecker reports which of the given blob keys rows in the database hold. +// +// Every path that deletes from storage consults it first, because a blob key is +// not private to the row that wrote it. With content-addressed keys — the default +// — identical content always yields an identical key, so the object written ahead +// of a failed insert may be the very object an existing row points at, and two +// rows with the same content share one object. A key is safe to remove only once +// no row holds it. +// +// Keys are looked up per field, which takes each blob field to have its own +// bucket: the model [BlobOpener] describes. Fields pointing at one shared bucket +// also share objects, and cleanup cannot see across them. +// +// The lookup always runs while the mutation's statement (and transaction) is still +// open, timed so that the table already reads the way the mutation will leave it: +// before the INSERT for blobs written ahead of a row, and after the UPDATE or +// DELETE for keys the mutation gives up ([BlobUpdateResult.Cleanup], +// [BlobDeleteResult.Cleanup]). Nothing has to be excluded from the result that way. +// +// A row inserted concurrently between the lookup and the delete is not observed; +// content-addressed storage needs reference counting or a sweeper to close that +// window entirely. +type BlobRefChecker interface { + ReferencedBlobKeys(ctx context.Context, keys []BlobKey) ([]BlobKey, error) +} + +// BlobQuerier queries existing blob keys from the database. +// [Blobs.Update] passes the mutated field names; [Blobs.Delete] passes nil +// to indicate all fields should be queried. +type BlobQuerier interface { + BlobRefChecker + QueryBlobKeys(ctx context.Context, fields []string) ([]BlobKey, error) +} + +// BlobUpdateResult holds post-update blob operations. +type BlobUpdateResult struct { + Rollback BlobOp // Deletes newly-written blobs. Call on SQL failure. + + blobs *Blobs + orphaned []BlobKey +} + +// Cleanup reports which of the blobs the update replaced no row holds any more, and +// returns the [BlobOp] that removes them from storage. +// +// Call it once the UPDATE has run and before the transaction commits — the reference +// check has to see the row on its new key, and it cannot run on a transaction that has +// already settled. Invoke the returned op after the commit. +func (r *BlobUpdateResult) Cleanup(ctx context.Context) (BlobOp, error) { + if r.blobs == nil { + return noOp, nil + } + return r.blobs.deleteOp(ctx, r.orphaned) +} + +// BlobDeleteResult holds the blob keys of the rows a delete mutation removes. +type BlobDeleteResult struct { + blobs *Blobs + keys []BlobKey +} + +// Cleanup reports which of those blobs no row holds any more, and returns the [BlobOp] +// that removes them from storage. +// +// Call it once the DELETE has run and before the transaction commits — the reference +// check has to see the rows gone, and it cannot run on a transaction that has already +// settled. Invoke the returned op after the commit. +func (r *BlobDeleteResult) Cleanup(ctx context.Context) (BlobOp, error) { + if r.blobs == nil { + return noOp, nil + } + return r.blobs.deleteOp(ctx, r.keys) +} + +// BlobOp is a deferred blob storage operation (e.g. rollback or commit). +type BlobOp func(context.Context) error + +// BlobKeyFunc generates a storage key for a blob from its content. +type BlobKeyFunc func(context.Context, []byte) (string, error) + +// Blobs orchestrates blob storage operations for a single mutation. +// Use [NewBlobs] to create, then call [Blobs.Set] or [Blobs.SetCleared] +// for each blob field, then [Blobs.Create] or [Blobs.Update]. +type Blobs struct { + opener BlobOpener + inputs []blobInput + refs BlobRefChecker +} + +type blobInput struct { + field string + data []byte + newKey BlobKeyFunc + apply func(string) + cleared bool + clear func() +} + +// NewBlobs creates a blob orchestrator for the given opener. +func NewBlobs(opener BlobOpener) *Blobs { + return &Blobs{opener: opener} +} + +// Set adds a blob field to be written. The apply callback is called with +// the generated key to set it on the SQL spec and node. +func (b *Blobs) Set(f string, data []byte, key BlobKeyFunc, apply func(string)) { + b.inputs = append(b.inputs, blobInput{field: f, data: data, newKey: key, apply: apply}) +} + +// SetCleared marks a blob field as cleared. The clear callback should +// remove the key column from the SQL spec. +func (b *Blobs) SetCleared(f string, clear func()) { + b.inputs = append(b.inputs, blobInput{field: f, cleared: true, clear: clear}) +} + +// Create prepares inputs, writes blobs, and returns a rollback [BlobOp]. +// refs keeps the rollback from deleting an object that rows in the database +// already reference; a nil refs skips that check. +func (b *Blobs) Create(ctx context.Context, refs BlobRefChecker) (BlobOp, error) { + b.refs = refs + writes, err := b.prepare(ctx) + if err != nil { + return nil, err + } + return b.write(ctx, writes) +} + +// Update prepares inputs, queries old keys, writes new blobs, and returns +// a [BlobUpdateResult] for post-SQL handling. +func (b *Blobs) Update(ctx context.Context, q BlobQuerier) (*BlobUpdateResult, error) { + if len(b.inputs) == 0 { + return noopBlobResult, nil + } + b.refs = q + writes, err := b.prepare(ctx) + if err != nil { + return nil, err + } + mutated := make([]string, len(b.inputs)) + cleared := make(map[string]bool) + for i := range b.inputs { + mutated[i] = b.inputs[i].field + if b.inputs[i].cleared { + cleared[b.inputs[i].field] = true + } + } + keys, err := q.QueryBlobKeys(ctx, mutated) + if err != nil { + return nil, fmt.Errorf("querying old blob keys: %w", err) + } + // Build a set of old keys per field to detect unchanged blobs. + oldKeys := make(map[string]string, len(keys)) + for _, k := range keys { + oldKeys[k.Field] = k.Key + } + // Filter out writes where the key is unchanged (same content). Keep this in + // its own slice — the orphan collection below reads the unfiltered writes. + filtered := make([]blobWrite, 0, len(writes)) + for _, wr := range writes { + if oldKeys[wr.Field] == wr.Key { + continue + } + filtered = append(filtered, wr) + } + rollback, err := b.write(ctx, filtered) + if err != nil { + return nil, err + } + // Collect orphaned blobs: old keys for fields that changed or were cleared. + var orphaned []BlobKey + for _, k := range keys { + if cleared[k.Field] { + orphaned = append(orphaned, k) + continue + } + for _, wr := range writes { + if wr.Field == k.Field && wr.Key != k.Key { + orphaned = append(orphaned, k) + break + } + } + } + // The orphaned keys are resolved by BlobUpdateResult.Cleanup rather than here: + // until the UPDATE runs, this row still holds them. + return &BlobUpdateResult{ + Rollback: rollback, + blobs: b, + orphaned: orphaned, + }, nil +} + +// Delete collects the blob keys held by the rows the mutation matches. Call it +// before the DELETE runs; [BlobDeleteResult.Cleanup] resolves what to remove from +// storage once the rows are gone. Use for delete mutations. +func (b *Blobs) Delete(ctx context.Context, q BlobQuerier) (*BlobDeleteResult, error) { + b.refs = q + keys, err := q.QueryBlobKeys(ctx, nil) + if err != nil { + return nil, err + } + return &BlobDeleteResult{blobs: b, keys: keys}, nil +} + +type blobWrite struct { + BlobKey + data []byte +} + +func (b *Blobs) prepare(ctx context.Context) ([]blobWrite, error) { + var writes []blobWrite + for _, inp := range b.inputs { + if inp.cleared { + inp.clear() + continue + } + k, err := inp.newKey(ctx, inp.data) + if err != nil { + return nil, fmt.Errorf("generating blob key for %s: %w", inp.field, err) + } + if inp.apply != nil { + inp.apply(k) + } + writes = append(writes, blobWrite{ + BlobKey: BlobKey{Field: inp.field, Key: k}, + data: inp.data, + }) + } + return writes, nil +} + +func (b *Blobs) write(ctx context.Context, writes []blobWrite) (BlobOp, error) { + if len(writes) == 0 { + return noOp, nil + } + w := NewBlobStore(b.opener) + var written []BlobKey + for _, wr := range writes { + if err := w.write(ctx, wr.Field, wr.Key, wr.data); err != nil { + // A failed write may still have left an object behind, so clean it up + // with the rest — under the same reference check. + written = append(written, wr.BlobKey) + errs := []error{fmt.Errorf("writing blob for %s: %w", wr.Field, err), w.Close()} + switch rollback, rerr := b.deleteOp(ctx, written); { + case rerr != nil: + errs = append(errs, rerr) + default: + errs = append(errs, rollback(ctx)) + } + return nil, errors.Join(errs...) + } + written = append(written, wr.BlobKey) + } + if err := w.Close(); err != nil { + return nil, err + } + return b.deleteOp(ctx, written) +} + +// deleteOp returns a [BlobOp] that removes keys from storage, skipping any key +// that rows outside this mutation still hold. +// +// The lookup happens here rather than inside the returned op: the op runs after +// the statement — or the whole transaction — has settled, when the mutation's own +// view of the table is no longer reachable. +func (b *Blobs) deleteOp(ctx context.Context, keys []BlobKey) (BlobOp, error) { + if len(keys) == 0 { + return noOp, nil + } + keys, err := b.unreferenced(ctx, keys) + if err != nil { + return nil, err + } + if len(keys) == 0 { + return noOp, nil + } + return func(ctx context.Context) error { + s := NewBlobStore(b.opener) + var errs []error + for _, k := range keys { + if err := s.delete(ctx, k.Field, k.Key); err != nil { + errs = append(errs, err) + } + } + if err := s.Close(); err != nil { + errs = append(errs, err) + } + return errors.Join(errs...) + }, nil +} + +// unreferenced returns the distinct keys no surviving row holds, dropping the +// ones still in use. See [BlobRefChecker] for why a key can be shared at all. +func (b *Blobs) unreferenced(ctx context.Context, keys []BlobKey) ([]BlobKey, error) { + if b.refs == nil { + return dedupBlobKeys(keys), nil + } + referenced, err := b.refs.ReferencedBlobKeys(ctx, keys) + if err != nil { + return nil, fmt.Errorf("checking blob key references: %w", err) + } + if len(referenced) == 0 { + return dedupBlobKeys(keys), nil + } + held := make(map[BlobKey]bool, len(referenced)) + for _, k := range referenced { + held[k] = true + } + unreferenced := make([]BlobKey, 0, len(keys)) + for _, k := range dedupBlobKeys(keys) { + if !held[k] { + unreferenced = append(unreferenced, k) + } + } + return unreferenced, nil +} + +func dedupBlobKeys(keys []BlobKey) []BlobKey { + seen := make(map[BlobKey]bool, len(keys)) + distinct := make([]BlobKey, 0, len(keys)) + for _, k := range keys { + if seen[k] { + continue + } + seen[k] = true + distinct = append(distinct, k) + } + return distinct +} + +var ( + noOp = func(context.Context) error { return nil } + noopBlobResult = &BlobUpdateResult{Rollback: noOp} +) + +// BlobReader returns a reader for the given key from the blob bucket. +// The returned reader closes both the underlying reader and the bucket. +// Returns nil, nil if the blob does not exist (fs.ErrNotExist). +func BlobReader(ctx context.Context, b Blob, key string) (io.ReadCloser, error) { + switch r, err := b.NewReader(ctx, key); { + case errors.Is(err, fs.ErrNotExist): + return nil, b.Close() + case err != nil: + return nil, errors.Join(err, b.Close()) + default: + return &blobReadCloser{ReadCloser: r, bucket: b}, nil + } +} + +type blobReadCloser struct { + io.ReadCloser + bucket Blob +} + +func (r *blobReadCloser) Close() error { + return errors.Join(r.ReadCloser.Close(), r.bucket.Close()) +} + +// BlobStore manages blob bucket lifecycles for read, write, and delete operations. +// It lazily opens buckets per field and reuses them for subsequent operations. +type BlobStore struct { + opener BlobOpener + buckets map[string]Blob +} + +// NewBlobStore creates a store that uses opener to lazily open buckets. +func NewBlobStore(opener BlobOpener) *BlobStore { + return &BlobStore{buckets: make(map[string]Blob), opener: opener} +} + +// Close closes all open buckets. +func (s *BlobStore) Close() error { + var errs []error + for _, b := range s.buckets { + errs = append(errs, b.Close()) + } + return errors.Join(errs...) +} + +// write writes data to the blob at key for the given field. +func (s *BlobStore) write(ctx context.Context, field, key string, data []byte) error { + b, err := s.bucket(ctx, field) + if err != nil { + return err + } + wr, err := b.NewWriter(ctx, key) + if err != nil { + return err + } + if _, err := wr.Write(data); err != nil { + // Leave the key alone — it may be an object other rows reference. The + // caller cleans up through [Blobs.deleteOp], which checks for references. + return errors.Join(err, wr.Close()) + } + return wr.Close() +} + +// delete removes the blob at key for the given field. +func (s *BlobStore) delete(ctx context.Context, field, key string) error { + b, err := s.bucket(ctx, field) + if err != nil { + return err + } + return b.Delete(ctx, key) +} + +// Read reads the blob at key for the given field. +// Returns nil, nil if the blob does not exist (fs.ErrNotExist). +func (s *BlobStore) Read(ctx context.Context, field, key string) ([]byte, error) { + b, err := s.bucket(ctx, field) + if err != nil { + return nil, err + } + rc, err := b.NewReader(ctx, key) + if errors.Is(err, fs.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + data, err := io.ReadAll(rc) + if closeErr := rc.Close(); closeErr != nil && err == nil { + err = closeErr + } + return data, err +} + +func (s *BlobStore) bucket(ctx context.Context, field string) (Blob, error) { + if b, ok := s.buckets[field]; ok { + return b, nil + } + if s.opener == nil { + return nil, errors.New("ent: blob storage not configured (missing WithBlobOpeners)") + } + b, err := s.opener(ctx, field) + if err != nil { + return nil, err + } + s.buckets[field] = b + return b, nil +} diff --git a/cmd/ent/ent.go b/cmd/ent/ent.go new file mode 100644 index 0000000..8d347b5 --- /dev/null +++ b/cmd/ent/ent.go @@ -0,0 +1,26 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package main + +import ( + "log" + + "entgo.io/ent/cmd/internal/base" + + "github.com/spf13/cobra" +) + +func main() { + log.SetFlags(0) + cmd := &cobra.Command{Use: "ent"} + cmd.AddCommand( + base.NewCmd(), + base.DescribeCmd(), + base.GenerateCmd(), + base.InitCmd(), + base.SchemaCmd(), + ) + cobra.CheckErr(cmd.Execute()) +} diff --git a/cmd/ent/ent_test.go b/cmd/ent/ent_test.go new file mode 100644 index 0000000..4d7294a --- /dev/null +++ b/cmd/ent/ent_test.go @@ -0,0 +1,39 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package main + +import ( + "bytes" + "os" + "os/exec" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCmd(t *testing.T) { + defer os.RemoveAll("ent") + cmd := exec.Command("go", "run", "entgo.io/ent/cmd/ent", "new", "User") + stderr := bytes.NewBuffer(nil) + cmd.Stderr = stderr + require.NoError(t, cmd.Run()) + require.Zero(t, stderr.String()) + cmd = exec.Command("go", "run", "entgo.io/ent/cmd/ent", "new", "User") + require.Error(t, cmd.Run()) + + _, err := os.Stat("ent/generate.go") + require.NoError(t, err) + _, err = os.Stat("ent/schema/user.go") + require.NoError(t, err) + + cmd = exec.Command("go", "run", "entgo.io/ent/cmd/ent", "generate", "./ent/schema") + stderr = bytes.NewBuffer(nil) + cmd.Stderr = stderr + require.NoError(t, cmd.Run()) + require.Zero(t, stderr.String()) + + _, err = os.Stat("ent/user.go") + require.NoError(t, err) +} diff --git a/cmd/entc/entc.go b/cmd/entc/entc.go new file mode 100644 index 0000000..38b4e5b --- /dev/null +++ b/cmd/entc/entc.go @@ -0,0 +1,41 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package main + +import ( + "bytes" + "log" + "os" + "path/filepath" + + "entgo.io/ent/cmd/internal/base" + "entgo.io/ent/entc/gen" + + "github.com/spf13/cobra" +) + +func main() { + log.SetFlags(0) + cmd := &cobra.Command{Use: "entc"} + cmd.AddCommand( + base.NewCmd(), + base.DescribeCmd(), + base.GenerateCmd(migrate), + base.InitCmd(), + ) + _ = cmd.Execute() +} + +func migrate(c *gen.Config) { + var ( + target = filepath.Join(c.Target, "generate.go") + oldCmd = []byte("entgo.io/ent/cmd/entc") + ) + buf, err := os.ReadFile(target) + if err != nil || !bytes.Contains(buf, oldCmd) { + return + } + _ = os.WriteFile(target, bytes.ReplaceAll(buf, oldCmd, []byte("entgo.io/ent/cmd/ent")), 0644) +} diff --git a/cmd/entc/entc_test.go b/cmd/entc/entc_test.go new file mode 100644 index 0000000..d01ee3d --- /dev/null +++ b/cmd/entc/entc_test.go @@ -0,0 +1,35 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package main + +import ( + "bytes" + "os" + "os/exec" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCmd(t *testing.T) { + defer os.RemoveAll("ent") + cmd := exec.Command("go", "run", "entgo.io/ent/cmd/entc", "new", "User") + stderr := bytes.NewBuffer(nil) + cmd.Stderr = stderr + require.NoError(t, cmd.Run(), stderr.String()) + + _, err := os.Stat("ent/generate.go") + require.NoError(t, err) + _, err = os.Stat("ent/schema/user.go") + require.NoError(t, err) + + cmd = exec.Command("go", "run", "entgo.io/ent/cmd/entc", "generate", "./ent/schema") + stderr = bytes.NewBuffer(nil) + cmd.Stderr = stderr + require.NoError(t, cmd.Run(), stderr.String()) + + _, err = os.Stat("ent/user.go") + require.NoError(t, err) +} diff --git a/cmd/entfix/entfix.go b/cmd/entfix/entfix.go new file mode 100644 index 0000000..6ee88cf --- /dev/null +++ b/cmd/entfix/entfix.go @@ -0,0 +1,107 @@ +package main + +import ( + "bufio" + "context" + "errors" + "fmt" + "os" + "os/signal" + "strings" + "syscall" + + _ "github.com/go-sql-driver/mysql" + _ "github.com/lib/pq" + _ "github.com/mattn/go-sqlite3" + + "entgo.io/ent/dialect/sql" + "entgo.io/ent/dialect/sql/schema" + "entgo.io/ent/entc/gen" + "github.com/alecthomas/kong" +) + +type ( + // App configures the entfix CLI. + App struct { + GlobalID GlobalID `cmd:"" name:"globalid" help:"Migrate unique global id ent_types to ent global feature"` + } + // GlobalID represents the 'entfix globalid' command. + GlobalID struct { + Dialect string `name:"dialect" help:"Database dialect" required:"" enum:"mysql,postgres,sqlite3"` + DSN string `name:"dsn" help:"Data source name" required:""` + Path string `name:"path" help:"Path to the generated ent code" required:""` + } +) + +func main() { + // Ensure to stop execution on Interrupt signal. + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) + defer stop() + app := kong.Parse( + new(App), + kong.BindTo(ctx, (*context.Context)(nil)), + kong.UsageOnError(), + ) + app.FatalIfErrorf(func() error { + err := app.Run() + if err := context.Cause(ctx); err != nil { + return err + } + if errors.Is(err, context.Canceled) { + return nil + } + return err + }()) +} + +func (cmd *GlobalID) Run(ctx context.Context) error { + fmt.Print(`IMPORTANT INFORMATION + + 'entfix globalid' will convert the allocated id ranges for your nodes from the + database stored 'ent_types' table to the new static configuration on the ent + schema itself. + + Please note, that the 'ent_types' table might differ between different environments + where your app is deployed. This is especially true if you are using + auto-migration instead of versioned migrations. + + Please check, that all 'ent_types' tables for all deployments are equal! + + Only 'yes' will be accepted to approve. + + Enter a value: `) + switch c, err := bufio.NewReader(os.Stdin).ReadString('\n'); { + case err != nil: + return err + case strings.TrimSpace(c) != "yes": + fmt.Println("\nAborted.") + return nil + } + db, err := sql.Open(cmd.Dialect, cmd.DSN) + if err != nil { + return err + } + rows := &sql.Rows{} + query, args := sql.Dialect(cmd.Dialect). + Select("type"). + From(sql.Table(schema.TypeTable)). + OrderBy(sql.Asc("id")). + Query() + if err := db.Query(ctx, query, args, rows); err != nil { + return err + } + defer rows.Close() + var ts []string + if err := sql.ScanSlice(rows, &ts); err != nil { + return err + } + is := make(gen.IncrementStarts, len(ts)) + for i, t := range ts { + is[t] = int64(i << 32) + } + if err := is.WriteToDisk(cmd.Path); err != nil { + return err + } + fmt.Println("\nSuccess! Please run code generation to complete the process.") + return nil +} diff --git a/cmd/entfix/entfix_test.go b/cmd/entfix/entfix_test.go new file mode 100644 index 0000000..6218522 --- /dev/null +++ b/cmd/entfix/entfix_test.go @@ -0,0 +1,89 @@ +package main + +import ( + "context" + "database/sql" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "entgo.io/ent/dialect" + entsql "entgo.io/ent/dialect/sql" + "entgo.io/ent/dialect/sql/schema" + "github.com/stretchr/testify/require" + + _ "github.com/mattn/go-sqlite3" +) + +func TestEntfix(t *testing.T) { + ctx := context.Background() + stdout, err := os.CreateTemp(t.TempDir(), "") + require.NoError(t, err) + oldStdout := os.Stdout + os.Stdout = stdout + t.Cleanup(func() { + os.Stdout = oldStdout + require.NoError(t, stdout.Close()) + }) + + cli := &GlobalID{ + Dialect: dialect.SQLite, + DSN: fmt.Sprintf("file:%s?mode=memory&cache=shared&_fk=1", t.Name()), + Path: t.TempDir(), + } + + // Prints details, requires confirmation. + os.Stdin = mockStdin(t, "no\n") + require.NoError(t, cli.Run(ctx)) + buf, err := os.ReadFile(stdout.Name()) + require.NoError(t, err) + require.True(t, strings.HasPrefix(string(buf), "IMPORTANT INFORMATION\n\n")) + require.True(t, strings.HasSuffix(string(buf), "Aborted.\n")) + f, err := os.Open(cli.Path) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, f.Close()) }) + _, err = f.Readdirnames(1) + require.ErrorIs(t, err, io.EOF) + + // If approved, converts the 'ent_types' table. + db, err := sql.Open(cli.Dialect, cli.DSN) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, db.Close()) }) + drv := entsql.OpenDB(dialect.SQLite, db) + m, err := schema.NewMigrate(drv, schema.WithGlobalUniqueID(true)) + require.NoError(t, err) + require.NoError(t, m.Create(ctx)) + _, err = db.ExecContext(ctx, fmt.Sprintf("INSERT INTO %s (type) VALUES ('z'), ('y'), ('a'), ('b')", schema.TypeTable)) + require.NoError(t, err) + + os.Stdin = mockStdin(t, "yes\n") + require.NoError(t, cli.Run(ctx)) + buf, err = os.ReadFile(stdout.Name()) + require.NoError(t, err) + require.True(t, strings.HasSuffix(string(buf), "Success! Please run code generation to complete the process.\n")) + c, err := os.ReadFile(filepath.Join(cli.Path, "internal", "globalid.go")) + require.NoError(t, err) + require.Contains(t, + string(c), + fmt.Sprintf(`const IncrementStarts = "{\"a\":%d,\"b\":%d,\"y\":%d,\"z\":%d}"`, 2<<32, 3<<32, 1<<32, 0), + ) +} + +func mockStdin(t *testing.T, content string) *os.File { + t.Helper() + stdin, err := os.CreateTemp(t.TempDir(), "") + require.NoError(t, err) + _, err = stdin.WriteString(content) + require.NoError(t, err) + _, err = stdin.Seek(0, 0) + require.NoError(t, err) + oldStdin := os.Stdin + t.Cleanup(func() { + os.Stdin = oldStdin + require.NoError(t, stdin.Close()) + }) + return stdin +} diff --git a/cmd/entfix/go.mod b/cmd/entfix/go.mod new file mode 100644 index 0000000..5abfab5 --- /dev/null +++ b/cmd/entfix/go.mod @@ -0,0 +1,32 @@ +module entgo.io/ent/cmd/entfix + +go 1.23 + +require ( + entgo.io/ent v0.14.2-0.20250119141711-a0182c96ebb6 + github.com/alecthomas/kong v1.6.1 + github.com/go-sql-driver/mysql v1.8.1 + github.com/lib/pq v1.10.9 + github.com/mattn/go-sqlite3 v1.14.24 + github.com/stretchr/testify v1.8.2 +) + +require ( + ariga.io/atlas v0.27.1-0.20240912191503-92195304dbe1 // indirect + filippo.io/edwards25519 v1.1.0 // indirect + github.com/agext/levenshtein v1.2.3 // indirect + github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/go-openapi/inflect v0.19.0 // indirect + github.com/google/go-cmp v0.6.0 // indirect + github.com/google/uuid v1.3.0 // indirect + github.com/hashicorp/hcl/v2 v2.23.0 // indirect + github.com/mitchellh/go-wordwrap v1.0.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/zclconf/go-cty v1.14.4 // indirect + golang.org/x/mod v0.20.0 // indirect + golang.org/x/sync v0.8.0 // indirect + golang.org/x/text v0.14.0 // indirect + golang.org/x/tools v0.24.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/cmd/entfix/go.sum b/cmd/entfix/go.sum new file mode 100644 index 0000000..7f95cc8 --- /dev/null +++ b/cmd/entfix/go.sum @@ -0,0 +1,67 @@ +ariga.io/atlas v0.27.1-0.20240912191503-92195304dbe1 h1:tqUmxmE7r2qqWmgczyNZNOyhzXLqmdUpRhVSuvCUqTU= +ariga.io/atlas v0.27.1-0.20240912191503-92195304dbe1/go.mod h1:cpxrs2J9HTrjMrfNKFmUpONY9L0vzAHZ1pY++nTtVw0= +entgo.io/ent v0.14.2-0.20250119141711-a0182c96ebb6 h1:7H+NesJeDdcNtZxp0s5Ip/IYLkuUfoRFTIAC0fGcezg= +entgo.io/ent v0.14.2-0.20250119141711-a0182c96ebb6/go.mod h1:nPwpHTcyAjBMt+hT6q7Y/SdF41e6IJudQPbTj1P5nYY= +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60= +github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= +github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo= +github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/kong v1.6.1 h1:/7bVimARU3uxPD0hbryPE8qWrS3Oz3kPQoxA/H2NKG8= +github.com/alecthomas/kong v1.6.1/go.mod h1:p2vqieVMeTAnaC83txKtXe8FLke2X07aruPWXyMPQrU= +github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= +github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= +github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-openapi/inflect v0.19.0 h1:9jCH9scKIbHeV9m12SmPilScz6krDxKRasNNSNPXu/4= +github.com/go-openapi/inflect v0.19.0/go.mod h1:lHpZVlpIQqLyKwJ4N+YSc9hchQy/i12fJykb83CRBH4= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68= +github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/hcl/v2 v2.23.0 h1:Fphj1/gCylPxHutVSEOf2fBOh1VE4AuLV7+kbJf3qos= +github.com/hashicorp/hcl/v2 v2.23.0/go.mod h1:62ZYHrXgPoX8xBnzl8QzbWq4dyDsDtfCRgIq1rbJEvA= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= +github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= +github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/zclconf/go-cty v1.14.4 h1:uXXczd9QDGsgu0i/QFR/hzI5NYCHLf6NQw/atrbnhq8= +github.com/zclconf/go-cty v1.14.4/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= +github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo= +github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM= +golang.org/x/mod v0.20.0 h1:utOm6MM3R3dnawAiJgn0y+xvuYRsm1RKM/4giyfDgV0= +golang.org/x/mod v0.20.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/tools v0.24.0 h1:J1shsA93PJUEVaUSaay7UXAyE8aimq3GW0pjlolpa24= +golang.org/x/tools v0.24.0/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/cmd/internal/base/base.go b/cmd/internal/base/base.go new file mode 100644 index 0000000..8eff8b0 --- /dev/null +++ b/cmd/internal/base/base.go @@ -0,0 +1,360 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +// Package base defines shared basic pieces of the ent command. +package base + +import ( + "bytes" + "errors" + "fmt" + "log" + "os" + "path/filepath" + "strings" + "text/template" + "unicode" + + "entgo.io/ent/cmd/internal/printer" + "entgo.io/ent/dialect/sql/schema" + "entgo.io/ent/entc" + "entgo.io/ent/entc/gen" + "entgo.io/ent/schema/field" + + "github.com/spf13/cobra" +) + +// IDType is a custom ID implementation for pflag. +type IDType field.Type + +// Set implements the Set method of the flag.Value interface. +func (t *IDType) Set(s string) error { + switch s { + case field.TypeInt.String(): + *t = IDType(field.TypeInt) + case field.TypeInt64.String(): + *t = IDType(field.TypeInt64) + case field.TypeUint.String(): + *t = IDType(field.TypeUint) + case field.TypeUint64.String(): + *t = IDType(field.TypeUint64) + case field.TypeString.String(): + *t = IDType(field.TypeString) + default: + return fmt.Errorf("invalid type %q", s) + } + return nil +} + +// Type returns the type representation of the id option for help command. +func (IDType) Type() string { + return fmt.Sprintf("%v", []field.Type{ + field.TypeInt, + field.TypeInt64, + field.TypeUint, + field.TypeUint64, + field.TypeString, + }) +} + +// String returns the default value for the help command. +func (IDType) String() string { + return field.TypeInt.String() +} + +// InitCmd returns the init command for ent/c packages. +func InitCmd() *cobra.Command { + c := NewCmd() + c.Use = "init [flags] [schemas]" + c.Short = "initialize an environment with zero or more schemas" + c.Example = examples( + "ent init Example", + "ent init --target entv1/schema User Group", + "ent init --template ./path/to/file.tmpl User", + ) + c.Deprecated = `use "ent new" instead` + return c +} + +// NewCmd returns the new command for ent/c packages. +func NewCmd() *cobra.Command { + var target, tmplPath string + cmd := &cobra.Command{ + Use: "new [flags] [schemas]", + Short: "initialize a new environment with zero or more schemas", + Example: examples( + "ent new Example", + "ent new --target entv1/schema User Group", + "ent new --template ./path/to/file.tmpl User", + ), + Args: func(_ *cobra.Command, names []string) error { + for _, name := range names { + if !unicode.IsUpper(rune(name[0])) { + return errors.New("schema names must begin with uppercase") + } + } + return nil + }, + Run: func(cmd *cobra.Command, names []string) { + var ( + err error + tmpl *template.Template + ) + if tmplPath != "" { + tmpl = template.New(filepath.Base(tmplPath)).Funcs(gen.Funcs) + tmpl, err = tmpl.ParseFiles(tmplPath) + } else { + tmpl = template.New("schema").Funcs(gen.Funcs) + tmpl, err = tmpl.Parse(defaultTemplate) + } + if err != nil { + log.Fatalln(fmt.Errorf("ent/new: could not parse template %w", err)) + } + if err := newEnv(target, names, tmpl); err != nil { + log.Fatalln(fmt.Errorf("ent/new: %w", err)) + } + }, + } + cmd.Flags().StringVar(&target, "target", defaultSchema, "target directory for schemas") + cmd.Flags().StringVar(&tmplPath, "template", "", "template to use for new schemas") + return cmd +} + +// DescribeCmd returns the describe command for ent/c packages. +func DescribeCmd() *cobra.Command { + return &cobra.Command{ + Use: "describe [flags] path", + Short: "print a description of the graph schema", + Example: examples( + "ent describe ./ent/schema", + "ent describe github.com/a8m/x", + ), + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, path []string) { + graph, err := entc.LoadGraph(path[0], &gen.Config{}) + if err != nil { + log.Fatalln(err) + } + printer.Fprint(os.Stdout, graph) + }, + } +} + +// GenerateCmd returns the generate command for ent/c packages. +func GenerateCmd(postRun ...func(*gen.Config)) *cobra.Command { + var ( + cfg gen.Config + storage string + features []string + templates []string + idtype = IDType(field.TypeInt) + cmd = &cobra.Command{ + Use: "generate [flags] path", + Short: "generate go code for the schema directory", + Example: examples( + "ent generate ./ent/schema", + "ent generate github.com/a8m/x", + ), + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, path []string) { + opts := []entc.Option{ + entc.Storage(storage), + entc.FeatureNames(features...), + } + for _, tmpl := range templates { + typ := "dir" + if parts := strings.SplitN(tmpl, "=", 2); len(parts) > 1 { + typ, tmpl = parts[0], parts[1] + } + switch typ { + case "dir": + opts = append(opts, entc.TemplateDir(tmpl)) + case "file": + opts = append(opts, entc.TemplateFiles(tmpl)) + case "glob": + opts = append(opts, entc.TemplateGlob(tmpl)) + default: + log.Fatalln("unsupported template type", typ) + } + } + // If the target directory is not inferred from + // the schema path, resolve its package path. + if cfg.Target != "" { + pkgPath, err := PkgPath(DefaultConfig, cfg.Target) + if err != nil { + log.Fatalln(err) + } + cfg.Package = pkgPath + } + cfg.IDType = &field.TypeInfo{Type: field.Type(idtype)} + if err := entc.Generate(path[0], &cfg, opts...); err != nil { + log.Fatalln(err) + } + for _, fn := range postRun { + fn(&cfg) + } + }, + } + ) + cmd.Flags().Var(&idtype, "idtype", "type of the id field") + cmd.Flags().StringVar(&storage, "storage", "sql", "storage driver to support in codegen") + cmd.Flags().StringVar(&cfg.Header, "header", "", "override codegen header") + cmd.Flags().StringVar(&cfg.Target, "target", "", "target directory for codegen") + cmd.Flags().StringSliceVarP(&features, "feature", "", nil, "extend codegen with additional features") + cmd.Flags().StringSliceVarP(&templates, "template", "", nil, "external templates to execute") + // The --idtype flag predates the field.("id") option. + // See, https://entgo.io/docs/schema-fields#id-field. + cobra.CheckErr(cmd.Flags().MarkHidden("idtype")) + return cmd +} + +// SchemaCmd returns DDL to use Ent as an Atlas schema loader. +func SchemaCmd() *cobra.Command { + var ( + cfg gen.Config + dlct, version string + features, buildTags []string + hashSymbols bool + cmd = &cobra.Command{ + Use: "schema [flags] path", + Short: "dump the DDL for the schema directory", + Example: examples( + "ent schema ./ent/schema --dialect mysql --version 5.6", + "ent schema ./ent/schema --dialect sqlite3", + "ent schema github.com/a8m/x --dialect postgres --version 15", + ), + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, path []string) { + for _, o := range []entc.Option{ + entc.FeatureNames(features...), + entc.BuildTags(buildTags...), + } { + if err := o(&cfg); err != nil { + log.Fatalln(err) + } + } + // If the target directory is not inferred from + // the schema path, resolve its package path. + if cfg.Target != "" { + pkgPath, err := PkgPath(DefaultConfig, cfg.Target) + if err != nil { + log.Fatalln(err) + } + cfg.Package = pkgPath + } + g, err := entc.LoadGraph(path[0], &cfg) + if err != nil { + log.Fatalln(err) + } + t, err := g.Tables() + if err != nil { + log.Fatalln(err) + } + v, err := g.Views() + if err != nil { + log.Fatalln(err) + } + ddl, err := schema.DDL(cmd.Context(), schema.DDLArgs{ + Dialect: dlct, + Version: version, + HashSymbols: hashSymbols, + Tables: append(t, v...), + }) + if err != nil { + log.Fatalln(err) + } + fmt.Println(ddl) + }, + } + ) + cmd.Flags().StringVar(&dlct, "dialect", "", "database dialect to use") + cmd.Flags().StringVar(&version, "version", "", "database version to assume") + cmd.Flags().StringSliceVarP(&features, "feature", "", nil, "extend codegen with additional features") + cmd.Flags().StringSliceVarP(&buildTags, "build-tags", "", nil, "go build tags to use when loading the schema graph") + cmd.Flags().BoolVar(&hashSymbols, "hash-symbols", false, "whether to hash long symbols") + cobra.CheckErr(cmd.MarkFlagRequired("dialect")) + return cmd +} + +// newEnv create a new environment for ent codegen. +func newEnv(target string, names []string, tmpl *template.Template) error { + if err := createDir(target); err != nil { + return fmt.Errorf("create dir %s: %w", target, err) + } + for _, name := range names { + if err := gen.ValidSchemaName(name); err != nil { + return fmt.Errorf("new schema %s: %w", name, err) + } + if fileExists(target, name) { + return fmt.Errorf("new schema %s: already exists", name) + } + b := bytes.NewBuffer(nil) + if err := tmpl.Execute(b, name); err != nil { + return fmt.Errorf("executing template %s: %w", name, err) + } + newFileTarget := filepath.Join(target, strings.ToLower(name+".go")) + if err := os.WriteFile(newFileTarget, b.Bytes(), 0644); err != nil { + return fmt.Errorf("writing file %s: %w", newFileTarget, err) + } + } + return nil +} + +func createDir(target string) error { + _, err := os.Stat(target) + if err == nil || !os.IsNotExist(err) { + return err + } + if err := os.MkdirAll(target, os.ModePerm); err != nil { + return fmt.Errorf("creating schema directory: %w", err) + } + if target != defaultSchema { + return nil + } + if err := os.WriteFile("ent/generate.go", []byte(genFile), 0644); err != nil { + return fmt.Errorf("creating generate.go file: %w", err) + } + return nil +} + +func fileExists(target, name string) bool { + var _, err = os.Stat(filepath.Join(target, strings.ToLower(name+".go"))) + + return err == nil +} + +const ( + // default schema package path. + defaultSchema = "ent/schema" + // ent/generate.go file used for "go generate" command. + genFile = "package ent\n\n//go:generate go run -mod=mod entgo.io/ent/cmd/ent generate ./schema\n" + // schema template for the "init" command. + defaultTemplate = `package schema + +import "entgo.io/ent" + +// {{ . }} holds the schema definition for the {{ . }} entity. +type {{ . }} struct { + ent.Schema +} + +// Fields of the {{ . }}. +func ({{ . }}) Fields() []ent.Field { + return nil +} + +// Edges of the {{ . }}. +func ({{ . }}) Edges() []ent.Edge { + return nil +} +` +) + +// examples formats the given examples to the cli. +func examples(ex ...string) string { + for i := range ex { + ex[i] = " " + ex[i] // indent each row with 2 spaces. + } + return strings.Join(ex, "\n") +} diff --git a/cmd/internal/base/packages.go b/cmd/internal/base/packages.go new file mode 100644 index 0000000..52323ce --- /dev/null +++ b/cmd/internal/base/packages.go @@ -0,0 +1,55 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package base + +import ( + "fmt" + "os" + "path" + "path/filepath" + + "golang.org/x/tools/go/packages" +) + +// DefaultConfig for loading Go base. +var DefaultConfig = &packages.Config{Mode: packages.NeedName} + +// PkgPath returns the Go package name for given target path. +// Even if the existing path is not exist yet in the filesystem. +// +// If base.Config is nil, DefaultConfig will be used to load base. +func PkgPath(config *packages.Config, target string) (string, error) { + if config == nil { + config = DefaultConfig + } + pathCheck, err := filepath.Abs(target) + if err != nil { + return "", err + } + var parts []string + if _, err := os.Stat(pathCheck); os.IsNotExist(err) { + parts = append(parts, filepath.Base(pathCheck)) + pathCheck = filepath.Dir(pathCheck) + } + // Try maximum 2 directories above the given + // target to find the root package or module. + for i := 0; i < 2; i++ { + pkgs, err := packages.Load(config, pathCheck) + if err != nil { + return "", fmt.Errorf("load package info: %w", err) + } + if len(pkgs) == 0 || len(pkgs[0].Errors) != 0 { + parts = append(parts, filepath.Base(pathCheck)) + pathCheck = filepath.Dir(pathCheck) + continue + } + pkgPath := pkgs[0].PkgPath + for j := len(parts) - 1; j >= 0; j-- { + pkgPath = path.Join(pkgPath, parts[j]) + } + return pkgPath, nil + } + return "", fmt.Errorf("root package or module was not found for: %s", target) +} diff --git a/cmd/internal/base/packages_test.go b/cmd/internal/base/packages_test.go new file mode 100644 index 0000000..cbc2879 --- /dev/null +++ b/cmd/internal/base/packages_test.go @@ -0,0 +1,49 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package base + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + "golang.org/x/tools/go/packages/packagestest" +) + +func TestPkgPath(t *testing.T) { packagestest.TestAll(t, testPkgPath) } +func testPkgPath(t *testing.T, x packagestest.Exporter) { + e := packagestest.Export(t, x, []packagestest.Module{ + { + Name: "golang.org/x", + Files: map[string]any{ + "x.go": "package x", + "y/y.go": "package y", + }, + }, + }) + defer e.Cleanup() + + e.Config.Dir = filepath.Dir(e.File("golang.org/x", "x.go")) + target := filepath.Join(e.Config.Dir, "ent") + pkgPath, err := PkgPath(e.Config, target) + require.NoError(t, err) + require.Equal(t, "golang.org/x/ent", pkgPath) + + e.Config.Dir = filepath.Dir(e.File("golang.org/x", "y/y.go")) + target = filepath.Join(e.Config.Dir, "ent") + pkgPath, err = PkgPath(e.Config, target) + require.NoError(t, err) + require.Equal(t, "golang.org/x/y/ent", pkgPath) + + target = filepath.Join(e.Config.Dir, "z/ent") + pkgPath, err = PkgPath(e.Config, target) + require.NoError(t, err) + require.Equal(t, "golang.org/x/y/z/ent", pkgPath) + + target = filepath.Join(e.Config.Dir, "z/e/n/t") + pkgPath, err = PkgPath(e.Config, target) + require.Error(t, err) + require.Empty(t, pkgPath) +} diff --git a/cmd/internal/printer/printer.go b/cmd/internal/printer/printer.go new file mode 100644 index 0000000..ceb3446 --- /dev/null +++ b/cmd/internal/printer/printer.go @@ -0,0 +1,141 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package printer + +import ( + "fmt" + "io" + "reflect" + "strconv" + "strings" + + "entgo.io/ent/entc/gen" + + "github.com/olekukonko/tablewriter" + "github.com/olekukonko/tablewriter/tw" +) + +// A Config controls the output of Fprint. +type Config struct { + io.Writer +} + +// Print prints a table description of the graph to the given writer. +func (p Config) Print(g *gen.Graph) { + for _, n := range g.Nodes { + p.node(n) + } +} + +// Fprint executes "pretty-printer" on the given writer. +func Fprint(w io.Writer, g *gen.Graph) { + Config{Writer: w}.Print(g) +} + +// node returns description of a type. The format of the description is: +// +// Type: +// +// +// +func (p Config) node(t *gen.Type) { + var ( + b strings.Builder + id []*gen.Field + table = tablewriter.NewWriter(&b) + header = []string{"Field", "Type", "Unique", "Optional", "Nillable", "Default", "UpdateDefault", "Immutable", "StructTag", "Validators", "Comment"} + ) + b.WriteString(t.Name + ":\n") + table.Options( + tablewriter.WithHeaderConfig(tw.CellConfig{ + Padding: tw.CellPadding{ + Global: tw.Padding{ + Left: tw.Space, + Right: tw.Space, + }, + }, + Formatting: tw.CellFormatting{ + AutoFormat: tw.Off, + }, + }), + tablewriter.WithRendition(tw.Rendition{ + Symbols: tw.NewSymbols(tw.StyleASCII), + }), + ) + table.Header(header) + var alignment = make([]tw.Align, 0) + if t.ID != nil { + id = append(id, t.ID) + } + for _, f := range append(id, t.Fields...) { + v := reflect.ValueOf(*f) + row := make([]string, len(header)) + for i := 0; i < len(row)-1; i++ { + field := v.FieldByNameFunc(func(name string) bool { + // The first field is mapped from "Name" to "Field". + return name == "Name" && i == 0 || name == header[i] + }) + row[i] = fmt.Sprint(field.Interface()) + _, err := strconv.Atoi(row[i]) + if err == nil { + alignment = append(alignment, tw.AlignRight) + } else { + alignment = append(alignment, tw.AlignLeft) + } + } + row[len(row)-1] = f.Comment() + _ = table.Append(row) + table.Options( + tablewriter.WithRowAlignmentConfig( + tw.CellAlignment{PerColumn: alignment}, + ), + ) + } + err := table.Render() + if err != nil { + return + } + // Create new table for edges + table = tablewriter.NewWriter(&b) + table.Options( + tablewriter.WithHeaderConfig(tw.CellConfig{ + Formatting: tw.CellFormatting{AutoFormat: tw.Off}, + Padding: tw.CellPadding{ + Global: tw.Padding{ + Left: tw.Space, + Right: tw.Space, + }, + }, + }), + tablewriter.WithRendition(tw.Rendition{ + Symbols: tw.NewSymbols(tw.StyleASCII), + }), + ) + table.Header([]string{"Edge", "Type", "Inverse", "BackRef", "Relation", "Unique", "Optional", "Comment"}) + hasEdges := false + for _, e := range t.Edges { + hasEdges = true + err := table.Append([]string{ + e.Name, + e.Type.Name, + strconv.FormatBool(e.IsInverse()), + e.Inverse, + e.Rel.Type.String(), + strconv.FormatBool(e.Unique), + strconv.FormatBool(e.Optional), + e.Comment(), + }) + if err != nil { + return + } + } + if hasEdges { + err := table.Render() + if err != nil { + return + } + } + io.WriteString(p, strings.ReplaceAll(b.String(), "\n", "\n\t")+"\n") +} diff --git a/cmd/internal/printer/printer_test.go b/cmd/internal/printer/printer_test.go new file mode 100644 index 0000000..8c4b4c8 --- /dev/null +++ b/cmd/internal/printer/printer_test.go @@ -0,0 +1,177 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package printer + +import ( + "strings" + "testing" + + "entgo.io/ent/entc/gen" + "entgo.io/ent/schema/field" + + "github.com/stretchr/testify/assert" +) + +func TestPrinter_Print(t *testing.T) { + tests := []struct { + input *gen.Graph + out string + }{ + { + input: &gen.Graph{ + Nodes: []*gen.Type{ + { + Name: "User", + ID: &gen.Field{Name: "id", Type: &field.TypeInfo{Type: field.TypeInt}}, + Fields: []*gen.Field{ + {Name: "name", Type: &field.TypeInfo{Type: field.TypeString}, Validators: 1}, + {Name: "age", Type: &field.TypeInfo{Type: field.TypeInt}, Nillable: true}, + {Name: "created_at", Type: &field.TypeInfo{Type: field.TypeTime}, Nillable: true, Immutable: true}, + }, + }, + }, + }, + out: ` +User: + +------------+-----------+--------+----------+----------+---------+---------------+-----------+-----------+------------+---------+ + | Field | Type | Unique | Optional | Nillable | Default | UpdateDefault | Immutable | StructTag | Validators | Comment | + +------------+-----------+--------+----------+----------+---------+---------------+-----------+-----------+------------+---------+ + | id | int | false | false | false | false | false | false | | 0 | | + | name | string | false | false | false | false | false | false | | 1 | | + | age | int | false | false | true | false | false | false | | 0 | | + | created_at | time.Time | false | false | true | false | false | true | | 0 | | + +------------+-----------+--------+----------+----------+---------+---------------+-----------+-----------+------------+---------+ + +`, + }, + { + input: &gen.Graph{ + Nodes: []*gen.Type{ + { + Name: "User", + ID: &gen.Field{Name: "id", Type: &field.TypeInfo{Type: field.TypeInt}}, + Edges: []*gen.Edge{ + {Name: "groups", Type: &gen.Type{Name: "Group"}, Rel: gen.Relation{Type: gen.M2M}, Optional: true}, + {Name: "spouse", Type: &gen.Type{Name: "User"}, Unique: true, Rel: gen.Relation{Type: gen.O2O}}, + }, + }, + }, + }, + out: ` +User: + +-------+------+--------+----------+----------+---------+---------------+-----------+-----------+------------+---------+ + | Field | Type | Unique | Optional | Nillable | Default | UpdateDefault | Immutable | StructTag | Validators | Comment | + +-------+------+--------+----------+----------+---------+---------------+-----------+-----------+------------+---------+ + | id | int | false | false | false | false | false | false | | 0 | | + +-------+------+--------+----------+----------+---------+---------------+-----------+-----------+------------+---------+ + +--------+-------+---------+---------+----------+--------+----------+---------+ + | Edge | Type | Inverse | BackRef | Relation | Unique | Optional | Comment | + +--------+-------+---------+---------+----------+--------+----------+---------+ + | groups | Group | false | | M2M | false | true | | + | spouse | User | false | | O2O | true | false | | + +--------+-------+---------+---------+----------+--------+----------+---------+ + +`, + }, + { + input: &gen.Graph{ + Nodes: []*gen.Type{ + { + Name: "User", + ID: &gen.Field{Name: "id", Type: &field.TypeInfo{Type: field.TypeInt}}, + Fields: []*gen.Field{ + {Name: "name", Type: &field.TypeInfo{Type: field.TypeString}, Validators: 1}, + {Name: "age", Type: &field.TypeInfo{Type: field.TypeInt}, Nillable: true}, + }, + Edges: []*gen.Edge{ + {Name: "groups", Type: &gen.Type{Name: "Group"}, Rel: gen.Relation{Type: gen.M2M}, Optional: true}, + {Name: "spouse", Type: &gen.Type{Name: "User"}, Unique: true, Rel: gen.Relation{Type: gen.O2O}}, + }, + }, + }, + }, + out: ` +User: + +-------+--------+--------+----------+----------+---------+---------------+-----------+-----------+------------+---------+ + | Field | Type | Unique | Optional | Nillable | Default | UpdateDefault | Immutable | StructTag | Validators | Comment | + +-------+--------+--------+----------+----------+---------+---------------+-----------+-----------+------------+---------+ + | id | int | false | false | false | false | false | false | | 0 | | + | name | string | false | false | false | false | false | false | | 1 | | + | age | int | false | false | true | false | false | false | | 0 | | + +-------+--------+--------+----------+----------+---------+---------------+-----------+-----------+------------+---------+ + +--------+-------+---------+---------+----------+--------+----------+---------+ + | Edge | Type | Inverse | BackRef | Relation | Unique | Optional | Comment | + +--------+-------+---------+---------+----------+--------+----------+---------+ + | groups | Group | false | | M2M | false | true | | + | spouse | User | false | | O2O | true | false | | + +--------+-------+---------+---------+----------+--------+----------+---------+ + +`, + }, + { + input: &gen.Graph{ + Nodes: []*gen.Type{ + { + Name: "User", + ID: &gen.Field{Name: "id", Type: &field.TypeInfo{Type: field.TypeInt}}, + Fields: []*gen.Field{ + {Name: "name", Type: &field.TypeInfo{Type: field.TypeString}, Validators: 1}, + {Name: "age", Type: &field.TypeInfo{Type: field.TypeInt}, Nillable: true}, + }, + Edges: []*gen.Edge{ + {Name: "groups", Type: &gen.Type{Name: "Group"}, Rel: gen.Relation{Type: gen.M2M}, Optional: true}, + {Name: "spouse", Type: &gen.Type{Name: "User"}, Unique: true, Rel: gen.Relation{Type: gen.O2O}}, + }, + }, + { + Name: "Group", + ID: &gen.Field{Name: "id", Type: &field.TypeInfo{Type: field.TypeInt}}, + Fields: []*gen.Field{ + {Name: "name", Type: &field.TypeInfo{Type: field.TypeString}}, + }, + Edges: []*gen.Edge{ + {Name: "users", Type: &gen.Type{Name: "User"}, Rel: gen.Relation{Type: gen.M2M}, Optional: true}, + }, + }, + }, + }, + out: ` +User: + +-------+--------+--------+----------+----------+---------+---------------+-----------+-----------+------------+---------+ + | Field | Type | Unique | Optional | Nillable | Default | UpdateDefault | Immutable | StructTag | Validators | Comment | + +-------+--------+--------+----------+----------+---------+---------------+-----------+-----------+------------+---------+ + | id | int | false | false | false | false | false | false | | 0 | | + | name | string | false | false | false | false | false | false | | 1 | | + | age | int | false | false | true | false | false | false | | 0 | | + +-------+--------+--------+----------+----------+---------+---------------+-----------+-----------+------------+---------+ + +--------+-------+---------+---------+----------+--------+----------+---------+ + | Edge | Type | Inverse | BackRef | Relation | Unique | Optional | Comment | + +--------+-------+---------+---------+----------+--------+----------+---------+ + | groups | Group | false | | M2M | false | true | | + | spouse | User | false | | O2O | true | false | | + +--------+-------+---------+---------+----------+--------+----------+---------+ + +Group: + +-------+--------+--------+----------+----------+---------+---------------+-----------+-----------+------------+---------+ + | Field | Type | Unique | Optional | Nillable | Default | UpdateDefault | Immutable | StructTag | Validators | Comment | + +-------+--------+--------+----------+----------+---------+---------------+-----------+-----------+------------+---------+ + | id | int | false | false | false | false | false | false | | 0 | | + | name | string | false | false | false | false | false | false | | 0 | | + +-------+--------+--------+----------+----------+---------+---------------+-----------+-----------+------------+---------+ + +-------+------+---------+---------+----------+--------+----------+---------+ + | Edge | Type | Inverse | BackRef | Relation | Unique | Optional | Comment | + +-------+------+---------+---------+----------+--------+----------+---------+ + | users | User | false | | M2M | false | true | | + +-------+------+---------+---------+----------+--------+----------+---------+ + +`, + }, + } + for _, tt := range tests { + b := &strings.Builder{} + Fprint(b, tt.input) + assert.Equal(t, tt.out, "\n"+b.String()) + } +} diff --git a/dialect/dialect.go b/dialect/dialect.go new file mode 100644 index 0000000..3378463 --- /dev/null +++ b/dialect/dialect.go @@ -0,0 +1,208 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package dialect + +import ( + "context" + "database/sql" + "database/sql/driver" + "fmt" + "log" + + "github.com/google/uuid" +) + +// Dialect names for external usage. +const ( + MySQL = "mysql" + SQLite = "sqlite3" + Postgres = "postgres" + Gremlin = "gremlin" +) + +// ExecQuerier wraps the 2 database operations. +type ExecQuerier interface { + // Exec executes a query that does not return records. For example, in SQL, INSERT or UPDATE. + // It scans the result into the pointer v. For SQL drivers, it is dialect/sql.Result. + Exec(ctx context.Context, query string, args, v any) error + // Query executes a query that returns rows, typically a SELECT in SQL. + // It scans the result into the pointer v. For SQL drivers, it is *dialect/sql.Rows. + Query(ctx context.Context, query string, args, v any) error +} + +// Driver is the interface that wraps all necessary operations for ent clients. +type Driver interface { + ExecQuerier + // Tx starts and returns a new transaction. + // The provided context is used until the transaction is committed or rolled back. + Tx(context.Context) (Tx, error) + // Close closes the underlying connection. + Close() error + // Dialect returns the dialect name of the driver. + Dialect() string +} + +// Tx wraps the Exec and Query operations in transaction. +type Tx interface { + ExecQuerier + driver.Tx +} + +type nopTx struct { + Driver +} + +func (nopTx) Commit() error { return nil } +func (nopTx) Rollback() error { return nil } + +// NopTx returns a Tx with a no-op Commit / Rollback methods wrapping +// the provided Driver d. +func NopTx(d Driver) Tx { + return nopTx{d} +} + +// DebugDriver is a driver that logs all driver operations. +type DebugDriver struct { + Driver // underlying driver. + log func(context.Context, ...any) // log function. defaults to log.Println. +} + +// Debug gets a driver and an optional logging function, and returns +// a new debugged-driver that prints all outgoing operations. +func Debug(d Driver, logger ...func(...any)) Driver { + logf := log.Println + if len(logger) == 1 { + logf = logger[0] + } + drv := &DebugDriver{d, func(_ context.Context, v ...any) { logf(v...) }} + return drv +} + +// DebugWithContext gets a driver and a logging function, and returns +// a new debugged-driver that prints all outgoing operations with context. +func DebugWithContext(d Driver, logger func(context.Context, ...any)) Driver { + drv := &DebugDriver{d, logger} + return drv +} + +// Exec logs its params and calls the underlying driver Exec method. +func (d *DebugDriver) Exec(ctx context.Context, query string, args, v any) error { + d.log(ctx, fmt.Sprintf("driver.Exec: query=%v args=%v", query, args)) + return d.Driver.Exec(ctx, query, args, v) +} + +// ExecContext logs its params and calls the underlying driver ExecContext method if it is supported. +func (d *DebugDriver) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) { + drv, ok := d.Driver.(interface { + ExecContext(context.Context, string, ...any) (sql.Result, error) + }) + if !ok { + return nil, fmt.Errorf("Driver.ExecContext is not supported") + } + d.log(ctx, fmt.Sprintf("driver.ExecContext: query=%v args=%v", query, args)) + return drv.ExecContext(ctx, query, args...) +} + +// Query logs its params and calls the underlying driver Query method. +func (d *DebugDriver) Query(ctx context.Context, query string, args, v any) error { + d.log(ctx, fmt.Sprintf("driver.Query: query=%v args=%v", query, args)) + return d.Driver.Query(ctx, query, args, v) +} + +// QueryContext logs its params and calls the underlying driver QueryContext method if it is supported. +func (d *DebugDriver) QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) { + drv, ok := d.Driver.(interface { + QueryContext(context.Context, string, ...any) (*sql.Rows, error) + }) + if !ok { + return nil, fmt.Errorf("Driver.QueryContext is not supported") + } + d.log(ctx, fmt.Sprintf("driver.QueryContext: query=%v args=%v", query, args)) + return drv.QueryContext(ctx, query, args...) +} + +// Tx adds an log-id for the transaction and calls the underlying driver Tx command. +func (d *DebugDriver) Tx(ctx context.Context) (Tx, error) { + tx, err := d.Driver.Tx(ctx) + if err != nil { + return nil, err + } + id := uuid.New().String() + d.log(ctx, fmt.Sprintf("driver.Tx(%s): started", id)) + return &DebugTx{tx, id, d.log, ctx}, nil +} + +// BeginTx adds an log-id for the transaction and calls the underlying driver BeginTx command if it is supported. +func (d *DebugDriver) BeginTx(ctx context.Context, opts *sql.TxOptions) (Tx, error) { + drv, ok := d.Driver.(interface { + BeginTx(context.Context, *sql.TxOptions) (Tx, error) + }) + if !ok { + return nil, fmt.Errorf("Driver.BeginTx is not supported") + } + tx, err := drv.BeginTx(ctx, opts) + if err != nil { + return nil, err + } + id := uuid.New().String() + d.log(ctx, fmt.Sprintf("driver.BeginTx(%s): started", id)) + return &DebugTx{tx, id, d.log, ctx}, nil +} + +// DebugTx is a transaction implementation that logs all transaction operations. +type DebugTx struct { + Tx // underlying transaction. + id string // transaction logging id. + log func(context.Context, ...any) // log function. defaults to fmt.Println. + ctx context.Context // underlying transaction context. +} + +// Exec logs its params and calls the underlying transaction Exec method. +func (d *DebugTx) Exec(ctx context.Context, query string, args, v any) error { + d.log(ctx, fmt.Sprintf("Tx(%s).Exec: query=%v args=%v", d.id, query, args)) + return d.Tx.Exec(ctx, query, args, v) +} + +// ExecContext logs its params and calls the underlying transaction ExecContext method if it is supported. +func (d *DebugTx) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) { + drv, ok := d.Tx.(interface { + ExecContext(context.Context, string, ...any) (sql.Result, error) + }) + if !ok { + return nil, fmt.Errorf("Tx.ExecContext is not supported") + } + d.log(ctx, fmt.Sprintf("Tx(%s).ExecContext: query=%v args=%v", d.id, query, args)) + return drv.ExecContext(ctx, query, args...) +} + +// Query logs its params and calls the underlying transaction Query method. +func (d *DebugTx) Query(ctx context.Context, query string, args, v any) error { + d.log(ctx, fmt.Sprintf("Tx(%s).Query: query=%v args=%v", d.id, query, args)) + return d.Tx.Query(ctx, query, args, v) +} + +// QueryContext logs its params and calls the underlying transaction QueryContext method if it is supported. +func (d *DebugTx) QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) { + drv, ok := d.Tx.(interface { + QueryContext(context.Context, string, ...any) (*sql.Rows, error) + }) + if !ok { + return nil, fmt.Errorf("Tx.QueryContext is not supported") + } + d.log(ctx, fmt.Sprintf("Tx(%s).QueryContext: query=%v args=%v", d.id, query, args)) + return drv.QueryContext(ctx, query, args...) +} + +// Commit logs this step and calls the underlying transaction Commit method. +func (d *DebugTx) Commit() error { + d.log(d.ctx, fmt.Sprintf("Tx(%s): committed", d.id)) + return d.Tx.Commit() +} + +// Rollback logs this step and calls the underlying transaction Rollback method. +func (d *DebugTx) Rollback() error { + d.log(d.ctx, fmt.Sprintf("Tx(%s): rollbacked", d.id)) + return d.Tx.Rollback() +} diff --git a/dialect/entsql/annotation.go b/dialect/entsql/annotation.go new file mode 100644 index 0000000..aae8a42 --- /dev/null +++ b/dialect/entsql/annotation.go @@ -0,0 +1,857 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package entsql + +import ( + "errors" + "fmt" + + "entgo.io/ent/dialect/sql" + "entgo.io/ent/schema" +) + +// Annotation is a builtin schema annotation for attaching +// SQL metadata to schema objects for both codegen and runtime. +type Annotation struct { + // The Schema option allows setting the schema which the table belongs to. + // Note, this option is no-op for Ent default migration engine. However, schema + // extensions (like Atlas) can accept this option and implement it accordingly. + // + // entsql.Annotation{ + // Schema: "public", + // } + // + Schema string `json:"schema,omitempty"` + + // The Table option allows overriding the default table + // name that is generated by ent. For example: + // + // entsql.Annotation{ + // Table: "Users", + // } + // + Table string `json:"table,omitempty"` + + // Charset defines the character-set of the table. For example: + // + // entsql.Annotation{ + // Charset: "utf8mb4", + // } + // + Charset string `json:"charset,omitempty"` + + // Collation defines the collation of the table (a set of rules for comparing + // characters in a character set). For example: + // + // entsql.Annotation{ + // Collation: "utf8mb4_bin", + // } + // + Collation string `json:"collation,omitempty"` + + // Default specifies a literal default value of a column. Note that using + // this option overrides the default behavior of the code-generation. + // + // entsql.Annotation{ + // Default: `{"key":"value"}`, + // } + // + Default string `json:"default,omitempty"` + + // DefaultExpr specifies an expression default value of a column. Using this option, + // users can define custom expressions to be set as database default values. Note that + // using this option overrides the default behavior of the code-generation. + // + // entsql.Annotation{ + // DefaultExpr: "CURRENT_TIMESTAMP", + // } + // + // entsql.Annotation{ + // DefaultExpr: "uuid_generate_v4()", + // } + // + // entsql.Annotation{ + // DefaultExpr: "(a + b)", + // } + // + DefaultExpr string `json:"default_expr,omitempty"` + + // DefaultExpr specifies an expression default value of a column per dialect. + // See, DefaultExpr for full doc. + // + // entsql.Annotation{ + // DefaultExprs: map[string]string{ + // dialect.MySQL: "uuid()", + // dialect.Postgres: "uuid_generate_v4", + // } + // + DefaultExprs map[string]string `json:"default_exprs,omitempty"` + + // Options defines the additional table options. For example: + // + // entsql.Annotation{ + // Options: "ENGINE = INNODB", + // } + // + Options string `json:"options,omitempty"` + + // Size defines the column size in the generated schema. For example: + // + // entsql.Annotation{ + // Size: 128, + // } + // + Size int64 `json:"size,omitempty"` + + // WithComments specifies whether fields' comments should + // be stored in the database schema as column comments. + // + // withCommentsEnabled := true + // entsql.WithComments{ + // WithComments: &withCommentsEnabled, + // } + // + WithComments *bool `json:"with_comments,omitempty"` + + // Incremental defines the auto-incremental behavior of a column. For example: + // + // incrementalEnabled := true + // entsql.Annotation{ + // Incremental: &incrementalEnabled, + // } + // + // By default, this value is nil defaulting to whatever best fits each scenario. + // + Incremental *bool `json:"incremental,omitempty"` + + // IncrementStart defines the auto-incremental start value of a column. For example: + // + // incrementStart := 100 + // entsql.Annotation{ + // IncrementStart: &incrementStart, + // } + // + // By default, this value is nil defaulting to whatever the database settings are. + // + IncrementStart *int `json:"increment_start,omitempty"` + + // OnDelete specifies a custom referential action for DELETE operations on parent + // table that has matching rows in the child table. + // + // For example, in order to delete rows from the parent table and automatically delete + // their matching rows in the child table, pass the following annotation: + // + // entsql.Annotation{ + // OnDelete: entsql.Cascade, + // } + // + OnDelete ReferenceOption `json:"on_delete,omitempty"` + + // Check allows injecting custom "DDL" for setting an unnamed "CHECK" clause in "CREATE TABLE". + // + // entsql.Annotation{ + // Check: "age < 10", + // } + // + Check string `json:"check,omitempty"` + + // Checks allows injecting custom "DDL" for setting named "CHECK" clauses in "CREATE TABLE". + // + // entsql.Annotation{ + // Checks: map[string]string{ + // "valid_discount": "price > discount_price", + // }, + // } + // + Checks map[string]string `json:"checks,omitempty"` + + // Skip indicates that the field or the schema is skipped/ignored during + // migration (e.g., defined externally). + // + // entsql.Annotation{ + // Skip: true, + // } + // + Skip bool `json:"skip,omitempty"` + + // ViewAs allows defining a view for the schema. For example: + // + // entsql.Annotation{ + // View: "SELECT name FROM users", + // } + ViewAs string `json:"view_as,omitempty"` + + // ViewFor allows defining a view for the schema per dialect. For example: + // + // entsql.Annotation{ + // ViewFor: map[string]string{ + // dialect.MySQL: "...", + // dialect.Postgres: "...", + // }, + // } + ViewFor map[string]string `json:"view_for,omitempty"` + + // error occurs during annotation build. This field is not + // serialized to JSON and used only by the codegen loader. + err error +} + +// Name describes the annotation name. +func (Annotation) Name() string { + return "EntSQL" +} + +// The Schema option allows setting the schema which the table belongs to. +// Note, this option is no-op for Ent default migration engine. However, schema +// extensions (like Atlas) can accept this option and implement it accordingly. +// +// func (T) Annotations() []schema.Annotation { +// return []schema.Annotation{ +// entsql.Schema("public"), +// } +// } +func Schema(s string) *Annotation { + return &Annotation{ + Schema: s, + } +} + +// The Table option allows overriding the default table +// name that is generated by ent. For example: +// +// func (T) Annotations() []schema.Annotation { +// return []schema.Annotation{ +// entsql.Table("Users"), +// } +// } +func Table(t string) *Annotation { + return &Annotation{ + Table: t, + } +} + +// SchemaTable allows setting both schema and table name in one annotation. +func SchemaTable(s, t string) *Annotation { + return &Annotation{ + Schema: s, + Table: t, + } +} + +// Check allows injecting custom "DDL" for setting an unnamed "CHECK" clause in "CREATE TABLE". +// +// entsql.Annotation{ +// Check: "(`age` < 10)", +// } +func Check(c string) *Annotation { + return &Annotation{ + Check: c, + } +} + +// Checks allows injecting custom "DDL" for setting named "CHECK" clauses in "CREATE TABLE". +// +// entsql.Annotation{ +// Checks: map[string]string{ +// "valid_discount": "price > discount_price", +// }, +// } +func Checks(c map[string]string) *Annotation { + return &Annotation{ + Checks: c, + } +} + +// Skip indicates that the field or the schema is skipped/ignored during +// migration (e.g., defined externally). +func Skip() *Annotation { + return &Annotation{Skip: true} +} + +// View specifies the definition of a view. +func View(as string) *Annotation { + return &Annotation{ViewAs: as} +} + +// ViewFor specifies the definition of a view. +func ViewFor(dialect string, as func(*sql.Selector)) *Annotation { + b := sql.Dialect(dialect).Select() + as(b) + switch q, args := b.Query(); { + case len(args) > 0: + return &Annotation{ + err: fmt.Errorf("entsql: view query should not contain arguments. got: %d", len(args)), + } + case q == "": + return &Annotation{ + err: errors.New("entsql: view query is empty"), + } + case b.Err() != nil: + return &Annotation{ + err: b.Err(), + } + default: + return &Annotation{ + ViewFor: map[string]string{dialect: q}, + } + } +} + +// Default specifies a literal default value of a column. Note that using +// this option overrides the default behavior of the code-generation. +// +// entsql.Annotation{ +// Default: `{"key":"value"}`, +// } +func Default(literal string) *Annotation { + return &Annotation{ + Default: literal, + } +} + +// DefaultExpr specifies an expression default value for the annotated column. +// Using this option, users can define custom expressions to be set as database +// default values.Note that using this option overrides the default behavior of +// the code-generation. +// +// field.UUID("id", uuid.Nil). +// Default(uuid.New). +// Annotations( +// entsql.DefaultExpr("uuid_generate_v4()"), +// ) +func DefaultExpr(expr string) *Annotation { + return &Annotation{ + DefaultExpr: expr, + } +} + +// DefaultExprs specifies an expression default value for the annotated +// column per dialect. See, DefaultExpr for full doc. +// +// field.UUID("id", uuid.Nil). +// Default(uuid.New). +// Annotations( +// entsql.DefaultExprs(map[string]string{ +// dialect.MySQL: "uuid()", +// dialect.Postgres: "uuid_generate_v4()", +// }), +// ) +func DefaultExprs(exprs map[string]string) *Annotation { + return &Annotation{ + DefaultExprs: exprs, + } +} + +// WithComments specifies whether fields' comments should +// be stored in the database schema as column comments. +// +// func (T) Annotations() []schema.Annotation { +// return []schema.Annotation{ +// entsql.WithComments(true), +// } +// } +func WithComments(b bool) *Annotation { + return &Annotation{ + WithComments: &b, + } +} + +// OnDelete specifies a custom referential action for DELETE operations on parent +// table that has matching rows in the child table. +// +// For example, in order to delete rows from the parent table and automatically delete +// their matching rows in the child table, pass the following annotation: +// +// func (T) Annotations() []schema.Annotation { +// return []schema.Annotation{ +// entsql.OnDelete(entsql.Cascade), +// } +// } +func OnDelete(opt ReferenceOption) *Annotation { + return &Annotation{ + OnDelete: opt, + } +} + +// IncrementStart specifies the starting value for auto-increment columns. +// +// For example, in order to define the starting value for auto-increment to be 100: +// +// func (T) Annotations() []schema.Annotation { +// return []schema.Annotation{ +// entsql.IncrementStart(100), +// } +// } +func IncrementStart(i int) *Annotation { + return &Annotation{ + IncrementStart: &i, + } +} + +// Merge implements the schema.Merger interface. +func (a Annotation) Merge(other schema.Annotation) schema.Annotation { + var ant Annotation + switch other := other.(type) { + case Annotation: + ant = other + case *Annotation: + if other != nil { + ant = *other + } + default: + return a + } + if s := ant.Schema; s != "" { + a.Schema = s + } + if t := ant.Table; t != "" { + a.Table = t + } + if c := ant.Charset; c != "" { + a.Charset = c + } + if c := ant.Collation; c != "" { + a.Collation = c + } + if d := ant.Default; d != "" { + a.Default = d + } + if d := ant.DefaultExpr; d != "" { + a.DefaultExpr = d + } + if d := ant.DefaultExprs; d != nil { + if a.DefaultExprs == nil { + a.DefaultExprs = make(map[string]string) + } + for dialect, x := range d { + a.DefaultExprs[dialect] = x + } + } + if o := ant.Options; o != "" { + a.Options = o + } + if s := ant.Size; s != 0 { + a.Size = s + } + if b := ant.WithComments; b != nil { + a.WithComments = b + } + if i := ant.Incremental; i != nil { + a.Incremental = i + } + if i := ant.IncrementStart; i != nil { + a.IncrementStart = i + } + if od := ant.OnDelete; od != "" { + a.OnDelete = od + } + if c := ant.Check; c != "" { + a.Check = c + } + if checks := ant.Checks; len(checks) > 0 { + if a.Checks == nil { + a.Checks = make(map[string]string) + } + for name, check := range checks { + a.Checks[name] = check + } + } + if ant.Skip { + a.Skip = true + } + if v := ant.ViewAs; v != "" { + a.ViewAs = v + } + if vf := ant.ViewFor; len(vf) > 0 { + if a.ViewFor == nil { + a.ViewFor = make(map[string]string) + } + for dialect, view := range vf { + a.ViewFor[dialect] = view + } + } + if ant.err != nil { + a.err = errors.Join(a.err, ant.err) + } + return a +} + +// Err returns the error that occurred during annotation build, if any. +func (a Annotation) Err() error { + return a.err +} + +var _ interface { + schema.Annotation + schema.Merger +} = (*Annotation)(nil) + +// ReferenceOption for constraint actions. +type ReferenceOption string + +// Reference options (actions) specified by ON UPDATE and ON DELETE +// subclauses of the FOREIGN KEY clause. +const ( + NoAction ReferenceOption = "NO ACTION" + Restrict ReferenceOption = "RESTRICT" + Cascade ReferenceOption = "CASCADE" + SetNull ReferenceOption = "SET NULL" + SetDefault ReferenceOption = "SET DEFAULT" +) + +// IndexAnnotation is a builtin schema annotation for attaching +// SQL metadata to schema indexes for both codegen and runtime. +type IndexAnnotation struct { + // Prefix defines a column prefix for a single string column index. + // In MySQL, the following annotation maps to: + // + // index.Fields("column"). + // Annotation(entsql.Prefix(100)) + // + // CREATE INDEX `table_column` ON `table`(`column`(100)) + // + Prefix uint + + // PrefixColumns defines column prefixes for a multi-column index. + // In MySQL, the following annotation maps to: + // + // index.Fields("c1", "c2", "c3"). + // Annotation( + // entsql.PrefixColumn("c1", 100), + // entsql.PrefixColumn("c2", 200), + // ) + // + // CREATE INDEX `table_c1_c2_c3` ON `table`(`c1`(100), `c2`(200), `c3`) + // + PrefixColumns map[string]uint + + // Desc defines the DESC clause for a single column index. + // In MySQL, the following annotation maps to: + // + // index.Fields("column"). + // Annotation(entsql.Desc()) + // + // CREATE INDEX `table_column` ON `table`(`column` DESC) + // + Desc bool + + // DescColumns defines the DESC clause for columns in multi-column index. + // In MySQL, the following annotation maps to: + // + // index.Fields("c1", "c2", "c3"). + // Annotation( + // entsql.DescColumns("c1", "c2"), + // ) + // + // CREATE INDEX `table_c1_c2_c3` ON `table`(`c1` DESC, `c2` DESC, `c3`) + // + DescColumns map[string]bool + + // IncludeColumns defines the INCLUDE clause for the index. + // Works only in Postgres and its definition is as follows: + // + // index.Fields("c1"). + // Annotation( + // entsql.IncludeColumns("c2"), + // ) + // + // CREATE INDEX "table_column" ON "table"("c1") INCLUDE ("c2") + // + IncludeColumns []string + + // Type defines the type of the index. + // In MySQL, the following annotation maps to: + // + // index.Fields("c1"). + // Annotation( + // entsql.IndexType("FULLTEXT"), + // ) + // + // CREATE FULLTEXT INDEX `table_c1` ON `table`(`c1`) + // + Type string + + // Types is like the Type option but allows mapping an index-type per dialect. + // + // index.Fields("c1"). + // Annotation( + // entsql.IndexTypes(map[string]string{ + // dialect.MySQL: "FULLTEXT", + // dialect.Postgres: "GIN", + // }), + // ) + // + Types map[string]string + + // OpClass defines the operator class for a single string column index. + // In PostgreSQL, the following annotation maps to: + // + // index.Fields("column"). + // Annotation( + // entsql.IndexType("BRIN"), + // entsql.OpClass("int8_bloom_ops"), + // ) + // + // CREATE INDEX "table_column" ON "table" USING BRIN ("column" int8_bloom_ops) + // + OpClass string + + // OpClassColumns defines operator-classes for a multi-column index. + // In PostgreSQL, the following annotation maps to: + // + // index.Fields("c1", "c2", "c3"). + // Annotation( + // entsql.IndexType("BRIN"), + // entsql.OpClassColumn("c1", "int8_bloom_ops"), + // entsql.OpClassColumn("c2", "int8_minmax_multi_ops(values_per_range=8)"), + // ) + // + // CREATE INDEX "table_column" ON "table" USING BRIN ("c1" int8_bloom_ops, "c2" int8_minmax_multi_ops(values_per_range=8), "c3") + // + OpClassColumns map[string]string + + // IndexWhere allows configuring partial indexes in SQLite and PostgreSQL. + // Read more: https://postgresql.org/docs/current/indexes-partial.html. + // + // Note that the `WHERE` clause should be defined exactly like it is + // stored in the database (i.e. normal form). Read more about this on + // the Atlas website: https://atlasgo.io/concepts/dev-database#diffing. + // + // index.Fields("a"). + // Annotations( + // entsql.IndexWhere("b AND c > 0"), + // ) + // CREATE INDEX "table_a" ON "table"("a") WHERE (b AND c > 0) + Where string +} + +// Prefix returns a new index annotation with a single string column index. +// In MySQL, the following annotation maps to: +// +// index.Fields("column"). +// Annotation(entsql.Prefix(100)) +// +// CREATE INDEX `table_column` ON `table`(`column`(100)) +func Prefix(prefix uint) *IndexAnnotation { + return &IndexAnnotation{ + Prefix: prefix, + } +} + +// PrefixColumn returns a new index annotation with column prefix for +// multi-column indexes. In MySQL, the following annotation maps to: +// +// index.Fields("c1", "c2", "c3"). +// Annotation( +// entsql.PrefixColumn("c1", 100), +// entsql.PrefixColumn("c2", 200), +// ) +// +// CREATE INDEX `table_c1_c2_c3` ON `table`(`c1`(100), `c2`(200), `c3`) +func PrefixColumn(name string, prefix uint) *IndexAnnotation { + return &IndexAnnotation{ + PrefixColumns: map[string]uint{ + name: prefix, + }, + } +} + +// OpClass defines the operator class for a single string column index. +// In PostgreSQL, the following annotation maps to: +// +// index.Fields("column"). +// Annotation( +// entsql.IndexType("BRIN"), +// entsql.OpClass("int8_bloom_ops"), +// ) +// +// CREATE INDEX "table_column" ON "table" USING BRIN ("column" int8_bloom_ops) +func OpClass(op string) *IndexAnnotation { + return &IndexAnnotation{ + OpClass: op, + } +} + +// OpClassColumn returns a new index annotation with column operator +// class for multi-column indexes. In PostgreSQL, the following annotation maps to: +// +// index.Fields("c1", "c2", "c3"). +// Annotation( +// entsql.IndexType("BRIN"), +// entsql.OpClassColumn("c1", "int8_bloom_ops"), +// entsql.OpClassColumn("c2", "int8_minmax_multi_ops(values_per_range=8)"), +// ) +// +// CREATE INDEX "table_column" ON "table" USING BRIN ("c1" int8_bloom_ops, "c2" int8_minmax_multi_ops(values_per_range=8), "c3") +func OpClassColumn(name, op string) *IndexAnnotation { + return &IndexAnnotation{ + OpClassColumns: map[string]string{ + name: op, + }, + } +} + +// Desc returns a new index annotation with the DESC clause for a +// single column index. In MySQL, the following annotation maps to: +// +// index.Fields("column"). +// Annotation(entsql.Desc()) +// +// CREATE INDEX `table_column` ON `table`(`column` DESC) +func Desc() *IndexAnnotation { + return &IndexAnnotation{ + Desc: true, + } +} + +// DescColumns returns a new index annotation with the DESC clause attached to +// the columns in the index. In MySQL, the following annotation maps to: +// +// index.Fields("c1", "c2", "c3"). +// Annotation( +// entsql.DescColumns("c1", "c2"), +// ) +// +// CREATE INDEX `table_c1_c2_c3` ON `table`(`c1` DESC, `c2` DESC, `c3`) +func DescColumns(names ...string) *IndexAnnotation { + ant := &IndexAnnotation{ + DescColumns: make(map[string]bool, len(names)), + } + for i := range names { + ant.DescColumns[names[i]] = true + } + return ant +} + +// IncludeColumns defines the INCLUDE clause for the index. +// Works only in Postgres and its definition is as follows: +// +// index.Fields("c1"). +// Annotation( +// entsql.IncludeColumns("c2"), +// ) +// +// CREATE INDEX "table_column" ON "table"("c1") INCLUDE ("c2") +func IncludeColumns(names ...string) *IndexAnnotation { + return &IndexAnnotation{IncludeColumns: names} +} + +// IndexType defines the type of the index. +// In MySQL, the following annotation maps to: +// +// index.Fields("c1"). +// Annotation( +// entsql.IndexType("FULLTEXT"), +// ) +// +// CREATE FULLTEXT INDEX `table_c1` ON `table`(`c1`) +func IndexType(t string) *IndexAnnotation { + return &IndexAnnotation{Type: t} +} + +// IndexTypes is like the Type option but allows mapping an index-type per dialect. +// +// index.Fields("c1"). +// Annotations( +// entsql.IndexTypes(map[string]string{ +// dialect.MySQL: "FULLTEXT", +// dialect.Postgres: "GIN", +// }), +// ) +func IndexTypes(types map[string]string) *IndexAnnotation { + return &IndexAnnotation{Types: types} +} + +// IndexWhere allows configuring partial indexes in SQLite and PostgreSQL. +// Read more: https://postgresql.org/docs/current/indexes-partial.html. +// +// Note that the `WHERE` clause should be defined exactly like it is +// stored in the database (i.e. normal form). Read more about this on the +// Atlas website: https://atlasgo.io/concepts/dev-database#diffing. +// +// index.Fields("a"). +// Annotations( +// entsql.IndexWhere("b AND c > 0"), +// ) +// CREATE INDEX "table_a" ON "table"("a") WHERE (b AND c > 0) +func IndexWhere(pred string) *IndexAnnotation { + return &IndexAnnotation{Where: pred} +} + +// Name describes the annotation name. +func (IndexAnnotation) Name() string { + return "EntSQLIndexes" +} + +// Merge implements the schema.Merger interface. +func (a IndexAnnotation) Merge(other schema.Annotation) schema.Annotation { + var ant IndexAnnotation + switch other := other.(type) { + case IndexAnnotation: + ant = other + case *IndexAnnotation: + if other != nil { + ant = *other + } + default: + return a + } + if ant.Prefix != 0 { + a.Prefix = ant.Prefix + } + if ant.PrefixColumns != nil { + if a.PrefixColumns == nil { + a.PrefixColumns = make(map[string]uint) + } + for column, prefix := range ant.PrefixColumns { + a.PrefixColumns[column] = prefix + } + } + if ant.OpClass != "" { + a.OpClass = ant.OpClass + } + if ant.OpClassColumns != nil { + if a.OpClassColumns == nil { + a.OpClassColumns = make(map[string]string) + } + for column, op := range ant.OpClassColumns { + a.OpClassColumns[column] = op + } + } + if ant.Desc { + a.Desc = ant.Desc + } + if ant.DescColumns != nil { + if a.DescColumns == nil { + a.DescColumns = make(map[string]bool) + } + for column, desc := range ant.DescColumns { + a.DescColumns[column] = desc + } + } + if ant.IncludeColumns != nil { + a.IncludeColumns = append(a.IncludeColumns, ant.IncludeColumns...) + } + if ant.Type != "" { + a.Type = ant.Type + } + if ant.Types != nil { + if a.Types == nil { + a.Types = make(map[string]string) + } + for dialect, t := range ant.Types { + a.Types[dialect] = t + } + } + if ant.Where != "" { + a.Where = ant.Where + } + return a +} + +var _ interface { + schema.Annotation + schema.Merger +} = (*IndexAnnotation)(nil) diff --git a/dialect/gremlin/client.go b/dialect/gremlin/client.go new file mode 100644 index 0000000..48612d9 --- /dev/null +++ b/dialect/gremlin/client.go @@ -0,0 +1,77 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package gremlin + +import ( + "context" + "fmt" + "net/http" +) + +// RoundTripper is an interface representing the ability to execute a +// single gremlin transaction, obtaining the Response for a given Request. +type RoundTripper interface { + RoundTrip(context.Context, *Request) (*Response, error) +} + +// The RoundTripperFunc type is an adapter to allow the use of ordinary functions as Gremlin RoundTripper. +type RoundTripperFunc func(context.Context, *Request) (*Response, error) + +// RoundTrip calls f(ctx, r). +func (f RoundTripperFunc) RoundTrip(ctx context.Context, r *Request) (*Response, error) { + return f(ctx, r) +} + +// Interceptor provides a hook to intercept the execution of a Gremlin Request. +type Interceptor func(RoundTripper) RoundTripper + +// A Client is a gremlin client. +type Client struct { + // Transport specifies the mechanism by which individual + // Gremlin requests are made. + Transport RoundTripper +} + +// MaxResponseSize defines the maximum response size allowed. +const MaxResponseSize = 2 << 20 + +// NewClient creates a gremlin client from config and options. +func NewClient(cfg Config, opt ...Option) (*Client, error) { + return cfg.Build(opt...) +} + +// NewHTTPClient creates an http based gremlin client. +func NewHTTPClient(url string, client *http.Client) (*Client, error) { + transport, err := NewHTTPTransport(url, client) + if err != nil { + return nil, err + } + return &Client{transport}, nil +} + +// Do sends a gremlin request and returns a gremlin response. +func (c Client) Do(ctx context.Context, req *Request) (*Response, error) { + rsp, err := c.Transport.RoundTrip(ctx, req) + if err == nil { + err = rsp.Err() + } + + // If we got an error, and the context has been canceled, + // the context's error is probably more useful. + if err != nil && ctx.Err() != nil { + err = ctx.Err() + } + return rsp, err +} + +// Query issues an eval request via the Do function. +func (c Client) Query(ctx context.Context, query string) (*Response, error) { + return c.Do(ctx, NewEvalRequest(query)) +} + +// Queryf formats a query string and invokes Query. +func (c Client) Queryf(ctx context.Context, format string, args ...any) (*Response, error) { + return c.Query(ctx, fmt.Sprintf(format, args...)) +} diff --git a/dialect/gremlin/client_test.go b/dialect/gremlin/client_test.go new file mode 100644 index 0000000..df9ff4b --- /dev/null +++ b/dialect/gremlin/client_test.go @@ -0,0 +1,93 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package gremlin + +import ( + "context" + "io" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestNewClient(t *testing.T) { + var cfg Config + cfg.Endpoint.URL, _ = url.Parse("http://gremlin-server/gremlin") + c, err := NewClient(cfg) + assert.NotNil(t, c) + assert.NoError(t, err) +} + +type mockRoundTripper struct{ mock.Mock } + +func (m *mockRoundTripper) RoundTrip(ctx context.Context, req *Request) (*Response, error) { + args := m.Called(ctx, req) + return args.Get(0).(*Response), args.Error(1) +} + +func TestClientRequest(t *testing.T) { + ctx := context.Background() + req, rsp := &Request{}, &Response{} + + var m mockRoundTripper + m.On("RoundTrip", ctx, req). + Run(func(mock.Arguments) { rsp.Status.Code = StatusSuccess }). + Return(rsp, nil). + Once() + defer m.AssertExpectations(t) + + response, err := Client{&m}.Do(context.Background(), req) + assert.NoError(t, err) + assert.Equal(t, rsp, response) +} + +func TestClientResponseError(t *testing.T) { + rsp := &Response{} + + var m mockRoundTripper + m.On("RoundTrip", mock.Anything, mock.Anything). + Run(func(mock.Arguments) { rsp.Status.Code = StatusServerError }). + Return(rsp, nil). + Once() + defer m.AssertExpectations(t) + + _, err := Client{&m}.Do(context.Background(), nil) + assert.Error(t, err) +} + +func TestClientCanceledContext(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + + var m mockRoundTripper + m.On("RoundTrip", ctx, mock.Anything). + Run(func(mock.Arguments) { cancel() }). + Return(&Response{}, io.ErrUnexpectedEOF). + Once() + defer m.AssertExpectations(t) + + _, err := Client{&m}.Query(ctx, "g.E()") + assert.EqualError(t, err, context.Canceled.Error()) +} + +func TestClientQuery(t *testing.T) { + rsp := &Response{} + rsp.Status.Code = StatusNoContent + + var m mockRoundTripper + m.On("RoundTrip", mock.Anything, mock.Anything). + Run(func(args mock.Arguments) { + req := args.Get(1).(*Request) + assert.Equal(t, "g.V(1)", req.Arguments[ArgsGremlin]) + }). + Return(rsp, nil). + Once() + defer m.AssertExpectations(t) + + rsp, err := Client{&m}.Queryf(context.Background(), "g.V(%d)", 1) + assert.NotNil(t, rsp) + assert.NoError(t, err) +} diff --git a/dialect/gremlin/config.go b/dialect/gremlin/config.go new file mode 100644 index 0000000..a5bbc0a --- /dev/null +++ b/dialect/gremlin/config.go @@ -0,0 +1,82 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package gremlin + +import ( + "fmt" + "net/http" + "net/url" +) + +type ( + // Config offers a declarative way to construct a client. + Config struct { + Endpoint Endpoint `env:"ENDPOINT" long:"endpoint" default:"" description:"gremlin endpoint to connect to"` + DisableExpansion bool `env:"DISABLE_EXPANSION" long:"disable-expansion" description:"disable bindings expansion"` + } + + // An Option configured client. + Option func(*options) + + options struct { + interceptors []Interceptor + httpClient *http.Client + } + + // Endpoint wraps a url to add flag unmarshalling. + Endpoint struct { + *url.URL + } +) + +// WithInterceptor adds interceptors to the client's transport. +func WithInterceptor(interceptors ...Interceptor) Option { + return func(opts *options) { + opts.interceptors = append(opts.interceptors, interceptors...) + } +} + +// WithHTTPClient assigns underlying http client to be used by http transport. +func WithHTTPClient(client *http.Client) Option { + return func(opts *options) { + opts.httpClient = client + } +} + +// Build constructs a client from Config. +func (cfg Config) Build(opt ...Option) (c *Client, err error) { + opts := cfg.buildOptions(opt) + switch cfg.Endpoint.Scheme { + case "http", "https": + c, err = NewHTTPClient(cfg.Endpoint.String(), opts.httpClient) + default: + err = fmt.Errorf("unsupported endpoint scheme: %s", cfg.Endpoint.Scheme) + } + if err != nil { + return nil, err + } + + for i := len(opts.interceptors) - 1; i >= 0; i-- { + c.Transport = opts.interceptors[i](c.Transport) + } + if !cfg.DisableExpansion { + c.Transport = ExpandBindings(c.Transport) + } + return c, nil +} + +func (Config) buildOptions(opts []Option) options { + var o options + for _, opt := range opts { + opt(&o) + } + return o +} + +// UnmarshalFlag implements flag.Unmarshaler interface. +func (ep *Endpoint) UnmarshalFlag(value string) (err error) { + ep.URL, err = url.Parse(value) + return +} diff --git a/dialect/gremlin/config_test.go b/dialect/gremlin/config_test.go new file mode 100644 index 0000000..07b41ca --- /dev/null +++ b/dialect/gremlin/config_test.go @@ -0,0 +1,154 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package gremlin + +import ( + "context" + "errors" + "net/http" + "net/url" + "testing" + + "github.com/jessevdk/go-flags" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestConfigParsing(t *testing.T) { + var cfg Config + _, err := flags.ParseArgs(&cfg, []string{ + "--disable-expansion", + "--endpoint", "http://localhost:8182/gremlin", + }) + assert.NoError(t, err) + assert.True(t, cfg.DisableExpansion) + assert.Equal(t, "http", cfg.Endpoint.Scheme) + assert.Equal(t, "http://localhost:8182/gremlin", cfg.Endpoint.String()) + + cfg = Config{} + _, err = flags.ParseArgs(&cfg, nil) + assert.NoError(t, err) + assert.NotNil(t, cfg.Endpoint.URL) +} + +func TestConfigBuild(t *testing.T) { + tests := []struct { + name string + cfg Config + opts []Option + wantErr bool + }{ + { + name: "HTTP", + cfg: Config{ + Endpoint: Endpoint{ + URL: func() *url.URL { + u, _ := url.Parse("http://gremlin-server/gremlin") + return u + }(), + }, + }, + }, + { + name: "NoScheme", + cfg: Config{ + Endpoint: Endpoint{ + URL: &url.URL{}, + }, + }, + wantErr: true, + }, + { + name: "BadScheme", + cfg: Config{ + Endpoint: Endpoint{ + URL: &url.URL{ + Scheme: "bad", + }, + }, + }, + wantErr: true, + }, + { + name: "WithOptions", + cfg: Config{ + Endpoint: Endpoint{ + URL: func() *url.URL { + u, _ := url.Parse("http://gremlin-server/gremlin") + return u + }(), + }, + DisableExpansion: true, + }, + opts: []Option{WithHTTPClient(&http.Client{})}, + }, + { + name: "NoExpansion", + cfg: Config{ + Endpoint: Endpoint{ + URL: &url.URL{ + Scheme: "bad", + }, + }, + DisableExpansion: true, + }, + wantErr: true, + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + client, err := tc.cfg.Build(tc.opts...) + if !tc.wantErr { + assert.NotNil(t, client) + assert.NoError(t, err) + } else { + assert.Error(t, err) + } + }) + } +} + +type testRoundTripper struct{ mock.Mock } + +func (rt *testRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + args := rt.Called(req) + rsp, _ := args.Get(0).(*http.Response) + return rsp, args.Error(1) +} + +func TestBuildWithHTTPClient(t *testing.T) { + var transport testRoundTripper + transport.On("RoundTrip", mock.Anything). + Return(nil, errors.New("noop")). + Once() + defer transport.AssertExpectations(t) + + u, err := url.Parse("http://gremlin-server:8182/gremlin") + require.NoError(t, err) + + client, err := Config{Endpoint: Endpoint{u}}. + Build(WithHTTPClient(&http.Client{Transport: &transport})) + require.NoError(t, err) + _, _ = client.Do(context.Background(), NewEvalRequest("g.V()")) +} + +func TestExpandOrdering(t *testing.T) { + var cfg Config + cfg.Endpoint.URL, _ = url.Parse("http://gremlin-server/gremlin") + interceptor := func(RoundTripper) RoundTripper { + return RoundTripperFunc(func(ctx context.Context, req *Request) (*Response, error) { + assert.Equal(t, `g.V().hasLabel("user")`, req.Arguments[ArgsGremlin]) + assert.Nil(t, req.Arguments[ArgsBindings]) + return nil, errors.New("noop") + }) + } + c, err := cfg.Build(WithInterceptor(interceptor)) + require.NoError(t, err) + req := NewEvalRequest("g.V().hasLabel($1)", WithBindings(map[string]any{"$1": "user"})) + _, _ = c.Do(context.Background(), req) +} diff --git a/dialect/gremlin/driver.go b/dialect/gremlin/driver.go new file mode 100644 index 0000000..8135ba2 --- /dev/null +++ b/dialect/gremlin/driver.go @@ -0,0 +1,58 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package gremlin + +import ( + "context" + "fmt" + + "entgo.io/ent/dialect" + "entgo.io/ent/dialect/gremlin/graph/dsl" +) + +// Driver is a dialect.Driver implementation for TinkerPop gremlin. +type Driver struct { + *Client +} + +// NewDriver returns a new dialect.Driver implementation for gremlin. +func NewDriver(c *Client) *Driver { + c.Transport = ExpandBindings(c.Transport) + return &Driver{c} +} + +// Dialect implements the dialect.Dialect method. +func (Driver) Dialect() string { return dialect.Gremlin } + +// Exec implements the dialect.Exec method. +func (c *Driver) Exec(ctx context.Context, query string, args, v any) error { + vr, ok := v.(*Response) + if !ok { + return fmt.Errorf("dialect/gremlin: invalid type %T. expect *gremlin.Response", v) + } + bindings, ok := args.(dsl.Bindings) + if !ok { + return fmt.Errorf("dialect/gremlin: invalid type %T. expect map[string]any for bindings", args) + } + res, err := c.Do(ctx, NewEvalRequest(query, WithBindings(bindings))) + if err != nil { + return err + } + *vr = *res + return nil +} + +// Query implements the dialect.Query method. +func (c *Driver) Query(ctx context.Context, query string, args, v any) error { + return c.Exec(ctx, query, args, v) +} + +// Close is a nop close call. It should close the connection in case of WS client. +func (Driver) Close() error { return nil } + +// Tx returns a nop transaction. +func (c *Driver) Tx(context.Context) (dialect.Tx, error) { return dialect.NopTx(c), nil } + +var _ dialect.Driver = (*Driver)(nil) diff --git a/dialect/gremlin/encoding/graphson/bench_test.go b/dialect/gremlin/encoding/graphson/bench_test.go new file mode 100644 index 0000000..d54f014 --- /dev/null +++ b/dialect/gremlin/encoding/graphson/bench_test.go @@ -0,0 +1,99 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package graphson + +import ( + "testing" + + jsoniter "github.com/json-iterator/go" +) + +type book struct { + ID string `json:"id" graphson:"g:UUID"` + Title string `json:"title"` + Author string `json:"author"` + Pages int `json:"num_pages"` + Chapters []string `json:"chapters"` +} + +func generateObject() *book { + return &book{ + ID: "21d5dcbf-1fd4-493e-9b74-d6c429f9e4a5", + Title: "The Art of Computer Programming, Vol. 2", + Author: "Donald E. Knuth", + Pages: 784, + Chapters: []string{"Random numbers", "Arithmetic"}, + } +} + +func BenchmarkMarshalObject(b *testing.B) { + obj := generateObject() + + b.ReportAllocs() + b.ResetTimer() + for n := 0; n < b.N; n++ { + _, err := Marshal(obj) + if err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkUnmarshalObject(b *testing.B) { + b.ReportAllocs() + out, err := Marshal(generateObject()) + if err != nil { + b.Fatal(err) + } + + obj := &book{} + + b.ResetTimer() + for n := 0; n < b.N; n++ { + err = Unmarshal(out, obj) + if err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkMarshalInterface(b *testing.B) { + b.ReportAllocs() + data, err := jsoniter.Marshal(generateObject()) + if err != nil { + b.Fatal(err) + } + + var obj any + if err = jsoniter.Unmarshal(data, &obj); err != nil { + b.Fatal(err) + } + + b.ResetTimer() + for n := 0; n < b.N; n++ { + _, err = Marshal(obj) + if err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkUnmarshalInterface(b *testing.B) { + b.ReportAllocs() + data, err := Marshal(generateObject()) + if err != nil { + b.Fatal(err) + } + + var obj any + + b.ResetTimer() + for n := 0; n < b.N; n++ { + err = Unmarshal(data, &obj) + if err != nil { + b.Fatal(err) + } + } +} diff --git a/dialect/gremlin/encoding/graphson/common_test.go b/dialect/gremlin/encoding/graphson/common_test.go new file mode 100644 index 0000000..6014a4d --- /dev/null +++ b/dialect/gremlin/encoding/graphson/common_test.go @@ -0,0 +1,54 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package graphson + +import ( + "unsafe" + + jsoniter "github.com/json-iterator/go" + "github.com/stretchr/testify/mock" +) + +type mocker struct { + mock.Mock +} + +// Encode belongs to jsoniter.ValEncoder interface. +func (m *mocker) Encode(ptr unsafe.Pointer, stream *jsoniter.Stream) { + m.Called(ptr, stream) +} + +// IsEmpty belongs to jsoniter.ValEncoder interface. +func (m *mocker) IsEmpty(ptr unsafe.Pointer) bool { + args := m.Called(ptr) + return args.Bool(0) +} + +// Decode implements jsoniter.ValDecoder interface. +func (m *mocker) Decode(ptr unsafe.Pointer, iter *jsoniter.Iterator) { + m.Called(ptr, iter) +} + +// CheckType implements typeChecker interface. +func (m *mocker) CheckType(typ Type) error { + args := m.Called(typ) + return args.Error(0) +} + +// MarshalGraphson implements Marshaler interface. +func (m *mocker) MarshalGraphson() ([]byte, error) { + args := m.Called() + data, err := args.Get(0), args.Error(1) + if data == nil { + return nil, err + } + return data.([]byte), err +} + +// UnmarshalGraphson implements Unmarshaler interface. +func (m *mocker) UnmarshalGraphson(data []byte) error { + args := m.Called(data) + return args.Error(0) +} diff --git a/dialect/gremlin/encoding/graphson/decode.go b/dialect/gremlin/encoding/graphson/decode.go new file mode 100644 index 0000000..59ad437 --- /dev/null +++ b/dialect/gremlin/encoding/graphson/decode.go @@ -0,0 +1,109 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package graphson + +import ( + "io" + "reflect" + + jsoniter "github.com/json-iterator/go" + "github.com/modern-go/reflect2" +) + +type decodeExtension struct { + jsoniter.DummyExtension +} + +// Unmarshal parses the graphson encoded data and stores the result +// in the value pointed to by v. +func Unmarshal(data []byte, v any) error { + return config.Unmarshal(data, v) +} + +// UnmarshalFromString parses the graphson encoded str and stores the result +// in the value pointed to by v. +func UnmarshalFromString(str string, v any) error { + return config.UnmarshalFromString(str, v) +} + +// Decoder defines a graphson decoder. +type Decoder interface { + Decode(any) error +} + +// NewDecoder create a graphson decoder. +func NewDecoder(r io.Reader) Decoder { + return config.NewDecoder(r) +} + +// Unmarshaler is the interface implemented by types +// that can unmarshal a graphson description of themselves. +type Unmarshaler interface { + UnmarshalGraphson([]byte) error +} + +// UpdateStructDescriptor decorates struct field encoders for graphson tags. +func (ext decodeExtension) UpdateStructDescriptor(desc *jsoniter.StructDescriptor) { + for _, binding := range desc.Fields { + if tag, ok := binding.Field.Tag().Lookup("graphson"); ok && tag != "-" { + if dec := ext.DecoratorOfStructField(binding.Decoder, tag); dec != nil { + binding.Decoder = dec + } + } + } +} + +// CreateDecoder returns a value decoder for type. +func (ext decodeExtension) CreateDecoder(typ reflect2.Type) jsoniter.ValDecoder { + if dec := ext.DecoderOfRegistered(typ); dec != nil { + return dec + } + if dec := ext.DecoderOfUnmarshaler(typ); dec != nil { + return dec + } + if dec := ext.DecoderOfNative(typ); dec != nil { + return dec + } + switch typ.Kind() { + case reflect.Array: + return ext.DecoderOfArray(typ) + case reflect.Slice: + return ext.DecoderOfSlice(typ) + case reflect.Map: + return ext.DecoderOfMap(typ) + default: + return nil + } +} + +// DecorateDecoder decorates an passed in value decoder for type. +func (ext decodeExtension) DecorateDecoder(typ reflect2.Type, dec jsoniter.ValDecoder) jsoniter.ValDecoder { + if dec := ext.DecoratorOfRegistered(dec); dec != nil { + return dec + } + if dec := ext.DecoratorOfUnmarshaler(typ, dec); dec != nil { + return dec + } + if dec := ext.DecoratorOfTyper(typ, dec); dec != nil { + return dec + } + if dec := ext.DecoratorOfNative(typ, dec); dec != nil { + return dec + } + switch typ.Kind() { + case reflect.Ptr, reflect.Struct: + return dec + case reflect.Interface: + return ext.DecoratorOfInterface(typ, dec) + case reflect.Slice: + return ext.DecoratorOfSlice(typ, dec) + case reflect.Array: + return ext.DecoratorOfArray(dec) + case reflect.Map: + return ext.DecoratorOfMap(dec) + default: + return ext.DecoderOfError("graphson: unsupported type: %s", typ.String()) + } +} diff --git a/dialect/gremlin/encoding/graphson/decode_test.go b/dialect/gremlin/encoding/graphson/decode_test.go new file mode 100644 index 0000000..17cf224 --- /dev/null +++ b/dialect/gremlin/encoding/graphson/decode_test.go @@ -0,0 +1,5 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package graphson diff --git a/dialect/gremlin/encoding/graphson/encode.go b/dialect/gremlin/encoding/graphson/encode.go new file mode 100644 index 0000000..7453abf --- /dev/null +++ b/dialect/gremlin/encoding/graphson/encode.go @@ -0,0 +1,98 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package graphson + +import ( + "io" + "reflect" + + jsoniter "github.com/json-iterator/go" + "github.com/modern-go/reflect2" +) + +type encodeExtension struct { + jsoniter.DummyExtension +} + +// Marshal returns the graphson encoding of v. +func Marshal(v any) ([]byte, error) { + return config.Marshal(v) +} + +// MarshalToString returns the graphson encoding of v as string. +func MarshalToString(v any) (string, error) { + return config.MarshalToString(v) +} + +// Encoder defines a graphson encoder. +type Encoder interface { + Encode(any) error +} + +// NewEncoder create a graphson encoder. +func NewEncoder(w io.Writer) Encoder { + return config.NewEncoder(w) +} + +// Marshaler is the interface implemented by types that +// can marshal themselves as graphson. +type Marshaler interface { + MarshalGraphson() ([]byte, error) +} + +// UpdateStructDescriptor decorates struct field encoders for graphson tags. +func (ext encodeExtension) UpdateStructDescriptor(desc *jsoniter.StructDescriptor) { + for _, binding := range desc.Fields { + if tag, ok := binding.Field.Tag().Lookup("graphson"); ok && tag != "-" { + if enc := ext.DecoratorOfStructField(binding.Encoder, tag); enc != nil { + binding.Encoder = enc + } + } + } +} + +// CreateEncoder returns a value encoder for type. +func (ext encodeExtension) CreateEncoder(typ reflect2.Type) jsoniter.ValEncoder { + if enc := ext.EncoderOfRegistered(typ); enc != nil { + return enc + } + if enc := ext.EncoderOfNative(typ); enc != nil { + return enc + } + switch typ.Kind() { + case reflect.Map: + return ext.EncoderOfMap(typ) + default: + return nil + } +} + +// DecorateEncoder decorates an passed in value encoder for type. +func (ext encodeExtension) DecorateEncoder(typ reflect2.Type, enc jsoniter.ValEncoder) jsoniter.ValEncoder { + if enc := ext.DecoratorOfRegistered(enc); enc != nil { + return enc + } + if enc := ext.DecoratorOfMarshaler(typ, enc); enc != nil { + return enc + } + if enc := ext.DecoratorOfTyper(typ, enc); enc != nil { + return enc + } + if enc := ext.DecoratorOfNative(typ, enc); enc != nil { + return enc + } + switch typ.Kind() { + case reflect.Ptr, reflect.Interface, reflect.Struct: + return enc + case reflect.Array: + return ext.DecoratorOfArray(enc) + case reflect.Slice: + return ext.DecoratorOfSlice(typ, enc) + case reflect.Map: + return ext.DecoratorOfMap(enc) + default: + return ext.EncoderOfError("graphson: unsupported type: %s", typ.String()) + } +} diff --git a/dialect/gremlin/encoding/graphson/encode_test.go b/dialect/gremlin/encoding/graphson/encode_test.go new file mode 100644 index 0000000..79927bd --- /dev/null +++ b/dialect/gremlin/encoding/graphson/encode_test.go @@ -0,0 +1,16 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package graphson + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestEncodeUnsupportedType(t *testing.T) { + _, err := Marshal(func() {}) + assert.Error(t, err) +} diff --git a/dialect/gremlin/encoding/graphson/error.go b/dialect/gremlin/encoding/graphson/error.go new file mode 100644 index 0000000..3372afd --- /dev/null +++ b/dialect/gremlin/encoding/graphson/error.go @@ -0,0 +1,45 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package graphson + +import ( + "fmt" + "unsafe" + + jsoniter "github.com/json-iterator/go" +) + +// EncoderOfError returns a value encoder which always fails to encode. +func (encodeExtension) EncoderOfError(format string, args ...any) jsoniter.ValEncoder { + return decoratorOfError(format, args...) +} + +// DecoderOfError returns a value decoder which always fails to decode. +func (decodeExtension) DecoderOfError(format string, args ...any) jsoniter.ValDecoder { + return decoratorOfError(format, args...) +} + +func decoratorOfError(format string, args ...any) errorCodec { + err := fmt.Errorf(format, args...) + return errorCodec{err} +} + +type errorCodec struct{ error } + +func (ec errorCodec) Encode(_ unsafe.Pointer, stream *jsoniter.Stream) { + if stream.Error == nil { + stream.Error = ec.error + } +} + +func (errorCodec) IsEmpty(unsafe.Pointer) bool { + return false +} + +func (ec errorCodec) Decode(_ unsafe.Pointer, iter *jsoniter.Iterator) { + if iter.Error == nil { + iter.Error = ec.error + } +} diff --git a/dialect/gremlin/encoding/graphson/error_test.go b/dialect/gremlin/encoding/graphson/error_test.go new file mode 100644 index 0000000..5cc6338 --- /dev/null +++ b/dialect/gremlin/encoding/graphson/error_test.go @@ -0,0 +1,30 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package graphson + +import ( + "bytes" + "errors" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestErrorCodec(t *testing.T) { + codec := errorCodec{errors.New("codec error")} + assert.False(t, codec.IsEmpty(nil)) + + var buf bytes.Buffer + stream := config.BorrowStream(&buf) + defer config.ReturnStream(stream) + codec.Encode(nil, stream) + assert.Empty(t, buf.Bytes()) + assert.EqualError(t, stream.Error, codec.Error()) + + iter := config.BorrowIterator([]byte{}) + defer config.ReturnIterator(iter) + codec.Decode(nil, iter) + assert.EqualError(t, iter.Error, codec.Error()) +} diff --git a/dialect/gremlin/encoding/graphson/extension.go b/dialect/gremlin/encoding/graphson/extension.go new file mode 100644 index 0000000..5e058de --- /dev/null +++ b/dialect/gremlin/encoding/graphson/extension.go @@ -0,0 +1,88 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package graphson + +import ( + "reflect" + + jsoniter "github.com/json-iterator/go" + "github.com/modern-go/reflect2" +) + +var ( + typeEncoders = map[string]jsoniter.ValEncoder{} + typeDecoders = map[string]jsoniter.ValDecoder{} +) + +// RegisterTypeEncoder register type encoder for typ. +func RegisterTypeEncoder(typ string, enc jsoniter.ValEncoder) { + typeEncoders[typ] = enc +} + +// RegisterTypeDecoder register type decoder for typ. +func RegisterTypeDecoder(typ string, dec jsoniter.ValDecoder) { + typeDecoders[typ] = dec +} + +type registeredEncoder struct{ jsoniter.ValEncoder } + +// EncoderOfRegistered returns a value encoder of a registered type. +func (encodeExtension) EncoderOfRegistered(typ reflect2.Type) jsoniter.ValEncoder { + enc := typeEncoders[typ.String()] + if enc != nil { + return registeredEncoder{enc} + } + if typ.Kind() == reflect.Ptr { + ptrType := typ.(reflect2.PtrType) + enc := typeEncoders[ptrType.Elem().String()] + if enc != nil { + return registeredEncoder{ + ValEncoder: &jsoniter.OptionalEncoder{ + ValueEncoder: enc, + }, + } + } + } + return nil +} + +// DecoratorOfRegistered decorates a value encoder of a registered type. +func (encodeExtension) DecoratorOfRegistered(enc jsoniter.ValEncoder) jsoniter.ValEncoder { + if _, ok := enc.(registeredEncoder); ok { + return enc + } + return nil +} + +type registeredDecoder struct{ jsoniter.ValDecoder } + +// DecoderOfRegistered returns a value decoder of a registered type. +func (decodeExtension) DecoderOfRegistered(typ reflect2.Type) jsoniter.ValDecoder { + dec := typeDecoders[typ.String()] + if dec != nil { + return registeredDecoder{dec} + } + if typ.Kind() == reflect.Ptr { + ptrType := typ.(reflect2.PtrType) + dec := typeDecoders[ptrType.Elem().String()] + if dec != nil { + return registeredDecoder{ + ValDecoder: &jsoniter.OptionalDecoder{ + ValueType: ptrType.Elem(), + ValueDecoder: dec, + }, + } + } + } + return nil +} + +// DecoratorOfRegistered decorates a value decoder of a registered type. +func (decodeExtension) DecoratorOfRegistered(dec jsoniter.ValDecoder) jsoniter.ValDecoder { + if _, ok := dec.(registeredDecoder); ok { + return dec + } + return nil +} diff --git a/dialect/gremlin/encoding/graphson/init.go b/dialect/gremlin/encoding/graphson/init.go new file mode 100644 index 0000000..98253c9 --- /dev/null +++ b/dialect/gremlin/encoding/graphson/init.go @@ -0,0 +1,16 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package graphson + +import ( + jsoniter "github.com/json-iterator/go" +) + +var config = jsoniter.Config{}.Froze() + +func init() { + config.RegisterExtension(&encodeExtension{}) + config.RegisterExtension(&decodeExtension{}) +} diff --git a/dialect/gremlin/encoding/graphson/interface.go b/dialect/gremlin/encoding/graphson/interface.go new file mode 100644 index 0000000..b3df3c3 --- /dev/null +++ b/dialect/gremlin/encoding/graphson/interface.go @@ -0,0 +1,153 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package graphson + +import ( + "bytes" + "errors" + "fmt" + "io" + "reflect" + "unsafe" + + jsoniter "github.com/json-iterator/go" + "github.com/modern-go/reflect2" +) + +// DecoratorOfInterface decorates a value decoder of an interface type. +func (decodeExtension) DecoratorOfInterface(typ reflect2.Type, dec jsoniter.ValDecoder) jsoniter.ValDecoder { + if _, ok := typ.(*reflect2.UnsafeEFaceType); ok { + return efaceDecoder{typ, dec} + } + return dec +} + +type efaceDecoder struct { + typ reflect2.Type + jsoniter.ValDecoder +} + +func (dec efaceDecoder) Decode(ptr unsafe.Pointer, iter *jsoniter.Iterator) { + switch next := iter.WhatIsNext(); next { + case jsoniter.StringValue, jsoniter.BoolValue, jsoniter.NilValue: + dec.ValDecoder.Decode(ptr, iter) + case jsoniter.ObjectValue: + dec.decode(ptr, iter) + default: + iter.ReportError("decode empty interface", fmt.Sprintf("unexpected value type: %d", next)) + } +} + +func (dec efaceDecoder) decode(ptr unsafe.Pointer, iter *jsoniter.Iterator) { + data := iter.SkipAndReturnBytes() + if iter.Error != nil && iter.Error != io.EOF { + return + } + + rtype, err := dec.reflectBytes(data) + if err != nil { + iter.ReportError("decode empty interface", err.Error()) + return + } + + it := config.BorrowIterator(data) + defer config.ReturnIterator(it) + + var val any + if rtype != nil { + val = rtype.New() + it.ReadVal(val) + val = rtype.Indirect(val) + } else { + if jsoniter.Get(data, TypeKey).LastError() == nil { + vk := jsoniter.Get(data, ValueKey) + if vk.LastError() == nil { + val = vk.GetInterface() + } + } + if val == nil { + val = it.Read() + } + } + + if it.Error != nil && it.Error != io.EOF { + iter.ReportError("decode empty interface", it.Error.Error()) + return + } + + // nolint: gas + dec.typ.UnsafeSet(ptr, unsafe.Pointer(&val)) +} + +func (dec efaceDecoder) reflectBytes(data []byte) (reflect2.Type, error) { + typ := Type(jsoniter.Get(data, TypeKey).ToString()) + rtype := dec.reflectType(typ) + if rtype != nil { + return rtype, nil + } + + switch typ { + case listType: + return dec.reflectSlice(data) + case mapType: + return dec.reflectMap(data) + default: + return nil, nil + } +} + +func (efaceDecoder) reflectType(typ Type) reflect2.Type { + switch typ { + case doubleType: + return reflect2.TypeOf(float64(0)) + case floatType: + return reflect2.TypeOf(float32(0)) + case byteType: + return reflect2.TypeOf(uint8(0)) + case int16Type: + return reflect2.TypeOf(int16(0)) + case int32Type: + return reflect2.TypeOf(int32(0)) + case int64Type, bigIntegerType: + return reflect2.TypeOf(int64(0)) + case byteBufferType: + return reflect2.TypeOf([]byte{}) + default: + return nil + } +} + +func (efaceDecoder) reflectSlice(data []byte) (reflect2.Type, error) { + var elem any + if err := Unmarshal(data, &[...]*any{&elem}); err != nil { + return nil, fmt.Errorf("cannot read first list element: %w", err) + } + + if elem == nil { + return reflect2.TypeOf([]any{}), nil + } + + sliceType := reflect.SliceOf(reflect.TypeOf(elem)) + return reflect2.Type2(sliceType), nil +} + +func (efaceDecoder) reflectMap(data []byte) (reflect2.Type, error) { + var key, elem any + if err := Unmarshal( + bytes.Replace(data, []byte(mapType), []byte(listType), 1), + &[...]*any{&key, &elem}, + ); err != nil { + return nil, fmt.Errorf("cannot unmarshal first map item: %w", err) + } + + if key == nil { + return reflect2.TypeOf(map[any]any{}), nil + } else if elem == nil { + return nil, errors.New("expect map element, but found only key") + } + + mapType := reflect.MapOf(reflect.TypeOf(key), reflect.TypeOf(elem)) + return reflect2.Type2(mapType), nil +} diff --git a/dialect/gremlin/encoding/graphson/interface_test.go b/dialect/gremlin/encoding/graphson/interface_test.go new file mode 100644 index 0000000..8b80781 --- /dev/null +++ b/dialect/gremlin/encoding/graphson/interface_test.go @@ -0,0 +1,352 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package graphson + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDecodeInterface(t *testing.T) { + tests := []struct { + name string + in string + want any + wantErr bool + }{ + { + name: "Boolean", + in: "false", + want: false, + }, + { + name: "String", + in: `"str"`, + want: "str", + }, + { + name: "Double", + in: `{ + "@type": "g:Double", + "@value": 3.14 + }`, + want: float64(3.14), + }, + { + name: "Float", + in: `{ + "@type": "g:Float", + "@value": -22.567 + }`, + want: float32(-22.567), + }, + { + name: "Int32", + in: `{ + "@type": "g:Int32", + "@value": 9000 + }`, + want: int32(9000), + }, + { + name: "Int64", + in: `{ + "@type": "g:Int64", + "@value": 188786 + }`, + want: int64(188786), + }, + { + name: "BigInteger", + in: `{ + "@type": "gx:BigInteger", + "@value": 352353463712 + }`, + want: int64(352353463712), + }, + { + name: "Byte", + in: `{ + "@type": "gx:Byte", + "@value": 100 + }`, + want: uint8(100), + }, + { + name: "Int16", + in: `{ + "@type": "gx:Int16", + "@value": 2000 + }`, + want: int16(2000), + }, + { + name: "UnknownType", + in: `{ + "@type": "g:T", + "@value": "label" + }`, + want: "label", + }, + { + name: "UntypedArray", + in: "[]", + wantErr: true, + }, + { + name: "NoType", + in: `{ + "@typ": "g:Int32", + "@value": 345 + }`, + wantErr: true, + }, + { + name: "BadObject", + in: `{ + "@type": "g:Int32", + "@value": 345 + `, + wantErr: true, + }, + { + name: "BadList", + in: `{ + "@type": "g:List", + "@value": [ + { + "@type": "g:Int64", + "@val": 123457990 + } + ] + }`, + wantErr: true, + }, + { + name: "BadMap", + in: `{ + "@type": "g:Map", + "@value": [ + { + "@type": "g:Int64", + "@val": 123457990 + }, + "First" + ] + }`, + wantErr: true, + }, + { + name: "KeyOnlyMap", + in: `{ + "@type": "g:Map", + "@value": ["Key"] + }`, + wantErr: true, + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + var got any + err := UnmarshalFromString(tc.in, &got) + if !tc.wantErr { + require.NoError(t, err) + assert.Equal(t, tc.want, got) + } else { + assert.Error(t, err) + } + }) + } +} + +func TestDecodeInterfaceSlice(t *testing.T) { + tests := []struct { + in string + want any + }{ + { + in: `{ + "@type": "g:List", + "@value": [] + }`, + want: []any{}, + }, + { + in: `{ + "@type": "g:List", + "@value": ["x", "y", "z"] + }`, + want: []string{"x", "y", "z"}, + }, + { + in: `{ + "@type": "g:List", + "@value": [ + { + "@type": "g:Int64", + "@value": 123457990 + }, + { + "@type": "g:Int64", + "@value": 23456111 + }, + { + "@type": "g:Int64", + "@value": -687450 + } + ] + }`, + want: []int64{123457990, 23456111, -687450}, + }, + { + in: `{ + "@type": "gx:ByteBuffer", + "@value": "AQIDBAU=" + }`, + want: []byte{1, 2, 3, 4, 5}, + }, + } + + for _, tc := range tests { + tc := tc + t.Run(fmt.Sprintf("%T", tc.want), func(t *testing.T) { + t.Parallel() + var got any + err := UnmarshalFromString(tc.in, &got) + require.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestDecodeInterfaceMap(t *testing.T) { + tests := []struct { + in string + want any + }{ + { + in: `{ + "@type": "g:Map", + "@value": [] + }`, + want: map[any]any{}, + }, + { + in: `{ + "@type": "g:Map", + "@value": [ + "Sep", + { + "@type": "g:Int32", + "@value": 9 + }, + "Oct", + { + "@type": "g:Int32", + "@value": 10 + }, + "Nov", + { + "@type": "g:Int32", + "@value": 11 + } + ] + }`, + want: map[string]int32{ + "Sep": int32(9), + "Oct": int32(10), + "Nov": int32(11), + }, + }, + { + in: `{ + "@type": "g:Map", + "@value": [ + "One", + { + "@type": "g:List", + "@value": [ + { + "@type": "g:Int32", + "@value": 1 + } + ] + }, + "Two", + { + "@type": "g:List", + "@value": [ + { + "@type": "g:Int32", + "@value": 2 + } + ] + }, + "Three", + { + "@type": "g:List", + "@value": [ + { + "@type": "g:Int32", + "@value": 3 + } + ] + } + ] + }`, + want: map[string][]int32{ + "One": {1}, + "Two": {2}, + "Three": {3}, + }, + }, + } + + for _, tc := range tests { + tc := tc + t.Run(fmt.Sprintf("%T", tc.want), func(t *testing.T) { + t.Parallel() + var got any + err := UnmarshalFromString(tc.in, &got) + require.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestDecodeInterfaceObject(t *testing.T) { + book := struct { + ID string `json:"id" graphson:"g:UUID"` + Title string `json:"title"` + Author string `json:"author"` + Pages int `json:"num_pages"` + Chapters []string `json:"chapters"` + }{ + ID: "21d5dcbf-1fd4-493e-9b74-d6c429f9e4a5", + Title: "The Art of Computer Programming, Vol. 2", + Author: "Donald E. Knuth", + Pages: 784, + Chapters: []string{"Random numbers", "Arithmetic"}, + } + data, err := Marshal(book) + require.NoError(t, err) + + var v any + err = Unmarshal(data, &v) + require.NoError(t, err) + + obj := v.(map[string]any) + assert.Equal(t, book.ID, obj["id"]) + assert.Equal(t, book.Title, obj["title"]) + assert.Equal(t, book.Author, obj["author"]) + assert.EqualValues(t, book.Pages, obj["num_pages"]) + assert.ElementsMatch(t, book.Chapters, obj["chapters"]) +} diff --git a/dialect/gremlin/encoding/graphson/lazy.go b/dialect/gremlin/encoding/graphson/lazy.go new file mode 100644 index 0000000..6ae9e68 --- /dev/null +++ b/dialect/gremlin/encoding/graphson/lazy.go @@ -0,0 +1,74 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package graphson + +import ( + "fmt" + "sync" + "unsafe" + + jsoniter "github.com/json-iterator/go" + "github.com/modern-go/reflect2" +) + +// LazyEncoderOf returns a lazy encoder for type. +func (encodeExtension) LazyEncoderOf(typ reflect2.Type) jsoniter.ValEncoder { + return &lazyEncoder{resolve: func() jsoniter.ValEncoder { + return config.EncoderOf(typ) + }} +} + +// LazyDecoderOf returns a lazy unique decoder for type. +func (decodeExtension) LazyDecoderOf(typ reflect2.Type) jsoniter.ValDecoder { + return &lazyDecoder{resolve: func() jsoniter.ValDecoder { + dec := config.DecoderOf(reflect2.PtrTo(typ)) + if td, ok := dec.(typeDecoder); ok { + td.typeChecker = &uniqueType{elemChecker: td.typeChecker} + dec = td + } + return dec + }} +} + +type lazyEncoder struct { + jsoniter.ValEncoder + resolve func() jsoniter.ValEncoder + once sync.Once +} + +func (enc *lazyEncoder) Encode(ptr unsafe.Pointer, stream *jsoniter.Stream) { + enc.once.Do(func() { enc.ValEncoder = enc.resolve() }) + enc.ValEncoder.Encode(ptr, stream) +} + +func (enc *lazyEncoder) IsEmpty(ptr unsafe.Pointer) bool { + enc.once.Do(func() { enc.ValEncoder = enc.resolve() }) + return enc.ValEncoder.IsEmpty(ptr) +} + +type lazyDecoder struct { + jsoniter.ValDecoder + resolve func() jsoniter.ValDecoder + once sync.Once +} + +func (dec *lazyDecoder) Decode(ptr unsafe.Pointer, iter *jsoniter.Iterator) { + dec.once.Do(func() { dec.ValDecoder = dec.resolve() }) + dec.ValDecoder.Decode(ptr, iter) +} + +type uniqueType struct { + typ Type + once sync.Once + elemChecker typeChecker +} + +func (u *uniqueType) CheckType(other Type) error { + u.once.Do(func() { u.typ = other }) + if u.typ != other { + return fmt.Errorf("expect type %s, but found %s", u.typ, other) + } + return u.elemChecker.CheckType(u.typ) +} diff --git a/dialect/gremlin/encoding/graphson/lazy_test.go b/dialect/gremlin/encoding/graphson/lazy_test.go new file mode 100644 index 0000000..a39dea5 --- /dev/null +++ b/dialect/gremlin/encoding/graphson/lazy_test.go @@ -0,0 +1,46 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package graphson + +import ( + "sync/atomic" + "testing" + + jsoniter "github.com/json-iterator/go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestLazyEncode(t *testing.T) { + var m mocker + m.On("IsEmpty", mock.Anything).Return(false).Once() + m.On("Encode", mock.Anything, mock.Anything).Once() + defer m.AssertExpectations(t) + + var cnt uint32 + var enc jsoniter.ValEncoder = &lazyEncoder{resolve: func() jsoniter.ValEncoder { + assert.Equal(t, uint32(1), atomic.AddUint32(&cnt, 1)) + return &m + }} + + enc.IsEmpty(nil) + enc.Encode(nil, nil) +} + +func TestLazyDecode(t *testing.T) { + var m mocker + m.On("Decode", mock.Anything, mock.Anything).Times(3) + defer m.AssertExpectations(t) + + var cnt uint32 + var dec jsoniter.ValDecoder = &lazyDecoder{resolve: func() jsoniter.ValDecoder { + assert.Equal(t, uint32(1), atomic.AddUint32(&cnt, 1)) + return &m + }} + + for i := 0; i < 3; i++ { + dec.Decode(nil, nil) + } +} diff --git a/dialect/gremlin/encoding/graphson/map.go b/dialect/gremlin/encoding/graphson/map.go new file mode 100644 index 0000000..de41551 --- /dev/null +++ b/dialect/gremlin/encoding/graphson/map.go @@ -0,0 +1,116 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package graphson + +import ( + "unsafe" + + jsoniter "github.com/json-iterator/go" + "github.com/modern-go/reflect2" +) + +// EncoderOfMap returns a value encoder of a map type. +func (ext encodeExtension) EncoderOfMap(typ reflect2.Type) jsoniter.ValEncoder { + mapType := typ.(reflect2.MapType) + return &mapEncoder{ + mapType: mapType, + keyEnc: ext.LazyEncoderOf(mapType.Key()), + elemEnc: ext.LazyEncoderOf(mapType.Elem()), + } +} + +// DecoratorOfMap decorates a value encoder of a map type. +func (encodeExtension) DecoratorOfMap(enc jsoniter.ValEncoder) jsoniter.ValEncoder { + return typeEncoder{enc, mapType} +} + +type mapEncoder struct { + mapType reflect2.MapType + keyEnc jsoniter.ValEncoder + elemEnc jsoniter.ValEncoder +} + +func (enc *mapEncoder) Encode(ptr unsafe.Pointer, stream *jsoniter.Stream) { + iter := enc.mapType.UnsafeIterate(ptr) + if !iter.HasNext() { + stream.WriteEmptyArray() + return + } + + stream.WriteArrayStart() + for { + key, elem := iter.UnsafeNext() + enc.keyEnc.Encode(key, stream) + stream.WriteMore() + enc.elemEnc.Encode(elem, stream) + if !iter.HasNext() { + break + } + stream.WriteMore() + } + stream.WriteArrayEnd() +} + +func (enc *mapEncoder) IsEmpty(ptr unsafe.Pointer) bool { + return !enc.mapType.UnsafeIterate(ptr).HasNext() +} + +// DecoderOfMap returns a value decoder of a map type. +func (ext decodeExtension) DecoderOfMap(typ reflect2.Type) jsoniter.ValDecoder { + mapType := typ.(reflect2.MapType) + keyType, elemType := mapType.Key(), mapType.Elem() + return &mapDecoder{ + mapType: mapType, + keyType: keyType, + elemType: elemType, + keyDec: ext.LazyDecoderOf(keyType), + elemDec: ext.LazyDecoderOf(elemType), + } +} + +// DecoratorOfMap decorates a value decoder of a map type. +func (decodeExtension) DecoratorOfMap(dec jsoniter.ValDecoder) jsoniter.ValDecoder { + return typeDecoder{dec, mapType} +} + +type mapDecoder struct { + mapType reflect2.MapType + keyType reflect2.Type + elemType reflect2.Type + keyDec jsoniter.ValDecoder + elemDec jsoniter.ValDecoder +} + +func (dec *mapDecoder) Decode(ptr unsafe.Pointer, iter *jsoniter.Iterator) { + mapType := dec.mapType + if mapType.UnsafeIsNil(ptr) { + mapType.UnsafeSet(ptr, mapType.UnsafeMakeMap(0)) + } + + var key unsafe.Pointer + if !iter.ReadArrayCB(func(iter *jsoniter.Iterator) bool { + if key == nil { + key = dec.keyType.UnsafeNew() + dec.keyDec.Decode(key, iter) + return iter.Error == nil + } + + elem := dec.elemType.UnsafeNew() + dec.elemDec.Decode(elem, iter) + if iter.Error != nil { + return false + } + + mapType.UnsafeSetIndex(ptr, key, elem) + key = nil + return true + }) { + return + } + + if key != nil { + iter.ReportError("decode map", "odd number of map items") + } +} diff --git a/dialect/gremlin/encoding/graphson/map_test.go b/dialect/gremlin/encoding/graphson/map_test.go new file mode 100644 index 0000000..aa3d152 --- /dev/null +++ b/dialect/gremlin/encoding/graphson/map_test.go @@ -0,0 +1,355 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package graphson + +import ( + "strings" + "testing" + + jsoniter "github.com/json-iterator/go" + "github.com/modern-go/reflect2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEncodeMap(t *testing.T) { + tests := []struct { + name string + in any + want string + }{ + { + name: "simple", + in: map[int32]string{ + 3: "Mar", + 1: "Jan", + 2: "Feb", + }, + want: `[ + { + "@type": "g:Int32", + "@value": 1 + }, + "Jan", + { + "@type": "g:Int32", + "@value": 2 + }, + "Feb", + { + "@type": "g:Int32", + "@value": 3 + }, + "Mar" + ]`, + }, + { + name: "mixed", + in: map[string]any{ + "byte": byte('a'), + "string": "str", + "slice": []int{1, 2, 3}, + "map": map[string]int{}, + }, + want: `[ + "byte", + { + "@type": "gx:Byte", + "@value": 97 + }, + "string", + "str", + "slice", + { + "@type": "g:List", + "@value": [ + { + "@type": "g:Int64", + "@value": 1 + }, + { + "@type": "g:Int64", + "@value": 2 + }, + { + "@type": "g:Int64", + "@value": 3 + } + ] + }, + "map", + { + "@type": "g:Map", + "@value": [] + } + ]`, + }, + { + name: "struct-key", + in: map[struct { + K string `json:"key"` + }]int32{ + + {"result"}: 42, + }, + want: `[ + { + "key": "result" + }, + { + "@type": "g:Int32", + "@value": 42 + } + ]`, + }, + { + name: "nil", + in: map[string]uint8(nil), + want: "null", + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + data, err := Marshal(tc.in) + require.NoError(t, err) + + assert.Equal(t, "g:Map", jsoniter.Get(data, "@type").ToString()) + var want []any + err = jsoniter.UnmarshalFromString(tc.want, &want) + require.NoError(t, err) + + got, ok := jsoniter.Get(data, "@value").GetInterface().([]any) + require.True(t, ok) + assert.ElementsMatch(t, want, got) + }) + } +} + +func TestDecodeMap(t *testing.T) { + tests := []struct { + name string + in string + want any + }{ + { + name: "empty", + in: `{ + "@type": "g:Map", + "@value": [] + }`, + want: map[int]int{}, + }, + { + name: "simple", + in: `{ + "@type": "g:Map", + "@value": [ + { + "@type": "g:Int32", + "@value": 6 + }, + "Jun", + { + "@type": "g:Int32", + "@value": 7 + }, + "Jul", + { + "@type": "g:Int32", + "@value": 8 + }, + "Aug" + ] + }`, + want: map[int]string{ + 6: "Jun", + 7: "Jul", + 8: "Aug", + }, + }, + { + name: "duplicate", + in: `{ + "@type": "g:Map", + "@value": [ + "Sep", + { + "@type": "g:Int32", + "@value": 9 + }, + "Oct", + { + "@type": "g:Int32", + "@value": 65 + }, + "Oct", + { + "@type": "g:Int32", + "@value": 10 + }, + "Nov", + null + ] + }`, + want: map[string]*int{ + "Sep": func() *int { v := 9; return &v }(), + "Oct": func() *int { v := 10; return &v }(), + "Nov": nil, + }, + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + typ := reflect2.TypeOf(tc.want).(reflect2.MapType) + got := typ.MakeMap(0) + err := UnmarshalFromString(tc.in, got) + require.NoError(t, err) + assert.Equal(t, tc.want, typ.Indirect(got)) + }) + } +} + +func TestDecodeMapIntoNil(t *testing.T) { + var got map[int64]int32 + err := UnmarshalFromString(`{ + "@type": "g:Map", + "@value": [ + { + "@type": "g:Int64", + "@value": 9 + }, + { + "@type": "g:Int32", + "@value": -9 + }, + { + "@type": "g:Int64", + "@value": 99 + }, + { + "@type": "g:Int32", + "@value": -99 + }, + { + "@type": "g:Int64", + "@value": 999 + }, + { + "@type": "g:Int32", + "@value": -999 + } + ] + }`, &got) + require.NoError(t, err) + assert.Equal(t, map[int64]int32{9: -9, 99: -99, 999: -999}, got) +} + +func TestDecodeBadMap(t *testing.T) { + tests := []struct { + name string + in string + }{ + { + name: "BadValue", + in: `{ + "@type": "g:Map", + "@value": [ + { + "@type": "g:Int64", + "@value": 9 + }, + { + "@type": "g:Int32", + "@value": "55" + } + ] + }`, + }, + { + name: "NoValue", + in: `{ + "@type": "g:Map", + "@value": [ + { + "@type": "g:Int64", + "@value": 9 + }, + { + "@type": "g:Int32", + "@value": 9 + }, + { + "@type": "g:Int64", + "@value": 42 + } + ] + }`, + }, + { + name: "AlterKeyType", + in: `{ + "@type": "g:Map", + "@value": [ + { + "@type": "g:Int64", + "@value": 9 + }, + { + "@type": "g:Int32", + "@value": 9 + }, + { + "@type": "g:Int32", + "@value": 42 + }, + { + "@type": "g:Int32", + "@value": 42 + } + ] + }`, + }, + { + name: "AlterValType", + in: `{ + "@type": "g:Map", + "@value": [ + { + "@type": "g:Int64", + "@value": 9 + }, + { + "@type": "g:Int32", + "@value": 9 + }, + { + "@type": "g:Int64", + "@value": 42 + }, + { + "@type": "g:Int64", + "@value": 42 + } + ] + }`, + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + var v map[int]int + err := NewDecoder(strings.NewReader(tc.in)).Decode(&v) + assert.Error(t, err) + }) + } +} diff --git a/dialect/gremlin/encoding/graphson/marshaler.go b/dialect/gremlin/encoding/graphson/marshaler.go new file mode 100644 index 0000000..3176f1f --- /dev/null +++ b/dialect/gremlin/encoding/graphson/marshaler.go @@ -0,0 +1,133 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package graphson + +import ( + "fmt" + "io" + "unsafe" + + jsoniter "github.com/json-iterator/go" + "github.com/modern-go/reflect2" +) + +// DecoratorOfMarshaler decorates a value encoder of a Marshaler interface. +func (ext encodeExtension) DecoratorOfMarshaler(typ reflect2.Type, enc jsoniter.ValEncoder) jsoniter.ValEncoder { + if typ == marshalerType { + enc := marshalerEncoder{enc, typ} + return directMarshalerEncoder{enc} + } + if typ.Implements(marshalerType) { + return marshalerEncoder{enc, typ} + } + ptrType := reflect2.PtrTo(typ) + if ptrType.Implements(marshalerType) { + ptrEnc := ext.LazyEncoderOf(ptrType) + enc := marshalerEncoder{ptrEnc, ptrType} + return referenceEncoder{enc} + } + return nil +} + +// DecoderOfUnmarshaler returns a value decoder of an Unmarshaler interface. +func (decodeExtension) DecoderOfUnmarshaler(typ reflect2.Type) jsoniter.ValDecoder { + ptrType := reflect2.PtrTo(typ) + if ptrType.Implements(unmarshalerType) { + return referenceDecoder{ + unmarshalerDecoder{ptrType}, + } + } + return nil +} + +// DecoratorOfUnmarshaler decorates a value encoder of an Unmarshaler interface. +func (decodeExtension) DecoratorOfUnmarshaler(typ reflect2.Type, dec jsoniter.ValDecoder) jsoniter.ValDecoder { + if reflect2.PtrTo(typ).Implements(unmarshalerType) { + return dec + } + return nil +} + +var ( + marshalerType = reflect2.TypeOfPtr((*Marshaler)(nil)).Elem() + unmarshalerType = reflect2.TypeOfPtr((*Unmarshaler)(nil)).Elem() +) + +type marshalerEncoder struct { + jsoniter.ValEncoder + reflect2.Type +} + +func (enc marshalerEncoder) Encode(ptr unsafe.Pointer, stream *jsoniter.Stream) { + marshaler := enc.Type.UnsafeIndirect(ptr).(Marshaler) + enc.encode(marshaler, stream) +} + +func (enc marshalerEncoder) encode(marshaler Marshaler, stream *jsoniter.Stream) { + data, err := marshaler.MarshalGraphson() + if err != nil { + stream.Error = fmt.Errorf("graphson: error calling MarshalGraphson for type %s: %w", enc.Type, err) + return + } + if !config.Valid(data) { + stream.Error = fmt.Errorf("graphson: syntax error when marshaling type %s", enc.Type) + return + } + _, stream.Error = stream.Write(data) +} + +type directMarshalerEncoder struct { + marshalerEncoder +} + +func (enc directMarshalerEncoder) Encode(ptr unsafe.Pointer, stream *jsoniter.Stream) { + marshaler := *(*Marshaler)(ptr) + enc.encode(marshaler, stream) +} + +type referenceEncoder struct { + jsoniter.ValEncoder +} + +func (enc referenceEncoder) Encode(ptr unsafe.Pointer, stream *jsoniter.Stream) { + // nolint: gas + enc.ValEncoder.Encode(unsafe.Pointer(&ptr), stream) +} + +func (enc referenceEncoder) IsEmpty(ptr unsafe.Pointer) bool { + // nolint: gas + return enc.ValEncoder.IsEmpty(unsafe.Pointer(&ptr)) +} + +type unmarshalerDecoder struct { + reflect2.Type +} + +func (dec unmarshalerDecoder) Decode(ptr unsafe.Pointer, iter *jsoniter.Iterator) { + bytes := iter.SkipAndReturnBytes() + if iter.Error != nil && iter.Error != io.EOF { + return + } + + unmarshaler := dec.UnsafeIndirect(ptr).(Unmarshaler) + if err := unmarshaler.UnmarshalGraphson(bytes); err != nil { + iter.ReportError( + "unmarshal graphson", + fmt.Sprintf( + "graphson: error calling UnmarshalGraphson for type %s: %s", + dec.Type, err, + ), + ) + } +} + +type referenceDecoder struct { + jsoniter.ValDecoder +} + +func (dec referenceDecoder) Decode(ptr unsafe.Pointer, iter *jsoniter.Iterator) { + // nolint: gas + dec.ValDecoder.Decode(unsafe.Pointer(&ptr), iter) +} diff --git a/dialect/gremlin/encoding/graphson/marshaler_test.go b/dialect/gremlin/encoding/graphson/marshaler_test.go new file mode 100644 index 0000000..2af42a9 --- /dev/null +++ b/dialect/gremlin/encoding/graphson/marshaler_test.go @@ -0,0 +1,101 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package graphson + +import ( + "errors" + "fmt" + "reflect" + "testing" + + jsoniter "github.com/json-iterator/go" + "github.com/modern-go/reflect2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestMarshalerEncode(t *testing.T) { + want := []byte(`{"@type": "g:Int32", "@value": 42}`) + m := &mocker{} + call := m.On("MarshalGraphson").Return(want, nil) + defer m.AssertExpectations(t) + + tests := []any{m, &m, func() *Marshaler { marshaler := Marshaler(m); return &marshaler }(), Marshaler(nil)} + call.Times(len(tests) - 1) + + for _, tc := range tests { + tc := tc + t.Run(fmt.Sprintf("%T", tc), func(t *testing.T) { + got, err := Marshal(tc) + assert.NoError(t, err) + if !reflect2.IsNil(tc) { + assert.Equal(t, want, got) + } else { + assert.Equal(t, []byte("null"), got) + } + }) + } +} + +func TestMarshalerError(t *testing.T) { + errStr := "marshaler error" + m := &mocker{} + m.On("MarshalGraphson").Return(nil, errors.New(errStr)).Once() + defer m.AssertExpectations(t) + + _, err := Marshal(m) + assert.Error(t, err) + assert.Contains(t, err.Error(), errStr) +} + +func TestBadMarshaler(t *testing.T) { + m := &mocker{} + m.On("MarshalGraphson").Return([]byte(`{"@type": "g:Int32", "@value":`), nil).Once() + defer m.AssertExpectations(t) + _, err := Marshal(m) + assert.Error(t, err) +} + +func TestUnmarshalerDecode(t *testing.T) { + data := `{"@type": "g:UUID", "@value": "cb682578-9d92-4499-9ebc-5c6aa73c5397"}` + var value string + + m := &mocker{} + m.On("UnmarshalGraphson", mock.Anything). + Run(func(args mock.Arguments) { + data := args.Get(0).([]byte) + value = jsoniter.Get(data, "@value").ToString() + }). + Return(nil). + Once() + defer m.AssertExpectations(t) + + err := UnmarshalFromString(data, m) + require.NoError(t, err) + assert.Equal(t, "cb682578-9d92-4499-9ebc-5c6aa73c5397", value) +} + +func TestUnmarshalerError(t *testing.T) { + errStr := "unmarshaler error" + m := &mocker{} + m.On("UnmarshalGraphson", mock.Anything).Return(errors.New(errStr)).Once() + defer m.AssertExpectations(t) + + err := Unmarshal([]byte(`{}`), m) + require.Error(t, err) + assert.Contains(t, err.Error(), + fmt.Sprintf("graphson: error calling UnmarshalGraphson for type %s: %s", + reflect.TypeOf(m), errStr, + ), + ) +} + +func TestUnmarshalBadInput(t *testing.T) { + m := &mocker{} + defer m.AssertExpectations(t) + err := UnmarshalFromString(`{"@type"}`, m) + assert.Error(t, err) +} diff --git a/dialect/gremlin/encoding/graphson/native.go b/dialect/gremlin/encoding/graphson/native.go new file mode 100644 index 0000000..9e26041 --- /dev/null +++ b/dialect/gremlin/encoding/graphson/native.go @@ -0,0 +1,137 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package graphson + +import ( + "fmt" + "io" + "math" + "reflect" + "unsafe" + + jsoniter "github.com/json-iterator/go" + "github.com/modern-go/reflect2" +) + +// EncoderOfNative returns a value encoder of a native type. +func (encodeExtension) EncoderOfNative(typ reflect2.Type) jsoniter.ValEncoder { + switch typ.Kind() { + case reflect.Float64: + return float64Encoder{typ} + default: + return nil + } +} + +// DecoratorOfNative decorates a value encoder of a native type. +func (encodeExtension) DecoratorOfNative(typ reflect2.Type, enc jsoniter.ValEncoder) jsoniter.ValEncoder { + switch typ.Kind() { + case reflect.Bool, reflect.String: + return enc + case reflect.Int64, reflect.Int, reflect.Uint32: + return typeEncoder{enc, int64Type} + case reflect.Int32, reflect.Int8, reflect.Uint16: + return typeEncoder{enc, int32Type} + case reflect.Int16: + return typeEncoder{enc, int16Type} + case reflect.Uint64, reflect.Uint: + return typeEncoder{enc, bigIntegerType} + case reflect.Uint8: + return typeEncoder{enc, byteType} + case reflect.Float32: + return typeEncoder{enc, floatType} + case reflect.Float64: + return typeEncoder{enc, doubleType} + default: + return nil + } +} + +// DecoderOfNative returns a value decoder of a native type. +func (decodeExtension) DecoderOfNative(typ reflect2.Type) jsoniter.ValDecoder { + switch typ.Kind() { + case reflect.Float64: + return float64Decoder{typ} + default: + return nil + } +} + +// DecoratorOfNative decorates a value decoder of a native type. +func (decodeExtension) DecoratorOfNative(typ reflect2.Type, dec jsoniter.ValDecoder) jsoniter.ValDecoder { + switch typ.Kind() { + case reflect.Bool: + return dec + case reflect.String: + return typeDecoder{dec, typeCheckerFunc(func(Type) error { return nil })} + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return typeDecoder{dec, integerTypes} + case reflect.Float32: + return typeDecoder{dec, floatTypes} + case reflect.Float64: + return typeDecoder{dec, doubleTypes} + default: + return nil + } +} + +type float64Encoder struct { + reflect2.Type +} + +func (enc float64Encoder) IsEmpty(ptr unsafe.Pointer) bool { + return enc.UnsafeIndirect(ptr).(float64) == 0 +} + +func (enc float64Encoder) Encode(ptr unsafe.Pointer, stream *jsoniter.Stream) { + f := enc.UnsafeIndirect(ptr).(float64) + switch { + case math.IsNaN(f): + stream.WriteString("NaN") + case math.IsInf(f, 1): + stream.WriteString("Infinity") + case math.IsInf(f, -1): + stream.WriteString("-Infinity") + default: + stream.WriteFloat64(f) + } +} + +type float64Decoder struct { + reflect2.Type +} + +var ( + integerTypes = Types{byteType, int16Type, int32Type, int64Type, bigIntegerType} + floatTypes = append(integerTypes, floatType, bigDecimal) + doubleTypes = append(floatTypes, doubleType) +) + +func (dec float64Decoder) Decode(ptr unsafe.Pointer, iter *jsoniter.Iterator) { + var val float64 + switch next := iter.WhatIsNext(); next { + case jsoniter.NumberValue: + val = iter.ReadFloat64() + case jsoniter.StringValue: + switch str := iter.ReadString(); str { + case "NaN": + val = math.NaN() + case "Infinity": + val = math.Inf(1) + case "-Infinity": + val = math.Inf(-1) + default: + iter.ReportError("decode float64", "invalid value "+str) + } + default: + iter.ReportError("decode float64", fmt.Sprintf("unexpected value type: %d", next)) + } + + if iter.Error == nil || iter.Error == io.EOF { + // nolint: gas + dec.UnsafeSet(ptr, unsafe.Pointer(&val)) + } +} diff --git a/dialect/gremlin/encoding/graphson/native_test.go b/dialect/gremlin/encoding/graphson/native_test.go new file mode 100644 index 0000000..cabe718 --- /dev/null +++ b/dialect/gremlin/encoding/graphson/native_test.go @@ -0,0 +1,299 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package graphson + +import ( + "fmt" + "math" + "testing" + + jsoniter "github.com/json-iterator/go" + "github.com/modern-go/reflect2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEncodeNative(t *testing.T) { + tests := []struct { + in any + want string + wantErr bool + }{ + { + in: true, + want: "true", + }, + { + in: "hello", + want: `"hello"`, + }, + { + in: int8(120), + want: `{ + "@type": "g:Int32", + "@value": 120 + }`, + }, + { + in: int16(-16), + want: `{ + "@type": "gx:Int16", + "@value": -16 + }`, + }, + { + in: int32(3232), + want: `{ + "@type": "g:Int32", + "@value": 3232 + }`, + }, + { + in: int64(646464), + want: `{ + "@type": "g:Int64", + "@value": 646464 + }`, + }, + { + in: int(127001), + want: `{ + "@type": "g:Int64", + "@value": 127001 + }`, + }, + { + in: uint8(81), + want: `{ + "@type": "gx:Byte", + "@value": 81 + }`, + }, + { + in: uint16(12345), + want: `{ + "@type": "g:Int32", + "@value": 12345 + }`, + }, + { + in: uint32(123454321), + want: `{ + "@type": "g:Int64", + "@value": 123454321 + }`, + }, + { + in: uint64(1234567890), + want: `{ + "@type": "gx:BigInteger", + "@value": 1234567890 + }`, + }, + { + in: uint(9876543210), + want: `{ + "@type" :"gx:BigInteger", + "@value": 9876543210 + }`, + }, + { + in: float32(math.Pi), + want: `{ + "@type": "g:Float", + "@value": 3.1415927 + }`, + }, + { + in: float64(math.E), + want: `{ + "@type": "g:Double", + "@value": 2.718281828459045 + }`, + }, + { + in: math.NaN(), + want: `{ + "@type": "g:Double", + "@value": "NaN" + }`, + }, + { + in: math.Inf(1), + want: `{ + "@type": "g:Double", + "@value": "Infinity" + }`, + }, + { + in: math.Inf(-1), + want: `{ + "@type": "g:Double", + "@value": "-Infinity" + }`, + }, + { + in: func() *int { v := 7142; return &v }(), + want: `{ + "@type": "g:Int64", + "@value": 7142 + }`, + }, + { + in: func() any { v := int16(6116); return &v }(), + want: `{ + "@type": "gx:Int16", + "@value": 6116 + }`, + }, + { + in: nil, + want: "null", + }, + { + in: make(chan int), + wantErr: true, + }, + } + + for _, tc := range tests { + tc := tc + t.Run(fmt.Sprintf("%T", tc.in), func(t *testing.T) { + t.Parallel() + got, err := MarshalToString(tc.in) + if !tc.wantErr { + assert.NoError(t, err) + assert.JSONEq(t, tc.want, got) + } else { + assert.Error(t, err) + assert.Empty(t, got) + } + }) + } +} + +func TestDecodeNative(t *testing.T) { + tests := []struct { + in string + want any + }{ + { + in: `{"@type": "g:Float", "@value": 3.14}`, + want: float32(3.14), + }, + { + in: `{"@type": "g:Float", "@value": "Float"}`, + }, + { + in: `{"@type": "g:Double", "@value": 2.71}`, + want: float64(2.71), + }, + { + in: `{"@type": "gx:BigDecimal", "@value": 3.142}`, + want: float32(3.142), + }, + { + in: `{"@type": "gx:BigDecimal", "@value": 55512.5176}`, + want: float64(55512.5176), + }, + { + in: `{"@type": "g:T", "@value": "world"}`, + want: "world", + }, + } + + for _, tc := range tests { + tc := tc + t.Run(fmt.Sprintf("%T", tc.want), func(t *testing.T) { + t.Parallel() + if tc.want != nil { + typ := reflect2.TypeOf(tc.want) + got := typ.New() + err := UnmarshalFromString(tc.in, got) + require.NoError(t, err) + assert.Equal(t, tc.want, typ.Indirect(got)) + } else { + var msg jsoniter.RawMessage + err := UnmarshalFromString(tc.in, &msg) + assert.Error(t, err) + } + }) + } +} + +func TestDecodeTypeMismatch(t *testing.T) { + t.Run("FloatToInt", func(t *testing.T) { + var v int + err := UnmarshalFromString(`{"@type": "g:Float", "@value": 3.14}`, &v) + assert.Error(t, err) + }) + t.Run("DoubleToFloat", func(t *testing.T) { + var v float32 + err := UnmarshalFromString(`{"@type": "g:Double", "@value": 5.51}`, &v) + assert.Error(t, err) + }) + t.Run("BigDecimalToUint64", func(t *testing.T) { + var v uint64 + err := UnmarshalFromString(`{"@type": "gx:BigDecimal", "@value": 5645.51834}`, &v) + assert.Error(t, err) + }) +} + +func TestDecodeNaNInfinity(t *testing.T) { + tests := []struct { + data []byte + expect func(*testing.T, float64, error) + }{ + { + data: []byte(`{"@type": "g:Double", "@value": "NaN"}`), + expect: func(t *testing.T, f float64, err error) { + assert.NoError(t, err) + assert.True(t, math.IsNaN(f)) + }, + }, + { + data: []byte(`{"@type": "g:Double", "@value": "Infinity"}`), + expect: func(t *testing.T, f float64, err error) { + assert.NoError(t, err) + assert.True(t, math.IsInf(f, 1)) + }, + }, + { + data: []byte(`{"@type": "g:Double", "@value": "-Infinity"}`), + expect: func(t *testing.T, f float64, err error) { + assert.NoError(t, err) + assert.True(t, math.IsInf(f, -1)) + }, + }, + { + data: []byte(`{"@type": "g:Double", "@value": "Junk"}`), + expect: func(t *testing.T, _ float64, err error) { + assert.Error(t, err) + }, + }, + { + data: []byte(`{"@type": "g:Double", "@value": [42]}`), + expect: func(t *testing.T, _ float64, err error) { + assert.Error(t, err) + }, + }, + } + + for _, tc := range tests { + var f float64 + err := Unmarshal(tc.data, &f) + tc.expect(t, f, err) + } +} + +func TestDecodeTypeDefinition(t *testing.T) { + type Status int + const StatusOk Status = 42 + + var status Status + err := UnmarshalFromString(`{"@type": "g:Int64", "@value": 42}`, &status) + assert.NoError(t, err) + assert.Equal(t, StatusOk, status) +} diff --git a/dialect/gremlin/encoding/graphson/raw.go b/dialect/gremlin/encoding/graphson/raw.go new file mode 100644 index 0000000..7e9939a --- /dev/null +++ b/dialect/gremlin/encoding/graphson/raw.go @@ -0,0 +1,35 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package graphson + +import ( + "errors" +) + +// RawMessage is a raw encoded graphson value. +type RawMessage []byte + +// RawMessage must implement Marshaler/Unmarshaler interfaces. +var ( + _ Marshaler = (*RawMessage)(nil) + _ Unmarshaler = (*RawMessage)(nil) +) + +// MarshalGraphson returns m as the graphson encoding of m. +func (m RawMessage) MarshalGraphson() ([]byte, error) { + if m == nil { + return []byte("null"), nil + } + return m, nil +} + +// UnmarshalGraphson sets *m to a copy of data. +func (m *RawMessage) UnmarshalGraphson(data []byte) error { + if m == nil { + return errors.New("graphson.RawMessage: UnmarshalGraphson on nil pointer") + } + *m = append((*m)[0:0], data...) + return nil +} diff --git a/dialect/gremlin/encoding/graphson/raw_test.go b/dialect/gremlin/encoding/graphson/raw_test.go new file mode 100644 index 0000000..e17097e --- /dev/null +++ b/dialect/gremlin/encoding/graphson/raw_test.go @@ -0,0 +1,32 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package graphson + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRawMessageEncoding(t *testing.T) { + var s struct{ M RawMessage } + got, err := MarshalToString(s) + require.NoError(t, err) + assert.Equal(t, `{"M":null}`, got) + + s.M = []byte(`"155a"`) + got, err = MarshalToString(s) + require.NoError(t, err) + assert.JSONEq(t, `{"M": "155a"}`, got) + + err = (*RawMessage)(nil).UnmarshalGraphson(s.M) + assert.Error(t, err) + + s.M = nil + err = UnmarshalFromString(got, &s) + require.NoError(t, err) + assert.Equal(t, `"155a"`, string(s.M)) +} diff --git a/dialect/gremlin/encoding/graphson/slice.go b/dialect/gremlin/encoding/graphson/slice.go new file mode 100644 index 0000000..9c3f96f --- /dev/null +++ b/dialect/gremlin/encoding/graphson/slice.go @@ -0,0 +1,142 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package graphson + +import ( + "fmt" + "io" + "reflect" + "unsafe" + + jsoniter "github.com/json-iterator/go" + "github.com/modern-go/reflect2" +) + +// DecoratorOfSlice decorates a value encoder of a slice type. +func (encodeExtension) DecoratorOfSlice(typ reflect2.Type, enc jsoniter.ValEncoder) jsoniter.ValEncoder { + encoder := typeEncoder{ValEncoder: enc} + sliceType := typ.(reflect2.SliceType) + if sliceType.Elem().Kind() == reflect.Uint8 { + encoder.Type = byteBufferType + } else { + encoder.Type = listType + } + return sliceEncoder{sliceType, encoder} +} + +// DecoratorOfArray decorates a value encoder of an array type. +func (encodeExtension) DecoratorOfArray(enc jsoniter.ValEncoder) jsoniter.ValEncoder { + return typeEncoder{enc, listType} +} + +// DecoderOfSlice returns a value decoder of a slice type. +func (ext decodeExtension) DecoderOfSlice(typ reflect2.Type) jsoniter.ValDecoder { + sliceType := typ.(reflect2.SliceType) + elemType := sliceType.Elem() + if elemType.Kind() == reflect.Uint8 { + return nil + } + return sliceDecoder{ + sliceType: sliceType, + elemDec: ext.LazyDecoderOf(elemType), + } +} + +// DecoderOfArray returns a value decoder of an array type. +func (ext decodeExtension) DecoderOfArray(typ reflect2.Type) jsoniter.ValDecoder { + arrayType := typ.(reflect2.ArrayType) + return arrayDecoder{ + arrayType: arrayType, + elemDec: ext.LazyDecoderOf(arrayType.Elem()), + } +} + +// DecoratorOfSlice decorates a value decoder of a slice type. +func (ext decodeExtension) DecoratorOfSlice(typ reflect2.Type, dec jsoniter.ValDecoder) jsoniter.ValDecoder { + if typ.(reflect2.SliceType).Elem().Kind() == reflect.Uint8 { + return typeDecoder{dec, byteBufferType} + } + return typeDecoder{dec, listType} +} + +// DecoratorOfArray decorates a value decoder of an array type. +func (ext decodeExtension) DecoratorOfArray(dec jsoniter.ValDecoder) jsoniter.ValDecoder { + return typeDecoder{dec, listType} +} + +type sliceEncoder struct { + sliceType reflect2.SliceType + jsoniter.ValEncoder +} + +func (enc sliceEncoder) Encode(ptr unsafe.Pointer, stream *jsoniter.Stream) { + if enc.sliceType.UnsafeIsNil(ptr) { + stream.WriteNil() + } else { + enc.ValEncoder.Encode(ptr, stream) + } +} + +type sliceDecoder struct { + sliceType reflect2.SliceType + elemDec jsoniter.ValDecoder +} + +func (dec sliceDecoder) Decode(ptr unsafe.Pointer, iter *jsoniter.Iterator) { + dec.decode(ptr, iter) + if iter.Error != nil && iter.Error != io.EOF { + iter.Error = fmt.Errorf("decoding slice %s: %w", dec.sliceType, iter.Error) + } +} + +func (dec sliceDecoder) decode(ptr unsafe.Pointer, iter *jsoniter.Iterator) { + sliceType := dec.sliceType + if iter.ReadNil() { + sliceType.UnsafeSetNil(ptr) + return + } + + sliceType.UnsafeSet(ptr, sliceType.UnsafeMakeSlice(0, 0)) + var length int + + iter.ReadArrayCB(func(iter *jsoniter.Iterator) bool { + idx := length + length++ + sliceType.UnsafeGrow(ptr, length) + elem := sliceType.UnsafeGetIndex(ptr, idx) + dec.elemDec.Decode(elem, iter) + return iter.Error == nil + }) +} + +type arrayDecoder struct { + arrayType reflect2.ArrayType + elemDec jsoniter.ValDecoder +} + +func (dec arrayDecoder) Decode(ptr unsafe.Pointer, iter *jsoniter.Iterator) { + dec.decode(ptr, iter) + if iter.Error != nil && iter.Error != io.EOF { + iter.Error = fmt.Errorf("decoding array %s: %w", dec.arrayType, iter.Error) + } +} + +func (dec arrayDecoder) decode(ptr unsafe.Pointer, iter *jsoniter.Iterator) { + var ( + arrayType = dec.arrayType + length int + ) + iter.ReadArrayCB(func(iter *jsoniter.Iterator) bool { + if length < arrayType.Len() { + idx := length + length++ + elem := arrayType.UnsafeGetIndex(ptr, idx) + dec.elemDec.Decode(elem, iter) + } else { + iter.Skip() + } + return iter.Error == nil + }) +} diff --git a/dialect/gremlin/encoding/graphson/slice_test.go b/dialect/gremlin/encoding/graphson/slice_test.go new file mode 100644 index 0000000..616441a --- /dev/null +++ b/dialect/gremlin/encoding/graphson/slice_test.go @@ -0,0 +1,219 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package graphson + +import ( + "bytes" + "fmt" + "strings" + "testing" + + "github.com/modern-go/reflect2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEncodeArray(t *testing.T) { + t.Parallel() + got, err := MarshalToString([...]string{"a", "b", "c"}) + require.NoError(t, err) + want := `{ "@type": "g:List", "@value": ["a", "b", "c"]}` + assert.JSONEq(t, want, got) +} + +func TestEncodeSlice(t *testing.T) { + tests := []struct { + in any + want string + }{ + { + in: []int32{5, 6, 7, 8}, + want: `{ + "@type": "g:List", + "@value": [ + { + "@type": "g:Int32", + "@value": 5 + }, + { + "@type": "g:Int32", + "@value": 6 + }, + { + "@type": "g:Int32", + "@value": 7 + }, + { + "@type": "g:Int32", + "@value": 8 + } + ] + }`, + }, + { + in: []byte{1, 2, 3, 4, 5}, + want: `{ + "@type": "gx:ByteBuffer", + "@value": "AQIDBAU=" + }`, + }, + { + in: [...]byte{4, 5}, + want: `{ + "@type": "g:List", + "@value": [ + { + "@type": "gx:Byte", + "@value": 4 + }, + { + "@type": "gx:Byte", + "@value": 5 + } + ] + }`, + }, + { + in: []uint64(nil), + want: "null", + }, + } + for _, tc := range tests { + tc := tc + t.Run(fmt.Sprintf("%T", tc.in), func(t *testing.T) { + t.Parallel() + var got bytes.Buffer + err := NewEncoder(&got).Encode(tc.in) + assert.NoError(t, err) + assert.JSONEq(t, tc.want, got.String()) + }) + } +} + +func TestDecodeSlice(t *testing.T) { + tests := []struct { + in string + want any + }{ + { + in: `{ + "@type": "g:List", + "@value": [ + { + "@type": "g:Int32", + "@value": 3 + }, + { + "@type": "g:Int32", + "@value": -2 + }, + { + "@type": "g:Int32", + "@value": 1 + } + ] + }`, + want: []int32{3, -2, 1}, + }, + { + in: `{ + "@type": "g:List", + "@value": ["a", "b", "c"] + }`, + want: []string{"a", "b", "c"}, + }, + { + in: `{ + "@type": "gx:ByteBuffer", + "@value": "AQIDBAU=" + }`, + want: []byte{1, 2, 3, 4, 5}, + }, + { + in: `{ + "@type": "g:List", + "@value": [ + { + "@type": "gx:Byte", + "@value": 42 + }, + { + "@type": "gx:Byte", + "@value": 55 + }, + { + "@type": "gx:Byte", + "@value": 94 + } + ] + }`, + want: [...]byte{42, 55}, + }, + } + + for _, tc := range tests { + tc := tc + t.Run(fmt.Sprintf("%T", tc.want), func(t *testing.T) { + t.Parallel() + typ := reflect2.TypeOf(tc.want) + got := typ.New() + err := NewDecoder(strings.NewReader(tc.in)).Decode(got) + require.NoError(t, err) + assert.Equal(t, tc.want, typ.Indirect(got)) + }) + } +} + +func TestDecodeBadSlice(t *testing.T) { + tests := []struct { + name string + in string + new func() any + }{ + { + name: "TypeMismatch", + in: `{ + "@type": "g:List", + "@value": [ + { + "@type": "g:Int32", + "@value": 3 + }, + { + "@type": "g:Int64", + "@value": 2 + } + ] + }`, + new: func() any { return &[]int{} }, + }, + { + name: "BadValue", + in: `{ + "@type": "g:List", + "@value": [ + { + "@type": "g:Int32", + "@value": 3 + }, + { + "@type": "g:Int32", + "@value": "2" + } + ] + }`, + new: func() any { return &[2]int{} }, + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + err := NewDecoder(strings.NewReader(tc.in)).Decode(tc.new()) + assert.Error(t, err) + }) + } +} diff --git a/dialect/gremlin/encoding/graphson/struct.go b/dialect/gremlin/encoding/graphson/struct.go new file mode 100644 index 0000000..4295702 --- /dev/null +++ b/dialect/gremlin/encoding/graphson/struct.go @@ -0,0 +1,39 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package graphson + +import jsoniter "github.com/json-iterator/go" + +// DecoratorOfStructField decorates a struct field value encoder. +func (encodeExtension) DecoratorOfStructField(enc jsoniter.ValEncoder, tag string) jsoniter.ValEncoder { + typ, _ := parseTag(tag) + if typ == "" { + return nil + } + + encoder, ok := enc.(typeEncoder) + if !ok { + encoder = typeEncoder{ValEncoder: enc} + } + encoder.Type = Type(typ) + + return encoder +} + +// DecoratorOfStructField decorates a struct field value decoder. +func (decodeExtension) DecoratorOfStructField(dec jsoniter.ValDecoder, tag string) jsoniter.ValDecoder { + typ, _ := parseTag(tag) + if typ == "" { + return nil + } + + decoder, ok := dec.(typeDecoder) + if !ok { + decoder = typeDecoder{ValDecoder: dec} + } + decoder.typeChecker = Type(typ) + + return decoder +} diff --git a/dialect/gremlin/encoding/graphson/struct_test.go b/dialect/gremlin/encoding/graphson/struct_test.go new file mode 100644 index 0000000..d138e77 --- /dev/null +++ b/dialect/gremlin/encoding/graphson/struct_test.go @@ -0,0 +1,205 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package graphson + +import ( + "testing" + + "github.com/modern-go/reflect2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEncodeStruct(t *testing.T) { + tests := []struct { + name string + in any + want string + }{ + { + name: "Simple", + in: struct { + S string + I int + }{ + S: "string", + I: 1000, + }, + want: `{ + "S":"string", + "I": { + "@type": "g:Int64", + "@value": 1000 + } + }`, + }, + { + name: "Tagged", + in: struct { + ID string `json:"requestId" graphson:"g:UUID"` + Seq int `json:"seq" graphson:"g:Int32"` + Op string `json:"op" graphson:","` + Args map[string]string `json:"args"` + }{ + ID: "cb682578-9d92-4499-9ebc-5c6aa73c5397", + Seq: 42, + Op: "authentication", + Args: map[string]string{ + "sasl": "AHN0ZXBocGhlbgBwYXNzd29yZA==", + }, + }, + want: `{ + "requestId": { + "@type": "g:UUID", + "@value": "cb682578-9d92-4499-9ebc-5c6aa73c5397" + }, + "seq": { + "@type": "g:Int32", + "@value": 42 + }, + "op": "authentication", + "args": { + "@type": "g:Map", + "@value": ["sasl", "AHN0ZXBocGhlbgBwYXNzd29yZA=="] + } + }`, + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + data, err := MarshalToString(tc.in) + require.NoError(t, err) + assert.JSONEq(t, tc.want, data) + }) + } +} + +func TestEncodeNestedStruct(t *testing.T) { + type S struct { + Parent *S `json:"parent,omitempty"` + ID int `json:"id" graphson:"g:Int32"` + } + + v := S{Parent: &S{ID: 1}, ID: 2} + want := `{ + "id": { + "@type": "g:Int32", + "@value": 2 + }, + "parent": { + "id": { + "@type": "g:Int32", + "@value": 1 + } + } + }` + + got, err := MarshalToString(&v) + require.NoError(t, err) + assert.JSONEq(t, want, got) +} + +func TestDecodeStruct(t *testing.T) { + tests := []struct { + name string + in string + want any + }{ + { + name: "Simple", + in: `{ + "S":"str", + "I": { + "@type": "g:Int32", + "@value": 9999 + } + }`, + want: struct { + S string + I int32 + }{ + S: "str", + I: 9999, + }, + }, + { + name: "Tagged", + in: `{ + "requestId": { + "@type": "g:UUID", + "@value": "cb682578-9d92-4499-9ebc-5c6aa73c5397" + }, + "seq": { + "@type": "g:Int32", + "@value": 42 + }, + "op": "authentication", + "args": { + "@type": "g:Map", + "@value": ["sasl", "AHN0ZXBocGhlbgBwYXNzd29yZA=="] + } + }`, + want: struct { + ID string `json:"requestId" graphson:"g:UUID"` + Seq int `json:"seq" graphson:"g:Int32"` + Op string `json:"op" graphson:","` + Args map[string]string `json:"args"` + }{ + ID: "cb682578-9d92-4499-9ebc-5c6aa73c5397", + Seq: 42, + Op: "authentication", + Args: map[string]string{ + "sasl": "AHN0ZXBocGhlbgBwYXNzd29yZA==", + }, + }, + }, + { + name: "Empty", + in: `{}`, + want: struct{}{}, + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + typ := reflect2.TypeOf(tc.want) + got := typ.New() + err := UnmarshalFromString(tc.in, got) + require.NoError(t, err) + assert.Equal(t, tc.want, typ.Indirect(got)) + }) + } +} + +func TestDecodeNestedStruct(t *testing.T) { + type S struct { + Parent *S `json:"parent,omitempty"` + ID int `json:"id" graphson:"g:Int32"` + } + + in := `{ + "id": { + "@type": "g:Int32", + "@value": 37 + }, + "parent": { + "id": { + "@type": "g:Int32", + "@value": 65 + } + } + }` + var got S + err := UnmarshalFromString(in, &got) + require.NoError(t, err) + + want := S{Parent: &S{ID: 65}, ID: 37} + assert.Equal(t, want, got) +} diff --git a/dialect/gremlin/encoding/graphson/tags.go b/dialect/gremlin/encoding/graphson/tags.go new file mode 100644 index 0000000..45f217a --- /dev/null +++ b/dialect/gremlin/encoding/graphson/tags.go @@ -0,0 +1,39 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package graphson + +import ( + "strings" +) + +type tagOptions string + +// parseTag splits a struct field's graphson tag into its type and +// comma-separated options. +func parseTag(tag string) (string, tagOptions) { + if idx := strings.Index(tag, ","); idx != -1 { + return tag[:idx], tagOptions(tag[idx+1:]) + } + return tag, "" +} + +// Contains reports whether a comma-separated list of options +// contains a particular substr flag. substr must be surrounded by a +// string boundary or commas. +func (opts tagOptions) Contains(opt string) bool { + s := string(opts) + for s != "" { + var next string + i := strings.Index(s, ",") + if i >= 0 { + s, next = s[:i], s[i+1:] + } + if s == opt { + return true + } + s = next + } + return false +} diff --git a/dialect/gremlin/encoding/graphson/tags_test.go b/dialect/gremlin/encoding/graphson/tags_test.go new file mode 100644 index 0000000..4658285 --- /dev/null +++ b/dialect/gremlin/encoding/graphson/tags_test.go @@ -0,0 +1,60 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package graphson + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParseTag(t *testing.T) { + tests := []struct { + name string + tag string + typ string + opts tagOptions + }{ + { + name: "Empty", + }, + { + name: "TypeOnly", + tag: "g:Int32", + typ: "g:Int32", + }, + { + name: "OptsOnly", + tag: ",opt1,opt2", + opts: "opt1,opt2", + }, + { + name: "TypeAndOpts", + tag: "g:UUID,opt3,opt4", + typ: "g:UUID", + opts: "opt3,opt4", + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + typ, opts := parseTag(tc.tag) + assert.Equal(t, tc.typ, typ) + assert.Equal(t, tc.opts, opts) + }) + } +} + +func TestTagOptionsContains(t *testing.T) { + _, opts := parseTag(",opt1,opt2,opt3") + assert.True(t, opts.Contains("opt1")) + assert.True(t, opts.Contains("opt2")) + assert.True(t, opts.Contains("opt3")) + assert.False(t, opts.Contains("opt4")) + assert.False(t, opts.Contains("opt11")) + assert.False(t, opts.Contains("")) +} diff --git a/dialect/gremlin/encoding/graphson/time.go b/dialect/gremlin/encoding/graphson/time.go new file mode 100644 index 0000000..e0bfe71 --- /dev/null +++ b/dialect/gremlin/encoding/graphson/time.go @@ -0,0 +1,34 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package graphson + +import ( + "time" + "unsafe" + + jsoniter "github.com/json-iterator/go" +) + +func init() { + RegisterTypeEncoder("time.Time", typeEncoder{timeCodec{}, Timestamp}) + RegisterTypeDecoder("time.Time", typeDecoder{timeCodec{}, Types{Timestamp, Date}}) +} + +type timeCodec struct{} + +func (timeCodec) IsEmpty(ptr unsafe.Pointer) bool { + ts := *((*time.Time)(ptr)) + return ts.IsZero() +} + +func (timeCodec) Encode(ptr unsafe.Pointer, stream *jsoniter.Stream) { + ts := *((*time.Time)(ptr)) + stream.WriteInt64(ts.UnixNano() / time.Millisecond.Nanoseconds()) +} + +func (timeCodec) Decode(ptr unsafe.Pointer, iter *jsoniter.Iterator) { + ns := iter.ReadInt64() * time.Millisecond.Nanoseconds() + *((*time.Time)(ptr)) = time.Unix(0, ns) +} diff --git a/dialect/gremlin/encoding/graphson/time_test.go b/dialect/gremlin/encoding/graphson/time_test.go new file mode 100644 index 0000000..6956e33 --- /dev/null +++ b/dialect/gremlin/encoding/graphson/time_test.go @@ -0,0 +1,36 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package graphson + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTimeEncoding(t *testing.T) { + const ms = 1481750076295 + ts := time.Unix(0, ms*time.Millisecond.Nanoseconds()) + + for _, v := range []any{ts, &ts} { + got, err := MarshalToString(v) + require.NoError(t, err) + assert.JSONEq(t, `{ "@type": "g:Timestamp", "@value": 1481750076295 }`, got) + } + + strs := []string{ + `{ "@type": "g:Timestamp", "@value": 1481750076295 }`, + `{ "@type": "g:Date", "@value": 1481750076295 }`, + } + + for _, str := range strs { + var v time.Time + err := UnmarshalFromString(str, &v) + assert.NoError(t, err) + assert.True(t, ts.Equal(v)) + } +} diff --git a/dialect/gremlin/encoding/graphson/type.go b/dialect/gremlin/encoding/graphson/type.go new file mode 100644 index 0000000..f73977c --- /dev/null +++ b/dialect/gremlin/encoding/graphson/type.go @@ -0,0 +1,155 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package graphson + +import ( + "fmt" + "reflect" + "strings" + "unsafe" + + jsoniter "github.com/json-iterator/go" + "github.com/modern-go/reflect2" +) + +// A Type is a graphson type. +type Type string + +// graphson typed value types. +const ( + // core + doubleType Type = "g:Double" + floatType Type = "g:Float" + int32Type Type = "g:Int32" + int64Type Type = "g:Int64" + listType Type = "g:List" + mapType Type = "g:Map" + Timestamp Type = "g:Timestamp" + Date Type = "g:Date" + + // extended + bigIntegerType Type = "gx:BigInteger" + bigDecimal Type = "gx:BigDecimal" + byteType Type = "gx:Byte" + byteBufferType Type = "gx:ByteBuffer" + int16Type Type = "gx:Int16" +) + +// String implements fmt.Stringer interface. +func (typ Type) String() string { + return string(typ) +} + +// CheckType implements typeChecker interface. +func (typ Type) CheckType(other Type) error { + if typ != other { + return fmt.Errorf("expect type %s, but found %s", typ, other) + } + return nil +} + +// Types is a slice of Type. +type Types []Type + +// Contains reports whether a slice of types contains a particular type. +func (types Types) Contains(typ Type) bool { + for i := range types { + if types[i] == typ { + return true + } + } + return false +} + +// String implements fmt.Stringer interface. +func (types Types) String() string { + var builder strings.Builder + builder.WriteByte('[') + for i := range types { + if i > 0 { + builder.WriteByte(',') + } + builder.WriteString(types[i].String()) + } + builder.WriteByte(']') + return builder.String() +} + +// CheckType implements typeChecker interface. +func (types Types) CheckType(typ Type) error { + if !types.Contains(typ) { + return fmt.Errorf("expect any of %s, but found %s", types, typ) + } + return nil +} + +// Typer is the interface implemented by types that +// define an underlying graphson type. +type Typer interface { + GraphsonType() Type +} + +var typerType = reflect2.TypeOfPtr((*Typer)(nil)).Elem() + +// DecoratorOfTyper decorates a value encoder of a Typer interface. +func (ext encodeExtension) DecoratorOfTyper(typ reflect2.Type, enc jsoniter.ValEncoder) jsoniter.ValEncoder { + if typ.Kind() != reflect.Struct { + return nil + } + if typ.Implements(typerType) { + return typerEncoder{ + typeEncoder: typeEncoder{ValEncoder: enc}, + typerOf: func(ptr unsafe.Pointer) Typer { + return typ.UnsafeIndirect(ptr).(Typer) + }, + } + } + ptrType := reflect2.PtrTo(typ) + if ptrType.Implements(typerType) { + return typerEncoder{ + typeEncoder: typeEncoder{ValEncoder: enc}, + typerOf: func(ptr unsafe.Pointer) Typer { + // nolint: gas + return ptrType.UnsafeIndirect(unsafe.Pointer(&ptr)).(Typer) + }, + } + } + return nil +} + +// DecoratorOfTyper decorates a value decoder of a Typer interface. +func (ext decodeExtension) DecoratorOfTyper(typ reflect2.Type, dec jsoniter.ValDecoder) jsoniter.ValDecoder { + ptrType := reflect2.PtrTo(typ) + if ptrType.Implements(typerType) { + return typerDecoder{ + typeDecoder: typeDecoder{ValDecoder: dec}, + typerOf: func(ptr unsafe.Pointer) Typer { + // nolint: gas + return ptrType.UnsafeIndirect(unsafe.Pointer(&ptr)).(Typer) + }, + } + } + return nil +} + +type typerEncoder struct { + typeEncoder + typerOf func(unsafe.Pointer) Typer +} + +func (enc typerEncoder) Encode(ptr unsafe.Pointer, stream *jsoniter.Stream) { + enc.typeEncoder.Type = enc.typerOf(ptr).GraphsonType() + enc.typeEncoder.Encode(ptr, stream) +} + +type typerDecoder struct { + typeDecoder + typerOf func(unsafe.Pointer) Typer +} + +func (dec typerDecoder) Decode(ptr unsafe.Pointer, iter *jsoniter.Iterator) { + dec.typeDecoder.typeChecker = dec.typerOf(ptr).GraphsonType() + dec.typeDecoder.Decode(ptr, iter) +} diff --git a/dialect/gremlin/encoding/graphson/type_test.go b/dialect/gremlin/encoding/graphson/type_test.go new file mode 100644 index 0000000..8f04248 --- /dev/null +++ b/dialect/gremlin/encoding/graphson/type_test.go @@ -0,0 +1,95 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package graphson + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestTypeCheckType(t *testing.T) { + assert.NoError(t, int32Type.CheckType(int32Type)) + assert.Error(t, int32Type.CheckType(int64Type)) +} + +func TestTypesCheckType(t *testing.T) { + assert.NoError(t, Types{int16Type, int32Type, int64Type}.CheckType(int32Type)) + assert.Error(t, Types{floatType, doubleType}.CheckType(bigIntegerType)) +} + +func TestTypesString(t *testing.T) { + assert.Equal(t, "[]", Types{}.String()) + assert.Equal(t, "[gx:Byte]", Types{byteType}.String()) + assert.Equal(t, "[gx:Int16,g:Int32,g:Int64]", Types{int16Type, int32Type, int64Type}.String()) +} + +type vertex struct { + ID int `json:"id"` + Label string `json:"label"` +} + +func (vertex) GraphsonType() Type { + return Type("g:Vertex") +} + +type mockVertex struct { + mock.Mock `json:"-"` + ID int `json:"id"` + Label string `json:"label"` +} + +func (m *mockVertex) GraphsonType() Type { + return m.Called().Get(0).(Type) +} + +func TestEncodeTyper(t *testing.T) { + m := &mockVertex{ID: 42, Label: "person"} + m.On("GraphsonType").Return(Type("g:Vertex")).Twice() + defer m.AssertExpectations(t) + + v := vertex{ID: m.ID, Label: m.Label} + var vv Typer = v + + want := `{ + "@type": "g:Vertex", + "@value": { + "id": { + "@type": "g:Int64", + "@value": 42 + }, + "label": "person" + } + }` + + for _, tc := range []any{m, &m, v, vv, &vv} { + got, err := MarshalToString(tc) + assert.NoError(t, err) + assert.JSONEq(t, want, got) + } +} + +func TestDecodeTyper(t *testing.T) { + var m mockVertex + m.On("GraphsonType").Return(Type("g:Vertex")).Once() + defer m.AssertExpectations(t) + + in := `{ + "@type": "g:Vertex", + "@value": { + "id": { + "@type": "g:Int64", + "@value": 55 + }, + "label": "user" + } + }` + + err := UnmarshalFromString(in, &m) + assert.NoError(t, err) + assert.Equal(t, 55, m.ID) + assert.Equal(t, "user", m.Label) +} diff --git a/dialect/gremlin/encoding/graphson/util.go b/dialect/gremlin/encoding/graphson/util.go new file mode 100644 index 0000000..66e693b --- /dev/null +++ b/dialect/gremlin/encoding/graphson/util.go @@ -0,0 +1,114 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package graphson + +import ( + "errors" + "io" + "unsafe" + + jsoniter "github.com/json-iterator/go" +) + +// graphson encoding type / value keys +const ( + TypeKey = "@type" + ValueKey = "@value" +) + +// typeEncoder adds graphson type information to a value encoder. +type typeEncoder struct { + jsoniter.ValEncoder + Type Type +} + +// Encode belongs to jsoniter.ValEncoder interface. +func (enc typeEncoder) Encode(ptr unsafe.Pointer, stream *jsoniter.Stream) { + stream.WriteObjectStart() + stream.WriteObjectField(TypeKey) + stream.WriteString(enc.Type.String()) + stream.WriteMore() + stream.WriteObjectField(ValueKey) + enc.ValEncoder.Encode(ptr, stream) + stream.WriteObjectEnd() +} + +type ( + // typeDecoder decorates a value decoder and adds graphson type verification. + typeDecoder struct { + jsoniter.ValDecoder + typeChecker + } + + // typeChecker defines an interface for graphson type verification. + typeChecker interface { + CheckType(Type) error + } + + // typeCheckerFunc allows the use of functions as type checkers. + typeCheckerFunc func(Type) error + + // typeValue defines a graphson type / value pair. + typeValue struct { + Type Type + Value jsoniter.RawMessage + } +) + +// Decode belongs to jsoniter.ValDecoder interface. +func (dec typeDecoder) Decode(ptr unsafe.Pointer, iter *jsoniter.Iterator) { + if iter.WhatIsNext() != jsoniter.ObjectValue { + dec.ValDecoder.Decode(ptr, iter) + return + } + + data := iter.SkipAndReturnBytes() + if iter.Error != nil && iter.Error != io.EOF { + return + } + + var tv typeValue + if err := jsoniter.Unmarshal(data, &tv); err != nil { + iter.ReportError("unmarshal type value", err.Error()) + return + } + + if err := dec.CheckType(tv.Type); err != nil { + iter.ReportError("check type", err.Error()) + return + } + + it := config.BorrowIterator(tv.Value) + defer config.ReturnIterator(it) + + dec.ValDecoder.Decode(ptr, it) + if it.Error != nil && it.Error != io.EOF { + iter.ReportError("decode value", it.Error.Error()) + } +} + +// UnmarshalJSON implements json.Unmarshaler interface. +func (tv *typeValue) UnmarshalJSON(data []byte) error { + var v struct { + Type *Type `json:"@type"` + Value jsoniter.RawMessage `json:"@value"` + } + + if err := jsoniter.Unmarshal(data, &v); err != nil { + return err + } + if v.Type == nil || v.Value == nil { + return errors.New("missing type or value") + } + + tv.Type = *v.Type + tv.Value = v.Value + return nil +} + +// CheckType implements typeChecker interface. +func (f typeCheckerFunc) CheckType(typ Type) error { + return f(typ) +} diff --git a/dialect/gremlin/encoding/graphson/util_test.go b/dialect/gremlin/encoding/graphson/util_test.go new file mode 100644 index 0000000..35db833 --- /dev/null +++ b/dialect/gremlin/encoding/graphson/util_test.go @@ -0,0 +1,129 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package graphson + +import ( + "bytes" + "errors" + "fmt" + "testing" + "unsafe" + + jsoniter "github.com/json-iterator/go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestTypeEncode(t *testing.T) { + var got bytes.Buffer + stream := config.BorrowStream(&got) + defer config.ReturnStream(stream) + + typ, val := int32Type, 42 + ptr := unsafe.Pointer(&val) + + var m mocker + m.On("Encode", ptr, stream). + Run(func(args mock.Arguments) { + stream := args.Get(1).(*jsoniter.Stream) + stream.WriteInt(val) + }). + Once() + defer m.AssertExpectations(t) + + typeEncoder{&m, typ}.Encode(ptr, stream) + require.NoError(t, stream.Flush()) + want := fmt.Sprintf(`{"@type": "%s", "@value": %d}`, typ, val) + assert.JSONEq(t, want, got.String()) +} + +func TestTypeDecode(t *testing.T) { + typ, val := int64Type, 84 + ptr := unsafe.Pointer(&val) + + data := fmt.Sprintf(`{"@value": %d, "@type": "%s"}`, val, typ) + iter := config.BorrowIterator([]byte(data)) + defer config.ReturnIterator(iter) + + m := &mocker{} + m.On("CheckType", typ). + Return(nil). + Once() + m.On("Decode", ptr, mock.Anything). + Run(func(args mock.Arguments) { + iter := args.Get(1).(*jsoniter.Iterator) + assert.Equal(t, val, iter.ReadInt()) + }). + Once() + defer m.AssertExpectations(t) + + typeDecoder{m, m}.Decode(ptr, iter) + assert.NoError(t, iter.Error) +} + +func TestTypeDecodeBadType(t *testing.T) { + typ, val := int64Type, 55 + ptr := unsafe.Pointer(&val) + + m := &mocker{} + m.On("CheckType", typ).Return(errors.New("bad type")).Once() + defer m.AssertExpectations(t) + + data := fmt.Sprintf(`{"@type": "%s", "@value": %d}`, typ, val) + iter := config.BorrowIterator([]byte(data)) + defer config.ReturnIterator(iter) + + typeDecoder{m, m}.Decode(ptr, iter) + require.Error(t, iter.Error) + assert.Contains(t, iter.Error.Error(), "bad type") +} + +func TestTypeDecodeDuplicateField(t *testing.T) { + data := `{"@type": "gx:Byte", "@value": 33, "@type": "g:Int32"}` + iter := config.BorrowIterator([]byte(data)) + defer config.ReturnIterator(iter) + var ptr unsafe.Pointer + + m := &mocker{} + m.On("CheckType", mock.MatchedBy(func(typ Type) bool { return typ == int32Type })). + Return(nil). + Once() + m.On("Decode", ptr, mock.Anything). + Run(func(args mock.Arguments) { + args.Get(1).(*jsoniter.Iterator).Skip() + require.NoError(t, iter.Error) + }). + Once() + defer m.AssertExpectations(t) + + typeDecoder{m, m}.Decode(ptr, iter) + assert.NoError(t, iter.Error) +} + +func TestTypeDecodeMissingField(t *testing.T) { + data := `{"@type": "g:Int32"}` + iter := config.BorrowIterator([]byte(data)) + defer config.ReturnIterator(iter) + + m := &mocker{} + defer m.AssertExpectations(t) + + typeDecoder{m, m}.Decode(nil, iter) + require.Error(t, iter.Error) + assert.Contains(t, iter.Error.Error(), "missing type or value") +} + +func TestTypeDecodeSyntaxError(t *testing.T) { + data := `{"@type": "gx:Int16", "@value", 65000}` + iter := config.BorrowIterator([]byte(data)) + defer config.ReturnIterator(iter) + + m := &mocker{} + defer m.AssertExpectations(t) + + typeDecoder{m, m}.Decode(nil, iter) + assert.Error(t, iter.Error) +} diff --git a/dialect/gremlin/encoding/mime.go b/dialect/gremlin/encoding/mime.go new file mode 100644 index 0000000..d10f3b9 --- /dev/null +++ b/dialect/gremlin/encoding/mime.go @@ -0,0 +1,30 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package encoding + +import ( + "bytes" +) + +// Mime defines a gremlin mime type. +type Mime []byte + +// GraphSON3Mime mime headers. +var ( + GraphSON3Mime = NewMime("application/vnd.gremlin-v3.0+json") +) + +// NewMime creates a wire format mime header. +func NewMime(s string) Mime { + var buf bytes.Buffer + buf.WriteByte(byte(len(s))) + buf.WriteString(s) + return buf.Bytes() +} + +// String implements fmt.Stringer interface. +func (m Mime) String() string { + return string(m[1:]) +} diff --git a/dialect/gremlin/encoding/mime_test.go b/dialect/gremlin/encoding/mime_test.go new file mode 100644 index 0000000..aed5e1c --- /dev/null +++ b/dialect/gremlin/encoding/mime_test.go @@ -0,0 +1,21 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package encoding + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewMime(t *testing.T) { + str := "application/vnd.gremlin-v2.0+json" + mime := NewMime(str) + require.Len(t, mime, len(str)+1) + assert.EqualValues(t, len(str), mime[0]) + assert.EqualValues(t, str, mime[1:]) + assert.Equal(t, str, mime.String()) +} diff --git a/dialect/gremlin/example_test.go b/dialect/gremlin/example_test.go new file mode 100644 index 0000000..4981a2c --- /dev/null +++ b/dialect/gremlin/example_test.go @@ -0,0 +1,46 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package gremlin + +import ( + "context" + "flag" + "log" + "os" + "time" +) + +func ExampleClient_Query() { + addr := flag.String("gremlin-server", os.Getenv("GREMLIN_SERVER"), "gremlin server address") + flag.Parse() + + if *addr == "" { + log.Fatal("missing gremlin server address") + } + + client, err := NewHTTPClient(*addr, nil) + if err != nil { + log.Fatalf("creating client: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + + rsp, err := client.Query(ctx, "g.E()") + if err != nil { + log.Fatalf("executing query: %v", err) + } + + edges, err := rsp.ReadEdges() + if err != nil { + log.Fatalf("unmashal edges") + } + + defer cancel() + + for _, e := range edges { + log.Println(e.String()) + } + // - Output: +} diff --git a/dialect/gremlin/expand.go b/dialect/gremlin/expand.go new file mode 100644 index 0000000..c173f67 --- /dev/null +++ b/dialect/gremlin/expand.go @@ -0,0 +1,47 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package gremlin + +import ( + "context" + "fmt" + "sort" + "strings" + + jsoniter "github.com/json-iterator/go" +) + +// ExpandBindings expands the given RoundTripper and expands the request bindings into the Gremlin traversal. +func ExpandBindings(rt RoundTripper) RoundTripper { + return RoundTripperFunc(func(ctx context.Context, r *Request) (*Response, error) { + bindings, ok := r.Arguments[ArgsBindings] + if !ok { + return rt.RoundTrip(ctx, r) + } + query, ok := r.Arguments[ArgsGremlin] + if !ok { + return rt.RoundTrip(ctx, r) + } + { + query, bindings := query.(string), bindings.(map[string]any) + keys := make(sort.StringSlice, 0, len(bindings)) + for k := range bindings { + keys = append(keys, k) + } + sort.Sort(sort.Reverse(keys)) + kv := make([]string, 0, len(bindings)*2) + for _, k := range keys { + s, err := jsoniter.MarshalToString(bindings[k]) + if err != nil { + return nil, fmt.Errorf("marshal bindings value for key %s: %w", k, err) + } + kv = append(kv, k, s) + } + delete(r.Arguments, ArgsBindings) + r.Arguments[ArgsGremlin] = strings.NewReplacer(kv...).Replace(query) + } + return rt.RoundTrip(ctx, r) + }) +} diff --git a/dialect/gremlin/expand_test.go b/dialect/gremlin/expand_test.go new file mode 100644 index 0000000..17b251f --- /dev/null +++ b/dialect/gremlin/expand_test.go @@ -0,0 +1,71 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package gremlin + +import ( + "context" + "strconv" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestExpandBindings(t *testing.T) { + tests := []struct { + req *Request + wantErr bool + wantQuery string + }{ + { + req: NewEvalRequest("no bindings"), + wantQuery: "no bindings", + }, + { + req: NewEvalRequest("g.V($0)", WithBindings(map[string]any{"$0": 1})), + wantQuery: "g.V(1)", + }, + { + req: NewEvalRequest("g.V().has($1, $2)", WithBindings(map[string]any{"$1": "name", "$2": "a8m"})), + wantQuery: "g.V().has(\"name\", \"a8m\")", + }, + { + req: NewEvalRequest("g.V().limit(n)", WithBindings(map[string]any{"n": 10})), + wantQuery: "g.V().limit(10)", + }, + { + req: NewEvalRequest("g.V()", WithBindings(map[string]any{"$0": func() {}})), + wantErr: true, + }, + { + req: NewEvalRequest("g.V().has($0, $1)", WithBindings(map[string]any{"$0": "active", "$1": true})), + wantQuery: "g.V().has(\"active\", true)", + }, + { + req: NewEvalRequest("g.V().has($1, $11)", WithBindings(map[string]any{"$1": "active", "$11": true})), + wantQuery: "g.V().has(\"active\", true)", + }, + } + for i, tt := range tests { + tt := tt + t.Run(strconv.Itoa(i), func(t *testing.T) { + rt := ExpandBindings(RoundTripperFunc(func(ctx context.Context, r *Request) (*Response, error) { + assert.Equal(t, tt.wantQuery, r.Arguments[ArgsGremlin]) + return nil, nil + })) + _, err := rt.RoundTrip(context.Background(), tt.req) + assert.Equal(t, tt.wantErr, err != nil) + }) + } +} + +func TestExpandBindingsNoQuery(t *testing.T) { + rt := ExpandBindings(RoundTripperFunc(func(ctx context.Context, r *Request) (*Response, error) { + return nil, nil + })) + _, err := rt.RoundTrip(context.Background(), &Request{Arguments: map[string]any{ + ArgsBindings: map[string]any{}, + }}) + assert.NoError(t, err) +} diff --git a/dialect/gremlin/graph/dsl/__/dsl.go b/dialect/gremlin/graph/dsl/__/dsl.go new file mode 100644 index 0000000..13562f2 --- /dev/null +++ b/dialect/gremlin/graph/dsl/__/dsl.go @@ -0,0 +1,75 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package __ + +import "entgo.io/ent/dialect/gremlin/graph/dsl" + +// As is the api for calling __.As(). +func As(args ...any) *dsl.Traversal { return New().As(args...) } + +// Is is the api for calling __.Is(). +func Is(args ...any) *dsl.Traversal { return New().Is(args...) } + +// Not is the api for calling __.Not(). +func Not(args ...any) *dsl.Traversal { return New().Not(args...) } + +// Has is the api for calling __.Has(). +func Has(args ...any) *dsl.Traversal { return New().Has(args...) } + +// HasNot is the api for calling __.HasNot(). +func HasNot(args ...any) *dsl.Traversal { return New().HasNot(args...) } + +// Or is the api for calling __.Or(). +func Or(args ...any) *dsl.Traversal { return New().Or(args...) } + +// And is the api for calling __.And(). +func And(args ...any) *dsl.Traversal { return New().And(args...) } + +// In is the api for calling __.In(). +func In(args ...any) *dsl.Traversal { return New().In(args...) } + +// Out is the api for calling __.Out(). +func Out(args ...any) *dsl.Traversal { return New().Out(args...) } + +// OutE is the api for calling __.OutE(). +func OutE(args ...any) *dsl.Traversal { return New().OutE(args...) } + +// InE is the api for calling __.InE(). +func InE(args ...any) *dsl.Traversal { return New().InE(args...) } + +// InV is the api for calling __.InV(). +func InV(args ...any) *dsl.Traversal { return New().InV(args...) } + +// V is the api for calling __.V(). +func V(args ...any) *dsl.Traversal { return New().V(args...) } + +// OutV is the api for calling __.OutV(). +func OutV(args ...any) *dsl.Traversal { return New().OutV(args...) } + +// Values is the api for calling __.Values(). +func Values(args ...string) *dsl.Traversal { return New().Values(args...) } + +// Union is the api for calling __.Union(). +func Union(args ...any) *dsl.Traversal { return New().Union(args...) } + +// Constant is the api for calling __.Constant(). +func Constant(args ...any) *dsl.Traversal { return New().Constant(args...) } + +// Properties is the api for calling __.Properties(). +func Properties(args ...any) *dsl.Traversal { return New().Properties(args...) } + +// OtherV is the api for calling __.OtherV(). +func OtherV() *dsl.Traversal { return New().OtherV() } + +// Count is the api for calling __.Count(). +func Count() *dsl.Traversal { return New().Count() } + +// Drop is the api for calling __.Drop(). +func Drop() *dsl.Traversal { return New().Drop() } + +// Fold is the api for calling __.Fold(). +func Fold() *dsl.Traversal { return New().Fold() } + +func New() *dsl.Traversal { return new(dsl.Traversal).Add(dsl.Token("__")) } diff --git a/dialect/gremlin/graph/dsl/dsl.go b/dialect/gremlin/graph/dsl/dsl.go new file mode 100644 index 0000000..bc624dd --- /dev/null +++ b/dialect/gremlin/graph/dsl/dsl.go @@ -0,0 +1,214 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +// Package dsl provide an API for writing gremlin dsl queries almost as-is +// in Go without using strings in the code. +// +// Note that, the API is not type-safe and assume the provided query and +// its arguments are valid. +package dsl + +import ( + "fmt" + "strings" + "time" +) + +// Node represents a DSL step in the traversal. +type Node interface { + // Code returns the code representation of the element and its bindings (if any). + Code() (string, []any) +} + +type ( + // Token holds a simple token, like assignment. + Token string + // List represents a list of elements. + List struct { + Elements []any + } + // Func represents a function call. + Func struct { + Name string + Args []any + } + // Block represents a block/group of nodes. + Block struct { + Nodes []any + } + // Var represents a variable assignment and usage. + Var struct { + Name string + Elem any + } +) + +// Code stringified the token. +func (t Token) Code() (string, []any) { return string(t), nil } + +// Code returns the code representation of a list. +func (l List) Code() (string, []any) { + c, args := codeList(", ", l.Elements...) + return fmt.Sprintf("[%s]", c), args +} + +// Code returns the code representation of a function call. +func (f Func) Code() (string, []any) { + c, args := codeList(", ", f.Args...) + return fmt.Sprintf("%s(%s)", f.Name, c), args +} + +// Code returns the code representation of group/block of nodes. +func (b Block) Code() (string, []any) { + return codeList("; ", b.Nodes...) +} + +// Code returns the code representation of variable declaration or its identifier. +func (v Var) Code() (string, []any) { + c, args := code(v.Elem) + if v.Name == "" { + return c, args + } + return fmt.Sprintf("%s = %s", v.Name, c), args +} + +// predefined nodes. +var ( + G = Token("g") + Dot = Token(".") +) + +// NewFunc returns a new function node. +func NewFunc(name string, args ...any) *Func { + return &Func{Name: name, Args: args} +} + +// NewList returns a new list node. +func NewList(args ...any) *List { + return &List{Elements: args} +} + +// Querier is the interface that wraps the Query method. +type Querier interface { + // Query returns the query-string (similar to the Gremlin byte-code) and its bindings. + Query() (string, Bindings) +} + +// Bindings are used to associate a variable with a value. +type Bindings map[string]any + +// Add adds new value to the bindings map, formats it if needed, and returns its generated name. +func (b Bindings) Add(v any) string { + k := fmt.Sprintf("$%x", len(b)) + switch v := v.(type) { + case time.Time: + b[k] = v.UnixNano() + default: + b[k] = v + } + return k +} + +// Cardinality of vertex properties. +type Cardinality string + +// Cardinality options. +const ( + Set Cardinality = "set" + Single Cardinality = "single" +) + +// Code implements the Node interface. +func (c Cardinality) Code() (string, []any) { return string(c), nil } + +// Keyword defines a Gremlin keyword. +type Keyword string + +// Keyword options. +const ( + ID Keyword = "id" +) + +// Code implements the Node interface. +func (k Keyword) Code() (string, []any) { return string(k), nil } + +// Order of vertex properties. +type Order string + +// Order options. +const ( + Incr Order = "incr" + Decr Order = "decr" + Shuffle Order = "shuffle" +) + +// Code implements the Node interface. +func (o Order) Code() (string, []any) { return string(o), nil } + +// Column references a particular type of column in a complex data structure such as a Map, a Map.Entry, or a Path. +type Column string + +// Column options. +const ( + Keys Column = "keys" + Values Column = "values" +) + +// Code implements the Node interface. +func (o Column) Code() (string, []any) { return string(o), nil } + +// Scope used for steps that have a variable scope which alter the manner in which the step will behave in relation to how the traverses are processed. +type Scope string + +// Scope options. +const ( + Local Scope = "local" + Global Scope = "global" +) + +// Code implements the Node interface. +func (s Scope) Code() (string, []any) { return string(s), nil } + +func codeList(sep string, vs ...any) (string, []any) { + var ( + br strings.Builder + args []any + ) + for i, node := range vs { + if i > 0 { + br.WriteString(sep) + } + c, nargs := code(node) + br.WriteString(c) + args = append(args, nargs...) + } + return br.String(), args +} + +func code(v any) (string, []any) { + switch n := v.(type) { + case Node: + return n.Code() + case *Traversal: + var ( + b strings.Builder + args []any + ) + for i := range n.nodes { + code, nargs := n.nodes[i].Code() + b.WriteString(code) + args = append(args, nargs...) + } + return b.String(), args + default: + return "%s", []any{v} + } +} + +func sface(args []string) (v []any) { + for _, s := range args { + v = append(v, s) + } + return +} diff --git a/dialect/gremlin/graph/dsl/dsl_test.go b/dialect/gremlin/graph/dsl/dsl_test.go new file mode 100644 index 0000000..64c9d11 --- /dev/null +++ b/dialect/gremlin/graph/dsl/dsl_test.go @@ -0,0 +1,238 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package dsl_test + +import ( + "strconv" + "testing" + + "entgo.io/ent/dialect/gremlin/graph/dsl" + "entgo.io/ent/dialect/gremlin/graph/dsl/__" + "entgo.io/ent/dialect/gremlin/graph/dsl/g" + "entgo.io/ent/dialect/gremlin/graph/dsl/p" + + "github.com/stretchr/testify/require" +) + +func TestTraverse(t *testing.T) { + tests := []struct { + input dsl.Querier + wantQuery string + wantBinds dsl.Bindings + }{ + { + input: g.V(5), + wantQuery: "g.V($0)", + wantBinds: dsl.Bindings{"$0": 5}, + }, + { + input: g.V(2).Both("knows"), + wantQuery: "g.V($0).both($1)", + wantBinds: dsl.Bindings{"$0": 2, "$1": "knows"}, + }, + { + input: g.V(49).BothE("knows").OtherV().ValueMap(), + wantQuery: "g.V($0).bothE($1).otherV().valueMap()", + wantBinds: dsl.Bindings{"$0": 49, "$1": "knows"}, + }, + { + input: g.AddV("person").Property("name", "a8m").Next(), + wantQuery: "g.addV($0).property($1, $2).next()", + wantBinds: dsl.Bindings{"$0": "person", "$1": "name", "$2": "a8m"}, + }, + { + input: dsl.Each([]any{1, 2, 3}, func(it *dsl.Traversal) *dsl.Traversal { + return g.V(it) + }), + wantQuery: "[$0, $1, $2].each { g.V(it) }", + wantBinds: dsl.Bindings{"$0": 1, "$1": 2, "$2": 3}, + }, + { + input: dsl.Each([]any{g.V(1).Next()}, func(it *dsl.Traversal) *dsl.Traversal { + return it.ID() + }), + wantQuery: "[g.V($0).next()].each { it.id() }", + wantBinds: dsl.Bindings{"$0": 1}, + }, + { + input: g.AddV("person").AddE("knows").To(g.V(2)), + wantQuery: "g.addV($0).addE($1).to(g.V($2))", + wantBinds: dsl.Bindings{"$0": "person", "$1": "knows", "$2": 2}, + }, + { + input: func() *dsl.Traversal { + v1 := g.V(2).Next() + v2 := g.AddV("person").Property("name", "a8m") + e1 := g.V(v1).AddE("knows").To(v2) + return dsl.Group(v1, v2, e1) + }(), + wantQuery: "t0 = g.V($0).next(); t1 = g.addV($1).property($2, $3); t2 = g.V(t0).addE($4).to(t1); t2", + wantBinds: dsl.Bindings{"$0": 2, "$1": "person", "$2": "name", "$3": "a8m", "$4": "knows"}, + }, + { + input: func() *dsl.Traversal { + v1 := g.AddV("person") + each := dsl.Each([]any{1, 2, 3}, func(it *dsl.Traversal) *dsl.Traversal { + return g.V(v1).AddE("knows").To(g.V(it)).Next() + }) + return dsl.Group(v1, each) + }(), + wantQuery: "t0 = g.addV($0); t1 = [$1, $2, $3].each { g.V(t0).addE($4).to(g.V(it)).next() }; t1", + wantBinds: dsl.Bindings{"$0": "person", "$1": 1, "$2": 2, "$3": 3, "$4": "knows"}, + }, + { + input: g.V().HasLabel("person"). + Choose(__.Values("age").Is(p.LTE(20))), + wantQuery: "g.V().hasLabel($0).choose(__.values($1).is(lte($2)))", + wantBinds: dsl.Bindings{"$0": "person", "$1": "age", "$2": 20}, + }, + { + input: g.AddV("person").Property("name", "a8m").Properties(), + wantQuery: "g.addV($0).property($1, $2).properties()", + wantBinds: dsl.Bindings{"$0": "person", "$1": "name", "$2": "a8m"}, + }, + { + input: func() *dsl.Traversal { + v1 := g.AddV("person").Next() + e1 := g.V(v1).AddE("knows").To(g.V(2).Next()) + return dsl.Group(v1, e1, g.V(v1).ValueMap(true)) + }(), + wantQuery: "t0 = g.addV($0).next(); t1 = g.V(t0).addE($1).to(g.V($2).next()); t2 = g.V(t0).valueMap($3); t2", + wantBinds: dsl.Bindings{"$0": "person", "$1": "knows", "$2": 2, "$3": true}, + }, + { + input: func() *dsl.Traversal { + vs := g.V().HasLabel("person").ToList() + edge := g.V(vs).AddE("assoc").To(g.V(1)).Iterate() + each := dsl.Each(vs, func(it *dsl.Traversal) *dsl.Traversal { + return g.V(1).AddE("inverse").To(it).Next() + }) + return dsl.Group(vs, edge, each) + }(), + wantQuery: "t0 = g.V().hasLabel($0).toList(); t1 = g.V(t0).addE($1).to(g.V($2)).iterate(); t2 = t0.each { g.V($3).addE($4).to(it).next() }; t2", + wantBinds: dsl.Bindings{"$0": "person", "$1": "assoc", "$2": 1, "$3": 1, "$4": "inverse"}, + }, + { + input: g.V().Where(__.Or(__.Has("age", 29), __.Has("age", 30))), + wantQuery: "g.V().where(__.or(__.has($0, $1), __.has($2, $3)))", + wantBinds: dsl.Bindings{"$0": "age", "$1": 29, "$2": "age", "$3": 30}, + }, + { + input: g.V().Has("name", p.Containing("le")).Has("name", p.StartingWith("A")), + wantQuery: `g.V().has($0, containing($1)).has($2, startingWith($3))`, + wantBinds: dsl.Bindings{"$0": "name", "$1": "le", "$2": "name", "$3": "A"}, + }, + { + input: g.AddV().Property(dsl.Single, "age", 32).ValueMap(), + wantQuery: "g.addV().property(single, $0, $1).valueMap()", + wantBinds: dsl.Bindings{"$0": "age", "$1": 32}, + }, + { + input: g.V().Count(), + wantQuery: "g.V().count()", + wantBinds: dsl.Bindings{}, + }, + { + input: g.V().HasNot("age"), + wantQuery: "g.V().hasNot($0)", + wantBinds: dsl.Bindings{"$0": "age"}, + }, + { + input: func() *dsl.Traversal { + v := g.V().HasID(1) + u := v.Clone().InE().Drop() + return dsl.Join(v, u) + }(), + wantQuery: "g.V().hasId($0); g.V().hasId($1).inE().drop()", + wantBinds: dsl.Bindings{"$0": 1, "$1": 1}, + }, + { + input: func() *dsl.Traversal { + v := g.V().HasID(1) + u := v.Clone().InE().Drop() + w := u.Clone() + return dsl.Join(v, u, w) + }(), + wantQuery: "g.V().hasId($0); g.V().hasId($1).inE().drop(); g.V().hasId($2).inE().drop()", + wantBinds: dsl.Bindings{"$0": 1, "$1": 1, "$2": 1}, + }, + { + input: g.V().OutE("knows").Where(__.InV().Has("name", "a8m")).OutV(), + wantQuery: "g.V().outE($0).where(__.inV().has($1, $2)).outV()", + wantBinds: dsl.Bindings{"$0": "knows", "$1": "name", "$2": "a8m"}, + }, + { + input: g.V().Has("name", p.Within("a8m", "alex")), + wantQuery: "g.V().has($0, within($1, $2))", + wantBinds: dsl.Bindings{"$0": "name", "$1": "a8m", "$2": "alex"}, + }, + { + input: g.V().HasID(p.Within(1, 2)), + wantQuery: "g.V().hasId(within($0, $1))", + wantBinds: dsl.Bindings{"$0": 1, "$1": 2}, + }, + { + input: g.V().HasID(p.Without(1, 2)), + wantQuery: "g.V().hasId(without($0, $1))", + wantBinds: dsl.Bindings{"$0": 1, "$1": 2}, + }, + { + input: g.V().Order().By("name"), + wantQuery: "g.V().order().by($0)", + wantBinds: dsl.Bindings{"$0": "name"}, + }, + { + input: g.V().Order().By("name", dsl.Incr), + wantQuery: "g.V().order().by($0, incr)", + wantBinds: dsl.Bindings{"$0": "name"}, + }, + { + input: g.V().Order().By("name", dsl.Incr).Undo(), + wantQuery: "g.V().order()", + wantBinds: dsl.Bindings{}, + }, + { + input: g.V().OutE("knows").Where(__.InV().Has("name", "a8m")).Undo(), + wantQuery: "g.V().outE($0)", + wantBinds: dsl.Bindings{"$0": "knows"}, + }, + { + input: g.V().Has("name").Group().By("name").By("age").Select(dsl.Values), + wantQuery: "g.V().has($0).group().by($1).by($2).select(values)", + wantBinds: dsl.Bindings{"$0": "name", "$1": "name", "$2": "age"}, + }, + { + input: g.V().Fold().Unfold(), + wantQuery: "g.V().fold().unfold()", + wantBinds: dsl.Bindings{}, + }, + { + input: g.V().Has("person", "name", "a8m").Count().Coalesce( + __.Is(p.NEQ(0)).Constant("unique constraint failed"), + g.AddV("person").Property("name", "a8m").ValueMap(true), + ), + wantQuery: "g.V().has($0, $1, $2).count().coalesce(__.is(neq($3)).constant($4), g.addV($5).property($6, $7).valueMap($8))", + wantBinds: dsl.Bindings{"$0": "person", "$1": "name", "$2": "a8m", "$3": 0, "$4": "unique constraint failed", "$5": "person", "$6": "name", "$7": "a8m", "$8": true}, + }, + { + input: g.V().Has("age").Property("age", __.Union(__.Values("age"), __.Constant(10)).Sum()).ValueMap(), + wantQuery: "g.V().has($0).property($1, __.union(__.values($2), __.constant($3)).sum()).valueMap()", + wantBinds: dsl.Bindings{"$0": "age", "$1": "age", "$2": "age", "$3": 10}, + }, + { + input: g.V().Has("age").SideEffect(__.Properties("name").Drop()).ValueMap(), + wantQuery: "g.V().has($0).sideEffect(__.properties($1).drop()).valueMap()", + wantBinds: dsl.Bindings{"$0": "age", "$1": "name"}, + }, + } + for i, tt := range tests { + tt := tt + t.Run(strconv.Itoa(i), func(t *testing.T) { + query, bindings := tt.input.Query() + require.Equal(t, tt.wantQuery, query) + require.Equal(t, tt.wantBinds, bindings) + }) + } +} diff --git a/dialect/gremlin/graph/dsl/g/g.go b/dialect/gremlin/graph/dsl/g/g.go new file mode 100644 index 0000000..1df6956 --- /dev/null +++ b/dialect/gremlin/graph/dsl/g/g.go @@ -0,0 +1,19 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package g + +import "entgo.io/ent/dialect/gremlin/graph/dsl" + +// V is the api for calling g.V(). +func V(args ...any) *dsl.Traversal { return dsl.NewTraversal().V(args...) } + +// E is the api for calling g.E(). +func E(args ...any) *dsl.Traversal { return dsl.NewTraversal().E(args...) } + +// AddV is the api for calling g.AddV(). +func AddV(args ...any) *dsl.Traversal { return dsl.NewTraversal().AddV(args...) } + +// AddE is the api for calling g.AddE(). +func AddE(args ...any) *dsl.Traversal { return dsl.NewTraversal().AddE(args...) } diff --git a/dialect/gremlin/graph/dsl/p/p.go b/dialect/gremlin/graph/dsl/p/p.go new file mode 100644 index 0000000..7595dcb --- /dev/null +++ b/dialect/gremlin/graph/dsl/p/p.go @@ -0,0 +1,93 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package p + +import ( + "entgo.io/ent/dialect/gremlin/graph/dsl" +) + +// EQ is the equal predicate. +func EQ(v any) *dsl.Traversal { + return op("eq", v) +} + +// NEQ is the not-equal predicate. +func NEQ(v any) *dsl.Traversal { + return op("neq", v) +} + +// GT is the greater than predicate. +func GT(v any) *dsl.Traversal { + return op("gt", v) +} + +// GTE is the greater than or equal predicate. +func GTE(v any) *dsl.Traversal { + return op("gte", v) +} + +// LT is the less than predicate. +func LT(v any) *dsl.Traversal { + return op("lt", v) +} + +// LTE is the less than or equal predicate. +func LTE(v any) *dsl.Traversal { + return op("lte", v) +} + +// Between is the between/contains predicate. +func Between(v, u any) *dsl.Traversal { + return op("between", v, u) +} + +// StartingWith is the prefix test predicate. +func StartingWith(prefix string) *dsl.Traversal { + return op("startingWith", prefix) +} + +// EndingWith is the suffix test predicate. +func EndingWith(suffix string) *dsl.Traversal { + return op("endingWith", suffix) +} + +// Containing is the sub string test predicate. +func Containing(substr string) *dsl.Traversal { + return op("containing", substr) +} + +// NotStartingWith is the negation of StartingWith. +func NotStartingWith(prefix string) *dsl.Traversal { + return op("notStartingWith", prefix) +} + +// NotEndingWith is the negation of EndingWith. +func NotEndingWith(suffix string) *dsl.Traversal { + return op("notEndingWith", suffix) +} + +// NotContaining is the negation of Containing. +func NotContaining(substr string) *dsl.Traversal { + return op("notContaining", substr) +} + +// Within Determines if a value is within the specified list of values. +func Within[T any](args ...T) *dsl.Traversal { + return op("within", args...) +} + +// Without determines if a value is not within the specified list of values. +func Without[T any](args ...T) *dsl.Traversal { + return op("without", args...) +} + +func op[T any](name string, args ...T) *dsl.Traversal { + t := &dsl.Traversal{} + vs := make([]any, len(args)) + for i, arg := range args { + vs[i] = arg + } + return t.Add(dsl.NewFunc(name, vs...)) +} diff --git a/dialect/gremlin/graph/dsl/traversal.go b/dialect/gremlin/graph/dsl/traversal.go new file mode 100644 index 0000000..4b59581 --- /dev/null +++ b/dialect/gremlin/graph/dsl/traversal.go @@ -0,0 +1,426 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package dsl + +import ( + "errors" + "fmt" + "strings" +) + +// Traversal mimics the TinkerPop graph traversal. +type Traversal struct { + // nodes holds the dsl nodes. first element is the reference name + // of the TinkerGraph. defaults to "g". + nodes []Node + errs []error +} + +// NewTraversal returns a new default traversal with "g" as a reference name to the Graph. +func NewTraversal() *Traversal { + return &Traversal{nodes: []Node{G}} +} + +// Group groups a list of traversals into one. all traversals are assigned into a temporary +// variables named by their index. The last variable functions as a return value of the query. +// Note that, this "temporary hack" is not perfect and may not work in some cases because of +// the limitation of evaluation order. +func Group(trs ...*Traversal) *Traversal { + var ( + b = Block{} + names = make(map[*Traversal]Token) + ) + for i, tr := range trs { + if _, ok := names[tr]; ok { + continue + } + v := &Var{Name: fmt.Sprintf("t%d", i), Elem: &Traversal{nodes: tr.nodes}} + b.Nodes = append(b.Nodes, v) + names[tr] = Token(v.Name) + } + for _, tr := range trs { + tr.nodes = []Node{names[tr]} + } + b.Nodes = append(b.Nodes, names[trs[len(trs)-1]]) + return &Traversal{nodes: []Node{b}} +} + +// Join joins a list of traversals with a semicolon separator. +func Join(trs ...*Traversal) *Traversal { + b := Block{} + for _, tr := range trs { + b.Nodes = append(b.Nodes, &Traversal{nodes: tr.nodes}) + } + return &Traversal{nodes: []Node{b}} +} + +// AddError adds an error to the traversal. +func (t *Traversal) AddError(err error) *Traversal { + t.errs = append(t.errs, err) + return t +} + +// Err returns a concatenated error of all errors encountered during +// the query-building, or were added manually by calling AddError. +func (t *Traversal) Err() error { + if len(t.errs) == 0 { + return nil + } + br := strings.Builder{} + for i := range t.errs { + if i > 0 { + br.WriteString("; ") + } + br.WriteString(t.errs[i].Error()) + } + return errors.New(br.String()) +} + +// V step is usually used to start a traversal but it may also be used mid-traversal. +func (t *Traversal) V(args ...any) *Traversal { + t.Add(Dot, NewFunc("V", args...)) + return t +} + +// OtherV maps the Edge to the incident vertex that was not just traversed from in the path history. +func (t *Traversal) OtherV() *Traversal { + t.Add(Dot, NewFunc("otherV")) + return t +} + +// E step is usually used to start a traversal but it may also be used mid-traversal. +func (t *Traversal) E(args ...any) *Traversal { + t.Add(Dot, NewFunc("E", args...)) + return t +} + +// AddV adds a vertex. +func (t *Traversal) AddV(args ...any) *Traversal { + t.Add(Dot, NewFunc("addV", args...)) + return t +} + +// AddE adds an edge. +func (t *Traversal) AddE(args ...any) *Traversal { + t.Add(Dot, NewFunc("addE", args...)) + return t +} + +// Next gets the next n-number of results from the traversal. +func (t *Traversal) Next() *Traversal { + return t.Add(Dot, NewFunc("next")) +} + +// Drop removes elements and properties from the graph. +func (t *Traversal) Drop() *Traversal { + return t.Add(Dot, NewFunc("drop")) +} + +// Property sets a Property value and related meta properties if supplied, +// if supported by the Graph and if the Element is a VertexProperty. +func (t *Traversal) Property(args ...any) *Traversal { + return t.Add(Dot, NewFunc("property", args...)) +} + +// Both maps the Vertex to its adjacent vertices given the edge labels. +func (t *Traversal) Both(args ...any) *Traversal { + return t.Add(Dot, NewFunc("both", args...)) +} + +// BothE maps the Vertex to its incident edges given the edge labels. +func (t *Traversal) BothE(args ...any) *Traversal { + return t.Add(Dot, NewFunc("bothE", args...)) +} + +// Has filters vertices, edges and vertex properties based on their properties. +// See: http://tinkerpop.apache.org/docs/current/reference/#has-step. +func (t *Traversal) Has(args ...any) *Traversal { + return t.Add(Dot, NewFunc("has", args...)) +} + +// HasNot filters vertices, edges and vertex properties based on the non-existence of properties. +// See: http://tinkerpop.apache.org/docs/current/reference/#has-step. +func (t *Traversal) HasNot(args ...any) *Traversal { + return t.Add(Dot, NewFunc("hasNot", args...)) +} + +// HasID filters vertices, edges and vertex properties based on their identifier. +func (t *Traversal) HasID(args ...any) *Traversal { + return t.Add(Dot, NewFunc("hasId", args...)) +} + +// HasLabel filters vertices, edges and vertex properties based on their label. +func (t *Traversal) HasLabel(args ...any) *Traversal { + return t.Add(Dot, NewFunc("hasLabel", args...)) +} + +// HasNext returns true if the iteration has more elements. +func (t *Traversal) HasNext() *Traversal { + return t.Add(Dot, NewFunc("hasNext")) +} + +// Match maps the Traverser to a Map of bindings as specified by the provided match traversals. +func (t *Traversal) Match(args ...any) *Traversal { + return t.Add(Dot, NewFunc("match", args...)) +} + +// Choose routes the current traverser to a particular traversal branch option which allows the creation of if-then-else like semantics within a traversal. +func (t *Traversal) Choose(args ...any) *Traversal { + return t.Add(Dot, NewFunc("choose", args...)) +} + +// Select arbitrary values from the traversal. +func (t *Traversal) Select(args ...any) *Traversal { + return t.Add(Dot, NewFunc("select", args...)) +} + +// Group organizes objects in the stream into a Map.Calls to group() are typically accompanied with by() modulators which help specify how the grouping should occur. +func (t *Traversal) Group() *Traversal { + return t.Add(Dot, NewFunc("group")) +} + +// Values maps the Element to the values of the associated properties given the provide property keys. +func (t *Traversal) Values(args ...string) *Traversal { + return t.Add(Dot, NewFunc("values", sface(args)...)) +} + +// ValueMap maps the Element to a Map of the property values key'd according to their Property.key(). +func (t *Traversal) ValueMap(args ...any) *Traversal { + return t.Add(Dot, NewFunc("valueMap", args...)) +} + +// Properties maps the Element to its associated properties given the provide property keys. +func (t *Traversal) Properties(args ...any) *Traversal { + return t.Add(Dot, NewFunc("properties", args...)) +} + +// Range filters the objects in the traversal by the number of them to pass through the stream. +func (t *Traversal) Range(args ...any) *Traversal { + return t.Add(Dot, NewFunc("range", args...)) +} + +// Limit filters the objects in the traversal by the number of them to pass through the stream, where only the first n objects are allowed as defined by the limit argument. +func (t *Traversal) Limit(args ...any) *Traversal { + return t.Add(Dot, NewFunc("limit", args...)) +} + +// ID maps the Element to its Element.id(). +func (t *Traversal) ID() *Traversal { + return t.Add(Dot, NewFunc("id")) +} + +// Label maps the Element to its Element.label(). +func (t *Traversal) Label() *Traversal { + return t.Add(Dot, NewFunc("label")) +} + +// From provides from()-modulation to respective steps. +func (t *Traversal) From(args ...any) *Traversal { + return t.Add(Dot, NewFunc("from", args...)) +} + +// To used as a modifier to addE(String) this method specifies the traversal to use for selecting the incoming vertex of the newly added Edge. +func (t *Traversal) To(args ...any) *Traversal { + return t.Add(Dot, NewFunc("to", args...)) +} + +// As provides a label to the step that can be accessed later in the traversal by other steps. +func (t *Traversal) As(args ...any) *Traversal { + return t.Add(Dot, NewFunc("as", args...)) +} + +// Or ensures that at least one of the provided traversals yield a result. +func (t *Traversal) Or(args ...any) *Traversal { + return t.Add(Dot, NewFunc("or", args...)) +} + +// And ensures that all of the provided traversals yield a result. +func (t *Traversal) And(args ...any) *Traversal { + return t.Add(Dot, NewFunc("and", args...)) +} + +// Is filters the E object if it is not P.eq(V) to the provided value. +func (t *Traversal) Is(args ...any) *Traversal { + return t.Add(Dot, NewFunc("is", args...)) +} + +// Not removes objects from the traversal stream when the traversal provided as an argument does not return any objects. +func (t *Traversal) Not(args ...any) *Traversal { + return t.Add(Dot, NewFunc("not", args...)) +} + +// In maps the Vertex to its incoming adjacent vertices given the edge labels. +func (t *Traversal) In(args ...any) *Traversal { + return t.Add(Dot, NewFunc("in", args...)) +} + +// Where filters the current object based on the object itself or the path history. +func (t *Traversal) Where(args ...any) *Traversal { + return t.Add(Dot, NewFunc("where", args...)) +} + +// Out maps the Vertex to its outgoing adjacent vertices given the edge labels. +func (t *Traversal) Out(args ...any) *Traversal { + return t.Add(Dot, NewFunc("out", args...)) +} + +// OutE maps the Vertex to its outgoing incident edges given the edge labels. +func (t *Traversal) OutE(args ...any) *Traversal { + return t.Add(Dot, NewFunc("outE", args...)) +} + +// InE maps the Vertex to its incoming incident edges given the edge labels. +func (t *Traversal) InE(args ...any) *Traversal { + return t.Add(Dot, NewFunc("inE", args...)) +} + +// OutV maps the Edge to its outgoing/tail incident Vertex. +func (t *Traversal) OutV(args ...any) *Traversal { + return t.Add(Dot, NewFunc("outV", args...)) +} + +// InV maps the Edge to its incoming/head incident Vertex. +func (t *Traversal) InV(args ...any) *Traversal { + return t.Add(Dot, NewFunc("inV", args...)) +} + +// ToList puts all the results into a Groovy list. +func (t *Traversal) ToList() *Traversal { + return t.Add(Dot, NewFunc("toList")) +} + +// Iterate iterates the traversal presumably for the generation of side-effects. +func (t *Traversal) Iterate() *Traversal { + return t.Add(Dot, NewFunc("iterate")) +} + +// Count maps the traversal stream to its reduction as a sum of the Traverser.bulk() values +// (i.e. count the number of traversers up to this point). +func (t *Traversal) Count(args ...any) *Traversal { + return t.Add(Dot, NewFunc("count", args...)) +} + +// Order all the objects in the traversal up to this point and then emit them one-by-one in their ordered sequence. +func (t *Traversal) Order(args ...any) *Traversal { + return t.Add(Dot, NewFunc("order", args...)) +} + +// By can be applied to a number of different step to alter their behaviors. +// This form is essentially an identity() modulation. +func (t *Traversal) By(args ...any) *Traversal { + return t.Add(Dot, NewFunc("by", args...)) +} + +// Fold rolls up objects in the stream into an aggregate list.. +func (t *Traversal) Fold() *Traversal { + return t.Add(Dot, NewFunc("fold")) +} + +// Unfold unrolls a Iterator, Iterable or Map into a linear form or simply emits the object if it is not one of those types. +func (t *Traversal) Unfold() *Traversal { + return t.Add(Dot, NewFunc("unfold")) +} + +// Sum maps the traversal stream to its reduction as a sum of the Traverser.get() values multiplied by their Traverser.bulk(). +func (t *Traversal) Sum(args ...any) *Traversal { + return t.Add(Dot, NewFunc("sum", args...)) +} + +// Mean determines the mean value in the stream. +func (t *Traversal) Mean(args ...any) *Traversal { + return t.Add(Dot, NewFunc("mean", args...)) +} + +// Min determines the smallest value in the stream. +func (t *Traversal) Min(args ...any) *Traversal { + return t.Add(Dot, NewFunc("min", args...)) +} + +// Max determines the greatest value in the stream. +func (t *Traversal) Max(args ...any) *Traversal { + return t.Add(Dot, NewFunc("max", args...)) +} + +// Coalesce evaluates the provided traversals and returns the result of the first traversal to emit at least one object. +func (t *Traversal) Coalesce(args ...any) *Traversal { + return t.Add(Dot, NewFunc("coalesce", args...)) +} + +// Dedup removes all duplicates in the traversal stream up to this point. +func (t *Traversal) Dedup(args ...any) *Traversal { + return t.Add(Dot, NewFunc("dedup", args...)) +} + +// Constant maps any object to a fixed E value. +func (t *Traversal) Constant(args ...any) *Traversal { + return t.Add(Dot, NewFunc("constant", args...)) +} + +// Union merges the results of an arbitrary number of traversals. +func (t *Traversal) Union(args ...any) *Traversal { + return t.Add(Dot, NewFunc("union", args...)) +} + +// SideEffect allows the traverser to proceed unchanged, but yield some computational +// sideEffect in the process. +func (t *Traversal) SideEffect(args ...any) *Traversal { + return t.Add(Dot, NewFunc("sideEffect", args...)) +} + +// Each is a Groovy each-loop function. +func Each(v any, cb func(it *Traversal) *Traversal) *Traversal { + t := &Traversal{} + switch v := v.(type) { + case *Traversal: + t.Add(&Var{Elem: v}) + case []any: + t.Add(NewList(v...)) + default: + t.Add(Token("undefined")) + } + t.Add(Dot, Token("each"), Token(" { ")) + t.Add(cb(&Traversal{nodes: []Node{Token("it")}}).nodes...) + t.Add(Token(" }")) + return t +} + +// Add is the public API for adding new nodes to the traversal by its sub packages. +func (t *Traversal) Add(n ...Node) *Traversal { + t.nodes = append(t.nodes, n...) + return t +} + +// Query returns the query-representation and its binding of this traversal object. +func (t *Traversal) Query() (string, Bindings) { + var ( + names []any + query strings.Builder + bindings = Bindings{} + ) + for _, n := range t.nodes { + code, args := n.Code() + query.WriteString(code) + for _, arg := range args { + names = append(names, bindings.Add(arg)) + } + } + return fmt.Sprintf(query.String(), names...), bindings +} + +// Clone creates a deep copy of an existing traversal. +func (t *Traversal) Clone() *Traversal { + if t == nil { + return nil + } + return &Traversal{nodes: append(make([]Node, 0, len(t.nodes)), t.nodes...)} +} + +// Undo reverts the last-step of the traversal. +func (t *Traversal) Undo() *Traversal { + if n := len(t.nodes); n > 2 { + t.nodes = t.nodes[:n-2] + } + return t +} diff --git a/dialect/gremlin/graph/edge.go b/dialect/gremlin/graph/edge.go new file mode 100644 index 0000000..02f4b47 --- /dev/null +++ b/dialect/gremlin/graph/edge.go @@ -0,0 +1,94 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package graph + +import ( + "fmt" + + "entgo.io/ent/dialect/gremlin/encoding/graphson" +) + +type ( + // An Edge between two vertices. + Edge struct { + Element + OutV, InV Vertex + } + + // graphson edge repr. + edge struct { + Element + OutV any `json:"outV"` + OutVLabel string `json:"outVLabel"` + InV any `json:"inV"` + InVLabel string `json:"inVLabel"` + } +) + +// NewEdge create a new graph edge. +func NewEdge(id any, label string, outV, inV Vertex) Edge { + return Edge{ + Element: NewElement(id, label), + OutV: outV, + InV: inV, + } +} + +// String implements fmt.Stringer interface. +func (e Edge) String() string { + return fmt.Sprintf("e[%v][%v-%s->%v]", e.ID, e.OutV.ID, e.Label, e.InV.ID) +} + +// MarshalGraphson implements graphson.Marshaler interface. +func (e Edge) MarshalGraphson() ([]byte, error) { + return graphson.Marshal(edge{ + Element: e.Element, + OutV: e.OutV.ID, + OutVLabel: e.OutV.Label, + InV: e.InV.ID, + InVLabel: e.InV.Label, + }) +} + +// UnmarshalGraphson implements graphson.Unmarshaler interface. +func (e *Edge) UnmarshalGraphson(data []byte) error { + var edge edge + if err := graphson.Unmarshal(data, &edge); err != nil { + return fmt.Errorf("unmarshalling edge: %w", err) + } + + *e = NewEdge( + edge.ID, edge.Label, + NewVertex(edge.OutV, edge.OutVLabel), + NewVertex(edge.InV, edge.InVLabel), + ) + return nil +} + +// GraphsonType implements graphson.Typer interface. +func (edge) GraphsonType() graphson.Type { + return "g:Edge" +} + +// Property denotes a key/value pair associated with an edge. +type Property struct { + Key string `json:"key"` + Value any `json:"value"` +} + +// NewProperty create a new graph edge property. +func NewProperty(key string, value any) Property { + return Property{key, value} +} + +// GraphsonType implements graphson.Typer interface. +func (Property) GraphsonType() graphson.Type { + return "g:Property" +} + +// String implements fmt.Stringer interface. +func (p Property) String() string { + return fmt.Sprintf("p[%s->%v]", p.Key, p.Value) +} diff --git a/dialect/gremlin/graph/edge_test.go b/dialect/gremlin/graph/edge_test.go new file mode 100644 index 0000000..8c8b4b6 --- /dev/null +++ b/dialect/gremlin/graph/edge_test.go @@ -0,0 +1,108 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package graph + +import ( + "fmt" + "testing" + + "entgo.io/ent/dialect/gremlin/encoding/graphson" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEdgeString(t *testing.T) { + e := NewEdge( + 13, "develops", + NewVertex(1, ""), + NewVertex(10, ""), + ) + assert.Equal(t, "e[13][1-develops->10]", fmt.Sprint(e)) +} + +func TestEdgeEncoding(t *testing.T) { + t.Parallel() + + e := NewEdge(13, "develops", + NewVertex(1, "person"), + NewVertex(10, "software"), + ) + got, err := graphson.MarshalToString(e) + require.NoError(t, err) + + want := `{ + "@type" : "g:Edge", + "@value" : { + "id" : { + "@type" : "g:Int64", + "@value" : 13 + }, + "label" : "develops", + "inVLabel" : "software", + "outVLabel" : "person", + "inV" : { + "@type" : "g:Int64", + "@value" : 10 + }, + "outV" : { + "@type" : "g:Int64", + "@value" : 1 + } + } + }` + assert.JSONEq(t, want, got) + + e = Edge{} + err = graphson.UnmarshalFromString(got, &e) + require.NoError(t, err) + + assert.Equal(t, NewElement(int64(13), "develops"), e.Element) + assert.Equal(t, NewVertex(int64(1), "person"), e.OutV) + assert.Equal(t, NewVertex(int64(10), "software"), e.InV) +} + +func TestPropertyEncoding(t *testing.T) { + t.Parallel() + + props := []Property{ + NewProperty("from", int32(2017)), + NewProperty("to", int32(2019)), + } + got, err := graphson.MarshalToString(props) + require.NoError(t, err) + + want := `{ + "@type" : "g:List", + "@value" : [ + { + "@type" : "g:Property", + "@value" : { + "key" : "from", + "value" : { + "@type" : "g:Int32", + "@value" : 2017 + } + } + }, + { + "@type" : "g:Property", + "@value" : { + "key" : "to", + "value" : { + "@type" : "g:Int32", + "@value" : 2019 + } + } + } + ] + }` + assert.JSONEq(t, want, got) +} + +func TestPropertyString(t *testing.T) { + p := NewProperty("since", 2019) + assert.Equal(t, "p[since->2019]", fmt.Sprint(p)) +} diff --git a/dialect/gremlin/graph/element.go b/dialect/gremlin/graph/element.go new file mode 100644 index 0000000..0eadcb5 --- /dev/null +++ b/dialect/gremlin/graph/element.go @@ -0,0 +1,16 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package graph + +// Element defines a base struct for graph elements. +type Element struct { + ID any `json:"id"` + Label string `json:"label"` +} + +// NewElement create a new graph element. +func NewElement(id any, label string) Element { + return Element{id, label} +} diff --git a/dialect/gremlin/graph/valuemap.go b/dialect/gremlin/graph/valuemap.go new file mode 100644 index 0000000..ea9fd24 --- /dev/null +++ b/dialect/gremlin/graph/valuemap.go @@ -0,0 +1,57 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package graph + +import ( + "errors" + "fmt" + "reflect" + + "github.com/mitchellh/mapstructure" +) + +// ValueMap models a .valueMap() gremlin response. +type ValueMap []map[string]any + +// Decode decodes a value map into v. +func (m ValueMap) Decode(v any) error { + rv := reflect.ValueOf(v) + if rv.Kind() != reflect.Ptr { + return errors.New("cannot unmarshal into a non pointer") + } + if rv.IsNil() { + return errors.New("cannot unmarshal into a nil pointer") + } + + if rv.Elem().Kind() != reflect.Slice { + v = &[]any{v} + } + return m.decode(v) +} + +func (m ValueMap) decode(v any) error { + cfg := mapstructure.DecoderConfig{ + DecodeHook: func(f, t reflect.Kind, data any) (any, error) { + if f == reflect.Slice && t != reflect.Slice { + rv := reflect.ValueOf(data) + if rv.Len() == 1 { + data = rv.Index(0).Interface() + } + } + return data, nil + }, + Result: v, + TagName: "json", + } + + dec, err := mapstructure.NewDecoder(&cfg) + if err != nil { + return fmt.Errorf("creating structure decoder: %w", err) + } + if err := dec.Decode(m); err != nil { + return fmt.Errorf("decoding value map: %w", err) + } + return nil +} diff --git a/dialect/gremlin/graph/valuemap_test.go b/dialect/gremlin/graph/valuemap_test.go new file mode 100644 index 0000000..74b59e7 --- /dev/null +++ b/dialect/gremlin/graph/valuemap_test.go @@ -0,0 +1,74 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package graph + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestValueMapDecodeOne(t *testing.T) { + vm := ValueMap{map[string]any{ + "id": int64(1), + "label": "person", + "name": []any{"marko"}, + "age": []any{int32(29)}, + }} + + var ent struct { + ID uint64 `json:"id"` + Label string `json:"label"` + Name string `json:"name"` + Age uint8 `json:"age"` + } + err := vm.Decode(&ent) + require.NoError(t, err) + + assert.Equal(t, uint64(1), ent.ID) + assert.Equal(t, "person", ent.Label) + assert.Equal(t, "marko", ent.Name) + assert.Equal(t, uint8(29), ent.Age) +} + +func TestValueMapDecodeMany(t *testing.T) { + vm := ValueMap{ + map[string]any{ + "id": int64(1), + "label": "person", + "name": []any{"chico"}, + }, + map[string]any{ + "id": int64(2), + "label": "person", + "name": []any{"dico"}, + }, + } + + ents := []struct { + ID int `json:"id"` + Label string `json:"label"` + Name string `json:"name"` + }{} + err := vm.Decode(&ents) + require.NoError(t, err) + + require.Len(t, ents, 2) + assert.Equal(t, 1, ents[0].ID) + assert.Equal(t, "person", ents[0].Label) + assert.Equal(t, "chico", ents[0].Name) + assert.Equal(t, 2, ents[1].ID) + assert.Equal(t, "person", ents[1].Label) + assert.Equal(t, "dico", ents[1].Name) +} + +func TestValueMapDecodeBadInput(t *testing.T) { + type s struct{ Name string } + err := ValueMap{}.Decode(s{}) + assert.Error(t, err) + err = ValueMap{}.Decode((*s)(nil)) + assert.Error(t, err) +} diff --git a/dialect/gremlin/graph/vertex.go b/dialect/gremlin/graph/vertex.go new file mode 100644 index 0000000..70cafa4 --- /dev/null +++ b/dialect/gremlin/graph/vertex.go @@ -0,0 +1,62 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package graph + +import ( + "fmt" + + "entgo.io/ent/dialect/gremlin/encoding/graphson" +) + +// Vertex represents a graph vertex. +type Vertex struct { + Element +} + +// NewVertex create a new graph vertex. +func NewVertex(id any, label string) Vertex { + if label == "" { + label = "vertex" + } + return Vertex{ + Element: NewElement(id, label), + } +} + +// GraphsonType implements graphson.Typer interface. +func (Vertex) GraphsonType() graphson.Type { + return "g:Vertex" +} + +// String implements fmt.Stringer interface. +func (v Vertex) String() string { + return fmt.Sprintf("v[%v]", v.ID) +} + +// VertexProperty denotes a key/value pair associated with a vertex. +type VertexProperty struct { + ID any `json:"id"` + Key string `json:"label"` + Value any `json:"value"` +} + +// NewVertexProperty create a new graph vertex property. +func NewVertexProperty(id any, key string, value any) VertexProperty { + return VertexProperty{ + ID: id, + Key: key, + Value: value, + } +} + +// GraphsonType implements graphson.Typer interface. +func (VertexProperty) GraphsonType() graphson.Type { + return "g:VertexProperty" +} + +// String implements fmt.Stringer interface. +func (vp VertexProperty) String() string { + return fmt.Sprintf("vp[%s->%v]", vp.Key, vp.Value) +} diff --git a/dialect/gremlin/graph/vertex_test.go b/dialect/gremlin/graph/vertex_test.go new file mode 100644 index 0000000..d56ea31 --- /dev/null +++ b/dialect/gremlin/graph/vertex_test.go @@ -0,0 +1,86 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package graph + +import ( + "fmt" + "testing" + + "entgo.io/ent/dialect/gremlin/encoding/graphson" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestVertexCreation(t *testing.T) { + v := NewVertex(45, "person") + assert.Equal(t, 45, v.ID) + assert.Equal(t, "person", v.Label) + v = NewVertex(46, "") + assert.Equal(t, "vertex", v.Label) +} + +func TestVertexString(t *testing.T) { + v := NewVertex(42, "") + assert.Equal(t, "v[42]", fmt.Sprint(v)) +} + +func TestVertexEncoding(t *testing.T) { + t.Parallel() + + v := NewVertex(1, "user") + got, err := graphson.MarshalToString(v) + require.NoError(t, err) + + want := `{ + "@type" : "g:Vertex", + "@value" : { + "id" : { + "@type" : "g:Int64", + "@value" : 1 + }, + "label" : "user" + } + }` + assert.JSONEq(t, want, got) + + v = Vertex{} + err = graphson.UnmarshalFromString(got, &v) + require.NoError(t, err) + + assert.Equal(t, int64(1), v.ID) + assert.Equal(t, "user", v.Label) +} + +func TestVertexPropertyEncoding(t *testing.T) { + t.Parallel() + + vp := NewVertexProperty("46ab60c2-918c-4cc4-a13b-350510e8908a", "name", "alex") + got, err := graphson.MarshalToString(vp) + require.NoError(t, err) + + want := `{ + "@type" : "g:VertexProperty", + "@value" : { + "id" : "46ab60c2-918c-4cc4-a13b-350510e8908a", + "label": "name", + "value": "alex" + } + }` + assert.JSONEq(t, want, got) + + vp = VertexProperty{} + err = graphson.UnmarshalFromString(got, &vp) + require.NoError(t, err) + + assert.Equal(t, "46ab60c2-918c-4cc4-a13b-350510e8908a", vp.ID) + assert.Equal(t, "name", vp.Key) + assert.Equal(t, "alex", vp.Value) +} + +func TestVertexPropertyString(t *testing.T) { + vp := NewVertexProperty(55, "country", "israel") + assert.Equal(t, "vp[country->israel]", fmt.Sprint(vp)) +} diff --git a/dialect/gremlin/http.go b/dialect/gremlin/http.go new file mode 100644 index 0000000..351c406 --- /dev/null +++ b/dialect/gremlin/http.go @@ -0,0 +1,85 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package gremlin + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "net/url" + + "entgo.io/ent/dialect/gremlin/encoding/graphson" + + jsoniter "github.com/json-iterator/go" +) + +type httpTransport struct { + client *http.Client + url string +} + +// NewHTTPTransport returns a new http transport. +func NewHTTPTransport(urlStr string, client *http.Client) (RoundTripper, error) { + u, err := url.Parse(urlStr) + if err != nil { + return nil, fmt.Errorf("gremlin/http: parsing url: %w", err) + } + if client == nil { + client = http.DefaultClient + } + return &httpTransport{client, u.String()}, nil +} + +// RoundTrip implements RouterTripper interface. +func (t *httpTransport) RoundTrip(ctx context.Context, req *Request) (*Response, error) { + if req.Operation != OpsEval { + return nil, fmt.Errorf("gremlin/http: unsupported operation: %q", req.Operation) + } + if _, ok := req.Arguments[ArgsGremlin]; !ok { + return nil, errors.New("gremlin/http: missing query expression") + } + + pr, pw := io.Pipe() + defer pr.Close() + go func() { + err := jsoniter.NewEncoder(pw).Encode(req.Arguments) + if err != nil { + err = fmt.Errorf("gremlin/http: encoding request: %w", err) + } + _ = pw.CloseWithError(err) + }() + + var br io.Reader + { + req, err := http.NewRequest(http.MethodPost, t.url, pr) + if err != nil { + return nil, fmt.Errorf("gremlin/http: creating http request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + rsp, err := t.client.Do(req.WithContext(ctx)) + if err != nil { + return nil, fmt.Errorf("gremlin/http: posting http request: %w", err) + } + defer rsp.Body.Close() + + if rsp.StatusCode < http.StatusOK || rsp.StatusCode > http.StatusPartialContent { + body, _ := io.ReadAll(rsp.Body) + return nil, fmt.Errorf("gremlin/http: status=%q, body=%q", rsp.Status, body) + } + if rsp.ContentLength > MaxResponseSize { + return nil, errors.New("gremlin/http: context length exceeds limit") + } + br = rsp.Body + } + + var rsp Response + if err := graphson.NewDecoder(io.LimitReader(br, MaxResponseSize)).Decode(&rsp); err != nil { + return nil, fmt.Errorf("gremlin/http: decoding response: %w", err) + } + return &rsp, nil +} diff --git a/dialect/gremlin/http_test.go b/dialect/gremlin/http_test.go new file mode 100644 index 0000000..9669142 --- /dev/null +++ b/dialect/gremlin/http_test.go @@ -0,0 +1,130 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package gremlin + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "testing" + + "entgo.io/ent/dialect/gremlin/encoding/graphson" + + jsoniter "github.com/json-iterator/go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHTTPTransportRoundTripper(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, r.Header.Get("Content-Type"), "application/json") + got, err := io.ReadAll(r.Body) + require.NoError(t, err) + assert.JSONEq(t, `{"gremlin": "g.V(1)", "language": "gremlin-groovy"}`, string(got)) + + _, err = io.WriteString(w, `{ + "requestId": "f679127f-8701-425c-af55-049a44720db6", + "result": { + "data": { + "@type": "g:List", + "@value": [ + { + "@type": "g:Vertex", + "@value": { + "id": { + "@type": "g:Int64", + "@value": 1 + }, + "label": "person" + } + } + ] + }, + "meta": { + "@type": "g:Map", + "@value": [] + } + }, + "status": { + "attributes": { + "@type": "g:Map", + "@value": [] + }, + "code": 200, + "message": "" + } + }`) + require.NoError(t, err) + })) + defer srv.Close() + + transport, err := NewHTTPTransport(srv.URL, nil) + require.NoError(t, err) + + rsp, err := transport.RoundTrip(context.Background(), NewEvalRequest("g.V(1)")) + require.NoError(t, err) + + assert.Equal(t, "f679127f-8701-425c-af55-049a44720db6", rsp.RequestID) + assert.Equal(t, 200, rsp.Status.Code) + assert.Empty(t, rsp.Status.Message) + + v := jsoniter.Get(rsp.Result.Data, graphson.ValueKey, 0, graphson.ValueKey) + require.NoError(t, v.LastError()) + assert.Equal(t, 1, v.Get("id", graphson.ValueKey).ToInt()) + assert.Equal(t, "person", v.Get("label").ToString()) +} + +func TestNewHTTPTransportBadURL(t *testing.T) { + transport, err := NewHTTPTransport(":", nil) + assert.Nil(t, transport) + assert.Error(t, err) +} + +func TestHTTPTransportBadRequest(t *testing.T) { + transport, err := NewHTTPTransport("example.com", nil) + require.NoError(t, err) + + req := NewEvalRequest("g.V()") + req.Operation = "" + rsp, err := transport.RoundTrip(context.Background(), req) + assert.EqualError(t, err, `gremlin/http: unsupported operation: ""`) + assert.Nil(t, rsp) + + req = NewEvalRequest("g.V()") + delete(req.Arguments, ArgsGremlin) + rsp, err = transport.RoundTrip(context.Background(), req) + assert.EqualError(t, err, "gremlin/http: missing query expression") + assert.Nil(t, rsp) +} + +func TestHTTPTransportBadResponseStatus(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + transport, err := NewHTTPTransport(srv.URL, nil) + require.NoError(t, err) + + _, err = transport.RoundTrip(context.Background(), NewEvalRequest("g.E().")) + require.Error(t, err) + assert.Contains(t, err.Error(), http.StatusText(http.StatusInternalServerError)) +} + +func TestHTTPTransportBadResponseBody(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, err := io.WriteString(w, "{{{") + require.NoError(t, err) + })) + defer srv.Close() + + transport, err := NewHTTPTransport(srv.URL, nil) + require.NoError(t, err) + + _, err = transport.RoundTrip(context.Background(), NewEvalRequest("g.E().")) + require.Error(t, err) + assert.Contains(t, err.Error(), "decoding response") +} diff --git a/dialect/gremlin/internal/ws/conn.go b/dialect/gremlin/internal/ws/conn.go new file mode 100644 index 0000000..d81c2a1 --- /dev/null +++ b/dialect/gremlin/internal/ws/conn.go @@ -0,0 +1,315 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package ws + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net/http" + "sync" + "time" + + "entgo.io/ent/dialect/gremlin" + "entgo.io/ent/dialect/gremlin/encoding" + "entgo.io/ent/dialect/gremlin/encoding/graphson" + + "github.com/gorilla/websocket" + "golang.org/x/sync/errgroup" +) + +const ( + // Time allowed to write a message to the peer. + writeWait = 5 * time.Second + + // Time allowed to read the next pong message from the peer. + pongWait = 10 * time.Second + + // Send pings to peer with this period. Must be less than pongWait. + pingPeriod = (pongWait * 9) / 10 +) + +type ( + // A Dialer contains options for connecting to Gremlin server. + Dialer struct { + // Underlying websocket dialer. + websocket.Dialer + + // Gremlin server basic auth credentials. + user, pass string + } + + // Conn performs operations on a gremlin server. + Conn struct { + // Underlying websocket connection. + conn *websocket.Conn + + // Credentials for basic authentication. + user, pass string + + // Goroutine tracking. + ctx context.Context + grp *errgroup.Group + + // Channel of outbound requests. + send chan io.Reader + + // Map of in flight requests. + inflight sync.Map + } + + // inflight tracks request state. + inflight struct { + // partially received data + frags []graphson.RawMessage + + // response channel + result chan<- result + } + + // represents an execution result. + result struct { + rsp *gremlin.Response + err error + } +) + +var ( + // DefaultDialer is a dialer with all fields set to the default values. + DefaultDialer = &Dialer{ + Dialer: websocket.Dialer{ + Proxy: http.ProxyFromEnvironment, + HandshakeTimeout: 5 * time.Second, + WriteBufferSize: 8192, + ReadBufferSize: 8192, + }, + } + + // ErrConnClosed is returned by the Conn's Execute method when + // the underlying gremlin server connection is closed. + ErrConnClosed = errors.New("gremlin: server connection closed") + + // ErrDuplicateRequest is returned by the Conns Execute method on + // request identifier key collision. + ErrDuplicateRequest = errors.New("gremlin: duplicate request") +) + +// Dial creates a new connection by calling DialContext with a background context. +func (d *Dialer) Dial(uri string) (*Conn, error) { + return d.DialContext(context.Background(), uri) +} + +// DialContext creates a new Gremlin connection. +func (d *Dialer) DialContext(ctx context.Context, uri string) (*Conn, error) { + c, rsp, err := d.Dialer.DialContext(ctx, uri, nil) + if err != nil { + return nil, fmt.Errorf("gremlin: dialing uri %s: %w", uri, err) + } + defer rsp.Body.Close() + + conn := &Conn{ + conn: c, + user: d.user, + pass: d.pass, + send: make(chan io.Reader), + } + conn.grp, conn.ctx = errgroup.WithContext(context.Background()) + + conn.grp.Go(conn.sender) + conn.grp.Go(conn.receiver) + + return conn, nil +} + +// Execute executes a request against a Gremlin server. +func (c *Conn) Execute(ctx context.Context, req *gremlin.Request) (*gremlin.Response, error) { + // buffered result channel prevents receiver block on context cancellation + result := make(chan result, 1) + + // request id must be unique across inflight request + if _, loaded := c.inflight.LoadOrStore(req.RequestID, &inflight{result: result}); loaded { + return nil, ErrDuplicateRequest + } + + pr, pw := io.Pipe() + defer pr.Close() + + // stream graphson encoding into request + c.grp.Go(func() error { + err := graphson.NewEncoder(pw).Encode(req) + if err != nil { + err = fmt.Errorf("encoding request: %w", err) + } + pw.CloseWithError(err) + return err + }) + + // local copy for single write + send := c.send + + for { + select { + case <-c.ctx.Done(): + c.inflight.Delete(req.RequestID) + return nil, ErrConnClosed + case <-ctx.Done(): + c.inflight.Delete(req.RequestID) + return nil, ctx.Err() + case send <- pr: + send = nil + case result := <-result: + return result.rsp, result.err + } + } +} + +// Close connection with a Gremlin server. +func (c *Conn) Close() error { + c.grp.Go(func() error { return ErrConnClosed }) + _ = c.grp.Wait() + return nil +} + +func (c *Conn) sender() error { + pinger := time.NewTicker(pingPeriod) + defer pinger.Stop() + + // closing connection terminates receiver + defer c.conn.Close() + + for { + select { + case r := <-c.send: + // ensure write completes within a window + c.conn.SetWriteDeadline(time.Now().Add(writeWait)) + + // fetch next message writer + w, err := c.conn.NextWriter(websocket.BinaryMessage) + if err != nil { + return fmt.Errorf("getting message writer: %w", err) + } + + // write mime header + if _, err := w.Write(encoding.GraphSON3Mime); err != nil { + return fmt.Errorf("writing mime header: %w", err) + } + + // write request body + if _, err := io.Copy(w, r); err != nil { + return fmt.Errorf("writing request: %w", err) + } + + // finish message write + if err := w.Close(); err != nil { + return fmt.Errorf("closing message writer: %w", err) + } + case <-c.ctx.Done(): + // connection closing + return c.conn.WriteControl( + websocket.CloseMessage, + websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""), + time.Time{}, + ) + case <-pinger.C: + // periodic connection keepalive + if err := c.conn.WriteControl(websocket.PingMessage, nil, time.Now().Add(writeWait)); err != nil { + return fmt.Errorf("writing ping message: %w", err) + } + } + } +} + +func (c *Conn) receiver() error { + // handle keepalive responses + c.conn.SetReadDeadline(time.Now().Add(pongWait)) + c.conn.SetPongHandler(func(string) error { + return c.conn.SetReadDeadline(time.Now().Add(pongWait)) + }) + + // complete all in flight requests on termination + defer c.inflight.Range(func(id, ifr any) bool { + ifr.(*inflight).result <- result{err: ErrConnClosed} + c.inflight.Delete(id) + return true + }) + + for { + // rely on sender connection close during termination + _, r, err := c.conn.NextReader() + if err != nil { + return fmt.Errorf("writing ping message: %w", err) + } + + // decode received response + var rsp gremlin.Response + if err := graphson.NewDecoder(r).Decode(&rsp); err != nil { + return fmt.Errorf("reading response: %w", err) + } + + ifr, ok := c.inflight.Load(rsp.RequestID) + if !ok { + // context cancellation aborts inflight requests + continue + } + + // handle incoming response + if done := c.receive(ifr.(*inflight), &rsp); done { + // stop tracking finished requests + c.inflight.Delete(rsp.RequestID) + } + } +} + +func (c *Conn) receive(ifr *inflight, rsp *gremlin.Response) bool { + result := result{rsp: rsp} + switch rsp.Status.Code { + case gremlin.StatusSuccess: + // quickly handle non fragmented responses + if ifr.frags == nil { + break + } + // handle fragment + fallthrough + case gremlin.StatusPartialContent: + // append received fragment + var frag []graphson.RawMessage + if err := graphson.Unmarshal(rsp.Result.Data, &frag); err != nil { + result.err = fmt.Errorf("decoding response fragment: %w", err) + break + } + ifr.frags = append(ifr.frags, frag...) + + // partial response requires additional fragments + if rsp.Status.Code == gremlin.StatusPartialContent { + return false + } + + // reassemble fragmented response + if rsp.Result.Data, result.err = graphson.Marshal(ifr.frags); result.err != nil { + result.err = fmt.Errorf("assembling fragmented response: %w", result.err) + } + case gremlin.StatusAuthenticate: + // receiver should never block + c.grp.Go(func() error { + var buf bytes.Buffer + if err := graphson.NewEncoder(&buf).Encode( + gremlin.NewAuthRequest(rsp.RequestID, c.user, c.pass), + ); err != nil { + return fmt.Errorf("encoding auth request: %w", err) + } + select { + case c.send <- &buf: + case <-c.ctx.Done(): + } + return c.ctx.Err() + }) + return false + } + + ifr.result <- result + return true +} diff --git a/dialect/gremlin/internal/ws/conn_test.go b/dialect/gremlin/internal/ws/conn_test.go new file mode 100644 index 0000000..f7439a9 --- /dev/null +++ b/dialect/gremlin/internal/ws/conn_test.go @@ -0,0 +1,390 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package ws + +import ( + "context" + "net/http" + "net/http/httptest" + "strconv" + "sync" + "testing" + + "entgo.io/ent/dialect/gremlin" + "entgo.io/ent/dialect/gremlin/encoding/graphson" + + "github.com/gorilla/websocket" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type conn struct{ *websocket.Conn } + +func (c conn) ReadRequest() (*gremlin.Request, error) { + _, data, err := c.ReadMessage() + if err != nil { + return nil, err + } + var req gremlin.Request + if err := graphson.Unmarshal(data[data[0]+1:], &req); err != nil { + return nil, err + } + return &req, nil +} + +func (c conn) WriteResponse(rsp *gremlin.Response) error { + data, err := graphson.Marshal(rsp) + if err != nil { + return err + } + return c.WriteMessage(websocket.BinaryMessage, data) +} + +func serve(handler func(conn)) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upgrader := websocket.Upgrader{ReadBufferSize: 1024, WriteBufferSize: 1024} + c, _ := upgrader.Upgrade(w, r, nil) + defer c.Close() + handler(conn{c}) + for { + _, _, err := c.ReadMessage() + if err != nil { + break + } + } + })) +} + +func TestConnectClosure(t *testing.T) { + var wg sync.WaitGroup + wg.Add(1) + defer wg.Wait() + + srv := serve(func(conn conn) { + defer wg.Done() + _, _, err := conn.ReadMessage() + assert.True(t, websocket.IsCloseError(err, websocket.CloseNormalClosure)) + }) + defer srv.Close() + + conn, err := DefaultDialer.Dial("ws://" + srv.Listener.Addr().String()) + require.NoError(t, err) + + err = conn.Close() + assert.NoError(t, err) + + _, err = conn.Execute(context.Background(), gremlin.NewEvalRequest("g.V()")) + assert.EqualError(t, err, ErrConnClosed.Error()) +} + +func TestSimpleQuery(t *testing.T) { + srv := serve(func(conn conn) { + typ, data, err := conn.ReadMessage() + require.NoError(t, err) + assert.Equal(t, websocket.BinaryMessage, typ) + + var req gremlin.Request + err = graphson.Unmarshal(data[data[0]+1:], &req) + require.NoError(t, err) + assert.Equal(t, "g.V()", req.Arguments["gremlin"]) + + rsp := gremlin.Response{RequestID: req.RequestID} + rsp.Status.Code = gremlin.StatusNoContent + err = conn.WriteResponse(&rsp) + require.NoError(t, err) + }) + defer srv.Close() + + conn, err := DefaultDialer.Dial("ws://" + srv.Listener.Addr().String()) + require.NoError(t, err) + defer assert.Condition(t, func() bool { return assert.NoError(t, conn.Close()) }) + + rsp, err := conn.Execute(context.Background(), gremlin.NewEvalRequest("g.V()")) + assert.NoError(t, err) + require.NotNil(t, rsp) + assert.Equal(t, gremlin.StatusNoContent, rsp.Status.Code) +} + +func TestDuplicateRequest(t *testing.T) { + // skip until flakiness will be fixed. + t.SkipNow() + srv := serve(func(conn conn) { + req, err := conn.ReadRequest() + require.NoError(t, err) + + rsp := gremlin.Response{RequestID: req.RequestID} + rsp.Status.Code = gremlin.StatusNoContent + err = conn.WriteResponse(&rsp) + require.NoError(t, err) + }) + defer srv.Close() + + conn, err := DefaultDialer.Dial("ws://" + srv.Listener.Addr().String()) + require.NoError(t, err) + defer conn.Close() + + var errors [2]error + req := gremlin.NewEvalRequest("g.V()") + + var wg sync.WaitGroup + wg.Add(len(errors)) + + for i := range errors { + go func(i int) { + _, errors[i] = conn.Execute(context.Background(), req) + wg.Done() + }(i) + } + wg.Wait() + + err = errors[0] + if err == nil { + err = errors[1] + } + assert.EqualError(t, err, ErrDuplicateRequest.Error()) +} + +func TestConnectCancellation(t *testing.T) { + srv := serve(func(conn) {}) + defer srv.Close() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + conn, err := DefaultDialer.DialContext(ctx, "ws://"+srv.Listener.Addr().String()) + assert.Error(t, err) + assert.Nil(t, conn) +} + +func TestQueryCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + + srv := serve(func(conn conn) { + if _, _, err := conn.ReadMessage(); err == nil { + cancel() + } + }) + defer srv.Close() + + conn, err := DefaultDialer.Dial("ws://" + srv.Listener.Addr().String()) + require.NoError(t, err) + defer conn.Close() + + _, err = conn.Execute(ctx, gremlin.NewEvalRequest("g.E()")) + assert.EqualError(t, err, context.Canceled.Error()) +} + +func TestBadResponse(t *testing.T) { + tests := []struct { + name string + mangle func(*gremlin.Response) *gremlin.Response + }{ + { + name: "NoStatus", + mangle: func(rsp *gremlin.Response) *gremlin.Response { + return rsp + }, + }, + { + name: "Malformed", + mangle: func(rsp *gremlin.Response) *gremlin.Response { + rsp.Status.Code = gremlin.StatusMalformedRequest + rsp.Status.Message = "bad request" + return rsp + }, + }, + { + name: "Unknown", + mangle: func(rsp *gremlin.Response) *gremlin.Response { + rsp.Status.Code = 424242 + return rsp + }, + }, + } + + srv := serve(func(conn conn) { + for { + req, err := conn.ReadRequest() + if err != nil { + break + } + + idx, err := strconv.ParseInt(req.Arguments["gremlin"].(string), 10, 0) + require.NoError(t, err) + + err = conn.WriteResponse(tests[idx].mangle(&gremlin.Response{RequestID: req.RequestID})) + require.NoError(t, err) + } + }) + defer srv.Close() + + conn, err := DefaultDialer.Dial("ws://" + srv.Listener.Addr().String()) + require.NoError(t, err) + defer conn.Close() + + var wg sync.WaitGroup + wg.Add(len(tests)) + + ctx := context.Background() + for i, tc := range tests { + i, tc := i, tc + t.Run(tc.name, func(t *testing.T) { + defer wg.Done() + rsp, err := conn.Execute(ctx, gremlin.NewEvalRequest(strconv.FormatInt(int64(i), 10))) + assert.NoError(t, err) + assert.True(t, rsp.IsErr()) + }) + } + wg.Wait() +} + +func TestServerHangup(t *testing.T) { + // skip until flakiness will be fixed. + t.SkipNow() + srv := serve(func(conn conn) { _ = conn.Close() }) + defer srv.Close() + + conn, err := DefaultDialer.Dial("ws://" + srv.Listener.Addr().String()) + require.NoError(t, err) + defer conn.Close() + + _, err = conn.Execute(context.Background(), gremlin.NewEvalRequest("g.V()")) + assert.EqualError(t, err, ErrConnClosed.Error()) + assert.Error(t, conn.ctx.Err()) +} + +func TestCanceledLongRequest(t *testing.T) { + // skip until flakiness will be fixed. + t.SkipNow() + ctx, cancel := context.WithCancel(context.Background()) + srv := serve(func(conn conn) { + var responses [3]*gremlin.Response + for i := 0; i < len(responses); i++ { + req, err := conn.ReadRequest() + require.NoError(t, err) + + rsp := gremlin.Response{RequestID: req.RequestID} + rsp.Status.Code = gremlin.StatusSuccess + rsp.Result.Data = graphson.RawMessage(`"ok"`) + responses[i] = &rsp + } + + cancel() + + responses[0], responses[2] = responses[2], responses[0] + for i := 0; i < len(responses); i++ { + err := conn.WriteResponse(responses[i]) + require.NoError(t, err) + } + }) + defer srv.Close() + + conn, err := DefaultDialer.Dial("ws://" + srv.Listener.Addr().String()) + require.NoError(t, err) + defer conn.Close() + + var wg sync.WaitGroup + wg.Add(3) + defer wg.Wait() + + for i := 0; i < 3; i++ { + go func(ctx context.Context, idx int) { + defer wg.Done() + rsp, err := conn.Execute(ctx, gremlin.NewEvalRequest("g.V()")) + if idx > 0 { + assert.NoError(t, err) + assert.EqualValues(t, []byte(`"ok"`), rsp.Result.Data) + } else { + assert.EqualError(t, err, context.Canceled.Error()) + } + }(ctx, i) + ctx = context.Background() + } +} + +func TestPartialResponse(t *testing.T) { + type kv struct { + Key string + Value int + } + kvs := []kv{ + {"one", 1}, + {"two", 2}, + {"three", 3}, + } + srv := serve(func(conn conn) { + req, err := conn.ReadRequest() + require.NoError(t, err) + + for i := range kvs { + data, err := graphson.Marshal([]kv{kvs[i]}) + require.NoError(t, err) + + rsp := gremlin.Response{RequestID: req.RequestID} + rsp.Result.Data = graphson.RawMessage(data) + + if i != len(kvs)-1 { + rsp.Status.Code = gremlin.StatusPartialContent + } else { + rsp.Status.Code = gremlin.StatusSuccess + } + + err = conn.WriteResponse(&rsp) + require.NoError(t, err) + } + }) + defer srv.Close() + + conn, err := DefaultDialer.Dial("ws://" + srv.Listener.Addr().String()) + require.NoError(t, err) + defer conn.Close() + + rsp, err := conn.Execute(context.Background(), gremlin.NewEvalRequest("g.E()")) + assert.NoError(t, err) + + var result []kv + err = graphson.Unmarshal(rsp.Result.Data, &result) + require.NoError(t, err) + assert.Equal(t, kvs, result) +} + +func TestAuthentication(t *testing.T) { + user, pass := "username", "password" + srv := serve(func(conn conn) { + req, err := conn.ReadRequest() + require.NoError(t, err) + + rsp := gremlin.Response{RequestID: req.RequestID} + rsp.Status.Code = gremlin.StatusAuthenticate + err = conn.WriteResponse(&rsp) + require.NoError(t, err) + + areq, err := conn.ReadRequest() + require.NoError(t, err) + + var acreds gremlin.Credentials + err = acreds.UnmarshalText([]byte(areq.Arguments["sasl"].(string))) + assert.NoError(t, err) + areq.Arguments["sasl"] = acreds + assert.Equal(t, gremlin.NewAuthRequest(req.RequestID, user, pass), areq) + + rsp = gremlin.Response{RequestID: req.RequestID} + rsp.Status.Code = gremlin.StatusNoContent + err = conn.WriteResponse(&rsp) + require.NoError(t, err) + }) + defer srv.Close() + + dialer := *DefaultDialer + dialer.user = user + dialer.pass = pass + + client, err := dialer.Dial("ws://" + srv.Listener.Addr().String()) + require.NoError(t, err) + defer client.Close() + + _, err = client.Execute(context.Background(), gremlin.NewEvalRequest("g.E().drop()")) + assert.NoError(t, err) +} diff --git a/dialect/gremlin/ocgremlin/client.go b/dialect/gremlin/ocgremlin/client.go new file mode 100644 index 0000000..f9308ec --- /dev/null +++ b/dialect/gremlin/ocgremlin/client.go @@ -0,0 +1,64 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package ocgremlin + +import ( + "context" + + "entgo.io/ent/dialect/gremlin" + + "go.opencensus.io/trace" +) + +// Transport is an gremlin.RoundTripper that instruments all outgoing requests with +// OpenCensus stats and tracing. +type Transport struct { + // Base is a wrapped gremlin.RoundTripper that does the actual requests. + Base gremlin.RoundTripper + + // StartOptions are applied to the span started by this Transport around each + // request. + // + // StartOptions.SpanKind will always be set to trace.SpanKindClient + // for spans started by this transport. + StartOptions trace.StartOptions + + // GetStartOptions allows to set start options per request. If set, + // StartOptions is going to be ignored. + GetStartOptions func(context.Context, *gremlin.Request) trace.StartOptions + + // NameFromRequest holds the function to use for generating the span name + // from the information found in the outgoing Gremlin Request. By default the + // name equals the URL Path. + FormatSpanName func(context.Context, *gremlin.Request) string + + // WithQuery, if set to true, will enable recording of gremlin queries in spans. + // Only allow this if it is safe to have queries recorded with respect to + // security. + WithQuery bool +} + +// RoundTrip implements gremlin.RoundTripper, delegating to Base and recording stats and traces for the request. +func (t *Transport) RoundTrip(ctx context.Context, req *gremlin.Request) (*gremlin.Response, error) { + spanNameFormatter := t.FormatSpanName + if spanNameFormatter == nil { + spanNameFormatter = func(context.Context, *gremlin.Request) string { + return "gremlin:traversal" + } + } + startOpts := t.StartOptions + if t.GetStartOptions != nil { + startOpts = t.GetStartOptions(ctx, req) + } + + var rt gremlin.RoundTripper = &traceTransport{ + base: t.Base, + formatSpanName: spanNameFormatter, + startOptions: startOpts, + withQuery: t.WithQuery, + } + rt = statsTransport{rt} + return rt.RoundTrip(ctx, req) +} diff --git a/dialect/gremlin/ocgremlin/client_test.go b/dialect/gremlin/ocgremlin/client_test.go new file mode 100644 index 0000000..d696988 --- /dev/null +++ b/dialect/gremlin/ocgremlin/client_test.go @@ -0,0 +1,74 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package ocgremlin + +import ( + "context" + "errors" + "testing" + + "entgo.io/ent/dialect/gremlin" + + "github.com/stretchr/testify/mock" + "go.opencensus.io/trace" +) + +type mockExporter struct { + mock.Mock +} + +func (e *mockExporter) ExportSpan(s *trace.SpanData) { + e.Called(s) +} + +func TestTransportOptions(t *testing.T) { + tests := []struct { + name string + spanName string + wantName string + }{ + { + name: "Default formatter", + wantName: "gremlin:traversal", + }, + { + name: "Custom formatter", + spanName: "tester", + wantName: "tester", + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + var exporter mockExporter + exporter.On( + "ExportSpan", + mock.MatchedBy(func(s *trace.SpanData) bool { return s.Name == tt.wantName })). + Once() + defer exporter.AssertExpectations(t) + trace.RegisterExporter(&exporter) + defer trace.UnregisterExporter(&exporter) + + transport := &mockTransport{} + transport.On("RoundTrip", mock.Anything, mock.Anything). + Return(nil, errors.New("noop")). + Once() + defer transport.AssertExpectations(t) + + rt := &Transport{ + Base: transport, + GetStartOptions: func(context.Context, *gremlin.Request) trace.StartOptions { + return trace.StartOptions{Sampler: trace.AlwaysSample()} + }, + } + if tt.spanName != "" { + rt.FormatSpanName = func(context.Context, *gremlin.Request) string { + return tt.spanName + } + } + _, _ = rt.RoundTrip(context.Background(), gremlin.NewEvalRequest("g.E()")) + }) + } +} diff --git a/dialect/gremlin/ocgremlin/stats.go b/dialect/gremlin/ocgremlin/stats.go new file mode 100644 index 0000000..3eac383 --- /dev/null +++ b/dialect/gremlin/ocgremlin/stats.go @@ -0,0 +1,117 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package ocgremlin + +import ( + "context" + "strconv" + "time" + + "entgo.io/ent/dialect/gremlin" + + "go.opencensus.io/stats" + "go.opencensus.io/stats/view" + "go.opencensus.io/tag" +) + +// The following measures are supported for use in custom views. +var ( + RequestCount = stats.Int64( + "gremlin/request_count", + "Number of Gremlin requests started", + stats.UnitDimensionless, + ) + ResponseBytes = stats.Int64( + "gremlin/response_bytes", + "Total number of bytes in response data", + stats.UnitBytes, + ) + RoundTripLatency = stats.Float64( + "gremlin/roundtrip_latency", + "End-to-end latency", + stats.UnitMilliseconds, + ) +) + +// The following tags are applied to stats recorded by this package. +var ( + // StatusCode is the numeric Gremlin response status code, + // or "error" if a transport error occurred and no status code was read. + StatusCode, _ = tag.NewKey("gremlin_status_code") +) + +// Default distributions used by views in this package. +var ( + DefaultSizeDistribution = view.Distribution(32, 64, 128, 256, 512, 1024, 2048, 4096, 8192, 16384, 32768, 65536, 131072, 262144, 524288, 1048576) + DefaultLatencyDistribution = view.Distribution(1, 2, 3, 4, 5, 6, 8, 10, 13, 16, 20, 25, 30, 40, 50, 65, 80, 100, 130, 160, 200, 250, 300, 400, 500, 650, 800, 1000, 2000, 5000, 10000, 20000, 50000, 100000) +) + +// Package ocgremlin provides some convenience views for measures. +// You still need to register these views for data to actually be collected. +var ( + RequestCountView = &view.View{ + Name: "gremlin/request_count", + Measure: RequestCount, + Aggregation: view.Count(), + Description: "Count of Gremlin requests started", + } + + ResponseCountView = &view.View{ + Name: "gremlin/response_count", + Measure: RoundTripLatency, + Aggregation: view.Count(), + Description: "Count of responses received, by response status", + TagKeys: []tag.Key{StatusCode}, + } + + ResponseBytesView = &view.View{ + Name: "gremlin/response_bytes", + Measure: ResponseBytes, + Aggregation: DefaultSizeDistribution, + Description: "Total number of bytes in response data", + } + + RoundTripLatencyView = &view.View{ + Name: "gremlin/roundtrip_latency", + Measure: RoundTripLatency, + Aggregation: DefaultLatencyDistribution, + Description: "End-to-end latency, by response code", + TagKeys: []tag.Key{StatusCode}, + } +) + +// Views are the default views provided by this package. +func Views() []*view.View { + return []*view.View{ + RequestCountView, + ResponseCountView, + ResponseBytesView, + RoundTripLatencyView, + } +} + +// statsTransport is an gremlin.RoundTripper that collects stats for the outgoing requests. +type statsTransport struct { + base gremlin.RoundTripper +} + +func (t statsTransport) RoundTrip(ctx context.Context, req *gremlin.Request) (*gremlin.Response, error) { + stats.Record(ctx, RequestCount.M(1)) + start := time.Now() + rsp, err := t.base.RoundTrip(ctx, req) + latency := float64(time.Since(start)) / float64(time.Millisecond) + var ( + tags = make([]tag.Mutator, 1) + ms = []stats.Measurement{RoundTripLatency.M(latency)} + ) + if err == nil { + tags[0] = tag.Upsert(StatusCode, strconv.Itoa(rsp.Status.Code)) + ms = append(ms, ResponseBytes.M(int64(len(rsp.Result.Data)))) + } else { + tags[0] = tag.Upsert(StatusCode, "error") + } + _ = stats.RecordWithTags(ctx, tags, ms...) + return rsp, err +} diff --git a/dialect/gremlin/ocgremlin/stats_test.go b/dialect/gremlin/ocgremlin/stats_test.go new file mode 100644 index 0000000..5709d38 --- /dev/null +++ b/dialect/gremlin/ocgremlin/stats_test.go @@ -0,0 +1,94 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package ocgremlin + +import ( + "context" + "strings" + "testing" + + "entgo.io/ent/dialect/gremlin" + "entgo.io/ent/dialect/gremlin/encoding/graphson" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "go.opencensus.io/stats/view" +) + +func TestStatsCollection(t *testing.T) { + err := view.Register( + RequestCountView, + ResponseCountView, + ResponseBytesView, + RoundTripLatencyView, + ) + require.NoError(t, err) + + req := gremlin.NewEvalRequest("g.E()") + rsp := &gremlin.Response{RequestID: req.RequestID} + rsp.Status.Code = gremlin.StatusSuccess + rsp.Result.Data = graphson.RawMessage( + `{"@type": "g:List", "@value": [{"@type": "g:Int32", "@value": 42}]}`, + ) + + transport := &mockTransport{} + transport.On("RoundTrip", mock.Anything, mock.Anything). + Return(rsp, nil). + Once() + defer transport.AssertExpectations(t) + + rt := &statsTransport{transport} + _, _ = rt.RoundTrip(context.Background(), req) + + tests := []struct { + name string + expect func(*testing.T, *view.Row) + }{ + { + name: "gremlin/request_count", + expect: func(t *testing.T, row *view.Row) { + count, ok := row.Data.(*view.CountData) + require.True(t, ok) + assert.Equal(t, int64(1), count.Value) + }, + }, + { + name: "gremlin/response_count", + expect: func(t *testing.T, row *view.Row) { + count, ok := row.Data.(*view.CountData) + require.True(t, ok) + assert.Equal(t, int64(1), count.Value) + }, + }, + { + name: "gremlin/response_bytes", + expect: func(t *testing.T, row *view.Row) { + data, ok := row.Data.(*view.DistributionData) + require.True(t, ok) + assert.EqualValues(t, len(rsp.Result.Data), data.Sum()) + }, + }, + { + name: "gremlin/roundtrip_latency", + expect: func(t *testing.T, row *view.Row) { + data, ok := row.Data.(*view.DistributionData) + require.True(t, ok) + assert.NotZero(t, data.Sum()) + }, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name[strings.Index(tt.name, "/")+1:], func(t *testing.T) { + v := view.Find(tt.name) + assert.NotNil(t, v) + rows, err := view.RetrieveData(tt.name) + require.NoError(t, err) + require.Len(t, rows, 1) + tt.expect(t, rows[0]) + }) + } +} diff --git a/dialect/gremlin/ocgremlin/trace.go b/dialect/gremlin/ocgremlin/trace.go new file mode 100644 index 0000000..7b7a8d9 --- /dev/null +++ b/dialect/gremlin/ocgremlin/trace.go @@ -0,0 +1,133 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package ocgremlin + +import ( + "context" + "fmt" + + "entgo.io/ent/dialect/gremlin" + + "go.opencensus.io/trace" +) + +// Attributes recorded on the span for the requests. +const ( + RequestIDAttribute = "gremlin.request_id" + OperationAttribute = "gremlin.operation" + QueryAttribute = "gremlin.query" + BindingAttribute = "gremlin.binding" + CodeAttribute = "gremlin.code" + MessageAttribute = "gremlin.message" +) + +type traceTransport struct { + base gremlin.RoundTripper + startOptions trace.StartOptions + formatSpanName func(context.Context, *gremlin.Request) string + withQuery bool +} + +func (t *traceTransport) RoundTrip(ctx context.Context, req *gremlin.Request) (*gremlin.Response, error) { + ctx, span := trace.StartSpan(ctx, + t.formatSpanName(ctx, req), + trace.WithSampler(t.startOptions.Sampler), + trace.WithSpanKind(trace.SpanKindClient), + ) + defer span.End() + + span.AddAttributes(requestAttrs(req, t.withQuery)...) + rsp, err := t.base.RoundTrip(ctx, req) + if err != nil { + span.SetStatus(trace.Status{Code: trace.StatusCodeUnknown, Message: err.Error()}) + return rsp, err + } + + span.AddAttributes(responseAttrs(rsp)...) + span.SetStatus(TraceStatus(rsp.Status.Code)) + return rsp, err +} + +func requestAttrs(req *gremlin.Request, withQuery bool) []trace.Attribute { + attrs := []trace.Attribute{ + trace.StringAttribute(RequestIDAttribute, req.RequestID), + trace.StringAttribute(OperationAttribute, req.Operation), + } + if withQuery { + query, _ := req.Arguments[gremlin.ArgsGremlin].(string) + attrs = append(attrs, trace.StringAttribute(QueryAttribute, query)) + if bindings, ok := req.Arguments[gremlin.ArgsBindings].(map[string]any); ok { + attrs = append(attrs, bindingsAttrs(bindings)...) + } + } + return attrs +} + +func bindingsAttrs(bindings map[string]any) []trace.Attribute { + attrs := make([]trace.Attribute, 0, len(bindings)) + for key, val := range bindings { + key = BindingAttribute + "." + key + attrs = append(attrs, bindingToAttr(key, val)) + } + return attrs +} + +func bindingToAttr(key string, val any) trace.Attribute { + switch v := val.(type) { + case nil: + return trace.StringAttribute(key, "") + case int64: + return trace.Int64Attribute(key, v) + case float64: + return trace.Float64Attribute(key, v) + case string: + return trace.StringAttribute(key, v) + case bool: + return trace.BoolAttribute(key, v) + default: + s := fmt.Sprintf("%v", v) + if len(s) > 256 { + s = s[:256] + } + return trace.StringAttribute(key, s) + } +} + +func responseAttrs(rsp *gremlin.Response) []trace.Attribute { + attrs := []trace.Attribute{ + trace.Int64Attribute(CodeAttribute, int64(rsp.Status.Code)), + } + if rsp.Status.Message != "" { + attrs = append(attrs, trace.StringAttribute(MessageAttribute, rsp.Status.Message)) + } + return attrs +} + +// TraceStatus is a utility to convert the gremlin status code to a trace.Status. +func TraceStatus(status int) trace.Status { + var code int32 + switch status { + case gremlin.StatusSuccess, + gremlin.StatusNoContent, + gremlin.StatusPartialContent: + code = trace.StatusCodeOK + case gremlin.StatusUnauthorized: + code = trace.StatusCodePermissionDenied + case gremlin.StatusAuthenticate: + code = trace.StatusCodeUnauthenticated + case gremlin.StatusMalformedRequest, + gremlin.StatusInvalidRequestArguments, + gremlin.StatusScriptEvaluationError: + code = trace.StatusCodeInvalidArgument + case gremlin.StatusServerError, + gremlin.StatusServerSerializationError: + code = trace.StatusCodeInternal + case gremlin.StatusServerTimeout: + code = trace.StatusCodeDeadlineExceeded + default: + code = trace.StatusCodeUnknown + } + return trace.Status{Code: code, Message: gremlin.StatusText(status)} +} diff --git a/dialect/gremlin/ocgremlin/trace_test.go b/dialect/gremlin/ocgremlin/trace_test.go new file mode 100644 index 0000000..a3d6423 --- /dev/null +++ b/dialect/gremlin/ocgremlin/trace_test.go @@ -0,0 +1,254 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package ocgremlin + +import ( + "bytes" + "context" + "errors" + "fmt" + "testing" + + "entgo.io/ent/dialect/gremlin" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "go.opencensus.io/trace" +) + +type mockTransport struct { + mock.Mock +} + +func (t *mockTransport) RoundTrip(ctx context.Context, req *gremlin.Request) (*gremlin.Response, error) { + args := t.Called(ctx, req) + rsp, _ := args.Get(0).(*gremlin.Response) + return rsp, args.Error(1) +} + +func TestTraceTransportRoundTrip(t *testing.T) { + _, parent := trace.StartSpan(context.Background(), "parent") + tests := []struct { + name string + parent *trace.Span + }{ + { + name: "no parent", + parent: nil, + }, + { + name: "parent", + parent: parent, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + transport := &mockTransport{} + transport.On("RoundTrip", mock.Anything, mock.Anything). + Run(func(args mock.Arguments) { + span := trace.FromContext(args.Get(0).(context.Context)) + require.NotNil(t, span) + if tt.parent != nil { + assert.Equal(t, tt.parent.SpanContext().TraceID, span.SpanContext().TraceID) + } + }). + Return(nil, errors.New("noop")). + Once() + defer transport.AssertExpectations(t) + + ctx, req := context.Background(), gremlin.NewEvalRequest("g.V()") + if tt.parent != nil { + ctx = trace.NewContext(ctx, tt.parent) + } + rt := &Transport{Base: transport} + _, _ = rt.RoundTrip(ctx, req) + }) + } +} + +type testExporter struct { + spans []*trace.SpanData +} + +func (t *testExporter) ExportSpan(s *trace.SpanData) { + t.spans = append(t.spans, s) +} + +func TestEndToEnd(t *testing.T) { + trace.ApplyConfig(trace.Config{DefaultSampler: trace.AlwaysSample()}) + var exporter testExporter + trace.RegisterExporter(&exporter) + defer trace.UnregisterExporter(&exporter) + + req := gremlin.NewEvalRequest("g.V()") + rsp := &gremlin.Response{ + RequestID: req.RequestID, + } + rsp.Status.Code = 200 + rsp.Status.Message = "OK" + + var transport mockTransport + transport.On("RoundTrip", mock.Anything, mock.Anything). + Return(rsp, nil). + Once() + defer transport.AssertExpectations(t) + + rt := &Transport{Base: &transport, WithQuery: true} + _, err := rt.RoundTrip(context.Background(), req) + require.NoError(t, err) + + require.Len(t, exporter.spans, 1) + attrs := exporter.spans[0].Attributes + assert.Len(t, attrs, 5) + assert.Equal(t, req.RequestID, attrs["gremlin.request_id"]) + assert.Equal(t, req.Operation, attrs["gremlin.operation"]) + assert.Equal(t, req.Arguments[gremlin.ArgsGremlin], attrs["gremlin.query"]) + assert.Equal(t, int64(200), attrs["gremlin.code"]) + assert.Equal(t, "OK", attrs["gremlin.message"]) +} + +func TestRequestAttributes(t *testing.T) { + tests := []struct { + name string + makeReq func() *gremlin.Request + wantAttrs []trace.Attribute + }{ + { + name: "Query without bindings", + makeReq: func() *gremlin.Request { + req := gremlin.NewEvalRequest("g.E().count()") + req.RequestID = "a8b5c664-03ca-4175-a9e7-569b46f3551c" + return req + }, + wantAttrs: []trace.Attribute{ + trace.StringAttribute("gremlin.request_id", "a8b5c664-03ca-4175-a9e7-569b46f3551c"), + trace.StringAttribute("gremlin.operation", "eval"), + trace.StringAttribute("gremlin.query", "g.E().count()"), + }, + }, + { + name: "Query with bindings", + makeReq: func() *gremlin.Request { + bindings := map[string]any{ + "$1": "user", "$2": int64(42), + "$3": 3.14, "$4": bytes.Repeat([]byte{0xff}, 257), + "$5": true, "$6": nil, + } + req := gremlin.NewEvalRequest( + `g.V().hasLabel($1).has("age",$2).has("v",$3).limit($4).valueMap($5)`, + gremlin.WithBindings(bindings), + ) + req.RequestID = "d3d986fa-bd22-41bd-b2f7-ef2f1f639260" + return req + }, + wantAttrs: []trace.Attribute{ + trace.StringAttribute("gremlin.request_id", "d3d986fa-bd22-41bd-b2f7-ef2f1f639260"), + trace.StringAttribute("gremlin.operation", "eval"), + trace.StringAttribute("gremlin.query", `g.V().hasLabel($1).has("age",$2).has("v",$3).limit($4).valueMap($5)`), + trace.StringAttribute("gremlin.binding.$1", "user"), + trace.Int64Attribute("gremlin.binding.$2", 42), + trace.Float64Attribute("gremlin.binding.$3", 3.14), + trace.StringAttribute("gremlin.binding.$4", func() string { + str := fmt.Sprintf("%v", bytes.Repeat([]byte{0xff}, 256)) + return str[:256] + }()), + trace.BoolAttribute("gremlin.binding.$5", true), + trace.StringAttribute("gremlin.binding.$6", ""), + }, + }, + { + name: "Authentication", + makeReq: func() *gremlin.Request { + return gremlin.NewAuthRequest( + "d239d950-59a1-41a7-a103-908f976ebd89", + "user", "pass", + ) + }, + wantAttrs: []trace.Attribute{ + trace.StringAttribute("gremlin.request_id", "d239d950-59a1-41a7-a103-908f976ebd89"), + trace.StringAttribute("gremlin.operation", "authentication"), + trace.StringAttribute("gremlin.query", ""), + }, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + req := tt.makeReq() + attrs := requestAttrs(req, true) + for _, attr := range attrs { + assert.Contains(t, tt.wantAttrs, attr) + } + assert.Len(t, attrs, len(tt.wantAttrs)) + }) + } +} + +func TestResponseAttributes(t *testing.T) { + tests := []struct { + name string + makeRsp func() *gremlin.Response + wantAttrs []trace.Attribute + }{ + { + name: "Success no message", + makeRsp: func() *gremlin.Response { + var rsp gremlin.Response + rsp.Status.Code = 204 + return &rsp + }, + wantAttrs: []trace.Attribute{ + trace.Int64Attribute("gremlin.code", 204), + }, + }, + { + name: "Authenticate with message", + makeRsp: func() *gremlin.Response { + var rsp gremlin.Response + rsp.Status.Code = 407 + rsp.Status.Message = "login required" + return &rsp + }, + wantAttrs: []trace.Attribute{ + trace.Int64Attribute("gremlin.code", 407), + trace.StringAttribute("gremlin.message", "login required"), + }, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + rsp := tt.makeRsp() + attrs := responseAttrs(rsp) + assert.Equal(t, tt.wantAttrs, attrs) + }) + } +} + +func TestTraceStatus(t *testing.T) { + tests := []struct { + in int + want trace.Status + }{ + {200, trace.Status{Code: trace.StatusCodeOK, Message: "Success"}}, + {204, trace.Status{Code: trace.StatusCodeOK, Message: "No Content"}}, + {206, trace.Status{Code: trace.StatusCodeOK, Message: "Partial Content"}}, + {401, trace.Status{Code: trace.StatusCodePermissionDenied, Message: "Unauthorized"}}, + {407, trace.Status{Code: trace.StatusCodeUnauthenticated, Message: "Authenticate"}}, + {498, trace.Status{Code: trace.StatusCodeInvalidArgument, Message: "Malformed Request"}}, + {499, trace.Status{Code: trace.StatusCodeInvalidArgument, Message: "Invalid Request Arguments"}}, + {500, trace.Status{Code: trace.StatusCodeInternal, Message: "Server Error"}}, + {597, trace.Status{Code: trace.StatusCodeInvalidArgument, Message: "Script Evaluation Error"}}, + {598, trace.Status{Code: trace.StatusCodeDeadlineExceeded, Message: "Server Timeout"}}, + {599, trace.Status{Code: trace.StatusCodeInternal, Message: "Server Serialization Error"}}, + {600, trace.Status{Code: trace.StatusCodeUnknown, Message: ""}}, + } + for _, tt := range tests { + assert.Equal(t, tt.want, TraceStatus(tt.in)) + } +} diff --git a/dialect/gremlin/request.go b/dialect/gremlin/request.go new file mode 100644 index 0000000..2f61706 --- /dev/null +++ b/dialect/gremlin/request.go @@ -0,0 +1,110 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package gremlin + +import ( + "bytes" + "encoding/base64" + "errors" + "time" + + "github.com/google/uuid" +) + +type ( + // A Request models a request message sent to the server. + Request struct { + RequestID string `json:"requestId" graphson:"g:UUID"` + Operation string `json:"op"` + Processor string `json:"processor"` + Arguments map[string]any `json:"args"` + } + + // RequestOption enables request customization. + RequestOption func(*Request) + + // Credentials holds request plain auth credentials. + Credentials struct{ Username, Password string } +) + +// NewEvalRequest returns a new evaluation request request. +func NewEvalRequest(query string, opts ...RequestOption) *Request { + r := &Request{ + RequestID: uuid.New().String(), + Operation: OpsEval, + Arguments: map[string]any{ + ArgsGremlin: query, + ArgsLanguage: "gremlin-groovy", + }, + } + for i := range opts { + opts[i](r) + } + return r +} + +// NewAuthRequest returns a new auth request. +func NewAuthRequest(requestID, username, password string) *Request { + return &Request{ + RequestID: requestID, + Operation: OpsAuthentication, + Arguments: map[string]any{ + ArgsSasl: Credentials{ + Username: username, + Password: password, + }, + ArgsSaslMechanism: "PLAIN", + }, + } +} + +// WithBindings sets request bindings. +func WithBindings(bindings map[string]any) RequestOption { + return func(r *Request) { + r.Arguments[ArgsBindings] = bindings + } +} + +// WithEvalTimeout sets script evaluation timeout. +func WithEvalTimeout(timeout time.Duration) RequestOption { + return func(r *Request) { + r.Arguments[ArgsEvalTimeout] = int64(timeout / time.Millisecond) + } +} + +// MarshalText implements encoding.TextMarshaler interface. +func (c Credentials) MarshalText() ([]byte, error) { + var buf bytes.Buffer + buf.WriteByte(0) + buf.WriteString(c.Username) + buf.WriteByte(0) + buf.WriteString(c.Password) + + enc := base64.StdEncoding + text := make([]byte, enc.EncodedLen(buf.Len())) + enc.Encode(text, buf.Bytes()) + return text, nil +} + +// UnmarshalText implements encoding.TextUnmarshaler interface. +func (c *Credentials) UnmarshalText(text []byte) error { + enc := base64.StdEncoding + data := make([]byte, enc.DecodedLen(len(text))) + + n, err := enc.Decode(data, text) + if err != nil { + return err + } + data = data[:n] + + parts := bytes.SplitN(data, []byte{0}, 3) + if len(parts) != 3 { + return errors.New("bad credentials data") + } + + c.Username = string(parts[1]) + c.Password = string(parts[2]) + return nil +} diff --git a/dialect/gremlin/request_test.go b/dialect/gremlin/request_test.go new file mode 100644 index 0000000..6622224 --- /dev/null +++ b/dialect/gremlin/request_test.go @@ -0,0 +1,135 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package gremlin + +import ( + "encoding/json" + "testing" + "time" + + "entgo.io/ent/dialect/gremlin/encoding/graphson" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEvaluateRequestEncode(t *testing.T) { + req := NewEvalRequest("g.V(x)", + WithBindings(map[string]any{"x": 1}), + WithEvalTimeout(time.Second), + ) + data, err := graphson.Marshal(req) + require.NoError(t, err) + + var got map[string]any + err = json.Unmarshal(data, &got) + require.NoError(t, err) + + assert.Equal(t, map[string]any{ + "@type": "g:UUID", + "@value": req.RequestID, + }, got["requestId"]) + assert.Equal(t, req.Operation, got["op"]) + assert.Equal(t, req.Processor, got["processor"]) + + args := got["args"].(map[string]any) + assert.Equal(t, "g:Map", args["@type"]) + assert.ElementsMatch(t, args["@value"], []any{ + "gremlin", "g.V(x)", "language", "gremlin-groovy", + "scriptEvaluationTimeout", map[string]any{ + "@type": "g:Int64", + "@value": float64(1000), + }, + "bindings", map[string]any{ + "@type": "g:Map", + "@value": []any{ + "x", + map[string]any{ + "@type": "g:Int64", + "@value": float64(1), + }, + }, + }, + }) +} + +func TestEvaluateRequestWithoutBindingsEncode(t *testing.T) { + req := NewEvalRequest("g.E()") + got, err := graphson.MarshalToString(req) + require.NoError(t, err) + assert.NotContains(t, got, "bindings") +} + +func TestAuthenticateRequestEncode(t *testing.T) { + req := NewAuthRequest("41d2e28a-20a4-4ab0-b379-d810dede3786", "user", "pass") + data, err := graphson.Marshal(req) + require.NoError(t, err) + + var got map[string]any + err = json.Unmarshal(data, &got) + require.NoError(t, err) + + assert.Equal(t, map[string]any{ + "@type": "g:UUID", + "@value": req.RequestID, + }, got["requestId"]) + assert.Equal(t, req.Operation, got["op"]) + assert.Equal(t, req.Processor, got["processor"]) + + args := got["args"].(map[string]any) + assert.Equal(t, "g:Map", args["@type"]) + assert.ElementsMatch(t, args["@value"], []any{ + "sasl", "AHVzZXIAcGFzcw==", "saslMechanism", "PLAIN", + }) +} + +func TestCredentialsMarshaling(t *testing.T) { + want := Credentials{ + Username: "username", + Password: "password", + } + + text, err := want.MarshalText() + assert.NoError(t, err) + assert.Equal(t, "AHVzZXJuYW1lAHBhc3N3b3Jk", string(text)) + + var got Credentials + err = got.UnmarshalText(text) + assert.NoError(t, err) + assert.Equal(t, want, got) +} + +func TestCredentialsBadEncodingMarshaling(t *testing.T) { + tests := []struct { + name string + text []byte + }{ + { + name: "BadBase64", + text: []byte{0x12}, + }, + { + name: "Empty", + text: []byte{}, + }, + { + name: "BadPrefix", + text: []byte("Kg=="), + }, + { + name: "NoSeparator", + text: []byte("AHVzZXI="), + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + var creds Credentials + err := creds.UnmarshalText(tc.text) + assert.Error(t, err) + }) + } +} diff --git a/dialect/gremlin/response.go b/dialect/gremlin/response.go new file mode 100644 index 0000000..5599c33 --- /dev/null +++ b/dialect/gremlin/response.go @@ -0,0 +1,127 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package gremlin + +import ( + "errors" + "fmt" + + "entgo.io/ent/dialect/gremlin/encoding/graphson" + "entgo.io/ent/dialect/gremlin/graph" +) + +// A Response models a response message received from the server. +type Response struct { + RequestID string `json:"requestId" graphson:"g:UUID"` + Status struct { + Code int `json:"code"` + Attributes map[string]any `json:"attributes"` + Message string `json:"message"` + } `json:"status"` + Result struct { + Data graphson.RawMessage `json:"data"` + Meta map[string]any `json:"meta"` + } `json:"result"` +} + +// IsErr returns whether response indicates an error. +func (rsp *Response) IsErr() bool { + switch rsp.Status.Code { + case StatusSuccess, StatusNoContent, StatusPartialContent: + return false + default: + return true + } +} + +// Err returns an error representing response status. +func (rsp *Response) Err() error { + if rsp.IsErr() { + return fmt.Errorf("gremlin: code=%d, message=%q", rsp.Status.Code, rsp.Status.Message) + } + return nil +} + +// ReadVal reads gremlin response data into v. +func (rsp *Response) ReadVal(v any) error { + if err := rsp.Err(); err != nil { + return err + } + if err := graphson.Unmarshal(rsp.Result.Data, v); err != nil { + return fmt.Errorf("gremlin: unmarshal response data: type=%T: %w", v, err) + } + return nil +} + +// ReadVertices returns response data as slice of vertices. +func (rsp *Response) ReadVertices() ([]graph.Vertex, error) { + var v []graph.Vertex + err := rsp.ReadVal(&v) + return v, err +} + +// ReadVertexProperties returns response data as slice of vertex properties. +func (rsp *Response) ReadVertexProperties() ([]graph.VertexProperty, error) { + var vp []graph.VertexProperty + err := rsp.ReadVal(&vp) + return vp, err +} + +// ReadEdges returns response data as slice of edges. +func (rsp *Response) ReadEdges() ([]graph.Edge, error) { + var e []graph.Edge + err := rsp.ReadVal(&e) + return e, err +} + +// ReadProperties returns response data as slice of properties. +func (rsp *Response) ReadProperties() ([]graph.Property, error) { + var p []graph.Property + err := rsp.ReadVal(&p) + return p, err +} + +// ReadValueMap returns response data as a value map. +func (rsp *Response) ReadValueMap() (graph.ValueMap, error) { + var m graph.ValueMap + err := rsp.ReadVal(&m) + return m, err +} + +// ReadBool returns response data as a bool. +func (rsp *Response) ReadBool() (bool, error) { + var b [1]*bool + if err := rsp.ReadVal(&b); err != nil { + return false, err + } + if b[0] == nil { + return false, errors.New("gremlin: no boolean value") + } + return *b[0], nil +} + +// ReadInt returns response data as an int. +func (rsp *Response) ReadInt() (int, error) { + var v [1]*int + if err := rsp.ReadVal(&v); err != nil { + return 0, err + } + if v[0] == nil { + return 0, errors.New("gremlin: no integer value") + } + return *v[0], nil +} + +// ReadString returns response data as a string. +func (rsp *Response) ReadString() (string, error) { + var v [1]*string + if err := rsp.ReadVal(&v); err != nil { + return "", err + } + if v[0] == nil { + return "", errors.New("gremlin: no string value") + } + return *v[0], nil +} diff --git a/dialect/gremlin/response_test.go b/dialect/gremlin/response_test.go new file mode 100644 index 0000000..dfd78c0 --- /dev/null +++ b/dialect/gremlin/response_test.go @@ -0,0 +1,564 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package gremlin + +import ( + "reflect" + "testing" + + "entgo.io/ent/dialect/gremlin/encoding/graphson" + "entgo.io/ent/dialect/gremlin/graph" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDecodeResponse(t *testing.T) { + in := `{ + "requestId": "a65f2d39-1efa-45d2-a06a-c736476500fc", + "result": { + "data": { + "@type": "g:List", + "@value": [ + { + "@type": "g:Map", + "@value": [ + { + "@type": "g:T", + "@value": "id" + }, + { + "@type": "g:Int64", + "@value": 1 + }, + { + "@type": "g:T", + "@value": "label" + }, + "person", + "name", + { + "@type": "g:List", + "@value": [ + "marko" + ] + }, + "age", + { + "@type": "g:List", + "@value": [ + { + "@type": "g:Int32", + "@value": 29 + } + ] + } + ] + }, + { + "@type": "g:Map", + "@value": [ + { + "@type": "g:T", + "@value": "id" + }, + { + "@type": "g:Int64", + "@value": 6 + }, + { + "@type": "g:T", + "@value": "label" + }, + "person", + "name", + { + "@type": "g:List", + "@value": [ + "peter" + ] + }, + "age", + { + "@type": "g:List", + "@value": [ + { + "@type": "g:Int32", + "@value": 35 + } + ] + } + ] + } + ] + }, + "meta": { + "@type": "g:Map", + "@value": [] + } + }, + "status": { + "attributes": { + "@type": "g:Map", + "@value": [] + }, + "code": 200, + "message": "" + } + }` + + var rsp Response + err := graphson.UnmarshalFromString(in, &rsp) + require.NoError(t, err) + + assert.Equal(t, "a65f2d39-1efa-45d2-a06a-c736476500fc", rsp.RequestID) + assert.Equal(t, 200, rsp.Status.Code) + assert.Empty(t, rsp.Status.Message) + assert.Empty(t, rsp.Status.Attributes) + assert.Empty(t, rsp.Result.Meta) + + var vm graph.ValueMap + err = graphson.Unmarshal(rsp.Result.Data, &vm) + require.NoError(t, err) + require.Len(t, vm, 2) + + type person struct { + ID int64 `json:"id"` + Name string `json:"name"` + Age int `json:"age"` + } + var people []person + err = vm.Decode(&people) + require.NoError(t, err) + assert.Equal(t, []person{ + {1, "marko", 29}, + {6, "peter", 35}, + }, people) +} + +func TestDecodeResponseWithError(t *testing.T) { + in := `{ + "requestId": "41d2e28a-20a4-4ab0-b379-d810dede3786", + "result": { + "data": null, + "meta": { + "@type": "g:Map", + "@value": [] + } + }, + "status": { + "attributes": { + "@type": "g:Map", + "@value": [] + }, + "code": 500, + "message": "Database Down" + } + }` + var rsp Response + err := graphson.UnmarshalFromString(in, &rsp) + require.NoError(t, err) + + err = rsp.Err() + require.Error(t, err) + assert.Contains(t, err.Error(), "Database Down") + + rsp = Response{} + err = graphson.UnmarshalFromString(`{"status": null}`, &rsp) + require.NoError(t, err) + assert.Error(t, rsp.Err()) +} + +func TestResponseReadVal(t *testing.T) { + var rsp Response + rsp.Status.Code = StatusSuccess + rsp.Result.Data = []byte(`{"@type": "g:Int32", "@value": 15}`) + + var v int32 + err := rsp.ReadVal(&v) + assert.NoError(t, err) + assert.Equal(t, int32(15), v) + + var s string + err = rsp.ReadVal(&s) + assert.Error(t, err) + + rsp.Status.Code = StatusServerError + err = rsp.ReadVal(&v) + assert.Error(t, err) +} + +func TestResponseReadGraphElements(t *testing.T) { + tests := []struct { + method string + data string + want any + }{ + { + method: "ReadVertices", + data: `{ + "@type": "g:List", + "@value": [ + { + "@type": "g:Vertex", + "@value": { + "id": { + "@type": "g:Int64", + "@value": 1 + }, + "label": "person" + } + }, + { + "@type": "g:Vertex", + "@value": { + "id": { + "@type": "g:Int64", + "@value": 6 + }, + "label": "person" + } + } + ] + }`, + want: []graph.Vertex{ + graph.NewVertex(int64(1), "person"), + graph.NewVertex(int64(6), "person"), + }, + }, + { + method: "ReadVertexProperties", + data: `{ + "@type": "g:List", + "@value": [ + { + "@type": "g:VertexProperty", + "@value": { + "id": { + "@type": "g:Int64", + "@value": 0 + }, + "label": "name", + "value": "marko" + } + }, + { + "@type": "g:VertexProperty", + "@value": { + "id": { + "@type": "g:Int64", + "@value": 2 + }, + "label": "age", + "value": { + "@type": "g:Int32", + "@value": 29 + } + } + } + ] + }`, + want: []graph.VertexProperty{ + graph.NewVertexProperty(int64(0), "name", "marko"), + graph.NewVertexProperty(int64(2), "age", int32(29)), + }, + }, + { + method: "ReadEdges", + data: `{ + "@type": "g:List", + "@value": [ + { + "@type": "g:Edge", + "@value": { + "id": { + "@type": "g:Int32", + "@value": 12 + }, + "inV": { + "@type": "g:Int64", + "@value": 3 + }, + "inVLabel": "software", + "label": "created", + "outV": { + "@type": "g:Int64", + "@value": 6 + }, + "outVLabel": "person" + } + } + ] + }`, + want: []graph.Edge{ + graph.NewEdge(int32(12), "created", + graph.NewVertex(int64(6), "person"), + graph.NewVertex(int64(3), "software"), + ), + }, + }, + { + method: "ReadProperties", + data: `{ + "@type": "g:List", + "@value": [ + { + "@type": "g:Property", + "@value": { + "key": "weight", + "value": { + "@type": "g:Double", + "@value": 0.2 + } + } + } + ] + }`, + want: []graph.Property{ + graph.NewProperty("weight", float64(0.2)), + }, + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.method, func(t *testing.T) { + t.Parallel() + var rsp Response + rsp.Status.Code = StatusSuccess + rsp.Result.Data = []byte(tc.data) + vals := reflect.ValueOf(&rsp).MethodByName(tc.method).Call(nil) + require.Len(t, vals, 2) + require.True(t, vals[1].IsNil()) + assert.Equal(t, tc.want, vals[0].Interface()) + }) + } +} + +func TestResponseReadValueMap(t *testing.T) { + t.Parallel() + var rsp Response + rsp.Status.Code = StatusSuccess + rsp.Result.Data = []byte(`{ + "@type": "g:List", + "@value": [ + { + "@type": "g:Map", + "@value": [ + "name", + { + "@type": "g:List", + "@value": [ + "alex" + ] + } + ] + } + ] + }`) + m, err := rsp.ReadValueMap() + require.NoError(t, err) + + var name string + err = m.Decode(&struct { + Name *string `json:"name"` + }{&name}) + require.NoError(t, err) + assert.Equal(t, "alex", name) +} + +func TestResponseReadBool(t *testing.T) { + tests := []struct { + name string + data string + want bool + wantErr bool + }{ + { + name: "Simple", + data: `{ + "@type": "g:List", + "@value": [ + true + ] + }`, + want: true, + }, + { + name: "Multi", + data: `{ + "@type": "g:List", + "@value": [ + false, + true + ] + }`, + want: false, + }, + { + name: "Empty", + data: `{ + "@type": "g:List", + "@value": [] + }`, + wantErr: true, + }, + { + name: "BadType", + data: `{ + "@type": "g:List", + "@value": [ + "user" + ] + }`, + wantErr: true, + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + var rsp Response + rsp.Status.Code = StatusSuccess + rsp.Result.Data = []byte(tc.data) + got, err := rsp.ReadBool() + if tc.wantErr { + assert.Error(t, err) + } else { + require.NoError(t, err) + assert.Equal(t, tc.want, got) + } + }) + } +} + +func TestResponseReadInt(t *testing.T) { + tests := []struct { + name string + data string + want int + wantErr bool + }{ + { + name: "Simple", + data: `{ + "@type": "g:List", + "@value": [ + { + "@type": "g:Int64", + "@value": 42 + } + ] + }`, + want: 42, + }, + { + name: "Multi", + data: `{ + "@type": "g:List", + "@value": [ + { + "@type": "g:Int64", + "@value": 55 + }, + { + "@type": "g:Int64", + "@value": 13 + } + ] + }`, + want: 55, + }, + { + name: "Empty", + data: `{ + "@type": "g:List", + "@value": [] + }`, + wantErr: true, + }, + { + name: "BadType", + data: `{ + "@type": "g:List", + "@value": [ + true + ] + }`, + wantErr: true, + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + var rsp Response + rsp.Status.Code = StatusSuccess + rsp.Result.Data = []byte(tc.data) + got, err := rsp.ReadInt() + if tc.wantErr { + assert.Error(t, err) + } else { + require.NoError(t, err) + assert.Equal(t, tc.want, got) + } + }) + } +} + +func TestResponseReadString(t *testing.T) { + tests := []struct { + name string + data string + want string + wantErr bool + }{ + { + name: "Simple", + data: `{ + "@type": "g:List", + "@value": ["foo"] + }`, + want: "foo", + }, + { + name: "Empty", + data: `{ + "@type": "g:List", + "@value": [] + }`, + wantErr: true, + }, + { + name: "BadType", + data: `{ + "@type": "g:List", + "@value": [ + true + ] + }`, + wantErr: true, + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + var rsp Response + rsp.Status.Code = StatusSuccess + rsp.Result.Data = []byte(tc.data) + got, err := rsp.ReadString() + if tc.wantErr { + assert.Error(t, err) + } else { + require.NoError(t, err) + assert.Equal(t, tc.want, got) + } + }) + } +} diff --git a/dialect/gremlin/status.go b/dialect/gremlin/status.go new file mode 100644 index 0000000..cd9439a --- /dev/null +++ b/dialect/gremlin/status.go @@ -0,0 +1,69 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package gremlin + +const ( + // StatusSuccess is returned on success. + StatusSuccess = 200 + + // StatusNoContent means the server processed the request but there is no result to return. + StatusNoContent = 204 + + // StatusPartialContent indicates the server successfully returned some content, but there + // is more in the stream to arrive wait for a success code to signify the end. + StatusPartialContent = 206 + + // StatusUnauthorized means the request attempted to access resources that + // the requesting user did not have access to. + StatusUnauthorized = 401 + + // StatusAuthenticate denotes a challenge from the server for the client to authenticate its request. + StatusAuthenticate = 407 + + // StatusMalformedRequest means the request message was not properly formatted which means it could not be parsed at + // all or the "op" code was not recognized such that Gremlin Server could properly route it for processing. + // Check the message format and retry the request. + StatusMalformedRequest = 498 + + // StatusInvalidRequestArguments means the request message was parsable, but the arguments supplied in the message + // were in conflict or incomplete. Check the message format and retry the request. + StatusInvalidRequestArguments = 499 + + // StatusServerError indicates a general server error occurred that prevented the request from being processed. + StatusServerError = 500 + + // StatusScriptEvaluationError is returned when the script submitted for processing evaluated in the ScriptEngine + // with errors and could not be processed. Check the script submitted for syntax errors or other problems + // and then resubmit. + StatusScriptEvaluationError = 597 + + // StatusServerTimeout means the server exceeded one of the timeout settings for the request and could therefore + // only partially responded or did not respond at all. + StatusServerTimeout = 598 + + // StatusServerSerializationError means the server was not capable of serializing an object that was returned from the + // script supplied on the request. Either transform the object into something Gremlin Server can process within + // the script or install mapper serialization classes to Gremlin Server. + StatusServerSerializationError = 599 +) + +var statusText = map[int]string{ + StatusSuccess: "Success", + StatusNoContent: "No Content", + StatusPartialContent: "Partial Content", + StatusUnauthorized: "Unauthorized", + StatusAuthenticate: "Authenticate", + StatusMalformedRequest: "Malformed Request", + StatusInvalidRequestArguments: "Invalid Request Arguments", + StatusServerError: "Server Error", + StatusScriptEvaluationError: "Script Evaluation Error", + StatusServerTimeout: "Server Timeout", + StatusServerSerializationError: "Server Serialization Error", +} + +// StatusText returns status text of code. +func StatusText(code int) string { + return statusText[code] +} diff --git a/dialect/gremlin/status_test.go b/dialect/gremlin/status_test.go new file mode 100644 index 0000000..d473fd0 --- /dev/null +++ b/dialect/gremlin/status_test.go @@ -0,0 +1,16 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package gremlin + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestStatusText(t *testing.T) { + assert.NotEmpty(t, StatusText(StatusSuccess)) + assert.Empty(t, StatusText(4242)) +} diff --git a/dialect/gremlin/tokens.go b/dialect/gremlin/tokens.go new file mode 100644 index 0000000..bd9186a --- /dev/null +++ b/dialect/gremlin/tokens.go @@ -0,0 +1,69 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package gremlin + +// Gremlin server operations. +const ( + // OpsAuthentication used by the client to authenticate itself. + OpsAuthentication = "authentication" + + // OpsBytecode used for a request that contains the Bytecode representation of a Traversal. + OpsBytecode = "bytecode" + + // OpsEval used to evaluate a Gremlin script provided as a string. + OpsEval = "eval" + + // OpsGather used to get a particular side-effect as produced by a previously executed Traversal. + OpsGather = "gather" + + // OpsKeys used to get all the keys of all side-effects as produced by a previously executed Traversal. + OpsKeys = "keys" + + // OpsClose used to get all the keys of all side-effects as produced by a previously executed Traversal. + OpsClose = "close" +) + +// Gremlin server operation processors. +const ( + // ProcessorTraversal is the default operation processor. + ProcessorTraversal = "traversal" +) + +const ( + // ArgsBatchSize allows to defines the number of iterations each ResponseMessage should contain + ArgsBatchSize = "batchSize" + + // ArgsBindings allows to provide a map of key/value pairs to apply + // as variables in the context of the Gremlin script. + ArgsBindings = "bindings" + + // ArgsAliases allows to define aliases that represent globally bound Graph and TraversalSource objects. + ArgsAliases = "aliases" + + // ArgsGremlin corresponds to the Traversal to evaluate. + ArgsGremlin = "gremlin" + + // ArgsSideEffect allows to specify the unique identifier for the request. + ArgsSideEffect = "sideEffect" + + // ArgsSideEffectKey allows to specify the key for a specific side-effect. + ArgsSideEffectKey = "sideEffectKey" + + // ArgsAggregateTo describes how side-effect data should be treated. + ArgsAggregateTo = "aggregateTo" + + // ArgsLanguage allows to change the flavor of Gremlin used (e.g. gremlin-groovy). + ArgsLanguage = "language" + + // ArgsEvalTimeout allows to override the server setting that determines + // the maximum time to wait for a script to execute on the server. + ArgsEvalTimeout = "scriptEvaluationTimeout" + + // ArgsSasl defines the response to the server authentication challenge. + ArgsSasl = "sasl" + + // ArgsSaslMechanism defines the SASL mechanism (e.g. PLAIN). + ArgsSaslMechanism = "saslMechanism" +) diff --git a/dialect/sql/bench_test.go b/dialect/sql/bench_test.go new file mode 100644 index 0000000..05ebab3 --- /dev/null +++ b/dialect/sql/bench_test.go @@ -0,0 +1,37 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package sql + +import ( + "testing" + + "entgo.io/ent/dialect" +) + +func BenchmarkInsertBuilder_Default(b *testing.B) { + for _, d := range []string{dialect.SQLite, dialect.MySQL, dialect.Postgres} { + b.Run(d, func(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + Dialect(d).Insert("users").Default().Returning("id").Query() + } + }) + } +} + +func BenchmarkInsertBuilder_Small(b *testing.B) { + for _, d := range []string{dialect.SQLite, dialect.MySQL, dialect.Postgres} { + b.Run(d, func(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + Dialect(d).Insert("users"). + Columns("id", "age", "first_name", "last_name", "nickname", "spouse_id", "created_at", "updated_at"). + Values(1, 30, "Ariel", "Mashraki", "a8m", 2, "2009-11-10 23:00:00", "2009-11-10 23:00:00"). + Returning("id"). + Query() + } + }) + } +} diff --git a/dialect/sql/builder.go b/dialect/sql/builder.go new file mode 100644 index 0000000..0cb2ffa --- /dev/null +++ b/dialect/sql/builder.go @@ -0,0 +1,3603 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +// Package sql provides wrappers around the standard database/sql package +// to allow the generated code to interact with a statically-typed API. +// +// Users that are interacting with this package should be aware that the +// following builders don't check the given SQL syntax nor validate or escape +// user-inputs. ~All validations are expected to be happened in the generated +// ent package. +package sql + +import ( + "context" + "database/sql/driver" + "errors" + "fmt" + "strconv" + "strings" + + "entgo.io/ent/dialect" +) + +// Querier wraps the basic Query method that is implemented +// by the different builders in this file. +type Querier interface { + // Query returns the query representation of the element + // and its arguments (if any). + Query() (string, []any) +} + +// querierErr allowed propagate Querier's inner error +type querierErr interface { + Err() error +} + +// ColumnBuilder is a builder for column definition in table creation. +type ColumnBuilder struct { + Builder + typ string // column type. + name string // column name. +} + +// Column returns a new ColumnBuilder with the given name. +// +// sql.Column("group_id").Type("int").Attr("UNIQUE") +func Column(name string) *ColumnBuilder { return &ColumnBuilder{name: name} } + +// Type sets the column type. +func (c *ColumnBuilder) Type(t string) *ColumnBuilder { + c.typ = t + return c +} + +// Query returns query representation of a Column. +func (c *ColumnBuilder) Query() (string, []any) { + c.Ident(c.name) + if c.typ != "" { + c.Pad().WriteString(c.typ) + } + return c.String(), c.args +} + +// ViewBuilder is a query builder for `CREATE VIEW` statement. +type ViewBuilder struct { + Builder + schema string // view schema. + name string // view name. + exists bool // check existence. + columns []Querier // table columns. + as Querier // view query. +} + +// CreateView returns a query builder for the `CREATE VIEW` statement. +// +// t := Table("users") +// CreateView("clean_users"). +// Columns( +// Column("id").Type("int").Attr("auto_increment"), +// Column("name").Type("varchar(255)"), +// ). +// As(Select(t.C("id"), t.C("name")).From(t)) +func CreateView(name string) *ViewBuilder { return &ViewBuilder{name: name} } + +// Schema sets the database name for the view. +func (v *ViewBuilder) Schema(name string) *ViewBuilder { + v.schema = name + return v +} + +// IfNotExists appends the `IF NOT EXISTS` clause to the `CREATE VIEW` statement. +func (v *ViewBuilder) IfNotExists() *ViewBuilder { + v.exists = true + return v +} + +// Column appends the given column to the `CREATE VIEW` statement. +func (v *ViewBuilder) Column(c *ColumnBuilder) *ViewBuilder { + v.columns = append(v.columns, c) + return v +} + +// Columns appends a list of columns to the builder. +func (v *ViewBuilder) Columns(columns ...*ColumnBuilder) *ViewBuilder { + v.columns = make([]Querier, 0, len(columns)) + for i := range columns { + v.columns = append(v.columns, columns[i]) + } + return v +} + +// As sets the view definition to the builder. +func (v *ViewBuilder) As(as Querier) *ViewBuilder { + v.as = as + return v +} + +// Query returns query representation of a `CREATE VIEW` statement. +// +// CREATE VIEW [IF NOT EXISTS] name AS +// +// (view definition) +func (v *ViewBuilder) Query() (string, []any) { + v.WriteString("CREATE VIEW ") + if v.exists { + v.WriteString("IF NOT EXISTS ") + } + v.writeSchema(v.schema) + v.Ident(v.name) + if len(v.columns) > 0 { + v.Pad().Wrap(func(b *Builder) { b.JoinComma(v.columns...) }) + } + v.WriteString(" AS ") + v.Join(v.as) + return v.String(), v.args +} + +// InsertBuilder is a builder for `INSERT INTO` statement. +type InsertBuilder struct { + Builder + table string + schema string + columns []string + defaults bool + returning []string + values [][]any + conflict *conflict +} + +// Insert creates a builder for the `INSERT INTO` statement. +// +// Insert("users"). +// Columns("name", "age"). +// Values("a8m", 10). +// Values("foo", 20) +// +// Note: Insert inserts all values in one batch. +func Insert(table string) *InsertBuilder { return &InsertBuilder{table: table} } + +// Schema sets the database name for the insert table. +func (i *InsertBuilder) Schema(name string) *InsertBuilder { + i.schema = name + return i +} + +// Set is a syntactic sugar API for inserting only one row. +func (i *InsertBuilder) Set(column string, v any) *InsertBuilder { + i.columns = append(i.columns, column) + if len(i.values) == 0 { + i.values = append(i.values, []any{v}) + } else { + i.values[0] = append(i.values[0], v) + } + return i +} + +// Columns appends columns to the INSERT statement. +func (i *InsertBuilder) Columns(columns ...string) *InsertBuilder { + i.columns = append(i.columns, columns...) + return i +} + +// Values append a value tuple for the insert statement. +func (i *InsertBuilder) Values(values ...any) *InsertBuilder { + i.values = append(i.values, values) + return i +} + +// Default sets the default values clause based on the dialect type. +func (i *InsertBuilder) Default() *InsertBuilder { + i.defaults = true + return i +} + +// Returning adds the `RETURNING` clause to the insert statement. +// Supported by SQLite and PostgreSQL. +func (i *InsertBuilder) Returning(columns ...string) *InsertBuilder { + i.returning = columns + return i +} + +type ( + // conflict holds the configuration for the + // `ON CONFLICT` / `ON DUPLICATE KEY` clause. + conflict struct { + target struct { + constraint string + columns []string + where *Predicate + } + action struct { + nothing bool + where *Predicate + update []func(*UpdateSet) + } + } + + // ConflictOption allows configuring the + // conflict config using functional options. + ConflictOption func(*conflict) +) + +// ConflictColumns sets the unique constraints that trigger the conflict +// resolution on insert to perform an upsert operation. The columns must +// have a unique constraint applied to trigger this behaviour. +// +// sql.Insert("users"). +// Columns("id", "name"). +// Values(1, "Mashraki"). +// OnConflict( +// sql.ConflictColumns("id"), +// sql.ResolveWithNewValues(), +// ) +func ConflictColumns(names ...string) ConflictOption { + return func(c *conflict) { + c.target.columns = names + } +} + +// ConflictConstraint allows setting the constraint +// name (i.e. `ON CONSTRAINT `) for PostgreSQL. +// +// sql.Insert("users"). +// Columns("id", "name"). +// Values(1, "Mashraki"). +// OnConflict( +// sql.ConflictConstraint("users_pkey"), +// sql.ResolveWithNewValues(), +// ) +func ConflictConstraint(name string) ConflictOption { + return func(c *conflict) { + c.target.constraint = name + } +} + +// ConflictWhere allows inference of partial unique indexes. See, PostgreSQL +// doc: https://www.postgresql.org/docs/current/sql-insert.html#SQL-ON-CONFLICT +func ConflictWhere(p *Predicate) ConflictOption { + return func(c *conflict) { + c.target.where = p + } +} + +// UpdateWhere allows setting the update condition. Only rows +// for which this expression returns true will be updated. +func UpdateWhere(p *Predicate) ConflictOption { + return func(c *conflict) { + c.action.where = p + } +} + +// DoNothing configures the conflict_action to `DO NOTHING`. +// Supported by SQLite and PostgreSQL. +// +// sql.Insert("users"). +// Columns("id", "name"). +// Values(1, "Mashraki"). +// OnConflict( +// sql.ConflictColumns("id"), +// sql.DoNothing() +// ) +func DoNothing() ConflictOption { + return func(c *conflict) { + c.action.nothing = true + } +} + +// ResolveWithIgnore sets each column to itself to force an update and return the ID, +// otherwise does not change any data. This may still trigger update hooks in the database. +// +// sql.Insert("users"). +// Columns("id"). +// Values(1). +// OnConflict( +// sql.ConflictColumns("id"), +// sql.ResolveWithIgnore() +// ) +// +// // Output: +// // MySQL: INSERT INTO `users` (`id`) VALUES(1) ON DUPLICATE KEY UPDATE `id` = `users`.`id` +// // PostgreSQL: INSERT INTO "users" ("id") VALUES(1) ON CONFLICT ("id") DO UPDATE SET "id" = "users"."id +func ResolveWithIgnore() ConflictOption { + return func(c *conflict) { + c.action.update = append(c.action.update, func(u *UpdateSet) { + for _, c := range u.columns { + u.SetIgnore(c) + } + }) + } +} + +// ResolveWithNewValues updates columns using the new values proposed +// for insertion using the special EXCLUDED/VALUES table. +// +// sql.Insert("users"). +// Columns("id", "name"). +// Values(1, "Mashraki"). +// OnConflict( +// sql.ConflictColumns("id"), +// sql.ResolveWithNewValues() +// ) +// +// // Output: +// // MySQL: INSERT INTO `users` (`id`, `name`) VALUES(1, 'Mashraki) ON DUPLICATE KEY UPDATE `id` = VALUES(`id`), `name` = VALUES(`name`), +// // PostgreSQL: INSERT INTO "users" ("id") VALUES(1) ON CONFLICT ("id") DO UPDATE SET "id" = "excluded"."id, "name" = "excluded"."name" +func ResolveWithNewValues() ConflictOption { + return func(c *conflict) { + c.action.update = append(c.action.update, func(u *UpdateSet) { + for _, c := range u.columns { + u.SetExcluded(c) + } + }) + } +} + +// ResolveWith allows setting a custom function to set the `UPDATE` clause. +// +// Insert("users"). +// Columns("id", "name"). +// Values(1, "Mashraki"). +// OnConflict( +// ConflictColumns("name"), +// ResolveWith(func(u *UpdateSet) { +// u.SetIgnore("id") +// u.SetNull("created_at") +// u.Set("name", Expr(u.Excluded().C("name"))) +// }), +// ) +func ResolveWith(fn func(*UpdateSet)) ConflictOption { + return func(c *conflict) { + c.action.update = append(c.action.update, fn) + } +} + +// OnConflict allows configuring the `ON CONFLICT` / `ON DUPLICATE KEY` clause +// of the `INSERT` statement. For example: +// +// sql.Insert("users"). +// Columns("id", "name"). +// Values(1, "Mashraki"). +// OnConflict( +// sql.ConflictColumns("id"), +// sql.ResolveWithNewValues() +// ) +func (i *InsertBuilder) OnConflict(opts ...ConflictOption) *InsertBuilder { + if i.conflict == nil { + i.conflict = &conflict{} + } + for _, opt := range opts { + opt(i.conflict) + } + return i +} + +// UpdateSet describes a set of changes of the `DO UPDATE` clause. +type UpdateSet struct { + *UpdateBuilder + columns []string +} + +// Table returns the table the `UPSERT` statement is executed on. +func (u *UpdateSet) Table() *SelectTable { + return Dialect(u.UpdateBuilder.dialect).Table(u.UpdateBuilder.table) +} + +// Columns returns all columns in the `INSERT` statement. +func (u *UpdateSet) Columns() []string { + return u.columns +} + +// UpdateColumns returns all columns in the `UPDATE` statement. +func (u *UpdateSet) UpdateColumns() []string { + return append(u.UpdateBuilder.nulls, u.UpdateBuilder.columns...) +} + +// Set sets a column to a given value. +func (u *UpdateSet) Set(column string, v any) *UpdateSet { + u.UpdateBuilder.Set(column, v) + return u +} + +// Add adds a numeric value to the given column. +func (u *UpdateSet) Add(column string, v any) *UpdateSet { + u.UpdateBuilder.Add(column, v) + return u +} + +// SetNull sets a column as null value. +func (u *UpdateSet) SetNull(column string) *UpdateSet { + u.UpdateBuilder.SetNull(column) + return u +} + +// SetIgnore sets the column to itself. For example, "id" = "users"."id". +func (u *UpdateSet) SetIgnore(name string) *UpdateSet { + return u.Set(name, Expr(u.Table().C(name))) +} + +// SetExcluded sets the column name to its EXCLUDED/VALUES value. +// For example, "c" = "excluded"."c", or `c` = VALUES(`c`). +func (u *UpdateSet) SetExcluded(name string) *UpdateSet { + switch u.UpdateBuilder.Dialect() { + case dialect.MySQL: + u.UpdateBuilder.Set(name, ExprFunc(func(b *Builder) { + b.WriteString("VALUES(").Ident(name).WriteByte(')') + })) + default: + t := Dialect(u.UpdateBuilder.dialect).Table("excluded") + u.UpdateBuilder.Set(name, Expr(t.C(name))) + } + return u +} + +// Query returns query representation of an `INSERT INTO` statement. +func (i *InsertBuilder) Query() (string, []any) { + query, args, _ := i.QueryErr() + return query, args +} + +// QueryErr returns query representation of an `INSERT INTO` +// statement and any error occurred in building the statement. +func (i *InsertBuilder) QueryErr() (string, []any, error) { + b := i.Builder.clone() + b.WriteString("INSERT INTO ") + b.writeSchema(i.schema) + b.Ident(i.table).Pad() + if i.defaults && len(i.columns) == 0 { + i.writeDefault(&b) + } else { + b.WriteByte('(').IdentComma(i.columns...).WriteByte(')') + b.WriteString(" VALUES ") + for j, v := range i.values { + if j > 0 { + b.Comma() + } + b.WriteByte('(').Args(v...).WriteByte(')') + } + } + if i.conflict != nil { + i.writeConflict(&b) + } + joinReturning(i.returning, &b) + return b.String(), b.args, b.Err() +} + +func (i *InsertBuilder) writeDefault(b *Builder) { + switch i.Dialect() { + case dialect.MySQL: + b.WriteString("VALUES ()") + case dialect.SQLite, dialect.Postgres: + b.WriteString("DEFAULT VALUES") + } +} + +func (i *InsertBuilder) writeConflict(b *Builder) { + switch i.Dialect() { + case dialect.MySQL: + b.WriteString(" ON DUPLICATE KEY UPDATE ") + // Fallback to ResolveWithIgnore() as MySQL + // does not support the "DO NOTHING" clause. + if i.conflict.action.nothing { + i.OnConflict(ResolveWithIgnore()) + } + case dialect.SQLite, dialect.Postgres: + b.WriteString(" ON CONFLICT") + switch t := i.conflict.target; { + case t.constraint != "" && len(t.columns) != 0: + b.AddError(fmt.Errorf("duplicate CONFLICT clauses: %q, %q", t.constraint, t.columns)) + case t.constraint != "": + b.WriteString(" ON CONSTRAINT ").Ident(t.constraint) + case len(t.columns) != 0: + b.WriteString(" (").IdentComma(t.columns...).WriteByte(')') + } + if p := i.conflict.target.where; p != nil { + b.WriteString(" WHERE ").Join(p) + } + if i.conflict.action.nothing { + b.WriteString(" DO NOTHING") + return + } + b.WriteString(" DO UPDATE SET ") + } + if len(i.conflict.action.update) == 0 { + b.AddError(errors.New("missing action for 'DO UPDATE SET' clause")) + } + u := &UpdateSet{UpdateBuilder: Dialect(i.dialect).Update(i.table), columns: i.columns} + u.Builder = *b + for _, f := range i.conflict.action.update { + f(u) + } + u.writeSetter(b) + if p := i.conflict.action.where; p != nil { + p.qualifier = i.table + b.WriteString(" WHERE ").Join(p) + } +} + +// UpdateBuilder is a builder for `UPDATE` statement. +type UpdateBuilder struct { + Builder + table string + schema string + where *Predicate + nulls []string + columns []string + returning []string + values []any + order []any + limit *int + prefix Queries +} + +// Update creates a builder for the `UPDATE` statement. +// +// Update("users").Set("name", "foo").Set("age", 10) +func Update(table string) *UpdateBuilder { return &UpdateBuilder{table: table} } + +// Schema sets the database name for the updated table. +func (u *UpdateBuilder) Schema(name string) *UpdateBuilder { + u.schema = name + return u +} + +// Set sets a column to a given value. If `Set` was called before with +// the same column name, it overrides the value of the previous call. +func (u *UpdateBuilder) Set(column string, v any) *UpdateBuilder { + for i := range u.columns { + if column == u.columns[i] { + u.values[i] = v + return u + } + } + u.columns = append(u.columns, column) + u.values = append(u.values, v) + return u +} + +// Add adds a numeric value to the given column. Note that, calling Set(c) +// after Add(c) will erase previous calls with c from the builder. +func (u *UpdateBuilder) Add(column string, v any) *UpdateBuilder { + u.columns = append(u.columns, column) + u.values = append(u.values, ExprFunc(func(b *Builder) { + b.WriteString("COALESCE") + b.Wrap(func(b *Builder) { + b.Ident(Table(u.table).C(column)).Comma().WriteByte('0') + }) + b.WriteString(" + ") + b.Arg(v) + })) + return u +} + +// SetNull sets a column as null value. +func (u *UpdateBuilder) SetNull(column string) *UpdateBuilder { + u.nulls = append(u.nulls, column) + return u +} + +// Where adds a where predicate for update statement. +func (u *UpdateBuilder) Where(p *Predicate) *UpdateBuilder { + if u.where != nil { + u.where = And(u.where, p) + } else { + u.where = p + } + return u +} + +// FromSelect makes it possible to update entities that match the sub-query. +func (u *UpdateBuilder) FromSelect(s *Selector) *UpdateBuilder { + u.Where(s.where) + if t := s.Table(); t != nil { + u.table = t.name + } + return u +} + +// Empty reports whether this builder does not contain update changes. +func (u *UpdateBuilder) Empty() bool { + return len(u.columns) == 0 && len(u.nulls) == 0 +} + +// OrderBy appends the `ORDER BY` clause to the `UPDATE` statement. +// Supported by SQLite and MySQL. +func (u *UpdateBuilder) OrderBy(columns ...string) *UpdateBuilder { + if u.postgres() { + u.AddError(errors.New("ORDER BY is not supported by PostgreSQL")) + return u + } + for i := range columns { + u.order = append(u.order, columns[i]) + } + return u +} + +// Limit appends the `LIMIT` clause to the `UPDATE` statement. +// Supported by SQLite and MySQL. +func (u *UpdateBuilder) Limit(limit int) *UpdateBuilder { + if u.postgres() { + u.AddError(errors.New("LIMIT is not supported by PostgreSQL")) + return u + } + u.limit = &limit + return u +} + +// Prefix prefixes the UPDATE statement with list of statements. +func (u *UpdateBuilder) Prefix(stmts ...Querier) *UpdateBuilder { + u.prefix = append(u.prefix, stmts...) + return u +} + +// Returning adds the `RETURNING` clause to the insert statement. +// Supported by SQLite and PostgreSQL. +func (u *UpdateBuilder) Returning(columns ...string) *UpdateBuilder { + u.returning = columns + return u +} + +// Query returns query representation of an `UPDATE` statement. +func (u *UpdateBuilder) Query() (string, []any) { + b := u.Builder.clone() + if len(u.prefix) > 0 { + b.join(u.prefix, " ") + b.Pad() + } + b.WriteString("UPDATE ") + b.writeSchema(u.schema) + b.Ident(u.table).WriteString(" SET ") + u.writeSetter(&b) + if u.where != nil { + b.WriteString(" WHERE ") + b.Join(u.where) + } + joinReturning(u.returning, &b) + joinOrder(u.order, &b) + if u.limit != nil { + b.WriteString(" LIMIT ") + b.WriteString(strconv.Itoa(*u.limit)) + } + return b.String(), b.args +} + +// writeSetter writes the "SET" clause for the UPDATE statement. +func (u *UpdateBuilder) writeSetter(b *Builder) { + for i, c := range u.nulls { + if i > 0 { + b.Comma() + } + b.Ident(c).WriteString(" = NULL") + } + if len(u.nulls) > 0 && len(u.columns) > 0 { + b.Comma() + } + for i, c := range u.columns { + if i > 0 { + b.Comma() + } + b.Ident(c).WriteString(" = ") + switch v := u.values[i].(type) { + case Querier: + b.Join(v) + default: + b.Arg(v) + } + } +} + +// DeleteBuilder is a builder for `DELETE` statement. +type DeleteBuilder struct { + Builder + table string + schema string + where *Predicate +} + +// Delete creates a builder for the `DELETE` statement. +// +// Delete("users"). +// Where( +// Or( +// EQ("name", "foo").And().EQ("age", 10), +// EQ("name", "bar").And().EQ("age", 20), +// And( +// EQ("name", "qux"), +// EQ("age", 1).Or().EQ("age", 2), +// ), +// ), +// ) +func Delete(table string) *DeleteBuilder { return &DeleteBuilder{table: table} } + +// Schema sets the database name for the table whose row will be deleted. +func (d *DeleteBuilder) Schema(name string) *DeleteBuilder { + d.schema = name + return d +} + +// Where appends a where predicate to the `DELETE` statement. +func (d *DeleteBuilder) Where(p *Predicate) *DeleteBuilder { + if d.where != nil { + d.where = And(d.where, p) + } else { + d.where = p + } + return d +} + +// FromSelect makes it possible to delete a sub query. +func (d *DeleteBuilder) FromSelect(s *Selector) *DeleteBuilder { + d.Where(s.where) + if t := s.Table(); t != nil { + d.table = t.name + } + return d +} + +// Query returns query representation of a `DELETE` statement. +func (d *DeleteBuilder) Query() (string, []any) { + d.WriteString("DELETE FROM ") + d.writeSchema(d.schema) + d.Ident(d.table) + if d.where != nil { + d.WriteString(" WHERE ") + d.Join(d.where) + } + return d.String(), d.args +} + +// Predicate is a where predicate. +type Predicate struct { + Builder + depth int + fns []func(*Builder) +} + +// P creates a new predicate. +// +// P().EQ("name", "a8m").And().EQ("age", 30) +func P(fns ...func(*Builder)) *Predicate { + return &Predicate{fns: fns} +} + +// ExprP creates a new predicate from the given expression. +// +// ExprP("A = ? AND B > ?", args...) +func ExprP(exr string, args ...any) *Predicate { + return P(func(b *Builder) { + b.Join(Expr(exr, args...)) + }) +} + +// Or combines all given predicates with OR between them. +// +// Or(EQ("name", "foo"), EQ("name", "bar")) +func Or(preds ...*Predicate) *Predicate { + p := P() + return p.Append(func(b *Builder) { + p.mayWrap(preds, b, "OR") + }) +} + +// False appends the FALSE keyword to the predicate. +// +// Delete().From("users").Where(False()) +func False() *Predicate { + return P().False() +} + +// False appends FALSE to the predicate. +func (p *Predicate) False() *Predicate { + return p.Append(func(b *Builder) { + b.WriteString("FALSE") + }) +} + +// Not wraps the given predicate with the not predicate. +// +// Not(Or(EQ("name", "foo"), EQ("name", "bar"))) +func Not(pred *Predicate) *Predicate { + return P().Not().Append(func(b *Builder) { + b.Wrap(func(b *Builder) { + b.Join(pred) + }) + }) +} + +// Not appends NOT to the predicate. +func (p *Predicate) Not() *Predicate { + return p.Append(func(b *Builder) { + b.WriteString("NOT ") + }) +} + +// ColumnsOp returns a new predicate between 2 columns. +func ColumnsOp(col1, col2 string, op Op) *Predicate { + return P().ColumnsOp(col1, col2, op) +} + +// ColumnsOp appends the given predicate between 2 columns. +func (p *Predicate) ColumnsOp(col1, col2 string, op Op) *Predicate { + return p.Append(func(b *Builder) { + b.Ident(col1) + b.WriteOp(op) + b.Ident(col2) + }) +} + +// And combines all given predicates with AND between them. +func And(preds ...*Predicate) *Predicate { + p := P() + return p.Append(func(b *Builder) { + p.mayWrap(preds, b, "AND") + }) +} + +// IsTrue appends a predicate that checks if the column value is truthy. +func IsTrue(col string) *Predicate { + return P().IsTrue(col) +} + +// IsTrue appends a predicate that checks if the column value is truthy. +func (p *Predicate) IsTrue(col string) *Predicate { + return p.Append(func(b *Builder) { + b.Ident(col) + }) +} + +// IsFalse appends a predicate that checks if the column value is falsey. +func IsFalse(col string) *Predicate { + return P().IsFalse(col) +} + +// IsFalse appends a predicate that checks if the column value is falsey. +func (p *Predicate) IsFalse(col string) *Predicate { + return p.Append(func(b *Builder) { + b.WriteString("NOT ").Ident(col) + }) +} + +// EQ returns a "=" predicate. +func EQ(col string, value any) *Predicate { + return P().EQ(col, value) +} + +// EQ appends a "=" predicate. +func (p *Predicate) EQ(col string, arg any) *Predicate { + // A small optimization to avoid passing + // arguments when it can be avoided. + switch arg := arg.(type) { + case bool: + if arg { + return IsTrue(col) + } + return IsFalse(col) + default: + return p.Append(func(b *Builder) { + b.Ident(col) + b.WriteOp(OpEQ) + p.arg(b, arg) + }) + } +} + +// ColumnsEQ appends a "=" predicate between 2 columns. +func ColumnsEQ(col1, col2 string) *Predicate { + return P().ColumnsEQ(col1, col2) +} + +// ColumnsEQ appends a "=" predicate between 2 columns. +func (p *Predicate) ColumnsEQ(col1, col2 string) *Predicate { + return p.ColumnsOp(col1, col2, OpEQ) +} + +// NEQ returns a "<>" predicate. +func NEQ(col string, value any) *Predicate { + return P().NEQ(col, value) +} + +// NEQ appends a "<>" predicate. +func (p *Predicate) NEQ(col string, arg any) *Predicate { + // A small optimization to avoid passing + // arguments when it can be avoided. + switch arg := arg.(type) { + case bool: + if arg { + return IsFalse(col) + } + return IsTrue(col) + default: + return p.Append(func(b *Builder) { + b.Ident(col) + b.WriteOp(OpNEQ) + p.arg(b, arg) + }) + } +} + +// ColumnsNEQ appends a "<>" predicate between 2 columns. +func ColumnsNEQ(col1, col2 string) *Predicate { + return P().ColumnsNEQ(col1, col2) +} + +// ColumnsNEQ appends a "<>" predicate between 2 columns. +func (p *Predicate) ColumnsNEQ(col1, col2 string) *Predicate { + return p.ColumnsOp(col1, col2, OpNEQ) +} + +// LT returns a "<" predicate. +func LT(col string, value any) *Predicate { + return P().LT(col, value) +} + +// LT appends a "<" predicate. +func (p *Predicate) LT(col string, arg any) *Predicate { + return p.Append(func(b *Builder) { + b.Ident(col) + p.WriteOp(OpLT) + p.arg(b, arg) + }) +} + +// ColumnsLT appends a "<" predicate between 2 columns. +func ColumnsLT(col1, col2 string) *Predicate { + return P().ColumnsLT(col1, col2) +} + +// ColumnsLT appends a "<" predicate between 2 columns. +func (p *Predicate) ColumnsLT(col1, col2 string) *Predicate { + return p.ColumnsOp(col1, col2, OpLT) +} + +// LTE returns a "<=" predicate. +func LTE(col string, value any) *Predicate { + return P().LTE(col, value) +} + +// LTE appends a "<=" predicate. +func (p *Predicate) LTE(col string, arg any) *Predicate { + return p.Append(func(b *Builder) { + b.Ident(col) + p.WriteOp(OpLTE) + p.arg(b, arg) + }) +} + +// ColumnsLTE appends a "<=" predicate between 2 columns. +func ColumnsLTE(col1, col2 string) *Predicate { + return P().ColumnsLTE(col1, col2) +} + +// ColumnsLTE appends a "<=" predicate between 2 columns. +func (p *Predicate) ColumnsLTE(col1, col2 string) *Predicate { + return p.ColumnsOp(col1, col2, OpLTE) +} + +// GT returns a ">" predicate. +func GT(col string, value any) *Predicate { + return P().GT(col, value) +} + +// GT appends a ">" predicate. +func (p *Predicate) GT(col string, arg any) *Predicate { + return p.Append(func(b *Builder) { + b.Ident(col) + p.WriteOp(OpGT) + p.arg(b, arg) + }) +} + +// ColumnsGT appends a ">" predicate between 2 columns. +func ColumnsGT(col1, col2 string) *Predicate { + return P().ColumnsGT(col1, col2) +} + +// ColumnsGT appends a ">" predicate between 2 columns. +func (p *Predicate) ColumnsGT(col1, col2 string) *Predicate { + return p.ColumnsOp(col1, col2, OpGT) +} + +// GTE returns a ">=" predicate. +func GTE(col string, value any) *Predicate { + return P().GTE(col, value) +} + +// GTE appends a ">=" predicate. +func (p *Predicate) GTE(col string, arg any) *Predicate { + return p.Append(func(b *Builder) { + b.Ident(col) + p.WriteOp(OpGTE) + p.arg(b, arg) + }) +} + +// ColumnsGTE appends a ">=" predicate between 2 columns. +func ColumnsGTE(col1, col2 string) *Predicate { + return P().ColumnsGTE(col1, col2) +} + +// ColumnsGTE appends a ">=" predicate between 2 columns. +func (p *Predicate) ColumnsGTE(col1, col2 string) *Predicate { + return p.ColumnsOp(col1, col2, OpGTE) +} + +// NotNull returns the `IS NOT NULL` predicate. +func NotNull(col string) *Predicate { + return P().NotNull(col) +} + +// NotNull appends the `IS NOT NULL` predicate. +func (p *Predicate) NotNull(col string) *Predicate { + return p.Append(func(b *Builder) { + b.Ident(col).WriteString(" IS NOT NULL") + }) +} + +// IsNull returns the `IS NULL` predicate. +func IsNull(col string) *Predicate { + return P().IsNull(col) +} + +// IsNull appends the `IS NULL` predicate. +func (p *Predicate) IsNull(col string) *Predicate { + return p.Append(func(b *Builder) { + b.Ident(col).WriteString(" IS NULL") + }) +} + +// In returns the `IN` predicate. +func In(col string, args ...any) *Predicate { + return P().In(col, args...) +} + +// In appends the `IN` predicate. +func (p *Predicate) In(col string, args ...any) *Predicate { + // If no arguments were provided, append the FALSE constant, since + // we cannot apply "IN ()". This will make this predicate falsy. + if len(args) == 0 { + return p.False() + } + return p.Append(func(b *Builder) { + b.Ident(col).WriteOp(OpIn) + b.Wrap(func(b *Builder) { + if s, ok := args[0].(*Selector); ok { + b.Join(s) + } else { + b.Args(args...) + } + }) + }) +} + +// InInts returns the `IN` predicate for ints. +func InInts(col string, args ...int) *Predicate { + return P().InInts(col, args...) +} + +// InValues adds the `IN` predicate for slice of driver.Value. +func InValues(col string, args ...driver.Value) *Predicate { + return P().InValues(col, args...) +} + +// InInts adds the `IN` predicate for ints. +func (p *Predicate) InInts(col string, args ...int) *Predicate { + iface := make([]any, len(args)) + for i := range args { + iface[i] = args[i] + } + return p.In(col, iface...) +} + +// InValues adds the `IN` predicate for slice of driver.Value. +func (p *Predicate) InValues(col string, args ...driver.Value) *Predicate { + iface := make([]any, len(args)) + for i := range args { + iface[i] = args[i] + } + return p.In(col, iface...) +} + +// NotIn returns the `Not IN` predicate. +func NotIn(col string, args ...any) *Predicate { + return P().NotIn(col, args...) +} + +// NotIn appends the `Not IN` predicate. +func (p *Predicate) NotIn(col string, args ...any) *Predicate { + // If no arguments were provided, append the NOT FALSE constant, since + // we cannot apply "NOT IN ()". This will make this predicate truthy. + if len(args) == 0 { + return Not(p.False()) + } + return p.Append(func(b *Builder) { + b.Ident(col).WriteOp(OpNotIn) + b.Wrap(func(b *Builder) { + if s, ok := args[0].(*Selector); ok { + b.Join(s) + } else { + b.Args(args...) + } + }) + }) +} + +// Exists returns the `Exists` predicate. +func Exists(query Querier) *Predicate { + return P().Exists(query) +} + +// Exists appends the `EXISTS` predicate with the given query. +func (p *Predicate) Exists(query Querier) *Predicate { + return p.Append(func(b *Builder) { + b.WriteString("EXISTS ") + b.Wrap(func(b *Builder) { + b.Join(query) + }) + }) +} + +// NotExists returns the `NotExists` predicate. +func NotExists(query Querier) *Predicate { + return P().NotExists(query) +} + +// NotExists appends the `NOT EXISTS` predicate with the given query. +func (p *Predicate) NotExists(query Querier) *Predicate { + return p.Append(func(b *Builder) { + b.WriteString("NOT EXISTS ") + b.Wrap(func(b *Builder) { + b.Join(query) + }) + }) +} + +// Like returns the `LIKE` predicate. +func Like(col, pattern string) *Predicate { + return P().Like(col, pattern) +} + +// Like appends the `LIKE` predicate. +func (p *Predicate) Like(col, pattern string) *Predicate { + return p.Append(func(b *Builder) { + b.Ident(col).WriteOp(OpLike) + b.Arg(pattern) + }) +} + +// escape escapes w with the default escape character ('/'), +// to be used by the pattern matching functions below. +// The second return value indicates if w was escaped or not. +func escape(w string) (string, bool) { + var n int + for i := range w { + if c := w[i]; c == '%' || c == '_' || c == '\\' { + n++ + } + } + // No characters to escape. + if n == 0 { + return w, false + } + var b strings.Builder + b.Grow(len(w) + n) + for _, c := range w { + if c == '%' || c == '_' || c == '\\' { + b.WriteByte('\\') + } + b.WriteRune(c) + } + return b.String(), true +} + +func (p *Predicate) escapedLike(col, left, right, word string) *Predicate { + return p.Append(func(b *Builder) { + w, escaped := escape(word) + b.Ident(col).WriteOp(OpLike) + b.Arg(left + w + right) + if p.dialect == dialect.SQLite && escaped { + p.WriteString(" ESCAPE ").Arg("\\") + } + }) +} + +// ContainsFold is a helper predicate that applies the LIKE predicate with case-folding. +func (p *Predicate) escapedLikeFold(col, left, substr, right string) *Predicate { + return p.Append(func(b *Builder) { + w, escaped := escape(substr) + switch b.dialect { + case dialect.MySQL: + // We assume the CHARACTER SET is configured to utf8mb4, + // because this how it is defined in dialect/sql/schema. + b.Ident(col).WriteString(" COLLATE utf8mb4_general_ci LIKE ") + b.Arg(left + strings.ToLower(w) + right) + case dialect.Postgres: + b.Ident(col).WriteString(" ILIKE ") + b.Arg(left + strings.ToLower(w) + right) + default: // SQLite. + var f Func + f.SetDialect(b.dialect) + f.Lower(col) + b.WriteString(f.String()).WriteString(" LIKE ") + b.Arg(left + strings.ToLower(w) + right) + if escaped { + p.WriteString(" ESCAPE ").Arg("\\") + } + } + }) +} + +// HasPrefix is a helper predicate that checks prefix using the LIKE predicate. +func HasPrefix(col, prefix string) *Predicate { + return P().HasPrefix(col, prefix) +} + +// HasPrefix is a helper predicate that checks prefix using the LIKE predicate. +func (p *Predicate) HasPrefix(col, prefix string) *Predicate { + return p.escapedLike(col, "", "%", prefix) +} + +// HasPrefixFold is a helper predicate that checks prefix using the ILIKE predicate. +func HasPrefixFold(col, prefix string) *Predicate { + return P().HasPrefixFold(col, prefix) +} + +// HasPrefixFold is a helper predicate that checks prefix using the ILIKE predicate. +func (p *Predicate) HasPrefixFold(col, prefix string) *Predicate { + return p.escapedLikeFold(col, "", prefix, "%") +} + +// ColumnsHasPrefix appends a new predicate that checks if the given column begins with the other column (prefix). +func ColumnsHasPrefix(col, prefixC string) *Predicate { + return P().ColumnsHasPrefix(col, prefixC) +} + +// ColumnsHasPrefix appends a new predicate that checks if the given column begins with the other column (prefix). +func (p *Predicate) ColumnsHasPrefix(col, prefixC string) *Predicate { + return p.Append(func(b *Builder) { + switch p.dialect { + case dialect.MySQL: + b.Ident(col) + b.WriteOp(OpLike) + b.S("CONCAT(REPLACE(REPLACE(").Ident(prefixC).S(", '_', '\\_'), '%', '\\%'), '%')") + case dialect.Postgres, dialect.SQLite: + b.Ident(col) + b.WriteOp(OpLike) + b.S("(REPLACE(REPLACE(").Ident(prefixC).S(", '_', '\\_'), '%', '\\%') || '%')") + if p.dialect == dialect.SQLite { + p.WriteString(" ESCAPE ").Arg("\\") + } + default: + b.AddError(fmt.Errorf("ColumnsHasPrefix: unsupported dialect: %q", p.dialect)) + } + }) +} + +// HasSuffix is a helper predicate that checks suffix using the LIKE predicate. +func HasSuffix(col, suffix string) *Predicate { return P().HasSuffix(col, suffix) } + +// HasSuffix is a helper predicate that checks suffix using the LIKE predicate. +func (p *Predicate) HasSuffix(col, suffix string) *Predicate { + return p.escapedLike(col, "%", "", suffix) +} + +// HasSuffixFold is a helper predicate that checks suffix using the ILIKE predicate. +func HasSuffixFold(col, suffix string) *Predicate { return P().HasSuffixFold(col, suffix) } + +// HasSuffixFold is a helper predicate that checks suffix using the ILIKE predicate. +func (p *Predicate) HasSuffixFold(col, suffix string) *Predicate { + return p.escapedLikeFold(col, "%", suffix, "") +} + +// EqualFold is a helper predicate that applies the "=" predicate with case-folding. +func EqualFold(col, sub string) *Predicate { return P().EqualFold(col, sub) } + +// EqualFold is a helper predicate that applies the "=" predicate with case-folding. +func (p *Predicate) EqualFold(col, sub string) *Predicate { + return p.Append(func(b *Builder) { + f := &Func{} + f.SetDialect(b.dialect) + switch b.dialect { + case dialect.MySQL: + // We assume the CHARACTER SET is configured to utf8mb4, + // because this how it is defined in dialect/sql/schema. + b.Ident(col).WriteString(" COLLATE utf8mb4_general_ci = ") + b.Arg(strings.ToLower(sub)) + case dialect.Postgres: + b.Ident(col).WriteString(" ILIKE ") + w, _ := escape(sub) + b.Arg(strings.ToLower(w)) + default: // SQLite. + f.Lower(col) + b.WriteString(f.String()) + b.WriteOp(OpEQ) + b.Arg(strings.ToLower(sub)) + } + }) +} + +// Contains is a helper predicate that checks substring using the LIKE predicate. +func Contains(col, sub string) *Predicate { return P().Contains(col, sub) } + +// Contains is a helper predicate that checks substring using the LIKE predicate. +func (p *Predicate) Contains(col, substr string) *Predicate { + return p.escapedLike(col, "%", "%", substr) +} + +// ContainsFold is a helper predicate that checks substring using the LIKE predicate with case-folding. +func ContainsFold(col, sub string) *Predicate { return P().ContainsFold(col, sub) } + +// ContainsFold is a helper predicate that applies the LIKE predicate with case-folding. +func (p *Predicate) ContainsFold(col, substr string) *Predicate { + return p.escapedLikeFold(col, "%", substr, "%") +} + +// CompositeGT returns a composite ">" predicate +func CompositeGT(columns []string, args ...any) *Predicate { + return P().CompositeGT(columns, args...) +} + +// CompositeLT returns a composite "<" predicate +func CompositeLT(columns []string, args ...any) *Predicate { + return P().CompositeLT(columns, args...) +} + +func (p *Predicate) compositeP(operator string, columns []string, args ...any) *Predicate { + return p.Append(func(b *Builder) { + b.Wrap(func(nb *Builder) { + nb.IdentComma(columns...) + }) + b.WriteString(operator) + b.WriteString("(") + b.Args(args...) + b.WriteString(")") + }) +} + +// CompositeGT returns a composite ">" predicate. +func (p *Predicate) CompositeGT(columns []string, args ...any) *Predicate { + const operator = " > " + return p.compositeP(operator, columns, args...) +} + +// CompositeLT appends a composite "<" predicate. +func (p *Predicate) CompositeLT(columns []string, args ...any) *Predicate { + const operator = " < " + return p.compositeP(operator, columns, args...) +} + +// Append appends a new function to the predicate callbacks. +// The callback list are executed on call to Query. +func (p *Predicate) Append(f func(*Builder)) *Predicate { + p.fns = append(p.fns, f) + return p +} + +// Query returns query representation of a predicate. +func (p *Predicate) Query() (string, []any) { + if p.Len() > 0 || len(p.args) > 0 { + p.Reset() + p.args = nil + } + for _, f := range p.fns { + f(&p.Builder) + } + return p.String(), p.args +} + +// arg calls Builder.Arg, but wraps `a` with parens in case of a Selector. +func (*Predicate) arg(b *Builder, a any) { + switch a.(type) { + case *Selector: + b.Wrap(func(b *Builder) { + b.Arg(a) + }) + default: + b.Arg(a) + } +} + +// clone returns a shallow clone of p. +func (p *Predicate) clone() *Predicate { + if p == nil { + return p + } + return &Predicate{fns: append([]func(*Builder){}, p.fns...)} +} + +func (p *Predicate) mayWrap(preds []*Predicate, b *Builder, op string) { + switch n := len(preds); { + case n == 1: + b.Join(preds[0]) + return + case n > 1 && p.depth != 0: + b.WriteByte('(') + defer b.WriteByte(')') + } + for i := range preds { + preds[i].depth = p.depth + 1 + if i > 0 { + b.WriteByte(' ') + b.WriteString(op) + b.WriteByte(' ') + } + if len(preds[i].fns) > 1 { + b.Wrap(func(b *Builder) { + b.Join(preds[i]) + }) + } else { + b.Join(preds[i]) + } + } +} + +// Func represents an SQL function. +type Func struct { + Builder + fns []func(*Builder) +} + +// Lower wraps the given column with the LOWER function. +// +// P().EQ(sql.Lower("name"), "a8m") +func Lower(ident string) string { + f := &Func{} + f.Lower(ident) + return f.String() +} + +// Lower wraps the given ident with the LOWER function. +func (f *Func) Lower(ident string) { + f.byName("LOWER", ident) +} + +// Count wraps the ident with the COUNT aggregation function. +func Count(ident string) string { + f := &Func{} + f.Count(ident) + return f.String() +} + +// Count wraps the ident with the COUNT aggregation function. +func (f *Func) Count(ident string) { + f.byName("COUNT", ident) +} + +// Max wraps the ident with the MAX aggregation function. +func Max(ident string) string { + f := &Func{} + f.Max(ident) + return f.String() +} + +// Max wraps the ident with the MAX aggregation function. +func (f *Func) Max(ident string) { + f.byName("MAX", ident) +} + +// Min wraps the ident with the MIN aggregation function. +func Min(ident string) string { + f := &Func{} + f.Min(ident) + return f.String() +} + +// Min wraps the ident with the MIN aggregation function. +func (f *Func) Min(ident string) { + f.byName("MIN", ident) +} + +// Sum wraps the ident with the SUM aggregation function. +func Sum(ident string) string { + f := &Func{} + f.Sum(ident) + return f.String() +} + +// Sum wraps the ident with the SUM aggregation function. +func (f *Func) Sum(ident string) { + f.byName("SUM", ident) +} + +// Avg wraps the ident with the AVG aggregation function. +func Avg(ident string) string { + f := &Func{} + f.Avg(ident) + return f.String() +} + +// Avg wraps the ident with the AVG aggregation function. +func (f *Func) Avg(ident string) { + f.byName("AVG", ident) +} + +// byName wraps an identifier with a function name. +func (f *Func) byName(fn, ident string) { + f.Append(func(b *Builder) { + f.WriteString(fn) + f.Wrap(func(b *Builder) { + b.Ident(ident) + }) + }) +} + +// Append appends a new function to the function callbacks. +// The callback list are executed on call to String. +func (f *Func) Append(fn func(*Builder)) *Func { + f.fns = append(f.fns, fn) + return f +} + +// String implements the fmt.Stringer. +func (f *Func) String() string { + for _, fn := range f.fns { + fn(&f.Builder) + } + return f.Builder.String() +} + +// As suffixed the given column with an alias (`a` AS `b`). +func As(ident string, as string) string { + b := &Builder{} + b.fromIdent(ident) + b.Ident(ident).Pad().WriteString("AS") + b.Pad().Ident(as) + return b.String() +} + +// Distinct prefixed the given columns with the `DISTINCT` keyword (DISTINCT `id`). +func Distinct(idents ...string) string { + b := &Builder{} + if len(idents) > 0 { + b.fromIdent(idents[0]) + } + b.WriteString("DISTINCT") + b.Pad().IdentComma(idents...) + return b.String() +} + +// TableView is a view that returns a table view. Can be a Table, Selector or a View (WITH statement). +type TableView interface { + view() + // C returns a formatted string prefixed + // with the table view qualifier. + C(string) string +} + +// queryView allows using Querier (expressions) in the FROM clause. +type queryView struct{ Querier } + +func (*queryView) view() {} + +func (q *queryView) C(column string) string { + if tv, ok := q.Querier.(TableView); ok { + return tv.C(column) + } + return column +} + +// SelectTable is a table selector. +type SelectTable struct { + Builder + as string + name string + schema string + quote bool +} + +// Table returns a new table selector. +// +// t1 := Table("users").As("u") +// return Select(t1.C("name")) +func Table(name string) *SelectTable { + return &SelectTable{quote: true, name: name} +} + +// Schema sets the schema name of the table. +func (s *SelectTable) Schema(name string) *SelectTable { + s.schema = name + return s +} + +// As adds the AS clause to the table selector. +func (s *SelectTable) As(alias string) *SelectTable { + s.as = alias + return s +} + +// C returns a formatted string for the table column. +func (s *SelectTable) C(column string) string { + name := s.name + if s.as != "" { + name = s.as + } + b := &Builder{dialect: s.dialect} + if s.as == "" { + b.writeSchema(s.schema) + } + b.Ident(name).WriteByte('.').Ident(column) + return b.String() +} + +// Columns returns a list of formatted strings for the table columns. +func (s *SelectTable) Columns(columns ...string) []string { + names := make([]string, 0, len(columns)) + for _, c := range columns { + names = append(names, s.C(c)) + } + return names +} + +// Unquote makes the table name to be formatted as raw string (unquoted). +// It is useful when you don't want to query tables under the current database. +// For example: "INFORMATION_SCHEMA.TABLE_CONSTRAINTS" in MySQL. +func (s *SelectTable) Unquote() *SelectTable { + s.quote = false + return s +} + +// ref returns the table reference. +func (s *SelectTable) ref() string { + if !s.quote { + return s.name + } + b := &Builder{dialect: s.dialect} + b.writeSchema(s.schema) + b.Ident(s.name) + if s.as != "" { + b.WriteString(" AS ") + b.Ident(s.as) + } + return b.String() +} + +// implement the table view. +func (*SelectTable) view() {} + +// join table option. +type join struct { + on *Predicate + kind string + table TableView +} + +// clone a joiner. +func (j join) clone() join { + if sel, ok := j.table.(*Selector); ok { + j.table = sel.Clone() + } + j.on = j.on.clone() + return j +} + +// Selector is a builder for the `SELECT` statement. +type Selector struct { + Builder + // ctx stores contextual data typically from + // generated code such as alternate table schemas. + ctx context.Context + as string + selection []selection + from []TableView + joins []join + collected [][]*Predicate + where *Predicate + or bool + not bool + order []any + group []string + having *Predicate + limit *int + offset *int + distinct bool + setOps []setOp + prefix Queries + lock *LockOptions +} + +// New returns a new Selector with the same dialect and context. +func (s *Selector) New() *Selector { + c := Dialect(s.dialect).Select() + if s.ctx != nil { + c = c.WithContext(s.ctx) + } + return c +} + +// WithContext sets the context into the *Selector. +func (s *Selector) WithContext(ctx context.Context) *Selector { + if ctx == nil { + panic("nil context") + } + s.ctx = ctx + return s +} + +// Context returns the Selector context or Background +// if nil. +func (s *Selector) Context() context.Context { + if s.ctx != nil { + return s.ctx + } + return context.Background() +} + +// Select returns a new selector for the `SELECT` statement. +// +// t1 := Table("users").As("u") +// t2 := Select().From(Table("groups")).Where(EQ("user_id", 10)).As("g") +// return Select(t1.C("id"), t2.C("name")). +// From(t1). +// Join(t2). +// On(t1.C("id"), t2.C("user_id")) +func Select(columns ...string) *Selector { + return (&Selector{}).Select(columns...) +} + +// SelectExpr is like Select, but supports passing arbitrary +// expressions for SELECT clause. +func SelectExpr(exprs ...Querier) *Selector { + return (&Selector{}).SelectExpr(exprs...) +} + +// selection represents a column or an expression selection. +type selection struct { + x Querier + c string + as string +} + +// Select changes the columns selection of the SELECT statement. +// Empty selection means all columns *. +func (s *Selector) Select(columns ...string) *Selector { + s.selection = make([]selection, len(columns)) + for i := range columns { + s.selection[i] = selection{c: columns[i]} + } + return s +} + +// SelectDistinct selects distinct columns. +func (s *Selector) SelectDistinct(columns ...string) *Selector { + return s.Select(columns...).Distinct() +} + +// AppendSelect appends additional columns to the SELECT statement. +func (s *Selector) AppendSelect(columns ...string) *Selector { + for i := range columns { + s.selection = append(s.selection, selection{c: columns[i]}) + } + return s +} + +// AppendSelectAs appends additional column to the SELECT statement with the given alias. +func (s *Selector) AppendSelectAs(column, as string) *Selector { + s.selection = append(s.selection, selection{c: column, as: as}) + return s +} + +// SelectExpr changes the columns selection of the SELECT statement +// with custom list of expressions. +func (s *Selector) SelectExpr(exprs ...Querier) *Selector { + s.selection = make([]selection, len(exprs)) + for i := range exprs { + s.selection[i] = selection{x: exprs[i]} + } + return s +} + +// AppendSelectExpr appends additional expressions to the SELECT statement. +func (s *Selector) AppendSelectExpr(exprs ...Querier) *Selector { + for i := range exprs { + s.selection = append(s.selection, selection{x: exprs[i]}) + } + return s +} + +// AppendSelectExprAs appends additional expressions to the SELECT statement with the given name. +func (s *Selector) AppendSelectExprAs(expr Querier, as string) *Selector { + x := expr + if _, ok := expr.(*raw); !ok { + x = ExprFunc(func(b *Builder) { + b.S("(").Join(expr).S(")") + }) + } + s.selection = append(s.selection, selection{ + x: x, + as: as, + }) + return s +} + +// FindSelection returns all occurrences in the selection that match the given column name. +// For example, for column "a" the following match: a, "a", "t"."a", "t"."b" AS "a". +func (s *Selector) FindSelection(name string) (matches []string) { + matchC := func(qualified string) bool { + switch ident, pg := s.isIdent(qualified), s.postgres(); { + case !ident: + if i := strings.IndexRune(qualified, '.'); i > 0 { + return qualified[i+1:] == name + } + case ident && pg: + if i := strings.Index(qualified, `"."`); i > 0 { + return s.unquote(qualified[i+2:]) == name + } + case ident: + if i := strings.Index(qualified, "`.`"); i > 0 { + return s.unquote(qualified[i+2:]) == name + } + } + return false + } + for _, c := range s.selection { + switch { + // Match aliases. + case c.as != "": + if ident := s.isIdent(c.as); !ident && c.as == name || ident && s.unquote(c.as) == name { + matches = append(matches, c.as) + } + // Match qualified columns. + case c.c != "" && s.isQualified(c.c) && matchC(c.c): + matches = append(matches, c.c) + // Match unqualified columns. + case c.c != "" && (c.c == name || s.isIdent(c.c) && s.unquote(c.c) == name): + matches = append(matches, c.c) + } + } + return matches +} + +// SelectedColumns returns the selected columns in the Selector. +func (s *Selector) SelectedColumns() []string { + columns := make([]string, 0, len(s.selection)) + for i := range s.selection { + if c := s.selection[i].c; c != "" { + columns = append(columns, c) + } + } + return columns +} + +// UnqualifiedColumns returns an unqualified version of the +// selected columns in the Selector. e.g. "t1"."c" => "c". +func (s *Selector) UnqualifiedColumns() []string { + columns := make([]string, 0, len(s.selection)) + for i := range s.selection { + c := s.selection[i].c + if c == "" { + continue + } + if s.isIdent(c) { + parts := strings.FieldsFunc(c, func(r rune) bool { + return r == '`' || r == '"' + }) + if n := len(parts); n > 0 && parts[n-1] != "" { + c = parts[n-1] + } + } + columns = append(columns, c) + } + return columns +} + +// From sets the source of `FROM` clause. +func (s *Selector) From(t TableView) *Selector { + s.from = nil + return s.AppendFrom(t) +} + +// AppendFrom appends a new TableView to the `FROM` clause. +func (s *Selector) AppendFrom(t TableView) *Selector { + s.from = append(s.from, t) + if st, ok := t.(state); ok { + st.SetDialect(s.dialect) + } + return s +} + +// FromExpr sets the expression of `FROM` clause. +func (s *Selector) FromExpr(x Querier) *Selector { + s.from = nil + return s.AppendFromExpr(x) +} + +// AppendFromExpr appends an expression (Queries) to the `FROM` clause. +func (s *Selector) AppendFromExpr(x Querier) *Selector { + s.from = append(s.from, &queryView{Querier: x}) + if st, ok := x.(state); ok { + st.SetDialect(s.dialect) + } + return s +} + +// Distinct adds the DISTINCT keyword to the `SELECT` statement. +func (s *Selector) Distinct() *Selector { + s.distinct = true + return s +} + +// SetDistinct sets explicitly if the returned rows are distinct or indistinct. +func (s *Selector) SetDistinct(v bool) *Selector { + s.distinct = v + return s +} + +// Limit adds the `LIMIT` clause to the `SELECT` statement. +func (s *Selector) Limit(limit int) *Selector { + s.limit = &limit + return s +} + +// Offset adds the `OFFSET` clause to the `SELECT` statement. +func (s *Selector) Offset(offset int) *Selector { + s.offset = &offset + return s +} + +// CollectPredicates indicates the appended predicated should be collected +// and not appended to the `WHERE` clause. +func (s *Selector) CollectPredicates() *Selector { + s.collected = append(s.collected, []*Predicate{}) + return s +} + +// CollectedPredicates returns the collected predicates. +func (s *Selector) CollectedPredicates() []*Predicate { + if len(s.collected) == 0 { + return nil + } + return s.collected[len(s.collected)-1] +} + +// UncollectedPredicates stop collecting predicates. +func (s *Selector) UncollectedPredicates() *Selector { + if len(s.collected) > 0 { + s.collected = s.collected[:len(s.collected)-1] + } + return s +} + +// Where sets or appends the given predicate to the statement. +func (s *Selector) Where(p *Predicate) *Selector { + if len(s.collected) > 0 { + s.collected[len(s.collected)-1] = append(s.collected[len(s.collected)-1], p) + return s + } + if s.not { + p = Not(p) + s.not = false + } + switch { + case s.where == nil: + s.where = p + case s.where != nil && s.or: + s.where = Or(s.where, p) + s.or = false + default: + s.where = And(s.where, p) + } + return s +} + +// P returns the predicate of a selector. +func (s *Selector) P() *Predicate { + return s.where +} + +// SetP sets explicitly the predicate function for the selector and clear its previous state. +func (s *Selector) SetP(p *Predicate) *Selector { + s.where = p + s.or = false + s.not = false + return s +} + +// FromSelect copies the predicate from a selector. +func (s *Selector) FromSelect(s2 *Selector) *Selector { + s.where = s2.where + return s +} + +// Not sets the next coming predicate with not. +func (s *Selector) Not() *Selector { + s.not = true + return s +} + +// Or sets the next coming predicate with OR operator (disjunction). +func (s *Selector) Or() *Selector { + s.or = true + return s +} + +// Table returns the selected table. +func (s *Selector) Table() *SelectTable { + if len(s.from) == 0 { + return nil + } + return selectTable(s.from[0]) +} + +// selectTable returns a *SelectTable from the given TableView. +func selectTable(t TableView) *SelectTable { + if t == nil { + return nil + } + switch view := t.(type) { + case *SelectTable: + return view + case *Selector: + if len(view.from) == 0 { + return nil + } + return selectTable(view.from[0]) + case *queryView, *WithBuilder: + return nil + default: + panic(fmt.Sprintf("unexpected TableView %T", t)) + } +} + +// TableName returns the name of the selected table or alias of selector. +func (s *Selector) TableName() string { + switch view := s.from[0].(type) { + case *SelectTable: + return view.name + case *Selector: + return view.as + default: + panic(fmt.Sprintf("unhandled TableView type %T", s.from)) + } +} + +// HasJoins reports if the selector has any JOINs. +func (s *Selector) HasJoins() bool { + return len(s.joins) > 0 +} + +// JoinedTable returns the first joined table with the given name. +func (s *Selector) JoinedTable(name string) (*SelectTable, bool) { + for _, j := range s.joins { + if t := selectTable(j.table); t != nil && t.name == name { + return t, true + } + } + return nil, false +} + +// JoinedTableView returns the first joined TableView with the given name or alias. +func (s *Selector) JoinedTableView(name string) (TableView, bool) { + for _, j := range s.joins { + switch t := j.table.(type) { + case *SelectTable: + if t.name == name || t.as == name { + return t, true + } + case *Selector: + if t.as == name { + return t, true + } + for _, t2 := range t.from { + if t3 := selectTable(t2); t3 != nil && (t3.name == name || t3.as == name) { + return t3, true + } + } + } + } + return nil, false +} + +// Join appends a `JOIN` clause to the statement. +func (s *Selector) Join(t TableView) *Selector { + return s.join("JOIN", t) +} + +// LeftJoin appends a `LEFT JOIN` clause to the statement. +func (s *Selector) LeftJoin(t TableView) *Selector { + return s.join("LEFT JOIN", t) +} + +// RightJoin appends a `RIGHT JOIN` clause to the statement. +func (s *Selector) RightJoin(t TableView) *Selector { + return s.join("RIGHT JOIN", t) +} + +// FullJoin appends a `FULL JOIN` clause to the statement. +func (s *Selector) FullJoin(t TableView) *Selector { + return s.join("FULL JOIN", t) +} + +// join adds a join table to the selector with the given kind. +func (s *Selector) join(kind string, t TableView) *Selector { + s.joins = append(s.joins, join{ + kind: kind, + table: t, + }) + switch view := t.(type) { + case *SelectTable: + if view.as == "" { + view.as = "t" + strconv.Itoa(len(s.joins)) + } + case *Selector: + if view.as == "" { + view.as = "t" + strconv.Itoa(len(s.joins)) + } + } + if st, ok := t.(state); ok { + st.SetDialect(s.dialect) + } + return s +} + +type ( + // setOp represents a set/compound operation. + setOp struct { + Type setOpType // Set operation type. + All bool // Quantifier was set to ALL (defaults to DISTINCT). + TableView // Query or table to operate on. + } + // setOpType is a set operation type. + setOpType string +) + +const ( + setOpTypeUnion setOpType = "UNION" + setOpTypeExcept setOpType = "EXCEPT" + setOpTypeIntersect setOpType = "INTERSECT" +) + +// Union appends the UNION (DISTINCT) clause to the query. +func (s *Selector) Union(t TableView) *Selector { + if s1, ok := t.(*Selector); ok && s == s1 { + s.AddError(errors.New("self UNION is not supported. Create a clone or a new selector instead")) + return s + } + s.setOps = append(s.setOps, setOp{ + Type: setOpTypeUnion, + TableView: t, + }) + return s +} + +// UnionAll appends the UNION ALL clause to the query. +func (s *Selector) UnionAll(t TableView) *Selector { + s.setOps = append(s.setOps, setOp{ + Type: setOpTypeUnion, + All: true, + TableView: t, + }) + return s +} + +// UnionDistinct appends the UNION DISTINCT clause to the query. +// Deprecated: use Union instead as by default, duplicate rows +// are eliminated unless ALL is specified. +func (s *Selector) UnionDistinct(t TableView) *Selector { + return s.Union(t) +} + +// Except appends the EXCEPT clause to the query. +func (s *Selector) Except(t TableView) *Selector { + s.setOps = append(s.setOps, setOp{ + Type: setOpTypeExcept, + TableView: t, + }) + return s +} + +// ExceptAll appends the EXCEPT ALL clause to the query. +func (s *Selector) ExceptAll(t TableView) *Selector { + if s.sqlite() { + s.AddError(errors.New("EXCEPT ALL is not supported by SQLite")) + } else { + s.setOps = append(s.setOps, setOp{ + Type: setOpTypeExcept, + All: true, + TableView: t, + }) + } + return s +} + +// Intersect appends the INTERSECT clause to the query. +func (s *Selector) Intersect(t TableView) *Selector { + s.setOps = append(s.setOps, setOp{ + Type: setOpTypeIntersect, + TableView: t, + }) + return s +} + +// IntersectAll appends the INTERSECT ALL clause to the query. +func (s *Selector) IntersectAll(t TableView) *Selector { + if s.sqlite() { + s.AddError(errors.New("INTERSECT ALL is not supported by SQLite")) + } else { + s.setOps = append(s.setOps, setOp{ + Type: setOpTypeIntersect, + All: true, + TableView: t, + }) + } + return s +} + +// setOpQuerier implements Querier for a compound set operation (UNION, EXCEPT, +// INTERSECT) where every branch is wrapped in parentheses. This is required +// when branches contain ORDER BY, LIMIT, or OFFSET (MySQL, Postgres, and the +// SQL standard). SQLite does not support parenthesized branches; on that +// dialect the parentheses — and any per-branch ORDER BY / LIMIT / OFFSET, +// which SQLite cannot honour inside a compound SELECT — are silently omitted. +// Use the package-level Union / UnionAll / Except / ExceptAll / Intersect / +// IntersectAll functions to build one. +type setOpQuerier struct { + Builder + op string + selectors []*Selector +} + +func (q *setOpQuerier) Query() (string, []any) { + b := q.Builder.clone() + // If no dialect was set explicitly on the querier (the common case — users + // call UnionAll(sel1, sel2) without a DialectBuilder), inherit it from the + // first selector so that b.sqlite() / b.postgres() return the right value. + if b.dialect == "" && len(q.selectors) > 0 { + b.SetDialect(q.selectors[0].dialect) + } + sqlite := b.sqlite() + for i, s := range q.selectors { + if i > 0 { + b.WriteString(" " + q.op + " ") + } + if sqlite { + // SQLite does not allow parenthesized compound-select branches and + // cannot honour per-branch ORDER BY / LIMIT / OFFSET. Emit a plain + // branch with those clauses stripped; the push-down is a no-op here. + clone := *s + clone.order = nil + clone.limit = nil + clone.offset = nil + b.Join(&clone) + } else { + b.WriteString("(") + b.Join(s) + b.WriteString(")") + } + } + return b.String(), b.args +} + +// Union returns a Querier that combines selectors with UNION (DISTINCT), +// wrapping every branch in parentheses. Use this instead of the chaining +// method when branches carry their own ORDER BY, LIMIT, or OFFSET — MySQL +// and the SQL standard require parentheses in that case. +func Union(selectors ...*Selector) Querier { + return &setOpQuerier{op: string(setOpTypeUnion), selectors: selectors} +} + +// UnionAll returns a Querier that combines selectors with UNION ALL, wrapping +// every branch in parentheses. Use this instead of the chaining method when +// branches carry their own ORDER BY, LIMIT, or OFFSET. +func UnionAll(selectors ...*Selector) Querier { + return &setOpQuerier{op: string(setOpTypeUnion) + " ALL", selectors: selectors} +} + +// Except returns a Querier that combines selectors with EXCEPT, wrapping every +// branch in parentheses. +func Except(selectors ...*Selector) Querier { + return &setOpQuerier{op: string(setOpTypeExcept), selectors: selectors} +} + +// ExceptAll returns a Querier that combines selectors with EXCEPT ALL, wrapping +// every branch in parentheses. +func ExceptAll(selectors ...*Selector) Querier { + return &setOpQuerier{op: string(setOpTypeExcept) + " ALL", selectors: selectors} +} + +// Intersect returns a Querier that combines selectors with INTERSECT, wrapping +// every branch in parentheses. +func Intersect(selectors ...*Selector) Querier { + return &setOpQuerier{op: string(setOpTypeIntersect), selectors: selectors} +} + +// IntersectAll returns a Querier that combines selectors with INTERSECT ALL, +// wrapping every branch in parentheses. +func IntersectAll(selectors ...*Selector) Querier { + return &setOpQuerier{op: string(setOpTypeIntersect) + " ALL", selectors: selectors} +} + +// Prefix prefixes the query with list of queries. +func (s *Selector) Prefix(queries ...Querier) *Selector { + s.prefix = append(s.prefix, queries...) + return s +} + +// C returns a formatted string for a selected column from this statement. +func (s *Selector) C(column string) string { + // Skip formatting qualified columns. + if s.isQualified(column) { + return column + } + if s.as != "" { + b := &Builder{dialect: s.dialect} + b.Ident(s.as) + b.WriteByte('.') + b.Ident(column) + return b.String() + } + return s.Table().C(column) +} + +// Columns returns a list of formatted strings for a selected columns from this statement. +func (s *Selector) Columns(columns ...string) []string { + names := make([]string, 0, len(columns)) + for _, c := range columns { + names = append(names, s.C(c)) + } + return names +} + +// OnP sets or appends the given predicate for the `ON` clause of the statement. +func (s *Selector) OnP(p *Predicate) *Selector { + if len(s.joins) > 0 { + join := &s.joins[len(s.joins)-1] + switch { + case join.on == nil: + join.on = p + default: + join.on = And(join.on, p) + } + } + return s +} + +// On sets the `ON` clause for the `JOIN` operation. +func (s *Selector) On(c1, c2 string) *Selector { + s.OnP(P(func(builder *Builder) { + builder.Ident(c1).WriteOp(OpEQ).Ident(c2) + })) + return s +} + +// As give this selection an alias. +func (s *Selector) As(alias string) *Selector { + s.as = alias + return s +} + +// Count sets the Select statement to be a `SELECT COUNT(*)`. +func (s *Selector) Count(columns ...string) *Selector { + column := "*" + if len(columns) > 0 { + b := &Builder{} + b.IdentComma(columns...) + column = b.String() + } + s.Select(Count(column)) + return s +} + +// LockAction tells the transaction what to do in case of +// requesting a row that is locked by other transaction. +type LockAction string + +const ( + // NoWait means never wait and returns an error. + NoWait LockAction = "NOWAIT" + // SkipLocked means never wait and skip. + SkipLocked LockAction = "SKIP LOCKED" +) + +// LockStrength defines the strength of the lock (see the list below). +type LockStrength string + +// A list of all locking clauses. +const ( + LockShare LockStrength = "SHARE" + LockUpdate LockStrength = "UPDATE" + LockNoKeyUpdate LockStrength = "NO KEY UPDATE" + LockKeyShare LockStrength = "KEY SHARE" +) + +type ( + // LockOptions defines a SELECT statement + // lock for protecting concurrent updates. + LockOptions struct { + // Strength of the lock. + Strength LockStrength + // Action of the lock. + Action LockAction + // Tables are an option tables. + Tables []string + // custom clause for locking. + clause string + } + // LockOption allows configuring the LockOptions using functional options. + LockOption func(*LockOptions) +) + +// WithLockAction sets the Action of the lock. +func WithLockAction(action LockAction) LockOption { + return func(c *LockOptions) { + c.Action = action + } +} + +// WithLockTables sets the Tables of the lock. +func WithLockTables(tables ...string) LockOption { + return func(c *LockOptions) { + c.Tables = tables + } +} + +// WithLockClause allows providing a custom clause for +// locking the statement. For example, in MySQL <= 8.22: +// +// Select(). +// From(Table("users")). +// ForShare( +// WithLockClause("LOCK IN SHARE MODE"), +// ) +func WithLockClause(clause string) LockOption { + return func(c *LockOptions) { + c.clause = clause + } +} + +// For sets the lock configuration for suffixing the `SELECT` +// statement with the `FOR [SHARE | UPDATE] ...` clause. +func (s *Selector) For(l LockStrength, opts ...LockOption) *Selector { + if s.Dialect() == dialect.SQLite { + s.AddError(errors.New("sql: SELECT .. FOR UPDATE/SHARE not supported in SQLite")) + } + s.lock = &LockOptions{Strength: l} + for _, opt := range opts { + opt(s.lock) + } + return s +} + +// ForShare sets the lock configuration for suffixing the +// `SELECT` statement with the `FOR SHARE` clause. +func (s *Selector) ForShare(opts ...LockOption) *Selector { + return s.For(LockShare, opts...) +} + +// ForUpdate sets the lock configuration for suffixing the +// `SELECT` statement with the `FOR UPDATE` clause. +func (s *Selector) ForUpdate(opts ...LockOption) *Selector { + return s.For(LockUpdate, opts...) +} + +// Clone returns a duplicate of the selector, including all associated steps. It can be +// used to prepare common SELECT statements and use them differently after the clone is made. +func (s *Selector) Clone() *Selector { + if s == nil { + return nil + } + joins := make([]join, len(s.joins)) + for i := range s.joins { + joins[i] = s.joins[i].clone() + } + return &Selector{ + Builder: s.Builder.clone(), + ctx: s.ctx, + as: s.as, + or: s.or, + not: s.not, + from: s.from, + limit: s.limit, + offset: s.offset, + distinct: s.distinct, + where: s.where.clone(), + having: s.having.clone(), + joins: append([]join{}, joins...), + group: append([]string{}, s.group...), + order: append([]any{}, s.order...), + selection: append([]selection{}, s.selection...), + } +} + +// Asc adds the ASC suffix for the given column. +func Asc(column string) string { + b := &Builder{} + b.Ident(column).WriteString(" ASC") + return b.String() +} + +// Desc adds the DESC suffix for the given column. +func Desc(column string) string { + b := &Builder{} + b.Ident(column).WriteString(" DESC") + return b.String() +} + +// DescExpr returns a new expression where the DESC suffix is added. +func DescExpr(x Querier) Querier { + return ExprFunc(func(b *Builder) { + b.Join(x) + b.WriteString(" DESC") + }) +} + +// OrderBy appends the `ORDER BY` clause to the `SELECT` statement. +func (s *Selector) OrderBy(columns ...string) *Selector { + for i := range columns { + s.order = append(s.order, columns[i]) + } + return s +} + +// OrderColumns returns the ordered columns in the Selector. +// Note, this function skips columns selected with expressions. +func (s *Selector) OrderColumns() []string { + columns := make([]string, 0, len(s.order)) + for i := range s.order { + if c, ok := s.order[i].(string); ok { + columns = append(columns, c) + } + } + return columns +} + +// OrderExpr appends the `ORDER BY` clause to the `SELECT` +// statement with custom list of expressions. +func (s *Selector) OrderExpr(exprs ...Querier) *Selector { + for i := range exprs { + s.order = append(s.order, exprs[i]) + } + return s +} + +// OrderExprFunc appends the `ORDER BY` expression that evaluates +// the given function. +func (s *Selector) OrderExprFunc(f func(*Builder)) *Selector { + return s.OrderExpr( + Dialect(s.Dialect()).Expr(f), + ) +} + +// ClearOrder clears the ORDER BY clause to be empty. +func (s *Selector) ClearOrder() *Selector { + s.order = nil + return s +} + +// GroupBy appends the `GROUP BY` clause to the `SELECT` statement. +func (s *Selector) GroupBy(columns ...string) *Selector { + s.group = append(s.group, columns...) + return s +} + +// Having appends a predicate for the `HAVING` clause. +func (s *Selector) Having(p *Predicate) *Selector { + s.having = p + return s +} + +// Query returns query representation of a `SELECT` statement. +func (s *Selector) Query() (string, []any) { + b := s.Builder.clone() + s.joinPrefix(&b) + b.WriteString("SELECT ") + if s.distinct { + b.WriteString("DISTINCT ") + } + if len(s.selection) > 0 { + s.joinSelect(&b) + } else { + b.WriteString("*") + } + if len(s.from) > 0 { + b.WriteString(" FROM ") + } + for i, from := range s.from { + if i > 0 { + b.Comma() + } + switch t := from.(type) { + case *SelectTable: + t.SetDialect(s.dialect) + b.WriteString(t.ref()) + case *Selector: + t.SetDialect(s.dialect) + b.Wrap(func(b *Builder) { + b.Join(t) + }) + if t.as != "" { + b.WriteString(" AS ") + b.Ident(t.as) + } + case *WithBuilder: + t.SetDialect(s.dialect) + b.Ident(t.Name()) + case *queryView: + b.Join(t.Querier) + } + } + for _, join := range s.joins { + b.WriteString(" " + join.kind + " ") + switch view := join.table.(type) { + case *SelectTable: + view.SetDialect(s.dialect) + b.WriteString(view.ref()) + case *Selector: + view.SetDialect(s.dialect) + b.Wrap(func(b *Builder) { + b.Join(view) + }) + b.WriteString(" AS ") + b.Ident(view.as) + case *WithBuilder: + view.SetDialect(s.dialect) + b.Ident(view.Name()) + } + if join.on != nil { + b.WriteString(" ON ") + b.Join(join.on) + } + } + if s.where != nil { + b.WriteString(" WHERE ") + b.Join(s.where) + } + if len(s.group) > 0 { + b.WriteString(" GROUP BY ") + b.IdentComma(s.group...) + } + if s.having != nil { + b.WriteString(" HAVING ") + b.Join(s.having) + } + if len(s.setOps) > 0 { + s.joinSetOps(&b) + } + joinOrder(s.order, &b) + if s.limit != nil { + b.WriteString(" LIMIT ") + b.WriteString(strconv.Itoa(*s.limit)) + } + if s.offset != nil { + b.WriteString(" OFFSET ") + b.WriteString(strconv.Itoa(*s.offset)) + } + s.joinLock(&b) + s.total = b.total + s.AddError(b.Err()) + return b.String(), b.args +} + +func (s *Selector) joinPrefix(b *Builder) { + if len(s.prefix) > 0 { + b.join(s.prefix, " ") + b.Pad() + } +} + +func (s *Selector) joinLock(b *Builder) { + if s.lock == nil { + return + } + b.Pad() + if s.lock.clause != "" { + b.WriteString(s.lock.clause) + return + } + b.WriteString("FOR ").WriteString(string(s.lock.Strength)) + if len(s.lock.Tables) > 0 { + b.WriteString(" OF ").IdentComma(s.lock.Tables...) + } + if s.lock.Action != "" { + b.Pad().WriteString(string(s.lock.Action)) + } +} + +func (s *Selector) joinSetOps(b *Builder) { + for _, op := range s.setOps { + b.WriteString(" " + string(op.Type) + " ") + if op.All { + b.WriteString("ALL ") + } + switch view := op.TableView.(type) { + case *SelectTable: + view.SetDialect(s.dialect) + b.WriteString(view.ref()) + case *Selector: + view.SetDialect(s.dialect) + b.Join(view) + if view.as != "" { + b.WriteString(" AS ") + b.Ident(view.as) + } + } + } +} + +func joinOrder(order []any, b *Builder) { + if len(order) == 0 { + return + } + b.WriteString(" ORDER BY ") + for i := range order { + if i > 0 { + b.Comma() + } + switch r := order[i].(type) { + case string: + b.Ident(r) + case Querier: + b.Join(r) + } + } +} + +func joinReturning(columns []string, b *Builder) { + if len(columns) == 0 || (!b.postgres() && !b.sqlite()) { + return + } + b.WriteString(" RETURNING ") + b.IdentComma(columns...) +} + +func (s *Selector) joinSelect(b *Builder) { + for i, sc := range s.selection { + if i > 0 { + b.Comma() + } + switch { + case sc.c != "": + b.Ident(sc.c) + case sc.x != nil: + b.Join(sc.x) + } + if sc.as != "" { + b.WriteString(" AS ") + b.Ident(sc.as) + } + } +} + +// implement the table view interface. +func (*Selector) view() {} + +// WithBuilder is the builder for the `WITH` statement. +type WithBuilder struct { + Builder + recursive bool + ctes []struct { + name string + columns []string + s Querier + } +} + +// With returns a new builder for the `WITH` statement. +// +// n := Queries{ +// With("users_view").As(Select().From(Table("users"))), +// Select().From(Table("users_view")), +// } +// return n.Query() +func With(name string, columns ...string) *WithBuilder { + return &WithBuilder{ + ctes: []struct { + name string + columns []string + s Querier + }{ + {name: name, columns: columns}, + }, + } +} + +// WithRecursive returns a new builder for the `WITH RECURSIVE` statement. +// +// n := Queries{ +// WithRecursive("users_view").As(Select().From(Table("users"))), +// Select().From(Table("users_view")), +// } +// return n.Query() +func WithRecursive(name string, columns ...string) *WithBuilder { + w := With(name, columns...) + w.recursive = true + return w +} + +// Name returns the name of the view. +func (w *WithBuilder) Name() string { + return w.ctes[0].name +} + +// As sets the view sub-query. Accepts any Querier — including *Selector and +// the set-operation builders returned by Union, UnionAll, Intersect, etc. +func (w *WithBuilder) As(s Querier) *WithBuilder { + w.ctes[len(w.ctes)-1].s = s + return w +} + +// With appends another named CTE to the statement. +func (w *WithBuilder) With(name string, columns ...string) *WithBuilder { + w.ctes = append(w.ctes, With(name, columns...).ctes...) + return w +} + +// C returns a formatted string for the WITH column. +func (w *WithBuilder) C(column string) string { + b := &Builder{dialect: w.dialect} + b.Ident(w.Name()).WriteByte('.').Ident(column) + return b.String() +} + +// Query returns query representation of a `WITH` clause. +func (w *WithBuilder) Query() (string, []any) { + w.WriteString("WITH ") + if w.recursive { + w.WriteString("RECURSIVE ") + } + for i, cte := range w.ctes { + if i > 0 { + w.Comma() + } + w.Ident(cte.name) + if len(cte.columns) > 0 { + w.WriteByte('(') + w.IdentComma(cte.columns...) + w.WriteByte(')') + } + w.WriteString(" AS ") + w.Wrap(func(b *Builder) { + b.Join(cte.s) + }) + } + return w.String(), w.args +} + +// implement the table view interface. +func (*WithBuilder) view() {} + +// WindowBuilder represents a builder for a window clause. +// Note that window functions support is limited and used +// only to query rows-limited edges in pagination. +type WindowBuilder struct { + Builder + fn func(*Builder) // e.g. ROW_NUMBER(), RANK() + partition func(*Builder) + order []any +} + +// RowNumber returns a new window clause with the ROW_NUMBER() as a function. +// Using this function will assign each row a number, from 1 to N, in the +// order defined by the ORDER BY clause in the window spec. +func RowNumber() *WindowBuilder { + return Window(func(b *Builder) { + b.WriteString("ROW_NUMBER()") + }) +} + +// Window returns a new window clause with a custom selector allowing +// for custom window functions. +// +// Window(func(b *Builder) { +// b.WriteString(Sum(posts.C("duration"))) +// }).PartitionBy("author_id").OrderBy("id"), "duration"). +func Window(fn func(*Builder)) *WindowBuilder { + return &WindowBuilder{fn: fn} +} + +// PartitionBy indicates to divide the query rows into groups by the given columns. +// Note that, standard SQL spec allows partition only by columns, and in order to +// use the "expression" version, use the PartitionByExpr. +func (w *WindowBuilder) PartitionBy(columns ...string) *WindowBuilder { + w.partition = func(b *Builder) { + b.IdentComma(columns...) + } + return w +} + +// PartitionExpr indicates to divide the query rows into groups by the given expression. +func (w *WindowBuilder) PartitionExpr(x Querier) *WindowBuilder { + w.partition = func(b *Builder) { + b.Join(x) + } + return w +} + +// OrderBy indicates how to sort rows in each partition. +func (w *WindowBuilder) OrderBy(columns ...string) *WindowBuilder { + for i := range columns { + w.order = append(w.order, columns[i]) + } + return w +} + +// OrderExpr appends the `ORDER BY` clause to the window +// partition with custom list of expressions. +func (w *WindowBuilder) OrderExpr(exprs ...Querier) *WindowBuilder { + for i := range exprs { + w.order = append(w.order, exprs[i]) + } + return w +} + +// Query returns query representation of the window function. +func (w *WindowBuilder) Query() (string, []any) { + w.fn(&w.Builder) + w.WriteString(" OVER ") + w.Wrap(func(b *Builder) { + if w.partition != nil { + b.WriteString("PARTITION BY ") + w.partition(b) + } + joinOrder(w.order, b) + }) + return w.Builder.String(), w.args +} + +// Wrapper wraps a given Querier with different format. +// Used to prefix/suffix other queries. +type Wrapper struct { + format string + wrapped Querier +} + +// Query returns query representation of a wrapped Querier. +func (w *Wrapper) Query() (string, []any) { + query, args := w.wrapped.Query() + return fmt.Sprintf(w.format, query), args +} + +// SetDialect calls SetDialect on the wrapped query. +func (w *Wrapper) SetDialect(name string) { + if s, ok := w.wrapped.(state); ok { + s.SetDialect(name) + } +} + +// Dialect calls Dialect on the wrapped query. +func (w *Wrapper) Dialect() string { + if s, ok := w.wrapped.(state); ok { + return s.Dialect() + } + return "" +} + +// Total returns the total number of arguments so far. +func (w *Wrapper) Total() int { + if s, ok := w.wrapped.(state); ok { + return s.Total() + } + return 0 +} + +// SetTotal sets the value of the total arguments. +// Used to pass this information between sub queries/expressions. +func (w *Wrapper) SetTotal(total int) { + if s, ok := w.wrapped.(state); ok { + s.SetTotal(total) + } +} + +// Raw returns a raw SQL query that is placed as-is in the query. +func Raw(s string) Querier { return &raw{s} } + +type raw struct{ s string } + +func (r *raw) Query() (string, []any) { return r.s, nil } + +// Expr returns an SQL expression that implements the Querier interface. +func Expr(exr string, args ...any) Querier { return &expr{s: exr, args: args} } + +type expr struct { + s string + args []any +} + +func (e *expr) Query() (string, []any) { return e.s, e.args } + +// ExprFunc returns an expression function that implements the Querier interface. +// +// Update("users"). +// Set("x", ExprFunc(func(b *Builder) { +// // The sql.Builder config (argc and dialect) +// // was set before the function was executed. +// b.Ident("x").WriteOp(OpAdd).Arg(1) +// })) +func ExprFunc(fn func(*Builder)) Querier { + return &exprFunc{fn: fn} +} + +type exprFunc struct { + Builder + fn func(*Builder) +} + +func (e *exprFunc) Query() (string, []any) { + b := e.Builder.clone() + e.fn(&b) + return b.Query() +} + +// Queries are list of queries join with space between them. +type Queries []Querier + +// Query returns query representation of Queriers. +func (n Queries) Query() (string, []any) { + b := &Builder{} + for i := range n { + if i > 0 { + b.Pad() + } + query, args := n[i].Query() + b.WriteString(query) + b.args = append(b.args, args...) + } + return b.String(), b.args +} + +// Builder is the base query builder for the sql dsl. +type Builder struct { + sb *strings.Builder // underlying builder. + dialect string // configured dialect. + args []any // query parameters. + total int // total number of parameters in query tree. + errs []error // errors that added during the query construction. + qualifier string // qualifier to prefix identifiers (e.g. table name). +} + +// Quote quotes the given identifier with the characters based +// on the configured dialect. It defaults to "`". +func (b *Builder) Quote(ident string) string { + quote := "`" + switch { + case b.postgres(): + // If it was quoted with the wrong + // identifier character. + if strings.Contains(ident, "`") { + return strings.ReplaceAll(ident, "`", `"`) + } + quote = `"` + // An identifier for unknown dialect. + case b.dialect == "" && strings.ContainsAny(ident, "`\""): + return ident + } + return quote + ident + quote +} + +// Ident appends the given string as an identifier. +func (b *Builder) Ident(s string) *Builder { + switch { + case len(s) == 0: + case !strings.HasSuffix(s, "*") && !b.isIdent(s) && !isFunc(s) && !isModifier(s) && !isAlias(s): + if b.qualifier != "" { + b.WriteString(b.Quote(b.qualifier)).WriteByte('.') + } + b.WriteString(b.Quote(s)) + case (isFunc(s) || isModifier(s) || isAlias(s)) && b.postgres(): + // Modifiers and aggregation functions that + // were called without dialect information. + b.WriteString(strings.ReplaceAll(s, "`", `"`)) + default: + b.WriteString(s) + } + return b +} + +// IdentComma calls Ident on all arguments and adds a comma between them. +func (b *Builder) IdentComma(s ...string) *Builder { + for i := range s { + if i > 0 { + b.Comma() + } + b.Ident(s[i]) + } + return b +} + +// String returns the accumulated string. +func (b *Builder) String() string { + if b.sb == nil { + return "" + } + return b.sb.String() +} + +// WriteByte wraps the Buffer.WriteByte to make it chainable with other methods. +func (b *Builder) WriteByte(c byte) *Builder { + if b.sb == nil { + b.sb = &strings.Builder{} + } + b.sb.WriteByte(c) + return b +} + +// WriteString wraps the Buffer.WriteString to make it chainable with other methods. +func (b *Builder) WriteString(s string) *Builder { + if b.sb == nil { + b.sb = &strings.Builder{} + } + b.sb.WriteString(s) + return b +} + +// S is a short version of WriteString. +func (b *Builder) S(s string) *Builder { + return b.WriteString(s) +} + +// Len returns the number of accumulated bytes. +func (b *Builder) Len() int { + if b.sb == nil { + return 0 + } + return b.sb.Len() +} + +// Reset resets the Builder to be empty. +func (b *Builder) Reset() *Builder { + if b.sb != nil { + b.sb.Reset() + } + return b +} + +// AddError appends an error to the builder errors. +func (b *Builder) AddError(err error) *Builder { + // allowed nil error make build process easier + if err != nil { + b.errs = append(b.errs, err) + } + return b +} + +func (b *Builder) writeSchema(schema string) { + if schema != "" && b.dialect != dialect.SQLite { + b.Ident(schema).WriteByte('.') + } +} + +// Err returns a concatenated error of all errors encountered during +// the query-building, or were added manually by calling AddError. +func (b *Builder) Err() error { + if len(b.errs) == 0 { + return nil + } + br := strings.Builder{} + for i := range b.errs { + if i > 0 { + br.WriteString("; ") + } + br.WriteString(b.errs[i].Error()) + } + return errors.New(br.String()) +} + +// An Op represents an operator. +type Op int + +// Predicate and arithmetic operators. +const ( + OpEQ Op = iota // = + OpNEQ // <> + OpGT // > + OpGTE // >= + OpLT // < + OpLTE // <= + OpIn // IN + OpNotIn // NOT IN + OpLike // LIKE + OpIsNull // IS NULL + OpNotNull // IS NOT NULL + OpAdd // + + OpSub // - + OpMul // * + OpDiv // / (Quotient) + OpMod // % (Reminder) +) + +var ops = [...]string{ + OpEQ: "=", + OpNEQ: "<>", + OpGT: ">", + OpGTE: ">=", + OpLT: "<", + OpLTE: "<=", + OpIn: "IN", + OpNotIn: "NOT IN", + OpLike: "LIKE", + OpIsNull: "IS NULL", + OpNotNull: "IS NOT NULL", + OpAdd: "+", + OpSub: "-", + OpMul: "*", + OpDiv: "/", + OpMod: "%", +} + +// WriteOp writes an operator to the builder. +func (b *Builder) WriteOp(op Op) *Builder { + switch { + case op >= OpEQ && op <= OpLike || op >= OpAdd && op <= OpMod: + b.Pad().WriteString(ops[op]).Pad() + case op == OpIsNull || op == OpNotNull: + b.Pad().WriteString(ops[op]) + default: + panic(fmt.Sprintf("invalid op %d", op)) + } + return b +} + +type ( + // StmtInfo holds an information regarding + // the statement + StmtInfo struct { + // The Dialect of the SQL driver. + Dialect string + } + // ParamFormatter wraps the FormatPram function. + ParamFormatter interface { + // The FormatParam function lets users define + // custom placeholder formatting for their types. + // For example, formatting the default placeholder + // from '?' to 'ST_GeomFromWKB(?)' for MySQL dialect. + FormatParam(placeholder string, info *StmtInfo) string + } +) + +// Arg appends an input argument to the builder. +func (b *Builder) Arg(a any) *Builder { + switch v := a.(type) { + case nil: + b.WriteString("NULL") + return b + case *raw: + b.WriteString(v.s) + return b + case Querier: + b.Join(v) + return b + } + // Default placeholder param (MySQL and SQLite). + format := "?" + if b.postgres() { + // Postgres' arguments are referenced using the syntax $n. + // $1 refers to the 1st argument, $2 to the 2nd, and so on. + format = "$" + strconv.Itoa(b.total+1) + } + if f, ok := a.(ParamFormatter); ok { + format = f.FormatParam(format, &StmtInfo{ + Dialect: b.dialect, + }) + } + return b.Argf(format, a) +} + +// Args appends a list of arguments to the builder. +func (b *Builder) Args(a ...any) *Builder { + for i := range a { + if i > 0 { + b.Comma() + } + b.Arg(a[i]) + } + return b +} + +// Argf appends an input argument to the builder +// with the given format. For example: +// +// FormatArg("JSON(?)", b). +// FormatArg("ST_GeomFromText(?)", geom) +func (b *Builder) Argf(format string, a any) *Builder { + switch a := a.(type) { + case nil: + b.WriteString("NULL") + return b + case *raw: + b.WriteString(a.s) + return b + case Querier: + b.Join(a) + return b + } + b.total++ + b.args = append(b.args, a) + b.WriteString(format) + return b +} + +// Comma adds a comma to the query. +func (b *Builder) Comma() *Builder { + return b.WriteString(", ") +} + +// Pad adds a space to the query. +func (b *Builder) Pad() *Builder { + return b.WriteByte(' ') +} + +// Join joins a list of Queries to the builder. +func (b *Builder) Join(qs ...Querier) *Builder { + return b.join(qs, "") +} + +// JoinComma joins a list of Queries and adds comma between them. +func (b *Builder) JoinComma(qs ...Querier) *Builder { + return b.join(qs, ", ") +} + +// join a list of Queries to the builder with a given separator. +func (b *Builder) join(qs []Querier, sep string) *Builder { + for i, q := range qs { + if i > 0 { + b.WriteString(sep) + } + st, ok := q.(state) + if ok { + st.SetDialect(b.dialect) + st.SetTotal(b.total) + } + query, args := q.Query() + b.WriteString(query) + b.args = append(b.args, args...) + b.total += len(args) + if qe, ok := q.(querierErr); ok { + if err := qe.Err(); err != nil { + b.AddError(err) + } + } + } + return b +} + +// Wrap gets a callback, and wraps its result with parentheses. +func (b *Builder) Wrap(f func(*Builder)) *Builder { + nb := &Builder{dialect: b.dialect, total: b.total, sb: &strings.Builder{}} + nb.WriteByte('(') + f(nb) + nb.WriteByte(')') + b.WriteString(nb.String()) + b.args = append(b.args, nb.args...) + b.total = nb.total + return b +} + +// Nested gets a callback, and wraps its result with parentheses. +// +// Deprecated: Use Builder.Wrap instead. +func (b *Builder) Nested(f func(*Builder)) *Builder { + return b.Wrap(f) +} + +// SetDialect sets the builder dialect. It's used for garnering dialect specific queries. +func (b *Builder) SetDialect(dialect string) { + b.dialect = dialect +} + +// Dialect returns the dialect of the builder. +func (b Builder) Dialect() string { + return b.dialect +} + +// Total returns the total number of arguments so far. +func (b Builder) Total() int { + return b.total +} + +// SetTotal sets the value of the total arguments. +// Used to pass this information between sub queries/expressions. +func (b *Builder) SetTotal(total int) { + b.total = total +} + +// Query implements the Querier interface. +func (b Builder) Query() (string, []any) { + return b.String(), b.args +} + +// clone returns a shallow clone of a builder. +func (b Builder) clone() Builder { + c := Builder{dialect: b.dialect, total: b.total, sb: &strings.Builder{}} + if len(b.args) > 0 { + c.args = append(c.args, b.args...) + } + if b.sb != nil { + c.sb.WriteString(b.sb.String()) + } + return c +} + +// postgres reports if the builder dialect is PostgreSQL. +func (b Builder) postgres() bool { + return b.Dialect() == dialect.Postgres +} + +// sqlite reports if the builder dialect is SQLite. +func (b Builder) sqlite() bool { + return b.Dialect() == dialect.SQLite +} + +// fromIdent sets the builder dialect from the identifier format. +func (b *Builder) fromIdent(ident string) { + if strings.Contains(ident, `"`) { + b.SetDialect(dialect.Postgres) + } + // otherwise, use the default. +} + +// isIdent reports if the given string is a dialect identifier. +func (b *Builder) isIdent(s string) bool { + switch { + case b.postgres(): + return strings.Contains(s, `"`) + default: + return strings.Contains(s, "`") + } +} + +// unquote database identifiers. +func (b *Builder) unquote(s string) string { + switch pg := b.postgres(); { + case len(s) < 2: + case !pg && s[0] == '`' && s[len(s)-1] == '`', pg && s[0] == '"' && s[len(s)-1] == '"': + if u, err := strconv.Unquote(s); err == nil { + return u + } + } + return s +} + +// isQualified reports if the given string is a qualified identifier. +func (b *Builder) isQualified(s string) bool { + ident, pg := b.isIdent(s), b.postgres() + return !ident && len(s) > 2 && strings.ContainsRune(s[1:len(s)-1], '.') || // . + ident && pg && strings.Contains(s, `"."`) || // "qualifier"."column" + ident && !pg && strings.Contains(s, "`.`") // `qualifier`.`column` +} + +// state wraps all methods for setting and getting +// update state between all queries in the query tree. +type state interface { + Dialect() string + SetDialect(string) + Total() int + SetTotal(int) +} + +// DialectBuilder prefixes all root builders with the `Dialect` constructor. +type DialectBuilder struct { + dialect string +} + +// Dialect creates a new DialectBuilder with the given dialect name. +func Dialect(name string) *DialectBuilder { + return &DialectBuilder{name} +} + +// String builds a dialect-aware expression string from the given callback. +func (d *DialectBuilder) String(f func(*Builder)) string { + b := &Builder{} + b.SetDialect(d.dialect) + f(b) + return b.String() +} + +// Expr builds a dialect-aware expression from the given callback. +func (d *DialectBuilder) Expr(f func(*Builder)) Querier { + return Expr(d.String(f)) +} + +// CreateView creates a ViewBuilder for the configured dialect. +// +// t := Table("users") +// Dialect(dialect.Postgres). +// CreateView("users"). +// Columns( +// Column("id").Type("int"), +// Column("name").Type("varchar(255)"), +// ). +// As(Select(t.C("id"), t.C("name")).From(t)) +func (d *DialectBuilder) CreateView(name string) *ViewBuilder { + b := CreateView(name) + b.SetDialect(d.dialect) + return b +} + +// Column creates a ColumnBuilder for the configured dialect. +// +// Dialect(dialect.Postgres).. +// Column("group_id").Type("int").Attr("UNIQUE") +func (d *DialectBuilder) Column(name string) *ColumnBuilder { + b := Column(name) + b.SetDialect(d.dialect) + return b +} + +// Insert creates a InsertBuilder for the configured dialect. +// +// Dialect(dialect.Postgres). +// Insert("users").Columns("age").Values(1) +func (d *DialectBuilder) Insert(table string) *InsertBuilder { + b := Insert(table) + b.SetDialect(d.dialect) + return b +} + +// Update creates a UpdateBuilder for the configured dialect. +// +// Dialect(dialect.Postgres). +// Update("users").Set("name", "foo") +func (d *DialectBuilder) Update(table string) *UpdateBuilder { + b := Update(table) + b.SetDialect(d.dialect) + return b +} + +// Delete creates a DeleteBuilder for the configured dialect. +// +// Dialect(dialect.Postgres). +// Delete().From("users") +func (d *DialectBuilder) Delete(table string) *DeleteBuilder { + b := Delete(table) + b.SetDialect(d.dialect) + return b +} + +// Select creates a Selector for the configured dialect. +// +// Dialect(dialect.Postgres). +// Select().From(Table("users")) +func (d *DialectBuilder) Select(columns ...string) *Selector { + b := Select(columns...) + b.SetDialect(d.dialect) + return b +} + +// SelectExpr is like Select, but supports passing arbitrary +// expressions for SELECT clause. +// +// Dialect(dialect.Postgres). +// SelectExpr(expr...). +// From(Table("users")) +func (d *DialectBuilder) SelectExpr(exprs ...Querier) *Selector { + b := SelectExpr(exprs...) + b.SetDialect(d.dialect) + return b +} + +// Table creates a SelectTable for the configured dialect. +// +// Dialect(dialect.Postgres). +// Table("users").As("u") +func (d *DialectBuilder) Table(name string) *SelectTable { + b := Table(name) + b.SetDialect(d.dialect) + return b +} + +// With creates a WithBuilder for the configured dialect. +// +// Dialect(dialect.Postgres). +// With("users_view"). +// As(Select().From(Table("users"))) +func (d *DialectBuilder) With(name string) *WithBuilder { + b := With(name) + b.SetDialect(d.dialect) + return b +} + +func isAlias(s string) bool { + return strings.Contains(s, " AS ") || strings.Contains(s, " as ") +} + +func isFunc(s string) bool { + return strings.Contains(s, "(") && strings.Contains(s, ")") +} + +func isModifier(s string) bool { + for _, m := range [...]string{"DISTINCT", "ALL", "WITH ROLLUP"} { + if strings.HasPrefix(s, m) { + return true + } + } + return false +} diff --git a/dialect/sql/builder_test.go b/dialect/sql/builder_test.go new file mode 100644 index 0000000..80bec09 --- /dev/null +++ b/dialect/sql/builder_test.go @@ -0,0 +1,2407 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package sql + +import ( + "context" + "database/sql/driver" + "fmt" + "strconv" + "strings" + "testing" + + "entgo.io/ent/dialect" + "github.com/stretchr/testify/require" +) + +func TestBuilder(t *testing.T) { + tests := []struct { + input Querier + wantQuery string + wantArgs []any + }{ + { + input: CreateView("clean_users"). + Columns( + Column("id").Type("int"), + Column("name").Type("varchar(255)"), + ). + As(Select("id", "name").From(Table("users"))), + wantQuery: "CREATE VIEW `clean_users` (`id` int, `name` varchar(255)) AS SELECT `id`, `name` FROM `users`", + }, + { + input: Dialect(dialect.Postgres). + CreateView("clean_users"). + Columns( + Column("id").Type("int"), + Column("name").Type("varchar(255)"), + ). + As(Select("id", "name").From(Table("users"))), + wantQuery: `CREATE VIEW "clean_users" ("id" int, "name" varchar(255)) AS SELECT "id", "name" FROM "users"`, + }, + { + input: CreateView("clean_users"). + Schema("schema"). + As(Select("id", "name").From(Table("users"))), + wantQuery: "CREATE VIEW `schema`.`clean_users` AS SELECT `id`, `name` FROM `users`", + }, + { + input: Insert("users").Columns("age").Values(1), + wantQuery: "INSERT INTO `users` (`age`) VALUES (?)", + wantArgs: []any{1}, + }, + { + input: Insert("users").Columns("age").Values(1).Schema("mydb"), + wantQuery: "INSERT INTO `mydb`.`users` (`age`) VALUES (?)", + wantArgs: []any{1}, + }, + { + input: Dialect(dialect.Postgres).Insert("users").Columns("age").Values(1), + wantQuery: `INSERT INTO "users" ("age") VALUES ($1)`, + wantArgs: []any{1}, + }, + { + input: Dialect(dialect.Postgres).Insert("users").Columns("age").Values(1).Schema("mydb"), + wantQuery: `INSERT INTO "mydb"."users" ("age") VALUES ($1)`, + wantArgs: []any{1}, + }, + { + input: Dialect(dialect.SQLite).Insert("users").Columns("age").Values(1).Schema("mydb"), + wantQuery: "INSERT INTO `users` (`age`) VALUES (?)", + wantArgs: []any{1}, + }, + { + input: Dialect(dialect.Postgres).Insert("users").Columns("age").Values(1).Returning("id"), + wantQuery: `INSERT INTO "users" ("age") VALUES ($1) RETURNING "id"`, + wantArgs: []any{1}, + }, + { + input: Dialect(dialect.Postgres).Insert("users").Columns("age").Values(1).Returning("id").Returning("name"), + wantQuery: `INSERT INTO "users" ("age") VALUES ($1) RETURNING "name"`, + wantArgs: []any{1}, + }, + { + input: Insert("users").Columns("name", "age").Values("a8m", 10), + wantQuery: "INSERT INTO `users` (`name`, `age`) VALUES (?, ?)", + wantArgs: []any{"a8m", 10}, + }, + { + input: Dialect(dialect.Postgres).Insert("users").Columns("name", "age").Values("a8m", 10), + wantQuery: `INSERT INTO "users" ("name", "age") VALUES ($1, $2)`, + wantArgs: []any{"a8m", 10}, + }, + { + input: Insert("users").Columns("name", "age").Values("a8m", 10).Values("foo", 20), + wantQuery: "INSERT INTO `users` (`name`, `age`) VALUES (?, ?), (?, ?)", + wantArgs: []any{"a8m", 10, "foo", 20}, + }, + { + input: Dialect(dialect.Postgres).Insert("users").Columns("name", "age").Values("a8m", 10).Values("foo", 20), + wantQuery: `INSERT INTO "users" ("name", "age") VALUES ($1, $2), ($3, $4)`, + wantArgs: []any{"a8m", 10, "foo", 20}, + }, + { + input: Dialect(dialect.Postgres).Insert("users"). + Columns("name", "age"). + Values("a8m", 10). + Values("foo", 20). + Values("bar", 30), + wantQuery: `INSERT INTO "users" ("name", "age") VALUES ($1, $2), ($3, $4), ($5, $6)`, + wantArgs: []any{"a8m", 10, "foo", 20, "bar", 30}, + }, + { + input: Update("users").Set("name", "foo"), + wantQuery: "UPDATE `users` SET `name` = ?", + wantArgs: []any{"foo"}, + }, + { + input: Update("users").Set("name", "foo").Schema("mydb"), + wantQuery: "UPDATE `mydb`.`users` SET `name` = ?", + wantArgs: []any{"foo"}, + }, + { + input: Dialect(dialect.Postgres).Update("users").Set("name", "foo"), + wantQuery: `UPDATE "users" SET "name" = $1`, + wantArgs: []any{"foo"}, + }, + { + input: Dialect(dialect.Postgres).Update("users").Set("name", "foo").Returning("*"), + wantQuery: `UPDATE "users" SET "name" = $1 RETURNING *`, + wantArgs: []any{"foo"}, + }, + { + input: Dialect(dialect.Postgres).Update("users").Set("name", "foo").Returning("id", "name"), + wantQuery: `UPDATE "users" SET "name" = $1 RETURNING "id", "name"`, + wantArgs: []any{"foo"}, + }, + { + input: Dialect(dialect.Postgres).Update("users").Set("name", "foo").Schema("mydb"), + wantQuery: `UPDATE "mydb"."users" SET "name" = $1`, + wantArgs: []any{"foo"}, + }, + { + input: Dialect(dialect.SQLite).Update("users").Set("name", "foo").Schema("mydb"), + wantQuery: "UPDATE `users` SET `name` = ?", + wantArgs: []any{"foo"}, + }, + { + input: Update("users").Set("name", "foo").Set("age", 10), + wantQuery: "UPDATE `users` SET `name` = ?, `age` = ?", + wantArgs: []any{"foo", 10}, + }, + { + input: Dialect(dialect.SQLite).Update("users").Set("name", "foo").Returning("id", "name").OrderBy("name").Limit(10), + wantQuery: "UPDATE `users` SET `name` = ? RETURNING `id`, `name` ORDER BY `name` LIMIT 10", + wantArgs: []any{"foo"}, + }, + { + input: Dialect(dialect.Postgres).Update("users").Set("name", "foo").Set("age", 10), + wantQuery: `UPDATE "users" SET "name" = $1, "age" = $2`, + wantArgs: []any{"foo", 10}, + }, + { + input: Dialect(dialect.Postgres).Update("users"). + Set("active", false). + Where(P(func(b *Builder) { + b.Ident("name").WriteString(" SIMILAR TO ").Arg("(b|c)%") + })), + wantQuery: `UPDATE "users" SET "active" = $1 WHERE "name" SIMILAR TO $2`, + wantArgs: []any{false, "(b|c)%"}, + }, + { + input: Update("users").Set("name", "foo").Where(EQ("name", "bar")), + wantQuery: "UPDATE `users` SET `name` = ? WHERE `name` = ?", + wantArgs: []any{"foo", "bar"}, + }, + { + input: Update("users").Set("name", "foo").Where(EQ("name", Expr("?", "bar"))), + wantQuery: "UPDATE `users` SET `name` = ? WHERE `name` = ?", + wantArgs: []any{"foo", "bar"}, + }, + { + input: Dialect(dialect.Postgres).Update("users").Set("name", "foo").Where(EQ("name", "bar")), + wantQuery: `UPDATE "users" SET "name" = $1 WHERE "name" = $2`, + wantArgs: []any{"foo", "bar"}, + }, + { + input: func() Querier { + p1, p2 := EQ("name", "bar"), Or(EQ("age", 10), EQ("age", 20)) + return Dialect(dialect.Postgres). + Update("users"). + Set("name", "foo"). + Where(p1). + Where(p2). + Where(p1). + Where(p2) + }(), + wantQuery: `UPDATE "users" SET "name" = $1 WHERE (("name" = $2 AND ("age" = $3 OR "age" = $4)) AND "name" = $5) AND ("age" = $6 OR "age" = $7)`, + wantArgs: []any{"foo", "bar", 10, 20, "bar", 10, 20}, + }, + { + input: Update("users").Set("name", "foo").SetNull("spouse_id"), + wantQuery: "UPDATE `users` SET `spouse_id` = NULL, `name` = ?", + wantArgs: []any{"foo"}, + }, + { + input: Dialect(dialect.Postgres).Update("users").Set("name", "foo").SetNull("spouse_id"), + wantQuery: `UPDATE "users" SET "spouse_id" = NULL, "name" = $1`, + wantArgs: []any{"foo"}, + }, + { + input: Update("users").Set("name", "foo"). + Where(EQ("name", "bar")). + Where(EQ("age", 20)), + wantQuery: "UPDATE `users` SET `name` = ? WHERE `name` = ? AND `age` = ?", + wantArgs: []any{"foo", "bar", 20}, + }, + { + input: Dialect(dialect.Postgres). + Update("users"). + Set("name", "foo"). + Where(EQ("name", "bar")). + Where(EQ("age", 20)), + wantQuery: `UPDATE "users" SET "name" = $1 WHERE "name" = $2 AND "age" = $3`, + wantArgs: []any{"foo", "bar", 20}, + }, + { + input: Update("users"). + Set("name", "foo"). + Set("age", 10). + Where(Or(EQ("name", "bar"), EQ("name", "baz"))), + wantQuery: "UPDATE `users` SET `name` = ?, `age` = ? WHERE `name` = ? OR `name` = ?", + wantArgs: []any{"foo", 10, "bar", "baz"}, + }, + { + input: Dialect(dialect.Postgres). + Update("users"). + Set("name", "foo"). + Set("age", 10). + Where(Or(EQ("name", "bar"), EQ("name", "baz"))), + wantQuery: `UPDATE "users" SET "name" = $1, "age" = $2 WHERE "name" = $3 OR "name" = $4`, + wantArgs: []any{"foo", 10, "bar", "baz"}, + }, + { + input: Update("users"). + Set("name", "foo"). + Set("age", 10). + Where(P().EQ("name", "foo")), + wantQuery: "UPDATE `users` SET `name` = ?, `age` = ? WHERE `name` = ?", + wantArgs: []any{"foo", 10, "foo"}, + }, + { + input: Dialect(dialect.Postgres). + Update("users"). + Add("rank", 10). + Where( + Or( + EQ("rank", Select("rank").From(Table("ranks")).Where(EQ("name", "foo"))), + GT("score", Select("score").From(Table("scores")).Where(GT("count", 0))), + ), + ), + wantQuery: `UPDATE "users" SET "rank" = COALESCE("users"."rank", 0) + $1 WHERE "rank" = (SELECT "rank" FROM "ranks" WHERE "name" = $2) OR "score" > (SELECT "score" FROM "scores" WHERE "count" > $3)`, + wantArgs: []any{10, "foo", 0}, + }, + { + input: Update("users"). + Add("rank", 10). + Where( + Or( + EQ("rank", Select("rank").From(Table("ranks")).Where(EQ("name", "foo"))), + GT("score", Select("score").From(Table("scores")).Where(GT("count", 0))), + ), + ), + wantQuery: "UPDATE `users` SET `rank` = COALESCE(`users`.`rank`, 0) + ? WHERE `rank` = (SELECT `rank` FROM `ranks` WHERE `name` = ?) OR `score` > (SELECT `score` FROM `scores` WHERE `count` > ?)", + wantArgs: []any{10, "foo", 0}, + }, + { + input: Dialect(dialect.Postgres). + Update("users"). + Set("name", "foo"). + Set("age", 10). + Where(P().EQ("name", "foo")), + wantQuery: `UPDATE "users" SET "name" = $1, "age" = $2 WHERE "name" = $3`, + wantArgs: []any{"foo", 10, "foo"}, + }, + { + input: Update("users"). + Set("name", "foo"). + Where(And(In("name", "bar", "baz"), NotIn("age", 1, 2))), + wantQuery: "UPDATE `users` SET `name` = ? WHERE `name` IN (?, ?) AND `age` NOT IN (?, ?)", + wantArgs: []any{"foo", "bar", "baz", 1, 2}, + }, + { + input: Dialect(dialect.Postgres). + Update("users"). + Set("name", "foo"). + Where(And(In("name", "bar", "baz"), NotIn("age", 1, 2))), + wantQuery: `UPDATE "users" SET "name" = $1 WHERE "name" IN ($2, $3) AND "age" NOT IN ($4, $5)`, + wantArgs: []any{"foo", "bar", "baz", 1, 2}, + }, + { + input: Update("users"). + Set("name", "foo"). + Where(And(HasPrefix("nickname", "a8m"), Contains("lastname", "mash"))), + wantQuery: "UPDATE `users` SET `name` = ? WHERE `nickname` LIKE ? AND `lastname` LIKE ?", + wantArgs: []any{"foo", "a8m%", "%mash%"}, + }, + { + input: Dialect(dialect.Postgres). + Update("users"). + Set("name", "foo"). + Where(And(HasPrefix("nickname", "a8m"), Contains("lastname", "mash"))), + wantQuery: `UPDATE "users" SET "name" = $1 WHERE "nickname" LIKE $2 AND "lastname" LIKE $3`, + wantArgs: []any{"foo", "a8m%", "%mash%"}, + }, + { + input: Update("users"). + Add("age", 1). + Where(HasPrefix("nickname", "a8m")), + wantQuery: "UPDATE `users` SET `age` = COALESCE(`users`.`age`, 0) + ? WHERE `nickname` LIKE ?", + wantArgs: []any{1, "a8m%"}, + }, + { + input: Update("users"). + Set("age", 1). + Add("age", 2). + Where(HasPrefix("nickname", "a8m")), + wantQuery: "UPDATE `users` SET `age` = ?, `age` = COALESCE(`users`.`age`, 0) + ? WHERE `nickname` LIKE ?", + wantArgs: []any{1, 2, "a8m%"}, + }, + { + input: Update("users"). + Add("age", 2). + Set("age", 1). + Where(HasPrefix("nickname", "a8m")), + wantQuery: "UPDATE `users` SET `age` = ? WHERE `nickname` LIKE ?", + wantArgs: []any{1, "a8m%"}, + }, + { + input: Dialect(dialect.Postgres). + Update("users"). + Add("age", 1). + Where(HasPrefix("nickname", "a8m")), + wantQuery: `UPDATE "users" SET "age" = COALESCE("users"."age", 0) + $1 WHERE "nickname" LIKE $2`, + wantArgs: []any{1, "a8m%"}, + }, + { + input: Update("users"). + Set("name", "foo"). + Where(And(HasPrefixFold("nickname", "a8m"), Contains("lastname", "mash"))), + wantQuery: "UPDATE `users` SET `name` = ? WHERE LOWER(`nickname`) LIKE ? AND `lastname` LIKE ?", + wantArgs: []any{"foo", "a8m%", "%mash%"}, + }, + { + input: Dialect(dialect.Postgres). + Update("users"). + Set("name", "foo"). + Where(And(HasPrefixFold("nickname", "a8m"), Contains("lastname", "mash"))), + wantQuery: `UPDATE "users" SET "name" = $1 WHERE "nickname" ILIKE $2 AND "lastname" LIKE $3`, + wantArgs: []any{"foo", "a8m%", "%mash%"}, + }, + { + input: Update("users"). + Add("age", 1). + Where(HasPrefixFold("nickname", "a8m")), + wantQuery: "UPDATE `users` SET `age` = COALESCE(`users`.`age`, 0) + ? WHERE LOWER(`nickname`) LIKE ?", + wantArgs: []any{1, "a8m%"}, + }, + { + input: Update("users"). + Set("age", 1). + Add("age", 2). + Where(HasPrefixFold("nickname", "a8m")), + wantQuery: "UPDATE `users` SET `age` = ?, `age` = COALESCE(`users`.`age`, 0) + ? WHERE LOWER(`nickname`) LIKE ?", + wantArgs: []any{1, 2, "a8m%"}, + }, + { + input: Update("users"). + Add("age", 2). + Set("age", 1). + Where(HasPrefixFold("nickname", "a8m")), + wantQuery: "UPDATE `users` SET `age` = ? WHERE LOWER(`nickname`) LIKE ?", + wantArgs: []any{1, "a8m%"}, + }, + { + input: Dialect(dialect.Postgres). + Update("users"). + Add("age", 1). + Where(HasPrefixFold("nickname", "a8m")), + wantQuery: `UPDATE "users" SET "age" = COALESCE("users"."age", 0) + $1 WHERE "nickname" ILIKE $2`, + wantArgs: []any{1, "a8m%"}, + }, + { + input: Dialect(dialect.Postgres). + Update("users"). + Set("name", "foo"). + Where(And(HasSuffixFold("nickname", "a8m"), Contains("lastname", "mash"))), + wantQuery: `UPDATE "users" SET "name" = $1 WHERE "nickname" ILIKE $2 AND "lastname" LIKE $3`, + wantArgs: []any{"foo", "%a8m", "%mash%"}, + }, + { + input: Update("users"). + Add("age", 1). + Where(HasSuffixFold("nickname", "a8m")), + wantQuery: "UPDATE `users` SET `age` = COALESCE(`users`.`age`, 0) + ? WHERE LOWER(`nickname`) LIKE ?", + wantArgs: []any{1, "%a8m"}, + }, + { + input: Update("users"). + Set("age", 1). + Add("age", 2). + Where(HasSuffixFold("nickname", "a8m")), + wantQuery: "UPDATE `users` SET `age` = ?, `age` = COALESCE(`users`.`age`, 0) + ? WHERE LOWER(`nickname`) LIKE ?", + wantArgs: []any{1, 2, "%a8m"}, + }, + { + input: Update("users"). + Add("age", 2). + Set("age", 1). + Where(HasSuffixFold("nickname", "a8m")), + wantQuery: "UPDATE `users` SET `age` = ? WHERE LOWER(`nickname`) LIKE ?", + wantArgs: []any{1, "%a8m"}, + }, + { + input: Dialect(dialect.Postgres). + Update("users"). + Add("age", 1). + Where(HasSuffixFold("nickname", "a8m")), + wantQuery: `UPDATE "users" SET "age" = COALESCE("users"."age", 0) + $1 WHERE "nickname" ILIKE $2`, + wantArgs: []any{1, "%a8m"}, + }, + { + input: Update("users"). + Add("age", 1). + Set("nickname", "a8m"). + Add("version", 10). + Set("name", "mashraki"), + wantQuery: "UPDATE `users` SET `age` = COALESCE(`users`.`age`, 0) + ?, `nickname` = ?, `version` = COALESCE(`users`.`version`, 0) + ?, `name` = ?", + wantArgs: []any{1, "a8m", 10, "mashraki"}, + }, + { + input: Dialect(dialect.Postgres). + Update("users"). + Add("age", 1). + Set("nickname", "a8m"). + Add("version", 10). + Set("name", "mashraki"), + wantQuery: `UPDATE "users" SET "age" = COALESCE("users"."age", 0) + $1, "nickname" = $2, "version" = COALESCE("users"."version", 0) + $3, "name" = $4`, + wantArgs: []any{1, "a8m", 10, "mashraki"}, + }, + { + input: Dialect(dialect.Postgres). + Update("users"). + Add("age", 1). + Set("nickname", "a8m"). + Add("version", 10). + Set("name", "mashraki"). + Set("first", "ariel"). + Add("score", 1e5). + Where(Or(EQ("age", 1), EQ("age", 2))), + wantQuery: `UPDATE "users" SET "age" = COALESCE("users"."age", 0) + $1, "nickname" = $2, "version" = COALESCE("users"."version", 0) + $3, "name" = $4, "first" = $5, "score" = COALESCE("users"."score", 0) + $6 WHERE "age" = $7 OR "age" = $8`, + wantArgs: []any{1, "a8m", 10, "mashraki", "ariel", 1e5, 1, 2}, + }, + { + input: Select(). + From(Table("users")). + Where(EQ("name", "Alex")), + wantQuery: "SELECT * FROM `users` WHERE `name` = ?", + wantArgs: []any{"Alex"}, + }, + { + input: Dialect(dialect.Postgres). + Select(). + From(Table("users")), + wantQuery: `SELECT * FROM "users"`, + }, + { + input: Dialect(dialect.Postgres). + Select(). + From(Table("users")). + Where(EQ("name", "Ariel")), + wantQuery: `SELECT * FROM "users" WHERE "name" = $1`, + wantArgs: []any{"Ariel"}, + }, + { + input: Select(). + From(Table("users")). + Where(Or(EQ("name", "BAR"), EQ("name", "BAZ"))), + wantQuery: "SELECT * FROM `users` WHERE `name` = ? OR `name` = ?", + wantArgs: []any{"BAR", "BAZ"}, + }, + { + input: func() Querier { + t1, t2 := Table("users"), Table("pets") + return Dialect(dialect.Postgres). + Select(). + From(t1). + Where(GT(t1.C("age"), 30)). + Where( + And( + Exists(Select().From(t2).Where(ColumnsEQ(t2.C("owner_id"), t1.C("id")))), + NotExists(Select().From(t2).Where(ColumnsEQ(t2.C("owner_id"), t1.C("id")))), + ), + ) + }(), + wantQuery: `SELECT * FROM "users" WHERE "users"."age" > $1 AND (EXISTS (SELECT * FROM "pets" WHERE "pets"."owner_id" = "users"."id") AND NOT EXISTS (SELECT * FROM "pets" WHERE "pets"."owner_id" = "users"."id"))`, + wantArgs: []any{30}, + }, + { + input: Update("users"). + Set("name", "foo"). + Set("age", 10). + Where(And(EQ("name", "foo"), EQ("age", 20))), + wantQuery: "UPDATE `users` SET `name` = ?, `age` = ? WHERE `name` = ? AND `age` = ?", + wantArgs: []any{"foo", 10, "foo", 20}, + }, + { + input: Delete("users"). + Where(NotNull("parent_id")), + wantQuery: "DELETE FROM `users` WHERE `parent_id` IS NOT NULL", + }, + { + input: Delete("users"). + Where(NotNull("parent_id")). + Schema("mydb"), + wantQuery: "DELETE FROM `mydb`.`users` WHERE `parent_id` IS NOT NULL", + }, + { + input: Dialect(dialect.SQLite). + Delete("users"). + Where(NotNull("parent_id")). + Schema("mydb"), + wantQuery: "DELETE FROM `users` WHERE `parent_id` IS NOT NULL", + }, + { + input: Dialect(dialect.Postgres). + Delete("users"). + Where(IsNull("parent_id")), + wantQuery: `DELETE FROM "users" WHERE "parent_id" IS NULL`, + }, + { + input: Dialect(dialect.Postgres). + Delete("users"). + Where(IsNull("parent_id")). + Schema("mydb"), + wantQuery: `DELETE FROM "mydb"."users" WHERE "parent_id" IS NULL`, + }, + { + input: Delete("users"). + Where(And(IsNull("parent_id"), NotIn("name", "foo", "bar"))), + wantQuery: "DELETE FROM `users` WHERE `parent_id` IS NULL AND `name` NOT IN (?, ?)", + wantArgs: []any{"foo", "bar"}, + }, + { + input: Dialect(dialect.Postgres). + Delete("users"). + Where(And(IsNull("parent_id"), NotIn("name", "foo", "bar"))), + wantQuery: `DELETE FROM "users" WHERE "parent_id" IS NULL AND "name" NOT IN ($1, $2)`, + wantArgs: []any{"foo", "bar"}, + }, + { + input: Delete("users"). + Where(And(IsNull("parent_id"), In("name"))), + wantQuery: "DELETE FROM `users` WHERE `parent_id` IS NULL AND FALSE", + }, + { + input: Delete("users"). + Where(And(IsNull("parent_id"), NotIn("name"))), + wantQuery: "DELETE FROM `users` WHERE `parent_id` IS NULL AND (NOT (FALSE))", + }, + { + input: Delete("users"). + Where(And(False(), False())), + wantQuery: "DELETE FROM `users` WHERE FALSE AND FALSE", + }, + { + input: Dialect(dialect.Postgres). + Delete("users"). + Where(And(False(), False())), + wantQuery: `DELETE FROM "users" WHERE FALSE AND FALSE`, + }, + { + input: Delete("users"). + Where(Or(NotNull("parent_id"), EQ("parent_id", 10))), + wantQuery: "DELETE FROM `users` WHERE `parent_id` IS NOT NULL OR `parent_id` = ?", + wantArgs: []any{10}, + }, + { + input: Dialect(dialect.Postgres). + Delete("users"). + Where(Or(NotNull("parent_id"), EQ("parent_id", 10))), + wantQuery: `DELETE FROM "users" WHERE "parent_id" IS NOT NULL OR "parent_id" = $1`, + wantArgs: []any{10}, + }, + { + input: Delete("users"). + Where( + Or( + And(EQ("name", "foo"), EQ("age", 10)), + And(EQ("name", "bar"), EQ("age", 20)), + And( + EQ("name", "qux"), + Or(EQ("age", 1), EQ("age", 2)), + ), + ), + ), + wantQuery: "DELETE FROM `users` WHERE (`name` = ? AND `age` = ?) OR (`name` = ? AND `age` = ?) OR (`name` = ? AND (`age` = ? OR `age` = ?))", + wantArgs: []any{"foo", 10, "bar", 20, "qux", 1, 2}, + }, + { + input: Dialect(dialect.Postgres). + Delete("users"). + Where( + Or( + And(EQ("name", "foo"), EQ("age", 10)), + And(EQ("name", "bar"), EQ("age", 20)), + And( + EQ("name", "qux"), + Or(EQ("age", 1), EQ("age", 2)), + ), + ), + ), + wantQuery: `DELETE FROM "users" WHERE ("name" = $1 AND "age" = $2) OR ("name" = $3 AND "age" = $4) OR ("name" = $5 AND ("age" = $6 OR "age" = $7))`, + wantArgs: []any{"foo", 10, "bar", 20, "qux", 1, 2}, + }, + { + input: Delete("users"). + Where( + Or( + And(EQ("name", "foo"), EQ("age", 10)), + And(EQ("name", "bar"), EQ("age", 20)), + ), + ). + Where(EQ("role", "admin")), + wantQuery: "DELETE FROM `users` WHERE ((`name` = ? AND `age` = ?) OR (`name` = ? AND `age` = ?)) AND `role` = ?", + wantArgs: []any{"foo", 10, "bar", 20, "admin"}, + }, + { + input: Dialect(dialect.Postgres). + Delete("users"). + Where( + Or( + And(EQ("name", "foo"), EQ("age", 10)), + And(EQ("name", "bar"), EQ("age", 20)), + ), + ). + Where(EQ("role", "admin")), + wantQuery: `DELETE FROM "users" WHERE (("name" = $1 AND "age" = $2) OR ("name" = $3 AND "age" = $4)) AND "role" = $5`, + wantArgs: []any{"foo", 10, "bar", 20, "admin"}, + }, + { + input: Select().From(Table("users")), + wantQuery: "SELECT * FROM `users`", + }, + { + input: Dialect(dialect.Postgres).Select().From(Table("users")), + wantQuery: `SELECT * FROM "users"`, + }, + { + input: Select().From(Table("users").Unquote()), + wantQuery: "SELECT * FROM users", + }, + { + input: Dialect(dialect.Postgres).Select().From(Table("users").Unquote()), + wantQuery: "SELECT * FROM users", + }, + { + input: Select().From(Table("users").As("u")), + wantQuery: "SELECT * FROM `users` AS `u`", + }, + { + input: Dialect(dialect.Postgres).Select().From(Table("users").As("u")), + wantQuery: `SELECT * FROM "users" AS "u"`, + }, + { + input: func() Querier { + t1 := Table("users").As("u") + t2 := Table("groups").As("g") + return Select(t1.C("id"), t2.C("name")).From(t1).Join(t2) + }(), + wantQuery: "SELECT `u`.`id`, `g`.`name` FROM `users` AS `u` JOIN `groups` AS `g`", + }, + { + input: func() Querier { + t1 := Table("users").As("u") + t2 := Table("groups").As("g") + return Dialect(dialect.Postgres).Select(t1.C("id"), t2.C("name")).From(t1).Join(t2) + }(), + wantQuery: `SELECT "u"."id", "g"."name" FROM "users" AS "u" JOIN "groups" AS "g"`, + }, + { + input: func() Querier { + t1 := Table("users").As("u") + t2 := Table("groups").As("g") + return Select(t1.C("id"), t2.C("name")). + From(t1). + Join(t2). + On(t1.C("id"), t2.C("user_id")) + }(), + wantQuery: "SELECT `u`.`id`, `g`.`name` FROM `users` AS `u` JOIN `groups` AS `g` ON `u`.`id` = `g`.`user_id`", + }, + { + input: func() Querier { + t1 := Table("users").As("u") + t2 := Table("groups").As("g") + return Dialect(dialect.Postgres). + Select(t1.C("id"), t2.C("name")). + From(t1). + Join(t2). + On(t1.C("id"), t2.C("user_id")) + }(), + wantQuery: `SELECT "u"."id", "g"."name" FROM "users" AS "u" JOIN "groups" AS "g" ON "u"."id" = "g"."user_id"`, + }, + { + input: func() Querier { + t1 := Table("users").As("u") + t2 := Table("groups").As("g") + return Select(t1.C("id"), t2.C("name")). + From(t1). + Join(t2). + On(t1.C("id"), t2.C("user_id")). + Where(And(EQ(t1.C("name"), "bar"), NotNull(t2.C("name")))) + }(), + wantQuery: "SELECT `u`.`id`, `g`.`name` FROM `users` AS `u` JOIN `groups` AS `g` ON `u`.`id` = `g`.`user_id` WHERE `u`.`name` = ? AND `g`.`name` IS NOT NULL", + wantArgs: []any{"bar"}, + }, + { + input: func() Querier { + t1 := Table("users").As("u") + t2 := Table("groups").As("g") + return Dialect(dialect.Postgres). + Select(t1.C("id"), t2.C("name")). + From(t1). + Join(t2). + On(t1.C("id"), t2.C("user_id")). + Where(And(EQ(t1.C("name"), "bar"), NotNull(t2.C("name")))) + }(), + wantQuery: `SELECT "u"."id", "g"."name" FROM "users" AS "u" JOIN "groups" AS "g" ON "u"."id" = "g"."user_id" WHERE "u"."name" = $1 AND "g"."name" IS NOT NULL`, + wantArgs: []any{"bar"}, + }, + { + input: func() Querier { + t1 := Table("users").As("u") + t2 := Table("user_groups").As("ug") + return Select(t1.C("id"), As(Count("`*`"), "group_count")). + From(t1). + LeftJoin(t2). + On(t1.C("id"), t2.C("user_id")). + GroupBy(t1.C("id")) + }(), + wantQuery: "SELECT `u`.`id`, COUNT(`*`) AS `group_count` FROM `users` AS `u` LEFT JOIN `user_groups` AS `ug` ON `u`.`id` = `ug`.`user_id` GROUP BY `u`.`id`", + }, + { + input: func() Querier { + t1 := Table("users").As("u") + t2 := Table("user_groups").As("ug") + return Select(t1.C("id"), As(Count("`*`"), "group_count")). + From(t1). + LeftJoin(t2). + OnP(P(func(b *Builder) { + b.Ident(t1.C("id")).WriteOp(OpEQ).Ident(t2.C("user_id")) + })). + GroupBy(t1.C("id")).Clone() + }(), + wantQuery: "SELECT `u`.`id`, COUNT(`*`) AS `group_count` FROM `users` AS `u` LEFT JOIN `user_groups` AS `ug` ON `u`.`id` = `ug`.`user_id` GROUP BY `u`.`id`", + }, + { + input: func() Querier { + t1 := Table("groups").As("g") + t2 := Table("user_groups").As("ug") + return Select(t1.C("id"), As(Count("`*`"), "user_count")). + From(t1). + RightJoin(t2). + On(t1.C("id"), t2.C("group_id")). + GroupBy(t1.C("id")) + }(), + wantQuery: "SELECT `g`.`id`, COUNT(`*`) AS `user_count` FROM `groups` AS `g` RIGHT JOIN `user_groups` AS `ug` ON `g`.`id` = `ug`.`group_id` GROUP BY `g`.`id`", + }, + { + input: func() Querier { + t1 := Table("groups").As("g") + t2 := Table("user_groups").As("ug") + return Select(t1.C("id"), As(Count("`*`"), "user_count")). + From(t1). + FullJoin(t2). + On(t1.C("id"), t2.C("group_id")). + GroupBy(t1.C("id")) + }(), + wantQuery: "SELECT `g`.`id`, COUNT(`*`) AS `user_count` FROM `groups` AS `g` FULL JOIN `user_groups` AS `ug` ON `g`.`id` = `ug`.`group_id` GROUP BY `g`.`id`", + }, + { + input: func() Querier { + t1 := Table("users").As("u") + return Select(t1.Columns("name", "age")...).From(t1) + }(), + wantQuery: "SELECT `u`.`name`, `u`.`age` FROM `users` AS `u`", + }, + { + input: func() Querier { + t1 := Table("users").As("u") + return Dialect(dialect.Postgres). + Select(t1.Columns("name", "age")...).From(t1) + }(), + wantQuery: `SELECT "u"."name", "u"."age" FROM "users" AS "u"`, + }, + { + input: func() Querier { + t1 := Dialect(dialect.Postgres). + Table("users").As("u") + return Dialect(dialect.Postgres). + Select(t1.Columns("name", "age")...).From(t1) + }(), + wantQuery: `SELECT "u"."name", "u"."age" FROM "users" AS "u"`, + }, + { + input: func() Querier { + t1 := Table("users").As("u") + t2 := Select().From(Table("groups")).Where(EQ("user_id", 10)).As("g") + return Select(t1.C("id"), t2.C("name")). + From(t1). + Join(t2). + On(t1.C("id"), t2.C("user_id")) + }(), + wantQuery: "SELECT `u`.`id`, `g`.`name` FROM `users` AS `u` JOIN (SELECT * FROM `groups` WHERE `user_id` = ?) AS `g` ON `u`.`id` = `g`.`user_id`", + wantArgs: []any{10}, + }, + { + input: func() Querier { + d := Dialect(dialect.Postgres) + t1 := d.Table("users").As("u") + t2 := d.Select().From(Table("groups")).Where(EQ("user_id", 10)).As("g") + return d.Select(t1.C("id"), t2.C("name")). + From(t1). + Join(t2). + On(t1.C("id"), t2.C("user_id")) + }(), + wantQuery: `SELECT "u"."id", "g"."name" FROM "users" AS "u" JOIN (SELECT * FROM "groups" WHERE "user_id" = $1) AS "g" ON "u"."id" = "g"."user_id"`, + wantArgs: []any{10}, + }, + { + input: func() Querier { + t1 := Table("users") + t2 := Table("groups") + t3 := Table("user_groups") + return Select(t1.C("*")).From(t1). + Join(t3).On(t1.C("id"), t3.C("user_id")). + Join(t2).On(t2.C("id"), t3.C("group_id")) + }(), + wantQuery: "SELECT `users`.* FROM `users` JOIN `user_groups` AS `t1` ON `users`.`id` = `t1`.`user_id` JOIN `groups` AS `t2` ON `t2`.`id` = `t1`.`group_id`", + }, + { + input: func() Querier { + d := Dialect(dialect.Postgres) + t1 := d.Table("users") + t2 := d.Table("groups") + t3 := d.Table("user_groups") + return d.Select(t1.C("*")).From(t1). + Join(t3).On(t1.C("id"), t3.C("user_id")). + Join(t2).On(t2.C("id"), t3.C("group_id")) + }(), + wantQuery: `SELECT "users".* FROM "users" JOIN "user_groups" AS "t1" ON "users"."id" = "t1"."user_id" JOIN "groups" AS "t2" ON "t2"."id" = "t1"."group_id"`, + }, + { + input: func() Querier { + selector := Select().Where(Or(EQ("name", "foo"), EQ("name", "bar"))) + return Delete("users").FromSelect(selector) + }(), + wantQuery: "DELETE FROM `users` WHERE `name` = ? OR `name` = ?", + wantArgs: []any{"foo", "bar"}, + }, + { + input: func() Querier { + d := Dialect(dialect.Postgres) + selector := d.Select().Where(Or(EQ("name", "foo"), EQ("name", "bar"))) + return d.Delete("users").FromSelect(selector) + }(), + wantQuery: `DELETE FROM "users" WHERE "name" = $1 OR "name" = $2`, + wantArgs: []any{"foo", "bar"}, + }, + { + input: func() Querier { + selector := Select().From(Table("users")).As("t") + return selector.Select(selector.C("name")) + }(), + wantQuery: "SELECT `t`.`name` FROM `users`", + }, + { + input: func() Querier { + selector := Dialect(dialect.Postgres). + Select().From(Table("users")).As("t") + return selector.Select(selector.C("name")) + }(), + wantQuery: `SELECT "t"."name" FROM "users"`, + }, + { + input: func() Querier { + selector := Select().From(Table("groups")).Where(EQ("name", "foo")) + return Delete("users").FromSelect(selector) + }(), + wantQuery: "DELETE FROM `groups` WHERE `name` = ?", + wantArgs: []any{"foo"}, + }, + { + input: func() Querier { + d := Dialect(dialect.Postgres) + selector := d.Select().From(Table("groups")).Where(EQ("name", "foo")) + return d.Delete("users").FromSelect(selector) + }(), + wantQuery: `DELETE FROM "groups" WHERE "name" = $1`, + wantArgs: []any{"foo"}, + }, + { + input: func() Querier { + selector := Select() + return Delete("users").FromSelect(selector) + }(), + wantQuery: "DELETE FROM `users`", + }, + { + input: func() Querier { + d := Dialect(dialect.Postgres) + selector := d.Select() + return d.Delete("users").FromSelect(selector) + }(), + wantQuery: `DELETE FROM "users"`, + }, + { + input: Select(). + From(Table("users")). + Where(Not(And(EQ("name", "foo"), EQ("age", "bar")))), + wantQuery: "SELECT * FROM `users` WHERE NOT (`name` = ? AND `age` = ?)", + wantArgs: []any{"foo", "bar"}, + }, + { + input: Dialect(dialect.Postgres). + Select(). + From(Table("users")). + Where(Not(And(EQ("name", "foo"), EQ("age", "bar")))), + wantQuery: `SELECT * FROM "users" WHERE NOT ("name" = $1 AND "age" = $2)`, + wantArgs: []any{"foo", "bar"}, + }, + { + input: Select(). + From(Table("users")). + Where(Or(EqualFold("name", "BAR"), EqualFold("name", "BAZ"))), + wantQuery: "SELECT * FROM `users` WHERE LOWER(`name`) = ? OR LOWER(`name`) = ?", + wantArgs: []any{"bar", "baz"}, + }, + { + input: Dialect(dialect.Postgres). + Select(). + From(Table("users")). + Where(Or(EqualFold("name", "BAR"), EqualFold("name", "BAZ"))), + wantQuery: `SELECT * FROM "users" WHERE "name" ILIKE $1 OR "name" ILIKE $2`, + wantArgs: []any{"bar", "baz"}, + }, + { + input: Dialect(dialect.Postgres). + Select(). + From(Table("users")). + Where(Or(EqualFold("name", "BAR%"), EqualFold("name", "%BAZ"))), + wantQuery: `SELECT * FROM "users" WHERE "name" ILIKE $1 OR "name" ILIKE $2`, + wantArgs: []any{"bar\\%", "\\%baz"}, + }, + { + input: Dialect(dialect.Postgres). + Select(). + From(Table("users")). + Where(Or(EqualFold("name", "BAR\\"), EqualFold("name", "\\BAZ"))), + wantQuery: `SELECT * FROM "users" WHERE "name" ILIKE $1 OR "name" ILIKE $2`, + wantArgs: []any{"bar\\\\", "\\\\baz"}, + }, + { + input: Dialect(dialect.MySQL). + Select(). + From(Table("users")). + Where(Or(EqualFold("name", "BAR"), EqualFold("name", "BAZ"))), + wantQuery: "SELECT * FROM `users` WHERE `name` COLLATE utf8mb4_general_ci = ? OR `name` COLLATE utf8mb4_general_ci = ?", + wantArgs: []any{"bar", "baz"}, + }, + { + input: Dialect(dialect.SQLite). + Select(). + From(Table("users")). + Where(And(ContainsFold("name", "Ariel"), ContainsFold("nick", "Bar"))), + wantQuery: "SELECT * FROM `users` WHERE LOWER(`name`) LIKE ? AND LOWER(`nick`) LIKE ?", + wantArgs: []any{"%ariel%", "%bar%"}, + }, + { + input: Dialect(dialect.Postgres). + Select(). + From(Table("users")). + Where(And(ContainsFold("name", "Ariel"), ContainsFold("nick", "Bar"))), + wantQuery: `SELECT * FROM "users" WHERE "name" ILIKE $1 AND "nick" ILIKE $2`, + wantArgs: []any{"%ariel%", "%bar%"}, + }, + { + input: Dialect(dialect.MySQL). + Select(). + From(Table("users")). + Where(And(ContainsFold("name", "Ariel"), ContainsFold("nick", "Bar"))), + wantQuery: "SELECT * FROM `users` WHERE `name` COLLATE utf8mb4_general_ci LIKE ? AND `nick` COLLATE utf8mb4_general_ci LIKE ?", + wantArgs: []any{"%ariel%", "%bar%"}, + }, + { + input: func() Querier { + s1 := Select(). + From(Table("users")). + Where(Not(And(EQ("name", "foo"), EQ("age", "bar")))) + return Queries{With("users_view").As(s1), Select("name").From(Table("users_view"))} + }(), + wantQuery: "WITH `users_view` AS (SELECT * FROM `users` WHERE NOT (`name` = ? AND `age` = ?)) SELECT `name` FROM `users_view`", + wantArgs: []any{"foo", "bar"}, + }, + { + input: func() Querier { + d := Dialect(dialect.Postgres) + s1 := d.Select(). + From(Table("users")). + Where(Not(And(EQ("name", "foo"), EQ("age", "bar")))) + return Queries{d.With("users_view").As(s1), d.Select("name").From(Table("users_view"))} + }(), + wantQuery: `WITH "users_view" AS (SELECT * FROM "users" WHERE NOT ("name" = $1 AND "age" = $2)) SELECT "name" FROM "users_view"`, + wantArgs: []any{"foo", "bar"}, + }, + { + input: func() Querier { + s1 := Select().From(Table("users")).Where(Not(And(EQ("name", "foo"), EQ("age", "bar")))).As("users_view") + return Select("name").From(s1) + }(), + wantQuery: "SELECT `name` FROM (SELECT * FROM `users` WHERE NOT (`name` = ? AND `age` = ?)) AS `users_view`", + wantArgs: []any{"foo", "bar"}, + }, + { + input: func() Querier { + d := Dialect(dialect.Postgres) + s1 := d.Select().From(Table("users")).Where(Not(And(EQ("name", "foo"), EQ("age", "bar")))).As("users_view") + return d.Select("name").From(s1) + }(), + wantQuery: `SELECT "name" FROM (SELECT * FROM "users" WHERE NOT ("name" = $1 AND "age" = $2)) AS "users_view"`, + wantArgs: []any{"foo", "bar"}, + }, + { + input: func() Querier { + t1 := Table("users") + return Select(). + From(t1). + Where(In(t1.C("id"), Select("owner_id").From(Table("pets")).Where(EQ("name", "pedro")))) + }(), + wantQuery: "SELECT * FROM `users` WHERE `users`.`id` IN (SELECT `owner_id` FROM `pets` WHERE `name` = ?)", + wantArgs: []any{"pedro"}, + }, + { + input: func() Querier { + t1 := Table("users") + return Dialect(dialect.Postgres). + Select(). + From(t1). + Where(In(t1.C("id"), Select("owner_id").From(Table("pets")).Where(EQ("name", "pedro")))) + }(), + wantQuery: `SELECT * FROM "users" WHERE "users"."id" IN (SELECT "owner_id" FROM "pets" WHERE "name" = $1)`, + wantArgs: []any{"pedro"}, + }, + { + input: func() Querier { + t1 := Table("users") + return Select(). + From(t1). + Where(Not(In(t1.C("id"), Select("owner_id").From(Table("pets")).Where(EQ("name", "pedro"))))) + }(), + wantQuery: "SELECT * FROM `users` WHERE NOT (`users`.`id` IN (SELECT `owner_id` FROM `pets` WHERE `name` = ?))", + wantArgs: []any{"pedro"}, + }, + { + input: func() Querier { + t1 := Table("users") + return Dialect(dialect.Postgres). + Select(). + From(t1). + Where(Not(In(t1.C("id"), Select("owner_id").From(Table("pets")).Where(EQ("name", "pedro"))))) + }(), + wantQuery: `SELECT * FROM "users" WHERE NOT ("users"."id" IN (SELECT "owner_id" FROM "pets" WHERE "name" = $1))`, + wantArgs: []any{"pedro"}, + }, + { + input: Select().Count().From(Table("users")), + wantQuery: "SELECT COUNT(*) FROM `users`", + }, + { + input: Dialect(dialect.Postgres). + Select().Count().From(Table("users")), + wantQuery: `SELECT COUNT(*) FROM "users"`, + }, + { + input: Select().Count(Distinct("id")).From(Table("users")), + wantQuery: "SELECT COUNT(DISTINCT `id`) FROM `users`", + }, + { + input: Dialect(dialect.Postgres). + Select().Count(Distinct("id")).From(Table("users")), + wantQuery: `SELECT COUNT(DISTINCT "id") FROM "users"`, + }, + { + input: func() Querier { + t1 := Table("users") + t2 := Select().From(Table("groups")) + t3 := Select().Count().From(t1).Join(t1).On(t2.C("id"), t1.C("blocked_id")) + return t3.Count(Distinct(t3.Columns("id", "name")...)) + }(), + wantQuery: "SELECT COUNT(DISTINCT `t1`.`id`, `t1`.`name`) FROM `users` AS `t1` JOIN `users` AS `t1` ON `groups`.`id` = `t1`.`blocked_id`", + }, + { + input: func() Querier { + d := Dialect(dialect.Postgres) + t1 := d.Table("users") + t2 := d.Select().From(Table("groups")) + t3 := d.Select().Count().From(t1).Join(t1).On(t2.C("id"), t1.C("blocked_id")) + return t3.Count(Distinct(t3.Columns("id", "name")...)) + }(), + wantQuery: `SELECT COUNT(DISTINCT "t1"."id", "t1"."name") FROM "users" AS "t1" JOIN "users" AS "t1" ON "groups"."id" = "t1"."blocked_id"`, + }, + { + input: Select(Sum("age"), Min("age")).From(Table("users")), + wantQuery: "SELECT SUM(`age`), MIN(`age`) FROM `users`", + }, + { + input: Dialect(dialect.Postgres). + Select(Sum("age"), Min("age")). + From(Table("users")), + wantQuery: `SELECT SUM("age"), MIN("age") FROM "users"`, + }, + { + input: func() Querier { + t1 := Table("users").As("u") + return Select(As(Max(t1.C("age")), "max_age")).From(t1) + }(), + wantQuery: "SELECT MAX(`u`.`age`) AS `max_age` FROM `users` AS `u`", + }, + { + input: func() Querier { + t1 := Table("users").As("u") + return Dialect(dialect.Postgres). + Select(As(Max(t1.C("age")), "max_age")). + From(t1) + }(), + wantQuery: `SELECT MAX("u"."age") AS "max_age" FROM "users" AS "u"`, + }, + { + input: Select("name", Count("*")). + From(Table("users")). + GroupBy("name"), + wantQuery: "SELECT `name`, COUNT(*) FROM `users` GROUP BY `name`", + }, + { + input: Dialect(dialect.Postgres). + Select("name", Count("*")). + From(Table("users")). + GroupBy("name"), + wantQuery: `SELECT "name", COUNT(*) FROM "users" GROUP BY "name"`, + }, + { + input: Select("name", Count("*")). + From(Table("users")). + GroupBy("name"). + OrderBy("name"), + wantQuery: "SELECT `name`, COUNT(*) FROM `users` GROUP BY `name` ORDER BY `name`", + }, + { + input: Dialect(dialect.Postgres). + Select("name", Count("*")). + From(Table("users")). + GroupBy("name"). + OrderBy("name"), + wantQuery: `SELECT "name", COUNT(*) FROM "users" GROUP BY "name" ORDER BY "name"`, + }, + { + input: Select("name", "age", Count("*")). + From(Table("users")). + GroupBy("name", "age"). + OrderBy(Desc("name"), "age"), + wantQuery: "SELECT `name`, `age`, COUNT(*) FROM `users` GROUP BY `name`, `age` ORDER BY `name` DESC, `age`", + }, + { + input: Dialect(dialect.Postgres). + Select("name", "age", Count("*")). + From(Table("users")). + GroupBy("name", "age"). + OrderBy(Desc("name"), "age"), + wantQuery: `SELECT "name", "age", COUNT(*) FROM "users" GROUP BY "name", "age" ORDER BY "name" DESC, "age"`, + }, + { + input: Select("*"). + From(Table("users")). + Limit(1), + wantQuery: "SELECT * FROM `users` LIMIT 1", + }, + { + input: Dialect(dialect.Postgres). + Select("*"). + From(Table("users")). + Limit(1), + wantQuery: `SELECT * FROM "users" LIMIT 1`, + }, + { + input: Select("age").Distinct().From(Table("users")), + wantQuery: "SELECT DISTINCT `age` FROM `users`", + }, + { + input: Dialect(dialect.Postgres). + Select("age"). + Distinct(). + From(Table("users")), + wantQuery: `SELECT DISTINCT "age" FROM "users"`, + }, + { + input: Select("age", "name").From(Table("users")).Distinct().OrderBy("name"), + wantQuery: "SELECT DISTINCT `age`, `name` FROM `users` ORDER BY `name`", + }, + { + input: Dialect(dialect.Postgres). + Select("age", "name"). + From(Table("users")). + Distinct(). + OrderBy("name"), + wantQuery: `SELECT DISTINCT "age", "name" FROM "users" ORDER BY "name"`, + }, + { + input: Select("age").From(Table("users")).Where(EQ("name", "foo")).Or().Where(EQ("name", "bar")), + wantQuery: "SELECT `age` FROM `users` WHERE `name` = ? OR `name` = ?", + wantArgs: []any{"foo", "bar"}, + }, + { + input: Dialect(dialect.Postgres). + Select("age"). + From(Table("users")). + Where(EQ("name", "foo")).Or().Where(EQ("name", "bar")), + wantQuery: `SELECT "age" FROM "users" WHERE "name" = $1 OR "name" = $2`, + wantArgs: []any{"foo", "bar"}, + }, + { + input: Queries{With("users_view").As(Select().From(Table("users"))), Select().From(Table("users_view"))}, + wantQuery: "WITH `users_view` AS (SELECT * FROM `users`) SELECT * FROM `users_view`", + }, + { + input: func() Querier { + base := Select("*").From(Table("groups")) + return Queries{With("groups").As(base.Clone().Where(EQ("name", "bar"))), base.Select("age")} + }(), + wantQuery: "WITH `groups` AS (SELECT * FROM `groups` WHERE `name` = ?) SELECT `age` FROM `groups`", + wantArgs: []any{"bar"}, + }, + { + input: SelectExpr(Raw("1")), + wantQuery: "SELECT 1", + }, + { + input: Select("*").From(SelectExpr(Raw("1")).As("s")), + wantQuery: "SELECT * FROM (SELECT 1) AS `s`", + }, + { + input: func() Querier { + builder := Dialect(dialect.Postgres) + t1 := builder.Table("groups") + t2 := builder.Table("users") + t3 := builder.Table("user_groups") + t4 := builder.Select(t3.C("id")). + From(t3). + Join(t2). + On(t3.C("id"), t2.C("id2")). + Where(EQ(t2.C("id"), "baz")) + return builder.Select(). + From(t1). + Join(t4). + On(t1.C("id"), t4.C("id")).Limit(1) + }(), + wantQuery: `SELECT * FROM "groups" JOIN (SELECT "user_groups"."id" FROM "user_groups" JOIN "users" AS "t1" ON "user_groups"."id" = "t1"."id2" WHERE "t1"."id" = $1) AS "t1" ON "groups"."id" = "t1"."id" LIMIT 1`, + wantArgs: []any{"baz"}, + }, + { + input: func() Querier { + t1 := Table("users") + return Dialect(dialect.Postgres). + Select(). + From(t1). + Where(CompositeGT(t1.Columns("id", "name"), 1, "Ariel")) + }(), + wantQuery: `SELECT * FROM "users" WHERE ("users"."id", "users"."name") > ($1, $2)`, + wantArgs: []any{1, "Ariel"}, + }, + { + input: func() Querier { + t1 := Table("users") + return Dialect(dialect.Postgres). + Select(). + From(t1). + Where(And(EQ("name", "Ariel"), CompositeGT(t1.Columns("id", "name"), 1, "Ariel"))) + }(), + wantQuery: `SELECT * FROM "users" WHERE "name" = $1 AND ("users"."id", "users"."name") > ($2, $3)`, + wantArgs: []any{"Ariel", 1, "Ariel"}, + }, + { + input: func() Querier { + t1 := Table("users") + return Dialect(dialect.Postgres). + Select(). + From(t1). + Where(And(EQ("name", "Ariel"), Or(EQ("surname", "Doe"), CompositeGT(t1.Columns("id", "name"), 1, "Ariel")))) + }(), + wantQuery: `SELECT * FROM "users" WHERE "name" = $1 AND ("surname" = $2 OR ("users"."id", "users"."name") > ($3, $4))`, + wantArgs: []any{"Ariel", "Doe", 1, "Ariel"}, + }, + { + input: func() Querier { + t1 := Table("users") + return Dialect(dialect.Postgres). + Select(). + From(Table("users")). + Where(And(EQ("name", "Ariel"), CompositeLT(t1.Columns("id", "name"), 1, "Ariel"))) + }(), + wantQuery: `SELECT * FROM "users" WHERE "name" = $1 AND ("users"."id", "users"."name") < ($2, $3)`, + wantArgs: []any{"Ariel", 1, "Ariel"}, + }, + { + input: Select(). + From(Table("pragma_table_info('t1')").Unquote()). + OrderBy("pk"), + wantQuery: "SELECT * FROM pragma_table_info('t1') ORDER BY `pk`", + }, + { + input: Dialect(dialect.Postgres). + Select("*"). + From(Table("users")). + Where(Or( + And(EQ("id", 1), InInts("group_id", 2, 3)), + And(EQ("id", 2), InValues("group_id", 4, 5)), + )). + Where(And( + Or(EQ("a", "a"), And(EQ("b", "b"), EQ("c", "c"))), + Not(Or(IsNull("d"), NotNull("e"))), + )). + Or(). + Where(And(NEQ("f", "f"), NEQ("g", "g"))), + wantQuery: strings.NewReplacer("\n", "", "\t", "").Replace(` + SELECT * FROM "users" + WHERE + ( + (("id" = $1 AND "group_id" IN ($2, $3)) OR ("id" = $4 AND "group_id" IN ($5, $6))) + AND + (("a" = $7 OR ("b" = $8 AND "c" = $9)) AND (NOT ("d" IS NULL OR "e" IS NOT NULL))) + ) + OR ("f" <> $10 AND "g" <> $11)`), + wantArgs: []any{1, 2, 3, 2, 4, 5, "a", "b", "c", "f", "g"}, + }, + { + input: Dialect(dialect.Postgres). + Select("*"). + From(Table("test")). + Where(P(func(b *Builder) { + b.WriteString("nlevel(").Ident("path").WriteByte(')').WriteOp(OpGT).Arg(1) + })), + wantQuery: `SELECT * FROM "test" WHERE nlevel("path") > $1`, + wantArgs: []any{1}, + }, + { + input: Dialect(dialect.Postgres). + Select("*"). + From(Table("test")). + Where(P(func(b *Builder) { + b.WriteString("nlevel(").Ident("path").WriteByte(')').WriteOp(OpGT).Arg(1) + })), + wantQuery: `SELECT * FROM "test" WHERE nlevel("path") > $1`, + wantArgs: []any{1}, + }, + { + input: Select("id").From(Table("users")).Where(ExprP("DATE(last_login_at) >= ?", "2022-05-03")), + wantQuery: "SELECT `id` FROM `users` WHERE DATE(last_login_at) >= ?", + wantArgs: []any{"2022-05-03"}, + }, + { + input: Select("id"). + From(Table("users")). + Where(P(func(b *Builder) { + b.WriteString("DATE(").Ident("last_login_at").WriteString(") >= ").Arg("2022-05-03") + })), + wantQuery: "SELECT `id` FROM `users` WHERE DATE(`last_login_at`) >= ?", + wantArgs: []any{"2022-05-03"}, + }, + { + input: Select("id").From(Table("events")).Where(ExprP("DATE_ADD(date, INTERVAL duration MINUTE) BETWEEN ? AND ?", "2022-05-03", "2022-05-04")), + wantQuery: "SELECT `id` FROM `events` WHERE DATE_ADD(date, INTERVAL duration MINUTE) BETWEEN ? AND ?", + wantArgs: []any{"2022-05-03", "2022-05-04"}, + }, + { + input: Select("id"). + From(Table("events")). + Where(P(func(b *Builder) { + b.WriteString("DATE_ADD(date, INTERVAL duration MINUTE) BETWEEN ").Arg("2022-05-03").WriteString(" AND ").Arg("2022-05-04") + })), + wantQuery: "SELECT `id` FROM `events` WHERE DATE_ADD(date, INTERVAL duration MINUTE) BETWEEN ? AND ?", + wantArgs: []any{"2022-05-03", "2022-05-04"}, + }, + { + input: func() Querier { + t1, t2 := Table("users").Schema("s1"), Table("pets").Schema("s2") + return Select("*"). + From(t1).Join(t2). + OnP(P(func(b *Builder) { + b.Ident(t1.C("id")).WriteOp(OpEQ).Ident(t2.C("owner_id")) + })). + Where(EQ(t2.C("name"), "pedro")) + }(), + wantQuery: "SELECT * FROM `s1`.`users` JOIN `s2`.`pets` AS `t1` ON `s1`.`users`.`id` = `t1`.`owner_id` WHERE `t1`.`name` = ?", + wantArgs: []any{"pedro"}, + }, + { + input: func() Querier { + t1, t2 := Table("users").Schema("s1"), Table("pets").Schema("s2") + sel := Select("*"). + From(t1).Join(t2). + OnP(P(func(b *Builder) { + b.Ident(t1.C("id")).WriteOp(OpEQ).Ident(t2.C("owner_id")) + })). + Where(EQ(t2.C("name"), "pedro")) + sel.SetDialect(dialect.SQLite) + return sel + }(), + wantQuery: "SELECT * FROM `users` JOIN `pets` AS `t1` ON `users`.`id` = `t1`.`owner_id` WHERE `t1`.`name` = ?", + wantArgs: []any{"pedro"}, + }, + { + input: Dialect(dialect.Postgres). + Select("*"). + From(Table("users")). + Where(ExprP("name = $1", "pedro")). + Where(P(func(b *Builder) { + b.Join(Expr("name = $2", "pedro")) + })). + Where(EQ("name", "pedro")). + Where( + And( + In( + "id", + Select("owner_id"). + From(Table("pets")). + Where(EQ("name", "luna")), + ), + EQ("active", true), + ), + ), + wantQuery: `SELECT * FROM "users" WHERE ((name = $1 AND name = $2) AND "name" = $3) AND ("id" IN (SELECT "owner_id" FROM "pets" WHERE "name" = $4) AND "active")`, + wantArgs: []any{"pedro", "pedro", "pedro", "luna"}, + }, + { + input: func() Querier { + t1 := Table("users") + return Dialect(dialect.Postgres). + Select(). + From(t1). + Where(ColumnsEQ(t1.C("id1"), t1.C("id2"))). + Where(ColumnsNEQ(t1.C("id1"), t1.C("id2"))). + Where(ColumnsGT(t1.C("id1"), t1.C("id2"))). + Where(ColumnsGTE(t1.C("id1"), t1.C("id2"))). + Where(ColumnsLT(t1.C("id1"), t1.C("id2"))). + Where(ColumnsLTE(t1.C("id1"), t1.C("id2"))) + }(), + wantQuery: strings.ReplaceAll(` +SELECT * FROM "users" +WHERE (((("users"."id1" = "users"."id2" AND "users"."id1" <> "users"."id2") +AND "users"."id1" > "users"."id2") AND "users"."id1" >= "users"."id2") +AND "users"."id1" < "users"."id2") AND "users"."id1" <= "users"."id2"`, "\n", ""), + }, + { + input: Select("name"). + From(Select("name", "age").From(Table("users"))), + wantQuery: "SELECT `name` FROM (SELECT `name`, `age` FROM `users`)", + }, + } + for i, tt := range tests { + t.Run(strconv.Itoa(i), func(t *testing.T) { + query, args := tt.input.Query() + require.Equal(t, tt.wantQuery, query) + require.Equal(t, tt.wantArgs, args) + }) + } +} + +func TestBuilder_Err(t *testing.T) { + b := Select("i-") + require.NoError(t, b.Err()) + b.AddError(fmt.Errorf("invalid")) + require.EqualError(t, b.Err(), "invalid") + b.AddError(fmt.Errorf("unexpected")) + require.EqualError(t, b.Err(), "invalid; unexpected") + b.Where(P(func(builder *Builder) { + builder.AddError(fmt.Errorf("inner")) + })) + _, _ = b.Query() + require.EqualError(t, b.Err(), "invalid; unexpected; inner") +} + +func TestSelector_OrderByExpr(t *testing.T) { + query, args := Select("*"). + From(Table("users")). + Where(GT("age", 28)). + OrderBy("name"). + OrderExpr(Expr("CASE WHEN id=? THEN id WHEN id=? THEN name END DESC", 1, 2)). + Query() + require.Equal(t, "SELECT * FROM `users` WHERE `age` > ? ORDER BY `name`, CASE WHEN id=? THEN id WHEN id=? THEN name END DESC", query) + require.Equal(t, []any{28, 1, 2}, args) + + query, args = Dialect(dialect.Postgres). + Select("*"). + From(Table("users")). + Where(GT("age", 28)). + OrderBy("name"). + OrderExpr(ExprFunc(func(b *Builder) { + b.WriteString("CASE") + b.WriteString(" WHEN ").Ident("id").WriteOp(OpEQ).Arg(1).WriteString(" THEN ").Ident("id") + b.WriteString(" WHEN ").Ident("id").WriteOp(OpEQ).Arg(2).WriteString(" THEN ").Ident("name") + b.WriteString(" END DESC") + })). + Query() + require.Equal(t, `SELECT * FROM "users" WHERE "age" > $1 ORDER BY "name", CASE WHEN "id" = $2 THEN "id" WHEN "id" = $3 THEN "name" END DESC`, query) + require.Equal(t, []any{28, 1, 2}, args) +} + +func TestSelector_ClearOrder(t *testing.T) { + query, args := Select("*"). + From(Table("users")). + OrderBy("name"). + ClearOrder(). + OrderBy("id"). + Query() + require.Equal(t, "SELECT * FROM `users` ORDER BY `id`", query) + require.Empty(t, args) +} + +func TestSelector_SelectExpr(t *testing.T) { + query, args := SelectExpr( + Expr("?", "a"), + ExprFunc(func(b *Builder) { + b.Ident("first_name").WriteOp(OpAdd).Ident("last_name") + }), + ExprFunc(func(b *Builder) { + b.WriteString("COALESCE(").Ident("age").Comma().Arg(0).WriteByte(')') + }), + Expr("?", "b"), + ).From(Table("users")).Query() + require.Equal(t, "SELECT ?, `first_name` + `last_name`, COALESCE(`age`, ?), ? FROM `users`", query) + require.Equal(t, []any{"a", 0, "b"}, args) + + query, args = Dialect(dialect.Postgres). + Select("name"). + AppendSelectExpr( + Expr("age + $1", 1), + ExprFunc(func(b *Builder) { + b.Wrap(func(b *Builder) { + b.WriteString("similarity(").Ident("name").Comma().Arg("A").WriteByte(')') + b.WriteOp(OpAdd) + b.WriteString("similarity(").Ident("desc").Comma().Arg("D").WriteByte(')') + }) + b.WriteString(" AS s") + }), + Expr("rank + $4", 10), + ). + From(Table("users")). + Query() + require.Equal(t, `SELECT "name", age + $1, (similarity("name", $2) + similarity("desc", $3)) AS s, rank + $4 FROM "users"`, query) + require.Equal(t, []any{1, "A", "D", 10}, args) +} + +func TestSelector_Union(t *testing.T) { + query, args := Dialect(dialect.Postgres). + Select("*"). + From(Table("users")). + Where(EQ("active", true)). + Union( + Select("*"). + From(Table("old_users1")). + Where( + And( + EQ("is_active", true), + GT("age", 20), + ), + ), + ). + UnionAll( + Select("*"). + From(Table("old_users2")). + Where( + And( + EQ("is_active", "true"), + LT("age", 18), + ), + ), + ). + Query() + require.Equal(t, `SELECT * FROM "users" WHERE "active" UNION SELECT * FROM "old_users1" WHERE "is_active" AND "age" > $1 UNION ALL SELECT * FROM "old_users2" WHERE "is_active" = $2 AND "age" < $3`, query) + require.Equal(t, []any{20, "true", 18}, args) +} + +func TestSelector_Except(t *testing.T) { + query, args := Dialect(dialect.Postgres). + Select("*"). + From(Table("users")). + Where(EQ("active", true)). + Except( + Select("*"). + From(Table("old_users1")). + Where( + And( + EQ("is_active", true), + GT("age", 20), + ), + ), + ). + ExceptAll( + Select("*"). + From(Table("old_users2")). + Where( + And( + EQ("is_active", "true"), + LT("age", 18), + ), + ), + ). + Query() + require.Equal(t, `SELECT * FROM "users" WHERE "active" EXCEPT SELECT * FROM "old_users1" WHERE "is_active" AND "age" > $1 EXCEPT ALL SELECT * FROM "old_users2" WHERE "is_active" = $2 AND "age" < $3`, query) + require.Equal(t, []any{20, "true", 18}, args) +} + +func TestSelector_Intersect(t *testing.T) { + query, args := Dialect(dialect.Postgres). + Select("*"). + From(Table("users")). + Where(EQ("active", true)). + Intersect( + Select("*"). + From(Table("old_users1")). + Where( + And( + EQ("is_active", true), + GT("age", 20), + ), + ), + ). + IntersectAll( + Select("*"). + From(Table("old_users2")). + Where( + And( + EQ("is_active", "true"), + LT("age", 18), + ), + ), + ). + Query() + require.Equal(t, `SELECT * FROM "users" WHERE "active" INTERSECT SELECT * FROM "old_users1" WHERE "is_active" AND "age" > $1 INTERSECT ALL SELECT * FROM "old_users2" WHERE "is_active" = $2 AND "age" < $3`, query) + require.Equal(t, []any{20, "true", 18}, args) +} + +func TestSelector_SetOperatorWithRecursive(t *testing.T) { + t1, t2, t3 := Table("files"), Table("files"), Table("path") + n := Queries{ + WithRecursive("path", "id", "name", "parent_id"). + As(Select(t1.Columns("id", "name", "parent_id")...). + From(t1). + Where( + And( + IsNull(t1.C("parent_id")), + EQ(t1.C("deleted"), false), + ), + ). + UnionAll( + Select(t2.Columns("id", "name", "parent_id")...). + From(t2). + Join(t3). + On(t2.C("parent_id"), t3.C("id")). + Where( + EQ(t2.C("deleted"), false), + ), + ), + ), + Select(t3.Columns("id", "name", "parent_id")...). + From(t3), + } + query, args := n.Query() + require.Equal(t, "WITH RECURSIVE `path`(`id`, `name`, `parent_id`) AS (SELECT `files`.`id`, `files`.`name`, `files`.`parent_id` FROM `files` WHERE `files`.`parent_id` IS NULL AND NOT `files`.`deleted` UNION ALL SELECT `files`.`id`, `files`.`name`, `files`.`parent_id` FROM `files` JOIN `path` AS `t1` ON `files`.`parent_id` = `t1`.`id` WHERE NOT `files`.`deleted`) SELECT `t1`.`id`, `t1`.`name`, `t1`.`parent_id` FROM `path` AS `t1`", query) + require.Nil(t, args) +} + +func TestBuilderContext(t *testing.T) { + type key string + want := "myval" + ctx := context.WithValue(context.Background(), key("mykey"), want) + sel := Dialect(dialect.Postgres).Select().WithContext(ctx) + if got := sel.Context().Value(key("mykey")).(string); got != want { + t.Fatalf("expected selector context key to be %q but got %q", want, got) + } + if got := sel.Clone().Context().Value(key("mykey")).(string); got != want { + t.Fatalf("expected cloned selector context key to be %q but got %q", want, got) + } +} + +type point struct { + xy []float64 + *testing.T +} + +// FormatParam implements the sql.ParamFormatter interface. +func (p point) FormatParam(placeholder string, info *StmtInfo) string { + require.Equal(p.T, dialect.MySQL, info.Dialect) + return "ST_GeomFromWKB(" + placeholder + ")" +} + +// Value implements the driver.Valuer interface. +func (p point) Value() (driver.Value, error) { + return p.xy, nil +} + +func TestParamFormatter(t *testing.T) { + p := point{xy: []float64{1, 2}, T: t} + query, args := Dialect(dialect.MySQL). + Select(). + From(Table("users")). + Where(EQ("point", p)). + Query() + require.Equal(t, "SELECT * FROM `users` WHERE `point` = ST_GeomFromWKB(?)", query) + require.Equal(t, p, args[0]) +} + +func TestSelectWithLock(t *testing.T) { + query, args := Dialect(dialect.MySQL). + Select(). + From(Table("users")). + Where(EQ("id", 1)). + ForUpdate(). + Query() + require.Equal(t, "SELECT * FROM `users` WHERE `id` = ? FOR UPDATE", query) + require.Equal(t, 1, args[0]) + + query, args = Dialect(dialect.Postgres). + Select(). + From(Table("users")). + Where(EQ("id", 1)). + ForUpdate(WithLockAction(NoWait)). + Query() + require.Equal(t, `SELECT * FROM "users" WHERE "id" = $1 FOR UPDATE NOWAIT`, query) + require.Equal(t, 1, args[0]) + + users, pets := Table("users"), Table("pets") + query, args = Dialect(dialect.Postgres). + Select(). + From(pets). + Join(users). + On(pets.C("owner_id"), users.C("id")). + Where(EQ("id", 20)). + ForUpdate( + WithLockAction(SkipLocked), + WithLockTables("pets"), + ). + Query() + require.Equal(t, `SELECT * FROM "pets" JOIN "users" AS "t1" ON "pets"."owner_id" = "t1"."id" WHERE "id" = $1 FOR UPDATE OF "pets" SKIP LOCKED`, query) + require.Equal(t, 20, args[0]) + + query, args = Dialect(dialect.MySQL). + Select(). + From(Table("users")). + Where(EQ("id", 20)). + ForShare(WithLockClause("LOCK IN SHARE MODE")). + Query() + require.Equal(t, "SELECT * FROM `users` WHERE `id` = ? LOCK IN SHARE MODE", query) + require.Equal(t, 20, args[0]) + + s := Dialect(dialect.SQLite). + Select(). + From(Table("users")). + Where(EQ("id", 1)). + ForUpdate() + s.Query() + require.EqualError(t, s.Err(), "sql: SELECT .. FOR UPDATE/SHARE not supported in SQLite") +} + +func TestSelector_UnionOrderBy(t *testing.T) { + table := Table("users") + query, _ := Dialect(dialect.Postgres). + Select("*"). + From(table). + Where(EQ("active", true)). + Union(Select("*").From(Table("old_users1"))). + OrderBy(table.C("whatever")). + Query() + require.Equal(t, `SELECT * FROM "users" WHERE "active" UNION SELECT * FROM "old_users1" ORDER BY "users"."whatever"`, query) +} + +// TestUnionAllFunc verifies the package-level UnionAll (and friends) wrap every +// branch in parentheses on MySQL/Postgres, enabling per-branch ORDER BY / LIMIT +// and an optional outer ORDER BY / LIMIT on the union result via a wrapping SELECT. +// On SQLite the parentheses and per-branch ORDER BY / LIMIT / OFFSET are omitted +// since SQLite does not support them inside a compound SELECT. +func TestUnionAllFunc(t *testing.T) { + t.Run("BothBranchesParenthesized", func(t *testing.T) { + // Default dialect (MySQL) — parens required. + migSel := Select("id", "kind"). + From(Table("migration_deployments")). + OrderBy(Desc("end_time"), Desc("id")). + Limit(20) + schemaSel := Select("id", "kind"). + From(Table("schema_deployments")). + OrderBy(Desc("end_time"), Desc("id")). + Limit(20) + query, args := UnionAll(migSel, schemaSel).Query() + require.Equal(t, + "(SELECT `id`, `kind` FROM `migration_deployments` ORDER BY `end_time` DESC, `id` DESC LIMIT 20) UNION ALL (SELECT `id`, `kind` FROM `schema_deployments` ORDER BY `end_time` DESC, `id` DESC LIMIT 20)", + query, + ) + require.Nil(t, args) + }) + + t.Run("WithOuterOrderAndLimit", func(t *testing.T) { + // Per-branch LIMIT push-down + outer ORDER BY / LIMIT on the union result, + // composed idiomatically via With(...).As(UnionAll(...)). + migSel := Select("id", "kind").From(Table("t1")).OrderBy(Desc("end_time")).Limit(20) + schemaSel := Select("id", "kind").From(Table("t2")).OrderBy(Desc("end_time")).Limit(20) + outer := Select("id", "kind").From(Table("all")).OrderBy(Desc("end_time")).Limit(10).Offset(0) + query, _ := Queries{ + With("all").As(UnionAll(migSel, schemaSel)), + outer, + }.Query() + require.Equal(t, + "WITH `all` AS ((SELECT `id`, `kind` FROM `t1` ORDER BY `end_time` DESC LIMIT 20) UNION ALL (SELECT `id`, `kind` FROM `t2` ORDER BY `end_time` DESC LIMIT 20)) SELECT `id`, `kind` FROM `all` ORDER BY `end_time` DESC LIMIT 10 OFFSET 0", + query, + ) + }) + + t.Run("Postgres", func(t *testing.T) { + // Postgres supports parenthesized branches (standard SQL) — same output shape + // as MySQL but with double-quoted identifiers. Dialect is inferred from selectors. + migSel := Dialect(dialect.Postgres).Select("id").From(Table("t1")).Limit(5) + schemaSel := Dialect(dialect.Postgres).Select("id").From(Table("t2")).Limit(5) + query, _ := UnionAll(migSel, schemaSel).Query() + require.Equal(t, `(SELECT "id" FROM "t1" LIMIT 5) UNION ALL (SELECT "id" FROM "t2" LIMIT 5)`, query) + }) + + t.Run("SQLite", func(t *testing.T) { + // SQLite does not support parenthesized compound-select branches. + // Per-branch ORDER BY / LIMIT / OFFSET are silently dropped; branches + // are emitted without wrapping parens. + // The dialect is inferred from the selectors — no explicit SetDialect needed. + migSel := Dialect(dialect.SQLite).Select("id").From(Table("t1")).OrderBy(Desc("end_time")).Limit(20) + schemaSel := Dialect(dialect.SQLite).Select("id").From(Table("t2")).OrderBy(Desc("end_time")).Limit(20) + query, _ := UnionAll(migSel, schemaSel).Query() + // No parens, no ORDER BY, no LIMIT on the branches. + require.Equal(t, "SELECT `id` FROM `t1` UNION ALL SELECT `id` FROM `t2`", query) + }) + + t.Run("Union", func(t *testing.T) { + query, _ := Union( + Select("*").From(Table("t1")).OrderBy("x"), + Select("*").From(Table("t2")).OrderBy("x"), + ).Query() + require.Equal(t, "(SELECT * FROM `t1` ORDER BY `x`) UNION (SELECT * FROM `t2` ORDER BY `x`)", query) + }) +} + +func TestUpdateBuilder_SetExpr(t *testing.T) { + d := Dialect(dialect.Postgres) + excluded := d.Table("excluded") + query, args := d.Update("users"). + Set("name", "Ariel"). + Set("active", Expr("NOT(active)")). + Set("age", Expr(excluded.C("age"))). + Set("x", ExprFunc(func(b *Builder) { + b.WriteString(excluded.C("x")).WriteString(" || ' (formerly ' || ").Ident("x").WriteString(" || ')'") + })). + Set("y", ExprFunc(func(b *Builder) { + b.Arg("~").WriteOp(OpAdd).WriteString(excluded.C("y")).WriteOp(OpAdd).Arg("~") + })). + Query() + require.Equal(t, `UPDATE "users" SET "name" = $1, "active" = NOT(active), "age" = "excluded"."age", "x" = "excluded"."x" || ' (formerly ' || "x" || ')', "y" = $2 + "excluded"."y" + $3`, query) + require.Equal(t, []any{"Ariel", "~", "~"}, args) +} + +func TestInsert_OnConflict(t *testing.T) { + t.Run("Postgres", func(t *testing.T) { // And SQLite. + query, args := Dialect(dialect.Postgres). + Insert("users"). + Columns("id", "email", "creation_time"). + Values("1", "user@example.com", 1633279231). + OnConflict( + ConflictColumns("email"), + ConflictWhere(EQ("name", "Ariel")), + ResolveWithNewValues(), + // Update all new values excepts id field. + ResolveWith(func(u *UpdateSet) { + u.SetIgnore("id") + u.SetIgnore("creation_time") + u.Add("version", 1) + }), + UpdateWhere(NEQ("updated_at", 0)), + ). + Query() + require.Equal(t, `INSERT INTO "users" ("id", "email", "creation_time") VALUES ($1, $2, $3) ON CONFLICT ("email") WHERE "name" = $4 DO UPDATE SET "id" = "users"."id", "email" = "excluded"."email", "creation_time" = "users"."creation_time", "version" = COALESCE("users"."version", 0) + $5 WHERE "users"."updated_at" <> $6`, query) + require.Equal(t, []any{"1", "user@example.com", 1633279231, "Ariel", 1, 0}, args) + + query, args = Dialect(dialect.Postgres). + Insert("users"). + Columns("id", "name"). + Values("1", "Mashraki"). + OnConflict( + ConflictConstraint("users_pkey"), + DoNothing(), + ). + Query() + require.Equal(t, `INSERT INTO "users" ("id", "name") VALUES ($1, $2) ON CONFLICT ON CONSTRAINT "users_pkey" DO NOTHING`, query) + require.Equal(t, []any{"1", "Mashraki"}, args) + + query, args = Dialect(dialect.Postgres). + Insert("users"). + Columns("id"). + Values(1). + OnConflict( + DoNothing(), + ). + Query() + require.Equal(t, `INSERT INTO "users" ("id") VALUES ($1) ON CONFLICT DO NOTHING`, query) + require.Equal(t, []any{1}, args) + + query, args = Dialect(dialect.Postgres). + Insert("users"). + Columns("id"). + Values(1). + OnConflict( + ConflictColumns("id"), + ResolveWithIgnore(), + ). + Query() + require.Equal(t, `INSERT INTO "users" ("id") VALUES ($1) ON CONFLICT ("id") DO UPDATE SET "id" = "users"."id"`, query) + require.Equal(t, []any{1}, args) + + query, args = Dialect(dialect.Postgres). + Insert("users"). + Columns("id", "name"). + Values(1, "Mashraki"). + OnConflict( + ConflictColumns("name"), + ResolveWith(func(s *UpdateSet) { + s.SetExcluded("name") + s.SetNull("created_at") + }), + ). + Query() + require.Equal(t, `INSERT INTO "users" ("id", "name") VALUES ($1, $2) ON CONFLICT ("name") DO UPDATE SET "created_at" = NULL, "name" = "excluded"."name"`, query) + require.Equal(t, []any{1, "Mashraki"}, args) + }) + + t.Run("MySQL", func(t *testing.T) { + query, args := Dialect(dialect.MySQL). + Insert("users"). + Columns("id", "email"). + Values("1", "user@example.com"). + OnConflict( + ResolveWithNewValues(), + ). + Query() + require.Equal(t, "INSERT INTO `users` (`id`, `email`) VALUES (?, ?) ON DUPLICATE KEY UPDATE `id` = VALUES(`id`), `email` = VALUES(`email`)", query) + require.Equal(t, []any{"1", "user@example.com"}, args) + + query, args = Dialect(dialect.MySQL). + Insert("users"). + Columns("id", "email"). + Values("1", "user@example.com"). + OnConflict( + ResolveWithIgnore(), + ). + Query() + require.Equal(t, "INSERT INTO `users` (`id`, `email`) VALUES (?, ?) ON DUPLICATE KEY UPDATE `id` = `users`.`id`, `email` = `users`.`email`", query) + require.Equal(t, []any{"1", "user@example.com"}, args) + + query, args = Dialect(dialect.MySQL). + Insert("users"). + Columns("id", "name"). + Values("1", "Mashraki"). + OnConflict( + ResolveWith(func(s *UpdateSet) { + s.SetExcluded("name") + s.SetNull("created_at") + s.Add("version", 1) + }), + ). + Query() + require.Equal(t, "INSERT INTO `users` (`id`, `name`) VALUES (?, ?) ON DUPLICATE KEY UPDATE `created_at` = NULL, `name` = VALUES(`name`), `version` = COALESCE(`users`.`version`, 0) + ?", query) + require.Equal(t, []any{"1", "Mashraki", 1}, args) + + query, args = Dialect(dialect.MySQL). + Insert("users"). + Columns("name", "rank"). + Values("Mashraki", nil). + OnConflict( + ResolveWithNewValues(), + ResolveWith(func(s *UpdateSet) { + s.Set("id", Expr("LAST_INSERT_ID(`id`)")) + }), + ). + Query() + require.Equal(t, "INSERT INTO `users` (`name`, `rank`) VALUES (?, NULL) ON DUPLICATE KEY UPDATE `name` = VALUES(`name`), `rank` = VALUES(`rank`), `id` = LAST_INSERT_ID(`id`)", query) + require.Equal(t, []any{"Mashraki"}, args) + + query, args = Dialect(dialect.MySQL). + Insert("users"). + Columns("name", "rank"). + Values("Ariel", 10). + Values("Mashraki", nil). + OnConflict( + ResolveWithNewValues(), + ResolveWith(func(s *UpdateSet) { + s.Set("id", Expr("LAST_INSERT_ID(`id`)")) + }), + ). + Query() + require.Equal(t, "INSERT INTO `users` (`name`, `rank`) VALUES (?, ?), (?, NULL) ON DUPLICATE KEY UPDATE `name` = VALUES(`name`), `rank` = VALUES(`rank`), `id` = LAST_INSERT_ID(`id`)", query) + require.Equal(t, []any{"Ariel", 10, "Mashraki"}, args) + }) +} + +func TestEscapePatterns(t *testing.T) { + q, args := Dialect(dialect.MySQL). + Update("users"). + SetNull("name"). + Where( + Or( + HasPrefix("nickname", "%a8m%"), + HasSuffix("nickname", "_alexsn_"), + Contains("nickname", "\\pedro\\"), + ContainsFold("nickname", "%AbcD%efg"), + ), + ). + Query() + require.Equal(t, "UPDATE `users` SET `name` = NULL WHERE `nickname` LIKE ? OR `nickname` LIKE ? OR `nickname` LIKE ? OR `nickname` COLLATE utf8mb4_general_ci LIKE ?", q) + require.Equal(t, []any{"\\%a8m\\%%", "%\\_alexsn\\_", "%\\\\pedro\\\\%", "%\\%abcd\\%efg%"}, args) + + q, args = Dialect(dialect.SQLite). + Update("users"). + SetNull("name"). + Where( + Or( + HasPrefix("nickname", "%a8m%"), + HasSuffix("nickname", "_alexsn_"), + Contains("nickname", "\\pedro\\"), + ContainsFold("nickname", "%AbcD%efg"), + ), + ). + Query() + require.Equal(t, "UPDATE `users` SET `name` = NULL WHERE `nickname` LIKE ? ESCAPE ? OR `nickname` LIKE ? ESCAPE ? OR `nickname` LIKE ? ESCAPE ? OR LOWER(`nickname`) LIKE ? ESCAPE ?", q) + require.Equal(t, []any{"\\%a8m\\%%", "\\", "%\\_alexsn\\_", "\\", "%\\\\pedro\\\\%", "\\", "%\\%abcd\\%efg%", "\\"}, args) + + q, args = Select("*").From(Table("dataset")). + Where(Contains("title", "_第一")).Query() + require.Equal(t, "SELECT * FROM `dataset` WHERE `title` LIKE ?", q) + require.Equal(t, []any{"%\\_第一%"}, args) +} + +func TestReusePredicates(t *testing.T) { + tests := []struct { + p *Predicate + wantQuery string + wantArgs []any + }{ + { + p: EQ("active", false), + wantQuery: `SELECT * FROM "users" WHERE NOT "active"`, + }, + { + p: Or( + EQ("a", "a"), + EQ("b", "b"), + ), + wantQuery: `SELECT * FROM "users" WHERE "a" = $1 OR "b" = $2`, + wantArgs: []any{"a", "b"}, + }, + { + p: Or( + EQ("a", "a"), + In("b"), + ), + wantQuery: `SELECT * FROM "users" WHERE "a" = $1 OR FALSE`, + wantArgs: []any{"a"}, + }, + { + p: And( + EQ("active", true), + HasPrefix("name", "foo"), + HasSuffix("name", "bar"), + Or( + In("id", Select("oid").From(Table("audit"))), + In("id", Select("oid").From(Table("history"))), + ), + ), + wantQuery: `SELECT * FROM "users" WHERE "active" AND "name" LIKE $1 AND "name" LIKE $2 AND ("id" IN (SELECT "oid" FROM "audit") OR "id" IN (SELECT "oid" FROM "history"))`, + wantArgs: []any{"foo%", "%bar"}, + }, + { + p: func() *Predicate { + t1 := Table("groups") + pivot := Table("user_groups") + matches := Select(pivot.C("user_id")). + From(pivot). + Join(t1). + On(pivot.C("group_id"), t1.C("id")). + Where(EQ(t1.C("name"), "ent")) + return And( + GT("balance", 0), + In("id", matches), + GT("balance", 100), + ) + }(), + wantQuery: `SELECT * FROM "users" WHERE "balance" > $1 AND "id" IN (SELECT "user_groups"."user_id" FROM "user_groups" JOIN "groups" AS "t1" ON "user_groups"."group_id" = "t1"."id" WHERE "t1"."name" = $2) AND "balance" > $3`, + wantArgs: []any{0, "ent", 100}, + }, + } + for _, tt := range tests { + query, args := Dialect(dialect.Postgres).Select().From(Table("users")).Where(tt.p).Query() + require.Equal(t, tt.wantQuery, query) + require.Equal(t, tt.wantArgs, args) + query, args = Dialect(dialect.Postgres).Select().From(Table("users")).Where(tt.p).Query() + require.Equal(t, tt.wantQuery, query) + require.Equal(t, tt.wantArgs, args) + } +} + +func TestBoolPredicates(t *testing.T) { + t1, t2 := Table("users"), Table("posts") + query, args := Select(). + From(t1). + Join(t2). + On(t1.C("id"), t2.C("author_id")). + Where( + And( + EQ(t1.C("active"), true), + NEQ(t2.C("deleted"), true), + ), + ). + Query() + require.Nil(t, args) + require.Equal(t, "SELECT * FROM `users` JOIN `posts` AS `t1` ON `users`.`id` = `t1`.`author_id` WHERE `users`.`active` AND NOT `t1`.`deleted`", query) +} + +func TestWindowFunction(t *testing.T) { + posts := Table("posts") + base := Select(posts.Columns("id", "content", "author_id")...). + From(posts). + Where(EQ("active", true)) + with := With("active_posts"). + As(base). + With("selected_posts"). + As( + Select(). + AppendSelect("*"). + AppendSelectExprAs( + RowNumber().PartitionBy("author_id").OrderBy("id").OrderExpr(Expr("f(`s`)")), + "row_number", + ). + From(Table("active_posts")), + ) + query, args := Select("*").From(Table("selected_posts")).Where(LTE("row_number", 2)).Prefix(with).Query() + require.Equal(t, "WITH `active_posts` AS (SELECT `posts`.`id`, `posts`.`content`, `posts`.`author_id` FROM `posts` WHERE `active`), `selected_posts` AS (SELECT *, (ROW_NUMBER() OVER (PARTITION BY `author_id` ORDER BY `id`, f(`s`))) AS `row_number` FROM `active_posts`) SELECT * FROM `selected_posts` WHERE `row_number` <= ?", query) + require.Equal(t, []any{2}, args) +} + +func TestWindowFunction_Select(t *testing.T) { + posts := Table("posts") + q := Select(). + AppendSelect("*"). + AppendSelectExprAs( + Window(func(b *Builder) { + b.WriteString(Sum(posts.C("duration"))) + }).PartitionBy("author_id").OrderBy("id"), "duration"). + From(posts) + + query, args := q.Query() + require.Equal(t, "SELECT *, (SUM(`posts`.`duration`) OVER (PARTITION BY `author_id` ORDER BY `id`)) AS `duration` FROM `posts`", query) + require.Nil(t, args) +} + +func TestSelector_UnqualifiedColumns(t *testing.T) { + t1, t2 := Table("t1"), Table("t2") + s := Select(t1.C("a"), t2.C("b")) + require.Equal(t, []string{"`t1`.`a`", "`t2`.`b`"}, s.SelectedColumns()) + require.Equal(t, []string{"a", "b"}, s.UnqualifiedColumns()) + + d := Dialect(dialect.Postgres) + t1, t2 = d.Table("t1"), d.Table("t2") + s = d.Select(t1.C("a"), t2.C("b")) + require.Equal(t, []string{`"t1"."a"`, `"t2"."b"`}, s.SelectedColumns()) + require.Equal(t, []string{"a", "b"}, s.UnqualifiedColumns()) +} + +func TestUpdateBuilder_OrderBy(t *testing.T) { + u := Dialect(dialect.MySQL).Update("users").Set("id", Expr("`id` + 1")).OrderBy("id") + require.NoError(t, u.Err()) + query, args := u.Query() + require.Nil(t, args) + require.Equal(t, "UPDATE `users` SET `id` = `id` + 1 ORDER BY `id`", query) + + u = Dialect(dialect.Postgres).Update("users").Set("id", Expr("id + 1")).OrderBy("id") + require.Error(t, u.Err()) +} + +func TestUpdateBuilder_WithPrefix(t *testing.T) { + u := Dialect(dialect.MySQL). + Update("users"). + Prefix(ExprFunc(func(b *Builder) { + b.WriteString("SET @i = ").Arg(1).WriteByte(';') + })). + Set("id", Expr("(@i:=@i+1)")). + OrderBy("id") + require.NoError(t, u.Err()) + query, args := u.Query() + require.Equal(t, []any{1}, args) + require.Equal(t, "SET @i = ?; UPDATE `users` SET `id` = (@i:=@i+1) ORDER BY `id`", query) + + u = Dialect(dialect.MySQL). + Update("users"). + Prefix(Expr("SET @i = 1;")). + Set("id", Expr("(@i:=@i+1)")). + OrderBy("id") + require.NoError(t, u.Err()) + query, args = u.Query() + require.Empty(t, args) + require.Equal(t, "SET @i = 1; UPDATE `users` SET `id` = (@i:=@i+1) ORDER BY `id`", query) +} + +func TestMultipleFrom(t *testing.T) { + query, args := Dialect(dialect.Postgres). + Select("items.*", As("ts_rank_cd(search, search_query)", "rank")). + From(Table("items")). + AppendFrom(Table("to_tsquery('neutrino|(dark & matter)')").As("search_query")). + Where(P(func(b *Builder) { + b.WriteString("search @@ search_query") + })). + OrderBy(Desc("rank")). + Query() + require.Empty(t, args) + require.Equal(t, `SELECT items.*, ts_rank_cd(search, search_query) AS "rank" FROM "items", to_tsquery('neutrino|(dark & matter)') AS "search_query" WHERE search @@ search_query ORDER BY "rank" DESC`, query) + + query, args = Dialect(dialect.Postgres). + Select("items.*", As("ts_rank_cd(search, search_query)", "rank")). + From(Table("items")). + AppendFromExpr(Expr("to_tsquery($1) AS search_query", "neutrino|(dark & matter)")). + Where(P(func(b *Builder) { + b.WriteString("search @@ search_query") + })). + Query() + require.Equal(t, []any{"neutrino|(dark & matter)"}, args) + require.Equal(t, `SELECT items.*, ts_rank_cd(search, search_query) AS "rank" FROM "items", to_tsquery($1) AS search_query WHERE search @@ search_query`, query) + + query, args = Dialect(dialect.Postgres). + Select("items.*", As("ts_rank_cd(search, search_query)", "rank")). + From(Table("items")). + Where(EQ("value", 10)). + AppendFromExpr(ExprFunc(func(b *Builder) { + b.WriteString("to_tsquery(").Arg("neutrino|(dark & matter)").WriteString(") AS search_query") + })). + Where(P(func(b *Builder) { + b.WriteString("search @@ search_query") + })). + Query() + require.Equal(t, []any{"neutrino|(dark & matter)", 10}, args) + require.Equal(t, `SELECT items.*, ts_rank_cd(search, search_query) AS "rank" FROM "items", to_tsquery($1) AS search_query WHERE "value" = $2 AND search @@ search_query`, query) +} + +func TestFormattedColumnFromSubQuery(t *testing.T) { + q := Select("*").From(Select("*").AppendSelectExprAs(P(func(b *Builder) { + b.SetDialect(dialect.Postgres) + b.WriteString("calculate_score") + b.Wrap(func(bb *Builder) { + bb.WriteString(Table("table_name").C("field_name")).Comma().Args("test") + }) + }), "score").From(Table("table_name").As("table_name_alias"))) + require.Equal(t, "`table_name_alias`.`score`", q.C("score")) +} + +func TestSelector_HasJoins(t *testing.T) { + s := Select("*").From(Table("t1")) + require.False(t, s.HasJoins()) + s.Join(Table("t2")) + require.True(t, s.HasJoins()) +} + +func TestSelector_JoinedTable(t *testing.T) { + s := Select("*").From(Table("t1")) + t2, ok := s.JoinedTable("t2") + require.False(t, ok) + require.Nil(t, t2) + s.Join(Table("t2").As("t2")) + t2, ok = s.JoinedTable("t2") + require.True(t, ok) + require.Equal(t, "`t2`.`c`", t2.C("c")) + s.LeftJoin(Select().From(Table("t3").As("t3")).Where(EQ("id", 1))) + t3, ok := s.JoinedTable("t3") + require.True(t, ok) + require.Equal(t, "`t3`.`c`", t3.C("c")) +} + +func TestSelector_JoinedTableView(t *testing.T) { + s := Select("*").From(Table("t1")) + t2, ok := s.JoinedTableView("t2") + require.False(t, ok) + require.Nil(t, t2) + s.Join(Table("users").As("t2")) + t2, ok = s.JoinedTableView("t2") + require.True(t, ok) + require.Equal(t, "`t2`.`c`", t2.C("c")) + s.LeftJoin(Select().From(Table("pets").As("t3")).Where(EQ("id", 1)).As("t4")) + t3, ok := s.JoinedTableView("t3") + require.True(t, ok) + require.Equal(t, "`t3`.`c`", t3.C("c")) + t4, ok := s.JoinedTableView("t4") + require.True(t, ok) + require.Equal(t, "`t4`.`c`", t4.C("c")) +} + +func TestSelector_Columns(t *testing.T) { + t.Run("MySQL", func(t *testing.T) { + s := Select("*").From(Table("users")) + require.Equal(t, []string{"`users`.`c`"}, s.Columns("c")) + // Already quoted. + require.Equal(t, []string{"`users`.`c`"}, s.Columns("`c`")) + t2 := Table("t2").As("t2") + s.Join(t2) + // Already quoted. + require.Equal(t, []string{"`t2`.`c1`"}, s.Columns(t2.C("c1"))) + require.Equal(t, []string{"t2.c1"}, s.Columns("t2.c1")) + }) + t.Run("Postgres", func(t *testing.T) { + b := Dialect(dialect.Postgres) + s := b.Select("*").From(Table("users")) + require.Equal(t, []string{`"users"."c"`}, s.Columns("c")) + // Already quoted. + require.Equal(t, []string{`"users"."c"`}, s.Columns(`"c"`)) + t2 := b.Table("t2").As("t2") + s.Join(t2) + // Already quoted. + require.Equal(t, []string{`"t2"."c1"`}, s.Columns(t2.C("c1"))) + require.Equal(t, []string{"t2.c1"}, s.Columns("t2.c1")) + }) +} + +func TestSelector_SelectedColumn(t *testing.T) { + t.Run("MySQL", func(t *testing.T) { + s := Select("*").From(Table("t1")) + require.Empty(t, s.FindSelection("c")) + s.Select("c") + require.Equal(t, []string{"c"}, s.FindSelection("c")) + s.Select(s.C("c")) + require.Equal(t, []string{"`t1`.`c`"}, s.FindSelection("c")) + s.AppendSelectAs(s.C("d"), "e") + require.Equal(t, []string{"e"}, s.FindSelection("e")) + require.Empty(t, s.FindSelection("d")) + t2 := Table("t2").As("t2") + s.Join(t2) + s.Select(t2.C("e"), "t2.e", s.C("e"), "t1.e", "e") + require.Equal(t, []string{"`t2`.`e`", "t2.e", "`t1`.`e`", "t1.e", "e"}, s.FindSelection("e")) + s.AppendSelectExprAs(ExprFunc(func(b *Builder) { + b.S("COUNT(").Ident("post_id").S(")") + }), "post_count") + require.Equal(t, []string{"post_count"}, s.FindSelection("post_count")) + }) + t.Run("Postgres", func(t *testing.T) { + b := Dialect(dialect.Postgres) + s := b.Select("*").From(Table("t1")) + require.Empty(t, s.FindSelection("c")) + s.Select("c") + require.Equal(t, []string{"c"}, s.FindSelection("c")) + s.Select(s.C("c")) + require.Equal(t, []string{`"t1"."c"`}, s.FindSelection("c")) + s.AppendSelectAs(s.C("d"), "e") + require.Equal(t, []string{"e"}, s.FindSelection("e")) + require.Empty(t, s.FindSelection("d")) + t2 := b.Table("t2").As("t2") + s.Join(t2) + s.Select(t2.C("e"), "t2.e", s.C("e"), "t1.e", "e") + require.Equal(t, []string{`"t2"."e"`, "t2.e", `"t1"."e"`, "t1.e", "e"}, s.FindSelection("e")) + }) +} + +func TestColumnsHasPrefix(t *testing.T) { + t.Run("MySQL", func(t *testing.T) { + query, args := Dialect(dialect.MySQL). + Select("*").From(Table("t1")).Where(ColumnsHasPrefix("a", "b")).Query() + require.Equal(t, "SELECT * FROM `t1` WHERE `a` LIKE CONCAT(REPLACE(REPLACE(`b`, '_', '\\_'), '%', '\\%'), '%')", query) + require.Empty(t, args) + }) + t.Run("Postgres", func(t *testing.T) { + query, args := Dialect(dialect.Postgres). + Select("*").From(Table("t1")).Where(ColumnsHasPrefix("a", "b")).Query() + require.Equal(t, `SELECT * FROM "t1" WHERE "a" LIKE (REPLACE(REPLACE("b", '_', '\_'), '%', '\%') || '%')`, query) + require.Empty(t, args) + }) + t.Run("SQLite", func(t *testing.T) { + query, args := Dialect(dialect.SQLite). + Select("*").From(Table("t1")).Where(ColumnsHasPrefix("a", "b")).Query() + require.Equal(t, "SELECT * FROM `t1` WHERE `a` LIKE (REPLACE(REPLACE(`b`, '_', '\\_'), '%', '\\%') || '%') ESCAPE ?", query) + require.Equal(t, []any{`\`}, args) + }) +} diff --git a/dialect/sql/driver.go b/dialect/sql/driver.go new file mode 100644 index 0000000..1c4c9bd --- /dev/null +++ b/dialect/sql/driver.go @@ -0,0 +1,305 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package sql + +import ( + "context" + "database/sql" + "database/sql/driver" + "errors" + "fmt" + "strconv" + "strings" + + "entgo.io/ent/dialect" +) + +// Driver is a dialect.Driver implementation for SQL based databases. +type Driver struct { + Conn + dialect string +} + +// NewDriver creates a new Driver with the given Conn and dialect. +func NewDriver(dialect string, c Conn) *Driver { + return &Driver{dialect: dialect, Conn: c} +} + +// Open wraps the database/sql.Open method and returns a dialect.Driver that implements the an ent/dialect.Driver interface. +func Open(dialect, source string) (*Driver, error) { + db, err := sql.Open(dialect, source) + if err != nil { + return nil, err + } + return NewDriver(dialect, Conn{db, dialect}), nil +} + +// OpenDB wraps the given database/sql.DB method with a Driver. +func OpenDB(dialect string, db *sql.DB) *Driver { + return NewDriver(dialect, Conn{db, dialect}) +} + +// DB returns the underlying *sql.DB instance. +func (d Driver) DB() *sql.DB { + return d.ExecQuerier.(*sql.DB) +} + +// Dialect implements the dialect.Dialect method. +func (d Driver) Dialect() string { + // If the underlying driver is wrapped with a telemetry driver. + for _, name := range []string{dialect.MySQL, dialect.SQLite, dialect.Postgres} { + if strings.HasPrefix(d.dialect, name) { + return name + } + } + return d.dialect +} + +// Tx starts and returns a transaction. +func (d *Driver) Tx(ctx context.Context) (dialect.Tx, error) { + return d.BeginTx(ctx, nil) +} + +// BeginTx starts a transaction with options. +func (d *Driver) BeginTx(ctx context.Context, opts *TxOptions) (dialect.Tx, error) { + tx, err := d.DB().BeginTx(ctx, opts) + if err != nil { + return nil, err + } + return &Tx{ + Conn: Conn{tx, d.dialect}, + Tx: tx, + }, nil +} + +// Close closes the underlying connection. +func (d *Driver) Close() error { return d.DB().Close() } + +// Tx implements dialect.Tx interface. +type Tx struct { + Conn + driver.Tx +} + +// ctyVarsKey is the key used for attaching and reading the context variables. +type ctxVarsKey struct{} + +// sessionVars holds sessions/transactions variables to set before every statement. +type sessionVars struct { + vars []struct{ k, v string } +} + +// WithVar returns a new context that holds the session variable to be executed before every query. +func WithVar(ctx context.Context, name, value string) context.Context { + sv, _ := ctx.Value(ctxVarsKey{}).(sessionVars) + sv.vars = append(sv.vars, struct { + k, v string + }{ + k: name, + v: value, + }) + return context.WithValue(ctx, ctxVarsKey{}, sv) +} + +// VarFromContext returns the session variable value from the context. +func VarFromContext(ctx context.Context, name string) (string, bool) { + sv, _ := ctx.Value(ctxVarsKey{}).(sessionVars) + for _, s := range sv.vars { + if s.k == name { + return s.v, true + } + } + return "", false +} + +// WithIntVar calls WithVar with the string representation of the value. +func WithIntVar(ctx context.Context, name string, value int) context.Context { + return WithVar(ctx, name, strconv.Itoa(value)) +} + +// ExecQuerier wraps the standard Exec and Query methods. +type ExecQuerier interface { + ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) + QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) +} + +// Conn implements dialect.ExecQuerier given ExecQuerier. +type Conn struct { + ExecQuerier + dialect string +} + +// Exec implements the dialect.Exec method. +func (c Conn) Exec(ctx context.Context, query string, args, v any) (rerr error) { + argv, ok := args.([]any) + if !ok { + return fmt.Errorf("dialect/sql: invalid type %T. expect []any for args", v) + } + ex, cf, err := c.maySetVars(ctx) + if err != nil { + return err + } + if cf != nil { + defer func() { rerr = errors.Join(rerr, cf()) }() + } + switch v := v.(type) { + case nil: + if _, err := ex.ExecContext(ctx, query, argv...); err != nil { + return err + } + case *sql.Result: + res, err := ex.ExecContext(ctx, query, argv...) + if err != nil { + return err + } + *v = res + default: + return fmt.Errorf("dialect/sql: invalid type %T. expect *sql.Result", v) + } + return nil +} + +// Query implements the dialect.Query method. +func (c Conn) Query(ctx context.Context, query string, args, v any) error { + vr, ok := v.(*Rows) + if !ok { + return fmt.Errorf("dialect/sql: invalid type %T. expect *sql.Rows", v) + } + argv, ok := args.([]any) + if !ok { + return fmt.Errorf("dialect/sql: invalid type %T. expect []any for args", args) + } + ex, cf, err := c.maySetVars(ctx) + if err != nil { + return err + } + rows, err := ex.QueryContext(ctx, query, argv...) + if err != nil { + if cf != nil { + err = errors.Join(err, cf()) + } + return err + } + *vr = Rows{rows} + if cf != nil { + vr.ColumnScanner = rowsWithCloser{rows, cf} + } + return nil +} + +// maySetVars sets the session variables before executing a query. +func (c Conn) maySetVars(ctx context.Context) (ExecQuerier, func() error, error) { + sv, _ := ctx.Value(ctxVarsKey{}).(sessionVars) + if len(sv.vars) == 0 { + return c, nil, nil + } + var ( + ex ExecQuerier // Underlying ExecQuerier. + cf func() error // Close function. + reset []string // Reset variables. + seen = make(map[string]struct{}, len(sv.vars)) + ) + switch e := c.ExecQuerier.(type) { + case *sql.Tx: + ex = e + case *sql.DB: + conn, err := e.Conn(ctx) + if err != nil { + return nil, nil, err + } + ex, cf = conn, conn.Close + } + for _, s := range sv.vars { + if _, ok := seen[s.k]; !ok { + switch c.dialect { + case dialect.Postgres: + reset = append(reset, fmt.Sprintf("RESET %s", s.k)) + case dialect.MySQL: + reset = append(reset, fmt.Sprintf("SET %s = NULL", s.k)) + } + seen[s.k] = struct{}{} + } + if _, err := ex.ExecContext(ctx, fmt.Sprintf("SET %s = '%s'", s.k, s.v)); err != nil { + if cf != nil { + err = errors.Join(err, cf()) + } + return nil, nil, err + } + } + // If there are variables to reset, and we need to return the + // connection to the pool, we need to clean up the variables. + if cls := cf; cf != nil && len(reset) > 0 { + cf = func() error { + for _, q := range reset { + if _, err := ex.ExecContext(ctx, q); err != nil { + return errors.Join(err, cls()) + } + } + return cls() + } + } + return ex, cf, nil +} + +var _ dialect.Driver = (*Driver)(nil) + +type ( + // Rows wraps the sql.Rows to avoid locks copy. + Rows struct{ ColumnScanner } + // Result is an alias to sql.Result. + Result = sql.Result + // NullBool is an alias to sql.NullBool. + NullBool = sql.NullBool + // NullInt64 is an alias to sql.NullInt64. + NullInt64 = sql.NullInt64 + // NullString is an alias to sql.NullString. + NullString = sql.NullString + // NullFloat64 is an alias to sql.NullFloat64. + NullFloat64 = sql.NullFloat64 + // NullTime represents a time.Time that may be null. + NullTime = sql.NullTime + // TxOptions holds the transaction options to be used in DB.BeginTx. + TxOptions = sql.TxOptions +) + +// NullScanner implements the sql.Scanner interface such that it +// can be used as a scan destination, similar to the types above. +type NullScanner struct { + S sql.Scanner + Valid bool // Valid is true if the Scan value is not NULL. +} + +// Scan implements the Scanner interface. +func (n *NullScanner) Scan(value any) error { + n.Valid = value != nil + if n.Valid { + return n.S.Scan(value) + } + return nil +} + +// ColumnScanner is the interface that wraps the standard +// sql.Rows methods used for scanning database rows. +type ColumnScanner interface { + Close() error + ColumnTypes() ([]*sql.ColumnType, error) + Columns() ([]string, error) + Err() error + Next() bool + NextResultSet() bool + Scan(dest ...any) error +} + +// rowsWithCloser wraps the ColumnScanner interface with a custom Close hook. +type rowsWithCloser struct { + ColumnScanner + closer func() error +} + +// Close closes the underlying ColumnScanner and calls the custom closer. +func (r rowsWithCloser) Close() error { + err := r.ColumnScanner.Close() + return errors.Join(err, r.closer()) +} diff --git a/dialect/sql/driver_test.go b/dialect/sql/driver_test.go new file mode 100644 index 0000000..6494750 --- /dev/null +++ b/dialect/sql/driver_test.go @@ -0,0 +1,93 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package sql + +import ( + "context" + "testing" + + "entgo.io/ent/dialect" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/stretchr/testify/require" +) + +func TestWithVars(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + db.SetMaxOpenConns(1) + drv := OpenDB(dialect.Postgres, db) + mock.ExpectExec("SET foo = 'bar'").WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectQuery("SELECT 1").WillReturnRows(sqlmock.NewRows([]string{"1"}).AddRow(1)) + mock.ExpectExec("RESET foo").WillReturnResult(sqlmock.NewResult(0, 0)) + rows := &Rows{} + err = drv.Query( + WithVar(context.Background(), "foo", "bar"), + "SELECT 1", + []any{}, + rows, + ) + require.NoError(t, err) + require.NoError(t, rows.Close(), "rows should be closed to release the connection") + require.NoError(t, mock.ExpectationsWereMet()) + + mock.ExpectExec("SET foo = 'bar'").WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectExec("SET foo = 'baz'").WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectQuery("SELECT 1").WillReturnRows(sqlmock.NewRows([]string{"1"}).AddRow(1)) + mock.ExpectExec("RESET foo").WillReturnResult(sqlmock.NewResult(0, 0)) + err = drv.Query( + WithVar(WithVar(context.Background(), "foo", "bar"), "foo", "baz"), + "SELECT 1", + []any{}, + rows, + ) + require.NoError(t, err) + require.NoError(t, rows.Close(), "rows should be closed to release the connection") + require.NoError(t, mock.ExpectationsWereMet()) + + mock.ExpectBegin() + mock.ExpectExec("SET foo = 'bar'").WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectQuery("SELECT 1").WillReturnRows(sqlmock.NewRows([]string{"1"}).AddRow(1)) + mock.ExpectCommit() + tx, err := drv.Tx(context.Background()) + require.NoError(t, err) + err = tx.Query( + WithVar(context.Background(), "foo", "bar"), + "SELECT 1", + []any{}, + rows, + ) + require.NoError(t, err) + require.NoError(t, tx.Commit()) + require.NoError(t, mock.ExpectationsWereMet()) + // Rows should not be closed to release the session, + // as a transaction is always scoped to a single connection. + + mock.ExpectExec("SET foo = 'qux'").WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectExec("INSERT INTO users DEFAULT VALUES").WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectExec("RESET foo").WillReturnResult(sqlmock.NewResult(0, 0)) + err = drv.Exec( + WithVar(context.Background(), "foo", "qux"), + "INSERT INTO users DEFAULT VALUES", + []any{}, + nil, + ) + require.NoError(t, err) + require.NoError(t, mock.ExpectationsWereMet()) + // No rows are returned, so no need to close them. + + mock.ExpectExec("SET foo = 'foo'").WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectExec("INSERT INTO users DEFAULT VALUES").WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectExec("RESET foo").WillReturnResult(sqlmock.NewResult(0, 0)) + err = drv.Exec( + WithVar(context.Background(), "foo", "foo"), + "INSERT INTO users DEFAULT VALUES", + []any{}, + nil, + ) + require.NoError(t, err) + require.NoError(t, mock.ExpectationsWereMet()) + // No rows are returned, so no need to close them. +} diff --git a/dialect/sql/scan.go b/dialect/sql/scan.go new file mode 100644 index 0000000..d996224 --- /dev/null +++ b/dialect/sql/scan.go @@ -0,0 +1,425 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package sql + +import ( + "database/sql" + "database/sql/driver" + "encoding/json" + "fmt" + "reflect" + "strings" + "time" +) + +// ScanOne scans one row to the given value. It fails if the rows holds more than 1 row. +func ScanOne(rows ColumnScanner, v any) error { + columns, err := rows.Columns() + if err != nil { + return fmt.Errorf("sql/scan: failed getting column names: %w", err) + } + if n := len(columns); n != 1 { + return fmt.Errorf("sql/scan: unexpected number of columns: %d", n) + } + if !rows.Next() { + if err := rows.Err(); err != nil { + return err + } + return sql.ErrNoRows + } + if err := rows.Scan(v); err != nil { + return err + } + if rows.Next() { + return fmt.Errorf("sql/scan: expect exactly one row in result set") + } + return rows.Err() +} + +// ScanInt64 scans and returns an int64 from the rows. +func ScanInt64(rows ColumnScanner) (int64, error) { + var n int64 + if err := ScanOne(rows, &n); err != nil { + return 0, err + } + return n, nil +} + +// ScanInt scans and returns an int from the rows. +func ScanInt(rows ColumnScanner) (int, error) { + n, err := ScanInt64(rows) + if err != nil { + return 0, err + } + return int(n), nil +} + +// ScanBool scans and returns a boolean from the rows. +func ScanBool(rows ColumnScanner) (bool, error) { + var b bool + if err := ScanOne(rows, &b); err != nil { + return false, err + } + return b, nil +} + +// ScanString scans and returns a string from the rows. +func ScanString(rows ColumnScanner) (string, error) { + var s string + if err := ScanOne(rows, &s); err != nil { + return "", err + } + return s, nil +} + +// ScanValue scans and returns a driver.Value from the rows. +func ScanValue(rows ColumnScanner) (driver.Value, error) { + var v driver.Value + if err := ScanOne(rows, &v); err != nil { + return "", err + } + return v, nil +} + +// ScanSlice scans the given ColumnScanner (basically, sql.Row or sql.Rows) into the given slice. +func ScanSlice(rows ColumnScanner, v any) error { + columns, err := rows.Columns() + if err != nil { + return fmt.Errorf("sql/scan: failed getting column names: %w", err) + } + rv := reflect.ValueOf(v) + switch { + case rv.Kind() != reflect.Ptr: + if t := reflect.TypeOf(v); t != nil { + return fmt.Errorf("sql/scan: ScanSlice(non-pointer %s)", t) + } + fallthrough + case rv.IsNil(): + return fmt.Errorf("sql/scan: ScanSlice(nil)") + } + rv = reflect.Indirect(rv) + if k := rv.Kind(); k != reflect.Slice { + return fmt.Errorf("sql/scan: invalid type %s. expected slice as an argument", k) + } + scan, err := scanType(rv.Type().Elem(), columns) + if err != nil { + return err + } + if n, m := len(columns), len(scan.columns); n > m { + return fmt.Errorf("sql/scan: columns do not match (%d > %d)", n, m) + } + for rows.Next() { + values := scan.values() + if err := rows.Scan(values...); err != nil { + return fmt.Errorf("sql/scan: failed scanning rows: %w", err) + } + vv, err := scan.value(values...) + if err != nil { + return err + } + rv.Set(reflect.Append(rv, vv)) + } + return rows.Err() +} + +// rowScan is the configuration for scanning one sql.Row. +type rowScan struct { + // column types of a row. + columns []reflect.Type + // value functions that converts the row columns (result) to a reflect.Value. + value func(v ...any) (reflect.Value, error) +} + +// values returns a []any from the configured column types. +func (r *rowScan) values() []any { + values := make([]any, len(r.columns)) + for i := range r.columns { + values[i] = reflect.New(r.columns[i]).Interface() + } + return values +} + +// scanType returns rowScan for the given reflect.Type. +func scanType(typ reflect.Type, columns []string) (*rowScan, error) { + switch k := typ.Kind(); { + case assignable(typ): + return &rowScan{ + columns: []reflect.Type{typ}, + value: func(v ...any) (reflect.Value, error) { + return reflect.Indirect(reflect.ValueOf(v[0])), nil + }, + }, nil + case k == reflect.Ptr: + return scanPtr(typ, columns) + case k == reflect.Struct: + return scanStruct(typ, columns) + default: + return nil, fmt.Errorf("sql/scan: unsupported type ([]%s)", k) + } +} + +var ( + timeType = reflect.TypeOf(time.Time{}) + scannerType = reflect.TypeOf((*sql.Scanner)(nil)).Elem() + nullJSONType = reflect.TypeOf((*nullJSON)(nil)).Elem() +) + +// nullJSON represents a json.RawMessage that may be NULL. +type nullJSON json.RawMessage + +// Scan implements the sql.Scanner interface. +func (j *nullJSON) Scan(v interface{}) error { + if v == nil { + return nil + } + *j = v.([]byte) + return nil +} + +// assignable reports if the given type can be assigned directly by `Rows.Scan`. +func assignable(typ reflect.Type) bool { + switch k := typ.Kind(); { + case typ.Implements(scannerType): + case k == reflect.Interface && typ.NumMethod() == 0: + case k == reflect.String || k >= reflect.Bool && k <= reflect.Float64: + case (k == reflect.Slice || k == reflect.Array) && typ.Elem().Kind() == reflect.Uint8: + default: + return false + } + return true +} + +// scanStruct returns the configuration for scanning a sql.Row into a struct. +func scanStruct(typ reflect.Type, columns []string) (*rowScan, error) { + var ( + scan = &rowScan{} + idxs = make([][]int, 0, typ.NumField()) + names = make(map[string][]int, typ.NumField()) + ) + for i := 0; i < typ.NumField(); i++ { + f := typ.Field(i) + // Skip unexported fields. + if f.PkgPath != "" { + continue + } + // Support 1-level embedding to accept types as `type T struct {ent.T; V int}`. + if typ := f.Type; f.Anonymous && typ.Kind() == reflect.Struct { + for j := 0; j < typ.NumField(); j++ { + names[columnName(typ.Field(j))] = []int{i, j} + } + continue + } + names[columnName(f)] = []int{i} + } + for _, c := range columns { + var idx []int + // Normalize columns if necessary, + // for example: COUNT(*) => count. + switch name := strings.Split(c, "(")[0]; { + case names[name] != nil: + idx = names[name] + case names[strings.ToLower(name)] != nil: + idx = names[strings.ToLower(name)] + default: + return nil, fmt.Errorf("sql/scan: missing struct field for column: %s (%s)", c, name) + } + idxs = append(idxs, idx) + rtype := typ.Field(idx[0]).Type + if len(idx) > 1 { + rtype = rtype.Field(idx[1]).Type + } + switch { + // If the field is not support by the standard + // convertAssign, assume it is a JSON field. + case !supportsScan(rtype): + rtype = nullJSONType + // Create a pointer to the actual reflect + // types to accept optional struct fields. + case !nillable(rtype): + rtype = reflect.PtrTo(rtype) + } + scan.columns = append(scan.columns, rtype) + } + scan.value = func(vs ...any) (reflect.Value, error) { + st := reflect.New(typ).Elem() + for i, v := range vs { + rv := reflect.Indirect(reflect.ValueOf(v)) + if rv.IsNil() { + continue + } + idx := idxs[i] + rvalue, ft := st.Field(idx[0]), st.Type().Field(idx[0]) + if len(idx) > 1 { + // Embedded field. + rvalue, ft = rvalue.Field(idx[1]), ft.Type.Field(idx[1]) + } + switch { + case rv.Type() == nullJSONType: + if rv = reflect.Indirect(rv); rv.IsNil() { + continue + } + if err := json.Unmarshal(rv.Bytes(), rvalue.Addr().Interface()); err != nil { + return reflect.Value{}, fmt.Errorf("unmarshal field %q: %w", ft.Name, err) + } + case !nillable(rvalue.Type()): + rv = reflect.Indirect(rv) + fallthrough + default: + rvalue.Set(rv) + } + } + return st, nil + } + return scan, nil +} + +// columnName returns the column name of a struct-field. +func columnName(f reflect.StructField) string { + name := strings.ToLower(f.Name) + if tag, ok := f.Tag.Lookup("sql"); ok { + name = tag + } else if tag, ok := f.Tag.Lookup("json"); ok { + name = strings.Split(tag, ",")[0] + } + return name +} + +// nillable reports if the reflect-type can have nil value. +func nillable(t reflect.Type) bool { + switch t.Kind() { + case reflect.Interface, reflect.Slice, reflect.Map, reflect.Ptr, reflect.UnsafePointer: + return true + } + return false +} + +// scanPtr wraps the underlying type with rowScan. +func scanPtr(typ reflect.Type, columns []string) (*rowScan, error) { + typ = typ.Elem() + scan, err := scanType(typ, columns) + if err != nil { + return nil, err + } + wrap := scan.value + scan.value = func(vs ...any) (reflect.Value, error) { + v, err := wrap(vs...) + if err != nil { + return reflect.Value{}, err + } + pt := reflect.PtrTo(v.Type()) + pv := reflect.New(pt.Elem()) + pv.Elem().Set(v) + return pv, nil + } + return scan, nil +} + +func supportsScan(t reflect.Type) bool { + if t.Implements(scannerType) || reflect.PtrTo(t).Implements(scannerType) { + return true + } + if t.Kind() == reflect.Ptr { + t = t.Elem() + } + switch t.Kind() { + case reflect.Bool, reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, + reflect.Float32, reflect.Float64, reflect.Pointer, reflect.String: + return true + case reflect.Slice: + return t == reflect.TypeOf(sql.RawBytes(nil)) || t == reflect.TypeOf([]byte(nil)) + case reflect.Interface: + return t == reflect.TypeOf((*any)(nil)).Elem() + default: + return t == reflect.TypeOf(time.Time{}) || t.Implements(scannerType) + } +} + +// UnknownType is a named type to any indicates the info +// needs to be extracted from the underlying rows. +type UnknownType any + +// ScanTypeOf returns the type used for scanning column i from the database. +func ScanTypeOf(rows *Rows, i int) any { + unknown := new(any) + ct, err := rows.ColumnTypes() + if err != nil || len(ct) <= i { + return unknown + } + rt := ct[i].ScanType() + if rt.Kind() == reflect.Pointer { + rt = rt.Elem() + } + // Handle NULL values. + switch k := rt.Kind(); k { + case reflect.Bool: + rt = reflect.TypeOf(sql.NullBool{}) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + rt = reflect.TypeOf(sql.NullInt64{}) + case reflect.Float32, reflect.Float64: + rt = reflect.TypeOf(sql.NullFloat64{}) + case reflect.String: + rt = reflect.TypeOf(sql.NullString{}) + default: + if k == reflect.Struct && rt == timeType { + rt = reflect.TypeOf(sql.NullTime{}) + } + } + return reflect.New(rt).Interface() +} + +// SelectValues maps a selected column to its value. +// Used by the generated code for storing runtime selected columns/expressions. +type SelectValues map[string]any + +// Set sets the value of the given column. +func (s *SelectValues) Set(name string, v any) { + if *s == nil { + *s = make(SelectValues) + } + if pv, ok := v.(*any); ok && pv != nil { + v = *pv + } + (*s)[name] = v +} + +// Get returns the value of the given column. +func (s SelectValues) Get(name string) (any, error) { + v, ok := s[name] + if !ok { + return nil, fmt.Errorf("%s value was not selected", name) + } + if v == nil { + return nil, nil + } + switch rv := reflect.Indirect(reflect.ValueOf(v)).Interface().(type) { + case NullString: + if rv.Valid { + return rv.String, nil + } + case NullInt64: + if rv.Valid { + return rv.Int64, nil + } + case NullFloat64: + if rv.Valid { + return rv.Float64, nil + } + case NullBool: + if rv.Valid { + return rv.Bool, nil + } + case NullTime: + if rv.Valid { + return rv.Time, nil + } + case sql.RawBytes: + return []byte(rv), nil + default: + return rv, nil + } + return nil, nil +} diff --git a/dialect/sql/scan_test.go b/dialect/sql/scan_test.go new file mode 100644 index 0000000..70b07b7 --- /dev/null +++ b/dialect/sql/scan_test.go @@ -0,0 +1,367 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package sql + +import ( + "database/sql" + "database/sql/driver" + "encoding/json" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +func TestScanSlice(t *testing.T) { + mock := sqlmock.NewRows([]string{"name"}). + AddRow("foo"). + AddRow("bar") + var v0 []string + require.NoError(t, ScanSlice(toRows(mock), &v0)) + require.Equal(t, []string{"foo", "bar"}, v0) + + mock = sqlmock.NewRows([]string{"age"}). + AddRow(1). + AddRow(2) + var v1 []int + require.NoError(t, ScanSlice(toRows(mock), &v1)) + require.Equal(t, []int{1, 2}, v1) + + mock = sqlmock.NewRows([]string{"name", "COUNT(*)"}). + AddRow("foo", 1). + AddRow("bar", 2) + var v2 []struct { + Name string + Count int + } + require.NoError(t, ScanSlice(toRows(mock), &v2)) + require.Equal(t, "foo", v2[0].Name) + require.Equal(t, "bar", v2[1].Name) + require.Equal(t, 1, v2[0].Count) + require.Equal(t, 2, v2[1].Count) + + mock = sqlmock.NewRows([]string{"nick_name", "COUNT(*)"}). + AddRow("foo", 1). + AddRow("bar", 2) + var v3 []struct { + Count int + Name string `json:"nick_name"` + } + require.NoError(t, ScanSlice(toRows(mock), &v3)) + require.Equal(t, "foo", v3[0].Name) + require.Equal(t, "bar", v3[1].Name) + require.Equal(t, 1, v3[0].Count) + require.Equal(t, 2, v3[1].Count) + + mock = sqlmock.NewRows([]string{"nick_name", "COUNT(*)"}). + AddRow("foo", 1). + AddRow("bar", 2) + var v4 []*struct { + Count int + Name string `json:"nick_name"` + Ignored string `json:"string"` + } + require.NoError(t, ScanSlice(toRows(mock), &v4)) + require.Equal(t, "foo", v4[0].Name) + require.Equal(t, "bar", v4[1].Name) + require.Equal(t, 1, v4[0].Count) + require.Equal(t, 2, v4[1].Count) + + mock = sqlmock.NewRows([]string{"nick_name", "COUNT(*)"}). + AddRow("foo", 1). + AddRow("bar", 2) + var v5 []*struct { + Count int + Name string `json:"name" sql:"nick_name"` + } + require.NoError(t, ScanSlice(toRows(mock), &v5)) + require.Equal(t, "foo", v5[0].Name) + require.Equal(t, "bar", v5[1].Name) + require.Equal(t, 1, v5[0].Count) + require.Equal(t, 2, v5[1].Count) + + mock = sqlmock.NewRows([]string{"age", "name"}). + AddRow(1, nil). + AddRow(nil, "a8m") + var v6 []struct { + Age NullInt64 + Name NullString + } + require.NoError(t, ScanSlice(toRows(mock), &v6)) + require.EqualValues(t, 1, v6[0].Age.Int64) + require.False(t, v6[0].Name.Valid) + require.False(t, v6[1].Age.Valid) + require.Equal(t, "a8m", v6[1].Name.String) + + u1, u2 := uuid.New().String(), uuid.New().String() + mock = sqlmock.NewRows([]string{"ids"}). + AddRow([]byte(u1)). + AddRow([]byte(u2)) + var ids []uuid.UUID + require.NoError(t, ScanSlice(toRows(mock), &ids)) + require.Equal(t, u1, ids[0].String()) + require.Equal(t, u2, ids[1].String()) + + mock = sqlmock.NewRows([]string{"pids"}). + AddRow([]byte(u1)). + AddRow([]byte(u2)) + var pids []*uuid.UUID + require.NoError(t, ScanSlice(toRows(mock), &pids)) + require.Equal(t, u1, pids[0].String()) + require.Equal(t, u2, pids[1].String()) + + mock = sqlmock.NewRows([]string{"id", "first", "last"}). + AddRow(10, "Ariel", "Mashraki") + err := ScanSlice(toRows(mock), nil) + require.EqualError(t, err, "sql/scan: ScanSlice(nil)") + type P struct { + _ int + ID int + First string + Last string + } + var p []P + err = ScanSlice(toRows(mock), p) + require.EqualError(t, err, "sql/scan: ScanSlice(non-pointer []sql.P)") + + require.NoError(t, ScanSlice(toRows(mock), &p)) + require.Equal(t, 10, p[0].ID) + require.Equal(t, "Ariel", p[0].First) + require.Equal(t, "Mashraki", p[0].Last) + + var pp []struct{ _, id int } + mock = sqlmock.NewRows([]string{"id"}). + AddRow(10) + err = ScanSlice(toRows(mock), &pp) + require.EqualError(t, err, "sql/scan: missing struct field for column: id (id)") + require.Empty(t, pp) +} + +func TestScanSlice_CamelTags(t *testing.T) { + mock := sqlmock.NewRows([]string{"nickName"}). + AddRow("foo"). + AddRow("bar") + var v []*struct { + NickName string `json:"nickName"` + } + require.NoError(t, ScanSlice(toRows(mock), &v)) + require.Equal(t, "foo", v[0].NickName) +} + +func TestScanJSON(t *testing.T) { + mock := sqlmock.NewRows([]string{"v", "p"}). + AddRow([]byte(`{"i": 1, "s":"a8m"}`), []byte(`{"i": 1, "s":"a8m"}`)). + AddRow([]byte(`{"i": 2, "s":"tmr"}`), []byte(`{"i": 2, "s":"tmr"}`)). + AddRow([]byte(nil), []byte(`null`)). + AddRow(nil, nil) + var v1 []*struct { + V struct { + I int `json:"i"` + S string `json:"s"` + } `json:"v"` + P *struct { + I int `json:"i"` + S string `json:"s"` + } `json:"p"` + } + require.NoError(t, ScanSlice(toRows(mock), &v1)) + require.Equal(t, 1, v1[0].V.I) + require.Equal(t, "a8m", v1[0].V.S) + require.Equal(t, v1[0].V, *v1[0].P) + require.Equal(t, 2, v1[1].V.I) + require.Equal(t, "tmr", v1[1].V.S) + require.Equal(t, v1[1].V, *v1[1].P) + require.Equal(t, 0, v1[2].V.I) + require.Equal(t, "", v1[2].V.S) + require.Nil(t, v1[2].P) + require.Equal(t, 0, v1[3].V.I) + require.Equal(t, "", v1[3].V.S) + require.Nil(t, v1[3].P) + + mock = sqlmock.NewRows([]string{"v", "p"}). + AddRow([]byte(`[1]`), []byte(`[1]`)). + AddRow([]byte(`[2]`), []byte(`[2]`)) + var v2 []*struct { + V []int `json:"v"` + P *[]int `json:"p"` + } + require.NoError(t, ScanSlice(toRows(mock), &v2)) + require.Equal(t, []int{1}, v2[0].V) + require.Equal(t, v2[0].V, *v2[0].P) + require.Equal(t, []int{2}, v2[1].V) + require.Equal(t, v2[1].V, *v2[1].P) + + mock = sqlmock.NewRows([]string{"v", "p"}). + AddRow([]byte(`null`), []byte(`{}`)). + AddRow(nil, nil) + var v3 []*struct { + V json.RawMessage `json:"v"` + P *json.RawMessage `json:"p"` + } + require.NoError(t, ScanSlice(toRows(mock), &v3)) + require.Equal(t, json.RawMessage("null"), v3[0].V) + require.Equal(t, json.RawMessage("{}"), *v3[0].P) + require.Equal(t, json.RawMessage(nil), v3[1].V) + require.Nil(t, v3[1].P) + + // Unmarshal errors. + mock = sqlmock.NewRows([]string{"v", "p"}). + AddRow([]byte(`{invalid}`), []byte(`{}`)) + require.EqualError(t, ScanSlice(toRows(mock), &v1), `unmarshal field "V": invalid character 'i' looking for beginning of object key string`) + mock = sqlmock.NewRows([]string{"v", "p"}). + AddRow([]byte(``), []byte(``)) + require.EqualError(t, ScanSlice(toRows(mock), &v1), `unmarshal field "V": unexpected end of JSON input`) +} + +func TestScanNestedStruct(t *testing.T) { + mock := sqlmock.NewRows([]string{"name", "age"}). + AddRow("foo", 1). + AddRow("bar", 2). + AddRow("baz", nil) + type T struct{ Name string } + var v []struct { + T + Age int + } + require.NoError(t, ScanSlice(toRows(mock), &v)) + require.Equal(t, "foo", v[0].Name) + require.Equal(t, 1, v[0].Age) + require.Equal(t, "bar", v[1].Name) + require.Equal(t, 2, v[1].Age) + require.Equal(t, "baz", v[2].Name) + require.Equal(t, 0, v[2].Age) + + mock = sqlmock.NewRows([]string{"name", "age"}). + AddRow("foo", 1). + AddRow("bar", nil) + type T1 struct{ Name **string } + var v1 []struct { + T1 + Age *int + } + require.NoError(t, ScanSlice(toRows(mock), &v1)) + require.Equal(t, "foo", **v1[0].Name) + require.Equal(t, "bar", **v1[1].Name) + require.Equal(t, 1, *v1[0].Age) + require.Nil(t, v1[1].Age) +} + +func TestScanSlicePtr(t *testing.T) { + mock := sqlmock.NewRows([]string{"name"}). + AddRow("foo"). + AddRow("bar") + var v0 []*string + require.NoError(t, ScanSlice(toRows(mock), &v0)) + require.Equal(t, "foo", *v0[0]) + require.Equal(t, "bar", *v0[1]) + + mock = sqlmock.NewRows([]string{"age"}). + AddRow(1). + AddRow(2) + var v1 []**int + require.NoError(t, ScanSlice(toRows(mock), &v1)) + require.Equal(t, 1, **v1[0]) + require.Equal(t, 2, **v1[1]) + + mock = sqlmock.NewRows([]string{"age", "name"}). + AddRow(1, "a8m"). + AddRow(2, "nati") + var v2 []*struct { + Age *int + Name **string + } + require.NoError(t, ScanSlice(toRows(mock), &v2)) + require.Equal(t, 1, *v2[0].Age) + require.Equal(t, "a8m", **v2[0].Name) + require.Equal(t, 2, *v2[1].Age) + require.Equal(t, "nati", **v2[1].Name) +} + +func TestScanInt64(t *testing.T) { + mock := sqlmock.NewRows([]string{"age"}). + AddRow("10"). + AddRow("20") + n, err := ScanInt64(toRows(mock)) + require.Error(t, err) + require.Zero(t, n) + + mock = sqlmock.NewRows([]string{"age", "count"}). + AddRow("10", "1") + n, err = ScanInt64(toRows(mock)) + require.Error(t, err) + require.Zero(t, n) + + mock = sqlmock.NewRows([]string{"count"}). + AddRow(10) + n, err = ScanInt64(toRows(mock)) + require.NoError(t, err) + require.EqualValues(t, 10, n) + + mock = sqlmock.NewRows([]string{"count"}). + AddRow("10") + n, err = ScanInt64(toRows(mock)) + require.NoError(t, err) + require.EqualValues(t, 10, n) +} + +func TestScanValue(t *testing.T) { + mock := sqlmock.NewRows([]string{"count"}). + AddRow(10) + n, err := ScanValue(toRows(mock)) + require.NoError(t, err) + require.EqualValues(t, 10, n) +} + +func TestScanOne(t *testing.T) { + mock := sqlmock.NewRows([]string{"name"}). + AddRow("10"). + AddRow("20") + err := ScanOne(toRows(mock), new(string)) + require.Error(t, err, "multiple lines") + + mock = sqlmock.NewRows([]string{"name"}). + AddRow("10") + err = ScanOne(toRows(mock), "") + require.Error(t, err, "not a pointer") + + mock = sqlmock.NewRows([]string{"name"}). + AddRow("10") + var s string + err = ScanOne(toRows(mock), &s) + require.NoError(t, err) + require.Equal(t, "10", s) +} + +func TestInterface(t *testing.T) { + mock := sqlmock.NewRows([]string{"age"}). + AddRow("10"). + AddRow("20") + var values []driver.Value + err := ScanSlice(toRows(mock), &values) + require.NoError(t, err) + require.Equal(t, []driver.Value{"10", "20"}, values) + + mock = sqlmock.NewRows([]string{"age"}). + AddRow(10). + AddRow(20) + values = values[:0:0] + err = ScanSlice(toRows(mock), &values) + require.NoError(t, err) + require.Equal(t, []driver.Value{int64(10), int64(20)}, values) +} + +func TestScanTypeOf(t *testing.T) { + mock := &Rows{ColumnScanner: toRows(sqlmock.NewRows([]string{"age"}).AddRow(10))} + tv := ScanTypeOf(mock, 0) + require.IsType(t, (*any)(nil), tv) +} + +func toRows(mrows *sqlmock.Rows) *sql.Rows { + db, mock, _ := sqlmock.New() + mock.ExpectQuery("").WillReturnRows(mrows) + rows, _ := db.Query("") + return rows +} diff --git a/dialect/sql/schema/atlas.go b/dialect/sql/schema/atlas.go new file mode 100644 index 0000000..491f66b --- /dev/null +++ b/dialect/sql/schema/atlas.go @@ -0,0 +1,1226 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package schema + +import ( + "context" + "crypto/md5" + "database/sql" + "errors" + "fmt" + "maps" + "net/url" + "reflect" + "slices" + "sort" + "strings" + + "ariga.io/atlas/sql/migrate" + "ariga.io/atlas/sql/schema" + "ariga.io/atlas/sql/sqlclient" + "ariga.io/atlas/sql/sqltool" + "entgo.io/ent/dialect" + entsql "entgo.io/ent/dialect/sql" + "entgo.io/ent/schema/field" +) + +// Atlas atlas migration engine. +type Atlas struct { + atDriver migrate.Driver + sqlDialect sqlDialect + + schema string // schema to use + indent string // plan indentation + errNoPlan bool // no plan error enabled + universalID bool // global unique ids + dropColumns bool // drop deleted columns + dropIndexes bool // drop deleted indexes + withForeignKeys bool // with foreign keys + hashSymbols bool // whether to use a hash for too long symbols, only for StateReader + mode Mode + hooks []Hook // hooks to apply before creation + diffHooks []DiffHook // diff hooks to run when diffing current and desired + diffOptions []schema.DiffOption // diff options to pass to the diff engine + applyHook []ApplyHook // apply hooks to run when applying the plan + skip ChangeKind // what changes to skip and not apply + dir migrate.Dir // the migration directory to read from + fmt migrate.Formatter // how to format the plan into migration files + + driver dialect.Driver // driver passed in when not using an atlas URL + url *url.URL // url of database connection + dialect string // Ent dialect to use when generating migration files + + types []string // pre-existing pk range allocation for global unique id +} + +// Diff compares the state read from a database connection or migration directory with the state defined by the Ent +// schema. Changes will be written to new migration files. +func Diff(ctx context.Context, u, name string, tables []*Table, opts ...MigrateOption) (err error) { + m, err := NewMigrateURL(u, opts...) + if err != nil { + return err + } + return m.NamedDiff(ctx, name, tables...) +} + +// NewMigrate creates a new Atlas form the given dialect.Driver. +func NewMigrate(drv dialect.Driver, opts ...MigrateOption) (*Atlas, error) { + a := &Atlas{driver: drv, withForeignKeys: true, mode: ModeInspect} + for _, opt := range opts { + opt(a) + } + a.dialect = a.driver.Dialect() + if err := a.init(); err != nil { + return nil, err + } + return a, nil +} + +// NewMigrateURL create a new Atlas from the given url. +func NewMigrateURL(u string, opts ...MigrateOption) (*Atlas, error) { + parsed, err := url.Parse(u) + if err != nil { + return nil, err + } + a := &Atlas{url: parsed, withForeignKeys: true, mode: ModeInspect} + for _, opt := range opts { + opt(a) + } + if a.dialect == "" { + a.dialect = parsed.Scheme + } + if err := a.init(); err != nil { + return nil, err + } + return a, nil +} + +// Create creates all schema resources in the database. It works in an "append-only" +// mode, which means, it only creates tables, appends columns to tables or modifies column types. +// +// Column can be modified by turning into a NULL from NOT NULL, or having a type conversion not +// resulting data altering. From example, changing varchar(255) to varchar(120) is invalid, but +// changing varchar(120) to varchar(255) is valid. For more info, see the convert function below. +func (a *Atlas) Create(ctx context.Context, tables ...*Table) (err error) { + a.setupTables(tables) + var creator Creator = CreateFunc(a.create) + for i := len(a.hooks) - 1; i >= 0; i-- { + creator = a.hooks[i](creator) + } + return creator.Create(ctx, tables...) +} + +// Diff compares the state read from the connected database with the state defined by Ent. +// Changes will be written to migration files by the configured Planner. +func (a *Atlas) Diff(ctx context.Context, tables ...*Table) error { + return a.NamedDiff(ctx, "changes", tables...) +} + +// NamedDiff compares the state read from the connected database with the state defined by Ent. +// Changes will be written to migration files by the configured Planner. +func (a *Atlas) NamedDiff(ctx context.Context, name string, tables ...*Table) error { + if a.dir == nil { + return errors.New("no migration directory given") + } + opts := []migrate.PlannerOption{migrate.PlanFormat(a.fmt)} + // Validate the migration directory before proceeding. + if err := migrate.Validate(a.dir); err != nil { + return fmt.Errorf("validating migration directory: %w", err) + } + a.setupTables(tables) + // Set up connections. + if a.driver != nil { + var err error + a.sqlDialect, err = a.entDialect(ctx, a.driver) + if err != nil { + return err + } + a.atDriver, err = a.sqlDialect.atOpen(a.sqlDialect) + if err != nil { + return err + } + } else { + c, err := sqlclient.OpenURL(ctx, a.url) + if err != nil { + return err + } + defer c.Close() + a.sqlDialect, err = a.entDialect(ctx, entsql.OpenDB(a.dialect, c.DB)) + if err != nil { + return err + } + a.atDriver = c.Driver + } + defer func() { + a.sqlDialect = nil + a.atDriver = nil + }() + if err := a.sqlDialect.init(ctx); err != nil { + return err + } + if a.universalID { + tables = append(tables, NewTypesTable()) + } + var ( + err error + plan *migrate.Plan + ) + switch a.mode { + case ModeInspect: + plan, err = a.planInspect(ctx, a.sqlDialect, name, tables) + case ModeReplay: + plan, err = a.planReplay(ctx, name, tables) + default: + return fmt.Errorf("unknown migration mode: %q", a.mode) + } + switch { + case err != nil: + return err + case len(plan.Changes) == 0: + if a.errNoPlan { + return migrate.ErrNoPlan + } + return nil + default: + return migrate.NewPlanner(nil, a.dir, opts...).WritePlan(plan) + } +} + +func (a *Atlas) cleanSchema(ctx context.Context, name string, err0 error) (err error) { + defer func() { + if err0 != nil { + err = errors.Join(err, err0) + } + }() + s, err := a.atDriver.InspectSchema(ctx, name, nil) + if err != nil { + return err + } + drop := make([]schema.Change, len(s.Tables)) + for i, t := range s.Tables { + drop[i] = &schema.DropTable{T: t, Extra: []schema.Clause{&schema.IfExists{}}} + } + return a.atDriver.ApplyChanges(ctx, drop) +} + +// VerifyTableRange ensures, that the defined autoincrement starting value is set for each table as defined by the +// TypTable. This is necessary for MySQL versions < 8.0. In those versions the defined starting value for AUTOINCREMENT +// columns was stored in memory, and when a server restarts happens and there are no rows yet in a table, the defined +// starting value is lost, which will result in incorrect behavior when working with global unique ids. Calling this +// method on service start ensures the information are correct and are set again, if they aren't. For MySQL versions > 8 +// calling this method is only required once after the upgrade. +func (a *Atlas) VerifyTableRange(ctx context.Context, tables []*Table) error { + if a.driver != nil { + var err error + a.sqlDialect, err = a.entDialect(ctx, a.driver) + if err != nil { + return err + } + } else { + c, err := sqlclient.OpenURL(ctx, a.url) + if err != nil { + return err + } + defer c.Close() + a.sqlDialect, err = a.entDialect(ctx, entsql.OpenDB(a.dialect, c.DB)) + if err != nil { + return err + } + } + defer func() { + a.sqlDialect = nil + }() + vr, ok := a.sqlDialect.(verifyRanger) + if !ok { + return nil + } + types, err := a.loadTypes(ctx, a.sqlDialect) + if err != nil { + // In most cases this means the table does not exist, which in turn + // indicates the user does not use global unique ids. + return err + } + for _, t := range tables { + id := indexOf(types, t.Name) + if id == -1 { + continue + } + if err := vr.verifyRange(ctx, a.sqlDialect, t, int64(id<<32)); err != nil { + return err + } + } + return nil +} + +type ( + // Differ is the interface that wraps the Diff method. + Differ interface { + // Diff returns a list of changes that construct a migration plan. + Diff(current, desired *schema.Schema) ([]schema.Change, error) + } + + // The DiffFunc type is an adapter to allow the use of ordinary function as Differ. + // If f is a function with the appropriate signature, DiffFunc(f) is a Differ that calls f. + DiffFunc func(current, desired *schema.Schema) ([]schema.Change, error) + + // DiffHook defines the "diff middleware". A function that gets a Differ and returns a Differ. + DiffHook func(Differ) Differ +) + +// Diff calls f(current, desired). +func (f DiffFunc) Diff(current, desired *schema.Schema) ([]schema.Change, error) { + return f(current, desired) +} + +// WithDiffHook adds a list of DiffHook to the schema migration. +// +// schema.WithDiffHook(func(next schema.Differ) schema.Differ { +// return schema.DiffFunc(func(current, desired *atlas.Schema) ([]atlas.Change, error) { +// // Code before standard diff. +// changes, err := next.Diff(current, desired) +// if err != nil { +// return nil, err +// } +// // After diff, you can filter +// // changes or return new ones. +// return changes, nil +// }) +// }) +func WithDiffHook(hooks ...DiffHook) MigrateOption { + return func(a *Atlas) { + a.diffHooks = append(a.diffHooks, hooks...) + } +} + +// WithDiffOptions adds a list of options to pass to the diff engine. +func WithDiffOptions(opts ...schema.DiffOption) MigrateOption { + return func(a *Atlas) { + a.diffOptions = append(a.diffOptions, opts...) + } +} + +// WithSkipChanges allows skipping/filtering list of changes +// returned by the Differ before executing migration planning. +// +// SkipChanges(schema.DropTable|schema.DropColumn) +func WithSkipChanges(skip ChangeKind) MigrateOption { + return func(a *Atlas) { + a.skip = skip + } +} + +// A ChangeKind denotes the kind of schema change. +type ChangeKind uint + +// List of change types. +const ( + NoChange ChangeKind = 0 + AddSchema ChangeKind = 1 << (iota - 1) + ModifySchema + DropSchema + AddTable + ModifyTable + DropTable + AddColumn + ModifyColumn + DropColumn + AddIndex + ModifyIndex + DropIndex + AddForeignKey + ModifyForeignKey + DropForeignKey + AddCheck + ModifyCheck + DropCheck +) + +// Is reports whether c is match the given change kind. +func (k ChangeKind) Is(c ChangeKind) bool { + return k == c || k&c != 0 +} + +// filterChanges is a DiffHook for filtering changes before plan. +func filterChanges(skip ChangeKind) DiffHook { + return func(next Differ) Differ { + return DiffFunc(func(current, desired *schema.Schema) ([]schema.Change, error) { + var f func([]schema.Change) []schema.Change + f = func(changes []schema.Change) (keep []schema.Change) { + var k ChangeKind + for _, c := range changes { + switch c := c.(type) { + case *schema.AddSchema: + k = AddSchema + case *schema.ModifySchema: + k = ModifySchema + if !skip.Is(k) { + c.Changes = f(c.Changes) + } + case *schema.DropSchema: + k = DropSchema + case *schema.AddTable: + k = AddTable + case *schema.ModifyTable: + k = ModifyTable + if !skip.Is(k) { + c.Changes = f(c.Changes) + } + case *schema.DropTable: + k = DropTable + case *schema.AddColumn: + k = AddColumn + case *schema.ModifyColumn: + k = ModifyColumn + case *schema.DropColumn: + k = DropColumn + case *schema.AddIndex: + k = AddIndex + case *schema.ModifyIndex: + k = ModifyIndex + case *schema.DropIndex: + k = DropIndex + case *schema.AddForeignKey: + k = AddIndex + case *schema.ModifyForeignKey: + k = ModifyForeignKey + case *schema.DropForeignKey: + k = DropForeignKey + case *schema.AddCheck: + k = AddCheck + case *schema.ModifyCheck: + k = ModifyCheck + case *schema.DropCheck: + k = DropCheck + } + if !skip.Is(k) { + keep = append(keep, c) + } + } + return + } + changes, err := next.Diff(current, desired) + if err != nil { + return nil, err + } + return f(changes), nil + }) + } +} + +func withoutForeignKeys(next Differ) Differ { + return DiffFunc(func(current, desired *schema.Schema) ([]schema.Change, error) { + changes, err := next.Diff(current, desired) + if err != nil { + return nil, err + } + for _, c := range changes { + switch c := c.(type) { + case *schema.AddTable: + c.T.ForeignKeys = nil + case *schema.ModifyTable: + c.T.ForeignKeys = nil + filtered := make([]schema.Change, 0, len(c.Changes)) + for _, change := range c.Changes { + switch change.(type) { + case *schema.AddForeignKey, *schema.DropForeignKey, *schema.ModifyForeignKey: + continue + default: + filtered = append(filtered, change) + } + } + c.Changes = filtered + } + } + return changes, nil + }) +} + +type ( + // Applier is the interface that wraps the Apply method. + Applier interface { + // Apply applies the given migrate.Plan on the database. + Apply(context.Context, dialect.ExecQuerier, *migrate.Plan) error + } + + // The ApplyFunc type is an adapter to allow the use of ordinary function as Applier. + // If f is a function with the appropriate signature, ApplyFunc(f) is an Applier that calls f. + ApplyFunc func(context.Context, dialect.ExecQuerier, *migrate.Plan) error + + // ApplyHook defines the "migration applying middleware". A function that gets an Applier and returns an Applier. + ApplyHook func(Applier) Applier +) + +// Apply calls f(ctx, tables...). +func (f ApplyFunc) Apply(ctx context.Context, conn dialect.ExecQuerier, plan *migrate.Plan) error { + return f(ctx, conn, plan) +} + +// WithApplyHook adds a list of ApplyHook to the schema migration. +// +// schema.WithApplyHook(func(next schema.Applier) schema.Applier { +// return schema.ApplyFunc(func(ctx context.Context, conn dialect.ExecQuerier, plan *migrate.Plan) error { +// // Example to hook into the apply process, or implement +// // a custom applier. +// // +// // for _, c := range plan.Changes { +// // fmt.Printf("%s: %s", c.Comment, c.Cmd) +// // } +// // +// return next.Apply(ctx, conn, plan) +// }) +// }) +func WithApplyHook(hooks ...ApplyHook) MigrateOption { + return func(a *Atlas) { + a.applyHook = append(a.applyHook, hooks...) + } +} + +// WithDir sets the atlas migration directory to use to store migration files. +func WithDir(dir migrate.Dir) MigrateOption { + return func(a *Atlas) { + a.dir = dir + } +} + +// WithFormatter sets atlas formatter to use to write changes to migration files. +func WithFormatter(fmt migrate.Formatter) MigrateOption { + return func(a *Atlas) { + a.fmt = fmt + } +} + +// WithDialect configures the Ent dialect to use when migrating for an Atlas supported dialect flavor. +// As an example, Ent can work with TiDB in MySQL dialect and Atlas can handle TiDB migrations. +func WithDialect(d string) MigrateOption { + return func(a *Atlas) { + a.dialect = d + } +} + +// WithMigrationMode instructs atlas how to compute the current state of the schema. This can be done by either +// replaying (ModeReplay) the migration directory on the connected database, or by inspecting (ModeInspect) the +// connection. Currently, ModeReplay is opt-in, and ModeInspect is the default. In future versions, ModeReplay will +// become the default behavior. This option has no effect when using online migrations. +func WithMigrationMode(mode Mode) MigrateOption { + return func(a *Atlas) { + a.mode = mode + } +} + +// Mode to compute the current state. +type Mode uint + +const ( + // ModeReplay computes the current state by replaying the migration directory on the connected database. + ModeReplay = iota + // ModeInspect computes the current state by inspecting the connected database. + ModeInspect +) + +// StateReader returns an atlas migrate.StateReader returning the state as described by the Ent table slice. +func (a *Atlas) StateReader(tables ...*Table) migrate.StateReaderFunc { + return func(ctx context.Context) (*schema.Realm, error) { + if a.sqlDialect == nil { + drv, err := a.entDialect(ctx, a.driver) + if err != nil { + return nil, err + } + a.sqlDialect = drv + } + if a.hashSymbols { + a.setupTables(tables) + } + return a.realm(tables) + } +} + +// atBuilder must be implemented by the different drivers in +// order to convert a dialect/sql/schema to atlas/sql/schema. +type atBuilder interface { + atOpen(dialect.ExecQuerier) (migrate.Driver, error) + atTable(*Table, *schema.Table) + supportsDefault(*Column) bool + atTypeC(*Column, *schema.Column) error + atUniqueC(*Table, *Column, *schema.Table, *schema.Column) + atIncrementC(*schema.Table, *schema.Column) + atIncrementT(*schema.Table, int64) + atIndex(*Index, *schema.Table, *schema.Index) error + atTypeRangeSQL(t ...string) string +} + +// init initializes the configuration object based on the options passed in. +func (a *Atlas) init() error { + skip := DropIndex | DropColumn + if a.skip != NoChange { + skip = a.skip + } + if a.dropIndexes { + skip &= ^DropIndex + } + if a.dropColumns { + skip &= ^DropColumn + } + if skip != NoChange { + a.diffHooks = append(a.diffHooks, filterChanges(skip)) + } + if !a.withForeignKeys { + a.diffHooks = append(a.diffHooks, withoutForeignKeys) + } + if a.dir != nil && a.fmt == nil { + switch a.dir.(type) { + case *sqltool.GooseDir: + a.fmt = sqltool.GooseFormatter + case *sqltool.DBMateDir: + a.fmt = sqltool.DBMateFormatter + case *sqltool.FlywayDir: + a.fmt = sqltool.FlywayFormatter + case *sqltool.LiquibaseDir: + a.fmt = sqltool.LiquibaseFormatter + default: // migrate.LocalDir, sqltool.GolangMigrateDir and custom ones + a.fmt = sqltool.GolangMigrateFormatter + } + } + // ModeReplay requires a migration directory. + if a.mode == ModeReplay && a.dir == nil { + return errors.New("sql/schema: WithMigrationMode(ModeReplay) requires versioned migrations: WithDir()") + } + return nil +} + +// create is the Atlas engine based online migration. +func (a *Atlas) create(ctx context.Context, tables ...*Table) (err error) { + if a.universalID { + tables = append(tables, NewTypesTable()) + } + if a.driver != nil { + a.sqlDialect, err = a.entDialect(ctx, a.driver) + if err != nil { + return err + } + } else { + c, err := sqlclient.OpenURL(ctx, a.url) + if err != nil { + return err + } + defer c.Close() + a.sqlDialect, err = a.entDialect(ctx, entsql.OpenDB(a.dialect, c.DB)) + if err != nil { + return err + } + } + defer func() { a.sqlDialect = nil }() + if err := a.sqlDialect.init(ctx); err != nil { + return err + } + a.atDriver, err = a.sqlDialect.atOpen(a.sqlDialect) + if err != nil { + return err + } + defer func() { a.atDriver = nil }() + plan, err := a.planInspect(ctx, a.sqlDialect, "changes", tables) + if err != nil { + return fmt.Errorf("sql/schema: %w", err) + } + if len(plan.Changes) == 0 { + return nil + } + // Open a transaction for backwards compatibility, + // even if the migration is not transactional. + tx, err := a.sqlDialect.Tx(ctx) + if err != nil { + return err + } + a.atDriver, err = a.sqlDialect.atOpen(tx) + if err != nil { + return err + } + // Apply plan (changes). + var applier Applier = ApplyFunc(func(ctx context.Context, tx dialect.ExecQuerier, plan *migrate.Plan) error { + for _, c := range plan.Changes { + if err := tx.Exec(ctx, c.Cmd, c.Args, nil); err != nil { + if c.Comment != "" { + err = fmt.Errorf("%s: %w", c.Comment, err) + } + return err + } + } + return nil + }) + for i := len(a.applyHook) - 1; i >= 0; i-- { + applier = a.applyHook[i](applier) + } + if err = applier.Apply(ctx, tx, plan); err != nil { + return errors.Join(fmt.Errorf("sql/schema: %w", err), tx.Rollback()) + } + return tx.Commit() +} + +// For BC reason, we omit the schema qualifier from the migration plan. +// This is currently limiting migrations to a single schema. +// If multi-schema migrations are required, one should use Atlas' schema loader for Ent. +var noQualifierOpt = func(opts *migrate.PlanOptions) { + var noQualifier string + opts.SchemaQualifier = &noQualifier +} + +// planInspect creates the current state by inspecting the connected database, computing the current state of the Ent schema +// and proceeds to diff the changes to create a migration plan. +func (a *Atlas) planInspect(ctx context.Context, conn dialect.ExecQuerier, name string, tables []*Table) (*migrate.Plan, error) { + current, err := a.atDriver.InspectSchema(ctx, a.schema, &schema.InspectOptions{ + Tables: func() (t []string) { + for i := range tables { + t = append(t, tables[i].Name) + } + return t + }(), + // Ent supports table-level inspection only. + Mode: schema.InspectSchemas | schema.InspectTables, + }) + if err != nil { + return nil, err + } + var types []string + if a.universalID { + types, err = a.loadTypes(ctx, conn) + if err != nil && !errors.Is(err, errTypeTableNotFound) { + return nil, err + } + a.types = types + } + realm, err := a.StateReader(tables...).ReadState(ctx) + if err != nil { + return nil, err + } + var desired *schema.Schema + switch { + case realm != nil && len(realm.Schemas) > 0: + desired = realm.Schemas[0] + default: + desired = &schema.Schema{} + } + desired.Name, desired.Attrs = current.Name, current.Attrs + return a.diff(ctx, name, current, desired, a.types[len(types):], noQualifierOpt) +} + +func (a *Atlas) planReplay(ctx context.Context, name string, tables []*Table) (*migrate.Plan, error) { + // We consider a database clean if there are no tables in the connected schema. + s, err := a.atDriver.InspectSchema(ctx, a.schema, nil) + if err != nil { + return nil, err + } + if len(s.Tables) > 0 { + return nil, &migrate.NotCleanError{Reason: fmt.Sprintf("found table %q", s.Tables[0].Name)} + } + // Replay the migration directory on the database. + ex, err := migrate.NewExecutor(a.atDriver, a.dir, &migrate.NopRevisionReadWriter{}) + if err != nil { + return nil, err + } + if err := ex.ExecuteN(ctx, 0); err != nil && !errors.Is(err, migrate.ErrNoPendingFiles) { + return nil, a.cleanSchema(ctx, a.schema, err) + } + // Inspect the current schema (migration directory). + current, err := a.atDriver.InspectSchema(ctx, a.schema, nil) + if err != nil { + return nil, a.cleanSchema(ctx, a.schema, err) + } + var types []string + if a.universalID { + if types, err = a.loadTypes(ctx, a.sqlDialect); err != nil && !errors.Is(err, errTypeTableNotFound) { + return nil, a.cleanSchema(ctx, a.schema, err) + } + a.types = types + } + if err := a.cleanSchema(ctx, a.schema, nil); err != nil { + return nil, fmt.Errorf("clean schemas after migration replaying: %w", err) + } + desired, err := a.tables(tables) + if err != nil { + return nil, err + } + // In case of replay mode, normalize the desired state (i.e. ent/schema). + if nr, ok := a.atDriver.(schema.Normalizer); ok { + ns, err := nr.NormalizeSchema(ctx, schema.New(current.Name).AddTables(desired...)) + if err != nil { + return nil, err + } + if len(ns.Tables) != len(desired) { + return nil, fmt.Errorf("unexpected number of tables after normalization: %d != %d", len(ns.Tables), len(desired)) + } + // Ensure all tables exist in the normalized format and the order is preserved. + for i, t := range desired { + d, ok := ns.Table(t.Name) + if !ok { + return nil, fmt.Errorf("table %q not found after normalization", t.Name) + } + desired[i] = d + } + } + return a.diff(ctx, name, current, + &schema.Schema{Name: current.Name, Attrs: current.Attrs, Tables: desired}, a.types[len(types):], + noQualifierOpt, + ) +} + +func (a *Atlas) diff(ctx context.Context, name string, current, desired *schema.Schema, newTypes []string, opts ...migrate.PlanOption) (*migrate.Plan, error) { + changes, err := (&diffDriver{a.atDriver, a.diffHooks}).SchemaDiff(current, desired, a.diffOptions...) + if err != nil { + return nil, err + } + filtered := make([]schema.Change, 0, len(changes)) + for _, c := range changes { + switch c.(type) { + // Select only table creation and modification. The reason we may encounter this, even though specific tables + // are passed to Inspect, is if the MySQL system variable 'lower_case_table_names' is set to 1. In such a case, + // the given tables will be returned from inspection because MySQL compares case-insensitive, but they won't + // match when compare them in code. + case *schema.AddTable, *schema.ModifyTable: + filtered = append(filtered, c) + } + } + if a.indent != "" { + opts = append(opts, func(opts *migrate.PlanOptions) { + opts.Indent = a.indent + }) + } + plan, err := a.atDriver.PlanChanges(ctx, name, filtered, opts...) + if err != nil { + return nil, err + } + if len(newTypes) > 0 { + plan.Changes = append(plan.Changes, &migrate.Change{ + Cmd: a.sqlDialect.atTypeRangeSQL(newTypes...), + Comment: fmt.Sprintf("add pk ranges for %s tables", strings.Join(newTypes, ",")), + }) + } + return plan, nil +} + +var errTypeTableNotFound = errors.New("ent_type table not found") + +// loadTypes loads the currently saved range allocations from the TypeTable. +func (a *Atlas) loadTypes(ctx context.Context, conn dialect.ExecQuerier) ([]string, error) { + // Fetch pre-existing type allocations. + exists, err := a.sqlDialect.tableExist(ctx, conn, TypeTable) + if err != nil { + return nil, err + } + if !exists { + return nil, errTypeTableNotFound + } + rows := &entsql.Rows{} + query, args := entsql.Dialect(a.dialect). + Select("type").From(entsql.Table(TypeTable)).OrderBy(entsql.Asc("id")).Query() + if err := conn.Query(ctx, query, args, rows); err != nil { + return nil, fmt.Errorf("query types table: %w", err) + } + defer rows.Close() + var types []string + if err := entsql.ScanSlice(rows, &types); err != nil { + return nil, err + } + return types, nil +} + +type db struct{ dialect.ExecQuerier } + +func (d *db) QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) { + rows := &entsql.Rows{} + if err := d.ExecQuerier.Query(ctx, query, args, rows); err != nil { + return nil, err + } + return rows.ColumnScanner.(*sql.Rows), nil +} + +func (d *db) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) { + var r sql.Result + if err := d.ExecQuerier.Exec(ctx, query, args, &r); err != nil { + return nil, err + } + return r, nil +} + +// tables converts an Ent table slice to an atlas tables. +func (a *Atlas) realm(tables []*Table) (*schema.Realm, error) { + var ( + sm = make(map[string]*schema.Schema) + byT = make(map[*Table]*schema.Table) + ) + for _, et := range tables { + if _, ok := sm[et.Schema]; !ok { + sm[et.Schema] = schema.New(et.Schema) + } + s := sm[et.Schema] + if et.View { + if et.Annotation == nil || et.Annotation.ViewAs == "" && et.Annotation.ViewFor[a.dialect] == "" { + continue // defined externally + } + def := et.Annotation.ViewFor[a.dialect] + if def == "" { + def = et.Annotation.ViewAs + } + av := schema.NewView(et.Name, def) + if et.Comment != "" { + av.SetComment(et.Comment) + } + if err := a.aVColumns(et, av); err != nil { + return nil, err + } + s.AddViews(av) + continue + } + at := schema.NewTable(et.Name) + if et.Comment != "" { + at.SetComment(et.Comment) + } + a.sqlDialect.atTable(et, at) + // universalID is the old implementation of the global unique id, relying on a table in the database. + // The new implementation is based on annotations attached to the schema. Only one can be enabled. + switch { + case a.universalID && et.Annotation != nil && et.Annotation.IncrementStart != nil: + return nil, errors.New("universal id and increment start annotation are mutually exclusive") + case a.universalID && et.Name != TypeTable && len(et.PrimaryKey) == 1: + r, err := a.pkRange(et) + if err != nil { + return nil, err + } + a.sqlDialect.atIncrementT(at, r) + case et.Annotation != nil && et.Annotation.IncrementStart != nil: + a.sqlDialect.atIncrementT(at, int64(*et.Annotation.IncrementStart)) + } + if err := a.aColumns(et, at); err != nil { + return nil, err + } + if err := a.aIndexes(et, at); err != nil { + return nil, err + } + s.AddTables(at) + byT[et] = at + } + for _, t1 := range tables { + if t1.View { + continue + } + t2 := byT[t1] + for _, fk1 := range t1.ForeignKeys { + fk2 := schema.NewForeignKey(fk1.Symbol). + SetTable(t2). + SetOnUpdate(schema.ReferenceOption(fk1.OnUpdate)). + SetOnDelete(schema.ReferenceOption(fk1.OnDelete)) + for _, c1 := range fk1.Columns { + c2, ok := t2.Column(c1.Name) + if !ok { + return nil, fmt.Errorf("unexpected fk %q column: %q", fk1.Symbol, c1.Name) + } + fk2.AddColumns(c2) + } + var refT *schema.Table + for _, t2 := range sm[fk1.RefTable.Schema].Tables { + if t2.Name == fk1.RefTable.Name { + refT = t2 + break + } + } + if refT == nil { + return nil, fmt.Errorf("unexpected fk %q ref-table: %q", fk1.Symbol, fk1.RefTable.Name) + } + fk2.SetRefTable(refT) + for _, c1 := range fk1.RefColumns { + c2, ok := refT.Column(c1.Name) + if !ok { + return nil, fmt.Errorf("unexpected fk %q ref-column: %q", fk1.Symbol, c1.Name) + } + fk2.AddRefColumns(c2) + } + t2.AddForeignKeys(fk2) + } + } + ss := slices.SortedFunc(maps.Values(sm), func(a, b *schema.Schema) int { + return strings.Compare(a.Name, b.Name) + }) + return &schema.Realm{Schemas: ss}, nil +} + +// tables converts an Ent table slice to an atlas table slice. +func (a *Atlas) tables(tables []*Table) ([]*schema.Table, error) { + r, err := a.realm(tables) + if err != nil { + return nil, err + } + var ts []*schema.Table + for _, s := range r.Schemas { + ts = append(ts, s.Tables...) + } + return ts, nil +} + +func (a *Atlas) aColumns(et *Table, at *schema.Table) error { + for _, c1 := range et.Columns { + c2 := schema.NewColumn(c1.Name). + SetNull(c1.Nullable) + if c1.Collation != "" { + c2.SetCollation(c1.Collation) + } + if c1.Comment != "" { + c2.SetComment(c1.Comment) + } + if err := a.sqlDialect.atTypeC(c1, c2); err != nil { + return err + } + if err := a.atDefault(c1, c2); err != nil { + return err + } + if c1.Unique && (len(et.PrimaryKey) != 1 || et.PrimaryKey[0] != c1) { + a.sqlDialect.atUniqueC(et, c1, at, c2) + } + if c1.Increment { + a.sqlDialect.atIncrementC(at, c2) + } + at.AddColumns(c2) + } + return nil +} + +func (a *Atlas) aVColumns(et *Table, at *schema.View) error { + for _, c1 := range et.Columns { + c2 := schema.NewColumn(c1.Name). + SetNull(c1.Nullable) + if c1.Collation != "" { + c2.SetCollation(c1.Collation) + } + if c1.Comment != "" { + c2.SetComment(c1.Comment) + } + if err := a.sqlDialect.atTypeC(c1, c2); err != nil { + return err + } + if err := a.atDefault(c1, c2); err != nil { + return err + } + at.AddColumns(c2) + } + return nil +} + +func (a *Atlas) atDefault(c1 *Column, c2 *schema.Column) error { + if c1.Default == nil || !a.sqlDialect.supportsDefault(c1) { + return nil + } + switch x := c1.Default.(type) { + case Expr: + if len(x) > 1 && (x[0] != '(' || x[len(x)-1] != ')') { + x = "(" + x + ")" + } + c2.SetDefault(&schema.RawExpr{X: string(x)}) + case map[string]Expr: + d, ok := x[a.sqlDialect.Dialect()] + if !ok { + return nil + } + if len(d) > 1 && (d[0] != '(' || d[len(d)-1] != ')') { + d = "(" + d + ")" + } + c2.SetDefault(&schema.RawExpr{X: string(d)}) + default: + switch { + case c1.Type == field.TypeJSON: + s, ok := c1.Default.(string) + if !ok { + return fmt.Errorf("invalid default value for JSON column %q: %v", c1.Name, c1.Default) + } + c2.SetDefault(&schema.Literal{V: strings.ReplaceAll(s, "'", "''")}) + default: + // Keep backwards compatibility with the old default value format. + x := fmt.Sprint(c1.Default) + if v, ok := c1.Default.(string); ok && c1.Type != field.TypeUUID && c1.Type != field.TypeTime { + // Escape single quote by replacing each with 2. + x = fmt.Sprintf("'%s'", strings.ReplaceAll(v, "'", "''")) + } + c2.SetDefault(&schema.RawExpr{X: x}) + } + } + return nil +} + +func (a *Atlas) aIndexes(et *Table, at *schema.Table) error { + // Primary-key index. + pk := make([]*schema.Column, 0, len(et.PrimaryKey)) + for _, c1 := range et.PrimaryKey { + c2, ok := at.Column(c1.Name) + if !ok { + return fmt.Errorf("unexpected primary-key column: %q", c1.Name) + } + pk = append(pk, c2) + } + // CreateFunc might clear the primary keys. + if len(pk) > 0 { + at.SetPrimaryKey(schema.NewPrimaryKey(pk...)) + } + // Rest of indexes. + for _, idx1 := range et.Indexes { + idx2 := schema.NewIndex(idx1.Name). + SetUnique(idx1.Unique) + if err := a.sqlDialect.atIndex(idx1, at, idx2); err != nil { + return err + } + desc := descIndexes(idx1) + for _, p := range idx2.Parts { + p.Desc = desc[p.C.Name] + } + at.AddIndexes(idx2) + } + return nil +} + +// setupTables ensures the table is configured properly, like table columns +// are linked to their indexes, and PKs columns are defined. +func (a *Atlas) setupTables(tables []*Table) { + for _, t := range tables { + t.mu.Lock() + if t.columns == nil { + t.columns = make(map[string]*Column, len(t.Columns)) + } + for _, c := range t.Columns { + t.columns[c.Name] = c + } + for _, idx := range t.Indexes { + idx.Name = a.symbol(idx.Name) + for _, c := range idx.Columns { + c.indexes.append(idx) + } + } + for _, pk := range t.PrimaryKey { + c := t.columns[pk.Name] + c.Key = PrimaryKey + pk.Key = PrimaryKey + } + for _, fk := range t.ForeignKeys { + fk.Symbol = a.symbol(fk.Symbol) + for i := range fk.Columns { + fk.Columns[i].foreign = fk + } + } + t.mu.Unlock() + } +} + +// symbol makes sure the symbol length is not longer than the maxlength in the dialect. +func (a *Atlas) symbol(name string) string { + size := 64 + if a.dialect == dialect.Postgres { + size = 63 + } + if len(name) <= size { + return name + } + return fmt.Sprintf("%s_%x", name[:size-33], md5.Sum([]byte(name))) +} + +// entDialect returns the Ent dialect as configured by the dialect option. +func (a *Atlas) entDialect(ctx context.Context, drv dialect.Driver) (sqlDialect, error) { + var d sqlDialect + switch a.dialect { + case dialect.MySQL: + d = &MySQL{Driver: drv} + case dialect.SQLite: + d = &SQLite{Driver: drv, WithForeignKeys: a.withForeignKeys} + case dialect.Postgres: + d = &Postgres{Driver: drv} + default: + return nil, fmt.Errorf("sql/schema: unsupported dialect %q", a.dialect) + } + if err := d.init(ctx); err != nil { + return nil, err + } + return d, nil +} + +func (a *Atlas) pkRange(et *Table) (int64, error) { + idx := indexOf(a.types, et.Name) + // If the table re-created, re-use its range from + // the past. Otherwise, allocate a new id-range. + if idx == -1 { + if len(a.types) > MaxTypes { + return 0, fmt.Errorf("max number of types exceeded: %d", MaxTypes) + } + idx = len(a.types) + a.types = append(a.types, et.Name) + } + return int64(idx << 32), nil +} + +func setAtChecks(et *Table, at *schema.Table) { + if check := et.Annotation.Check; check != "" { + at.AddChecks(&schema.Check{ + Expr: check, + }) + } + if checks := et.Annotation.Checks; len(et.Annotation.Checks) > 0 { + names := make([]string, 0, len(checks)) + for name := range checks { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + at.AddChecks(&schema.Check{ + Name: name, + Expr: checks[name], + }) + } + } +} + +// descIndexes returns a map holding the DESC mapping if exist. +func descIndexes(idx *Index) map[string]bool { + descs := make(map[string]bool) + if idx.Annotation == nil { + return descs + } + // If DESC (without a column) was defined on the + // annotation, map it to the single column index. + if idx.Annotation.Desc && len(idx.Columns) == 1 { + descs[idx.Columns[0].Name] = idx.Annotation.Desc + } + for column, desc := range idx.Annotation.DescColumns { + descs[column] = desc + } + return descs +} + +// driver decorates the atlas migrate.Driver and adds "diff hooking" and functionality. +type diffDriver struct { + migrate.Driver + hooks []DiffHook // hooks to apply +} + +// RealmDiff creates the diff between two realms. Since Ent does not care about Realms, +// not even schema changes, calling this method raises an error. +func (r *diffDriver) RealmDiff(_, _ *schema.Realm, _ ...schema.DiffOption) ([]schema.Change, error) { + return nil, errors.New("sqlDialect does not support working with realms") +} + +// SchemaDiff creates the diff between two schemas, but includes "diff hooks". +func (r *diffDriver) SchemaDiff(from, to *schema.Schema, opts ...schema.DiffOption) ([]schema.Change, error) { + var d Differ = DiffFunc(func(current, desired *schema.Schema) ([]schema.Change, error) { + return r.Driver.SchemaDiff(current, desired, opts...) + }) + for i := len(r.hooks) - 1; i >= 0; i-- { + d = r.hooks[i](d) + } + return d.Diff(from, to) +} + +// removeAttr is a temporary patch due to compiler errors we get by using the generic +// schema.RemoveAttr function (:1: internal compiler error: panic: ...). +// Can be removed in Go 1.20. See: https://github.com/golang/go/issues/54302. +func removeAttr(attrs []schema.Attr, t reflect.Type) []schema.Attr { + f := make([]schema.Attr, 0, len(attrs)) + for _, a := range attrs { + if reflect.TypeOf(a) != t { + f = append(f, a) + } + } + return f +} diff --git a/dialect/sql/schema/migrate.go b/dialect/sql/schema/migrate.go new file mode 100644 index 0000000..7b955c0 --- /dev/null +++ b/dialect/sql/schema/migrate.go @@ -0,0 +1,160 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package schema + +import ( + "context" + "fmt" + "math" + + "entgo.io/ent/dialect" + "entgo.io/ent/dialect/sql" + "entgo.io/ent/schema/field" +) + +const ( + // TypeTable defines the table name holding the type information. + TypeTable = "ent_types" + + // MaxTypes defines the max number of types can be created when + // defining universal ids. The left 16-bits are reserved. + MaxTypes = math.MaxUint16 +) + +// NewTypesTable returns a new table for holding the global-id information. +func NewTypesTable() *Table { + return NewTable(TypeTable). + AddPrimary(&Column{Name: "id", Type: field.TypeUint, Increment: true}). + AddColumn(&Column{Name: "type", Type: field.TypeString, Unique: true}) +} + +// MigrateOption allows configuring Atlas using functional arguments. +type MigrateOption func(*Atlas) + +// WithGlobalUniqueID sets the universal ids options to the migration. +// Defaults to false. +func WithGlobalUniqueID(b bool) MigrateOption { + return func(a *Atlas) { + a.universalID = b + } +} + +// WithIndent sets Atlas to generate SQL statements with indentation. +// An empty string indicates no indentation. +func WithIndent(indent string) MigrateOption { + return func(a *Atlas) { + a.indent = indent + } +} + +// WithErrNoPlan sets Atlas to returns a migrate.ErrNoPlan in case +// the migration plan is empty. Defaults to false. +func WithErrNoPlan(b bool) MigrateOption { + return func(a *Atlas) { + a.errNoPlan = b + } +} + +// WithSchemaName sets the database schema for the migration. +// If not set, the CURRENT_SCHEMA() is used. +func WithSchemaName(ns string) MigrateOption { + return func(a *Atlas) { + a.schema = ns + } +} + +// WithDropColumn sets the columns dropping option to the migration. +// Defaults to false. +func WithDropColumn(b bool) MigrateOption { + return func(a *Atlas) { + a.dropColumns = b + } +} + +// WithDropIndex sets the indexes dropping option to the migration. +// Defaults to false. +func WithDropIndex(b bool) MigrateOption { + return func(a *Atlas) { + a.dropIndexes = b + } +} + +// WithForeignKeys enables creating foreign-key in ddl. Defaults to true. +func WithForeignKeys(b bool) MigrateOption { + return func(a *Atlas) { + a.withForeignKeys = b + } +} + +// WithHooks adds a list of hooks to the schema migration. +func WithHooks(hooks ...Hook) MigrateOption { + return func(a *Atlas) { + a.hooks = append(a.hooks, hooks...) + } +} + +type ( + // Creator is the interface that wraps the Create method. + Creator interface { + // Create creates the given tables in the database. See Migrate.Create for more details. + Create(context.Context, ...*Table) error + } + + // The CreateFunc type is an adapter to allow the use of ordinary function as Creator. + // If f is a function with the appropriate signature, CreateFunc(f) is a Creator that calls f. + CreateFunc func(context.Context, ...*Table) error + + // Hook defines the "create middleware". A function that gets a Creator and returns a Creator. + // For example: + // + // hook := func(next schema.Creator) schema.Creator { + // return schema.CreateFunc(func(ctx context.Context, tables ...*schema.Table) error { + // fmt.Println("Tables:", tables) + // return next.Create(ctx, tables...) + // }) + // } + // + Hook func(Creator) Creator +) + +// Create calls f(ctx, tables...). +func (f CreateFunc) Create(ctx context.Context, tables ...*Table) error { + return f(ctx, tables...) +} + +// exist checks if the given COUNT query returns a value >= 1. +func exist(ctx context.Context, conn dialect.ExecQuerier, query string, args ...any) (bool, error) { + rows := &sql.Rows{} + if err := conn.Query(ctx, query, args, rows); err != nil { + return false, fmt.Errorf("reading schema information %w", err) + } + defer rows.Close() + n, err := sql.ScanInt(rows) + if err != nil { + return false, err + } + return n > 0, nil +} + +func indexOf(a []string, s string) int { + for i := range a { + if a[i] == s { + return i + } + } + return -1 +} + +type sqlDialect interface { + atBuilder + dialect.Driver + init(context.Context) error + tableExist(context.Context, dialect.ExecQuerier, string) (bool, error) +} + +// verifyRanger wraps the method for verifying global-id range correctness. +type verifyRanger interface { + verifyRange(context.Context, dialect.ExecQuerier, *Table, int64) error +} diff --git a/dialect/sql/schema/migrate_test.go b/dialect/sql/schema/migrate_test.go new file mode 100644 index 0000000..b0560b1 --- /dev/null +++ b/dialect/sql/schema/migrate_test.go @@ -0,0 +1,464 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package schema + +import ( + "context" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "sync" + "testing" + "text/template" + "time" + + "ariga.io/atlas/sql/migrate" + "ariga.io/atlas/sql/schema" + "ariga.io/atlas/sql/sqlite" + "ariga.io/atlas/sql/sqltool" + "entgo.io/ent/dialect" + "entgo.io/ent/dialect/entsql" + "entgo.io/ent/dialect/sql" + "entgo.io/ent/schema/field" + + "github.com/DATA-DOG/go-sqlmock" + _ "github.com/mattn/go-sqlite3" + "github.com/stretchr/testify/require" +) + +func TestMigrate_SchemaName(t *testing.T) { + db, mk, err := sqlmock.New() + require.NoError(t, err) + mk.ExpectQuery(escape("SHOW server_version_num")). + WillReturnRows(sqlmock.NewRows([]string{"server_version_num"}).AddRow("130000")) + mk.ExpectQuery(escape("SELECT current_setting('server_version_num'), current_setting('default_table_access_method', true), current_setting('crdb_version', true)")). + WillReturnRows(sqlmock.NewRows([]string{"current_setting", "current_setting", "current_setting"}).AddRow("130000", "heap", "")) + mk.ExpectQuery("SELECT nspname AS schema_name,.+"). + WithArgs("public"). // Schema "public" param is used. + WillReturnRows(sqlmock.NewRows([]string{"schema_name", "comment"}).AddRow("public", "default schema")) + mk.ExpectQuery("SELECT t3.oid, t1.table_schema,.+"). + WillReturnRows(sqlmock.NewRows([]string{})) + m, err := NewMigrate(sql.OpenDB("postgres", db), WithSchemaName("public"), WithDiffHook(func(next Differ) Differ { + return DiffFunc(func(current, desired *schema.Schema) ([]schema.Change, error) { + return nil, nil // Noop. + }) + })) + require.NoError(t, err) + require.NoError(t, m.Create(context.Background())) + require.NoError(t, mk.ExpectationsWereMet()) + + // Without schema name the CURRENT_SCHEMA is used. + mk.ExpectQuery(escape("SHOW server_version_num")). + WillReturnRows(sqlmock.NewRows([]string{"server_version_num"}).AddRow("130000")) + mk.ExpectQuery(escape("SELECT current_setting('server_version_num'), current_setting('default_table_access_method', true), current_setting('crdb_version', true)")). + WillReturnRows(sqlmock.NewRows([]string{"current_setting", "current_setting", "current_setting"}).AddRow("130000", "heap", "")) + mk.ExpectQuery("SELECT nspname AS schema_name,.+CURRENT_SCHEMA().+"). + WillReturnRows(sqlmock.NewRows([]string{"schema_name", "comment"}).AddRow("public", "default schema")) + mk.ExpectQuery("SELECT t3.oid, t1.table_schema,.+"). + WillReturnRows(sqlmock.NewRows([]string{})) + m, err = NewMigrate(sql.OpenDB("postgres", db), WithDiffHook(func(next Differ) Differ { + return DiffFunc(func(current, desired *schema.Schema) ([]schema.Change, error) { + return nil, nil // Noop. + }) + })) + require.NoError(t, err) + require.NoError(t, m.Create(context.Background())) +} + +func escape(query string) string { + rows := strings.Split(query, "\n") + for i := range rows { + rows[i] = strings.TrimPrefix(rows[i], " ") + } + query = strings.Join(rows, " ") + return strings.TrimSpace(regexp.QuoteMeta(query)) + "$" +} + +func TestMigrate_Formatter(t *testing.T) { + db, _, err := sqlmock.New() + require.NoError(t, err) + + // If no formatter is given it will be set according to the given migration directory implementation. + for _, tt := range []struct { + dir migrate.Dir + fmt migrate.Formatter + }{ + {&migrate.LocalDir{}, sqltool.GolangMigrateFormatter}, + {&sqltool.GolangMigrateDir{}, sqltool.GolangMigrateFormatter}, + {&sqltool.GooseDir{}, sqltool.GooseFormatter}, + {&sqltool.DBMateDir{}, sqltool.DBMateFormatter}, + {&sqltool.FlywayDir{}, sqltool.FlywayFormatter}, + {&sqltool.LiquibaseDir{}, sqltool.LiquibaseFormatter}, + {struct{ migrate.Dir }{}, sqltool.GolangMigrateFormatter}, // default one if migration dir is unknown + } { + m, err := NewMigrate(sql.OpenDB("", db), WithDir(tt.dir)) + require.NoError(t, err) + require.Equal(t, tt.fmt, m.fmt) + } + + // If a formatter is given, it is not overridden. + m, err := NewMigrate(sql.OpenDB("", db), WithDir(&migrate.LocalDir{}), WithFormatter(migrate.DefaultFormatter)) + require.NoError(t, err) + require.Equal(t, migrate.DefaultFormatter, m.fmt) +} + +func TestMigrate_DiffJoinTableAllocationBC(t *testing.T) { + // Due to a bug in previous versions, if the universal ID option was enabled and the schema did contain an M2M + // relation, the join table would have had an entry for the join table in the types table. This test ensures, + // that the PK range allocated for the join table stays in place, since it's removal would break existing projects + // due to shifted ranges. + + db, err := sql.Open(dialect.SQLite, "file:test?mode=memory&_fk=1") + require.NoError(t, err) + + // Mock an existing database with an allocation for a join table. + for _, stmt := range []string{ + "CREATE TABLE `groups` (`id` integer NOT NULL PRIMARY KEY AUTOINCREMENT, `name` text NOT NULL);", + "CREATE INDEX `short` ON `groups` (`id`);", + "CREATE INDEX `long____________________________1cb2e7e47a309191385af4ad320875b1` ON `groups` (`id`);", + "CREATE TABLE `users` (`id` integer NOT NULL PRIMARY KEY AUTOINCREMENT, `name` text NOT NULL);", + "INSERT INTO sqlite_sequence (name, seq) VALUES (\"users\", 4294967296);", + "CREATE TABLE `user_groups` (`user_id` integer NOT NULL, `group_id` integer NOT NULL, PRIMARY KEY (`user_id`, `group_id`), CONSTRAINT `user_groups_user_id` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE, CONSTRAINT `user_groups_group_id` FOREIGN KEY (`group_id`) REFERENCES `groups` (`id`) ON DELETE CASCADE);", + "INSERT INTO sqlite_sequence (name, seq) VALUES (\"user_groups\", 8589934592);", + "CREATE TABLE `ent_types` (`id` integer NOT NULL PRIMARY KEY AUTOINCREMENT, `type` text NOT NULL);", + "CREATE UNIQUE INDEX `ent_types_type_key` ON `ent_types` (`type`);", + "INSERT INTO `ent_types` (`type`) VALUES ('groups'), ('users'), ('user_groups');", + "INSERT INTO `groups` (`name`) VALUES ('seniors'), ('juniors')", + "INSERT INTO `users` (`name`) VALUES ('masseelch'), ('a8m'), ('rotemtam')", + "INSERT INTO `user_groups` (`user_id`, `group_id`) VALUES (4294967297, 1), (4294967298, 1), (4294967299, 2)", + } { + _, err := db.ExecContext(context.Background(), stmt) + require.NoError(t, err) + } + + // Expect to have no changes when migration runs with fix. + m, err := NewMigrate(db, WithGlobalUniqueID(true), WithDiffHook(func(next Differ) Differ { + return DiffFunc(func(current, desired *schema.Schema) ([]schema.Change, error) { + changes, err := next.Diff(current, desired) + if err != nil { + return nil, err + } + require.Len(t, changes, 0) + return changes, nil + }) + })) + require.NoError(t, err) + require.NoError(t, m.Create(context.Background(), tables...)) + + // Expect to have no changes to the allocation when the join table is dropped. + m, err = NewMigrate(db, WithGlobalUniqueID(true)) + require.NoError(t, err) + require.NoError(t, m.Create(context.Background(), groupsTable, usersTable)) + + rows, err := db.QueryContext(context.Background(), "SELECT `type` from `ent_types` ORDER BY `id` ASC") + require.NoError(t, err) + var types []string + for rows.Next() { + var typ string + require.NoError(t, rows.Scan(&typ)) + types = append(types, typ) + } + require.NoError(t, rows.Err()) + require.Equal(t, []string{"groups", "users", "user_groups"}, types) +} + +var ( + groupsColumns = []*Column{ + {Name: "id", Type: field.TypeInt, Increment: true}, + {Name: "name", Type: field.TypeString}, + } + groupsTable = &Table{ + Name: "groups", + Columns: groupsColumns, + PrimaryKey: []*Column{groupsColumns[0]}, + Indexes: []*Index{ + { + Name: "short", + Columns: []*Column{groupsColumns[0]}}, + { + Name: "long_" + strings.Repeat("_", 60), + Columns: []*Column{groupsColumns[0]}, + }, + }, + } + usersColumns = []*Column{ + {Name: "id", Type: field.TypeInt, Increment: true}, + {Name: "name", Type: field.TypeString}, + } + usersTable = &Table{ + Name: "users", + Columns: usersColumns, + PrimaryKey: []*Column{usersColumns[0]}, + } + userGroupsColumns = []*Column{ + {Name: "user_id", Type: field.TypeInt}, + {Name: "group_id", Type: field.TypeInt}, + } + userGroupsTable = &Table{ + Name: "user_groups", + Columns: userGroupsColumns, + PrimaryKey: []*Column{userGroupsColumns[0], userGroupsColumns[1]}, + ForeignKeys: []*ForeignKey{ + { + Symbol: "user_groups_user_id", + Columns: []*Column{userGroupsColumns[0]}, + RefColumns: []*Column{usersColumns[0]}, + OnDelete: Cascade, + }, + { + Symbol: "user_groups_group_id", + Columns: []*Column{userGroupsColumns[1]}, + RefColumns: []*Column{groupsColumns[0]}, + OnDelete: Cascade, + }, + }, + } + tables = []*Table{ + groupsTable, + usersTable, + userGroupsTable, + } + petColumns = []*Column{ + {Name: "id", Type: field.TypeInt, Increment: true}, + } + petsTable = &Table{ + Name: "pets", + Columns: petColumns, + PrimaryKey: petColumns, + } +) + +func init() { + userGroupsTable.ForeignKeys[0].RefTable = usersTable + userGroupsTable.ForeignKeys[1].RefTable = groupsTable +} + +func TestMigrate_Diff(t *testing.T) { + ctx := context.Background() + + db, err := sql.Open(dialect.SQLite, "file:test?mode=memory&_fk=1") + require.NoError(t, err) + + p := t.TempDir() + d, err := migrate.NewLocalDir(p) + require.NoError(t, err) + + m, err := NewMigrate(db, WithDir(d)) + require.NoError(t, err) + require.NoError(t, m.Diff(ctx, &Table{Name: "users"})) + v := time.Now().UTC().Format("20060102150405") + requireFileEqual(t, filepath.Join(p, v+"_changes.up.sql"), "-- create \"users\" table\nCREATE TABLE `users` ();\n") + requireFileEqual(t, filepath.Join(p, v+"_changes.down.sql"), "-- reverse: create \"users\" table\nDROP TABLE `users`;\n") + require.FileExists(t, filepath.Join(p, migrate.HashFileName)) + + // Test integrity file. + p = t.TempDir() + d, err = migrate.NewLocalDir(p) + require.NoError(t, err) + m, err = NewMigrate(db, WithDir(d)) + require.NoError(t, err) + require.NoError(t, m.Diff(ctx, &Table{Name: "users"})) + requireFileEqual(t, filepath.Join(p, v+"_changes.up.sql"), "-- create \"users\" table\nCREATE TABLE `users` ();\n") + requireFileEqual(t, filepath.Join(p, v+"_changes.down.sql"), "-- reverse: create \"users\" table\nDROP TABLE `users`;\n") + require.FileExists(t, filepath.Join(p, migrate.HashFileName)) + require.NoError(t, d.WriteFile("tmp.sql", nil)) + require.ErrorIs(t, m.Diff(ctx, &Table{Name: "users"}), migrate.ErrChecksumMismatch) + + p = t.TempDir() + d, err = migrate.NewLocalDir(p) + require.NoError(t, err) + f, err := migrate.NewTemplateFormatter( + template.Must(template.New("").Parse("{{ .Name }}.sql")), + template.Must(template.New("").Parse( + `{{ range .Changes }}{{ printf "%s;\n" .Cmd }}{{ end }}`, + )), + ) + require.NoError(t, err) + + // Join tables (mapping between user and group) will not result in an entry to the types table. + m, err = NewMigrate(db, WithFormatter(f), WithDir(d), WithGlobalUniqueID(true)) + require.NoError(t, err) + require.NoError(t, m.Diff(ctx, tables...)) + changesSQL := strings.Join([]string{ + "CREATE TABLE `groups` (`id` integer NOT NULL PRIMARY KEY AUTOINCREMENT, `name` text NOT NULL);", + "CREATE INDEX `short` ON `groups` (`id`);", + "CREATE INDEX `long____________________________1cb2e7e47a309191385af4ad320875b1` ON `groups` (`id`);", + "CREATE TABLE `users` (`id` integer NOT NULL PRIMARY KEY AUTOINCREMENT, `name` text NOT NULL);", + fmt.Sprintf("INSERT INTO sqlite_sequence (name, seq) VALUES (\"users\", %d);", 1<<32), + "CREATE TABLE `user_groups` (`user_id` integer NOT NULL, `group_id` integer NOT NULL, PRIMARY KEY (`user_id`, `group_id`), CONSTRAINT `user_groups_user_id` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE, CONSTRAINT `user_groups_group_id` FOREIGN KEY (`group_id`) REFERENCES `groups` (`id`) ON DELETE CASCADE);", + "CREATE TABLE `ent_types` (`id` integer NOT NULL PRIMARY KEY AUTOINCREMENT, `type` text NOT NULL);", + "CREATE UNIQUE INDEX `ent_types_type_key` ON `ent_types` (`type`);", + "INSERT INTO `ent_types` (`type`) VALUES ('groups'), ('users');", + "", + }, "\n") + requireFileEqual(t, filepath.Join(p, "changes.sql"), changesSQL) + + // Skipping table creation should write only the ent_type insertion. + m, err = NewMigrate(db, WithFormatter(f), WithDir(d), WithGlobalUniqueID(true), WithDiffOptions(schema.DiffSkipChanges(&schema.AddTable{}))) + require.NoError(t, err) + require.NoError(t, m.Diff(ctx, tables...)) + requireFileEqual(t, filepath.Join(p, "changes.sql"), "INSERT INTO `ent_types` (`type`) VALUES ('groups'), ('users');\n") + + // Enable indentations. + m, err = NewMigrate(db, WithFormatter(f), WithDir(d), WithGlobalUniqueID(true), WithIndent(" ")) + require.NoError(t, err) + // Adding another node will result in a new entry to the TypeTable (without actually creating it). + _, err = db.ExecContext(ctx, changesSQL, nil, nil) + require.NoError(t, err) + require.NoError(t, m.NamedDiff(ctx, "changes_2", petsTable)) + requireFileEqual(t, + filepath.Join(p, "changes_2.sql"), strings.Join([]string{ + "CREATE TABLE `pets` (\n `id` integer NOT NULL PRIMARY KEY AUTOINCREMENT\n);", + fmt.Sprintf("INSERT INTO sqlite_sequence (name, seq) VALUES (\"pets\", %d);", 2<<32), + "INSERT INTO `ent_types` (`type`) VALUES ('pets');", "", + }, "\n")) + + // Checksum will be updated as well. + require.NoError(t, migrate.Validate(d)) + + require.NoError(t, m.NamedDiff(ctx, "no_changes"), "should not error if WithErrNoPlan is not set") + // Enable WithErrNoPlan. + m, err = NewMigrate(db, WithFormatter(f), WithDir(d), WithGlobalUniqueID(true), WithErrNoPlan(true)) + require.NoError(t, err) + err = m.NamedDiff(ctx, "no_changes") + require.ErrorIs(t, err, migrate.ErrNoPlan) +} + +func requireFileEqual(t *testing.T, name, contents string) { + c, err := os.ReadFile(name) + require.NoError(t, err) + require.Equal(t, contents, string(c)) +} + +func TestMigrateWithoutForeignKeys(t *testing.T) { + tbl := &schema.Table{ + Name: "tbl", + Columns: []*schema.Column{ + {Name: "id", Type: &schema.ColumnType{Type: &schema.IntegerType{T: "bigint"}}}, + }, + } + fk := &schema.ForeignKey{ + Symbol: "fk", + Table: tbl, + Columns: tbl.Columns[1:], + RefTable: tbl, + RefColumns: tbl.Columns[:1], + OnUpdate: schema.NoAction, + OnDelete: schema.Cascade, + } + tbl.ForeignKeys = append(tbl.ForeignKeys, fk) + t.Run("AddTable", func(t *testing.T) { + mdiff := DiffFunc(func(_, _ *schema.Schema) ([]schema.Change, error) { + return []schema.Change{ + &schema.AddTable{ + T: tbl, + }, + }, nil + }) + df, err := withoutForeignKeys(mdiff).Diff(nil, nil) + require.NoError(t, err) + require.Len(t, df, 1) + actual, ok := df[0].(*schema.AddTable) + require.True(t, ok) + require.Nil(t, actual.T.ForeignKeys) + }) + t.Run("ModifyTable", func(t *testing.T) { + mdiff := DiffFunc(func(_, _ *schema.Schema) ([]schema.Change, error) { + return []schema.Change{ + &schema.ModifyTable{ + T: tbl, + Changes: []schema.Change{ + &schema.AddIndex{ + I: &schema.Index{ + Name: "id_key", + Parts: []*schema.IndexPart{ + {C: tbl.Columns[0]}, + }, + }, + }, + &schema.DropForeignKey{ + F: fk, + }, + &schema.AddForeignKey{ + F: fk, + }, + &schema.ModifyForeignKey{ + From: fk, + To: fk, + Change: schema.ChangeRefColumn, + }, + &schema.AddColumn{ + C: &schema.Column{Name: "name", Type: &schema.ColumnType{Type: &schema.StringType{T: "varchar(255)"}}}, + }, + }, + }, + }, nil + }) + df, err := withoutForeignKeys(mdiff).Diff(nil, nil) + require.NoError(t, err) + require.Len(t, df, 1) + actual, ok := df[0].(*schema.ModifyTable) + require.True(t, ok) + require.Len(t, actual.Changes, 2) + addIndex, ok := actual.Changes[0].(*schema.AddIndex) + require.True(t, ok) + require.EqualValues(t, "id_key", addIndex.I.Name) + addColumn, ok := actual.Changes[1].(*schema.AddColumn) + require.True(t, ok) + require.EqualValues(t, "name", addColumn.C.Name) + }) +} + +func TestAtlas_StateReader(t *testing.T) { + db, err := sql.Open(dialect.SQLite, "file:test?mode=memory&_fk=1") + require.NoError(t, err) + m, err := NewMigrate(db) + require.NoError(t, err) + realm, err := m.StateReader(&Table{ + Name: "users", + Columns: []*Column{ + {Name: "id", Type: field.TypeInt64, Increment: true}, + {Name: "name", Type: field.TypeString}, + {Name: "active", Type: field.TypeBool}, + }, + Annotation: &entsql.Annotation{ + IncrementStart: func(i int) *int { return &i }(100), + }, + }).ReadState(context.Background()) + require.NoError(t, err) + require.NotNil(t, realm) + require.Len(t, realm.Schemas, 1) + require.Len(t, realm.Schemas[0].Tables, 1) + require.Equal(t, "users", realm.Schemas[0].Tables[0].Name) + require.Equal(t, []schema.Attr{&sqlite.AutoIncrement{Seq: 100}}, realm.Schemas[0].Tables[0].Attrs) + require.Equal(t, + realm.Schemas[0].Tables[0].Columns, + []*schema.Column{ + schema.NewIntColumn("id", "integer"). + AddAttrs(&sqlite.AutoIncrement{}), + schema.NewStringColumn("name", "text"), + schema.NewBoolColumn("active", "bool"), + }, + ) +} + +func TestAtlas_ParallelCreate(t *testing.T) { + var wg sync.WaitGroup + wg.Add(10) + for i := 0; i < 10; i++ { + db, err := sql.Open(dialect.SQLite, fmt.Sprintf("file:test-%d?mode=memory&_fk=1", i)) + require.NoError(t, err) + m, err := NewMigrate(db) + require.NoError(t, err) + go func() { + defer wg.Done() + require.NoError(t, m.Create(context.Background(), petsTable)) + require.NoError(t, db.Close()) + }() + } + wg.Wait() +} diff --git a/dialect/sql/schema/mysql.go b/dialect/sql/schema/mysql.go new file mode 100644 index 0000000..47aff25 --- /dev/null +++ b/dialect/sql/schema/mysql.go @@ -0,0 +1,333 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package schema + +import ( + "context" + "fmt" + "math" + "reflect" + "strconv" + "strings" + + "entgo.io/ent/dialect" + "entgo.io/ent/dialect/sql" + "entgo.io/ent/schema/field" + + "ariga.io/atlas/sql/migrate" + "ariga.io/atlas/sql/mysql" + "ariga.io/atlas/sql/schema" +) + +// MySQL adapter for Atlas migration engine. +type MySQL struct { + dialect.Driver + schema string + version string +} + +// init loads the MySQL version from the database for later use in the migration process. +func (d *MySQL) init(ctx context.Context) error { + if d.version != "" { + return nil // already initialized. + } + rows := &sql.Rows{} + if err := d.Query(ctx, "SHOW VARIABLES LIKE 'version'", []any{}, rows); err != nil { + return fmt.Errorf("mysql: querying mysql version %w", err) + } + defer rows.Close() + if !rows.Next() { + if err := rows.Err(); err != nil { + return err + } + return fmt.Errorf("mysql: version variable was not found") + } + version := make([]string, 2) + if err := rows.Scan(&version[0], &version[1]); err != nil { + return fmt.Errorf("mysql: scanning mysql version: %w", err) + } + d.version = version[1] + return nil +} + +func (d *MySQL) tableExist(ctx context.Context, conn dialect.ExecQuerier, name string) (bool, error) { + query, args := sql.Select(sql.Count("*")).From(sql.Table("TABLES").Schema("INFORMATION_SCHEMA")). + Where(sql.And( + d.matchSchema(), + sql.EQ("TABLE_NAME", name), + )).Query() + return exist(ctx, conn, query, args...) +} + +// matchSchema returns the predicate for matching table schema. +func (d *MySQL) matchSchema(columns ...string) *sql.Predicate { + column := "TABLE_SCHEMA" + if len(columns) > 0 { + column = columns[0] + } + if d.schema != "" { + return sql.EQ(column, d.schema) + } + return sql.EQ(column, sql.Raw("(SELECT DATABASE())")) +} + +func (d *MySQL) atOpen(conn dialect.ExecQuerier) (migrate.Driver, error) { + return mysql.Open(&db{ExecQuerier: conn}) +} + +func (d *MySQL) atTable(t1 *Table, t2 *schema.Table) { + t2.SetCharset("utf8mb4").SetCollation("utf8mb4_bin") + if t1.Annotation == nil { + return + } + if charset := t1.Annotation.Charset; charset != "" { + t2.SetCharset(charset) + } + if collate := t1.Annotation.Collation; collate != "" { + t2.SetCollation(collate) + } + if opts := t1.Annotation.Options; opts != "" { + t2.AddAttrs(&mysql.CreateOptions{ + V: opts, + }) + } + // Check if the connected database supports the CHECK clause. + // For MySQL, is >= "8.0.16" and for MariaDB it is "10.2.1". + v1, v2 := d.version, "8.0.16" + if v, ok := d.mariadb(); ok { + v1, v2 = v, "10.2.1" + } + if compareVersions(v1, v2) >= 0 { + setAtChecks(t1, t2) + } +} + +func (d *MySQL) supportsDefault(c *Column) bool { + _, maria := d.mariadb() + switch c.Default.(type) { + case Expr, map[string]Expr: + if maria { + return compareVersions(d.version, "10.2.0") >= 0 + } + return c.supportDefault() && compareVersions(d.version, "8.0.0") >= 0 + default: + return c.supportDefault() || maria + } +} + +func (d *MySQL) supportsUUID() bool { + _, maria := d.mariadb() + return maria && compareVersions(d.version, "10.7.0") >= 0 +} + +func (d *MySQL) atTypeC(c1 *Column, c2 *schema.Column) error { + if c1.SchemaType != nil && c1.SchemaType[dialect.MySQL] != "" { + t, err := mysql.ParseType(strings.ToLower(c1.SchemaType[dialect.MySQL])) + if err != nil { + return err + } + c2.Type.Type = t + return nil + } + var t schema.Type + switch c1.Type { + case field.TypeBool: + t = &schema.BoolType{T: "boolean"} + case field.TypeBlob: + return fmt.Errorf("blob fields are not stored in the database") + case field.TypeInt8: + t = &schema.IntegerType{T: mysql.TypeTinyInt} + case field.TypeUint8: + t = &schema.IntegerType{T: mysql.TypeTinyInt, Unsigned: true} + case field.TypeInt16: + t = &schema.IntegerType{T: mysql.TypeSmallInt} + case field.TypeUint16: + t = &schema.IntegerType{T: mysql.TypeSmallInt, Unsigned: true} + case field.TypeInt32: + t = &schema.IntegerType{T: mysql.TypeInt} + case field.TypeUint32: + t = &schema.IntegerType{T: mysql.TypeInt, Unsigned: true} + case field.TypeInt, field.TypeInt64: + t = &schema.IntegerType{T: mysql.TypeBigInt} + case field.TypeUint, field.TypeUint64: + t = &schema.IntegerType{T: mysql.TypeBigInt, Unsigned: true} + case field.TypeBytes: + size := int64(math.MaxUint16) + if c1.Size > 0 { + size = c1.Size + } + switch { + case size <= math.MaxUint8: + t = &schema.BinaryType{T: mysql.TypeTinyBlob} + case size <= math.MaxUint16: + t = &schema.BinaryType{T: mysql.TypeBlob} + case size < 1<<24: + t = &schema.BinaryType{T: mysql.TypeMediumBlob} + case size <= math.MaxUint32: + t = &schema.BinaryType{T: mysql.TypeLongBlob} + } + case field.TypeJSON: + t = &schema.JSONType{T: mysql.TypeJSON} + if compareVersions(d.version, "5.7.8") == -1 { + t = &schema.BinaryType{T: mysql.TypeLongBlob} + } + case field.TypeString: + size := c1.Size + if size == 0 { + size = d.defaultSize(c1) + } + switch { + case c1.typ == "tinytext", c1.typ == "text": + t = &schema.StringType{T: c1.typ} + case size <= math.MaxUint16: + t = &schema.StringType{T: mysql.TypeVarchar, Size: int(size)} + case size == 1<<24-1: + t = &schema.StringType{T: mysql.TypeMediumText} + default: + t = &schema.StringType{T: mysql.TypeLongText} + } + case field.TypeFloat32, field.TypeFloat64: + t = &schema.FloatType{T: c1.scanTypeOr(mysql.TypeDouble)} + case field.TypeTime: + t = &schema.TimeType{T: c1.scanTypeOr(mysql.TypeTimestamp)} + // In MariaDB or in MySQL < v8.0.2, the TIMESTAMP column has both `DEFAULT CURRENT_TIMESTAMP` + // and `ON UPDATE CURRENT_TIMESTAMP` if neither is specified explicitly. this behavior is + // suppressed if the column is defined with a `DEFAULT` clause or with the `NULL` attribute. + if _, maria := d.mariadb(); maria || compareVersions(d.version, "8.0.2") == -1 && c1.Default == nil { + c2.SetNull(c1.Attr == "") + } + case field.TypeEnum: + t = &schema.EnumType{T: mysql.TypeEnum, Values: c1.Enums} + case field.TypeUUID: + if d.supportsUUID() { + // Native support for the uuid type + t = &schema.UUIDType{T: mysql.TypeUUID} + } else { + // "CHAR(X) BINARY" is treated as "CHAR(X) COLLATE latin1_bin", and in MySQL < 8, + // and "COLLATE utf8mb4_bin" in MySQL >= 8. However we already set the table to + t = &schema.StringType{T: mysql.TypeChar, Size: 36} + c2.SetCollation("utf8mb4_bin") + } + default: + t, err := mysql.ParseType(strings.ToLower(c1.typ)) + if err != nil { + return err + } + c2.Type.Type = t + } + c2.Type.Type = t + return nil +} + +func (d *MySQL) atUniqueC(t1 *Table, c1 *Column, t2 *schema.Table, c2 *schema.Column) { + // For UNIQUE columns, MySQL create an implicit index + // named as the column with an extra index in case the + // name is already taken (, , , ...). + for _, idx := range t1.Indexes { + // Index also defined explicitly, and will be add in atIndexes. + if idx.Unique && d.atImplicitIndexName(idx, c1) { + return + } + } + t2.AddIndexes(schema.NewUniqueIndex(c1.Name).AddColumns(c2)) +} + +func (d *MySQL) atIncrementC(t *schema.Table, c *schema.Column) { + if c.Default != nil { + t.Attrs = removeAttr(t.Attrs, reflect.TypeOf(&mysql.AutoIncrement{})) + } else { + c.AddAttrs(&mysql.AutoIncrement{}) + } +} + +func (d *MySQL) atIncrementT(t *schema.Table, v int64) { + if v >= 0 { + t.AddAttrs(mysql.NewAutoIncrement(uint64(v))) + } +} + +func (d *MySQL) atImplicitIndexName(idx *Index, c1 *Column) bool { + if idx.Name == c1.Name { + return true + } + if !strings.HasPrefix(idx.Name, c1.Name+"_") { + return false + } + i, err := strconv.ParseInt(strings.TrimLeft(idx.Name, c1.Name+"_"), 10, 64) + return err == nil && i > 1 +} + +func (d *MySQL) atIndex(idx1 *Index, t2 *schema.Table, idx2 *schema.Index) error { + prefix := indexParts(idx1) + for _, c1 := range idx1.Columns { + c2, ok := t2.Column(c1.Name) + if !ok { + return fmt.Errorf("unexpected index %q column: %q", idx1.Name, c1.Name) + } + part := &schema.IndexPart{C: c2} + if v, ok := prefix[c1.Name]; ok { + part.AddAttrs(&mysql.SubPart{Len: int(v)}) + } + idx2.AddParts(part) + } + if t, ok := indexType(idx1, dialect.MySQL); ok { + idx2.AddAttrs(&mysql.IndexType{T: t}) + } + return nil +} + +func (*MySQL) atTypeRangeSQL(ts ...string) string { + for i := range ts { + ts[i] = fmt.Sprintf("('%s')", ts[i]) + } + return fmt.Sprintf("INSERT INTO `%s` (`type`) VALUES %s", TypeTable, strings.Join(ts, ", ")) +} + +// mariadb reports if the migration runs on MariaDB and returns the semver string. +func (d *MySQL) mariadb() (string, bool) { + idx := strings.Index(d.version, "MariaDB") + if idx == -1 { + return "", false + } + return d.version[:idx-1], true +} + +// defaultSize returns the default size for MySQL/MariaDB varchar type +// based on column size, charset and table indexes, in order to avoid +// index prefix key limit (767) for older versions of MySQL/MariaDB. +func (d *MySQL) defaultSize(c *Column) int64 { + size := DefaultStringLen + version, checked := d.version, "5.7.0" + if v, ok := d.mariadb(); ok { + version, checked = v, "10.2.2" + } + switch { + // Version is >= 5.7 for MySQL, or >= 10.2.2 for MariaDB. + case compareVersions(version, checked) != -1: + // Column is non-unique, or not part of any index (reaching + // the error 1071). + case !c.Unique && len(c.indexes) == 0 && !c.PrimaryKey(): + default: + size = 191 + } + return size +} + +// indexParts returns a map holding the sub_part mapping if exists. +func indexParts(idx *Index) map[string]uint { + parts := make(map[string]uint) + if idx.Annotation == nil { + return parts + } + // If prefix (without a name) was defined on the + // annotation, map it to the single column index. + if idx.Annotation.Prefix > 0 && len(idx.Columns) == 1 { + parts[idx.Columns[0].Name] = idx.Annotation.Prefix + } + for column, part := range idx.Annotation.PrefixColumns { + parts[column] = part + } + return parts +} diff --git a/dialect/sql/schema/postgres.go b/dialect/sql/schema/postgres.go new file mode 100644 index 0000000..77ea1c5 --- /dev/null +++ b/dialect/sql/schema/postgres.go @@ -0,0 +1,260 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package schema + +import ( + "context" + "fmt" + "reflect" + "strconv" + "strings" + + "entgo.io/ent/dialect" + "entgo.io/ent/dialect/sql" + "entgo.io/ent/schema/field" + + "ariga.io/atlas/sql/migrate" + "ariga.io/atlas/sql/postgres" + "ariga.io/atlas/sql/schema" +) + +// Postgres adapter for Atlas migration engine. +type Postgres struct { + dialect.Driver + schema string + version string +} + +// init loads the Postgres version from the database for later use in the migration process. +// It returns an error if the server version is lower than v10. +func (d *Postgres) init(ctx context.Context) error { + if d.version != "" { + return nil // already initialized. + } + rows := &sql.Rows{} + if err := d.Query(ctx, "SHOW server_version_num", []any{}, rows); err != nil { + return fmt.Errorf("querying server version %w", err) + } + defer rows.Close() + if !rows.Next() { + if err := rows.Err(); err != nil { + return err + } + return fmt.Errorf("server_version_num variable was not found") + } + var version string + if err := rows.Scan(&version); err != nil { + return fmt.Errorf("scanning version: %w", err) + } + if len(version) < 6 { + return fmt.Errorf("malformed version: %s", version) + } + d.version = fmt.Sprintf("%s.%s.%s", version[:2], version[2:4], version[4:]) + if compareVersions(d.version, "10.0.0") == -1 { + return fmt.Errorf("unsupported postgres version: %s", d.version) + } + return nil +} + +// tableExist checks if a table exists in the database and current schema. +func (d *Postgres) tableExist(ctx context.Context, conn dialect.ExecQuerier, name string) (bool, error) { + query, args := sql.Dialect(dialect.Postgres). + Select(sql.Count("*")).From(sql.Table("tables").Schema("information_schema")). + Where(sql.And( + d.matchSchema(), + sql.EQ("table_name", name), + )).Query() + return exist(ctx, conn, query, args...) +} + +// matchSchema returns the predicate for matching table schema. +func (d *Postgres) matchSchema(columns ...string) *sql.Predicate { + column := "table_schema" + if len(columns) > 0 { + column = columns[0] + } + if d.schema != "" { + return sql.EQ(column, d.schema) + } + return sql.EQ(column, sql.Raw("CURRENT_SCHEMA()")) +} + +// maxCharSize defines the maximum size of limited character types in Postgres (10 MB). +const maxCharSize = 10 << 20 + +func (d *Postgres) atOpen(conn dialect.ExecQuerier) (migrate.Driver, error) { + return postgres.Open(&db{ExecQuerier: conn}) +} + +func (d *Postgres) atTable(t1 *Table, t2 *schema.Table) { + if t1.Annotation != nil { + setAtChecks(t1, t2) + } +} + +func (d *Postgres) supportsDefault(*Column) bool { + // PostgreSQL supports default values for all standard types. + return true +} + +func (d *Postgres) atTypeC(c1 *Column, c2 *schema.Column) error { + if c1.SchemaType != nil && c1.SchemaType[dialect.Postgres] != "" { + t, err := postgres.ParseType(strings.ToLower(c1.SchemaType[dialect.Postgres])) + if err != nil { + return err + } + c2.Type.Type = t + if s, ok := t.(*postgres.SerialType); c1.foreign != nil && ok { + c2.Type.Type = s.IntegerType() + } + return nil + } + var t schema.Type + switch c1.Type { + case field.TypeBlob: + return fmt.Errorf("blob fields are not stored in the database") + case field.TypeBool: + t = &schema.BoolType{T: postgres.TypeBoolean} + case field.TypeUint8, field.TypeInt8, field.TypeInt16: + t = &schema.IntegerType{T: postgres.TypeSmallInt} + case field.TypeUint16, field.TypeInt32: + t = &schema.IntegerType{T: postgres.TypeInt} + case field.TypeUint32, field.TypeInt, field.TypeUint, field.TypeInt64, field.TypeUint64: + t = &schema.IntegerType{T: postgres.TypeBigInt} + case field.TypeFloat32: + t = &schema.FloatType{T: c1.scanTypeOr(postgres.TypeReal)} + case field.TypeFloat64: + t = &schema.FloatType{T: c1.scanTypeOr(postgres.TypeDouble)} + case field.TypeBytes: + t = &schema.BinaryType{T: postgres.TypeBytea} + case field.TypeUUID: + t = &postgres.UUIDType{T: postgres.TypeUUID} + case field.TypeJSON: + t = &schema.JSONType{T: postgres.TypeJSONB} + case field.TypeString: + t = &schema.StringType{T: postgres.TypeVarChar} + if c1.Size > maxCharSize { + t = &schema.StringType{T: postgres.TypeText} + } + case field.TypeTime: + t = &schema.TimeType{T: c1.scanTypeOr(postgres.TypeTimestampWTZ)} + case field.TypeEnum: + // Although atlas supports enum types, we keep backwards compatibility + // with previous versions of ent and use varchar (see cType). + t = &schema.StringType{T: postgres.TypeVarChar} + case field.TypeOther: + t = &schema.UnsupportedType{T: c1.typ} + default: + t, err := postgres.ParseType(strings.ToLower(c1.typ)) + if err != nil { + return err + } + c2.Type.Type = t + } + c2.Type.Type = t + return nil +} + +func (d *Postgres) atUniqueC(t1 *Table, c1 *Column, t2 *schema.Table, c2 *schema.Column) { + // For UNIQUE columns, PostgreSQL creates an implicit index named + // "__key". + for _, idx := range t1.Indexes { + // Index also defined explicitly, and will be added in atIndexes. + if idx.Unique && d.atImplicitIndexName(idx, t1, c1) { + return + } + } + t2.AddIndexes(schema.NewUniqueIndex(fmt.Sprintf("%s_%s_key", t1.Name, c1.Name)).AddColumns(c2)) +} + +func (d *Postgres) atImplicitIndexName(idx *Index, t1 *Table, c1 *Column) bool { + p := fmt.Sprintf("%s_%s_key", t1.Name, c1.Name) + if idx.Name == p { + return true + } + i, err := strconv.ParseInt(strings.TrimPrefix(idx.Name, p), 10, 64) + return err == nil && i > 0 +} + +func (d *Postgres) atIncrementC(t *schema.Table, c *schema.Column) { + // Skip marking this column as an identity in case it is + // serial type or a default was already defined for it. + if _, ok := c.Type.Type.(*postgres.SerialType); ok || c.Default != nil { + t.Attrs = removeAttr(t.Attrs, reflect.TypeOf(&postgres.Identity{})) + return + } + id := &postgres.Identity{} + for _, a := range t.Attrs { + if a, ok := a.(*postgres.Identity); ok { + id = a + } + } + c.AddAttrs(id) +} + +func (d *Postgres) atIncrementT(t *schema.Table, v int64) { + t.AddAttrs(&postgres.Identity{Sequence: &postgres.Sequence{Start: v}}) +} + +// indexOpClass returns a map holding the operator-class mapping if exists. +func indexOpClass(idx *Index) map[string]string { + opc := make(map[string]string) + if idx.Annotation == nil { + return opc + } + // If operator-class (without a name) was defined on + // the annotation, map it to the single column index. + if idx.Annotation.OpClass != "" && len(idx.Columns) == 1 { + opc[idx.Columns[0].Name] = idx.Annotation.OpClass + } + for column, op := range idx.Annotation.OpClassColumns { + opc[column] = op + } + return opc +} + +func (d *Postgres) atIndex(idx1 *Index, t2 *schema.Table, idx2 *schema.Index) error { + opc := indexOpClass(idx1) + for _, c1 := range idx1.Columns { + c2, ok := t2.Column(c1.Name) + if !ok { + return fmt.Errorf("unexpected index %q column: %q", idx1.Name, c1.Name) + } + part := &schema.IndexPart{C: c2} + if v, ok := opc[c1.Name]; ok { + var op postgres.IndexOpClass + if err := op.UnmarshalText([]byte(v)); err != nil { + return fmt.Errorf("unmarshalling operator-class %q for column %q: %v", v, c1.Name, err) + } + part.Attrs = append(part.Attrs, &op) + } + idx2.AddParts(part) + } + if t, ok := indexType(idx1, dialect.Postgres); ok { + idx2.AddAttrs(&postgres.IndexType{T: t}) + } + if ant, supportsInclude := idx1.Annotation, compareVersions(d.version, "11.0.0") >= 0; ant != nil && len(ant.IncludeColumns) > 0 && supportsInclude { + columns := make([]*schema.Column, len(ant.IncludeColumns)) + for i, ic := range ant.IncludeColumns { + c, ok := t2.Column(ic) + if !ok { + return fmt.Errorf("include column %q was not found for index %q", ic, idx1.Name) + } + columns[i] = c + } + idx2.AddAttrs(&postgres.IndexInclude{Columns: columns}) + } + if idx1.Annotation != nil && idx1.Annotation.Where != "" { + idx2.AddAttrs(&postgres.IndexPredicate{P: idx1.Annotation.Where}) + } + return nil +} + +func (*Postgres) atTypeRangeSQL(ts ...string) string { + for i := range ts { + ts[i] = fmt.Sprintf("('%s')", ts[i]) + } + return fmt.Sprintf(`INSERT INTO "%s" ("type") VALUES %s`, TypeTable, strings.Join(ts, ", ")) +} diff --git a/dialect/sql/schema/schema.go b/dialect/sql/schema/schema.go new file mode 100644 index 0000000..ef5f61a --- /dev/null +++ b/dialect/sql/schema/schema.go @@ -0,0 +1,695 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +// Package schema contains all schema migration logic for SQL dialects. +package schema + +import ( + "context" + "fmt" + "slices" + "strconv" + "strings" + "sync" + + "ariga.io/atlas/sql/migrate" + "ariga.io/atlas/sql/mysql" + "ariga.io/atlas/sql/postgres" + "ariga.io/atlas/sql/schema" + "ariga.io/atlas/sql/sqlite" + entdialect "entgo.io/ent/dialect" + "entgo.io/ent/dialect/entsql" + "entgo.io/ent/dialect/sql" + "entgo.io/ent/schema/field" +) + +const ( + // DefaultStringLen describes the default length for string/varchar types. + DefaultStringLen int64 = 255 + // Null is the string representation of NULL in SQL. + Null = "NULL" + // PrimaryKey is the string representation of PKs in SQL. + PrimaryKey = "PRI" + // UniqueKey is the string representation of PKs in SQL. + UniqueKey = "UNI" +) + +// Table schema definition for SQL dialects. +type Table struct { + mu sync.Mutex + Name string + Schema string + Columns []*Column + columns map[string]*Column + Indexes []*Index + PrimaryKey []*Column + ForeignKeys []*ForeignKey + Annotation *entsql.Annotation + Comment string + View bool // Indicate the table is a view. + Pos string // filename:line of the ent schema definition. +} + +// NewTable returns a new table with the given name. +func NewTable(name string) *Table { + return &Table{ + Name: name, + columns: make(map[string]*Column), + } +} + +// NewView returns a new view with the given name. +func NewView(name string) *Table { + t := NewTable(name) + t.View = true + return t +} + +// SetComment sets the table comment. +func (t *Table) SetComment(c string) *Table { + t.Comment = c + return t +} + +// SetSchema sets the table schema. +func (t *Table) SetSchema(s string) *Table { + t.Schema = s + return t +} + +// SetPos sets the table position. +func (t *Table) SetPos(p string) *Table { + t.Pos = p + return t +} + +// AddPrimary adds a new primary key to the table. +func (t *Table) AddPrimary(c *Column) *Table { + c.Key = PrimaryKey + t.AddColumn(c) + t.PrimaryKey = append(t.PrimaryKey, c) + return t +} + +// AddForeignKey adds a foreign key to the table. +func (t *Table) AddForeignKey(fk *ForeignKey) *Table { + t.ForeignKeys = append(t.ForeignKeys, fk) + return t +} + +// AddColumn adds a new column to the table. +func (t *Table) AddColumn(c *Column) *Table { + t.columns[c.Name] = c + t.Columns = append(t.Columns, c) + return t +} + +// HasColumn reports if the table contains a column with the given name. +func (t *Table) HasColumn(name string) bool { + _, ok := t.Column(name) + return ok +} + +// Column returns the column with the given name. If exists. +func (t *Table) Column(name string) (*Column, bool) { + if c, ok := t.columns[name]; ok { + return c, true + } + // In case the column was added + // directly to the Columns field. + for _, c := range t.Columns { + if c.Name == name { + return c, true + } + } + return nil, false +} + +// SetAnnotation the entsql.Annotation on the table. +func (t *Table) SetAnnotation(ant *entsql.Annotation) *Table { + t.Annotation = ant + return t +} + +// AddIndex creates and adds a new index to the table from the given options. +func (t *Table) AddIndex(name string, unique bool, columns []string) *Table { + return t.addIndex(&Index{ + Name: name, + Unique: unique, + columns: columns, + Columns: make([]*Column, 0, len(columns)), + }) +} + +// AddIndex creates and adds a new index to the table from the given options. +func (t *Table) addIndex(idx *Index) *Table { + for _, name := range idx.columns { + c, ok := t.columns[name] + if ok { + c.indexes.append(idx) + idx.Columns = append(idx.Columns, c) + } + } + t.Indexes = append(t.Indexes, idx) + return t +} + +// column returns a table column by its name. +// faster than map lookup for most cases. +func (t *Table) column(name string) (*Column, bool) { + for _, c := range t.Columns { + if c.Name == name { + return c, true + } + } + return nil, false +} + +// Index returns a table index by its exact name. +func (t *Table) Index(name string) (*Index, bool) { + idx, ok := t.index(name) + if ok && idx.Name == name { + return idx, ok + } + return nil, false +} + +// index returns a table index by its name. +func (t *Table) index(name string) (*Index, bool) { + for _, idx := range t.Indexes { + if name == idx.Name || name == idx.realname { + return idx, true + } + // Same as below, there are cases where the index name + // is unknown (created automatically on column constraint). + if len(idx.Columns) == 1 && idx.Columns[0].Name == name { + return idx, true + } + } + // If it is an "implicit index" (unique constraint on + // table creation) and it wasn't loaded in table scanning. + c, ok := t.column(name) + if !ok { + // Postgres naming convention for unique constraint (
__key). + name = strings.TrimPrefix(name, t.Name+"_") + name = strings.TrimSuffix(name, "_key") + c, ok = t.column(name) + } + if ok && c.Unique { + return &Index{Name: name, Unique: c.Unique, Columns: []*Column{c}, columns: []string{c.Name}}, true + } + return nil, false +} + +// CopyTables returns a deep-copy of the given tables. This utility function is +// useful for copying the generated schema tables (i.e. migrate.Tables) before +// running schema migration when there is a need for execute multiple migrations +// concurrently. e.g. running parallel unit-tests using the generated enttest package. +func CopyTables(tables []*Table) ([]*Table, error) { + var ( + copyT = make([]*Table, len(tables)) + byName = make(map[string]*Table) + ) + for i, t := range tables { + copyT[i] = &Table{ + Name: t.Name, + Columns: make([]*Column, len(t.Columns)), + Indexes: make([]*Index, len(t.Indexes)), + ForeignKeys: make([]*ForeignKey, len(t.ForeignKeys)), + } + for j, c := range t.Columns { + cc := *c + // SchemaType and Enums are read-only fields. + cc.indexes = nil + cc.foreign = nil + copyT[i].Columns[j] = &cc + } + if at := t.Annotation; at != nil { + cat := *at + copyT[i].Annotation = &cat + } + byName[t.Name] = copyT[i] + } + for i, t := range tables { + ct := copyT[i] + for _, c := range t.PrimaryKey { + cc, ok := ct.column(c.Name) + if !ok { + return nil, fmt.Errorf("sql/schema: missing primary key column %q", c.Name) + } + ct.PrimaryKey = append(ct.PrimaryKey, cc) + } + for j, idx := range t.Indexes { + cidx := &Index{ + Name: idx.Name, + Unique: idx.Unique, + Columns: make([]*Column, len(idx.Columns)), + } + if at := idx.Annotation; at != nil { + cat := *at + cidx.Annotation = &cat + } + for k, c := range idx.Columns { + cc, ok := ct.column(c.Name) + if !ok { + return nil, fmt.Errorf("sql/schema: missing index column %q", c.Name) + } + cidx.Columns[k] = cc + } + ct.Indexes[j] = cidx + } + for j, fk := range t.ForeignKeys { + cfk := &ForeignKey{ + Symbol: fk.Symbol, + OnUpdate: fk.OnUpdate, + OnDelete: fk.OnDelete, + Columns: make([]*Column, len(fk.Columns)), + RefColumns: make([]*Column, len(fk.RefColumns)), + } + for k, c := range fk.Columns { + cc, ok := ct.column(c.Name) + if !ok { + return nil, fmt.Errorf("sql/schema: missing foreign-key column %q", c.Name) + } + cfk.Columns[k] = cc + } + cref, ok := byName[fk.RefTable.Name] + if !ok { + return nil, fmt.Errorf("sql/schema: missing foreign-key ref-table %q", fk.RefTable.Name) + } + cfk.RefTable = cref + for k, c := range fk.RefColumns { + cc, ok := cref.column(c.Name) + if !ok { + return nil, fmt.Errorf("sql/schema: missing foreign-key ref-column %q", c.Name) + } + cfk.RefColumns[k] = cc + } + ct.ForeignKeys[j] = cfk + } + } + return copyT, nil +} + +// Column schema definition for SQL dialects. +type Column struct { + Name string // column name. + Type field.Type // column type. + SchemaType map[string]string // optional schema type per dialect. + Attr string // extra attributes. + Size int64 // max size parameter for string, blob, etc. + Key string // key definition (PRI, UNI or MUL). + Unique bool // column with unique constraint. + Increment bool // auto increment attribute. + Nullable bool // null or not null attribute. + Default any // default value. + Enums []string // enum values. + Collation string // collation type (utf8mb4_unicode_ci, utf8mb4_general_ci) + typ string // row column type (used for Rows.Scan). + indexes Indexes // linked indexes. + foreign *ForeignKey // linked foreign-key. + Comment string // optional column comment. +} + +// Expr represents a raw expression. It is used to distinguish between +// literal values and raw expressions when defining default values. +type Expr string + +// UniqueKey returns boolean indicates if this column is a unique key. +// Used by the migration tool when parsing the `DESCRIBE TABLE` output Go objects. +func (c *Column) UniqueKey() bool { return c.Key == UniqueKey } + +// PrimaryKey returns boolean indicates if this column is on of the primary key columns. +// Used by the migration tool when parsing the `DESCRIBE TABLE` output Go objects. +func (c *Column) PrimaryKey() bool { return c.Key == PrimaryKey } + +// ConvertibleTo reports whether a column can be converted to the new column without altering its data. +func (c *Column) ConvertibleTo(d *Column) bool { + switch { + case c.Type == d.Type: + if c.Size != 0 && d.Size != 0 { + // Types match and have a size constraint. + return c.Size <= d.Size + } + return true + case c.IntType() && d.IntType() || c.UintType() && d.UintType(): + return c.Type <= d.Type + case c.UintType() && d.IntType(): + // uintX can not be converted to intY, when X > Y. + return c.Type-field.TypeUint8 <= d.Type-field.TypeInt8 + case c.Type == field.TypeString && d.Type == field.TypeEnum || + c.Type == field.TypeEnum && d.Type == field.TypeString: + return true + case c.Type.Integer() && d.Type == field.TypeString: + return true + } + return c.FloatType() && d.FloatType() +} + +// IntType reports whether the column is an int type (int8 ... int64). +func (c Column) IntType() bool { return c.Type >= field.TypeInt8 && c.Type <= field.TypeInt64 } + +// UintType reports of the given type is a uint type (int8 ... int64). +func (c Column) UintType() bool { return c.Type >= field.TypeUint8 && c.Type <= field.TypeUint64 } + +// FloatType reports of the given type is a float type (float32, float64). +func (c Column) FloatType() bool { return c.Type == field.TypeFloat32 || c.Type == field.TypeFloat64 } + +// ScanDefault scans the default value string to its interface type. +func (c *Column) ScanDefault(value string) error { + switch { + case strings.ToUpper(value) == Null: // ignore. + case c.IntType(): + v := &sql.NullInt64{} + if err := v.Scan(value); err != nil { + return fmt.Errorf("scanning int value for column %q: %w", c.Name, err) + } + c.Default = v.Int64 + case c.UintType(): + v := &sql.NullInt64{} + if err := v.Scan(value); err != nil { + return fmt.Errorf("scanning uint value for column %q: %w", c.Name, err) + } + c.Default = uint64(v.Int64) + case c.FloatType(): + v := &sql.NullFloat64{} + if err := v.Scan(value); err != nil { + return fmt.Errorf("scanning float value for column %q: %w", c.Name, err) + } + c.Default = v.Float64 + case c.Type == field.TypeBool: + v := &sql.NullBool{} + if err := v.Scan(value); err != nil { + return fmt.Errorf("scanning bool value for column %q: %w", c.Name, err) + } + c.Default = v.Bool + case c.Type == field.TypeString || c.Type == field.TypeEnum: + v := &sql.NullString{} + if err := v.Scan(value); err != nil { + return fmt.Errorf("scanning string value for column %q: %w", c.Name, err) + } + c.Default = v.String + case c.Type == field.TypeJSON: + v := &sql.NullString{} + if err := v.Scan(value); err != nil { + return fmt.Errorf("scanning json value for column %q: %w", c.Name, err) + } + c.Default = v.String + case c.Type == field.TypeBytes: + c.Default = []byte(value) + case c.Type == field.TypeUUID: + // skip function + if !strings.Contains(value, "()") { + c.Default = value + } + default: + return fmt.Errorf("unsupported default type: %v default to %q", c.Type, value) + } + return nil +} + +// supportDefault reports if the column type supports default value. +func (c Column) supportDefault() bool { + switch t := c.Type; t { + case field.TypeString, field.TypeEnum: + return c.Size < 1<<16 // not a text. + case field.TypeBool, field.TypeTime, field.TypeUUID: + return true + default: + return t.Numeric() + } +} + +// scanTypeOr returns the scanning type or the given value. +func (c *Column) scanTypeOr(t string) string { + if c.typ != "" { + return strings.ToLower(c.typ) + } + return t +} + +// ForeignKey definition for creation. +type ForeignKey struct { + Symbol string // foreign-key name. Generated if empty. + Columns []*Column // table column + RefTable *Table // referenced table. + RefColumns []*Column // referenced columns. + OnUpdate ReferenceOption // action on update. + OnDelete ReferenceOption // action on delete. +} + +// ReferenceOption for constraint actions. +type ReferenceOption string + +// Reference options. +const ( + NoAction ReferenceOption = "NO ACTION" + Restrict ReferenceOption = "RESTRICT" + Cascade ReferenceOption = "CASCADE" + SetNull ReferenceOption = "SET NULL" + SetDefault ReferenceOption = "SET DEFAULT" +) + +// ConstName returns the constant name of a reference option. It's used by entc for printing the constant name in templates. +func (r ReferenceOption) ConstName() string { + return strings.ReplaceAll(strings.Title(strings.ToLower(string(r))), " ", "") +} + +// Index definition for table index. +type Index struct { + Name string // index name. + Unique bool // uniqueness. + Columns []*Column // actual table columns. + Annotation *entsql.IndexAnnotation // index annotation. + columns []string // columns loaded from query scan. + realname string // real name in the database (Postgres only). +} + +// Indexes used for scanning all sql.Rows into a list of indexes, because +// multiple sql rows can represent the same index (multi-columns indexes). +type Indexes []*Index + +// append wraps the basic `append` function by filtering duplicates indexes. +func (i *Indexes) append(idx1 *Index) { + for _, idx2 := range *i { + if idx2.Name == idx1.Name { + return + } + } + *i = append(*i, idx1) +} + +// compareVersions returns an integer comparing the 2 versions. +func compareVersions(v1, v2 string) int { + pv1, ok1 := parseVersion(v1) + pv2, ok2 := parseVersion(v2) + if !ok1 && !ok2 { + return 0 + } + if !ok1 { + return -1 + } + if !ok2 { + return 1 + } + if v := compare(pv1.major, pv2.major); v != 0 { + return v + } + if v := compare(pv1.minor, pv2.minor); v != 0 { + return v + } + return compare(pv1.patch, pv2.patch) +} + +// version represents a parsed MySQL version. +type version struct { + major int + minor int + patch int +} + +// parseVersion returns an integer comparing the 2 versions. +func parseVersion(v string) (*version, bool) { + parts := strings.Split(v, ".") + if len(parts) == 0 { + return nil, false + } + var ( + err error + ver = &version{} + ) + for i, e := range []*int{&ver.major, &ver.minor, &ver.patch} { + if i == len(parts) { + break + } + if *e, err = strconv.Atoi(strings.Split(parts[i], "-")[0]); err != nil { + return nil, false + } + } + return ver, true +} + +func compare(v1, v2 int) int { + if v1 == v2 { + return 0 + } + if v1 < v2 { + return -1 + } + return 1 +} + +func indexType(idx *Index, d string) (string, bool) { + ant := idx.Annotation + if ant == nil { + return "", false + } + if ant.Types != nil && ant.Types[d] != "" { + return ant.Types[d], true + } + if ant.Type != "" { + return ant.Type, true + } + return "", false +} + +type driver struct { + sqlDialect + schema.Differ + migrate.PlanApplier +} + +var drivers = func(v string) map[string]driver { + return map[string]driver{ + entdialect.SQLite: { + &SQLite{ + WithForeignKeys: true, + Driver: nopDriver{dialect: entdialect.SQLite}, + }, + sqlite.DefaultDiff, + sqlite.DefaultPlan, + }, + entdialect.MySQL: { + &MySQL{ + version: v, + Driver: nopDriver{dialect: entdialect.MySQL}, + }, + mysql.DefaultDiff, + mysql.DefaultPlan, + }, + entdialect.Postgres: { + &Postgres{ + version: v, + Driver: nopDriver{dialect: entdialect.Postgres}, + }, + postgres.DefaultDiff, + postgres.DefaultPlan, + }, + } +} + +type DDLArgs struct { + // Dialect and Version of the target database. + Dialect, Version string + // HashSymbols indicates whether to hash long symbols in the DDL. + HashSymbols bool + // Tables to dump. + Tables []*Table + // Options to pass to the migration plan engine. + Options []migrate.PlanOption +} + +// Dump the schema DDL for the given tables. +// +// Deprecated: use DDL instead. +func Dump(ctx context.Context, dialect, version string, tables []*Table, opts ...migrate.PlanOption) (string, error) { + return DDL(ctx, DDLArgs{ + Dialect: dialect, + Version: version, + Tables: tables, + Options: opts, + }) +} + +// DDL the schema DDL for the given tables. +func DDL(ctx context.Context, args DDLArgs) (string, error) { + args.Options = append([]migrate.PlanOption{func(o *migrate.PlanOptions) { + o.Mode = migrate.PlanModeDump + o.Indent = " " + }}, args.Options...) + d, ok := drivers(args.Version)[args.Dialect] + if !ok { + return "", fmt.Errorf("unsupported dialect %q", args.Dialect) + } + a := &Atlas{ + sqlDialect: d, + dialect: args.Dialect, + hashSymbols: args.HashSymbols, + } + r, err := a.StateReader(args.Tables...).ReadState(ctx) + if err != nil { + return "", err + } + // Since the Atlas version bundled with Ent does not support view management, + // simply spit out the definition instead of letting Atlas plan them. + var vs []*schema.View + for _, s := range r.Schemas { + vs = append(vs, s.Views...) + s.Views = nil + } + var c schema.Changes + if slices.ContainsFunc(args.Tables, func(t *Table) bool { return t.Schema != "" }) { + c, err = d.RealmDiff(&schema.Realm{}, r) + } else { + c, err = d.SchemaDiff(&schema.Schema{}, r.Schemas[0]) + } + if err != nil { + return "", err + } + p, err := d.PlanChanges(ctx, "dump", c, args.Options...) + if err != nil { + return "", err + } + for _, v := range vs { + q, _ := sql.Dialect(args.Dialect). + CreateView(v.Name). + Schema(v.Schema.Name). + Columns(func(cols []*schema.Column) (bs []*sql.ColumnBuilder) { + for _, c := range cols { + bs = append(bs, sql.Dialect(args.Dialect).Column(c.Name).Type(c.Type.Raw)) + } + return + }(v.Columns)...). + As(sql.Raw(v.Def)). + Query() + p.Changes = append(p.Changes, &migrate.Change{ + Cmd: q, + Comment: fmt.Sprintf("Add %q view", v.Name), + }) + } + for _, t := range args.Tables { + p.Directives = append(p.Directives, fmt.Sprintf( + "-- atlas:pos %s%s[type=%s] %s", + func() string { + if t.Schema != "" { + return t.Schema + "[type=schema]." + } + return "" + }(), + t.Name, + func() string { + if t.View { + return "view" + } + return "table" + }(), + t.Pos, + )) + } + f, err := migrate.DefaultFormatter.FormatFile(p) + if err != nil { + return "", err + } + return string(f.Bytes()), nil +} diff --git a/dialect/sql/schema/schema_test.go b/dialect/sql/schema/schema_test.go new file mode 100644 index 0000000..fa640f4 --- /dev/null +++ b/dialect/sql/schema/schema_test.go @@ -0,0 +1,390 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package schema + +import ( + "context" + "fmt" + "strings" + "testing" + + "entgo.io/ent/dialect" + "entgo.io/ent/dialect/entsql" + "entgo.io/ent/dialect/sql" + "entgo.io/ent/schema/field" + + "github.com/stretchr/testify/require" +) + +func TestColumn_ConvertibleTo(t *testing.T) { + c1 := &Column{Type: field.TypeString, Size: 10} + require.True(t, c1.ConvertibleTo(&Column{Type: field.TypeString, Size: 10})) + require.True(t, c1.ConvertibleTo(&Column{Type: field.TypeString, Size: 255})) + require.False(t, c1.ConvertibleTo(&Column{Type: field.TypeString, Size: 9})) + require.False(t, c1.ConvertibleTo(&Column{Type: field.TypeFloat32})) + + c1 = &Column{Type: field.TypeFloat32} + require.True(t, c1.ConvertibleTo(&Column{Type: field.TypeFloat32})) + require.True(t, c1.ConvertibleTo(&Column{Type: field.TypeFloat64})) + require.False(t, c1.ConvertibleTo(&Column{Type: field.TypeString})) + require.False(t, c1.ConvertibleTo(&Column{Type: field.TypeUint})) + + c1 = &Column{Type: field.TypeFloat64} + require.True(t, c1.ConvertibleTo(&Column{Type: field.TypeFloat32})) + require.True(t, c1.ConvertibleTo(&Column{Type: field.TypeFloat64})) + require.False(t, c1.ConvertibleTo(&Column{Type: field.TypeString})) + require.False(t, c1.ConvertibleTo(&Column{Type: field.TypeUint})) + + c1 = &Column{Type: field.TypeUint} + require.True(t, c1.ConvertibleTo(&Column{Type: field.TypeUint})) + require.True(t, c1.ConvertibleTo(&Column{Type: field.TypeInt})) + require.True(t, c1.ConvertibleTo(&Column{Type: field.TypeInt64})) + require.True(t, c1.ConvertibleTo(&Column{Type: field.TypeUint64})) + require.False(t, c1.ConvertibleTo(&Column{Type: field.TypeInt8})) + require.False(t, c1.ConvertibleTo(&Column{Type: field.TypeUint8})) + require.False(t, c1.ConvertibleTo(&Column{Type: field.TypeUint16})) + require.False(t, c1.ConvertibleTo(&Column{Type: field.TypeUint32})) + require.True(t, c1.ConvertibleTo(&Column{Type: field.TypeString})) + require.True(t, c1.ConvertibleTo(&Column{Type: field.TypeString, Size: 1})) + + c1 = &Column{Type: field.TypeInt} + require.True(t, c1.ConvertibleTo(&Column{Type: field.TypeInt})) + require.True(t, c1.ConvertibleTo(&Column{Type: field.TypeInt64})) + require.False(t, c1.ConvertibleTo(&Column{Type: field.TypeInt8})) + require.False(t, c1.ConvertibleTo(&Column{Type: field.TypeInt32})) + require.False(t, c1.ConvertibleTo(&Column{Type: field.TypeUint})) + require.False(t, c1.ConvertibleTo(&Column{Type: field.TypeUint8})) + require.False(t, c1.ConvertibleTo(&Column{Type: field.TypeUint16})) + require.False(t, c1.ConvertibleTo(&Column{Type: field.TypeUint32})) + require.True(t, c1.ConvertibleTo(&Column{Type: field.TypeString})) + require.True(t, c1.ConvertibleTo(&Column{Type: field.TypeString, Size: 1})) +} + +func TestColumn_ScanDefault(t *testing.T) { + c1 := &Column{Type: field.TypeString, Size: 10} + require.NoError(t, c1.ScanDefault("Hello World")) + require.Equal(t, "Hello World", c1.Default) + require.NoError(t, c1.ScanDefault("1")) + require.Equal(t, "1", c1.Default) + + c1 = &Column{Type: field.TypeInt64} + require.NoError(t, c1.ScanDefault("128")) + require.Equal(t, int64(128), c1.Default) + require.NoError(t, c1.ScanDefault("1")) + require.Equal(t, int64(1), c1.Default) + require.Error(t, c1.ScanDefault("foo")) + + c1 = &Column{Type: field.TypeUint64} + require.NoError(t, c1.ScanDefault("128")) + require.Equal(t, uint64(128), c1.Default) + require.NoError(t, c1.ScanDefault("1")) + require.Equal(t, uint64(1), c1.Default) + require.Error(t, c1.ScanDefault("foo")) + + c1 = &Column{Type: field.TypeFloat64} + require.NoError(t, c1.ScanDefault("128.1")) + require.Equal(t, 128.1, c1.Default) + require.NoError(t, c1.ScanDefault("1")) + require.Equal(t, float64(1), c1.Default) + require.Error(t, c1.ScanDefault("foo")) + + c1 = &Column{Type: field.TypeBool} + require.NoError(t, c1.ScanDefault("1")) + require.Equal(t, true, c1.Default) + require.NoError(t, c1.ScanDefault("true")) + require.Equal(t, true, c1.Default) + require.NoError(t, c1.ScanDefault("0")) + require.Equal(t, false, c1.Default) + require.NoError(t, c1.ScanDefault("false")) + require.Equal(t, false, c1.Default) + require.Error(t, c1.ScanDefault("foo")) + + c1 = &Column{Type: field.TypeUUID} + require.NoError(t, c1.ScanDefault("gen_random_uuid()")) + require.Equal(t, nil, c1.Default) + require.NoError(t, c1.ScanDefault("00000000-0000-0000-0000-000000000000")) + require.Equal(t, "00000000-0000-0000-0000-000000000000", c1.Default) +} + +func TestCopyTables(t *testing.T) { + users := &Table{ + Name: "users", + Columns: []*Column{ + {Name: "id", Type: field.TypeInt}, + {Name: "name", Type: field.TypeString}, + {Name: "spouse_id", Type: field.TypeInt}, + }, + } + users.PrimaryKey = users.Columns[:1] + users.Indexes = append(users.Indexes, &Index{ + Name: "name", + Columns: users.Columns[1:2], + }) + users.AddForeignKey(&ForeignKey{ + Columns: users.Columns[2:], + RefTable: users, + RefColumns: users.Columns[:1], + OnUpdate: SetNull, + }) + users.SetAnnotation(&entsql.Annotation{Table: "Users"}) + pets := &Table{ + Name: "pets", + Columns: []*Column{ + {Name: "id", Type: field.TypeInt}, + {Name: "name", Type: field.TypeString}, + {Name: "owner_id", Type: field.TypeInt}, + }, + } + pets.Indexes = append(pets.Indexes, &Index{ + Name: "name", + Unique: true, + Columns: pets.Columns[1:2], + Annotation: entsql.Desc(), + }) + pets.AddForeignKey(&ForeignKey{ + Columns: pets.Columns[2:], + RefTable: users, + RefColumns: users.Columns[:1], + OnDelete: SetDefault, + }) + tables := []*Table{users, pets} + copyT, err := CopyTables(tables) + require.NoError(t, err) + require.Equal(t, tables, copyT) +} + +func TestDDL(t *testing.T) { + const ( + hash = "249590215c5bc8be0106146e65d7fa7f" + idx = "super_duper_mega_ultra_hyper_giga_colossal_unbelievably_long_index_name" + hashMY = "super_duper_mega_ultra_hyper_gi_249590215c5bc8be0106146e65d7fa7f" + hashPG = "super_duper_mega_ultra_hyper_g_249590215c5bc8be0106146e65d7fa7f" + ) + tbls := func() []*Table { + users := &Table{ + Name: "users", + Pos: "users.go:15", + Columns: []*Column{ + {Name: "id", Type: field.TypeInt}, + {Name: "name", Type: field.TypeString}, + {Name: "spouse_id", Type: field.TypeInt}, + }, + } + users.PrimaryKey = users.Columns[:1] + users.Indexes = append(users.Indexes, &Index{ + Name: "name", + Columns: users.Columns[1:2], + }) + users.AddForeignKey(&ForeignKey{ + Columns: users.Columns[2:], + RefTable: users, + RefColumns: users.Columns[:1], + OnUpdate: SetDefault, + }) + users.SetAnnotation(&entsql.Annotation{Table: "Users"}) + pets := &Table{ + Name: "pets", + Pos: "pets.go:15", + Columns: []*Column{ + {Name: "id", Type: field.TypeInt}, + {Name: "name", Type: field.TypeString}, + {Name: "fur_color", Type: field.TypeEnum, Enums: []string{"black", "white"}}, + {Name: "owner_id", Type: field.TypeInt}, + }, + } + pets.Indexes = append(pets.Indexes, &Index{ + Name: idx, + Unique: true, + Columns: pets.Columns[1:2], + Annotation: entsql.Desc(), + }) + pets.AddForeignKey(&ForeignKey{ + Columns: pets.Columns[3:], + RefTable: users, + RefColumns: users.Columns[:1], + OnDelete: SetDefault, + }) + petsWithoutFur := &Table{ + Name: "pets_without_fur", + Pos: "pets.go:30", + View: true, + Columns: append(pets.Columns[:2], pets.Columns[3]), + Annotation: entsql.View("SELECT id, name, owner_id FROM pets"), + } + petNames := &Table{ + Name: "pet_names", + Pos: "pets.go:45", + View: true, + Columns: pets.Columns[1:1], + Annotation: entsql.ViewFor(dialect.Postgres, func(s *sql.Selector) { + s.Select("name").From(sql.Table("pets")) + }), + } + return []*Table{users, pets, petsWithoutFur, petNames} + } + my := func(length int, idx string) string { + return fmt.Sprintf(strings.ReplaceAll(`-- Add new schema named "s1" +CREATE DATABASE $s1$; +-- Add new schema named "s2" +CREATE DATABASE $s2$; +-- Add new schema named "s3" +CREATE DATABASE $s3$; +-- Create "users" table +CREATE TABLE $s1$.$users$ ( + $id$ bigint NOT NULL, + $name$ varchar(%d) NOT NULL, + $spouse_id$ bigint NOT NULL, + PRIMARY KEY ($id$), + INDEX $name$ ($name$), + FOREIGN KEY ($spouse_id$) REFERENCES $s1$.$users$ ($id$) ON UPDATE SET DEFAULT +) CHARSET utf8mb4 COLLATE utf8mb4_bin; +-- Create "pets" table +CREATE TABLE $s2$.$pets$ ( + $id$ bigint NOT NULL, + $name$ varchar(%d) NOT NULL, + $owner_id$ bigint NOT NULL, + $owner_id$ bigint NOT NULL, + UNIQUE INDEX $%s$ ($name$ DESC), + FOREIGN KEY ($owner_id$) REFERENCES $s1$.$users$ ($id$) ON DELETE SET DEFAULT +) CHARSET utf8mb4 COLLATE utf8mb4_bin; +-- Add "pets_without_fur" view +CREATE VIEW $s3$.$pets_without_fur$ ($id$, $name$, $owner_id$) AS SELECT id, name, owner_id FROM pets; +`, "$", "`"), length, length, idx) + } + + pg := func(idx string) string { + return fmt.Sprintf(`-- Add new schema named "s1" +CREATE SCHEMA "s1"; +-- Add new schema named "s2" +CREATE SCHEMA "s2"; +-- Add new schema named "s3" +CREATE SCHEMA "s3"; +-- Create "users" table +CREATE TABLE "s1"."users" ( + "id" bigint NOT NULL, + "name" character varying NOT NULL, + "spouse_id" bigint NOT NULL, + PRIMARY KEY ("id"), + FOREIGN KEY ("spouse_id") REFERENCES "s1"."users" ("id") ON UPDATE SET DEFAULT +); +-- Create index "name" to table: "users" +CREATE INDEX "name" ON "s1"."users" ("name"); +-- Create "pets" table +CREATE TABLE "s2"."pets" ( + "id" bigint NOT NULL, + "name" character varying NOT NULL, + "owner_id" bigint NOT NULL, + "owner_id" bigint NOT NULL, + FOREIGN KEY ("owner_id") REFERENCES "s1"."users" ("id") ON DELETE SET DEFAULT +); +-- Create index "%s" to table: "pets" +CREATE UNIQUE INDEX "%s" ON "s2"."pets" ("name" DESC); +-- Add "pets_without_fur" view +CREATE VIEW "s3"."pets_without_fur" ("id", "name", "owner_id") AS SELECT id, name, owner_id FROM pets; +-- Add "pet_names" view +CREATE VIEW "s3"."pet_names" AS SELECT "name" FROM "pets"; +`, idx, idx) + } + + for _, tt := range []struct { + dialect, version, expected string + hash bool + }{ + { + dialect.SQLite, "", + fmt.Sprintf(strings.ReplaceAll(`-- Create "users" table +CREATE TABLE $users$ ( + $id$ integer NOT NULL, + $name$ text NOT NULL, + $spouse_id$ integer NOT NULL, + PRIMARY KEY ($id$), + FOREIGN KEY ($spouse_id$) REFERENCES $users$ ($id$) ON UPDATE SET DEFAULT +); +-- Create index "name" to table: "users" +CREATE INDEX $name$ ON $users$ ($name$); +-- Create "pets" table +CREATE TABLE $pets$ ( + $id$ integer NOT NULL, + $name$ text NOT NULL, + $owner_id$ integer NOT NULL, + $owner_id$ integer NOT NULL, + FOREIGN KEY ($owner_id$) REFERENCES $users$ ($id$) ON DELETE SET DEFAULT +); +-- Create index "%s" to table: "pets" +CREATE UNIQUE INDEX $%s$ ON $pets$ ($name$ DESC); +-- Add "pets_without_fur" view +CREATE VIEW $pets_without_fur$ ($id$, $name$, $owner_id$) AS SELECT id, name, owner_id FROM pets; +`, "$", "`"), idx, idx), false, + }, + {dialect.MySQL, "5.6", my(255, idx), false}, + {dialect.MySQL, "5.6", my(191, hashMY), true}, + {dialect.MySQL, "5.7", my(255, idx), false}, + {dialect.MySQL, "8", my(255, hashMY), true}, + {dialect.Postgres, "12", pg(idx), false}, + {dialect.Postgres, "13", pg(idx), false}, + {dialect.Postgres, "14", pg(hashPG), true}, + {dialect.Postgres, "15", pg(hashPG), true}, + } { + n := tt.dialect + if tt.version != "" { + n += ":" + tt.version + } + pos := `-- atlas:pos users[type=table] users.go:15 +-- atlas:pos pets[type=table] pets.go:15 +-- atlas:pos pets_without_fur[type=view] pets.go:30 +-- atlas:pos pet_names[type=view] pets.go:45 + +` + tables := tbls() + if tt.dialect != dialect.SQLite { + tables[0].Schema = "s1" + tables[1].Schema = "s2" + tables[2].Schema = "s3" + tables[3].Schema = "s3" + pos = `-- atlas:pos s1[type=schema].users[type=table] users.go:15 +-- atlas:pos s2[type=schema].pets[type=table] pets.go:15 +-- atlas:pos s3[type=schema].pets_without_fur[type=view] pets.go:30 +-- atlas:pos s3[type=schema].pet_names[type=view] pets.go:45 + +` + } + t.Run(n, func(t *testing.T) { + ac, err := DDL(context.Background(), DDLArgs{ + Dialect: tt.dialect, + Version: tt.version, + Tables: tables, + HashSymbols: tt.hash, + }) + require.NoError(t, err) + require.Equal(t, pos+tt.expected, ac) + }) + t.Run(n+" single schema", func(t *testing.T) { + ac, err := DDL(context.Background(), DDLArgs{ + Dialect: tt.dialect, + Version: tt.version, + Tables: tables[0:1], + HashSymbols: tt.hash, + }) + require.NoError(t, err) + if tt.dialect != dialect.SQLite { + require.Contains(t, ac, "s1[type=schema].") + require.NotContains(t, ac, "s2[type=schema].") + require.Contains(t, ac, "-- Add new schema named \"s1\"") + } + }) + t.Run(n+" no schema", func(t *testing.T) { + tables[0].Schema = "" + ac, err := DDL(context.Background(), DDLArgs{ + Dialect: tt.dialect, + Version: tt.version, + Tables: tables[0:1], + HashSymbols: tt.hash, + }) + require.NoError(t, err) + require.NotContains(t, ac, "[type=schema].") + require.Contains(t, ac, "[type=table]") + }) + } +} diff --git a/dialect/sql/schema/sqlite.go b/dialect/sql/schema/sqlite.go new file mode 100644 index 0000000..944c0f8 --- /dev/null +++ b/dialect/sql/schema/sqlite.go @@ -0,0 +1,227 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package schema + +import ( + "context" + stdsql "database/sql" + "fmt" + "reflect" + "strconv" + "strings" + + "entgo.io/ent/dialect" + "entgo.io/ent/dialect/sql" + "entgo.io/ent/schema/field" + + "ariga.io/atlas/sql/migrate" + "ariga.io/atlas/sql/schema" + "ariga.io/atlas/sql/sqlite" +) + +type ( + // SQLite adapter for Atlas migration engine. + SQLite struct { + dialect.Driver + WithForeignKeys bool + } + // SQLiteTx implements dialect.Tx. + SQLiteTx struct { + dialect.Tx + commit func() error // Override Commit to toggle foreign keys back on after Commit. + rollback func() error // Override Rollback to toggle foreign keys back on after Rollback. + } +) + +// Tx implements opens a transaction. +func (d *SQLite) Tx(ctx context.Context) (dialect.Tx, error) { + db := &db{d} + if _, err := db.ExecContext(ctx, "PRAGMA foreign_keys = off"); err != nil { + return nil, fmt.Errorf("sqlite: set 'foreign_keys = off': %w", err) + } + t, err := d.Driver.Tx(ctx) + if err != nil { + return nil, err + } + tx := &tx{t} + cm, err := sqlite.CommitFunc(ctx, db, tx, true) + if err != nil { + return nil, err + } + return &SQLiteTx{Tx: t, commit: cm, rollback: sqlite.RollbackFunc(ctx, db, tx, true)}, nil +} + +// Commit ensures foreign keys are toggled back on after commit. +func (tx *SQLiteTx) Commit() error { + return tx.commit() +} + +// Rollback ensures foreign keys are toggled back on after rollback. +func (tx *SQLiteTx) Rollback() error { + return tx.rollback() +} + +// init makes sure that foreign_keys support is enabled. +func (d *SQLite) init(ctx context.Context) error { + on, err := exist(ctx, d, "PRAGMA foreign_keys") + if err != nil { + return fmt.Errorf("sqlite: check foreign_keys pragma: %w", err) + } + if !on { + // foreign_keys pragma is off, either enable it by execute "PRAGMA foreign_keys=ON" + // or add the following parameter in the connection string "_fk=1". + return fmt.Errorf("sqlite: foreign_keys pragma is off: missing %q in the connection string", "_fk=1") + } + return nil +} + +func (d *SQLite) tableExist(ctx context.Context, conn dialect.ExecQuerier, name string) (bool, error) { + query, args := sql.Select().Count(). + From(sql.Table("sqlite_master")). + Where(sql.And( + sql.EQ("type", "table"), + sql.EQ("name", name), + )). + Query() + return exist(ctx, conn, query, args...) +} + +func (d *SQLite) atOpen(conn dialect.ExecQuerier) (migrate.Driver, error) { + return sqlite.Open(&db{ExecQuerier: conn}) +} + +func (d *SQLite) atTable(t1 *Table, t2 *schema.Table) { + if t1.Annotation != nil { + setAtChecks(t1, t2) + } +} + +func (d *SQLite) supportsDefault(*Column) bool { + // SQLite supports default values for all standard types. + return true +} + +func (d *SQLite) atTypeC(c1 *Column, c2 *schema.Column) error { + if c1.SchemaType != nil && c1.SchemaType[dialect.SQLite] != "" { + t, err := sqlite.ParseType(strings.ToLower(c1.SchemaType[dialect.SQLite])) + if err != nil { + return err + } + c2.Type.Type = t + return nil + } + var t schema.Type + switch c1.Type { + case field.TypeBlob: + return fmt.Errorf("blob fields are not stored in the database") + case field.TypeBool: + t = &schema.BoolType{T: "bool"} + case field.TypeInt8, field.TypeUint8, field.TypeInt16, field.TypeUint16, field.TypeInt32, + field.TypeUint32, field.TypeUint, field.TypeInt, field.TypeInt64, field.TypeUint64: + t = &schema.IntegerType{T: sqlite.TypeInteger} + case field.TypeBytes: + t = &schema.BinaryType{T: sqlite.TypeBlob} + case field.TypeString, field.TypeEnum: + // SQLite does not impose any length restrictions on + // the length of strings, BLOBs or numeric values. + t = &schema.StringType{T: sqlite.TypeText} + case field.TypeFloat32, field.TypeFloat64: + t = &schema.FloatType{T: sqlite.TypeReal} + case field.TypeTime: + t = &schema.TimeType{T: "datetime"} + case field.TypeJSON: + t = &schema.JSONType{T: "json"} + case field.TypeUUID: + t = &sqlite.UUIDType{T: "uuid"} + case field.TypeOther: + t = &schema.UnsupportedType{T: c1.typ} + default: + t, err := sqlite.ParseType(strings.ToLower(c1.typ)) + if err != nil { + return err + } + c2.Type.Type = t + } + c2.Type.Type = t + return nil +} + +func (d *SQLite) atUniqueC(t1 *Table, c1 *Column, t2 *schema.Table, c2 *schema.Column) { + // For UNIQUE columns, SQLite create an implicit index named + // "sqlite_autoindex_
_". Ent uses the PostgreSQL approach + // in its migration, and name these indexes as "
__key". + for _, idx := range t1.Indexes { + // Index also defined explicitly, and will be add in atIndexes. + if idx.Unique && d.atImplicitIndexName(idx, t1, c1) { + return + } + } + t2.AddIndexes(schema.NewUniqueIndex(fmt.Sprintf("%s_%s_key", t2.Name, c1.Name)).AddColumns(c2)) +} + +func (d *SQLite) atImplicitIndexName(idx *Index, t1 *Table, c1 *Column) bool { + if idx.Name == c1.Name { + return true + } + p := fmt.Sprintf("sqlite_autoindex_%s_", t1.Name) + if !strings.HasPrefix(idx.Name, p) { + return false + } + i, err := strconv.ParseInt(strings.TrimPrefix(idx.Name, p), 10, 64) + return err == nil && i > 0 +} + +func (d *SQLite) atIncrementC(t *schema.Table, c *schema.Column) { + if c.Default != nil { + t.Attrs = removeAttr(t.Attrs, reflect.TypeOf(&sqlite.AutoIncrement{})) + } else { + c.AddAttrs(&sqlite.AutoIncrement{}) + } +} + +func (d *SQLite) atIncrementT(t *schema.Table, v int64) { + t.AddAttrs(&sqlite.AutoIncrement{Seq: v}) +} + +func (d *SQLite) atIndex(idx1 *Index, t2 *schema.Table, idx2 *schema.Index) error { + for _, c1 := range idx1.Columns { + c2, ok := t2.Column(c1.Name) + if !ok { + return fmt.Errorf("unexpected index %q column: %q", idx1.Name, c1.Name) + } + idx2.AddParts(&schema.IndexPart{C: c2}) + } + if idx1.Annotation != nil && idx1.Annotation.Where != "" { + idx2.AddAttrs(&sqlite.IndexPredicate{P: idx1.Annotation.Where}) + } + return nil +} + +func (*SQLite) atTypeRangeSQL(ts ...string) string { + for i := range ts { + ts[i] = fmt.Sprintf("('%s')", ts[i]) + } + return fmt.Sprintf("INSERT INTO `%s` (`type`) VALUES %s", TypeTable, strings.Join(ts, ", ")) +} + +type tx struct { + dialect.Tx +} + +func (tx *tx) QueryContext(ctx context.Context, query string, args ...any) (*stdsql.Rows, error) { + rows := &sql.Rows{} + if err := tx.Query(ctx, query, args, rows); err != nil { + return nil, err + } + return rows.ColumnScanner.(*stdsql.Rows), nil +} + +func (tx *tx) ExecContext(ctx context.Context, query string, args ...any) (stdsql.Result, error) { + var r stdsql.Result + if err := tx.Exec(ctx, query, args, &r); err != nil { + return nil, err + } + return r, nil +} diff --git a/dialect/sql/schema/writer.go b/dialect/sql/schema/writer.go new file mode 100644 index 0000000..611caac --- /dev/null +++ b/dialect/sql/schema/writer.go @@ -0,0 +1,365 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package schema + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "regexp" + "strconv" + "strings" + "time" + "unicode" + + "entgo.io/ent/dialect" + "entgo.io/ent/dialect/sql" + + "ariga.io/atlas/sql/migrate" +) + +type ( + // WriteDriver is a driver that writes all driver exec operations to its writer. + // Note that this driver is used only for printing or writing statements to SQL + // files, and may require manual changes to the generated SQL statements. + WriteDriver struct { + dialect.Driver // optional driver for query calls. + io.Writer // target for exec statements. + FormatFunc func(string) (string, error) + } + // DirWriter implements the io.Writer interface + // for writing to an Atlas managed directory. + DirWriter struct { + Dir migrate.Dir // target directory. + Formatter migrate.Formatter // optional formatter. + b bytes.Buffer // working buffer. + changes []*migrate.Change // changes to flush. + } +) + +// Write implements the io.Writer interface. +func (d *DirWriter) Write(p []byte) (int, error) { + return d.b.Write(trimReturning(p)) +} + +// Change converts all written statement so far into a migration +// change with the given comment. +func (d *DirWriter) Change(comment string) { + // Trim semicolon and new line, because formatter adds it. + d.changes = append(d.changes, &migrate.Change{Comment: comment, Cmd: strings.TrimRight(d.b.String(), ";\n")}) + d.b.Reset() +} + +// Flush flushes the written statements to the directory. +func (d *DirWriter) Flush(name string) error { + switch { + case d.b.Len() != 0: + return fmt.Errorf("writer has undocumented change. Use Change or FlushChange instead") + case len(d.changes) == 0: + return errors.New("writer has no changes to flush") + default: + return migrate.NewPlanner(nil, d.Dir, migrate.PlanFormat(d.Formatter)). + WritePlan(&migrate.Plan{ + Name: name, + Changes: d.changes, + }) + } +} + +// FlushChange combines Change and Flush. +func (d *DirWriter) FlushChange(name, comment string) error { + d.Change(comment) + return d.Flush(name) +} + +// NewWriteDriver creates a dialect.Driver that writes all driver exec statement to its writer. +func NewWriteDriver(dialect string, w io.Writer) *WriteDriver { + return &WriteDriver{ + Writer: w, + Driver: nopDriver{dialect: dialect}, + } +} + +// Exec implements the dialect.Driver.Exec method. +func (w *WriteDriver) Exec(_ context.Context, query string, args, res any) error { + if rr, ok := res.(*sql.Result); ok { + *rr = noResult{} + } + if !strings.HasSuffix(query, ";") { + query += ";" + } + if args != nil { + args, ok := args.([]any) + if !ok { + return fmt.Errorf("unexpected args type: %T", args) + } + query = w.expandArgs(query, args) + } + _, err := io.WriteString(w, query+"\n") + return err +} + +// Query implements the dialect.Driver.Query method. +func (w *WriteDriver) Query(ctx context.Context, query string, args, res any) error { + if strings.HasPrefix(query, "INSERT") || strings.HasPrefix(query, "UPDATE") { + if err := w.Exec(ctx, query, args, nil); err != nil { + return err + } + if rr, ok := res.(*sql.Rows); ok { + cols := func() []string { + // If the query has a RETURNING clause, mock the result. + var clause string + outer: + for i := 0; i < len(query); i++ { + switch q := query[i]; { + case q == '\'', q == '"', q == '`': // string or identifier + _, skip := skipQuoted(query, i) + if skip == -1 { + return nil // malformed SQL + } + i = skip + continue + case reReturning.MatchString(query[i:]): + var j int + inner: + // Forward until next unquoted ';' appears, or we reach the end of the query. + for j = i; j < len(query); j++ { + switch query[j] { + case '\'', '"', '`': // string or identifier + _, skip := skipQuoted(query, j) + if skip == -1 { + return nil // malformed RETURNING clause + } + j = skip + case ';': + break inner + } + } + clause = query[i:j] + break outer + } + } + cols := strings.Split(reReturning.ReplaceAllString(clause, ""), ",") + for i := range cols { + cols[i] = strings.TrimSpace(cols[i]) + } + return cols + }() + *rr = sql.Rows{ColumnScanner: &noRows{cols: cols}} + } + return nil + } + switch w.Driver.(type) { + case nil, nopDriver: + return errors.New("query is not supported by the WriteDriver") + default: + return w.Driver.Query(ctx, query, args, res) + } +} + +// expandArgs combines to arguments and statement into a single statement to +// print or write into a file (before editing). +// Note, the output may be incorrect or unsafe SQL and require manual changes. +func (w *WriteDriver) expandArgs(query string, args []any) string { + var ( + b strings.Builder + p = w.placeholder() + scan = w.scanPlaceholder() + ) + for i := 0; i < len(query); i++ { + Top: + switch query[i] { + case p: + idx, size := scan(query[i+1:]) + // Unrecognized placeholder. + if idx < 0 || idx >= len(args) { + return query + } + i += size + v, err := w.formatArg(args[idx]) + if err != nil { + // Unexpected formatting error. + return query + } + b.WriteString(v) + // String or identifier. + case '\'', '"', '`': + for j := i + 1; j < len(query); j++ { + switch query[j] { + case '\\': + j++ + case query[i]: + b.WriteString(query[i : j+1]) + i = j + break Top + } + } + // Unexpected EOS. + return query + default: + b.WriteByte(query[i]) + } + } + return b.String() +} + +func (w *WriteDriver) scanPlaceholder() func(string) (int, int) { + switch w.Dialect() { + case dialect.Postgres: + return func(s string) (int, int) { + var i int + for i < len(s) && unicode.IsDigit(rune(s[i])) { + i++ + } + idx, err := strconv.ParseInt(s[:i], 10, 64) + if err != nil { + return -1, 0 + } + // Placeholders are 1-based. + return int(idx) - 1, i + } + default: + idx := -1 + return func(string) (int, int) { + idx++ + return idx, 0 + } + } +} + +func (w *WriteDriver) placeholder() byte { + if w.Dialect() == dialect.Postgres { + return '$' + } + return '?' +} + +func (w *WriteDriver) formatArg(v any) (string, error) { + if w.FormatFunc != nil { + return w.FormatFunc(fmt.Sprint(v)) + } + switch v := v.(type) { + case nil: + return "NULL", nil + case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64: + return fmt.Sprintf("%d", v), nil + case float32, float64: + return fmt.Sprintf("%g", v), nil + case bool: + if v { + return "1", nil + } else { + return "0", nil + } + case string: + return "'" + strings.ReplaceAll(v, "'", "''") + "'", nil + case json.RawMessage: + return "'" + strings.ReplaceAll(string(v), "'", "''") + "'", nil + case []byte: + return "{{ BINARY_VALUE }}", nil + case time.Time: + return "{{ TIME_VALUE }}", nil + case fmt.Stringer: + return "'" + strings.ReplaceAll(v.String(), "'", "''") + "'", nil + default: + return "{{ VALUE }}", nil + } +} + +var reReturning = regexp.MustCompile(`(?i)^\s?RETURNING`) + +// trimReturning trims any RETURNING suffix from INSERT/UPDATE queries. +// Note, that the output may be incorrect or unsafe SQL and require manual changes. +func trimReturning(query []byte) []byte { + var b bytes.Buffer +loop: + for i := 0; i < len(query); i++ { + switch q := query[i]; { + case q == '\'', q == '"', q == '`': // string or identifier + s, skip := skipQuoted(query, i) + if skip == -1 { + return query + } + b.Write(s) + i = skip + continue + case reReturning.Match(query[i:]): + // Forward until next unquoted ';' appears. + for j := i; j < len(query); j++ { // skip "RETURNING" + switch query[j] { + case '\'', '"', '`': // string or identifier + _, skip := skipQuoted(query, j) + if skip == -1 { + return query + } + j = skip + case ';': + b.WriteString(";") + i += j + continue loop + } + } + } + b.WriteByte(query[i]) + } + return b.Bytes() +} + +func skipQuoted[T []byte | string](query T, idx int) (T, int) { + for j := idx + 1; j < len(query); j++ { + switch query[j] { + case '\\': + j++ + case query[idx]: + return query[idx : j+1], j + } + } + // Unexpected EOS. + return query, -1 +} + +// Tx writes the transaction start. +func (w *WriteDriver) Tx(context.Context) (dialect.Tx, error) { + return dialect.NopTx(w), nil +} + +// noResult represents a zero result. +type noResult struct{} + +func (noResult) LastInsertId() (int64, error) { return 0, nil } +func (noResult) RowsAffected() (int64, error) { return 0, nil } + +// noRows represents no rows. +type noRows struct { + sql.ColumnScanner + cols []string + done bool +} + +func (*noRows) Close() error { return nil } +func (*noRows) Err() error { return nil } +func (r *noRows) Next() bool { + if !r.done { + r.done = true + return true + } + return false +} +func (r *noRows) Columns() ([]string, error) { return r.cols, nil } +func (*noRows) Scan(...any) error { return nil } + +type nopDriver struct { + dialect.Driver + dialect string +} + +func (d nopDriver) Dialect() string { return d.dialect } + +func (nopDriver) Query(context.Context, string, any, any) error { + return nil +} diff --git a/dialect/sql/schema/writer_test.go b/dialect/sql/schema/writer_test.go new file mode 100644 index 0000000..35dd004 --- /dev/null +++ b/dialect/sql/schema/writer_test.go @@ -0,0 +1,189 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package schema + +import ( + "bytes" + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "entgo.io/ent/dialect" + "entgo.io/ent/dialect/sql" + "entgo.io/ent/dialect/sql/sqljson" + + "ariga.io/atlas/sql/migrate" + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +func TestWriteDriver(t *testing.T) { + b := &bytes.Buffer{} + w := NewWriteDriver(dialect.MySQL, b) + ctx := context.Background() + tx, err := w.Tx(ctx) + require.NoError(t, err) + err = tx.Query(ctx, "SELECT `name` FROM `users`", nil, nil) + require.EqualError(t, err, "query is not supported by the WriteDriver") + err = tx.Exec(ctx, "ALTER TABLE `users` ADD COLUMN `age` int", nil, nil) + require.NoError(t, err) + err = tx.Exec(ctx, "ALTER TABLE `users` ADD COLUMN `NAME` varchar(100);", nil, nil) + require.NoError(t, err) + require.NoError(t, tx.Commit()) + lines := strings.Split(b.String(), "\n") + require.Len(t, lines, 3) + require.Equal(t, "ALTER TABLE `users` ADD COLUMN `age` int;", lines[0]) + require.Equal(t, "ALTER TABLE `users` ADD COLUMN `NAME` varchar(100);", lines[1]) + require.Empty(t, lines[2], "file ends with blank line") + + b.Reset() + query, args := sql.Update("users").Schema("test").Set("a", 1).Set("b", "a").Set("c", "'c'").Set("d", true).Where(sql.EQ("p", 0.2)).Query() + err = w.Exec(ctx, query, args, nil) + require.NoError(t, err) + require.Equal(t, "UPDATE `test`.`users` SET `a` = 1, `b` = 'a', `c` = '''c''', `d` = 1 WHERE `p` = 0.2;\n", b.String()) + + b.Reset() + query, args = sql.Dialect(dialect.MySQL).Update("users").Schema("test").Set("a", "{}").Where(sqljson.ValueIsNull("a")).Query() + err = w.Exec(ctx, query, args, nil) + require.NoError(t, err) + require.Equal(t, "UPDATE `test`.`users` SET `a` = '{}' WHERE JSON_CONTAINS(`a`, 'null', '$');\n", b.String()) + + b.Reset() + w = NewWriteDriver(dialect.Postgres, b) + query, args = sql.Dialect(dialect.Postgres).Update("users").Set("id", uuid.Nil).Set("a", 1).Set("b", time.Now()).Query() + err = w.Exec(ctx, query, args, nil) + require.NoError(t, err) + require.Equal(t, `UPDATE "users" SET "id" = '00000000-0000-0000-0000-000000000000', "a" = 1, "b" = {{ TIME_VALUE }};`+"\n", b.String()) + + b.Reset() + err = w.Exec(ctx, `INSERT INTO "users" (name) VALUES("a8m") RETURNING id`, nil, nil) + require.NoError(t, err) + require.Equal(t, `INSERT INTO "users" (name) VALUES("a8m") RETURNING id;`+"\n", b.String()) + + // batchCreator uses tx.Query when doing an insert + b.Reset() + err = w.Query(ctx, `INSERT INTO "users" (name) VALUES("a8m") RETURNING id`, nil, nil) + require.NoError(t, err) + require.Equal(t, `INSERT INTO "users" (name) VALUES("a8m") RETURNING id;`+"\n", b.String()) + + // correct columns are extracted from a returning clause and returned by sql.ColumnScanner. + for q, cols := range map[string][]string{ + `INSERT INTO "users" (name) VALUES("a8m") RETURNING id`: {"id"}, + `INSERT INTO "users" (name) VALUES("a8m") RETURNING id, "name"`: {"id", `"name"`}, + `INSERT INTO "users" (name) VALUES("a8m") RETURNING "id", "name"`: {`"id"`, `"name"`}, + `INSERT INTO "users" (name) VALUES("a8m") RETURNING "id", "name"; DROP "groups"`: {`"id"`, `"name"`}, + } { + var rows sql.Rows + err = w.Query(ctx, q, nil, &rows) + require.NoError(t, err) + require.True(t, rows.Next()) + c, err := rows.Columns() + require.NoError(t, err) + require.Equal(t, cols, c) + require.NoError(t, rows.Scan()) + } + b.Reset() +} + +func TestDirWriter(t *testing.T) { + for _, tt := range []struct { + dialect string + exec []string + comments []string + args [][]any + want string + }{ + { + dialect.MySQL, + []string{ + "UPDATE `test`.`users` SET `a` = ?", + "UPDATE `test`.`users` SET `b` = ?", + }, + []string{ + "Comment 1.", + "Comment 2.", + }, + [][]any{ + {1}, + {2}, + }, + "-- Comment 1.\nUPDATE `test`.`users` SET `a` = 1;\n-- Comment 2.\nUPDATE `test`.`users` SET `b` = 2;\n", + }, + { + dialect.Postgres, + []string{ + "INSERT INTO \"users\" (\"name\", \"email\") VALUES ($1, $2) RETURNING \"id\"", + "INSERT INTO \"groups\" (\"name\") VALUES ($1) RETURNING \"id\"", + }, + []string{ + "Seed users table", + "Seed groups table", + }, + [][]any{ + {"masseelch", "j@ariga.io"}, + {"admins"}, + }, + strings.Join([]string{ + "-- Seed users table\nINSERT INTO \"users\" (\"name\", \"email\") VALUES ('masseelch', 'j@ariga.io');\n", + "-- Seed groups table\nINSERT INTO \"groups\" (\"name\") VALUES ('admins');\n", + }, ""), + }, + { + dialect.SQLite, + []string{ + "INSERT INTO `users` (`name`, `email`) VALUES (?, ?) RETURNING `id`", + "INSERT INTO `groups` (`name`) VALUES (?) RETURNING `id`", + }, + []string{ + "Seed users table", + "Seed groups table", + }, + [][]any{ + {"masseelch", "j@ariga.io"}, + {"admins"}, + }, + strings.Join([]string{ + "-- Seed users table\nINSERT INTO `users` (`name`, `email`) VALUES ('masseelch', 'j@ariga.io');\n", + "-- Seed groups table\nINSERT INTO `groups` (`name`) VALUES ('admins');\n", + }, ""), + }, + { + dialect.SQLite + " no space", + []string{"INSERT INTO `users` (`name`) VALUES (?)RETURNING `id`"}, + []string{"Seed users table"}, + [][]any{{"masseelch"}}, + "-- Seed users table\nINSERT INTO `users` (`name`) VALUES ('masseelch');\n", + }, + } { + t.Run(tt.dialect, func(t *testing.T) { + var ( + p = t.TempDir() + dir = func() migrate.Dir { + d, err := migrate.NewLocalDir(p) + require.NoError(t, err) + return d + }() + w = &DirWriter{Dir: dir} + drv = NewWriteDriver(tt.dialect, w) + ) + for i := range tt.exec { + require.NoError(t, drv.Exec(context.Background(), tt.exec[i], tt.args[i], nil)) + w.Change(tt.comments[i]) + } + require.NoError(t, w.Flush("migration_file")) + files, err := os.ReadDir(p) + require.NoError(t, err) + require.Len(t, files, 2) + require.Contains(t, files[0].Name(), "_migration_file.sql") + buf, err := os.ReadFile(filepath.Join(p, files[0].Name())) + require.NoError(t, err) + require.Equal(t, tt.want, string(buf)) + require.Equal(t, "atlas.sum", files[1].Name()) + }) + } +} diff --git a/dialect/sql/sql.go b/dialect/sql/sql.go new file mode 100644 index 0000000..e4c3d05 --- /dev/null +++ b/dialect/sql/sql.go @@ -0,0 +1,428 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package sql + +import ( + "fmt" + "strings" + + "entgo.io/ent/dialect" +) + +// The following helpers exist to simplify the way raw predicates +// are defined and used in both ent/schema and generated code. For +// full predicates API, check out the sql.P in builder.go. + +// FieldIsNull returns a raw predicate to check if the given field is NULL. +func FieldIsNull(name string) func(*Selector) { + return func(s *Selector) { + s.Where(IsNull(s.C(name))) + } +} + +// FieldNotNull returns a raw predicate to check if the given field is not NULL. +func FieldNotNull(name string) func(*Selector) { + return func(s *Selector) { + s.Where(NotNull(s.C(name))) + } +} + +// FieldEQ returns a raw predicate to check if the given field equals to the given value. +func FieldEQ(name string, v any) func(*Selector) { + return func(s *Selector) { + s.Where(EQ(s.C(name), v)) + } +} + +// FieldsEQ returns a raw predicate to check if the given fields (columns) are equal. +func FieldsEQ(field1, field2 string) func(*Selector) { + return func(s *Selector) { + s.Where(ColumnsEQ(s.C(field1), s.C(field2))) + } +} + +// FieldNEQ returns a raw predicate to check if the given field does not equal to the given value. +func FieldNEQ(name string, v any) func(*Selector) { + return func(s *Selector) { + s.Where(NEQ(s.C(name), v)) + } +} + +// FieldsNEQ returns a raw predicate to check if the given fields (columns) are not equal. +func FieldsNEQ(field1, field2 string) func(*Selector) { + return func(s *Selector) { + s.Where(ColumnsNEQ(s.C(field1), s.C(field2))) + } +} + +// FieldGT returns a raw predicate to check if the given field is greater than the given value. +func FieldGT(name string, v any) func(*Selector) { + return func(s *Selector) { + s.Where(GT(s.C(name), v)) + } +} + +// FieldsGT returns a raw predicate to check if field1 is greater than field2. +func FieldsGT(field1, field2 string) func(*Selector) { + return func(s *Selector) { + s.Where(ColumnsGT(s.C(field1), s.C(field2))) + } +} + +// FieldGTE returns a raw predicate to check if the given field is greater than or equal the given value. +func FieldGTE(name string, v any) func(*Selector) { + return func(s *Selector) { + s.Where(GTE(s.C(name), v)) + } +} + +// FieldsGTE returns a raw predicate to check if field1 is greater than or equal field2. +func FieldsGTE(field1, field2 string) func(*Selector) { + return func(s *Selector) { + s.Where(ColumnsGTE(s.C(field1), s.C(field2))) + } +} + +// FieldLT returns a raw predicate to check if the value of the field is less than the given value. +func FieldLT(name string, v any) func(*Selector) { + return func(s *Selector) { + s.Where(LT(s.C(name), v)) + } +} + +// FieldsLT returns a raw predicate to check if field1 is lower than field2. +func FieldsLT(field1, field2 string) func(*Selector) { + return func(s *Selector) { + s.Where(ColumnsLT(s.C(field1), s.C(field2))) + } +} + +// FieldLTE returns a raw predicate to check if the value of the field is less than the given value. +func FieldLTE(name string, v any) func(*Selector) { + return func(s *Selector) { + s.Where(LTE(s.C(name), v)) + } +} + +// FieldsLTE returns a raw predicate to check if field1 is lower than or equal field2. +func FieldsLTE(field1, field2 string) func(*Selector) { + return func(s *Selector) { + s.Where(ColumnsLTE(s.C(field1), s.C(field2))) + } +} + +// FieldsHasPrefix returns a raw predicate to checks if field1 begins with the value of field2. +func FieldsHasPrefix(field1, field2 string) func(*Selector) { + return func(s *Selector) { + s.Where(ColumnsHasPrefix(s.C(field1), s.C(field2))) + } +} + +// FieldIn returns a raw predicate to check if the value of the field is IN the given values. +func FieldIn[T any](name string, vs ...T) func(*Selector) { + return func(s *Selector) { + v := make([]any, len(vs)) + for i := range v { + v[i] = vs[i] + } + s.Where(In(s.C(name), v...)) + } +} + +// FieldNotIn returns a raw predicate to check if the value of the field is NOT IN the given values. +func FieldNotIn[T any](name string, vs ...T) func(*Selector) { + return func(s *Selector) { + v := make([]any, len(vs)) + for i := range v { + v[i] = vs[i] + } + s.Where(NotIn(s.C(name), v...)) + } +} + +// FieldEqualFold returns a raw predicate to check if the field is equal to the given string under case-folding. +func FieldEqualFold(name string, substr string) func(*Selector) { + return func(s *Selector) { + s.Where(EqualFold(s.C(name), substr)) + } +} + +// FieldHasPrefix returns a raw predicate to check if the field has the given prefix. +func FieldHasPrefix(name string, prefix string) func(*Selector) { + return func(s *Selector) { + s.Where(HasPrefix(s.C(name), prefix)) + } +} + +// FieldHasPrefixFold returns a raw predicate to check if the field has the given prefix with case-folding +func FieldHasPrefixFold(name string, prefix string) func(*Selector) { + return func(s *Selector) { + s.Where(HasPrefixFold(s.C(name), prefix)) + } +} + +// FieldHasSuffix returns a raw predicate to check if the field has the given suffix. +func FieldHasSuffix(name string, suffix string) func(*Selector) { + return func(s *Selector) { + s.Where(HasSuffix(s.C(name), suffix)) + } +} + +// FieldHasSuffixFold returns a raw predicate to check if the field has the given suffix with case-folding +func FieldHasSuffixFold(name string, suffix string) func(*Selector) { + return func(s *Selector) { + s.Where(HasSuffixFold(s.C(name), suffix)) + } +} + +// FieldContains returns a raw predicate to check if the field contains the given substring. +func FieldContains(name string, substr string) func(*Selector) { + return func(s *Selector) { + s.Where(Contains(s.C(name), substr)) + } +} + +// FieldContainsFold returns a raw predicate to check if the field contains the given substring with case-folding. +func FieldContainsFold(name string, substr string) func(*Selector) { + return func(s *Selector) { + s.Where(ContainsFold(s.C(name), substr)) + } +} + +// AndPredicates returns a new predicate for joining multiple generated predicates with AND between them. +func AndPredicates[P ~func(*Selector)](predicates ...P) func(*Selector) { + return func(s *Selector) { + s.CollectPredicates() + for _, p := range predicates { + p(s) + } + collected := s.CollectedPredicates() + s.UncollectedPredicates() + switch len(collected) { + case 0: + case 1: + s.Where(collected[0]) + default: + s.Where(And(collected...)) + } + } +} + +// OrPredicates returns a new predicate for joining multiple generated predicates with OR between them. +func OrPredicates[P ~func(*Selector)](predicates ...P) func(*Selector) { + return func(s *Selector) { + s.CollectPredicates() + for _, p := range predicates { + p(s) + } + collected := s.CollectedPredicates() + s.UncollectedPredicates() + switch len(collected) { + case 0: + case 1: + s.Where(collected[0]) + default: + s.Where(Or(collected...)) + } + } +} + +// NotPredicates wraps the generated predicates with NOT. For example, NOT(P), NOT((P1 AND P2)). +func NotPredicates[P ~func(*Selector)](predicates ...P) func(*Selector) { + return func(s *Selector) { + s.CollectPredicates() + for _, p := range predicates { + p(s) + } + collected := s.CollectedPredicates() + s.UncollectedPredicates() + switch len(collected) { + case 0: + case 1: + s.Where(Not(collected[0])) + default: + s.Where(Not(And(collected...))) + } + } +} + +// ColumnCheck is a function that verifies whether the +// specified column exists within the given table. +type ColumnCheck func(table, column string) error + +// NewColumnCheck returns a function that verifies whether the specified column exists +// within the given table. This function is utilized by the generated code to validate +// column names in ordering functions. +func NewColumnCheck(checks map[string]func(string) bool) ColumnCheck { + return func(table, column string) error { + check, ok := checks[table] + if !ok { + return fmt.Errorf("unknown table %q", table) + } + if !check(column) { + return fmt.Errorf("unknown column %q for table %q", column, table) + } + return nil + } +} + +type ( + // OrderFieldTerm represents an ordering by a field. + OrderFieldTerm struct { + OrderTermOptions + Field string // Field name. + } + // OrderExprTerm represents an ordering by an expression. + OrderExprTerm struct { + OrderTermOptions + Expr func(*Selector) Querier // Expression. + } + // OrderTerm represents an ordering by a term. + OrderTerm interface { + term() + } + // OrderTermOptions represents options for ordering by a term. + OrderTermOptions struct { + Desc bool // Whether to sort in descending order. + As string // Optional alias. + Selected bool // Whether the term should be selected. + NullsFirst bool // Whether to sort nulls first. + NullsLast bool // Whether to sort nulls last. + } + // OrderTermOption is an option for ordering by a term. + OrderTermOption func(*OrderTermOptions) +) + +// OrderDesc returns an option to sort in descending order. +func OrderDesc() OrderTermOption { + return func(o *OrderTermOptions) { + o.Desc = true + } +} + +// OrderAsc returns an option to sort in ascending order. +func OrderAsc() OrderTermOption { + return func(o *OrderTermOptions) { + o.Desc = false + } +} + +// OrderAs returns an option to set the alias for the ordering. +func OrderAs(as string) OrderTermOption { + return func(o *OrderTermOptions) { + o.As = as + } +} + +// OrderSelected returns an option to select the ordering term. +func OrderSelected() OrderTermOption { + return func(o *OrderTermOptions) { + o.Selected = true + } +} + +// OrderSelectAs returns an option to set and select the alias for the ordering. +func OrderSelectAs(as string) OrderTermOption { + return func(o *OrderTermOptions) { + o.As = as + o.Selected = true + } +} + +// OrderNullsFirst returns an option to sort nulls first. +func OrderNullsFirst() OrderTermOption { + return func(o *OrderTermOptions) { + o.NullsFirst = true + } +} + +// OrderNullsLast returns an option to sort nulls last. +func OrderNullsLast() OrderTermOption { + return func(o *OrderTermOptions) { + o.NullsLast = true + } +} + +// NewOrderTermOptions returns a new OrderTermOptions from the given options. +func NewOrderTermOptions(opts ...OrderTermOption) *OrderTermOptions { + o := &OrderTermOptions{} + for _, opt := range opts { + opt(o) + } + return o +} + +// OrderByField returns an ordering by the given field. +func OrderByField(field string, opts ...OrderTermOption) *OrderFieldTerm { + return &OrderFieldTerm{Field: field, OrderTermOptions: *NewOrderTermOptions(opts...)} +} + +// OrderBySum returns an ordering by the sum of the given field. +func OrderBySum(field string, opts ...OrderTermOption) *OrderExprTerm { + return orderByAgg("SUM", field, opts...) +} + +// OrderByCount returns an ordering by the count of the given field. +func OrderByCount(field string, opts ...OrderTermOption) *OrderExprTerm { + return orderByAgg("COUNT", field, opts...) +} + +// orderByAgg returns an ordering by the aggregation of the given field. +func orderByAgg(fn, field string, opts ...OrderTermOption) *OrderExprTerm { + return &OrderExprTerm{ + OrderTermOptions: *NewOrderTermOptions( + append( + // Default alias is "_". + []OrderTermOption{OrderAs(fmt.Sprintf("%s_%s", strings.ToLower(fn), field))}, + opts..., + )..., + ), + Expr: func(s *Selector) Querier { + var c string + switch { + case field == "*", isFunc(field): + c = field + default: + c = s.C(field) + } + return Raw(fmt.Sprintf("%s(%s)", fn, c)) + }, + } +} + +// OrderByRand returns a term to natively order by a random value. +func OrderByRand() func(*Selector) { + return func(s *Selector) { + s.OrderExprFunc(func(b *Builder) { + switch s.Dialect() { + case dialect.MySQL: + b.WriteString("RAND()") + default: + b.WriteString("RANDOM()") + } + }) + } +} + +// ToFunc returns a function that sets the ordering on the given selector. +// This is used by the generated code. +func (f *OrderFieldTerm) ToFunc() func(*Selector) { + return func(s *Selector) { + s.OrderExprFunc(func(b *Builder) { + b.WriteString(s.C(f.Field)) + if f.Desc { + b.WriteString(" DESC") + } + if f.NullsFirst { + b.WriteString(" NULLS FIRST") + } else if f.NullsLast { + b.WriteString(" NULLS LAST") + } + }) + } +} + +func (OrderFieldTerm) term() {} +func (OrderExprTerm) term() {} diff --git a/dialect/sql/sql_test.go b/dialect/sql/sql_test.go new file mode 100644 index 0000000..ef3343d --- /dev/null +++ b/dialect/sql/sql_test.go @@ -0,0 +1,471 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package sql + +import ( + "testing" + + "entgo.io/ent/dialect" + + "github.com/stretchr/testify/require" +) + +func TestFieldIsNull(t *testing.T) { + p := FieldIsNull("name") + t.Run("MySQL", func(t *testing.T) { + s := Dialect(dialect.MySQL).Select("*").From(Table("users")) + p(s) + query, args := s.Query() + require.Equal(t, "SELECT * FROM `users` WHERE `users`.`name` IS NULL", query) + require.Empty(t, args) + }) + t.Run("PostgreSQL", func(t *testing.T) { + s := Dialect(dialect.Postgres).Select("*").From(Table("users")) + p(s) + query, args := s.Query() + require.Equal(t, `SELECT * FROM "users" WHERE "users"."name" IS NULL`, query) + require.Empty(t, args) + }) +} + +func TestFieldNotNull(t *testing.T) { + p := FieldNotNull("name") + t.Run("MySQL", func(t *testing.T) { + s := Dialect(dialect.MySQL).Select("*").From(Table("users")) + p(s) + query, args := s.Query() + require.Equal(t, "SELECT * FROM `users` WHERE `users`.`name` IS NOT NULL", query) + require.Empty(t, args) + }) + t.Run("PostgreSQL", func(t *testing.T) { + s := Dialect(dialect.Postgres).Select("*").From(Table("users")) + p(s) + query, args := s.Query() + require.Equal(t, `SELECT * FROM "users" WHERE "users"."name" IS NOT NULL`, query) + require.Empty(t, args) + }) +} + +func TestFieldEQ(t *testing.T) { + p := FieldEQ("name", "a8m") + t.Run("MySQL", func(t *testing.T) { + s := Dialect(dialect.MySQL).Select("*").From(Table("users")) + p(s) + query, args := s.Query() + require.Equal(t, "SELECT * FROM `users` WHERE `users`.`name` = ?", query) + require.Equal(t, []any{"a8m"}, args) + }) + t.Run("PostgreSQL", func(t *testing.T) { + s := Dialect(dialect.Postgres).Select("*").From(Table("users")) + p(s) + query, args := s.Query() + require.Equal(t, `SELECT * FROM "users" WHERE "users"."name" = $1`, query) + require.Equal(t, []any{"a8m"}, args) + }) +} + +func TestFieldsEQ(t *testing.T) { + p := FieldsEQ("create_time", "update_time") + t.Run("MySQL", func(t *testing.T) { + s := Dialect(dialect.MySQL).Select("*").From(Table("users")) + p(s) + query, args := s.Query() + require.Equal(t, "SELECT * FROM `users` WHERE `users`.`create_time` = `users`.`update_time`", query) + require.Empty(t, args) + }) + t.Run("PostgreSQL", func(t *testing.T) { + s := Dialect(dialect.Postgres).Select("*").From(Table("users")) + p(s) + query, args := s.Query() + require.Equal(t, `SELECT * FROM "users" WHERE "users"."create_time" = "users"."update_time"`, query) + require.Empty(t, args) + }) +} + +func TestFieldsNEQ(t *testing.T) { + p := FieldsNEQ("create_time", "update_time") + t.Run("MySQL", func(t *testing.T) { + s := Dialect(dialect.MySQL).Select("*").From(Table("users")) + p(s) + query, args := s.Query() + require.Equal(t, "SELECT * FROM `users` WHERE `users`.`create_time` <> `users`.`update_time`", query) + require.Empty(t, args) + }) + t.Run("PostgreSQL", func(t *testing.T) { + s := Dialect(dialect.Postgres).Select("*").From(Table("users")) + p(s) + query, args := s.Query() + require.Equal(t, `SELECT * FROM "users" WHERE "users"."create_time" <> "users"."update_time"`, query) + require.Empty(t, args) + }) +} + +func TestFieldNEQ(t *testing.T) { + p := FieldNEQ("name", "a8m") + t.Run("MySQL", func(t *testing.T) { + s := Dialect(dialect.MySQL).Select("*").From(Table("users")) + p(s) + query, args := s.Query() + require.Equal(t, "SELECT * FROM `users` WHERE `users`.`name` <> ?", query) + require.Equal(t, []any{"a8m"}, args) + }) + t.Run("PostgreSQL", func(t *testing.T) { + s := Dialect(dialect.Postgres).Select("*").From(Table("users")) + p(s) + query, args := s.Query() + require.Equal(t, `SELECT * FROM "users" WHERE "users"."name" <> $1`, query) + require.Equal(t, []any{"a8m"}, args) + }) +} + +func TestFieldGT(t *testing.T) { + p := FieldGT("stars", 1000) + t.Run("MySQL", func(t *testing.T) { + s := Dialect(dialect.MySQL).Select("*").From(Table("users")) + p(s) + query, args := s.Query() + require.Equal(t, "SELECT * FROM `users` WHERE `users`.`stars` > ?", query) + require.Equal(t, []any{1000}, args) + }) + t.Run("PostgreSQL", func(t *testing.T) { + s := Dialect(dialect.Postgres).Select("*").From(Table("users")) + p(s) + query, args := s.Query() + require.Equal(t, `SELECT * FROM "users" WHERE "users"."stars" > $1`, query) + require.Equal(t, []any{1000}, args) + }) +} + +func TestFieldsGT(t *testing.T) { + p := FieldsGT("a", "b") + t.Run("MySQL", func(t *testing.T) { + s := Dialect(dialect.MySQL).Select("*").From(Table("users")) + p(s) + query, args := s.Query() + require.Equal(t, "SELECT * FROM `users` WHERE `users`.`a` > `users`.`b`", query) + require.Empty(t, args) + }) + t.Run("PostgreSQL", func(t *testing.T) { + s := Dialect(dialect.Postgres).Select("*").From(Table("users")) + p(s) + query, args := s.Query() + require.Equal(t, `SELECT * FROM "users" WHERE "users"."a" > "users"."b"`, query) + require.Empty(t, args) + }) +} + +func TestFieldGTE(t *testing.T) { + p := FieldGTE("stars", 1000) + t.Run("MySQL", func(t *testing.T) { + s := Dialect(dialect.MySQL).Select("*").From(Table("users")) + p(s) + query, args := s.Query() + require.Equal(t, "SELECT * FROM `users` WHERE `users`.`stars` >= ?", query) + require.Equal(t, []any{1000}, args) + }) + t.Run("PostgreSQL", func(t *testing.T) { + s := Dialect(dialect.Postgres).Select("*").From(Table("users")) + p(s) + query, args := s.Query() + require.Equal(t, `SELECT * FROM "users" WHERE "users"."stars" >= $1`, query) + require.Equal(t, []any{1000}, args) + }) +} + +func TestFieldsGTE(t *testing.T) { + p := FieldsGTE("a", "b") + t.Run("MySQL", func(t *testing.T) { + s := Dialect(dialect.MySQL).Select("*").From(Table("users")) + p(s) + query, args := s.Query() + require.Equal(t, "SELECT * FROM `users` WHERE `users`.`a` >= `users`.`b`", query) + require.Empty(t, args) + }) + t.Run("PostgreSQL", func(t *testing.T) { + s := Dialect(dialect.Postgres).Select("*").From(Table("users")) + p(s) + query, args := s.Query() + require.Equal(t, `SELECT * FROM "users" WHERE "users"."a" >= "users"."b"`, query) + require.Empty(t, args) + }) +} + +func TestFieldLT(t *testing.T) { + p := FieldLT("stars", 1000) + t.Run("MySQL", func(t *testing.T) { + s := Dialect(dialect.MySQL).Select("*").From(Table("users")) + p(s) + query, args := s.Query() + require.Equal(t, "SELECT * FROM `users` WHERE `users`.`stars` < ?", query) + require.Equal(t, []any{1000}, args) + }) + t.Run("PostgreSQL", func(t *testing.T) { + s := Dialect(dialect.Postgres).Select("*").From(Table("users")) + p(s) + query, args := s.Query() + require.Equal(t, `SELECT * FROM "users" WHERE "users"."stars" < $1`, query) + require.Equal(t, []any{1000}, args) + }) +} + +func TestFieldsLT(t *testing.T) { + p := FieldsLT("a", "b") + t.Run("MySQL", func(t *testing.T) { + s := Dialect(dialect.MySQL).Select("*").From(Table("users")) + p(s) + query, args := s.Query() + require.Equal(t, "SELECT * FROM `users` WHERE `users`.`a` < `users`.`b`", query) + require.Empty(t, args) + }) + t.Run("PostgreSQL", func(t *testing.T) { + s := Dialect(dialect.Postgres).Select("*").From(Table("users")) + p(s) + query, args := s.Query() + require.Equal(t, `SELECT * FROM "users" WHERE "users"."a" < "users"."b"`, query) + require.Empty(t, args) + }) +} + +func TestFieldLTE(t *testing.T) { + p := FieldLTE("stars", 1000) + t.Run("MySQL", func(t *testing.T) { + s := Dialect(dialect.MySQL).Select("*").From(Table("users")) + p(s) + query, args := s.Query() + require.Equal(t, "SELECT * FROM `users` WHERE `users`.`stars` <= ?", query) + require.Equal(t, []any{1000}, args) + }) + t.Run("PostgreSQL", func(t *testing.T) { + s := Dialect(dialect.Postgres).Select("*").From(Table("users")) + p(s) + query, args := s.Query() + require.Equal(t, `SELECT * FROM "users" WHERE "users"."stars" <= $1`, query) + require.Equal(t, []any{1000}, args) + }) +} + +func TestFieldsLTE(t *testing.T) { + p := FieldsLTE("a", "b") + t.Run("MySQL", func(t *testing.T) { + s := Dialect(dialect.MySQL).Select("*").From(Table("users")) + p(s) + query, args := s.Query() + require.Equal(t, "SELECT * FROM `users` WHERE `users`.`a` <= `users`.`b`", query) + require.Empty(t, args) + }) + t.Run("PostgreSQL", func(t *testing.T) { + s := Dialect(dialect.Postgres).Select("*").From(Table("users")) + p(s) + query, args := s.Query() + require.Equal(t, `SELECT * FROM "users" WHERE "users"."a" <= "users"."b"`, query) + require.Empty(t, args) + }) +} + +func TestFieldIn(t *testing.T) { + p := FieldIn("name", "a8m", "foo", "bar") + t.Run("MySQL", func(t *testing.T) { + s := Dialect(dialect.MySQL).Select("*").From(Table("users")) + p(s) + query, args := s.Query() + require.Equal(t, "SELECT * FROM `users` WHERE `users`.`name` IN (?, ?, ?)", query) + require.Equal(t, []any{"a8m", "foo", "bar"}, args) + }) + t.Run("PostgreSQL", func(t *testing.T) { + s := Dialect(dialect.Postgres).Select("*").From(Table("users")) + p(s) + query, args := s.Query() + require.Equal(t, `SELECT * FROM "users" WHERE "users"."name" IN ($1, $2, $3)`, query) + require.Equal(t, []any{"a8m", "foo", "bar"}, args) + }) +} + +func TestFieldNotIn(t *testing.T) { + p := FieldNotIn("id", 1, 2, 3) + t.Run("MySQL", func(t *testing.T) { + s := Dialect(dialect.MySQL).Select("*").From(Table("users")) + p(s) + query, args := s.Query() + require.Equal(t, "SELECT * FROM `users` WHERE `users`.`id` NOT IN (?, ?, ?)", query) + require.Equal(t, []any{1, 2, 3}, args) + }) + t.Run("PostgreSQL", func(t *testing.T) { + s := Dialect(dialect.Postgres).Select("*").From(Table("users")) + p(s) + query, args := s.Query() + require.Equal(t, `SELECT * FROM "users" WHERE "users"."id" NOT IN ($1, $2, $3)`, query) + require.Equal(t, []any{1, 2, 3}, args) + }) +} + +func TestFieldEqualFold(t *testing.T) { + p := FieldEqualFold("name", "a8m") + t.Run("MySQL", func(t *testing.T) { + s := Dialect(dialect.MySQL).Select("*").From(Table("users")) + p(s) + query, args := s.Query() + require.Equal(t, "SELECT * FROM `users` WHERE `users`.`name` COLLATE utf8mb4_general_ci = ?", query) + require.Equal(t, []any{"a8m"}, args) + }) + t.Run("PostgreSQL", func(t *testing.T) { + s := Dialect(dialect.Postgres).Select("*").From(Table("users")) + p(s) + query, args := s.Query() + require.Equal(t, `SELECT * FROM "users" WHERE "users"."name" ILIKE $1`, query) + require.Equal(t, []any{"a8m"}, args) + }) +} + +func TestFieldHasPrefix(t *testing.T) { + p := FieldHasPrefix("name", "a8m") + t.Run("MySQL", func(t *testing.T) { + s := Dialect(dialect.MySQL).Select("*").From(Table("users")) + p(s) + query, args := s.Query() + require.Equal(t, "SELECT * FROM `users` WHERE `users`.`name` LIKE ?", query) + require.Equal(t, []any{"a8m%"}, args) + }) + t.Run("PostgreSQL", func(t *testing.T) { + s := Dialect(dialect.Postgres).Select("*").From(Table("users")) + p(s) + query, args := s.Query() + require.Equal(t, `SELECT * FROM "users" WHERE "users"."name" LIKE $1`, query) + require.Equal(t, []any{"a8m%"}, args) + }) +} + +func TestFieldHasPrefixFold(t *testing.T) { + p := FieldHasPrefixFold("name", "a8m") + t.Run("MySQL", func(t *testing.T) { + s := Dialect(dialect.MySQL).Select("*").From(Table("users")) + p(s) + query, args := s.Query() + require.Equal(t, "SELECT * FROM `users` WHERE `users`.`name` COLLATE utf8mb4_general_ci LIKE ?", query) + require.Equal(t, []any{"a8m%"}, args) + }) + t.Run("PostgreSQL", func(t *testing.T) { + s := Dialect(dialect.Postgres).Select("*").From(Table("users")) + p(s) + query, args := s.Query() + require.Equal(t, `SELECT * FROM "users" WHERE "users"."name" ILIKE $1`, query) + require.Equal(t, []any{"a8m%"}, args) + }) +} + +func TestFieldHasSuffix(t *testing.T) { + p := FieldHasSuffix("name", "a8m") + t.Run("MySQL", func(t *testing.T) { + s := Dialect(dialect.MySQL).Select("*").From(Table("users")) + p(s) + query, args := s.Query() + require.Equal(t, "SELECT * FROM `users` WHERE `users`.`name` LIKE ?", query) + require.Equal(t, []any{"%a8m"}, args) + }) + t.Run("PostgreSQL", func(t *testing.T) { + s := Dialect(dialect.Postgres).Select("*").From(Table("users")) + p(s) + query, args := s.Query() + require.Equal(t, `SELECT * FROM "users" WHERE "users"."name" LIKE $1`, query) + require.Equal(t, []any{"%a8m"}, args) + }) +} + +func TestFieldHasSuffixFold(t *testing.T) { + p := FieldHasSuffixFold("name", "a8m") + t.Run("MySQL", func(t *testing.T) { + s := Dialect(dialect.MySQL).Select("*").From(Table("users")) + p(s) + query, args := s.Query() + require.Equal(t, "SELECT * FROM `users` WHERE `users`.`name` COLLATE utf8mb4_general_ci LIKE ?", query) + require.Equal(t, []any{"%a8m"}, args) + }) + t.Run("PostgreSQL", func(t *testing.T) { + s := Dialect(dialect.Postgres).Select("*").From(Table("users")) + p(s) + query, args := s.Query() + require.Equal(t, `SELECT * FROM "users" WHERE "users"."name" ILIKE $1`, query) + require.Equal(t, []any{"%a8m"}, args) + }) +} + +func TestFieldContains(t *testing.T) { + p := FieldContains("name", "a8m") + t.Run("MySQL", func(t *testing.T) { + s := Dialect(dialect.MySQL).Select("*").From(Table("users")) + p(s) + query, args := s.Query() + require.Equal(t, "SELECT * FROM `users` WHERE `users`.`name` LIKE ?", query) + require.Equal(t, []any{"%a8m%"}, args) + }) + t.Run("PostgreSQL", func(t *testing.T) { + s := Dialect(dialect.Postgres).Select("*").From(Table("users")) + p(s) + query, args := s.Query() + require.Equal(t, `SELECT * FROM "users" WHERE "users"."name" LIKE $1`, query) + require.Equal(t, []any{"%a8m%"}, args) + }) +} + +func TestFieldContainsFold(t *testing.T) { + p := FieldContainsFold("name", "a8m") + t.Run("MySQL", func(t *testing.T) { + s := Dialect(dialect.MySQL).Select("*").From(Table("users")) + p(s) + query, args := s.Query() + require.Equal(t, "SELECT * FROM `users` WHERE `users`.`name` COLLATE utf8mb4_general_ci LIKE ?", query) + require.Equal(t, []any{"%a8m%"}, args) + }) + t.Run("PostgreSQL", func(t *testing.T) { + s := Dialect(dialect.Postgres).Select("*").From(Table("users")) + p(s) + query, args := s.Query() + require.Equal(t, `SELECT * FROM "users" WHERE "users"."name" ILIKE $1`, query) + require.Equal(t, []any{"%a8m%"}, args) + }) +} + +func TestAndPredicates(t *testing.T) { + s := Select("*").From(Table("users")).Where(EQ("name", "a8m")) + p := AndPredicates( + FieldEQ("a", "foo"), + FieldEQ("b", 1), + func(s *Selector) { + petT := Table("pets").As("p") + s.Join(petT).On(petT.C("owner_id"), s.C("id")) + }, + ) + p(s) + query, args := s.Query() + require.Equal(t, "SELECT * FROM `users` JOIN `pets` AS `p` ON `p`.`owner_id` = `users`.`id` WHERE `name` = ? AND (`users`.`a` = ? AND `users`.`b` = ?)", query) + require.Equal(t, []any{"a8m", "foo", 1}, args) +} + +func TestOrPredicates(t *testing.T) { + s := Select("*").From(Table("users")).Where(EQ("name", "a8m")) + p := OrPredicates( + AndPredicates( + FieldEQ("a", "foo"), + FieldEQ("b", 1), + ), + func(s *Selector) { + petT := Table("pets").As("p") + s.Join(petT).On(petT.C("owner_id"), s.C("id")) + s.Where(EQ(petT.C("name"), "c")) + }, + ) + p(s) + query, args := s.Query() + require.Equal(t, "SELECT * FROM `users` JOIN `pets` AS `p` ON `p`.`owner_id` = `users`.`id` WHERE `name` = ? AND ((`users`.`a` = ? AND `users`.`b` = ?) OR `p`.`name` = ?)", query) + require.Equal(t, []any{"a8m", "foo", 1, "c"}, args) +} + +func TestNotPredicates(t *testing.T) { + s := Select("*").From(Table("users")).Where(EQ("name", "a8m")) + NotPredicates(FieldEQ("a", "a"), FieldEQ("b", "b"))(s) + NotPredicates(FieldEQ("c", "c"))(s) + query, args := s.Query() + require.Equal(t, "SELECT * FROM `users` WHERE (`name` = ? AND (NOT (`users`.`a` = ? AND `users`.`b` = ?))) AND (NOT (`users`.`c` = ?))", query) + require.Equal(t, []any{"a8m", "a", "b", "c"}, args) +} diff --git a/dialect/sql/sqlgraph/blob.go b/dialect/sql/sqlgraph/blob.go new file mode 100644 index 0000000..844b262 --- /dev/null +++ b/dialect/sql/sqlgraph/blob.go @@ -0,0 +1,155 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package sqlgraph + +import ( + "context" + + "entgo.io/ent" + "entgo.io/ent/dialect" + "entgo.io/ent/dialect/sql" +) + +// BlobSpec configures SQL-level blob key queries and implements [ent.BlobQuerier]. +type BlobSpec struct { + Driver dialect.Driver + Table string + Columns map[string]string // field name -> key column name + // CheckRefs holds the fields whose keys more than one row can hold, the ones + // declaring CheckRefs on the schema. Only these are looked up before a delete; + // a key generated fresh per write has no other holder to find. An empty + // CheckRefs makes [BlobSpec.ReferencedBlobKeys] a no-op. + CheckRefs map[string]bool + Predicate func(*sql.Selector) +} + +// maxBlobKeysPerQuery bounds the keys per reference lookup, keeping the +// statement clear of driver placeholder limits on large deletes. +const maxBlobKeysPerQuery = 500 + +// ReferencedBlobKeys implements [ent.BlobRefChecker]. It returns the subset of keys +// some row holds in its field's key column. [BlobSpec.Predicate] is deliberately not +// applied: the question is which keys are in use anywhere, and callers time the lookup +// so the mutation's own rows already read the way they will settle. +// +// Only fields listed in [BlobSpec.CheckRefs] are looked up. Keys of any other field are +// reported as held by no row, which is what an unshared key strategy guarantees anyway. +// +// The lookup only asks whether a key is used, never how often, so an index on each +// blob key column lets it stop at the first match. +func (s *BlobSpec) ReferencedBlobKeys(ctx context.Context, keys []ent.BlobKey) ([]ent.BlobKey, error) { + if len(keys) == 0 || len(s.CheckRefs) == 0 { + return nil, nil + } + // Group the distinct keys by the column they would be stored in. + lookup := make(map[string][]any) + seen := make(map[ent.BlobKey]bool, len(keys)) + for _, k := range keys { + if k.Key == "" || seen[k] || !s.CheckRefs[k.Field] { + continue + } + seen[k] = true + if col, ok := s.Columns[k.Field]; ok { + lookup[col] = append(lookup[col], k.Key) + } + } + var referenced []ent.BlobKey + for field := range s.CheckRefs { + col, ok := s.Columns[field] + if !ok { + continue + } + vals := lookup[col] + for len(vals) > 0 { + n := min(len(vals), maxBlobKeysPerQuery) + found, err := s.selectReferenced(ctx, col, vals[:n]) + if err != nil { + return nil, err + } + for _, key := range found { + referenced = append(referenced, ent.BlobKey{Field: field, Key: key}) + } + vals = vals[n:] + } + } + return referenced, nil +} + +// selectReferenced returns which of vals appear in col. +func (s *BlobSpec) selectReferenced(ctx context.Context, col string, vals []any) ([]string, error) { + query, args := sql.Dialect(s.Driver.Dialect()). + Select(col). + Distinct(). + From(sql.Table(s.Table)). + Where(sql.In(col, vals...)). + Query() + rows := &sql.Rows{} + if err := s.Driver.Query(ctx, query, args, rows); err != nil { + return nil, err + } + defer rows.Close() + var found []string + for rows.Next() { + var key string + if err := rows.Scan(&key); err != nil { + return nil, err + } + found = append(found, key) + } + return found, rows.Err() +} + +// QueryBlobKeys implements [ent.BlobQuerier]. +// If fields is nil, all columns are queried (for deletes); +// otherwise only the named fields are queried. +func (s *BlobSpec) QueryBlobKeys(ctx context.Context, fields []string) ([]ent.BlobKey, error) { + cols := s.Columns + if len(fields) > 0 { + cols = make(map[string]string, len(fields)) + for _, f := range fields { + if c, ok := s.Columns[f]; ok { + cols[f] = c + } + } + } + if len(cols) == 0 { + return nil, nil + } + names := make([]string, 0, len(cols)) + colNames := make([]string, 0, len(cols)) + for field, col := range cols { + names = append(names, field) + colNames = append(colNames, col) + } + selector := sql.Dialect(s.Driver.Dialect()). + Select(colNames...). + From(sql.Table(s.Table)) + if s.Predicate != nil { + s.Predicate(selector) + } + query, args := selector.Query() + rows := &sql.Rows{} + if err := s.Driver.Query(ctx, query, args, rows); err != nil { + return nil, err + } + defer rows.Close() + var keys []ent.BlobKey + for rows.Next() { + vals := make([]*string, len(colNames)) + ptrs := make([]any, len(colNames)) + for i := range vals { + ptrs[i] = &vals[i] + } + if err := rows.Scan(ptrs...); err != nil { + return nil, err + } + for i, v := range vals { + if v != nil && *v != "" { + keys = append(keys, ent.BlobKey{Field: names[i], Key: *v}) + } + } + } + return keys, rows.Err() +} diff --git a/dialect/sql/sqlgraph/blob_test.go b/dialect/sql/sqlgraph/blob_test.go new file mode 100644 index 0000000..7eeb124 --- /dev/null +++ b/dialect/sql/sqlgraph/blob_test.go @@ -0,0 +1,182 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package sqlgraph + +import ( + "context" + "database/sql/driver" + "fmt" + "testing" + + "entgo.io/ent" + "entgo.io/ent/dialect" + "entgo.io/ent/dialect/sql" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/stretchr/testify/require" +) + +func TestBlobSpecReferencedBlobKeys(t *testing.T) { + tests := []struct { + name string + dialect string + keys []ent.BlobKey + expect func(sqlmock.Sqlmock) + want []ent.BlobKey + }{ + { + name: "keys held by a row", + dialect: dialect.MySQL, + keys: []ent.BlobKey{ + {Field: "content", Key: "k1"}, + {Field: "content", Key: "k2"}, + }, + expect: func(m sqlmock.Sqlmock) { + m.ExpectQuery(escape("SELECT DISTINCT `content_key` FROM `documents` WHERE `content_key` IN (?, ?)")). + WithArgs("k1", "k2"). + WillReturnRows(sqlmock.NewRows([]string{"content_key"}).AddRow("k1")) + }, + want: []ent.BlobKey{{Field: "content", Key: "k1"}}, + }, + { + name: "keys held by no row", + dialect: dialect.SQLite, + keys: []ent.BlobKey{{Field: "content", Key: "gone"}}, + expect: func(m sqlmock.Sqlmock) { + m.ExpectQuery(escape("SELECT DISTINCT `content_key` FROM `documents` WHERE `content_key` IN (?)")). + WithArgs("gone"). + WillReturnRows(sqlmock.NewRows([]string{"content_key"})) + }, + want: nil, + }, + { + // The mutation's own rows are not filtered out here -- callers time the + // lookup so those rows already read the way they will settle. + name: "the mutation's predicate is not applied", + dialect: dialect.Postgres, + keys: []ent.BlobKey{{Field: "content", Key: "k1"}}, + expect: func(m sqlmock.Sqlmock) { + m.ExpectQuery(escape(`SELECT DISTINCT "content_key" FROM "documents" WHERE "content_key" IN ($1)`)). + WithArgs("k1"). + WillReturnRows(sqlmock.NewRows([]string{"content_key"}).AddRow("k1")) + }, + want: []ent.BlobKey{{Field: "content", Key: "k1"}}, + }, + { + name: "duplicate keys are looked up once", + dialect: dialect.MySQL, + keys: []ent.BlobKey{ + {Field: "content", Key: "k1"}, + {Field: "content", Key: "k1"}, + }, + expect: func(m sqlmock.Sqlmock) { + m.ExpectQuery(escape("SELECT DISTINCT `content_key` FROM `documents` WHERE `content_key` IN (?)")). + WithArgs("k1"). + WillReturnRows(sqlmock.NewRows([]string{"content_key"}).AddRow("k1")) + }, + want: []ent.BlobKey{{Field: "content", Key: "k1"}}, + }, + { + name: "unknown fields and empty keys are skipped", + dialect: dialect.MySQL, + keys: []ent.BlobKey{ + {Field: "nosuchfield", Key: "k1"}, + {Field: "content", Key: ""}, + }, + expect: func(sqlmock.Sqlmock) {}, + want: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + tt.expect(mock) + spec := &BlobSpec{ + Driver: sql.OpenDB(tt.dialect, db), + Table: "documents", + // Set to prove ReferencedBlobKeys ignores it. + Predicate: func(s *sql.Selector) { s.Where(sql.EQ("id", 7)) }, + Columns: map[string]string{"content": "content_key", "meta": "meta_key"}, + CheckRefs: map[string]bool{"content": true}, + } + referenced, err := spec.ReferencedBlobKeys(context.Background(), tt.keys) + require.NoError(t, err) + require.Equal(t, tt.want, referenced) + require.NoError(t, mock.ExpectationsWereMet()) + }) + } +} + +// A key set larger than maxBlobKeysPerQuery is split across statements so the +// query stays clear of driver placeholder limits. +func TestBlobSpecReferencedBlobKeysChunks(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + var ( + keys = make([]ent.BlobKey, 0, maxBlobKeysPerQuery+1) + first = make([]driver.Value, 0, maxBlobKeysPerQuery) + ) + for i := range maxBlobKeysPerQuery + 1 { + key := fmt.Sprintf("k%d", i) + keys = append(keys, ent.BlobKey{Field: "content", Key: key}) + if i < maxBlobKeysPerQuery { + first = append(first, key) + } + } + // The first statement carries a full chunk, the leftover key its own. Matched + // loosely — escape anchors the pattern, and the argument list asserts the size. + mock.ExpectQuery("SELECT DISTINCT"). + WithArgs(first...). + WillReturnRows(sqlmock.NewRows([]string{"content_key"}).AddRow(keys[0].Key)) + mock.ExpectQuery(escape("SELECT DISTINCT `content_key` FROM `documents` WHERE `content_key` IN (?)")). + WithArgs(keys[maxBlobKeysPerQuery].Key). + WillReturnRows(sqlmock.NewRows([]string{"content_key"}).AddRow(keys[maxBlobKeysPerQuery].Key)) + spec := &BlobSpec{ + Driver: sql.OpenDB(dialect.MySQL, db), + Table: "documents", + Columns: map[string]string{"content": "content_key"}, + CheckRefs: map[string]bool{"content": true}, + } + referenced, err := spec.ReferencedBlobKeys(context.Background(), keys) + require.NoError(t, err) + require.Equal(t, []ent.BlobKey{keys[0], keys[maxBlobKeysPerQuery]}, referenced) + require.NoError(t, mock.ExpectationsWereMet()) +} + +// Empty input, and fields that did not opt in, must not reach the database. +func TestBlobSpecReferencedBlobKeysNoop(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + spec := &BlobSpec{ + Driver: sql.OpenDB(dialect.MySQL, db), + Table: "documents", + Columns: map[string]string{"content": "content_key"}, + CheckRefs: map[string]bool{"content": true}, + } + keys := []ent.BlobKey{{Field: "content", Key: "k"}} + referenced, err := spec.ReferencedBlobKeys(context.Background(), nil) + require.NoError(t, err) + require.Empty(t, referenced) + + // No field opted in: the whole lookup is skipped. + spec.CheckRefs = nil + referenced, err = spec.ReferencedBlobKeys(context.Background(), keys) + require.NoError(t, err) + require.Empty(t, referenced) + + // Some other field opted in, but not this one. + spec.CheckRefs = map[string]bool{"thumbnail": true} + referenced, err = spec.ReferencedBlobKeys(context.Background(), keys) + require.NoError(t, err) + require.Empty(t, referenced) + + spec.CheckRefs = map[string]bool{"content": true} + spec.Columns = nil + referenced, err = spec.ReferencedBlobKeys(context.Background(), keys) + require.NoError(t, err) + require.Empty(t, referenced) + require.NoError(t, mock.ExpectationsWereMet()) +} diff --git a/dialect/sql/sqlgraph/entql.go b/dialect/sql/sqlgraph/entql.go new file mode 100644 index 0000000..407cc10 --- /dev/null +++ b/dialect/sql/sqlgraph/entql.go @@ -0,0 +1,334 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package sqlgraph + +import ( + "fmt" + + "entgo.io/ent/dialect/sql" + "entgo.io/ent/entql" +) + +type ( + // A Schema holds a representation of ent/schema at runtime. Each Node + // represents a single schema-type and its relations in the graph (storage). + // + // It is used for translating common graph traversal operations to the + // underlying SQL storage. For example, an operation like `has_edge(E)`, + // will be translated to an SQL lookup based on the relation type and the + // FK configuration. + Schema struct { + Nodes []*Node + } + + // A Node in the graph holds the SQL information for an ent/schema. + Node struct { + NodeSpec + + // Type holds the node type (schema name). + Type string + + // Fields maps from field names to their spec. + Fields map[string]*FieldSpec + + // Edges maps from edge names to their spec. + Edges map[string]struct { + To *Node + Spec *EdgeSpec + } + } +) + +// AddE adds an edge to the graph. It fails, if one of the node +// types is missing. +// +// g.AddE("pets", spec, "user", "pet") +// g.AddE("friends", spec, "user", "user") +func (g *Schema) AddE(name string, spec *EdgeSpec, from, to string) error { + var fromT, toT *Node + for i := range g.Nodes { + t := g.Nodes[i].Type + if t == from { + fromT = g.Nodes[i] + } + if t == to { + toT = g.Nodes[i] + } + } + if fromT == nil || toT == nil { + return fmt.Errorf("from/to type was not found") + } + if fromT.Edges == nil { + fromT.Edges = make(map[string]struct { + To *Node + Spec *EdgeSpec + }) + } + fromT.Edges[name] = struct { + To *Node + Spec *EdgeSpec + }{ + To: toT, + Spec: spec, + } + return nil +} + +// MustAddE is like AddE but panics if the edge can be added to the graph. +func (g *Schema) MustAddE(name string, spec *EdgeSpec, from, to string) { + if err := g.AddE(name, spec, from, to); err != nil { + panic(err) + } +} + +// EvalP evaluates the entql predicate on the given selector (query builder). +func (g *Schema) EvalP(nodeType string, p entql.P, selector *sql.Selector) error { + var node *Node + for i := range g.Nodes { + if g.Nodes[i].Type == nodeType { + node = g.Nodes[i] + break + } + } + if node == nil { + return fmt.Errorf("node %s was not found in the graph schema", nodeType) + } + pr, err := evalExpr(node, selector, p) + if err != nil { + return err + } + selector.Where(pr) + return nil +} + +// FuncSelector represents a selector function to be used as an entql foreign-function. +const FuncSelector entql.Func = "func_selector" + +// wrappedFunc wraps the selector-function to an ent-expression. +type wrappedFunc struct { + entql.Expr + Func func(*sql.Selector) +} + +// WrapFunc wraps a selector-func with an entql call expression. +func WrapFunc(s func(*sql.Selector)) *entql.CallExpr { + return &entql.CallExpr{ + Func: FuncSelector, + Args: []entql.Expr{wrappedFunc{Func: s}}, + } +} + +var ( + binary = [...]sql.Op{ + entql.OpEQ: sql.OpEQ, + entql.OpNEQ: sql.OpNEQ, + entql.OpGT: sql.OpGT, + entql.OpGTE: sql.OpGTE, + entql.OpLT: sql.OpLT, + entql.OpLTE: sql.OpLTE, + entql.OpIn: sql.OpIn, + entql.OpNotIn: sql.OpNotIn, + } + nary = [...]func(...*sql.Predicate) *sql.Predicate{ + entql.OpAnd: sql.And, + entql.OpOr: sql.Or, + } + strFunc = map[entql.Func]func(string, string) *sql.Predicate{ + entql.FuncContains: sql.Contains, + entql.FuncContainsFold: sql.ContainsFold, + entql.FuncEqualFold: sql.EqualFold, + entql.FuncHasPrefix: sql.HasPrefix, + entql.FuncHasSuffix: sql.HasSuffix, + } + nullFunc = [...]func(string) *sql.Predicate{ + entql.OpEQ: sql.IsNull, + entql.OpNEQ: sql.NotNull, + } +) + +// state represents the state of a predicate evaluation. +// Note that, the evaluation output is a predicate to be +// applied on the database. +type state struct { + sql.Builder + context *Node + selector *sql.Selector +} + +// evalExpr evaluates the entql expression and returns a new SQL predicate to be applied on the database. +func evalExpr(context *Node, selector *sql.Selector, expr entql.Expr) (p *sql.Predicate, err error) { + ex := &state{ + context: context, + selector: selector, + } + defer catch(&err) + p = ex.evalExpr(expr) + return +} + +// evalExpr evaluates any expression. +func (e *state) evalExpr(expr entql.Expr) *sql.Predicate { + switch expr := expr.(type) { + case *entql.BinaryExpr: + return e.evalBinary(expr) + case *entql.UnaryExpr: + return sql.Not(e.evalExpr(expr.X)) + case *entql.NaryExpr: + ps := make([]*sql.Predicate, len(expr.Xs)) + for i, x := range expr.Xs { + ps[i] = e.evalExpr(x) + } + return nary[expr.Op](ps...) + case *entql.CallExpr: + switch expr.Func { + case entql.FuncHasPrefix, entql.FuncHasSuffix, entql.FuncContains, entql.FuncEqualFold, entql.FuncContainsFold: + expect(len(expr.Args) == 2, "invalid number of arguments for %s", expr.Func) + f, ok := expr.Args[0].(*entql.Field) + expect(ok, "*entql.Field, got %T", expr.Args[0]) + v, ok := expr.Args[1].(*entql.Value) + expect(ok, "*entql.Value, got %T", expr.Args[1]) + s, ok := v.V.(string) + expect(ok, "string value, got %T", v.V) + return strFunc[expr.Func](e.field(f), s) + case entql.FuncHasEdge: + expect(len(expr.Args) > 0, "invalid number of arguments for %s", expr.Func) + edge, ok := expr.Args[0].(*entql.Edge) + expect(ok, "*entql.Edge, got %T", expr.Args[0]) + return e.evalEdge(edge.Name, expr.Args[1:]...) + } + } + panic("invalid") +} + +// evalBinary evaluates binary expressions. +func (e *state) evalBinary(expr *entql.BinaryExpr) *sql.Predicate { + switch expr.Op { + case entql.OpOr: + return sql.Or(e.evalExpr(expr.X), e.evalExpr(expr.Y)) + case entql.OpAnd: + return sql.And(e.evalExpr(expr.X), e.evalExpr(expr.Y)) + case entql.OpEQ, entql.OpNEQ: + if expr.Y == (*entql.Value)(nil) { + f, ok := expr.X.(*entql.Field) + expect(ok, "*entql.Field, got %T", expr.Y) + return nullFunc[expr.Op](e.field(f)) + } + fallthrough + default: + field, ok := expr.X.(*entql.Field) + expect(ok, "expr.X to be *entql.Field (got %T)", expr.X) + _, ok = expr.Y.(*entql.Field) + if !ok { + _, ok = expr.Y.(*entql.Value) + } + expect(ok, "expr.Y to be *entql.Field or *entql.Value (got %T)", expr.X) + switch x := expr.Y.(type) { + case *entql.Field: + return sql.ColumnsOp(e.field(field), e.field(x), binary[expr.Op]) + case *entql.Value: + c := e.field(field) + return sql.P(func(b *sql.Builder) { + b.Ident(c).WriteOp(binary[expr.Op]) + args(b, x) + }) + default: + panic("unreachable") + } + } +} + +// evalEdge evaluates has-edge and has-edge-with calls. +func (e *state) evalEdge(name string, exprs ...entql.Expr) *sql.Predicate { + edge, ok := e.context.Edges[name] + expect(ok, "edge %q was not found for node %q", name, e.context.Type) + var fromC, toC string + switch { + case edge.To.ID != nil: + toC = edge.To.ID.Column + // Edge-owner points to its edge schema. + case edge.To.CompositeID != nil && !edge.Spec.Inverse: + toC = edge.To.CompositeID[0].Column + // Edge-backref points to its edge schema. + case edge.To.CompositeID != nil && edge.Spec.Inverse: + toC = edge.To.CompositeID[1].Column + default: + panic(evalError{fmt.Sprintf("expect id definition for edge %q", name)}) + } + switch { + case e.context.ID != nil: + fromC = e.context.ID.Column + case e.context.CompositeID != nil && (edge.Spec.Rel == M2O || (edge.Spec.Rel == O2O && edge.Spec.Inverse)): + // An edge-schema with a composite id can query + // only edges that it owns (holds the foreign-key). + default: + panic(evalError{fmt.Sprintf("unexpected edge-query from an edge-schema %q", e.context.Type)}) + } + step := NewStep( + From(e.context.Table, fromC), + To(edge.To.Table, toC), + Edge(edge.Spec.Rel, edge.Spec.Inverse, edge.Spec.Table, edge.Spec.Columns...), + ) + selector := e.selector.Clone().SetP(nil) + selector.SetTotal(e.Total()) + if len(exprs) == 0 { + HasNeighbors(selector, step) + return selector.P() + } + HasNeighborsWith(selector, step, func(s *sql.Selector) { + for _, expr := range exprs { + if cx, ok := expr.(*entql.CallExpr); ok && cx.Func == FuncSelector { + expect(len(cx.Args) == 1, "invalid number of arguments for %s", FuncSelector) + wrapped, ok := cx.Args[0].(wrappedFunc) + expect(ok, "invalid argument for %s: %T", FuncSelector, cx.Args[0]) + wrapped.Func(s) + } else { + p, err := evalExpr(edge.To, s, expr) + expect(err == nil, "edge evaluation failed for %s->%s: %s", e.context.Type, name, err) + s.Where(p) + } + } + }) + return selector.P() +} + +func (e *state) field(f *entql.Field) string { + _, ok := e.context.Fields[f.Name] + expect(ok || e.context.ID.Column == f.Name, "field %q was not found for node %q", f.Name, e.context.Type) + return e.selector.C(f.Name) +} + +func args(b *sql.Builder, v *entql.Value) { + vs, ok := v.V.([]any) + if !ok { + b.Arg(v.V) + return + } + b.WriteByte('(').Args(vs...).WriteByte(')') +} + +// expect panics if the condition is false. +func expect(cond bool, msg string, args ...any) { + if !cond { + panic(evalError{fmt.Sprintf("expect "+msg, args...)}) + } +} + +type evalError struct { + msg string +} + +func (p evalError) Error() string { + return fmt.Sprintf("sqlgraph: %s", p.msg) +} + +func catch(err *error) { + if e := recover(); e != nil { + xerr, ok := e.(evalError) + if !ok { + panic(e) + } + *err = xerr + } +} diff --git a/dialect/sql/sqlgraph/entql_test.go b/dialect/sql/sqlgraph/entql_test.go new file mode 100644 index 0000000..d1693f7 --- /dev/null +++ b/dialect/sql/sqlgraph/entql_test.go @@ -0,0 +1,171 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package sqlgraph + +import ( + "strconv" + "testing" + + "entgo.io/ent/dialect" + "entgo.io/ent/dialect/sql" + "entgo.io/ent/entql" + "entgo.io/ent/schema/field" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGraph_AddE(t *testing.T) { + g := &Schema{ + Nodes: []*Node{{Type: "user"}, {Type: "pet"}}, + } + err := g.AddE("pets", &EdgeSpec{Rel: O2M}, "user", "pet") + assert.NoError(t, err) + err = g.AddE("owner", &EdgeSpec{Rel: O2M}, "pet", "user") + assert.NoError(t, err) + err = g.AddE("groups", &EdgeSpec{Rel: M2M}, "pet", "groups") + assert.Error(t, err) +} + +func TestGraph_EvalP(t *testing.T) { + g := &Schema{ + Nodes: []*Node{ + { + Type: "user", + NodeSpec: NodeSpec{ + Table: "users", + ID: &FieldSpec{Column: "uid"}, + }, + Fields: map[string]*FieldSpec{ + "name": {Column: "name", Type: field.TypeString}, + "last": {Column: "last", Type: field.TypeString}, + }, + }, + { + Type: "pet", + NodeSpec: NodeSpec{ + Table: "pets", + ID: &FieldSpec{Column: "pid"}, + }, + Fields: map[string]*FieldSpec{ + "name": {Column: "name", Type: field.TypeString}, + }, + }, + { + Type: "group", + NodeSpec: NodeSpec{ + Table: "groups", + ID: &FieldSpec{Column: "gid"}, + }, + Fields: map[string]*FieldSpec{ + "name": {Column: "name", Type: field.TypeString}, + }, + }, + }, + } + err := g.AddE("pets", &EdgeSpec{Rel: O2M, Table: "pets", Columns: []string{"owner_id"}}, "user", "pet") + require.NoError(t, err) + err = g.AddE("owner", &EdgeSpec{Rel: M2O, Inverse: true, Table: "pets", Columns: []string{"owner_id"}}, "pet", "user") + require.NoError(t, err) + err = g.AddE("groups", &EdgeSpec{Rel: M2M, Table: "user_groups", Columns: []string{"user_id", "group_id"}}, "user", "group") + require.NoError(t, err) + err = g.AddE("users", &EdgeSpec{Rel: M2M, Inverse: true, Table: "user_groups", Columns: []string{"user_id", "group_id"}}, "group", "user") + require.NoError(t, err) + + tests := []struct { + s *sql.Selector + p entql.P + wantQuery string + wantArgs []any + wantErr bool + }{ + { + s: sql.Dialect(dialect.Postgres).Select().From(sql.Table("users")), + p: entql.FieldHasPrefix("name", "a"), + wantQuery: `SELECT * FROM "users" WHERE "users"."name" LIKE $1`, + wantArgs: []any{"a%"}, + }, + { + s: sql.Dialect(dialect.Postgres).Select().From(sql.Table("users")). + Where(sql.EQ("age", 1)), + p: entql.FieldHasPrefix("name", "a"), + wantQuery: `SELECT * FROM "users" WHERE "age" = $1 AND "users"."name" LIKE $2`, + wantArgs: []any{1, "a%"}, + }, + { + s: sql.Dialect(dialect.Postgres).Select().From(sql.Table("users")). + Where(sql.EQ("age", 1)), + p: entql.FieldHasPrefix("name", "a"), + wantQuery: `SELECT * FROM "users" WHERE "age" = $1 AND "users"."name" LIKE $2`, + wantArgs: []any{1, "a%"}, + }, + { + s: sql.Dialect(dialect.Postgres).Select().From(sql.Table("users")), + p: entql.EQ(entql.F("name"), entql.F("last")), + wantQuery: `SELECT * FROM "users" WHERE "users"."name" = "users"."last"`, + }, + { + s: sql.Dialect(dialect.Postgres).Select().From(sql.Table("users")), + p: entql.EQ(entql.F("name"), entql.F("last")), + wantQuery: `SELECT * FROM "users" WHERE "users"."name" = "users"."last"`, + }, + { + s: sql.Dialect(dialect.Postgres).Select().From(sql.Table("users")), + p: entql.And(entql.FieldNil("name"), entql.FieldNotNil("last")), + wantQuery: `SELECT * FROM "users" WHERE "users"."name" IS NULL AND "users"."last" IS NOT NULL`, + }, + { + s: sql.Dialect(dialect.Postgres).Select().From(sql.Table("users")). + Where(sql.EQ("foo", "bar")), + p: entql.Or(entql.FieldEQ("name", "foo"), entql.FieldEQ("name", "baz")), + wantQuery: `SELECT * FROM "users" WHERE "foo" = $1 AND ("users"."name" = $2 OR "users"."name" = $3)`, + wantArgs: []any{"bar", "foo", "baz"}, + }, + { + s: sql.Dialect(dialect.Postgres).Select().From(sql.Table("users")), + p: entql.HasEdge("pets"), + wantQuery: `SELECT * FROM "users" WHERE EXISTS (SELECT "pets"."owner_id" FROM "pets" WHERE "users"."uid" = "pets"."owner_id")`, + }, + { + s: sql.Dialect(dialect.Postgres).Select().From(sql.Table("users")), + p: entql.HasEdge("groups"), + wantQuery: `SELECT * FROM "users" WHERE "users"."uid" IN (SELECT "user_groups"."user_id" FROM "user_groups")`, + }, + { + s: sql.Dialect(dialect.Postgres).Select().From(sql.Table("users")), + p: entql.HasEdgeWith("pets", entql.Or(entql.FieldEQ("name", "pedro"), entql.FieldEQ("name", "xabi"))), + wantQuery: `SELECT * FROM "users" WHERE EXISTS (SELECT "pets"."owner_id" FROM "pets" WHERE "users"."uid" = "pets"."owner_id" AND ("pets"."name" = $1 OR "pets"."name" = $2))`, + wantArgs: []any{"pedro", "xabi"}, + }, + { + s: sql.Dialect(dialect.Postgres).Select().From(sql.Table("users")).Where(sql.EQ("active", true)), + p: entql.HasEdgeWith("groups", entql.Or(entql.FieldEQ("name", "GitHub"), entql.FieldEQ("name", "GitLab"))), + wantQuery: `SELECT * FROM "users" WHERE "active" AND "users"."uid" IN (SELECT "user_groups"."user_id" FROM "user_groups" JOIN "groups" AS "t1" ON "user_groups"."group_id" = "t1"."gid" WHERE "t1"."name" = $1 OR "t1"."name" = $2)`, + wantArgs: []any{"GitHub", "GitLab"}, + }, + { + s: sql.Dialect(dialect.Postgres).Select().From(sql.Table("users")).Where(sql.EQ("active", true)), + p: entql.And(entql.HasEdge("pets"), entql.HasEdge("groups"), entql.EQ(entql.F("name"), entql.F("uid"))), + wantQuery: `SELECT * FROM "users" WHERE "active" AND (EXISTS (SELECT "pets"."owner_id" FROM "pets" WHERE "users"."uid" = "pets"."owner_id") AND "users"."uid" IN (SELECT "user_groups"."user_id" FROM "user_groups") AND "users"."name" = "users"."uid")`, + }, + { + s: sql.Dialect(dialect.Postgres).Select().From(sql.Table("users")).Where(sql.EQ("active", true)), + p: entql.HasEdgeWith("pets", entql.FieldEQ("name", "pedro"), WrapFunc(func(s *sql.Selector) { + s.Where(sql.EQ("owner_id", 10)) + })), + wantQuery: `SELECT * FROM "users" WHERE "active" AND EXISTS (SELECT "pets"."owner_id" FROM "pets" WHERE ("users"."uid" = "pets"."owner_id" AND "pets"."name" = $1) AND "owner_id" = $2)`, + wantArgs: []any{"pedro", 10}, + }, + } + for i, tt := range tests { + t.Run(strconv.Itoa(i), func(t *testing.T) { + err = g.EvalP("user", tt.p, tt.s) + require.Equal(t, tt.wantErr, err != nil, err) + query, args := tt.s.Query() + require.Equal(t, tt.wantQuery, query) + require.Equal(t, tt.wantArgs, args) + }) + } +} diff --git a/dialect/sql/sqlgraph/errors.go b/dialect/sql/sqlgraph/errors.go new file mode 100644 index 0000000..863cdc0 --- /dev/null +++ b/dialect/sql/sqlgraph/errors.go @@ -0,0 +1,74 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package sqlgraph + +import ( + "errors" + "strings" +) + +// IsConstraintError returns true if the error resulted from a database constraint violation. +func IsConstraintError(err error) bool { + var e *ConstraintError + return errors.As(err, &e) || + IsUniqueConstraintError(err) || + IsForeignKeyConstraintError(err) || + IsCheckConstraintError(err) +} + +// IsUniqueConstraintError reports if the error resulted from a DB uniqueness constraint violation. +// e.g. duplicate value in unique index. +func IsUniqueConstraintError(err error) bool { + if err == nil { + return false + } + for _, s := range []string{ + "Error 1062", // MySQL + "violates unique constraint", // Postgres + "UNIQUE constraint failed", // SQLite + } { + if strings.Contains(err.Error(), s) { + return true + } + } + return false +} + +// IsForeignKeyConstraintError reports if the error resulted from a database foreign-key constraint violation. +// e.g. parent row does not exist. +func IsForeignKeyConstraintError(err error) bool { + if err == nil { + return false + } + for _, s := range []string{ + "Error 1451", // MySQL (Cannot delete or update a parent row). + "Error 1452", // MySQL (Cannot add or update a child row). + "violates foreign key constraint", // Postgres + "FOREIGN KEY constraint failed", // SQLite + } { + if strings.Contains(err.Error(), s) { + return true + } + } + return false +} + +// IsCheckConstraintError reports if the error resulted from a database check constraint violation. +// e.g. a value does not satisfy a check condition. +func IsCheckConstraintError(err error) bool { + if err == nil { + return false + } + for _, s := range []string{ + "Error 3819", // MySQL + "violates check constraint", // Postgres + "CHECK constraint failed", // SQLite + } { + if strings.Contains(err.Error(), s) { + return true + } + } + return false +} diff --git a/dialect/sql/sqlgraph/graph.go b/dialect/sql/sqlgraph/graph.go new file mode 100644 index 0000000..4dc392f --- /dev/null +++ b/dialect/sql/sqlgraph/graph.go @@ -0,0 +1,2076 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +// Package sqlgraph provides graph abstraction capabilities on top +// of sql-based databases for ent codegen. +package sqlgraph + +import ( + "context" + "database/sql/driver" + "encoding/json" + "errors" + "fmt" + "math" + "sort" + + "entgo.io/ent/dialect" + "entgo.io/ent/dialect/sql" + "entgo.io/ent/schema/field" +) + +// Rel is an edge relation type. +type Rel int + +// Relation types. +const ( + _ Rel = iota // Unknown. + O2O // One to one / has one. + O2M // One to many / has many. + M2O // Many to one (inverse perspective for O2M). + M2M // Many to many. +) + +// String returns the relation name. +func (r Rel) String() (s string) { + switch r { + case O2O: + s = "O2O" + case O2M: + s = "O2M" + case M2O: + s = "M2O" + case M2M: + s = "M2M" + default: + s = "Unknown" + } + return s +} + +// A ConstraintError represents an error from mutation that violates a specific constraint. +type ConstraintError struct { + msg string +} + +func (e ConstraintError) Error() string { return e.msg } + +// A Step provides a path-step information to the traversal functions. +type Step struct { + // From is the source of the step. + From struct { + // V can be either one vertex or set of vertices. + // It can be a pre-processed step (sql.Query) or a simple Go type (integer or string). + V any + // Table holds the table name of V (from). + Table string + // Column to join with. Usually the "id" column. + Column string + } + // Edge holds the edge information for getting the neighbors. + Edge struct { + // Rel of the edge. + Rel Rel + // Schema is an optional name of the database + // where the table is defined. + Schema string + // Table name of where this edge columns reside. + Table string + // Columns of the edge. + // In O2O and M2O, it holds the foreign-key column. Hence, len == 1. + // In M2M, it holds the primary-key columns of the join table. Hence, len == 2. + Columns []string + // Inverse indicates if the edge is an inverse edge. + Inverse bool + } + // To is the dest of the path (the neighbors). + To struct { + // Table holds the table name of the neighbors (to). + Table string + // Schema is an optional name of the database + // where the table is defined. + Schema string + // Column to join with. Usually the "id" column. + Column string + } +} + +// StepOption allows configuring Steps using functional options. +type StepOption func(*Step) + +// From sets the source of the step. +func From(table, column string, v ...any) StepOption { + return func(s *Step) { + s.From.Table = table + s.From.Column = column + if len(v) > 0 { + s.From.V = v[0] + } + } +} + +// To sets the destination of the step. +func To(table, column string) StepOption { + return func(s *Step) { + s.To.Table = table + s.To.Column = column + } +} + +// Edge sets the edge info for getting the neighbors. +func Edge(rel Rel, inverse bool, table string, columns ...string) StepOption { + return func(s *Step) { + s.Edge.Rel = rel + s.Edge.Table = table + s.Edge.Columns = columns + s.Edge.Inverse = inverse + } +} + +// NewStep gets list of options and returns a configured step. +// +// NewStep( +// From("table", "pk", V), +// To("table", "pk"), +// Edge("name", O2M, "fk"), +// ) +func NewStep(opts ...StepOption) *Step { + s := &Step{} + for _, opt := range opts { + opt(s) + } + return s +} + +// FromEdgeOwner returns true if the step is from an edge owner. +// i.e., from the table that holds the foreign-key. +func (s *Step) FromEdgeOwner() bool { + return s.Edge.Rel == M2O || (s.Edge.Rel == O2O && s.Edge.Inverse) +} + +// ToEdgeOwner returns true if the step is to an edge owner. +// i.e., to the table that holds the foreign-key. +func (s *Step) ToEdgeOwner() bool { + return s.Edge.Rel == O2M || (s.Edge.Rel == O2O && !s.Edge.Inverse) +} + +// ThroughEdgeTable returns true if the step is through a join-table. +func (s *Step) ThroughEdgeTable() bool { + return s.Edge.Rel == M2M +} + +// Neighbors returns a Selector for evaluating the path-step +// and getting the neighbors of one vertex. +func Neighbors(dialect string, s *Step) (q *sql.Selector) { + builder := sql.Dialect(dialect) + switch { + case s.ThroughEdgeTable(): + pk1, pk2 := s.Edge.Columns[1], s.Edge.Columns[0] + if s.Edge.Inverse { + pk1, pk2 = pk2, pk1 + } + to := builder.Table(s.To.Table).Schema(s.To.Schema) + join := builder.Table(s.Edge.Table).Schema(s.Edge.Schema) + match := builder.Select(join.C(pk1)). + From(join). + Where(sql.EQ(join.C(pk2), s.From.V)) + q = builder.Select(). + From(to). + Join(match). + On(to.C(s.To.Column), match.C(pk1)) + case s.FromEdgeOwner(): + t1 := builder.Table(s.To.Table).Schema(s.To.Schema) + t2 := builder.Select(s.Edge.Columns[0]). + From(builder.Table(s.Edge.Table).Schema(s.Edge.Schema)). + Where(sql.EQ(s.From.Column, s.From.V)) + q = builder.Select(). + From(t1). + Join(t2). + On(t1.C(s.To.Column), t2.C(s.Edge.Columns[0])) + case s.ToEdgeOwner(): + q = builder.Select(). + From(builder.Table(s.To.Table).Schema(s.To.Schema)). + Where(sql.EQ(s.Edge.Columns[0], s.From.V)) + } + return q +} + +// SetNeighbors returns a Selector for evaluating the path-step +// and getting the neighbors of set of vertices. +func SetNeighbors(dialect string, s *Step) (q *sql.Selector) { + set := s.From.V.(*sql.Selector) + builder := sql.Dialect(dialect) + switch { + case s.ThroughEdgeTable(): + pk1, pk2 := s.Edge.Columns[1], s.Edge.Columns[0] + if s.Edge.Inverse { + pk1, pk2 = pk2, pk1 + } + to := builder.Table(s.To.Table).Schema(s.To.Schema) + set.Select(set.C(s.From.Column)) + join := builder.Table(s.Edge.Table).Schema(s.Edge.Schema) + match := builder.Select(join.C(pk1)). + From(join). + Join(set). + On(join.C(pk2), set.C(s.From.Column)) + q = builder.Select(). + From(to). + Join(match). + On(to.C(s.To.Column), match.C(pk1)) + case s.FromEdgeOwner(): + t1 := builder.Table(s.To.Table).Schema(s.To.Schema) + set.Select(set.C(s.Edge.Columns[0])) + q = builder.Select(). + From(t1). + Join(set). + On(t1.C(s.To.Column), set.C(s.Edge.Columns[0])) + case s.ToEdgeOwner(): + t1 := builder.Table(s.To.Table).Schema(s.To.Schema) + set.Select(set.C(s.From.Column)) + q = builder.Select(). + From(t1). + Join(set). + On(t1.C(s.Edge.Columns[0]), set.C(s.From.Column)) + } + return q +} + +// HasNeighbors applies on the given Selector a neighbors check. +func HasNeighbors(q *sql.Selector, s *Step) { + builder := sql.Dialect(q.Dialect()) + switch { + case s.ThroughEdgeTable(): + pk1 := s.Edge.Columns[0] + if s.Edge.Inverse { + pk1 = s.Edge.Columns[1] + } + join := builder.Table(s.Edge.Table).Schema(s.Edge.Schema) + q.Where( + sql.In( + q.C(s.From.Column), + builder.Select(join.C(pk1)).From(join), + ), + ) + case s.FromEdgeOwner(): + q.Where(sql.NotNull(q.C(s.Edge.Columns[0]))) + case s.ToEdgeOwner(): + to := builder.Table(s.Edge.Table).Schema(s.Edge.Schema) + // In case the edge reside on the same table, give + // the edge an alias to make qualifier different. + if s.From.Table == s.Edge.Table { + to.As(fmt.Sprintf("%s_edge", s.Edge.Table)) + } + q.Where( + sql.Exists( + builder.Select(to.C(s.Edge.Columns[0])). + From(to). + Where( + sql.ColumnsEQ( + q.C(s.From.Column), + to.C(s.Edge.Columns[0]), + ), + ), + ), + ) + } +} + +// HasNeighborsWith applies on the given Selector a neighbors check. +// The given predicate applies its filtering on the selector. +func HasNeighborsWith(q *sql.Selector, s *Step, pred func(*sql.Selector)) { + builder := sql.Dialect(q.Dialect()) + switch { + case s.ThroughEdgeTable(): + pk1, pk2 := s.Edge.Columns[1], s.Edge.Columns[0] + if s.Edge.Inverse { + pk1, pk2 = pk2, pk1 + } + to := builder.Table(s.To.Table).Schema(s.To.Schema) + edge := builder.Table(s.Edge.Table).Schema(s.Edge.Schema) + join := builder.Select(edge.C(pk2)). + From(edge). + Join(to). + On(edge.C(pk1), to.C(s.To.Column)) + matches := builder.Select().From(to) + matches.WithContext(q.Context()) + pred(matches) + join.FromSelect(matches) + q.Where(sql.In(q.C(s.From.Column), join)) + case s.FromEdgeOwner(): + to := builder.Table(s.To.Table).Schema(s.To.Schema) + // Avoid ambiguity in case both source + // and edge tables are the same. + if s.To.Table == q.TableName() { + to.As(fmt.Sprintf("%s_edge", s.To.Table)) + // Choose the alias name until we do not + // have a collision. Limit to 5 iterations. + for i := 1; i <= 5; i++ { + if to.C("c") != q.C("c") { + break + } + to.As(fmt.Sprintf("%s_edge_%d", s.To.Table, i)) + } + } + matches := builder.Select(to.C(s.To.Column)). + From(to) + matches.WithContext(q.Context()) + matches.Where( + sql.ColumnsEQ( + q.C(s.Edge.Columns[0]), + to.C(s.To.Column), + ), + ) + pred(matches) + q.Where(sql.Exists(matches)) + case s.ToEdgeOwner(): + to := builder.Table(s.Edge.Table).Schema(s.Edge.Schema) + // Avoid ambiguity in case both source + // and edge tables are the same. + if s.Edge.Table == q.TableName() { + to.As(fmt.Sprintf("%s_edge", s.Edge.Table)) + // Choose the alias name until we do not + // have a collision. Limit to 5 iterations. + for i := 1; i <= 5; i++ { + if to.C("c") != q.C("c") { + break + } + to.As(fmt.Sprintf("%s_edge_%d", s.Edge.Table, i)) + } + } + matches := builder.Select(to.C(s.Edge.Columns[0])). + From(to) + matches.WithContext(q.Context()) + matches.Where( + sql.ColumnsEQ( + q.C(s.From.Column), + to.C(s.Edge.Columns[0]), + ), + ) + pred(matches) + q.Where(sql.Exists(matches)) + } +} + +// countAlias returns the alias to use for the count column. +func countAlias(q *sql.Selector, s *Step, opt *sql.OrderTermOptions) string { + if opt.As != "" { + return opt.As + } + selected := make(map[string]struct{}) + for _, c := range q.SelectedColumns() { + selected[c] = struct{}{} + } + column := fmt.Sprintf("count_%s", s.To.Table) + // If the column was already selected, + // try to find a free alias. + if _, ok := selected[column]; ok { + for i := 1; i <= 5; i++ { + ci := fmt.Sprintf("%s_%d", column, i) + if _, ok := selected[ci]; !ok { + return ci + } + } + } + return column +} + +// OrderByNeighborsCount appends ordering based on the number of neighbors. +// For example, order users by their number of posts. +func OrderByNeighborsCount(q *sql.Selector, s *Step, opts ...sql.OrderTermOption) { + var ( + join *sql.Selector + opt = sql.NewOrderTermOptions(opts...) + build = sql.Dialect(q.Dialect()) + ) + switch { + case s.FromEdgeOwner(): + // For M2O and O2O inverse, the FK resides in the same table. + // Hence, the order by is on the nullability of the column. + x := func(b *sql.Builder) { + b.Ident(s.From.Column) + if opt.Desc { + b.WriteOp(sql.OpNotNull) + } else { + b.WriteOp(sql.OpIsNull) + } + } + q.OrderExpr(build.Expr(x)) + case s.ThroughEdgeTable(): + countAs := countAlias(q, s, opt) + terms := []sql.OrderTerm{ + sql.OrderByCount("*", append([]sql.OrderTermOption{sql.OrderAs(countAs)}, opts...)...), + } + pk1 := s.Edge.Columns[0] + if s.Edge.Inverse { + pk1 = s.Edge.Columns[1] + } + joinT := build.Table(s.Edge.Table).Schema(s.Edge.Schema) + join = build.Select( + joinT.C(pk1), + ).From(joinT).GroupBy(joinT.C(pk1)) + selectTerms(join, terms) + q.LeftJoin(join). + On( + q.C(s.From.Column), + join.C(pk1), + ) + orderTerms(q, join, terms) + case s.ToEdgeOwner(): + countAs := countAlias(q, s, opt) + terms := []sql.OrderTerm{ + sql.OrderByCount("*", append([]sql.OrderTermOption{sql.OrderAs(countAs)}, opts...)...), + } + edgeT := build.Table(s.Edge.Table).Schema(s.Edge.Schema) + join = build.Select( + edgeT.C(s.Edge.Columns[0]), + ).From(edgeT).GroupBy(edgeT.C(s.Edge.Columns[0])) + selectTerms(join, terms) + q.LeftJoin(join). + On( + q.C(s.From.Column), + join.C(s.Edge.Columns[0]), + ) + orderTerms(q, join, terms) + } +} + +func orderTerms(q, join *sql.Selector, ts []sql.OrderTerm) { + for _, t := range ts { + t := t + var ( + // Order by column or expression. + orderC string + orderX func(*sql.Selector) sql.Querier + // Order by options. + desc, nullsfirst, nullslast bool + ) + switch t := t.(type) { + case *sql.OrderFieldTerm: + f := t.Field + if t.As != "" { + f = t.As + } + orderC = join.C(f) + if t.Selected { + q.AppendSelect(orderC) + } + desc = t.Desc + nullsfirst = t.NullsFirst + nullslast = t.NullsLast + case *sql.OrderExprTerm: + if t.As != "" { + orderC = join.C(t.As) + if t.Selected { + q.AppendSelect(orderC) + } + } else { + orderX = t.Expr + } + desc = t.Desc + nullsfirst = t.NullsFirst + nullslast = t.NullsLast + default: + continue + } + q.OrderExprFunc(func(b *sql.Builder) { + // Write the ORDER BY term. + switch { + case orderC != "": + b.WriteString(orderC) + case orderX != nil: + b.Join(orderX(join)) + } + // Unlike MySQL and SQLite, NULL values sort as if larger than any other value. Therefore, + // we need to explicitly order NULLs first on ASC and last on DESC unless specified otherwise. + switch normalizePG := b.Dialect() == dialect.Postgres && !nullsfirst && !nullslast; { + case normalizePG && desc: + b.WriteString(" DESC NULLS LAST") + case normalizePG: + b.WriteString(" NULLS FIRST") + case desc: + b.WriteString(" DESC") + } + if nullsfirst { + b.WriteString(" NULLS FIRST") + } else if nullslast { + b.WriteString(" NULLS LAST") + } + }) + } +} + +// selectTerms appends the select terms to the joined query. +// Afterward, the term aliases are utilized to order the root query. +func selectTerms(q *sql.Selector, ts []sql.OrderTerm) { + for _, t := range ts { + switch t := t.(type) { + case *sql.OrderFieldTerm: + if t.As != "" { + q.AppendSelectAs(q.C(t.Field), t.As) + } else { + q.AppendSelect(q.C(t.Field)) + } + case *sql.OrderExprTerm: + q.AppendSelectExprAs(t.Expr(q), t.As) + } + } +} + +// OrderByNeighborTerms appends ordering based on the number of neighbors. +// For example, order users by their number of posts. +func OrderByNeighborTerms(q *sql.Selector, s *Step, opts ...sql.OrderTerm) { + var ( + join *sql.Selector + build = sql.Dialect(q.Dialect()) + ) + switch { + case s.FromEdgeOwner(): + toT := build.Table(s.To.Table).Schema(s.To.Schema) + join = build.Select(toT.C(s.To.Column)). + From(toT) + selectTerms(join, opts) + q.LeftJoin(join). + On(q.C(s.Edge.Columns[0]), join.C(s.To.Column)) + case s.ThroughEdgeTable(): + pk1, pk2 := s.Edge.Columns[1], s.Edge.Columns[0] + if s.Edge.Inverse { + pk1, pk2 = pk2, pk1 + } + toT := build.Table(s.To.Table).Schema(s.To.Schema) + joinT := build.Table(s.Edge.Table).Schema(s.Edge.Schema) + join = build.Select(pk2). + From(toT). + Join(joinT). + On(toT.C(s.To.Column), joinT.C(pk1)). + GroupBy(pk2) + selectTerms(join, opts) + q.LeftJoin(join). + On(q.C(s.From.Column), join.C(pk2)) + case s.ToEdgeOwner(): + toT := build.Table(s.Edge.Table).Schema(s.Edge.Schema) + join = build.Select(toT.C(s.Edge.Columns[0])). + From(toT). + GroupBy(toT.C(s.Edge.Columns[0])) + selectTerms(join, opts) + q.LeftJoin(join). + On(q.C(s.From.Column), join.C(s.Edge.Columns[0])) + } + orderTerms(q, join, opts) +} + +// NeighborsLimit provides a modifier function that limits the +// number of neighbors (rows) loaded per parent row (node). +type NeighborsLimit struct { + // SrcCTE, LimitCTE and RowNumber hold the identifier names + // to src query, new limited one (using window function) and + // the column for counting rows. + SrcCTE, LimitCTE, RowNumber string + // DefaultOrderField sets the default ordering for + // sub-queries in case no order terms were provided. + DefaultOrderField string +} + +// LimitNeighbors returns a modifier that limits the number of neighbors (rows) loaded per parent +// row (node). The "partitionBy" is the foreign-key column (edge) to partition the window function +// by, the "limit" is the maximum number of rows per parent, and the "orderBy" defines the order of +// how neighbors (connected by the edge) are returned. +// +// This function is useful for non-unique edges, such as O2M and M2M, where the same parent can +// have multiple children. +func LimitNeighbors(partitionBy string, limit int, orderBy ...sql.Querier) func(*sql.Selector) { + l := &NeighborsLimit{ + SrcCTE: "src_query", + LimitCTE: "limited_query", + RowNumber: "row_number", + DefaultOrderField: "id", + } + return l.Modifier(partitionBy, limit, orderBy...) +} + +// Modifier returns a modifier function that limits the number of rows of the eager load query. +func (l *NeighborsLimit) Modifier(partitionBy string, limit int, orderBy ...sql.Querier) func(s *sql.Selector) { + return func(s *sql.Selector) { + var ( + d = sql.Dialect(s.Dialect()) + rn = sql.RowNumber().PartitionBy(partitionBy) + ) + switch { + case len(orderBy) > 0: + rn.OrderExpr(orderBy...) + case l.DefaultOrderField != "": + rn.OrderBy(l.DefaultOrderField) + default: + s.AddError(errors.New("no order terms provided for window function")) + return + } + s.SetDistinct(false) + with := d.With(l.SrcCTE). + As(s.Clone()). + With(l.LimitCTE). + As( + d.Select("*"). + AppendSelectExprAs(rn, l.RowNumber). + From(d.Table(l.SrcCTE)), + ) + t := d.Table(l.LimitCTE).As(s.TableName()) + *s = *d.Select(s.UnqualifiedColumns()...). + From(t). + Where(sql.LTE(t.C(l.RowNumber), limit)). + Prefix(with) + } +} + +type ( + // FieldSpec holds the information for updating a field + // column in the database. + FieldSpec struct { + Column string + Type field.Type + Value driver.Value // value to be stored. + } + + // EdgeTarget holds the information for the target nodes + // of an edge. + EdgeTarget struct { + Nodes []driver.Value + IDSpec *FieldSpec + // Additional fields can be set on the + // edge join table. Valid for M2M edges. + Fields []*FieldSpec + } + + // EdgeSpec holds the information for updating a field + // column in the database. + EdgeSpec struct { + Rel Rel + Inverse bool + Table string + Schema string + Columns []string + Bidi bool // bidirectional edge. + Target *EdgeTarget // target nodes. + } + + // EdgeSpecs used for perform common operations on list of edges. + EdgeSpecs []*EdgeSpec + + // NodeSpec defines the information for querying and + // decoding nodes in the graph. + NodeSpec struct { + Table string + Schema string + Columns []string + ID *FieldSpec // primary key. + CompositeID []*FieldSpec // composite id (edge schema). + } +) + +// NewFieldSpec creates a new FieldSpec with its required fields. +func NewFieldSpec(column string, typ field.Type) *FieldSpec { + return &FieldSpec{Column: column, Type: typ} +} + +// AddColumnOnce adds the given column to the spec if it is not already present. +func (n *NodeSpec) AddColumnOnce(column string) *NodeSpec { + for _, c := range n.Columns { + if c == column { + return n + } + } + n.Columns = append(n.Columns, column) + return n +} + +// FieldValues returns the values of additional fields that were set on the join-table. +func (e *EdgeTarget) FieldValues() []any { + vs := make([]any, len(e.Fields)) + for i, f := range e.Fields { + vs[i] = f.Value + } + return vs +} + +type ( + // CreateSpec holds the information for creating + // a node in the graph. + CreateSpec struct { + Table string + Schema string + ID *FieldSpec + Fields []*FieldSpec + Edges []*EdgeSpec + + // The OnConflict option allows providing on-conflict + // options to the INSERT statement. + // + // sqlgraph.CreateSpec{ + // OnConflict: []sql.ConflictOption{ + // sql.ResolveWithNewValues(), + // }, + // } + // + OnConflict []sql.ConflictOption + } + + // BatchCreateSpec holds the information for creating + // multiple nodes in the graph. + BatchCreateSpec struct { + Nodes []*CreateSpec + + // The OnConflict option allows providing on-conflict + // options to the INSERT statement. + // + // sqlgraph.CreateSpec{ + // OnConflict: []sql.ConflictOption{ + // sql.ResolveWithNewValues(), + // }, + // } + // + OnConflict []sql.ConflictOption + } +) + +// NewCreateSpec creates a new node creation spec. +func NewCreateSpec(table string, id *FieldSpec) *CreateSpec { + return &CreateSpec{Table: table, ID: id} +} + +// SetField appends a new field setter to the creation spec. +func (u *CreateSpec) SetField(column string, t field.Type, value driver.Value) { + u.Fields = append(u.Fields, &FieldSpec{ + Column: column, + Type: t, + Value: value, + }) +} + +// CreateNode applies the CreateSpec on the graph. The operation creates a new +// record in the database, and connects it to other nodes specified in spec.Edges. +func CreateNode(ctx context.Context, drv dialect.Driver, spec *CreateSpec) error { + gr := graph{tx: drv, builder: sql.Dialect(drv.Dialect())} + cr := &creator{CreateSpec: spec, graph: gr} + return cr.node(ctx, drv) +} + +// BatchCreate applies the BatchCreateSpec on the graph. +func BatchCreate(ctx context.Context, drv dialect.Driver, spec *BatchCreateSpec) error { + gr := graph{tx: drv, builder: sql.Dialect(drv.Dialect())} + cr := &batchCreator{BatchCreateSpec: spec, graph: gr} + return cr.nodes(ctx, drv) +} + +type ( + // EdgeMut defines edge mutations. + EdgeMut struct { + Add []*EdgeSpec + Clear []*EdgeSpec + } + + // FieldMut defines field mutations. + FieldMut struct { + Set []*FieldSpec // field = ? + Add []*FieldSpec // field = field + ? + Clear []*FieldSpec // field = NULL + } + + // UpdateSpec holds the information for updating one + // or more nodes in the graph. + UpdateSpec struct { + Node *NodeSpec + Edges EdgeMut + Fields FieldMut + Predicate func(*sql.Selector) + Modifiers []func(*sql.UpdateBuilder) + + ScanValues func(columns []string) ([]any, error) + Assign func(columns []string, values []any) error + } +) + +// NewUpdateSpec creates a new node update spec. +func NewUpdateSpec(table string, columns []string, id ...*FieldSpec) *UpdateSpec { + spec := &UpdateSpec{ + Node: &NodeSpec{Table: table, Columns: columns}, + } + switch { + case len(id) == 1: + spec.Node.ID = id[0] + case len(id) > 1: + spec.Node.CompositeID = id + } + return spec +} + +// AddModifier adds a new statement modifier to the spec. +func (u *UpdateSpec) AddModifier(m func(*sql.UpdateBuilder)) { + u.Modifiers = append(u.Modifiers, m) +} + +// AddModifiers adds a list of statement modifiers to the spec. +func (u *UpdateSpec) AddModifiers(m ...func(*sql.UpdateBuilder)) { + u.Modifiers = append(u.Modifiers, m...) +} + +// SetField appends a new field setter to the update spec. +func (u *UpdateSpec) SetField(column string, t field.Type, value driver.Value) { + u.Fields.Set = append(u.Fields.Set, &FieldSpec{ + Column: column, + Type: t, + Value: value, + }) +} + +// AddField appends a new field adder to the update spec. +func (u *UpdateSpec) AddField(column string, t field.Type, value driver.Value) { + u.Fields.Add = append(u.Fields.Add, &FieldSpec{ + Column: column, + Type: t, + Value: value, + }) +} + +// ClearField appends a new field cleaner (set to NULL) to the update spec. +func (u *UpdateSpec) ClearField(column string, t field.Type) { + u.Fields.Clear = append(u.Fields.Clear, &FieldSpec{ + Column: column, + Type: t, + }) +} + +// UpdateNode applies the UpdateSpec on one node in the graph. +func UpdateNode(ctx context.Context, drv dialect.Driver, spec *UpdateSpec) error { + tx, err := drv.Tx(ctx) + if err != nil { + return err + } + gr := graph{tx: tx, builder: sql.Dialect(drv.Dialect())} + cr := &updater{UpdateSpec: spec, graph: gr} + if err := cr.node(ctx, tx); err != nil { + return rollback(tx, err) + } + return tx.Commit() +} + +// UpdateNodes applies the UpdateSpec on a set of nodes in the graph. +func UpdateNodes(ctx context.Context, drv dialect.Driver, spec *UpdateSpec) (int, error) { + gr := graph{tx: drv, builder: sql.Dialect(drv.Dialect())} + cr := &updater{UpdateSpec: spec, graph: gr} + return cr.nodes(ctx, drv) +} + +// NotFoundError returns when trying to update an +// entity, and it was not found in the database. +type NotFoundError struct { + table string + id driver.Value +} + +func (e *NotFoundError) Error() string { + return fmt.Sprintf("record with id %v not found in table %s", e.id, e.table) +} + +// DeleteSpec holds the information for delete one +// or more nodes in the graph. +type DeleteSpec struct { + Node *NodeSpec + Predicate func(*sql.Selector) +} + +// NewDeleteSpec creates a new node deletion spec. +func NewDeleteSpec(table string, id *FieldSpec) *DeleteSpec { + return &DeleteSpec{Node: &NodeSpec{Table: table, ID: id}} +} + +// DeleteNodes applies the DeleteSpec on the graph. +func DeleteNodes(ctx context.Context, drv dialect.Driver, spec *DeleteSpec) (int, error) { + var ( + res sql.Result + builder = sql.Dialect(drv.Dialect()) + ) + selector := builder.Select(). + From(builder.Table(spec.Node.Table).Schema(spec.Node.Schema)). + WithContext(ctx) + if pred := spec.Predicate; pred != nil { + pred(selector) + } + query, args := builder.Delete(spec.Node.Table).Schema(spec.Node.Schema).FromSelect(selector).Query() + if err := drv.Exec(ctx, query, args, &res); err != nil { + return 0, err + } + affected, err := res.RowsAffected() + if err != nil { + return 0, err + } + return int(affected), nil +} + +// QuerySpec holds the information for querying +// nodes in the graph. +type QuerySpec struct { + Node *NodeSpec // Nodes info. + From *sql.Selector // Optional query source (from path). + + Limit int + Offset int + Unique bool + Order func(*sql.Selector) + Predicate func(*sql.Selector) + Modifiers []func(*sql.Selector) + + ScanValues func(columns []string) ([]any, error) + Assign func(columns []string, values []any) error +} + +// NewQuerySpec creates a new node query spec. +func NewQuerySpec(table string, columns []string, id *FieldSpec) *QuerySpec { + return &QuerySpec{ + Node: &NodeSpec{ + ID: id, + Table: table, + Columns: columns, + }, + } +} + +// QueryNodes queries the nodes in the graph query and scans them to the given values. +func QueryNodes(ctx context.Context, drv dialect.Driver, spec *QuerySpec) error { + builder := sql.Dialect(drv.Dialect()) + qr := &query{graph: graph{builder: builder}, QuerySpec: spec} + return qr.nodes(ctx, drv) +} + +// CountNodes counts the nodes in the given graph query. +func CountNodes(ctx context.Context, drv dialect.Driver, spec *QuerySpec) (int, error) { + builder := sql.Dialect(drv.Dialect()) + qr := &query{graph: graph{builder: builder}, QuerySpec: spec} + return qr.count(ctx, drv) +} + +// EdgeQuerySpec holds the information for querying +// edges in the graph. +type EdgeQuerySpec struct { + Edge *EdgeSpec + Predicate func(*sql.Selector) + ScanValues func() [2]any + Assign func(out, in any) error +} + +// QueryEdges queries the edges in the graph and scans the result with the given dest function. +func QueryEdges(ctx context.Context, drv dialect.Driver, spec *EdgeQuerySpec) error { + if len(spec.Edge.Columns) != 2 { + return fmt.Errorf("sqlgraph: edge query requires 2 columns (out, in)") + } + out, in := spec.Edge.Columns[0], spec.Edge.Columns[1] + if spec.Edge.Inverse { + out, in = in, out + } + selector := sql.Dialect(drv.Dialect()). + Select(out, in). + From(sql.Table(spec.Edge.Table).Schema(spec.Edge.Schema)) + if p := spec.Predicate; p != nil { + p(selector) + } + rows := &sql.Rows{} + query, args := selector.Query() + if err := drv.Query(ctx, query, args, rows); err != nil { + return err + } + defer rows.Close() + for rows.Next() { + values := spec.ScanValues() + if err := rows.Scan(values[0], values[1]); err != nil { + return err + } + if err := spec.Assign(values[0], values[1]); err != nil { + return err + } + } + return rows.Err() +} + +type query struct { + graph + *QuerySpec +} + +func (q *query) nodes(ctx context.Context, drv dialect.Driver) error { + rows := &sql.Rows{} + selector, err := q.selector(ctx) + if err != nil { + return err + } + query, args := selector.Query() + if err := drv.Query(ctx, query, args, rows); err != nil { + return err + } + defer rows.Close() + columns, err := rows.Columns() + if err != nil { + return err + } + for rows.Next() { + values, err := q.ScanValues(columns) + if err != nil { + return err + } + for i, v := range values { + if _, ok := v.(*sql.UnknownType); ok { + values[i] = sql.ScanTypeOf(rows, i) + } + } + if err := rows.Scan(values...); err != nil { + return err + } + if err := q.Assign(columns, values); err != nil { + return err + } + } + return rows.Err() +} + +func (q *query) count(ctx context.Context, drv dialect.Driver) (int, error) { + rows := &sql.Rows{} + selector, err := q.selector(ctx) + if err != nil { + return 0, err + } + // Remove any ORDER BY clauses present in the COUNT query as + // they are not allowed in some databases, such as PostgreSQL. + if q.Order != nil { + selector.ClearOrder() + } + // If no columns were selected in count, + // the default selection is by node ids. + columns := q.Node.Columns + if len(columns) == 0 && q.Node.ID != nil { + columns = append(columns, q.Node.ID.Column) + } + for i, c := range columns { + columns[i] = selector.C(c) + } + if q.Unique { + selector.SetDistinct(false) + selector.Count(sql.Distinct(columns...)) + } else { + selector.Count(columns...) + } + query, args := selector.Query() + if err := drv.Query(ctx, query, args, rows); err != nil { + return 0, err + } + defer rows.Close() + return sql.ScanInt(rows) +} + +func (q *query) selector(ctx context.Context) (*sql.Selector, error) { + selector := q.builder. + Select(). + From(q.builder.Table(q.Node.Table).Schema(q.Node.Schema)). + WithContext(ctx) + if q.From != nil { + selector = q.From + selector.WithContext(ctx) + } + selector.Select(selector.Columns(q.Node.Columns...)...) + if order := q.Order; order != nil { + order(selector) + } + if pred := q.Predicate; pred != nil { + pred(selector) + } + if q.Offset != 0 { + // Limit is mandatory for the offset clause. We start + // with default value, and override it below if needed. + selector.Offset(q.Offset).Limit(math.MaxInt32) + } + if q.Limit != 0 { + selector.Limit(q.Limit) + } + if q.Unique { + selector.Distinct() + } + for _, m := range q.Modifiers { + m(selector) + } + if err := selector.Err(); err != nil { + return nil, err + } + return selector, nil +} + +type updater struct { + graph + *UpdateSpec +} + +func (u *updater) node(ctx context.Context, tx dialect.ExecQuerier) error { + var ( + id driver.Value + idp *sql.Predicate + addEdges = EdgeSpecs(u.Edges.Add).GroupRel() + clearEdges = EdgeSpecs(u.Edges.Clear).GroupRel() + ) + switch { + // In case it is not an edge schema, the id holds the PK + // of the node used for linking it with the other nodes. + case u.Node.ID != nil: + id = u.Node.ID.Value + idp = sql.EQ(u.Node.ID.Column, id) + case len(u.Node.CompositeID) == 2: + idp = sql.And( + sql.EQ(u.Node.CompositeID[0].Column, u.Node.CompositeID[0].Value), + sql.EQ(u.Node.CompositeID[1].Column, u.Node.CompositeID[1].Value), + ) + case len(u.Node.CompositeID) != 2: + return fmt.Errorf("sql/sqlgraph: invalid composite id for update table %q", u.Node.Table) + default: + return fmt.Errorf("sql/sqlgraph: missing node id for update table %q", u.Node.Table) + } + update := u.builder.Update(u.Node.Table).Schema(u.Node.Schema).Where(idp) + if pred := u.Predicate; pred != nil { + selector := u.builder.Select().From(u.builder.Table(u.Node.Table).Schema(u.Node.Schema)) + pred(selector) + update.FromSelect(selector) + } + if err := u.setTableColumns(update, addEdges, clearEdges); err != nil { + return err + } + for _, m := range u.Modifiers { + m(update) + } + if err := update.Err(); err != nil { + return err + } + if !update.Empty() { + var res sql.Result + query, args := update.Query() + if err := tx.Exec(ctx, query, args, &res); err != nil { + return err + } + affected, err := res.RowsAffected() + if err != nil { + return err + } + // In case there are zero affected rows by this statement, we need to distinguish + // between the case of "record was not found" and "record was not changed". + if affected == 0 && u.Predicate != nil { + if err := u.ensureExists(ctx); err != nil { + return err + } + } + } + if id != nil { + // Not an edge schema. + if err := u.setExternalEdges(ctx, []driver.Value{id}, addEdges, clearEdges); err != nil { + return err + } + } + // Ignore querying the database when there's nothing + // to scan into it. + if u.ScanValues == nil { + return nil + } + selector := u.builder.Select(u.Node.Columns...). + From(u.builder.Table(u.Node.Table).Schema(u.Node.Schema)). + // Skip adding the custom predicates that were attached + // to the updater as they may point to columns that were + // changed by the UPDATE statement. + Where(idp) + rows := &sql.Rows{} + query, args := selector.Query() + if err := tx.Query(ctx, query, args, rows); err != nil { + return err + } + return u.scan(rows) +} + +func (u *updater) nodes(ctx context.Context, drv dialect.Driver) (int, error) { + var ( + addEdges = EdgeSpecs(u.Edges.Add).GroupRel() + clearEdges = EdgeSpecs(u.Edges.Clear).GroupRel() + multiple = hasExternalEdges(addEdges, clearEdges) + update = u.builder.Update(u.Node.Table).Schema(u.Node.Schema) + selector = u.builder.Select(). + From(u.builder.Table(u.Node.Table).Schema(u.Node.Schema)). + WithContext(ctx) + ) + switch { + // In case it is not an edge schema, the id holds the PK of + // the returned nodes are used for updating external tables. + case u.Node.ID != nil: + selector.Select(u.Node.ID.Column) + case len(u.Node.CompositeID) == 2: + // Other edge-schemas (M2M tables) cannot be updated by this operation. + // Also, in case there is a need to update an external foreign-key, it must + // be a single value and the user should use the "update by id" API instead. + if multiple { + return 0, fmt.Errorf("sql/sqlgraph: update edge schema table %q cannot update external tables", u.Node.Table) + } + case len(u.Node.CompositeID) != 2: + return 0, fmt.Errorf("sql/sqlgraph: invalid composite id for update table %q", u.Node.Table) + default: + return 0, fmt.Errorf("sql/sqlgraph: missing node id for update table %q", u.Node.Table) + } + if err := u.setTableColumns(update, addEdges, clearEdges); err != nil { + return 0, err + } + if pred := u.Predicate; pred != nil { + pred(selector) + } + // In case of single statement update, avoid opening a transaction manually. + if !multiple { + update.FromSelect(selector) + return u.updateTable(ctx, update) + } + tx, err := drv.Tx(ctx) + if err != nil { + return 0, err + } + u.tx = tx + affected, err := func() (int, error) { + var ( + ids []driver.Value + rows = &sql.Rows{} + query, args = selector.Query() + ) + if err := u.tx.Query(ctx, query, args, rows); err != nil { + return 0, fmt.Errorf("querying table %s: %w", u.Node.Table, err) + } + defer rows.Close() + if err := sql.ScanSlice(rows, &ids); err != nil { + return 0, fmt.Errorf("scan node ids: %w", err) + } + if err := rows.Close(); err != nil { + return 0, err + } + if len(ids) == 0 { + return 0, nil + } + update.Where(matchID(u.Node.ID.Column, ids)) + // In case of multi statement update, that change can + // affect more than 1 table, and therefore, we return + // the list of ids as number of affected records. + if _, err := u.updateTable(ctx, update); err != nil { + return 0, err + } + if err := u.setExternalEdges(ctx, ids, addEdges, clearEdges); err != nil { + return 0, err + } + return len(ids), nil + }() + if err != nil { + return 0, rollback(tx, err) + } + return affected, tx.Commit() +} + +func (u *updater) updateTable(ctx context.Context, stmt *sql.UpdateBuilder) (int, error) { + for _, m := range u.Modifiers { + m(stmt) + } + if err := stmt.Err(); err != nil { + return 0, err + } + if stmt.Empty() { + return 0, nil + } + var ( + res sql.Result + query, args = stmt.Query() + ) + if err := u.tx.Exec(ctx, query, args, &res); err != nil { + return 0, err + } + affected, err := res.RowsAffected() + if err != nil { + return 0, err + } + return int(affected), nil +} + +func (u *updater) setExternalEdges(ctx context.Context, ids []driver.Value, addEdges, clearEdges map[Rel][]*EdgeSpec) error { + if err := u.graph.clearM2MEdges(ctx, ids, clearEdges[M2M]); err != nil { + return err + } + if err := u.graph.addM2MEdges(ctx, ids, addEdges[M2M]); err != nil { + return err + } + if err := u.graph.clearFKEdges(ctx, ids, append(clearEdges[O2M], clearEdges[O2O]...)); err != nil { + return err + } + if err := u.graph.addFKEdges(ctx, ids, append(addEdges[O2M], addEdges[O2O]...)); err != nil { + return err + } + return nil +} + +// setTableColumns sets the table columns and foreign_keys used in insert. +func (u *updater) setTableColumns(update *sql.UpdateBuilder, addEdges, clearEdges map[Rel][]*EdgeSpec) error { + // Avoid multiple assignments to the same column. + setEdges := make(map[string]bool) + for _, e := range addEdges[M2O] { + setEdges[e.Columns[0]] = true + } + for _, e := range addEdges[O2O] { + if e.Inverse || e.Bidi { + setEdges[e.Columns[0]] = true + } + } + for _, fi := range u.Fields.Clear { + update.SetNull(fi.Column) + } + for _, e := range clearEdges[M2O] { + if col := e.Columns[0]; !setEdges[col] { + update.SetNull(col) + } + } + for _, e := range clearEdges[O2O] { + col := e.Columns[0] + if (e.Inverse || e.Bidi) && !setEdges[col] { + update.SetNull(col) + } + } + err := setTableColumns(u.Fields.Set, addEdges, func(column string, value driver.Value) { + update.Set(column, value) + }) + if err != nil { + return err + } + for _, fi := range u.Fields.Add { + update.Add(fi.Column, fi.Value) + } + return nil +} + +func (u *updater) scan(rows *sql.Rows) error { + defer rows.Close() + columns, err := rows.Columns() + if err != nil { + return err + } + if !rows.Next() { + if err := rows.Err(); err != nil { + return err + } + if len(u.Node.CompositeID) == 2 { + return &NotFoundError{table: u.Node.Table, id: []driver.Value{u.Node.CompositeID[0].Value, u.Node.CompositeID[1].Value}} + } + return &NotFoundError{table: u.Node.Table, id: u.Node.ID.Value} + } + values, err := u.ScanValues(columns) + if err != nil { + return err + } + for i, v := range values { + if _, ok := v.(*sql.UnknownType); ok { + values[i] = sql.ScanTypeOf(rows, i) + } + } + if err := rows.Scan(values...); err != nil { + return fmt.Errorf("failed scanning rows: %w", err) + } + if err := u.Assign(columns, values); err != nil { + return err + } + return nil +} + +func (u *updater) ensureExists(ctx context.Context) error { + exists := u.builder.Select().From(u.builder.Table(u.Node.Table).Schema(u.Node.Schema)).Where(sql.EQ(u.Node.ID.Column, u.Node.ID.Value)) + u.Predicate(exists) + query, args := u.builder.SelectExpr(sql.Exists(exists)).Query() + rows := &sql.Rows{} + if err := u.tx.Query(ctx, query, args, rows); err != nil { + return err + } + defer rows.Close() + found, err := sql.ScanBool(rows) + if err != nil { + return err + } + if !found { + return &NotFoundError{table: u.Node.Table, id: u.Node.ID.Value} + } + return nil +} + +type creator struct { + graph + *CreateSpec +} + +func (c *creator) node(ctx context.Context, drv dialect.Driver) error { + var ( + edges = EdgeSpecs(c.Edges).GroupRel() + insert = c.builder.Insert(c.Table).Schema(c.Schema).Default() + ) + if err := c.setTableColumns(insert, edges); err != nil { + return err + } + tx, err := c.mayTx(ctx, drv, edges) + if err != nil { + return err + } + if err := func() error { + // In case the spec does not contain an ID field, we assume + // we interact with an edge-schema with composite primary key. + if c.ID == nil { + c.ensureConflict(insert) + query, args, err := insert.QueryErr() + if err != nil { + return err + } + return c.tx.Exec(ctx, query, args, nil) + } + if err := c.insert(ctx, insert); err != nil { + return err + } + if err := c.graph.addM2MEdges(ctx, []driver.Value{c.ID.Value}, edges[M2M]); err != nil { + return err + } + return c.graph.addFKEdges(ctx, []driver.Value{c.ID.Value}, append(edges[O2M], edges[O2O]...)) + }(); err != nil { + return rollback(tx, err) + } + return tx.Commit() +} + +// mayTx opens a new transaction if the create operation spans across multiple statements. +func (c *creator) mayTx(ctx context.Context, drv dialect.Driver, edges map[Rel][]*EdgeSpec) (dialect.Tx, error) { + if !hasExternalEdges(edges, nil) { + return dialect.NopTx(drv), nil + } + tx, err := drv.Tx(ctx) + if err != nil { + return nil, err + } + c.tx = tx + return tx, nil +} + +// setTableColumns sets the table columns and foreign_keys used in insert. +func (c *creator) setTableColumns(insert *sql.InsertBuilder, edges map[Rel][]*EdgeSpec) error { + err := setTableColumns(c.Fields, edges, func(column string, value driver.Value) { + insert.Set(column, value) + }) + return err +} + +// insert a node to its table and sets its ID if it was not provided by the user. +func (c *creator) insert(ctx context.Context, insert *sql.InsertBuilder) error { + c.ensureConflict(insert) + // If the id field was provided by the user. + if c.ID.Value != nil { + insert.Set(c.ID.Column, c.ID.Value) + // In case of "ON CONFLICT", the record may exist in the + // database, and we need to get back the database id field. + if len(c.CreateSpec.OnConflict) == 0 { + query, args, err := insert.QueryErr() + if err != nil { + return err + } + return c.tx.Exec(ctx, query, args, nil) + } + } + return c.insertLastID(ctx, insert.Returning(c.ID.Column)) +} + +// ensureConflict ensures the ON CONFLICT is added to the insert statement. +func (c *creator) ensureConflict(insert *sql.InsertBuilder) { + if opts := c.CreateSpec.OnConflict; len(opts) > 0 { + insert.OnConflict(opts...) + c.ensureLastInsertID(insert) + } +} + +// ensureLastInsertID ensures the LAST_INSERT_ID was added to the +// 'ON DUPLICATE ... UPDATE' clause in it was not provided. +func (c *creator) ensureLastInsertID(insert *sql.InsertBuilder) { + if c.ID == nil || !c.ID.Type.Numeric() || c.ID.Value != nil || insert.Dialect() != dialect.MySQL { + return + } + insert.OnConflict(sql.ResolveWith(func(s *sql.UpdateSet) { + for _, column := range s.UpdateColumns() { + if column == c.ID.Column { + return + } + } + s.Set(c.ID.Column, sql.Expr(fmt.Sprintf("LAST_INSERT_ID(%s)", s.Table().C(c.ID.Column)))) + })) +} + +type batchCreator struct { + graph + *BatchCreateSpec +} + +func (c *batchCreator) nodes(ctx context.Context, drv dialect.Driver) error { + if len(c.Nodes) == 0 { + return nil + } + columns := make(map[string]struct{}) + values := make([]map[string]driver.Value, len(c.Nodes)) + for i, node := range c.Nodes { + if i > 0 && node.Table != c.Nodes[i-1].Table { + return fmt.Errorf("more than 1 table for batch insert: %q != %q", node.Table, c.Nodes[i-1].Table) + } + values[i] = make(map[string]driver.Value) + if node.ID != nil && node.ID.Value != nil { + columns[node.ID.Column] = struct{}{} + values[i][node.ID.Column] = node.ID.Value + } + edges := EdgeSpecs(node.Edges).GroupRel() + err := setTableColumns(node.Fields, edges, func(column string, value driver.Value) { + columns[column] = struct{}{} + values[i][column] = value + }) + if err != nil { + return err + } + } + for column := range columns { + for i := range values { + if _, exists := values[i][column]; !exists { + if c.Nodes[i].ID != nil && column == c.Nodes[i].ID.Column { + // If the ID value was provided to one of the nodes, it should be + // provided to all others because this affects the way we calculate + // their values in MySQL and SQLite dialects. + return fmt.Errorf("inconsistent id values for batch insert") + } + // Assign NULL values for empty placeholders. + values[i][column] = nil + } + } + } + sorted := keys(columns) + insert := c.builder.Insert(c.Nodes[0].Table).Schema(c.Nodes[0].Schema).Default().Columns(sorted...) + for i := range values { + vs := make([]any, len(sorted)) + for j, c := range sorted { + vs[j] = values[i][c] + } + insert.Values(vs...) + } + tx, err := c.mayTx(ctx, drv) + if err != nil { + return err + } + c.tx = tx + if err := func() error { + // In case the spec does not contain an ID field, we assume + // we interact with an edge-schema with composite primary key. + if c.Nodes[0].ID == nil { + c.ensureConflict(insert) + query, args := insert.Query() + return tx.Exec(ctx, query, args, nil) + } + if err := c.batchInsert(ctx, tx, insert); err != nil { + return fmt.Errorf("insert nodes to table %q: %w", c.Nodes[0].Table, err) + } + if err := c.batchAddM2M(ctx, c.BatchCreateSpec); err != nil { + return err + } + // FKs that exist in different tables can't be updated in batch (using the CASE + // statement), because we rely on RowsAffected to check if the FK column is NULL. + for _, node := range c.Nodes { + edges := EdgeSpecs(node.Edges).GroupRel() + if err := c.graph.addFKEdges(ctx, []driver.Value{node.ID.Value}, append(edges[O2M], edges[O2O]...)); err != nil { + return err + } + } + return nil + }(); err != nil { + return rollback(tx, err) + } + return tx.Commit() +} + +// mayTx opens a new transaction if the create operation spans across multiple statements. +func (c *batchCreator) mayTx(ctx context.Context, drv dialect.Driver) (dialect.Tx, error) { + for _, node := range c.Nodes { + for _, edge := range node.Edges { + if isExternalEdge(edge) { + return drv.Tx(ctx) + } + } + } + return dialect.NopTx(drv), nil +} + +// batchInsert inserts a batch of nodes to their table and sets their ID if it was not provided by the user. +func (c *batchCreator) batchInsert(ctx context.Context, tx dialect.ExecQuerier, insert *sql.InsertBuilder) error { + c.ensureConflict(insert) + return c.insertLastIDs(ctx, tx, insert.Returning(c.Nodes[0].ID.Column)) +} + +// ensureConflict ensures the ON CONFLICT is added to the insert statement. +func (c *batchCreator) ensureConflict(insert *sql.InsertBuilder) { + if opts := c.BatchCreateSpec.OnConflict; len(opts) > 0 { + insert.OnConflict(opts...) + } +} + +// GroupRel groups edges by their relation type. +func (es EdgeSpecs) GroupRel() map[Rel][]*EdgeSpec { + edges := make(map[Rel][]*EdgeSpec) + for _, edge := range es { + edges[edge.Rel] = append(edges[edge.Rel], edge) + } + return edges +} + +// GroupTable groups edges by their table name. +func (es EdgeSpecs) GroupTable() map[string][]*EdgeSpec { + edges := make(map[string][]*EdgeSpec) + for _, edge := range es { + edges[edge.Table] = append(edges[edge.Table], edge) + } + return edges +} + +// FilterRel returns edges for the given relation type. +func (es EdgeSpecs) FilterRel(r Rel) EdgeSpecs { + edges := make([]*EdgeSpec, 0, len(es)) + for _, edge := range es { + if edge.Rel == r { + edges = append(edges, edge) + } + } + return edges +} + +// The common operations shared between the different builders. +// +// M2M edges reside in join tables and require INSERT and DELETE +// queries for adding or removing edges respectively. +// +// O2M and non-inverse O2O edges also reside in external tables, +// but use UPDATE queries (fk = ?, fk = NULL). +type graph struct { + tx dialect.ExecQuerier + builder *sql.DialectBuilder +} + +func (g *graph) clearM2MEdges(ctx context.Context, ids []driver.Value, edges EdgeSpecs) error { + // Remove all M2M edges from the same type at once. + // The EdgeSpec is the same for all members in a group. + tables := edges.GroupTable() + for _, table := range edgeKeys(tables) { + edges := tables[table] + preds := make([]*sql.Predicate, 0, len(edges)) + for _, edge := range edges { + fromC, toC := edge.Columns[0], edge.Columns[1] + if edge.Inverse { + fromC, toC = toC, fromC + } + // If there are no specific edges (to target-nodes) to remove, + // clear all edges that go out (or come in) from the nodes. + if len(edge.Target.Nodes) == 0 { + preds = append(preds, matchID(fromC, ids)) + if edge.Bidi { + preds = append(preds, matchID(toC, ids)) + } + } else { + pk1, pk2 := ids, edge.Target.Nodes + preds = append(preds, matchIDs(fromC, pk1, toC, pk2)) + if edge.Bidi { + preds = append(preds, matchIDs(toC, pk1, fromC, pk2)) + } + } + } + deleter := g.builder.Delete(table).Where(sql.Or(preds...)) + if edges[0].Schema != "" { + // If the Schema field was provided to the EdgeSpec (by the + // generated code), it should be the same for all EdgeSpecs. + deleter.Schema(edges[0].Schema) + } + query, args := deleter.Query() + if err := g.tx.Exec(ctx, query, args, nil); err != nil { + return fmt.Errorf("remove m2m edge for table %s: %w", table, err) + } + } + return nil +} + +func (g *graph) addM2MEdges(ctx context.Context, ids []driver.Value, edges EdgeSpecs) error { + // Insert all M2M edges from the same type at once. + // The EdgeSpec is the same for all members in a group. + tables := edges.GroupTable() + for _, table := range edgeKeys(tables) { + var ( + edges = tables[table] + columns = edges[0].Columns + values = make([]any, 0, len(edges[0].Target.Fields)) + ) + // Additional fields, such as edge-schema fields. Note, we use the first index, + // because Ent generates the same spec fields for all edges from the same type. + for _, f := range edges[0].Target.Fields { + values = append(values, f.Value) + columns = append(columns, f.Column) + } + insert := g.builder.Insert(table).Columns(columns...) + if edges[0].Schema != "" { + // If the Schema field was provided to the EdgeSpec (by the + // generated code), it should be the same for all EdgeSpecs. + insert.Schema(edges[0].Schema) + } + for _, edge := range edges { + pk1, pk2 := ids, edge.Target.Nodes + if edge.Inverse { + pk1, pk2 = pk2, pk1 + } + for _, pair := range product(pk1, pk2) { + insert.Values(append([]any{pair[0], pair[1]}, values...)...) + if edge.Bidi { + insert.Values(append([]any{pair[1], pair[0]}, values...)...) + } + } + } + // Ignore conflicts only if edges do not contain extra fields, because these fields + // can hold different values on different insertions (e.g. time.Now() or uuid.New()). + if len(edges[0].Target.Fields) == 0 { + insert.OnConflict(sql.DoNothing()) + } + query, args := insert.Query() + if err := g.tx.Exec(ctx, query, args, nil); err != nil { + return fmt.Errorf("add m2m edge for table %s: %w", table, err) + } + } + return nil +} + +func (g *graph) batchAddM2M(ctx context.Context, spec *BatchCreateSpec) error { + tables := make(map[string]*sql.InsertBuilder) + for _, node := range spec.Nodes { + edges := EdgeSpecs(node.Edges).FilterRel(M2M) + for name, edges := range edges.GroupTable() { + if len(edges) != 1 { + return fmt.Errorf("expect exactly 1 edge-spec per table, but got %d", len(edges)) + } + edge := edges[0] + insert, ok := tables[name] + if !ok { + columns := edge.Columns + // Additional fields, such as edge-schema fields. + for _, f := range edge.Target.Fields { + columns = append(columns, f.Column) + } + insert = g.builder.Insert(name).Columns(columns...) + if edge.Schema != "" { + // If the Schema field was provided to the EdgeSpec (by the + // generated code), it should be the same for all EdgeSpecs. + insert.Schema(edge.Schema) + } + // Ignore conflicts only if edges do not contain extra fields, because these fields + // can hold different values on different insertions (e.g. time.Now() or uuid.New()). + if len(edge.Target.Fields) == 0 { + insert.OnConflict(sql.DoNothing()) + } + } + tables[name] = insert + pk1, pk2 := []driver.Value{node.ID.Value}, edge.Target.Nodes + if edge.Inverse { + pk1, pk2 = pk2, pk1 + } + for _, pair := range product(pk1, pk2) { + insert.Values(append([]any{pair[0], pair[1]}, edge.Target.FieldValues()...)...) + if edge.Bidi { + insert.Values(append([]any{pair[1], pair[0]}, edge.Target.FieldValues()...)...) + } + } + } + } + for _, table := range insertKeys(tables) { + query, args := tables[table].Query() + if err := g.tx.Exec(ctx, query, args, nil); err != nil { + return fmt.Errorf("add m2m edge for table %s: %w", table, err) + } + } + return nil +} + +func (g *graph) clearFKEdges(ctx context.Context, ids []driver.Value, edges []*EdgeSpec) error { + for _, edge := range edges { + if edge.Rel == O2O && edge.Inverse { + continue + } + // O2O relations can be cleared without + // passing the target ids. + pred := matchID(edge.Columns[0], ids) + if nodes := edge.Target.Nodes; len(nodes) > 0 { + pred = matchIDs(edge.Target.IDSpec.Column, edge.Target.Nodes, edge.Columns[0], ids) + } + query, args := g.builder.Update(edge.Table). + Schema(edge.Schema). + SetNull(edge.Columns[0]). + Where(pred). + Query() + if err := g.tx.Exec(ctx, query, args, nil); err != nil { + return fmt.Errorf("add %s edge for table %s: %w", edge.Rel, edge.Table, err) + } + } + return nil +} + +func (g *graph) addFKEdges(ctx context.Context, ids []driver.Value, edges []*EdgeSpec) error { + id := ids[0] + if len(ids) > 1 && len(edges) != 0 { + // O2M and non-inverse O2O edges are defined by a FK in the "other" + // table. Therefore, ids[i+1] will override ids[i] which is invalid. + return fmt.Errorf("unable to link FK edge to more than 1 node: %v", ids) + } + for _, edge := range edges { + if edge.Rel == O2O && edge.Inverse { + continue + } + p := sql.EQ(edge.Target.IDSpec.Column, edge.Target.Nodes[0]) + // Use "IN" predicate instead of list of "OR" + // in case of more than on nodes to connect. + if len(edge.Target.Nodes) > 1 { + p = sql.InValues(edge.Target.IDSpec.Column, edge.Target.Nodes...) + } + query, args := g.builder.Update(edge.Table). + Schema(edge.Schema). + Set(edge.Columns[0], id). + Where(sql.And(p, sql.IsNull(edge.Columns[0]))). + Query() + var res sql.Result + if err := g.tx.Exec(ctx, query, args, &res); err != nil { + return fmt.Errorf("add %s edge for table %s: %w", edge.Rel, edge.Table, err) + } + affected, err := res.RowsAffected() + if err != nil { + return err + } + // Setting the FK value of the "other" table without clearing it before, is not allowed. + // Including no-op (same id), because we rely on "affected" to determine if the FK set. + if ids := edge.Target.Nodes; int(affected) < len(ids) { + return &ConstraintError{msg: fmt.Sprintf("one of %v is already connected to a different %s", ids, edge.Columns[0])} + } + } + return nil +} + +func hasExternalEdges(addEdges, clearEdges map[Rel][]*EdgeSpec) bool { + // M2M edges reside in a join-table, and O2M edges reside + // in the M2O table (the entity that holds the FK). + if len(clearEdges[M2M]) > 0 || len(addEdges[M2M]) > 0 || + len(clearEdges[O2M]) > 0 || len(addEdges[O2M]) > 0 { + return true + } + for _, edges := range [][]*EdgeSpec{clearEdges[O2O], addEdges[O2O]} { + for _, e := range edges { + if !e.Inverse { + return true + } + } + } + return false +} + +// isExternalEdge reports if the given edge requires an UPDATE +// or an INSERT to other table. +func isExternalEdge(e *EdgeSpec) bool { + return e.Rel == M2M || e.Rel == O2M || e.Rel == O2O && !e.Inverse +} + +// setTableColumns is shared between updater and creator. +func setTableColumns(fields []*FieldSpec, edges map[Rel][]*EdgeSpec, set func(string, driver.Value)) (err error) { + for _, fi := range fields { + value := fi.Value + if fi.Type == field.TypeJSON { + buf, err := json.Marshal(value) + if err != nil { + return fmt.Errorf("marshal value for column %s: %w", fi.Column, err) + } + // If the underlying driver does not support JSON types, + // driver.DefaultParameterConverter will convert it to uint8. + value = json.RawMessage(buf) + } + set(fi.Column, value) + } + for _, e := range edges[M2O] { + set(e.Columns[0], e.Target.Nodes[0]) + } + for _, e := range edges[O2O] { + if e.Inverse || e.Bidi { + set(e.Columns[0], e.Target.Nodes[0]) + } + } + return nil +} + +// insertLastID invokes the insert query on the transaction and returns the LastInsertID. +func (c *creator) insertLastID(ctx context.Context, insert *sql.InsertBuilder) error { + query, args, err := insert.QueryErr() + if err != nil { + return err + } + // MySQL does not support the "RETURNING" clause. + if insert.Dialect() != dialect.MySQL { + rows := &sql.Rows{} + if err := c.tx.Query(ctx, query, args, rows); err != nil { + return err + } + defer rows.Close() + switch _, ok := c.ID.Value.(field.ValueScanner); { + case ok: + // If the ID implements the sql.Scanner + // interface it should be a pointer type. + return sql.ScanOne(rows, c.ID.Value) + case c.ID.Type.Numeric(): + // Normalize the type to int64 to make it + // looks like LastInsertId. + id, err := sql.ScanInt64(rows) + if err != nil { + return err + } + c.ID.Value = id + return nil + default: + return sql.ScanOne(rows, &c.ID.Value) + } + } + // MySQL. + var res sql.Result + if err := c.tx.Exec(ctx, query, args, &res); err != nil { + return err + } + // If the ID field is not numeric (e.g. string), + // there is no way to scan the LAST_INSERT_ID. + if c.ID.Type.Numeric() { + id, err := res.LastInsertId() + if err != nil { + return err + } + c.ID.Value = id + } + return nil +} + +// insertLastIDs invokes the batch insert query on the transaction and returns the LastInsertID of all entities. +func (c *batchCreator) insertLastIDs(ctx context.Context, tx dialect.ExecQuerier, insert *sql.InsertBuilder) error { + query, args, err := insert.QueryErr() + if err != nil { + return err + } + // MySQL does not support the "RETURNING" clause. + if insert.Dialect() != dialect.MySQL { + rows := &sql.Rows{} + if err := tx.Query(ctx, query, args, rows); err != nil { + return err + } + defer rows.Close() + for i := 0; rows.Next(); i++ { + node := c.Nodes[i] + switch _, ok := node.ID.Value.(field.ValueScanner); { + case ok: + // If the ID implements the sql.Scanner + // interface it should be a pointer type. + if err := rows.Scan(node.ID.Value); err != nil { + return err + } + case node.ID.Type.Numeric(): + // Normalize the type to int64 to make it looks + // like LastInsertId. + var id int64 + if err := rows.Scan(&id); err != nil { + return err + } + node.ID.Value = id + default: + if err := rows.Scan(&node.ID.Value); err != nil { + return err + } + } + } + return rows.Err() + } + // MySQL. + var res sql.Result + if err := tx.Exec(ctx, query, args, &res); err != nil { + return err + } + // If the ID field is not numeric (e.g. string), + // there is no way to scan the LAST_INSERT_ID. + if len(c.Nodes) > 0 && c.Nodes[0].ID.Type.Numeric() { + id, err := res.LastInsertId() + if err != nil { + return err + } + affected, err := res.RowsAffected() + if err != nil { + return err + } + // Assume the ID field is AUTO_INCREMENT + // if its type is numeric. + for i := 0; int64(i) < affected && i < len(c.Nodes); i++ { + c.Nodes[i].ID.Value = id + int64(i) + } + } + return nil +} + +// rollback calls to tx.Rollback and wraps the given error with the rollback error if occurred. +func rollback(tx dialect.Tx, err error) error { + if rerr := tx.Rollback(); rerr != nil { + err = fmt.Errorf("%w: %v", err, rerr) + } + return err +} + +func edgeKeys(m map[string][]*EdgeSpec) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +func insertKeys(m map[string]*sql.InsertBuilder) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +func keys(m map[string]struct{}) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +func matchID(column string, pk []driver.Value) *sql.Predicate { + if len(pk) > 1 { + return sql.InValues(column, pk...) + } + return sql.EQ(column, pk[0]) +} + +func matchIDs(column1 string, pk1 []driver.Value, column2 string, pk2 []driver.Value) *sql.Predicate { + p := matchID(column1, pk1) + if len(pk2) > 1 { + // Use "IN" predicate instead of list of "OR" + // in case of more than on nodes to connect. + return sql.And(p, sql.InValues(column2, pk2...)) + } + return sql.And(p, sql.EQ(column2, pk2[0])) +} + +// cartesian product of 2 id sets. +func product(a, b []driver.Value) [][2]driver.Value { + c := make([][2]driver.Value, 0, len(a)*len(b)) + for i := range a { + for j := range b { + c = append(c, [2]driver.Value{a[i], b[j]}) + } + } + return c +} diff --git a/dialect/sql/sqlgraph/graph_test.go b/dialect/sql/sqlgraph/graph_test.go new file mode 100644 index 0000000..ac1b085 --- /dev/null +++ b/dialect/sql/sqlgraph/graph_test.go @@ -0,0 +1,2755 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package sqlgraph + +import ( + "context" + "database/sql/driver" + "errors" + "fmt" + "regexp" + "strings" + "testing" + + "entgo.io/ent/dialect" + "entgo.io/ent/dialect/sql" + "entgo.io/ent/schema/field" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/stretchr/testify/require" +) + +func TestNeighbors(t *testing.T) { + tests := []struct { + name string + input *Step + wantQuery string + wantArgs []any + }{ + { + name: "O2O/1type", + // Since the relation is on the same sql.Table, + // V used as a reference value. + input: NewStep( + From("users", "id", 1), + To("users", "id"), + Edge(O2O, false, "users", "spouse_id"), + ), + wantQuery: "SELECT * FROM `users` WHERE `spouse_id` = ?", + wantArgs: []any{1}, + }, + { + name: "O2O/1type/inverse", + input: NewStep( + From("nodes", "id", 1), + To("nodes", "id"), + Edge(O2O, true, "nodes", "prev_id"), + ), + wantQuery: "SELECT * FROM `nodes` JOIN (SELECT `prev_id` FROM `nodes` WHERE `id` = ?) AS `t1` ON `nodes`.`id` = `t1`.`prev_id`", + wantArgs: []any{1}, + }, + { + name: "O2M/1type", + input: NewStep( + From("users", "id", 1), + To("users", "id"), + Edge(O2M, false, "users", "parent_id"), + ), + wantQuery: "SELECT * FROM `users` WHERE `parent_id` = ?", + wantArgs: []any{1}, + }, + { + name: "O2O/2types", + input: NewStep( + From("users", "id", 2), + To("card", "id"), + Edge(O2O, false, "cards", "owner_id"), + ), + wantQuery: "SELECT * FROM `card` WHERE `owner_id` = ?", + wantArgs: []any{2}, + }, + { + name: "O2O/2types/inverse", + input: NewStep( + From("cards", "id", 2), + To("users", "id"), + Edge(O2O, true, "cards", "owner_id"), + ), + wantQuery: "SELECT * FROM `users` JOIN (SELECT `owner_id` FROM `cards` WHERE `id` = ?) AS `t1` ON `users`.`id` = `t1`.`owner_id`", + wantArgs: []any{2}, + }, + { + name: "O2M/2types", + input: NewStep( + From("users", "id", 1), + To("pets", "id"), + Edge(O2M, false, "pets", "owner_id"), + ), + wantQuery: "SELECT * FROM `pets` WHERE `owner_id` = ?", + wantArgs: []any{1}, + }, + { + name: "M2O/2types/inverse", + input: NewStep( + From("pets", "id", 2), + To("users", "id"), + Edge(M2O, true, "pets", "owner_id"), + ), + wantQuery: "SELECT * FROM `users` JOIN (SELECT `owner_id` FROM `pets` WHERE `id` = ?) AS `t1` ON `users`.`id` = `t1`.`owner_id`", + wantArgs: []any{2}, + }, + { + name: "M2O/1type/inverse", + input: NewStep( + From("users", "id", 2), + To("users", "id"), + Edge(M2O, true, "users", "parent_id"), + ), + wantQuery: "SELECT * FROM `users` JOIN (SELECT `parent_id` FROM `users` WHERE `id` = ?) AS `t1` ON `users`.`id` = `t1`.`parent_id`", + wantArgs: []any{2}, + }, + { + name: "M2M/2type", + input: NewStep( + From("groups", "id", 2), + To("users", "id"), + Edge(M2M, false, "user_groups", "group_id", "user_id"), + ), + wantQuery: "SELECT * FROM `users` JOIN (SELECT `user_groups`.`user_id` FROM `user_groups` WHERE `user_groups`.`group_id` = ?) AS `t1` ON `users`.`id` = `t1`.`user_id`", + wantArgs: []any{2}, + }, + { + name: "M2M/2type/inverse", + input: NewStep( + From("users", "id", 2), + To("groups", "id"), + Edge(M2M, true, "user_groups", "group_id", "user_id"), + ), + wantQuery: "SELECT * FROM `groups` JOIN (SELECT `user_groups`.`group_id` FROM `user_groups` WHERE `user_groups`.`user_id` = ?) AS `t1` ON `groups`.`id` = `t1`.`group_id`", + wantArgs: []any{2}, + }, + { + name: "schema/O2O/1type", + // Since the relation is on the same sql.Table, + // V used as a reference value. + input: func() *Step { + step := NewStep( + From("users", "id", 1), + To("users", "id"), + Edge(O2O, false, "users", "spouse_id"), + ) + step.To.Schema = "mydb" + return step + }(), + wantQuery: "SELECT * FROM `mydb`.`users` WHERE `spouse_id` = ?", + wantArgs: []any{1}, + }, + { + name: "schema/O2O/1type/inverse", + input: func() *Step { + step := NewStep( + From("nodes", "id", 1), + To("nodes", "id"), + Edge(O2O, true, "nodes", "prev_id"), + ) + step.To.Schema = "mydb" + step.Edge.Schema = "mydb" + return step + }(), + wantQuery: "SELECT * FROM `mydb`.`nodes` JOIN (SELECT `prev_id` FROM `mydb`.`nodes` WHERE `id` = ?) AS `t1` ON `mydb`.`nodes`.`id` = `t1`.`prev_id`", + wantArgs: []any{1}, + }, + { + name: "schema/O2M/1type", + input: func() *Step { + step := NewStep( + From("users", "id", 1), + To("users", "id"), + Edge(O2M, false, "users", "parent_id"), + ) + step.To.Schema = "mydb" + return step + }(), + wantQuery: "SELECT * FROM `mydb`.`users` WHERE `parent_id` = ?", + wantArgs: []any{1}, + }, + { + name: "schema/O2O/2types", + input: func() *Step { + step := NewStep( + From("users", "id", 2), + To("card", "id"), + Edge(O2O, false, "cards", "owner_id"), + ) + step.To.Schema = "mydb" + return step + }(), + wantQuery: "SELECT * FROM `mydb`.`card` WHERE `owner_id` = ?", + wantArgs: []any{2}, + }, + { + name: "schema/O2O/2types/inverse", + input: func() *Step { + step := NewStep( + From("cards", "id", 2), + To("users", "id"), + Edge(O2O, true, "cards", "owner_id"), + ) + step.To.Schema = "mydb" + step.Edge.Schema = "mydb" + return step + }(), + wantQuery: "SELECT * FROM `mydb`.`users` JOIN (SELECT `owner_id` FROM `mydb`.`cards` WHERE `id` = ?) AS `t1` ON `mydb`.`users`.`id` = `t1`.`owner_id`", + wantArgs: []any{2}, + }, + { + name: "schema/O2M/2types", + input: func() *Step { + step := NewStep( + From("users", "id", 1), + To("pets", "id"), + Edge(O2M, false, "pets", "owner_id"), + ) + step.To.Schema = "mydb" + return step + }(), + wantQuery: "SELECT * FROM `mydb`.`pets` WHERE `owner_id` = ?", + wantArgs: []any{1}, + }, + { + name: "schema/M2O/2types/inverse", + input: func() *Step { + step := NewStep( + From("pets", "id", 2), + To("users", "id"), + Edge(M2O, true, "pets", "owner_id"), + ) + step.To.Schema = "s1" + step.Edge.Schema = "s2" + return step + }(), + wantQuery: "SELECT * FROM `s1`.`users` JOIN (SELECT `owner_id` FROM `s2`.`pets` WHERE `id` = ?) AS `t1` ON `s1`.`users`.`id` = `t1`.`owner_id`", + wantArgs: []any{2}, + }, + { + name: "schema/M2O/1type/inverse", + input: func() *Step { + step := NewStep( + From("users", "id", 2), + To("users", "id"), + Edge(M2O, true, "users", "parent_id"), + ) + step.To.Schema = "s1" + step.Edge.Schema = "s1" + return step + }(), + wantQuery: "SELECT * FROM `s1`.`users` JOIN (SELECT `parent_id` FROM `s1`.`users` WHERE `id` = ?) AS `t1` ON `s1`.`users`.`id` = `t1`.`parent_id`", + wantArgs: []any{2}, + }, + { + name: "schema/M2M/2type", + input: func() *Step { + step := NewStep( + From("groups", "id", 2), + To("users", "id"), + Edge(M2M, false, "user_groups", "group_id", "user_id"), + ) + step.To.Schema = "s1" + step.Edge.Schema = "s2" + return step + }(), + wantQuery: "SELECT * FROM `s1`.`users` JOIN (SELECT `s2`.`user_groups`.`user_id` FROM `s2`.`user_groups` WHERE `s2`.`user_groups`.`group_id` = ?) AS `t1` ON `s1`.`users`.`id` = `t1`.`user_id`", + wantArgs: []any{2}, + }, + { + name: "schema/M2M/2type/inverse", + input: func() *Step { + step := NewStep( + From("users", "id", 2), + To("groups", "id"), + Edge(M2M, true, "user_groups", "group_id", "user_id"), + ) + step.To.Schema = "s1" + step.Edge.Schema = "s2" + return step + }(), + wantQuery: "SELECT * FROM `s1`.`groups` JOIN (SELECT `s2`.`user_groups`.`group_id` FROM `s2`.`user_groups` WHERE `s2`.`user_groups`.`user_id` = ?) AS `t1` ON `s1`.`groups`.`id` = `t1`.`group_id`", + wantArgs: []any{2}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + selector := Neighbors("", tt.input) + query, args := selector.Query() + require.Equal(t, tt.wantQuery, query) + require.Equal(t, tt.wantArgs, args) + }) + } +} + +func TestSetNeighbors(t *testing.T) { + tests := []struct { + name string + input *Step + wantQuery string + wantArgs []any + }{ + { + name: "O2M/2types", + input: NewStep( + From("users", "id", sql.Select().From(sql.Table("users")).Where(sql.EQ("name", "a8m"))), + To("pets", "id"), + Edge(O2M, false, "users", "owner_id"), + ), + wantQuery: `SELECT * FROM "pets" JOIN (SELECT "users"."id" FROM "users" WHERE "name" = $1) AS "t1" ON "pets"."owner_id" = "t1"."id"`, + wantArgs: []any{"a8m"}, + }, + { + name: "M2O/2types", + input: NewStep( + From("pets", "id", sql.Select().From(sql.Table("pets")).Where(sql.EQ("name", "pedro"))), + To("users", "id"), + Edge(M2O, true, "pets", "owner_id"), + ), + wantQuery: `SELECT * FROM "users" JOIN (SELECT "pets"."owner_id" FROM "pets" WHERE "name" = $1) AS "t1" ON "users"."id" = "t1"."owner_id"`, + wantArgs: []any{"pedro"}, + }, + { + name: "M2M/2types", + input: NewStep( + From("users", "id", sql.Select().From(sql.Table("users")).Where(sql.EQ("name", "a8m"))), + To("groups", "id"), + Edge(M2M, false, "user_groups", "user_id", "group_id"), + ), + wantQuery: ` +SELECT * +FROM "groups" +JOIN + (SELECT "user_groups"."group_id" + FROM "user_groups" + JOIN + (SELECT "users"."id" + FROM "users" + WHERE "name" = $1) AS "t1" ON "user_groups"."user_id" = "t1"."id") AS "t1" ON "groups"."id" = "t1"."group_id"`, + wantArgs: []any{"a8m"}, + }, + { + name: "M2M/2types/inverse", + input: NewStep( + From("groups", "id", sql.Select().From(sql.Table("groups")).Where(sql.EQ("name", "GitHub"))), + To("users", "id"), + Edge(M2M, true, "user_groups", "user_id", "group_id"), + ), + wantQuery: ` +SELECT * +FROM "users" +JOIN + (SELECT "user_groups"."user_id" + FROM "user_groups" + JOIN + (SELECT "groups"."id" + FROM "groups" + WHERE "name" = $1) AS "t1" ON "user_groups"."group_id" = "t1"."id") AS "t1" ON "users"."id" = "t1"."user_id"`, + wantArgs: []any{"GitHub"}, + }, + { + name: "schema/O2M/2types", + input: func() *Step { + step := NewStep( + From("users", "id", sql.Select().From(sql.Table("users").Schema("s2")).Where(sql.EQ("name", "a8m"))), + To("pets", "id"), + Edge(O2M, false, "users", "owner_id"), + ) + step.To.Schema = "s1" + return step + }(), + wantQuery: `SELECT * FROM "s1"."pets" JOIN (SELECT "s2"."users"."id" FROM "s2"."users" WHERE "name" = $1) AS "t1" ON "s1"."pets"."owner_id" = "t1"."id"`, + wantArgs: []any{"a8m"}, + }, + { + name: "schema/M2O/2types", + input: func() *Step { + step := NewStep( + From("pets", "id", sql.Select().From(sql.Table("pets").Schema("s2")).Where(sql.EQ("name", "pedro"))), + To("users", "id"), + Edge(M2O, true, "pets", "owner_id"), + ) + step.To.Schema = "s1" + return step + }(), + wantQuery: `SELECT * FROM "s1"."users" JOIN (SELECT "s2"."pets"."owner_id" FROM "s2"."pets" WHERE "name" = $1) AS "t1" ON "s1"."users"."id" = "t1"."owner_id"`, + wantArgs: []any{"pedro"}, + }, + { + name: "schema/M2M/2types", + input: func() *Step { + step := NewStep( + From("users", "id", sql.Select().From(sql.Table("users").Schema("s2")).Where(sql.EQ("name", "a8m"))), + To("groups", "id"), + Edge(M2M, false, "user_groups", "user_id", "group_id"), + ) + step.To.Schema = "s1" + step.Edge.Schema = "s3" + return step + }(), + wantQuery: ` +SELECT * +FROM "s1"."groups" +JOIN + (SELECT "s3"."user_groups"."group_id" + FROM "s3"."user_groups" + JOIN + (SELECT "s2"."users"."id" + FROM "s2"."users" + WHERE "name" = $1) AS "t1" ON "s3"."user_groups"."user_id" = "t1"."id") AS "t1" ON "s1"."groups"."id" = "t1"."group_id"`, + wantArgs: []any{"a8m"}, + }, + { + name: "schema/M2M/2types/inverse", + input: func() *Step { + step := NewStep( + From("groups", "id", sql.Select().From(sql.Table("groups").Schema("s2")).Where(sql.EQ("name", "GitHub"))), + To("users", "id"), + Edge(M2M, true, "user_groups", "user_id", "group_id"), + ) + step.To.Schema = "s1" + step.Edge.Schema = "s3" + return step + }(), + wantQuery: ` +SELECT * +FROM "s1"."users" +JOIN + (SELECT "s3"."user_groups"."user_id" + FROM "s3"."user_groups" + JOIN + (SELECT "s2"."groups"."id" + FROM "s2"."groups" + WHERE "name" = $1) AS "t1" ON "s3"."user_groups"."group_id" = "t1"."id") AS "t1" ON "s1"."users"."id" = "t1"."user_id"`, + wantArgs: []any{"GitHub"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + selector := SetNeighbors("postgres", tt.input) + query, args := selector.Query() + tt.wantQuery = strings.Join(strings.Fields(tt.wantQuery), " ") + require.Equal(t, tt.wantQuery, query) + require.Equal(t, tt.wantArgs, args) + }) + } +} + +func TestHasNeighbors(t *testing.T) { + tests := []struct { + name string + step *Step + selector *sql.Selector + wantQuery string + }{ + { + name: "O2O/1type", + // A nodes sql.Table; linked-list (next->prev). The "prev" + // node holds association pointer. The neighbors query + // here checks if a node "has-next". + step: NewStep( + From("nodes", "id"), + To("nodes", "id"), + Edge(O2O, false, "nodes", "prev_id"), + ), + selector: sql.Select("*").From(sql.Table("nodes")), + wantQuery: "SELECT * FROM `nodes` WHERE EXISTS (SELECT `nodes_edge`.`prev_id` FROM `nodes` AS `nodes_edge` WHERE `nodes`.`id` = `nodes_edge`.`prev_id`)", + }, + { + name: "O2O/1type/inverse", + // Same example as above, but the neighbors + // query checks if a node "has-previous". + step: NewStep( + From("nodes", "id"), + To("nodes", "id"), + Edge(O2O, true, "nodes", "prev_id"), + ), + selector: sql.Select("*").From(sql.Table("nodes")), + wantQuery: "SELECT * FROM `nodes` WHERE `nodes`.`prev_id` IS NOT NULL", + }, + { + name: "O2M/2type2", + step: NewStep( + From("users", "id"), + To("pets", "id"), + Edge(O2M, false, "pets", "owner_id"), + ), + selector: sql.Select("*").From(sql.Table("users")), + wantQuery: "SELECT * FROM `users` WHERE EXISTS (SELECT `pets`.`owner_id` FROM `pets` WHERE `users`.`id` = `pets`.`owner_id`)", + }, + { + name: "M2O/2type2", + step: NewStep( + From("pets", "id"), + To("users", "id"), + Edge(M2O, true, "pets", "owner_id"), + ), + selector: sql.Select("*").From(sql.Table("pets")), + wantQuery: "SELECT * FROM `pets` WHERE `pets`.`owner_id` IS NOT NULL", + }, + { + name: "M2M/2types", + step: NewStep( + From("users", "id"), + To("groups", "id"), + Edge(M2M, false, "user_groups", "user_id", "group_id"), + ), + selector: sql.Select("*").From(sql.Table("users")), + wantQuery: "SELECT * FROM `users` WHERE `users`.`id` IN (SELECT `user_groups`.`user_id` FROM `user_groups`)", + }, + { + name: "M2M/2types/inverse", + step: NewStep( + From("users", "id"), + To("groups", "id"), + Edge(M2M, true, "group_users", "group_id", "user_id"), + ), + selector: sql.Select("*").From(sql.Table("users")), + wantQuery: "SELECT * FROM `users` WHERE `users`.`id` IN (SELECT `group_users`.`user_id` FROM `group_users`)", + }, + { + name: "schema/O2O/1type", + step: func() *Step { + step := NewStep( + From("nodes", "id"), + To("nodes", "id"), + Edge(O2O, false, "nodes", "prev_id"), + ) + step.Edge.Schema = "s1" + return step + }(), + selector: sql.Select("*").From(sql.Table("nodes").Schema("s1")), + wantQuery: "SELECT * FROM `s1`.`nodes` WHERE EXISTS (SELECT `nodes_edge`.`prev_id` FROM `s1`.`nodes` AS `nodes_edge` WHERE `s1`.`nodes`.`id` = `nodes_edge`.`prev_id`)", + }, + { + name: "schema/O2O/1type/inverse", + // Same example as above, but the neighbors + // query checks if a node "has-previous". + step: NewStep( + From("nodes", "id"), + To("nodes", "id"), + Edge(O2O, true, "nodes", "prev_id"), + ), + selector: sql.Select("*").From(sql.Table("nodes").Schema("s1")), + wantQuery: "SELECT * FROM `s1`.`nodes` WHERE `s1`.`nodes`.`prev_id` IS NOT NULL", + }, + { + name: "schema/O2M/2type2", + step: func() *Step { + step := NewStep( + From("users", "id"), + To("pets", "id"), + Edge(O2M, false, "pets", "owner_id"), + ) + step.Edge.Schema = "s2" + return step + }(), + selector: sql.Select("*").From(sql.Table("users").Schema("s1")), + wantQuery: "SELECT * FROM `s1`.`users` WHERE EXISTS (SELECT `s2`.`pets`.`owner_id` FROM `s2`.`pets` WHERE `s1`.`users`.`id` = `s2`.`pets`.`owner_id`)", + }, + { + name: "schema/M2O/2type2", + step: NewStep( + From("pets", "id"), + To("users", "id"), + Edge(M2O, true, "pets", "owner_id"), + ), + selector: sql.Select("*").From(sql.Table("pets").Schema("s1")), + wantQuery: "SELECT * FROM `s1`.`pets` WHERE `s1`.`pets`.`owner_id` IS NOT NULL", + }, + { + name: "schema/M2M/2types", + step: func() *Step { + step := NewStep( + From("users", "id"), + To("groups", "id"), + Edge(M2M, false, "user_groups", "user_id", "group_id"), + ) + step.Edge.Schema = "s2" + return step + }(), + selector: sql.Select("*").From(sql.Table("users").Schema("s1")), + wantQuery: "SELECT * FROM `s1`.`users` WHERE `s1`.`users`.`id` IN (SELECT `s2`.`user_groups`.`user_id` FROM `s2`.`user_groups`)", + }, + { + name: "schema/M2M/2types/inverse", + step: func() *Step { + step := NewStep( + From("users", "id"), + To("groups", "id"), + Edge(M2M, true, "group_users", "group_id", "user_id"), + ) + step.Edge.Schema = "s2" + return step + }(), + selector: sql.Select("*").From(sql.Table("users").Schema("s1")), + wantQuery: "SELECT * FROM `s1`.`users` WHERE `s1`.`users`.`id` IN (SELECT `s2`.`group_users`.`user_id` FROM `s2`.`group_users`)", + }, + { + name: "O2M/2type2/selector", + step: NewStep( + From("users", "id"), + To("pets", "id"), + Edge(O2M, false, "pets", "owner_id"), + ), + selector: sql.Select("*").From(sql.Select("*").From(sql.Table("users")).As("users")).As("users"), + wantQuery: "SELECT * FROM (SELECT * FROM `users`) AS `users` WHERE EXISTS (SELECT `pets`.`owner_id` FROM `pets` WHERE `users`.`id` = `pets`.`owner_id`)", + }, + { + name: "M2O/2type2/selector", + step: NewStep( + From("pets", "id"), + To("users", "id"), + Edge(M2O, true, "pets", "owner_id"), + ), + selector: sql.Select("*").From(sql.Select("*").From(sql.Table("pets")).As("pets")).As("pets"), + wantQuery: "SELECT * FROM (SELECT * FROM `pets`) AS `pets` WHERE `pets`.`owner_id` IS NOT NULL", + }, + { + name: "M2M/2types/selector", + step: NewStep( + From("users", "id"), + To("groups", "id"), + Edge(M2M, false, "user_groups", "user_id", "group_id"), + ), + selector: sql.Select("*").From(sql.Select("*").From(sql.Table("users")).As("users")).As("users"), + wantQuery: "SELECT * FROM (SELECT * FROM `users`) AS `users` WHERE `users`.`id` IN (SELECT `user_groups`.`user_id` FROM `user_groups`)", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + for _, s := range []*sql.Selector{tt.selector, tt.selector.Clone()} { + HasNeighbors(s, tt.step) + query, args := s.Query() + require.Equal(t, tt.wantQuery, query) + require.Empty(t, args) + } + }) + } +} + +func TestHasNeighborsWith(t *testing.T) { + tests := []struct { + name string + step *Step + selector *sql.Selector + predicate func(*sql.Selector) + wantQuery string + wantArgs []any + }{ + { + name: "O2O", + step: NewStep( + From("users", "id"), + To("cards", "id"), + Edge(O2O, false, "cards", "owner_id"), + ), + selector: sql.Dialect("postgres").Select("*").From(sql.Table("users")), + predicate: func(s *sql.Selector) { + s.Where(sql.EQ("expired", false)) + }, + wantQuery: `SELECT * FROM "users" WHERE EXISTS (SELECT "cards"."owner_id" FROM "cards" WHERE "users"."id" = "cards"."owner_id" AND NOT "expired")`, + }, + { + name: "O2O/inverse", + step: NewStep( + From("cards", "id"), + To("users", "id"), + Edge(O2O, true, "cards", "owner_id"), + ), + selector: sql.Dialect("postgres").Select("*").From(sql.Table("cards")), + predicate: func(s *sql.Selector) { + s.Where(sql.EQ("name", "a8m")) + }, + wantQuery: `SELECT * FROM "cards" WHERE EXISTS (SELECT "users"."id" FROM "users" WHERE "cards"."owner_id" = "users"."id" AND "name" = $1)`, + wantArgs: []any{"a8m"}, + }, + { + name: "O2M", + step: NewStep( + From("users", "id"), + To("pets", "id"), + Edge(O2M, false, "pets", "owner_id"), + ), + selector: sql.Dialect("postgres").Select("*"). + From(sql.Table("users")). + Where(sql.EQ("last_name", "mashraki")), + predicate: func(s *sql.Selector) { + s.Where(sql.EQ("name", "pedro")) + }, + wantQuery: `SELECT * FROM "users" WHERE "last_name" = $1 AND EXISTS (SELECT "pets"."owner_id" FROM "pets" WHERE "users"."id" = "pets"."owner_id" AND "name" = $2)`, + wantArgs: []any{"mashraki", "pedro"}, + }, + { + name: "M2O", + step: NewStep( + From("pets", "id"), + To("users", "id"), + Edge(M2O, true, "pets", "owner_id"), + ), + selector: sql.Dialect("postgres").Select("*"). + From(sql.Table("pets")). + Where(sql.EQ("name", "pedro")), + predicate: func(s *sql.Selector) { + s.Where(sql.EQ("last_name", "mashraki")) + }, + wantQuery: `SELECT * FROM "pets" WHERE "name" = $1 AND EXISTS (SELECT "users"."id" FROM "users" WHERE "pets"."owner_id" = "users"."id" AND "last_name" = $2)`, + wantArgs: []any{"pedro", "mashraki"}, + }, + { + name: "M2M", + step: NewStep( + From("users", "id"), + To("groups", "id"), + Edge(M2M, false, "user_groups", "user_id", "group_id"), + ), + selector: sql.Dialect("postgres").Select("*").From(sql.Table("users")), + predicate: func(s *sql.Selector) { + s.Where(sql.EQ("name", "GitHub")) + }, + wantQuery: ` +SELECT * +FROM "users" +WHERE "users"."id" IN + (SELECT "user_groups"."user_id" + FROM "user_groups" + JOIN "groups" AS "t1" ON "user_groups"."group_id" = "t1"."id" WHERE "name" = $1)`, + wantArgs: []any{"GitHub"}, + }, + { + name: "M2M/inverse", + step: NewStep( + From("groups", "id"), + To("users", "id"), + Edge(M2M, true, "user_groups", "user_id", "group_id"), + ), + selector: sql.Dialect("postgres").Select("*").From(sql.Table("groups")), + predicate: func(s *sql.Selector) { + s.Where(sql.EQ("name", "a8m")) + }, + wantQuery: ` +SELECT * +FROM "groups" +WHERE "groups"."id" IN + (SELECT "user_groups"."group_id" + FROM "user_groups" + JOIN "users" AS "t1" ON "user_groups"."user_id" = "t1"."id" WHERE "name" = $1)`, + wantArgs: []any{"a8m"}, + }, + { + name: "M2M/inverse", + step: NewStep( + From("groups", "id"), + To("users", "id"), + Edge(M2M, true, "user_groups", "user_id", "group_id"), + ), + selector: sql.Dialect("postgres").Select("*").From(sql.Table("groups")), + predicate: func(s *sql.Selector) { + s.Where(sql.And(sql.NotNull("name"), sql.EQ("name", "a8m"))) + }, + wantQuery: ` +SELECT * +FROM "groups" +WHERE "groups"."id" IN + (SELECT "user_groups"."group_id" + FROM "user_groups" + JOIN "users" AS "t1" ON "user_groups"."user_id" = "t1"."id" WHERE "name" IS NOT NULL AND "name" = $1)`, + wantArgs: []any{"a8m"}, + }, + { + name: "schema/O2O", + step: func() *Step { + step := NewStep( + From("users", "id"), + To("cards", "id"), + Edge(O2O, false, "cards", "owner_id"), + ) + step.Edge.Schema = "s2" + return step + }(), + selector: sql.Dialect("postgres").Select("*").From(sql.Table("users").Schema("s1")), + predicate: func(s *sql.Selector) { + s.Where(sql.EQ("expired", false)) + }, + wantQuery: `SELECT * FROM "s1"."users" WHERE EXISTS (SELECT "s2"."cards"."owner_id" FROM "s2"."cards" WHERE "s1"."users"."id" = "s2"."cards"."owner_id" AND NOT "expired")`, + }, + { + name: "schema/O2M", + step: func() *Step { + step := NewStep( + From("users", "id"), + To("pets", "id"), + Edge(O2M, false, "pets", "owner_id"), + ) + step.Edge.Schema = "s2" + return step + }(), + selector: sql.Dialect("postgres").Select("*"). + From(sql.Table("users").Schema("s1")). + Where(sql.EQ("last_name", "mashraki")), + predicate: func(s *sql.Selector) { + s.Where(sql.EQ("name", "pedro")) + }, + wantQuery: `SELECT * FROM "s1"."users" WHERE "last_name" = $1 AND EXISTS (SELECT "s2"."pets"."owner_id" FROM "s2"."pets" WHERE "s1"."users"."id" = "s2"."pets"."owner_id" AND "name" = $2)`, + wantArgs: []any{"mashraki", "pedro"}, + }, + { + name: "schema/M2M", + step: func() *Step { + step := NewStep( + From("users", "id"), + To("groups", "id"), + Edge(M2M, false, "user_groups", "user_id", "group_id"), + ) + step.To.Schema = "s3" + step.Edge.Schema = "s2" + return step + }(), + selector: sql.Dialect("postgres").Select("*").From(sql.Table("users").Schema("s1")), + predicate: func(s *sql.Selector) { + s.Where(sql.EQ("name", "GitHub")) + }, + wantQuery: ` +SELECT * +FROM "s1"."users" +WHERE "s1"."users"."id" IN + (SELECT "s2"."user_groups"."user_id" + FROM "s2"."user_groups" + JOIN "s3"."groups" AS "t1" ON "s2"."user_groups"."group_id" = "t1"."id" WHERE "name" = $1)`, + wantArgs: []any{"GitHub"}, + }, + { + name: "O2M/selector", + step: NewStep( + From("users", "id"), + To("pets", "id"), + Edge(O2M, false, "pets", "owner_id"), + ), + selector: sql.Dialect("postgres").Select("*"). + From(sql.Select("*").From(sql.Table("users")).As("users")). + Where(sql.EQ("last_name", "mashraki")).As("users"), + predicate: func(s *sql.Selector) { + s.Where(sql.EQ("name", "pedro")) + }, + wantQuery: `SELECT * FROM (SELECT * FROM "users") AS "users" WHERE "last_name" = $1 AND EXISTS (SELECT "pets"."owner_id" FROM "pets" WHERE "users"."id" = "pets"."owner_id" AND "name" = $2)`, + wantArgs: []any{"mashraki", "pedro"}, + }, + { + name: "M2O/selector", + step: NewStep( + From("pets", "id"), + To("users", "id"), + Edge(M2O, true, "pets", "owner_id"), + ), + selector: sql.Dialect("postgres").Select("*"). + From(sql.Select("*").From(sql.Table("pets")).As("pets")). + Where(sql.EQ("name", "pedro")).As("pets"), + predicate: func(s *sql.Selector) { + s.Where(sql.EQ("last_name", "mashraki")) + }, + wantQuery: `SELECT * FROM (SELECT * FROM "pets") AS "pets" WHERE "name" = $1 AND EXISTS (SELECT "users"."id" FROM "users" WHERE "pets"."owner_id" = "users"."id" AND "last_name" = $2)`, + wantArgs: []any{"pedro", "mashraki"}, + }, + { + name: "M2M/selector", + step: NewStep( + From("users", "id"), + To("groups", "id"), + Edge(M2M, false, "user_groups", "user_id", "group_id"), + ), + selector: sql.Dialect("postgres").Select("*").From(sql.Select("*").From(sql.Table("users")).As("users")).As("users"), + predicate: func(s *sql.Selector) { + s.Where(sql.EQ("name", "GitHub")) + }, + wantQuery: `SELECT * FROM (SELECT * FROM "users") AS "users" WHERE "users"."id" IN (SELECT "user_groups"."user_id" FROM "user_groups" JOIN "groups" AS "t1" ON "user_groups"."group_id" = "t1"."id" WHERE "name" = $1)`, + wantArgs: []any{"GitHub"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + for _, s := range []*sql.Selector{tt.selector, tt.selector.Clone()} { + HasNeighborsWith(s, tt.step, tt.predicate) + query, args := s.Query() + tt.wantQuery = strings.Join(strings.Fields(tt.wantQuery), " ") + require.Equal(t, tt.wantQuery, query) + require.Equal(t, tt.wantArgs, args) + } + }) + } +} + +func TestHasNeighborsWithContext(t *testing.T) { + type key string + ctx := context.WithValue(context.Background(), key("mykey"), "myval") + for _, rel := range [...]Rel{M2M, O2M, O2O} { + t.Run(rel.String(), func(t *testing.T) { + sel := sql.Dialect(dialect.Postgres). + Select("*"). + From(sql.Table("users")). + WithContext(ctx) + step := NewStep( + From("users", "id"), + To("groups", "id"), + Edge(rel, false, "user_groups", "user_id", "group_id"), + ) + var called bool + pred := func(s *sql.Selector) { + called = true + got := s.Context().Value(key("mykey")).(string) + require.Equal(t, "myval", got) + } + HasNeighborsWith(sel, step, pred) + require.True(t, called, "expected predicate function to be called") + }) + } +} + +func TestOrderByNeighborsCount(t *testing.T) { + build := sql.Dialect(dialect.Postgres) + t1 := build.Table("users") + s := build.Select(t1.C("name")). + From(t1) + t.Run("O2M", func(t *testing.T) { + s := s.Clone() + OrderByNeighborsCount(s, + NewStep( + From("users", "id"), + To("pets", "owner_id"), + Edge(O2M, false, "pets", "owner_id"), + ), + sql.OrderDesc(), + sql.OrderAs("count_pets"), + ) + query, args := s.Query() + require.Empty(t, args) + require.Equal(t, `SELECT "users"."name" FROM "users" LEFT JOIN (SELECT "pets"."owner_id", COUNT(*) AS "count_pets" FROM "pets" GROUP BY "pets"."owner_id") AS "t1" ON "users"."id" = "t1"."owner_id" ORDER BY "t1"."count_pets" DESC NULLS LAST`, query) + }) + t.Run("O2M/Selected", func(t *testing.T) { + s := s.Clone() + OrderByNeighborsCount(s, + NewStep( + From("users", "id"), + To("pets", "owner_id"), + Edge(O2M, false, "pets", "owner_id"), + ), + sql.OrderDesc(), + sql.OrderSelectAs("count_pets"), + ) + query, args := s.Query() + require.Empty(t, args) + require.Equal(t, `SELECT "users"."name", "t1"."count_pets" FROM "users" LEFT JOIN (SELECT "pets"."owner_id", COUNT(*) AS "count_pets" FROM "pets" GROUP BY "pets"."owner_id") AS "t1" ON "users"."id" = "t1"."owner_id" ORDER BY "t1"."count_pets" DESC NULLS LAST`, query) + }) + t.Run("M2M", func(t *testing.T) { + s := s.Clone() + OrderByNeighborsCount(s, + NewStep( + From("users", "id"), + To("groups", "id"), + Edge(M2M, false, "user_groups", "user_id", "group_id"), + ), + ) + query, args := s.Query() + require.Empty(t, args) + require.Equal(t, `SELECT "users"."name" FROM "users" LEFT JOIN (SELECT "user_groups"."user_id", COUNT(*) AS "count_groups" FROM "user_groups" GROUP BY "user_groups"."user_id") AS "t1" ON "users"."id" = "t1"."user_id" ORDER BY "t1"."count_groups" NULLS FIRST`, query) + }) + // Zero or one. + t.Run("M2O", func(t *testing.T) { + s1, s2 := s.Clone(), s.Clone() + OrderByNeighborsCount(s1, + NewStep( + From("pets", "owner_id"), + To("users", "id"), + Edge(M2O, true, "pets", "owner_id"), + ), + ) + query, args := s1.Query() + require.Empty(t, args) + require.Equal(t, `SELECT "users"."name" FROM "users" ORDER BY "owner_id" IS NULL`, query) + + OrderByNeighborsCount(s2, + NewStep( + From("pets", "owner_id"), + To("users", "id"), + Edge(M2O, true, "pets", "owner_id"), + ), + sql.OrderDesc(), + ) + query, args = s2.Query() + require.Empty(t, args) + require.Equal(t, `SELECT "users"."name" FROM "users" ORDER BY "owner_id" IS NOT NULL`, query) + }) +} + +func TestOrderByNeighborTerms(t *testing.T) { + build := sql.Dialect(dialect.Postgres) + t1 := build.Table("users") + s := build.Select(t1.C("name")). + From(t1) + t.Run("M2O", func(t *testing.T) { + s := s.Clone() + OrderByNeighborTerms(s, + NewStep( + From("users", "id"), + To("workplace", "id"), + Edge(M2O, true, "users", "workplace_id"), + ), + sql.OrderByField("name"), + ) + query, args := s.Query() + require.Empty(t, args) + require.Equal(t, `SELECT "users"."name" FROM "users" LEFT JOIN (SELECT "workplace"."id", "workplace"."name" FROM "workplace") AS "t1" ON "users"."workplace_id" = "t1"."id" ORDER BY "t1"."name" NULLS FIRST`, query) + }) + t.Run("M2O/SelectedAs", func(t *testing.T) { + s := s.Clone() + OrderByNeighborTerms(s, + NewStep( + From("users", "id"), + To("workplace", "id"), + Edge(M2O, true, "users", "workplace_id"), + ), + sql.OrderByField( + "name", + sql.OrderSelectAs("workplace_name"), + ), + ) + query, args := s.Query() + require.Empty(t, args) + require.Equal(t, `SELECT "users"."name", "t1"."workplace_name" FROM "users" LEFT JOIN (SELECT "workplace"."id", "workplace"."name" AS "workplace_name" FROM "workplace") AS "t1" ON "users"."workplace_id" = "t1"."id" ORDER BY "t1"."workplace_name" NULLS FIRST`, query) + }) + t.Run("M2O/NullsLast", func(t *testing.T) { + s := s.Clone() + OrderByNeighborTerms(s, + NewStep( + From("users", "id"), + To("workplace", "id"), + Edge(M2O, true, "users", "workplace_id"), + ), + sql.OrderByField( + "name", + sql.OrderNullsLast(), + ), + ) + query, args := s.Query() + require.Empty(t, args) + require.Equal(t, `SELECT "users"."name" FROM "users" LEFT JOIN (SELECT "workplace"."id", "workplace"."name" FROM "workplace") AS "t1" ON "users"."workplace_id" = "t1"."id" ORDER BY "t1"."name" NULLS LAST`, query) + }) + t.Run("O2M", func(t *testing.T) { + s := s.Clone() + OrderByNeighborTerms(s, + NewStep( + From("users", "id"), + To("repos", "id"), + Edge(O2M, false, "repo", "owner_id"), + ), + sql.OrderBySum( + "num_stars", + sql.OrderSelectAs("total_stars"), + ), + ) + query, args := s.Query() + require.Empty(t, args) + require.Equal(t, `SELECT "users"."name", "t1"."total_stars" FROM "users" LEFT JOIN (SELECT "repo"."owner_id", SUM("repo"."num_stars") AS "total_stars" FROM "repo" GROUP BY "repo"."owner_id") AS "t1" ON "users"."id" = "t1"."owner_id" ORDER BY "t1"."total_stars" NULLS FIRST`, query) + }) + t.Run("M2M", func(t *testing.T) { + s := s.Clone() + OrderByNeighborTerms(s, + NewStep( + From("users", "id"), + To("group", "id"), + Edge(M2M, false, "user_groups", "user_id", "group_id"), + ), + sql.OrderBySum( + "num_users", + sql.OrderSelectAs("total_users"), + ), + ) + query, args := s.Query() + require.Empty(t, args) + require.Equal(t, `SELECT "users"."name", "t1"."total_users" FROM "users" LEFT JOIN (SELECT "user_id", SUM("group"."num_users") AS "total_users" FROM "group" JOIN "user_groups" AS "t1" ON "group"."id" = "t1"."group_id" GROUP BY "user_id") AS "t1" ON "users"."id" = "t1"."user_id" ORDER BY "t1"."total_users" NULLS FIRST`, query) + }) + t.Run("M2M/NullsLast", func(t *testing.T) { + s := s.Clone() + OrderByNeighborTerms(s, + NewStep( + From("users", "id"), + To("group", "id"), + Edge(M2M, false, "user_groups", "user_id", "group_id"), + ), + sql.OrderBySum( + "num_users", + sql.OrderAs("total_users"), + sql.OrderNullsLast(), + ), + ) + query, args := s.Query() + require.Empty(t, args) + require.Equal(t, `SELECT "users"."name" FROM "users" LEFT JOIN (SELECT "user_id", SUM("group"."num_users") AS "total_users" FROM "group" JOIN "user_groups" AS "t1" ON "group"."id" = "t1"."group_id" GROUP BY "user_id") AS "t1" ON "users"."id" = "t1"."user_id" ORDER BY "t1"."total_users" NULLS LAST`, query) + }) +} + +func TestCreateNode(t *testing.T) { + tests := []struct { + name string + spec *CreateSpec + expect func(sqlmock.Sqlmock) + wantErr bool + }{ + { + name: "fields", + spec: &CreateSpec{ + Table: "users", + ID: &FieldSpec{Column: "id", Type: field.TypeInt}, + Fields: []*FieldSpec{ + {Column: "age", Type: field.TypeInt, Value: 30}, + {Column: "name", Type: field.TypeString, Value: "a8m"}, + }, + }, + expect: func(m sqlmock.Sqlmock) { + m.ExpectExec(escape("INSERT INTO `users` (`age`, `name`) VALUES (?, ?)")). + WithArgs(30, "a8m"). + WillReturnResult(sqlmock.NewResult(1, 1)) + }, + }, + { + name: "modifiers", + spec: &CreateSpec{ + Table: "users", + ID: &FieldSpec{Column: "id", Type: field.TypeInt}, + Fields: []*FieldSpec{ + {Column: "age", Type: field.TypeInt, Value: 30}, + {Column: "name", Type: field.TypeString, Value: "a8m"}, + }, + OnConflict: []sql.ConflictOption{ + sql.ResolveWithNewValues(), + }, + }, + expect: func(m sqlmock.Sqlmock) { + m.ExpectExec(escape("INSERT INTO `users` (`age`, `name`) VALUES (?, ?) ON DUPLICATE KEY UPDATE `age` = VALUES(`age`), `name` = VALUES(`name`), `id` = LAST_INSERT_ID(`users`.`id`)")). + WithArgs(30, "a8m"). + WillReturnResult(sqlmock.NewResult(1, 1)) + }, + }, + { + name: "fields/user-defined-id", + spec: &CreateSpec{ + Table: "users", + ID: &FieldSpec{Column: "id", Value: 1}, + Fields: []*FieldSpec{ + {Column: "age", Type: field.TypeInt, Value: 30}, + {Column: "name", Type: field.TypeString, Value: "a8m"}, + }, + }, + expect: func(m sqlmock.Sqlmock) { + m.ExpectExec(escape("INSERT INTO `users` (`age`, `name`, `id`) VALUES (?, ?, ?)")). + WithArgs(30, "a8m", 1). + WillReturnResult(sqlmock.NewResult(1, 1)) + }, + }, + { + name: "fields/json", + spec: &CreateSpec{ + Table: "users", + ID: &FieldSpec{Column: "id", Type: field.TypeInt}, + Fields: []*FieldSpec{ + {Column: "json", Type: field.TypeJSON, Value: struct{}{}}, + }, + }, + expect: func(m sqlmock.Sqlmock) { + m.ExpectExec(escape("INSERT INTO `users` (`json`) VALUES (?)")). + WithArgs([]byte("{}")). + WillReturnResult(sqlmock.NewResult(1, 1)) + }, + }, + { + name: "edges/m2o", + spec: &CreateSpec{ + Table: "pets", + ID: &FieldSpec{Column: "id", Type: field.TypeInt}, + Fields: []*FieldSpec{ + {Column: "name", Type: field.TypeString, Value: "pedro"}, + }, + Edges: []*EdgeSpec{ + {Rel: M2O, Columns: []string{"owner_id"}, Inverse: true, Target: &EdgeTarget{Nodes: []driver.Value{2}}}, + }, + }, + expect: func(m sqlmock.Sqlmock) { + m.ExpectExec(escape("INSERT INTO `pets` (`name`, `owner_id`) VALUES (?, ?)")). + WithArgs("pedro", 2). + WillReturnResult(sqlmock.NewResult(1, 1)) + }, + }, + { + name: "edges/o2o/inverse", + spec: &CreateSpec{ + Table: "cards", + ID: &FieldSpec{Column: "id", Type: field.TypeInt}, + Fields: []*FieldSpec{ + {Column: "number", Type: field.TypeString, Value: "0001"}, + }, + Edges: []*EdgeSpec{ + {Rel: O2O, Columns: []string{"owner_id"}, Inverse: true, Target: &EdgeTarget{Nodes: []driver.Value{2}}}, + }, + }, + expect: func(m sqlmock.Sqlmock) { + m.ExpectExec(escape("INSERT INTO `cards` (`number`, `owner_id`) VALUES (?, ?)")). + WithArgs("0001", 2). + WillReturnResult(sqlmock.NewResult(1, 1)) + }, + }, + { + name: "edges/o2m", + spec: &CreateSpec{ + Table: "users", + ID: &FieldSpec{Column: "id", Type: field.TypeInt}, + Fields: []*FieldSpec{ + {Column: "name", Type: field.TypeString, Value: "a8m"}, + }, + Edges: []*EdgeSpec{ + {Rel: O2M, Table: "pets", Columns: []string{"owner_id"}, Target: &EdgeTarget{Nodes: []driver.Value{2}, IDSpec: &FieldSpec{Column: "id"}}}, + }, + }, + expect: func(m sqlmock.Sqlmock) { + m.ExpectBegin() + m.ExpectExec(escape("INSERT INTO `users` (`name`) VALUES (?)")). + WithArgs("a8m"). + WillReturnResult(sqlmock.NewResult(1, 1)) + m.ExpectExec(escape("UPDATE `pets` SET `owner_id` = ? WHERE `id` = ? AND `owner_id` IS NULL")). + WithArgs(1, 2). + WillReturnResult(sqlmock.NewResult(1, 1)) + m.ExpectCommit() + }, + }, + { + name: "edges/o2m", + spec: &CreateSpec{ + Table: "users", + ID: &FieldSpec{Column: "id", Type: field.TypeInt}, + Fields: []*FieldSpec{ + {Column: "name", Type: field.TypeString, Value: "a8m"}, + }, + Edges: []*EdgeSpec{ + {Rel: O2M, Table: "pets", Columns: []string{"owner_id"}, Target: &EdgeTarget{Nodes: []driver.Value{2, 3, 4}, IDSpec: &FieldSpec{Column: "id"}}}, + }, + }, + expect: func(m sqlmock.Sqlmock) { + m.ExpectBegin() + m.ExpectExec(escape("INSERT INTO `users` (`name`) VALUES (?)")). + WithArgs("a8m"). + WillReturnResult(sqlmock.NewResult(1, 1)) + m.ExpectExec(escape("UPDATE `pets` SET `owner_id` = ? WHERE `id` IN (?, ?, ?) AND `owner_id` IS NULL")). + WithArgs(1, 2, 3, 4). + WillReturnResult(sqlmock.NewResult(1, 3)) + m.ExpectCommit() + }, + }, + { + name: "edges/o2o", + spec: &CreateSpec{ + Table: "users", + ID: &FieldSpec{Column: "id", Type: field.TypeInt}, + Fields: []*FieldSpec{ + {Column: "name", Type: field.TypeString, Value: "a8m"}, + }, + Edges: []*EdgeSpec{ + {Rel: O2O, Table: "cards", Columns: []string{"owner_id"}, Target: &EdgeTarget{Nodes: []driver.Value{2}, IDSpec: &FieldSpec{Column: "id"}}}, + }, + }, + expect: func(m sqlmock.Sqlmock) { + m.ExpectBegin() + m.ExpectExec(escape("INSERT INTO `users` (`name`) VALUES (?)")). + WithArgs("a8m"). + WillReturnResult(sqlmock.NewResult(1, 1)) + m.ExpectExec(escape("UPDATE `cards` SET `owner_id` = ? WHERE `id` = ? AND `owner_id` IS NULL")). + WithArgs(1, 2). + WillReturnResult(sqlmock.NewResult(1, 1)) + m.ExpectCommit() + }, + }, + { + name: "edges/o2o/bidi", + spec: &CreateSpec{ + Table: "users", + ID: &FieldSpec{Column: "id", Type: field.TypeInt}, + Fields: []*FieldSpec{ + {Column: "name", Type: field.TypeString, Value: "a8m"}, + }, + Edges: []*EdgeSpec{ + {Rel: O2O, Bidi: true, Table: "users", Columns: []string{"spouse_id"}, Target: &EdgeTarget{Nodes: []driver.Value{2}, IDSpec: &FieldSpec{Column: "id"}}}, + }, + }, + expect: func(m sqlmock.Sqlmock) { + m.ExpectBegin() + m.ExpectExec(escape("INSERT INTO `users` (`name`, `spouse_id`) VALUES (?, ?)")). + WithArgs("a8m", 2). + WillReturnResult(sqlmock.NewResult(1, 1)) + m.ExpectExec(escape("UPDATE `users` SET `spouse_id` = ? WHERE `id` = ? AND `spouse_id` IS NULL")). + WithArgs(1, 2). + WillReturnResult(sqlmock.NewResult(1, 1)) + m.ExpectCommit() + }, + }, + { + name: "edges/m2m", + spec: &CreateSpec{ + Table: "groups", + ID: &FieldSpec{Column: "id", Type: field.TypeInt}, + Fields: []*FieldSpec{ + {Column: "name", Type: field.TypeString, Value: "GitHub"}, + }, + Edges: []*EdgeSpec{ + {Rel: M2M, Table: "group_users", Columns: []string{"group_id", "user_id"}, Target: &EdgeTarget{Nodes: []driver.Value{2}, IDSpec: &FieldSpec{Column: "id"}}}, + }, + }, + expect: func(m sqlmock.Sqlmock) { + m.ExpectBegin() + m.ExpectExec(escape("INSERT INTO `groups` (`name`) VALUES (?)")). + WithArgs("GitHub"). + WillReturnResult(sqlmock.NewResult(1, 1)) + m.ExpectExec(escape("INSERT INTO `group_users` (`group_id`, `user_id`) VALUES (?, ?) ON DUPLICATE KEY UPDATE `group_id` = `group_users`.`group_id`, `user_id` = `group_users`.`user_id`")). + WithArgs(1, 2). + WillReturnResult(sqlmock.NewResult(1, 1)) + m.ExpectCommit() + }, + }, + { + name: "edges/m2m/fields", + spec: &CreateSpec{ + Table: "groups", + ID: &FieldSpec{Column: "id", Type: field.TypeInt}, + Fields: []*FieldSpec{ + {Column: "name", Type: field.TypeString, Value: "GitHub"}, + }, + Edges: []*EdgeSpec{ + {Rel: M2M, Table: "group_users", Columns: []string{"group_id", "user_id"}, Target: &EdgeTarget{Nodes: []driver.Value{2}, IDSpec: &FieldSpec{Column: "id"}, Fields: []*FieldSpec{{Column: "ts", Type: field.TypeInt, Value: 3}}}}, + }, + }, + expect: func(m sqlmock.Sqlmock) { + m.ExpectBegin() + m.ExpectExec(escape("INSERT INTO `groups` (`name`) VALUES (?)")). + WithArgs("GitHub"). + WillReturnResult(sqlmock.NewResult(1, 1)) + m.ExpectExec(escape("INSERT INTO `group_users` (`group_id`, `user_id`, `ts`) VALUES (?, ?, ?)")). + WithArgs(1, 2, 3). + WillReturnResult(sqlmock.NewResult(1, 1)) + m.ExpectCommit() + }, + }, + { + name: "edges/m2m/inverse", + spec: &CreateSpec{ + Table: "users", + ID: &FieldSpec{Column: "id", Type: field.TypeInt}, + Fields: []*FieldSpec{ + {Column: "name", Type: field.TypeString, Value: "mashraki"}, + }, + Edges: []*EdgeSpec{ + {Rel: M2M, Inverse: true, Table: "group_users", Columns: []string{"group_id", "user_id"}, Target: &EdgeTarget{Nodes: []driver.Value{2}, IDSpec: &FieldSpec{Column: "id"}}}, + }, + }, + expect: func(m sqlmock.Sqlmock) { + m.ExpectBegin() + m.ExpectExec(escape("INSERT INTO `users` (`name`) VALUES (?)")). + WithArgs("mashraki"). + WillReturnResult(sqlmock.NewResult(1, 1)) + m.ExpectExec(escape("INSERT INTO `group_users` (`group_id`, `user_id`) VALUES (?, ?) ON DUPLICATE KEY UPDATE `group_id` = `group_users`.`group_id`, `user_id` = `group_users`.`user_id`")). + WithArgs(2, 1). + WillReturnResult(sqlmock.NewResult(1, 1)) + m.ExpectCommit() + }, + }, + { + name: "edges/m2m/bidi", + spec: &CreateSpec{ + Table: "users", + ID: &FieldSpec{Column: "id", Type: field.TypeInt}, + Fields: []*FieldSpec{ + {Column: "name", Type: field.TypeString, Value: "mashraki"}, + }, + Edges: []*EdgeSpec{ + {Rel: M2M, Bidi: true, Table: "user_friends", Columns: []string{"user_id", "friend_id"}, Target: &EdgeTarget{Nodes: []driver.Value{2}, IDSpec: &FieldSpec{Column: "id"}}}, + }, + }, + expect: func(m sqlmock.Sqlmock) { + m.ExpectBegin() + m.ExpectExec(escape("INSERT INTO `users` (`name`) VALUES (?)")). + WithArgs("mashraki"). + WillReturnResult(sqlmock.NewResult(1, 1)) + m.ExpectExec(escape("INSERT INTO `user_friends` (`user_id`, `friend_id`) VALUES (?, ?), (?, ?) ON DUPLICATE KEY UPDATE `user_id` = `user_friends`.`user_id`, `friend_id` = `user_friends`.`friend_id`")). + WithArgs(1, 2, 2, 1). + WillReturnResult(sqlmock.NewResult(1, 1)) + m.ExpectCommit() + }, + }, + { + name: "edges/m2m/bidi/fields", + spec: &CreateSpec{ + Table: "users", + ID: &FieldSpec{Column: "id", Type: field.TypeInt}, + Fields: []*FieldSpec{ + {Column: "name", Type: field.TypeString, Value: "mashraki"}, + }, + Edges: []*EdgeSpec{ + {Rel: M2M, Bidi: true, Table: "user_friends", Columns: []string{"user_id", "friend_id"}, Target: &EdgeTarget{Nodes: []driver.Value{2}, IDSpec: &FieldSpec{Column: "id"}, Fields: []*FieldSpec{{Column: "ts", Type: field.TypeInt, Value: 3}}}}, + }, + }, + expect: func(m sqlmock.Sqlmock) { + m.ExpectBegin() + m.ExpectExec(escape("INSERT INTO `users` (`name`) VALUES (?)")). + WithArgs("mashraki"). + WillReturnResult(sqlmock.NewResult(1, 1)) + m.ExpectExec(escape("INSERT INTO `user_friends` (`user_id`, `friend_id`, `ts`) VALUES (?, ?, ?), (?, ?, ?)")). + WithArgs(1, 2, 3, 2, 1, 3). + WillReturnResult(sqlmock.NewResult(1, 1)) + m.ExpectCommit() + }, + }, + { + name: "edges/m2m/bidi/batch", + spec: &CreateSpec{ + Table: "users", + ID: &FieldSpec{Column: "id", Type: field.TypeInt}, + Fields: []*FieldSpec{ + {Column: "name", Type: field.TypeString, Value: "mashraki"}, + }, + Edges: []*EdgeSpec{ + {Rel: M2M, Bidi: true, Table: "user_friends", Columns: []string{"user_id", "friend_id"}, Target: &EdgeTarget{Nodes: []driver.Value{2}, IDSpec: &FieldSpec{Column: "id"}}}, + {Rel: M2M, Bidi: true, Table: "user_friends", Columns: []string{"user_id", "friend_id"}, Target: &EdgeTarget{Nodes: []driver.Value{3}, IDSpec: &FieldSpec{Column: "id"}}}, + {Rel: M2M, Inverse: true, Table: "group_users", Columns: []string{"group_id", "user_id"}, Target: &EdgeTarget{Nodes: []driver.Value{4}, IDSpec: &FieldSpec{Column: "id"}}}, + {Rel: M2M, Inverse: true, Table: "group_users", Columns: []string{"group_id", "user_id"}, Target: &EdgeTarget{Nodes: []driver.Value{5}, IDSpec: &FieldSpec{Column: "id"}}}, + }, + }, + expect: func(m sqlmock.Sqlmock) { + m.ExpectBegin() + m.ExpectExec(escape("INSERT INTO `users` (`name`) VALUES (?)")). + WithArgs("mashraki"). + WillReturnResult(sqlmock.NewResult(1, 1)) + m.ExpectExec(escape("INSERT INTO `group_users` (`group_id`, `user_id`) VALUES (?, ?), (?, ?) ON DUPLICATE KEY UPDATE `group_id` = `group_users`.`group_id`, `user_id` = `group_users`.`user_id`")). + WithArgs(4, 1, 5, 1). + WillReturnResult(sqlmock.NewResult(1, 1)) + m.ExpectExec(escape("INSERT INTO `user_friends` (`user_id`, `friend_id`) VALUES (?, ?), (?, ?), (?, ?), (?, ?) ON DUPLICATE KEY UPDATE `user_id` = `user_friends`.`user_id`, `friend_id` = `user_friends`.`friend_id`")). + WithArgs(1, 2, 2, 1, 1, 3, 3, 1). + WillReturnResult(sqlmock.NewResult(1, 1)) + m.ExpectCommit() + }, + }, + { + name: "schema", + spec: &CreateSpec{ + Table: "users", + Schema: "test", + ID: &FieldSpec{Column: "id", Type: field.TypeInt}, + Fields: []*FieldSpec{ + {Column: "age", Type: field.TypeInt, Value: 30}, + {Column: "name", Type: field.TypeString, Value: "a8m"}, + }, + }, + expect: func(m sqlmock.Sqlmock) { + m.ExpectExec(escape("INSERT INTO `test`.`users` (`age`, `name`) VALUES (?, ?)")). + WithArgs(30, "a8m"). + WillReturnResult(sqlmock.NewResult(1, 1)) + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + tt.expect(mock) + err = CreateNode(context.Background(), sql.OpenDB(dialect.MySQL, db), tt.spec) + require.Equal(t, tt.wantErr, err != nil, err) + }) + } +} + +func TestBatchCreate(t *testing.T) { + tests := []struct { + name string + spec *BatchCreateSpec + expect func(sqlmock.Sqlmock) + wantErr bool + }{ + { + name: "empty", + spec: &BatchCreateSpec{}, + expect: func(m sqlmock.Sqlmock) { + m.ExpectBegin() + m.ExpectCommit() + }, + }, + { + name: "fields with modifiers", + spec: &BatchCreateSpec{ + Nodes: []*CreateSpec{ + { + Table: "users", + ID: &FieldSpec{Column: "id", Type: field.TypeInt}, + Fields: []*FieldSpec{ + {Column: "age", Type: field.TypeInt, Value: 32}, + {Column: "name", Type: field.TypeString, Value: "a8m"}, + {Column: "active", Type: field.TypeBool, Value: false}, + }, + }, + { + Table: "users", + ID: &FieldSpec{Column: "id", Type: field.TypeInt}, + Fields: []*FieldSpec{ + {Column: "age", Type: field.TypeInt, Value: 30}, + {Column: "name", Type: field.TypeString, Value: "nati"}, + {Column: "active", Type: field.TypeBool, Value: true}, + }, + }, + }, + OnConflict: []sql.ConflictOption{ + sql.ResolveWithIgnore(), + }, + }, + expect: func(m sqlmock.Sqlmock) { + m.ExpectExec(escape("INSERT INTO `users` (`active`, `age`, `name`) VALUES (?, ?, ?), (?, ?, ?) ON DUPLICATE KEY UPDATE `active` = `users`.`active`, `age` = `users`.`age`, `name` = `users`.`name`")). + WithArgs(false, 32, "a8m", true, 30, "nati"). + WillReturnResult(sqlmock.NewResult(10, 2)) + }, + }, + { + name: "no tx", + spec: &BatchCreateSpec{ + Nodes: []*CreateSpec{ + { + Table: "users", + ID: &FieldSpec{Column: "id", Type: field.TypeInt}, + Fields: []*FieldSpec{ + {Column: "age", Type: field.TypeInt, Value: 32}, + {Column: "name", Type: field.TypeString, Value: "a8m"}, + {Column: "active", Type: field.TypeBool, Value: false}, + }, + Edges: []*EdgeSpec{ + {Rel: M2O, Table: "company", Columns: []string{"workplace_id"}, Target: &EdgeTarget{Nodes: []driver.Value{2}}}, + {Rel: O2O, Inverse: true, Table: "users", Columns: []string{"best_friend_id"}, Target: &EdgeTarget{Nodes: []driver.Value{3}, IDSpec: &FieldSpec{Column: "id"}}}, + }, + }, + { + Table: "users", + ID: &FieldSpec{Column: "id", Type: field.TypeInt}, + Fields: []*FieldSpec{ + {Column: "age", Type: field.TypeInt, Value: 30}, + {Column: "name", Type: field.TypeString, Value: "nati"}, + }, + Edges: []*EdgeSpec{ + {Rel: M2O, Table: "company", Columns: []string{"workplace_id"}, Target: &EdgeTarget{Nodes: []driver.Value{2}}}, + {Rel: O2O, Inverse: true, Table: "users", Columns: []string{"best_friend_id"}, Target: &EdgeTarget{Nodes: []driver.Value{4}, IDSpec: &FieldSpec{Column: "id"}}}, + }, + }, + }, + }, + expect: func(m sqlmock.Sqlmock) { + // Insert nodes with FKs. + m.ExpectExec(escape("INSERT INTO `users` (`active`, `age`, `best_friend_id`, `name`, `workplace_id`) VALUES (?, ?, ?, ?, ?), (NULL, ?, ?, ?, ?)")). + WithArgs(false, 32, 3, "a8m", 2, 30, 4, "nati", 2). + WillReturnResult(sqlmock.NewResult(10, 2)) + }, + }, + { + name: "with tx", + spec: &BatchCreateSpec{ + Nodes: []*CreateSpec{ + { + Table: "users", + ID: &FieldSpec{Column: "id", Type: field.TypeInt}, + Fields: []*FieldSpec{ + {Column: "name", Type: field.TypeString, Value: "a8m"}, + }, + Edges: []*EdgeSpec{ + {Rel: O2O, Table: "cards", Columns: []string{"owner_id"}, Target: &EdgeTarget{Nodes: []driver.Value{3}, IDSpec: &FieldSpec{Column: "id"}}}, + }, + }, + { + Table: "users", + ID: &FieldSpec{Column: "id", Type: field.TypeInt}, + Fields: []*FieldSpec{ + {Column: "name", Type: field.TypeString, Value: "nati"}, + }, + Edges: []*EdgeSpec{ + {Rel: O2O, Table: "cards", Columns: []string{"owner_id"}, Target: &EdgeTarget{Nodes: []driver.Value{4}, IDSpec: &FieldSpec{Column: "id"}}}, + }, + }, + }, + }, + expect: func(m sqlmock.Sqlmock) { + m.ExpectBegin() + m.ExpectExec(escape("INSERT INTO `users` (`name`) VALUES (?), (?)")). + WithArgs("a8m", "nati"). + WillReturnResult(sqlmock.NewResult(10, 2)) + m.ExpectExec(escape("UPDATE `cards` SET `owner_id` = ? WHERE `id` = ? AND `owner_id` IS NULL")). + WithArgs(10 /* LAST_INSERT_ID() */, 3). + WillReturnResult(sqlmock.NewResult(1, 1)) + m.ExpectExec(escape("UPDATE `cards` SET `owner_id` = ? WHERE `id` = ? AND `owner_id` IS NULL")). + WithArgs(11 /* LAST_INSERT_ID() + 1 */, 4). + WillReturnResult(sqlmock.NewResult(1, 1)) + m.ExpectCommit() + }, + }, + { + name: "multiple", + spec: &BatchCreateSpec{ + Nodes: []*CreateSpec{ + { + Table: "users", + ID: &FieldSpec{Column: "id", Type: field.TypeInt}, + Fields: []*FieldSpec{ + {Column: "age", Type: field.TypeInt, Value: 32}, + {Column: "name", Type: field.TypeString, Value: "a8m"}, + {Column: "active", Type: field.TypeBool, Value: false}, + }, + Edges: []*EdgeSpec{ + {Rel: M2M, Inverse: true, Table: "group_users", Columns: []string{"group_id", "user_id"}, Target: &EdgeTarget{Nodes: []driver.Value{2}, IDSpec: &FieldSpec{Column: "id"}}}, + {Rel: M2M, Table: "user_products", Columns: []string{"user_id", "product_id"}, Target: &EdgeTarget{Nodes: []driver.Value{2}, IDSpec: &FieldSpec{Column: "id"}}}, + {Rel: M2M, Table: "user_friends", Bidi: true, Columns: []string{"user_id", "friend_id"}, Target: &EdgeTarget{IDSpec: &FieldSpec{Column: "id", Type: field.TypeInt}, Nodes: []driver.Value{2}}}, + {Rel: M2O, Table: "company", Columns: []string{"workplace_id"}, Target: &EdgeTarget{Nodes: []driver.Value{2}}}, + {Rel: O2M, Table: "pets", Columns: []string{"owner_id"}, Target: &EdgeTarget{Nodes: []driver.Value{2}, IDSpec: &FieldSpec{Column: "id"}}}, + }, + }, + { + Table: "users", + ID: &FieldSpec{Column: "id", Type: field.TypeInt}, + Fields: []*FieldSpec{ + {Column: "age", Type: field.TypeInt, Value: 30}, + {Column: "name", Type: field.TypeString, Value: "nati"}, + }, + Edges: []*EdgeSpec{ + {Rel: M2M, Inverse: true, Table: "group_users", Columns: []string{"group_id", "user_id"}, Target: &EdgeTarget{Nodes: []driver.Value{2}, IDSpec: &FieldSpec{Column: "id"}}}, + {Rel: M2M, Table: "user_products", Columns: []string{"user_id", "product_id"}, Target: &EdgeTarget{Nodes: []driver.Value{2}, IDSpec: &FieldSpec{Column: "id"}}}, + {Rel: M2M, Table: "user_friends", Bidi: true, Columns: []string{"user_id", "friend_id"}, Target: &EdgeTarget{IDSpec: &FieldSpec{Column: "id", Type: field.TypeInt}, Nodes: []driver.Value{2}}}, + {Rel: O2M, Table: "pets", Columns: []string{"owner_id"}, Target: &EdgeTarget{Nodes: []driver.Value{3}, IDSpec: &FieldSpec{Column: "id"}}}, + }, + }, + }, + }, + expect: func(m sqlmock.Sqlmock) { + m.ExpectBegin() + // Insert nodes with FKs. + m.ExpectExec(escape("INSERT INTO `users` (`active`, `age`, `name`, `workplace_id`) VALUES (?, ?, ?, ?), (NULL, ?, ?, NULL)")). + WithArgs(false, 32, "a8m", 2, 30, "nati"). + WillReturnResult(sqlmock.NewResult(10, 2)) + // Insert M2M inverse-edges. + m.ExpectExec(escape("INSERT INTO `group_users` (`group_id`, `user_id`) VALUES (?, ?), (?, ?) ON DUPLICATE KEY UPDATE `group_id` = `group_users`.`group_id`, `user_id` = `group_users`.`user_id`")). + WithArgs(2, 10, 2, 11). + WillReturnResult(sqlmock.NewResult(2, 2)) + // Insert M2M bidirectional edges. + m.ExpectExec(escape("INSERT INTO `user_friends` (`user_id`, `friend_id`) VALUES (?, ?), (?, ?), (?, ?), (?, ?) ON DUPLICATE KEY UPDATE `user_id` = `user_friends`.`user_id`, `friend_id` = `user_friends`.`friend_id`")). + WithArgs(10, 2, 2, 10, 11, 2, 2, 11). + WillReturnResult(sqlmock.NewResult(2, 2)) + // Insert M2M edges. + m.ExpectExec(escape("INSERT INTO `user_products` (`user_id`, `product_id`) VALUES (?, ?), (?, ?) ON DUPLICATE KEY UPDATE `user_id` = `user_products`.`user_id`, `product_id` = `user_products`.`product_id`")). + WithArgs(10, 2, 11, 2). + WillReturnResult(sqlmock.NewResult(2, 2)) + // Update FKs exist in different tables. + m.ExpectExec(escape("UPDATE `pets` SET `owner_id` = ? WHERE `id` = ? AND `owner_id` IS NULL")). + WithArgs(10 /* id of the 1st new node */, 2 /* pet id */). + WillReturnResult(sqlmock.NewResult(2, 2)) + m.ExpectExec(escape("UPDATE `pets` SET `owner_id` = ? WHERE `id` = ? AND `owner_id` IS NULL")). + WithArgs(11 /* id of the 2nd new node */, 3 /* pet id */). + WillReturnResult(sqlmock.NewResult(2, 2)) + m.ExpectCommit() + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + tt.expect(mock) + err = BatchCreate(context.Background(), sql.OpenDB("mysql", db), tt.spec) + require.Equal(t, tt.wantErr, err != nil, err) + }) + } +} + +type user struct { + id int + age int + name string + bfID int + edges struct { + fk1 int + fk2 int + } +} + +func (*user) values(columns []string) ([]any, error) { + values := make([]any, len(columns)) + for i := range columns { + switch c := columns[i]; c { + case "id", "age", "best_friend_id", "fk1", "fk2": + values[i] = &sql.NullInt64{} + case "name": + values[i] = &sql.NullString{} + default: + return nil, fmt.Errorf("unexpected column %q", c) + } + } + return values, nil +} + +func (u *user) assign(columns []string, values []any) error { + if len(columns) != len(values) { + return fmt.Errorf("mismatch number of values") + } + for i, c := range columns { + switch c { + case "id": + u.id = int(values[i].(*sql.NullInt64).Int64) + case "age": + u.age = int(values[i].(*sql.NullInt64).Int64) + case "name": + u.name = values[i].(*sql.NullString).String + case "best_friend_id": + u.bfID = int(values[i].(*sql.NullInt64).Int64) + case "fk1": + u.edges.fk1 = int(values[i].(*sql.NullInt64).Int64) + case "fk2": + u.edges.fk2 = int(values[i].(*sql.NullInt64).Int64) + default: + return fmt.Errorf("unknown column %q", c) + } + } + return nil +} + +func TestUpdateNode(t *testing.T) { + tests := []struct { + name string + spec *UpdateSpec + prepare func(sqlmock.Sqlmock) + wantErr bool + wantUser *user + }{ + { + name: "fields/set", + spec: &UpdateSpec{ + Node: &NodeSpec{ + Table: "users", + Columns: []string{"id", "name", "age"}, + ID: &FieldSpec{Column: "id", Type: field.TypeInt, Value: 1}, + }, + Fields: FieldMut{ + Set: []*FieldSpec{ + {Column: "age", Type: field.TypeInt, Value: 30}, + {Column: "name", Type: field.TypeString, Value: "Ariel"}, + }, + }, + }, + prepare: func(mock sqlmock.Sqlmock) { + mock.ExpectBegin() + mock.ExpectExec(escape("UPDATE `users` SET `age` = ?, `name` = ? WHERE `id` = ?")). + WithArgs(30, "Ariel", 1). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectQuery(escape("SELECT `id`, `name`, `age` FROM `users` WHERE `id` = ?")). + WithArgs(1). + WillReturnRows(sqlmock.NewRows([]string{"id", "age", "name"}). + AddRow(1, 30, "Ariel")) + mock.ExpectCommit() + }, + wantUser: &user{name: "Ariel", age: 30, id: 1}, + }, + { + name: "fields/set_modifier", + spec: &UpdateSpec{ + Node: &NodeSpec{ + Table: "users", + Columns: []string{"id", "name", "age"}, + ID: &FieldSpec{Column: "id", Type: field.TypeInt, Value: 1}, + }, + Modifiers: []func(*sql.UpdateBuilder){ + func(u *sql.UpdateBuilder) { + u.Set("name", sql.Expr(sql.Lower("name"))) + }, + }, + }, + prepare: func(mock sqlmock.Sqlmock) { + mock.ExpectBegin() + mock.ExpectExec(escape("UPDATE `users` SET `name` = LOWER(`name`) WHERE `id` = ?")). + WithArgs(1). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectQuery(escape("SELECT `id`, `name`, `age` FROM `users` WHERE `id` = ?")). + WithArgs(1). + WillReturnRows(sqlmock.NewRows([]string{"id", "age", "name"}). + AddRow(1, 30, "Ariel")) + mock.ExpectCommit() + }, + wantUser: &user{name: "Ariel", age: 30, id: 1}, + }, + { + name: "fields/add_set_clear", + spec: &UpdateSpec{ + Node: &NodeSpec{ + Table: "users", + Columns: []string{"id", "name", "age"}, + ID: &FieldSpec{Column: "id", Type: field.TypeInt, Value: 1}, + }, + Predicate: func(s *sql.Selector) { + s.Where(sql.EQ("deleted", false)) + }, + Fields: FieldMut{ + Add: []*FieldSpec{ + {Column: "age", Type: field.TypeInt, Value: 1}, + }, + Set: []*FieldSpec{ + {Column: "deleted", Type: field.TypeBool, Value: true}, + }, + Clear: []*FieldSpec{ + {Column: "name", Type: field.TypeString}, + }, + }, + }, + prepare: func(mock sqlmock.Sqlmock) { + mock.ExpectBegin() + mock.ExpectExec(escape("UPDATE `users` SET `name` = NULL, `deleted` = ?, `age` = COALESCE(`users`.`age`, 0) + ? WHERE `id` = ? AND NOT `deleted`")). + WithArgs(true, 1, 1). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectQuery(escape("SELECT `id`, `name`, `age` FROM `users` WHERE `id` = ?")). + WithArgs(1). + WillReturnRows(sqlmock.NewRows([]string{"id", "age", "name"}). + AddRow(1, 31, nil)) + mock.ExpectCommit() + }, + wantUser: &user{age: 31, id: 1}, + }, + { + name: "fields/ensure_exists", + spec: &UpdateSpec{ + Node: &NodeSpec{ + Table: "users", + Columns: []string{"id", "name", "age"}, + ID: &FieldSpec{Column: "id", Type: field.TypeInt, Value: 1}, + }, + Predicate: func(s *sql.Selector) { + s.Where(sql.EQ("deleted", false)) + }, + Fields: FieldMut{ + Add: []*FieldSpec{ + {Column: "age", Type: field.TypeInt, Value: 1}, + }, + Set: []*FieldSpec{ + {Column: "deleted", Type: field.TypeBool, Value: true}, + }, + Clear: []*FieldSpec{ + {Column: "name", Type: field.TypeString}, + }, + }, + }, + prepare: func(mock sqlmock.Sqlmock) { + mock.ExpectBegin() + mock.ExpectExec(escape("UPDATE `users` SET `name` = NULL, `deleted` = ?, `age` = COALESCE(`users`.`age`, 0) + ? WHERE `id` = ? AND NOT `deleted`")). + WithArgs(true, 1, 1). + WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectQuery(escape("SELECT EXISTS (SELECT * FROM `users` WHERE `id` = ? AND NOT `deleted`)")). + WithArgs(1). + WillReturnRows(sqlmock.NewRows([]string{"exists"}). + AddRow(false)) + mock.ExpectRollback() + }, + wantErr: true, + wantUser: &user{}, + }, + { + name: "edges/o2o_non_inverse and m2o", + spec: &UpdateSpec{ + Node: &NodeSpec{ + Table: "users", + Columns: []string{"id", "name", "age"}, + ID: &FieldSpec{Column: "id", Type: field.TypeInt, Value: 1}, + }, + Edges: EdgeMut{ + Clear: []*EdgeSpec{ + {Rel: O2O, Columns: []string{"car_id"}, Inverse: true}, + {Rel: M2O, Columns: []string{"workplace_id"}, Inverse: true}, + }, + Add: []*EdgeSpec{ + {Rel: O2O, Columns: []string{"card_id"}, Inverse: true, Target: &EdgeTarget{Nodes: []driver.Value{2}}}, + {Rel: M2O, Columns: []string{"parent_id"}, Inverse: true, Target: &EdgeTarget{Nodes: []driver.Value{2}}}, + }, + }, + }, + prepare: func(mock sqlmock.Sqlmock) { + mock.ExpectBegin() + mock.ExpectExec(escape("UPDATE `users` SET `workplace_id` = NULL, `car_id` = NULL, `parent_id` = ?, `card_id` = ? WHERE `id` = ?")). + WithArgs(2, 2, 1). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectQuery(escape("SELECT `id`, `name`, `age` FROM `users` WHERE `id` = ?")). + WithArgs(1). + WillReturnRows(sqlmock.NewRows([]string{"id", "age", "name"}). + AddRow(1, 31, nil)) + mock.ExpectCommit() + }, + wantUser: &user{age: 31, id: 1}, + }, + { + name: "edges/o2o_bidi", + spec: &UpdateSpec{ + Node: &NodeSpec{ + Table: "users", + Columns: []string{"id", "name", "age"}, + ID: &FieldSpec{Column: "id", Type: field.TypeInt, Value: 1}, + }, + Edges: EdgeMut{ + Clear: []*EdgeSpec{ + {Rel: O2O, Table: "users", Bidi: true, Columns: []string{"partner_id"}, Target: &EdgeTarget{IDSpec: &FieldSpec{Column: "id"}}}, + {Rel: O2O, Table: "users", Bidi: true, Columns: []string{"spouse_id"}, Target: &EdgeTarget{IDSpec: &FieldSpec{Column: "id", Type: field.TypeInt}, Nodes: []driver.Value{2}}}, + }, + Add: []*EdgeSpec{ + {Rel: O2O, Table: "users", Bidi: true, Columns: []string{"spouse_id"}, Target: &EdgeTarget{IDSpec: &FieldSpec{Column: "id", Type: field.TypeInt}, Nodes: []driver.Value{3}}}, + }, + }, + }, + prepare: func(mock sqlmock.Sqlmock) { + mock.ExpectBegin() + // Clear the "partner" from 1's column, and set "spouse 3". + // "spouse 2" is implicitly removed when setting a different foreign-key. + mock.ExpectExec(escape("UPDATE `users` SET `partner_id` = NULL, `spouse_id` = ? WHERE `id` = ?")). + WithArgs(3, 1). + WillReturnResult(sqlmock.NewResult(1, 1)) + // Clear the "partner_id" column from previous 1's partner. + mock.ExpectExec(escape("UPDATE `users` SET `partner_id` = NULL WHERE `partner_id` = ?")). + WithArgs(1). + WillReturnResult(sqlmock.NewResult(1, 1)) + // Clear "spouse 1" from 3's column. + mock.ExpectExec(escape("UPDATE `users` SET `spouse_id` = NULL WHERE `id` = ? AND `spouse_id` = ?")). + WithArgs(2, 1). + WillReturnResult(sqlmock.NewResult(1, 1)) + // Set 3's column to point "spouse 1". + mock.ExpectExec(escape("UPDATE `users` SET `spouse_id` = ? WHERE `id` = ? AND `spouse_id` IS NULL")). + WithArgs(1, 3). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectQuery(escape("SELECT `id`, `name`, `age` FROM `users` WHERE `id` = ?")). + WithArgs(1). + WillReturnRows(sqlmock.NewRows([]string{"id", "age", "name"}). + AddRow(1, 31, nil)) + mock.ExpectCommit() + }, + wantUser: &user{age: 31, id: 1}, + }, + { + name: "edges/clear_add_m2m", + spec: &UpdateSpec{ + Node: &NodeSpec{ + Table: "users", + Columns: []string{"id", "name", "age"}, + ID: &FieldSpec{Column: "id", Type: field.TypeInt, Value: 1}, + }, + Edges: EdgeMut{ + Clear: []*EdgeSpec{ + {Rel: M2M, Table: "user_friends", Bidi: true, Columns: []string{"user_id", "friend_id"}, Target: &EdgeTarget{IDSpec: &FieldSpec{Column: "id", Type: field.TypeInt}, Nodes: []driver.Value{2}}}, + {Rel: M2M, Inverse: true, Table: "group_users", Columns: []string{"group_id", "user_id"}, Target: &EdgeTarget{IDSpec: &FieldSpec{Column: "id", Type: field.TypeInt}, Nodes: []driver.Value{3, 7}}}, + // Clear all "following" edges (and their inverse). + {Rel: M2M, Table: "user_following", Bidi: true, Columns: []string{"following_id", "follower_id"}, Target: &EdgeTarget{IDSpec: &FieldSpec{Column: "id"}}}, + // Clear all "user_blocked" edges. + {Rel: M2M, Table: "user_blocked", Columns: []string{"user_id", "blocked_id"}, Target: &EdgeTarget{IDSpec: &FieldSpec{Column: "id"}}}, + // Clear all "comments" edges. + {Rel: M2M, Inverse: true, Table: "comment_responders", Columns: []string{"comment_id", "responder_id"}, Target: &EdgeTarget{IDSpec: &FieldSpec{Column: "id"}}}, + }, + Add: []*EdgeSpec{ + {Rel: M2M, Table: "user_friends", Bidi: true, Columns: []string{"user_id", "friend_id"}, Target: &EdgeTarget{IDSpec: &FieldSpec{Column: "id", Type: field.TypeInt}, Nodes: []driver.Value{4}}}, + {Rel: M2M, Inverse: true, Table: "group_users", Columns: []string{"group_id", "user_id"}, Target: &EdgeTarget{IDSpec: &FieldSpec{Column: "id", Type: field.TypeInt}, Nodes: []driver.Value{5}}}, + {Rel: M2M, Inverse: true, Table: "group_users", Columns: []string{"group_id", "user_id"}, Target: &EdgeTarget{IDSpec: &FieldSpec{Column: "id", Type: field.TypeInt}, Nodes: []driver.Value{6, 8}}}, + }, + }, + }, + prepare: func(mock sqlmock.Sqlmock) { + mock.ExpectBegin() + // Clear comment responders. + mock.ExpectExec(escape("DELETE FROM `comment_responders` WHERE `responder_id` = ?")). + WithArgs(1). + WillReturnResult(sqlmock.NewResult(1, 1)) + // Remove user groups. + mock.ExpectExec(escape("DELETE FROM `group_users` WHERE `user_id` = ? AND `group_id` IN (?, ?)")). + WithArgs(1, 3, 7). + WillReturnResult(sqlmock.NewResult(1, 1)) + // Clear all blocked users. + mock.ExpectExec(escape("DELETE FROM `user_blocked` WHERE `user_id` = ?")). + WithArgs(1). + WillReturnResult(sqlmock.NewResult(1, 1)) + // Clear all user following. + mock.ExpectExec(escape("DELETE FROM `user_following` WHERE `following_id` = ? OR `follower_id` = ?")). + WithArgs(1, 1). + WillReturnResult(sqlmock.NewResult(1, 2)) + // Clear user friends. + mock.ExpectExec(escape("DELETE FROM `user_friends` WHERE (`user_id` = ? AND `friend_id` = ?) OR (`friend_id` = ? AND `user_id` = ?)")). + WithArgs(1, 2, 1, 2). + WillReturnResult(sqlmock.NewResult(1, 1)) + // Add new groups. + mock.ExpectExec(escape("INSERT INTO `group_users` (`group_id`, `user_id`) VALUES (?, ?), (?, ?), (?, ?)")). + WithArgs(5, 1, 6, 1, 8, 1). + WillReturnResult(sqlmock.NewResult(1, 1)) + // Add new friends. + mock.ExpectExec(escape("INSERT INTO `user_friends` (`user_id`, `friend_id`) VALUES (?, ?), (?, ?)")). + WithArgs(1, 4, 4, 1). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectQuery(escape("SELECT `id`, `name`, `age` FROM `users` WHERE `id` = ?")). + WithArgs(1). + WillReturnRows(sqlmock.NewRows([]string{"id", "age", "name"}). + AddRow(1, 31, nil)) + mock.ExpectCommit() + }, + wantUser: &user{age: 31, id: 1}, + }, + { + name: "schema/fields/set", + spec: &UpdateSpec{ + Node: &NodeSpec{ + Table: "users", + Schema: "mydb", + Columns: []string{"id", "name", "age"}, + ID: &FieldSpec{Column: "id", Type: field.TypeInt, Value: 1}, + }, + Fields: FieldMut{ + Set: []*FieldSpec{ + {Column: "age", Type: field.TypeInt, Value: 30}, + {Column: "name", Type: field.TypeString, Value: "Ariel"}, + }, + }, + }, + prepare: func(mock sqlmock.Sqlmock) { + mock.ExpectBegin() + mock.ExpectExec(escape("UPDATE `mydb`.`users` SET `age` = ?, `name` = ? WHERE `id` = ?")). + WithArgs(30, "Ariel", 1). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectQuery(escape("SELECT `id`, `name`, `age` FROM `mydb`.`users` WHERE `id` = ?")). + WithArgs(1). + WillReturnRows(sqlmock.NewRows([]string{"id", "age", "name"}). + AddRow(1, 30, "Ariel")) + mock.ExpectCommit() + }, + wantUser: &user{name: "Ariel", age: 30, id: 1}, + }, + { + name: "schema/edges/clear_bidi_o2o", + spec: &UpdateSpec{ + Node: &NodeSpec{ + Table: "users", + Schema: "mydb", + Columns: []string{"id", "name", "age", "best_friend_id"}, + ID: &FieldSpec{Column: "id", Type: field.TypeInt, Value: 1}, + }, + Edges: EdgeMut{ + Clear: []*EdgeSpec{ + // Clear O2O bidi edge + {Rel: O2O, Schema: "mydb", Table: "users", Bidi: true, Columns: []string{"best_friend_id"}, Target: &EdgeTarget{IDSpec: &FieldSpec{Column: "id"}}}, + }, + }, + }, + prepare: func(mock sqlmock.Sqlmock) { + mock.ExpectBegin() + // Clear best friend. + mock.ExpectExec(escape("UPDATE `mydb`.`users` SET `best_friend_id` = NULL WHERE `id` = ?")). + WithArgs(1). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectExec(escape("UPDATE `mydb`.`users` SET `best_friend_id` = NULL WHERE `best_friend_id` = ?")). + WithArgs(1). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectQuery(escape("SELECT `id`, `name`, `age`, `best_friend_id` FROM `mydb`.`users` WHERE `id` = ?")). + WithArgs(1). + WillReturnRows(sqlmock.NewRows([]string{"id", "age", "name", "best_friend_id"}). + AddRow(1, 31, nil, nil)) + mock.ExpectCommit() + }, + wantUser: &user{age: 31, id: 1}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + tt.prepare(mock) + usr := &user{} + tt.spec.Assign = usr.assign + tt.spec.ScanValues = usr.values + err = UpdateNode(context.Background(), sql.OpenDB("", db), tt.spec) + require.Equal(t, tt.wantErr, err != nil, err) + require.Equal(t, tt.wantUser, usr) + }) + } +} + +func TestExecUpdateNode(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + mock.ExpectBegin() + mock.ExpectExec(escape("UPDATE `users` SET `age` = ?, `name` = ? WHERE `id` = ?")). + WithArgs(30, "Ariel", 1). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectCommit() + err = UpdateNode(context.Background(), sql.OpenDB("", db), &UpdateSpec{ + Node: &NodeSpec{ + Table: "users", + Columns: []string{"id", "name", "age"}, + ID: &FieldSpec{Column: "id", Type: field.TypeInt, Value: 1}, + }, + Fields: FieldMut{ + Set: []*FieldSpec{ + {Column: "age", Type: field.TypeInt, Value: 30}, + {Column: "name", Type: field.TypeString, Value: "Ariel"}, + }, + }, + }) + require.NoError(t, err) +} + +func TestUpdateNodes(t *testing.T) { + tests := []struct { + name string + spec *UpdateSpec + prepare func(sqlmock.Sqlmock) + wantErr bool + wantAffected int + }{ + { + name: "without predicate", + spec: &UpdateSpec{ + Node: &NodeSpec{ + Table: "users", + ID: &FieldSpec{Column: "id", Type: field.TypeInt}, + }, + Fields: FieldMut{ + Set: []*FieldSpec{ + {Column: "age", Type: field.TypeInt, Value: 30}, + {Column: "name", Type: field.TypeString, Value: "Ariel"}, + }, + }, + }, + prepare: func(mock sqlmock.Sqlmock) { + // Apply field changes. + mock.ExpectExec(escape("UPDATE `users` SET `age` = ?, `name` = ?")). + WithArgs(30, "Ariel"). + WillReturnResult(sqlmock.NewResult(0, 2)) + }, + wantAffected: 2, + }, + { + name: "with predicate", + spec: &UpdateSpec{ + Node: &NodeSpec{ + Table: "users", + ID: &FieldSpec{Column: "id", Type: field.TypeInt}, + }, + Fields: FieldMut{ + Clear: []*FieldSpec{ + {Column: "age", Type: field.TypeInt}, + {Column: "name", Type: field.TypeString}, + }, + }, + Predicate: func(s *sql.Selector) { + s.Where(sql.EQ("name", "a8m")) + }, + }, + prepare: func(mock sqlmock.Sqlmock) { + // Clear fields. + mock.ExpectExec(escape("UPDATE `users` SET `age` = NULL, `name` = NULL WHERE `name` = ?")). + WithArgs("a8m"). + WillReturnResult(sqlmock.NewResult(0, 1)) + }, + wantAffected: 1, + }, + { + name: "with modifier", + spec: &UpdateSpec{ + Node: &NodeSpec{ + Table: "users", + ID: &FieldSpec{Column: "id", Type: field.TypeInt}, + }, + Modifiers: []func(*sql.UpdateBuilder){ + func(u *sql.UpdateBuilder) { + u.Set("id", sql.Expr("id + 1")).OrderBy("id") + }, + }, + }, + prepare: func(mock sqlmock.Sqlmock) { + mock.ExpectExec(escape("UPDATE `users` SET `id` = id + 1 ORDER BY `id`")). + WillReturnResult(sqlmock.NewResult(0, 1)) + }, + wantAffected: 1, + }, + { + name: "own_fks/m2o_o2o_inverse", + spec: &UpdateSpec{ + Node: &NodeSpec{ + Table: "users", + ID: &FieldSpec{Column: "id", Type: field.TypeInt}, + }, + Edges: EdgeMut{ + Clear: []*EdgeSpec{ + {Rel: O2O, Columns: []string{"car_id"}, Inverse: true}, + {Rel: M2O, Columns: []string{"workplace_id"}, Inverse: true}, + }, + Add: []*EdgeSpec{ + {Rel: O2O, Columns: []string{"card_id"}, Inverse: true, Target: &EdgeTarget{Nodes: []driver.Value{3}}}, + {Rel: M2O, Columns: []string{"parent_id"}, Inverse: true, Target: &EdgeTarget{Nodes: []driver.Value{4}}}, + }, + }, + }, + prepare: func(mock sqlmock.Sqlmock) { + // Clear "car" and "workplace" foreign_keys and add "card" and a "parent". + mock.ExpectExec(escape("UPDATE `users` SET `workplace_id` = NULL, `car_id` = NULL, `parent_id` = ?, `card_id` = ?")). + WithArgs(4, 3). + WillReturnResult(sqlmock.NewResult(0, 3)) + }, + wantAffected: 3, + }, + { + name: "o2m", + spec: &UpdateSpec{ + Node: &NodeSpec{ + Table: "users", + ID: &FieldSpec{Column: "id", Type: field.TypeInt}, + }, + Fields: FieldMut{ + Add: []*FieldSpec{ + {Column: "version", Type: field.TypeInt, Value: 1}, + }, + }, + Edges: EdgeMut{ + Clear: []*EdgeSpec{ + {Rel: O2M, Table: "cards", Columns: []string{"owner_id"}, Target: &EdgeTarget{Nodes: []driver.Value{20, 30}, IDSpec: &FieldSpec{Column: "id"}}}, + }, + Add: []*EdgeSpec{ + {Rel: O2M, Table: "pets", Columns: []string{"owner_id"}, Target: &EdgeTarget{Nodes: []driver.Value{40}, IDSpec: &FieldSpec{Column: "id"}}}, + }, + }, + }, + prepare: func(mock sqlmock.Sqlmock) { + mock.ExpectBegin() + // Get all node ids first. + mock.ExpectQuery(escape("SELECT `id` FROM `users`")). + WillReturnRows(sqlmock.NewRows([]string{"id"}). + AddRow(10)) + mock.ExpectExec(escape("UPDATE `users` SET `version` = COALESCE(`users`.`version`, 0) + ? WHERE `id` = ?")). + WithArgs(1, 10). + WillReturnResult(sqlmock.NewResult(0, 1)) + // Clear "owner_id" column in the "cards" table. + mock.ExpectExec(escape("UPDATE `cards` SET `owner_id` = NULL WHERE `id` IN (?, ?) AND `owner_id` = ?")). + WithArgs(20, 30, 10). + WillReturnResult(sqlmock.NewResult(0, 2)) + // Set "owner_id" column in the "pets" table. + mock.ExpectExec(escape("UPDATE `pets` SET `owner_id` = ? WHERE `id` = ? AND `owner_id` IS NULL")). + WithArgs(10, 40). + WillReturnResult(sqlmock.NewResult(0, 2)) + mock.ExpectCommit() + }, + wantAffected: 1, + }, + { + name: "m2m_one", + spec: &UpdateSpec{ + Node: &NodeSpec{ + Table: "users", + ID: &FieldSpec{Column: "id", Type: field.TypeInt}, + }, + Edges: EdgeMut{ + Clear: []*EdgeSpec{ + {Rel: M2M, Table: "group_users", Columns: []string{"group_id", "user_id"}, Inverse: true, Target: &EdgeTarget{Nodes: []driver.Value{2, 3}}}, + {Rel: M2M, Table: "user_followers", Columns: []string{"user_id", "follower_id"}, Bidi: true, Target: &EdgeTarget{Nodes: []driver.Value{5, 6}}}, + {Rel: M2M, Table: "user_friends", Columns: []string{"user_id", "friend_id"}, Bidi: true, Target: &EdgeTarget{Nodes: []driver.Value{4}}}, + }, + Add: []*EdgeSpec{ + {Rel: M2M, Table: "group_users", Columns: []string{"group_id", "user_id"}, Inverse: true, Target: &EdgeTarget{Nodes: []driver.Value{7, 8}}}, + {Rel: M2M, Table: "user_followers", Columns: []string{"user_id", "follower_id"}, Bidi: true, Target: &EdgeTarget{Nodes: []driver.Value{9}}}, + }, + }, + }, + prepare: func(mock sqlmock.Sqlmock) { + mock.ExpectBegin() + // Get all node ids first. + mock.ExpectQuery(escape("SELECT `id` FROM `users`")). + WillReturnRows(sqlmock.NewRows([]string{"id"}). + AddRow(1)) + // Clear user's groups. + mock.ExpectExec(escape("DELETE FROM `group_users` WHERE `user_id` = ? AND `group_id` IN (?, ?)")). + WithArgs(1, 2, 3). + WillReturnResult(sqlmock.NewResult(0, 2)) + // Clear user's followers. + mock.ExpectExec(escape("DELETE FROM `user_followers` WHERE (`user_id` = ? AND `follower_id` IN (?, ?)) OR (`follower_id` = ? AND `user_id` IN (?, ?))")). + WithArgs(1, 5, 6, 1, 5, 6). + WillReturnResult(sqlmock.NewResult(0, 2)) + // Clear user's friends. + mock.ExpectExec(escape("DELETE FROM `user_friends` WHERE (`user_id` = ? AND `friend_id` = ?) OR (`friend_id` = ? AND `user_id` = ?)")). + WithArgs(1, 4, 1, 4). + WillReturnResult(sqlmock.NewResult(0, 2)) + // Attach new groups to user. + mock.ExpectExec(escape("INSERT INTO `group_users` (`group_id`, `user_id`) VALUES (?, ?), (?, ?)")). + WithArgs(7, 1, 8, 1). + WillReturnResult(sqlmock.NewResult(0, 2)) + // Attach new friends to user. + mock.ExpectExec(escape("INSERT INTO `user_followers` (`user_id`, `follower_id`) VALUES (?, ?), (?, ?)")). + WithArgs(1, 9, 9, 1). + WillReturnResult(sqlmock.NewResult(0, 2)) + mock.ExpectCommit() + }, + wantAffected: 1, + }, + { + name: "m2m_many", + spec: &UpdateSpec{ + Node: &NodeSpec{ + Table: "users", + ID: &FieldSpec{Column: "id", Type: field.TypeInt}, + }, + Edges: EdgeMut{ + Clear: []*EdgeSpec{ + {Rel: M2M, Table: "group_users", Columns: []string{"group_id", "user_id"}, Inverse: true, Target: &EdgeTarget{Nodes: []driver.Value{2, 3}}}, + {Rel: M2M, Table: "user_followers", Columns: []string{"user_id", "follower_id"}, Bidi: true, Target: &EdgeTarget{Nodes: []driver.Value{5, 6}}}, + {Rel: M2M, Table: "user_friends", Columns: []string{"user_id", "friend_id"}, Bidi: true, Target: &EdgeTarget{Nodes: []driver.Value{4}}}, + }, + Add: []*EdgeSpec{ + {Rel: M2M, Table: "group_users", Columns: []string{"group_id", "user_id"}, Inverse: true, Target: &EdgeTarget{Nodes: []driver.Value{7, 8}}}, + {Rel: M2M, Table: "user_followers", Columns: []string{"user_id", "follower_id"}, Bidi: true, Target: &EdgeTarget{Nodes: []driver.Value{9}}}, + }, + }, + }, + prepare: func(mock sqlmock.Sqlmock) { + mock.ExpectBegin() + // Get all node ids first. + mock.ExpectQuery(escape("SELECT `id` FROM `users`")). + WillReturnRows(sqlmock.NewRows([]string{"id"}). + AddRow(10). + AddRow(20)) + // Clear user's groups. + mock.ExpectExec(escape("DELETE FROM `group_users` WHERE `user_id` IN (?, ?) AND `group_id` IN (?, ?)")). + WithArgs(10, 20, 2, 3). + WillReturnResult(sqlmock.NewResult(0, 2)) + // Clear user's followers. + mock.ExpectExec(escape("DELETE FROM `user_followers` WHERE (`user_id` IN (?, ?) AND `follower_id` IN (?, ?)) OR (`follower_id` IN (?, ?) AND `user_id` IN (?, ?))")). + WithArgs(10, 20, 5, 6, 10, 20, 5, 6). + WillReturnResult(sqlmock.NewResult(0, 2)) + // Clear user's friends. + mock.ExpectExec(escape("DELETE FROM `user_friends` WHERE (`user_id` IN (?, ?) AND `friend_id` = ?) OR (`friend_id` IN (?, ?) AND `user_id` = ?)")). + WithArgs(10, 20, 4, 10, 20, 4). + WillReturnResult(sqlmock.NewResult(0, 2)) + // Attach new groups to user. + mock.ExpectExec(escape("INSERT INTO `group_users` (`group_id`, `user_id`) VALUES (?, ?), (?, ?), (?, ?), (?, ?)")). + WithArgs(7, 10, 7, 20, 8, 10, 8, 20). + WillReturnResult(sqlmock.NewResult(0, 4)) + // Attach new friends to user. + mock.ExpectExec(escape("INSERT INTO `user_followers` (`user_id`, `follower_id`) VALUES (?, ?), (?, ?), (?, ?), (?, ?)")). + WithArgs(10, 9, 9, 10, 20, 9, 9, 20). + WillReturnResult(sqlmock.NewResult(0, 4)) + mock.ExpectCommit() + }, + wantAffected: 2, + }, + { + name: "m2m_edge_schema", + spec: &UpdateSpec{ + Node: &NodeSpec{ + Table: "users", + CompositeID: []*FieldSpec{{Column: "user_id", Type: field.TypeInt}, {Column: "group_id", Type: field.TypeInt}}, + }, + Predicate: func(s *sql.Selector) { + s.Where(sql.EQ("version", 1)) + }, + Fields: FieldMut{ + Add: []*FieldSpec{ + {Column: "version", Type: field.TypeInt, Value: 1}, + }, + }, + }, + prepare: func(mock sqlmock.Sqlmock) { + mock.ExpectExec(escape("UPDATE `users` SET `version` = COALESCE(`users`.`version`, 0) + ? WHERE `version` = ?")). + WithArgs(1, 1). + WillReturnResult(sqlmock.NewResult(0, 4)) + }, + wantAffected: 4, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + tt.prepare(mock) + affected, err := UpdateNodes(context.Background(), sql.OpenDB("", db), tt.spec) + require.Equal(t, tt.wantErr, err != nil, err) + require.Equal(t, tt.wantAffected, affected) + }) + } +} + +func TestDeleteNodes(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + mock.ExpectExec(escape("DELETE FROM `users`")). + WillReturnResult(sqlmock.NewResult(0, 2)) + affected, err := DeleteNodes(context.Background(), sql.OpenDB("", db), &DeleteSpec{ + Node: &NodeSpec{ + Table: "users", + ID: &FieldSpec{Column: "id", Type: field.TypeInt}, + }, + }) + require.NoError(t, err) + require.Equal(t, 2, affected) +} + +func TestDeleteNodesSchema(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + mock.ExpectExec(escape("DELETE FROM `mydb`.`users`")). + WillReturnResult(sqlmock.NewResult(0, 2)) + affected, err := DeleteNodes(context.Background(), sql.OpenDB("", db), &DeleteSpec{ + Node: &NodeSpec{ + Table: "users", + Schema: "mydb", + ID: &FieldSpec{Column: "id", Type: field.TypeInt}, + }, + }) + require.NoError(t, err) + require.Equal(t, 2, affected) +} + +func TestQueryNodes(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + mock.ExpectQuery(escape("SELECT DISTINCT `users`.`id`, `users`.`age`, `users`.`name`, `users`.`fk1`, `users`.`fk2` FROM `users` WHERE `age` < ? ORDER BY `id` LIMIT 3 OFFSET 4 FOR UPDATE NOWAIT")). + WithArgs(40). + WillReturnRows(sqlmock.NewRows([]string{"id", "age", "name", "fk1", "fk2"}). + AddRow(1, 10, nil, nil, nil). + AddRow(2, 20, "", 0, 0). + AddRow(3, 30, "a8m", 1, 1)) + mock.ExpectQuery(escape("SELECT COUNT(DISTINCT `users`.`id`) FROM `users` WHERE `age` < ? LIMIT 3 OFFSET 4 FOR UPDATE NOWAIT")). + WithArgs(40). + WillReturnRows(sqlmock.NewRows([]string{"COUNT"}). + AddRow(3)) + mock.ExpectQuery(escape("SELECT COUNT(DISTINCT `users`.`name`) FROM `users` WHERE `age` < ? LIMIT 3 OFFSET 4 FOR UPDATE NOWAIT")). + WithArgs(40). + WillReturnRows(sqlmock.NewRows([]string{"COUNT"}). + AddRow(3)) + + var ( + users []*user + spec = &QuerySpec{ + Node: &NodeSpec{ + Table: "users", + Columns: []string{"id", "age", "name", "fk1", "fk2"}, + ID: &FieldSpec{Column: "id", Type: field.TypeInt}, + }, + Limit: 3, + Offset: 4, + Unique: true, + Order: func(s *sql.Selector) { + s.OrderBy("id") + }, + Predicate: func(s *sql.Selector) { + s.Where(sql.LT("age", 40)) + }, + Modifiers: []func(*sql.Selector){ + func(s *sql.Selector) { s.ForUpdate(sql.WithLockAction(sql.NoWait)) }, + }, + ScanValues: func(columns []string) ([]any, error) { + u := &user{} + users = append(users, u) + return u.values(columns) + }, + Assign: func(columns []string, values []any) error { + return users[len(users)-1].assign(columns, values) + }, + } + ) + + // Query and scan. + err = QueryNodes(context.Background(), sql.OpenDB("", db), spec) + require.NoError(t, err) + require.Equal(t, &user{id: 1, age: 10, name: ""}, users[0]) + require.Equal(t, &user{id: 2, age: 20, name: ""}, users[1]) + require.Equal(t, &user{id: 3, age: 30, name: "a8m", edges: struct{ fk1, fk2 int }{1, 1}}, users[2]) + + // Count nodes. + spec.Node.Columns = nil + n, err := CountNodes(context.Background(), sql.OpenDB("", db), spec) + require.NoError(t, err) + require.Equal(t, 3, n) + + // Count nodes. + spec.Node.Columns = []string{"name"} + n, err = CountNodes(context.Background(), sql.OpenDB("", db), spec) + require.NoError(t, err) + require.Equal(t, 3, n) +} + +func TestQueryNodesSchema(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + mock.ExpectQuery(escape("SELECT DISTINCT `mydb`.`users`.`id`, `mydb`.`users`.`age`, `mydb`.`users`.`name`, `mydb`.`users`.`fk1`, `mydb`.`users`.`fk2` FROM `mydb`.`users` WHERE `age` < ? ORDER BY `id` LIMIT 3 OFFSET 4")). + WithArgs(40). + WillReturnRows(sqlmock.NewRows([]string{"id", "age", "name", "fk1", "fk2"}). + AddRow(1, 10, nil, nil, nil). + AddRow(2, 20, "", 0, 0). + AddRow(3, 30, "a8m", 1, 1)) + var ( + users []*user + spec = &QuerySpec{ + Node: &NodeSpec{ + Table: "users", + Schema: "mydb", + Columns: []string{"id", "age", "name", "fk1", "fk2"}, + ID: &FieldSpec{Column: "id", Type: field.TypeInt}, + }, + Limit: 3, + Offset: 4, + Unique: true, + Order: func(s *sql.Selector) { + s.OrderBy("id") + }, + Predicate: func(s *sql.Selector) { + s.Where(sql.LT("age", 40)) + }, + ScanValues: func(columns []string) ([]any, error) { + u := &user{} + users = append(users, u) + return u.values(columns) + }, + Assign: func(columns []string, values []any) error { + return users[len(users)-1].assign(columns, values) + }, + } + ) + + // Query and scan. + err = QueryNodes(context.Background(), sql.OpenDB("", db), spec) + require.NoError(t, err) + require.Equal(t, &user{id: 1, age: 10, name: ""}, users[0]) + require.Equal(t, &user{id: 2, age: 20, name: ""}, users[1]) + require.Equal(t, &user{id: 3, age: 30, name: "a8m", edges: struct{ fk1, fk2 int }{1, 1}}, users[2]) +} + +func TestQueryEdges(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + mock.ExpectQuery(escape("SELECT `group_id`, `user_id` FROM `user_groups` WHERE `user_id` IN (?, ?, ?)")). + WithArgs(1, 2, 3). + WillReturnRows(sqlmock.NewRows([]string{"group_id", "user_id"}). + AddRow(4, 5). + AddRow(4, 6)) + + var ( + edges [][]int64 + spec = &EdgeQuerySpec{ + Edge: &EdgeSpec{ + Inverse: true, + Table: "user_groups", + Columns: []string{"user_id", "group_id"}, + }, + Predicate: func(s *sql.Selector) { + s.Where(sql.InValues("user_id", 1, 2, 3)) + }, + ScanValues: func() [2]any { + return [2]any{&sql.NullInt64{}, &sql.NullInt64{}} + }, + Assign: func(out, in any) error { + o, i := out.(*sql.NullInt64), in.(*sql.NullInt64) + edges = append(edges, []int64{o.Int64, i.Int64}) + return nil + }, + } + ) + + // Query and scan. + err = QueryEdges(context.Background(), sql.OpenDB("", db), spec) + require.NoError(t, err) + require.Equal(t, [][]int64{{4, 5}, {4, 6}}, edges) +} + +func TestQueryEdgesSchema(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + mock.ExpectQuery(escape("SELECT `group_id`, `user_id` FROM `mydb`.`user_groups` WHERE `user_id` IN (?, ?, ?)")). + WithArgs(1, 2, 3). + WillReturnRows(sqlmock.NewRows([]string{"group_id", "user_id"}). + AddRow(4, 5). + AddRow(4, 6)) + + var ( + edges [][]int64 + spec = &EdgeQuerySpec{ + Edge: &EdgeSpec{ + Inverse: true, + Table: "user_groups", + Schema: "mydb", + Columns: []string{"user_id", "group_id"}, + }, + Predicate: func(s *sql.Selector) { + s.Where(sql.InValues("user_id", 1, 2, 3)) + }, + ScanValues: func() [2]any { + return [2]any{&sql.NullInt64{}, &sql.NullInt64{}} + }, + Assign: func(out, in any) error { + o, i := out.(*sql.NullInt64), in.(*sql.NullInt64) + edges = append(edges, []int64{o.Int64, i.Int64}) + return nil + }, + } + ) + + // Query and scan. + err = QueryEdges(context.Background(), sql.OpenDB("", db), spec) + require.NoError(t, err) + require.Equal(t, [][]int64{{4, 5}, {4, 6}}, edges) +} + +func TestIsConstraintError(t *testing.T) { + tests := []struct { + name string + errMessage string + expectedConstraint bool + expectedFK bool + expectedUnique bool + expectedCheck bool + }{ + { + name: "MySQL FK", + errMessage: `insert node to table "pets": Error 1452: Cannot add or update a child row: a foreign key` + + " constraint fails (`test`.`pets`, CONSTRAINT `pets_users_pets` FOREIGN KEY (`user_pets`) REFERENCES " + + "`users` (`id`) ON DELETE SET NULL)", + expectedConstraint: true, + expectedFK: true, + expectedUnique: false, + expectedCheck: false, + }, + { + name: "SQLite FK", + errMessage: `insert node to table "pets": FOREIGN KEY constraint failed`, + expectedConstraint: true, + expectedFK: true, + expectedUnique: false, + expectedCheck: false, + }, + { + name: "Postgres FK", + errMessage: `insert node to table "pets": pq: insert or update on table "pets" violates foreign key constraint "pets_users_pets"`, + expectedConstraint: true, + expectedFK: true, + expectedUnique: false, + expectedCheck: false, + }, + { + name: "MySQL FK", + errMessage: "Error 1451: Cannot delete or update a parent row: a foreign key constraint " + + "fails (`test`.`groups`, CONSTRAINT `groups_group_infos_info` FOREIGN KEY (`group_info`) REFERENCES `group_infos` (`id`))", + expectedConstraint: true, + expectedFK: true, + expectedUnique: false, + expectedCheck: false, + }, + { + name: "SQLite FK", + errMessage: `FOREIGN KEY constraint failed`, + expectedConstraint: true, + expectedFK: true, + expectedUnique: false, + expectedCheck: false, + }, + { + name: "Postgres FK", + errMessage: `pq: update or delete on table "group_infos" violates foreign key constraint "groups_group_infos_info" on table "groups"`, + expectedConstraint: true, + expectedFK: true, + expectedUnique: false, + expectedCheck: false, + }, + { + name: "MySQL Unique", + errMessage: `insert node to table "file_types": UNIQUE constraint failed: file_types.name ent: constraint failed: insert node to table "file_types": UNIQUE constraint failed: file_types.name`, + expectedConstraint: true, + expectedFK: false, + expectedUnique: true, + expectedCheck: false, + }, + { + name: "SQLite Unique", + errMessage: `insert node to table "file_types": UNIQUE constraint failed: file_types.name ent: constraint failed: insert node to table "file_types": UNIQUE constraint failed: file_types.name`, + expectedConstraint: true, + expectedFK: false, + expectedUnique: true, + expectedCheck: false, + }, + { + name: "Postgres Unique", + errMessage: `insert node to table "file_types": pq: duplicate key value violates unique constraint "file_types_name_key" ent: constraint failed: insert node to table "file_types": pq: duplicate key value violates unique constraint "file_types_name_key"`, + expectedConstraint: true, + expectedFK: false, + expectedUnique: true, + expectedCheck: false, + }, + { + name: "MySQL Check", + errMessage: `insert node to table "users": Error 3819: Check constraint 'users_age_check' is violated`, + expectedConstraint: true, + expectedFK: false, + expectedUnique: false, + expectedCheck: true, + }, + { + name: "SQLite Check", + errMessage: `insert node to table "users": CHECK constraint failed: age >= 18`, + expectedConstraint: true, + expectedFK: false, + expectedUnique: false, + expectedCheck: true, + }, + { + name: "Postgres Check", + errMessage: `insert node to table "users": pq: new row for relation "users" violates check constraint "users_age_check"`, + expectedConstraint: true, + expectedFK: false, + expectedUnique: false, + expectedCheck: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := errors.New(tt.errMessage) + require.EqualValues(t, tt.expectedConstraint, IsConstraintError(err)) + require.EqualValues(t, tt.expectedFK, IsForeignKeyConstraintError(err)) + require.EqualValues(t, tt.expectedUnique, IsUniqueConstraintError(err)) + require.EqualValues(t, tt.expectedCheck, IsCheckConstraintError(err)) + }) + } +} + +func TestLimitNeighbors(t *testing.T) { + t.Run("O2M", func(t *testing.T) { + const fk = "author_id" + // Authors load their posts. + s := sql.Select(fk, "id").From(sql.Table("posts")) + LimitNeighbors(fk, 2)(s) + query, args := s.Query() + require.Equal(t, + "WITH `src_query` AS (SELECT `author_id`, `id` FROM `posts`), `limited_query` AS (SELECT *, (ROW_NUMBER() OVER (PARTITION BY `author_id` ORDER BY `id`)) AS `row_number` FROM `src_query`) SELECT `author_id`, `id` FROM `limited_query` AS `posts` WHERE `posts`.`row_number` <= ?", + query, + ) + require.Equal(t, []any{2}, args) + }) + t.Run("M2M", func(t *testing.T) { + const fk = "user_id" + edgeT, neighborsT := sql.Table("user_groups"), sql.Table("groups") + s := sql.Select(fk, "id", "name").From(neighborsT).Join(edgeT).On(neighborsT.C("id"), edgeT.C("group_id")) + LimitNeighbors(fk, 1, sql.ExprFunc(func(b *sql.Builder) { b.Ident("updated_at") }))(s) + query, args := s.Query() + require.Equal(t, + "WITH `src_query` AS (SELECT `user_id`, `id`, `name` FROM `groups` JOIN `user_groups` AS `t1` ON `groups`.`id` = `t1`.`group_id`), `limited_query` AS (SELECT *, (ROW_NUMBER() OVER (PARTITION BY `user_id` ORDER BY `updated_at`)) AS `row_number` FROM `src_query`) SELECT `user_id`, `id`, `name` FROM `limited_query` AS `groups` WHERE `groups`.`row_number` <= ?", + query, + ) + require.Equal(t, []any{1}, args) + }) +} + +func escape(query string) string { + rows := strings.Split(query, "\n") + for i := range rows { + rows[i] = strings.TrimPrefix(rows[i], " ") + } + query = strings.Join(rows, " ") + return strings.TrimSpace(regexp.QuoteMeta(query)) + "$" +} diff --git a/dialect/sql/sqljson/dialect.go b/dialect/sql/sqljson/dialect.go new file mode 100644 index 0000000..f65a895 --- /dev/null +++ b/dialect/sql/sqljson/dialect.go @@ -0,0 +1,222 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package sqljson + +import ( + "fmt" + "reflect" + + "entgo.io/ent/dialect" + "entgo.io/ent/dialect/sql" +) + +type sqlite struct{} + +// Append implements the driver.Append method. +func (d *sqlite) Append(u *sql.UpdateBuilder, column string, elems []any, opts ...Option) { + setCase(u, column, when{ + Cond: func(b *sql.Builder) { + typ := func(b *sql.Builder) *sql.Builder { + return b.WriteString("JSON_TYPE").Wrap(func(b *sql.Builder) { + b.Ident(column).Comma() + identPath(column, opts...).mysqlPath(b) + }) + } + typ(b).WriteOp(sql.OpIsNull) + b.WriteString(" OR ") + typ(b).WriteOp(sql.OpEQ).WriteString("'null'") + }, + Then: func(b *sql.Builder) { + if len(opts) > 0 { + b.WriteString("JSON_SET").Wrap(func(b *sql.Builder) { + b.Ident(column).Comma() + identPath(column, opts...).mysqlPath(b) + b.Comma().Argf("JSON(?)", marshalArg(elems)) + }) + } else { + b.Arg(marshalArg(elems)) + } + }, + Else: func(b *sql.Builder) { + b.WriteString("JSON_INSERT").Wrap(func(b *sql.Builder) { + b.Ident(column).Comma() + // If no path was provided the top-level value is + // a JSON array. i.e. JSON_INSERT(c, '$[#]', ?). + path := func(b *sql.Builder) { b.WriteString("'$[#]'") } + if len(opts) > 0 { + p := identPath(column, opts...) + p.Path = append(p.Path, "[#]") + path = p.mysqlPath + } + for i, e := range elems { + if i > 0 { + b.Comma() + } + path(b) + b.Comma() + d.appendArg(b, e) + } + }) + }, + }) +} + +func (d *sqlite) appendArg(b *sql.Builder, v any) { + switch { + case !isPrimitive(v): + b.Argf("JSON(?)", marshalArg(v)) + default: + b.Arg(v) + } +} + +type mysql struct{} + +// Append implements the driver.Append method. +func (d *mysql) Append(u *sql.UpdateBuilder, column string, elems []any, opts ...Option) { + setCase(u, column, when{ + Cond: func(b *sql.Builder) { + typ := func(b *sql.Builder) *sql.Builder { + b.WriteString("JSON_TYPE(JSON_EXTRACT(") + b.Ident(column).Comma() + identPath(column, opts...).mysqlPath(b) + return b.WriteString("))") + } + typ(b).WriteOp(sql.OpIsNull) + b.WriteString(" OR ") + typ(b).WriteOp(sql.OpEQ).WriteString("'NULL'") + }, + Then: func(b *sql.Builder) { + if len(opts) > 0 { + b.WriteString("JSON_SET").Wrap(func(b *sql.Builder) { + b.Ident(column).Comma() + identPath(column, opts...).mysqlPath(b) + b.Comma().WriteString("JSON_ARRAY(").Args(d.marshalArgs(elems)...).WriteByte(')') + }) + } else { + b.WriteString("JSON_ARRAY(").Args(d.marshalArgs(elems)...).WriteByte(')') + } + }, + Else: func(b *sql.Builder) { + b.WriteString("JSON_ARRAY_APPEND").Wrap(func(b *sql.Builder) { + b.Ident(column).Comma() + for i, e := range elems { + if i > 0 { + b.Comma() + } + identPath(column, opts...).mysqlPath(b) + b.Comma() + d.appendArg(b, e) + } + }) + }, + }) +} + +func (d *mysql) marshalArgs(args []any) []any { + vs := make([]any, len(args)) + for i, v := range args { + if !isPrimitive(v) { + v = marshalArg(v) + } + vs[i] = v + } + return vs +} + +func (d *mysql) appendArg(b *sql.Builder, v any) { + switch { + case !isPrimitive(v): + b.Argf("CAST(? AS JSON)", marshalArg(v)) + default: + b.Arg(v) + } +} + +type postgres struct{} + +// Append implements the driver.Append method. +func (*postgres) Append(u *sql.UpdateBuilder, column string, elems []any, opts ...Option) { + setCase(u, column, when{ + Cond: func(b *sql.Builder) { + valuePath(b, column, append(opts, Cast("jsonb"))...) + b.WriteOp(sql.OpIsNull) + b.WriteString(" OR ") + valuePath(b, column, append(opts, Cast("jsonb"))...) + b.WriteOp(sql.OpEQ).WriteString("'null'::jsonb") + }, + Then: func(b *sql.Builder) { + if len(opts) > 0 { + b.WriteString("jsonb_set").Wrap(func(b *sql.Builder) { + b.Ident(column).Comma() + identPath(column, opts...).pgArrayPath(b) + b.Comma().Arg(marshalArg(elems)) + b.Comma().WriteString("true") + }) + } else { + b.Arg(marshalArg(elems)) + } + }, + Else: func(b *sql.Builder) { + if len(opts) > 0 { + b.WriteString("jsonb_set").Wrap(func(b *sql.Builder) { + b.Ident(column).Comma() + identPath(column, opts...).pgArrayPath(b) + b.Comma() + path := identPath(column, opts...) + path.value(b) + b.WriteString(" || ").Arg(marshalArg(elems)) + b.Comma().WriteString("true") + }) + } else { + b.Ident(column).WriteString(" || ").Arg(marshalArg(elems)) + } + }, + }) +} + +// driver groups all dialect-specific methods. +type driver interface { + Append(u *sql.UpdateBuilder, column string, elems []any, opts ...Option) +} + +func newDriver(name string) (driver, error) { + switch name { + case dialect.SQLite: + return (*sqlite)(nil), nil + case dialect.MySQL: + return (*mysql)(nil), nil + case dialect.Postgres: + return (*postgres)(nil), nil + default: + return nil, fmt.Errorf("sqljson: unknown driver %q", name) + } +} + +type when struct{ Cond, Then, Else func(*sql.Builder) } + +// setCase sets the column value using the "CASE WHEN" statement. +// The x defines the condition/predicate, t is the true (if) case, +// and 'f' defines the false (else). +func setCase(u *sql.UpdateBuilder, column string, w when) { + u.Set(column, sql.ExprFunc(func(b *sql.Builder) { + b.WriteString("CASE WHEN ").Wrap(func(b *sql.Builder) { + w.Cond(b) + }) + b.WriteString(" THEN ") + w.Then(b) + b.WriteString(" ELSE ") + w.Else(b) + b.WriteString(" END") + })) +} + +func isPrimitive(v any) bool { + switch reflect.TypeOf(v).Kind() { + case reflect.Array, reflect.Slice, reflect.Map, reflect.Struct, reflect.Ptr, reflect.Interface: + return false + } + return true +} diff --git a/dialect/sql/sqljson/sqljson.go b/dialect/sql/sqljson/sqljson.go new file mode 100644 index 0000000..0cf324f --- /dev/null +++ b/dialect/sql/sqljson/sqljson.go @@ -0,0 +1,727 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package sqljson + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + "unicode" + + "entgo.io/ent/dialect" + "entgo.io/ent/dialect/sql" +) + +// HasKey return a predicate for checking that a JSON key +// exists and not NULL. +// +// sqljson.HasKey("column", sql.DotPath("a.b[2].c")) +func HasKey(column string, opts ...Option) *sql.Predicate { + return sql.P(func(b *sql.Builder) { + switch b.Dialect() { + case dialect.SQLite: + // JSON_TYPE returns NULL in case the path selects an element + // that does not exist. See: https://sqlite.org/json1.html#jtype. + path := identPath(column, opts...) + path.mysqlFunc("JSON_TYPE", b) + b.WriteOp(sql.OpNotNull) + default: + valuePath(b, column, opts...) + b.WriteOp(sql.OpNotNull) + } + }) +} + +// ValueIsNull return a predicate for checking that a JSON value +// (returned by the path) is a null literal (JSON "null"). +// +// In order to check if the column is NULL (database NULL), or if +// the JSON key exists, use sql.IsNull or sqljson.HasKey. +// +// sqljson.ValueIsNull("a", sqljson.Path("b")) +func ValueIsNull(column string, opts ...Option) *sql.Predicate { + return sql.P(func(b *sql.Builder) { + switch b.Dialect() { + case dialect.MySQL: + path := identPath(column, opts...) + b.WriteString("JSON_CONTAINS").Wrap(func(b *sql.Builder) { + b.Ident(column).Comma() + b.WriteString("'null'").Comma() + path.mysqlPath(b) + }) + case dialect.Postgres: + valuePath(b, column, append(opts, Cast("jsonb"))...) + b.WriteOp(sql.OpEQ).WriteString("'null'::jsonb") + case dialect.SQLite: + path := identPath(column, opts...) + path.mysqlFunc("JSON_TYPE", b) + b.WriteOp(sql.OpEQ).WriteString("'null'") + } + }) +} + +// ValueIsNotNull return a predicate for checking that a JSON value +// (returned by the path) is not null literal (JSON "null"). +// +// sqljson.ValueIsNotNull("a", sqljson.Path("b")) +func ValueIsNotNull(column string, opts ...Option) *sql.Predicate { + return sql.P(func(b *sql.Builder) { + switch b.Dialect() { + case dialect.Postgres: + valuePath(b, column, append(opts, Cast("jsonb"))...) + b.WriteOp(sql.OpNEQ).WriteString("'null'::jsonb") + case dialect.SQLite: + path := identPath(column, opts...) + path.mysqlFunc("JSON_TYPE", b) + b.WriteOp(sql.OpNEQ).WriteString("'null'") + case dialect.MySQL: + path := identPath(column, opts...) + b.WriteString("NOT(JSON_CONTAINS").Wrap(func(b *sql.Builder) { + b.Ident(column).Comma() + b.WriteString("'null'").Comma() + path.mysqlPath(b) + }).WriteString(")") + } + }) +} + +// ValueEQ return a predicate for checking that a JSON value +// (returned by the path) is equal to the given argument. +// +// sqljson.ValueEQ("a", 1, sqljson.Path("b")) +func ValueEQ(column string, arg any, opts ...Option) *sql.Predicate { + return sql.P(func(b *sql.Builder) { + opts = normalizePG(b, arg, opts) + valuePath(b, column, opts...) + b.WriteOp(sql.OpEQ) + // Inline boolean values, as some drivers (e.g., MySQL) encode them as 0/1. + if v, ok := arg.(bool); ok { + b.WriteString(strconv.FormatBool(v)) + } else { + b.Arg(arg) + } + }) +} + +// ValueNEQ return a predicate for checking that a JSON value +// (returned by the path) is not equal to the given argument. +// +// sqljson.ValueNEQ("a", 1, sqljson.Path("b")) +func ValueNEQ(column string, arg any, opts ...Option) *sql.Predicate { + return sql.P(func(b *sql.Builder) { + opts = normalizePG(b, arg, opts) + valuePath(b, column, opts...) + b.WriteOp(sql.OpNEQ).Arg(arg) + }) +} + +// ValueGT return a predicate for checking that a JSON value +// (returned by the path) is greater than the given argument. +// +// sqljson.ValueGT("a", 1, sqljson.Path("b")) +func ValueGT(column string, arg any, opts ...Option) *sql.Predicate { + return sql.P(func(b *sql.Builder) { + opts = normalizePG(b, arg, opts) + valuePath(b, column, opts...) + b.WriteOp(sql.OpGT).Arg(arg) + }) +} + +// ValueGTE return a predicate for checking that a JSON value +// (returned by the path) is greater than or equal to the given +// argument. +// +// sqljson.ValueGTE("a", 1, sqljson.Path("b")) +func ValueGTE(column string, arg any, opts ...Option) *sql.Predicate { + return sql.P(func(b *sql.Builder) { + opts = normalizePG(b, arg, opts) + valuePath(b, column, opts...) + b.WriteOp(sql.OpGTE).Arg(arg) + }) +} + +// ValueLT return a predicate for checking that a JSON value +// (returned by the path) is less than the given argument. +// +// sqljson.ValueLT("a", 1, sqljson.Path("b")) +func ValueLT(column string, arg any, opts ...Option) *sql.Predicate { + return sql.P(func(b *sql.Builder) { + opts = normalizePG(b, arg, opts) + valuePath(b, column, opts...) + b.WriteOp(sql.OpLT).Arg(arg) + }) +} + +// ValueLTE return a predicate for checking that a JSON value +// (returned by the path) is less than or equal to the given +// argument. +// +// sqljson.ValueLTE("a", 1, sqljson.Path("b")) +func ValueLTE(column string, arg any, opts ...Option) *sql.Predicate { + return sql.P(func(b *sql.Builder) { + opts = normalizePG(b, arg, opts) + valuePath(b, column, opts...) + b.WriteOp(sql.OpLTE).Arg(arg) + }) +} + +// ValueContains return a predicate for checking that a JSON +// value (returned by the path) contains the given argument. +// +// sqljson.ValueContains("a", 1, sqljson.Path("b")) +func ValueContains(column string, arg any, opts ...Option) *sql.Predicate { + return sql.P(func(b *sql.Builder) { + path := identPath(column, opts...) + switch b.Dialect() { + case dialect.MySQL: + b.WriteString("JSON_CONTAINS").Wrap(func(b *sql.Builder) { + b.Ident(column).Comma() + b.Arg(marshalArg(arg)).Comma() + path.mysqlPath(b) + }) + b.WriteOp(sql.OpEQ).Arg(1) + case dialect.SQLite: + b.WriteString("EXISTS").Wrap(func(b *sql.Builder) { + b.WriteString("SELECT * FROM JSON_EACH").Wrap(func(b *sql.Builder) { + b.Ident(column).Comma() + path.mysqlPath(b) + }) + b.WriteString(" WHERE ").Ident("value").WriteOp(sql.OpEQ).Arg(arg) + }) + case dialect.Postgres: + opts = normalizePG(b, arg, opts) + path.Cast = "jsonb" + path.value(b) + b.WriteString(" @> ").Arg(marshalArg(arg)) + } + }) +} + +// StringHasPrefix return a predicate for checking that a JSON string value +// (returned by the path) has the given substring as prefix +func StringHasPrefix(column string, prefix string, opts ...Option) *sql.Predicate { + return sql.P(func(b *sql.Builder) { + opts = append([]Option{Unquote(true)}, opts...) + valuePath(b, column, opts...) + b.Join(sql.HasPrefix("", prefix)) + }) +} + +// StringHasSuffix return a predicate for checking that a JSON string value +// (returned by the path) has the given substring as suffix +func StringHasSuffix(column string, suffix string, opts ...Option) *sql.Predicate { + return sql.P(func(b *sql.Builder) { + opts = append([]Option{Unquote(true)}, opts...) + valuePath(b, column, opts...) + b.Join(sql.HasSuffix("", suffix)) + }) +} + +// StringContains return a predicate for checking that a JSON string value +// (returned by the path) contains the given substring +func StringContains(column string, sub string, opts ...Option) *sql.Predicate { + return sql.P(func(b *sql.Builder) { + opts = append([]Option{Unquote(true)}, opts...) + valuePath(b, column, opts...) + b.Join(sql.Contains("", sub)) + }) +} + +// ValueIn return a predicate for checking that a JSON value +// (returned by the path) is IN the given arguments. +// +// sqljson.ValueIn("a", []any{1, 2, 3}, sqljson.Path("b")) +func ValueIn(column string, args []any, opts ...Option) *sql.Predicate { + return valueInOp(column, args, opts, sql.OpIn) +} + +// ValueNotIn return a predicate for checking that a JSON value +// (returned by the path) is NOT IN the given arguments. +// +// sqljson.ValueNotIn("a", []any{1, 2, 3}, sqljson.Path("b")) +func ValueNotIn(column string, args []any, opts ...Option) *sql.Predicate { + if len(args) == 0 { + return sql.NotIn(column) + } + return valueInOp(column, args, opts, sql.OpNotIn) +} + +func valueInOp(column string, args []any, opts []Option, op sql.Op) *sql.Predicate { + return sql.P(func(b *sql.Builder) { + if allString(args) { + opts = append(opts, Unquote(true)) + } + if len(args) > 0 { + opts = normalizePG(b, args[0], opts) + } + valuePath(b, column, opts...) + b.WriteOp(op) + b.Wrap(func(b *sql.Builder) { + if s, ok := args[0].(*sql.Selector); ok { + b.Join(s) + } else { + b.Args(args...) + } + }) + }) +} + +// LenEQ return a predicate for checking that an array length +// of a JSON (returned by the path) is equal to the given argument. +// +// sqljson.LenEQ("a", 1, sqljson.Path("b")) +func LenEQ(column string, size int, opts ...Option) *sql.Predicate { + return sql.P(func(b *sql.Builder) { + lenPath(b, column, opts...) + b.WriteOp(sql.OpEQ).Arg(size) + }) +} + +// LenNEQ return a predicate for checking that an array length +// of a JSON (returned by the path) is not equal to the given argument. +// +// sqljson.LenEQ("a", 1, sqljson.Path("b")) +func LenNEQ(column string, size int, opts ...Option) *sql.Predicate { + return sql.P(func(b *sql.Builder) { + lenPath(b, column, opts...) + b.WriteOp(sql.OpNEQ).Arg(size) + }) +} + +// LenGT return a predicate for checking that an array length +// of a JSON (returned by the path) is greater than the given +// argument. +// +// sqljson.LenGT("a", 1, sqljson.Path("b")) +func LenGT(column string, size int, opts ...Option) *sql.Predicate { + return sql.P(func(b *sql.Builder) { + lenPath(b, column, opts...) + b.WriteOp(sql.OpGT).Arg(size) + }) +} + +// LenGTE return a predicate for checking that an array length +// of a JSON (returned by the path) is greater than or equal to +// the given argument. +// +// sqljson.LenGTE("a", 1, sqljson.Path("b")) +func LenGTE(column string, size int, opts ...Option) *sql.Predicate { + return sql.P(func(b *sql.Builder) { + lenPath(b, column, opts...) + b.WriteOp(sql.OpGTE).Arg(size) + }) +} + +// LenLT return a predicate for checking that an array length +// of a JSON (returned by the path) is less than the given +// argument. +// +// sqljson.LenLT("a", 1, sqljson.Path("b")) +func LenLT(column string, size int, opts ...Option) *sql.Predicate { + return sql.P(func(b *sql.Builder) { + lenPath(b, column, opts...) + b.WriteOp(sql.OpLT).Arg(size) + }) +} + +// LenLTE return a predicate for checking that an array length +// of a JSON (returned by the path) is less than or equal to +// the given argument. +// +// sqljson.LenLTE("a", 1, sqljson.Path("b")) +func LenLTE(column string, size int, opts ...Option) *sql.Predicate { + return sql.P(func(b *sql.Builder) { + lenPath(b, column, opts...) + b.WriteOp(sql.OpLTE).Arg(size) + }) +} + +// LenPath returns an SQL expression for getting the length +// of a JSON value (returned by the path). +func LenPath(column string, opts ...Option) sql.Querier { + return sql.ExprFunc(func(b *sql.Builder) { + lenPath(b, column, opts...) + }) +} + +// OrderLen returns a custom predicate function (as defined in the doc), +// that sets the result order by the length of the given JSON value. +func OrderLen(column string, opts ...Option) func(*sql.Selector) { + return func(s *sql.Selector) { + s.OrderExpr(LenPath(column, opts...)) + } +} + +// OrderLenDesc returns a custom predicate function (as defined in the doc), that +// sets the result order by the length of the given JSON value, but in descending order. +func OrderLenDesc(column string, opts ...Option) func(*sql.Selector) { + return func(s *sql.Selector) { + s.OrderExpr( + sql.DescExpr(LenPath(column, opts...)), + ) + } +} + +// LenPath writes to the given SQL builder the JSON path for +// getting the length of a given JSON path. +// +// sqljson.LenPath(b, Path("a", "b", "[1]", "c")) +func lenPath(b *sql.Builder, column string, opts ...Option) { + path := identPath(column, opts...) + path.length(b) +} + +// Append writes to the given SQL builder the SQL command for appending JSON values +// into the array, optionally defined as a key. Note, the generated SQL will use the +// Go semantics, the JSON column/key will be set to the given Array in case it is `null` +// or NULL. For example: +// +// Append(u, column, []string{"a", "b"}) +// UPDATE "t" SET "c" = CASE +// WHEN ("c" IS NULL OR "c" = 'null'::jsonb) +// THEN $1 ELSE "c" || $2 END +// +// Append(u, column, []any{"a", 1}, sqljson.Path("a")) +// UPDATE "t" SET "c" = CASE +// WHEN (("c"->'a')::jsonb IS NULL OR ("c"->'a')::jsonb = 'null'::jsonb) +// THEN jsonb_set("c", '{a}', $1, true) ELSE jsonb_set("c", '{a}', "c"->'a' || $2, true) END +func Append[T any](u *sql.UpdateBuilder, column string, elems []T, opts ...Option) { + if len(elems) == 0 { + u.AddError(fmt.Errorf("sqljson: cannot append an empty array to column %q", column)) + return + } + drv, err := newDriver(u.Dialect()) + if err != nil { + u.AddError(err) + return + } + vs := make([]any, len(elems)) + for i, e := range elems { + vs[i] = e + } + drv.Append(u, column, vs, opts...) +} + +// Option allows for calling database JSON paths with functional options. +type Option func(*PathOptions) + +// Path sets the path to the JSON value of a column. +// +// ValuePath(b, "column", Path("a", "b", "[1]", "c")) +func Path(path ...string) Option { + return func(p *PathOptions) { + p.Path = path + } +} + +// DotPath is similar to Path, but accepts string with dot format. +// +// ValuePath(b, "column", DotPath("a.b.c")) +// ValuePath(b, "column", DotPath("a.b[2].c")) +// +// Note that DotPath is ignored if the input is invalid. +func DotPath(dotpath string) Option { + path, _ := ParsePath(dotpath) + return func(p *PathOptions) { + p.Path = path + } +} + +// Unquote indicates that the result value should be unquoted. +// +// ValuePath(b, "column", Path("a", "b", "[1]", "c"), Unquote(true)) +func Unquote(unquote bool) Option { + return func(p *PathOptions) { + p.Unquote = unquote + } +} + +// Cast indicates that the result value should be cast to the given type. +// +// ValuePath(b, "column", Path("a", "b", "[1]", "c"), Cast("int")) +func Cast(typ string) Option { + return func(p *PathOptions) { + p.Cast = typ + } +} + +// PathOptions holds the options for accessing a JSON value from an identifier. +type PathOptions struct { + Ident string + Path []string + Cast string + Unquote bool +} + +// identPath creates a PathOptions for the given identifier. +func identPath(ident string, opts ...Option) *PathOptions { + path := &PathOptions{Ident: ident} + for i := range opts { + opts[i](path) + } + return path +} + +func (p *PathOptions) Query() (string, []any) { + return p.Ident, nil +} + +// ValuePath returns an SQL expression for getting the JSON +// value of a column with an optional path and cast options. +// +// sqljson.ValueEQ( +// column, +// sqljson.ValuePath(column, Path("a"), Cast("int")), +// sqljson.Path("a"), +// ) +func ValuePath(column string, opts ...Option) sql.Querier { + return sql.ExprFunc(func(b *sql.Builder) { + valuePath(b, column, opts...) + }) +} + +// OrderValue returns a custom predicate function (as defined in the doc), +// that sets the result order by the given JSON value. +func OrderValue(column string, opts ...Option) func(*sql.Selector) { + return func(s *sql.Selector) { + s.OrderExpr(ValuePath(column, opts...)) + } +} + +// OrderValueDesc returns a custom predicate function (as defined in the doc), +// that sets the result order by the given JSON value, but in descending order. +func OrderValueDesc(column string, opts ...Option) func(*sql.Selector) { + return func(s *sql.Selector) { + s.OrderExpr( + sql.DescExpr(ValuePath(column, opts...)), + ) + } +} + +// valuePath writes to the given SQL builder the JSON path for +// getting the value of a given JSON path. +// Use sqljson.ValuePath for using a JSON value as an argument. +func valuePath(b *sql.Builder, column string, opts ...Option) { + path := identPath(column, opts...) + path.value(b) +} + +// value writes the path for getting the JSON value. +func (p *PathOptions) value(b *sql.Builder) { + switch { + case len(p.Path) == 0: + b.Ident(p.Ident) + case b.Dialect() == dialect.Postgres: + if p.Cast != "" { + b.WriteByte('(') + defer b.WriteString(")::" + p.Cast) + } + p.pgTextPath(b) + default: + if p.Unquote && b.Dialect() == dialect.MySQL { + b.WriteString("JSON_UNQUOTE(") + defer b.WriteByte(')') + } + p.mysqlFunc("JSON_EXTRACT", b) + } +} + +// value writes the path for getting the length of a JSON value. +func (p *PathOptions) length(b *sql.Builder) { + switch { + case b.Dialect() == dialect.Postgres: + b.WriteString("JSONB_ARRAY_LENGTH(") + p.pgTextPath(b) + b.WriteByte(')') + case b.Dialect() == dialect.MySQL: + p.mysqlFunc("JSON_LENGTH", b) + default: + p.mysqlFunc("JSON_ARRAY_LENGTH", b) + } +} + +// mysqlFunc writes the JSON path in MySQL format for the +// given function. `JSON_EXTRACT("a", '$.b.c')`. +func (p *PathOptions) mysqlFunc(fn string, b *sql.Builder) { + b.WriteString(fn).WriteByte('(') + b.Ident(p.Ident).Comma() + p.mysqlPath(b) + b.WriteByte(')') +} + +// mysqlPath writes the JSON path in MySQL (or SQLite) format. +func (p *PathOptions) mysqlPath(b *sql.Builder) { + b.WriteString(`'$`) + for _, p := range p.Path { + switch _, isIndex := isJSONIdx(p); { + case isIndex: + b.WriteString(p) + case p == "*" || isQuoted(p) || isIdentifier(p): + b.WriteString("." + p) + default: + b.WriteString(`."` + p + `"`) + } + } + b.WriteByte('\'') +} + +// pgTextPath writes the JSON path in PostgreSQL text format: `"a"->'b'->>'c'`. +func (p *PathOptions) pgTextPath(b *sql.Builder) { + b.Ident(p.Ident) + for i, s := range p.Path { + b.WriteString("->") + if p.Unquote && i == len(p.Path)-1 { + b.WriteString(">") + } + if idx, ok := isJSONIdx(s); ok { + b.WriteString(idx) + } else { + b.WriteString("'" + s + "'") + } + } +} + +// pgArrayPath writes the JSON path in PostgreSQL array text[] format: '{a,1,b}'. +func (p *PathOptions) pgArrayPath(b *sql.Builder) { + b.WriteString("'{") + for i, s := range p.Path { + if i > 0 { + b.Comma() + } + if idx, ok := isJSONIdx(s); ok { + s = idx + } + b.WriteString(s) + } + b.WriteString("}'") +} + +// ParsePath parses the "dotpath" for the DotPath option. +// +// "a.b" => ["a", "b"] +// "a[1][2]" => ["a", "[1]", "[2]"] +// "a.\"b.c\" => ["a", "\"b.c\""] +func ParsePath(dotpath string) ([]string, error) { + var ( + i, p int + path []string + ) + for i < len(dotpath) { + switch r := dotpath[i]; { + case r == '"': + if i == len(dotpath)-1 { + return nil, fmt.Errorf("unexpected quote") + } + idx := strings.IndexRune(dotpath[i+1:], '"') + if idx == -1 || idx == 0 { + return nil, fmt.Errorf("unbalanced quote") + } + i += idx + 2 + case r == '[': + if p != i { + path = append(path, dotpath[p:i]) + } + p = i + if i == len(dotpath)-1 { + return nil, fmt.Errorf("unexpected bracket") + } + idx := strings.IndexRune(dotpath[i:], ']') + if idx == -1 || idx == 1 { + return nil, fmt.Errorf("unbalanced bracket") + } + if !isNumber(dotpath[i+1 : i+idx]) { + return nil, fmt.Errorf("invalid index %q", dotpath[i:i+idx+1]) + } + i += idx + 1 + case r == '.' || r == ']': + if p != i { + path = append(path, dotpath[p:i]) + } + i++ + p = i + default: + i++ + } + } + if p != i { + path = append(path, dotpath[p:i]) + } + return path, nil +} + +// normalizePG adds cast option to the JSON path is the argument type is +// not string, in order to avoid "missing type casts" error in Postgres. +func normalizePG(b *sql.Builder, arg any, opts []Option) []Option { + if b.Dialect() != dialect.Postgres { + return opts + } + base := []Option{Unquote(true)} + switch arg.(type) { + case string: + case bool: + base = append(base, Cast("bool")) + case float32, float64: + base = append(base, Cast("float")) + case int8, int16, int32, int64, int, uint8, uint16, uint32, uint64: + base = append(base, Cast("int")) + } + return append(base, opts...) +} + +func isIdentifier(name string) bool { + if name == "" { + return false + } + for i, c := range name { + if !unicode.IsLetter(c) && c != '_' && (i == 0 || !unicode.IsDigit(c)) { + return false + } + } + return true +} + +func isQuoted(s string) bool { + if s == "" { + return false + } + return s[0] == '"' && s[len(s)-1] == '"' +} + +// isJSONIdx reports whether the string represents a JSON index. +func isJSONIdx(s string) (string, bool) { + if len(s) > 2 && s[0] == '[' && s[len(s)-1] == ']' && (isNumber(s[1:len(s)-1]) || s[1] == '#' && isNumber(s[2:len(s)-1])) { + return s[1 : len(s)-1], true + } + return "", false +} + +// isNumber reports whether the string is a number (category N). +func isNumber(s string) bool { + for _, r := range s { + if !unicode.IsNumber(r) { + return false + } + } + return true +} + +// allString reports if the slice contains only strings. +func allString(v []any) bool { + for i := range v { + if _, ok := v[i].(string); !ok { + return false + } + } + return true +} + +// marshalArg stringifies the given argument to a valid JSON document. +func marshalArg(arg any) any { + if buf, err := json.Marshal(arg); err == nil { + arg = string(buf) + } + return arg +} diff --git a/dialect/sql/sqljson/sqljson_test.go b/dialect/sql/sqljson/sqljson_test.go new file mode 100644 index 0000000..c3f72ad --- /dev/null +++ b/dialect/sql/sqljson/sqljson_test.go @@ -0,0 +1,535 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package sqljson_test + +import ( + "strconv" + "testing" + + "entgo.io/ent/dialect" + "entgo.io/ent/dialect/sql" + "entgo.io/ent/dialect/sql/sqljson" + "github.com/stretchr/testify/require" +) + +func TestWritePath(t *testing.T) { + tests := []struct { + input sql.Querier + wantQuery string + wantArgs []any + }{ + { + input: sql.Dialect(dialect.Postgres). + Select("*"). + From(sql.Table("users")). + Where(sqljson.ValueEQ("a", 1, sqljson.Path("b", "c", "[1]", "d"), sqljson.Cast("int"))), + wantQuery: `SELECT * FROM "users" WHERE ("a"->'b'->'c'->1->>'d')::int = $1`, + wantArgs: []any{1}, + }, + { + input: sql.Dialect(dialect.MySQL). + Select("*"). + From(sql.Table("users")). + Where(sqljson.ValueEQ("a", "a", sqljson.DotPath("b.c[1].d"))), + wantQuery: "SELECT * FROM `users` WHERE JSON_EXTRACT(`a`, '$.b.c[1].d') = ?", + wantArgs: []any{"a"}, + }, + { + input: sql.Dialect(dialect.MySQL). + Select("*"). + From(sql.Table("users")). + Where(sqljson.ValueEQ("a", true, sqljson.DotPath("b.c[1].d"))), + wantQuery: "SELECT * FROM `users` WHERE JSON_EXTRACT(`a`, '$.b.c[1].d') = true", + }, + { + input: sql.Dialect(dialect.MySQL). + Select("*"). + From(sql.Table("users")). + Where(sqljson.ValueEQ("a", "a", sqljson.DotPath("b.\"c[1]\".d[1][2].e"))), + wantQuery: "SELECT * FROM `users` WHERE JSON_EXTRACT(`a`, '$.b.\"c[1]\".d[1][2].e') = ?", + wantArgs: []any{"a"}, + }, + { + input: sql.Select("*"). + From(sql.Table("test")). + Where(sqljson.ValueEQ("j", sqljson.ValuePath("j", sqljson.DotPath("a.*.b")), sqljson.DotPath("a.*.c"))), + wantQuery: "SELECT * FROM `test` WHERE JSON_EXTRACT(`j`, '$.a.*.c') = JSON_EXTRACT(`j`, '$.a.*.b')", + }, + { + input: sql.Dialect(dialect.Postgres). + Select("*"). + From(sql.Table("test")). + Where(sqljson.ValueEQ("j", sqljson.ValuePath("j", sqljson.DotPath("a.*.b")), sqljson.DotPath("a.*.c"))), + wantQuery: `SELECT * FROM "test" WHERE "j"->'a'->'*'->>'c' = "j"->'a'->'*'->'b'`, + }, + { + input: sql.Select("*"). + From(sql.Table("test")). + Where(sqljson.HasKey("j", sqljson.DotPath("a.*.c"))), + wantQuery: "SELECT * FROM `test` WHERE JSON_EXTRACT(`j`, '$.a.*.c') IS NOT NULL", + }, + { + input: sql.Select("*"). + From(sql.Table("test")). + Where(sqljson.HasKey("j", sqljson.DotPath("a.*.c"))), + wantQuery: "SELECT * FROM `test` WHERE JSON_EXTRACT(`j`, '$.a.*.c') IS NOT NULL", + }, + { + input: sql.Dialect(dialect.SQLite). + Select("*"). + From(sql.Table("test")). + Where(sqljson.HasKey("j", sqljson.DotPath("attributes[1].body"))), + wantQuery: "SELECT * FROM `test` WHERE JSON_TYPE(`j`, '$.attributes[1].body') IS NOT NULL", + }, + { + input: sql.Dialect(dialect.SQLite). + Select("*"). + From(sql.Table("test")). + Where(sqljson.HasKey("j", sqljson.DotPath("a.*.c"))), + wantQuery: "SELECT * FROM `test` WHERE JSON_TYPE(`j`, '$.a.*.c') IS NOT NULL", + }, + { + input: sql.Dialect(dialect.SQLite). + Select("*"). + From(sql.Table("test")). + Where( + sql.And( + sql.GT("id", 100), + sqljson.HasKey("j", sqljson.DotPath("a.*.c")), + sql.EQ("active", true), + ), + ), + wantQuery: "SELECT * FROM `test` WHERE `id` > ? AND JSON_TYPE(`j`, '$.a.*.c') IS NOT NULL AND `active`", + wantArgs: []any{100}, + }, + { + input: sql.Dialect(dialect.Postgres). + Select("*"). + From(sql.Table("test")). + Where(sql.And( + sql.EQ("e", 10), + sqljson.ValueEQ("a", 1, sqljson.DotPath("b.c")), + )), + wantQuery: `SELECT * FROM "test" WHERE "e" = $1 AND ("a"->'b'->>'c')::int = $2`, + wantArgs: []any{10, 1}, + }, + { + input: sql.Dialect(dialect.MySQL). + Select("*"). + From(sql.Table("users")). + Where(sqljson.ValueEQ("a", "a", sqljson.Path("b", "c", "[1]", "d"), sqljson.Unquote(true))), + wantQuery: "SELECT * FROM `users` WHERE JSON_UNQUOTE(JSON_EXTRACT(`a`, '$.b.c[1].d')) = ?", + wantArgs: []any{"a"}, + }, + { + input: sql.Dialect(dialect.Postgres). + Select("*"). + From(sql.Table("users")). + Where(sqljson.ValueEQ("a", "a", sqljson.Path("b", "c", "[1]", "d"), sqljson.Unquote(true))), + wantQuery: `SELECT * FROM "users" WHERE "a"->'b'->'c'->1->>'d' = $1`, + wantArgs: []any{"a"}, + }, + { + input: sql.Dialect(dialect.Postgres). + Select("*"). + From(sql.Table("users")). + Where(sqljson.ValueEQ("a", 1, sqljson.Path("b", "c", "[1]", "d"), sqljson.Cast("int"))), + wantQuery: `SELECT * FROM "users" WHERE ("a"->'b'->'c'->1->>'d')::int = $1`, + wantArgs: []any{1}, + }, + { + input: sql.Dialect(dialect.Postgres). + Select("*"). + From(sql.Table("users")). + Where( + sql.Or( + sqljson.ValueNEQ("a", 1, sqljson.Path("b")), + sqljson.ValueGT("a", 1, sqljson.Path("c")), + sqljson.ValueGTE("a", 1.1, sqljson.Path("d")), + sqljson.ValueLT("a", 1, sqljson.Path("e")), + sqljson.ValueLTE("a", 1, sqljson.Path("f")), + ), + ), + wantQuery: `SELECT * FROM "users" WHERE ("a"->>'b')::int <> $1 OR ("a"->>'c')::int > $2 OR ("a"->>'d')::float >= $3 OR ("a"->>'e')::int < $4 OR ("a"->>'f')::int <= $5`, + wantArgs: []any{1, 1, 1.1, 1, 1}, + }, + { + input: sql.Dialect(dialect.Postgres). + Select("*"). + From(sql.Table("users")). + Where(sqljson.LenEQ("a", 1)), + wantQuery: `SELECT * FROM "users" WHERE JSONB_ARRAY_LENGTH("a") = $1`, + wantArgs: []any{1}, + }, + { + input: sql.Dialect(dialect.MySQL). + Select("*"). + From(sql.Table("users")). + Where(sqljson.LenEQ("a", 1)), + wantQuery: "SELECT * FROM `users` WHERE JSON_LENGTH(`a`, '$') = ?", + wantArgs: []any{1}, + }, + { + input: sql.Dialect(dialect.SQLite). + Select("*"). + From(sql.Table("users")). + Where(sqljson.LenEQ("a", 1)), + wantQuery: "SELECT * FROM `users` WHERE JSON_ARRAY_LENGTH(`a`, '$') = ?", + wantArgs: []any{1}, + }, + { + input: sql.Dialect(dialect.SQLite). + Select("*"). + From(sql.Table("users")). + Where( + sql.Or( + sqljson.LenGT("a", 1, sqljson.Path("b")), + sqljson.LenGTE("a", 1, sqljson.Path("c")), + sqljson.LenLT("a", 1, sqljson.Path("d")), + sqljson.LenLTE("a", 1, sqljson.Path("e")), + ), + ), + wantQuery: "SELECT * FROM `users` WHERE JSON_ARRAY_LENGTH(`a`, '$.b') > ? OR JSON_ARRAY_LENGTH(`a`, '$.c') >= ? OR JSON_ARRAY_LENGTH(`a`, '$.d') < ? OR JSON_ARRAY_LENGTH(`a`, '$.e') <= ?", + wantArgs: []any{1, 1, 1, 1}, + }, + { + input: sql.Dialect(dialect.MySQL). + Select("*"). + From(sql.Table("users")). + Where(sqljson.ValueContains("tags", "foo")), + wantQuery: "SELECT * FROM `users` WHERE JSON_CONTAINS(`tags`, ?, '$') = ?", + wantArgs: []any{"\"foo\"", 1}, + }, + { + input: sql.Dialect(dialect.MySQL). + Select("*"). + From(sql.Table("users")). + Where(sqljson.ValueContains("tags", 1, sqljson.Path("a"))), + wantQuery: "SELECT * FROM `users` WHERE JSON_CONTAINS(`tags`, ?, '$.a') = ?", + wantArgs: []any{"1", 1}, + }, + { + input: sql.Dialect(dialect.SQLite). + Select("*"). + From(sql.Table("users")). + Where(sqljson.ValueContains("tags", "foo")), + wantQuery: "SELECT * FROM `users` WHERE EXISTS(SELECT * FROM JSON_EACH(`tags`, '$') WHERE `value` = ?)", + wantArgs: []any{"foo"}, + }, + { + input: sql.Dialect(dialect.SQLite). + Select("*"). + From(sql.Table("users")). + Where(sqljson.ValueContains("tags", 1, sqljson.Path("a"))), + wantQuery: "SELECT * FROM `users` WHERE EXISTS(SELECT * FROM JSON_EACH(`tags`, '$.a') WHERE `value` = ?)", + wantArgs: []any{1}, + }, + { + input: sql.Dialect(dialect.Postgres). + Select("*"). + From(sql.Table("users")). + Where(sqljson.ValueContains("tags", "foo")), + wantQuery: "SELECT * FROM \"users\" WHERE \"tags\" @> $1", + wantArgs: []any{"\"foo\""}, + }, + { + input: sql.Dialect(dialect.Postgres). + Select("*"). + From(sql.Table("users")). + Where(sqljson.ValueContains("tags", 1, sqljson.Path("a"))), + wantQuery: "SELECT * FROM \"users\" WHERE (\"tags\"->'a')::jsonb @> $1", + wantArgs: []any{"1"}, + }, + { + input: sql.Dialect(dialect.Postgres). + Select("*"). + From(sql.Table("users")). + Where(sqljson.ValueIsNull("c", sqljson.Path("a"))), + wantQuery: `SELECT * FROM "users" WHERE ("c"->'a')::jsonb = 'null'::jsonb`, + }, + { + input: sql.Dialect(dialect.MySQL). + Select("*"). + From(sql.Table("users")). + Where(sqljson.ValueIsNull("c", sqljson.Path("a"))), + wantQuery: "SELECT * FROM `users` WHERE JSON_CONTAINS(`c`, 'null', '$.a')", + }, + { + input: sql.Dialect(dialect.SQLite). + Select("*"). + From(sql.Table("users")). + Where(sqljson.ValueIsNull("c", sqljson.Path("a"))), + wantQuery: "SELECT * FROM `users` WHERE JSON_TYPE(`c`, '$.a') = 'null'", + }, + { + input: sql.Dialect(dialect.Postgres). + Select("*"). + From(sql.Table("users")). + Where(sqljson.ValueIsNotNull("c", sqljson.Path("a"))), + wantQuery: `SELECT * FROM "users" WHERE ("c"->'a')::jsonb <> 'null'::jsonb`, + }, + { + input: sql.Dialect(dialect.MySQL). + Select("*"). + From(sql.Table("users")). + Where(sqljson.ValueIsNotNull("c", sqljson.Path("a"))), + wantQuery: "SELECT * FROM `users` WHERE NOT(JSON_CONTAINS(`c`, 'null', '$.a'))", + }, + { + input: sql.Dialect(dialect.SQLite). + Select("*"). + From(sql.Table("users")). + Where(sqljson.ValueIsNotNull("c", sqljson.Path("a"))), + wantQuery: "SELECT * FROM `users` WHERE JSON_TYPE(`c`, '$.a') <> 'null'", + }, + { + input: sql.Dialect(dialect.Postgres). + Select("*"). + From(sql.Table("users")). + Where(sqljson.StringContains("a", "substr", sqljson.Path("b", "c", "[1]", "d"))), + wantQuery: `SELECT * FROM "users" WHERE "a"->'b'->'c'->1->>'d' LIKE $1`, + wantArgs: []any{"%substr%"}, + }, + { + input: sql.Dialect(dialect.Postgres). + Select("*"). + From(sql.Table("users")). + Where( + sql.And( + sqljson.StringContains("a", "c", sqljson.Path("a")), + sqljson.StringContains("b", "d", sqljson.Path("b")), + ), + ), + wantQuery: `SELECT * FROM "users" WHERE "a"->>'a' LIKE $1 AND "b"->>'b' LIKE $2`, + wantArgs: []any{"%c%", "%d%"}, + }, + { + input: sql.Dialect(dialect.MySQL). + Select("*"). + From(sql.Table("users")). + Where(sqljson.StringContains("a", "substr", sqljson.Path("b", "c", "[1]", "d"))), + wantQuery: "SELECT * FROM `users` WHERE JSON_UNQUOTE(JSON_EXTRACT(`a`, '$.b.c[1].d')) LIKE ?", + wantArgs: []any{"%substr%"}, + }, + { + input: sql.Dialect(dialect.MySQL). + Select("*"). + From(sql.Table("users")). + Where( + sql.And( + sqljson.StringContains("a", "c", sqljson.Path("a")), + sqljson.StringContains("b", "d", sqljson.Path("b")), + ), + ), + wantQuery: "SELECT * FROM `users` WHERE JSON_UNQUOTE(JSON_EXTRACT(`a`, '$.a')) LIKE ? AND JSON_UNQUOTE(JSON_EXTRACT(`b`, '$.b')) LIKE ?", + wantArgs: []any{"%c%", "%d%"}, + }, + { + input: sql.Dialect(dialect.Postgres). + Select("*"). + From(sql.Table("users")). + Where(sqljson.StringHasPrefix("a", "substr", sqljson.Path("b", "c", "[1]", "d"))), + wantQuery: `SELECT * FROM "users" WHERE "a"->'b'->'c'->1->>'d' LIKE $1`, + wantArgs: []any{"substr%"}, + }, + { + input: sql.Dialect(dialect.MySQL). + Select("*"). + From(sql.Table("users")). + Where(sqljson.StringHasPrefix("a", "substr", sqljson.Path("b", "c", "[1]", "d"))), + wantQuery: "SELECT * FROM `users` WHERE JSON_UNQUOTE(JSON_EXTRACT(`a`, '$.b.c[1].d')) LIKE ?", + wantArgs: []any{"substr%"}, + }, + { + input: sql.Dialect(dialect.Postgres). + Select("*"). + From(sql.Table("users")). + Where(sqljson.StringHasSuffix("a", "substr", sqljson.Path("b", "c", "[1]", "d"))), + wantQuery: `SELECT * FROM "users" WHERE "a"->'b'->'c'->1->>'d' LIKE $1`, + wantArgs: []any{"%substr"}, + }, + { + input: sql.Dialect(dialect.MySQL). + Select("*"). + From(sql.Table("users")). + Where(sqljson.StringHasSuffix("a", "substr", sqljson.Path("b", "c", "[1]", "d"))), + wantQuery: "SELECT * FROM `users` WHERE JSON_UNQUOTE(JSON_EXTRACT(`a`, '$.b.c[1].d')) LIKE ?", + wantArgs: []any{"%substr"}, + }, + { + input: sql.Dialect(dialect.MySQL). + Select("*"). + From(sql.Table("users")). + Where(sqljson.ValueIn("a", []any{"a", "b"}, sqljson.Path("b"))), + wantQuery: "SELECT * FROM `users` WHERE JSON_UNQUOTE(JSON_EXTRACT(`a`, '$.b')) IN (?, ?)", + wantArgs: []any{"a", "b"}, + }, + { + input: sql.Dialect(dialect.MySQL). + Select("*"). + From(sql.Table("users")). + Where(sqljson.ValueIn("a", []any{1, 2}, sqljson.Path("b"))), + wantQuery: "SELECT * FROM `users` WHERE JSON_EXTRACT(`a`, '$.b') IN (?, ?)", + wantArgs: []any{1, 2}, + }, + { + input: sql.Dialect(dialect.MySQL). + Select("*"). + From(sql.Table("users")). + Where(sqljson.ValueIn("a", []any{1, "a"}, sqljson.Path("b"))), + wantQuery: "SELECT * FROM `users` WHERE JSON_EXTRACT(`a`, '$.b') IN (?, ?)", + wantArgs: []any{1, "a"}, + }, + { + input: sql.Dialect(dialect.MySQL). + Select("*"). + From(sql.Table("users")). + Where(sqljson.ValueIn("a", []any{1, 2}, sqljson.Path("foo-bar", "3000"))), + wantQuery: "SELECT * FROM `users` WHERE JSON_EXTRACT(`a`, '$.\"foo-bar\".\"3000\"') IN (?, ?)", + wantArgs: []any{1, 2}, + }, + { + input: sql.Dialect(dialect.MySQL). + Select("*"). + From(sql.Table("users")). + OrderExpr( + sqljson.LenPath("a", sqljson.Path("b")), + ), + wantQuery: "SELECT * FROM `users` ORDER BY JSON_LENGTH(`a`, '$.b')", + }, + } + for i, tt := range tests { + t.Run(strconv.Itoa(i), func(t *testing.T) { + query, args := tt.input.Query() + require.Equal(t, tt.wantQuery, query) + require.Equal(t, tt.wantArgs, args) + }) + } +} + +func TestParsePath(t *testing.T) { + tests := []struct { + input string + wantPath []string + wantErr bool + }{ + { + input: "a.b.c", + wantPath: []string{"a", "b", "c"}, + }, + { + input: "a[1][2]", + wantPath: []string{"a", "[1]", "[2]"}, + }, + { + input: "a[1][2].b", + wantPath: []string{"a", "[1]", "[2]", "b"}, + }, + { + input: `a."b.c[0]"`, + wantPath: []string{"a", `"b.c[0]"`}, + }, + { + input: `a."b.c[0]".d`, + wantPath: []string{"a", `"b.c[0]"`, "d"}, + }, + { + input: `...`, + }, + { + input: `.a.b.`, + wantPath: []string{"a", "b"}, + }, + { + input: `a."`, + wantErr: true, + }, + { + input: `a[`, + wantErr: true, + }, + { + input: `a[a]`, + wantErr: true, + }, + } + for i, tt := range tests { + t.Run(strconv.Itoa(i), func(t *testing.T) { + path, err := sqljson.ParsePath(tt.input) + require.Equal(t, tt.wantPath, path) + require.Equal(t, tt.wantErr, err != nil) + }) + } +} + +func TestAppend(t *testing.T) { + tests := []struct { + input sql.Querier + wantQuery string + wantArgs []any + }{ + { + input: func() sql.Querier { + u := sql.Dialect(dialect.Postgres).Update("t") + sqljson.Append(u, "c", []string{"a"}) + return u + }(), + wantQuery: `UPDATE "t" SET "c" = CASE WHEN ("c" IS NULL OR "c" = 'null'::jsonb) THEN $1 ELSE "c" || $2 END`, + wantArgs: []any{`["a"]`, `["a"]`}, + }, + { + input: func() sql.Querier { + u := sql.Dialect(dialect.Postgres).Update("t") + sqljson.Append(u, "c", []string{"a"}, sqljson.Path("a")) + return u + }(), + wantQuery: `UPDATE "t" SET "c" = CASE WHEN (("c"->'a')::jsonb IS NULL OR ("c"->'a')::jsonb = 'null'::jsonb) THEN jsonb_set("c", '{a}', $1, true) ELSE jsonb_set("c", '{a}', "c"->'a' || $2, true) END`, + wantArgs: []any{`["a"]`, `["a"]`}, + }, + { + input: func() sql.Querier { + u := sql.Dialect(dialect.SQLite).Update("t") + sqljson.Append(u, "c", []string{"a"}) + return u + }(), + wantQuery: "UPDATE `t` SET `c` = CASE WHEN (JSON_TYPE(`c`, '$') IS NULL OR JSON_TYPE(`c`, '$') = 'null') THEN ? ELSE JSON_INSERT(`c`, '$[#]', ?) END", + wantArgs: []any{`["a"]`, "a"}, + }, + { + input: func() sql.Querier { + u := sql.Dialect(dialect.SQLite).Update("t") + sqljson.Append(u, "c", []any{"a", struct{}{}}, sqljson.Path("a")) + return u + }(), + wantQuery: "UPDATE `t` SET `c` = CASE WHEN (JSON_TYPE(`c`, '$.a') IS NULL OR JSON_TYPE(`c`, '$.a') = 'null') THEN JSON_SET(`c`, '$.a', JSON(?)) ELSE JSON_INSERT(`c`, '$.a[#]', ?, '$.a[#]', JSON(?)) END", + wantArgs: []any{`["a",{}]`, "a", "{}"}, + }, + { + input: func() sql.Querier { + u := sql.Dialect(dialect.MySQL).Update("t") + sqljson.Append(u, "c", []string{"a"}) + return u + }(), + wantQuery: "UPDATE `t` SET `c` = CASE WHEN (JSON_TYPE(JSON_EXTRACT(`c`, '$')) IS NULL OR JSON_TYPE(JSON_EXTRACT(`c`, '$')) = 'NULL') THEN JSON_ARRAY(?) ELSE JSON_ARRAY_APPEND(`c`, '$', ?) END", + wantArgs: []any{"a", "a"}, + }, + { + input: func() sql.Querier { + u := sql.Dialect(dialect.MySQL).Update("t") + sqljson.Append(u, "c", []string{"a"}, sqljson.Path("a")) + return u + }(), + wantQuery: "UPDATE `t` SET `c` = CASE WHEN (JSON_TYPE(JSON_EXTRACT(`c`, '$.a')) IS NULL OR JSON_TYPE(JSON_EXTRACT(`c`, '$.a')) = 'NULL') THEN JSON_SET(`c`, '$.a', JSON_ARRAY(?)) ELSE JSON_ARRAY_APPEND(`c`, '$.a', ?) END", + wantArgs: []any{"a", "a"}, + }, + } + for i, tt := range tests { + t.Run(strconv.Itoa(i), func(t *testing.T) { + query, args := tt.input.Query() + require.Equal(t, tt.wantQuery, query) + require.Equal(t, tt.wantArgs, args) + }) + } +} diff --git a/doc/.gitignore b/doc/.gitignore new file mode 100644 index 0000000..86fd7b6 --- /dev/null +++ b/doc/.gitignore @@ -0,0 +1,12 @@ +.DS_Store + +node_modules + +lib/core/metadata.js +lib/core/MetadataBlog.js + +website/translated_docs +website/build/ +website/node_modules +website/i18n/* +website/package-lock.json diff --git a/doc/md/aggregate.md b/doc/md/aggregate.md new file mode 100644 index 0000000..e58f0ea --- /dev/null +++ b/doc/md/aggregate.md @@ -0,0 +1,172 @@ +--- +id: aggregate +title: Aggregation +--- + +## Aggregation + +The `Aggregate` option allows adding one or more aggregation functions. + +```go +package main + +import ( + "context" + + "/ent" + "/ent/payment" + "/ent/pet" +) + +func Do(ctx context.Context, client *ent.Client) { + // Aggregate one field. + sum, err := client.Payment.Query(). + Aggregate( + ent.Sum(payment.Amount), + ). + Int(ctx) + + // Aggregate multiple fields. + var v []struct { + Sum, Min, Max, Count int + } + err := client.Pet.Query(). + Aggregate( + ent.Sum(pet.FieldAge), + ent.Min(pet.FieldAge), + ent.Max(pet.FieldAge), + ent.Count(), + ). + Scan(ctx, &v) +} +``` + +## Group By + +Group by `name` and `age` fields of all users, and sum their total age. + +```go +package main + +import ( + "context" + + "/ent" + "/ent/user" +) + +func Do(ctx context.Context, client *ent.Client) { + var v []struct { + Name string `json:"name"` + Age int `json:"age"` + Sum int `json:"sum"` + Count int `json:"count"` + } + err := client.User.Query(). + GroupBy(user.FieldName, user.FieldAge). + Aggregate(ent.Count(), ent.Sum(user.FieldAge)). + Scan(ctx, &v) +} +``` + +Group by one field. + +```go +package main + +import ( + "context" + + "/ent" + "/ent/user" +) + +func Do(ctx context.Context, client *ent.Client) { + names, err := client.User. + Query(). + GroupBy(user.FieldName). + Strings(ctx) +} +``` + +## Group By Edge + +Custom aggregation functions can be useful if you want to write your own storage-specific logic. + +The following shows how to group by the `id` and the `name` of all users and calculate the average `age` of their pets. + +```go +package main + +import ( + "context" + "log" + + "/ent" + "/ent/pet" + "/ent/user" +) + +func Do(ctx context.Context, client *ent.Client) { + var users []struct { + ID int + Name string + Average float64 + } + err := client.User.Query(). + GroupBy(user.FieldID, user.FieldName). + Aggregate(func(s *sql.Selector) string { + t := sql.Table(pet.Table) + s.Join(t).On(s.C(user.FieldID), t.C(pet.OwnerColumn)) + return sql.As(sql.Avg(t.C(pet.FieldAge)), "average") + }). + Scan(ctx, &users) +} +``` + +## Having + Group By + +[Custom SQL modifiers](https://entgo.io/docs/feature-flags/#custom-sql-modifiers) can be useful if you want to control all query parts. +The following shows how to retrieve the oldest users for each role. + + +```go +package main + +import ( + "context" + "log" + + "entgo.io/ent/dialect/sql" + "/ent" + "/ent/user" +) + +func Do(ctx context.Context, client *ent.Client) { + var users []struct { + Id Int + Age Int + Role string + } + err := client.User.Query(). + Modify(func(s *sql.Selector) { + s.GroupBy(user.Role) + s.Having( + sql.EQ( + user.FieldAge, + sql.Raw(sql.Max(user.FieldAge)), + ), + ) + }). + ScanX(ctx, &users) +} + +``` + +**Note:** The `sql.Raw` is crucial to have. It tells the predicate that `sql.Max` is not an argument. + +The above code essentially generates the following SQL query: + +```sql +SELECT * FROM user GROUP BY user.role HAVING user.age = MAX(user.age) +``` diff --git a/doc/md/blob-storage.md b/doc/md/blob-storage.md new file mode 100644 index 0000000..5771edc --- /dev/null +++ b/doc/md/blob-storage.md @@ -0,0 +1,386 @@ +--- +id: blob-storage +title: Blob Storage +--- + +## Overview + +Ent's `field.Blob` type stores field data in external blob storage (e.g., S3, GCS, local filesystem) +rather than in the database. The database only stores a **key** (a short string) referencing the blob. +This keeps database rows small while supporting arbitrarily large binary or text payloads. + +## Quick Start + +### 1. Define a schema with blob fields + +```go +package schema + +import ( + "crypto" + + "entgo.io/ent" + "entgo.io/ent/schema/field" +) + +type Document struct { + ent.Schema +} + +func (Document) Fields() []ent.Field { + return []ent.Field{ + field.String("name").Unique(), + field.Blob("content"), + field.Blob("avatar").Optional(), + } +} +``` + +### 2. Configure blob storage + +Each entity type with blob fields requires a `BlobOpener` — a function that opens a +blob bucket for a given field name: + +```go +import ( + "context" + + "myapp/ent" + "myapp/ent/document" + + _ "gocloud.dev/blob/s3blob" // or fileblob, gcsblob, etc. +) + +client := ent.NewClient( + ent.Driver(drv), + ent.WithBlobOpeners(ent.BlobOpeners{ + Document: func(ctx context.Context, field string) (ent.Blob, error) { + switch field { + case document.FieldContent: + return blob.OpenBucket(ctx, "s3://my-bucket/content") + case document.FieldAvatar: + return blob.OpenBucket(ctx, "s3://my-bucket/avatars") + default: + return nil, fmt.Errorf("unknown blob field: %s", field) + } + }, + }), +) +``` + +### 3. Use the generated API + +```go +// Create — pass []byte directly. +doc := client.Document.Create(). + SetName("readme"). + SetContent([]byte("Hello, World!")). + SaveX(ctx) + +// Read — the struct field holds the loaded value. +fmt.Println(string(doc.Content)) // "Hello, World!" + +// Update +doc = doc.Update(). + SetContent([]byte("Updated content")). + SaveX(ctx) +``` + +## `field.Blob` API + +| Method | Description | +|--------|-------------| +| `Optional()` | Field is nullable; not required on create. | +| `Nillable()` | Struct field is a pointer (distinguishes zero value from unset). | +| `Immutable()` | Field can only be set on create, not updated. | +| `Lazy()` | Mutation accepts `io.Reader`; struct field omitted; use Reader method to read. | +| `HashKey(h)` | Content-addressable key via hash (default: `crypto.SHA256`). | +| `UUIDKey()` | Random UUID v7 key per write. | +| `CheckRefs()` | Verify no row still holds a key before deleting the object. Use with `HashKey`. | +| `DualWrite(...)` | Migration mode: write to both blob storage and database column. | +| `GoType(typ)` | Override the default `[]byte` Go type. | +| `ValueScanner(vs)` | Custom codec between Go type and raw bytes (required for non-`[]byte`/`string` GoType). | +| `StorageKey(key)` | Override the database column name. | +| `StructTag(s)` | Set the struct tag on the generated field. | +| `Comment(c)` | Set the field comment. | +| `Annotations(...)` | Attach codegen annotations. | +| `Deprecated(...)` | Mark the field as deprecated. | + +## Key Strategies + +Every blob field requires a **key function** that determines the storage key for a given +piece of data. By default (when neither `UUIDKey` nor `HashKey` is called), blobs use +SHA-256 content hashing. + +### HashKey (default) + +Content-addressable storage: the data is hashed to produce the key. Identical content +always maps to the same key, enabling deduplication and write-skip optimizations on update. + +```go +field.Blob("content").HashKey(crypto.SHA256) +``` + +On update, if the new content produces the same hash as the existing key, the write to +blob storage is skipped entirely. + +Because rows with identical content share one object, pair `HashKey` with +[`CheckRefs`](#shared-objects-and-checkrefs) so cleanup does not remove an object another row +still points at. + +### UUIDKey + +Each write generates a new random UUID (v7) as the storage key. This guarantees uniqueness +but does not deduplicate. + +```go +field.Blob("content").UUIDKey() +``` + +No two rows can hold the same key, so cleanup can remove an object as soon as its row lets go. +Leave [`CheckRefs`](#shared-objects-and-checkrefs) off here — the lookup would run on every +mutation to answer a question whose answer is fixed. + +## Lazy Fields + +By default, blob data is automatically loaded into the entity struct field when the row is +scanned from the database. For large blobs where you don't always need the data in memory, +use `Lazy()`: + +```go +field.Blob("content").Lazy() +``` + +With `Lazy()`: +- The **mutation builder** accepts an `io.Reader` (which is fully buffered before writing). +- The entity **struct field is omitted** — data is not loaded on scan. +- A **Reader method** (e.g., `ContentReader`) is generated to explicitly open a reader from storage. + +```go +// Create with io.Reader. +doc := client.Document.Create(). + SetName("large-file"). + SetContent(bytes.NewReader(largeData)). + SaveX(ctx) + +// Read explicitly via the Reader method. +rc, err := doc.ContentReader(ctx) +if err != nil { ... } +defer rc.Close() +data, _ := io.ReadAll(rc) +``` + +## DualWrite (Migration Mode) + +`DualWrite()` preserves the original bytes column alongside the blob key column. This is +useful when migrating an existing column to blob storage: + +```go +field.Blob("payload"). + DualWrite(map[string]string{ + dialect.MySQL: "json", + dialect.Postgres: "jsonb", + dialect.SQLite: "json", + }) +``` + +In DualWrite mode: +- **Writes** go to both blob storage and the database column. +- **Reads** prefer blob storage (if a key exists) and fall back to the database column. + +The optional `columnType` argument overrides the database column type per dialect to prevent +schema drift when migrating from an existing column definition. + +## Custom GoType + +Blob fields default to `[]byte`. You can override the Go type: + +```go +// String type (automatic conversion). +field.Blob("description").GoType("") + +// Custom struct with a ValueScanner. +field.Blob("config"). + GoType(&MyConfig{}). + ValueScanner(configScanner{}) +``` + +When using a custom `GoType` other than `string`, a `ValueScanner` must be provided +to encode/decode between the Go type and the raw bytes stored in blob storage. + +## Blob Interface + +Any blob storage backend must implement the `ent.Blob` interface: + +```go +type Blob interface { + NewReader(ctx context.Context, key string) (io.ReadCloser, error) + NewWriter(ctx context.Context, key string) (io.WriteCloser, error) + Delete(ctx context.Context, key string) error + Close() error +} +``` + +- `NewReader` should return `fs.ErrNotExist` (or a wrapping error) when the key does not exist. +- `Delete` should return `nil` (not an error) when the key does not exist. + +The [Go CDK `blob` package](https://gocloud.dev/howto/blob/) provides implementations +for S3, GCS, Azure, and local filesystem that satisfy this interface via a thin adapter. + +## Lifecycle and Cleanup + +### Shared objects and `CheckRefs` + +By default, cleanup removes a blob object as soon as the row that wrote it goes away or moves +off the key. That is correct exactly when no two rows can hold the same key — the guarantee +[`UUIDKey`](#uuidkey) gives you, since every write generates a fresh key. + +It is **not** correct for `HashKey`. There the key *is* the content, so any two rows holding +identical content point at one object in storage. That is what makes deduplication work, and +it means removing the object because one row let go can strand every other row still pointing +at it. Concretely, without the check: + +- A failed INSERT rolls back "its" blob — which may be the object an existing row with the + same content already points at. +- Deleting one of two rows with identical content removes the object the other still uses. +- Updating a row off a shared key removes an object another row still holds. + +`CheckRefs` turns on the guard. Add it to every content-addressed blob field: + +```go +field.Blob("content"). + HashKey(crypto.SHA256). + CheckRefs() +``` + +It is opt-in and per field, so a schema can mix strategies: pay for the lookup on the +content-addressed fields and skip it on the UUID-keyed ones, where the answer is always the +same. With it on, every cleanup path looks up the keys it is about to remove in their key +columns and skips the ones a row still holds. + +The lookup is timed so the table already reads the way the mutation will leave it — before +the INSERT for blobs written ahead of a row, after the UPDATE or DELETE for keys the mutation +gives up — and always while the statement and its transaction are still open. A row inserted +concurrently between the lookup and the delete is not seen. Closing that window entirely +requires reference counting or a periodic sweep of the bucket against the key columns. + +### Index the key columns + +**Index the key column of every field using `CheckRefs`.** The lookup asks only *whether* a +key is still in use, never how often, so an index lets it stop at the first matching row +instead of scanning the table. Every create, update, and delete touching that field runs the +query, so an unindexed key column turns each of those mutations into a full table scan. + +Declare the index by **field** name — for a blob field, `index.Fields` targets the generated +key column (`_key` by default): + +```go +func (Document) Indexes() []ent.Index { + return []ent.Index{ + index.Fields("content"), // indexes content_key + index.Fields("avatar"), // indexes avatar_key + } +} +``` + +Leave them non-unique — rows sharing content share a key, which is the whole point. + +One exception: a `DualWrite` field that is not also `Lazy()` keeps a real data column, and +`index.Fields` targets *that* column rather than the key column. For those fields, create the +index on `_key` outside the schema. + +Fields without `CheckRefs` need no such index — they never run the lookup. + +### Create + +Blobs are written **before** the database row is inserted. If the SQL INSERT fails +(e.g., constraint violation), the generated code deletes the just-written blobs — except, +for `CheckRefs` fields, any that rows in the database already reference. + +### Update + +On update: +1. New blob data is written to storage. +2. The SQL UPDATE executes. +3. On success, old blobs are deleted — for `CheckRefs` fields, unless another row still + references them. +4. On SQL failure, newly-written blobs are rolled back (deleted) under the same rule. + +When using `HashKey`, if the content hasn't changed (same hash), the write is skipped entirely. + +### Delete + +Generated delete builders query existing blob keys before deleting the row, then remove +the blobs from storage after a successful SQL DELETE — keeping, for `CheckRefs` fields, any +object that rows outside the deleted set still reference. + +## OnConflict (Upsert) + +Blob fields work with `OnConflict` / upsert builders. Blobs are written to storage +**before** the SQL executes. If the INSERT succeeds (no conflict), the new key is stored. +If there is a conflict, behavior depends on the conflict action and key strategy. + +The following per-field methods are generated on the upsert builder: + +- **`Update()`** — Sets the blob key column (and data column for DualWrite fields) to + the value provided on create. +- **`Clear()`** — Nulls the blob key column (and data column for DualWrite). Only + generated for optional fields. **Note:** this does not delete the old blob from storage — + the previously-referenced blob becomes orphaned. Use a regular Update with `Clear()` + if you need storage cleanup. + +```go +client.Document.Create(). + SetName("readme"). + SetContent([]byte("data")). + SetAttachment([]byte("file")). + OnConflict(). + UpdateContent(). // update only the content blob + UpdateAttachment(). // update only the attachment blob + ExecX(ctx) +``` + +You can also use `UpdateNewValues()` to update all fields (including all blob key columns) +at once. + +### With HashKey (content-addressable) + +Since the key is derived from content, identical data always produces the same key: + +- **`Update()`** — The SQL updates the key column to the new key. If the content + matches what the row already held, the key is unchanged and it is a no-op in storage. + If the content differs, the row moves to the new key and the old object is left behind — + `OnConflict` does not query old keys the way a regular Update does. +- **`Ignore()`** — The SQL sets each column to itself. If the content differs from the + existing row's, the blob written to storage has no database reference. +- **`DoNothing()`** — No row is inserted, which surfaces as `sql.ErrNoRows`. The blob + written to storage is deleted, unless the conflicting row already references that same + key — the common case when the content is identical. + +Upserting identical content is idempotent in storage: the write lands on the key that is +already there, and cleanup leaves it in place because the existing row references it. + +```go +client.Document.Create(). + SetName("readme"). + SetContent([]byte("data")). + OnConflict(). + UpdateContent(). + ExecX(ctx) +``` + +### With UUIDKey (random keys) + +Each write generates a new unique key. This means the blob written before the SQL is always +at a **new** key that didn't previously exist: + +- **`Update()`** — The SQL updates the key column to the new UUID key. The old blob + at the previous key becomes orphaned and is **not** automatically cleaned up (OnConflict + does not query old keys like a regular Update does). +- **`Ignore()`** — The SQL succeeds without changing anything; the row keeps its existing + key. The newly-written blob is orphaned in storage with no database reference. +- **`DoNothing()`** — No row is inserted, which surfaces as `sql.ErrNoRows`. Because a fresh + UUID is referenced by nobody, the newly-written blob is deleted. + +For these reasons, `UUIDKey` is **not recommended** with `OnConflict`. If you need upsert +semantics, prefer `HashKey` which is inherently idempotent. diff --git a/doc/md/ci.mdx b/doc/md/ci.mdx new file mode 100644 index 0000000..82eccbc --- /dev/null +++ b/doc/md/ci.mdx @@ -0,0 +1,293 @@ +--- +id: ci +title: Continuous Integration +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +To ensure the quality of their software, teams often apply _Continuous +Integration_ workflows, commonly known as CI. With CI, teams continuously run a suite +of automated verifications against every change to the code-base. During CI, +teams may run many kinds of verifications: +* Compilation or build of the most recent version to make sure it + isn't broken. +* Linting to enforce any accepted code-style standards. +* Unit tests that verify individual components work as expected + and that changes to the codebase do not cause regressions in + other areas. +* Security scans to make sure no known vulnerabilities are introduced + to the codebase. +* And much more! + +From our discussions with the Ent community, we have learned +that many teams using Ent already use CI and would like to enforce some +Ent-specific verifications into their workflows. + +To support the community with this effort we have started this guide which +documents common best practices to verify in CI and introduces +[ent/contrib/ci](https://github.com/ent/contrib/tree/master/ci) a GitHub Action +we maintain that codifies them. + +## Verify all generated files are checked in + +Ent heavily relies on code generation. In our experience, generated code +should always be checked into source control. This is done for two reasons: +* If generated code is checked into source control, it can be read + along with the main application code. Having generated code present when + the code is reviewed or when a repository is browsed is essential to get + a complete picture of how things work. +* Differences in development environments between team members can easily be + spotted and remedied. This further reduces the chance of "it works on my + machine" type issues since everyone is running the same code. + +If you're using GitHub for source control, it's easy to verify that all generated +files are checked in with the `ent/contrib/ci` GitHub Action. +Otherwise, we supply a simple bash script that you can integrate in your existing +CI flow. + + + +Simply add a file named `.github/workflows/ent-ci.yaml` in your repository: + +```yaml +name: EntCI +on: + push: + # Run whenever code is changed in the master. + branches: + - master + # Run on PRs where something changed under the `ent/` directory. + pull_request: + paths: + - 'ent/*' +jobs: + ent: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3.0.1 + - uses: actions/setup-go@v3 + with: + go-version-file: 'go.mod' + - uses: ent/contrib/ci@master +``` + + + + +```bash +go generate ./... +status=$(git status --porcelain) +if [ -n "$status" ]; then + echo "you need to run 'go generate ./...' and commit the changes" + echo "$status" + exit 1 +fi +``` + + + + +## Lint migration files + +Changes to your project's Ent schema almost always result in a modification +of your database. If you are using [Versioned Migrations](/docs/versioned-migrations) +to manage changes to your database schema, you can run [migration linting](https://atlasgo.io/versioned/lint) +as part of your continuous integration flow. This is done for multiple reasons: + +* Linting replays your migration directory on a [database container](https://atlasgo.io/concepts/dev-database) to + make sure all SQL statements are valid and in the correct order. +* [Migration directory integrity](/docs/versioned-migrations#atlas-migration-directory-integrity-file) + is enforced - ensuring that history wasn't accidentally changed and that migrations that are + planned in parallel are unified to a clean linear history. +* Destructive changes are detected notifying you of any potential data loss that may be + caused by your migrations way before they reach your production database. +* Linting detects data-dependant changes that _may_ fail upon deployment and require + more careful review from your side. + +If you're using GitHub, you can use the [Official Atlas Action](https://github.com/ariga/atlas-action) +to run migration linting during CI. + +Add `.github/workflows/atlas-ci.yaml` to your repo with the following contents: + + + +```yaml +name: Atlas CI +on: + # Run whenever code is changed in the master branch, + # change this to your root branch. + push: + branches: + - master + # Run on PRs where something changed under the `ent/migrate/migrations/` directory. + pull_request: + paths: + - 'ent/migrate/migrations/*' +jobs: + lint: + services: + # Spin up a mysql:8.0.29 container to be used as the dev-database for analysis. + mysql: + image: mysql:8.0.29 + env: + MYSQL_ROOT_PASSWORD: pass + MYSQL_DATABASE: test + ports: + - "3306:3306" + options: >- + --health-cmd "mysqladmin ping -ppass" + --health-interval 10s + --health-start-period 10s + --health-timeout 5s + --health-retries 10 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: ariga/setup-atlas@v0 + with: + cloud-token: ${{ secrets.ATLAS_CLOUD_TOKEN }} + - uses: ariga/atlas-action/migrate/lint@v1 + with: + dir: 'file://ent/migrate/migrations' + dir-name: 'my-project' # The name of the project in Atlas Cloud + dev-url: "mysql://root:pass@localhost:3306/dev" +``` + + + + +```yaml +name: Atlas CI +on: + # Run whenever code is changed in the master branch, + # change this to your root branch. + push: + branches: + - master + # Run on PRs where something changed under the `ent/migrate/migrations/` directory. + pull_request: + paths: + - 'ent/migrate/migrations/*' +jobs: + lint: + services: + # Spin up a maria:11 container to be used as the dev-database for analysis. + mariadb: + image: mariadb:11 + env: + MYSQL_DATABASE: dev + MYSQL_ROOT_PASSWORD: pass + ports: + - "3306:3306" + options: >- + --health-cmd "healthcheck.sh --su-mysql --connect --innodb_initialized" + --health-interval 10s + --health-start-period 10s + --health-timeout 5s + --health-retries 10 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: ariga/setup-atlas@v0 + with: + cloud-token: ${{ secrets.ATLAS_CLOUD_TOKEN }} + - uses: ariga/atlas-action/migrate/lint@v1 + with: + dir: 'file://ent/migrate/migrations' + dir-name: 'my-project' # The name of the project in Atlas Cloud + dev-url: "maria://root:pass@localhost:3306/dev" +``` + + + + +```yaml +name: Atlas CI +on: + # Run whenever code is changed in the master branch, + # change this to your root branch. + push: + branches: + - master + # Run on PRs where something changed under the `ent/migrate/migrations/` directory. + pull_request: + paths: + - 'ent/migrate/migrations/*' +jobs: + lint: + services: + # Spin up a postgres:15 container to be used as the dev-database for analysis. + postgres: + image: postgres:15 + env: + POSTGRES_DB: dev + POSTGRES_PASSWORD: pass + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-start-period 10s + --health-timeout 5s + --health-retries 5 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: ariga/setup-atlas@v0 + with: + cloud-token: ${{ secrets.ATLAS_CLOUD_TOKEN }} + - uses: ariga/atlas-action/migrate/lint@v1 + with: + dir: 'file://ent/migrate/migrations' + dir-name: 'my-project' # The name of the project in Atlas Cloud + dev-url: postgres://postgres:pass@localhost:5432/dev?sslmode=disable +``` + + + + +```yaml +name: Atlas CI +on: + # Run whenever code is changed in the master branch, + # change this to your root branch. + push: + branches: + - master + # Run on PRs where something changed under the `ent/migrate/migrations/` directory. + pull_request: + paths: + - 'ent/migrate/migrations/*' +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: ariga/setup-atlas@v0 + with: + cloud-token: ${{ secrets.ATLAS_CLOUD_TOKEN }} + - uses: ariga/atlas-action/migrate/lint@v1 + with: + dir: 'file://ent/migrate/migrations' + dir-name: 'my-project' # The name of the project in Atlas Cloud + dev-url: sqlite://file?mode=memory&_fk=1 +``` + + + + +Notice that running `atlas migrate lint` requires a clean [dev-database](https://atlasgo.io/concepts/dev-database) +which is provided by the `services` block in the example code above. \ No newline at end of file diff --git a/doc/md/code-gen.md b/doc/md/code-gen.md new file mode 100644 index 0000000..aa9c6cd --- /dev/null +++ b/doc/md/code-gen.md @@ -0,0 +1,296 @@ +--- +id: code-gen +title: Introduction +--- + +## Installation + +The project comes with a codegen tool called `ent`. In order to install +`ent` run the following command: + +```bash +go get entgo.io/ent/cmd/ent +``` + +## Initialize A New Schema + +In order to generate one or more schema templates, run `ent init` as follows: + +```bash +go run -mod=mod entgo.io/ent/cmd/ent new User Pet +``` + +`init` will create the 2 schemas (`user.go` and `pet.go`) under the `ent/schema` directory. +If the `ent` directory does not exist, it will create it as well. The convention +is to have an `ent` directory under the root directory of the project. + +## Generate Assets + +After adding a few [fields](schema-fields.mdx) and [edges](schema-edges.mdx), you want to generate +the assets for working with your entities. Run `ent generate` from the root directory of the project, +or use `go generate`: + + +```bash +go generate ./ent +``` + +The `generate` command generates the following assets for the schemas: + +- `Client` and `Tx` objects used for interacting with the graph. +- CRUD builders for each schema type. See [CRUD](crud.mdx) for more info. +- Entity object (Go struct) for each of the schema types. +- Package containing constants and predicates used for interacting with the builders. +- A `migrate` package for SQL dialects. See [Migration](migrate.md) for more info. +- A `hook` package for adding mutation middlewares. See [Hooks](hooks.md) for more info. + +## Version Compatibility Between `entc` And `ent` + +When working with `ent` CLI in a project, you want to make sure the version being +used by the CLI is **identical** to the `ent` version used by your project. + +One of the options for achieving this is asking `go generate` to use the version +mentioned in the `go.mod` file when running `ent`. If your project does not use +[Go modules](https://github.com/golang/go/wiki/Modules#quick-start), setup one as follows: + +```console +go mod init +``` + +And then, re-run the following command in order to add `ent` to your `go.mod` file: + +```console +go get entgo.io/ent/cmd/ent +``` + +Add a `generate.go` file to your project under `/ent`: + +```go +package ent + +//go:generate go run -mod=mod entgo.io/ent/cmd/ent generate ./schema +``` + +Finally, you can run `go generate ./ent` from the root directory of your project +in order to run `ent` code generation on your project schemas. + +## Code Generation Options + +For more info about codegen options, run `ent generate -h`: + +```console +generate go code for the schema directory + +Usage: + ent generate [flags] path + +Examples: + ent generate ./ent/schema + ent generate github.com/a8m/x + +Flags: + --feature strings extend codegen with additional features + --header string override codegen header + -h, --help help for generate + --storage string storage driver to support in codegen (default "sql") + --target string target directory for codegen + --template strings external templates to execute +``` + +## Storage Options + +`ent` can generate assets for both SQL and Gremlin dialect. The default dialect is SQL. + +## External Templates + +`ent` accepts external Go templates to execute. If the template name already defined by +`ent`, it will override the existing one. Otherwise, it will write the execution output to +a file with the same name as the template. The flag format supports `file`, `dir` and `glob` +as follows: + +```console +go run -mod=mod entgo.io/ent/cmd/ent generate --template --template glob="path/to/*.tmpl" ./ent/schema +``` + +More information and examples can be found in the [external templates doc](templates.md). + +## Use `entc` as a Package + +Another option for running `ent` code generation is to create a file named `ent/entc.go` with the following content, +and then the `ent/generate.go` file to execute it: + +```go title="ent/entc.go" +// +build ignore + +package main + +import ( + "log" + + "entgo.io/ent/entc" + "entgo.io/ent/entc/gen" + "entgo.io/ent/schema/field" +) + +func main() { + if err := entc.Generate("./schema", &gen.Config{}); err != nil { + log.Fatal("running ent codegen:", err) + } +} +``` + +```go title="ent/generate.go" +package ent + +//go:generate go run -mod=mod entc.go +``` + +The full example exists in [GitHub](https://github.com/ent/ent/tree/master/examples/entcpkg). + +## Schema Description + +In order to get a description of your graph schema, run: + +```bash +go run -mod=mod entgo.io/ent/cmd/ent describe ./ent/schema +``` + +An example for the output is as follows: + +```console +Pet: + +-------+---------+--------+----------+----------+---------+---------------+-----------+-----------------------+------------+ + | Field | Type | Unique | Optional | Nillable | Default | UpdateDefault | Immutable | StructTag | Validators | + +-------+---------+--------+----------+----------+---------+---------------+-----------+-----------------------+------------+ + | id | int | false | false | false | false | false | false | json:"id,omitempty" | 0 | + | name | string | false | false | false | false | false | false | json:"name,omitempty" | 0 | + +-------+---------+--------+----------+----------+---------+---------------+-----------+-----------------------+------------+ + +-------+------+---------+---------+----------+--------+----------+ + | Edge | Type | Inverse | BackRef | Relation | Unique | Optional | + +-------+------+---------+---------+----------+--------+----------+ + | owner | User | true | pets | M2O | true | true | + +-------+------+---------+---------+----------+--------+----------+ + +User: + +-------+---------+--------+----------+----------+---------+---------------+-----------+-----------------------+------------+ + | Field | Type | Unique | Optional | Nillable | Default | UpdateDefault | Immutable | StructTag | Validators | + +-------+---------+--------+----------+----------+---------+---------------+-----------+-----------------------+------------+ + | id | int | false | false | false | false | false | false | json:"id,omitempty" | 0 | + | age | int | false | false | false | false | false | false | json:"age,omitempty" | 0 | + | name | string | false | false | false | false | false | false | json:"name,omitempty" | 0 | + +-------+---------+--------+----------+----------+---------+---------------+-----------+-----------------------+------------+ + +------+------+---------+---------+----------+--------+----------+ + | Edge | Type | Inverse | BackRef | Relation | Unique | Optional | + +------+------+---------+---------+----------+--------+----------+ + | pets | Pet | false | | O2M | false | true | + +------+------+---------+---------+----------+--------+----------+ +``` + +## Code Generation Hooks + +The `entc` package provides an option to add a list of hooks (middlewares) to the code-generation phase. +This option is ideal for adding custom validators for the schema, or for generating additional assets +using the graph schema. + +```go +// +build ignore + +package main + +import ( + "fmt" + "log" + "reflect" + + "entgo.io/ent/entc" + "entgo.io/ent/entc/gen" +) + +func main() { + err := entc.Generate("./schema", &gen.Config{ + Hooks: []gen.Hook{ + EnsureStructTag("json"), + }, + }) + if err != nil { + log.Fatalf("running ent codegen: %v", err) + } +} + +// EnsureStructTag ensures all fields in the graph have a specific tag name. +func EnsureStructTag(name string) gen.Hook { + return func(next gen.Generator) gen.Generator { + return gen.GenerateFunc(func(g *gen.Graph) error { + for _, node := range g.Nodes { + for _, field := range node.Fields { + tag := reflect.StructTag(field.StructTag) + if _, ok := tag.Lookup(name); !ok { + return fmt.Errorf("struct tag %q is missing for field %s.%s", name, node.Name, field.Name) + } + } + } + return next.Generate(g) + }) + } +} +``` + +## External Dependencies + +In order to extend the generated client and builders under the `ent` package, and inject them external +dependencies as struct fields, use the `entc.Dependency` option in your [`ent/entc.go`](#use-entc-as-a-package) +file: + +```go title="ent/entc.go" {3-12} +func main() { + opts := []entc.Option{ + entc.Dependency( + entc.DependencyType(&http.Client{}), + ), + entc.Dependency( + entc.DependencyName("Writer"), + entc.DependencyTypeInfo(&field.TypeInfo{ + Ident: "io.Writer", + PkgPath: "io", + }), + ), + } + if err := entc.Generate("./schema", &gen.Config{}, opts...); err != nil { + log.Fatalf("running ent codegen: %v", err) + } +} +``` + +Then, use it in your application: + +```go title="example_test.go" {5-6,15-16} +func Example_Deps() { + client, err := ent.Open( + "sqlite3", + "file:ent?mode=memory&cache=shared&_fk=1", + ent.Writer(os.Stdout), + ent.HTTPClient(http.DefaultClient), + ) + if err != nil { + log.Fatalf("failed opening connection to sqlite: %v", err) + } + defer client.Close() + // An example for using the injected dependencies in the generated builders. + client.User.Use(func(next ent.Mutator) ent.Mutator { + return hook.UserFunc(func(ctx context.Context, m *ent.UserMutation) (ent.Value, error) { + _ = m.HTTPClient + _ = m.Writer + return next.Mutate(ctx, m) + }) + }) + // ... +} +``` + +The full example exists in [GitHub](https://github.com/ent/ent/tree/master/examples/entcpkg). + +## Feature Flags + +The `entc` package provides a collection of code-generation features that be added or removed using flags. + +For more information, please see the [features-flags page](features.md). diff --git a/doc/md/community.md b/doc/md/community.md new file mode 100644 index 0000000..bf4e300 --- /dev/null +++ b/doc/md/community.md @@ -0,0 +1,17 @@ +--- +id: community +title: Join our Community +--- + +Ent maintainers, contributors and users hang out in our Discord server and the #ent channel in the Gophers Slack workspace. + +### Discord + +To Join our Discord server, click [here](https://discord.gg/qZmPgTE6RX). + +### Slack + +To join the discussion in slack: + +1. Sign up to the Gophers Slack workspace via the [invite link](https://invite.slack.golangbridge.org/). +2. Join the #ent channel manually or via [this link](https://app.slack.com/client/T029RQSE6/C01FMSQDT53). (it will work only if you are logged in to slack from your browser) \ No newline at end of file diff --git a/doc/md/components/_atlas_migrate_apply.mdx b/doc/md/components/_atlas_migrate_apply.mdx new file mode 100644 index 0000000..af30b44 --- /dev/null +++ b/doc/md/components/_atlas_migrate_apply.mdx @@ -0,0 +1,48 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + + + + +```shell +atlas migrate apply \ + --dir "file://ent/migrate/migrations" \ + --url "mysql://root:pass@localhost:3306/example" +``` + + + + +```shell +atlas migrate apply \ + --dir "file://ent/migrate/migrations" \ + --url "maria://root:pass@localhost:3306/example" +``` + + + + +```shell +atlas migrate apply \ + --dir "file://ent/migrate/migrations" \ + --url "postgres://postgres:pass@localhost:5432/database?search_path=public&sslmode=disable" +``` + + + + +```shell +atlas migrate apply \ + --dir "file://ent/migrate/migrations" \ + --url "sqlite://file.db?_fk=1" +``` + + + diff --git a/doc/md/components/_atlas_migrate_diff.mdx b/doc/md/components/_atlas_migrate_diff.mdx new file mode 100644 index 0000000..2d00b30 --- /dev/null +++ b/doc/md/components/_atlas_migrate_diff.mdx @@ -0,0 +1,52 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + + + + +```shell +atlas migrate diff migration_name \ + --dir "file://ent/migrate/migrations" \ + --to "ent://ent/schema" \ + --dev-url "docker://mysql/8/ent" +``` + + + + +```shell +atlas migrate diff migration_name \ + --dir "file://ent/migrate/migrations" \ + --to "ent://ent/schema" \ + --dev-url "docker://mariadb/latest/test" +``` + + + + +```shell +atlas migrate diff migration_name \ + --dir "file://ent/migrate/migrations" \ + --to "ent://ent/schema" \ + --dev-url "docker://postgres/15/test?search_path=public" +``` + + + + +```shell +atlas migrate diff migration_name \ + --dir "file://ent/migrate/migrations" \ + --to "ent://ent/schema" \ + --dev-url "sqlite://file?mode=memory&_fk=1" +``` + + + \ No newline at end of file diff --git a/doc/md/components/_installation_instructions.mdx b/doc/md/components/_installation_instructions.mdx new file mode 100644 index 0000000..d0075ba --- /dev/null +++ b/doc/md/components/_installation_instructions.mdx @@ -0,0 +1,53 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +To install the latest release of Atlas, simply run one of the following commands in your terminal, or check out the +[Atlas website](https://atlasgo.io/getting-started#installation): + + + + +```shell +curl -sSf https://atlasgo.sh | sh +``` + + + + +```shell +brew install ariga/tap/atlas +``` + + + + +```shell +docker pull arigaio/atlas +docker run --rm arigaio/atlas --help +``` + +If the container needs access to the host network or a local directory, use the `--net=host` flag and mount the desired +directory: + +```shell +docker run --rm --net=host \ + -v $(pwd)/migrations:/migrations \ + arigaio/atlas migrate apply + --url "mysql://root:pass@:3306/test" +``` + + + + +Download the [latest release](https://release.ariga.io/atlas/atlas-windows-amd64-latest.exe) and +move the atlas binary to a file location on your system PATH. + + + diff --git a/doc/md/contributors.md b/doc/md/contributors.md new file mode 100644 index 0000000..a23d2d9 --- /dev/null +++ b/doc/md/contributors.md @@ -0,0 +1,144 @@ +--- +id: contributors +title: Contributors +--- + +Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Ariel Mashraki
Ariel Mashraki

🚧 📖 💻
Alex Snast
Alex Snast

💻
Rotem Tamir
Rotem Tamir

🚧 📖 💻
Ciaran Liedeman
Ciaran Liedeman

💻
Marwan Sulaiman
Marwan Sulaiman

💻
Nathaniel Peiffer
Nathaniel Peiffer

💻
Travis Cline
Travis Cline

💻
Jeremy
Jeremy

💻
aca
aca

💻
BrentChesny
BrentChesny

💻 📖
Giau. Tran Minh
Giau. Tran Minh

💻 👀
Hylke Visser
Hylke Visser

💻
Pavel Kerbel
Pavel Kerbel

💻
zhangnan
zhangnan

💻
mori yuta
mori yuta

💻 🌍 👀
Christoph Hartmann
Christoph Hartmann

💻
Ruben de Vries
Ruben de Vries

💻
Aleksandr Razumov
Aleksandr Razumov

💻
apbuteau
apbuteau

💻
Harold.Luo
Harold.Luo

💻
ido shveki
ido shveki

💻
MasseElch
MasseElch

💻
Jian Li
Jian Li

💻
Noah-Jerome Lotzer
Noah-Jerome Lotzer

💻
danforth
danforth

💻
maxilozoz
maxilozoz

💻
zzwx
zzwx

💻
MengYX
MengYX

🌍
mattn
mattn

🌍
Hugo Briand
Hugo Briand

💻
Dan Enman
Dan Enman

💻
Rumen Nikiforov
Rumen Nikiforov

💻
陈杨文
陈杨文

💻
Qiaosen (Joeson) Huang
Qiaosen (Joeson) Huang

🐛
AlonDavidBehr
AlonDavidBehr

💻 👀
DuGlaser
DuGlaser

📖
Shane Hanna
Shane Hanna

📖
Mahmudul Haque
Mahmudul Haque

💻
Benjamin Bourgeais
Benjamin Bourgeais

💻
8ayac(Yoshinori Hayashi)
8ayac(Yoshinori Hayashi)

📖
y-yagi
y-yagi

📖
Ben Woodward
Ben Woodward

💻
WzyJerry
WzyJerry

💻
Tarrence van As
Tarrence van As

📖 💻
Yuya Sumie
Yuya Sumie

📖
Michal Mazurek
Michal Mazurek

💻
Takafumi Umemoto
Takafumi Umemoto

📖
Khadija Sidhpuri
Khadija Sidhpuri

💻
Neel Modi
Neel Modi

💻
Boris Shomodjvarac
Boris Shomodjvarac

📖
Sadman Sakib
Sadman Sakib

📖
dakimura
dakimura

💻
Risky Feryansyah
Risky Feryansyah

💻
seiichi
seiichi

💻
Emmanuel T Odeke
Emmanuel T Odeke

💻
Hiroki Isogai
Hiroki Isogai

📖
李清山
李清山

💻
s-takehana
s-takehana

📖
Kuiba
Kuiba

💻
storyicon
storyicon

💻
Evan Lurvey
Evan Lurvey

💻
Brian
Brian

📖
Shen Yang
Shen Yang

💻
sivchari
sivchari

💻
mook
mook

💻
heliumbrain
heliumbrain

📖
Jeremy Maxey-Vesperman
Jeremy Maxey-Vesperman

💻 📖
Christopher Schmitt
Christopher Schmitt

📖
Gerardo Reyes
Gerardo Reyes

💻
Naor Matania
Naor Matania

💻
idc77
idc77

📖
Sungyun Hur
Sungyun Hur

📖
peanut-pg
peanut-pg

📖
Mehmet Yılmaz
Mehmet Yılmaz

💻
Roman Maklakov
Roman Maklakov

💻
Genevieve
Genevieve

💻
Clarence
Clarence

💻
Nicholas Anderson
Nicholas Anderson

💻
Zhizhen He
Zhizhen He

💻
Pedro Henrique
Pedro Henrique

💻
MrParano1d
MrParano1d

💻
Thomas Prebble
Thomas Prebble

💻
Huy TQ
Huy TQ

💻
maorlipchuk
maorlipchuk

💻
Motonori Iwata
Motonori Iwata

📖
Charles Ge
Charles Ge

💻
Thomas Meitz
Thomas Meitz

💻 📖
Justin Johnson
Justin Johnson

💻
hax10
hax10

💻
water-a
water-a

🐛
jhwz
jhwz

📖
Dan Kortschak
Dan Kortschak

📖
+ + + + + + +This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! diff --git a/doc/md/crud.mdx b/doc/md/crud.mdx new file mode 100644 index 0000000..3758535 --- /dev/null +++ b/doc/md/crud.mdx @@ -0,0 +1,637 @@ +--- +id: crud +title: CRUD API +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +As mentioned in the [introduction](code-gen.md) section, running `ent` on the schemas, +will generate the following assets: + +- `Client` and `Tx` objects used for interacting with the graph. +- CRUD builders for each schema type. +- Entity object (Go struct) for each of the schema type. +- Package containing constants and predicates used for interacting with the builders. +- A `migrate` package for SQL dialects. See [Migration](migrate.md) for more info. + +## Create A New Client + + + + +```go +package main + +import ( + "context" + "log" + + "entdemo/ent" + + _ "github.com/mattn/go-sqlite3" +) + +func main() { + client, err := ent.Open("sqlite3", "file:ent?mode=memory&cache=shared&_fk=1") + if err != nil { + log.Fatalf("failed opening connection to sqlite: %v", err) + } + defer client.Close() + // Run the auto migration tool. + if err := client.Schema.Create(context.Background()); err != nil { + log.Fatalf("failed creating schema resources: %v", err) + } +} +``` + + + + +```go +package main + +import ( + "context" + "log" + + "entdemo/ent" + + _ "github.com/lib/pq" +) + +func main() { + client, err := ent.Open("postgres","host= port= user= dbname= password=") + if err != nil { + log.Fatalf("failed opening connection to postgres: %v", err) + } + defer client.Close() + // Run the auto migration tool. + if err := client.Schema.Create(context.Background()); err != nil { + log.Fatalf("failed creating schema resources: %v", err) + } +} +``` + + + + +```go +package main + +import ( + "context" + "log" + + "entdemo/ent" + + _ "github.com/go-sql-driver/mysql" +) + +func main() { + client, err := ent.Open("mysql", ":@tcp(:)/?parseTime=True") + if err != nil { + log.Fatalf("failed opening connection to mysql: %v", err) + } + defer client.Close() + // Run the auto migration tool. + if err := client.Schema.Create(context.Background()); err != nil { + log.Fatalf("failed creating schema resources: %v", err) + } +} +``` + + + + +```go +package main + +import ( + "log" + + "entdemo/ent" +) + +func main() { + client, err := ent.Open("gremlin", "http://localhost:8182") + if err != nil { + log.Fatal(err) + } +} +``` + + + + +## Create An Entity + +**Save** a user. + +```go +a8m, err := client.User. // UserClient. + Create(). // User create builder. + SetName("a8m"). // Set field value. + SetNillableAge(age). // Avoid nil checks. + AddGroups(g1, g2). // Add many edges. + SetSpouse(nati). // Set unique edge. + Save(ctx) // Create and return. +``` + +**SaveX** a pet; Unlike **Save**, **SaveX** panics if an error occurs. + +```go +pedro := client.Pet. // PetClient. + Create(). // Pet create builder. + SetName("pedro"). // Set field value. + SetOwner(a8m). // Set owner (unique edge). + SaveX(ctx) // Create and return. +``` + +## Create Many + +**Save** a bulk of pets. + +```go {1,8} +pets, err := client.Pet.CreateBulk( + client.Pet.Create().SetName("pedro").SetOwner(a8m), + client.Pet.Create().SetName("xabi").SetOwner(a8m), + client.Pet.Create().SetName("layla").SetOwner(a8m), +).Save(ctx) + +names := []string{"pedro", "xabi", "layla"} +pets, err := client.Pet.MapCreateBulk(names, func(c *ent.PetCreate, i int) { + c.SetName(names[i]).SetOwner(a8m) +}).Save(ctx) +``` + +## Update One + +Update an entity that was returned from the database. + +```go +a8m, err = a8m.Update(). // User update builder. + RemoveGroup(g2). // Remove a specific edge. + ClearCard(). // Clear a unique edge. + SetAge(30). // Set a field value. + AddRank(10). // Increment a field value. + AppendInts([]int{1}). // Append values to a JSON array. + Save(ctx) // Save and return. +``` + + +## Update By ID + +```go +pedro, err := client.Pet. // PetClient. + UpdateOneID(id). // Pet update builder. + SetName("pedro"). // Set field name. + SetOwnerID(owner). // Set unique edge, using id. + Save(ctx) // Save and return. +``` + + +#### Update One With Condition + +In some projects, the "update many" operation is not allowed and is blocked using hooks. However, there is still a need +to update a single entity by its ID while ensuring it meets a specific condition. In this case, you can use the `Where` +option as follows: + + + + +```go +err := client.Todo. + UpdateOneID(id). + SetStatus(todo.StatusDone). + AddVersion(1). + Where( + todo.Version(currentVersion), + ). + Exec(ctx) +switch { +// If the entity does not meet a specific condition, +// the operation will return an "ent.NotFoundError". +case ent.IsNotFound(err): + fmt.Println("todo item was not found") +// Any other error. +case err != nil: + fmt.Println("update error:", err) +} +``` + + + +```go +err := client.Todo. + UpdateOne(node). + SetStatus(todo.StatusDone). + AddVersion(1). + Where( + todo.Version(currentVersion), + ). + Exec(ctx) +switch { +// If the entity does not meet a specific condition, +// the operation will return an "ent.NotFoundError". +case ent.IsNotFound(err): + fmt.Println("todo item was not found") +// Any other error. +case err != nil: + fmt.Println("update error:", err) +} +``` + + + +```go +firstTodo, err = firstTodo. + Update(). + SetStatus(todo.StatusDone). + AddVersion(1). + Where( + // Ensure the current version matches the one in the database. + todo.Version(firstTodo.Version), + ). + Save(ctx) +switch { +// If the entity does not meet a specific condition, +// the operation will return an "ent.NotFoundError". +case ent.IsNotFound(err): + fmt.Println("todo item was not found") +// Any other error. +case err != nil: + fmt.Println("update error:", err) +} +``` + + + +## Update Many + +Filter using predicates. + +```go +n, err := client.User. // UserClient. + Update(). // User update builder. + Where( // + user.Or( // (age >= 30 OR name = "bar") + user.AgeGT(30), // + user.Name("bar"), // AND + ), // + user.HasFollowers(), // UserHasFollowers() + ). // + SetName("foo"). // Set field name. + Save(ctx) // exec and return. +``` + +Query edge-predicates. + +```go +n, err := client.User. // UserClient. + Update(). // User update builder. + Where( // + user.HasFriendsWith( // UserHasFriendsWith ( + user.Or( // age = 20 + user.Age(20), // OR + user.Age(30), // age = 30 + ) // ) + ), // + ). // + SetName("a8m"). // Set field name. + Save(ctx) // exec and return. +``` + +## Upsert One + +Ent supports [upsert](https://en.wikipedia.org/wiki/Merge_(SQL)) records using the [`sql/upsert`](features.md#upsert) +feature-flag. + +```go +err := client.User. + Create(). + SetAge(30). + SetName("Ariel"). + OnConflict(). + // Use the new values that were set on create. + UpdateNewValues(). + Exec(ctx) + +id, err := client.User. + Create(). + SetAge(30). + SetName("Ariel"). + OnConflict(). + // Use the "age" that was set on create. + UpdateAge(). + // Set a different "name" in case of conflict. + SetName("Mashraki"). + ID(ctx) + +// Customize the UPDATE clause. +err := client.User. + Create(). + SetAge(30). + SetName("Ariel"). + OnConflict(). + UpdateNewValues(). + // Override some of the fields with a custom update. + Update(func(u *ent.UserUpsert) { + u.SetAddress("localhost") + u.AddCount(1) + u.ClearPhone() + }). + Exec(ctx) +``` + +In PostgreSQL, the [conflict target](https://www.postgresql.org/docs/current/sql-insert.html#SQL-ON-CONFLICT) is required: + +```go +// Setting the column names using the fluent API. +err := client.User. + Create(). + SetName("Ariel"). + OnConflictColumns(user.FieldName). + UpdateNewValues(). + Exec(ctx) + +// Setting the column names using the SQL API. +err := client.User. + Create(). + SetName("Ariel"). + OnConflict( + sql.ConflictColumns(user.FieldName), + ). + UpdateNewValues(). + Exec(ctx) + +// Setting the constraint name using the SQL API. +err := client.User. + Create(). + SetName("Ariel"). + OnConflict( + sql.ConflictConstraint(constraint), + ). + UpdateNewValues(). + Exec(ctx) +``` + +In order to customize the executed statement, use the SQL API: + +```go +id, err := client.User. + Create(). + OnConflict( + sql.ConflictColumns(...), + sql.ConflictWhere(...), + sql.UpdateWhere(...), + ). + Update(func(u *ent.UserUpsert) { + u.SetAge(30) + u.UpdateName() + }). + ID(ctx) + +// INSERT INTO "users" (...) VALUES (...) ON CONFLICT WHERE ... DO UPDATE SET ... WHERE ... +``` + +:::info +Since the upsert API is implemented using the `ON CONFLICT` clause (and `ON DUPLICATE KEY` in MySQL), +Ent executes only one statement to the database, and therefore, only create [hooks](hooks.md) are applied +for such operations. +::: + +## Upsert Many + +```go +err := client.User. // UserClient + CreateBulk(builders...). // User bulk create. + OnConflict(). // User bulk upsert. + UpdateNewValues(). // Use the values that were set on create in case of conflict. + Exec(ctx) // Execute the statement. +``` + +## Query The Graph + +Get all users with followers. +```go +users, err := client.User. // UserClient. + Query(). // User query builder. + Where(user.HasFollowers()). // filter only users with followers. + All(ctx) // query and return. +``` + +Get all followers of a specific user; Start the traversal from a node in the graph. +```go +users, err := a8m. + QueryFollowers(). + All(ctx) +``` + +Get all pets of the followers of a user. +```go +users, err := a8m. + QueryFollowers(). + QueryPets(). + All(ctx) +``` + +Count the number of posts without comments. +```go +n, err := client.Post. + Query(). + Where( + post.Not( + post.HasComments(), + ) + ). + Count(ctx) +``` + +More advance traversals can be found in the [next section](traversals.md). + +## Field Selection + +Get all pet names. + +```go +names, err := client.Pet. + Query(). + Select(pet.FieldName). + Strings(ctx) +``` + +Get all unique pet names. + +```go +names, err := client.Pet. + Query(). + Unique(true). + Select(pet.FieldName). + Strings(ctx) +``` + +Count the number of unique pet names. + +```go +n, err := client.Pet. + Query(). + Unique(true). + Select(pet.FieldName). + Count(ctx) +``` + +Select partial objects and partial associations. +Get all pets and their owners, but select and fill only the `ID` and `Name` fields. + +```go +pets, err := client.Pet. + Query(). + Select(pet.FieldName). + WithOwner(func (q *ent.UserQuery) { + q.Select(user.FieldName) + }). + All(ctx) +``` + +Scan all pet names and ages to custom struct. + +```go +var v []struct { + Age int `json:"age"` + Name string `json:"name"` +} +err := client.Pet. + Query(). + Select(pet.FieldAge, pet.FieldName). + Scan(ctx, &v) +if err != nil { + log.Fatal(err) +} +``` + +Update an entity and return a partial of it. + +```go +pedro, err := client.Pet. + UpdateOneID(id). + SetAge(9). + SetName("pedro"). + // Select allows selecting one or more fields (columns) of the returned entity. + // The default is selecting all fields defined in the entity schema. + Select(pet.FieldName). + Save(ctx) +``` + +## Delete One + +Delete an entity: + +```go +err := client.User. + DeleteOne(a8m). + Exec(ctx) +``` + +Delete by ID: + +```go +err := client.User. + DeleteOneID(id). + Exec(ctx) +``` + +#### Delete One With Condition + +In some projects, the "delete many" operation is not allowed and is blocked using hooks. However, there is still a need +to delete a single entity by its ID while ensuring it meets a specific condition. In this case, you can use the `Where` +option as follows: + +```go +err := client.Todo. + DeleteOneID(id). + Where( + // Allow deleting only expired todos. + todo.ExpireLT(time.Now()), + ). + Exec(ctx) +switch { +// If the entity does not meet a specific condition, +// the operation will return an "ent.NotFoundError". +case ent.IsNotFound(err): + fmt.Println("todo item was not found") +// Any other error. +case err != nil: + fmt.Println("deletion error:", err) +} +``` + + +## Delete Many + +Delete using predicates: + +```go +affected, err := client.File. + Delete(). + Where(file.UpdatedAtLT(date)). + Exec(ctx) +``` + +## Mutation + +Each generated node type has its own type of mutation. For example, all [`User` builders](crud.mdx#create-an-entity), share +the same generated `UserMutation` object. +However, all builder types implement the generic `ent.Mutation` interface. + +For example, in order to write a generic code that apply a set of methods on both `ent.UserCreate` +and `ent.UserUpdate`, use the `UserMutation` object: + +```go +func Do() { + creator := client.User.Create() + SetAgeName(creator.Mutation()) + updater := client.User.UpdateOneID(id) + SetAgeName(updater.Mutation()) +} + +// SetAgeName sets the age and the name for any mutation. +func SetAgeName(m *ent.UserMutation) { + m.SetAge(32) + m.SetName("Ariel") +} +``` + +In some cases, you want to apply a set of methods on multiple types. +For cases like this, either use the generic `ent.Mutation` interface, +or create your own interface. + +```go +func Do() { + creator1 := client.User.Create() + SetName(creator1.Mutation(), "a8m") + + creator2 := client.Pet.Create() + SetName(creator2.Mutation(), "pedro") +} + +// SetNamer wraps the 2 methods for getting +// and setting the "name" field in mutations. +type SetNamer interface { + SetName(string) + Name() (string, bool) +} + +func SetName(m SetNamer, name string) { + if _, exist := m.Name(); !exist { + m.SetName(name) + } +} +``` diff --git a/doc/md/data-migrations.mdx b/doc/md/data-migrations.mdx new file mode 100644 index 0000000..a23c58a --- /dev/null +++ b/doc/md/data-migrations.mdx @@ -0,0 +1,316 @@ +--- +id: data-migrations +title: Data Migrations +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +Migrations are usually used for changing the database schema, but in some cases, there is a need to modify the data +stored in the database. For example, adding seed data, or back-filling empty columns with custom default values. + +Migrations of this type are called data migrations. In this document, we will discuss how to use Ent to plan data +migrations and integrate them into your regular schema migrations workflow. + +### Migration Types + +Ent currently supports two types of migrations, [versioned migration](versioned-migrations.mdx) and [declarative migration](migrate.md) +(also known as automatic migration). Data migrations can be executed in both types of migrations. + +## Versioned Migrations + +When using versioned migrations, data migrations should be stored on the same `migrations` directory and executed the +same way as regular migrations. It is recommended, however, to store data migrations and schema migrations in separate +files so that they can be easily tested. + +The format used for such migrations is SQL, as the file can be safely executed (and stored without changes) even if +the Ent schema was modified and the generated code is not compatible with the data migration file anymore. + +There are two ways to create data migrations scripts, manually and generated. By manually editing, users write all the SQL +statements and can control exactly what will be executed. Alternatively, users can use Ent to generate the data migrations +for them. It is recommended to verify that the generated file was correctly generated, as in some cases it may need to +be manually fixed or edited. + +### Manual Creation + +1\. If you don't have Atlas installed, check out its [getting-started](https://atlasgo.io/getting-started/#installation) +guide. + +2\. Create a new migration file using [Atlas](https://atlasgo.io/versioned/new): +```shell +atlas migrate new \ + --dir "file://my/project/migrations" +``` + +3\. Edit the migration file and add the custom data migration there. For example: +```sql title="ent/migrate/migrations/20221126185750_backfill_data.sql" +-- Backfill NULL or null tags with a default value. +UPDATE `users` SET `tags` = '["foo","bar"]' WHERE `tags` IS NULL OR JSON_CONTAINS(`tags`, 'null', '$'); +``` + +4\. Update the migration directory [integrity file](https://atlasgo.io/concepts/migration-directory-integrity): +```shell +atlas migrate hash \ + --dir "file://my/project/migrations" +``` + +Check out the [Testing](#testing) section below if you're unsure how to test the data migration file. + +### Generated Scripts + +Currently, Ent provides initial support for generating data migration files. By using this option, users can simplify the +process of writing complex SQL statements manually in most cases. Still, it is recommended to verify that the generated +file was correctly generated, as in some edge cases it may need to be manually edited. + +1\. Create your [versioned-migration setup](/docs/versioned/intro), in case it +is not set. + +2\. Create your first data-migration function. Below, you will find some examples that demonstrate how to write such a +function: + + + + +```go title="ent/migrate/migratedata/migratedata.go" +package migratedata + +// BackfillUnknown back-fills all empty users' names with the default value 'Unknown'. +func BackfillUnknown(dir *migrate.LocalDir) error { + w := &schema.DirWriter{Dir: dir} + client := ent.NewClient(ent.Driver(schema.NewWriteDriver(dialect.MySQL, w))) + + // Change all empty names to 'unknown'. + err := client.User. + Update(). + Where( + user.NameEQ(""), + ). + SetName("Unknown"). + Exec(context.Background()) + if err != nil { + return fmt.Errorf("failed generating statement: %w", err) + } + + // Write the content to the migration directory. + return w.FlushChange( + "unknown_names", + "Backfill all empty user names with default value 'unknown'.", + ) +} +``` + +Then, using this function in `ent/migrate/main.go` will generate the following migration file: + +```sql title="migrations/20221126185750_unknown_names.sql" +-- Backfill all empty user names with default value 'unknown'. +UPDATE `users` SET `name` = 'Unknown' WHERE `users`.`name` = ''; +``` + + + + +```go title="ent/migrate/migratedata/migratedata.go" +package migratedata + +// BackfillUserTags is used to generate the migration file '20221126185750_backfill_user_tags.sql'. +func BackfillUserTags(dir *migrate.LocalDir) error { + w := &schema.DirWriter{Dir: dir} + client := ent.NewClient(ent.Driver(schema.NewWriteDriver(dialect.MySQL, w))) + + // Add defaults "foo" and "bar" tags for users without any. + err := client.User. + Update(). + Where(func(s *sql.Selector) { + s.Where( + sql.Or( + sql.IsNull(user.FieldTags), + sqljson.ValueIsNull(user.FieldTags), + ), + ) + }). + SetTags([]string{"foo", "bar"}). + Exec(context.Background()) + if err != nil { + return fmt.Errorf("failed generating backfill statement: %w", err) + } + // Document all changes until now with a custom comment. + w.Change("Backfill NULL or null tags with a default value.") + + // Append the "org" special tag for users with a specific prefix or suffix. + err = client.User. + Update(). + Where( + user.Or( + user.NameHasPrefix("org-"), + user.NameHasSuffix("-org"), + ), + // Append to only those without this tag. + func(s *sql.Selector) { + s.Where( + sql.Not(sqljson.ValueContains(user.FieldTags, "org")), + ) + }, + ). + AppendTags([]string{"org"}). + Exec(context.Background()) + if err != nil { + return fmt.Errorf("failed generating backfill statement: %w", err) + } + // Document all changes until now with a custom comment. + w.Change("Append the 'org' tag for organization accounts in case they don't have it.") + + // Write the content to the migration directory. + return w.Flush("backfill_user_tags") +} +``` + +Then, using this function in `ent/migrate/main.go` will generate the following migration file: + +```sql title="migrations/20221126185750_backfill_user_tags.sql" +-- Backfill NULL or null tags with a default value. +UPDATE `users` SET `tags` = '["foo","bar"]' WHERE `tags` IS NULL OR JSON_CONTAINS(`tags`, 'null', '$'); +-- Append the 'org' tag for organization accounts in case they don't have it. +UPDATE `users` SET `tags` = CASE WHEN (JSON_TYPE(JSON_EXTRACT(`tags`, '$')) IS NULL OR JSON_TYPE(JSON_EXTRACT(`tags`, '$')) = 'NULL') THEN JSON_ARRAY('org') ELSE JSON_ARRAY_APPEND(`tags`, '$', 'org') END WHERE (`users`.`name` LIKE 'org-%' OR `users`.`name` LIKE '%-org') AND (NOT (JSON_CONTAINS(`tags`, '"org"', '$') = 1)); +``` + + + + +```go title="ent/migrate/migratedata/migratedata.go" +package migratedata + +// SeedUsers add the initial users to the database. +func SeedUsers(dir *migrate.LocalDir) error { + w := &schema.DirWriter{Dir: dir} + client := ent.NewClient(ent.Driver(schema.NewWriteDriver(dialect.MySQL, w))) + + // The statement that generates the INSERT statement. + err := client.User.CreateBulk( + client.User.Create().SetName("a8m").SetAge(1).SetTags([]string{"foo"}), + client.User.Create().SetName("nati").SetAge(1).SetTags([]string{"bar"}), + ).Exec(context.Background()) + if err != nil { + return fmt.Errorf("failed generating statement: %w", err) + } + + // Write the content to the migration directory. + return w.FlushChange( + "seed_users", + "Add the initial users to the database.", + ) +} +``` + +Then, using this function in `ent/migrate/main.go` will generate the following migration file: + +```sql title="migrations/20221126212120_seed_users.sql" +-- Add the initial users to the database. +INSERT INTO `users` (`age`, `name`, `tags`) VALUES (1, 'a8m', '["foo"]'), (1, 'nati', '["bar"]'); +``` + + + + +3\. In case the generated file was edited, the migration directory [integrity file](https://atlasgo.io/concepts/migration-directory-integrity) +needs to be updated with the following command: + +```shell +atlas migrate hash \ + --dir "file://my/project/migrations" +``` + +### Testing + +After adding the migration files, it is highly recommended that you apply them on a local database to ensure they are +valid and achieve the intended results. The following process can be done manually or automated by a program. + +1\. Execute all migration files until the last created one, the data migration file: + +```shell +# Total number of files. +number_of_files=$(ls ent/migrate/migrations/*.sql | wc -l) + +# Execute all files without the latest. +atlas migrate apply $[number_of_files-1] \ + --dir "file://my/project/migrations" \ + -u "mysql://root:pass@localhost:3306/test" +``` + +2\. Ensure the last migration file is pending execution: + +```shell +atlas migrate status \ + --dir "file://my/project/migrations" \ + -u "mysql://root:pass@localhost:3306/test" + +Migration Status: PENDING + -- Current Version: + -- Next Version: + -- Executed Files: + -- Pending Files: 1 +``` + +3\. Fill the local database with temporary data that represents the production database before running the data +migration file. + +4\. Run `atlas migrate apply` and ensure it was executed successfully. + +```shell +atlas migrate apply \ + --dir "file://my/project/migrations" \ + -u "mysql://root:pass@localhost:3306/test" +``` + +Note, by using `atlas schema clean` you can clean the database you use for local development and repeat this process +until the data migration file achieves the desired result. + + +## Automatic Migrations + +In the declarative workflow, data migrations are implemented using Diff or Apply [Hooks](migrate.md#atlas-diff-and-apply-hooks). +This is because, unlike the versioned option, migrations of this type do not hold a name or a version when they are applied. +Therefore, when a data is written using hooks, the type of the `schema.Change` must be checked before its +execution to ensure the data migration was not applied more than once. + +```go +func FillNullValues(dbdialect string) schema.ApplyHook { + return func(next schema.Applier) schema.Applier { + return schema.ApplyFunc(func(ctx context.Context, conn dialect.ExecQuerier, plan *migrate.Plan) error { + //highlight-next-line-info + // Search the schema.Change that triggers the data migration. + hasC := func() bool { + for _, c := range plan.Changes { + m, ok := c.Source.(*schema.ModifyTable) + if ok && m.T.Name == user.Table && schema.Changes(m.Changes).IndexModifyColumn(user.FieldName) != -1 { + return true + } + } + return false + }() + // Change was found, apply the data migration. + if hasC { + //highlight-info-start + // At this stage, there are three ways to UPDATE the NULL values to "Unknown". + // Append a custom migrate.Change to migrate.Plan, execute an SQL statement + // directly on the dialect.ExecQuerier, or use the generated ent.Client. + //highlight-info-end + + // Create a temporary client from the migration connection. + client := ent.NewClient( + ent.Driver(sql.NewDriver(dbdialect, sql.Conn{ExecQuerier: conn.(*sql.Tx)})), + ) + if err := client.User. + Update(). + SetName("Unknown"). + Where(user.NameIsNil()). + Exec(ctx); err != nil { + return err + } + } + return next.Apply(ctx, conn, plan) + }) + } +} +``` + +For more examples, check out the [Apply Hook](migrate.md#apply-hook-example) examples section. diff --git a/doc/md/dialects.md b/doc/md/dialects.md new file mode 100644 index 0000000..e650ba0 --- /dev/null +++ b/doc/md/dialects.md @@ -0,0 +1,41 @@ +--- +id: dialects +title: Supported Dialects +--- + +## MySQL + +MySQL supports all the features that are mentioned in the [Migration](migrate.md) section, +and it's being tested constantly on the following 3 versions: `5.6.35`, `5.7.26` and `8`. + +## MariaDB + +MariaDB supports all the features that are mentioned in the [Migration](migrate.md) section, +and it's being tested constantly on the following 3 versions: `10.2`, `10.3` and latest version. + +## PostgreSQL + +PostgreSQL supports all the features that are mentioned in the [Migration](migrate.md) section, +and it's being tested constantly on the following 8 versions: `10`, `11`, `12`, `13`, `14`, `15`, `16`, and `17`. + +## CockroachDB **(preview)** + +CockroachDB support is in preview and requires the [Atlas migration engine](migrate.md#atlas-integration). +The integration with CRDB is currently tested on versions `v21.2.11`. + +## SQLite + +Using [Atlas](https://github.com/ariga/atlas), the SQLite driver supports all the features that +are mentioned in the [Migration](migrate.md) section. Note that some changes, like column modification, +are performed on a temporary table using the sequence of operations described in [SQLite official documentation](https://www.sqlite.org/lang_altertable.html#otheralter). + +## Gremlin + +Gremlin does not support migration nor indexes, and **it's considered experimental**. + +## TiDB **(preview)** + +TiDB support is in preview and requires the [Atlas migration engine](migrate.md#atlas-integration). +TiDB is MySQL compatible and thus any feature that works on MySQL _should_ work on TiDB as well. +For a list of known compatibility issues, visit: https://docs.pingcap.com/tidb/stable/mysql-compatibility +The integration with TiDB is currently tested on versions `5.4.0`, `6.0.0`. diff --git a/doc/md/eager-load.mdx b/doc/md/eager-load.mdx new file mode 100644 index 0000000..20a6b0b --- /dev/null +++ b/doc/md/eager-load.mdx @@ -0,0 +1,192 @@ +--- +id: eager-load +title: Eager Loading +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +## Overview + +`ent` supports querying entities with their associations (through their edges). The associated entities +are populated to the `Edges` field in the returned object. + +Let's give an example of what the API looks like for the following schema: + +![er-group-users](https://entgo.io/images/assets/er_user_pets_groups.png) + + + +**Query all users with their pets:** +```go +users, err := client.User. + Query(). + WithPets(). + All(ctx) +if err != nil { + return err +} +// The returned users look as follows: +// +// [ +// User { +// ID: 1, +// Name: "a8m", +// Edges: { +// Pets: [Pet(...), ...] +// ... +// } +// }, +// ... +// ] +// +for _, u := range users { + for _, p := range u.Edges.Pets { + fmt.Printf("User(%v) -> Pet(%v)\n", u.ID, p.ID) + // Output: + // User(...) -> Pet(...) + } +} +``` + +Eager loading allows to query more than one association (including nested), and also +filter, sort or limit their result. For example: + +```go +admins, err := client.User. + Query(). + Where(user.Admin(true)). + // Populate the `pets` that associated with the `admins`. + WithPets(). + // Populate the first 5 `groups` that associated with the `admins`. + WithGroups(func(q *ent.GroupQuery) { + q.Limit(5) // Limit to 5. + q.WithUsers() // Populate the `users` of each `groups`. + }). + All(ctx) +if err != nil { + return err +} + +// The returned users look as follows: +// +// [ +// User { +// ID: 1, +// Name: "admin1", +// Edges: { +// Pets: [Pet(...), ...] +// Groups: [ +// Group { +// ID: 7, +// Name: "GitHub", +// Edges: { +// Users: [User(...), ...] +// ... +// } +// } +// ] +// } +// }, +// ... +// ] +// +for _, admin := range admins { + for _, p := range admin.Edges.Pets { + fmt.Printf("Admin(%v) -> Pet(%v)\n", u.ID, p.ID) + // Output: + // Admin(...) -> Pet(...) + } + for _, g := range admin.Edges.Groups { + for _, u := range g.Edges.Users { + fmt.Printf("Admin(%v) -> Group(%v) -> User(%v)\n", u.ID, g.ID, u.ID) + // Output: + // Admin(...) -> Group(...) -> User(...) + } + } +} +``` + +## API + +Each query-builder has a list of methods in the form of `With(...func(Query))` for each of its edges. +`` stands for the edge name (like, `WithGroups`) and `` for the edge type (like, `GroupQuery`). + +Note that only SQL dialects support this feature. + +## Named Edges + +In some cases there is a need for preloading edges with custom names. For example, a GraphQL query that has two aliases +referencing the same edge with different arguments. For this situation, Ent provides another API named `WithNamed` +that can be enabled using the [`namedges`](features.md#named-edges) feature-flag and seamlessly integrated with +[EntGQL Fields Collection](tutorial-todo-gql-field-collection.md). + + + + +See the GraphQL tab to learn more about the motivation behind this API. + +```go +posts, err := client.Post.Query(). + WithNamedComments("published", func(q *ent.CommentQuery) { + q.Where(comment.StatusEQ(comment.StatusPublished)) + }) + WithNamedComments("draft", func(q *ent.CommentQuery) { + q.Where(comment.StatusEQ(comment.StatusDraft)) + }). + Paginate(...) + +// Get the preloaded edges by their name: +for _, p := range posts { + published, err := p.Edges.NamedComments("published") + if err != nil { + return err + } + draft, err := p.Edges.NamedComments("draft") + if err != nil { + return err + } +} +``` + + + + +An example of a GraphQL query that has two aliases referencing the same edge with different arguments. + +```graphql +query { + posts { + id + title + published: comments(where: { status: PUBLISHED }) { + edges { + node { + text + } + } + } + draft: comments(where: { status: DRAFT }) { + edges { + node { + text + } + } + } + } +} +``` + + + + +## Implementation + +Since an Ent query can eager-load more than one edge, it is not possible to load all associations in a single +`JOIN` operation. Therefore, Ent executes additional query to load each association. This expected to be optimized +in future versions. diff --git a/doc/md/extension.md b/doc/md/extension.md new file mode 100644 index 0000000..dc258d4 --- /dev/null +++ b/doc/md/extension.md @@ -0,0 +1,232 @@ +--- +id: extensions +title: Extensions +--- + +### Introduction + +The Ent [Extension API](https://pkg.go.dev/entgo.io/ent/entc#Extension) +facilitates the creation of code-generation extensions that bundle together [codegen hooks](code-gen.md#code-generation-hooks), +[templates](templates.md) and [annotations](templates.md#annotations) to create reusable components +that add new rich functionality to Ent's core. For example, Ent's [entgql plugin](https://pkg.go.dev/entgo.io/contrib/entgql#Extension) +exposes an `Extension` that automatically generates GraphQL servers from an Ent schema. + +### Defining a New Extension + +All extension's must implement the [Extension](https://pkg.go.dev/entgo.io/ent/entc#Extension) interface: + +```go +type Extension interface { + // Hooks holds an optional list of Hooks to apply + // on the graph before/after the code-generation. + Hooks() []gen.Hook + + // Annotations injects global annotations to the gen.Config object that + // can be accessed globally in all templates. Unlike schema annotations, + // being serializable to JSON raw value is not mandatory. + // + // {{- with $.Config.Annotations.GQL }} + // {{/* Annotation usage goes here. */}} + // {{- end }} + // + Annotations() []Annotation + + // Templates specifies a list of alternative templates + // to execute or to override the default. + Templates() []*gen.Template + + // Options specifies a list of entc.Options to evaluate on + // the gen.Config before executing the code generation. + Options() []Option +} +``` +To simplify the development of new extensions, developers can embed [entc.DefaultExtension](https://pkg.go.dev/entgo.io/ent/entc#DefaultExtension) +to create extensions without implementing all methods: + +```go +package hello + +// GreetExtension implements entc.Extension. +type GreetExtension struct { + entc.DefaultExtension +} +``` + +### Adding Templates + +Ent supports adding [external templates](templates.md) that will be rendered during +code generation. To bundle such external templates on an extension, implement the `Templates` +method: +```gotemplate title="templates/greet.tmpl" +{{/* Tell Intellij/GoLand to enable the autocompletion based on the *gen.Graph type. */}} +{{/* gotype: entgo.io/ent/entc/gen.Graph */}} + +{{ define "greet" }} + +{{/* Add the base header for the generated file */}} +{{ $pkg := base $.Config.Package }} +{{ template "header" $ }} + +{{/* Loop over all nodes and add the Greet method */}} +{{ range $n := $.Nodes }} + {{ $receiver := $n.Receiver }} + func ({{ $receiver }} *{{ $n.Name }}) Greet() string { + return "Hello, {{ $n.Name }}" + } +{{ end }} + +{{ end }} +``` +```go +func (*GreetExtension) Templates() []*gen.Template { + return []*gen.Template{ + gen.MustParse(gen.NewTemplate("greet").ParseFiles("templates/greet.tmpl")), + } +} +``` + +### Adding Global Annotations + +Annotations are a convenient way to supply users of our extension with an API +to modify the behavior of code generation. To add annotations to our extension, +implement the `Annotations` method. Let's say in our `GreetExtension` we want +to provide users with the ability to configure the greeting word in the generated +code: + +```go +// GreetingWord implements entc.Annotation. +type GreetingWord string + +// Name of the annotation. Used by the codegen templates. +func (GreetingWord) Name() string { + return "GreetingWord" +} +``` +Then add it to the `GreetExtension` struct: +```go +type GreetExtension struct { + entc.DefaultExtension + word GreetingWord +} +``` +Next, implement the `Annotations` method: +```go +func (s *GreetExtension) Annotations() []entc.Annotation { + return []entc.Annotation{ + s.word, + } +} +``` +Now, from within your templates you can access the `GreetingWord` annotation: +```gotemplate +func ({{ $receiver }} *{{ $n.Name }}) Greet() string { + return "{{ $.Annotations.GreetingWord }}, {{ $n.Name }}" +} +``` + +### Adding Hooks + +The entc package provides an option to add a list of [hooks](code-gen.md#code-generation-hooks) +(middlewares) to the code-generation phase. This option is ideal for adding custom validators for the +schema, or for generating additional assets using the graph schema. To bundle +code generation hooks with your extension, implement the `Hooks` method: + +```go +func (s *GreetExtension) Hooks() []gen.Hook { + return []gen.Hook{ + DisallowTypeName("Shalom"), + } +} + +// DisallowTypeName ensures there is no ent.Schema with the given name in the graph. +func DisallowTypeName(name string) gen.Hook { + return func(next gen.Generator) gen.Generator { + return gen.GenerateFunc(func(g *gen.Graph) error { + for _, node := range g.Nodes { + if node.Name == name { + return fmt.Errorf("entc: validation failed, type named %q not allowed", name) + } + } + return next.Generate(g) + }) + } +} +``` + +### Using an Extension in Code Generation + +To use an extension in our code-generation configuration, use `entc.Extensions`, a helper +method that returns an `entc.Option` that applies our chosen extensions: + +```go title="ent/entc.go" +//+build ignore + +package main + +import ( + "fmt" + "log" + + "entgo.io/ent/entc" + "entgo.io/ent/entc/gen" +) + +func main() { + err := entc.Generate("./schema", + &gen.Config{}, + entc.Extensions(&GreetExtension{ + word: GreetingWord("Shalom"), + }), + ) + if err != nil { + log.Fatal("running ent codegen:", err) + } +} +``` + +### Community Extensions + +- **[entoas](https://github.com/ent/contrib/tree/master/entoas)** + `entoas` is an extension that originates from `elk` and was ported into its own extension and is now the official + generator for and opinionated OpenAPI Specification document. You can use this to rapidly develop and document a + RESTful HTTP server. There will be a new extension released soon providing a generated implementation integrating for + the document provided by `entoas` using `ent`. + +- **[entrest](https://github.com/lrstanley/entrest)** + `entrest` is an alternative to `entoas`(+ `ogent`) and `elk` (before it was discontinued). entrest generates a compliant, + efficient, and feature-complete OpenAPI specification from your Ent schema, along with a functional RESTful API server + implementation. The highlight features include: toggleable pagination, advanced filtering/querying capabilities, sorting + (even through relationships), eager-loading edges, and a bunch more. + +- **[entgql](https://github.com/ent/contrib/tree/master/entgql)** + This extension helps users build [GraphQL](https://graphql.org/) servers from Ent schemas. `entgql` integrates + with [gqlgen](https://github.com/99designs/gqlgen), a popular, schema-first Go library for building GraphQL servers. + The extension includes the generation of type-safe GraphQL filters, which enable users to effortlessly map GraphQL + queries to Ent queries. + Follow [this tutorial](https://entgo.io/docs/tutorial-todo-gql) to get started. + +- **[entproto](https://github.com/ent/contrib/tree/master/entproto)** + `entproto` generates Protobuf message definitions and gRPC service definitions from Ent schemas. The project also + includes `protoc-gen-entgrpc`, a `protoc` (Protobuf compiler) plugin that is used to generate a working implementation + of the gRPC service definition generated by Entproto. In this manner, we can easily create a gRPC server that can + serve requests to our service without writing any code (aside from defining the Ent schema)! + To learn how to use and set up `entproto`, read [this tutorial](https://entgo.io/docs/grpc-intro). For more background + you can read [this blog post](https://entgo.io/blog/2021/03/18/generating-a-grpc-server-with-ent), + or [this blog post](https://entgo.io/blog/2021/06/28/gprc-ready-for-use/) discussing more `entproto` features. + +- **[elk (discontinued)](https://github.com/masseelch/elk)** + `elk` is an extension that generates RESTful API endpoints from Ent schemas. The extension generates HTTP CRUD + handlers from the Ent schema, as well as an OpenAPI JSON file. By using it, you can easily build a RESTful HTTP server + for your application. **Please note, that `elk` has been discontinued in favor of `entoas`**. An implementation generator + is in the works. + Read [this blog post](https://entgo.io/blog/2021/07/29/generate-a-fully-working-go-crud-http-api-with-ent) on how to + work with `elk`, and [this blog post](https://entgo.io/blog/2021/09/10/openapi-generator) on how to generate + an [OpenAPI Specification](https://swagger.io/resources/open-api/). + +- **[entviz (discontinued)](https://github.com/hedwigz/entviz)** + `entviz` is an extension that generates visual diagrams from Ent schemas. These diagrams visualize the schema in a web + browser, and stay updated as we continue coding. `entviz` can be configured in such a way that every time we + regenerate the schema, the diagram is automatically updated, making it easy to view the changes being made. + Learn how to integrate `entviz` in your project + in [this blog post](https://entgo.io/blog/2021/08/26/visualizing-your-data-graph-using-entviz). **This extension has been + archived by the maintainer as of 2023-09-16**. diff --git a/doc/md/faq.md b/doc/md/faq.md new file mode 100644 index 0000000..7783648 --- /dev/null +++ b/doc/md/faq.md @@ -0,0 +1,849 @@ +--- +id: faq +title: Frequently Asked Questions (FAQ) +sidebar_label: FAQ +--- + +## Questions + +[How to create an entity from a struct `T`?](#how-to-create-an-entity-from-a-struct-t) +[How to create a struct (or a mutation) level validator?](#how-to-create-a-mutation-level-validator) +[How to write an audit-log extension?](#how-to-write-an-audit-log-extension) +[How to write custom predicates?](#how-to-write-custom-predicates) +[How to add custom predicates to the codegen assets?](#how-to-add-custom-predicates-to-the-codegen-assets) +[How to define a network address field in PostgreSQL?](#how-to-define-a-network-address-field-in-postgresql) +[How to customize time fields to type `DATETIME` in MySQL?](#how-to-customize-time-fields-to-type-datetime-in-mysql) +[How to use a custom generator of IDs?](#how-to-use-a-custom-generator-of-ids) +[How to use a custom XID globally unique ID?](#how-to-use-a-custom-xid-globally-unique-id) +[How to define a spatial data type field in MySQL?](#how-to-define-a-spatial-data-type-field-in-mysql) +[How to extend the generated models?](#how-to-extend-the-generated-models) +[How to extend the generated builders?](#how-to-extend-the-generated-builders) +[How to store Protobuf objects in a BLOB column?](#how-to-store-protobuf-objects-in-a-blob-column) +[How to add `CHECK` constraints to table?](#how-to-add-check-constraints-to-table) +[How to define a custom precision numeric field?](#how-to-define-a-custom-precision-numeric-field) +[How to configure two or more `DB` to separate read and write?](#how-to-configure-two-or-more-db-to-separate-read-and-write) +[How to configure `json.Marshal` to inline the `edges` keys in the top level object?](#how-to-configure-jsonmarshal-to-inline-the-edges-keys-in-the-top-level-object) + +## Answers + +#### How to create an entity from a struct `T`? + +The different builders don't support the option of setting the entity fields (or edges) from a given struct `T`. +The reason is that there's no way to distinguish between zero/real values when updating the database (for example, `&ent.T{Age: 0, Name: ""}`). +Setting these values, may set incorrect values in the database or update unnecessary columns. + +However, the [external template](templates.md) option lets you extend the default code-generation assets by adding custom logic. +For example, in order to generate a method for each of the create-builders, that accepts a struct as an input and configure the builder, +use the following template: + +```gotemplate +{{ range $n := $.Nodes }} + {{ $builder := $n.CreateName }} + {{ $receiver := $n.CreateReceiver }} + + func ({{ $receiver }} *{{ $builder }}) Set{{ $n.Name }}(input *{{ $n.Name }}) *{{ $builder }} { + {{- range $f := $n.Fields }} + {{- $setter := print "Set" $f.StructField }} + {{ $receiver }}.{{ $setter }}(input.{{ $f.StructField }}) + {{- end }} + return {{ $receiver }} + } +{{ end }} +``` + +#### How to create a mutation level validator? + +In order to implement a mutation-level validator, you can either use [schema hooks](hooks.md#schema-hooks) for validating +changes applied on one entity type, or use [transaction hooks](transactions.md#hooks) for validating mutations that being +applied on multiple entity types (e.g. a GraphQL mutation). For example: + +```go +// A VersionHook is a dummy example for a hook that validates the "version" field +// is incremented by 1 on each update. Note that this is just a dummy example, and +// it doesn't promise consistency in the database. +func VersionHook() ent.Hook { + type OldSetVersion interface { + SetVersion(int) + Version() (int, bool) + OldVersion(context.Context) (int, error) + } + return func(next ent.Mutator) ent.Mutator { + return ent.MutateFunc(func(ctx context.Context, m ent.Mutation) (ent.Value, error) { + ver, ok := m.(OldSetVersion) + if !ok { + return next.Mutate(ctx, m) + } + oldV, err := ver.OldVersion(ctx) + if err != nil { + return nil, err + } + curV, exists := ver.Version() + if !exists { + return nil, fmt.Errorf("version field is required in update mutation") + } + if curV != oldV+1 { + return nil, fmt.Errorf("version field must be incremented by 1") + } + // Add an SQL predicate that validates the "version" column is equal + // to "oldV" (ensure it wasn't changed during the mutation by others). + return next.Mutate(ctx, m) + }) + } +} +``` + +#### How to write an audit-log extension? + +The preferred way for writing such an extension is to use [ent.Mixin](schema-mixin.md). Use the `Fields` option for +setting the fields that are shared between all schemas that import the mixed-schema, and use the `Hooks` option for +attaching a mutation-hook for all mutations that are being applied on these schemas. Here's an example, based on a +discussion in the [repository issue-tracker](https://github.com/ent/ent/issues/830): + +```go +// AuditMixin implements the ent.Mixin for sharing +// audit-log capabilities with package schemas. +type AuditMixin struct{ + mixin.Schema +} + +// Fields of the AuditMixin. +func (AuditMixin) Fields() []ent.Field { + return []ent.Field{ + field.Time("created_at"). + Immutable(). + Default(time.Now), + field.Int("created_by"). + Optional(), + field.Time("updated_at"). + Default(time.Now). + UpdateDefault(time.Now), + field.Int("updated_by"). + Optional(), + } +} + +// Hooks of the AuditMixin. +func (AuditMixin) Hooks() []ent.Hook { + return []ent.Hook{ + hooks.AuditHook, + } +} + +// A AuditHook is an example for audit-log hook. +func AuditHook(next ent.Mutator) ent.Mutator { + // AuditLogger wraps the methods that are shared between all mutations of + // schemas that embed the AuditLog mixin. The variable "exists" is true, if + // the field already exists in the mutation (e.g. was set by a different hook). + type AuditLogger interface { + SetCreatedAt(time.Time) + CreatedAt() (value time.Time, exists bool) + SetCreatedBy(int) + CreatedBy() (id int, exists bool) + SetUpdatedAt(time.Time) + UpdatedAt() (value time.Time, exists bool) + SetUpdatedBy(int) + UpdatedBy() (id int, exists bool) + } + return ent.MutateFunc(func(ctx context.Context, m ent.Mutation) (ent.Value, error) { + ml, ok := m.(AuditLogger) + if !ok { + return nil, fmt.Errorf("unexpected audit-log call from mutation type %T", m) + } + usr, err := viewer.UserFromContext(ctx) + if err != nil { + return nil, err + } + switch op := m.Op(); { + case op.Is(ent.OpCreate): + ml.SetCreatedAt(time.Now()) + if _, exists := ml.CreatedBy(); !exists { + ml.SetCreatedBy(usr.ID) + } + case op.Is(ent.OpUpdateOne | ent.OpUpdate): + ml.SetUpdatedAt(time.Now()) + if _, exists := ml.UpdatedBy(); !exists { + ml.SetUpdatedBy(usr.ID) + } + } + return next.Mutate(ctx, m) + }) +} +``` + +#### How to write custom predicates? + +Users can provide custom predicates to apply on the query before it's executed. For example: + +```go +pets := client.Pet. + Query(). + Where(predicate.Pet(func(s *sql.Selector) { + s.Where(sql.InInts(pet.OwnerColumn, 1, 2, 3)) + })). + AllX(ctx) + +users := client.User. + Query(). + Where(predicate.User(func(s *sql.Selector) { + s.Where(sqljson.ValueContains(user.FieldTags, "tag")) + })). + AllX(ctx) +``` + +For more examples, go to the [predicates](predicates.md#custom-predicates) page, or search in the repository +issue-tracker for more advance examples like [issue-842](https://github.com/ent/ent/issues/842#issuecomment-707896368). + +#### How to add custom predicates to the codegen assets? + +The [template](templates.md) option enables the capability for extending or overriding the default codegen assets. +In order to generate a type-safe predicate for the [example above](#how-to-write-custom-predicates), use the template +option for doing it as follows: + +```gotemplate +{{/* A template that adds the "Glob" predicate for all string fields. */}} +{{ define "where/additional/strings" }} + {{ range $f := $.Fields }} + {{ if $f.IsString }} + {{ $func := print $f.StructField "Glob" }} + // {{ $func }} applies the Glob predicate on the {{ quote $f.Name }} field. + func {{ $func }}(pattern string) predicate.{{ $.Name }} { + return predicate.{{ $.Name }}(func(s *sql.Selector) { + s.Where(sql.P(func(b *sql.Builder) { + b.Ident(s.C({{ $f.Constant }})).WriteString(" glob" ).Arg(pattern) + })) + }) + } + {{ end }} + {{ end }} +{{ end }} +``` + +#### How to define a network address field in PostgreSQL? + +The [GoType](schema-fields.mdx#go-type) and the [SchemaType](schema-fields.mdx#database-type) +options allow users to define database-specific fields. For example, in order to define a + [`macaddr`](https://www.postgresql.org/docs/13/datatype-net-types.html#DATATYPE-MACADDR) field, use the following configuration: + +```go +func (T) Fields() []ent.Field { + return []ent.Field{ + field.String("mac"). + GoType(&MAC{}). + SchemaType(map[string]string{ + dialect.Postgres: "macaddr", + }). + Validate(func(s string) error { + _, err := net.ParseMAC(s) + return err + }), + } +} + +// MAC represents a physical hardware address. +type MAC struct { + net.HardwareAddr +} + +// Scan implements the Scanner interface. +func (m *MAC) Scan(value any) (err error) { + switch v := value.(type) { + case nil: + case []byte: + m.HardwareAddr, err = net.ParseMAC(string(v)) + case string: + m.HardwareAddr, err = net.ParseMAC(v) + default: + err = fmt.Errorf("unexpected type %T", v) + } + return +} + +// Value implements the driver Valuer interface. +func (m MAC) Value() (driver.Value, error) { + return m.HardwareAddr.String(), nil +} +``` +Note that, if the database doesn't support the `macaddr` type (e.g. SQLite on testing), the field fallback to its +native type (i.e. `string`). + +`inet` example: + +```go +func (T) Fields() []ent.Field { + return []ent.Field{ + field.String("ip"). + GoType(&Inet{}). + SchemaType(map[string]string{ + dialect.Postgres: "inet", + }). + Validate(func(s string) error { + if net.ParseIP(s) == nil { + return fmt.Errorf("invalid value for ip %q", s) + } + return nil + }), + } +} + +// Inet represents a single IP address +type Inet struct { + net.IP +} + +// Scan implements the Scanner interface +func (i *Inet) Scan(value any) (err error) { + switch v := value.(type) { + case nil: + case []byte: + if i.IP = net.ParseIP(string(v)); i.IP == nil { + err = fmt.Errorf("invalid value for ip %q", v) + } + case string: + if i.IP = net.ParseIP(v); i.IP == nil { + err = fmt.Errorf("invalid value for ip %q", v) + } + default: + err = fmt.Errorf("unexpected type %T", v) + } + return +} + +// Value implements the driver Valuer interface +func (i Inet) Value() (driver.Value, error) { + return i.IP.String(), nil +} +``` + +#### How to customize time fields to type `DATETIME` in MySQL? + +`Time` fields use the MySQL `TIMESTAMP` type in the schema creation by default, and this type + has a range of '1970-01-01 00:00:01' UTC to '2038-01-19 03:14:07' UTC (see, [MySQL docs](https://dev.mysql.com/doc/refman/5.6/en/datetime.html)). + +In order to customize time fields for a wider range, use the MySQL `DATETIME` as follows: +```go +field.Time("birth_date"). + Optional(). + SchemaType(map[string]string{ + dialect.MySQL: "datetime", + }), +``` + +#### How to use a custom generator of IDs? + +If you're using a custom ID generator instead of using auto-incrementing IDs in +your database (e.g. Twitter's [Snowflake](https://github.com/twitter-archive/snowflake/tree/snowflake-2010)), +you will need to write a custom ID field which automatically calls the generator +on resource creation. + +To achieve this, you can either make use of `DefaultFunc` or of schema hooks - +depending on your use case. If the generator does not return an error, +`DefaultFunc` is more concise, whereas setting a hook on resource creation +will allow you to capture errors as well. An example of how to use +`DefaultFunc` can be seen in the section regarding [the ID field](schema-fields.mdx#id-field). + +Here is an example of how to use a custom generator with hooks, taking as an +example [sonyflake](https://github.com/sony/sonyflake). + +```go +// BaseMixin to be shared will all different schemas. +type BaseMixin struct { + mixin.Schema +} + +// Fields of the Mixin. +func (BaseMixin) Fields() []ent.Field { + return []ent.Field{ + field.Uint64("id"), + } +} + +// Hooks of the Mixin. +func (BaseMixin) Hooks() []ent.Hook { + return []ent.Hook{ + hook.On(IDHook(), ent.OpCreate), + } +} + +func IDHook() ent.Hook { + sf := sonyflake.NewSonyflake(sonyflake.Settings{}) + type IDSetter interface { + SetID(uint64) + } + return func(next ent.Mutator) ent.Mutator { + return ent.MutateFunc(func(ctx context.Context, m ent.Mutation) (ent.Value, error) { + is, ok := m.(IDSetter) + if !ok { + return nil, fmt.Errorf("unexpected mutation %T", m) + } + id, err := sf.NextID() + if err != nil { + return nil, err + } + is.SetID(id) + return next.Mutate(ctx, m) + }) + } +} + +// User holds the schema definition for the User entity. +type User struct { + ent.Schema +} + +// Mixin of the User. +func (User) Mixin() []ent.Mixin { + return []ent.Mixin{ + // Embed the BaseMixin in the user schema. + BaseMixin{}, + } +} +``` + +#### How to use a custom XID globally unique ID? + +Package [xid](https://github.com/rs/xid) is a globally unique ID generator library that uses the [Mongo Object ID](https://docs.mongodb.org/manual/reference/object-id/) +algorithm to generate a 12 byte, 20 character ID with no configuration. The xid package comes with [database/sql](https://pkg.go.dev/database/sql) `sql.Scanner` and `driver.Valuer` interfaces required by Ent for serialization. + +To store an XID in any string field use the [GoType](schema-fields.mdx#go-type) schema configuration: + +```go +// Fields of type T. +func (T) Fields() []ent.Field { + return []ent.Field{ + field.String("id"). + GoType(xid.ID{}). + DefaultFunc(xid.New), + } +} +``` + +Or as a reusable [Mixin](schema-mixin.md) across multiple schemas: + +```go +package schema + +import ( + "entgo.io/ent" + "entgo.io/ent/schema/field" + "entgo.io/ent/schema/mixin" + "github.com/rs/xid" +) + +// BaseMixin to be shared will all different schemas. +type BaseMixin struct { + mixin.Schema +} + +// Fields of the User. +func (BaseMixin) Fields() []ent.Field { + return []ent.Field{ + field.String("id"). + GoType(xid.ID{}). + DefaultFunc(xid.New), + } +} + +// User holds the schema definition for the User entity. +type User struct { + ent.Schema +} + +// Mixin of the User. +func (User) Mixin() []ent.Mixin { + return []ent.Mixin{ + // Embed the BaseMixin in the user schema. + BaseMixin{}, + } +} +``` + +In order to use extended identifiers (XIDs) with gqlgen, follow the configuration mentioned in the [issue tracker](https://github.com/ent/ent/issues/1526#issuecomment-831034884). + +#### How to define a spatial data type field in MySQL? + +The [GoType](schema-fields.mdx#go-type) and the [SchemaType](schema-fields.mdx#database-type) +options allow users to define database-specific fields. For example, in order to define a +[`POINT`](https://dev.mysql.com/doc/refman/8.0/en/spatial-type-overview.html) field, use the following configuration: + +```go +// Fields of the Location. +func (Location) Fields() []ent.Field { + return []ent.Field{ + field.String("name"), + field.Other("coords", &Point{}). + SchemaType(Point{}.SchemaType()), + } +} +``` + +```go +package schema + +import ( + "database/sql/driver" + "fmt" + + "entgo.io/ent/dialect" + "entgo.io/ent/dialect/sql" + "github.com/paulmach/orb" + "github.com/paulmach/orb/encoding/wkb" +) + +// A Point consists of (X,Y) or (Lat, Lon) coordinates +// and it is stored in MySQL the POINT spatial data type. +type Point [2]float64 + +// Scan implements the Scanner interface. +func (p *Point) Scan(value any) error { + bin, ok := value.([]byte) + if !ok { + return fmt.Errorf("invalid binary value for point") + } + var op orb.Point + if err := wkb.Scanner(&op).Scan(bin[4:]); err != nil { + return err + } + p[0], p[1] = op.X(), op.Y() + return nil +} + +// Value implements the driver Valuer interface. +func (p Point) Value() (driver.Value, error) { + op := orb.Point{p[0], p[1]} + return wkb.Value(op).Value() +} + +// FormatParam implements the sql.ParamFormatter interface to tell the SQL +// builder that the placeholder for a Point parameter needs to be formatted. +func (p Point) FormatParam(placeholder string, info *sql.StmtInfo) string { + if info.Dialect == dialect.MySQL { + return "ST_GeomFromWKB(" + placeholder + ")" + } + return placeholder +} + +// SchemaType defines the schema-type of the Point object. +func (Point) SchemaType() map[string]string { + return map[string]string{ + dialect.MySQL: "POINT", + } +} +``` + +A full example exists in the [example repository](https://github.com/a8m/entspatial). + + +#### How to extend the generated models? + +Ent supports extending generated types (both global types and models) using custom templates. For example, in order to +add additional struct fields or methods to the generated model, we can override the `model/fields/additional` template like in this +[example](https://github.com/ent/ent/blob/dd4792f5b30bdd2db0d9a593a977a54cb3f0c1ce/examples/entcpkg/ent/template/static.tmpl). + + +If your custom fields/methods require additional imports, you can add those imports using custom templates as well: + +```gotemplate +{{- define "import/additional/field_types" -}} + "github.com/path/to/your/custom/type" +{{- end -}} + +{{- define "import/additional/client_dependencies" -}} + "github.com/path/to/your/custom/type" +{{- end -}} +``` + +#### How to extend the generated builders? + +See the *[Injecting External Dependencies](code-gen.md#external-dependencies)* section, or follow the +example on [GitHub](https://github.com/ent/ent/tree/master/examples/entcpkg). + +#### How to store Protobuf objects in a BLOB column? + +Assuming we have a Protobuf message defined: +```protobuf +syntax = "proto3"; + +package pb; + +option go_package = "project/pb"; + +message Hi { + string Greeting = 1; +} +``` + +We add receiver methods to the generated protobuf struct such that it implements [ValueScanner](https://pkg.go.dev/entgo.io/ent/schema/field#ValueScanner) + +```go +func (x *Hi) Value() (driver.Value, error) { + return proto.Marshal(x) +} + +func (x *Hi) Scan(src any) error { + if src == nil { + return nil + } + if b, ok := src.([]byte); ok { + if err := proto.Unmarshal(b, x); err != nil { + return err + } + return nil + } + return fmt.Errorf("unexpected type %T", src) +} +``` + +We add a new `field.Bytes` to our schema, setting the generated protobuf struct as its underlying `GoType`: + +```go +// Fields of the Message. +func (Message) Fields() []ent.Field { + return []ent.Field{ + field.Bytes("hi"). + GoType(&pb.Hi{}), + } +} +``` + +Test that it works: + +```go +package main + +import ( + "context" + "testing" + + "project/ent/enttest" + "project/pb" + + _ "github.com/mattn/go-sqlite3" + "github.com/stretchr/testify/require" +) + +func TestMain(t *testing.T) { + client := enttest.Open(t, "sqlite3", "file:ent?mode=memory&cache=shared&_fk=1") + defer client.Close() + + msg := client.Message.Create(). + SetHi(&pb.Hi{ + Greeting: "hello", + }). + SaveX(context.TODO()) + + ret := client.Message.GetX(context.TODO(), msg.ID) + require.Equal(t, "hello", ret.Hi.Greeting) +} +``` + +#### How to add `CHECK` constraints to table? + +The [`entsql.Annotation`](schema-annotations.md) option allows adding custom `CHECK` constraints to the `CREATE TABLE` +statement. In order to add `CHECK` constraints to your schema, use the following example: + +```go +func (User) Annotations() []schema.Annotation { + return []schema.Annotation{ + &entsql.Annotation{ + // The `Check` option allows adding an + // unnamed CHECK constraint to table DDL. + Check: "website <> 'entgo.io'", + + // The `Checks` option allows adding multiple CHECK constraints + // to table creation. The keys are used as the constraint names. + Checks: map[string]string{ + "valid_nickname": "nickname <> firstname", + "valid_firstname": "length(first_name) > 1", + }, + }, + } +} +``` + +#### How to define a custom precision numeric field? + +Using [GoType](schema-fields.mdx#go-type) and [SchemaType](schema-fields.mdx#database-type) it is possible to define +custom precision numeric fields. For example, defining a field that uses [big.Int](https://pkg.go.dev/math/big). + +```go +func (T) Fields() []ent.Field { + return []ent.Field{ + field.Int("precise"). + GoType(new(BigInt)). + SchemaType(map[string]string{ + dialect.SQLite: "numeric(78, 0)", + dialect.Postgres: "numeric(78, 0)", + }), + } +} + +type BigInt struct { + big.Int +} + +func (b *BigInt) Scan(src any) error { + var i sql.NullString + if err := i.Scan(src); err != nil { + return err + } + if !i.Valid { + return nil + } + if _, ok := b.Int.SetString(i.String, 10); ok { + return nil + } + return fmt.Errorf("could not scan type %T with value %v into BigInt", src, src) +} + +func (b *BigInt) Value() (driver.Value, error) { + return b.String(), nil +} +``` + +#### How to configure two or more `DB` to separate read and write? + +You can wrap the `dialect.Driver` with your own driver and implement this logic. For example. + +You can extend it, add support for multiple read replicas and add some load-balancing magic. + +```go +func main() { + // ... + wd, err := sql.Open(dialect.MySQL, "root:pass@tcp()/?parseTime=True") + if err != nil { + log.Fatal(err) + } + rd, err := sql.Open(dialect.MySQL, "readonly:pass@tcp()/?parseTime=True") + if err != nil { + log.Fatal(err) + } + client := ent.NewClient(ent.Driver(&multiDriver{w: wd, r: rd})) + defer client.Close() + // Use the client here. +} + +type multiDriver struct { + r, w dialect.Driver +} + +var _ dialect.Driver = (*multiDriver)(nil) + +func (d *multiDriver) Query(ctx context.Context, query string, args, v any) error { + e := d.r + // Mutation statements that use the RETURNING clause. + if ent.QueryFromContext(ctx) == nil { + e = d.w + } + return e.Query(ctx, query, args, v) +} + +func (d *multiDriver) Exec(ctx context.Context, query string, args, v any) error { + return d.w.Exec(ctx, query, args, v) +} + +func (d *multiDriver) Tx(ctx context.Context) (dialect.Tx, error) { + return d.w.Tx(ctx) +} + +func (d *multiDriver) BeginTx(ctx context.Context, opts *sql.TxOptions) (dialect.Tx, error) { + return d.w.(interface { + BeginTx(context.Context, *sql.TxOptions) (dialect.Tx, error) + }).BeginTx(ctx, opts) +} + +func (d *multiDriver) Close() error { + rerr := d.r.Close() + werr := d.w.Close() + if rerr != nil { + return rerr + } + if werr != nil { + return werr + } + return nil +} + +func (d *multiDriver) Dialect() string { + return d.r.Dialect() +} +``` + +#### How to configure `json.Marshal` to inline the `edges` keys in the top level object? + +To encode entities without the `edges` attribute, users can follow these two steps: + +1. Omit the default `edges` tag generated by Ent. +2. Extend the generated models with a custom MarshalJSON method. + +These two steps can be automated using [codegen extensions](extension.md), and a full working example is available under +the [examples/jsonencode](https://github.com/ent/ent/tree/master/examples/jsonencode) directory. + +```go title="ent/entc.go" {17,28} +//go:build ignore +// +build ignore + +package main + +import ( + "log" + + "entgo.io/ent/entc" + "entgo.io/ent/entc/gen" + "entgo.io/ent/schema/edge" +) + +func main() { + opts := []entc.Option{ + entc.Extensions{ + &EncodeExtension{}, + ), + } + err := entc.Generate("./schema", &gen.Config{}, opts...) + if err != nil { + log.Fatalf("running ent codegen: %v", err) + } +} + +// EncodeExtension is an implementation of entc.Extension that adds a MarshalJSON +// method to each generated type and inlines the Edges field to the top level JSON. +type EncodeExtension struct { + entc.DefaultExtension +} + +// Templates of the extension. +func (e *EncodeExtension) Templates() []*gen.Template { + return []*gen.Template{ + gen.MustParse(gen.NewTemplate("model/additional/jsonencode"). + Parse(` +{{ if $.Edges }} + // MarshalJSON implements the json.Marshaler interface. + func ({{ $.Receiver }} *{{ $.Name }}) MarshalJSON() ([]byte, error) { + type Alias {{ $.Name }} + return json.Marshal(&struct { + *Alias + {{ $.Name }}Edges + }{ + Alias: (*Alias)({{ $.Receiver }}), + {{ $.Name }}Edges: {{ $.Receiver }}.Edges, + }) + } +{{ end }} +`)), + } +} + +// Hooks of the extension. +func (e *EncodeExtension) Hooks() []gen.Hook { + return []gen.Hook{ + func(next gen.Generator) gen.Generator { + return gen.GenerateFunc(func(g *gen.Graph) error { + tag := edge.Annotation{StructTag: `json:"-"`} + for _, n := range g.Nodes { + n.Annotations.Set(tag.Name(), tag) + } + return next.Generate(g) + }) + }, + } +} +``` diff --git a/doc/md/features.md b/doc/md/features.md new file mode 100644 index 0000000..c429416 --- /dev/null +++ b/doc/md/features.md @@ -0,0 +1,436 @@ +--- +id: feature-flags +title: Feature Flags +sidebar_label: Feature Flags +--- + +The framework provides a collection of code-generation features that be added or removed using flags. + +## Usage + +Feature flags can be provided either by CLI flags or as arguments to the `gen` package. + +#### CLI + +```console +go run -mod=mod entgo.io/ent/cmd/ent generate --feature privacy,entql ./ent/schema +``` + +#### Go + +```go +// +build ignore + +package main + +import ( + "log" + "text/template" + + "entgo.io/ent/entc" + "entgo.io/ent/entc/gen" +) + +func main() { + err := entc.Generate("./schema", &gen.Config{ + Features: []gen.Feature{ + gen.FeaturePrivacy, + gen.FeatureEntQL, + }, + Templates: []*gen.Template{ + gen.MustParse(gen.NewTemplate("static"). + Funcs(template.FuncMap{"title": strings.ToTitle}). + ParseFiles("template/static.tmpl")), + }, + }) + if err != nil { + log.Fatalf("running ent codegen: %v", err) + } +} +``` + +## List of Features + +### Auto-Solve Merge Conflicts + +The `schema/snapshot` option tells `entc` (ent codegen) to store a snapshot of the latest schema in an internal package, +and use it to automatically solve merge conflicts when user's schema can't be built. + +This option can be added to a project using the `--feature schema/snapshot` flag, but please see +[ent/ent/issues/852](https://github.com/ent/ent/issues/852) to get more context about it. + +### Privacy Layer + +The privacy layer allows configuring privacy policy for queries and mutations of entities in the database. + +This option can be added to a project using the `--feature privacy` flag, and you can learn more about in the +[privacy](privacy.mdx) documentation. + +### EntQL Filtering + +The `entql` option provides a generic and dynamic filtering capability at runtime for the different query builders. + +This option can be added to a project using the `--feature entql` flag, and you can learn more about in the +[privacy](privacy.mdx#multi-tenancy) documentation. + +### Named Edges + +The `namedges` option provides an API for preloading edges with custom names. + +This option can be added to a project using the `--feature namedges` flag, and you can learn more about in the +[Eager Loading](eager-load.mdx) documentation. + +### Bidirectional Edge Refs + +The `bidiedges` option guides Ent to set two-way references when eager-loading (O2M/O2O) edges. + +This option can be added to a project using the `--feature bidiedges` flag. + +:::note +Users that use the standard encoding/json.MarshalJSON should detach the circular references before calling `json.Marshal`. +::: + +### Schema Config + +The `sql/schemaconfig` option lets you pass alternate SQL database names to models. This is useful when your models don't all live under one database and are spread out across different schemas. + +This option can be added to a project using the `--feature sql/schemaconfig` flag. Once you generate the code, you can now use a new option as such: + +```go +c, err := ent.Open(dialect, conn, ent.AlternateSchema(ent.SchemaConfig{ + User: "usersdb", + Car: "carsdb", +})) +c.User.Query().All(ctx) // SELECT * FROM `usersdb`.`users` +c.Car.Query().All(ctx) // SELECT * FROM `carsdb`.`cars` +``` + +### Row-level Locks + +The `sql/lock` option lets configure row-level locking using the SQL `SELECT ... FOR {UPDATE | SHARE}` syntax. + +This option can be added to a project using the `--feature sql/lock` flag. + +```go +tx, err := client.Tx(ctx) +if err != nil { + log.Fatal(err) +} + +tx.Pet.Query(). + Where(pet.Name(name)). + ForUpdate(). + Only(ctx) + +tx.Pet.Query(). + Where(pet.ID(id)). + ForShare( + sql.WithLockTables(pet.Table), + sql.WithLockAction(sql.NoWait), + ). + Only(ctx) +``` + +### Custom SQL Modifiers + +The `sql/modifier` option lets add custom SQL modifiers to the builders and mutate the statements before they are executed. + +This option can be added to a project using the `--feature sql/modifier` flag. + +#### Modify Example 1 + +```go +client.Pet. + Query(). + Modify(func(s *sql.Selector) { + s.Select("SUM(LENGTH(name))") + }). + IntX(ctx) +``` + +The above code will produce the following SQL query: + +```sql +SELECT SUM(LENGTH(name)) FROM `pet` +``` + +#### Select and Scan Dynamic Values + +If you work with SQL modifiers and need to scan dynamic values not present in your Ent schema definition, such as +aggregation or custom ordering, you can apply `AppendSelect`/`AppendSelectAs` to the `sql.Selector`. You can later +access their values using the `Value` method defined on each entity: + +```go {6,11} +const as = "name_length" + +// Query the entity with the dynamic value. +p := client.Pet.Query(). + Modify(func(s *sql.Selector) { + s.AppendSelectAs("LENGTH(name)", as) + }). + FirstX(ctx) + +// Read the value from the entity. +n, err := p.Value(as) +if err != nil { + log.Fatal(err) +} +fmt.Println("Name length: %d == %d", n, len(p.Name)) +``` + +#### Modify Example 2 + +```go +var p1 []struct { + ent.Pet + NameLength int `sql:"length"` +} + +client.Pet.Query(). + Order(ent.Asc(pet.FieldID)). + Modify(func(s *sql.Selector) { + s.AppendSelect("LENGTH(name)") + }). + ScanX(ctx, &p1) +``` + +The above code will produce the following SQL query: + +```sql +SELECT `pet`.*, LENGTH(name) FROM `pet` ORDER BY `pet`.`id` ASC +``` + +#### Modify Example 3 + +```go +var v []struct { + Count int `json:"count"` + Price int `json:"price"` + CreatedAt time.Time `json:"created_at"` +} + +client.User. + Query(). + Where( + user.CreatedAtGT(x), + user.CreatedAtLT(y), + ). + Modify(func(s *sql.Selector) { + s.Select( + sql.As(sql.Count("*"), "count"), + sql.As(sql.Sum("price"), "price"), + sql.As("DATE(created_at)", "created_at"), + ). + GroupBy("DATE(created_at)"). + OrderBy(sql.Desc("DATE(created_at)")) + }). + ScanX(ctx, &v) +``` + +The above code will produce the following SQL query: + +```sql +SELECT + COUNT(*) AS `count`, + SUM(`price`) AS `price`, + DATE(created_at) AS `created_at` +FROM + `users` +WHERE + `created_at` > x AND `created_at` < y +GROUP BY + DATE(created_at) +ORDER BY + DATE(created_at) DESC +``` + +#### Modify Example 4 + +```go +var gs []struct { + ent.Group + UsersCount int `sql:"users_count"` +} + +client.Group.Query(). + Order(ent.Asc(group.FieldID)). + Modify(func(s *sql.Selector) { + t := sql.Table(group.UsersTable) + s.LeftJoin(t). + On( + s.C(group.FieldID), + t.C(group.UsersPrimaryKey[1]), + ). + // Append the "users_count" column to the selected columns. + AppendSelect( + sql.As(sql.Count(t.C(group.UsersPrimaryKey[1])), "users_count"), + ). + GroupBy(s.C(group.FieldID)) + }). + ScanX(ctx, &gs) +``` + +The above code will produce the following SQL query: + +```sql +SELECT + `groups`.*, + COUNT(`t1`.`group_id`) AS `users_count` +FROM + `groups` LEFT JOIN `user_groups` AS `t1` +ON + `groups`.`id` = `t1`.`group_id` +GROUP BY + `groups`.`id` +ORDER BY + `groups`.`id` ASC +``` + + +#### Modify Example 5 + +```go +client.User.Update(). + Modify(func(s *sql.UpdateBuilder) { + s.Set(user.FieldName, sql.Expr(fmt.Sprintf("UPPER(%s)", user.FieldName))) + }). + ExecX(ctx) +``` + +The above code will produce the following SQL query: + +```sql +UPDATE `users` SET `name` = UPPER(`name`) +``` + +#### Modify Example 6 + +```go +client.User.Update(). + Modify(func(u *sql.UpdateBuilder) { + u.Set(user.FieldID, sql.ExprFunc(func(b *sql.Builder) { + b.Ident(user.FieldID).WriteOp(sql.OpAdd).Arg(1) + })) + u.OrderBy(sql.Desc(user.FieldID)) + }). + ExecX(ctx) +``` + +The above code will produce the following SQL query: + +```sql +UPDATE `users` SET `id` = `id` + 1 ORDER BY `id` DESC +``` + +#### Modify Example 7 + +Append elements to the `values` array in a JSON column: + +```go +client.User.Update(). + Modify(func(u *sql.UpdateBuilder) { + sqljson.Append(u, user.FieldTags, []string{"tag1", "tag2"}, sqljson.Path("values")) + }). + ExecX(ctx) +``` + +The above code will produce the following SQL query: + +```sql +UPDATE `users` SET `tags` = CASE + WHEN (JSON_TYPE(JSON_EXTRACT(`tags`, '$.values')) IS NULL OR JSON_TYPE(JSON_EXTRACT(`tags`, '$.values')) = 'NULL') + THEN JSON_SET(`tags`, '$.values', JSON_ARRAY(?, ?)) + ELSE JSON_ARRAY_APPEND(`tags`, '$.values', ?, '$.values', ?) END + WHERE `id` = ? +``` + +### SQL Raw API + +The `sql/execquery` option allows executing statements using the `ExecContext`/`QueryContext` methods of the underlying +driver. For full documentation, see: [DB.ExecContext](https://pkg.go.dev/database/sql#DB.ExecContext), and +[DB.QueryContext](https://pkg.go.dev/database/sql#DB.QueryContext). + +```go +// From ent.Client. +if _, err := client.ExecContext(ctx, "TRUNCATE t1"); err != nil { + return err +} + +// From ent.Tx. +tx, err := client.Tx(ctx) +if err != nil { + return err +} +if err := tx.User.Create().Exec(ctx); err != nil { + return err +} +if _, err := tx.ExecContext("SAVEPOINT user_created"); err != nil { + return err +} +// ... +``` + +:::warning Note +Statements executed using `ExecContext`/`QueryContext` do not go through Ent, and may skip fundamental layers in your +application such as hooks, privacy (authorization), and validators. +::: + +### Upsert + +The `sql/upsert` option lets configure upsert and bulk-upsert logic using the SQL `ON CONFLICT` / `ON DUPLICATE KEY` +syntax. For full documentation, go to the [Upsert API](crud.mdx#upsert-one). + +This option can be added to a project using the `--feature sql/upsert` flag. + +```go +// Use the new values that were set on create. +id, err := client.User. + Create(). + SetAge(30). + SetName("Ariel"). + OnConflict(). + UpdateNewValues(). + ID(ctx) + +// In PostgreSQL, the conflict target is required. +err := client.User. + Create(). + SetAge(30). + SetName("Ariel"). + OnConflictColumns(user.FieldName). + UpdateNewValues(). + Exec(ctx) + +// Bulk upsert is also supported. +client.User. + CreateBulk(builders...). + OnConflict( + sql.ConflictWhere(...), + sql.UpdateWhere(...), + ). + UpdateNewValues(). + Exec(ctx) + +// INSERT INTO "users" (...) VALUES ... ON CONFLICT WHERE ... DO UPDATE SET ... WHERE ... +``` + +### Globally Unique ID + +By default, SQL primary-keys start from 1 for each table; which means that multiple entities of different types +can share the same ID. Unlike AWS Neptune, where node IDs are UUIDs. + +This does not work well if you work with [GraphQL](https://graphql.org/learn/schema/#scalar-types), which requires +the object ID to be unique. + +To enable the Universal-IDs support for your project, simply use the `--feature sql/globalid` flag. + +:::warning Note +If you have used the `migrate.WithGlobalUniqueID(true)` migration option in the past, please read +[this guide](globalid-migrate) before you switch your project to use the new globalid feature. +::: + +**How does it work?** `ent` migration allocates a 1<<32 range for the IDs of each entity (table), +and store this information alongside your generated code (`internal/globalid.go`). For example, type `A` will have the +range of `[1,4294967296)` for its IDs, and type `B` will have the range of `[4294967296,8589934592)`, etc. + +Note that if this option is enabled, the maximum number of possible tables is **65535**. diff --git a/doc/md/generating-ent-schemas.md b/doc/md/generating-ent-schemas.md new file mode 100644 index 0000000..9a2d76a --- /dev/null +++ b/doc/md/generating-ent-schemas.md @@ -0,0 +1,225 @@ +--- +id: generating-ent-schemas +title: Generating Schemas +--- + +## Introduction + +To facilitate the creation of tooling that generates `ent.Schema`s programmatically, `ent` supports the manipulation of +the `schema/` directory using the `entgo.io/contrib/schemast` package. + +## API + +### Loading + +In order to manipulate an existing schema directory we must first load it into a `schemast.Context` object: + +```go +package main + +import ( + "fmt" + "log" + + "entgo.io/contrib/schemast" +) + +func main() { + ctx, err := schemast.Load("./ent/schema") + if err != nil { + log.Fatalf("failed: %v", err) + } + if ctx.HasType("user") { + fmt.Println("schema directory contains a schema named User!") + } +} +``` + +### Printing + +To print back out our context to a target directory, use `schemast.Print`: + +```go +package main + +import ( + "log" + + "entgo.io/contrib/schemast" +) + +func main() { + ctx, err := schemast.Load("./ent/schema") + if err != nil { + log.Fatalf("failed: %v", err) + } + // A no-op since we did not manipulate the Context at all. + if err := schemast.Print("./ent/schema"); err != nil { + log.Fatalf("failed: %v", err) + } +} +``` + +### Mutators + +To mutate the `ent/schema` directory, we can use `schemast.Mutate`, which takes a list of +`schemast.Mutator`s to apply to the context: + +```go +package schemast + +// Mutator changes a Context. +type Mutator interface { + Mutate(ctx *Context) error +} +``` + +Currently, only a single type of `schemast.Mutator` is implemented, `UpsertSchema`: + +```go +package schemast + +// UpsertSchema implements Mutator. UpsertSchema will add to the Context the type named +// Name if not present and rewrite the type's Fields, Edges, Indexes and Annotations methods. +type UpsertSchema struct { + Name string + Fields []ent.Field + Edges []ent.Edge + Indexes []ent.Index + Annotations []schema.Annotation +} +``` + +To use it: + +```go +package main + +import ( + "log" + + "entgo.io/contrib/schemast" + "entgo.io/ent" + "entgo.io/ent/schema/field" +) + +func main() { + ctx, err := schemast.Load("./ent/schema") + if err != nil { + log.Fatalf("failed: %v", err) + } + mutations := []schemast.Mutator{ + &schemast.UpsertSchema{ + Name: "User", + Fields: []ent.Field{ + field.String("name"), + }, + }, + &schemast.UpsertSchema{ + Name: "Team", + Fields: []ent.Field{ + field.String("name"), + }, + }, + } + err = schemast.Mutate(ctx, mutations...) + if err := ctx.Print("./ent/schema"); err != nil { + log.Fatalf("failed: %v", err) + } +} +``` + +After running this program, observe two new files exist in the schema directory: `user.go` and `team.go`: + +```go +// user.go +package schema + +import ( + "entgo.io/ent" + "entgo.io/ent/schema" + "entgo.io/ent/schema/field" +) + +type User struct { + ent.Schema +} + +func (User) Fields() []ent.Field { + return []ent.Field{field.String("name")} +} +func (User) Edges() []ent.Edge { + return nil +} +func (User) Annotations() []schema.Annotation { + return nil +} +``` + +```go +package schema + +import ( + "entgo.io/ent" + "entgo.io/ent/schema" + "entgo.io/ent/schema/field" +) + +type Team struct { + ent.Schema +} + +func (Team) Fields() []ent.Field { + return []ent.Field{field.String("name")} +} +func (Team) Edges() []ent.Edge { + return nil +} +func (Team) Annotations() []schema.Annotation { + return nil +} +``` + +### Working with Edges + +Edges are defined in `ent` this way: + +```go +edge.To("edge_name", OtherSchema.Type) +``` + +This syntax relies on the fact that the `OtherSchema` struct already exists when we define the edge so we can refer to +its `Type` method. When we are generating schemas programmatically, obviously we need somehow to describe the edge to +the code-generator before the type definitions exist. To do this you can do something like: + +```go +type placeholder struct { + ent.Schema +} + +func withType(e ent.Edge, typeName string) ent.Edge { + e.Descriptor().Type = typeName + return e +} + +func newEdgeTo(edgeName, otherType string) ent.Edge { + // we pass a placeholder type to the edge constructor: + e := edge.To(edgeName, placeholder.Type) + // then we override the other type's name directly on the edge descriptor: + return withType(e, otherType) +} +``` + +## Examples + +The `protoc-gen-ent` ([doc](https://github.com/ent/contrib/tree/master/entproto/cmd/protoc-gen-ent)) is a protoc plugin +that programmatically generates `ent.Schema`s from .proto files, it uses the `schemast` to manipulate the +target `schema` directory. To see +how, [read the source code](https://github.com/ent/contrib/blob/master/entproto/cmd/protoc-gen-ent/main.go#L34). + +## Caveats + +`schemast` is still experimental, APIs are subject to change in the future. In addition, a small portion of +the `ent.Field` definition API is unsupported at this point in time, to see a full list of unsupported features see +the [source code](https://github.com/ent/contrib/blob/aed7a43a3e54550c1dd9a1a066ce1236b4bae56c/schemast/field.go#L158). + diff --git a/doc/md/getting-started.mdx b/doc/md/getting-started.mdx new file mode 100644 index 0000000..112e431 --- /dev/null +++ b/doc/md/getting-started.mdx @@ -0,0 +1,701 @@ +--- +id: getting-started +title: Quick Introduction +sidebar_label: Quick Introduction +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; +import AtlasMigrateDiff from './components/_atlas_migrate_diff.mdx'; +import AtlasMigrateApply from './components/_atlas_migrate_apply.mdx'; +import InstallationInstructions from './components/_installation_instructions.mdx'; + +**ent** is a simple, yet powerful entity framework for Go, that makes it easy to build +and maintain applications with large data-models and sticks with the following principles: + +- Easily model database schema as a graph structure. +- Define schema as a programmatic Go code. +- Static typing based on code generation. +- Database queries and graph traversals are easy to write. +- Simple to extend and customize using Go templates. + +![gopher-schema-as-code](https://entgo.io/images/assets/gopher-schema-as-code.png) + +## Setup A Go Environment + +If your project directory is outside [GOPATH](https://github.com/golang/go/wiki/GOPATH) or you are not familiar with +GOPATH, setup a [Go module](https://github.com/golang/go/wiki/Modules#quick-start) project as follows: + +```console +go mod init entdemo +``` + +## Create Your First Schema + +Go to the root directory of your project, and run: + +```console +go run -mod=mod entgo.io/ent/cmd/ent new User +``` + +The command above will generate the schema for `User` under `entdemo/ent/schema/` directory: + +```go title="entdemo/ent/schema/user.go" + +package schema + +import "entgo.io/ent" + +// User holds the schema definition for the User entity. +type User struct { + ent.Schema +} + +// Fields of the User. +func (User) Fields() []ent.Field { + return nil +} + +// Edges of the User. +func (User) Edges() []ent.Edge { + return nil +} + +``` + +Add 2 fields to the `User` schema: + +```go title="entdemo/ent/schema/user.go" +// Fields of the User. +func (User) Fields() []ent.Field { + return []ent.Field{ + field.Int("age"). + Positive(), + field.String("name"). + Default("unknown"), + } +} +``` + +Run `go generate` from the root directory of the project as follows: + +```go +go generate ./ent +``` + +This produces the following files: +```console {12-20} +ent +├── client.go +├── config.go +├── context.go +├── ent.go +├── generate.go +├── mutation.go +... truncated +├── schema +│ └── user.go +├── tx.go +├── user +│ ├── user.go +│ └── where.go +├── user.go +├── user_create.go +├── user_delete.go +├── user_query.go +└── user_update.go +``` + + +## Create Your First Entity + +To get started, create a new `Client` to run schema migration and interact with your entities: + + + + +```go title="entdemo/start.go" +package main + +import ( + "context" + "log" + + "entdemo/ent" + + _ "github.com/mattn/go-sqlite3" +) + +func main() { + client, err := ent.Open("sqlite3", "file:ent?mode=memory&cache=shared&_fk=1") + if err != nil { + log.Fatalf("failed opening connection to sqlite: %v", err) + } + defer client.Close() + // Run the auto migration tool. + // highlight-start + if err := client.Schema.Create(context.Background()); err != nil { + log.Fatalf("failed creating schema resources: %v", err) + } + // highlight-end +} +``` + + + + +```go title="entdemo/start.go" +package main + +import ( + "context" + "log" + + "entdemo/ent" + + _ "github.com/lib/pq" +) + +func main() { + client, err := ent.Open("postgres","host= port= user= dbname= password=") + if err != nil { + log.Fatalf("failed opening connection to postgres: %v", err) + } + defer client.Close() + // Run the auto migration tool. + // highlight-start + if err := client.Schema.Create(context.Background()); err != nil { + log.Fatalf("failed creating schema resources: %v", err) + } + // highlight-end +} +``` + + + + +```go title="entdemo/start.go" +package main + +import ( + "context" + "log" + + "entdemo/ent" + + _ "github.com/go-sql-driver/mysql" +) + +func main() { + client, err := ent.Open("mysql", ":@tcp(:)/?parseTime=True") + if err != nil { + log.Fatalf("failed opening connection to mysql: %v", err) + } + defer client.Close() + // Run the auto migration tool. + // highlight-start + if err := client.Schema.Create(context.Background()); err != nil { + log.Fatalf("failed creating schema resources: %v", err) + } + // highlight-end +} +``` + + + + +After running schema migration, we're ready to create our user. For the sake of this example, let's name this function +_CreateUser_: + +```go title="entdemo/start.go" +func CreateUser(ctx context.Context, client *ent.Client) (*ent.User, error) { + u, err := client.User. + Create(). + SetAge(30). + SetName("a8m"). + Save(ctx) + if err != nil { + return nil, fmt.Errorf("failed creating user: %w", err) + } + log.Println("user was created: ", u) + return u, nil +} +``` + +## Query Your Entities + +`ent` generates a package for each entity schema that contains its predicates, default values, validators +and additional information about storage elements (column names, primary keys, etc). + +```go title="entdemo/start.go" +func QueryUser(ctx context.Context, client *ent.Client) (*ent.User, error) { + u, err := client.User. + Query(). + Where(user.Name("a8m")). + // `Only` fails if no user found, + // or more than 1 user returned. + Only(ctx) + if err != nil { + return nil, fmt.Errorf("failed querying user: %w", err) + } + log.Println("user returned: ", u) + return u, nil +} +``` + + +## Add Your First Edge (Relation) + +In this part of the tutorial, we want to declare an edge (relation) to another entity in the schema. +Let's create 2 additional entities named `Car` and `Group` with a few fields. We use `ent` CLI +to generate the initial schemas: + +```console +go run -mod=mod entgo.io/ent/cmd/ent new Car Group +``` + +And then we add the rest of the fields manually: + +```go title="entdemo/ent/schema/car.go" +// Fields of the Car. +func (Car) Fields() []ent.Field { + return []ent.Field{ + field.String("model"), + field.Time("registered_at"), + } +} +``` + +```go title="entdemo/ent/schema/group.go" +// Fields of the Group. +func (Group) Fields() []ent.Field { + return []ent.Field{ + field.String("name"). + // Regexp validation for group name. + Match(regexp.MustCompile("[a-zA-Z_]+$")), + } +} +``` + +Let's define our first relation. An edge from `User` to `Car` defining that a user +can **have 1 or more** cars, but a car **has only one** owner (one-to-many relation). + +![er-user-cars](https://entgo.io/images/assets/re_user_cars.png) + +Let's add the `"cars"` edge to the `User` schema, and run `go generate ./ent`: + +```go title="entdemo/ent/schema/user.go" +// Edges of the User. +func (User) Edges() []ent.Edge { + return []ent.Edge{ + edge.To("cars", Car.Type), + } +} +``` + +We continue our example by creating 2 cars and adding them to a user. + +```go title="entdemo/start.go" +func CreateCars(ctx context.Context, client *ent.Client) (*ent.User, error) { + // Create a new car with model "Tesla". + tesla, err := client.Car. + Create(). + SetModel("Tesla"). + SetRegisteredAt(time.Now()). + Save(ctx) + if err != nil { + return nil, fmt.Errorf("failed creating car: %w", err) + } + log.Println("car was created: ", tesla) + + // Create a new car with model "Ford". + ford, err := client.Car. + Create(). + SetModel("Ford"). + SetRegisteredAt(time.Now()). + Save(ctx) + if err != nil { + return nil, fmt.Errorf("failed creating car: %w", err) + } + log.Println("car was created: ", ford) + + // Create a new user, and add it the 2 cars. + a8m, err := client.User. + Create(). + SetAge(30). + SetName("a8m"). + AddCars(tesla, ford). + Save(ctx) + if err != nil { + return nil, fmt.Errorf("failed creating user: %w", err) + } + log.Println("user was created: ", a8m) + return a8m, nil +} +``` +But what about querying the `cars` edge (relation)? Here's how we do it: + +```go title="entdemo/start.go" +func QueryCars(ctx context.Context, a8m *ent.User) error { + cars, err := a8m.QueryCars().All(ctx) + if err != nil { + return fmt.Errorf("failed querying user cars: %w", err) + } + log.Println("returned cars:", cars) + + // What about filtering specific cars. + ford, err := a8m.QueryCars(). + Where(car.Model("Ford")). + Only(ctx) + if err != nil { + return fmt.Errorf("failed querying user cars: %w", err) + } + log.Println(ford) + return nil +} +``` + +## Add Your First Inverse Edge (BackRef) +Assume we have a `Car` object and we want to get its owner; the user that this car belongs to. +For this, we have another type of edge called "inverse edge" that is defined using the `edge.From` +function. + +![er-cars-owner](https://entgo.io/images/assets/re_cars_owner.png) + +The new edge created in the diagram above is translucent, to emphasize that we don't create another +edge in the database. It's just a back-reference to the real edge (relation). + +Let's add an inverse edge named `owner` to the `Car` schema, reference it to the `cars` edge +in the `User` schema, and run `go generate ./ent`. + +```go title="entdemo/ent/schema/car.go" +// Edges of the Car. +func (Car) Edges() []ent.Edge { + return []ent.Edge{ + // Create an inverse-edge called "owner" of type `User` + // and reference it to the "cars" edge (in User schema) + // explicitly using the `Ref` method. + edge.From("owner", User.Type). + Ref("cars"). + // setting the edge to unique, ensure + // that a car can have only one owner. + Unique(), + } +} +``` +We'll continue the user/cars example above by querying the inverse edge. + +```go title="entdemo/start.go" +func QueryCarUsers(ctx context.Context, a8m *ent.User) error { + cars, err := a8m.QueryCars().All(ctx) + if err != nil { + return fmt.Errorf("failed querying user cars: %w", err) + } + // Query the inverse edge. + for _, c := range cars { + owner, err := c.QueryOwner().Only(ctx) + if err != nil { + return fmt.Errorf("failed querying car %q owner: %w", c.Model, err) + } + log.Printf("car %q owner: %q\n", c.Model, owner.Name) + } + return nil +} +``` + +## Visualize the Schema + +If you have reached this point, you have successfully executed the schema migration and created several entities in the +database. To view the SQL schema generated by Ent for the database, install [Atlas](https://github.com/ariga/atlas) +and run the following command: + +#### Install Atlas + + + + + + +#### Inspect The Ent Schema + +```bash +atlas schema inspect \ + -u "ent://ent/schema" \ + --dev-url "sqlite://file?mode=memory&_fk=1" \ + -w +``` + +#### ERD and SQL Schema + +[![erd](https://atlasgo.io/uploads/erd-example.png)](https://gh.atlasgo.cloud/explore/40d83919) + + + + +#### Inspect The Ent Schema + +```bash +atlas schema inspect \ + -u "ent://ent/schema" \ + --dev-url "sqlite://file?mode=memory&_fk=1" \ + --format '{{ sql . " " }}' +``` + +#### SQL Output + +```sql +-- Create "cars" table +CREATE TABLE `cars` ( + `id` integer NOT NULL PRIMARY KEY AUTOINCREMENT, + `model` text NOT NULL, + `registered_at` datetime NOT NULL, + `user_cars` integer NULL, + CONSTRAINT `cars_users_cars` FOREIGN KEY (`user_cars`) REFERENCES `users` (`id`) ON DELETE SET NULL +); + +-- Create "users" table +CREATE TABLE `users` ( + `id` integer NOT NULL PRIMARY KEY AUTOINCREMENT, + `age` integer NOT NULL, + `name` text NOT NULL DEFAULT 'unknown' +); +``` + + + + +## Create Your Second Edge + +We'll continue our example by creating a M2M (many-to-many) relationship between users and groups. + +![er-group-users](https://entgo.io/images/assets/re_group_users.png) + +As you can see, each group entity can **have many** users, and a user can **be connected to many** groups; +a simple "many-to-many" relationship. In the above illustration, the `Group` schema is the owner +of the `users` edge (relation), and the `User` entity has a back-reference/inverse edge to this +relationship named `groups`. Let's define this relationship in our schemas: + +```go title="entdemo/ent/schema/group.go" +// Edges of the Group. +func (Group) Edges() []ent.Edge { + return []ent.Edge{ + edge.To("users", User.Type), + } +} +``` + +```go title="entdemo/ent/schema/user.go" +// Edges of the User. +func (User) Edges() []ent.Edge { + return []ent.Edge{ + edge.To("cars", Car.Type), + // Create an inverse-edge called "groups" of type `Group` + // and reference it to the "users" edge (in Group schema) + // explicitly using the `Ref` method. + edge.From("groups", Group.Type). + Ref("users"), + } +} +``` + +We run `ent` on the schema directory to re-generate the assets. +```console +go generate ./ent +``` + +## Run Your First Graph Traversal + +In order to run our first graph traversal, we need to generate some data (nodes and edges, or in other words, +entities and relations). Let's create the following graph using the framework: + +![re-graph](https://entgo.io/images/assets/re_graph_getting_started.png) + + +```go title="entdemo/start.go" +func CreateGraph(ctx context.Context, client *ent.Client) error { + // First, create the users. + a8m, err := client.User. + Create(). + SetAge(30). + SetName("Ariel"). + Save(ctx) + if err != nil { + return err + } + neta, err := client.User. + Create(). + SetAge(28). + SetName("Neta"). + Save(ctx) + if err != nil { + return err + } + // Then, create the cars, and attach them to the users created above. + err = client.Car. + Create(). + SetModel("Tesla"). + SetRegisteredAt(time.Now()). + // Attach this car to Ariel. + SetOwner(a8m). + Exec(ctx) + if err != nil { + return err + } + err = client.Car. + Create(). + SetModel("Mazda"). + SetRegisteredAt(time.Now()). + // Attach this car to Ariel. + SetOwner(a8m). + Exec(ctx) + if err != nil { + return err + } + err = client.Car. + Create(). + SetModel("Ford"). + SetRegisteredAt(time.Now()). + // Attach this car to Neta. + SetOwner(neta). + Exec(ctx) + if err != nil { + return err + } + // Create the groups, and add their users in the creation. + err = client.Group. + Create(). + SetName("GitLab"). + AddUsers(neta, a8m). + Exec(ctx) + if err != nil { + return err + } + err = client.Group. + Create(). + SetName("GitHub"). + AddUsers(a8m). + Exec(ctx) + if err != nil { + return err + } + log.Println("The graph was created successfully") + return nil +} +``` + +Now when we have a graph with data, we can run a few queries on it: + +1. Get all user's cars within the group named "GitHub": + + ```go title="entdemo/start.go" + func QueryGithub(ctx context.Context, client *ent.Client) error { + cars, err := client.Group. + Query(). + Where(group.Name("GitHub")). // (Group(Name=GitHub),) + QueryUsers(). // (User(Name=Ariel, Age=30),) + QueryCars(). // (Car(Model=Tesla, RegisteredAt=