diff --git a/CHANGELOG.md b/CHANGELOG.md index c515f54c..bf8f9f62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ Versioning](http://semver.org/spec/v2.0.0.html) except to the first release. ### Added +- Implemented box.schema.user operations requests and sugar interface. + ### Changed ### Fixed diff --git a/box/box_test.go b/box/box_test.go index 31e614c1..41212712 100644 --- a/box/box_test.go +++ b/box/box_test.go @@ -10,7 +10,6 @@ import ( func TestNew(t *testing.T) { // Create a box instance with a nil connection. This should lead to a panic later. b := box.New(nil) - // Ensure the box instance is not nil (which it shouldn't be), but this is not meaningful // since we will panic when we call the Info method with the nil connection. require.NotNil(t, b) diff --git a/box/schema.go b/box/schema.go new file mode 100644 index 00000000..569e1bc4 --- /dev/null +++ b/box/schema.go @@ -0,0 +1,17 @@ +package box + +import "github.com/tarantool/go-tarantool/v2" + +// Schema represents the schema-related operations in Tarantool. +// It holds a connection to interact with the Tarantool instance. +type Schema struct { + conn tarantool.Doer // Connection interface for interacting with Tarantool. +} + +// Schema returns a new Schema instance, providing access to schema-related operations. +// It uses the connection from the Box instance to communicate with Tarantool. +func (b *Box) Schema() *Schema { + return &Schema{ + conn: b.conn, // Pass the Box connection to the Schema. + } +} diff --git a/box/schema_user.go b/box/schema_user.go new file mode 100644 index 00000000..274f956c --- /dev/null +++ b/box/schema_user.go @@ -0,0 +1,220 @@ +package box + +import ( + "context" + "fmt" + + "github.com/tarantool/go-tarantool/v2" + "github.com/vmihailenco/msgpack/v5" +) + +// SchemaUser provides methods to interact with schema-related user operations in Tarantool. +type SchemaUser struct { + conn tarantool.Doer // Connection interface for interacting with Tarantool. +} + +// User returns a new SchemaUser instance, allowing schema-related user operations. +func (s *Schema) User() *SchemaUser { + return &SchemaUser{conn: s.conn} +} + +// UserExistsRequest represents a request to check if a user exists in Tarantool. +type UserExistsRequest struct { + *tarantool.CallRequest // Underlying Tarantool call request. +} + +// UserExistsResponse represents the response to a user existence check. +type UserExistsResponse struct { + Exists bool // True if the user exists, false otherwise. +} + +// DecodeMsgpack decodes the response from a Msgpack-encoded byte slice. +func (uer *UserExistsResponse) DecodeMsgpack(d *msgpack.Decoder) error { + arrayLen, err := d.DecodeArrayLen() + if err != nil { + return err + } + + // Ensure that the response array contains exactly 1 element (the "Exists" field). + if arrayLen != 1 { + return fmt.Errorf("protocol violation; expected 1 array entry, got %d", arrayLen) + } + + // Decode the boolean value indicating whether the user exists. + uer.Exists, err = d.DecodeBool() + + return err +} + +// NewUserExistsRequest creates a new request to check if a user exists. +func NewUserExistsRequest(username string) UserExistsRequest { + callReq := tarantool.NewCallRequest("box.schema.user.exists").Args([]interface{}{username}) + + return UserExistsRequest{ + callReq, + } +} + +// Exists checks if the specified user exists in Tarantool. +func (u *SchemaUser) Exists(ctx context.Context, username string) (bool, error) { + // Create a request and send it to Tarantool. + req := NewUserExistsRequest(username).Context(ctx) + resp := &UserExistsResponse{} + + // Execute the request and parse the response. + err := u.conn.Do(req).GetTyped(resp) + + return resp.Exists, err +} + +// UserCreateOptions represents options for creating a user in Tarantool. +type UserCreateOptions struct { + // IfNotExists - if true, prevents an error if the user already exists. + IfNotExists bool `msgpack:"if_not_exists"` + // Password for the new user. + Password string `msgpack:"password"` +} + +// UserCreateRequest represents a request to create a new user in Tarantool. +type UserCreateRequest struct { + *tarantool.CallRequest // Underlying Tarantool call request. +} + +// NewUserCreateRequest creates a new request to create a user with specified options. +func NewUserCreateRequest(username string, options UserCreateOptions) UserCreateRequest { + callReq := tarantool.NewCallRequest("box.schema.user.create"). + Args([]interface{}{username, options}) + + return UserCreateRequest{ + callReq, + } +} + +// UserCreateResponse represents the response to a user creation request. +type UserCreateResponse struct { +} + +// DecodeMsgpack decodes the response for a user creation request. +// In this case, the response does not contain any data. +func (uer *UserCreateResponse) DecodeMsgpack(_ *msgpack.Decoder) error { + return nil +} + +// Create creates a new user in Tarantool with the given username and options. +func (u *SchemaUser) Create(ctx context.Context, username string, options UserCreateOptions) error { + // Create a request and send it to Tarantool. + req := NewUserCreateRequest(username, options).Context(ctx) + resp := &UserCreateResponse{} + + // Execute the request and handle the response. + fut := u.conn.Do(req) + + err := fut.GetTyped(resp) + if err != nil { + return err + } + + return nil +} + +// UserDropOptions represents options for dropping a user in Tarantool. +type UserDropOptions struct { + IfExists bool `msgpack:"if_exists"` // If true, prevents an error if the user does not exist. +} + +// UserDropRequest represents a request to drop a user from Tarantool. +type UserDropRequest struct { + *tarantool.CallRequest // Underlying Tarantool call request. +} + +// NewUserDropRequest creates a new request to drop a user with specified options. +func NewUserDropRequest(username string, options UserDropOptions) UserDropRequest { + callReq := tarantool.NewCallRequest("box.schema.user.drop"). + Args([]interface{}{username, options}) + + return UserDropRequest{ + callReq, + } +} + +// UserDropResponse represents the response to a user drop request. +type UserDropResponse struct{} + +// Drop drops the specified user from Tarantool, with optional conditions. +func (u *SchemaUser) Drop(ctx context.Context, username string, options UserDropOptions) error { + // Create a request and send it to Tarantool. + req := NewUserDropRequest(username, options).Context(ctx) + resp := &UserCreateResponse{} + + // Execute the request and handle the response. + fut := u.conn.Do(req) + + err := fut.GetTyped(resp) + if err != nil { + return err + } + + return nil +} + +// UserPasswordRequest represents a request to retrieve a user's password from Tarantool. +type UserPasswordRequest struct { + *tarantool.CallRequest // Underlying Tarantool call request. +} + +// NewUserPasswordRequest creates a new request to fetch the user's password. +// It takes the username and constructs the request to Tarantool. +func NewUserPasswordRequest(username string) UserPasswordRequest { + // Create a request to get the user's password. + callReq := tarantool.NewCallRequest("box.schema.user.password").Args([]interface{}{username}) + + return UserPasswordRequest{ + callReq, + } +} + +// UserPasswordResponse represents the response to the user password request. +// It contains the password hash. +type UserPasswordResponse struct { + Hash string // The password hash of the user. +} + +// DecodeMsgpack decodes the response from Tarantool in Msgpack format. +// It expects the response to be an array of length 1, containing the password hash string. +func (upr *UserPasswordResponse) DecodeMsgpack(d *msgpack.Decoder) error { + // Decode the array length. + arrayLen, err := d.DecodeArrayLen() + if err != nil { + return err + } + + // Ensure the array contains exactly 1 element (the password hash). + if arrayLen != 1 { + return fmt.Errorf("protocol violation; expected 1 array entry, got %d", arrayLen) + } + + // Decode the string containing the password hash. + upr.Hash, err = d.DecodeString() + + return err +} + +// Password sends a request to retrieve the user's password from Tarantool. +// It returns the password hash as a string or an error if the request fails. +func (u *SchemaUser) Password(ctx context.Context, username string) (string, error) { + // Create the request and send it to Tarantool. + req := NewUserPasswordRequest(username).Context(ctx) + resp := &UserPasswordResponse{} + + // Execute the request and handle the response. + fut := u.conn.Do(req) + + // Get the decoded response. + err := fut.GetTyped(resp) + if err != nil { + return "", err + } + + // Return the password hash. + return resp.Hash, nil +} diff --git a/box/tarantool_test.go b/box/tarantool_test.go index 3d638b5b..c886bcec 100644 --- a/box/tarantool_test.go +++ b/box/tarantool_test.go @@ -2,6 +2,7 @@ package box_test import ( "context" + "errors" "log" "os" "testing" @@ -9,6 +10,7 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/require" + "github.com/tarantool/go-iproto" "github.com/tarantool/go-tarantool/v2" "github.com/tarantool/go-tarantool/v2/box" "github.com/tarantool/go-tarantool/v2/test_helpers" @@ -61,6 +63,85 @@ func TestBox_Info(t *testing.T) { validateInfo(t, resp.Info) } +func TestBox_Sugar_Schema(t *testing.T) { + const ( + username = "opensource" + password = "enterprise" + ) + + ctx := context.TODO() + + conn, err := tarantool.Connect(ctx, dialer, tarantool.Opts{}) + require.NoError(t, err) + + b := box.New(conn) + + // Create new user + err = b.Schema().User().Create(ctx, username, box.UserCreateOptions{Password: password}) + require.NoError(t, err) + + // Get error that user already exists + err = b.Schema().User().Create(ctx, username, box.UserCreateOptions{Password: password}) + require.Error(t, err) + + // Require that error code is ER_USER_EXISTS + var boxErr tarantool.Error + errors.As(err, &boxErr) + require.Equal(t, iproto.ER_USER_EXISTS, boxErr.Code) + + // Check that already exists by exists call procedure + exists, err := b.Schema().User().Exists(ctx, username) + require.True(t, exists) + require.NoError(t, err) + + // There is no error if IfNotExists option is true + err = b.Schema().User().Create(ctx, username, box.UserCreateOptions{ + Password: password, + IfNotExists: true, + }) + + require.NoError(t, err) + + // Require password hash + hash, err := b.Schema().User().Password(ctx, username) + require.NoError(t, err) + require.NotEmpty(t, hash) + + // Check that password is valid and we can connect to tarantool with such credentials + var newUserDialer = tarantool.NetDialer{ + Address: server, + User: username, + Password: password, + } + + // We can connect with our new credentials + newUserConn, err := tarantool.Connect(ctx, newUserDialer, tarantool.Opts{}) + require.NoError(t, err) + require.NotNil(t, newUserConn) + require.NoError(t, newUserConn.Close()) + + // Try to drop user + err = b.Schema().User().Drop(ctx, username, box.UserDropOptions{}) + require.NoError(t, err) + + // Require error cause user already deleted + err = b.Schema().User().Drop(ctx, username, box.UserDropOptions{}) + require.Error(t, err) + + // Require that error code is ER_NO_SUCH_USER + errors.As(err, &boxErr) + require.Equal(t, iproto.ER_NO_SUCH_USER, boxErr.Code) + + // No error with option IfExists: true + err = b.Schema().User().Drop(ctx, username, box.UserDropOptions{IfExists: true}) + require.NoError(t, err) + + // Check that user not exists after drop + exists, err = b.Schema().User().Exists(ctx, username) + require.False(t, exists) + require.NoError(t, err) +} + func runTestMain(m *testing.M) int { instance, err := test_helpers.StartTarantool(test_helpers.StartOpts{ Dialer: dialer, diff --git a/box/testdata/config.lua b/box/testdata/config.lua index f3ee1a7b..5833908f 100644 --- a/box/testdata/config.lua +++ b/box/testdata/config.lua @@ -5,9 +5,9 @@ box.cfg{ } box.schema.user.create('test', { password = 'test' , if_not_exists = true }) -box.schema.user.grant('test', 'execute', 'universe', nil, { if_not_exists = true }) +box.schema.user.grant('test', 'super', nil) -- Set listen only when every other thing is configured. box.cfg{ listen = os.getenv("TEST_TNT_LISTEN"), -} +} \ No newline at end of file