diff --git a/api/handler.go b/api/handler.go index a01506b..fd686c6 100644 --- a/api/handler.go +++ b/api/handler.go @@ -29,6 +29,12 @@ func Handler() *mux.Router { m.Get(router.UpdateGenus).Handler(handler(serveUpdateGenus)) m.Get(router.DeleteGenus).Handler(handler(serveDeleteGenus)) + m.Get(router.Species).Handler(handler(serveSpecies)) + m.Get(router.CreateSpecies).Handler(handler(serveCreateSpecies)) + m.Get(router.SpeciesList).Handler(handler(serveSpeciesList)) + m.Get(router.UpdateSpecies).Handler(handler(serveUpdateSpecies)) + m.Get(router.DeleteSpecies).Handler(handler(serveDeleteSpecies)) + return m } diff --git a/api/species.go b/api/species.go new file mode 100644 index 0000000..fe0b7c3 --- /dev/null +++ b/api/species.go @@ -0,0 +1,92 @@ +package api + +import ( + "encoding/json" + "net/http" + "strconv" + + "github.com/gorilla/mux" + "github.com/thermokarst/bactdb/models" +) + +func serveSpecies(w http.ResponseWriter, r *http.Request) error { + id, err := strconv.ParseInt(mux.Vars(r)["Id"], 10, 0) + if err != nil { + return err + } + + species, err := store.Species.Get(id) + if err != nil { + return err + } + + return writeJSON(w, species) +} + +func serveCreateSpecies(w http.ResponseWriter, r *http.Request) error { + var species models.Species + err := json.NewDecoder(r.Body).Decode(&species) + if err != nil { + return err + } + + created, err := store.Species.Create(&species) + if err != nil { + return err + } + if created { + w.WriteHeader(http.StatusCreated) + } + + return writeJSON(w, species) +} + +func serveSpeciesList(w http.ResponseWriter, r *http.Request) error { + var opt models.SpeciesListOptions + if err := schemaDecoder.Decode(&opt, r.URL.Query()); err != nil { + return err + } + + species, err := store.Species.List(&opt) + if err != nil { + return err + } + if species == nil { + species = []*models.Species{} + } + + return writeJSON(w, species) +} + +func serveUpdateSpecies(w http.ResponseWriter, r *http.Request) error { + id, _ := strconv.ParseInt(mux.Vars(r)["Id"], 10, 0) + var species models.Species + err := json.NewDecoder(r.Body).Decode(&species) + if err != nil { + return err + } + + updated, err := store.Species.Update(id, &species) + if err != nil { + return err + } + if updated { + w.WriteHeader(http.StatusOK) + } + + return writeJSON(w, species) +} + +func serveDeleteSpecies(w http.ResponseWriter, r *http.Request) error { + id, _ := strconv.ParseInt(mux.Vars(r)["Id"], 10, 0) + + deleted, err := store.Species.Delete(id) + if err != nil { + return err + } + if deleted { + w.WriteHeader(http.StatusOK) + } + + return writeJSON(w, &models.Species{}) +} diff --git a/api/species_test.go b/api/species_test.go new file mode 100644 index 0000000..418091e --- /dev/null +++ b/api/species_test.go @@ -0,0 +1,147 @@ +package api + +import ( + "testing" + + "github.com/thermokarst/bactdb/models" +) + +func TestSpecies_Get(t *testing.T) { + setup() + + want := &models.Species{Id: 1, GenusId: 1, SpeciesName: "Test Species"} + + calledGet := false + store.Species.(*models.MockSpeciesService).Get_ = func(id int64) (*models.Species, error) { + if id != want.Id { + t.Errorf("wanted request for species %d but got %d", want.Id, id) + } + calledGet = true + return want, nil + } + + got, err := apiClient.Species.Get(want.Id) + if err != nil { + t.Fatal(err) + } + + if !calledGet { + t.Error("!calledGet") + } + if !normalizeDeepEqual(want, got) { + t.Errorf("got species %+v but wanted species %+v", got, want) + } +} + +func TestSpecies_Create(t *testing.T) { + setup() + + want := &models.Species{Id: 1, GenusId: 1, SpeciesName: "Test Species"} + + calledPost := false + store.Species.(*models.MockSpeciesService).Create_ = func(species *models.Species) (bool, error) { + if !normalizeDeepEqual(want, species) { + t.Errorf("wanted request for species %d but got %d", want, species) + } + calledPost = true + return true, nil + } + + success, err := apiClient.Species.Create(want) + if err != nil { + t.Fatal(err) + } + + if !calledPost { + t.Error("!calledPost") + } + if !success { + t.Error("!success") + } +} + +func TestSpecies_List(t *testing.T) { + setup() + + want := []*models.Species{{Id: 1, GenusId: 1, SpeciesName: "Test Species"}} + wantOpt := &models.SpeciesListOptions{ListOptions: models.ListOptions{Page: 1, PerPage: 10}} + + calledList := false + store.Species.(*models.MockSpeciesService).List_ = func(opt *models.SpeciesListOptions) ([]*models.Species, error) { + if !normalizeDeepEqual(wantOpt, opt) { + t.Errorf("wanted options %d but got %d", wantOpt, opt) + } + calledList = true + return want, nil + } + + species, err := apiClient.Species.List(wantOpt) + if err != nil { + t.Fatal(err) + } + + if !calledList { + t.Error("!calledList") + } + + if !normalizeDeepEqual(&want, &species) { + t.Errorf("got species %+v but wanted species %+v", species, want) + } +} + +func TestSpecies_Update(t *testing.T) { + setup() + + want := &models.Species{Id: 1, GenusId: 1, SpeciesName: "Test Species"} + + calledPut := false + store.Species.(*models.MockSpeciesService).Update_ = func(id int64, species *models.Species) (bool, error) { + if id != want.Id { + t.Errorf("wanted request for species %d but got %d", want.Id, id) + } + if !normalizeDeepEqual(want, species) { + t.Errorf("wanted request for species %d but got %d", want, species) + } + calledPut = true + return true, nil + } + + success, err := apiClient.Species.Update(1, want) + if err != nil { + t.Fatal(err) + } + + if !calledPut { + t.Error("!calledPut") + } + if !success { + t.Error("!success") + } +} + +func TestSpecies_Delete(t *testing.T) { + setup() + + want := &models.Species{Id: 1, GenusId: 1, SpeciesName: "Test Species"} + + calledDelete := false + store.Species.(*models.MockSpeciesService).Delete_ = func(id int64) (bool, error) { + if id != want.Id { + t.Errorf("wanted request for species %d but got %d", want.Id, id) + } + calledDelete = true + return true, nil + } + + success, err := apiClient.Species.Delete(1) + if err != nil { + t.Fatal(err) + } + + if !calledDelete { + t.Error("!calledDelete") + } + if !success { + t.Error("!success") + } +} diff --git a/datastore/datastore.go b/datastore/datastore.go index 43faafd..1919e3a 100644 --- a/datastore/datastore.go +++ b/datastore/datastore.go @@ -9,9 +9,10 @@ import ( // A datastore access point (in PostgreSQL) type Datastore struct { - Users models.UsersService - Genera models.GeneraService - dbh modl.SqlExecutor + Users models.UsersService + Genera models.GeneraService + Species models.SpeciesService + dbh modl.SqlExecutor } var ( @@ -29,12 +30,14 @@ func NewDatastore(dbh modl.SqlExecutor) *Datastore { d := &Datastore{dbh: dbh} d.Users = &usersStore{d} d.Genera = &generaStore{d} + d.Species = &speciesStore{d} return d } func NewMockDatastore() *Datastore { return &Datastore{ - Users: &models.MockUsersService{}, - Genera: &models.MockGeneraService{}, + Users: &models.MockUsersService{}, + Genera: &models.MockGeneraService{}, + Species: &models.MockSpeciesService{}, } } diff --git a/datastore/migrations/00001_AddUsers_up.sql b/datastore/migrations/00001_AddUsers_up.sql index 9dd8907..4ef446a 100644 --- a/datastore/migrations/00001_AddUsers_up.sql +++ b/datastore/migrations/00001_AddUsers_up.sql @@ -5,9 +5,9 @@ CREATE TABLE users ( id BIGSERIAL NOT NULL, username CHARACTER VARYING(100), - createdat TIMESTAMP WITH TIME ZONE, - updatedat TIMESTAMP WITH TIME ZONE, - deletedat TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE, + updated_at TIMESTAMP WITH TIME ZONE, + deleted_at TIMESTAMP WITH TIME ZONE, CONSTRAINT users_pkey PRIMARY KEY (id) ); diff --git a/datastore/migrations/00002_AddGenera_up.sql b/datastore/migrations/00002_AddGenera_up.sql index 3fa4d79..e9ea156 100644 --- a/datastore/migrations/00002_AddGenera_up.sql +++ b/datastore/migrations/00002_AddGenera_up.sql @@ -3,11 +3,11 @@ CREATE TABLE genera ( id BIGSERIAL NOT NULL, - genusname CHARACTER VARYING(100), + genus_name CHARACTER VARYING(100), - createdat TIMESTAMP WITH TIME ZONE, - updatedat TIMESTAMP WITH TIME ZONE, - deletedat TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE, + updated_at TIMESTAMP WITH TIME ZONE, + deleted_at TIMESTAMP WITH TIME ZONE, CONSTRAINT genus_pkey PRIMARY KEY (id) ); @@ -15,5 +15,5 @@ CREATE TABLE genera ( CREATE UNIQUE INDEX genusname_idx ON genera USING btree - (genusname COLLATE pg_catalog."default"); + (genus_name COLLATE pg_catalog."default"); diff --git a/datastore/migrations/00003_AddSpecies_down.sql b/datastore/migrations/00003_AddSpecies_down.sql new file mode 100644 index 0000000..3ce7db5 --- /dev/null +++ b/datastore/migrations/00003_AddSpecies_down.sql @@ -0,0 +1,5 @@ +-- bactdb +-- Matthew R Dillon + +DROP TABLE species; + diff --git a/datastore/migrations/00003_AddSpecies_up.sql b/datastore/migrations/00003_AddSpecies_up.sql new file mode 100644 index 0000000..88a192a --- /dev/null +++ b/datastore/migrations/00003_AddSpecies_up.sql @@ -0,0 +1,15 @@ +-- bactdb +-- Matthew Dillon + +CREATE TABLE species ( + id BIGSERIAL NOT NULL, + genus_id BIGINT NOT NULL, + species_name CHARACTER VARYING(100), + + created_at TIMESTAMP WITH TIME ZONE, + updated_at TIMESTAMP WITH TIME ZONE, + deleted_at TIMESTAMP WITH TIME ZONE, + + CONSTRAINT species_pkey PRIMARY KEY (id), + FOREIGN KEY (genus_id) REFERENCES genera(id) +); diff --git a/datastore/migrations/00004_AddStrain_down.sql b/datastore/migrations/00004_AddStrain_down.sql new file mode 100644 index 0000000..94edb83 --- /dev/null +++ b/datastore/migrations/00004_AddStrain_down.sql @@ -0,0 +1,5 @@ +-- bactdb +-- Matthew R Dillon + +DROP TABLE strains; + diff --git a/datastore/migrations/00004_AddStrain_up.sql b/datastore/migrations/00004_AddStrain_up.sql new file mode 100644 index 0000000..855f3a9 --- /dev/null +++ b/datastore/migrations/00004_AddStrain_up.sql @@ -0,0 +1,20 @@ +-- bactdb +-- Matthew R Dillon + +CREATE TABLE strains ( + id BIGSERIAL NOT NULL, + species_id BIGINT, + strain_name CHARACTER VARYING(100), + strain_type CHARACTER VARYING(100), + etymology CHARACTER VARYING(500), + accession_banks CHARACTER VARYING(100), + genbank_embl_ddb CHARACTER VARYING(100), + + created_at TIMESTAMP WITH TIME ZONE, + updated_at TIMESTAMP WITH TIME ZONE, + deleted_at TIMESTAMP WITH TIME ZONE, + + CONSTRAINT strain_pkey PRIMARY KEY (id), + FOREIGN KEY (species_id) REFERENCES species(id) +); + diff --git a/datastore/migrations/00005_AddObservationTypes_down.sql b/datastore/migrations/00005_AddObservationTypes_down.sql new file mode 100644 index 0000000..39f6366 --- /dev/null +++ b/datastore/migrations/00005_AddObservationTypes_down.sql @@ -0,0 +1,5 @@ +-- bactdb +-- Matthew R Dillon + +DROP TABLE observation_types; + diff --git a/datastore/migrations/00005_AddObservationTypes_up.sql b/datastore/migrations/00005_AddObservationTypes_up.sql new file mode 100644 index 0000000..d18d8b5 --- /dev/null +++ b/datastore/migrations/00005_AddObservationTypes_up.sql @@ -0,0 +1,14 @@ +-- bactdb +-- Matthew R Dillon + +CREATE TABLE observation_types ( + id BIGSERIAL NOT NULL, + observation_type_name CHARACTER VARYING(100), + + created_at TIMESTAMP WITH TIME ZONE, + updated_at TIMESTAMP WITH TIME ZONE, + deleted_at TIMESTAMP WITH TIME ZONE, + + CONSTRAINT observation_types_pkey PRIMARY KEY (id) +); + diff --git a/datastore/migrations/00006_AddObservations_down.sql b/datastore/migrations/00006_AddObservations_down.sql new file mode 100644 index 0000000..5b5ef85 --- /dev/null +++ b/datastore/migrations/00006_AddObservations_down.sql @@ -0,0 +1,5 @@ +-- bactdb +-- Matthew R Dillon + +DROP TABLE observations; + diff --git a/datastore/migrations/00006_AddObservations_up.sql b/datastore/migrations/00006_AddObservations_up.sql new file mode 100644 index 0000000..df0a1be --- /dev/null +++ b/datastore/migrations/00006_AddObservations_up.sql @@ -0,0 +1,16 @@ +-- bactdb +-- Matthew R Dillon + +CREATE TABLE observations ( + id BIGSERIAL NOT NULL, + observation_name CHARACTER VARYING(100), + observation_type_id BIGINT, + + created_at TIMESTAMP WITH TIME ZONE, + updated_at TIMESTAMP WITH TIME ZONE, + deleted_at TIMESTAMP WITH TIME ZONE, + + CONSTRAINT observations_pkey PRIMARY KEY (id), + FOREIGN KEY (observation_type_id) REFERENCES observation_types(id) +); + diff --git a/datastore/migrations/00007_AddStrainsObservations_down.sql b/datastore/migrations/00007_AddStrainsObservations_down.sql new file mode 100644 index 0000000..c67ba07 --- /dev/null +++ b/datastore/migrations/00007_AddStrainsObservations_down.sql @@ -0,0 +1,5 @@ +-- bactdb +-- Matthew R Dillon + +DROP TABLE strainsobservations; + diff --git a/datastore/migrations/00007_AddStrainsObservations_up.sql b/datastore/migrations/00007_AddStrainsObservations_up.sql new file mode 100644 index 0000000..4c939be --- /dev/null +++ b/datastore/migrations/00007_AddStrainsObservations_up.sql @@ -0,0 +1,17 @@ +-- bactdb +-- Matthew R Dillon + +CREATE TABLE strainsobservations ( + id BIGSERIAL NOT NULL, + strain_id BIGINT NOT NULL, + observations_id BIGINT NOT NULL, + + created_at TIMESTAMP WITH TIME ZONE, + updated_at TIMESTAMP WITH TIME ZONE, + deleted_at TIMESTAMP WITH TIME ZONE, + + CONSTRAINT strainsobservations_pkey PRIMARY KEY (id), + FOREIGN KEY (strain_id) REFERENCES strains(id), + FOREIGN KEY (observations_id) REFERENCES observations(id) +); + diff --git a/datastore/migrations/00008_AddText_Measurements_down.sql b/datastore/migrations/00008_AddText_Measurements_down.sql new file mode 100644 index 0000000..3b8bc9b --- /dev/null +++ b/datastore/migrations/00008_AddText_Measurements_down.sql @@ -0,0 +1,5 @@ +-- bactdb +-- Matthew R Dillon + +DROP TABLE text_measurements; + diff --git a/datastore/migrations/00008_AddText_Measurements_up.sql b/datastore/migrations/00008_AddText_Measurements_up.sql new file mode 100644 index 0000000..d0992ce --- /dev/null +++ b/datastore/migrations/00008_AddText_Measurements_up.sql @@ -0,0 +1,14 @@ +-- bactdb +-- Matthew R Dillon + +CREATE TABLE text_measurements ( + id BIGSERIAL NOT NULL, + text_measurement_name CHARACTER VARYING(100), + + created_at TIMESTAMP WITH TIME ZONE, + updated_at TIMESTAMP WITH TIME ZONE, + deleted_at TIMESTAMP WITH TIME ZONE, + + CONSTRAINT text_measurements_pkey PRIMARY KEY (id) +); + diff --git a/datastore/migrations/00009_AddUnit_Types_down.sql b/datastore/migrations/00009_AddUnit_Types_down.sql new file mode 100644 index 0000000..ffbb77c --- /dev/null +++ b/datastore/migrations/00009_AddUnit_Types_down.sql @@ -0,0 +1,5 @@ +-- bactdb +-- Matthew R Dillon + +DROP TABLE unit_types; + diff --git a/datastore/migrations/00009_AddUnit_Types_up.sql b/datastore/migrations/00009_AddUnit_Types_up.sql new file mode 100644 index 0000000..348242a --- /dev/null +++ b/datastore/migrations/00009_AddUnit_Types_up.sql @@ -0,0 +1,15 @@ +-- bactdb +-- Matthew R Dillon + +CREATE TABLE unit_types ( + id BIGSERIAL NOT NULL, + name CHARACTER VARYING(100), + symbol CHARACTER VARYING(10), + + created_at TIMESTAMP WITH TIME ZONE, + updated_at TIMESTAMP WITH TIME ZONE, + deleted_at TIMESTAMP WITH TIME ZONE, + + CONSTRAINT unit_types_pkey PRIMARY KEY (id) +); + diff --git a/datastore/migrations/00010_AddNumerical_Measurements_down.sql b/datastore/migrations/00010_AddNumerical_Measurements_down.sql new file mode 100644 index 0000000..b8fbc65 --- /dev/null +++ b/datastore/migrations/00010_AddNumerical_Measurements_down.sql @@ -0,0 +1,5 @@ +-- bactdb +-- Matthew R Dillon + +DROP TABLE numerical_measurements; + diff --git a/datastore/migrations/00010_AddNumerical_Measurements_up.sql b/datastore/migrations/00010_AddNumerical_Measurements_up.sql new file mode 100644 index 0000000..b1deda2 --- /dev/null +++ b/datastore/migrations/00010_AddNumerical_Measurements_up.sql @@ -0,0 +1,17 @@ +-- bactdb +-- Matthew R Dillon + +CREATE TABLE numerical_measurements ( + id BIGSERIAL NOT NULL, + measurement_value NUMERIC(6, 4) NOT NULL, + confidence_interval NUMERIC(6,4) NULL, + unit_type_id BIGINT, + + created_at TIMESTAMP WITH TIME ZONE, + updated_at TIMESTAMP WITH TIME ZONE, + deleted_at TIMESTAMP WITH TIME ZONE, + + CONSTRAINT numerical_measurements_pkey PRIMARY KEY (id), + FOREIGN KEY (unit_type_id) REFERENCES unit_types(id) +); + diff --git a/datastore/migrations/00011_AddStrainObsMeasurements_down.sql b/datastore/migrations/00011_AddStrainObsMeasurements_down.sql new file mode 100644 index 0000000..815b20d --- /dev/null +++ b/datastore/migrations/00011_AddStrainObsMeasurements_down.sql @@ -0,0 +1,5 @@ +-- bactdb +-- Matthew R Dillon + +DROP TABLE strainsobsmeasurements; + diff --git a/datastore/migrations/00011_AddStrainObsMeasurements_up.sql b/datastore/migrations/00011_AddStrainObsMeasurements_up.sql new file mode 100644 index 0000000..9abf103 --- /dev/null +++ b/datastore/migrations/00011_AddStrainObsMeasurements_up.sql @@ -0,0 +1,17 @@ +-- bactdb +-- Matthew R Dillon + +CREATE TABLE strainsobsmeasurements ( + id BIGSERIAL NOT NULL, + strainsobservations_id BIGINT, + measurement_table CHARACTER VARYING(15), + measurement_id BIGINT, + + created_at TIMESTAMP WITH TIME ZONE, + updated_at TIMESTAMP WITH TIME ZONE, + deleted_at TIMESTAMP WITH TIME ZONE, + + CONSTRAINT strainsobsmeasurements_pkey PRIMARY KEY (id), + FOREIGN KEY (strainsobservations_id) REFERENCES strainsobservations(id) +); + diff --git a/datastore/species.go b/datastore/species.go new file mode 100644 index 0000000..651c3e0 --- /dev/null +++ b/datastore/species.go @@ -0,0 +1,79 @@ +package datastore + +import "github.com/thermokarst/bactdb/models" + +func init() { + DB.AddTableWithName(models.Species{}, "species").SetKeys(true, "Id") +} + +type speciesStore struct { + *Datastore +} + +func (s *speciesStore) Get(id int64) (*models.Species, error) { + var species []*models.Species + if err := s.dbh.Select(&species, `SELECT * FROM species WHERE id=$1;`, id); err != nil { + return nil, err + } + if len(species) == 0 { + return nil, models.ErrSpeciesNotFound + } + return species[0], nil +} + +func (s *speciesStore) Create(species *models.Species) (bool, error) { + if err := s.dbh.Insert(species); err != nil { + return false, err + } + return true, nil +} + +func (s *speciesStore) List(opt *models.SpeciesListOptions) ([]*models.Species, error) { + if opt == nil { + opt = &models.SpeciesListOptions{} + } + var species []*models.Species + err := s.dbh.Select(&species, `SELECT * FROM species LIMIT $1 OFFSET $2;`, opt.PerPageOrDefault(), opt.Offset()) + if err != nil { + return nil, err + } + return species, nil +} + +func (s *speciesStore) Update(id int64, species *models.Species) (bool, error) { + _, err := s.Get(id) + if err != nil { + return false, err + } + + if id != species.Id { + return false, models.ErrSpeciesNotFound + } + + changed, err := s.dbh.Update(species) + if err != nil { + return false, err + } + + if changed == 0 { + return false, ErrNoRowsUpdated + } + + return true, nil +} + +func (s *speciesStore) Delete(id int64) (bool, error) { + species, err := s.Get(id) + if err != nil { + return false, err + } + + deleted, err := s.dbh.Delete(species) + if err != nil { + return false, err + } + if deleted == 0 { + return false, ErrNoRowsDeleted + } + return true, nil +} diff --git a/datastore/species_test.go b/datastore/species_test.go new file mode 100644 index 0000000..5839e95 --- /dev/null +++ b/datastore/species_test.go @@ -0,0 +1,172 @@ +package datastore + +import ( + "reflect" + "testing" + + "github.com/thermokarst/bactdb/models" +) + +func TestSpeciesStore_Get_db(t *testing.T) { + tx, _ := DB.Begin() + defer tx.Rollback() + + // Test on a clean database + tx.Exec(`DELETE FROM species;`) + tx.Exec(`DELETE FROM genera;`) + + wantGenus := &models.Genus{GenusName: "Test Genus"} + if err := tx.Insert(wantGenus); err != nil { + t.Fatal(err) + } + + want := &models.Species{Id: 1, GenusId: wantGenus.Id, SpeciesName: "Test Species"} + if err := tx.Insert(want); err != nil { + t.Fatal(err) + } + + d := NewDatastore(tx) + species, err := d.Species.Get(1) + if err != nil { + t.Fatal(err) + } + + normalizeTime(&want.CreatedAt, &want.UpdatedAt, &want.DeletedAt) + if !reflect.DeepEqual(species, want) { + t.Errorf("got species %+v, want %+v", species, want) + } +} + +func TestSpeciesStore_Create_db(t *testing.T) { + tx, _ := DB.Begin() + defer tx.Rollback() + + // Test on a clean database + tx.Exec(`DELETE FROM species;`) + tx.Exec(`DELETE FROM genera;`) + + genus := &models.Genus{} + if err := tx.Insert(genus); err != nil { + t.Fatal(err) + } + + species := &models.Species{Id: 1, GenusId: genus.Id, SpeciesName: "Test Species"} + + d := NewDatastore(tx) + created, err := d.Species.Create(species) + if err != nil { + t.Fatal(err) + } + + if !created { + t.Error("!created") + } + if species.Id == 0 { + t.Error("want nonzero species.Id after submitting") + } +} + +func TestSpeciesStore_List_db(t *testing.T) { + tx, _ := DB.Begin() + defer tx.Rollback() + + // Test on a clean database + tx.Exec(`DELETE FROM species;`) + tx.Exec(`DELETE FROM genera;`) + + genus := &models.Genus{} + + if err := tx.Insert(genus); err != nil { + t.Fatal(err) + } + + want := []*models.Species{{Id: 1, GenusId: genus.Id, SpeciesName: "Test Species"}} + + if err := tx.Insert(want[0]); err != nil { + t.Fatal(err) + } + + d := NewDatastore(tx) + species, err := d.Species.List(&models.SpeciesListOptions{ListOptions: models.ListOptions{Page: 1, PerPage: 10}}) + if err != nil { + t.Fatal(err) + } + + for _, g := range want { + normalizeTime(&g.CreatedAt, &g.UpdatedAt, &g.DeletedAt) + } + if !reflect.DeepEqual(species, want) { + t.Errorf("got species %+v, want %+v", species, want) + } +} + +func TestSpeciesStore_Update_db(t *testing.T) { + tx, _ := DB.Begin() + defer tx.Rollback() + + // Test on a clean database + tx.Exec(`DELETE FROM species;`) + tx.Exec(`DELETE FROM genera;`) + + d := NewDatastore(nil) + // Add a new record + genus := &models.Genus{GenusName: "Test Genus"} + _, err := d.Genera.Create(genus) + if err != nil { + t.Fatal(err) + } + species := &models.Species{GenusId: genus.Id, SpeciesName: "Test Species"} + created, err := d.Species.Create(species) + if err != nil { + t.Fatal(err) + } + if !created { + t.Error("!created") + } + + // Tweak it + species.SpeciesName = "Updated Species" + updated, err := d.Species.Update(species.Id, species) + if err != nil { + t.Fatal(err) + } + + if !updated { + t.Error("!updated") + } +} + +func TestSpeciesStore_Delete_db(t *testing.T) { + tx, _ := DB.Begin() + defer tx.Rollback() + + // Test on a clean database + tx.Exec(`DELETE FROM species;`) + tx.Exec(`DELETE FROM genera;`) + + d := NewDatastore(tx) + // Add a new record + genus := &models.Genus{GenusName: "Test Genus"} + _, err := d.Genera.Create(genus) + if err != nil { + t.Fatal(err) + } + species := &models.Species{GenusId: genus.Id, SpeciesName: "Test Species"} + created, err := d.Species.Create(species) + if err != nil { + t.Fatal(err) + } + if !created { + t.Error("!created") + } + + // Delete it + deleted, err := d.Species.Delete(species.Id) + if err != nil { + t.Fatal(err) + } + + if !deleted { + t.Error("!delete") + } +} diff --git a/models/client.go b/models/client.go index bb321e7..b94f3f1 100644 --- a/models/client.go +++ b/models/client.go @@ -16,8 +16,9 @@ import ( // A Client communicates with bactdb's HTTP API. type Client struct { - Users UsersService - Genera GeneraService + Users UsersService + Genera GeneraService + Species SpeciesService // BaseURL for HTTP requests to bactdb's API. BaseURL *url.URL @@ -47,6 +48,7 @@ func NewClient(httpClient *http.Client) *Client { } c.Users = &usersService{c} c.Genera = &generaService{c} + c.Species = &speciesService{c} return c } diff --git a/models/genera.go b/models/genera.go index fb87694..60cc2b0 100644 --- a/models/genera.go +++ b/models/genera.go @@ -12,10 +12,10 @@ import ( // A Genus is a high-level classifier in bactdb. type Genus struct { Id int64 `json:"id,omitempty"` - GenusName string `json:"genus_name"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt time.Time `json:"deleted_at"` + GenusName string `db:"genus_name" json:"genus_name"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + DeletedAt time.Time `db:"deleted_at" json:"deleted_at"` } // GeneraService interacts with the genus-related endpoints in bactdb's API. diff --git a/models/species.go b/models/species.go new file mode 100644 index 0000000..19b69fb --- /dev/null +++ b/models/species.go @@ -0,0 +1,200 @@ +package models + +import ( + "errors" + "net/http" + "strconv" + "time" + + "github.com/thermokarst/bactdb/router" +) + +// A Species is a high-level classifier in bactdb. +type Species struct { + Id int64 `json:"id,omitempty"` + GenusId int64 `db:"genus_id" json:"genus_id"` + SpeciesName string `db:"species_name" json:"species_name"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + DeletedAt time.Time `db:"deleted_at" json:"deleted_at"` +} + +// SpeciesService interacts with the species-related endpoints in bactdb's API. +type SpeciesService interface { + // Get a species + Get(id int64) (*Species, error) + + // List all species + List(opt *SpeciesListOptions) ([]*Species, error) + + // Create a species record + Create(species *Species) (bool, error) + + // Update an existing species + Update(id int64, species *Species) (updated bool, err error) + + // Delete an existing species + Delete(id int64) (deleted bool, err error) +} + +var ( + ErrSpeciesNotFound = errors.New("species not found") +) + +type speciesService struct { + client *Client +} + +func (s *speciesService) Get(id int64) (*Species, error) { + // Pass in key value pairs as strings, sp that the gorilla mux URL generation is happy + strId := strconv.FormatInt(id, 10) + + url, err := s.client.url(router.Species, map[string]string{"Id": strId}, nil) + if err != nil { + return nil, err + } + + req, err := s.client.NewRequest("GET", url.String(), nil) + if err != nil { + return nil, err + } + + var species *Species + _, err = s.client.Do(req, &species) + if err != nil { + return nil, err + } + + return species, nil +} + +func (s *speciesService) Create(species *Species) (bool, error) { + url, err := s.client.url(router.CreateSpecies, nil, nil) + if err != nil { + return false, err + } + + req, err := s.client.NewRequest("POST", url.String(), species) + if err != nil { + return false, err + } + + resp, err := s.client.Do(req, &species) + if err != nil { + return false, err + } + + return resp.StatusCode == http.StatusCreated, nil +} + +type SpeciesListOptions struct { + ListOptions +} + +func (s *speciesService) List(opt *SpeciesListOptions) ([]*Species, error) { + url, err := s.client.url(router.SpeciesList, nil, opt) + if err != nil { + return nil, err + } + + req, err := s.client.NewRequest("GET", url.String(), nil) + if err != nil { + return nil, err + } + + var species []*Species + _, err = s.client.Do(req, &species) + if err != nil { + return nil, err + } + + return species, nil +} + +func (s *speciesService) Update(id int64, species *Species) (bool, error) { + strId := strconv.FormatInt(id, 10) + + url, err := s.client.url(router.UpdateSpecies, map[string]string{"Id": strId}, nil) + if err != nil { + return false, err + } + + req, err := s.client.NewRequest("PUT", url.String(), species) + if err != nil { + return false, err + } + + resp, err := s.client.Do(req, &species) + if err != nil { + return false, err + } + + return resp.StatusCode == http.StatusOK, nil +} + +func (s *speciesService) Delete(id int64) (bool, error) { + strId := strconv.FormatInt(id, 10) + + url, err := s.client.url(router.DeleteSpecies, map[string]string{"Id": strId}, nil) + if err != nil { + return false, err + } + + req, err := s.client.NewRequest("DELETE", url.String(), nil) + if err != nil { + return false, err + } + + var species *Species + resp, err := s.client.Do(req, &species) + if err != nil { + return false, err + } + + return resp.StatusCode == http.StatusOK, nil +} + +type MockSpeciesService struct { + Get_ func(id int64) (*Species, error) + List_ func(opt *SpeciesListOptions) ([]*Species, error) + Create_ func(species *Species) (bool, error) + Update_ func(id int64, species *Species) (bool, error) + Delete_ func(id int64) (bool, error) +} + +var _ SpeciesService = &MockSpeciesService{} + +func (s *MockSpeciesService) Get(id int64) (*Species, error) { + if s.Get_ == nil { + return nil, nil + } + return s.Get_(id) +} + +func (s *MockSpeciesService) Create(species *Species) (bool, error) { + if s.Create_ == nil { + return false, nil + } + return s.Create_(species) +} + +func (s *MockSpeciesService) List(opt *SpeciesListOptions) ([]*Species, error) { + if s.List_ == nil { + return nil, nil + } + return s.List_(opt) +} + +func (s *MockSpeciesService) Update(id int64, species *Species) (bool, error) { + if s.Update_ == nil { + return false, nil + } + return s.Update_(id, species) +} + +func (s *MockSpeciesService) Delete(id int64) (bool, error) { + if s.Delete_ == nil { + return false, nil + } + return s.Delete_(id) +} diff --git a/models/species_test.go b/models/species_test.go new file mode 100644 index 0000000..f2ef1e9 --- /dev/null +++ b/models/species_test.go @@ -0,0 +1,167 @@ +package models + +import ( + "net/http" + "reflect" + "testing" + + "github.com/thermokarst/bactdb/router" +) + +func TestSpeciesService_Get(t *testing.T) { + setup() + defer teardown() + + want := &Species{Id: 1, GenusId: 1, SpeciesName: "Test Species"} + + var called bool + mux.HandleFunc(urlPath(t, router.Species, map[string]string{"Id": "1"}), func(w http.ResponseWriter, r *http.Request) { + called = true + testMethod(t, r, "GET") + + writeJSON(w, want) + }) + + species, err := client.Species.Get(1) + if err != nil { + t.Errorf("Species.Get returned error: %v", err) + } + + if !called { + t.Fatal("!called") + } + + normalizeTime(&want.CreatedAt, &want.UpdatedAt, &want.DeletedAt) + + if !reflect.DeepEqual(species, want) { + t.Errorf("Species.Get returned %+v, want %+v", species, want) + } +} + +func TestSpeciesService_Create(t *testing.T) { + setup() + defer teardown() + + want := &Species{Id: 1, GenusId: 1, SpeciesName: "Test Species"} + + var called bool + mux.HandleFunc(urlPath(t, router.CreateSpecies, nil), func(w http.ResponseWriter, r *http.Request) { + called = true + testMethod(t, r, "POST") + testBody(t, r, `{"id":1,"genus_id":1,"species_name":"Test Species","created_at":"0001-01-01T00:00:00Z","updated_at":"0001-01-01T00:00:00Z","deleted_at":"0001-01-01T00:00:00Z"}`+"\n") + + w.WriteHeader(http.StatusCreated) + writeJSON(w, want) + }) + + species := &Species{Id: 1, GenusId: 1, SpeciesName: "Test Species"} + created, err := client.Species.Create(species) + if err != nil { + t.Errorf("Species.Create returned error: %v", err) + } + + if !created { + t.Error("!created") + } + + if !called { + t.Fatal("!called") + } + + normalizeTime(&want.CreatedAt, &want.UpdatedAt, &want.DeletedAt) + if !reflect.DeepEqual(species, want) { + t.Errorf("Species.Create returned %+v, want %+v", species, want) + } +} + +func TestSpeciesService_List(t *testing.T) { + setup() + defer teardown() + + want := []*Species{{Id: 1, GenusId: 1, SpeciesName: "Test Species"}} + + var called bool + mux.HandleFunc(urlPath(t, router.SpeciesList, nil), func(w http.ResponseWriter, r *http.Request) { + called = true + testMethod(t, r, "GET") + testFormValues(t, r, values{}) + + writeJSON(w, want) + }) + + species, err := client.Species.List(nil) + if err != nil { + t.Errorf("Species.List returned error: %v", err) + } + + if !called { + t.Fatal("!called") + } + + for _, u := range want { + normalizeTime(&u.CreatedAt, &u.UpdatedAt, &u.DeletedAt) + } + if !reflect.DeepEqual(species, want) { + t.Errorf("Species.List return %+v, want %+v", species, want) + } +} + +func TestSpeciesService_Update(t *testing.T) { + setup() + defer teardown() + + want := &Species{Id: 1, GenusId: 1, SpeciesName: "Test Species"} + + var called bool + mux.HandleFunc(urlPath(t, router.UpdateSpecies, map[string]string{"Id": "1"}), func(w http.ResponseWriter, r *http.Request) { + called = true + testMethod(t, r, "PUT") + testBody(t, r, `{"id":1,"genus_id":1,"species_name":"Test Species Updated","created_at":"0001-01-01T00:00:00Z","updated_at":"0001-01-01T00:00:00Z","deleted_at":"0001-01-01T00:00:00Z"}`+"\n") + + w.WriteHeader(http.StatusOK) + writeJSON(w, want) + }) + + species := &Species{Id: 1, GenusId: 1, SpeciesName: "Test Species Updated"} + updated, err := client.Species.Update(1, species) + if err != nil { + t.Errorf("Species.Update returned error: %v", err) + } + + if !updated { + t.Error("!updated") + } + + if !called { + t.Fatal("!called") + } +} + +func TestSpeciesService_Delete(t *testing.T) { + setup() + defer teardown() + + want := &Species{Id: 1, GenusId: 1, SpeciesName: "Test Species"} + + var called bool + mux.HandleFunc(urlPath(t, router.DeleteSpecies, map[string]string{"Id": "1"}), func(w http.ResponseWriter, r *http.Request) { + called = true + testMethod(t, r, "DELETE") + + w.WriteHeader(http.StatusOK) + writeJSON(w, want) + }) + + deleted, err := client.Species.Delete(1) + if err != nil { + t.Errorf("Species.Delete returned error: %v", err) + } + + if !deleted { + t.Error("!deleted") + } + + if !called { + t.Fatal("!called") + } +} diff --git a/models/users.go b/models/users.go index e398295..d7a526d 100644 --- a/models/users.go +++ b/models/users.go @@ -12,10 +12,10 @@ import ( // A User is a person that has administrative access to bactdb. type User struct { Id int64 `json:"id,omitempty"` - UserName string `sql:"size:100" json:"user_name"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt time.Time `json:"deleted_at"` + UserName string `json:"user_name"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + DeletedAt time.Time `db:"deleted_at" json:"deleted_at"` } // UsersService interacts with the user-related endpoints in bactdb's API. diff --git a/router/api.go b/router/api.go index 1df34ee..a7731ab 100644 --- a/router/api.go +++ b/router/api.go @@ -16,5 +16,13 @@ func API() *mux.Router { m.Path("/genera/{Id:.+}").Methods("GET").Name(Genus) m.Path("/genera/{Id:.+}").Methods("PUT").Name(UpdateGenus) m.Path("/genera/{Id:.+}").Methods("DELETE").Name(DeleteGenus) + + // Species + m.Path("/species").Methods("GET").Name(SpeciesList) + m.Path("/species").Methods("POST").Name(CreateSpecies) + m.Path("/species/{Id:.+}").Methods("GET").Name(Species) + m.Path("/species/{Id:.+}").Methods("PUT").Name(UpdateSpecies) + m.Path("/species/{Id:.+}").Methods("DELETE").Name(DeleteSpecies) + return m } diff --git a/router/routes.go b/router/routes.go index e3bb7bb..124dd98 100644 --- a/router/routes.go +++ b/router/routes.go @@ -10,4 +10,10 @@ const ( Genera = "genera" UpdateGenus = "genus:update" DeleteGenus = "genus:delete" + + Species = "species" + CreateSpecies = "species:create" + SpeciesList = "species:list" + UpdateSpecies = "species:update" + DeleteSpecies = "species:delete" )