From dc41ca31b39b99a59992ff226a328b2ae63633d8 Mon Sep 17 00:00:00 2001 From: Axel Wagner Date: Sun, 31 Jul 2016 00:22:53 +0200 Subject: [PATCH] Add a package to abstract SQL operations (#18) * Add a package to abstract SQL operations The data package should improve code-reuse by bundling together the SQL operations into a common package. It will be marginally less efficient than using SQL directly, but it will enforce best practices, remove duplication and prevent mistakes from broken queries. #7 is the associated issue. To begin with, this updates termine to use the new package. It also refactors termine a bit. Fixes #3 --- data/data.go | 427 ++++++++++++++++++++++++++++++++++++++++++++ termine/announce.go | 93 +++------- termine/clear.go | 9 +- termine/help.go | 31 ++-- termine/location.go | 63 +++---- termine/main.go | 69 +++++-- termine/next.go | 40 ++--- termine/override.go | 35 ++-- termine/password.go | 37 ++-- termine/sql.go | 19 -- termine/yarpnarp.go | 40 ++--- 11 files changed, 634 insertions(+), 229 deletions(-) create mode 100644 data/data.go delete mode 100644 termine/sql.go diff --git a/data/data.go b/data/data.go new file mode 100644 index 0000000..59e68f8 --- /dev/null +++ b/data/data.go @@ -0,0 +1,427 @@ +// Package data implements sql operations over the NoName e.V. website-db. +package data + +import ( + "database/sql" + "errors" + "flag" + "fmt" + "net/url" + "strconv" + "strings" + "time" +) + +var ( + driver = flag.String("driver", "postgres", "Der benutzte sql-Treiber") + connect = flag.String("connect", "dbname=nnev user=anon host=/var/run/postgresql sslmode=disable", "Die Verbindusgsspezifikation") +) + +// OpenDB opens a connection to the database with parameters derived from flags. +func OpenDB() (*sql.DB, error) { + return sql.Open(*driver, *connect) +} + +// Querier is an interface used to query the database. Both *sql.DB and *sql.Tx +// implement it. +type Querier interface { + // Query executes a query that returns rows, typically a SELECT. The args + // are for any placeholder parameters in the query. + Query(query string, args ...interface{}) (*sql.Rows, error) + + // QueryRow executes a query that is expected to return at most one row. + // QueryRow always returns a non-nil value. Errors are deferred until Row's + // Scan method is called. + QueryRow(query string, args ...interface{}) *sql.Row +} + +// Execer is an interface used to write to the database. Both *sql.DB and +// *sql.Tx implement it. +type Execer interface { + // Exec executes a query without returning any rows. The args are for any + // placeholder parameters in the query. + Exec(query string, args ...interface{}) (sql.Result, error) +} + +// NullTime represents a time.Time that may be null. NullTime implements the +// sql.scanner interface so it can be used as a scan destination, similar to +// sql.NullString. +type NullTime struct { + Time time.Time + Valid bool // Vaid is true if Time is not NULL +} + +// Scan implements the sql.scanner interface. +func (n *NullTime) Scan(value interface{}) error { + if value == nil { + *n = NullTime{} + return nil + } + n.Valid = true + + t, ok := value.(time.Time) + if !ok { + return fmt.Errorf("can't save %T as time.Time", value) + } + n.Time = t + return nil +} + +// scanner is an interface to retrieve values from an sql row. Both *sql.Row +// and *sql.Rows implement it. +type scanner interface { + // Scan copies the columns from the matched row into the values pointed at + // by dest. See the documentation on Rows.Scan for details. If more than + // one row matches the query, Scan uses the first row and discards the + // rest. If no row matches the query, Scan returns ErrNoRows. + Scan(dest ...interface{}) error +} + +// row is an abstraction over the data elements to share code. +type item interface { + // selectFragment returns an SQL query fragment of the form "SELECT field1, + // field2,... " with all columns for this type. + selectFragment() string + + // scanFrom calls the scanners Scan method to retrieve the fields in the + // order output by selectFragment. + scanFrom(scanner) error +} + +// queryRow is a convenience function to get a single data row. +func queryRow(q Querier, r item, constrict string, args ...interface{}) error { + row := q.QueryRow(r.selectFragment()+constrict, args...) + return r.scanFrom(row) +} + +// TerminIterator is an iterator over a subset of the termine table. +type TerminIterator struct { + rows *sql.Rows + err error + t *Termin +} + +// Next advances to the next element. It returns false if there is none. +func (it *TerminIterator) Next() bool { + if it.err != nil { + return false + } + if !it.rows.Next() { + it.err = it.rows.Err() + return false + } + it.t = new(Termin) + it.err = it.t.scanFrom(it.rows) + return it.err == nil +} + +// Val returns the current row. Requires Next() to be true. +func (it *TerminIterator) Val() *Termin { + return it.t +} + +// Close closes the iterator and returns the last error that occured when +// reading. It must be called after being done with the iterator. No other +// methods may be called after Close. +func (it *TerminIterator) Close() error { + err := it.rows.Close() + it.rows = nil + if it.err == nil { + it.err = err + } + return it.err +} + +// One is a convenience method for queries that expect exactly one result. It +// returns an error if there is not exactly one result. Must be the only method +// being called. +func (it *TerminIterator) One() (*Termin, error) { + defer it.rows.Close() + if !it.Next() { + if it.err == nil { + it.err = sql.ErrNoRows + } + return nil, it.err + } + t := it.Val() + if it.Next() { + return nil, errors.New("more than one result") + } + return t, nil +} + +// First is a convenience method for queries where only the first result is of +// interest. It returns an error if there is no result. Must be the only method +// being called. +func (it *TerminIterator) First() (*Termin, error) { + defer it.rows.Close() + if !it.Next() { + if it.err == nil { + it.err = sql.ErrNoRows + } + return nil, it.err + } + return it.Val(), nil +} + +// QueryTermine queries the termine table for all rows where cond is true. cond +// must be a valid SQL fragment following a "SELECT field,..." statement on +// termine. If possible, one of the more specific functions should be used. +func QueryTermine(q Querier, conds string, args ...interface{}) *TerminIterator { + sel := (*Termin)(nil).selectFragment() + rows, err := q.Query(sel+conds, args...) + return &TerminIterator{rows, err, nil} +} + +// FutureTermine returns an iterator over all future meetings (starting with +// today's) in chronological order. +func FutureTermine(q Querier) *TerminIterator { + return QueryTermine(q, "WHERE date >= $1 ORDER BY date ASC", time.Now()) +} + +// LastTermine returns an iterator over all past meetings (including today's), +// in reverse chronological order. +func LastTermine(q Querier) *TerminIterator { + return QueryTermine(q, "WHERE date <= $1 ORDER BY date DESC", time.Now()) +} + +// Termin is the representation of a meeting. +type Termin struct { + // Date (when set) is the date of the meeting. + Date NullTime + + // Stammtisch (when set) is whether this meeting is a Stammtisch. + Stammtisch sql.NullBool + + // Vortrag (when set) contains the id of this meetings talk. + Vortrag sql.NullInt64 + + // Location is the location of a potential Stammtisch. + Location string + + // Override is a short string to display if a meeting isn't happening. + Override string + + // OverrideLong is a long description of a meeting that isn't happening, to + // be sent by E-Mail in the announcement. + OverrideLong string +} + +// GetTermin returns the meeting of the specified date. +func GetTermin(q Querier, date time.Time) (*Termin, error) { + t := new(Termin) + r := q.QueryRow(t.selectFragment()+"WHERE date = $1", date) + err := t.scanFrom(r) + return t, err +} + +func (t *Termin) selectFragment() string { + return "SELECT date, stammtisch, vortrag, location, override, override_long FROM termine " +} + +func (t *Termin) scanFrom(s scanner) error { + return s.Scan(&t.Date, &t.Stammtisch, &t.Vortrag, &t.Location, &t.Override, &t.OverrideLong) +} + +// GetVortrag returns the talk of this meeting. It returns nil, nil, if there +// is no talk. +func (t *Termin) GetVortrag(q Querier) (*Vortrag, error) { + if !t.Vortrag.Valid { + return nil, nil + } + return GetVortrag(q, int(t.Vortrag.Int64)) +} + +// Update writes back the meeting data to the database. +func (t *Termin) Update(e Execer) error { + var fields []string + var values []interface{} + + add := func(k string, v interface{}) { + values = append(values, v) + fields = append(fields, fmt.Sprintf("%s = $%d", k, len(values))) + } + + if t.Stammtisch.Valid { + add("stammtisch", t.Stammtisch.Bool) + } else { + fields = append(fields, "stammtisch = NULL") + } + if t.Vortrag.Valid { + add("vortrag", t.Vortrag.Int64) + } else { + fields = append(fields, "vortrag = NULL") + } + add("location", t.Location) + add("override", t.Override) + add("override_long", t.OverrideLong) + + values = append(values, t.Date.Time) + query := fmt.Sprintf("UPDATE termine SET %s WHERE date = $%d", strings.Join(fields, ", "), len(values)) + + _, err := e.Exec(query, values...) + return err +} + +// Insert adds this meeting to the database. +func (t *Termin) Insert(e Execer) error { + if !t.Date.Valid { + return errors.New("termin needs a date") + } + var fields []string + var values []interface{} + + add := func(field string, value interface{}) { + fields = append(fields, field) + values = append(values, value) + } + + add("date", t.Date.Time) + if t.Stammtisch.Valid { + add("stammtisch", t.Stammtisch.Bool) + } + if t.Vortrag.Valid { + add("vortrag", t.Vortrag.Int64) + } + add("location", t.Location) + add("override", t.Override) + add("override_long", t.OverrideLong) + + placeholder := make([]string, 0, len(values)) + for i := 1; i <= len(values); i++ { + placeholder = append(placeholder, "$"+strconv.Itoa(i)) + } + + query := fmt.Sprintf("INSERT INTO termine (%s) SELECT %s WHERE NOT EXISTS (SELECT 1 FROM termine WHERE date = $1)", strings.Join(fields, ", "), strings.Join(placeholder, ", ")) + _, err := e.Exec(query, values...) + return err +} + +// Vortrag is the representation of a Talk. +type Vortrag struct { + // ID is this talks unique id. + ID int + + // Date (if set) is the date of this talk. + Date NullTime + + // Topic is the topic of this talk. + Topic string + + // Abstract is a short summary of the talk. + Abstract string + + // Speaker is the name of the speaker. + Speaker string + + // InfoURL is a url where to find further information. + InfoURL string + + // Password is the password to edit this talk. + Password string +} + +func (v *Vortrag) selectFragment() string { + return "SELECT id, date, topic, abstract, speaker, info_url, password FROM votraege " +} + +func (v *Vortrag) scanFrom(s scanner) error { + return s.Scan(&v.ID, &v.Date, &v.Topic, &v.Speaker, &v.InfoURL, &v.Password) +} + +// GetVortrag returns the talk with the given id. +func GetVortrag(q Querier, id int) (*Vortrag, error) { + v := new(Vortrag) + r := q.QueryRow(v.selectFragment()+"WHERE id = $1", id) + err := v.scanFrom(r) + return v, err +} + +// Link is the representation of an informational link. +type Link struct { + // Kind (if given) is the type of this link. + Kind sql.NullString + + // URL is the url of this link. + URL *url.URL +} + +// Links returns the informational links of this talk. +func (v *Vortrag) Links(q Querier) ([]Link, error) { + return nil, nil +} + +// Zusage is the representation of an RSVP. +type Zusage struct { + // Nick is the nick this applies to. + Nick sql.NullString + + // Kommt is whether this person intends to be there. + Kommt bool + + // Kommentar is the optional comment given by this person. + Kommentar string + + // Registered (if given) is the time this person RSVPed. + Registered NullTime +} + +func (z *Zusage) selectFragment() string { + return "SELECT nick, kommt, kommentar, registered FROM zusagen " +} + +func (z *Zusage) scanFrom(s scanner) error { + return s.Scan(&z.Nick, &z.Kommt, &z.Kommentar, &z.Registered) +} + +// ZusagenIterator is an iterator over a subset of the zusagen table. +type ZusagenIterator struct { + rows *sql.Rows + err error + z *Zusage +} + +// Next advances to the next element. It returns false if there is none. +func (it *ZusagenIterator) Next() bool { + if it.err != nil { + return false + } + if !it.rows.Next() { + it.err = it.rows.Err() + return false + } + it.z = new(Zusage) + it.err = it.z.scanFrom(it.rows) + return it.err == nil +} + +// Val returns the current row. Requires Next() to be true. +func (it *ZusagenIterator) Val() *Zusage { + return it.z +} + +// Close closes the iterator and returns the last error that occured when +// reading. It must be called after being done with the iterator. No other +// methods may be called after Close. +func (it *ZusagenIterator) Close() error { + err := it.rows.Close() + it.rows = nil + if it.err == nil { + it.err = err + } + return it.err +} + +// Zusagen returns all rows of the zusagen table in unspecified order. +func Zusagen(q Querier) *ZusagenIterator { + return QueryZusagen(q, "", nil) +} + +// QueryZusagen queries the zusagen table for all rows where cond is true. cond +// must be a valid SQL fragment following a "SELECT field,..." statement on +// zusagen. If possible, one of the more specific functions should be used. +func QueryZusagen(q Querier, conds string, args ...interface{}) *ZusagenIterator { + sel := (*Zusage)(nil).selectFragment() + rows, err := q.Query(sel+conds, args...) + return &ZusagenIterator{rows, err, nil} +} diff --git a/termine/announce.go b/termine/announce.go index a525467..b89a62e 100644 --- a/termine/announce.go +++ b/termine/announce.go @@ -3,6 +3,7 @@ package main import ( "bytes" "database/sql" + "errors" "flag" "fmt" "io" @@ -12,7 +13,8 @@ import ( "os" "os/exec" "text/template" - "time" + + "github.com/nnev/website/data" ) var cmdAnnounce = &Command{ @@ -31,18 +33,7 @@ func init() { cmdAnnounce.Run = RunAnnounce } -func isStammtisch(date time.Time) (stammt bool, err error) { - err = db.QueryRow("SELECT stammtisch FROM termine WHERE date = $1", date).Scan(&stammt) - return -} - -func announceStammtisch(date time.Time) { - loc, err := getLocation(date) - if err != nil { - log.Println("Kann Location nicht auslesen:", err) - return - } - +func announceStammtisch(t *data.Termin) error { maildraft := `Liebe Treffler, am kommenden Donnerstag ist wieder Stammtisch. Diesmal sind wir bei {{.Location}}. @@ -56,43 +47,22 @@ Damit wir passend reservieren können, tragt bitte bis Dienstag Abend, mailtmpl := template.Must(template.New("maildraft").Parse(maildraft)) mailbuf := new(bytes.Buffer) - type data struct { - Location string - } - if err = mailtmpl.Execute(mailbuf, data{loc}); err != nil { - log.Println("Fehler beim Füllen des Templates:", err) - return + if err := mailtmpl.Execute(mailbuf, t); err != nil { + return fmt.Errorf("Fehler beim Füllen des Templates: %v", err) } mail := mailbuf.Bytes() - sendAnnouncement("Bitte für Stammtisch eintragen", mail) + return sendAnnouncement("Bitte für Stammtisch eintragen", mail) } -func announceC14(date time.Time) { - var data struct { - Topic, - Abstract, - Speaker string - } - - if err := db.QueryRow("SELECT topic FROM vortraege WHERE date = $1", date).Scan(&data.Topic); err != nil { - if err == sql.ErrNoRows { - fmt.Println("Es gibt nächsten Donnerstag noch keine c¼h. :(") - return - } - - log.Println("Kann topic nicht auslesen:", err) - return - } - - if err := db.QueryRow("SELECT abstract FROM vortraege WHERE date = $1", date).Scan(&data.Abstract); err != nil { - log.Println("Kann abstract nicht auslesen:", err) - return +func announceC14(t *data.Termin) error { + vortrag, err := t.GetVortrag(cmdAnnounce.Tx) + if err == sql.ErrNoRows { + fmt.Println("Es gibt nächsten Donnerstag noch keine c¼h. :(") + return nil } - - if err := db.QueryRow("SELECT speaker FROM vortraege WHERE date = $1", date).Scan(&data.Speaker); err != nil { - log.Println("Kann speaker nicht auslesen:", err) - return + if err != nil { + log.Fatal("Kann vortrag nicht lesen:", err) } maildraft := `Liebe Treffler, @@ -113,15 +83,14 @@ Wer mehr Informationen möchte: mailtmpl := template.Must(template.New("maildraft").Parse(maildraft)) mailbuf := new(bytes.Buffer) - if err := mailtmpl.Execute(mailbuf, data); err != nil { - log.Println("Fehler beim Füllen des Templates:", err) - return + if err := mailtmpl.Execute(mailbuf, vortrag); err != nil { + return fmt.Errorf("Fehler beim Füllen des Templates: %v", err) } mail := mailbuf.Bytes() - sendAnnouncement(data.Topic, mail) + return sendAnnouncement(vortrag.Topic, mail) } -func sendAnnouncement(subject string, msg []byte) { +func sendAnnouncement(subject string, msg []byte) error { mail := new(bytes.Buffer) fmt.Fprintf(mail, "From: frank@noname-ev.de\r\n") fmt.Fprintf(mail, "To: %s\r\n", mime.QEncoding.Encode("utf-8", *targetmailaddr)) @@ -143,29 +112,23 @@ func sendAnnouncement(subject string, msg []byte) { cmd.Stderr = stdout if err := cmd.Run(); err != nil { - log.Println("Fehler beim Senden der Mail: ", err) - log.Println("Output von Sendmail:") io.Copy(os.Stderr, stdout) + return fmt.Errorf("Fehler beim Senden der Mail: %v", err) } + return nil } -func RunAnnounce() { - var nextRelevantDate time.Time - - if err := db.QueryRow("SELECT date FROM termine WHERE date > NOW() AND override = '' ORDER BY date ASC LIMIT 1").Scan(&nextRelevantDate); err != nil { - log.Println("Kann nächsten Termin nicht auslesen:", err) - return +func RunAnnounce() error { + t, err := data.FutureTermine(cmdAnnounce.Tx).First() + if err == sql.ErrNoRows { + return errors.New("Keine termine gefunden") } - - isStm, err := isStammtisch(nextRelevantDate) if err != nil { - log.Println("Kann stammtischiness nicht auslesen:", err) - return + return fmt.Errorf("Kann nächsten Termin nicht auslesen: %v", err) } - if isStm { - announceStammtisch(nextRelevantDate) - } else { - announceC14(nextRelevantDate) + if t.Stammtisch.Bool { + return announceStammtisch(t) } + return announceC14(t) } diff --git a/termine/clear.go b/termine/clear.go index 4980402..36b8d3a 100644 --- a/termine/clear.go +++ b/termine/clear.go @@ -2,7 +2,7 @@ package main import ( "flag" - "log" + "fmt" ) var cmdClear = &Command{ @@ -21,9 +21,10 @@ func init() { cmdClear.Run = RunClear } -func RunClear() { - _, err := db.Exec("DELETE FROM zusagen") +func RunClear() error { + _, err := cmdClear.Tx.Exec("DELETE FROM zusagen") if err != nil { - log.Println("Kann Tabelle nicht leeren:", err) + return fmt.Errorf("Kann Tabelle nicht leeren: %v", err) } + return nil } diff --git a/termine/help.go b/termine/help.go index c2c26b6..25a0c88 100644 --- a/termine/help.go +++ b/termine/help.go @@ -20,16 +20,21 @@ func init() { } func showCmdHelp(cmd *Command) { - log.Println("Nutzung:\n") - log.Println(" ", cmd.UsageLine, "\n") + log.Println("Nutzung:") + log.Println() + log.Println(" ", cmd.UsageLine) + log.Println() log.Println(cmd.Long) } func showGlobalHelp() { - log.Println("Tool zum Bearbeiten der nnev-Termin Datenbank\n") - log.Println("Nutzung:\n") + log.Println("Tool zum Bearbeiten der nnev-Termin Datenbank.") + log.Println() + log.Println("Nutzung:") + log.Println() log.Printf(" %s [flags] befehl [argumente]\n\n", os.Args[0]) - log.Println("Die vorhandenen Befehle sind:\n") + log.Println("Die vorhandenen Befehle sind:") + log.Println() w := tabwriter.NewWriter(os.Stderr, 8, 4, 2, ' ', 0) @@ -45,27 +50,25 @@ func showGlobalHelp() { log.Printf("\nDie Benutzung eines Befehls zeigt dir \"%s help [befehl]\" an.\n", os.Args[0]) - log.Println("\nFlags:\n") + log.Println() + log.Println("Flags:") + log.Println() flag.PrintDefaults() } -func RunHelp() { +func RunHelp() error { if cmdHelp.Flag.NArg() < 1 { showGlobalHelp() - return + return nil } for _, cmd := range Commands { - if cmd.Name() == "help" { - continue - } - if cmd.Name() == cmdHelp.Flag.Arg(0) { showCmdHelp(cmd) - return + return nil } } - log.Printf("Unbekannter Befehl \"%s\"\n", cmdHelp.Flag.Arg(0)) + return fmt.Errorf("Unbekannter Befehl \"%s\"\n", cmdHelp.Flag.Arg(0)) } diff --git a/termine/location.go b/termine/location.go index ee31517..9c191d1 100644 --- a/termine/location.go +++ b/termine/location.go @@ -1,10 +1,14 @@ package main import ( + "database/sql" + "errors" "flag" "fmt" - "log" + "os" "time" + + "github.com/nnev/website/data" ) var cmdLocation = &Command{ @@ -24,51 +28,26 @@ func init() { cmdLocation.Run = RunLocation } -func getLocation(date time.Time) (location string, err error) { - err = db.QueryRow("SELECT location FROM termine WHERE date = $1", date).Scan(&location) - return -} +func RunLocation() error { + if cmdLocation.Flag.NArg() > 1 { + showCmdHelp(cmdLocation) + os.Exit(1) + } -func setLocation(date time.Time, location string) (updated bool, err error) { - result, err := db.Exec("UPDATE termine SET location = $2 WHERE date = $1", date, location) - if err != nil { - return false, err + t, err := data.QueryTermine(cmdLocation.Tx, "WHERE date >= $1 AND stammtisch = true", time.Now()).First() + if err == sql.ErrNoRows { + return errors.New("Termin muss erst mittels next hinzugefügt werden.") } - n, err := result.RowsAffected() if err != nil { - return false, err - } - - return n > 0, nil -} - -func RunLocation() { - // Wir holen uns die nächsten 5 Donnerstage -- darunter muss ein Stammtisch - // sein - var stammtisch time.Time - for _, d := range getNextThursdays(5) { - if d.Day() < 8 { - stammtisch = d - break - } + return fmt.Errorf("Kann Termin nicht lesen: %v", err) } - if cmdLocation.Flag.NArg() == 0 { - loc, err := getLocation(stammtisch) - if err != nil { - log.Println("Kann Location nicht auslesen:", err) - return - } - fmt.Println(loc) - } else { - updated, err := setLocation(stammtisch, cmdLocation.Flag.Arg(0)) - if err != nil { - log.Println("Kann Location nicht setzen:", err) - return - } - if !updated { - log.Println("Termin noch nicht vorhanden.") - log.Println("Füge ihn erst mittels next hinzu.") - } + fmt.Println(t.Location) + return nil + } + t.Location = cmdLocation.Flag.Arg(1) + if err = t.Update(cmdLocation.Tx); err != nil { + return fmt.Errorf("Kann Location nicht setzen: %v", err) } + return nil } diff --git a/termine/main.go b/termine/main.go index db2154c..26d2506 100644 --- a/termine/main.go +++ b/termine/main.go @@ -1,23 +1,29 @@ package main import ( + "database/sql" + "errors" "flag" + "fmt" "log" "os" "os/exec" "strings" + + _ "github.com/lib/pq" + + "github.com/nnev/website/data" ) var ( - driver = flag.String("driver", "postgres", "Der benutzte sql-Treiber") - connect = flag.String("connect", "dbname=nnev user=anon host=/var/run/postgresql sslmode=disable", "Die Verbindusgsspezifikation") websitehook = flag.String("hook", "/usr/bin/update-website", "Hook zum neu Bauen der Website") _ = flag.Bool("help", false, "Zeige Hilfe") + ErrUsage = errors.New("wrong usage") ) type Command struct { // Run runs the command. - Run func() + Run func() error // UsageLine is the one-line usage message. // The first word in the line is taken to be the command name. @@ -34,6 +40,10 @@ type Command struct { // NeedsDB is true, if the command needs to connect to the database NeedsDB bool + // Tx will be set to a transaction that should be used for all database + // accesses, if NeedsDB is true. + Tx *sql.Tx + // RegenWebsite is true, if the website needs to be rebuild after the command RegenWebsite bool } @@ -58,19 +68,34 @@ func (cmd *Command) Name() string { return name } -func (cmd *Command) parseAndRun() { +func (cmd *Command) parseAndRun() (err error) { args := flag.Args() cmd.Flag.Parse(args[1:]) if cmd.NeedsDB { - err := OpenDB() + db, err := data.OpenDB() + if err != nil { + return fmt.Errorf("Fehler beim Verbinden zur Datenbank: %v", err) + } + defer db.Close() + + cmd.Tx, err = db.Begin() if err != nil { - log.Println("Fehler beim Verbinden zur Datenbank:", err) - return + return fmt.Errorf("Kann keine Transaktion starten: %v", err) } + defer func() { + if err != nil { + cmd.Tx.Rollback() + } + if err = cmd.Tx.Commit(); err != nil { + err = fmt.Errorf("Kann Transaktion nicht committen: %v", err) + } + }() } - cmd.Run() + if err := cmd.Run(); err != nil { + return err + } if cmd.RegenWebsite { cmd := exec.Command(*websitehook) @@ -78,9 +103,22 @@ func (cmd *Command) parseAndRun() { if err != nil { log.Println("Hook fehlgeschlagen:", err) log.Println("Output:") - log.Print(string(output)) + log.Fatal(string(output)) } } + return nil +} + +func ExpectNArg(fs *flag.FlagSet, n int) error { + if fs.NArg() < n { + log.Printf("Nicht genug Argumente.") + return ErrUsage + } + if fs.NArg() > n { + log.Printf("Zu viele Argumente.") + return ErrUsage + } + return nil } func main() { @@ -97,10 +135,19 @@ func main() { continue } - cmd.parseAndRun() + if err := cmd.parseAndRun(); err != nil { + if err == ErrUsage { + if cmd != cmdHelp { + showCmdHelp(cmd) + return + } + os.Exit(2) + } + log.Fatal(err) + } return } cmdHelp.parseAndRun() - os.Exit(1) + os.Exit(2) } diff --git a/termine/next.go b/termine/next.go index 84fbb2d..f68f13d 100644 --- a/termine/next.go +++ b/termine/next.go @@ -1,11 +1,14 @@ package main import ( + "database/sql" "flag" + "fmt" "log" - "os" "strconv" "time" + + "github.com/nnev/website/data" ) var cmdNext = &Command{ @@ -46,33 +49,30 @@ func getNextThursdays(n int) (next []time.Time) { return next } -func RunNext() { +func RunNext() error { if cmdNext.Flag.NArg() < 1 { - log.Printf("Nicht genug Argumente. Siehe %s help next\n", os.Args[0]) - return + return ErrUsage } n, err := strconv.Atoi(cmdNext.Flag.Arg(0)) if err != nil { - log.Printf("Kann \"%s\" nicht als Nummer parsen. Siehe %s help next\n", cmdNext.Flag.Arg(0), os.Args[0]) - return + log.Printf("Kann %q nicht als Nummer parsen.\n", cmdNext.Flag.Arg(0)) + return ErrUsage } - tx, err := db.Begin() - if err != nil { - log.Println("SQL-Fehler:", err) - return - } for _, d := range getNextThursdays(n) { - _, err := tx.Exec("INSERT INTO termine (stammtisch, date, override, location, override_long) SELECT $2, $1, '', '', '' WHERE NOT EXISTS (SELECT 1 FROM termine WHERE date = $1)", d, d.Day() < 8) - if err != nil { - log.Println("SQL-Fehler:", err) - tx.Rollback() - return + var t data.Termin + t.Date = data.NullTime{ + Valid: true, + Time: d, + } + t.Stammtisch = sql.NullBool{ + Valid: true, + Bool: d.Day() < 8, + } + if err := t.Insert(cmdNext.Tx); err != nil { + return fmt.Errorf("Kann termin für %v nicht einfügen: %v", d, err) } } - err = tx.Commit() - if err != nil { - log.Println("SQL-Fehler:", err) - } + return nil } diff --git a/termine/override.go b/termine/override.go index eb701a8..6ef56cf 100644 --- a/termine/override.go +++ b/termine/override.go @@ -1,11 +1,16 @@ package main import ( + "database/sql" + "errors" "flag" + "fmt" "io/ioutil" "log" "os" "time" + + "github.com/nnev/website/data" ) var cmdOverride = &Command{ @@ -31,32 +36,32 @@ func init() { cmdOverride.Run = RunOverride } -func RunOverride() { - if cmdOverride.Flag.NArg() < 2 { - log.Printf("Nicht genug Argumente. Siehe %s help override\n", os.Args[0]) - return +func RunOverride() error { + if cmdOverride.Flag.NArg() != 2 { + log.Printf("Falsche Anzahl an Argumenten.") + return ErrUsage } date, err := time.ParseInLocation("2006-01-02", cmdOverride.Flag.Arg(0), time.Local) if err != nil { - log.Printf("Kann \"%s\" nicht als Datum parsen. Siehe %s help override\n", cmdNext.Flag.Arg(0), os.Args[0]) - return + log.Printf("Kann \"%s\" nicht als Datum parsen.", cmdNext.Flag.Arg(0)) + return ErrUsage } override_long, err := ioutil.ReadAll(os.Stdin) if err != nil { - log.Println("Kann nicht von stdin lesen:", err) - return + return fmt.Errorf("Kann nicht von stdin lesen: %v", err) } - result, err := db.Exec("UPDATE termine SET override = $2, override_long = $3 WHERE date = $1", date, cmdOverride.Flag.Arg(1), string(override_long)) + t, err := data.GetTermin(cmdOverride.Tx, date) + if err == sql.ErrNoRows { + return errors.New("Termin nicht vorhanden. Füge ihn erst mittels next hinzu.") + } if err != nil { - log.Println("Kann Eintrag nicht ändern:", err) - return + return fmt.Errorf("Kann Termin nicht lesen: %v", err) } - if n, err := result.RowsAffected(); err != nil && n == 0 { - log.Println("Termin noch nicht vorhanden.") - log.Println("Füge ihn erst mittels next hinzu.") - } + t.Override = cmdOverride.Flag.Arg(1) + t.OverrideLong = string(override_long) + return t.Update(cmdOverride.Tx) } diff --git a/termine/password.go b/termine/password.go index e3eff7c..cabb3ed 100644 --- a/termine/password.go +++ b/termine/password.go @@ -2,11 +2,13 @@ package main import ( "database/sql" + "errors" "flag" "fmt" "log" - "os" "strconv" + + "github.com/nnev/website/data" ) var cmdPassword = &Command{ @@ -24,31 +26,30 @@ func init() { cmdPassword.Run = RunPassword } -func RunPassword() { - if cmdPassword.Flag.NArg() < 1 { - log.Printf("Nicht genug Argumente. Siehe %s help password\n", os.Args[0]) - return +func RunPassword() error { + if err := ExpectNArg(cmdPassword.Flag, 1); err != nil { + return err } id, err := strconv.Atoi(cmdPassword.Flag.Arg(0)) if err != nil { - log.Printf("Kann \"%s\" nicht als Nummer parsen. Siehe %s help password\n", cmdPassword.Flag.Arg(0), os.Args[0]) - return + log.Printf("Kann %q nicht als Nummer parsen.", cmdPassword.Flag.Arg(0)) + return ErrUsage } - var pw sql.NullString - - err = db.QueryRow("SELECT password FROM vortraege WHERE id = $1", id).Scan(&pw) + v, err := data.GetVortrag(cmdPassword.Tx, id) if err == sql.ErrNoRows { - log.Println("Vortrag existiert nicht") - return + return errors.New("Vortrag existiert nicht") } - - if !pw.Valid { - log.Println("Kein Passwort gesetzt") - return + if err != nil { + return fmt.Errorf("Kann Vortrag nicht lesen: %v", err) } - fmt.Println("Passwort:", pw.String) - fmt.Printf("Link: https://www.noname-ev.de/edit_c14.html?id=%d&pw=%s\n", id, pw.String) + if v.Password == "" { + fmt.Println("Kein Password gesetzt") + return nil + } + fmt.Println("Passwort:", v.Password) + fmt.Printf("Link: https://www.noname-ev.de/edit_c14.html?id=%d&pw=%s\n", id, v.Password) + return nil } diff --git a/termine/sql.go b/termine/sql.go deleted file mode 100644 index fdeb374..0000000 --- a/termine/sql.go +++ /dev/null @@ -1,19 +0,0 @@ -package main - -import ( - "database/sql" - - _ "github.com/lib/pq" -) - -var ( - db *sql.DB -) - -func OpenDB() (err error) { - db, err = sql.Open(*driver, *connect) - if err != nil { - return err - } - return nil -} diff --git a/termine/yarpnarp.go b/termine/yarpnarp.go index 8a3a19a..a2cb84d 100644 --- a/termine/yarpnarp.go +++ b/termine/yarpnarp.go @@ -3,13 +3,12 @@ package main import ( "flag" "fmt" - "log" "os" "sort" "text/tabwriter" "time" - "github.com/jmoiron/sqlx" + "github.com/nnev/website/data" ) var cmdYarpNarp = &Command{ @@ -25,14 +24,7 @@ func init() { cmdYarpNarp.Run = RunYarpNarp } -type Zusage struct { - Nick string - Kommt bool - Kommentar string - Registered time.Time -} - -type Zusagen []Zusage +type Zusagen []*data.Zusage func (z Zusagen) Len() int { return len(z) @@ -49,13 +41,16 @@ func (z Zusagen) Less(i, j int) bool { return false } - if z[i].Registered.Before(z[j].Registered) { + // If one or both registered fields are NULL, just treat them as the zero + // time (so they sort before all other values) + if z[i].Registered.Time.Before(z[j].Registered.Time) { return true - } else if z[j].Registered.Before(z[i].Registered) { + } else if z[j].Registered.Time.Before(z[i].Registered.Time) { return false } - if z[i].Nick < z[j].Nick { + // If one or both nick fields are NULL, just tream them as the empty string + if z[i].Nick.String < z[j].Nick.String { return true } @@ -65,8 +60,8 @@ func (z Zusagen) Less(i, j int) bool { func (z Zusagen) minWidth() int { var nick int for _, zusage := range z { - if len(zusage.Nick) > nick { - nick = len(zusage.Nick) + if len(zusage.Nick.String) > nick { + nick = len(zusage.Nick.String) } } // nick + padding + Kommt + Date @@ -95,13 +90,15 @@ func maybeTruncate(s string, width int, truncate bool) string { return s } -func RunYarpNarp() { +func RunYarpNarp() error { var zusagen Zusagen - dbx := sqlx.NewDb(db, *driver) - if err := sqlx.Select(dbx, &zusagen, "SELECT nick, kommt, kommentar, registered FROM zusagen"); err != nil { - log.Println("Datenbankfehler:", err) - return + it := data.Zusagen(cmdYarpNarp.Tx) + for it.Next() { + zusagen = append(zusagen, it.Val()) + } + if err := it.Close(); err != nil { + return err } sort.Sort(zusagen) @@ -113,7 +110,8 @@ func RunYarpNarp() { w := tabwriter.NewWriter(os.Stdout, 0, 4, 1, ' ', tabwriter.DiscardEmptyColumns) fmt.Fprintf(w, "Nick\tKommt\tLetzte Änderung\t%s\n", maybeTruncate("Kommentar", width, trunc)) for _, z := range zusagen { - fmt.Fprintf(w, "%s\t%v\t%s\t%s\n", z.Nick, formatBool(z.Kommt), z.Registered.In(time.Local).Format("2006-01-02 15:04:05"), maybeTruncate(z.Kommentar, width, trunc)) + fmt.Fprintf(w, "%s\t%v\t%s\t%s\n", z.Nick, formatBool(z.Kommt), z.Registered.Time.In(time.Local).Format("2006-01-02 15:04:05"), maybeTruncate(z.Kommentar, width, trunc)) } w.Flush() + return nil }