From b6c59d97ad30a1ad7d1fb006adddafc92a6748a0 Mon Sep 17 00:00:00 2001 From: Dave New Date: Tue, 31 Oct 2023 08:52:59 +0200 Subject: [PATCH] fix: code completions for identity and built-in types (#1268) --- .github/workflows/publish_npm_packages.yml | 6 +- .github/workflows/test_npm_modules.yml | 2 +- .../workflows/update_npm_packages_dist.yml | 2 +- cmd/program/client.go | 2 +- cmd/program/commands.go | 2 +- cmd/program/model.go | 2 +- .../testdata/set_backlinks/schema.keel | 51 ++ .../testdata/set_backlinks/tests.test.ts | 88 ++++ packages/wasm/lib/main.go | 4 +- runtime/actions/query_test.go | 84 ++++ runtime/apis/graphql/graphql_test.go | 2 +- runtime/expressions/operand.go | 38 +- schema/completions/completions.go | 142 +++--- schema/completions/completions_test.go | 472 ++++++++++++++---- schema/parser/operand.go | 27 +- schema/reader/reader.go | 10 +- schema/schema.go | 62 ++- .../proto.json | 421 ++++++++++++++++ .../schema.keel | 30 ++ .../errors.json | 4 +- .../errors.json | 6 +- .../errors.json | 4 +- .../errors.json | 4 +- .../schema.keel | 1 + .../errors.json | 4 +- .../errors.json | 4 +- .../errors.json | 174 +++++++ .../schema.keel | 30 ++ .../errors.json | 38 ++ .../schema.keel | 14 + schema/validation/errorhandling/errors.yml | 8 +- schema/validation/errorhandling/format.go | 2 +- schema/validation/rules/field/field.go | 56 +-- schema/validation/validation.go | 1 - 34 files changed, 1480 insertions(+), 317 deletions(-) create mode 100644 schema/testdata/proto_set_attribute_ctx_identity_fields/proto.json create mode 100644 schema/testdata/proto_set_attribute_ctx_identity_fields/schema.keel create mode 100644 schema/testdata/validation_set_attribute_ctx_identity_fields_invalid_types/errors.json create mode 100644 schema/testdata/validation_set_attribute_ctx_identity_fields_invalid_types/schema.keel create mode 100644 schema/testdata/validation_set_attribute_ctx_identity_invalid_fields/errors.json create mode 100644 schema/testdata/validation_set_attribute_ctx_identity_invalid_fields/schema.keel diff --git a/.github/workflows/publish_npm_packages.yml b/.github/workflows/publish_npm_packages.yml index 2d2b24414..505bb0c93 100644 --- a/.github/workflows/publish_npm_packages.yml +++ b/.github/workflows/publish_npm_packages.yml @@ -40,12 +40,12 @@ jobs: - uses: actions/setup-node@v3 with: - node-version: 18 - token: ${{ secrets.NPM_TOKEN }} + node-version: 18.12.1 + token: ${{ secrets.NPM_TOKEN }} - uses: pnpm/action-setup@v2.4.0 with: - version: 8.5.1 + version: 8.10.0 - name: Checkout repository uses: actions/checkout@v3 diff --git a/.github/workflows/test_npm_modules.yml b/.github/workflows/test_npm_modules.yml index 348ddc8b9..671b1dc6b 100644 --- a/.github/workflows/test_npm_modules.yml +++ b/.github/workflows/test_npm_modules.yml @@ -60,7 +60,7 @@ jobs: node-version: 16.14.2 # vscode (electron) uses this so we want to make the tests use a comparable environment - uses: pnpm/action-setup@v2 with: - version: 8.5.1 + version: 8.10.0 - name: Setup golang uses: actions/setup-go@v3 with: diff --git a/.github/workflows/update_npm_packages_dist.yml b/.github/workflows/update_npm_packages_dist.yml index 039982d46..25a16ee3b 100644 --- a/.github/workflows/update_npm_packages_dist.yml +++ b/.github/workflows/update_npm_packages_dist.yml @@ -33,7 +33,7 @@ jobs: steps: - uses: actions/setup-node@v3 with: - node-version: 18 + node-version: 18.12.1 token: ${{ secrets.NPM_TOKEN }} - name: Adding `${{ inputs.publishTag }}` tag to ${{ matrix.package }}@${{ inputs.version }} run: npm dist-tag add @teamkeel/${{ matrix.package }}@${{ inputs.version }} ${{ inputs.publishTag }} diff --git a/cmd/program/client.go b/cmd/program/client.go index 3255c5b4e..82a2c99d5 100644 --- a/cmd/program/client.go +++ b/cmd/program/client.go @@ -32,7 +32,7 @@ type GenerateClientModel struct { Err error Schema *proto.Schema - SchemaFiles []reader.SchemaFile + SchemaFiles []*reader.SchemaFile Secrets map[string]string Config *config.ProjectConfig diff --git a/cmd/program/commands.go b/cmd/program/commands.go index 9341d0b92..d667cfded 100644 --- a/cmd/program/commands.go +++ b/cmd/program/commands.go @@ -42,7 +42,7 @@ func NextMsgCommand(ch chan tea.Msg) tea.Cmd { type LoadSchemaMsg struct { Schema *proto.Schema Config *config.ProjectConfig - SchemaFiles []reader.SchemaFile + SchemaFiles []*reader.SchemaFile Secrets map[string]string Err error } diff --git a/cmd/program/model.go b/cmd/program/model.go index ce5b43854..53fe64bee 100644 --- a/cmd/program/model.go +++ b/cmd/program/model.go @@ -133,7 +133,7 @@ type Model struct { Err error Schema *proto.Schema Config *config.ProjectConfig - SchemaFiles []reader.SchemaFile + SchemaFiles []*reader.SchemaFile Database db.Database DatabaseConnInfo *db.ConnectionInfo GeneratedFiles codegen.GeneratedFiles diff --git a/integration/testdata/set_backlinks/schema.keel b/integration/testdata/set_backlinks/schema.keel index 9b282cccc..48dda296a 100644 --- a/integration/testdata/set_backlinks/schema.keel +++ b/integration/testdata/set_backlinks/schema.keel @@ -62,3 +62,54 @@ model Record { actions: [create, update] ) } + +model UserExtension { + fields { + name Text + identity1 Identity { + @unique + @relation(user1) + } + identity2 Identity { + @unique + @relation(user2) + } + user1 User + user2 User + email Text + isVerified Boolean + signedUpAt Timestamp + issuer Text + externalId Text + } + + actions { + create createExt() with (n: Text) { + @set(userExtension.name = n) + @set(userExtension.identity1 = ctx.identity) + @set(userExtension.identity2.id = ctx.identity.id) + @set(userExtension.user1 = ctx.identity.user) + @set(userExtension.user2.id = ctx.identity.user.id) + @set(userExtension.email = ctx.identity.email) + @set(userExtension.isVerified = ctx.identity.emailVerified) + @set(userExtension.signedUpAt = ctx.identity.createdAt) + @set(userExtension.issuer = ctx.identity.issuer) + @set(userExtension.externalId = ctx.identity.externalId) + @permission(expression: ctx.isAuthenticated) + } + + update updateExt(id) with (n: Text) { + @set(userExtension.name = n) + @set(userExtension.identity1 = ctx.identity) + @set(userExtension.identity2.id = ctx.identity.id) + @set(userExtension.user1 = ctx.identity.user) + @set(userExtension.user2.id = ctx.identity.user.id) + @set(userExtension.email = ctx.identity.email) + @set(userExtension.isVerified = ctx.identity.emailVerified) + @set(userExtension.signedUpAt = ctx.identity.createdAt) + @set(userExtension.issuer = ctx.identity.issuer) + @set(userExtension.externalId = ctx.identity.externalId) + @permission(expression: ctx.isAuthenticated) + } + } +} \ No newline at end of file diff --git a/integration/testdata/set_backlinks/tests.test.ts b/integration/testdata/set_backlinks/tests.test.ts index c395faad1..60cdc0c25 100644 --- a/integration/testdata/set_backlinks/tests.test.ts +++ b/integration/testdata/set_backlinks/tests.test.ts @@ -223,3 +223,91 @@ test("update - @set with backlinks and no user backlink", async () => { message: "field 'isActive' cannot be null", }); }); + +test("create - @set with identity fields", async () => { + const { identityCreated } = await actions.authenticate({ + createIfNotExists: true, + emailPassword: { + email: "user@keel.xyz", + password: "1234", + }, + }); + expect(identityCreated).toBeTruthy(); + + const identity = await models.identity.update( + { email: "user@keel.xyz" }, + { externalId: "extId" } + ); + + const org = await models.organisation.create({ + name: "Keel", + isActive: true, + }); + const user = await models.user.create({ + name: "Keelson", + identityId: identity!.id, + organisationId: org.id, + }); + + const extension = await actions + .withIdentity(identity!) + .createExt({ n: "Keelson" }); + + expect(extension.name).toEqual("Keelson"); + expect(extension.identity1Id).toEqual(identity?.id); + expect(extension.identity2Id).toEqual(identity?.id); + expect(extension.user1Id).toEqual(user.id); + expect(extension.user2Id).toEqual(user.id); + expect(extension.email).toEqual(identity?.email); + expect(extension.isVerified).toEqual(identity?.emailVerified); + expect(extension.issuer).toEqual(identity?.issuer); + // https://linear.app/keel/issue/KE-1192/datetime-precision-loss + //expect(extension.signedUpAt).toEqual(identity?.createdAt); + expect(extension.externalId).toEqual(identity?.externalId); +}); + +test("update - @set with identity fields", async () => { + const { identityCreated } = await actions.authenticate({ + createIfNotExists: true, + emailPassword: { + email: "user@keel.xyz", + password: "1234", + }, + }); + expect(identityCreated).toBeTruthy(); + + const identity = await models.identity.update( + { email: "user@keel.xyz" }, + { externalId: "extId" } + ); + + const org = await models.organisation.create({ + name: "Keel", + isActive: true, + }); + const user = await models.user.create({ + name: "Keelson", + identityId: identity!.id, + organisationId: org.id, + }); + + const { id } = await actions + .withIdentity(identity!) + .createExt({ n: "Keelson" }); + + const extension = await actions + .withIdentity(identity!) + .updateExt({ where: { id: id }, values: { n: "Keelson" } }); + + expect(extension.name).toEqual("Keelson"); + expect(extension.identity1Id).toEqual(identity?.id); + expect(extension.identity2Id).toEqual(identity?.id); + expect(extension.user1Id).toEqual(user.id); + expect(extension.user2Id).toEqual(user.id); + expect(extension.email).toEqual(identity?.email); + expect(extension.isVerified).toEqual(identity?.emailVerified); + expect(extension.issuer).toEqual(identity?.issuer); + // https://linear.app/keel/issue/KE-1192/datetime-precision-loss + //expect(extension.signedUpAt).toEqual(identity?.createdAt); + expect(extension.externalId).toEqual(identity?.externalId); +}); diff --git a/packages/wasm/lib/main.go b/packages/wasm/lib/main.go index 275504851..33f66a060 100644 --- a/packages/wasm/lib/main.go +++ b/packages/wasm/lib/main.go @@ -207,10 +207,10 @@ func validate(this js.Value, args []js.Value) any { return newPromise(func() (any, error) { schemaFilesArg := args[0].Get("schemaFiles") - schemaFiles := []reader.SchemaFile{} + schemaFiles := []*reader.SchemaFile{} for i := 0; i < schemaFilesArg.Length(); i++ { f := schemaFilesArg.Index(i) - schemaFiles = append(schemaFiles, reader.SchemaFile{ + schemaFiles = append(schemaFiles, &reader.SchemaFile{ FileName: f.Get("filename").String(), Contents: f.Get("contents").String(), }) diff --git a/runtime/actions/query_test.go b/runtime/actions/query_test.go index e9d873a07..a4ada1d81 100644 --- a/runtime/actions/query_test.go +++ b/runtime/actions/query_test.go @@ -2684,6 +2684,90 @@ var testCases = []testCase{ RETURNING "product"."id"`, expectedArgs: []any{"prodcode", "brand", true, true}, }, + { + name: "create_set_ctx_identity_fields", + keelSchema: ` + model Person { + fields { + email Text + created Timestamp + emailVerified Boolean + externalId Text + issuer Text + } + actions { + create createPerson() { + @set(person.email = ctx.identity.email) + @set(person.created = ctx.identity.createdAt) + @set(person.emailVerified = ctx.identity.emailVerified) + @set(person.externalId = ctx.identity.externalId) + @set(person.issuer = ctx.identity.issuer) + } + } + @permission(expression: true, actions: [create]) + }`, + actionName: "createPerson", + input: map[string]any{}, + identity: identity, + expectedTemplate: ` + WITH + select_identity (column_0, column_1, column_2, column_3, column_4) AS ( + SELECT "identity"."email", "identity"."created_at", "identity"."email_verified", "identity"."external_id", "identity"."issuer" + FROM "identity" + WHERE "identity"."id" IS NOT DISTINCT FROM ?), + new_1_person AS ( + INSERT INTO "person" (created, email, email_verified, external_id, issuer) + VALUES ( + (SELECT column_1 FROM select_identity), + (SELECT column_0 FROM select_identity), + (SELECT column_2 FROM select_identity), + (SELECT column_3 FROM select_identity), + (SELECT column_4 FROM select_identity)) + RETURNING *) + SELECT * FROM new_1_person`, + expectedArgs: []any{identity.Id}, + }, + { + name: "update_set_ctx_identity_fields", + keelSchema: ` + model Person { + fields { + email Text + created Timestamp + emailVerified Boolean + externalId Text + issuer Text + } + actions { + update updatePerson(id) { + @set(person.email = ctx.identity.email) + @set(person.created = ctx.identity.createdAt) + @set(person.emailVerified = ctx.identity.emailVerified) + @set(person.externalId = ctx.identity.externalId) + @set(person.issuer = ctx.identity.issuer) + } + } + @permission(expression: true, actions: [create]) + }`, + actionName: "updatePerson", + input: map[string]any{"where": map[string]any{"id": "xyz"}}, + identity: identity, + expectedTemplate: ` + WITH + select_identity (column_0, column_1, column_2, column_3, column_4) AS ( + SELECT "identity"."email", "identity"."created_at", "identity"."email_verified", "identity"."external_id", "identity"."issuer" + FROM "identity" + WHERE "identity"."id" IS NOT DISTINCT FROM ?) + UPDATE "person" SET + created = (SELECT column_1 FROM select_identity), + email = (SELECT column_0 FROM select_identity), + email_verified = (SELECT column_2 FROM select_identity), + external_id = (SELECT column_3 FROM select_identity), + issuer = (SELECT column_4 FROM select_identity) + WHERE "person"."id" IS NOT DISTINCT FROM ? + RETURNING "person".*`, + expectedArgs: []any{identity.Id, "xyz"}, + }, } func TestQueryBuilder(t *testing.T) { diff --git a/runtime/apis/graphql/graphql_test.go b/runtime/apis/graphql/graphql_test.go index d1a446c8a..c5f1d0dd3 100644 --- a/runtime/apis/graphql/graphql_test.go +++ b/runtime/apis/graphql/graphql_test.go @@ -55,7 +55,7 @@ func TestGraphQL(t *testing.T) { t.Run(name, func(t *testing.T) { builder := schema.Builder{} protoSchema, err := builder.MakeFromInputs(&reader.Inputs{ - SchemaFiles: []reader.SchemaFile{ + SchemaFiles: []*reader.SchemaFile{ { Contents: tc.schema, }, diff --git a/runtime/expressions/operand.go b/runtime/expressions/operand.go index d0a5c640d..97ea953eb 100644 --- a/runtime/expressions/operand.go +++ b/runtime/expressions/operand.go @@ -165,8 +165,7 @@ func (resolver *OperandResolver) IsExplicitInput() bool { // such as: @where(post.author.isActive) func (resolver *OperandResolver) IsModelDbColumn() bool { return !resolver.IsLiteral() && - !resolver.IsContextField() && - !resolver.IsContextDbColumn() && + !resolver.IsContext() && !resolver.IsExplicitInput() && !resolver.IsImplicitInput() } @@ -175,7 +174,7 @@ func (resolver *OperandResolver) IsModelDbColumn() bool { // which will require database access (such as with identity backlinks), // such as: @permission(expression: ctx.identity.user.isActive) func (resolver *OperandResolver) IsContextDbColumn() bool { - return resolver.operand.Ident.IsContext() && resolver.isContextIdentityDbColumn() + return resolver.operand.Ident.IsContextIdentity() && !resolver.operand.Ident.IsContextIdentityId() } // IsContextField returns true if the expression operand refers to a value on the context @@ -189,7 +188,11 @@ func (resolver *OperandResolver) IsContextDbColumn() bool { // then it returns false, because that can no longer be resolved solely from the // in memory context data. func (resolver *OperandResolver) IsContextField() bool { - return resolver.operand.Ident.IsContext() && !resolver.isContextIdentityDbColumn() + return resolver.operand.Ident.IsContext() && !resolver.IsContextDbColumn() +} + +func (resolver *OperandResolver) IsContext() bool { + return resolver.operand.Ident.IsContext() } // GetOperandType returns the equivalent protobuf type for the expression operand. @@ -322,7 +325,7 @@ func (resolver *OperandResolver) ResolveValue(args map[string]any) (any, error) case resolver.IsModelDbColumn(), resolver.IsContextDbColumn(): // todo: https://linear.app/keel/issue/RUN-153/set-attribute-to-support-targeting-database-fields panic("cannot resolve operand value from the database") - case resolver.operand.Ident.IsContextIdentityField(): + case resolver.operand.Ident.IsContextIdentityId(): isAuthenticated := auth.IsAuthenticated(resolver.Context) if !isAuthenticated { return nil, nil @@ -416,28 +419,3 @@ func toTime(s string) time.Time { tm, _ := time.Parse(time.RFC3339, s) return tm } - -// isContextIdentityDbColumn works out if this operand traverses an Identity field and requires the database. -func (resolver *OperandResolver) isContextIdentityDbColumn() bool { - if resolver.operand.Ident == nil { - return false - } - fragments := lo.Map(resolver.operand.Ident.Fragments, func(frag *parser.IdentFragment, _ int) string { - return frag.Fragment - }) - - if len(fragments) < 3 { - return false - } - if fragments[0] != "ctx" { - return false - } - if fragments[1] != "identity" { - return false - } - if fragments[2] == parser.ImplicitFieldNameId { - return false - } - - return true -} diff --git a/schema/completions/completions.go b/schema/completions/completions.go index c505859d8..483c3a12b 100644 --- a/schema/completions/completions.go +++ b/schema/completions/completions.go @@ -8,6 +8,7 @@ import ( "github.com/samber/lo" "github.com/teamkeel/keel/casing" "github.com/teamkeel/keel/config" + sch "github.com/teamkeel/keel/schema" "github.com/teamkeel/keel/schema/node" "github.com/teamkeel/keel/schema/parser" "github.com/teamkeel/keel/schema/query" @@ -40,20 +41,16 @@ const ( ) func Completions(schemaFiles []*reader.SchemaFile, pos *node.Position, cfg *config.ProjectConfig) []*CompletionItem { - var schema string - asts := []*parser.AST{} - for _, f := range schemaFiles { - // parse the schema ignoring any errors, it's very likely the - // schema is not in a valid state - ast, _ := parser.Parse(f) - asts = append(asts, ast) if f.FileName == pos.Filename { schema = f.Contents } } + builder := sch.Builder{} + asts, _, _ := builder.PrepareAst(&reader.Inputs{SchemaFiles: schemaFiles}) + tokenAtPos := NewTokensAtPosition(schema, pos) // First check if we're within an attribute's arguments list @@ -126,7 +123,7 @@ func getUndefinedFieldCompletions(asts []*parser.AST, tokenAtPos *TokensAtPositi enum := query.Enum(asts, field.Type.Value) - if model == nil && enum == nil { + if model == nil && enum == nil && !parser.IsBuiltInFieldType(field.Type.Value) { items = append(items, &CompletionItem{ Label: field.Type.Value, Kind: KindType, @@ -407,24 +404,6 @@ func getJobCompletions() []*CompletionItem { return completions } -var builtInFieldCompletions = []*CompletionItem{ - { - Label: parser.ImplicitFieldNameId, - Description: parser.FieldTypeID, - Kind: KindField, - }, - { - Label: parser.ImplicitFieldNameCreatedAt, - Description: parser.FieldTypeDatetime, - Kind: KindField, - }, - { - Label: parser.ImplicitFieldNameUpdatedAt, - Description: parser.FieldTypeDatetime, - Kind: KindField, - }, -} - var modelBlockKeywords = []*CompletionItem{ { Label: parser.KeywordFields, @@ -540,16 +519,12 @@ func getBuiltInTypeCompletions() []*CompletionItem { Kind: KindType, }) } - completions = append(completions, &CompletionItem{ - Label: "Identity", - Kind: KindModel, - }) return completions } func getActionInputCompletions(asts []*parser.AST, tokenAtPos *TokensAtPosition) []*CompletionItem { // inside action input args - auto-complete field names - completions := append([]*CompletionItem{}, builtInFieldCompletions...) + completions := []*CompletionItem{} block := tokenAtPos.StartOfBlock() @@ -661,7 +636,11 @@ func getAttributeArgCompletions(asts []*parser.AST, t *TokensAtPosition, cfg *co model := query.Model(asts, modelName) fields := query.ModelFields(model, func(f *parser.FieldNode) bool { - return f.IsScalar() + return f.IsScalar() && + f.Type.Value != parser.FieldTypeDatetime && + f.Type.Value != parser.FieldTypeSecret && + f.Type.Value != parser.FieldTypePassword && + f.Type.Value != parser.FieldTypeID }) allFields := lo.Map(fields, func(f *parser.FieldNode, _ int) *CompletionItem { @@ -729,8 +708,6 @@ func getSortableArgCompletions(asts []*parser.AST, t *TokensAtPosition, cfg *con }) } - completions = append(completions, builtInFieldCompletions...) - return completions } @@ -776,18 +753,16 @@ func getOrderByArgCompletions(asts []*parser.AST, t *TokensAtPosition, cfg *conf }) } - completions = append(completions, builtInFieldCompletions...) - return completions } return []*CompletionItem{ { - Label: "asc", + Label: parser.OrderByAscending, Kind: KindKeyword, }, { - Label: "desc", + Label: parser.OrderByDescending, Kind: KindKeyword, }, } @@ -808,8 +783,6 @@ func getScheduleArgCompletions(asts []*parser.AST, t *TokensAtPosition, cfg *con }) } - completions = append(completions, builtInFieldCompletions...) - return completions } @@ -936,46 +909,56 @@ func getExpressionCompletions(asts []*parser.AST, t *TokensAtPosition, cfg *conf switch previousIdents[0] { case "ctx": var completions []*CompletionItem - completions = []*CompletionItem{ - { - Label: "identity", - Description: "Identity", - Kind: KindField, - }, - { - Label: "now", - Description: "Timestamp", - Kind: KindField, - }, - { - Label: "env", - Description: "Environment Variables", - Kind: KindField, - }, - { - Label: "secrets", - Description: "Secrets", - Kind: KindField, - }, - { - Label: "isAuthenticated", - Description: "Authentication Indicator", - Kind: KindField, - }, - { - Label: "headers", - Description: "Request Headers", - Kind: KindField, - }, - } - if len(previousIdents) == 2 { - switch previousIdents[1] { - case "env": - completions = getEnvironmentVariableCompletions(cfg) - case "secrets": - completions = getSecretsCompletions(cfg) + switch { + case len(previousIdents) == 1: + completions = []*CompletionItem{ + { + Label: "identity", + Description: "Identity", + Kind: KindModel, + }, + { + Label: "now", + Description: "Timestamp", + Kind: KindField, + }, + { + Label: "env", + Description: "Environment Variables", + Kind: KindField, + }, + { + Label: "secrets", + Description: "Secrets", + Kind: KindField, + }, + { + Label: "isAuthenticated", + Description: "Authentication Indicator", + Kind: KindField, + }, + { + Label: "headers", + Description: "Request Headers", + Kind: KindField, + }, + } + case previousIdents[1] == "env" && len(previousIdents) == 2: + completions = getEnvironmentVariableCompletions(cfg) + case previousIdents[1] == "secrets" && len(previousIdents) == 2: + completions = getSecretsCompletions(cfg) + case previousIdents[1] == "identity": + model := query.Model(asts, "Identity") + fieldNames, ok := getFieldNamesAtPath(asts, model, previousIdents[2:]) + if !ok { + // if we were unable to resolve the relevant model + // return no completions as returning the default + // fields in this case could be unhelpful + return []*CompletionItem{} } + + return fieldNames } return completions @@ -990,7 +973,6 @@ func getExpressionCompletions(asts []*parser.AST, t *TokensAtPosition, cfg *conf return []*CompletionItem{} } - fieldNames = append(fieldNames, builtInFieldCompletions...) return fieldNames default: @@ -1251,7 +1233,7 @@ func getEnvironmentVariableCompletions(cfg *config.ProjectConfig) []*CompletionI for _, key := range cfg.AllEnvironmentVariables() { builtInFieldCompletions = append(builtInFieldCompletions, &CompletionItem{ Label: key, - Description: "Environment Variables", + Description: "Environment Variable", Kind: KindField, }) diff --git a/schema/completions/completions_test.go b/schema/completions/completions_test.go index bd53cdb53..23ceba9c0 100644 --- a/schema/completions/completions_test.go +++ b/schema/completions/completions_test.go @@ -147,11 +147,13 @@ func TestCompositeUniqueCompletions(t *testing.T) { fields { title Text subTitle Text + date Date + timestamp Timestamp } @unique([ } `, - expected: []string{"title", "subTitle"}, + expected: []string{"subTitle", "title", "date"}, }, { name: "existing-composite", @@ -205,7 +207,22 @@ func TestCompositeUniqueCompletions(t *testing.T) { @unique([ } `, - expected: []string{"title", "subTitle"}, + expected: []string{"subTitle", "title"}, + }, + { + name: "relationship", + schema: ` + model B {} + model A { + fields { + title Text + relation B + subTitle Text + } + @unique([relation. + } + `, + expected: []string{}, }, } @@ -530,68 +547,29 @@ func TestActionCompletions(t *testing.T) { enum Sex { Male Female - } + } - model Author { + model Author { fields { - name Text + name Text } - } + } - model Person { + model Person { fields { - title Text + title Text author Author } actions { - create createPerson() with (title, author.) { - @permission(expression: true) - } + create createPerson() with (title, author.) { + @permission(expression: true) + } } - } - + } `, expected: []string{"createdAt", "id", "name", "updatedAt"}, }, - { - name: "set-expression-nested", - schema: ` - model User { - fields { - identity Identity - name Text - } - - actions { - create createUser() with (name) { - @set(user.identity = ctx.identity) - @permission(expression: ctx.isAuthenticated) - } - } - } - - model Team { - fields { - name Text - } - } - - model UserTeam { - fields { - user User - team Team - } - - actions { - create createTeam() with (team.name) { - @set() - } - } - } - `, - expected: []string{"ctx", "userTeam"}, - }, { name: "model-field-inputs-relationship-multi-file", schema: ` @@ -698,21 +676,210 @@ func TestActionCompletions(t *testing.T) { }`, expected: []string{"@function", "@orderBy", "@permission", "@set", "@sortable", "@validate", "@where"}, }, - // @where tests + } + + runTestsCases(t, cases) +} + +func TestWhereAttributeCompletions(t *testing.T) { + cases := []testCase{ + { + name: "where-attribute-ctx-env", + schema: ` + model Post { + fields { + text Text + } + actions { + list Posts() { + @where(record.text == ctx.env.) + } + } + }`, + expected: []string{"TEST", "TEST_2"}, + }, + { + name: "where-attribute-ctx-env-no-completions", + schema: ` + model Post { + actions { + fields { + text Text + } + list Posts() { + @where(post.text == ctx.env.TEST.) + } + } + }`, + expected: []string{}, + }, + { + name: "where-attribute-ctx-secrets", + schema: ` + model Post { + fields { + text Text + } + actions { + list Posts() { + @where(post.text == ctx.secrets.) + } + } + }`, + expected: []string{"API_KEY"}, + }, + { + name: "where-attribute-ctx-isauthenticated-no-completions", + schema: ` + model Post { + fields { + text Text + } + actions { + list Posts() { + @where(post.text == ctx.isAuthenticated.) + } + } + }`, + expected: []string{}, + }, + { + name: "where-attribute-ctx", + schema: ` + model CompanyUser { + fields { + identity Identity @unique @relation(user) + } + } + model Record { + fields { + owner CompanyUser + } + actions { + list listRecords() { + @where(record.owner == ctx.) + } + } + }`, + expected: []string{"env", "headers", "identity", "isAuthenticated", "now", "secrets"}, + }, + { + name: "where-attribute-ctx-identity", + schema: ` + model CompanyUser { + fields { + name Text + identity Identity @unique @relation(user) + } + } + model Record { + fields { + owner CompanyUser + } + actions { + list listRecords() { + @where(record.owner == ctx.identity.) + } + } + }`, + expected: []string{"createdAt", "email", "emailVerified", "externalId", "id", "issuer", "password", "updatedAt", "user"}, + }, + + { + name: "where-attribute-ctx-identity-user", + schema: ` + model CompanyUser { + fields { + name Text + identity Identity @unique @relation(user) + company Company + } + } + model Company { + fields { + name Text + } + } + model Record { + fields { + owner CompanyUser + } + actions { + list listRecords() { + @where(record.owner == ctx.identity.user.) + } + } + }`, + expected: []string{"company", "createdAt", "id", "identity", "name", "updatedAt"}, + }, + { + name: "where-attribute-ctx-identity-user-company", + schema: ` + model CompanyUser { + fields { + name Text + identity Identity @unique @relation(user) + company Company + } + } + model Company { + fields { + name Text + } + } + model Record { + fields { + owner CompanyUser + } + actions { + list listRecords() { + @where(record.owner == ctx.identity.user.company.) + } + } + }`, + expected: []string{"createdAt", "id", "name", "updatedAt"}, + }, + { + name: "where-attribute-ctx-identity-user-company-name-no-completions", + schema: ` + model CompanyUser { + fields { + name Text + identity Identity @unique @relation(user) + company Company + } + } + model Company { + fields { + name Text + } + } + model Record { + fields { + owner CompanyUser + } + actions { + list listRecords() { + @where(record.owner == ctx.identity.user.company.name.) + } + } + }`, + expected: []string{}, + }, { name: "where-attribute-model-fields", schema: ` model Person { - fields { - name Text - age Number - } - actions { - list people() { - @where(person.) + fields { + name Text + age Number } - } - }`, + actions { + list people() { + @where(person.) + } + } + }`, expected: []string{"name", "age", "id", "createdAt", "updatedAt"}, }, { @@ -725,30 +892,30 @@ func TestActionCompletions(t *testing.T) { } } model Person { - fields { - dogs Dog[] - } - actions { - list people() { - @where(person.dogs.) + fields { + dogs Dog[] } - } - }`, + actions { + list people() { + @where(person.dogs.) + } + } + }`, expected: []string{"breed", "id", "createdAt", "owner", "updatedAt"}, }, { name: "where-attribute-model-fields-relationships-multi-file", schema: ` model Person { - fields { - dogs Dog[] - } - actions { - list people() { - @where(person.dogs.) + fields { + dogs Dog[] } - } - }`, + actions { + list people() { + @where(person.dogs.) + } + } + }`, otherSchema: ` model Dog { fields { @@ -763,15 +930,15 @@ func TestActionCompletions(t *testing.T) { name: "where-attribute-enums", schema: ` model Pet { - fields { - species Animal - } - actions { - list pets() { - @where(pet.species == ) + fields { + species Animal } - } - }`, + actions { + list pets() { + @where(pet.species == ) + } + } + }`, otherSchema: ` enum Animal { Dog @@ -785,15 +952,15 @@ func TestActionCompletions(t *testing.T) { name: "where-attribute-enum-values", schema: ` model Pet { - fields { - species Animal - } - actions { - list pets() { - @where(pet.species == Animal.) + fields { + species Animal } - } - }`, + actions { + list pets() { + @where(pet.species == Animal.) + } + } + }`, otherSchema: ` enum Animal { Dog @@ -808,6 +975,123 @@ func TestActionCompletions(t *testing.T) { runTestsCases(t, cases) } +func TestSetAttributeCompletions(t *testing.T) { + cases := []testCase{ + { + name: "set-expression", + schema: ` + model User { + fields { + identity Identity + } + } + model Team { + fields { + name Text + } + } + model UserTeam { + fields { + user User + team Team + } + actions { + create createTeam() with (team.name) { + @set() + } + } + }`, + expected: []string{"ctx", "userTeam"}, + }, + { + name: "set-attribute-ctx", + schema: ` + model User { + fields { + name Text + identity Identity @unique @relation(user) + } + } + model Post { + fields { + owner User + } + actions { + create create() { + @set(post.owner = ctx.) + } + } + }`, + expected: []string{"env", "headers", "identity", "isAuthenticated", "now", "secrets"}, + }, + { + name: "set-attribute-ctx-identity", + schema: ` + model User { + fields { + name Text + identity Identity @unique @relation(user) + } + } + model Post { + fields { + owner User + } + actions { + list create() { + @set(post.owner = ctx.identity.) + } + } + }`, + expected: []string{"createdAt", "email", "emailVerified", "externalId", "id", "issuer", "password", "updatedAt", "user"}, + }, + { + name: "set-attribute-ctx-identity-user", + schema: ` + model User { + fields { + name Text + identity Identity @unique @relation(user) + } + } + model Post { + fields { + owner User + } + actions { + list create() { + @set(post.owner = ctx.identity.user.) + } + } + }`, + expected: []string{"createdAt", "id", "identity", "name", "updatedAt"}, + }, + { + name: "set-attribute-first-operand-ctx-identity", + schema: ` + model User { + fields { + name Text + identity Identity @unique @relation(user) + } + } + model Post { + fields { + owner User + } + actions { + list create() { + @set(user.owner = ctx.identity.) + } + } + }`, + expected: []string{"createdAt", "email", "emailVerified", "externalId", "id", "issuer", "password", "updatedAt", "user"}, + }, + } + + runTestsCases(t, cases) +} + func TestFunctionCompletions(t *testing.T) { cases := []testCase{ // name tests @@ -1334,7 +1618,7 @@ func TestAPICompletions(t *testing.T) { } } `, - expected: []string{"Person"}, + expected: []string{"Identity", "Person"}, }, } diff --git a/schema/parser/operand.go b/schema/parser/operand.go index 43234d8ff..61dd13c6e 100644 --- a/schema/parser/operand.go +++ b/schema/parser/operand.go @@ -131,10 +131,31 @@ func (ident *Ident) IsContext() bool { return ident != nil && ident.Fragments[0].Fragment == "ctx" } -func (ident *Ident) IsContextIdentityField() bool { - if ident.IsContext() && len(ident.Fragments) > 1 { - return ident.Fragments[1].Fragment == "identity" +func (ident *Ident) IsContextIdentity() bool { + if !ident.IsContext() { + return false } + + if len(ident.Fragments) > 1 && ident.Fragments[1].Fragment == "identity" { + return true + } + + return false +} + +func (ident *Ident) IsContextIdentityId() bool { + if !ident.IsContextIdentity() { + return false + } + + if len(ident.Fragments) == 2 { + return true + } + + if len(ident.Fragments) == 3 && ident.Fragments[2].Fragment == "id" { + return true + } + return false } diff --git a/schema/reader/reader.go b/schema/reader/reader.go index 1e44c5705..28638518c 100644 --- a/schema/reader/reader.go +++ b/schema/reader/reader.go @@ -10,7 +10,7 @@ import ( // given directory. type Inputs struct { Directory string - SchemaFiles []SchemaFile + SchemaFiles []*SchemaFile } type SchemaFile struct { @@ -25,7 +25,7 @@ type SchemaFile struct { func FromDir(dirName string) (*Inputs, error) { inputs := &Inputs{ Directory: dirName, - SchemaFiles: []SchemaFile{}, + SchemaFiles: []*SchemaFile{}, } globPattern := filepath.Join(dirName, "*.keel") schemaFileNames, err := filepath.Glob(globPattern) @@ -37,7 +37,7 @@ func FromDir(dirName string) (*Inputs, error) { if err != nil { return nil, err } - inputs.SchemaFiles = append(inputs.SchemaFiles, SchemaFile{ + inputs.SchemaFiles = append(inputs.SchemaFiles, &SchemaFile{ FileName: fName, Contents: string(fileBytes), }) @@ -50,12 +50,12 @@ func FromFile(filename string) (*Inputs, error) { if err != nil { return nil, err } - schemaFile := SchemaFile{ + schemaFile := &SchemaFile{ FileName: filename, Contents: string(fileBytes), } return &Inputs{ Directory: path.Dir(filename), - SchemaFiles: []SchemaFile{schemaFile}, + SchemaFiles: []*SchemaFile{schemaFile}, }, nil } diff --git a/schema/schema.go b/schema/schema.go index 2cb11d019..5e8874a25 100644 --- a/schema/schema.go +++ b/schema/schema.go @@ -21,7 +21,7 @@ import ( // from a given Keel Builder. Construct one, then call the Make method. type Builder struct { asts []*parser.AST - schemaFiles []reader.SchemaFile + schemaFiles []*reader.SchemaFile Config *config.ProjectConfig proto *proto.Schema } @@ -50,19 +50,8 @@ func (scm *Builder) MakeFromDirectory(directory string) (*proto.Schema, error) { return scm.makeFromInputs(allInputFiles) } -// MakeFromFile constructs a proto.Schema from the given .keel file. -func (scm *Builder) MakeFromFile(filename string) (*proto.Schema, error) { - allInputFiles, err := reader.FromFile(filename) - if err != nil { - return nil, err - } - - scm.schemaFiles = allInputFiles.SchemaFiles - return scm.makeFromInputs(allInputFiles) -} - func (scm *Builder) MakeFromString(schemaString string) (*proto.Schema, error) { - scm.schemaFiles = append(scm.schemaFiles, reader.SchemaFile{ + scm.schemaFiles = append(scm.schemaFiles, &reader.SchemaFile{ Contents: schemaString, FileName: "schema.keel", }) @@ -78,7 +67,7 @@ func (scm *Builder) MakeFromInputs(inputs *reader.Inputs) (*proto.Schema, error) return scm.makeFromInputs(inputs) } -func (scm *Builder) SchemaFiles() []reader.SchemaFile { +func (scm *Builder) SchemaFiles() []*reader.SchemaFile { return scm.schemaFiles } @@ -86,19 +75,17 @@ func (scm *Builder) ASTs() []*parser.AST { return scm.asts } -func (scm *Builder) makeFromInputs(allInputFiles *reader.Inputs) (*proto.Schema, error) { +// PrepareAst will parse the ASTs and will add built-in models, fields, and other bits. +func (scm *Builder) PrepareAst(allInputFiles *reader.Inputs) ([]*parser.AST, errorhandling.ValidationErrors, error) { + asts := []*parser.AST{} + parseErrors := errorhandling.ValidationErrors{} + // - For each of the .keel (schema) files specified... // - Parse to AST // - Add built-in fields - // - With the parsed (AST) schemas as a set: - // - Validate them (as a set) - // - Convert the set to a single / aggregate proto model - asts := []*parser.AST{} - parseErrors := errorhandling.ValidationErrors{} for i, oneInputSchemaFile := range allInputFiles.SchemaFiles { - declarations, err := parser.Parse(&oneInputSchemaFile) + declarations, err := parser.Parse(oneInputSchemaFile) if err != nil { - // try to convert into a validation error and move to next schema file if perr, ok := err.(parser.Error); ok { verr := errorhandling.NewValidationError(errorhandling.ErrorInvalidSyntax, errorhandling.TemplateLiterals{ @@ -107,10 +94,9 @@ func (scm *Builder) makeFromInputs(allInputFiles *reader.Inputs) (*proto.Schema, }, }, perr) parseErrors.Errors = append(parseErrors.Errors, verr) - continue + } else { + return nil, parseErrors, fmt.Errorf("parser.Parse() failed on file: %s, with error %v", oneInputSchemaFile.FileName, err) } - - return nil, fmt.Errorf("parser.Parse() failed on file: %s, with error %v", oneInputSchemaFile.FileName, err) } // Insert built in models like Identity. We only want to call this once @@ -150,8 +136,17 @@ func (scm *Builder) makeFromInputs(allInputFiles *reader.Inputs) (*proto.Schema, }) } - // Now insert the foreign key fields (for relationships) - errDetails = scm.insertForeignKeyFields(asts) + return asts, parseErrors, nil +} + +func (scm *Builder) makeFromInputs(allInputFiles *reader.Inputs) (*proto.Schema, error) { + asts, parseErrors, err := scm.PrepareAst(allInputFiles) + if err != nil { + return nil, err + } + + // insert the foreign key fields (for relationships) + errDetails := scm.insertForeignKeyFields(asts) if errDetails != nil { parseErrors.Errors = append(parseErrors.Errors, &errorhandling.ValidationError{ ErrorDetails: errDetails, @@ -164,9 +159,9 @@ func (scm *Builder) makeFromInputs(allInputFiles *reader.Inputs) (*proto.Schema, } v := validation.NewValidator(asts) - err := v.RunAllValidators() - if err != nil { - return nil, err + validationErrors := v.RunAllValidators() + if validationErrors != nil { + return nil, validationErrors } scm.asts = asts @@ -256,8 +251,7 @@ func (scm *Builder) insertBuiltInFields(declarations *parser.AST) { // insertForeignKeyFields works with the given GLOBAL set of asts, i.e. a set that has been // built and combined from all input files. It analyses the foreign key fields that should be auto // generated and injected into each model. -func (scm *Builder) insertForeignKeyFields( - asts []*parser.AST) *errorhandling.ErrorDetails { +func (scm *Builder) insertForeignKeyFields(asts []*parser.AST) *errorhandling.ErrorDetails { for _, mdl := range query.Models(asts) { fkFieldsToAdd := []*parser.FieldNode{} @@ -329,11 +323,11 @@ func (scm *Builder) insertForeignKeyFields( return nil } -func (scm *Builder) insertBuiltInModels(declarations *parser.AST, schemaFile reader.SchemaFile) { +func (scm *Builder) insertBuiltInModels(declarations *parser.AST, schemaFile *reader.SchemaFile) { scm.insertIdentityModel(declarations, schemaFile) } -func (scm *Builder) insertIdentityModel(declarations *parser.AST, schemaFile reader.SchemaFile) { +func (scm *Builder) insertIdentityModel(declarations *parser.AST, schemaFile *reader.SchemaFile) { declaration := &parser.DeclarationNode{ Model: &parser.ModelNode{ BuiltIn: true, diff --git a/schema/testdata/proto_set_attribute_ctx_identity_fields/proto.json b/schema/testdata/proto_set_attribute_ctx_identity_fields/proto.json new file mode 100644 index 000000000..4953ad3e4 --- /dev/null +++ b/schema/testdata/proto_set_attribute_ctx_identity_fields/proto.json @@ -0,0 +1,421 @@ +{ + "models": [ + { + "name": "UserExtension", + "fields": [ + { + "modelName": "UserExtension", + "name": "email", + "type": { + "type": "TYPE_STRING" + } + }, + { + "modelName": "UserExtension", + "name": "isVerified", + "type": { + "type": "TYPE_BOOL" + } + }, + { + "modelName": "UserExtension", + "name": "signedUpAt", + "type": { + "type": "TYPE_DATETIME" + } + }, + { + "modelName": "UserExtension", + "name": "issuer", + "type": { + "type": "TYPE_STRING" + } + }, + { + "modelName": "UserExtension", + "name": "externalId", + "type": { + "type": "TYPE_STRING" + } + }, + { + "modelName": "UserExtension", + "name": "id", + "type": { + "type": "TYPE_ID" + }, + "primaryKey": true, + "defaultValue": { + "useZeroValue": true + } + }, + { + "modelName": "UserExtension", + "name": "createdAt", + "type": { + "type": "TYPE_DATETIME" + }, + "defaultValue": { + "useZeroValue": true + } + }, + { + "modelName": "UserExtension", + "name": "updatedAt", + "type": { + "type": "TYPE_DATETIME" + }, + "defaultValue": { + "useZeroValue": true + } + } + ], + "actions": [ + { + "modelName": "UserExtension", + "name": "createExt", + "type": "ACTION_TYPE_CREATE", + "implementation": "ACTION_IMPLEMENTATION_AUTO", + "permissions": [ + { + "modelName": "UserExtension", + "actionName": "createExt", + "expression": { + "source": "ctx.isAuthenticated" + } + } + ], + "setExpressions": [ + { + "source": "userExtension.email = ctx.identity.email" + }, + { + "source": "userExtension.isVerified = ctx.identity.emailVerified" + }, + { + "source": "userExtension.signedUpAt = ctx.identity.createdAt" + }, + { + "source": "userExtension.issuer = ctx.identity.issuer" + }, + { + "source": "userExtension.externalId = ctx.identity.externalId" + } + ], + "inputMessageName": "CreateExtInput" + }, + { + "modelName": "UserExtension", + "name": "updateExt", + "type": "ACTION_TYPE_UPDATE", + "implementation": "ACTION_IMPLEMENTATION_AUTO", + "permissions": [ + { + "modelName": "UserExtension", + "actionName": "updateExt", + "expression": { + "source": "ctx.isAuthenticated" + } + } + ], + "setExpressions": [ + { + "source": "userExtension.email = ctx.identity.email" + }, + { + "source": "userExtension.isVerified = ctx.identity.emailVerified" + }, + { + "source": "userExtension.signedUpAt = ctx.identity.createdAt" + }, + { + "source": "userExtension.issuer = ctx.identity.issuer" + }, + { + "source": "userExtension.externalId = ctx.identity.externalId" + } + ], + "inputMessageName": "UpdateExtInput" + } + ] + }, + { + "name": "Identity", + "fields": [ + { + "modelName": "Identity", + "name": "email", + "type": { + "type": "TYPE_STRING" + }, + "optional": true, + "uniqueWith": [ + "issuer" + ] + }, + { + "modelName": "Identity", + "name": "emailVerified", + "type": { + "type": "TYPE_BOOL" + }, + "defaultValue": { + "expression": { + "source": "false" + } + } + }, + { + "modelName": "Identity", + "name": "password", + "type": { + "type": "TYPE_PASSWORD" + }, + "optional": true + }, + { + "modelName": "Identity", + "name": "externalId", + "type": { + "type": "TYPE_STRING" + }, + "optional": true + }, + { + "modelName": "Identity", + "name": "issuer", + "type": { + "type": "TYPE_STRING" + }, + "optional": true, + "uniqueWith": [ + "email" + ] + }, + { + "modelName": "Identity", + "name": "id", + "type": { + "type": "TYPE_ID" + }, + "primaryKey": true, + "defaultValue": { + "useZeroValue": true + } + }, + { + "modelName": "Identity", + "name": "createdAt", + "type": { + "type": "TYPE_DATETIME" + }, + "defaultValue": { + "useZeroValue": true + } + }, + { + "modelName": "Identity", + "name": "updatedAt", + "type": { + "type": "TYPE_DATETIME" + }, + "defaultValue": { + "useZeroValue": true + } + } + ], + "actions": [ + { + "modelName": "Identity", + "name": "authenticate", + "type": "ACTION_TYPE_WRITE", + "implementation": "ACTION_IMPLEMENTATION_RUNTIME", + "inputMessageName": "AuthenticateInput", + "responseMessageName": "AuthenticateResponse" + }, + { + "modelName": "Identity", + "name": "requestPasswordReset", + "type": "ACTION_TYPE_WRITE", + "implementation": "ACTION_IMPLEMENTATION_RUNTIME", + "inputMessageName": "RequestPasswordResetInput", + "responseMessageName": "RequestPasswordResetResponse" + }, + { + "modelName": "Identity", + "name": "resetPassword", + "type": "ACTION_TYPE_WRITE", + "implementation": "ACTION_IMPLEMENTATION_RUNTIME", + "inputMessageName": "ResetPasswordInput", + "responseMessageName": "ResetPasswordResponse" + } + ] + } + ], + "apis": [ + { + "name": "Api", + "apiModels": [ + { + "modelName": "Identity" + }, + { + "modelName": "UserExtension" + } + ] + } + ], + "messages": [ + { + "name": "Any" + }, + { + "name": "CreateExtInput" + }, + { + "name": "UpdateExtWhere", + "fields": [ + { + "messageName": "UpdateExtWhere", + "name": "id", + "type": { + "type": "TYPE_ID", + "modelName": "UserExtension", + "fieldName": "id" + }, + "target": [ + "id" + ] + } + ] + }, + { + "name": "UpdateExtValues" + }, + { + "name": "UpdateExtInput", + "fields": [ + { + "messageName": "UpdateExtInput", + "name": "where", + "type": { + "type": "TYPE_MESSAGE", + "messageName": "UpdateExtWhere" + } + }, + { + "messageName": "UpdateExtInput", + "name": "values", + "type": { + "type": "TYPE_MESSAGE", + "messageName": "UpdateExtValues" + }, + "optional": true + } + ] + }, + { + "name": "EmailPasswordInput", + "fields": [ + { + "messageName": "EmailPasswordInput", + "name": "email", + "type": { + "type": "TYPE_STRING" + } + }, + { + "messageName": "EmailPasswordInput", + "name": "password", + "type": { + "type": "TYPE_STRING" + } + } + ] + }, + { + "name": "AuthenticateInput", + "fields": [ + { + "messageName": "AuthenticateInput", + "name": "createIfNotExists", + "type": { + "type": "TYPE_BOOL" + }, + "optional": true + }, + { + "messageName": "AuthenticateInput", + "name": "emailPassword", + "type": { + "type": "TYPE_MESSAGE", + "messageName": "EmailPasswordInput" + } + } + ] + }, + { + "name": "AuthenticateResponse", + "fields": [ + { + "messageName": "AuthenticateResponse", + "name": "identityCreated", + "type": { + "type": "TYPE_BOOL" + } + }, + { + "messageName": "AuthenticateResponse", + "name": "token", + "type": { + "type": "TYPE_STRING" + } + } + ] + }, + { + "name": "RequestPasswordResetInput", + "fields": [ + { + "messageName": "RequestPasswordResetInput", + "name": "email", + "type": { + "type": "TYPE_STRING" + } + }, + { + "messageName": "RequestPasswordResetInput", + "name": "redirectUrl", + "type": { + "type": "TYPE_STRING" + } + } + ] + }, + { + "name": "RequestPasswordResetResponse" + }, + { + "name": "ResetPasswordInput", + "fields": [ + { + "messageName": "ResetPasswordInput", + "name": "token", + "type": { + "type": "TYPE_STRING" + } + }, + { + "messageName": "ResetPasswordInput", + "name": "password", + "type": { + "type": "TYPE_STRING" + } + } + ] + }, + { + "name": "ResetPasswordResponse" + } + ] +} \ No newline at end of file diff --git a/schema/testdata/proto_set_attribute_ctx_identity_fields/schema.keel b/schema/testdata/proto_set_attribute_ctx_identity_fields/schema.keel new file mode 100644 index 000000000..bdfbf3045 --- /dev/null +++ b/schema/testdata/proto_set_attribute_ctx_identity_fields/schema.keel @@ -0,0 +1,30 @@ + +model UserExtension { + fields { + email Text + isVerified Boolean + signedUpAt Timestamp + issuer Text + externalId Text + } + + actions { + create createExt() { + @set(userExtension.email = ctx.identity.email) + @set(userExtension.isVerified = ctx.identity.emailVerified) + @set(userExtension.signedUpAt = ctx.identity.createdAt) + @set(userExtension.issuer = ctx.identity.issuer) + @set(userExtension.externalId = ctx.identity.externalId) + @permission(expression: ctx.isAuthenticated) + } + + update updateExt(id) { + @set(userExtension.email = ctx.identity.email) + @set(userExtension.isVerified = ctx.identity.emailVerified) + @set(userExtension.signedUpAt = ctx.identity.createdAt) + @set(userExtension.issuer = ctx.identity.issuer) + @set(userExtension.externalId = ctx.identity.externalId) + @permission(expression: ctx.isAuthenticated) + } + } +} \ No newline at end of file diff --git a/schema/testdata/validation_fields_unique_in_model/errors.json b/schema/testdata/validation_fields_unique_in_model/errors.json index d346b4cce..3ddc60426 100644 --- a/schema/testdata/validation_fields_unique_in_model/errors.json +++ b/schema/testdata/validation_fields_unique_in_model/errors.json @@ -1,8 +1,8 @@ { "errors": [ { - "message": "You have duplicate field names name", - "hint": "Remove 'name' on line 4", + "message": "Cannot use 'name' as it has already been defined on this model", + "hint": "Rename this field to some other name which has not yet been defined", "code": "E003", "pos": { "filename": "testdata/validation_fields_unique_in_model/schema.keel", diff --git a/schema/testdata/validation_identity_backlinks_invalid_relations_field/errors.json b/schema/testdata/validation_identity_backlinks_invalid_relations_field/errors.json index 86b2011d5..70dc743af 100644 --- a/schema/testdata/validation_identity_backlinks_invalid_relations_field/errors.json +++ b/schema/testdata/validation_identity_backlinks_invalid_relations_field/errors.json @@ -1,9 +1,9 @@ { "errors": [ { - "message": "You have a reserved field name createdAt", - "hint": "Try 'createdAter' instead", - "code": "E006", + "message": "Cannot use 'createdAt' as it has already been defined on this model", + "hint": "Rename this field to some other name which has not yet been defined", + "code": "E003", "pos": { "filename": "", "offset": 0, diff --git a/schema/testdata/validation_identity_backlinks_no_relations_attribute/errors.json b/schema/testdata/validation_identity_backlinks_no_relations_attribute/errors.json index 8ac6c0502..a11e07443 100644 --- a/schema/testdata/validation_identity_backlinks_no_relations_attribute/errors.json +++ b/schema/testdata/validation_identity_backlinks_no_relations_attribute/errors.json @@ -1,8 +1,8 @@ { "errors": [ { - "message": "You have duplicate field names companyEmployee", - "hint": "Remove 'companyEmployee' on line 0", + "message": "Cannot use 'companyEmployee' as it has already been defined on this model", + "hint": "Rename this field to some other name which has not yet been defined", "code": "E003", "pos": { "filename": "", diff --git a/schema/testdata/validation_reserved_field_name_created_at/errors.json b/schema/testdata/validation_reserved_field_name_created_at/errors.json index 83d540619..1d4e10ec5 100644 --- a/schema/testdata/validation_reserved_field_name_created_at/errors.json +++ b/schema/testdata/validation_reserved_field_name_created_at/errors.json @@ -1,8 +1,8 @@ { "errors": [ { - "message": "You have a reserved field name createdAt", - "hint": "Try 'createdAter' instead", + "message": "Cannot use 'createdAt' as it already exists as a built-in field", + "hint": "Rename this field to some other name which has not yet been defined", "code": "E006", "pos": { "filename": "testdata/validation_reserved_field_name_created_at/schema.keel", diff --git a/schema/testdata/validation_reserved_field_name_created_at/schema.keel b/schema/testdata/validation_reserved_field_name_created_at/schema.keel index c4e1e8bff..a50923f75 100644 --- a/schema/testdata/validation_reserved_field_name_created_at/schema.keel +++ b/schema/testdata/validation_reserved_field_name_created_at/schema.keel @@ -1,5 +1,6 @@ model User { fields { createdAt Text + } } diff --git a/schema/testdata/validation_reserved_field_name_id/errors.json b/schema/testdata/validation_reserved_field_name_id/errors.json index a3506de01..01fa8ff04 100644 --- a/schema/testdata/validation_reserved_field_name_id/errors.json +++ b/schema/testdata/validation_reserved_field_name_id/errors.json @@ -1,8 +1,8 @@ { "errors": [ { - "message": "You have a reserved field name id", - "hint": "Try 'ider' instead", + "message": "Cannot use 'id' as it already exists as a built-in field", + "hint": "Rename this field to some other name which has not yet been defined", "code": "E006", "pos": { "filename": "testdata/validation_reserved_field_name_id/schema.keel", diff --git a/schema/testdata/validation_reserved_field_name_updated_at/errors.json b/schema/testdata/validation_reserved_field_name_updated_at/errors.json index 977606c72..08cc5730c 100644 --- a/schema/testdata/validation_reserved_field_name_updated_at/errors.json +++ b/schema/testdata/validation_reserved_field_name_updated_at/errors.json @@ -1,8 +1,8 @@ { "errors": [ { - "message": "You have a reserved field name updatedAt", - "hint": "Try 'updatedAter' instead", + "message": "Cannot use 'updatedAt' as it already exists as a built-in field", + "hint": "Rename this field to some other name which has not yet been defined", "code": "E006", "pos": { "filename": "testdata/validation_reserved_field_name_updated_at/schema.keel", diff --git a/schema/testdata/validation_set_attribute_ctx_identity_fields_invalid_types/errors.json b/schema/testdata/validation_set_attribute_ctx_identity_fields_invalid_types/errors.json new file mode 100644 index 000000000..c538e4e34 --- /dev/null +++ b/schema/testdata/validation_set_attribute_ctx_identity_fields_invalid_types/errors.json @@ -0,0 +1,174 @@ +{ + "errors": [ + { + "message": "userExtension.email is Number and ctx.identity.email is Text", + "hint": "Please make sure that you are evaluating entities of the same type", + "code": "E026", + "pos": { + "filename": "testdata/validation_set_attribute_ctx_identity_fields_invalid_types/schema.keel", + "offset": 224, + "line": 13, + "column": 18 + }, + "endPos": { + "filename": "testdata/validation_set_attribute_ctx_identity_fields_invalid_types/schema.keel", + "offset": 264, + "line": 13, + "column": 58 + } + }, + { + "message": "userExtension.isVerified is Number and ctx.identity.emailVerified is Boolean", + "hint": "Please make sure that you are evaluating entities of the same type", + "code": "E026", + "pos": { + "filename": "testdata/validation_set_attribute_ctx_identity_fields_invalid_types/schema.keel", + "offset": 283, + "line": 14, + "column": 18 + }, + "endPos": { + "filename": "testdata/validation_set_attribute_ctx_identity_fields_invalid_types/schema.keel", + "offset": 336, + "line": 14, + "column": 71 + } + }, + { + "message": "userExtension.signedUpAt is Number and ctx.identity.createdAt is Timestamp", + "hint": "Please make sure that you are evaluating entities of the same type", + "code": "E026", + "pos": { + "filename": "testdata/validation_set_attribute_ctx_identity_fields_invalid_types/schema.keel", + "offset": 355, + "line": 15, + "column": 18 + }, + "endPos": { + "filename": "testdata/validation_set_attribute_ctx_identity_fields_invalid_types/schema.keel", + "offset": 404, + "line": 15, + "column": 67 + } + }, + { + "message": "userExtension.issuer is Number and ctx.identity.issuer is Text", + "hint": "Please make sure that you are evaluating entities of the same type", + "code": "E026", + "pos": { + "filename": "testdata/validation_set_attribute_ctx_identity_fields_invalid_types/schema.keel", + "offset": 423, + "line": 16, + "column": 18 + }, + "endPos": { + "filename": "testdata/validation_set_attribute_ctx_identity_fields_invalid_types/schema.keel", + "offset": 465, + "line": 16, + "column": 60 + } + }, + { + "message": "userExtension.externalId is Number and ctx.identity.externalId is Text", + "hint": "Please make sure that you are evaluating entities of the same type", + "code": "E026", + "pos": { + "filename": "testdata/validation_set_attribute_ctx_identity_fields_invalid_types/schema.keel", + "offset": 484, + "line": 17, + "column": 18 + }, + "endPos": { + "filename": "testdata/validation_set_attribute_ctx_identity_fields_invalid_types/schema.keel", + "offset": 534, + "line": 17, + "column": 68 + } + }, + { + "message": "userExtension.email is Number and ctx.identity.email is Text", + "hint": "Please make sure that you are evaluating entities of the same type", + "code": "E026", + "pos": { + "filename": "testdata/validation_set_attribute_ctx_identity_fields_invalid_types/schema.keel", + "offset": 652, + "line": 22, + "column": 18 + }, + "endPos": { + "filename": "testdata/validation_set_attribute_ctx_identity_fields_invalid_types/schema.keel", + "offset": 692, + "line": 22, + "column": 58 + } + }, + { + "message": "userExtension.isVerified is Number and ctx.identity.emailVerified is Boolean", + "hint": "Please make sure that you are evaluating entities of the same type", + "code": "E026", + "pos": { + "filename": "testdata/validation_set_attribute_ctx_identity_fields_invalid_types/schema.keel", + "offset": 711, + "line": 23, + "column": 18 + }, + "endPos": { + "filename": "testdata/validation_set_attribute_ctx_identity_fields_invalid_types/schema.keel", + "offset": 764, + "line": 23, + "column": 71 + } + }, + { + "message": "userExtension.signedUpAt is Number and ctx.identity.createdAt is Timestamp", + "hint": "Please make sure that you are evaluating entities of the same type", + "code": "E026", + "pos": { + "filename": "testdata/validation_set_attribute_ctx_identity_fields_invalid_types/schema.keel", + "offset": 783, + "line": 24, + "column": 18 + }, + "endPos": { + "filename": "testdata/validation_set_attribute_ctx_identity_fields_invalid_types/schema.keel", + "offset": 832, + "line": 24, + "column": 67 + } + }, + { + "message": "userExtension.issuer is Number and ctx.identity.issuer is Text", + "hint": "Please make sure that you are evaluating entities of the same type", + "code": "E026", + "pos": { + "filename": "testdata/validation_set_attribute_ctx_identity_fields_invalid_types/schema.keel", + "offset": 851, + "line": 25, + "column": 18 + }, + "endPos": { + "filename": "testdata/validation_set_attribute_ctx_identity_fields_invalid_types/schema.keel", + "offset": 893, + "line": 25, + "column": 60 + } + }, + { + "message": "userExtension.externalId is Number and ctx.identity.externalId is Text", + "hint": "Please make sure that you are evaluating entities of the same type", + "code": "E026", + "pos": { + "filename": "testdata/validation_set_attribute_ctx_identity_fields_invalid_types/schema.keel", + "offset": 912, + "line": 26, + "column": 18 + }, + "endPos": { + "filename": "testdata/validation_set_attribute_ctx_identity_fields_invalid_types/schema.keel", + "offset": 962, + "line": 26, + "column": 68 + } + } + ] +} \ No newline at end of file diff --git a/schema/testdata/validation_set_attribute_ctx_identity_fields_invalid_types/schema.keel b/schema/testdata/validation_set_attribute_ctx_identity_fields_invalid_types/schema.keel new file mode 100644 index 000000000..317efd32e --- /dev/null +++ b/schema/testdata/validation_set_attribute_ctx_identity_fields_invalid_types/schema.keel @@ -0,0 +1,30 @@ + +model UserExtension { + fields { + email Number + isVerified Number + signedUpAt Number + issuer Number + externalId Number + } + + actions { + create createExt() { + @set(userExtension.email = ctx.identity.email) + @set(userExtension.isVerified = ctx.identity.emailVerified) + @set(userExtension.signedUpAt = ctx.identity.createdAt) + @set(userExtension.issuer = ctx.identity.issuer) + @set(userExtension.externalId = ctx.identity.externalId) + @permission(expression: ctx.isAuthenticated) + } + + update updateExt(id) { + @set(userExtension.email = ctx.identity.email) + @set(userExtension.isVerified = ctx.identity.emailVerified) + @set(userExtension.signedUpAt = ctx.identity.createdAt) + @set(userExtension.issuer = ctx.identity.issuer) + @set(userExtension.externalId = ctx.identity.externalId) + @permission(expression: ctx.isAuthenticated) + } + } +} \ No newline at end of file diff --git a/schema/testdata/validation_set_attribute_ctx_identity_invalid_fields/errors.json b/schema/testdata/validation_set_attribute_ctx_identity_invalid_fields/errors.json new file mode 100644 index 000000000..46fa1ac9d --- /dev/null +++ b/schema/testdata/validation_set_attribute_ctx_identity_invalid_fields/errors.json @@ -0,0 +1,38 @@ +{ + "errors": [ + { + "message": "'unknown' not found on 'Identity'", + "hint": "Did you mean one of email, emailVerified, password, externalId, issuer, id, createdAt, or updatedAt?", + "code": "E020", + "pos": { + "filename": "testdata/validation_set_attribute_ctx_identity_invalid_fields/schema.keel", + "offset": 160, + "line": 8, + "column": 55 + }, + "endPos": { + "filename": "testdata/validation_set_attribute_ctx_identity_invalid_fields/schema.keel", + "offset": 167, + "line": 8, + "column": 62 + } + }, + { + "message": "'unknown' not found on 'Identity'", + "hint": "Did you mean one of email, emailVerified, password, externalId, issuer, id, createdAt, or updatedAt?", + "code": "E020", + "pos": { + "filename": "testdata/validation_set_attribute_ctx_identity_invalid_fields/schema.keel", + "offset": 264, + "line": 11, + "column": 55 + }, + "endPos": { + "filename": "testdata/validation_set_attribute_ctx_identity_invalid_fields/schema.keel", + "offset": 271, + "line": 11, + "column": 62 + } + } + ] +} \ No newline at end of file diff --git a/schema/testdata/validation_set_attribute_ctx_identity_invalid_fields/schema.keel b/schema/testdata/validation_set_attribute_ctx_identity_invalid_fields/schema.keel new file mode 100644 index 000000000..a8f3dc9f4 --- /dev/null +++ b/schema/testdata/validation_set_attribute_ctx_identity_invalid_fields/schema.keel @@ -0,0 +1,14 @@ +model UserExtension { + fields { + unknown Text + } + + actions { + create createExt() { + @set(userExtension.unknown = ctx.identity.unknown) + } + update updateExt(id) { + @set(userExtension.unknown = ctx.identity.unknown) + } + } +} \ No newline at end of file diff --git a/schema/validation/errorhandling/errors.yml b/schema/validation/errorhandling/errors.yml index dbf5f39f2..f4d460306 100644 --- a/schema/validation/errorhandling/errors.yml +++ b/schema/validation/errorhandling/errors.yml @@ -6,8 +6,8 @@ en: message: "Action names should be written in lowerCamelCase" hint: "Did you mean '{{ .Suggested }}'?" E003: - message: "You have duplicate field names {{ .Name }}" - hint: "Remove '{{ .Name }}' on line {{ .Line }}" + message: "Cannot use '{{ .Name }}' as it has already been defined on this model" + hint: "Rename this field to some other name which has not yet been defined" E004: message: "You have duplicate actions Model:{{ .Model }} Name:{{ .Name }}" hint: "Remove '{{ .Name }}' on line {{ .Line }}" @@ -15,8 +15,8 @@ en: message: "Action inputs must be one of the fields defined in the model" hint: "{{ .Suggested }}" E006: - message: "You have a reserved field name {{ .Name }}" - hint: "Try '{{ .Suggestion }}' instead" + message: "Cannot use '{{ .Name }}' as it already exists as a built-in field" + hint: "Rename this field to some other name which has not yet been defined" E008: message: "Action {{ .Name }} must either take a unique field as input or filter on a unique field using a @where attribute" hint: "Did you mean to add 'id' as an input?" diff --git a/schema/validation/errorhandling/format.go b/schema/validation/errorhandling/format.go index a0f639344..27b969536 100644 --- a/schema/validation/errorhandling/format.go +++ b/schema/validation/errorhandling/format.go @@ -13,7 +13,7 @@ import ( // in the source file that produced the error // // The output is formatted using ANSI colours (if supported by the environment). -func (verrs *ValidationErrors) ToAnnotatedSchema(sources []reader.SchemaFile) string { +func (verrs *ValidationErrors) ToAnnotatedSchema(sources []*reader.SchemaFile) string { // Number of lines of the source code to render before and after the line with the error bufferLines := 3 diff --git a/schema/validation/rules/field/field.go b/schema/validation/rules/field/field.go index 84045d9ae..b324b4004 100644 --- a/schema/validation/rules/field/field.go +++ b/schema/validation/rules/field/field.go @@ -3,7 +3,6 @@ package field import ( "fmt" "sort" - "strings" "github.com/teamkeel/keel/formatting" "github.com/teamkeel/keel/schema/parser" @@ -11,56 +10,31 @@ import ( "github.com/teamkeel/keel/schema/validation/errorhandling" ) -var ( - reservedFieldNames = []string{"id", "createdAt", "updatedAt"} -) - -func ReservedNameRule(asts []*parser.AST) (errs errorhandling.ValidationErrors) { - +func UniqueFieldNamesRule(asts []*parser.AST) (errs errorhandling.ValidationErrors) { for _, model := range query.Models(asts) { + fieldNames := map[string]*parser.FieldNode{} for _, field := range query.ModelFields(model) { - - if field.BuiltIn { - continue - } - - for _, reserved := range reservedFieldNames { - if strings.EqualFold(reserved, field.Name.Value) { + if existingField, ok := fieldNames[field.Name.Value]; ok { + if field.BuiltIn { errs.Append(errorhandling.ErrorReservedFieldName, map[string]string{ - "Name": field.Name.Value, - "Suggestion": fmt.Sprintf("%ser", field.Name.Value), + "Name": field.Name.Value, + "Line": fmt.Sprint(field.Name.Pos.Line), + }, + existingField.Name, + ) + } else { + errs.Append(errorhandling.ErrorFieldNamesUniqueInModel, + map[string]string{ + "Name": field.Name.Value, + "Line": fmt.Sprint(field.Name.Pos.Line), }, field.Name, ) } } - } - } - - return -} - -func UniqueFieldNamesRule(asts []*parser.AST) (errs errorhandling.ValidationErrors) { - for _, model := range query.Models(asts) { - fieldNames := map[string]bool{} - for _, field := range query.ModelFields(model) { - // Ignore built in fields as usage of these field names is handled - // by reservedFieldNamesRule - if field.BuiltIn { - continue - } - if _, ok := fieldNames[field.Name.Value]; ok { - errs.Append(errorhandling.ErrorFieldNamesUniqueInModel, - map[string]string{ - "Name": field.Name.Value, - "Line": fmt.Sprint(field.Name.Pos.Line), - }, - field.Name, - ) - } - fieldNames[field.Name.Value] = true + fieldNames[field.Name.Value] = field } } diff --git a/schema/validation/validation.go b/schema/validation/validation.go index 6c4b1c469..e04fe88dd 100644 --- a/schema/validation/validation.go +++ b/schema/validation/validation.go @@ -40,7 +40,6 @@ var validatorFuncs = []validationFunc{ actions.CreateOperationRequiredFieldsRule, actions.ReservedActionNameRule, - field.ReservedNameRule, field.ValidFieldTypesRule, field.UniqueFieldNamesRule,