Skip to content

feat: Add CREATE NAMESPACE to group GraphQL queries and mutations#2187

Open
velo wants to merge 1 commit into
mainfrom
feat/graphql-namespaces
Open

feat: Add CREATE NAMESPACE to group GraphQL queries and mutations#2187
velo wants to merge 1 commit into
mainfrom
feat/graphql-namespaces

Conversation

@velo

@velo velo commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator

What

Adds CREATE NAMESPACE — a single construct that groups GraphQL queries/mutations under a sub-object (e.g. backend { ... } / admin { ... }) and declares parameters shared by every function in the namespace. Replaces the convention of long name prefixes (backendFoo, adminBar) and per-function repetition of the same auth columns.

CREATE NAMESPACE admin (
  impersonateTenantId String,                                    -- external → admin(impersonateTenantId: String)
  authDataGroupIds ARRAY<String> NOT NULL METADATA FROM 'auth.https://datasqrl.com/data_group_ids'  -- hidden JWT claim
);

admin.pendingDeployments(limit Int = 10) :=
  SELECT ... WHERE array_contains(:admin.authDataGroupIds, 'datasqrl_admin')
    AND (:admin.impersonateTenantId IS NULL OR tenantId = :admin.impersonateTenantId);

produces:

type Query { admin(impersonateTenantId: String): AdminQueries }
type AdminQueries { pendingDeployments(limit: Int = 10): [...] }
  • A namespace param with METADATA FROM 'auth....' is a hidden JWT claim; a param without it becomes an argument exposed on the namespace field itself.
  • Functions join by the <name>.func path prefix and inherit the params automatically; reference them as :<name>.<param>.
  • Namespace field arguments propagate to sub-queries via the same parent-parameter mechanism relationships already use (this.); claims are read from the JWT.

How it works (reuses existing machinery)

  • A size-2 path (admin.foo) whose head resolves to no table is a namespace (vs a relationship). New SqrlTableFunction.namespaced flag.
  • External namespace params → ParentParameter on the sub-function + exposed as args on the namespace field. Metadata params → MetadataParameter. Both hidden from the sub-field.
  • Runtime: the namespace field's static fetcher returns its arguments as the source object; children bind :ns.arg from it. New StaticQueryCoords.
  • Mutations can be namespaced via a user-supplied .graphqls (KafkaMutationCoords.parentType). Subscriptions cannot be namespaced (GraphQL requires a single root subscription field); the parser blocks it.

Also fixes a latent GraphQLSchemaConverter bug (missing comma between sibling nested-field variable declarations in generated REST/MCP operations), which corrected several pre-existing snapshots.

Tests

  • dagplanner/namespacedFunctionTest.sqrl + namespace*-fail.sqrl (compile + inferred-schema model).
  • GraphQLSchemaConverterTest, UseCaseCompileTest (namespaced-package) — validates a hand-written namespaced query + mutation schema.
  • usecases/namespaced/ end-to-end via FullUseCaseIT: proves namespace-argument propagation (backend(minTokens: 300) filters the sub-query), a namespaced mutation, and a subscription against real Postgres/Kafka/Flink.

Limitation

A namespaced function is access-only, so it cannot be invoked from another function body via Table(ns.fn(...)). Compositions like foo := SELECT * FROM Table(fooWithTime(...)) need the callee kept as a flat callable helper or inlined.

Signed-off-by: Marvin Froeder <marvin@datasqrl.com>
@codecov

codecov Bot commented Jul 3, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 1.73913% with 226 lines in your changes missing coverage. Please review.
✅ Project coverage is 16.24%. Comparing base (f624591) to head (0bac4f5).
⚠️ Report is 1 commits behind head on main.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
...in/java/com/datasqrl/planner/SqlScriptPlanner.java 0.00% 83 Missing ⚠️
...java/com/datasqrl/server/GraphqlSchemaFactory.java 0.00% 52 Missing ⚠️
.../java/com/datasqrl/server/GraphqlSchemaWalker.java 0.00% 49 Missing ⚠️
...m/datasqrl/planner/parser/SqrlStatementParser.java 0.00% 15 Missing ⚠️
...ava/com/datasqrl/server/GraphqlModelGenerator.java 0.00% 8 Missing ⚠️
...java/com/datasqrl/planner/NamespaceDefinition.java 0.00% 5 Missing ⚠️
...c/main/java/com/datasqrl/compile/GqlGenerator.java 0.00% 4 Missing ⚠️
.../datasqrl/server/graphql/GraphQLEngineBuilder.java 0.00% 3 Missing ⚠️
.../com/datasqrl/server/graphql/RootGraphQLModel.java 0.00% 3 Missing ⚠️
...l/planner/parser/SqrlCreateNamespaceStatement.java 0.00% 1 Missing ⚠️
... and 3 more
Additional details and impacted files
@@             Coverage Diff              @@
##               main    #2187      +/-   ##
============================================
- Coverage     16.39%   16.24%   -0.16%     
  Complexity     1037     1037              
============================================
  Files           618      620       +2     
  Lines         17911    18089     +178     
  Branches       2189     2215      +26     
============================================
+ Hits           2937     2939       +2     
- Misses        14676    14851     +175     
- Partials        298      299       +1     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

@mbroecheler

Copy link
Copy Markdown
Contributor

This is a really nice idea, thanks @velo.
I see a lot of value in having a reusable set of arguments - in particular for auth. That keeps code DRY and avoid accidental auth leaks where arguments are not aligned.

I am not sure about the nesting syntax within GraphQL. This is definitely an accepted practice, but it feels a bit hacky to me, since you are using a type (which is supposed to be part of the entity model) to group query endpoints. I can see the benefit from a human user (all "admin" endpoints are in one place) but also downsides (to find out what the admin ednpoints are I need to navigate to the type) and wonder if the benefits still matter a lot in a world where AI does most of this low level coding.
But the bigger question from a SQLR perspective is how this maps to REST and MCP which are derived from GraphQL. Those would need to be "namespace" aware and flatten out the nesting differently than when we navigate the type hierarchy. This creates extra complexity and could introduce confusion.

Hence, I'm wondering if we can separate the code reuse from the presentation level concerns:

  1. Not repeating arguments (in particular auth) repeatedly
  2. Pretty-fiying the GraphQL schema for those users that find it nicer to group by type.

Meaning, we could make 2. something that works by convention when the user supplies the GraphQL schema, which I think is what this PR proposes for mutations. We allow the same for subscriptions. Something like: we map queries/mutations which have this type of nesting to functions/mutation tables by concatenating the components with _ and combining arguments. This would also allow us to support arbitrarily deep nesting of this type. This is how we have been treating GraphQL schema customization so far: we have a default mapping but if the user wants to clean things up (e.g. introduce enums, extend types, fine tune scalar types, etc) we support that when we map. For me, this falls into that bucket.

For 1, I'm wondering if we can keep things aligned with the existing SQL syntax and convention by supporting something like this:

CREATE TABLE _MyBackendAuth (
    authDataGroupIds ARRAY<String> NOT NULL METADATA FROM 'auth.https://datasqrl.com/data_group_ids'
);

CREATE TABLE _MyBackendFunctionAuth (
    impersonateTenantId String
) LIKE _MyBackendAuth;

CREATE TABLE SomeBackendMutation (
   ....
) LIKE _MyBackendAuth;

backend_SomeFunction(arg String) LIKE _MyBackendFunctionAuth := SELECT ...;

This has the benefit that:

  • uses the SQL LIKE syntax which seems most appropriate
  • It works with mutation tables
  • it only requires a minor syntax enhancement to functions to add LIKE support for arguments. We already support RETURNS so this would be a simple regex update.
  • no additional tracking or anything

Just throwing out some ideas.
/cc @ferenc-csaky

@ferenc-csaky

Copy link
Copy Markdown
Collaborator

My concern was introducing the NAMESPACE SQL construct for API-things seems to be hacky, and pretty much the point Matthias also described. My original idea was to maybe utilize Flink catalog/database in a non-disruptive way for this, but after I gave it some thought those are neither a good fit. I like (haha) the LIKE syntax described, and it also makes this pretty flexible, won't force a different behavior from a Flink SQL POV, so I am +1 for it.

@velo

velo commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator Author

I am not sure about the nesting syntax within GraphQL. This is definitely an accepted practice, but it feels a bit hacky to me, since you are using a type (which is supposed to be part of the entity model) to group query endpoints.

That is a pretty common industry practice. Shopify's, GitLab, and WPGraphQL do that.

And I like the flexibility it gave me, like the ability to impersonate a tenantId (installationId, dataGroupId, whatever our hearts desire), that is extremely powerful on troubleshooting session.

I can see the benefit from a human user (all "admin" endpoints are in one place) but also downsides (to find out what the admin ednpoints are I need to navigate to the type) and wonder if the benefits still matter a lot in a world where AI does most of this low level coding.

Honestly, my main motivation on this change was to group queries without prefix.

With AI taking over user attention is likely more thin, and having things grouped when they are related, most likely make devs life easier when they need to review something.

and having prefix on endpoint is one more thing to keep in our limited heads =)

I need to filter out the admin on adminPendingDeployment, but could be just me.

Anyways, my goal was to get a better graphql syntax. The changes on sqrl were just to accommodate that.

Maybe use the LIKE as the way to declare the namespace?!

CREATE TABLE _MyBackendAuth (
    authDataGroupIds ARRAY<String> NOT NULL METADATA FROM 'auth.https://datasqrl.com/data_group_ids'
);

CREATE TABLE SomeBackendMutation (
    impersonateTenantId String
) LIKE _MyBackendAuth as admin;

leading to:

admin.someBackendMutation(limit Int = 10) :=
  SELECT ... WHERE array_contains(:admin.authDataGroupIds, 'datasqrl_admin')
    AND (:impersonateTenantId IS NULL OR tenantId = :impersonateTenantId);

the as admin could drive the nesting of the graphql API. If nothing is set it stays at top level.

Another alternative, would be allowing qualified tables

CREATE TABLE admin.SomeBackendMutation (
    impersonateTenantId String
) LIKE _MyBackendAuth;

What do you think?

@velo

velo commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator Author

Also AI said:

The AI argument favors structure over convention. An AI (or codegen, or MCP tooling) consumes the schema via introspection. A namespace type is machine-readable grouping: the tool can fetch just AdminQueries, present it as a coherent tool group, and never mis-classify. A naming prefix is a human convention the model has to guess at. "Navigate to the type to see the admin endpoints" is one introspection hop — precisely the operation machines are good at. If humans do less of the low-level reading, the case for structured-over-stringly gets stronger, not weaker.

@mbroecheler

Copy link
Copy Markdown
Contributor

Just brainstorming here:

One thing we could do is treat QUERYSPACE (I like that better than the generic and overloaded namespace) as another type of table that doesn't get exposed as a mutation but as a grouping of queries:

CREATE TABLE _MyBackendAuth (
    authDataGroupIds ARRAY<String> NOT NULL METADATA FROM 'auth.https://datasqrl.com/data_group_ids'
);

CREATE QUERYSPACE admin (
    impersonateTenantId String
) LIKE _MyBackendAuth;

admin.someBackendFunction(otherArg Int) :=
  SELECT ... WHERE array_contains(this.authDataGroupIds, 'datasqrl_admin')
    AND (this.impersonateTenantId IS NULL OR tenantId = this.impersonateTenantId)
    AND some_column > :otherArg;

Behind the scenes we would treat queryspace like a "normal" table that doesn't get exposed as a mutation but as a query groupging:

  • before handing the SQL string to the Flink SQL parser we replace CREATE QUERYSPACE -> CREATE TABLE
  • we set a boolean isQuerySpace flag on the resulting table
  • this then allows us to treat someBackendFunction as a relationahip function on the admin table. The one caveat here is that those columns from the admin table would then have to be referred to via this.impersonateTenantId instead of :impersonateTenantId.
  • we would add special handling that says a) does not expose queryspace tables as mutations but instead uses them to group queries and b) throws an exception if a queryspace table is used as an input node in the DAG.
  • In the calling of those functions, the this vars would get resolved against the parent args.

That way, the changes to SQRL are pretty minimal. And, most importantly, the mental model still works: tables map to types and X.y is a relationship field on X.
The real difference then is how we generate the GraphQL schema and do the mapping, but the SQRL model is relatively unchanged and conceptually sound. And then we can still control (via compiler flag) whether the generated GraphQL should use nesting or prefixing (defaulting to the former).

WDYT @velo @ferenc-csaky

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants