Skip to content

Commit 2d1ad7f

Browse files
Merge pull request #59 from stackql/claude/add-predicate-pushdown-config-option
Claude/add predicate pushdown config option
2 parents bf51836 + 353381f commit 2d1ad7f

File tree

16 files changed

+2356
-0
lines changed

16 files changed

+2356
-0
lines changed

.claude/skills/stackql-provider-development.md

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -521,6 +521,238 @@ sqlExternalTables:
521521
precision: integer
522522
```
523523

524+
### Query Parameter Pushdown (`queryParamPushdown`)
525+
526+
Enables SQL clause pushdown to API query parameters. Supports OData and custom API dialects for filter, projection, ordering, and limit operations.
527+
528+
**Location:** Can be set at **provider, providerService, service, resource, or method level**. Config inherits from higher levels, with lower levels overriding higher levels:
529+
530+
```
531+
Method -> Resource -> Service -> ProviderService -> Provider
532+
```
533+
534+
This allows you to set a default OData config at the service level and have all resources/methods inherit it, while still allowing specific methods to override with custom settings.
535+
536+
```yaml
537+
x-stackQL-config:
538+
queryParamPushdown:
539+
# Column projection (SELECT clause pushdown)
540+
select:
541+
dialect: odata | custom # "custom" is default; "odata" applies OData defaults
542+
paramName: "$select" # Not required for OData (default: "$select")
543+
delimiter: "," # Not required for OData (default: ",")
544+
supportedColumns: # Optional, omit or ["*"] for all columns
545+
- "id"
546+
- "name"
547+
- "status"
548+
549+
# Row filtering (WHERE clause pushdown)
550+
filter:
551+
dialect: odata | custom # "custom" is default; "odata" applies OData defaults
552+
paramName: "$filter" # Not required for OData (default: "$filter")
553+
syntax: odata # Not required for OData (default: "odata")
554+
supportedOperators: # Required - which operators can be pushed down
555+
- "eq"
556+
- "ne"
557+
- "gt"
558+
- "lt"
559+
- "ge"
560+
- "le"
561+
- "contains"
562+
- "startswith"
563+
supportedColumns: # Optional, omit or ["*"] for all columns
564+
- "displayName"
565+
- "status"
566+
- "createdDate"
567+
568+
# Ordering (ORDER BY clause pushdown)
569+
orderBy:
570+
dialect: odata | custom # "custom" is default; "odata" applies OData defaults
571+
paramName: "$orderby" # Not required for OData (default: "$orderby")
572+
syntax: odata # Not required for OData (default: "odata")
573+
supportedColumns: # Optional, omit or ["*"] for all columns
574+
- "name"
575+
- "createdDate"
576+
577+
# Row limit (LIMIT clause pushdown)
578+
top:
579+
dialect: odata | custom # "custom" is default; "odata" applies OData defaults
580+
paramName: "$top" # Not required for OData (default: "$top")
581+
maxValue: 1000 # Optional, cap on pushdown value
582+
583+
# Count (SELECT COUNT(*) pushdown)
584+
count:
585+
dialect: odata | custom # "custom" is default; "odata" applies OData defaults
586+
paramName: "$count" # Not required for OData (default: "$count")
587+
paramValue: "true" # Not required for OData (default: "true")
588+
responseKey: "@odata.count"# Not required for OData (default: "@odata.count")
589+
```
590+
591+
**Minimal OData Configuration:**
592+
593+
When using OData dialect, defaults are applied automatically:
594+
595+
```yaml
596+
x-stackQL-config:
597+
queryParamPushdown:
598+
select: {}
599+
filter:
600+
dialect: odata
601+
supportedOperators: ["eq", "ne", "gt", "lt", "contains"]
602+
orderBy:
603+
dialect: odata
604+
top:
605+
dialect: odata
606+
count:
607+
dialect: odata
608+
```
609+
610+
**Custom API Configuration:**
611+
612+
For APIs with custom query parameter names:
613+
614+
```yaml
615+
x-stackQL-config:
616+
queryParamPushdown:
617+
select:
618+
paramName: "fields"
619+
delimiter: ","
620+
filter:
621+
paramName: "filter"
622+
syntax: "key_value" # filter[status]=active&filter[region]=us-east-1
623+
supportedOperators:
624+
- "eq"
625+
supportedColumns:
626+
- "status"
627+
- "region"
628+
orderBy:
629+
paramName: "sort"
630+
syntax: "prefix" # sort=-createdAt (prefix - for desc)
631+
supportedColumns:
632+
- "createdAt"
633+
- "name"
634+
top:
635+
paramName: "limit"
636+
maxValue: 100
637+
count:
638+
paramName: "include_count"
639+
paramValue: "1"
640+
responseKey: "meta.total"
641+
```
642+
643+
**Supported Filter Syntaxes:**
644+
645+
| Syntax | Example Output | Use Case |
646+
|--------|---------------|----------|
647+
| `odata` | `$filter=status eq 'active' and region eq 'us-east-1'` | OData APIs |
648+
| `key_value` | `filter[status]=active&filter[region]=us-east-1` | Rails-style APIs |
649+
| `simple` | `status=active&region=us-east-1` | Basic query params |
650+
651+
**Supported OrderBy Syntaxes:**
652+
653+
| Syntax | Example | Notes |
654+
|--------|---------|-------|
655+
| `odata` | `$orderby=name desc,date asc` | Space-separated direction |
656+
| `prefix` | `sort=-name,+date` | `-` for desc, `+` or none for asc |
657+
| `suffix` | `sort=name:desc,date:asc` | Colon-separated direction |
658+
659+
**Column/Operator Support Logic:**
660+
661+
| Value | Behavior |
662+
|-------|----------|
663+
| omitted / `null` | All items allowed |
664+
| `["*"]` | Explicit "all items" (same as omitted) |
665+
| `["col1", "col2"]` | Only these items supported |
666+
| `[]` | No items supported (effectively disabled) |
667+
668+
**OData Example with Inheritance (TripPin Reference Service):**
669+
670+
Set a default config at service level, then override at resource or method level as needed:
671+
672+
```yaml
673+
# Service-level config - inherited by all resources/methods
674+
x-stackQL-config:
675+
queryParamPushdown:
676+
select:
677+
dialect: odata
678+
filter:
679+
dialect: odata
680+
supportedOperators:
681+
- "eq"
682+
- "ne"
683+
orderBy:
684+
dialect: odata
685+
top:
686+
dialect: odata
687+
count:
688+
dialect: odata
689+
690+
components:
691+
x-stackQL-resources:
692+
# Inherits service-level config (no override needed)
693+
airlines:
694+
id: odata.trippin.airlines
695+
name: airlines
696+
methods:
697+
list:
698+
operation:
699+
$ref: '#/paths/~1Airlines/get'
700+
# No config - inherits from service level
701+
sqlVerbs:
702+
select:
703+
- $ref: '#/components/x-stackQL-resources/airlines/methods/list'
704+
705+
# Resource-level override with full operator support
706+
people:
707+
id: odata.trippin.people
708+
name: people
709+
config:
710+
queryParamPushdown:
711+
filter:
712+
dialect: odata
713+
supportedOperators:
714+
- "eq"
715+
- "ne"
716+
- "gt"
717+
- "lt"
718+
- "contains"
719+
- "startswith"
720+
methods:
721+
list:
722+
operation:
723+
$ref: '#/paths/~1People/get'
724+
sqlVerbs:
725+
select:
726+
- $ref: '#/components/x-stackQL-resources/people/methods/list'
727+
728+
# Method-level override with restricted columns
729+
airports:
730+
id: odata.trippin.airports
731+
name: airports
732+
methods:
733+
list:
734+
operation:
735+
$ref: '#/paths/~1Airports/get'
736+
config:
737+
queryParamPushdown:
738+
select:
739+
dialect: odata
740+
supportedColumns:
741+
- "Name"
742+
- "IcaoCode"
743+
filter:
744+
dialect: odata
745+
supportedColumns:
746+
- "Name"
747+
- "IcaoCode"
748+
top:
749+
dialect: odata
750+
maxValue: 100
751+
sqlVerbs:
752+
select:
753+
- $ref: '#/components/x-stackQL-resources/airports/methods/list'
754+
```
755+
524756
---
525757

526758
## Method Definition

anysdk/config.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ type StackQLConfig interface {
2121
GetVariations() (Variations, bool)
2222
GetViews() map[string]View
2323
GetExternalTables() map[string]SQLExternalTable
24+
GetQueryParamPushdown() (QueryParamPushdown, bool)
2425
//
2526
isObjectSchemaImplicitlyUnioned() bool
2627
setResource(rsc Resource)
@@ -36,6 +37,7 @@ type standardStackQLConfig struct {
3637
Views map[string]*standardViewContainer `json:"views" yaml:"views"`
3738
ExternalTables map[string]standardSQLExternalTable `json:"sqlExternalTables" yaml:"sqlExternalTables"`
3839
Auth *standardAuthDTO `json:"auth,omitempty" yaml:"auth,omitempty"`
40+
QueryParamPushdown *standardQueryParamPushdown `json:"queryParamPushdown,omitempty" yaml:"queryParamPushdown,omitempty"`
3941
}
4042

4143
func (qt standardStackQLConfig) JSONLookup(token string) (interface{}, error) {
@@ -48,6 +50,8 @@ func (qt standardStackQLConfig) JSONLookup(token string) (interface{}, error) {
4850
return qt.RequestTranslate, nil
4951
case "views":
5052
return qt.Views, nil
53+
case "queryParamPushdown":
54+
return qt.QueryParamPushdown, nil
5155
default:
5256
return nil, fmt.Errorf("could not resolve token '%s' from QueryTranspose doc object", token)
5357
}
@@ -126,6 +130,13 @@ func (cfg *standardStackQLConfig) GetAuth() (AuthDTO, bool) {
126130
return cfg.Auth, cfg.Auth != nil
127131
}
128132

133+
func (cfg *standardStackQLConfig) GetQueryParamPushdown() (QueryParamPushdown, bool) {
134+
if cfg.QueryParamPushdown == nil {
135+
return nil, false
136+
}
137+
return cfg.QueryParamPushdown, true
138+
}
139+
129140
func (cfg *standardStackQLConfig) GetExternalTables() map[string]SQLExternalTable {
130141
rv := make(map[string]SQLExternalTable, len(cfg.ExternalTables))
131142
if cfg.ExternalTables != nil {

anysdk/operation_store.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ type OperationStore interface {
6262
GetGraphQL() GraphQL
6363
GetInverse() (OperationInverse, bool)
6464
GetStackQLConfig() StackQLConfig
65+
GetQueryParamPushdown() (QueryParamPushdown, bool)
6566
GetParameters() map[string]Addressable
6667
GetPathItem() *openapi3.PathItem
6768
GetAPIMethod() string
@@ -405,6 +406,42 @@ func (op *standardOpenAPIOperationStore) getStackQLConfig() (StackQLConfig, bool
405406
return rv, rv != nil
406407
}
407408

409+
// GetQueryParamPushdown returns the queryParamPushdown config with inheritance.
410+
// It walks up the hierarchy: Method -> Resource -> Service -> ProviderService -> Provider
411+
func (op *standardOpenAPIOperationStore) GetQueryParamPushdown() (QueryParamPushdown, bool) {
412+
// Check method-level config first
413+
if op.StackQLConfig != nil {
414+
if qpp, ok := op.StackQLConfig.GetQueryParamPushdown(); ok {
415+
return qpp, true
416+
}
417+
}
418+
// Check resource-level config
419+
if op.Resource != nil {
420+
if qpp, ok := op.Resource.GetQueryParamPushdown(); ok {
421+
return qpp, true
422+
}
423+
}
424+
// Check service-level config
425+
if op.OpenAPIService != nil {
426+
if qpp, ok := op.OpenAPIService.getQueryParamPushdown(); ok {
427+
return qpp, true
428+
}
429+
}
430+
// Check providerService-level config
431+
if op.ProviderService != nil {
432+
if qpp, ok := op.ProviderService.GetQueryParamPushdown(); ok {
433+
return qpp, true
434+
}
435+
}
436+
// Check provider-level config
437+
if op.Provider != nil {
438+
if qpp, ok := op.Provider.GetQueryParamPushdown(); ok {
439+
return qpp, true
440+
}
441+
}
442+
return nil, false
443+
}
444+
408445
func (op *standardOpenAPIOperationStore) GetAPIMethod() string {
409446
return op.APIMethod
410447
}

anysdk/provider.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ type Provider interface {
2828
GetProviderServices() map[string]ProviderService
2929
GetPaginationRequestTokenSemantic() (TokenSemantic, bool)
3030
GetPaginationResponseTokenSemantic() (TokenSemantic, bool)
31+
GetQueryParamPushdown() (QueryParamPushdown, bool)
3132
GetProviderService(key string) (ProviderService, error)
3233
getQueryTransposeAlgorithm() string
3334
GetRequestTranslateAlgorithm() string
@@ -128,6 +129,13 @@ func (pr *standardProvider) GetPaginationResponseTokenSemantic() (TokenSemantic,
128129
return pr.StackQLConfig.Pagination.ResponseToken, true
129130
}
130131

132+
func (pr *standardProvider) GetQueryParamPushdown() (QueryParamPushdown, bool) {
133+
if pr.StackQLConfig != nil {
134+
return pr.StackQLConfig.GetQueryParamPushdown()
135+
}
136+
return nil, false
137+
}
138+
131139
func (pr *standardProvider) MarshalJSON() ([]byte, error) {
132140
return jsoninfo.MarshalStrictStruct(pr)
133141
}

anysdk/providerService.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ type ProviderService interface {
2525
GetResourcesShallow() (ResourceRegister, error)
2626
GetPaginationRequestTokenSemantic() (TokenSemantic, bool)
2727
getPaginationResponseTokenSemantic() (TokenSemantic, bool)
28+
GetQueryParamPushdown() (QueryParamPushdown, bool)
2829
ConditionIsValid(lhs string, rhs interface{}) bool
2930
GetID() string
3031
GetServiceFragment(resourceKey string) (Service, error)
@@ -178,6 +179,13 @@ func (sv *standardProviderService) getPaginationResponseTokenSemantic() (TokenSe
178179
return sv.StackQLConfig.Pagination.ResponseToken, true
179180
}
180181

182+
func (sv *standardProviderService) GetQueryParamPushdown() (QueryParamPushdown, bool) {
183+
if sv.StackQLConfig != nil {
184+
return sv.StackQLConfig.GetQueryParamPushdown()
185+
}
186+
return nil, false
187+
}
188+
181189
func (sv *standardProviderService) ConditionIsValid(lhs string, rhs interface{}) bool {
182190
elem := sv.ToMap()[lhs]
183191
return reflect.TypeOf(elem) == reflect.TypeOf(rhs)

0 commit comments

Comments
 (0)