From cdd7df8e628d32237bdcd76adf53b3676e518b18 Mon Sep 17 00:00:00 2001 From: Michael Mayer Date: Sun, 21 Nov 2021 14:05:07 +0100 Subject: [PATCH] Database: Add manual schema migrations #319 --- internal/commands/migrate.go | 4 +- internal/entity/cell.go | 25 +-- internal/entity/db_fixtures.go | 23 +++ internal/entity/db_migrate.go | 37 ++++ internal/entity/db_tables.go | 111 ++++++++++++ internal/entity/entity.go | 178 -------------------- internal/entity/file.go | 2 +- internal/entity/values.go | 9 + internal/migrate/auto.go | 31 ++++ internal/migrate/dialect_mysql.go | 10 ++ internal/migrate/dialect_sqlite.go | 10 ++ internal/migrate/dialects.go | 12 ++ internal/migrate/generate.go | 93 ++++++++++ internal/migrate/migrate.go | 44 +++++ internal/migrate/migration.go | 57 +++++++ internal/migrate/migrations.go | 13 ++ internal/migrate/mysql/20211121-094727.sql | 1 + internal/migrate/sqlite/20211121-094727.sql | 1 + internal/query/query.go | 2 +- 19 files changed, 470 insertions(+), 193 deletions(-) create mode 100644 internal/entity/db_fixtures.go create mode 100644 internal/entity/db_migrate.go create mode 100644 internal/entity/db_tables.go create mode 100644 internal/migrate/auto.go create mode 100644 internal/migrate/dialect_mysql.go create mode 100644 internal/migrate/dialect_sqlite.go create mode 100644 internal/migrate/dialects.go create mode 100644 internal/migrate/generate.go create mode 100644 internal/migrate/migrate.go create mode 100644 internal/migrate/migration.go create mode 100644 internal/migrate/migrations.go create mode 100644 internal/migrate/mysql/20211121-094727.sql create mode 100644 internal/migrate/sqlite/20211121-094727.sql diff --git a/internal/commands/migrate.go b/internal/commands/migrate.go index 7d14e1be9..97d543a2c 100644 --- a/internal/commands/migrate.go +++ b/internal/commands/migrate.go @@ -29,13 +29,13 @@ func migrateAction(ctx *cli.Context) error { return err } - log.Infoln("migrating database") + log.Infoln("migrating database schema...") conf.InitDb() elapsed := time.Since(start) - log.Infof("database migration completed in %s", elapsed) + log.Infof("migration completed in %s", elapsed) conf.Shutdown() diff --git a/internal/entity/cell.go b/internal/entity/cell.go index 80d205620..689a157f9 100644 --- a/internal/entity/cell.go +++ b/internal/entity/cell.go @@ -98,13 +98,14 @@ func (m *Cell) Refresh(api string) (err error) { PhotoCount: 1, } + m.Place = &place + m.PlaceID = l.PlaceID() + // Create or update place. if err = place.Save(); err != nil { - log.Warnf("place: failed updating %s [%s]", place.ID, time.Since(start)) + log.Errorf("index: %s while saving place %s", err, place.ID) } else { - m.Place = &place - m.PlaceID = l.PlaceID() - log.Tracef("place: updated %s [%s]", place.ID, time.Since(start)) + log.Tracef("index: updated place %s", place.ID) } m.CellName = l.Name() @@ -116,16 +117,18 @@ func (m *Cell) Refresh(api string) (err error) { err = m.Save() if err != nil { - log.Warnf("place: failed updating %s [%s]", m.ID, time.Since(start)) + log.Errorf("index: %s while updating cell %s [%s]", err, m.ID, time.Since(start)) return err - } else if oldPlaceID != m.PlaceID { - err = UnscopedDb().Table(Photo{}.TableName()). - Where("place_id = ?", oldPlaceID). - UpdateColumn("place_id", m.PlaceID). - Error + } else if oldPlaceID == m.PlaceID { + log.Tracef("index: cell %s keeps place_id %s", m.ID, m.PlaceID) + } else if err := UnscopedDb().Table(Photo{}.TableName()). + Where("place_id = ?", oldPlaceID). + UpdateColumn("place_id", m.PlaceID). + Error; err != nil { + log.Warnf("index: %s while changing place_id from %s to %s", err, oldPlaceID, m.PlaceID) } - log.Debugf("place: updated %s [%s]", m.ID, time.Since(start)) + log.Debugf("index: updated cell %s [%s]", m.ID, time.Since(start)) return err } diff --git a/internal/entity/db_fixtures.go b/internal/entity/db_fixtures.go new file mode 100644 index 000000000..9072c0335 --- /dev/null +++ b/internal/entity/db_fixtures.go @@ -0,0 +1,23 @@ +package entity + +// CreateDefaultFixtures inserts default fixtures for test and production. +func CreateDefaultFixtures() { + CreateUnknownAddress() + CreateDefaultUsers() + CreateUnknownPlace() + CreateUnknownLocation() + CreateUnknownCountry() + CreateUnknownCamera() + CreateUnknownLens() +} + +// ResetTestFixtures re-creates registered database tables and inserts test fixtures. +func ResetTestFixtures() { + Entities.Migrate() + Entities.WaitForMigration() + Entities.Truncate() + + CreateDefaultFixtures() + + CreateTestFixtures() +} diff --git a/internal/entity/db_migrate.go b/internal/entity/db_migrate.go new file mode 100644 index 000000000..bc1d71a7a --- /dev/null +++ b/internal/entity/db_migrate.go @@ -0,0 +1,37 @@ +package entity + +// MigrateDb creates database tables and inserts default fixtures as needed. +func MigrateDb(dropDeprecated bool) { + if dropDeprecated { + DeprecatedTables.Drop() + } + + Entities.Migrate() + Entities.WaitForMigration() + + CreateDefaultFixtures() +} + +// InitTestDb connects to and completely initializes the test database incl fixtures. +func InitTestDb(driver, dsn string) *Gorm { + if HasDbProvider() { + return nil + } + + if driver == "test" || driver == "sqlite" || driver == "" || dsn == "" { + driver = "sqlite3" + dsn = ".test.db" + } + + log.Infof("initializing %s test db in %s", driver, dsn) + + db := &Gorm{ + Driver: driver, + Dsn: dsn, + } + + SetDbProvider(db) + ResetTestFixtures() + + return db +} diff --git a/internal/entity/db_tables.go b/internal/entity/db_tables.go new file mode 100644 index 000000000..d60b85482 --- /dev/null +++ b/internal/entity/db_tables.go @@ -0,0 +1,111 @@ +package entity + +import ( + "fmt" + "time" + + "github.com/photoprism/photoprism/internal/migrate" + "github.com/photoprism/photoprism/pkg/txt" +) + +type Tables map[string]interface{} + +// Entities contains database entities and their table names. +var Entities = Tables{ + migrate.Migration{}.TableName(): &migrate.Migration{}, + "errors": &Error{}, + "addresses": &Address{}, + "users": &User{}, + "accounts": &Account{}, + "folders": &Folder{}, + "duplicates": &Duplicate{}, + File{}.TableName(): &File{}, + "files_share": &FileShare{}, + "files_sync": &FileSync{}, + Photo{}.TableName(): &Photo{}, + "details": &Details{}, + Place{}.TableName(): &Place{}, + Cell{}.TableName(): &Cell{}, + "cameras": &Camera{}, + "lenses": &Lens{}, + "countries": &Country{}, + "albums": &Album{}, + "photos_albums": &PhotoAlbum{}, + "labels": &Label{}, + "categories": &Category{}, + "photos_labels": &PhotoLabel{}, + "keywords": &Keyword{}, + "photos_keywords": &PhotoKeyword{}, + "passwords": &Password{}, + "links": &Link{}, + Subject{}.TableName(): &Subject{}, + Face{}.TableName(): &Face{}, + Marker{}.TableName(): &Marker{}, +} + +// WaitForMigration waits for the database migration to be successful. +func (list Tables) WaitForMigration() { + type RowCount struct { + Count int + } + + attempts := 100 + for name := range list { + for i := 0; i <= attempts; i++ { + count := RowCount{} + if err := Db().Raw(fmt.Sprintf("SELECT COUNT(*) AS count FROM %s", name)).Scan(&count).Error; err == nil { + log.Tracef("entity: %s migrated", txt.Quote(name)) + break + } else { + log.Debugf("entity: waiting for %s migration (%s)", txt.Quote(name), err.Error()) + } + + if i == attempts { + panic("migration failed") + } + + time.Sleep(50 * time.Millisecond) + } + } +} + +// Truncate removes all data from tables without dropping them. +func (list Tables) Truncate() { + for name := range list { + if err := Db().Exec(fmt.Sprintf("DELETE FROM %s WHERE 1", name)).Error; err == nil { + // log.Debugf("entity: removed all data from %s", name) + break + } else if err.Error() != "record not found" { + log.Debugf("entity: %s in %s", err, txt.Quote(name)) + } + } +} + +// Migrate migrates all database tables of registered entities. +func (list Tables) Migrate() { + for name, entity := range list { + if err := UnscopedDb().AutoMigrate(entity).Error; err != nil { + log.Debugf("entity: %s (waiting 1s)", err.Error()) + + time.Sleep(time.Second) + + if err := UnscopedDb().AutoMigrate(entity).Error; err != nil { + log.Errorf("entity: failed migrating %s", txt.Quote(name)) + panic(err) + } + } + } + + if err := migrate.Auto(Db()); err != nil { + log.Error(err) + } +} + +// Drop drops all database tables of registered entities. +func (list Tables) Drop() { + for _, entity := range list { + if err := UnscopedDb().DropTableIfExists(entity).Error; err != nil { + panic(err) + } + } +} diff --git a/internal/entity/entity.go b/internal/entity/entity.go index a58aabcd8..8bf24063d 100644 --- a/internal/entity/entity.go +++ b/internal/entity/entity.go @@ -10,11 +10,6 @@ https://github.com/photoprism/photoprism/wiki/Storage package entity import ( - "fmt" - "time" - - "github.com/photoprism/photoprism/pkg/txt" - "github.com/jinzhu/gorm" "github.com/photoprism/photoprism/internal/event" ) @@ -28,176 +23,3 @@ func logError(result *gorm.DB) { log.Error(result.Error.Error()) } } - -// TypeString returns an entity type string for logging. -func TypeString(entityType string) string { - if entityType == "" { - return "unknown" - } - - return entityType -} - -type Types map[string]interface{} - -// Entities contains database entities and their table names. -var Entities = Types{ - "errors": &Error{}, - "addresses": &Address{}, - "users": &User{}, - "accounts": &Account{}, - "folders": &Folder{}, - "duplicates": &Duplicate{}, - "files": &File{}, - "files_share": &FileShare{}, - "files_sync": &FileSync{}, - "photos": &Photo{}, - "details": &Details{}, - "places": &Place{}, - "cells": &Cell{}, - "cameras": &Camera{}, - "lenses": &Lens{}, - "countries": &Country{}, - "albums": &Album{}, - "photos_albums": &PhotoAlbum{}, - "labels": &Label{}, - "categories": &Category{}, - "photos_labels": &PhotoLabel{}, - "keywords": &Keyword{}, - "photos_keywords": &PhotoKeyword{}, - "passwords": &Password{}, - "links": &Link{}, - Subject{}.TableName(): &Subject{}, - Face{}.TableName(): &Face{}, - Marker{}.TableName(): &Marker{}, -} - -type RowCount struct { - Count int -} - -// WaitForMigration waits for the database migration to be successful. -func (list Types) WaitForMigration() { - attempts := 100 - for name := range list { - for i := 0; i <= attempts; i++ { - count := RowCount{} - if err := Db().Raw(fmt.Sprintf("SELECT COUNT(*) AS count FROM %s", name)).Scan(&count).Error; err == nil { - log.Tracef("entity: %s migrated", txt.Quote(name)) - break - } else { - log.Debugf("entity: waiting for %s migration (%s)", txt.Quote(name), err.Error()) - } - - if i == attempts { - panic("migration failed") - } - - time.Sleep(50 * time.Millisecond) - } - } -} - -// Truncate removes all data from tables without dropping them. -func (list Types) Truncate() { - for name := range list { - if err := Db().Exec(fmt.Sprintf("DELETE FROM %s WHERE 1", name)).Error; err == nil { - // log.Debugf("entity: removed all data from %s", name) - break - } else if err.Error() != "record not found" { - log.Debugf("entity: %s in %s", err, txt.Quote(name)) - } - } -} - -// Migrate migrates all database tables of registered entities. -func (list Types) Migrate() { - for name, entity := range list { - if err := UnscopedDb().AutoMigrate(entity).Error; err != nil { - log.Debugf("entity: %s (waiting 1s)", err.Error()) - - time.Sleep(time.Second) - - if err := UnscopedDb().AutoMigrate(entity).Error; err != nil { - log.Errorf("entity: failed migrating %s", txt.Quote(name)) - panic(err) - } - } - } -} - -// Drop drops all database tables of registered entities. -func (list Types) Drop() { - for _, entity := range list { - if err := UnscopedDb().DropTableIfExists(entity).Error; err != nil { - panic(err) - } - } -} - -// CreateDefaultFixtures inserts default fixtures for test and production. -func CreateDefaultFixtures() { - CreateUnknownAddress() - CreateDefaultUsers() - CreateUnknownPlace() - CreateUnknownLocation() - CreateUnknownCountry() - CreateUnknownCamera() - CreateUnknownLens() -} - -// MigrateIndexes runs additional table index migration queries. -func MigrateIndexes() { - if err := Db().Exec("DROP INDEX IF EXISTS idx_places_place_label ON places").Error; err != nil { - log.Errorf("%s: %s (drop index)", DbDialect(), err) - } -} - -// MigrateDb creates database tables and inserts default fixtures as needed. -func MigrateDb(dropDeprecated bool) { - if dropDeprecated { - DeprecatedTables.Drop() - } - - Entities.Migrate() - Entities.WaitForMigration() - - MigrateIndexes() - - CreateDefaultFixtures() -} - -// ResetTestFixtures re-creates registered database tables and inserts test fixtures. -func ResetTestFixtures() { - Entities.Migrate() - Entities.WaitForMigration() - Entities.Truncate() - - CreateDefaultFixtures() - - CreateTestFixtures() -} - -// InitTestDb connects to and completely initializes the test database incl fixtures. -func InitTestDb(driver, dsn string) *Gorm { - if HasDbProvider() { - return nil - } - - if driver == "test" || driver == "sqlite" || driver == "" || dsn == "" { - driver = "sqlite3" - dsn = ".test.db" - } - - log.Infof("initializing %s test db in %s", driver, dsn) - - db := &Gorm{ - Driver: driver, - Dsn: dsn, - } - - SetDbProvider(db) - ResetTestFixtures() - - return db -} diff --git a/internal/entity/file.go b/internal/entity/file.go index a23551e9a..82dcdebc2 100644 --- a/internal/entity/file.go +++ b/internal/entity/file.go @@ -252,7 +252,7 @@ func (m *File) ReplaceHash(newHash string) error { return nil } - entities := Types{ + entities := Tables{ "albums": Album{}, "labels": Label{}, } diff --git a/internal/entity/values.go b/internal/entity/values.go index 543eb1823..26247bccc 100644 --- a/internal/entity/values.go +++ b/internal/entity/values.go @@ -71,3 +71,12 @@ func Trim(s string, maxLen int) string { func SanitizeTypeString(s string) string { return Trim(ToASCII(strings.ToLower(s)), TrimTypeString) } + +// TypeString returns an entity type string for logging. +func TypeString(entityType string) string { + if entityType == "" { + return "unknown" + } + + return entityType +} diff --git a/internal/migrate/auto.go b/internal/migrate/auto.go new file mode 100644 index 000000000..4f45e08cb --- /dev/null +++ b/internal/migrate/auto.go @@ -0,0 +1,31 @@ +package migrate + +import ( + "fmt" + + "github.com/jinzhu/gorm" +) + +// Auto automatically migrates the database provided. +func Auto(db *gorm.DB) error { + if db == nil { + return fmt.Errorf("migrate: database connection required") + } + + name := db.Dialect().GetName() + + if name == "" { + return fmt.Errorf("migrate: database has no dialect name") + } + + if err := db.AutoMigrate(&Migration{}).Error; err != nil { + return fmt.Errorf("migrate: %s (create migrations table)", err) + } + + if migrations, ok := Dialects[name]; ok && len(migrations) > 0 { + migrations.Start(db) + return nil + } else { + return fmt.Errorf("migrate: no migrations found for %s", name) + } +} diff --git a/internal/migrate/dialect_mysql.go b/internal/migrate/dialect_mysql.go new file mode 100644 index 000000000..4331d0c99 --- /dev/null +++ b/internal/migrate/dialect_mysql.go @@ -0,0 +1,10 @@ +// Code generated by go generate; DO NOT EDIT. +package migrate + +var DialectMySQL = Migrations{ + { + ID: "20211121-094727", + Dialect: "mysql", + Query: "DROP INDEX IF EXISTS uix_places_place_label ON places;", + }, +} diff --git a/internal/migrate/dialect_sqlite.go b/internal/migrate/dialect_sqlite.go new file mode 100644 index 000000000..f48cd8844 --- /dev/null +++ b/internal/migrate/dialect_sqlite.go @@ -0,0 +1,10 @@ +// Code generated by go generate; DO NOT EDIT. +package migrate + +var DialectSQLite = Migrations{ + { + ID: "20211121-094727", + Dialect: "sqlite", + Query: "DROP INDEX IF EXISTS idx_places_place_label ON places;", + }, +} diff --git a/internal/migrate/dialects.go b/internal/migrate/dialects.go new file mode 100644 index 000000000..4ab89a1ad --- /dev/null +++ b/internal/migrate/dialects.go @@ -0,0 +1,12 @@ +package migrate + +// Supported database dialects. +const ( + MySQL = "mysql" + SQLite = "sqlite3" +) + +var Dialects = map[string]Migrations{ + MySQL: DialectMySQL, + SQLite: DialectSQLite, +} diff --git a/internal/migrate/generate.go b/internal/migrate/generate.go new file mode 100644 index 000000000..4fcc06e47 --- /dev/null +++ b/internal/migrate/generate.go @@ -0,0 +1,93 @@ +//go:build ignore +// +build ignore + +// This generates countries.go by running "go generate" +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "text/template" +) + +func gen_migrations(name string) { + if name == "" { + return + } + + dialect := strings.ToLower(name) + + type Migration struct { + ID string + Dialect string + Query string + } + + var migrations []Migration + + // Folder in which migration files are stored. + folder := "./" + dialect + + // Returns directory entries sorted by filename. + files, _ := os.ReadDir(folder) + + fmt.Printf("generating %s...", dialect) + + // Read migrations from files. + for _, file := range files { + filePath := filepath.Join(folder, file.Name()) + + if file.IsDir() { + continue + } else if id := strings.SplitN(filepath.Base(file.Name()), ".", 2)[0]; id == "" { + fmt.Printf("e") + // Ignore. + } else if query, err := os.ReadFile(filePath); err == nil && len(query) > 0 { + fmt.Printf(".") + migrations = append(migrations, Migration{ID: id, Dialect: dialect, Query: string(query)}) + } else { + fmt.Printf("f") + fmt.Println(err.Error()) + } + } + + fmt.Printf(" found %d migrations\n", len(migrations)) + + // Create source file from migrations. + f, err := os.Create(fmt.Sprintf("dialect_%s.go", dialect)) + + if err != nil { + panic(err) + } + + defer f.Close() + + // Render source template. + migrationsTemplate.Execute(f, struct { + Name string + Migrations []Migration + }{ + Name: name, + Migrations: migrations, + }) +} + +func main() { + gen_migrations("MySQL") + gen_migrations("SQLite") +} + +var migrationsTemplate = template.Must(template.New("").Parse(`// Code generated by go generate; DO NOT EDIT. +package migrate + +var Dialect{{ print .Name }} = Migrations{ +{{- range .Migrations }} + { + ID: {{ printf "%q" .ID }}, + Dialect: {{ printf "%q" .Dialect }}, + Query: {{ printf "%q" .Query }}, + }, +{{- end }} +}`)) diff --git a/internal/migrate/migrate.go b/internal/migrate/migrate.go new file mode 100644 index 000000000..86ed35006 --- /dev/null +++ b/internal/migrate/migrate.go @@ -0,0 +1,44 @@ +/* + +Package migrate provides database schema migrations. + +Copyright (c) 2018 - 2021 Michael Mayer + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + + PhotoPrism® is a registered trademark of Michael Mayer. You may use it as required + to describe our software, run your own server, for educational purposes, but not for + offering commercial goods, products, or services without prior written permission. + In other words, please ask. + +Feel free to send an e-mail to hello@photoprism.org if you have questions, +want to support our work, or just want to say hello. + +Additional information can be found in our Developer Guide: +https://docs.photoprism.org/developer-guide/ + +*/ +package migrate + +//go:generate go run generate.go +//go:generate go fmt . + +import ( + "github.com/photoprism/photoprism/internal/event" +) + +var log = event.Log + +// Values is a shortcut for map[string]interface{} +type Values map[string]interface{} diff --git a/internal/migrate/migration.go b/internal/migrate/migration.go new file mode 100644 index 000000000..0229933a4 --- /dev/null +++ b/internal/migrate/migration.go @@ -0,0 +1,57 @@ +package migrate + +import ( + "time" + + "github.com/jinzhu/gorm" +) + +// Migration represents a database schema migration. +type Migration struct { + ID string `gorm:"size:16;primary_key;auto_increment:false;" json:"ID" yaml:"ID"` + Dialect string `gorm:"size:16;" json:"Dialect" yaml:"Dialect,omitempty"` + Error string `gorm:"size:255;" json:"Error" yaml:"Error,omitempty"` + Source string `gorm:"size:16;" json:"Source" yaml:"Source,omitempty"` + Query string `gorm:"-" json:"Query" yaml:"Query,omitempty"` + StartedAt time.Time `json:"StartedAt" yaml:"StartedAt,omitempty"` + FinishedAt *time.Time `json:"FinishedAt" yaml:"FinishedAt,omitempty"` +} + +// TableName returns the entity database table name. +func (Migration) TableName() string { + return "migrations" +} + +// Fail marks the migration as failed by adding an error message. +func (m *Migration) Fail(err error, db *gorm.DB) { + if err == nil { + return + } + + m.Error = err.Error() + db.Model(m).Updates(Values{"Error": m.Error}) +} + +// Finish updates the FinishedAt timestamp when the migration was successful. +func (m *Migration) Finish(db *gorm.DB) { + db.Model(m).Updates(Values{"FinishedAt": time.Now().UTC()}) +} + +// Execute runs the migration. +func (m *Migration) Execute(db *gorm.DB) { + start := time.Now() + + m.StartedAt = start.UTC().Round(time.Second) + + if err := db.Create(m).Error; err != nil { + return + } + + if err := db.Exec(m.Query).Error; err != nil { + m.Fail(err, db) + log.Errorf("migration %s failed: %s [%s]", m.ID, err, time.Since(start)) + } else { + m.Finish(db) + log.Infof("migration %s successful [%s]", m.ID, time.Since(start)) + } +} diff --git a/internal/migrate/migrations.go b/internal/migrate/migrations.go new file mode 100644 index 000000000..b8710f9cb --- /dev/null +++ b/internal/migrate/migrations.go @@ -0,0 +1,13 @@ +package migrate + +import "github.com/jinzhu/gorm" + +// Migrations represents a sorted list of migrations. +type Migrations []Migration + +// Start runs all migrations that haven't been executed yet. +func (m *Migrations) Start(db *gorm.DB) { + for _, migration := range *m { + migration.Execute(db) + } +} diff --git a/internal/migrate/mysql/20211121-094727.sql b/internal/migrate/mysql/20211121-094727.sql new file mode 100644 index 000000000..96b131971 --- /dev/null +++ b/internal/migrate/mysql/20211121-094727.sql @@ -0,0 +1 @@ +DROP INDEX IF EXISTS uix_places_place_label ON places; \ No newline at end of file diff --git a/internal/migrate/sqlite/20211121-094727.sql b/internal/migrate/sqlite/20211121-094727.sql new file mode 100644 index 000000000..371e141b4 --- /dev/null +++ b/internal/migrate/sqlite/20211121-094727.sql @@ -0,0 +1 @@ +DROP INDEX IF EXISTS idx_places_place_label ON places; \ No newline at end of file diff --git a/internal/query/query.go b/internal/query/query.go index 0816db26f..890fd671d 100644 --- a/internal/query/query.go +++ b/internal/query/query.go @@ -1,6 +1,6 @@ /* -Package query contains frequently used database queries for use in commands and API. +Package query provides frequently used database queries for use in commands and API. Copyright (c) 2018 - 2021 Michael Mayer