Skip to content

[FEATURE] snake_case surface: make presentation and resolution agree across columns, parameters and request bodies (without dropping wire spellings) #131

Description

@jeffreyaven

Summary

The request-side casing mechanism (request.nativeCasing) is backward compatible: wire-cased keys always match first and snake_case is an additional alias. The response-side mechanism (config.snake_case_aliases) is not: it renames the column display/DDL surface to snake_case outright, so any existing query that projects, filters, orders by or references a wire-cased (camelCase / PascalCase) column name breaks the moment a provider ships with the flag enabled. To make snake-on-the-surface / native-on-the-wire adoptable for published providers with an existing user base (google, aws), the column surface needs an aliasing mode to match the parameter surface.

Four work items are tracked here. They were found while adopting nativeCasing on the google provider family and share one root concern: which spellings the engine accepts, and which spellings it presents, must agree - and accepting a new spelling must never remove an old one.

Target end state

surface presented as accepted as tracked in
response columns (DESCRIBE, SELECT *) snake snake and wire item 1
column refs in projection / ORDER BY / local WHERE - snake and wire item 1
parameters (SHOW METHODS, docs) snake snake and wire items 2, 4
top-level data__ body keys (SHOW INSERT, docs) snake snake and wire items 3, 4
nested body/JSON blob contents wire wire unchanged
server variables wire wire unchanged

Nothing in this issue removes a wire spelling from the accepted set, on any surface.

Current behaviour (verified against main)

Request side - dual-cased, wire-first, non-breaking:

  • GetParameter, parameterMatch and operation-param lookup (internal/anysdk/operation_store.go) all try the key as given first and only on a miss apply the snake -> wire retry, gated on request.nativeCasing being declared.
  • GetParametersIncludingNativeCasing augments the wire-keyed set with snake aliases and never clobbers a real wire parameter of the same name.
  • getschemaAttributeMatcher accepts both the wire property name and its ToSnake form and maps back to the wire key - but see item 3: it is only reachable from the naive* translator path, not the default data__ path.

Response side - renamed, wire names removed from the surface:

  • With StackQLConfig.SnakeCaseAliases enabled (internal/anysdk/config.go), getPropertiesColumns, ToDescriptionMap and GetAllColumns (internal/anysdk/schema.go) set the column display/DDL name to casing.ToSnake(wireKey). The wire name is retained only internally (GetWireName) for response payload extraction.
  • The regression test contract is explicit: wire-cased keys must NOT appear in DESCRIBE output (TestToDescriptionMapSnakeAliases).
  • The RDBMS insertion table is therefore created with snake DDL columns only. There is no camel/Pascal fallback in column resolution, so a query like the one below either errors on the backend or degrades to projecting the identifier as a string literal (the failure mode noted in the TestGetAllColumnsSnakeAliases comment):
-- works today against a provider without the flag; breaks when the flag ships
SELECT machineType, creationTimestamp
FROM google.compute.instances
WHERE project = 'p' AND zone = 'z'
ORDER BY creationTimestamp DESC;

Why this blocks adoption

For a provider with no installed base the rename is fine. For google/aws it means a hard flag day: every existing script, dashboard and stackql-deploy template that touches a response field by wire name breaks in a single provider release, while the parameter surface (correctly) keeps accepting both spellings. The asymmetry is the problem - inputs are dual-cased, outputs are single-cased.

Scale on google alone: 19,756 distinct camelCase schema property names across 70,274 properties, published as a single rolling v00.00.00000 with no earlier version for users to pin.


Item 1: response-column surface is a rename, not an alias

Make the response-column surface dual-cased under the flag, mirroring the parameter treatment:

  1. Column resolution fallback (preferred): keep snake as the display/DDL name, but when a projected/referenced identifier misses the column set and the provider has snake_case_aliases enabled, retry with casing.ToSnake(identifier) before failing. This is the exact mirror of the GetParameter reverse-casing retry, applied at the column descriptor / select-items resolution layer, and requires no DDL change.
  2. Alternative: emit both names into the tabulation (wire name as a true alias column sharing the extraction key), with the snake name canonical in DESCRIBE/star expansion. Heavier: doubles the visible column count or needs display filtering.
  3. Alternative: make the rename opt-in per session/user rather than per provider doc. Punts the migration problem to every user individually; least preferred.

Option 1 acceptance criteria:

  • With the flag enabled, SELECT machine_type ... and SELECT machineType ... both resolve to the same column and value.
  • ORDER BY and locally-evaluated WHERE predicates accept both spellings.
  • DESCRIBE / SELECT * output remains snake-only (unchanged from current flag behaviour).
  • With the flag absent, behaviour is byte-identical to today (existing TestToDescriptionMapDisabledIsWireKeyed / TestGetAllColumnsDisabledIsWireKeyed still pass).
  • A regression test asserting the camel -> snake column fallback, alongside the existing surface-parity tests in casing_surface_regression_test.go.

Item 2: acronym round-trip asymmetry on the parameter surface

casing.ToSnake is a botocore xform_name port and intentionally lossy for acronyms (IPProtocol -> ip_protocol), but the routing-side retry uses FromSnake (ip_protocol -> ipProtocol != IPProtocol), so the snake spelling of acronym-headed wire names resolves on the ToSnake-derived alias paths (GetParametersIncludingNativeCasing, body attribute matcher) but can miss on the FromSnake retry paths (GetParameter, parameterMatch). Wire-cased input is unaffected.

Suggested fix: build a per-method snake -> wire map from ToSnake over the declared parameter names (the total, forward direction) and use it in the retry paths instead of FromSnake, which guarantees the alias surface is consistent everywhere.

Reproduces on google healthcare - BinaryId, ConsentId, GroupId, PatientId all round-trip to binaryId etc. and fail to resolve:

EXEC google.healthcare.fhir."binary-read" @binary_id = 'b', ...
  -> required param not supplied for exec: could not find variable 'BinaryId'

Google compute (IPProtocol, IPAddress) hits the same class on the response side.


Item 3: nativeCasing snake aliases do not reach request bodies under the default data__ regime

Verified live (stackql v0.10.601, google storage, method with request: {nativeCasing: camel, mediaType: application/json}):

  • Query parameter aliasing works and is wire-correct: WHERE max_results = 2 and WHERE maxResults = 2 both produce ...?maxResults=2 on the wire.
  • Body attribute aliasing does not: INSERT ... (data__storage_class) VALUES ('NEARLINE') sends {"storage_class": "NEARLINE"} on the wire; the API silently ignores the unknown key and the bucket is created STANDARD. No error is surfaced.

Cause: the nativeCasing-aware body matcher (getRequestBodySchemaAttributeMatcher -> getschemaAttributeMatcher) is only wired into the requestBodyTranslate: naive* translator path in inferTranslator (internal/anysdk/operation_store.go). The default (data__ prefix) path uses getDefaultRequestBodyMatcher() / requestBodyBaseKeyFuzzyMatcher, which has no casing awareness, and the armoury body construction copies body-map keys verbatim.

Proposed fix: when the method declares request.nativeCasing, the default translator should rename post-prefix body keys via the schema attribute matcher (snake -> wire property name), preserving wire-cased keys verbatim (wire-first, mirroring parameter treatment). Unknown keys that match neither spelling should error rather than pass through, closing the silent-drop hazard generally.

Acceptance criteria:

-- both must produce {"storageClass": "NEARLINE"} on the wire
INSERT INTO google.storage.buckets(project, data__name, data__location, data__storageClass)
SELECT 'p', 'b', 'US', 'NEARLINE';
INSERT INTO google.storage.buckets(project, data__name, data__location, data__storage_class)
SELECT 'p', 'b', 'US', 'NEARLINE';
  • Absent request.nativeCasing, behaviour is byte-identical to today.
  • A regression test covering the snake body key -> wire-cased JSON body rename, alongside the existing request-side tests in casing_surface_regression_test.go.

Item 4: the parameter surface is dual-cased for resolution but wire-only for presentation

With config.snake_case_aliases enabled and a method declaring request.nativeCasing, the column surface renames to snake but the parameter surface does not: SHOW METHODS and SHOW INSERT still present wire names. A reader following generated docs (which render snake under provider-utils --snake-case-aliases=fields,params) sees one spelling, and SHOW METHODS on the same method reports another. Both spellings resolve, so nothing is broken at runtime - the two surfaces just disagree about what to call things.

Verified behaviour

Two registries identical except config.snake_case_aliases, stackql v0.10.601:

                                                       flag OFF                flag ON
DESCRIBE google.compute.networks (column)              autoCreateSubnetworks   auto_create_subnetworks
SHOW METHODS google.storage.buckets (param)            ifMetagenerationMatch   ifMetagenerationMatch
SHOW METHODS google.accessapproval....settings (param) foldersId               foldersId
SHOW INSERT google.storage.buckets (body key)          data__storageClass      data__storageClass

Only the column row moves. This is not an exec-only quirk - folders_delete_access_approval_settings is a DELETE method.

Cause

ToPresentationMap (internal/anysdk/operation_store.go) builds RequiredParams from getRequiredNonBodyParameters(), which is keyed by wire name. The snake alias set built by GetParametersIncludingNativeCasing is consulted only on the resolution paths (GetParameter, parameterMatch, IngestMap), never on the presentation path. The same applies to the request-body attributes rendered by SHOW INSERT.

Proposal

When the method declares request.nativeCasing and the provider enables snake_case_aliases, render the presentation surface through the same alias map already used for resolution:

  • SHOW METHODS required/optional parameter names
  • SHOW INSERT top-level data__ body keys (nested contents keep wire casing, as today)

Presentation only - no change to how inputs resolve, so no query can change behaviour. Server variables keep their wire spelling in both presentation and resolution.

Exec input resolution

Exec input resolution is currently split, and should be made uniform as part of this item:

-- required exec arg: snake does NOT resolve
EXEC google.run.locations.export_image @locationsId = 'us-central1', @locations_id_1 = 'x', @projectsId = 'p'
  -> required param not supplied for exec: could not find variable 'locationsId1'

-- optional exec arg: snake DOES resolve, and is wire-correct
EXEC google.compute.addresses.move @address = 'a', @project = 'p', @request_id = 'r'
  -> .../addresses/a/move?requestId=r

Aliasing both is preferred: it makes the surface uniform and lets docgen drop its exec carve-out. If exec stays wire-only, state that explicitly so tooling can rely on it - provider-utils currently renders exec methods wire-only for exactly this reason.

Acceptance criteria

  • With the flag and nativeCasing, SHOW METHODS and SHOW INSERT present snake names for parameters and top-level body keys; nested body contents and server variables keep wire casing.
  • Required and optional exec arguments behave identically to each other, whichever way it is resolved.
  • With snake_case_aliases absent, presentation is byte-identical to today.
  • Regression tests alongside the existing request-side tests in casing_surface_regression_test.go.

Item 5: a metadata-only request block breaks body-less EXEC methods

Adopting request.nativeCasing means emitting a request block on methods that have no request body, purely to carry the casing declaration. That breaks every body-less method whose SQL verb is EXEC, which fail before dispatch with:

no request body for operation =  compute.instances.stop

Casing is incidental: the failure is triggered by the presence of a request block that declares no body schema, whatever it contains. It surfaced during nativeCasing adoption only because that is the first reason to emit such a block.

The trigger is the SQL verb, not the HTTP verb. Body-less GET, DELETE and POST all fail when mapped to EXEC; the same HTTP verbs are unaffected when mapped to SELECT / DELETE / INSERT.

Verified reproduction (stackql v0.10.601, local file:// registry)

Two registries generated from the same discovery revision, differing only by a request: {nativeCasing: camel} block on body-less operations:

operation HTTP -> SQL without the block with the block
google.healthcare.fhir."binary-read" GET -> exec dispatched never dispatched
google.apigee.debugsession_data...delete_data DELETE -> exec dispatched never dispatched
google.compute.instances.stop POST -> exec Google 404, dispatched never dispatched
SELECT ... google.compute.instances GET -> select rows rows
DELETE ... google.compute.networks DELETE -> delete Google 404 Google 404
google.aiplatform.agents_iam_policies.get_iam_policy POST -> select Google 400 Google 400
google.sqladmin.instances_entra_id_certificate... POST -> insert Google 403 Google 403

Blast radius on the google family: 363 of 9678 methods are body-less EXEC.

Cause

getRequestBodySchema (internal/anysdk/operation_store.go) conflates "this method declares no body" with "error":

func (op *standardOpenAPIOperationStore) getRequestBodySchema() (Schema, error) {
	if op.Request != nil && op.Request.Schema != nil {
		return op.Request.Schema, nil
	}
	return nil, fmt.Errorf("no request body for operation =  %s", op.GetName())
}

The EXEC analyzer in stackql core (internal/stackql/primitivegenerator/statement_analyzer.go) then treats that error as fatal whenever a request block exists at all:

requestSchema, err := method.GetRequestBodySchema()
req, reqExists := method.GetRequest()
if err != nil && reqExists {
	return nil, err
}

With no request block, reqExists is false and the missing schema is tolerated. Adding a metadata-only block makes reqExists true while the schema is still absent, so the statement fails during analysis.

Candidate fixes

Option A - any-sdk local (no core change). Return (nil, nil) from getRequestBodySchema when the method declares no body schema, reserving the error for genuine failures. The stackql guard then never fires, and analyzeSchemaVsMap is only reached when an EXEC payload was supplied. Callers needing a nil guard: getRequestBodyStringifiedPaths (dereferences .getProperties()) and getRequestBodySchemaAttributeMatcher (dereferences .FindByPath()). getRequestBodyAttributes / ...NoRename already guard with if s != nil; introspection.go already checks bodySchema != nil; shims.go discards the error.

Option B - stackql core call site. Tighten the guard so a request block that declares no body is not taken to imply one, e.g. if err != nil && reqExists && req.GetBodyMediaType() != "". Correct but cannot be carried by this issue.

Option A is preferred if the semantics are agreed: "no body declared" is a normal state for a method, not an error condition.

Acceptance criteria

  • With request: {nativeCasing: camel} on a body-less EXEC method, EXEC dispatches and returns the API response (identical to the same method with no request block), for GET-, DELETE- and POST-backed exec methods alike.
  • Body-less GET/DELETE/POST mapped to non-exec verbs, and POST-with-body, remain byte-identical.
  • An EXEC carrying a payload against a method with no body schema still errors.
  • Regression test alongside the existing request-side tests in casing_surface_regression_test.go.

Current workaround

The google provider generator omits the request block for body-less EXEC methods (isExecMethod in generateStackQLResources), so those 363 methods keep wire-cased parameters only. It can be removed once this is fixed.


Backward compatibility (hard requirement, all items)

Every one of these must keep working exactly as today, before and after, on any provider with either flag enabled:

-- wire-case column in a projection
SELECT machineType, creationTimestamp FROM google.compute.instances
WHERE project = 'p' AND zone = 'z';

-- wire-case column in a locally-evaluated predicate and ORDER BY
SELECT name FROM google.compute.networks
WHERE project = 'p' AND autoCreateSubnetworks = false
ORDER BY creationTimestamp DESC;

-- wire-case parameter in a WHERE clause
SELECT name FROM google.storage.buckets
WHERE project = 'p' AND maxResults = 2;

-- wire-case parameter in an INSERT column list, and wire-case body keys
INSERT INTO google.storage.buckets(project, requestId, data__name, data__storageClass)
SELECT 'p', 'r', 'b', 'NEARLINE';

-- wire-case EXEC arguments
EXEC google.compute.instances.stop
  @instance = 'vm', @project = 'p', @zone = 'z', @requestId = 'r';

Resolution stays wire-first: the key as written is matched first, and a snake alias is never allowed to clobber a real wire name of the same spelling. Renaming a presentation surface must not remove the wire spelling from the resolvable set.

Fail-safe (must not regress, all items)

Unresolvable names error today rather than silently producing wrong results, and that must hold for both spellings:

SELECT ... WHERE max_resultz = 2          -> could not locate symbol max_resultz
SELECT auto_create_subnetworkz FROM ...   -> no such column: auto_create_subnetworkz

A key that matches no wire name must not degrade into a silently-dropped parameter, a silently-dropped body key (item 3), or a client-side filter over a column that does not exist.

Out of scope

  • Wire-first matching order on the request side: correct as is, and unchanged by every item here.
  • Enabling either flag in any published provider (separate registry decisions per provider, gated on this issue).
  • Nested/structured body attribute renames beyond the existing top-level treatment.
  • Response column extraction changes: GetWireName remains the extraction key throughout.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions