commit daff6a8f097359899c89c37bcb0d00272bdb3711 Author: Aneurin Barker Snook Date: Tue Jul 7 17:25:02 2026 +0100 initial commit diff --git a/column.go b/column.go new file mode 100644 index 0000000..9d61371 --- /dev/null +++ b/column.go @@ -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) +} diff --git a/column_test.go b/column_test.go new file mode 100644 index 0000000..871a922 --- /dev/null +++ b/column_test.go @@ -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") + }) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..21b8afd --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f571a34 --- /dev/null +++ b/go.sum @@ -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= diff --git a/on.go b/on.go new file mode 100644 index 0000000..884931c --- /dev/null +++ b/on.go @@ -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 +} diff --git a/query.go b/query.go new file mode 100644 index 0000000..c478c27 --- /dev/null +++ b/query.go @@ -0,0 +1,6 @@ +package simql + +type Query interface { + Args() []any + String() string +} diff --git a/select.go b/select.go new file mode 100644 index 0000000..e7daaa3 --- /dev/null +++ b/select.go @@ -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 +} diff --git a/select_test.go b/select_test.go new file mode 100644 index 0000000..8ee1b06 --- /dev/null +++ b/select_test.go @@ -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") + }) + } +} diff --git a/table.go b/table.go new file mode 100644 index 0000000..d3f42b9 --- /dev/null +++ b/table.go @@ -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)) +} diff --git a/table_test.go b/table_test.go new file mode 100644 index 0000000..6a60b0b --- /dev/null +++ b/table_test.go @@ -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") + }) + } +} diff --git a/where.go b/where.go new file mode 100644 index 0000000..92f5723 --- /dev/null +++ b/where.go @@ -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) +} diff --git a/where_test.go b/where_test.go new file mode 100644 index 0000000..b2df521 --- /dev/null +++ b/where_test.go @@ -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") + }) + } +}