Skip to content

Commit f0384f9

Browse files
committed
0.6
1 parent cc56df6 commit f0384f9

7 files changed

Lines changed: 257 additions & 15 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Change Log
22

3-
## Unreleased
3+
## 0.6.0 - 2026-04-17
44

55
### Added
66
* Configurable JWT token expiry (`TokenExpiry` config, default 24h)
@@ -18,6 +18,8 @@
1818
* TLS minimum version enforced to TLS 1.2
1919
* Session keys now use SHA-256 hash instead of raw token strings
2020
* Removed hardcoded JWT secret from sample config
21+
* RPC calls now emit named parameters in a deterministic order (function-signature order, falling back to alphabetical), so `pg_stat_statements` aggregates identical calls under a single `queryid` instead of one per JSON key permutation
22+
* M2M view relationship synthesis now deterministically prefers view junctions over base-table junctions (fixes a flaky "permission denied" error when the base junction lived in a schema the caller lacked USAGE on)
2123

2224
### Improved
2325
* Session improvements and tests

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ It is mostly compatible with [PostgREST](https://postgrest.org/en/stable/), with
88

99
The main differences are:
1010

11-
* SmoothDB is in development and beta quality. Prefer PostgREST for now, which is rock solid
11+
* SmoothDB is actively developed and maturing quickly; PostgREST remains the more battle-tested option for critical production workloads
1212
* SmoothDB is faster and has a lower CPU load
1313
* It is written in Go
1414
* Can be used both stand-alone and as a library (the main motivation for writing this)
@@ -19,6 +19,10 @@ The main differences are:
1919
See [TODO.md](TODO.md) for the many things to be completed.
2020
Please create issues to let me know your priorities.
2121

22+
## About this project
23+
24+
SmoothDB is not *vibe-coded*. It was started in 2022 as a solid, versatile middleware meant to serve as a reliable building block for data-driven applications. Since 2025 we have been pairing that foundation with strong LLMs to harden, review, and extend the code — aiming to follow the high bar set by PostgREST in correctness, safety, and API fidelity.
25+
2226
## Getting started
2327

2428
### Install

database/querybuilder.go

Lines changed: 61 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -810,6 +810,54 @@ func onConflictClause(table, schema string, fields []string,
810810
return s
811811
}
812812

813+
// orderedRecordKeys returns record's keys in a deterministic order for use
814+
// when building RPC CALL sites. When a function signature is available and
815+
// has no unnamed parameters, IN/INOUT/VARIADIC argument names are emitted in
816+
// declared order; any extra keys (or the whole record when no signature is
817+
// known) follow in alphabetical order. columnFields, when non-empty,
818+
// restricts which keys are included.
819+
func orderedRecordKeys(record Record, columnFields map[string]struct{}, f *Function) []string {
820+
included := func(key string) bool {
821+
if len(columnFields) == 0 {
822+
return true
823+
}
824+
_, ok := columnFields[key]
825+
return ok
826+
}
827+
828+
var keys []string
829+
seen := map[string]bool{}
830+
if f != nil && !f.HasUnnamed {
831+
for _, arg := range f.Arguments {
832+
// Only IN-like modes are passed as input params.
833+
// Mode 0 is the default (IN) when proargmodes is NULL.
834+
if arg.Mode != 0 && arg.Mode != 'i' && arg.Mode != 'b' && arg.Mode != 'v' {
835+
continue
836+
}
837+
if _, ok := record[arg.Name]; !ok {
838+
continue
839+
}
840+
if !included(arg.Name) {
841+
continue
842+
}
843+
keys = append(keys, arg.Name)
844+
seen[arg.Name] = true
845+
}
846+
}
847+
var extra []string
848+
for k := range record {
849+
if seen[k] {
850+
continue
851+
}
852+
if !included(k) {
853+
continue
854+
}
855+
extra = append(extra, k)
856+
}
857+
sort.Strings(extra)
858+
return append(keys, extra...)
859+
}
860+
813861
type CommonBuilder struct{}
814862

815863
func (CommonBuilder) BuildInsert(table string, records []Record, parts *QueryParts, options *QueryOptions, info *SchemaInfo) (
@@ -1192,15 +1240,21 @@ func (CommonBuilder) BuildExecute(name string, record Record, parts *QueryParts,
11921240
query string, valueList []any, err error) {
11931241

11941242
stack := BuildStack{info: info}
1243+
schema := options.Schema
1244+
1245+
var f *Function
1246+
if info != nil {
1247+
f = info.GetFunction(_s(name, schema))
1248+
}
1249+
1250+
// Determine a deterministic key order so identical RPC calls generate
1251+
// identical SQL (pg_stat_statements hashes by normalized query). Prefer
1252+
// the declared function-signature order; fall back to alphabetical.
1253+
keys := orderedRecordKeys(record, parts.columnFields, f)
1254+
11951255
var pairs string
11961256
var i int
1197-
for key := range record {
1198-
// check if there are specified columns
1199-
if len(parts.columnFields) > 0 {
1200-
if _, ok := parts.columnFields[key]; !ok {
1201-
continue
1202-
}
1203-
}
1257+
for _, key := range keys {
12041258
if pairs != "" {
12051259
pairs += ", "
12061260
}
@@ -1213,11 +1267,9 @@ func (CommonBuilder) BuildExecute(name string, record Record, parts *QueryParts,
12131267
valueList = append(valueList, record[key])
12141268
}
12151269

1216-
schema := options.Schema
12171270
// Extract the return type and discover if it is a table.
12181271
// In that case, we will use its name and schema to compose the select clause
12191272
var t, s string
1220-
f := info.GetFunction(_s(name, schema))
12211273
if f != nil {
12221274
rettype := info.GetTypeById(f.ReturnTypeId)
12231275
if rettype.IsTable {

database/querybuilder_test.go

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,3 +332,151 @@ func TestRecursiveParserErrors(t *testing.T) {
332332
}
333333
}
334334
}
335+
336+
// TestBuildExecuteDeterministicOrder guards against the pg_stat_statements
337+
// fragmentation bug (see CollHub doc 27565): named RPC parameters must be
338+
// emitted in a stable order regardless of Go map iteration so identical calls
339+
// hash to the same queryid.
340+
func TestBuildExecuteDeterministicOrder(t *testing.T) {
341+
record := Record{
342+
"p_a": 1,
343+
"p_b": "hello",
344+
"p_c": 2,
345+
"p_d": 3.14,
346+
"p_e": true,
347+
}
348+
349+
// Without SchemaInfo: fall back to alphabetical key order.
350+
t.Run("no schema info", func(t *testing.T) {
351+
var first string
352+
for i := 0; i < 50; i++ {
353+
q, _, err := CommonBuilder{}.BuildExecute("fn", record, &QueryParts{}, &QueryOptions{}, nil)
354+
if err != nil {
355+
t.Fatalf("BuildExecute error: %v", err)
356+
}
357+
if i == 0 {
358+
first = q
359+
continue
360+
}
361+
if q != first {
362+
t.Fatalf("non-deterministic SQL across runs:\n first: %s\n got: %s", first, q)
363+
}
364+
}
365+
// Alphabetical: p_a, p_b, p_c, p_d, p_e
366+
want := `SELECT * FROM "fn"("p_a" := $1, "p_b" := $2, "p_c" := $3, "p_d" := $4, "p_e" := $5) t `
367+
if first != want {
368+
t.Errorf("expected alphabetical order\n want: %s\n got: %s", want, first)
369+
}
370+
})
371+
372+
// With SchemaInfo: use declared signature order.
373+
t.Run("with signature", func(t *testing.T) {
374+
info := &SchemaInfo{
375+
cachedTypes: map[uint32]Type{0: {}},
376+
cachedFunctions: map[string]Function{
377+
"fn": {
378+
Name: "fn",
379+
Schema: "",
380+
Arguments: []Argument{
381+
{Name: "p_c", Mode: 'i'},
382+
{Name: "p_a", Mode: 'i'},
383+
{Name: "p_e", Mode: 'i'},
384+
{Name: "p_b", Mode: 'i'},
385+
{Name: "p_d", Mode: 'i'},
386+
},
387+
},
388+
},
389+
}
390+
var first string
391+
var firstValues []any
392+
for i := 0; i < 50; i++ {
393+
q, v, err := CommonBuilder{}.BuildExecute("fn", record, &QueryParts{}, &QueryOptions{}, info)
394+
if err != nil {
395+
t.Fatalf("BuildExecute error: %v", err)
396+
}
397+
if i == 0 {
398+
first = q
399+
firstValues = v
400+
continue
401+
}
402+
if q != first {
403+
t.Fatalf("non-deterministic SQL across runs:\n first: %s\n got: %s", first, q)
404+
}
405+
if !compareValues(v, firstValues) {
406+
t.Fatalf("non-deterministic values across runs:\n first: %v\n got: %v", firstValues, v)
407+
}
408+
}
409+
// Signature order: p_c, p_a, p_e, p_b, p_d
410+
want := `SELECT * FROM "fn"("p_c" := $1, "p_a" := $2, "p_e" := $3, "p_b" := $4, "p_d" := $5) t `
411+
if first != want {
412+
t.Errorf("expected signature order\n want: %s\n got: %s", want, first)
413+
}
414+
wantValues := []any{2, 1, true, "hello", 3.14}
415+
if !compareValues(firstValues, wantValues) {
416+
t.Errorf("values must follow key order\n want: %v\n got: %v", wantValues, firstValues)
417+
}
418+
})
419+
420+
// Extra record keys not in signature come after, in alphabetical order.
421+
t.Run("extra keys after signature", func(t *testing.T) {
422+
info := &SchemaInfo{
423+
cachedTypes: map[uint32]Type{0: {}},
424+
cachedFunctions: map[string]Function{
425+
"fn": {
426+
Name: "fn",
427+
Schema: "",
428+
Arguments: []Argument{
429+
{Name: "p_a", Mode: 'i'},
430+
{Name: "p_b", Mode: 'i'},
431+
},
432+
},
433+
},
434+
}
435+
rec := Record{"p_b": 2, "p_a": 1, "z_extra": 99, "a_extra": 98}
436+
var first string
437+
for i := 0; i < 50; i++ {
438+
q, _, err := CommonBuilder{}.BuildExecute("fn", rec, &QueryParts{}, &QueryOptions{}, info)
439+
if err != nil {
440+
t.Fatalf("BuildExecute error: %v", err)
441+
}
442+
if i == 0 {
443+
first = q
444+
continue
445+
}
446+
if q != first {
447+
t.Fatalf("non-deterministic SQL:\n first: %s\n got: %s", first, q)
448+
}
449+
}
450+
want := `SELECT * FROM "fn"("p_a" := $1, "p_b" := $2, "a_extra" := $3, "z_extra" := $4) t `
451+
if first != want {
452+
t.Errorf("unexpected order\n want: %s\n got: %s", want, first)
453+
}
454+
})
455+
456+
// OUT/TABLE-mode arguments must not be emitted as input params.
457+
t.Run("skip out mode args", func(t *testing.T) {
458+
info := &SchemaInfo{
459+
cachedTypes: map[uint32]Type{0: {}},
460+
cachedFunctions: map[string]Function{
461+
"fn": {
462+
Name: "fn",
463+
Schema: "",
464+
Arguments: []Argument{
465+
{Name: "p_in", Mode: 'i'},
466+
{Name: "p_out", Mode: 'o'},
467+
{Name: "p_tbl", Mode: 't'},
468+
},
469+
},
470+
},
471+
}
472+
rec := Record{"p_in": 1}
473+
q, _, err := CommonBuilder{}.BuildExecute("fn", rec, &QueryParts{}, &QueryOptions{}, info)
474+
if err != nil {
475+
t.Fatalf("BuildExecute error: %v", err)
476+
}
477+
want := `SELECT * FROM "fn"("p_in" := $1) t `
478+
if q != want {
479+
t.Errorf("OUT/TABLE args should be skipped\n want: %s\n got: %s", want, q)
480+
}
481+
})
482+
}

database/schemainfo.go

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package database
22

33
import (
44
"context"
5+
"sort"
56

67
"github.com/samber/lo"
78
)
@@ -499,8 +500,17 @@ func (si *SchemaInfo) addViewRelationships(deps []ViewColDep) {
499500
// M2M view relationships: for each existing M2M between base tables,
500501
// create M2M rels for view surfaces on both sides and the junction.
501502
// Only process each M2M pair once (deduplicate by junction + sorted table pair).
503+
// Iterate the relationships map in sorted key order so the deduplication
504+
// below (first junction wins) is deterministic across runs, and prefers
505+
// view junctions so callers only need privileges on the exposed schema.
502506
seen := map[string]bool{}
503-
for _, rels := range si.cachedRelationships {
507+
relKeys := make([]string, 0, len(si.cachedRelationships))
508+
for k := range si.cachedRelationships {
509+
relKeys = append(relKeys, k)
510+
}
511+
sort.Strings(relKeys)
512+
for _, k := range relKeys {
513+
rels := si.cachedRelationships[k]
504514
for _, rel := range rels {
505515
if rel.Type != M2M {
506516
continue
@@ -522,6 +532,26 @@ func (si *SchemaInfo) addViewRelationships(deps []ViewColDep) {
522532
tableSurfaces := collectSurfaces(tName, tSchema, rel.Columns)
523533
relSurfaces := collectSurfaces(rName, rSchema, rel.RelatedColumns)
524534
juncSurfaces := collectSurfaces(jTable, jSchema, append(rel.JColumns, rel.JRelatedColumns...))
535+
// Prefer view junctions over base-table junctions so the
536+
// synthesized M2M between two view endpoints goes through a
537+
// junction exposed in the same (or similarly accessible)
538+
// schema, rather than leaking into a hidden base schema the
539+
// caller may not have USAGE on. Stable alphabetical tiebreak
540+
// keeps the choice deterministic across runs.
541+
sortViewsFirst := func(s []surfaceEntry) {
542+
sort.SliceStable(s, func(i, j int) bool {
543+
if s[i].isView != s[j].isView {
544+
return s[i].isView
545+
}
546+
if s[i].schema != s[j].schema {
547+
return s[i].schema < s[j].schema
548+
}
549+
return s[i].name < s[j].name
550+
})
551+
}
552+
sortViewsFirst(tableSurfaces)
553+
sortViewsFirst(relSurfaces)
554+
sortViewsFirst(juncSurfaces)
525555

526556
for _, ts := range tableSurfaces {
527557
for _, rs := range relSurfaces {

go.mod

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@ require (
1919
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
2020
github.com/jackc/puddle/v2 v2.2.2 // indirect
2121
github.com/mattn/go-colorable v0.1.14 // indirect
22-
github.com/mattn/go-isatty v0.0.20 // indirect
22+
github.com/mattn/go-isatty v0.0.21 // indirect
2323
golang.org/x/sync v0.20.0 // indirect
24-
golang.org/x/sys v0.42.0 // indirect
25-
golang.org/x/text v0.35.0 // indirect
24+
golang.org/x/sys v0.43.0 // indirect
25+
golang.org/x/text v0.36.0 // indirect
2626
)

go.sum

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/
2424
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
2525
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
2626
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
27+
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
28+
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
2729
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
2830
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
2931
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ -60,10 +62,14 @@ golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
6062
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
6163
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
6264
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
65+
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
66+
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
6367
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
6468
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
6569
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
6670
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
71+
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
72+
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
6773
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
6874
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
6975
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=

0 commit comments

Comments
 (0)