2023-02-04 10:32:39 +00:00
|
|
|
package pkg
|
|
|
|
|
|
|
|
import (
|
|
|
|
"context"
|
Convert from cents to “price” and back
I do not want to use floats in the Go lang application, because it is
not supposed to do anything with these values other than to print and
retrieve them from the user; all computations will be performed by
PostgreSQL in cents.
That means i have to “convert” from the price format that users expect
to see (e.g., 1.234,56) to cents (e.g., 123456) and back when passing
data between Go and PostgreSQL, and that conversion depends on the
currency’s decimal places.
At first i did everything in Go, but saw that i would need to do it in
a loop when retrieving the list of products, and immediately knew it was
a mistake—i needed a PL/pgSQL function for that.
I still need to convert from string to float, however, when printing the
value to the user. Because the string representation is in C, but i
need to format it according to the locale with golang/x/text. That
package has the information of how to correctly format numbers, but it
is in an internal package that i can not use, and numbers.Digit only
accepts numeric types, not a string.
2023-02-05 12:55:12 +00:00
|
|
|
"fmt"
|
2023-02-04 10:32:39 +00:00
|
|
|
"github.com/julienschmidt/httprouter"
|
|
|
|
"html/template"
|
|
|
|
"math"
|
|
|
|
"net/http"
|
2023-02-14 11:39:54 +00:00
|
|
|
"strconv"
|
2023-04-23 01:20:01 +00:00
|
|
|
"strings"
|
2023-02-04 10:32:39 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
type ProductEntry struct {
|
|
|
|
Slug string
|
|
|
|
Name string
|
Convert from cents to “price” and back
I do not want to use floats in the Go lang application, because it is
not supposed to do anything with these values other than to print and
retrieve them from the user; all computations will be performed by
PostgreSQL in cents.
That means i have to “convert” from the price format that users expect
to see (e.g., 1.234,56) to cents (e.g., 123456) and back when passing
data between Go and PostgreSQL, and that conversion depends on the
currency’s decimal places.
At first i did everything in Go, but saw that i would need to do it in
a loop when retrieving the list of products, and immediately knew it was
a mistake—i needed a PL/pgSQL function for that.
I still need to convert from string to float, however, when printing the
value to the user. Because the string representation is in C, but i
need to format it according to the locale with golang/x/text. That
package has the information of how to correctly format numbers, but it
is in an internal package that i can not use, and numbers.Digit only
accepts numeric types, not a string.
2023-02-05 12:55:12 +00:00
|
|
|
Price string
|
2023-03-26 11:51:57 +00:00
|
|
|
Tags []string
|
2023-02-04 10:32:39 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
type productsIndexPage struct {
|
|
|
|
Products []*ProductEntry
|
2023-04-23 01:20:01 +00:00
|
|
|
Filters *productFilterForm
|
2023-02-04 10:32:39 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
func IndexProducts(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
|
|
|
conn := getConn(r)
|
|
|
|
company := mustGetCompany(r)
|
2023-04-23 01:20:01 +00:00
|
|
|
locale := getLocale(r)
|
|
|
|
filters := newProductFilterForm(locale)
|
|
|
|
if err := filters.Parse(r); err != nil {
|
|
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
|
|
return
|
|
|
|
}
|
2023-02-04 10:32:39 +00:00
|
|
|
page := &productsIndexPage{
|
2023-04-23 01:20:01 +00:00
|
|
|
Products: mustCollectProductEntries(r.Context(), conn, company, filters),
|
|
|
|
Filters: filters,
|
2023-02-04 10:32:39 +00:00
|
|
|
}
|
2023-03-23 09:55:02 +00:00
|
|
|
mustRenderMainTemplate(w, r, "products/index.gohtml", page)
|
2023-02-04 10:32:39 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
func GetProductForm(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
|
|
|
locale := getLocale(r)
|
|
|
|
conn := getConn(r)
|
|
|
|
company := mustGetCompany(r)
|
|
|
|
form := newProductForm(r.Context(), conn, locale, company)
|
|
|
|
slug := params[0].Value
|
|
|
|
if slug == "new" {
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
mustRenderNewProductForm(w, r, form)
|
|
|
|
return
|
|
|
|
}
|
Return HTTP 404 instead of 500 for invalid UUID values in URL
Since most of PL/pgSQL functions accept a `uuid` domain, we get an error
if the value is not valid, forcing us to return an HTTP 500, as we
can not detect that the error was due to that.
Instead, i now validate that the slug is indeed a valid UUID before
attempting to send it to the database, returning the correct HTTP error
code and avoiding useless calls to the database.
I based the validation function of Parse() from Google’s uuid package[0]
because this function is an order or magnitude faster in benchmarks:
goos: linux
goarch: amd64
pkg: dev.tandem.ws/tandem/numerus/pkg
cpu: Intel(R) Core(TM) i5-6200U CPU @ 2.30GHz
BenchmarkValidUuid-4 36946050 29.37 ns/op
BenchmarkValidUuid_Re-4 3633169 306.70 ns/op
The regular expression used for the benchmark was:
var re = regexp.MustCompile("^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-4[a-fA-F0-9]{3}-[8|9|aA|bB][a-fA-F0-9]{3}-[a-fA-F0-9]{12}$")
And the input parameter for both functions was the following valid UUID,
because most of the time the passed UUID will be valid:
"f47ac10b-58cc-0372-8567-0e02b2c3d479"
I did not use the uuid package, even though it is in Debian’s
repository, because i only need to check whether the value is valid,
not convert it to a byte array. As far as i know, that package can not
do that.
[0]: https://github.com/google/uuid
2023-07-17 09:46:11 +00:00
|
|
|
if !ValidUuid(slug) {
|
|
|
|
http.NotFound(w, r)
|
|
|
|
return
|
|
|
|
}
|
2023-03-26 11:51:57 +00:00
|
|
|
if !form.MustFillFromDatabase(r.Context(), conn, slug) {
|
2023-02-14 11:46:11 +00:00
|
|
|
http.NotFound(w, r)
|
|
|
|
return
|
2023-02-04 10:32:39 +00:00
|
|
|
}
|
|
|
|
w.WriteHeader(http.StatusOK)
|
2023-03-27 07:44:04 +00:00
|
|
|
mustRenderEditProductForm(w, r, slug, form)
|
2023-02-04 10:32:39 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
func mustRenderNewProductForm(w http.ResponseWriter, r *http.Request, form *productForm) {
|
2023-04-25 13:28:55 +00:00
|
|
|
mustRenderMainTemplate(w, r, "products/new.gohtml", form)
|
2023-02-04 10:32:39 +00:00
|
|
|
}
|
|
|
|
|
2023-03-27 07:44:04 +00:00
|
|
|
type editProductPage struct {
|
|
|
|
Slug string
|
|
|
|
ProductName string
|
|
|
|
Form *productForm
|
|
|
|
}
|
|
|
|
|
|
|
|
func mustRenderEditProductForm(w http.ResponseWriter, r *http.Request, slug string, form *productForm) {
|
|
|
|
page := &editProductPage{
|
|
|
|
Slug: slug,
|
|
|
|
ProductName: form.Name.Val,
|
|
|
|
Form: form,
|
|
|
|
}
|
2023-04-25 13:28:55 +00:00
|
|
|
mustRenderMainTemplate(w, r, "products/edit.gohtml", page)
|
2023-02-04 10:32:39 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
func HandleAddProduct(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
|
|
|
conn := getConn(r)
|
|
|
|
locale := getLocale(r)
|
|
|
|
company := mustGetCompany(r)
|
|
|
|
form := newProductForm(r.Context(), conn, locale, company)
|
|
|
|
if err := form.Parse(r); err != nil {
|
|
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
if err := verifyCsrfTokenValid(r); err != nil {
|
|
|
|
http.Error(w, err.Error(), http.StatusForbidden)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
if !form.Validate() {
|
2023-03-27 07:44:04 +00:00
|
|
|
if !IsHTMxRequest(r) {
|
|
|
|
w.WriteHeader(http.StatusUnprocessableEntity)
|
|
|
|
}
|
2023-02-04 10:32:39 +00:00
|
|
|
mustRenderNewProductForm(w, r, form)
|
|
|
|
return
|
|
|
|
}
|
2023-02-14 11:39:54 +00:00
|
|
|
taxes := mustSliceAtoi(form.Tax.Selected)
|
2023-03-26 11:51:57 +00:00
|
|
|
conn.MustExec(r.Context(), "select add_product($1, $2, $3, $4, $5, $6)", company.Id, form.Name, form.Description, form.Price, taxes, form.Tags)
|
2023-04-25 13:28:55 +00:00
|
|
|
htmxRedirect(w, r, companyURI(company, "/products"))
|
2023-02-14 11:39:54 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
func sliceAtoi(s []string) ([]int, error) {
|
2023-03-26 11:51:57 +00:00
|
|
|
var i []int
|
2023-02-14 11:39:54 +00:00
|
|
|
for _, vs := range s {
|
|
|
|
vi, err := strconv.Atoi(vs)
|
|
|
|
if err != nil {
|
|
|
|
return i, err
|
2023-02-08 12:47:36 +00:00
|
|
|
}
|
2023-02-14 11:39:54 +00:00
|
|
|
i = append(i, vi)
|
2023-02-08 12:47:36 +00:00
|
|
|
}
|
2023-02-14 11:39:54 +00:00
|
|
|
return i, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func mustSliceAtoi(s []string) []int {
|
|
|
|
i, err := sliceAtoi(s)
|
|
|
|
if err != nil {
|
|
|
|
panic(err)
|
|
|
|
}
|
|
|
|
return i
|
2023-02-04 10:32:39 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
func HandleUpdateProduct(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
|
|
|
conn := getConn(r)
|
|
|
|
locale := getLocale(r)
|
|
|
|
company := mustGetCompany(r)
|
|
|
|
form := newProductForm(r.Context(), conn, locale, company)
|
|
|
|
if err := form.Parse(r); err != nil {
|
|
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
if err := verifyCsrfTokenValid(r); err != nil {
|
|
|
|
http.Error(w, err.Error(), http.StatusForbidden)
|
|
|
|
return
|
|
|
|
}
|
2023-03-27 07:44:04 +00:00
|
|
|
slug := params[0].Value
|
Return HTTP 404 instead of 500 for invalid UUID values in URL
Since most of PL/pgSQL functions accept a `uuid` domain, we get an error
if the value is not valid, forcing us to return an HTTP 500, as we
can not detect that the error was due to that.
Instead, i now validate that the slug is indeed a valid UUID before
attempting to send it to the database, returning the correct HTTP error
code and avoiding useless calls to the database.
I based the validation function of Parse() from Google’s uuid package[0]
because this function is an order or magnitude faster in benchmarks:
goos: linux
goarch: amd64
pkg: dev.tandem.ws/tandem/numerus/pkg
cpu: Intel(R) Core(TM) i5-6200U CPU @ 2.30GHz
BenchmarkValidUuid-4 36946050 29.37 ns/op
BenchmarkValidUuid_Re-4 3633169 306.70 ns/op
The regular expression used for the benchmark was:
var re = regexp.MustCompile("^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-4[a-fA-F0-9]{3}-[8|9|aA|bB][a-fA-F0-9]{3}-[a-fA-F0-9]{12}$")
And the input parameter for both functions was the following valid UUID,
because most of the time the passed UUID will be valid:
"f47ac10b-58cc-0372-8567-0e02b2c3d479"
I did not use the uuid package, even though it is in Debian’s
repository, because i only need to check whether the value is valid,
not convert it to a byte array. As far as i know, that package can not
do that.
[0]: https://github.com/google/uuid
2023-07-17 09:46:11 +00:00
|
|
|
if !ValidUuid(slug) {
|
|
|
|
http.NotFound(w, r)
|
|
|
|
return
|
|
|
|
}
|
2023-02-04 10:32:39 +00:00
|
|
|
if !form.Validate() {
|
2023-05-05 08:57:48 +00:00
|
|
|
if !IsHTMxRequest(r) {
|
|
|
|
w.WriteHeader(http.StatusUnprocessableEntity)
|
|
|
|
}
|
2023-03-27 07:44:04 +00:00
|
|
|
mustRenderEditProductForm(w, r, slug, form)
|
2023-02-04 10:32:39 +00:00
|
|
|
return
|
|
|
|
}
|
2023-02-14 11:39:54 +00:00
|
|
|
taxes := mustSliceAtoi(form.Tax.Selected)
|
2023-03-26 11:51:57 +00:00
|
|
|
if ok := conn.MustGetBool(r.Context(), "select edit_product($1, $2, $3, $4, $5, $6)", slug, form.Name, form.Description, form.Price, taxes, form.Tags); !ok {
|
2023-02-04 10:32:39 +00:00
|
|
|
http.NotFound(w, r)
|
2023-05-05 08:54:40 +00:00
|
|
|
return
|
2023-02-04 10:32:39 +00:00
|
|
|
}
|
2023-04-25 13:28:55 +00:00
|
|
|
htmxRedirect(w, r, companyURI(company, "/products"))
|
2023-02-04 10:32:39 +00:00
|
|
|
}
|
|
|
|
|
2023-04-23 01:20:01 +00:00
|
|
|
type productFilterForm struct {
|
|
|
|
Name *InputField
|
|
|
|
Tags *TagsField
|
|
|
|
TagsCondition *ToggleField
|
|
|
|
}
|
|
|
|
|
|
|
|
func newProductFilterForm(locale *Locale) *productFilterForm {
|
|
|
|
return &productFilterForm{
|
|
|
|
Name: &InputField{
|
|
|
|
Name: "number",
|
2023-04-25 13:21:34 +00:00
|
|
|
Label: pgettext("input", "Name", locale),
|
2023-04-23 01:20:01 +00:00
|
|
|
Type: "search",
|
|
|
|
},
|
|
|
|
Tags: &TagsField{
|
|
|
|
Name: "tags",
|
|
|
|
Label: pgettext("input", "Tags", locale),
|
|
|
|
},
|
|
|
|
TagsCondition: &ToggleField{
|
|
|
|
Name: "tags_condition",
|
|
|
|
Label: pgettext("input", "Tags Condition", locale),
|
|
|
|
Selected: "and",
|
|
|
|
FirstOption: &ToggleOption{
|
|
|
|
Value: "and",
|
|
|
|
Label: pgettext("tag condition", "All", locale),
|
|
|
|
Description: gettext("Invoices must have all the specified labels.", locale),
|
|
|
|
},
|
|
|
|
SecondOption: &ToggleOption{
|
|
|
|
Value: "or",
|
|
|
|
Label: pgettext("tag condition", "Any", locale),
|
|
|
|
Description: gettext("Invoices must have at least one of the specified labels.", locale),
|
|
|
|
},
|
|
|
|
},
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func (form *productFilterForm) Parse(r *http.Request) error {
|
|
|
|
if err := r.ParseForm(); err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
form.Name.FillValue(r)
|
|
|
|
form.Tags.FillValue(r)
|
|
|
|
form.TagsCondition.FillValue(r)
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2023-07-16 18:56:11 +00:00
|
|
|
func (form *productFilterForm) HasValue() bool {
|
|
|
|
return form.Name.HasValue() ||
|
|
|
|
form.Tags.HasValue()
|
|
|
|
}
|
|
|
|
|
2023-04-23 01:20:01 +00:00
|
|
|
func mustCollectProductEntries(ctx context.Context, conn *Conn, company *Company, filters *productFilterForm) []*ProductEntry {
|
|
|
|
args := []interface{}{company.Id}
|
|
|
|
where := []string{"product.company_id = $1"}
|
|
|
|
appendWhere := func(expression string, value interface{}) {
|
|
|
|
args = append(args, value)
|
|
|
|
where = append(where, fmt.Sprintf(expression, len(args)))
|
|
|
|
}
|
|
|
|
if filters != nil {
|
|
|
|
name := strings.TrimSpace(filters.Name.String())
|
|
|
|
if name != "" {
|
|
|
|
appendWhere("product.name ilike $%d", "%"+name+"%")
|
|
|
|
}
|
|
|
|
if len(filters.Tags.Tags) > 0 {
|
|
|
|
if filters.TagsCondition.Selected == "and" {
|
|
|
|
appendWhere("product.tags @> $%d", filters.Tags)
|
|
|
|
} else {
|
|
|
|
appendWhere("product.tags && $%d", filters.Tags)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
rows := conn.MustQuery(ctx, fmt.Sprintf(`
|
2023-03-26 11:51:57 +00:00
|
|
|
select product.slug
|
|
|
|
, product.name
|
|
|
|
, to_price(price, decimal_digits)
|
Replace tag relations with array attributes
It all started when i wanted to try to filter invoices by multiple tags
using an “AND”, instead of “OR” as it was doing until now. But
something felt off and seemed to me that i was doing thing much more
complex than needed, all to be able to list the tags as a suggestion
in the input field—which i am not doing yet.
I found this article series[0] exploring different approaches for
tagging, which includes the one i was using, and comparing their
performance. I have not actually tested it, but it seems that i have
chosen the worst option, in both query time and storage.
I attempted to try using an array attribute to each table, which is more
or less the same they did in the articles but without using a separate
relation for tags, and i found out that all the queries were way easier
to write, and needed two joins less, so it was a no-brainer.
[0]: http://www.databasesoup.com/2015/01/tag-all-things.html
2023-04-07 19:31:35 +00:00
|
|
|
, tags
|
2023-03-26 11:51:57 +00:00
|
|
|
from product
|
|
|
|
join company using (company_id)
|
|
|
|
join currency using (currency_code)
|
2023-04-23 01:20:01 +00:00
|
|
|
where (%s)
|
2023-03-26 11:51:57 +00:00
|
|
|
order by name
|
2023-04-23 01:20:01 +00:00
|
|
|
`, strings.Join(where, ") AND (")), args...)
|
2023-02-04 10:32:39 +00:00
|
|
|
defer rows.Close()
|
|
|
|
|
|
|
|
var entries []*ProductEntry
|
|
|
|
for rows.Next() {
|
|
|
|
entry := &ProductEntry{}
|
2023-04-23 01:20:01 +00:00
|
|
|
if err := rows.Scan(&entry.Slug, &entry.Name, &entry.Price, &entry.Tags); err != nil {
|
2023-02-04 10:32:39 +00:00
|
|
|
panic(err)
|
|
|
|
}
|
|
|
|
entries = append(entries, entry)
|
|
|
|
}
|
|
|
|
if rows.Err() != nil {
|
|
|
|
panic(rows.Err())
|
|
|
|
}
|
|
|
|
|
|
|
|
return entries
|
|
|
|
}
|
|
|
|
|
|
|
|
type productForm struct {
|
|
|
|
locale *Locale
|
Convert from cents to “price” and back
I do not want to use floats in the Go lang application, because it is
not supposed to do anything with these values other than to print and
retrieve them from the user; all computations will be performed by
PostgreSQL in cents.
That means i have to “convert” from the price format that users expect
to see (e.g., 1.234,56) to cents (e.g., 123456) and back when passing
data between Go and PostgreSQL, and that conversion depends on the
currency’s decimal places.
At first i did everything in Go, but saw that i would need to do it in
a loop when retrieving the list of products, and immediately knew it was
a mistake—i needed a PL/pgSQL function for that.
I still need to convert from string to float, however, when printing the
value to the user. Because the string representation is in C, but i
need to format it according to the locale with golang/x/text. That
package has the information of how to correctly format numbers, but it
is in an internal package that i can not use, and numbers.Digit only
accepts numeric types, not a string.
2023-02-05 12:55:12 +00:00
|
|
|
company *Company
|
2023-02-04 10:32:39 +00:00
|
|
|
Name *InputField
|
|
|
|
Description *InputField
|
|
|
|
Price *InputField
|
|
|
|
Tax *SelectField
|
2023-03-26 11:51:57 +00:00
|
|
|
Tags *TagsField
|
2023-02-04 10:32:39 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
func newProductForm(ctx context.Context, conn *Conn, locale *Locale, company *Company) *productForm {
|
|
|
|
return &productForm{
|
Convert from cents to “price” and back
I do not want to use floats in the Go lang application, because it is
not supposed to do anything with these values other than to print and
retrieve them from the user; all computations will be performed by
PostgreSQL in cents.
That means i have to “convert” from the price format that users expect
to see (e.g., 1.234,56) to cents (e.g., 123456) and back when passing
data between Go and PostgreSQL, and that conversion depends on the
currency’s decimal places.
At first i did everything in Go, but saw that i would need to do it in
a loop when retrieving the list of products, and immediately knew it was
a mistake—i needed a PL/pgSQL function for that.
I still need to convert from string to float, however, when printing the
value to the user. Because the string representation is in C, but i
need to format it according to the locale with golang/x/text. That
package has the information of how to correctly format numbers, but it
is in an internal package that i can not use, and numbers.Digit only
accepts numeric types, not a string.
2023-02-05 12:55:12 +00:00
|
|
|
locale: locale,
|
|
|
|
company: company,
|
2023-02-04 10:32:39 +00:00
|
|
|
Name: &InputField{
|
|
|
|
Name: "name",
|
|
|
|
Label: pgettext("input", "Name", locale),
|
|
|
|
Type: "text",
|
|
|
|
Required: true,
|
|
|
|
},
|
|
|
|
Description: &InputField{
|
|
|
|
Name: "description",
|
|
|
|
Label: pgettext("input", "Description", locale),
|
2023-02-07 14:28:22 +00:00
|
|
|
Type: "textarea",
|
2023-02-04 10:32:39 +00:00
|
|
|
},
|
|
|
|
Price: &InputField{
|
|
|
|
Name: "price",
|
|
|
|
Label: pgettext("input", "Price", locale),
|
|
|
|
Type: "number",
|
|
|
|
Required: true,
|
|
|
|
Attributes: []template.HTMLAttr{
|
|
|
|
`min="0"`,
|
Convert from cents to “price” and back
I do not want to use floats in the Go lang application, because it is
not supposed to do anything with these values other than to print and
retrieve them from the user; all computations will be performed by
PostgreSQL in cents.
That means i have to “convert” from the price format that users expect
to see (e.g., 1.234,56) to cents (e.g., 123456) and back when passing
data between Go and PostgreSQL, and that conversion depends on the
currency’s decimal places.
At first i did everything in Go, but saw that i would need to do it in
a loop when retrieving the list of products, and immediately knew it was
a mistake—i needed a PL/pgSQL function for that.
I still need to convert from string to float, however, when printing the
value to the user. Because the string representation is in C, but i
need to format it according to the locale with golang/x/text. That
package has the information of how to correctly format numbers, but it
is in an internal package that i can not use, and numbers.Digit only
accepts numeric types, not a string.
2023-02-05 12:55:12 +00:00
|
|
|
template.HTMLAttr(fmt.Sprintf(`step="%v"`, company.MinCents())),
|
2023-02-04 10:32:39 +00:00
|
|
|
},
|
|
|
|
},
|
|
|
|
Tax: &SelectField{
|
2023-02-05 13:06:33 +00:00
|
|
|
Name: "tax",
|
2023-02-08 12:47:36 +00:00
|
|
|
Label: pgettext("input", "Taxes", locale),
|
|
|
|
Multiple: true,
|
2023-02-12 20:06:48 +00:00
|
|
|
Options: mustGetTaxOptions(ctx, conn, company),
|
2023-02-04 10:32:39 +00:00
|
|
|
},
|
2023-03-26 11:51:57 +00:00
|
|
|
Tags: &TagsField{
|
|
|
|
Name: "tags",
|
|
|
|
Label: pgettext("input", "Tags", locale),
|
|
|
|
},
|
2023-02-04 10:32:39 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func (form *productForm) Parse(r *http.Request) error {
|
|
|
|
if err := r.ParseForm(); err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
form.Name.FillValue(r)
|
|
|
|
form.Description.FillValue(r)
|
|
|
|
form.Price.FillValue(r)
|
|
|
|
form.Tax.FillValue(r)
|
2023-03-26 11:51:57 +00:00
|
|
|
form.Tags.FillValue(r)
|
2023-02-04 10:32:39 +00:00
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (form *productForm) Validate() bool {
|
|
|
|
validator := newFormValidator()
|
|
|
|
validator.CheckRequiredInput(form.Name, gettext("Name can not be empty.", form.locale))
|
|
|
|
if validator.CheckRequiredInput(form.Price, gettext("Price can not be empty.", form.locale)) {
|
Convert from cents to “price” and back
I do not want to use floats in the Go lang application, because it is
not supposed to do anything with these values other than to print and
retrieve them from the user; all computations will be performed by
PostgreSQL in cents.
That means i have to “convert” from the price format that users expect
to see (e.g., 1.234,56) to cents (e.g., 123456) and back when passing
data between Go and PostgreSQL, and that conversion depends on the
currency’s decimal places.
At first i did everything in Go, but saw that i would need to do it in
a loop when retrieving the list of products, and immediately knew it was
a mistake—i needed a PL/pgSQL function for that.
I still need to convert from string to float, however, when printing the
value to the user. Because the string representation is in C, but i
need to format it according to the locale with golang/x/text. That
package has the information of how to correctly format numbers, but it
is in an internal package that i can not use, and numbers.Digit only
accepts numeric types, not a string.
2023-02-05 12:55:12 +00:00
|
|
|
validator.CheckValidDecimal(form.Price, form.company.MinCents(), math.MaxFloat64, gettext("Price must be a number greater than zero.", form.locale))
|
2023-02-04 10:32:39 +00:00
|
|
|
}
|
|
|
|
validator.CheckValidSelectOption(form.Tax, gettext("Selected tax is not valid.", form.locale))
|
2023-03-01 10:55:26 +00:00
|
|
|
validator.CheckAtMostOneOfEachGroup(form.Tax, gettext("You can only select a tax of each class.", form.locale))
|
2023-02-04 10:32:39 +00:00
|
|
|
return validator.AllOK()
|
|
|
|
}
|
2023-03-26 11:51:57 +00:00
|
|
|
|
|
|
|
func (form *productForm) MustFillFromDatabase(ctx context.Context, conn *Conn, slug string) bool {
|
|
|
|
return !notFoundErrorOrPanic(conn.QueryRow(ctx, `
|
|
|
|
select product.name
|
|
|
|
, product.description
|
|
|
|
, to_price(price, decimal_digits)
|
|
|
|
, array_agg(tax_id)
|
2023-05-27 19:36:10 +00:00
|
|
|
, tags
|
2023-03-26 11:51:57 +00:00
|
|
|
from product
|
|
|
|
left join product_tax using (product_id)
|
|
|
|
join company using (company_id)
|
|
|
|
join currency using (currency_code)
|
|
|
|
where product.slug = $1
|
|
|
|
group by product_id
|
|
|
|
, product.name
|
|
|
|
, product.description
|
|
|
|
, price
|
Replace tag relations with array attributes
It all started when i wanted to try to filter invoices by multiple tags
using an “AND”, instead of “OR” as it was doing until now. But
something felt off and seemed to me that i was doing thing much more
complex than needed, all to be able to list the tags as a suggestion
in the input field—which i am not doing yet.
I found this article series[0] exploring different approaches for
tagging, which includes the one i was using, and comparing their
performance. I have not actually tested it, but it seems that i have
chosen the worst option, in both query time and storage.
I attempted to try using an array attribute to each table, which is more
or less the same they did in the articles but without using a separate
relation for tags, and i found out that all the queries were way easier
to write, and needed two joins less, so it was a no-brainer.
[0]: http://www.databasesoup.com/2015/01/tag-all-things.html
2023-04-07 19:31:35 +00:00
|
|
|
, tags
|
2023-03-26 11:51:57 +00:00
|
|
|
, decimal_digits
|
|
|
|
`, slug).Scan(
|
|
|
|
form.Name,
|
|
|
|
form.Description,
|
|
|
|
form.Price,
|
|
|
|
form.Tax,
|
|
|
|
form.Tags))
|
|
|
|
}
|
2023-04-23 01:20:01 +00:00
|
|
|
|
|
|
|
func HandleProductSearch(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
2023-04-24 00:00:38 +00:00
|
|
|
filters := newProductFilterForm(getLocale(r))
|
2023-04-23 01:20:01 +00:00
|
|
|
query := r.URL.Query()
|
2023-04-24 00:00:38 +00:00
|
|
|
index := query.Get("index")
|
|
|
|
filters.Name.Val = strings.TrimSpace(query.Get("product.name." + index))
|
|
|
|
var products []*ProductEntry
|
|
|
|
if len(filters.Name.Val) > 0 {
|
|
|
|
products = mustCollectProductEntries(r.Context(), getConn(r), mustGetCompany(r), filters)
|
2023-04-23 01:20:01 +00:00
|
|
|
}
|
2023-04-24 00:00:38 +00:00
|
|
|
mustRenderStandaloneTemplate(w, r, "products/search.gohtml", products)
|
2023-04-23 01:20:01 +00:00
|
|
|
}
|
2023-05-09 10:18:31 +00:00
|
|
|
|
|
|
|
func ServeEditProductTags(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
|
|
|
conn := getConn(r)
|
|
|
|
locale := getLocale(r)
|
|
|
|
company := getCompany(r)
|
|
|
|
slug := params[0].Value
|
Return HTTP 404 instead of 500 for invalid UUID values in URL
Since most of PL/pgSQL functions accept a `uuid` domain, we get an error
if the value is not valid, forcing us to return an HTTP 500, as we
can not detect that the error was due to that.
Instead, i now validate that the slug is indeed a valid UUID before
attempting to send it to the database, returning the correct HTTP error
code and avoiding useless calls to the database.
I based the validation function of Parse() from Google’s uuid package[0]
because this function is an order or magnitude faster in benchmarks:
goos: linux
goarch: amd64
pkg: dev.tandem.ws/tandem/numerus/pkg
cpu: Intel(R) Core(TM) i5-6200U CPU @ 2.30GHz
BenchmarkValidUuid-4 36946050 29.37 ns/op
BenchmarkValidUuid_Re-4 3633169 306.70 ns/op
The regular expression used for the benchmark was:
var re = regexp.MustCompile("^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-4[a-fA-F0-9]{3}-[8|9|aA|bB][a-fA-F0-9]{3}-[a-fA-F0-9]{12}$")
And the input parameter for both functions was the following valid UUID,
because most of the time the passed UUID will be valid:
"f47ac10b-58cc-0372-8567-0e02b2c3d479"
I did not use the uuid package, even though it is in Debian’s
repository, because i only need to check whether the value is valid,
not convert it to a byte array. As far as i know, that package can not
do that.
[0]: https://github.com/google/uuid
2023-07-17 09:46:11 +00:00
|
|
|
if !ValidUuid(slug) {
|
|
|
|
http.NotFound(w, r)
|
|
|
|
return
|
|
|
|
}
|
2023-05-09 10:18:31 +00:00
|
|
|
form := newTagsForm(companyURI(company, "/products/"+slug+"/tags"), slug, locale)
|
2023-05-27 19:36:10 +00:00
|
|
|
if notFoundErrorOrPanic(conn.QueryRow(r.Context(), `select tags from product where slug = $1`, form.Slug).Scan(form.Tags)) {
|
2023-05-09 10:18:31 +00:00
|
|
|
http.NotFound(w, r)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
mustRenderStandaloneTemplate(w, r, "tags/edit.gohtml", form)
|
|
|
|
}
|
|
|
|
|
|
|
|
func HandleUpdateProductTags(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
|
|
|
locale := getLocale(r)
|
|
|
|
conn := getConn(r)
|
|
|
|
company := getCompany(r)
|
|
|
|
slug := params[0].Value
|
Return HTTP 404 instead of 500 for invalid UUID values in URL
Since most of PL/pgSQL functions accept a `uuid` domain, we get an error
if the value is not valid, forcing us to return an HTTP 500, as we
can not detect that the error was due to that.
Instead, i now validate that the slug is indeed a valid UUID before
attempting to send it to the database, returning the correct HTTP error
code and avoiding useless calls to the database.
I based the validation function of Parse() from Google’s uuid package[0]
because this function is an order or magnitude faster in benchmarks:
goos: linux
goarch: amd64
pkg: dev.tandem.ws/tandem/numerus/pkg
cpu: Intel(R) Core(TM) i5-6200U CPU @ 2.30GHz
BenchmarkValidUuid-4 36946050 29.37 ns/op
BenchmarkValidUuid_Re-4 3633169 306.70 ns/op
The regular expression used for the benchmark was:
var re = regexp.MustCompile("^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-4[a-fA-F0-9]{3}-[8|9|aA|bB][a-fA-F0-9]{3}-[a-fA-F0-9]{12}$")
And the input parameter for both functions was the following valid UUID,
because most of the time the passed UUID will be valid:
"f47ac10b-58cc-0372-8567-0e02b2c3d479"
I did not use the uuid package, even though it is in Debian’s
repository, because i only need to check whether the value is valid,
not convert it to a byte array. As far as i know, that package can not
do that.
[0]: https://github.com/google/uuid
2023-07-17 09:46:11 +00:00
|
|
|
if !ValidUuid(slug) {
|
|
|
|
http.NotFound(w, r)
|
|
|
|
return
|
|
|
|
}
|
2023-05-09 10:18:31 +00:00
|
|
|
form := newTagsForm(companyURI(company, "/products/"+slug+"/tags/edit"), slug, locale)
|
|
|
|
if err := form.Parse(r); err != nil {
|
|
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
if err := verifyCsrfTokenValid(r); err != nil {
|
|
|
|
http.Error(w, err.Error(), http.StatusForbidden)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
if conn.MustGetText(r.Context(), "", "update product set tags = $1 where slug = $2 returning slug", form.Tags, form.Slug) == "" {
|
|
|
|
http.NotFound(w, r)
|
|
|
|
}
|
|
|
|
mustRenderStandaloneTemplate(w, r, "tags/view.gohtml", form)
|
|
|
|
}
|