initial commit
This commit is contained in:
@@ -0,0 +1,29 @@
|
|||||||
|
package simql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
)
|
||||||
|
|
||||||
|
var columnRegexp = regexp.MustCompile("^([^ ]+)( as ([^ ]+))?$")
|
||||||
|
|
||||||
|
type Column string
|
||||||
|
|
||||||
|
func (c Column) IsValid() bool {
|
||||||
|
return columnRegexp.MatchString(string(c))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Column) Parse() (string, string, bool) {
|
||||||
|
if columnRegexp.MatchString(string(c)) {
|
||||||
|
result := columnRegexp.FindAllStringSubmatch(string(c), -1)
|
||||||
|
for _, r := range result {
|
||||||
|
column, as := r[1], r[3]
|
||||||
|
return column, as, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Column) String() string {
|
||||||
|
return string(c)
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package simql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/alecthomas/assert/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestColumn(t *testing.T) {
|
||||||
|
type TestCase struct {
|
||||||
|
In string
|
||||||
|
IsValid bool
|
||||||
|
Column string
|
||||||
|
As string
|
||||||
|
}
|
||||||
|
|
||||||
|
testCases := []TestCase{
|
||||||
|
// Column only
|
||||||
|
{In: "*", IsValid: true, Column: "*"},
|
||||||
|
{In: "email", IsValid: true, Column: "email"},
|
||||||
|
{In: "customer.*", IsValid: true, Column: "customer.*"},
|
||||||
|
{In: "customer.email", IsValid: true, Column: "customer.email"},
|
||||||
|
// Column and alias
|
||||||
|
{In: "customer.email as email", IsValid: true, Column: "customer.email", As: "email"},
|
||||||
|
// Invalid format (spaces)
|
||||||
|
{In: "customer.email as email"},
|
||||||
|
{In: " customer.email as email"},
|
||||||
|
{In: "also as broken "},
|
||||||
|
// Invalid format (disallowed characters) - TODO
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, testCase := range testCases {
|
||||||
|
t.Run(testCase.In, func(t *testing.T) {
|
||||||
|
c := Column(testCase.In)
|
||||||
|
column, as, ok := c.Parse()
|
||||||
|
|
||||||
|
assert.Equal(t, testCase.IsValid, ok, "incorrect test case")
|
||||||
|
assert.Equal(t, testCase.Column, column, "parsed column incorrectly")
|
||||||
|
assert.Equal(t, testCase.As, as, "parsed as incorrectly")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
module code.aneur.in/go/simql
|
||||||
|
|
||||||
|
go 1.25.6
|
||||||
|
|
||||||
|
require github.com/alecthomas/assert/v2 v2.11.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/alecthomas/repr v0.4.0 // indirect
|
||||||
|
github.com/hexops/gotextdiff v1.0.3 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||||
|
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||||
|
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
|
||||||
|
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||||
|
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||||
|
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package simql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type OnClause struct {
|
||||||
|
column string
|
||||||
|
operator string
|
||||||
|
value any
|
||||||
|
|
||||||
|
and []*OnClause
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *OnClause) And(column, operator string, value any) *OnClause {
|
||||||
|
o.and = append(o.and, On(column, operator, value))
|
||||||
|
return o
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *OnClause) String() string {
|
||||||
|
strs := []string{fmt.Sprintf("%s %s %s", o.column, o.operator, o.value)}
|
||||||
|
|
||||||
|
for _, next := range o.and {
|
||||||
|
strs = append(strs, "and", next.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(strs, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func On(column, operator string, value any) *OnClause {
|
||||||
|
o := &OnClause{
|
||||||
|
column: column,
|
||||||
|
operator: operator,
|
||||||
|
value: value,
|
||||||
|
|
||||||
|
and: []*OnClause{},
|
||||||
|
}
|
||||||
|
|
||||||
|
return o
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package simql
|
||||||
|
|
||||||
|
type Query interface {
|
||||||
|
Args() []any
|
||||||
|
String() string
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package simql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SelectQuery struct {
|
||||||
|
columns []Column
|
||||||
|
|
||||||
|
// (from|join type), table, as, A, operator, B
|
||||||
|
tables []SelectTable
|
||||||
|
|
||||||
|
where []Where
|
||||||
|
args []any
|
||||||
|
|
||||||
|
limit int
|
||||||
|
offset int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SelectQuery) Args() []any {
|
||||||
|
return s.args
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SelectQuery) Column(def string) *SelectQuery {
|
||||||
|
s.columns = append(s.columns, Column(def))
|
||||||
|
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SelectQuery) Columns(defs ...string) *SelectQuery {
|
||||||
|
for _, def := range defs {
|
||||||
|
s.columns = append(s.columns, Column(def))
|
||||||
|
}
|
||||||
|
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SelectQuery) IsValid() bool {
|
||||||
|
for _, c := range s.columns {
|
||||||
|
if !c.IsValid() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, t := range s.tables {
|
||||||
|
if !t.IsValid() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, w := range s.where {
|
||||||
|
if !w.IsValid() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SelectQuery) LeftJoin(def string) *SelectQuery {
|
||||||
|
s.tables = append(s.tables, LeftJoin(def))
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SelectQuery) Limit(limit int) *SelectQuery {
|
||||||
|
s.limit = limit
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SelectQuery) InnerJoin(def string) *SelectQuery {
|
||||||
|
s.tables = append(s.tables, InnerJoin(def))
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SelectQuery) RightJoin(def string) *SelectQuery {
|
||||||
|
s.tables = append(s.tables, RightJoin(def))
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SelectQuery) Offset(limit int) *SelectQuery {
|
||||||
|
s.limit = limit
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SelectQuery) String() string {
|
||||||
|
strs := []string{"select"}
|
||||||
|
|
||||||
|
if len(s.columns) > 0 {
|
||||||
|
for i, column := range s.columns {
|
||||||
|
if i < len(s.columns)-1 {
|
||||||
|
strs = append(strs, fmt.Sprintf("%s,", column.String()))
|
||||||
|
} else {
|
||||||
|
strs = append(strs, column.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
strs = append(strs, "*")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, table := range s.tables {
|
||||||
|
strs = append(strs, table.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(s.where) > 0 {
|
||||||
|
strs = append(strs, "where")
|
||||||
|
|
||||||
|
for _, w := range s.where {
|
||||||
|
strs = append(strs, w.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.limit > 0 {
|
||||||
|
if s.offset > 0 {
|
||||||
|
strs = append(strs, fmt.Sprintf("limit %d, %d", s.offset, s.limit))
|
||||||
|
} else {
|
||||||
|
strs = append(strs, fmt.Sprintf("limit %d", s.limit))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(strs, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SelectQuery) Where(def string, args ...any) *SelectQuery {
|
||||||
|
s.where = append(s.where, Where(def))
|
||||||
|
|
||||||
|
if len(args) > 0 {
|
||||||
|
if s.args == nil {
|
||||||
|
s.args = []any{}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.args = append(s.args, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func Select(from string) *SelectQuery {
|
||||||
|
s := &SelectQuery{
|
||||||
|
columns: []Column{},
|
||||||
|
tables: []SelectTable{From(from)},
|
||||||
|
|
||||||
|
where: []Where{},
|
||||||
|
}
|
||||||
|
|
||||||
|
return s
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package simql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/alecthomas/assert/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSelect(t *testing.T) {
|
||||||
|
type TestCase struct {
|
||||||
|
In *SelectQuery
|
||||||
|
Args []any
|
||||||
|
String string
|
||||||
|
}
|
||||||
|
|
||||||
|
testCases := []TestCase{
|
||||||
|
{
|
||||||
|
In: Select("customer"),
|
||||||
|
String: "select * from customer",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
In: Select("customer as c"),
|
||||||
|
String: "select * from customer as c",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
In: Select("customer as c").Columns("c.id", "c.name"),
|
||||||
|
String: "select c.id, c.name from customer as c",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
In: Select("customer as c").InnerJoin("invoices as i on i.customer_id = c.id").Columns("c.name", "i.date"),
|
||||||
|
String: "select c.name, i.date from customer as c inner join invoices as i on i.customer_id = c.id",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
In: Select("customer").Where("id = ?", 1234),
|
||||||
|
Args: []any{1234},
|
||||||
|
String: "select * from customer where id = ?",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
In: Select("customer").Where("id = ?", 1234).Where("and amount > ?", 1000),
|
||||||
|
Args: []any{1234, 1000},
|
||||||
|
String: "select * from customer where id = ? and amount > ?",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
In: Select("customer").Where("id = ?", 1234).Columns("name", "email"),
|
||||||
|
Args: []any{1234},
|
||||||
|
String: "select name, email from customer where id = ?",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, testCase := range testCases {
|
||||||
|
t.Run(testCase.String, func(t *testing.T) {
|
||||||
|
assert.Equal(t, testCase.Args, testCase.In.Args(), "collected arguments incorrectly")
|
||||||
|
assert.Equal(t, testCase.String, testCase.In.String(), "stringified incorrectly")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package simql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var selectTableRegexp = regexp.MustCompile("^(from|left join|inner join|right join) ([^ ]+)( (as) ([^ ]+))?( (on) ([^ ]+) (=|!=|>|>=|<|<=) ([^ ]+)( (and) ([^ ]+) (=|!=|>|>=|<|<=) ([^ ]+))*)?$")
|
||||||
|
|
||||||
|
type SelectTable string
|
||||||
|
|
||||||
|
func (t SelectTable) IsValid() bool {
|
||||||
|
return selectTableRegexp.MatchString(string(t))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t SelectTable) Parse() (selectType, table, as string, on *OnClause, ok bool) {
|
||||||
|
if selectTableRegexp.MatchString(string(t)) {
|
||||||
|
ok = true
|
||||||
|
|
||||||
|
result := selectTableRegexp.FindAllStringSubmatch(string(t), -1)
|
||||||
|
|
||||||
|
for i, r := range result {
|
||||||
|
// for j, c := range r {
|
||||||
|
// fmt.Println(i, j, c)
|
||||||
|
// }
|
||||||
|
|
||||||
|
if i == 0 {
|
||||||
|
selectType, table = r[1], r[2]
|
||||||
|
|
||||||
|
if r[4] == "as" {
|
||||||
|
as = r[5]
|
||||||
|
}
|
||||||
|
|
||||||
|
if r[7] == "on" {
|
||||||
|
on = On(r[8], r[9], r[10])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if r[12] == "and" {
|
||||||
|
on.And(r[13], r[14], r[15])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t SelectTable) String() string {
|
||||||
|
return string(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func From(def string) SelectTable {
|
||||||
|
if strings.Index(def, "from ") == 0 {
|
||||||
|
return SelectTable(def)
|
||||||
|
}
|
||||||
|
|
||||||
|
return SelectTable(fmt.Sprintf("from %s", def))
|
||||||
|
}
|
||||||
|
|
||||||
|
func InnerJoin(def string) SelectTable {
|
||||||
|
if strings.Index(def, "inner join ") == 0 {
|
||||||
|
return SelectTable(def)
|
||||||
|
}
|
||||||
|
|
||||||
|
return SelectTable(fmt.Sprintf("inner join %s", def))
|
||||||
|
}
|
||||||
|
|
||||||
|
func LeftJoin(def string) SelectTable {
|
||||||
|
if strings.Index(def, "left join ") == 0 {
|
||||||
|
return SelectTable(def)
|
||||||
|
}
|
||||||
|
|
||||||
|
return SelectTable(fmt.Sprintf("left join %s", def))
|
||||||
|
}
|
||||||
|
|
||||||
|
func RightJoin(def string) SelectTable {
|
||||||
|
if strings.Index(def, "right join ") == 0 {
|
||||||
|
return SelectTable(def)
|
||||||
|
}
|
||||||
|
|
||||||
|
return SelectTable(fmt.Sprintf("right join %s", def))
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package simql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/alecthomas/assert/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSelectTable(t *testing.T) {
|
||||||
|
type TestCase struct {
|
||||||
|
In string
|
||||||
|
IsValid bool
|
||||||
|
SelectType string
|
||||||
|
Table string
|
||||||
|
As string
|
||||||
|
On *OnClause
|
||||||
|
}
|
||||||
|
|
||||||
|
testCases := []TestCase{
|
||||||
|
{
|
||||||
|
In: "from customer",
|
||||||
|
IsValid: true,
|
||||||
|
SelectType: "from",
|
||||||
|
Table: "customer",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
In: "right join invoice on invoice.customer_id = customer.id",
|
||||||
|
IsValid: true,
|
||||||
|
SelectType: "right join",
|
||||||
|
Table: "invoice",
|
||||||
|
On: On("invoice.customer_id", "=", "customer.id"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
In: "inner join invoice as i on i.customer_id = c.id",
|
||||||
|
IsValid: true,
|
||||||
|
SelectType: "inner join",
|
||||||
|
Table: "invoice",
|
||||||
|
As: "i",
|
||||||
|
On: On("i.customer_id", "=", "c.id"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
In: "left join invoice as i on i.customer_id = c.id and i.paid = false",
|
||||||
|
IsValid: true,
|
||||||
|
SelectType: "left join",
|
||||||
|
Table: "invoice",
|
||||||
|
As: "i",
|
||||||
|
On: On("i.customer_id", "=", "c.id").And("i.paid", "=", "false"),
|
||||||
|
},
|
||||||
|
// Currently failing - need to fix bug with 3+ on statements
|
||||||
|
{
|
||||||
|
In: "left join invoice as i on i.customer_id = c.id and i.paid = false and i.amount > 1000",
|
||||||
|
IsValid: true,
|
||||||
|
SelectType: "left join",
|
||||||
|
Table: "invoice",
|
||||||
|
As: "i",
|
||||||
|
On: On("i.customer_id", "=", "c.id").And("i.paid", "=", "false").And("i.amount", ">", "1000"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, testCase := range testCases {
|
||||||
|
t.Run(testCase.In, func(t *testing.T) {
|
||||||
|
st := SelectTable(testCase.In)
|
||||||
|
selectType, table, as, on, ok := st.Parse()
|
||||||
|
|
||||||
|
assert.Equal(t, testCase.IsValid, ok, "incorrect test case")
|
||||||
|
assert.Equal(t, testCase.SelectType, selectType, "parsed select type incorrectly")
|
||||||
|
assert.Equal(t, testCase.Table, table, "parsed table incorrectly")
|
||||||
|
assert.Equal(t, testCase.As, as, "parsed as incorrectly")
|
||||||
|
assert.Equal(t, testCase.On, on, "parsed on incorrectly")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package simql
|
||||||
|
|
||||||
|
import "regexp"
|
||||||
|
|
||||||
|
var whereRegexp = regexp.MustCompile("^((and|or) )?([^ ]+) (=|!=|>|>=|<|<=) ([^ ]+)?$")
|
||||||
|
|
||||||
|
type Where string
|
||||||
|
|
||||||
|
func (w Where) IsValid() bool {
|
||||||
|
return whereRegexp.MatchString(string(w))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w Where) Parse() (glue, column, operator string, value any, ok bool) {
|
||||||
|
if whereRegexp.MatchString(string(w)) {
|
||||||
|
ok = true
|
||||||
|
|
||||||
|
result := whereRegexp.FindAllStringSubmatch(string(w), -1)
|
||||||
|
for _, r := range result {
|
||||||
|
glue, column, operator, value = r[2], r[3], r[4], r[5]
|
||||||
|
|
||||||
|
if value == "?" {
|
||||||
|
value = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w Where) String() string {
|
||||||
|
return string(w)
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package simql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/alecthomas/assert/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWhere(t *testing.T) {
|
||||||
|
type TestCase struct {
|
||||||
|
In string
|
||||||
|
IsValid bool
|
||||||
|
Glue string
|
||||||
|
Column string
|
||||||
|
Operator string
|
||||||
|
Value any
|
||||||
|
}
|
||||||
|
|
||||||
|
testCases := []TestCase{
|
||||||
|
{In: "id = ?", IsValid: true, Column: "id", Operator: "="},
|
||||||
|
{In: "id = 1234", IsValid: true, Column: "id", Operator: "=", Value: "1234"},
|
||||||
|
{In: "and id = ?", IsValid: true, Glue: "and", Column: "id", Operator: "="},
|
||||||
|
{In: "or id = ?", IsValid: true, Glue: "or", Column: "id", Operator: "="},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, testCase := range testCases {
|
||||||
|
t.Run(testCase.In, func(t *testing.T) {
|
||||||
|
w := Where(testCase.In)
|
||||||
|
glue, column, operator, value, ok := w.Parse()
|
||||||
|
|
||||||
|
assert.Equal(t, testCase.IsValid, ok, "incorrect test case")
|
||||||
|
assert.Equal(t, testCase.Glue, glue, "parsed glue incorrectly")
|
||||||
|
assert.Equal(t, testCase.Column, column, "parsed column incorrectly")
|
||||||
|
assert.Equal(t, testCase.Operator, operator, "parsed operator incorrectly")
|
||||||
|
assert.Equal(t, testCase.Value, value, "parsed value incorrectly")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user