| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197 |
- /**
- * RTK Query generated-hook → endpoint synthesizer.
- *
- * RTK Query's `createApi({ endpoints })` defines endpoints as object-literal
- * properties (`getX: build.query(...)`) and generates one `useGetXQuery` /
- * `useUpdateYMutation` hook per endpoint, exported via a `const {…} = api`
- * destructuring. Neither the endpoint nor the generated hook is otherwise a node,
- * so a `component → useGetXQuery → getX → queryFn` flow has nothing to connect to.
- *
- * This validates the two halves: extraction mints a function node for each
- * endpoint (named by its key, both the `build => ({...})` arrow form and the
- * `endpoints(build){ return {...} }` method-shorthand form) and for each generated
- * hook binding; then the synthesizer bridges hook→endpoint by the naming
- * convention (incl. the `useLazyGetXQuery` variant → the same endpoint). Precision
- * is gated to genuinely-generated hooks: a hand-written `use*Query` arrow is never
- * bridged, and no edge ever crosses files.
- */
- import { describe, it, expect, beforeEach, afterEach } from 'vitest';
- import * as fs from 'node:fs';
- import * as path from 'node:path';
- import * as os from 'node:os';
- import { CodeGraph } from '../src';
- describe('rtk-query synthesizer', () => {
- let dir: string;
- beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rtk-query-')); });
- afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });
- it('extracts endpoints + generated hooks and bridges hook→endpoint (arrow + method + lazy + factory forms)', async () => {
- // Arrow form (shapeshift-style): `endpoints: build => ({...})`, `queryFn: () => {}`.
- fs.writeFileSync(
- path.join(dir, 'fiatRampApi.ts'),
- `import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
- import { fetchRamps } from './ramps';
- export const fiatRampApi = createApi({
- reducerPath: 'fiatRampApi',
- baseQuery: fetchBaseQuery({ baseUrl: '/' }),
- endpoints: build => ({
- getFiatRamps: build.query({
- queryFn: async () => {
- const data = await fetchRamps();
- return { data };
- },
- }),
- placeOrder: build.mutation({
- query: body => ({ url: 'order', method: 'POST', body }),
- }),
- }),
- });
- export const { useGetFiatRampsQuery, usePlaceOrderMutation, useLazyGetFiatRampsQuery } = fiatRampApi;
- `
- );
- // Method-shorthand form (basetool-style): `endpoints(builder){ return {...} }`,
- // `query(){}` method handler, plus a factory-handler endpoint (no fn literal).
- fs.writeFileSync(
- path.join(dir, 'dashApi.ts'),
- `import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
- import { makeCheckFn } from './factory';
- export const dashApi = createApi({
- reducerPath: 'dash',
- baseQuery: fetchBaseQuery({ baseUrl: '/' }),
- endpoints(builder) {
- return {
- getDashboards: builder.query({
- query() {
- return '/dashboards';
- },
- }),
- checkConnection: builder.mutation({
- queryFn: makeCheckFn('/check'),
- }),
- };
- },
- });
- export const { useGetDashboardsQuery, useCheckConnectionMutation } = dashApi;
- `
- );
- // Components consuming the generated hooks.
- fs.writeFileSync(
- path.join(dir, 'Views.tsx'),
- `import { useGetFiatRampsQuery, useLazyGetFiatRampsQuery } from './fiatRampApi';
- import { useGetDashboardsQuery } from './dashApi';
- export function FiatForm() {
- const { data } = useGetFiatRampsQuery();
- return data;
- }
- export function DashList() {
- const { data } = useGetDashboardsQuery();
- return data;
- }
- export function LazyForm() {
- const [load] = useLazyGetFiatRampsQuery();
- return load;
- }
- `
- );
- const cg = await CodeGraph.init(dir, { silent: true });
- await cg.indexAll();
- const db = (cg as any).db.db;
- // Endpoints are extracted as function nodes named by their key.
- const endpoints = db
- .prepare(`SELECT name, kind FROM nodes WHERE name IN ('getFiatRamps','placeOrder','getDashboards','checkConnection')`)
- .all();
- expect(endpoints.length).toBe(4);
- expect(endpoints.every((n: any) => n.kind === 'function')).toBe(true);
- // Generated hooks are extracted as function nodes carrying the sentinel.
- const hooks = db
- .prepare(`SELECT name FROM nodes WHERE signature = '= RTK Query generated hook' ORDER BY name`)
- .all()
- .map((r: any) => r.name);
- expect(hooks).toEqual([
- 'useCheckConnectionMutation',
- 'useGetDashboardsQuery',
- 'useGetFiatRampsQuery',
- 'useLazyGetFiatRampsQuery',
- 'usePlaceOrderMutation',
- ]);
- // hook → endpoint synth edges, including the Lazy variant mapping to the same endpoint.
- const synth = db
- .prepare(
- `SELECT s.name source, t.name target, s.file_path sf, t.file_path tf
- FROM edges e JOIN nodes s ON s.id = e.source JOIN nodes t ON t.id = e.target
- WHERE json_extract(e.metadata,'$.synthesizedBy') = 'rtk-query'`
- )
- .all();
- const pairs = synth.map((r: any) => `${r.source}->${r.target}`).sort();
- expect(pairs).toEqual([
- 'useCheckConnectionMutation->checkConnection',
- 'useGetDashboardsQuery->getDashboards',
- 'useGetFiatRampsQuery->getFiatRamps',
- 'useLazyGetFiatRampsQuery->getFiatRamps',
- 'usePlaceOrderMutation->placeOrder',
- ]);
- // Every synth edge stays within one file (RTK colocates api + hooks).
- expect(synth.every((r: any) => r.sf === r.tf)).toBe(true);
- // The component reaches the hook (normal import/call resolution), so the full
- // `component → hook → endpoint` chain is connected.
- const compToHook = db
- .prepare(
- `SELECT s.name source, t.name target FROM edges e
- JOIN nodes s ON s.id = e.source JOIN nodes t ON t.id = e.target
- WHERE s.name = 'FiatForm' AND t.name = 'useGetFiatRampsQuery' AND e.kind = 'calls'`
- )
- .all();
- expect(compToHook.length).toBeGreaterThan(0);
- cg.close?.();
- });
- it('does not bridge a hand-written use*Query hook (no createApi, no sentinel) — 0 synth edges', async () => {
- // A real custom hook of the same name shape, plus a same-file `getThing`
- // function it could spuriously map to. Without the generated-hook sentinel +
- // createApi destructuring, the synthesizer must produce nothing.
- fs.writeFileSync(
- path.join(dir, 'useGetThingQuery.ts'),
- `export function getThing() { return 42; }
- export const useGetThingQuery = () => {
- return getThing();
- };
- `
- );
- fs.writeFileSync(
- path.join(dir, 'Thing.tsx'),
- `import { useGetThingQuery } from './useGetThingQuery';
- export function Thing() {
- return useGetThingQuery();
- }
- `
- );
- const cg = await CodeGraph.init(dir, { silent: true });
- await cg.indexAll();
- const db = (cg as any).db.db;
- const synth = db
- .prepare(`SELECT count(*) c FROM edges WHERE json_extract(metadata,'$.synthesizedBy') = 'rtk-query'`)
- .get();
- expect(synth.c).toBe(0);
- // The hand-written hook keeps its real body (not a sentinel binding).
- const sentinel = db
- .prepare(`SELECT count(*) c FROM nodes WHERE signature = '= RTK Query generated hook'`)
- .get();
- expect(sentinel.c).toBe(0);
- cg.close?.();
- });
- });
|