diff --git a/cmd/server/start.go b/cmd/server/start.go index 919f81c..88f73a0 100644 --- a/cmd/server/start.go +++ b/cmd/server/start.go @@ -7,14 +7,19 @@ import ( "os/user" "github.com/glass-cms/glasscms/ctx" + "github.com/glass-cms/glasscms/database" + "github.com/glass-cms/glasscms/item" "github.com/glass-cms/glasscms/server" "github.com/lmittmann/tint" "github.com/spf13/cobra" + "github.com/spf13/viper" ) type StartCommand struct { Command *cobra.Command logger *slog.Logger + + databaseConfig *database.Config } func NewStartCommand() *StartCommand { @@ -24,6 +29,7 @@ func NewStartCommand() *StartCommand { Level: slog.LevelDebug, }), ), + databaseConfig: &database.Config{}, } sc.Command = &cobra.Command{ @@ -32,11 +38,50 @@ func NewStartCommand() *StartCommand { RunE: sc.Execute, } + flagset := sc.Command.Flags() + + flagset.StringVar( + &sc.databaseConfig.DSN, + database.ArgDSN, + "", + "The data source name (DSN) for the database", + ) + _ = viper.BindPFlag(database.ArgDSN, flagset.Lookup(database.ArgDSN)) + + flagset.StringVar( + &sc.databaseConfig.Driver, + database.ArgDriver, + "", + "The name of the database driver", + ) + _ = viper.BindPFlag(database.ArgDriver, flagset.Lookup(database.ArgDriver)) + + flagset.IntVar( + &sc.databaseConfig.MaxConnections, + database.ArgMaxConnections, + database.MaxConnectionsDefault, + "The maximum number of connections that can be opened to the database", + ) + _ = viper.BindPFlag(database.ArgMaxConnections, flagset.Lookup(database.ArgMaxConnections)) + + flagset.IntVar( + &sc.databaseConfig.MaxIdleConnections, + database.ArgMaxIdleConnections, + database.MaxIdleConnectionsDefault, + "The maximum number of idle connections that can be maintained", + ) + _ = viper.BindPFlag(database.ArgMaxIdleConnections, flagset.Lookup(database.ArgMaxIdleConnections)) + return sc } func (c *StartCommand) Execute(cmd *cobra.Command, _ []string) error { - server, err := server.New(c.logger) + db, err := database.NewConnection(*c.databaseConfig) + if err != nil { + return err + } + + server, err := server.New(c.logger, item.NewRepository(db)) if err != nil { return err } diff --git a/config.yaml b/config.yaml index 93112a9..2a2332d 100644 --- a/config.yaml +++ b/config.yaml @@ -1,4 +1,5 @@ -output: ./out/ +output: './out' database: - driver: sqlite3 + dsn: ':memory:' + driver: 'sqlite3' \ No newline at end of file diff --git a/database/config.go b/database/config.go index 2955459..022528b 100644 --- a/database/config.go +++ b/database/config.go @@ -2,12 +2,21 @@ package database import ( "database/sql" + "errors" "fmt" + + // Import the SQLite3 driver. + _ "github.com/mattn/go-sqlite3" ) type Driver int32 const ( + ArgDriver = "database.driver" + ArgDSN = "database.dsn" + ArgMaxConnections = "database.max_connections" + ArgMaxIdleConnections = "database.max_idle_connections" + DriverUnrecognized Driver = -1 DriverUnspecified Driver = iota DriverPostgres @@ -52,6 +61,14 @@ type Config struct { // The sql.DB object represents a pool of zero or more underlying connections. // It's safe for concurrent use by multiple goroutines. func NewConnection(cfg Config) (*sql.DB, error) { + if _, ok := DriverValue[cfg.Driver]; !ok { + return nil, fmt.Errorf("unrecognized database driver: %s", cfg.Driver) + } + + if cfg.DSN == "" { + return nil, errors.New("data source name (DSN) is required") + } + db, err := sql.Open(cfg.Driver, cfg.DSN) if err != nil { return nil, fmt.Errorf("failed to open database connection: %w", err) diff --git a/database/migrate.go b/database/migrate.go new file mode 100644 index 0000000..76cd673 --- /dev/null +++ b/database/migrate.go @@ -0,0 +1,21 @@ +package database + +import ( + "database/sql" + "embed" + + "github.com/pressly/goose/v3" +) + +//go:embed migrations/*.sql +var embedMigrations embed.FS + +// MigrateDatabase migrates the database to the latest version. +func MigrateDatabase(db *sql.DB, cfg Config) error { + if err := goose.SetDialect(cfg.Driver); err != nil { + return err + } + goose.SetBaseFS(embedMigrations) + + return goose.Up(db, "migrations") +} diff --git a/database/migrations/1_create_items.sql b/database/migrations/1_create_items.sql index 4a4cf79..d031cdd 100644 --- a/database/migrations/1_create_items.sql +++ b/database/migrations/1_create_items.sql @@ -1,3 +1,4 @@ +-- +goose Up CREATE TABLE items ( uid TEXT PRIMARY KEY, create_time TIMESTAMP NOT NULL, @@ -8,4 +9,7 @@ CREATE TABLE items ( path TEXT NOT NULL, content TEXT, properties JSON -); \ No newline at end of file +); + +-- +goose Down +DROP TABLE items; \ No newline at end of file diff --git a/go.mod b/go.mod index a319829..f96a03f 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,9 @@ go 1.22.0 require ( github.com/djherbis/times v1.6.0 github.com/lmittmann/tint v1.0.4 + github.com/mattn/go-sqlite3 v1.14.22 github.com/oapi-codegen/oapi-codegen/v2 v2.3.0 + github.com/pressly/goose/v3 v3.21.1 github.com/spf13/cobra v1.8.0 github.com/spf13/pflag v1.0.5 github.com/spf13/viper v1.18.2 @@ -28,6 +30,7 @@ require ( github.com/josharian/intern v1.0.0 // indirect github.com/magiconair/properties v1.8.7 // indirect github.com/mailru/easyjson v0.7.7 // indirect + github.com/mfridman/interpolate v0.0.2 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect github.com/pelletier/go-toml/v2 v2.1.0 // indirect @@ -36,14 +39,15 @@ require ( github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/sethvargo/go-retry v0.2.4 // indirect github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.11.0 // indirect github.com/spf13/cast v1.6.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect - go.uber.org/atomic v1.9.0 // indirect - go.uber.org/multierr v1.9.0 // indirect - golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 // indirect golang.org/x/mod v0.17.0 // indirect + golang.org/x/sync v0.7.0 // indirect golang.org/x/sys v0.20.0 // indirect golang.org/x/tools v0.21.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect diff --git a/go.sum b/go.sum index 5edc296..89e9100 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c= github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= @@ -20,6 +22,11 @@ github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -38,10 +45,18 @@ github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0V github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= +github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/oapi-codegen/oapi-codegen/v2 v2.3.0 h1:rICjNsHbPP1LttefanBPnwsSwl09SqhCO7Ee623qR84= github.com/oapi-codegen/oapi-codegen/v2 v2.3.0/go.mod h1:4k+cJeSq5ntkwlcpQSxLxICCxQzCL772o30PxdibRt4= github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4= @@ -51,6 +66,10 @@ github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0V github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pressly/goose/v3 v3.21.1 h1:5SSAKKWej8LVVzNLuT6KIvP1eFDuPvxa+B6H0w78buQ= +github.com/pressly/goose/v3 v3.21.1/go.mod h1:sqthmzV8PitchEkjecFJII//l43dLOCzfWh8pHEe+vE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= @@ -59,6 +78,8 @@ github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6ke github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/sethvargo/go-retry v0.2.4 h1:T+jHEQy/zKJf5s95UkguisicE0zuF9y7+/vgz08Ocec= +github.com/sethvargo/go-retry v0.2.4/go.mod h1:1afjQuvh7s4gflMObvjLPaWgluLLyhA1wmVZ6KLpICw= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= @@ -74,7 +95,6 @@ github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMV github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= @@ -86,12 +106,10 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= -go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= -go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= -go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= -go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= -golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= -golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 h1:aAcj0Da7eBAtrTp03QXWvm88pSyOt+UgdZw2BFZ+lEw= +golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8/go.mod h1:CQ1k9gNrJ50XIzaKCRR2hssIjF07kZFEiieALBM/ARQ= golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= @@ -114,3 +132,17 @@ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= +modernc.org/libc v1.41.0 h1:g9YAc6BkKlgORsUWj+JwqoB1wU3o4DE3bM3yvA3k+Gk= +modernc.org/libc v1.41.0/go.mod h1:w0eszPsiXoOnoMJgrXjglgLuDy/bt5RR4y3QzUUeodY= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E= +modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E= +modernc.org/sqlite v1.29.6 h1:0lOXGrycJPptfHDuohfYgNqoe4hu+gYuN/pKgY5XjS4= +modernc.org/sqlite v1.29.6/go.mod h1:S02dvcmm7TnTRvGhv8IGYyLnIt7AS2KPaB1F/71p75U= +modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= +modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/item/repository.go b/item/repository.go index 0bed7a1..22d2326 100644 --- a/item/repository.go +++ b/item/repository.go @@ -3,6 +3,9 @@ package item import ( "context" "database/sql" + "encoding/json" + "errors" + "fmt" ) type Repository struct { @@ -14,19 +17,154 @@ func NewRepository(db *sql.DB) *Repository { } // CreateItem creates a new item in the database. -func (r *Repository) CreateItem(ctx context.Context, tx *sql.Tx, item *Item) error { - // TODO: Implement this method. +func (r *Repository) CreateItem(ctx context.Context, item *Item) error { + query := ` + INSERT INTO items (uid, create_time, update_time, hash, display_name, name, path, content, properties) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ` + + propertiesJSON, err := json.Marshal(item.Properties) + if err != nil { + return fmt.Errorf("failed to marshal properties: %w", err) + } + + stmt, err := r.db.PrepareContext(ctx, query) + if err != nil { + return fmt.Errorf("failed to prepare statement: %w", err) + } + defer stmt.Close() + + _, err = stmt.ExecContext(ctx, + item.UID, + item.CreateTime, + item.UpdateTime, + item.Hash, + item.DisplayName, + item.Name, + item.Path, + item.Content, + propertiesJSON, + ) + + if err != nil { + return fmt.Errorf("failed to insert item: %w", err) + } + return nil } // GetItem retrieves an item from the database by its UID. -func (r *Repository) GetItem(ctx context.Context, tx *sql.Tx, uid string) (*Item, error) { - // TODO: Implement this method. - return nil, nil +func (r *Repository) GetItem(ctx context.Context, uid string) (*Item, error) { + query := ` + SELECT uid, create_time, update_time, hash, display_name, name, path, content, properties + FROM items + WHERE uid = $1 + ` + var item Item + var propertiesJSON []byte + + stmt, err := r.db.PrepareContext(ctx, query) + if err != nil { + return nil, fmt.Errorf("failed to prepare statement: %w", err) + } + defer stmt.Close() + + err = stmt.QueryRowContext(ctx, uid).Scan( + &item.UID, + &item.CreateTime, + &item.UpdateTime, + &item.Hash, + &item.DisplayName, + &item.Name, + &item.Path, + &item.Content, + &propertiesJSON, + ) + + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("item not found: %w", err) + } + return nil, fmt.Errorf("failed to retrieve item: %w", err) + } + + err = json.Unmarshal(propertiesJSON, &item.Properties) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal properties: %w", err) + } + + return &item, nil } // UpdateItem updates an existing item in the database. -func (r *Repository) UpdateItem(ctx context.Context, tx *sql.Tx, item *Item) error { - // TODO: Implement this method. +func (r *Repository) UpdateItem(ctx context.Context, item *Item) error { + query := ` + UPDATE items + SET update_time = $1, hash = $2, display_name = $3, name = $4, path = $5, content = $6, properties = $7 + WHERE uid = $8 + ` + + propertiesJSON, err := json.Marshal(item.Properties) + if err != nil { + return fmt.Errorf("failed to marshal properties: %w", err) + } + + stmt, err := r.db.PrepareContext(ctx, query) + if err != nil { + return fmt.Errorf("failed to prepare statement: %w", err) + } + defer stmt.Close() + + result, err := stmt.ExecContext(ctx, + item.UpdateTime, + item.Hash, + item.DisplayName, + item.Name, + item.Path, + item.Content, + propertiesJSON, + item.UID, + ) + + if err != nil { + return fmt.Errorf("failed to update item: %w", err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("failed to get rows affected: %w", err) + } + + if rowsAffected == 0 { + return errors.New("item not found") + } + + return nil +} + +// DeleteItem deletes an item from the database by its UID. +func (r *Repository) DeleteItem(ctx context.Context, uid string) error { + query := `DELETE FROM items WHERE uid = $1` + + stmt, err := r.db.PrepareContext(ctx, query) + if err != nil { + return fmt.Errorf("failed to prepare statement: %w", err) + } + defer stmt.Close() + + result, err := stmt.ExecContext(ctx, uid) + if err != nil { + return fmt.Errorf("failed to delete item: %w", err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("failed to get rows affected: %w", err) + } + + if rowsAffected == 0 { + return errors.New("item not found") + } + return nil } diff --git a/item/repository_test.go b/item/repository_test.go new file mode 100644 index 0000000..5c4324f --- /dev/null +++ b/item/repository_test.go @@ -0,0 +1,372 @@ +package item_test + +import ( + "context" + "database/sql" + "testing" + "time" + + "github.com/glass-cms/glasscms/database" + "github.com/glass-cms/glasscms/item" + _ "github.com/mattn/go-sqlite3" + "github.com/stretchr/testify/assert" +) + +func GetTestDatabase() *sql.DB { + db, err := sql.Open("sqlite3", ":memory:") + if err != nil { + panic(err) + } + + if err = database.MigrateDatabase(db, database.Config{ + Driver: "sqlite3", + }); err != nil { + panic(err) + } + return db +} + +func SeedDatabase(db *sql.DB, items ...*item.Item) error { + repo := item.NewRepository(db) + for _, i := range items { + if err := repo.CreateItem(context.Background(), i); err != nil { + return err + } + } + return nil +} + +func getTestItem() *item.Item { + return &item.Item{ + UID: "1234", + CreateTime: time.Now(), + UpdateTime: time.Now(), + Hash: "hash", + DisplayName: "DisplayName", + Name: "Name", + Path: "Path", + Content: "Content", + Properties: map[string]interface{}{"key": "value"}, + } +} + +func TestRepository_CreateItem(t *testing.T) { + t.Parallel() + + type fields struct { + db *sql.DB + } + type args struct { + ctx context.Context + item *item.Item + } + tests := map[string]struct { + fields fields + args args + wantErr bool + }{ + "Successful creation": { + fields: fields{ + db: GetTestDatabase(), + }, + args: args{ + ctx: context.Background(), + item: getTestItem(), + }, + wantErr: false, + }, + "Context canceled": { + fields: fields{ + db: GetTestDatabase(), + }, + args: args{ + ctx: func() context.Context { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + return ctx + }(), + item: getTestItem(), + }, + wantErr: true, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + r := item.NewRepository(tt.fields.db) + err := r.CreateItem(tt.args.ctx, tt.args.item) + assert.Equal(t, tt.wantErr, err != nil, "Repository.CreateItem() error = %v, wantErr %v", err, tt.wantErr) + }) + } +} + +func TestRepository_GetItem(t *testing.T) { + t.Parallel() + + type fields struct { + db *sql.DB + seed func(*sql.DB) + } + type args struct { + ctx context.Context + uid string + } + tests := map[string]struct { + fields fields + args args + want *item.Item + wantErr bool + }{ + "Successful retrieval": { + fields: fields{ + db: GetTestDatabase(), + seed: func(db *sql.DB) { + if err := SeedDatabase(db, getTestItem()); err != nil { + t.Error(err) + } + }, + }, + args: args{ + ctx: context.Background(), + uid: "1234", + }, + want: getTestItem(), + wantErr: false, + }, + "Context canceled": { + fields: fields{ + db: GetTestDatabase(), + seed: func(db *sql.DB) { + if err := SeedDatabase(db, getTestItem()); err != nil { + t.Error(err) + } + }, + }, + args: args{ + ctx: func() context.Context { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + return ctx + }(), + uid: "1234", + }, + want: nil, + wantErr: true, + }, + "Item not found": { + fields: fields{ + db: GetTestDatabase(), + }, + args: args{ + ctx: context.Background(), + uid: "nonexistent", + }, + want: nil, + wantErr: true, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + r := item.NewRepository(tt.fields.db) + if tt.fields.seed != nil { + tt.fields.seed(tt.fields.db) + } + got, err := r.GetItem(tt.args.ctx, tt.args.uid) + assert.Equal(t, tt.wantErr, err != nil, "Repository.GetItem() error = %v, wantErr %v", err, tt.wantErr) + if tt.want != nil && got != nil { + assert.Equal(t, tt.want.UID, got.UID) + assert.WithinDuration(t, tt.want.CreateTime, got.CreateTime, time.Second) + assert.WithinDuration(t, tt.want.UpdateTime, got.UpdateTime, time.Second) + assert.Equal(t, tt.want.Hash, got.Hash) + assert.Equal(t, tt.want.DisplayName, got.DisplayName) + assert.Equal(t, tt.want.Name, got.Name) + assert.Equal(t, tt.want.Path, got.Path) + assert.Equal(t, tt.want.Content, got.Content) + assert.Equal(t, tt.want.Properties, got.Properties) + } + }) + } +} + +func TestRepository_UpdateItem(t *testing.T) { + t.Parallel() + + type fields struct { + db *sql.DB + seed func(*sql.DB) + } + type args struct { + ctx context.Context + item *item.Item + } + tests := map[string]struct { + fields fields + args args + wantErr bool + }{ + "Successful update": { + fields: fields{ + db: GetTestDatabase(), + seed: func(db *sql.DB) { + if err := SeedDatabase(db, getTestItem()); err != nil { + t.Error(err) + } + }, + }, + args: args{ + ctx: context.Background(), + item: &item.Item{ + UID: "1234", + CreateTime: time.Now(), + UpdateTime: time.Now(), + Hash: "newhash", + DisplayName: "NewDisplayName", + Name: "NewName", + Path: "NewPath", + Content: "NewContent", + Properties: map[string]interface{}{"newkey": "newvalue"}, + }, + }, + wantErr: false, + }, + "Context canceled": { + fields: fields{ + db: GetTestDatabase(), + seed: func(db *sql.DB) { + if err := SeedDatabase(db, getTestItem()); err != nil { + t.Error(err) + } + }, + }, + args: args{ + ctx: func() context.Context { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + return ctx + }(), + item: &item.Item{ + UID: "1234", + CreateTime: time.Now(), + UpdateTime: time.Now(), + Hash: "newhash", + DisplayName: "NewDisplayName", + Name: "NewName", + Path: "NewPath", + Content: "NewContent", + Properties: map[string]interface{}{"newkey": "newvalue"}, + }, + }, + wantErr: true, + }, + "Update non-existent item": { + fields: fields{ + db: GetTestDatabase(), + }, + args: args{ + ctx: context.Background(), + item: &item.Item{ + UID: "nonexistent", + CreateTime: time.Now(), + UpdateTime: time.Now(), + Hash: "newhash", + DisplayName: "NewDisplayName", + Name: "NewName", + Path: "NewPath", + Content: "NewContent", + Properties: map[string]interface{}{"newkey": "newvalue"}, + }, + }, + wantErr: true, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + r := item.NewRepository(tt.fields.db) + if tt.fields.seed != nil { + tt.fields.seed(tt.fields.db) + } + err := r.UpdateItem(tt.args.ctx, tt.args.item) + assert.Equal(t, tt.wantErr, err != nil, "Repository.UpdateItem() error = %v, wantErr %v", err, tt.wantErr) + }) + } +} + +func TestRepository_DeleteItem(t *testing.T) { + t.Parallel() + + type fields struct { + db *sql.DB + seed func(*sql.DB) + } + type args struct { + ctx context.Context + uid string + } + tests := map[string]struct { + fields fields + args args + wantErr bool + }{ + "Successful deletion": { + fields: fields{ + db: GetTestDatabase(), + seed: func(db *sql.DB) { + if err := SeedDatabase(db, getTestItem()); err != nil { + t.Error(err) + } + }, + }, + args: args{ + ctx: context.Background(), + uid: "1234", + }, + wantErr: false, + }, + "Context canceled": { + fields: fields{ + db: GetTestDatabase(), + seed: func(db *sql.DB) { + if err := SeedDatabase(db, getTestItem()); err != nil { + t.Error(err) + } + }, + }, + args: args{ + ctx: func() context.Context { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + return ctx + }(), + uid: "1234", + }, + wantErr: true, + }, + "Item not found": { + fields: fields{ + db: GetTestDatabase(), + }, + args: args{ + ctx: context.Background(), + uid: "nonexistent", + }, + wantErr: true, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + r := item.NewRepository(tt.fields.db) + if tt.fields.seed != nil { + tt.fields.seed(tt.fields.db) + } + err := r.DeleteItem(tt.args.ctx, tt.args.uid) + assert.Equal(t, tt.wantErr, err != nil, "Repository.DeleteItem() error = %v, wantErr %v", err, tt.wantErr) + }) + } +} diff --git a/server/server.go b/server/server.go index 3a053ce..1f86443 100644 --- a/server/server.go +++ b/server/server.go @@ -8,6 +8,7 @@ import ( "time" "github.com/glass-cms/glasscms/api" + "github.com/glass-cms/glasscms/item" ) const ( @@ -20,16 +21,20 @@ const ( type Server struct { logger *slog.Logger server *http.Server + + repository *item.Repository } var _ api.ServerInterface = (*Server)(nil) func New( logger *slog.Logger, + repo *item.Repository, opts ...Option, ) (*Server, error) { server := &Server{ - logger: logger, + logger: logger, + repository: repo, } server.server = &http.Server{