Skip to content

*!: add JSONB containment predicates usable on postgres and sqlite3 - #49

Merged
AlexisMontagne merged 6 commits into
masterfrom
am-sqlbuilder-jsonb
Oct 26, 2026
Merged

AlexisMontagne merged 6 commits into
masterfrom
am-sqlbuilder-jsonb

Conversation

@AlexisMontagne

Copy link
Copy Markdown
Member

What does this PR do?

Services write their queries with x/sqlbuilder and run their tests on SQLite, but anything touching JSONB columns had to be written in raw PostgreSQL SQL and could not be tested there. The builder also had no way to know which database it writes for: only sql.DB exposed its driver, transactions did not.

This PR makes the driver reachable from any sql.Queryer, hands it to the query segments through QueryWriter, and uses it to add JSONB containment predicates that write @> on PostgreSQL and the json1 functions on SQLite.

No related issue.

What are the observable changes?

  • sql.DriverOf(q Queryer) string returns the driver of a DB or of a transaction, or "" when q does not expose one. The transactions of backend/{simple,postgres,sqlite3,balancer,static} and middleware/logger now implement Driver() string.
  • Breaking: sqlbuilder.QueryWriter gains a Driver() string method. Implementations of QueryWriter outside x/sqlbuilder have to add it; callers of WriteTo are unaffected.
  • New predicates, each with a Static… variant:
Predicate PostgreSQL SQLite
JSONBArrayContainsAny (col @> $1::jsonb OR col @> $2::jsonb …) EXISTS over json_each(col)
JSONBArrayContainsAll col @> $1::jsonb count of the matched elements of json_each(col)
JSONBObjectContains col @> $1::jsonb one condition per leaf: scalars, nested objects, arrays of scalars
  • The SQLite form is written for the sqlite3 driver, the PostgreSQL one for any other driver, including "".
  • Values can only hold strings and numbers. Booleans, null and arrays of objects return an error when the query is built, on both databases, so a query that cannot be expressed on SQLite also fails against PostgreSQL.

Good PR checklist

  • Title makes sense
  • Is against the correct branch
  • Only addresses one issue
  • Properly assigned
  • Added/updated tests
  • Added/updated documentation
  • Properly labeled

Additional Notes

  • Same results on both databases: integration/jsonb_test.go runs 16 cases, plus one inside sql.ExecuteTx, against SQLite and PostgreSQL 18. Both return the same rows.
  • Transaction wrappers outside this repo should implement Driver() as well. Without it, the predicates write the PostgreSQL form, which fails with a syntax error on SQLite instead of returning wrong rows.
  • POSTGRES_URL test runs: with it set, go test ./... has to run with -p 1. integration and x/sqlbuilder/upserter both create a foo table in the same database and collide when run in parallel. This was already the case before this PR.
  • cyclop warnings: they show up on InsertStatement.buildQueries and SelectStatement.buildQuery because their signatures changed. Their complexity is the same as on master.

Only sql.DB exposed its driver, so code holding a transaction, such as the
x/sqlbuilder upserter, could not tell which dialect to write. Transactions
now implement an optional Driver method, read through sql.DriverOf.
Lets query segments write SQL specific to the database they target, the
driver being read from the Queryer of the QueryBuilder. Implementations of
QueryWriter outside this package have to add the Driver method.
Adds JSONBArrayContainsAny, JSONBArrayContainsAll and JSONBObjectContains,
written with the @> operator on PostgreSQL and with the json1 functions on
SQLite, so the same predicate can be used by services and their SQLite
backed tests. Only strings and numbers are supported as values.
@AlexisMontagne
AlexisMontagne requested a review from a team as a code owner October 23, 2026 18:33
@AlexisMontagne
AlexisMontagne removed the request for review from a team October 23, 2026 18:33
@AlexisMontagne AlexisMontagne self-assigned this Oct 23, 2026

func (is InsertStatement) buildQueries(vvs []map[string]any, qvs map[string]any) (string, []any, error) {
var qw queryWriter
func (is InsertStatement) buildQueries(driver string, vvs []map[string]any, qvs map[string]any) (string, []any, error) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚫 [golangci] reported by reviewdog 🐶
calculated cyclomatic complexity for function buildQueries is 21, max is 15 (cyclop)

}

func (ss SelectStatement) buildQuery(vs map[string]any) (string, []any, []string, error) {
func (ss SelectStatement) buildQuery(driver string, vs map[string]any) (string, []any, []string, error) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚫 [golangci] reported by reviewdog 🐶
calculated cyclomatic complexity for function buildQuery is 19, max is 15 (cyclop)

Comment thread integration/jsonb_test.go
)
}

func TestJSONBPredicateClause(t *testing.T) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My initial use case is directly a JSON Array like [{"type":"campaign","id":12},{"type":"contribution","id":14}]. Could you add a test that match this please? Just to be sure :)

Thanks for the PR btw, nice feature for the ORM.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I updated the PR to match your schema and realized the PR was not soliving your problem (it was only handling slices or object of scalars not double nested shapes). It should now solve your use case

@karitham karitham left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is awesome. Thanks for this!

I can work my way around the postgres layers and queries but the sqlite code is simply inscrutable.

It works in your tests, it works in my tests, and LLMs say it's fine so let's just hide it here and never look at it again 🙏


// Driver returns the driver the query is built for, or an empty string
// when the queryer of the QueryBuilder does not expose it.
Driver() string

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While we're here, should we type driver? I think the signature of statement and other functions that take a driver are only clear because it's named driver, but they look really off.

Then ideally we have each impl export it's own typed driver, even if it's a string it makes it easier to know the driver you're comparing against is the correct one, when we need to pull this kind of thing.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I dont think it is a bad idea, but i really dont want to break interfaces in this PR more than what i already did.

The goal of driver was to piggy back off of the method that was already there in sql.DB.

In this PR we only assert Driver() == "sqlite3", given the fact our postgres driver setup could be more dynamic.

When it comes to typing we have:

But there is two things missing:

  • There is no proper scheme to unwrap middleware
  • it does not support any queryer (i.e tx)

If you have suggestion on how to acheive that without messing up too many interface it could be a nice abstraction!

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with the breaking concern. I think it's a nice to have and the cost now is too big. If we ever do another big refactor I'd say it would be worth investigating, but now is probably not the time as you mention

The IsXYZ assertions are very much footgun-y, I had never seen them and I'm glad about it, too dangerous.

Relying on string comparison is not the end of the world and we don't have that many. They're also trivially testable so it's not the worst anyhow, we can live with it

Columns holding arrays of objects, such as the bound entities of a
conversation, could not be filtered as the predicates only accepted
strings and numbers. On SQLite every wanted object or array is now matched
with an iteration of its own, as @> does on PostgreSQL.
}

if res == nil {
return nil, nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚫 [golangci] reported by reviewdog 🐶
return both a nil error and an invalid value: use a sentinel error instead (nilnil)

Comment thread integration/jsonb_test.go
).Query(ctx, nil)

if err != nil {
return nil, err

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚫 [golangci] reported by reviewdog 🐶
error returned from external package is unwrapped: sig: func (*github.com/upfluence/sql/x/sqlbuilder.SelectQueryer).Query(ctx context.Context, qvs map[string]any) (github.com/upfluence/sql/x/sqlbuilder.Cursor, error) (wrapcheck)

Comment thread integration/jsonb_test.go
var id int64

if err := sc.Scan(map[string]any{"id": &id}); err != nil {
return err

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚫 [golangci] reported by reviewdog 🐶
error returned from interface method should be wrapped: sig: func (github.com/upfluence/sql/x/sqlbuilder.Scanner).Scan(map[string]any) error (wrapcheck)

Comment thread integration/jsonb_test.go
},
)

return ids, err

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚫 [golangci] reported by reviewdog 🐶
error returned from external package is unwrapped: sig: func github.com/upfluence/sql/x/sqlbuilder.ScrollCursor(c github.com/upfluence/sql/x/sqlbuilder.Cursor, fn github.com/upfluence/sql/x/sqlbuilder.ScanFunc) error (wrapcheck)

@AlexisMontagne
AlexisMontagne merged commit d69d591 into master Oct 26, 2026
4 of 5 checks passed
@AlexisMontagne
AlexisMontagne deleted the am-sqlbuilder-jsonb branch October 26, 2026 14:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants