mirror of
https://github.com/hanzoai/orm.git
synced 2026-10-30 15:01:22 +00:00
Modules declare go 1.27.1 and the build images follow. go fix applied the modernizers: any, reflect.TypeFor, the atomic types, WaitGroup.Go, the reflect field iterators, CutPrefix, SplitSeq, strings.Builder and range over int. omitzero is not applied, since it changes what the JSON says. Co-authored-by: Hanzo Dev <[email protected]>
141 lines
4.6 KiB
Go
141 lines
4.6 KiB
Go
package db
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"sync/atomic"
|
|
"unicode"
|
|
)
|
|
|
|
var idCounter atomic.Uint64
|
|
|
|
// QueryFilter holds a filter condition.
|
|
type QueryFilter struct {
|
|
Field string
|
|
Op string
|
|
Value any
|
|
}
|
|
|
|
// QueryOrder holds an order directive.
|
|
type QueryOrder struct {
|
|
Field string
|
|
Desc bool
|
|
}
|
|
|
|
// ParseFilterString parses "Field=" into field and operator.
|
|
// ParseFilterString splits "field op" — the Datastore spelling, where the operator
|
|
// rides on the end of the string and is separated from the field by a space.
|
|
//
|
|
// That space is not decoration and it must be cut. It used to survive into the
|
|
// field, so `Filter("model =", v)` looked for the property `"model "`, which no
|
|
// entity has: json_extract returned NULL, the predicate matched nothing, and the
|
|
// query answered ZERO ROWS with no error. Every filtered read in this package was
|
|
// empty, and empty is what an honest "no matches" looks like — so nothing reported
|
|
// it. Trim both ends; a caller who writes extra spaces means the field.
|
|
func ParseFilterString(s string) (field, op string) {
|
|
s = strings.TrimSpace(s)
|
|
// Longest first: ">=" must not be read as ">" with a stray "=" on the field.
|
|
for _, opStr := range []string{">=", "<=", "!=", "=", ">", "<"} {
|
|
if before, ok := strings.CutSuffix(s, opStr); ok {
|
|
return strings.TrimSpace(before), opStr
|
|
}
|
|
}
|
|
return s, "="
|
|
}
|
|
|
|
// NormalizeOp converts operators to SQL.
|
|
func NormalizeOp(op string) string {
|
|
switch op {
|
|
case "=", "==":
|
|
return "="
|
|
case "!=", "<>":
|
|
return "!="
|
|
case ">", ">=", "<", "<=":
|
|
return op
|
|
default:
|
|
return "="
|
|
}
|
|
}
|
|
|
|
// ToJSONFieldName converts a Go struct field name (PascalCase) to its JSON
|
|
// equivalent (camelCase) by lowercasing the first letter of each path segment.
|
|
// Handles nested paths like "Account.TransactionHash" → "account.transactionHash".
|
|
//
|
|
// It also drops every character a JSON field path cannot contain, because the
|
|
// result is interpolated into a SQL string literal — json_extract(data, '$.X')
|
|
// — where a quote ENDS the literal and the rest is executed. Callers pass this
|
|
// straight from a query string: commerce's generic REST list helper does
|
|
// .Order(c.Query("sort")) for every entity it serves. Without the filter,
|
|
// ?sort=x') UNION SELECT name FROM sqlite_master -- closed the literal and
|
|
// appended a working UNION.
|
|
//
|
|
// Filtering rather than rejecting keeps the signature and fails CLOSED: a name
|
|
// that had illegal characters becomes a field that does not exist, so a filter
|
|
// on it matches no rows and an ORDER BY on it sorts every row equally. A field
|
|
// path is identifier segments joined by dots and never needed anything else.
|
|
func ToJSONFieldName(field string) string {
|
|
if field == "" {
|
|
return field
|
|
}
|
|
parts := strings.Split(field, ".")
|
|
for i, p := range parts {
|
|
parts[i] = LowercaseFirst(jsonPathSegment(p))
|
|
}
|
|
return strings.Join(parts, ".")
|
|
}
|
|
|
|
// jsonPathSegment keeps only what an identifier may hold. Everything else —
|
|
// quotes, parentheses, whitespace, comment markers — is dropped rather than
|
|
// escaped, so there is no escaping to get wrong and no dialect to be right about.
|
|
func jsonPathSegment(s string) string {
|
|
var b strings.Builder
|
|
for _, r := range s {
|
|
if r == '_' || (r >= '0' && r <= '9') || (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') {
|
|
b.WriteRune(r)
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// LowercaseFirst lowercases the first character of a string.
|
|
func LowercaseFirst(s string) string {
|
|
if s == "" {
|
|
return s
|
|
}
|
|
runes := []rune(s)
|
|
runes[0] = unicode.ToLower(runes[0])
|
|
return string(runes)
|
|
}
|
|
|
|
// newStringID allocates the string ID of a key the store assigns itself: the
|
|
// wall-clock nanosecond with a process-local sequence appended, e.g.
|
|
// "17847909129933610000001". Keys come out roughly time-ordered, which is why
|
|
// it is a clock and not a UUID.
|
|
//
|
|
// It is unique WITHIN A PROCESS ONLY, and deliberately unexported so nothing
|
|
// outside this package can mistake it for a general ID generator. Two
|
|
// processes that read the same nanosecond and are at the same point in their
|
|
// own sequence produce the same value; the counter is per-process and wraps at
|
|
// 10000. Anything that must be unique across processes wants a UUID
|
|
// (uuid.NewString), not this. Callers that need a store-assigned key should go
|
|
// through NewIncompleteKey or AllocateIDs.
|
|
func newStringID() string {
|
|
seq := idCounter.Add(1)
|
|
return fmt.Sprintf("%d%04d", timeNow().UnixNano(), seq%10000)
|
|
}
|
|
|
|
// DecodeCursor parses a cursor string.
|
|
func DecodeCursor(s string) (Cursor, error) {
|
|
return &SimpleCursor{ID: s}, nil
|
|
}
|
|
|
|
// SimpleCursor is a basic cursor implementation.
|
|
type SimpleCursor struct {
|
|
ID string
|
|
Offset int
|
|
}
|
|
|
|
func (c *SimpleCursor) String() string {
|
|
return c.ID
|
|
}
|