Skip to content

Commit 55cb58f

Browse files
committed
feat(api): implement PostgreSQL store operations and migrations
Complete implementation of PostgreSQL store with all CRUD operations, entity mappings, database migrations, and query filtering system. - Implement all store operations (create, read, update, delete) - Add entity package with model-to-database mappings and relations - Create database migrations covering all tables and types - Implement query options (pagination, sorting, filtering) with bun - Add internal filter parsing for complex queries (contains, eq, bool, gt, ne) - Implement error handling utilities and SQL error mapping - Add unit tests - Configure bun ORM with pgx driver and relation loading
1 parent 8085d44 commit 55cb58f

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

53 files changed

+2782
-74
lines changed

api/server.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ func (s *Server) Setup(ctx context.Context) error {
9898
store, err = mongo.NewStore(ctx, s.env.MongoURI, cache, mongooptions.RunMigatrions)
9999
case "postgres":
100100
uri := pg.URI(s.env.PostgresHost, s.env.PostgresPort, s.env.PostgresUsername, s.env.PostgresPassword, s.env.PostgresDatabase)
101-
store, err = pg.New(ctx, uri, pgoptions.Log("INFO", true)) // TODO: Log envs
101+
store, err = pg.New(ctx, uri, pgoptions.Log("INFO", true), pgoptions.Migrate()) // TODO: Log envs
102102
default:
103103
log.WithField("database", s.env.Database).Error("invalid database")
104104
return errors.New("invalid database")

api/store/pg/api-key.go

Lines changed: 77 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,88 @@ import (
44
"context"
55

66
"github.com/shellhub-io/shellhub/api/store"
7+
"github.com/shellhub-io/shellhub/api/store/pg/entity"
8+
"github.com/shellhub-io/shellhub/pkg/clock"
79
"github.com/shellhub-io/shellhub/pkg/models"
810
)
911

10-
func (pg *Pg) APIKeyCreate(ctx context.Context, APIKey *models.APIKey) (insertedID string, err error)
12+
func (pg *Pg) APIKeyCreate(ctx context.Context, apiKey *models.APIKey) (string, error) {
13+
apiKey.CreatedAt = clock.Now()
14+
apiKey.UpdatedAt = clock.Now()
1115

12-
func (pg *Pg) APIKeyResolve(ctx context.Context, resolver store.APIKeyResolver, value string, opts ...store.QueryOption) (*models.APIKey, error)
16+
if _, err := pg.driver.NewInsert().Model(entity.APIKeyFromModel(apiKey)).Exec(ctx); err != nil {
17+
return "", fromSqlError(err)
18+
}
1319

14-
func (pg *Pg) APIKeyConflicts(ctx context.Context, tenantID string, target *models.APIKeyConflicts) (conflicts []string, has bool, err error)
20+
return apiKey.ID, nil
21+
}
1522

16-
func (pg *Pg) APIKeyList(ctx context.Context, opts ...store.QueryOption) (apiKeys []models.APIKey, count int, err error)
23+
func (pg *Pg) APIKeyConflicts(ctx context.Context, tenantID string, target *models.APIKeyConflicts) ([]string, bool, error) {
24+
apiKeys := make([]map[string]any, 0)
25+
if err := pg.driver.NewSelect().Model((*entity.Namespace)(nil)).Column("name").Where("name = ?", target.Name).Scan(ctx, &apiKeys); err != nil {
26+
return nil, false, fromSqlError(err)
27+
}
1728

18-
func (pg *Pg) APIKeySave(ctx context.Context, apiKey *models.APIKey) (err error)
29+
conflicts := make([]string, 0)
30+
for _, apiKey := range apiKeys {
31+
if apiKey["name"] == target.Name {
32+
conflicts = append(conflicts, "name")
33+
}
34+
}
1935

20-
func (pg *Pg) APIKeyDelete(ctx context.Context, apiKey *models.APIKey) (err error)
36+
return conflicts, len(conflicts) > 0, nil
37+
}
38+
39+
func (pg *Pg) APIKeyList(ctx context.Context, opts ...store.QueryOption) ([]models.APIKey, int, error) {
40+
entities := make([]entity.APIKey, 0)
41+
42+
query := pg.driver.NewSelect().Model(&entities)
43+
if err := applyOptions(ctx, query, opts...); err != nil {
44+
return nil, 0, fromSqlError(err)
45+
}
46+
47+
count, err := query.ScanAndCount(ctx)
48+
if err != nil {
49+
return nil, 0, fromSqlError(err)
50+
}
51+
52+
apiKeys := make([]models.APIKey, len(entities))
53+
for i, e := range entities {
54+
apiKeys[i] = *entity.APIKeyToModel(&e)
55+
}
56+
57+
return apiKeys, count, nil
58+
}
59+
60+
func (pg *Pg) APIKeyGet(ctx context.Context, id string) (*models.APIKey, error) {
61+
a := new(entity.APIKey)
62+
if err := pg.driver.NewSelect().Model(a).Where("id = ?", id).Scan(ctx); err != nil {
63+
return nil, fromSqlError(err)
64+
}
65+
66+
return entity.APIKeyToModel(a), nil
67+
}
68+
69+
func (pg *Pg) APIKeyGetByName(ctx context.Context, tenantID string, name string) (*models.APIKey, error) {
70+
a := new(entity.APIKey)
71+
if err := pg.driver.NewSelect().Model(a).Where("namespace_id = ?", tenantID).Where("name = ?", name).Scan(ctx); err != nil {
72+
return nil, fromSqlError(err)
73+
}
74+
75+
return entity.APIKeyToModel(a), nil
76+
}
77+
78+
func (pg *Pg) APIKeyUpdate(ctx context.Context, apiKey *models.APIKey) error {
79+
a := entity.APIKeyFromModel(apiKey)
80+
a.UpdatedAt = clock.Now()
81+
_, err := pg.driver.NewUpdate().Model(a).WherePK().Exec(ctx)
82+
83+
return fromSqlError(err)
84+
}
85+
86+
func (pg *Pg) APIKeyDelete(ctx context.Context, apiKey *models.APIKey) error {
87+
a := entity.APIKeyFromModel(apiKey)
88+
_, err := pg.driver.NewDelete().Model(a).WherePK().Exec(ctx)
89+
90+
return fromSqlError(err)
91+
}

api/store/pg/api-key_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
package pg_test

api/store/pg/dbtest/fixtures/.keep

Whitespace-only changes.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
- model: APIKey
2+
rows:
3+
- id: f23a2e56cd3fcfba002c72675c870e1e7813292adc40bbf14cea479a2e07976a
4+
name: dev
5+
created_by: 507f1f77bcf86cd799439011
6+
tenant_id: 00000000-0000-4000-0000-000000000000
7+
role: admin
8+
created_at: '2023-01-01T12:00:00.000Z'
9+
updated_at: '2023-01-01T12:00:00.000Z'
10+
expires_in: 0
11+
- id: a1b2c73ea41f70870c035283336d72228118213ed03ec78043ffee48d827af11
12+
name: prod
13+
created_by: 507f1f77bcf86cd799439011
14+
tenant_id: 00000000-0000-4000-0000-000000000000
15+
role: operator
16+
created_at: '2023-01-02T12:00:00.000Z'
17+
updated_at: '2023-01-02T12:00:00.000Z'
18+
expires_in: 10
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
- model: User
2+
rows:
3+
- id: 0195cefa-aa01-7efb-8098-c9c173056250
4+
created_at: 2025-01-15T10:30:00+00:00
5+
updated_at: 2025-01-15T10:30:00+00:00
6+
last_login: null
7+
status: confirmed
8+
origin: local
9+
external_id: ""
10+
name: Jonh Doe
11+
username: john_doe
12+
13+
security_email: [email protected]
14+
password_digest: "$2y$12$VVm2ETx7AvaGlfMYqNYK9uzU2M45YZ70YnT..O.s1o2zdE1pekhq6"
15+
auth_methods: [ local ]
16+
namespace_ownership_limit: -1
17+
email_marketing: true
18+
preferred_namespace_id: null

api/store/pg/device.go

Lines changed: 117 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,133 @@
11
package pg
22

33
import (
4-
"context"
4+
"context" //nolint:gosec
55
"time"
66

77
"github.com/shellhub-io/shellhub/api/store"
8+
"github.com/shellhub-io/shellhub/api/store/pg/entity"
9+
"github.com/shellhub-io/shellhub/pkg/clock"
810
"github.com/shellhub-io/shellhub/pkg/models"
11+
"github.com/uptrace/bun"
12+
"github.com/uptrace/bun/dialect/pgdialect"
913
)
1014

11-
func (pg *Pg) DeviceCreate(ctx context.Context, device *models.Device) (insertedUID string, err error)
15+
type deviceExpr string
1216

13-
func (pg *Pg) DeviceList(ctx context.Context, acceptable store.DeviceAcceptable, opts ...store.QueryOption) ([]models.Device, int, error)
17+
const (
18+
deviceExprOnline deviceExpr = `
19+
CASE
20+
WHEN "device"."disconnected_at" IS NULL AND "device"."seen_at" > ?
21+
THEN true
22+
ELSE false
23+
END AS "online"`
24+
)
25+
26+
func (pg *Pg) DeviceCreate(ctx context.Context, device *models.Device) (string, error) {
27+
device.CreatedAt = clock.Now()
28+
29+
e := entity.DeviceFromModel(device)
30+
if _, err := pg.driver.NewInsert().Model(e).Exec(ctx); err != nil {
31+
return "", fromSqlError(err)
32+
}
33+
34+
return e.ID, nil
35+
}
36+
37+
func (pg *Pg) DeviceConflicts(ctx context.Context, target *models.DeviceConflicts) ([]string, bool, error) {
38+
devices := make([]map[string]any, 0)
39+
if err := pg.driver.NewSelect().Model((*entity.Device)(nil)).Column("name").Where("name = ?", target.Name).Scan(ctx, &devices); err != nil {
40+
return nil, false, fromSqlError(err)
41+
}
42+
43+
conflicts := make([]string, 0)
44+
for _, device := range devices {
45+
if device["name"] == target.Name {
46+
conflicts = append(conflicts, "name")
47+
}
48+
}
49+
50+
return conflicts, len(conflicts) > 0, nil
51+
}
52+
53+
func (pg *Pg) DeviceList(ctx context.Context, opts ...store.QueryOption) ([]models.Device, int, error) {
54+
entities := make([]entity.Device, 0)
55+
56+
query := pg.driver.
57+
NewSelect().
58+
Model(&entities).
59+
Column("device.*").
60+
Relation("Namespace").
61+
ColumnExpr(string(deviceExprOnline), time.Now().Add(-2*time.Minute)).
62+
ColumnExpr(`
63+
CASE
64+
WHEN "device"."status" <> 'accepted'
65+
THEN true
66+
ELSE false
67+
END AS "acceptable"`,
68+
)
69+
70+
if err := applyOptions(ctx, query, opts...); err != nil {
71+
return nil, 0, fromSqlError(err)
72+
}
73+
74+
count, err := query.ScanAndCount(ctx)
75+
if err != nil {
76+
return nil, 0, fromSqlError(err)
77+
}
78+
79+
devices := make([]models.Device, len(entities))
80+
for i, e := range entities {
81+
devices[i] = *entity.DeviceToModel(&e)
82+
}
83+
84+
return devices, count, nil
85+
}
86+
87+
func (pg *Pg) DeviceResolve(ctx context.Context, resolver store.DeviceResolver, val string, opts ...store.QueryOption) (*models.Device, error) {
88+
d := new(entity.Device)
89+
90+
query := pg.driver.
91+
NewSelect().
92+
Model(d).
93+
Where("? = ?", bun.Ident("device."+string(resolver)), val).
94+
Column("device.*").
95+
Relation("Namespace").
96+
ColumnExpr(string(deviceExprOnline), time.Now().Add(-2*time.Minute))
97+
98+
if err := query.Scan(ctx); err != nil {
99+
return nil, fromSqlError(err)
100+
}
101+
102+
return entity.DeviceToModel(d), nil
103+
}
14104

15-
func (pg *Pg) DeviceResolve(ctx context.Context, resolver store.DeviceResolver, value string, opts ...store.QueryOption) (*models.Device, error)
105+
func (pg *Pg) DeviceUpdate(ctx context.Context, device *models.Device) error {
106+
d := entity.DeviceFromModel(device)
107+
d.UpdatedAt = clock.Now()
108+
_, err := pg.driver.NewUpdate().Model(d).WherePK().Exec(ctx)
16109

17-
func (pg *Pg) DeviceConflicts(ctx context.Context, target *models.DeviceConflicts) (conflicts []string, has bool, err error)
110+
return fromSqlError(err)
111+
}
18112

19-
func (pg *Pg) DeviceUpdate(ctx context.Context, device *models.Device) error
113+
func (pg *Pg) DeviceHeartbeat(ctx context.Context, ids []string, lastSeen time.Time) (int64, error) {
114+
r, err := pg.driver.NewUpdate().
115+
Model((*entity.Device)(nil)).
116+
Set("seen_at = ?", lastSeen).
117+
Set("disconnected_at = NULL").
118+
TableExpr("(SELECT unnest(?::varchar[]) as id) as _data", pgdialect.Array(ids)).
119+
Where("device.id = _data.id").
120+
Exec(ctx)
121+
if err != nil {
122+
return 0, fromSqlError(err)
123+
}
20124

21-
func (pg *Pg) DeviceHeartbeat(ctx context.Context, uids []string, lastSeen time.Time) (modifiedCount int64, err error)
125+
return r.RowsAffected()
126+
}
22127

23-
func (pg *Pg) DeviceDelete(ctx context.Context, device *models.Device) error
128+
func (pg *Pg) DeviceDelete(ctx context.Context, device *models.Device) error {
129+
d := entity.DeviceFromModel(device)
130+
_, err := pg.driver.NewDelete().Model(d).WherePK().Exec(ctx)
24131

25-
func (pg *Pg) DeviceDeleteMany(ctx context.Context, uids []string) (deletedCount int64, err error)
132+
return fromSqlError(err)
133+
}

api/store/pg/device_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
package pg_test

api/store/pg/entity/api-key.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package entity
2+
3+
import (
4+
"time"
5+
6+
"github.com/shellhub-io/shellhub/pkg/api/authorizer"
7+
"github.com/shellhub-io/shellhub/pkg/models"
8+
"github.com/uptrace/bun"
9+
)
10+
11+
type APIKey struct {
12+
bun.BaseModel `bun:"table:api_keys"`
13+
14+
KeyDigest string `bun:"key_digest,pk"`
15+
NamespaceID string `bun:"namespace_id,pk"`
16+
Name string `bun:"name"`
17+
Role string `bun:"role"`
18+
UserID string `bun:"user_id"`
19+
CreatedAt time.Time `bun:"created_at"`
20+
UpdatedAt time.Time `bun:"updated_at"`
21+
ExpiresIn int64 `bun:"expires_in,nullzero"`
22+
}
23+
24+
func APIKeyFromModel(model *models.APIKey) *APIKey {
25+
return &APIKey{
26+
Name: model.Name,
27+
NamespaceID: model.TenantID,
28+
KeyDigest: model.ID,
29+
Role: model.Role.String(),
30+
UserID: model.CreatedBy,
31+
CreatedAt: model.CreatedAt,
32+
UpdatedAt: model.UpdatedAt,
33+
ExpiresIn: model.ExpiresIn,
34+
}
35+
}
36+
37+
func APIKeyToModel(entity *APIKey) *models.APIKey {
38+
return &models.APIKey{
39+
ID: entity.KeyDigest,
40+
Name: entity.Name,
41+
TenantID: entity.NamespaceID,
42+
Role: authorizer.Role(entity.Role),
43+
CreatedBy: entity.UserID,
44+
CreatedAt: entity.CreatedAt,
45+
UpdatedAt: entity.UpdatedAt,
46+
ExpiresIn: entity.ExpiresIn,
47+
}
48+
}

0 commit comments

Comments
 (0)