Skip to content

Commit 90481ed

Browse files
committed
feat: gate subscription hook generation on @realtime smart tag
- Add smartTags field to Table interface in both query and codegen packages - Parse smart tags from PostGraphile @-prefixed description lines in inferTablesFromIntrospection - Export parseSmartTags utility from @constructive-io/graphql-query - Gate subscription hook generation: only tables with @realtime smart tag get hooks - Gate useConnectionState + subscriptions barrel: only emitted when at least one table has @realtime - Add comprehensive Smart Tag Gating test suite (5 new tests, all passing) - All 344 codegen tests + 18 query tests passing, typecheck clean
1 parent 24fe3ee commit 90481ed

7 files changed

Lines changed: 158 additions & 12 deletions

File tree

graphql/codegen/src/__tests__/codegen/subscription-hooks.test.ts

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
* - Subscription barrel file
88
*/
99
import { generateSubscriptionsBarrel } from '../../core/codegen/barrel';
10+
import { generate } from '../../core/codegen/index';
1011
import {
1112
generateAllSubscriptionHooks,
1213
generateConnectionStateHook,
@@ -42,6 +43,7 @@ function createTable(partial: Partial<Table> & { name: string }): Table {
4243
query: partial.query,
4344
inflection: partial.inflection,
4445
constraints: partial.constraints,
46+
smartTags: partial.smartTags,
4547
};
4648
}
4749

@@ -63,6 +65,25 @@ const contactTable = createTable({
6365
},
6466
});
6567

68+
const contactTableWithRealtime = createTable({
69+
name: 'Contact',
70+
fields: [
71+
{ name: 'id', type: fieldTypes.uuid },
72+
{ name: 'firstName', type: fieldTypes.string },
73+
{ name: 'lastName', type: fieldTypes.string },
74+
{ name: 'email', type: fieldTypes.string },
75+
{ name: 'createdAt', type: fieldTypes.datetime },
76+
],
77+
query: {
78+
all: 'contacts',
79+
one: 'contact',
80+
create: 'createContact',
81+
update: 'updateContact',
82+
delete: 'deleteContact',
83+
},
84+
smartTags: { '@realtime': true },
85+
});
86+
6687
const projectTable = createTable({
6788
name: 'Project',
6889
fields: [
@@ -80,6 +101,24 @@ const projectTable = createTable({
80101
},
81102
});
82103

104+
const projectTableWithRealtime = createTable({
105+
name: 'Project',
106+
fields: [
107+
{ name: 'id', type: fieldTypes.uuid },
108+
{ name: 'name', type: fieldTypes.string },
109+
{ name: 'active', type: fieldTypes.boolean },
110+
{ name: 'createdAt', type: fieldTypes.datetime },
111+
],
112+
query: {
113+
all: 'projects',
114+
one: 'project',
115+
create: 'createProject',
116+
update: 'updateProject',
117+
delete: 'deleteProject',
118+
},
119+
smartTags: { '@realtime': true },
120+
});
121+
83122
describe('Subscription naming utils', () => {
84123
it('generates subscription hook name', () => {
85124
expect(getSubscriptionHookName(contactTable)).toBe(
@@ -243,3 +282,67 @@ describe('Subscription Barrel Generator', () => {
243282
expect(result).toContain('./useProjectSubscription');
244283
});
245284
});
285+
286+
describe('Smart Tag Gating', () => {
287+
const minConfig = {
288+
tables: { include: [], exclude: [], systemExclude: [] },
289+
queries: { include: [], exclude: [], systemExclude: [] },
290+
mutations: { include: [], exclude: [], systemExclude: [] },
291+
codegen: { skipQueryField: false },
292+
reactQuery: true,
293+
} as any;
294+
295+
it('does not generate subscription hooks when no tables have @realtime', () => {
296+
const result = generate({
297+
tables: [contactTable, projectTable],
298+
config: minConfig,
299+
});
300+
expect(result.stats.subscriptionHooks).toBe(0);
301+
const subFiles = result.files.filter((f) => f.path.startsWith('subscriptions/'));
302+
expect(subFiles).toHaveLength(0);
303+
});
304+
305+
it('generates subscription hooks only for tables with @realtime', () => {
306+
const result = generate({
307+
tables: [contactTableWithRealtime, projectTable],
308+
config: minConfig,
309+
});
310+
expect(result.stats.subscriptionHooks).toBe(1);
311+
const subFiles = result.files.filter((f) => f.path.startsWith('subscriptions/'));
312+
expect(subFiles.some((f) => f.path.includes('useContactSubscription'))).toBe(true);
313+
expect(subFiles.some((f) => f.path.includes('useProjectSubscription'))).toBe(false);
314+
expect(subFiles.some((f) => f.path.includes('useConnectionState'))).toBe(true);
315+
expect(subFiles.some((f) => f.path === 'subscriptions/index.ts')).toBe(true);
316+
});
317+
318+
it('generates subscription hooks for all @realtime tables', () => {
319+
const result = generate({
320+
tables: [contactTableWithRealtime, projectTableWithRealtime],
321+
config: minConfig,
322+
});
323+
expect(result.stats.subscriptionHooks).toBe(2);
324+
const subFiles = result.files.filter((f) => f.path.startsWith('subscriptions/'));
325+
expect(subFiles.some((f) => f.path.includes('useContactSubscription'))).toBe(true);
326+
expect(subFiles.some((f) => f.path.includes('useProjectSubscription'))).toBe(true);
327+
});
328+
329+
it('does not emit useConnectionState or barrel when no @realtime tables', () => {
330+
const result = generate({
331+
tables: [contactTable],
332+
config: minConfig,
333+
});
334+
const subFiles = result.files.filter((f) => f.path.startsWith('subscriptions/'));
335+
expect(subFiles).toHaveLength(0);
336+
const mainBarrel = result.files.find((f) => f.path === 'index.ts');
337+
expect(mainBarrel?.content).not.toContain('./subscriptions');
338+
});
339+
340+
it('emits subscriptions barrel in main index when @realtime tables exist', () => {
341+
const result = generate({
342+
tables: [contactTableWithRealtime],
343+
config: minConfig,
344+
});
345+
const mainBarrel = result.files.find((f) => f.path === 'index.ts');
346+
expect(mainBarrel?.content).toContain('./subscriptions');
347+
});
348+
});

graphql/codegen/src/core/codegen/index.ts

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -299,27 +299,31 @@ export function generate(options: GenerateOptions): GenerateResult {
299299
}
300300

301301
// 8b. Generate subscription hooks (subscriptions/*.ts)
302-
const subscriptionHooks = generateAllSubscriptionHooks(tables);
302+
// Only generate for tables with the @realtime smart tag
303+
const realtimeTables = tables.filter(
304+
(t) => t.smartTags?.['@realtime'] !== undefined,
305+
);
306+
const subscriptionHooks = generateAllSubscriptionHooks(realtimeTables);
303307
for (const hook of subscriptionHooks) {
304308
files.push({
305309
path: `subscriptions/${hook.fileName}`,
306310
content: hook.content,
307311
});
308312
}
309313

310-
// 8c. Generate connection state hook
311-
const connectionStateHook = generateConnectionStateHook();
312-
files.push({
313-
path: `subscriptions/${connectionStateHook.fileName}`,
314-
content: connectionStateHook.content,
315-
});
316-
317-
// 8d. Generate subscriptions/index.ts barrel
314+
// 8c. Generate connection state hook + barrel only if any table has @realtime
318315
const hasSubscriptions = subscriptionHooks.length > 0;
319316
if (hasSubscriptions) {
317+
const connectionStateHook = generateConnectionStateHook();
318+
files.push({
319+
path: `subscriptions/${connectionStateHook.fileName}`,
320+
content: connectionStateHook.content,
321+
});
322+
323+
// 8d. Generate subscriptions/index.ts barrel
320324
files.push({
321325
path: 'subscriptions/index.ts',
322-
content: generateSubscriptionsBarrel(tables),
326+
content: generateSubscriptionsBarrel(realtimeTables),
323327
});
324328
}
325329

graphql/codegen/src/types/schema.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ export interface Table {
1818
query?: TableQueryNames;
1919
/** Constraint information */
2020
constraints?: TableConstraints;
21+
/** Smart tags parsed from PostGraphile @-prefixed comment directives */
22+
smartTags?: Record<string, string | true>;
2123
}
2224

2325
/**

graphql/query/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,4 +40,4 @@ export * from './client';
4040
export * from './introspect';
4141

4242
// Utility functions
43-
export { stripSmartComments } from './utils';
43+
export { parseSmartTags, stripSmartComments } from './utils';

graphql/query/src/introspect/infer-tables.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
*/
1515
import { lcFirst, pluralize, singularize, ucFirst } from 'inflekt';
1616

17-
import { stripSmartComments } from '../utils';
17+
import { parseSmartTags, stripSmartComments } from '../utils';
1818

1919
import type {
2020
IntrospectionField,
@@ -324,6 +324,9 @@ function buildCleanTable(
324324
// Extract description from entity type (PostgreSQL COMMENT), strip smart comments
325325
const description = commentsEnabled ? stripSmartComments(entityType.description) : undefined;
326326

327+
// Parse smart tags from raw description before they are stripped
328+
const smartTags = parseSmartTags(entityType.description);
329+
327330
return {
328331
table: {
329332
name: entityName,
@@ -333,6 +336,7 @@ function buildCleanTable(
333336
inflection,
334337
query,
335338
constraints,
339+
...(smartTags ? { smartTags } : {}),
336340
},
337341
hasRealOperation,
338342
};

graphql/query/src/types/schema.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ export interface Table {
1818
query?: TableQueryNames;
1919
/** Constraint information */
2020
constraints?: TableConstraints;
21+
/** Smart tags parsed from PostGraphile @-prefixed comment directives */
22+
smartTags?: Record<string, string | true>;
2123
}
2224

2325
/**

graphql/query/src/utils.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,3 +73,34 @@ export function stripSmartComments(
7373

7474
return result;
7575
}
76+
77+
/**
78+
* Parse PostGraphile smart tags from a description string.
79+
*
80+
* Smart tags are lines starting with `@` in PostgreSQL COMMENTs.
81+
* Each line is parsed as `@tagName` (boolean true) or `@tagName value`.
82+
*
83+
* Returns undefined if no smart tags are found.
84+
*/
85+
export function parseSmartTags(
86+
description: string | null | undefined,
87+
): Record<string, string | true> | undefined {
88+
if (!description) return undefined;
89+
90+
const lines = description.split('\n');
91+
let tags: Record<string, string | true> | undefined;
92+
93+
for (const line of lines) {
94+
const trimmed = line.trim();
95+
if (!trimmed.startsWith('@')) continue;
96+
97+
const spaceIdx = trimmed.indexOf(' ');
98+
const tagName = spaceIdx === -1 ? trimmed : trimmed.slice(0, spaceIdx);
99+
const tagValue = spaceIdx === -1 ? true : trimmed.slice(spaceIdx + 1).trim();
100+
101+
if (!tags) tags = {};
102+
tags[tagName] = tagValue || true;
103+
}
104+
105+
return tags;
106+
}

0 commit comments

Comments
 (0)