├── .github
└── workflows
│ └── unit-tests.yml
├── .prettierignore
├── LICENSE
├── README.md
├── examples
├── basic
│ ├── .codesandbox
│ │ └── template.json
│ ├── .gitignore
│ ├── index.html
│ ├── package-lock.json
│ ├── package.json
│ ├── public
│ │ └── index.html
│ ├── src
│ │ └── index.tsx
│ ├── tsconfig.json
│ └── vite.config.ts
└── table-filters
│ ├── .codesandbox
│ └── template.json
│ ├── .gitignore
│ ├── index.html
│ ├── package-lock.json
│ ├── package.json
│ ├── src
│ ├── components
│ │ ├── App.tsx
│ │ ├── InvoiceList.tsx
│ │ ├── InvoiceListFilters.tsx
│ │ └── InvoicePreview.tsx
│ ├── context.ts
│ ├── index.css
│ ├── index.tsx
│ ├── models
│ │ ├── models.ts
│ │ ├── queries.ts
│ │ ├── services.ts
│ │ └── stores.ts
│ ├── router.tsx
│ ├── server
│ │ ├── data
│ │ │ ├── acme.ts
│ │ │ └── data.ts
│ │ └── index.ts
│ └── styles.css
│ ├── tsconfig.json
│ └── vite.config.ts
├── packages
├── mst-query-generator
│ ├── .gitignore
│ ├── generator
│ │ ├── config.ts
│ │ ├── generate.ts
│ │ └── scaffold.ts
│ ├── package-lock.json
│ ├── package.json
│ ├── src
│ │ ├── RootTypeOverride.ts
│ │ ├── field-handler-enum.ts
│ │ ├── field-handler-interface-union.ts
│ │ ├── field-handler-list.ts
│ │ ├── field-handler-object.ts
│ │ ├── field-handler-scalar.ts
│ │ ├── field-handler.ts
│ │ ├── index.ts
│ │ ├── logger.ts
│ │ ├── models
│ │ │ ├── Arg.ts
│ │ │ ├── Config.ts
│ │ │ ├── Directive.ts
│ │ │ ├── EnumValue.ts
│ │ │ ├── Field.ts
│ │ │ ├── FieldOverride.ts
│ │ │ ├── GeneratedField.ts
│ │ │ ├── GeneratedFile.ts
│ │ │ ├── InputField.ts
│ │ │ ├── InterfaceOrUnionTypeResult.ts
│ │ │ ├── Kind.ts
│ │ │ ├── Overrides.ts
│ │ │ ├── RootType.ts
│ │ │ ├── Schema.ts
│ │ │ ├── Specificity.ts
│ │ │ ├── Type.ts
│ │ │ ├── TypeResolver.ts
│ │ │ └── index.ts
│ │ ├── type-handler-enum.ts
│ │ ├── type-handler-interface-union.ts
│ │ ├── type-handler-object.ts
│ │ ├── type-handler.ts
│ │ ├── types.ts
│ │ └── utils.ts
│ ├── tests
│ │ ├── __snapshots__
│ │ │ ├── type-resolver.test.ts.snap
│ │ │ └── typeResolver.test.ts.snap
│ │ ├── field-handler.test.ts
│ │ ├── filter-types.test.ts
│ │ ├── generate.test.ts
│ │ ├── override.test.ts
│ │ ├── scaffold.test.ts
│ │ ├── schema
│ │ │ ├── abstractTypes.graphql
│ │ │ ├── todos.graphql
│ │ │ └── unionTypes.graphql
│ │ ├── type-handler.test.ts
│ │ └── type-resolver.test.ts
│ ├── tsconfig.build.json
│ ├── tsconfig.json
│ └── vite.config.ts
└── mst-query
│ ├── .gitignore
│ ├── .prettierignore
│ ├── package-lock.json
│ ├── package.json
│ ├── src
│ ├── MstQueryHandler.ts
│ ├── QueryClient.ts
│ ├── QueryClientProvider.tsx
│ ├── QueryStore.ts
│ ├── create.ts
│ ├── hooks.ts
│ ├── index.ts
│ ├── merge.ts
│ ├── stores.ts
│ └── utils.ts
│ ├── tests
│ ├── api
│ │ ├── api.ts
│ │ └── data.ts
│ ├── models
│ │ ├── AddItemMutation.ts
│ │ ├── ArrayQuery.ts
│ │ ├── ErrorMutation.ts
│ │ ├── ItemModel.ts
│ │ ├── ItemQuery.ts
│ │ ├── ItemQueryWithOptionalRequest.ts
│ │ ├── ListModel.ts
│ │ ├── ListQuery.ts
│ │ ├── RemoveItemMutation.ts
│ │ ├── RootStore.ts
│ │ ├── SafeReferenceQuery.ts
│ │ ├── SetDescriptionMutation.ts
│ │ ├── UnionModel.ts
│ │ └── UserModel.ts
│ ├── mstQuery.test.tsx
│ └── utils
│ │ ├── index.ts
│ │ └── utils.ts
│ ├── tsconfig.build.json
│ ├── tsconfig.json
│ └── vite.config.ts
└── prettier.config.js
/.github/workflows/unit-tests.yml:
--------------------------------------------------------------------------------
1 | name: Unit Tests
2 |
3 | on:
4 | push:
5 | branches: [ main ]
6 | pull_request:
7 | branches: [ main ]
8 |
9 | workflow_dispatch:
10 |
11 | jobs:
12 |
13 | test:
14 | name: Test
15 | runs-on: ubuntu-latest
16 | steps:
17 |
18 | - name: Setup Node.js
19 | uses: actions/setup-node@v2
20 | with:
21 | node-version: 18
22 |
23 | - name: Checkout repository
24 | uses: actions/checkout@v2
25 |
26 | - name: Install dependencies for mst-query
27 | working-directory: ./packages/mst-query
28 | run: npm install
29 |
30 | - name: Run mst-query test
31 | working-directory: ./packages/mst-query
32 | run: npm run test
33 |
34 | - name: Install dependencies for mst-query-generator
35 | working-directory: ./packages/mst-query-generator
36 | run: npm install
37 |
38 | - name: Run mst-query-generator test
39 | working-directory: ./packages/mst-query-generator
40 | run: npm run test
41 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | LICENSE
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 Conrab Opto
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | Query library for mobx-state-tree
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | # Features
10 |
11 | - Automatic Normalization
12 | - Garbage Collection
13 | - Infinite Scroll + Pagination Queries
14 | - Optimistic Mutations
15 | - Request Argument Type Validation
16 | - Abort Requests
17 | - Generate Models From Graphql Schema
18 |
19 | # Examples
20 |
21 | - [Basic](https://codesandbox.io/p/devbox/mst-query-basic-example-nk49ds?file=%2Fsrc%2Findex.tsx)
22 | - [Table Filters](https://codesandbox.io/p/devbox/mst-query-table-filters-example-2j3h3v?file=%2Fsrc%2Findex.tsx%3A18%2C26)
23 |
24 | # Basic Usage
25 |
26 | First, create a query...
27 |
28 | ```ts
29 | import { createQuery, createModelStore } from 'mst-query';
30 |
31 | const MessageQuery = createQuery('MessageQuery', {
32 | data: types.reference(MessageModel),
33 | request: types.model({ id: types.string }),
34 | endpoint({ request }) {
35 | return fetch(`messages/${request.id}`).then((res) => res.json());
36 | },
37 | });
38 | ```
39 |
40 | ...then use the query in a React component!
41 |
42 | ```tsx
43 | const MesssageView = observer((props) => {
44 | const { id, messageStore } = props;
45 | const { data, error, isLoading } = useQuery(messageStore.messageQuery, {
46 | request: { id },
47 | });
48 | if (error) {
49 | return
An error occured...
;
50 | }
51 | if (!data) {
52 | return Loading...
;
53 | }
54 | return {data.message}
;
55 | });
56 | ```
57 |
58 | # Documentation
59 |
60 | ## Installation
61 |
62 | ```
63 | npm install --save mst-query mobx-state-tree
64 | ```
65 |
66 | ## Configuration
67 |
68 | ```tsx
69 | import { createModelStore, createRootStore, QueryClient, createContext } from 'mst-query';
70 |
71 | const MessageQuery = createQuery('MessageQuery', {
72 | data: types.reference(MessageModel),
73 | request: types.model({ id: types.string }),
74 | endpoint({ request }) {
75 | return fetch(`messages/${request.id}`).then((res) => res.json());
76 | },
77 | });
78 |
79 | const MessageStore = createModelStore('MessageStore', MessageModel).props({
80 | messageQuery: types.optional(MessageQuery, {}),
81 | });
82 |
83 | const RootStore = createRootStore({
84 | messageStore: types.optional(MessageStore, {}),
85 | });
86 |
87 | const queryClient = new QueryClient({ RootStore });
88 | const { QueryClientProvider, useRootStore } = createContext(queryClient);
89 |
90 | function App() {
91 | return (
92 |
93 |
94 |
95 | );
96 | }
97 | ```
98 |
99 | ## Queries
100 |
101 | ### `createQuery`
102 |
103 | ```tsx
104 | import { types } from 'mobx-state-tree';
105 | import { createQuery } from 'mst-query';
106 | import { MessageModel } from './models';
107 | import { getItems } from './api';
108 |
109 | const MessageListQuery = createQuery('MessageListQuery', {
110 | data: types.array(types.reference(MessageModel)),
111 | request: types.model({ filter: '' }),
112 | endpoint({ request }) {
113 | return fetch(`messages?filter=${request.filter}`).then((res) => res.json());
114 | },
115 | });
116 | ```
117 |
118 | ### `useQuery`
119 |
120 | ```tsx
121 | import { useQuery } from 'mst-query';
122 | import { observer } from 'mobx-react';
123 | import { MessageQuery } from './MessageQuery';
124 |
125 | const MesssageView = observer((props) => {
126 | const { id, snapshot, result } = props;
127 | const rootStore = useRootStore();
128 | const {
129 | data,
130 | error,
131 | isLoading,
132 | isFetched,
133 | isRefetching,
134 | isFetchingMore,
135 | query,
136 | refetch,
137 | cachedAt,
138 | } = useQuery(rootStore.messageStore.messageQuery, {
139 | data: snapshot,
140 | request: { id },
141 | enabled: !!id,
142 | onError(data, self) {},
143 | staleTime: 0,
144 | });
145 | if (error) {
146 | return An error occured...
;
147 | }
148 | if (isLoading) {
149 | return Loading...
;
150 | }
151 | return {data.message}
;
152 | });
153 | ```
154 |
155 | ## Paginated and infinite lists
156 |
157 | ```tsx
158 | import { types } from 'mobx-state-tree';
159 | import { createInfiniteQuery, RequestModel } from 'mst-query';
160 | import { MessageModel } from './models';
161 |
162 | const MessagesQuery = createInfiniteQuery('MessagesQuery', {
163 | data: types.model({ items: types.array(types.reference(MessageModel)) }),
164 | pagination: types.model({ offset: types.number, limit: types.number }),
165 | endpoint({ request }) {
166 | return fetch(`messages?offset=${request.offset}&limit=${request.limit}`).then((res) =>
167 | res.json()
168 | );
169 | },
170 | });
171 |
172 | const MessageStore = createModelStore('MessageStore', MessageModel).props({
173 | messagesQuery: types.optional(MessagesQuery, {}),
174 | });
175 | ```
176 |
177 | ```tsx
178 | import { useInfiniteQuery } from 'mst-query';
179 | import { observer } from 'mobx-react';
180 | import { MessageListQuery } from './MessageListQuery';
181 |
182 | const MesssageListView = observer((props) => {
183 | const [offset, setOffset] = useState(0);
184 | const { data, isFetchingMore, query } = useInfiniteQuery(messageStore.messagesQuery, {
185 | request: { filter: '' },
186 | pagination: { offset, limit: 20 },
187 | });
188 | if (isFetchingMore) {
189 | return Is fetching more results...
;
190 | }
191 | return (
192 |
193 | {data.items.map((item) => (
194 |
195 | ))}
196 |
197 |
198 | );
199 | });
200 | ```
201 |
202 | ## Mutations
203 |
204 | ### `createMutation`
205 |
206 | ```tsx
207 | import { types } from 'mobx-state-tree';
208 | import { createMutation } from 'mst-query';
209 |
210 | const AddMessageMutation = createMutation('AddMessage', {
211 | data: types.reference(MessageModel),
212 | request: types.model({ message: types.string }),
213 | });
214 |
215 | const MessageStore = createModelStore('MessageStore', MessageModel)
216 | .props({
217 | messagesQuery: types.optional(MessagesQuery, {}),
218 | addMessageMutation: types.optional(AddMessageMutation, {}),
219 | })
220 | .actions((self) => ({
221 | afterCreate() {
222 | onMutate(self.addMessageMutation, (data) => {
223 | self.messagesQuery.data?.items.push(data);
224 | });
225 | },
226 | }));
227 | ```
228 |
229 | ### `useMutation`
230 |
231 | ```tsx
232 | import { useMutation } from 'mst-query';
233 | import { observer } from 'mobx-react';
234 | import { AddMessageMutation } from './AddMessageMutation';
235 |
236 | const AddMessage = observer((props) => {
237 | const { messageStore } = props;
238 | const [message, setMessage] = useState('');
239 | const [addMessage, { isLoading }] = useMutation(messageStore.addMessageMutation);
240 | return (
241 |
242 |
261 | );
262 | });
263 | ```
264 |
265 | ## Model generator (GraphQL)
266 |
267 | Generate mobx-state-tree models from your graphql schema.
268 |
269 | ```ts
270 | npx mst-query-generator schema.graphql
271 | ```
272 |
273 | ## Cache
274 |
275 | The option `staleTime` controls how much time should pass before a cached value needs to be refetched from the server.
276 |
277 | ### Garbage collection
278 |
279 | ```tsx
280 | rootStore.runGc();
281 | ```
282 |
283 | ### Globally interacting with queries
284 |
285 | ```tsx
286 | const queriesWithId = rootStore.getQueries(MessageQuery, (q) => q.request.id === 'message-id');
287 | queriesWithId.forEach((q) => q.refetch());
288 |
289 | const allMessageMutations = rootStore.getQueries(UpdateMessageMutation);
290 | allMessageMutations.forEach((m) => m.abort());
291 | ```
292 |
--------------------------------------------------------------------------------
/examples/basic/.codesandbox/template.json:
--------------------------------------------------------------------------------
1 | {
2 | "title": "mst-query-basic-example",
3 | "description": "mst-query basic example",
4 | "published": true
5 | }
--------------------------------------------------------------------------------
/examples/basic/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
--------------------------------------------------------------------------------
/examples/basic/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | Mst-query React Basic Example App
10 |
11 |
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/examples/basic/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "mst-query-basic-example",
3 | "private": true,
4 | "type": "module",
5 | "scripts": {
6 | "dev": "vite",
7 | "build": "tsc && vite build",
8 | "preview": "vite preview"
9 | },
10 | "dependencies": {
11 | "react": "^18.2.0",
12 | "react-dom": "18.2.0",
13 | "mobx": "6.13.5",
14 | "mobx-react": "9.1.1",
15 | "mobx-state-tree": "6.0.1",
16 | "mst-query": "4.0.3"
17 | },
18 | "devDependencies": {
19 | "@types/react": "^18.2.79",
20 | "@types/react-dom": "^18.2.25",
21 | "@vitejs/plugin-react": "^4.3.1",
22 | "typescript": "5.3.3",
23 | "vite": "5.4.9"
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/examples/basic/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
13 |
14 |
23 | React App
24 |
25 |
26 |
27 |
30 |
31 |
41 |
42 |
43 |
--------------------------------------------------------------------------------
/examples/basic/src/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { StrictMode } from 'react';
2 | import ReactDOM from 'react-dom/client';
3 | import { types } from 'mobx-state-tree';
4 | import { observer } from 'mobx-react';
5 | import {
6 | createContext,
7 | QueryClient,
8 | useQuery,
9 | createQuery,
10 | createRootStore,
11 | createModelStore,
12 | } from 'mst-query';
13 |
14 | export const PostModel = types.model('PostModel', {
15 | id: types.identifierNumber,
16 | title: types.string,
17 | body: types.maybe(types.string),
18 | userId: types.number,
19 | });
20 |
21 | export const PostsQuery = createQuery('PostsQuery', {
22 | data: types.array(types.reference(PostModel)),
23 | async endpoint() {
24 | const response = await fetch('https://jsonplaceholder.typicode.com/posts');
25 | const result = await response.json();
26 | return result.map((d: any) => ({
27 | userId: d.userId,
28 | id: d.id,
29 | title: d.title,
30 | }));
31 | },
32 | });
33 |
34 | const PostQuery = createQuery('PostQuery', {
35 | data: types.reference(PostModel), request: types.model({ id: types.number }),
36 | async endpoint({ request }) {
37 | const { id } = request;
38 | const response = await fetch(`https://jsonplaceholder.typicode.com/posts/${id}`);
39 | return await response.json();
40 | },
41 | });
42 |
43 | const PostStore = createModelStore('PostStore', PostModel)
44 | .props({
45 | postsQuery: types.optional(PostsQuery, {}),
46 | postQuery: types.optional(PostQuery, {}),
47 | })
48 | .views((self) => ({
49 | get postList() {
50 | return Array.from(self.models.values());
51 | },
52 | }));
53 |
54 | const RootStore = createRootStore({
55 | postStore: types.optional(PostStore, {}),
56 | });
57 |
58 | const queryClient = new QueryClient({ RootStore });
59 | const { useRootStore, QueryClientProvider } = createContext(queryClient);
60 |
61 | const Posts: React.FC = observer((props) => {
62 | const { setSelectedPost } = props;
63 | const { postStore } = useRootStore();
64 |
65 | const { data, error, isLoading } = useQuery(postStore.postsQuery);
66 |
67 | return (
68 |
69 |
Posts
70 |
71 | {!data ? (
72 | 'Loading...'
73 | ) : error ? (
74 |
Error: {error.message}
75 | ) : (
76 | <>
77 |
102 |
{data && isLoading ? 'Background Updating...' : ' '}
103 | >
104 | )}
105 |
106 |
107 | );
108 | });
109 |
110 | const Post: React.FC = observer((props) => {
111 | const { post, setSelectedPost } = props;
112 | const { postStore } = useRootStore();
113 |
114 | const { data, isLoading, error } = useQuery(postStore.postQuery, {
115 | request: { id: post.id },
116 | enabled: !!post
117 | });
118 |
119 | return (
120 |
121 |
126 | {!data ? (
127 | 'Loading...'
128 | ) : error ? (
129 |
Error: {error.message}
130 | ) : (
131 | <>
132 |
{data.title}
133 |
136 |
{isLoading ? 'Background Updating...' : ' '}
137 | >
138 | )}
139 |
140 | );
141 | });
142 |
143 | const env = {};
144 |
145 | const App = observer(() => {
146 | const [selectedPost, setSelectedPost] = React.useState(null);
147 | return (
148 |
149 |
150 | {selectedPost ? (
151 |
152 | ) : (
153 |
154 | )}
155 |
156 |
157 | );
158 | });
159 |
160 | const rootElement: any = document.getElementById('root');
161 | const root = ReactDOM.createRoot(rootElement);
162 | root.render(
163 |
164 |
165 |
166 | );
167 |
--------------------------------------------------------------------------------
/examples/basic/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "lib": ["DOM", "DOM.Iterable", "ESNext"],
5 | "module": "ESNext",
6 | "skipLibCheck": true,
7 |
8 | "moduleResolution": "Bundler",
9 | "allowImportingTsExtensions": true,
10 | "resolveJsonModule": true,
11 | "isolatedModules": true,
12 | "noEmit": true,
13 | "jsx": "react-jsx",
14 |
15 | "strict": true,
16 | },
17 | "include": ["src"]
18 | }
19 |
--------------------------------------------------------------------------------
/examples/basic/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite'
2 | import react from '@vitejs/plugin-react'
3 |
4 | // https://vitejs.dev/config/
5 | export default defineConfig({
6 | plugins: [react()]
7 | })
8 |
--------------------------------------------------------------------------------
/examples/table-filters/.codesandbox/template.json:
--------------------------------------------------------------------------------
1 | {
2 | "title": "mst-query-table-filters-example",
3 | "description": "mst-query table filters example",
4 | "published": true
5 | }
--------------------------------------------------------------------------------
/examples/table-filters/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
--------------------------------------------------------------------------------
/examples/table-filters/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | mst-query v2 - table filters
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/examples/table-filters/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "mst-query-table-filters-example",
3 | "private": true,
4 | "type": "module",
5 | "scripts": {
6 | "dev": "vite",
7 | "build": "tsc && vite build",
8 | "preview": "vite preview"
9 | },
10 | "dependencies": {
11 | "mobx": "6.13.5",
12 | "mobx-react": "9.1.1",
13 | "mobx-state-tree": "6.0.1",
14 | "mst-query": "4.0.3",
15 | "react": "^18.2.0",
16 | "react-dom": "^18.2.0",
17 | "react-router-dom": "^6.4.2"
18 | },
19 | "devDependencies": {
20 | "@types/react": "^18.2.79",
21 | "@types/react-dom": "^18.2.25",
22 | "@vitejs/plugin-react": "^4.3.1",
23 | "typescript": "5.3.3",
24 | "vite": "5.4.9"
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/examples/table-filters/src/components/App.tsx:
--------------------------------------------------------------------------------
1 | import { observer } from 'mobx-react';
2 | import { useQuery } from 'mst-query';
3 | import { useEffect } from 'react';
4 | import { Link, Outlet, useNavigate, useParams } from 'react-router-dom';
5 | import { useRootStore } from '../context';
6 | import { CompanyModelType } from '../models/models';
7 |
8 | const Sidebar = observer(({ items }: { items: CompanyModelType[] }) => {
9 | const { companyId } = useParams();
10 | const navigate = useNavigate();
11 |
12 | useEffect(() => {
13 | if (!companyId && items.length) {
14 | navigate(`/company/${items[0].id}`);
15 | }
16 | }, [companyId, items, navigate]);
17 |
18 | return (
19 |
20 | {items.map((item: any) => (
21 |
22 | {item.name}
23 |
24 | ))}
25 |
26 | );
27 | });
28 |
29 | const MainScreen = observer(({ companies }: { companies: CompanyModelType[] }) => {
30 | return (
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 | );
40 | });
41 |
42 | export const App = observer(() => {
43 | const rootStore = useRootStore();
44 | const { data } = useQuery(rootStore.baseQuery);
45 | if (!data) {
46 | return Loading app...
;
47 | }
48 | return ;
49 | });
50 |
--------------------------------------------------------------------------------
/examples/table-filters/src/components/InvoiceList.tsx:
--------------------------------------------------------------------------------
1 | import { observer } from 'mobx-react';
2 | import { useState } from 'react';
3 | import { useParams, useSearchParams } from 'react-router-dom';
4 | import { useMutation, useInfiniteQuery } from 'mst-query';
5 | import { useRootStore } from '../context';
6 | import { InvoiceModelType, InvoiceFilterModelType } from '../models/models';
7 | import { InvoiceEditor } from './InvoicePreview';
8 | import { InvoiceListFilters } from './InvoiceListFilters';
9 |
10 | const List = observer(
11 | (props: {
12 | items: InvoiceModelType[];
13 | onRowClick: (item: InvoiceModelType) => void;
14 | minAmount: number;
15 | selectedItemId?: string;
16 | }) => {
17 | const { items, minAmount, onRowClick, selectedItemId } = props;
18 | return (
19 | <>
20 | {items
21 | .filter((invoice) => invoice.amount > minAmount)
22 | .map((invoice) => (
23 | onRowClick(invoice)}
26 | style={{
27 | background: selectedItemId === invoice.id ? '#eee' : undefined,
28 | }}>
29 | {invoice.id} |
30 | {invoice.amount} |
31 | {invoice.createdBy.shortName} |
32 |
33 | ))}
34 | >
35 | );
36 | }
37 | );
38 |
39 | export const InvoiceList = observer(() => {
40 | const rootStore = useRootStore();
41 | const invoiceService = rootStore.invoiceService;
42 | const invoiceStore = rootStore.invoiceStore;
43 |
44 | const { companyId } = useParams();
45 | const [searchParams, setSearchParams] = useSearchParams();
46 | const [offset, setOffset] = useState(0);
47 | const [previewId, setPreviewId] = useState();
48 |
49 | const { data, isLoading, isFetchingMore, isRefetching, refetch } = useInfiniteQuery(
50 | invoiceService.invoiceListQuery,
51 | {
52 | request: { id: companyId! },
53 | pagination: { offset },
54 | }
55 | );
56 |
57 | const [remove] = useMutation(invoiceService.removeInvoiceFilterMutation);
58 | const [save] = useMutation(invoiceService.saveInvoiceFilterMutation);
59 |
60 | const selectedFilter =
61 | data?.filters.find((filter) => filter.id === searchParams.get('filter')) ??
62 | data?.filters[0];
63 | const preview = previewId && invoiceStore.models.get(previewId);
64 |
65 | const handleOnSelectedFilter = (filter: InvoiceFilterModelType) => {
66 | setSearchParams({ filter: filter.id });
67 | };
68 |
69 | const saveFilter = async (filter: InvoiceFilterModelType) => {
70 | const result = await save({
71 | request: { id: filter.id, filter: filter.filter ?? '' },
72 | });
73 | if (result.data) {
74 | setSearchParams({ filter: result.data?.id });
75 | }
76 | };
77 |
78 | const addFilter = async () => {
79 | const result = await save({
80 | request: { id: undefined, filter: '' },
81 | });
82 | if (result.data) {
83 | setSearchParams({ filter: result.data.id });
84 | }
85 | };
86 |
87 | const removeFilter = async (filter: InvoiceFilterModelType) => {
88 | await remove({ request: { id: filter.id } });
89 | if (data?.filters.length) {
90 | setSearchParams({ filter: data.filters[0].id });
91 | }
92 | };
93 |
94 | const loadMore = () => {
95 | const offset = (data?.invoices.length ?? 0) + 1;
96 | setOffset(offset);
97 | };
98 |
99 | const reset = () => {
100 | setOffset(0);
101 | refetch({ request: { id: companyId }, pagination: { offset: 0 } });
102 | };
103 |
104 | if (!data || !selectedFilter) {
105 | return Loading invoices...
;
106 | }
107 | return (
108 |
109 |
110 |
111 |
112 |
113 |
126 |
127 |
132 |
137 |
138 |
139 |
143 |
144 |
145 |
isLoading:{`${isLoading}`}
146 |
isRefetching:{`${isRefetching}`}
147 |
isFetchingMore:{`${isFetchingMore}`}
148 |
149 |
150 |
151 |
152 |
153 |
154 | Invoice id |
155 | Amount |
156 | Created by |
157 |
158 |
159 |
160 |
164 | setPreviewId((id) => (id === item.id ? undefined : item.id))
165 | }
166 | selectedItemId={previewId}
167 | />
168 |
169 |
170 |
171 |
172 | {preview && }
173 |
174 |
175 |
176 | );
177 | });
178 |
--------------------------------------------------------------------------------
/examples/table-filters/src/components/InvoiceListFilters.tsx:
--------------------------------------------------------------------------------
1 | import { observer } from "mobx-react";
2 | import { useState } from "react";
3 | import { InvoiceFilterModelType } from "../models/models";
4 |
5 | export const InvoiceListFilter = observer(
6 | ({ filter }: { filter: InvoiceFilterModelType }) => {
7 | const [filterText, setFilterText] = useState(filter.filter ?? "");
8 | return (
9 | <>
10 | setFilterText(ev.target.value)}
14 | placeholder={"Filter min amount..."}
15 | />
16 |
19 | >
20 | );
21 | }
22 | );
23 |
24 | export const InvoiceListFilters = observer(
25 | ({ selectedFilter }: { selectedFilter: InvoiceFilterModelType }) => {
26 | return (
27 |
28 | {selectedFilter && (
29 |
30 | )}
31 |
32 | );
33 | }
34 | );
35 |
--------------------------------------------------------------------------------
/examples/table-filters/src/components/InvoicePreview.tsx:
--------------------------------------------------------------------------------
1 | import { observer } from "mobx-react";
2 | import { useQuery } from "mst-query";
3 | import { useRootStore } from "../context";
4 | import { InvoiceModelType } from "../models/models";
5 |
6 | const useInvoiceQuery = (id: string, initialData: any) => {
7 | const rootStore = useRootStore();
8 | const invoiceApiStore = rootStore.invoiceService;
9 | return useQuery(invoiceApiStore.invoiceQuery, {
10 | request: { id },
11 | initialData,
12 | staleTime: 5000
13 | });
14 | };
15 |
16 | export const InvoiceEditor = observer(
17 | (props: { invoice: InvoiceModelType }) => {
18 | const { invoice } = props;
19 | const { isLoading } = useInvoiceQuery(invoice.id, invoice);
20 | return (
21 |
22 |
{isLoading && `Loading invoice...`}
23 |
Invoice id
24 |
{invoice.id}
25 |
Amount
26 |
{invoice.amount}
27 |
Due date
28 |
{invoice.dueDate?.toDateString()}
29 |
Created by
30 |
{invoice.createdBy?.name}
31 |
Approver
32 |
{invoice.createdBy?.approver?.name}
33 |
34 | );
35 | }
36 | );
37 |
--------------------------------------------------------------------------------
/examples/table-filters/src/context.ts:
--------------------------------------------------------------------------------
1 | import { createContext, QueryClient } from "mst-query";
2 | import { RootStore } from "./models/stores";
3 |
4 | export const queryClient = new QueryClient({ RootStore });
5 |
6 | export const { QueryClientProvider, useRootStore } = createContext(queryClient);
7 |
--------------------------------------------------------------------------------
/examples/table-filters/src/index.css:
--------------------------------------------------------------------------------
1 | :root {
2 | font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
3 | font-size: 16px;
4 | line-height: 24px;
5 | font-weight: 400;
6 |
7 | font-synthesis: none;
8 | text-rendering: optimizeLegibility;
9 | -webkit-font-smoothing: antialiased;
10 | -moz-osx-font-smoothing: grayscale;
11 | -webkit-text-size-adjust: 100%;
12 | }
13 |
14 | * {
15 | box-sizing: border-box;
16 | }
17 |
18 | html {
19 | height: 100%;
20 | }
21 |
22 | body {
23 | margin: 0;
24 | height: 100%;
25 | background: white;
26 | color: #333;
27 | }
28 |
29 | #root {
30 | display: flex;
31 | min-height: 100%;
32 | }
33 |
34 | .app-container {
35 | flex: 1;
36 | display: flex;
37 | }
38 |
39 | .app-sidebar {
40 | display: flex;
41 | width: 200px;
42 | padding: 24px;
43 | background-color: #f0f0f0;
44 | border-right: 1px solid #ddd;
45 | }
46 |
47 | .app-list {
48 | display: flex;
49 | padding: 24px;
50 | flex: 1;
51 | }
52 |
53 | .invoice-list {
54 | flex: 1;
55 | }
56 |
57 | .invoice-list-filters {
58 | margin-bottom: 12px;
59 | }
60 |
61 | .invoice-list-filters > select {
62 | margin-right: 12px;
63 | }
64 |
65 | .invoice-list-filters > input {
66 | margin-right: 4px;
67 | }
68 |
69 | .invoice-list-buttons {
70 | margin-bottom: 24px;
71 | }
72 |
73 | .invoice-list-buttons > * {
74 | margin-right: 12px;
75 | }
76 |
77 | .invoice-list-result {
78 | display: flex;
79 | margin-top: 12px;
80 | }
81 |
82 | .invoice-list-table {
83 | flex: 1;
84 | border: 1px solid #ddd;
85 | overflow: auto;
86 | height: 800px;
87 | }
88 |
89 | .invoice-list-preview {
90 | flex: 1;
91 | margin-left: 24px;
92 | }
93 |
94 | table {
95 | table-layout: fixed;
96 | border-collapse: collapse;
97 | }
98 |
99 | thead th:nth-child(1) {
100 | width: 35%;
101 | }
102 |
103 | thead th:nth-child(2) {
104 | width: 32%;
105 | }
106 |
107 | thead th:nth-child(3) {
108 | width: 32%;
109 | }
110 |
111 | tbody td {
112 | text-align: center;
113 | }
114 |
115 | tbody tr:hover {
116 | background: #eee;
117 | cursor: pointer;
118 | }
119 |
120 | th,
121 | td {
122 | padding: 12px;
123 | }
124 |
--------------------------------------------------------------------------------
/examples/table-filters/src/index.tsx:
--------------------------------------------------------------------------------
1 | import { StrictMode } from "react";
2 | import * as ReactDOMClient from "react-dom/client";
3 | import { RouterProvider } from "react-router-dom";
4 | import { createContext, QueryClient } from "mst-query";
5 | import { RootStore } from "./models/stores";
6 | import { router } from "./router";
7 | import "./styles.css";
8 |
9 | const rootElement = document.getElementById("root");
10 | const root = ReactDOMClient.createRoot(rootElement as HTMLElement);
11 |
12 | export const queryClient = new QueryClient({ RootStore });
13 |
14 | export const { QueryClientProvider, useRootStore } = createContext(queryClient);
15 |
16 | root.render(
17 |
18 |
19 |
20 |
21 |
22 | );
23 |
--------------------------------------------------------------------------------
/examples/table-filters/src/models/models.ts:
--------------------------------------------------------------------------------
1 | import { IAnyModelType, Instance, types } from 'mobx-state-tree';
2 |
3 | export const UserModel = types.model('UserModel', {
4 | id: types.identifier,
5 | name: types.maybe(types.string),
6 | shortName: types.maybe(types.string),
7 | approver: types.maybe(types.reference(types.late((): IAnyModelType => UserModel))),
8 | });
9 |
10 | export type UserModelType = Instance;
11 |
12 | export const InvoiceModel = types.model('InvoiceModel', {
13 | id: types.identifier,
14 | company: types.maybe(types.string),
15 | amount: types.number,
16 | dueDate: types.maybe(types.Date),
17 | createdBy: types.reference(UserModel),
18 | });
19 |
20 | export type InvoiceModelType = Instance;
21 |
22 | export const InvoiceFilterModel = types
23 | .model('InvoiceFilterModel', {
24 | id: types.identifier,
25 | name: types.string,
26 | filter: types.maybe(types.string),
27 | })
28 | .volatile((self) => ({
29 | hasChanged: false,
30 | }))
31 | .views((self) => ({
32 | get filterMinAmount() {
33 | if (!self.filter) {
34 | return 0;
35 | }
36 | const parsed = parseFloat(self.filter);
37 | return Number.isNaN(parsed) ? 0 : parsed;
38 | },
39 | }))
40 | .actions((self) => ({
41 | setFilter(filter: string) {
42 | self.filter = filter;
43 |
44 | if (!self.hasChanged) {
45 | self.hasChanged = true;
46 | }
47 | },
48 | }))
49 | .actions((self) => ({
50 | setHasChanged(hasChanged: boolean) {
51 | self.hasChanged = hasChanged;
52 | },
53 | }));
54 |
55 | export type InvoiceFilterModelType = Instance;
56 |
57 | export const InvoiceListModel = types.model('InvoiceListModel', {
58 | filters: types.array(
59 | types.safeReference(InvoiceFilterModel, {
60 | acceptsUndefined: false,
61 | })
62 | ),
63 | invoices: types.array(
64 | types.safeReference(InvoiceModel, {
65 | acceptsUndefined: false,
66 | })
67 | ),
68 | });
69 |
70 | export type InvoiceListModelType = Instance;
71 |
72 | export const CompanyModel = types.model('CompanyModel', {
73 | id: types.identifier,
74 | name: types.string,
75 | invoiceList: types.maybe(InvoiceListModel),
76 | });
77 |
78 | export type CompanyModelType = Instance;
79 |
--------------------------------------------------------------------------------
/examples/table-filters/src/models/queries.ts:
--------------------------------------------------------------------------------
1 | import { Instance, types } from 'mobx-state-tree';
2 | import { createMutation, createQuery, createInfiniteQuery } from 'mst-query';
3 | import { CompanyModel, InvoiceFilterModel, InvoiceListModel, InvoiceModel } from './models';
4 | import * as api from '../server';
5 |
6 | export const SaveInvoiceFilterMutation = createMutation('SaveInvoiceFilterMutation', {
7 | data: types.safeReference(InvoiceFilterModel),
8 | request: types.model({
9 | id: types.maybe(types.string),
10 | filter: types.string,
11 | }),
12 | endpoint({ request }) {
13 | return api.saveInvoiceFilter(request.id, request.filter);
14 | },
15 | });
16 |
17 | export const RemoveInvoiceFilterMutation = createMutation('SaveInvoiceFilterMutation', {
18 | data: types.safeReference(InvoiceFilterModel),
19 | request: types.model({ id: types.string }),
20 | endpoint({ request }) {
21 | return api.removeInvoiceFilter(request.id);
22 | },
23 | });
24 |
25 | export const InvoiceQuery = createQuery('InvoiceQuery', {
26 | data: types.safeReference(InvoiceModel),
27 | request: types.model({ id: types.string }),
28 | endpoint({ request }) {
29 | return api.getInvoice(request.id);
30 | },
31 | });
32 |
33 | export const InvoiceListQuery = createInfiniteQuery('InvoiceListQuery', {
34 | data: InvoiceListModel,
35 | request: types.model({ id: types.string }),
36 | pagination: types.model({ offset: 0 }),
37 | onQueryMore({ query, data }) {
38 | query.data?.invoices.push(...data.invoices);
39 | },
40 | async endpoint({ request, pagination }) {
41 | return api.getInvoiceList(pagination.offset);
42 | },
43 | });
44 |
45 | export const AppQuery = createQuery('AppQuery', {
46 | data: types.array(types.reference(CompanyModel)),
47 | async endpoint() {
48 | return api.getCompanies();
49 | },
50 | });
51 |
52 | export type AppQueryType = Instance;
53 |
--------------------------------------------------------------------------------
/examples/table-filters/src/models/services.ts:
--------------------------------------------------------------------------------
1 | import { types, Instance, destroy, castToReferenceSnapshot } from 'mobx-state-tree';
2 | import { onMutate } from 'mst-query';
3 | import {
4 | InvoiceListQuery,
5 | InvoiceQuery,
6 | RemoveInvoiceFilterMutation,
7 | SaveInvoiceFilterMutation,
8 | } from './queries';
9 |
10 | export const InvoiceService = types
11 | .model({
12 | invoiceQuery: types.optional(InvoiceQuery, {}),
13 | invoiceListQuery: types.optional(InvoiceListQuery, {}),
14 | saveInvoiceFilterMutation: types.optional(SaveInvoiceFilterMutation, {}),
15 | removeInvoiceFilterMutation: types.optional(RemoveInvoiceFilterMutation, {}),
16 | })
17 | .views((self) => ({
18 | get isMutating() {
19 | return (
20 | self.saveInvoiceFilterMutation.isLoading ||
21 | self.removeInvoiceFilterMutation.isLoading
22 | );
23 | },
24 | }))
25 | .actions((self) => ({
26 | afterCreate() {
27 | onMutate(self.saveInvoiceFilterMutation, (data) => {
28 | const filter = self.invoiceListQuery.data?.filters.find((f) => f.id === data?.id);
29 | if (!filter) {
30 | // filer not in list, add it
31 | self.invoiceListQuery.data?.filters.push(castToReferenceSnapshot(data));
32 | } else {
33 | // filter in list, just set hasChanged to false
34 | filter.setHasChanged(false);
35 | }
36 | });
37 | onMutate(self.removeInvoiceFilterMutation, (data) => {
38 | if (data) {
39 | destroy(data);
40 | }
41 |
42 | if (!self.invoiceListQuery.data?.filters.length) {
43 | self.saveInvoiceFilterMutation.mutate({
44 | request: { id: undefined, filter: '' },
45 | });
46 | }
47 | });
48 | },
49 | }));
50 |
51 | export type InvoiceServiceType = Instance;
52 |
--------------------------------------------------------------------------------
/examples/table-filters/src/models/stores.ts:
--------------------------------------------------------------------------------
1 | import { types, Instance } from 'mobx-state-tree';
2 | import { createModelStore, createRootStore } from 'mst-query';
3 | import { InvoiceService } from './services';
4 | import { InvoiceFilterModel, InvoiceModel, CompanyModel, UserModel } from './models';
5 | import { AppQuery } from './queries';
6 |
7 | export const InvoiceModelStore = createModelStore('InvoiceModelStore', InvoiceModel);
8 |
9 | export const InvoiceFilterModelStore = createModelStore(
10 | 'InvoiceFilterModelStore',
11 | InvoiceFilterModel
12 | );
13 |
14 | export const CompanyModelStore = createModelStore('CompanyModelStore', CompanyModel);
15 |
16 | export const UserModelStore = createModelStore('UserModelStore', UserModel);
17 |
18 | export const RootStore = createRootStore({
19 | invoiceStore: types.optional(InvoiceModelStore, {}),
20 | invoiceFilterStore: types.optional(InvoiceFilterModelStore, {}),
21 | companyStore: types.optional(CompanyModelStore, {}),
22 | userStore: types.optional(UserModelStore, {}),
23 | }).props({
24 | invoiceService: types.optional(InvoiceService, {}),
25 | baseQuery: types.optional(AppQuery, {}),
26 | });
27 |
28 | export type RootStoreType = Instance;
29 |
--------------------------------------------------------------------------------
/examples/table-filters/src/router.tsx:
--------------------------------------------------------------------------------
1 | import { createBrowserRouter } from "react-router-dom";
2 | import { App } from "./components/App";
3 | import { InvoiceList } from "./components/InvoiceList";
4 |
5 | export const router = createBrowserRouter([
6 | {
7 | path: "/",
8 | element: ,
9 | children: [
10 | {
11 | path: "company/:companyId",
12 | element:
13 | }
14 | ]
15 | }
16 | ]);
17 |
--------------------------------------------------------------------------------
/examples/table-filters/src/server/data/data.ts:
--------------------------------------------------------------------------------
1 | export const baseData = [
2 | {
3 | id: "acme",
4 | name: "Acme corporation"
5 | }
6 | ];
7 |
--------------------------------------------------------------------------------
/examples/table-filters/src/server/index.ts:
--------------------------------------------------------------------------------
1 | import { listData, listMoreData } from './data/acme';
2 | import { baseData } from './data/data';
3 |
4 | export const wait = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms));
5 |
6 | let filters = 2;
7 |
8 | export const saveInvoiceFilter = async (id: string | undefined, filterStr: string) => {
9 | const filter = listData.filters.find((filter) => filter.id === id);
10 | await wait(200);
11 | if (filter) {
12 | return {
13 | ...filter,
14 | filter: filterStr,
15 | };
16 | }
17 | const newId = ++filters;
18 | const newFilter = {
19 | id: `filter-${newId}`,
20 | name: `Saved filter ${newId}`,
21 | filter: filterStr,
22 | };
23 | listData.filters.push(newFilter);
24 | listMoreData.filters.push(newFilter);
25 | return newFilter;
26 | };
27 |
28 | export const removeInvoiceFilter = async (id: string) => {
29 | listData.filters = listData.filters.filter((f) => f.id === id);
30 | listMoreData.filters = listMoreData.filters.filter((f) => f.id === id);
31 | await wait(1000);
32 | return id;
33 | };
34 |
35 | export const getInvoice = async (id: string) => {
36 | const invoice = [...listData.invoices, ...listMoreData.invoices].find(
37 | (invoice) => invoice.id === id
38 | );
39 | await wait(2000);
40 | return {
41 | ...invoice,
42 | dueDate: new Date('2022-10-27T12:46:40.706Z'),
43 | createdBy: {
44 | id: 'user-1',
45 | name: 'Kim Ode',
46 | approver: {
47 | id: 'user-2',
48 | name: 'Johan Andersson',
49 | shortName: 'JA',
50 | },
51 | },
52 | };
53 | };
54 |
55 | export const getInvoiceList = async (offset: number) => {
56 | await wait(200);
57 | if (offset >= 70) {
58 | return {
59 | ...listMoreData,
60 | invoices: [],
61 | };
62 | }
63 | if (offset > 0) {
64 | return listMoreData;
65 | }
66 | return listData;
67 | };
68 |
69 | export const getCompanies = async () => {
70 | await wait(50);
71 | return baseData;
72 | };
73 |
--------------------------------------------------------------------------------
/examples/table-filters/src/styles.css:
--------------------------------------------------------------------------------
1 | :root {
2 | font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
3 | font-size: 16px;
4 | line-height: 24px;
5 | font-weight: 400;
6 |
7 | font-synthesis: none;
8 | text-rendering: optimizeLegibility;
9 | -webkit-font-smoothing: antialiased;
10 | -moz-osx-font-smoothing: grayscale;
11 | -webkit-text-size-adjust: 100%;
12 | }
13 |
14 | * {
15 | box-sizing: border-box;
16 | }
17 |
18 | html {
19 | height: 100%;
20 | }
21 |
22 | body {
23 | margin: 0;
24 | height: 100%;
25 | background: white;
26 | color: #333;
27 | }
28 |
29 | #root {
30 | display: flex;
31 | min-height: 100%;
32 | }
33 |
34 | .app-container {
35 | flex: 1;
36 | display: flex;
37 | }
38 |
39 | .app-sidebar {
40 | display: flex;
41 | width: 200px;
42 | padding: 24px;
43 | background-color: #f0f0f0;
44 | border-right: 1px solid #ddd;
45 | }
46 |
47 | .app-list {
48 | display: flex;
49 | padding: 24px;
50 | flex: 1;
51 | }
52 |
53 | .invoice-list {
54 | flex: 1;
55 | }
56 |
57 | .invoice-list-filters {
58 | margin-bottom: 12px;
59 | }
60 |
61 | .invoice-list-filters > select {
62 | margin-right: 12px;
63 | }
64 |
65 | .invoice-list-filters > input {
66 | margin-right: 4px;
67 | }
68 |
69 | .invoice-list-buttons {
70 | margin-bottom: 24px;
71 | }
72 |
73 | .invoice-list-buttons > * {
74 | margin-right: 12px;
75 | }
76 |
77 | .invoice-list-result {
78 | display: flex;
79 | margin-top: 12px;
80 | }
81 |
82 | .invoice-list-table {
83 | flex: 2;
84 | border: 1px solid #ddd;
85 | overflow: auto;
86 | height: 800px;
87 | }
88 |
89 | .invoice-list-preview {
90 | flex: 1;
91 | margin-left: 24px;
92 | }
93 |
94 | table {
95 | table-layout: fixed;
96 | border-collapse: collapse;
97 | }
98 |
99 | thead th:nth-child(1) {
100 | width: 35%;
101 | }
102 |
103 | thead th:nth-child(2) {
104 | width: 32%;
105 | }
106 |
107 | thead th:nth-child(3) {
108 | width: 32%;
109 | }
110 |
111 | tbody td {
112 | text-align: center;
113 | }
114 |
115 | tbody tr:hover {
116 | background: #eee;
117 | cursor: pointer;
118 | }
119 |
120 | th,
121 | td {
122 | padding: 12px;
123 | }
124 |
--------------------------------------------------------------------------------
/examples/table-filters/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "lib": ["DOM", "DOM.Iterable", "ESNext"],
5 | "module": "ESNext",
6 | "skipLibCheck": true,
7 |
8 | "moduleResolution": "Bundler",
9 | "allowImportingTsExtensions": true,
10 | "resolveJsonModule": true,
11 | "isolatedModules": true,
12 | "noEmit": true,
13 | "jsx": "react-jsx",
14 |
15 | "strict": true,
16 | },
17 | "include": ["src"]
18 | }
19 |
--------------------------------------------------------------------------------
/examples/table-filters/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite'
2 | import react from '@vitejs/plugin-react'
3 |
4 | // https://vitejs.dev/config/
5 | export default defineConfig({
6 | plugins: [react()]
7 | })
8 |
--------------------------------------------------------------------------------
/packages/mst-query-generator/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | dist/
3 |
--------------------------------------------------------------------------------
/packages/mst-query-generator/generator/config.ts:
--------------------------------------------------------------------------------
1 | // @ts-nocheck
2 | import path from 'path';
3 | import arg from 'arg';
4 | import { cosmiconfigSync } from 'cosmiconfig';
5 | import { Config } from '../src/models';
6 |
7 | const explorer = cosmiconfigSync('mst-query-generator');
8 |
9 | export const getConfig = (): Config => {
10 | const args = parseArgs();
11 | return parseConfigOrDefault(args);
12 | };
13 |
14 | const parseArgs = () => {
15 | const availableArgs = {
16 | '--force': Boolean,
17 | '--outDir': String,
18 | '--verbose': Boolean,
19 | '--models': Boolean,
20 | '--index': Boolean,
21 | '--fieldOverrides': String,
22 | '--withTypeRefsPath': String,
23 | };
24 |
25 | try {
26 | return arg(availableArgs);
27 | } catch (e) {
28 | const errorMessage = [
29 | 'Example usage: ',
30 | 'generator',
31 | '--outDir=',
32 | '--force=',
33 | '--verbose=',
34 | '--models=',
35 | '--index=',
36 | '--fieldOverrides=',
37 | '--excludes=',
38 | 'graphql-schema.graphql',
39 | ];
40 | console.error(`${errorMessage.join('\n\t')}\n`);
41 | console.error(`Valid options: ${Object.keys(availableArgs).join(', ')}\n`);
42 | throw e;
43 | }
44 | };
45 |
46 | export const parseConfigOrDefault = (args: arg.Result): Config => {
47 | const configArgs = {
48 | force: args['--force'] || false,
49 | input: args._[0] || `${path.resolve(__dirname)}/schema.graphql`,
50 | outDir: path.resolve(process.cwd(), args['--outDir'] || './models'),
51 | verbose: args['--verbose'] || false,
52 | models: args['--models'] || false,
53 | index: args['--index'] || false,
54 | fieldOverrides: args['--fieldOverrides'] || '',
55 | excludes: args['--excludes'] || '',
56 | withTypeRefsPath: args['--withTypeRefsPath'] || '',
57 | };
58 | const defaultConfig = new Config(configArgs);
59 |
60 | try {
61 | console.log('Searching for configuration...');
62 | const result = explorer.search();
63 |
64 | if (result) {
65 | console.log('Configuration found, loading...');
66 | return new Config({ ...result.config });
67 | }
68 |
69 | console.log('Configuration not found, using default config');
70 |
71 | return defaultConfig;
72 | } catch (e) {
73 | console.error(e.message);
74 | return defaultConfig;
75 | }
76 | };
77 |
--------------------------------------------------------------------------------
/packages/mst-query-generator/generator/generate.ts:
--------------------------------------------------------------------------------
1 | import { RootType, Config, GeneratedFile } from '../src/models';
2 | import { IHandleType, ITypeResolver, HandlerOptions, TypeHandlerProps } from '../src/types';
3 | import {
4 | filterTypes,
5 | header,
6 | nameToCamelCase,
7 | newRow,
8 | reservedGraphqlNames,
9 | validateTypes,
10 | } from '../src/utils';
11 | import { typeHandler as defaultTypeHandler } from '../src/type-handler';
12 |
13 | export interface GenerateProps {
14 | rootTypes: RootType[];
15 | typeResolver?: ITypeResolver;
16 | typeHandler?: IHandleType;
17 | config?: Config;
18 | }
19 |
20 | export class Generate implements GenerateProps {
21 | rootTypes: RootType[];
22 | typeResolver: ITypeResolver | undefined;
23 | typeHandler: IHandleType | undefined;
24 | files: GeneratedFile[];
25 | excludes: string[];
26 | config?: Config;
27 |
28 | knownTypes: string[];
29 | refs: string[];
30 |
31 | constructor(params: GenerateProps) {
32 | this.rootTypes = params.rootTypes;
33 | this.typeResolver = params.typeResolver;
34 | this.typeHandler = params.typeHandler ? params.typeHandler : defaultTypeHandler;
35 | this.files = [] as GeneratedFile[];
36 | this.config = params.config ?? undefined;
37 | this.knownTypes = [];
38 | this.refs = [];
39 |
40 | const excludes = this.config?.excludes ?? [];
41 | this.excludes = [...excludes, ...reservedGraphqlNames];
42 | }
43 |
44 | public UpdateNamesToCamelCase() {
45 | this.rootTypes
46 | .filter((type) => !type.canCamelCase)
47 | .forEach((type) => {
48 | type.updateName(nameToCamelCase(type.name));
49 |
50 | type.fields?.forEach((field) => {
51 | field.updateName(nameToCamelCase(field.name));
52 | field.args?.forEach((arg) => {
53 | arg.updateName(nameToCamelCase(arg.name));
54 | });
55 | });
56 |
57 | type.inputFields.forEach((inputField) => {
58 | inputField.updateName(nameToCamelCase(inputField.name));
59 | });
60 | });
61 | }
62 |
63 | public GenerateModelBase() {
64 | const content = [
65 | `import { types } from "mobx-state-tree"`,
66 | `\n`,
67 | `export const ModelBase = types.model({});`,
68 | ];
69 | const file = new GeneratedFile({ name: 'ModelBase', content });
70 |
71 | this.files.push(file);
72 | }
73 |
74 | public GenerateTypes = () => {
75 | validateTypes(this.rootTypes, this.excludes);
76 |
77 | const rootTypes = filterTypes(this.rootTypes, this.excludes);
78 | this.knownTypes = rootTypes.map((x) => x.name);
79 |
80 | rootTypes.forEach((rootType) => {
81 | const props = {
82 | rootType,
83 | knownTypes: this.knownTypes,
84 | } as TypeHandlerProps;
85 |
86 | const options = {
87 | config: this.config,
88 | typeResolver: this.typeResolver,
89 | } as HandlerOptions;
90 |
91 | const generatedFiles = this.typeHandler?.(props, options);
92 |
93 | if (generatedFiles?.length) {
94 | this.files.push(...generatedFiles);
95 | }
96 | });
97 | };
98 |
99 | public GenerateIndexFile(): void {
100 | const content = [
101 | `${header}`,
102 | newRow,
103 | `${this.files.map((file) => `export * from './${file.name}';`).join(newRow)}`,
104 | ];
105 |
106 | const file = new GeneratedFile({ name: 'index', content });
107 |
108 | this.files.push(file);
109 | }
110 | }
111 |
--------------------------------------------------------------------------------
/packages/mst-query-generator/generator/scaffold.ts:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | import {
4 | ConfigurationLoggerType,
5 | SchemaTypesLoggerType,
6 | printConfigurationParams as defaultConfigLogger,
7 | printDetectedSchemaTypes as defaultSchemaTypesLogger,
8 | } from '../src/logger';
9 | import fs from 'fs';
10 | import path from 'path';
11 | import { Config, Schema, GeneratedFile } from '../src/models';
12 | import { Generate } from './generate';
13 | import { TypeResolver } from '../src/models/TypeResolver';
14 | import { filterTypes } from '../src/utils';
15 | import { getConfig } from './config';
16 | import { typeHandler } from '../src/type-handler';
17 | import { buildSchema, graphqlSync, getIntrospectionQuery } from 'graphql';
18 |
19 | function main() {
20 | const config = getConfig();
21 | const json = scaffold(config);
22 | const schema = new Schema(json.__schema);
23 | const rootTypes = filterTypes(schema.types);
24 | const typeResolver = new TypeResolver({ rootTypes });
25 |
26 | var generate = new Generate({
27 | rootTypes,
28 | typeHandler,
29 | typeResolver,
30 | config,
31 | });
32 |
33 | generate.GenerateModelBase();
34 | generate.GenerateTypes();
35 |
36 | if (config.index) {
37 | generate.GenerateIndexFile();
38 | }
39 |
40 | const files = generate.files;
41 |
42 | writeFiles(config, files);
43 | }
44 |
45 | const getSchemaJson = (input: string | undefined) => {
46 | if (!input?.endsWith('.graphql')) {
47 | throw new Error(`Expected a .graphql file as input but got ${input}`);
48 | }
49 |
50 | const text = fs.readFileSync(input, { encoding: 'utf8' });
51 | const schema = buildSchema(text);
52 | const res = graphqlSync({ schema, source: getIntrospectionQuery() });
53 |
54 | if (!res.data) {
55 | throw new Error(`graphql parse error:\n\n${JSON.stringify(res, null, 2)}`);
56 | }
57 |
58 | return res.data as any;
59 | };
60 |
61 | export const scaffold = (
62 | config: Config,
63 | configLoggerFn?: ConfigurationLoggerType,
64 | schemaTypesLoggerFn?: SchemaTypesLoggerType
65 | ) => {
66 | const { input, outDir, verbose = false } = config;
67 | const configLogger = configLoggerFn ?? defaultConfigLogger;
68 | const schemaLogger = schemaTypesLoggerFn ?? defaultSchemaTypesLogger;
69 |
70 | if (verbose) {
71 | configLogger?.(input, outDir);
72 | }
73 |
74 | var json = getSchemaJson(input);
75 |
76 | if (verbose) {
77 | schemaLogger?.(json);
78 | }
79 |
80 | return json;
81 | };
82 |
83 | export const writeFiles = (config: Config, files: GeneratedFile[]) => {
84 | const { outDir, force, verbose } = config;
85 |
86 | files.forEach((file) => {
87 | const { name } = file;
88 | const contents = file.toString();
89 |
90 | if (!fs.existsSync(outDir)) {
91 | fs.mkdirSync(outDir);
92 | }
93 |
94 | writeFile(name, contents, outDir, force, verbose);
95 | });
96 | };
97 |
98 | const writeFile = (
99 | name: string,
100 | contents: string,
101 | outDir: string,
102 | force: boolean = false,
103 | verbose: boolean = false
104 | ) => {
105 | const fnName = path.resolve(outDir, name + '.' + 'ts');
106 | if (!fs.existsSync(fnName) || force) {
107 | verbose && console.log('Writing file ' + fnName);
108 | fs.writeFileSync(fnName, contents);
109 | } else {
110 | verbose && console.log('Skipping file ' + fnName);
111 | }
112 | };
113 |
114 | export default function () {
115 | main();
116 | }
117 |
--------------------------------------------------------------------------------
/packages/mst-query-generator/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "mst-query-generator",
3 | "version": "3.4.0",
4 | "description": "Code generator for mst-query",
5 | "source": "src/index.ts",
6 | "main": "dist/src/index.js",
7 | "target": "node",
8 | "bin": {
9 | "mst-query-generator": "./dist/src/index.js"
10 | },
11 | "scripts": {
12 | "watch": "vitest",
13 | "test": "vitest run",
14 | "build": "tsc -p tsconfig.build.json"
15 | },
16 | "repository": {
17 | "type": "git",
18 | "url": "git+https://github.com/ConrabOpto/mst-query.git"
19 | },
20 | "author": "Conrab Opto (www.conrabopto.se)",
21 | "license": "MIT",
22 | "files": [
23 | "dist"
24 | ],
25 | "dependencies": {
26 | "arg": "^5.0.0",
27 | "camelcase": "^6.2.0",
28 | "cosmiconfig": "^7.0.0",
29 | "graphql": "16.6.0",
30 | "pluralize": "^8.0.0"
31 | },
32 | "devDependencies": {
33 | "@types/graphql": "^14.5.0",
34 | "@types/node": "^18.13.0",
35 | "@types/pluralize": "^0.0.29",
36 | "nodemon": "^2.0.20",
37 | "prettier": "^2.2.1",
38 | "ts-node": "^10.9.1",
39 | "typescript": "^4.9.5",
40 | "vitest": "^0.18.0"
41 | },
42 | "volta": {
43 | "node": "18.12.1"
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/packages/mst-query-generator/src/RootTypeOverride.ts:
--------------------------------------------------------------------------------
1 | // import { RootType } from './models';
2 | // import { FieldOverride } from './models/FieldOverride';
3 | // import { Overrides } from './models/Overrides';
4 | // import { isIdType } from './utils';
5 |
6 | // export class RootTypeOverride {
7 | // rootType: RootType;
8 | // overrides: Overrides;
9 |
10 | // constructor(params: any) {
11 | // this.rootType = params.rootType;
12 | // this.overrides = params.overrides;
13 | // }
14 |
15 | // public getMstTypeForField(fieldName: string, oldFieldType: string) {
16 | // const mostSpecificIdOverride = this.getMostSpecificIdOverride();
17 | // const hasIdOverride = () => mostSpecificIdOverride !== null;
18 |
19 | // const override = this.overrides.getOverrideForField(
20 | // this.rootType.name,
21 | // fieldName,
22 | // oldFieldType
23 | // );
24 |
25 | // if (hasIdOverride() && override && isIdType(override, oldFieldType)) {
26 | // if (override && override.specificity === mostSpecificIdOverride?.specificity) {
27 | // return [override.newFieldType, override.typeImportPath];
28 | // }
29 |
30 | // return ['frozen()', undefined];
31 | // }
32 |
33 | // return override && [override.newFieldType, override.typeImportPath];
34 | // }
35 |
36 | // private getMostSpecificIdOverride() {
37 | // if (!this.rootType.fields) {
38 | // return null;
39 | // }
40 |
41 | // const scalarFields = this.rootType.fields
42 | // .map((field) => {
43 | // const type = field.GetTypeOrDefault();
44 | // return { name: field.name, type };
45 | // })
46 | // .filter(({ type }) => type !== null)
47 | // .filter(({ type }) => type!.kind.isScalar);
48 |
49 | // const overrides = scalarFields.map(({ name, type }) => {
50 | // return this.overrides.getOverrideForField(this.rootType.name, name, type!.name!);
51 | // });
52 | // const idOverrides = overrides
53 | // .filter((override) => override !== null)
54 | // .filter((override) => override!.isIdentifier());
55 |
56 | // const mostSpecificIdOverride = this.overrides.getMostSpecificOverride(idOverrides);
57 |
58 | // const mostSpecificIdOverrides = idOverrides.filter((override) => {
59 | // return override.specificity === mostSpecificIdOverride?.specificity;
60 | // });
61 |
62 | // if (mostSpecificIdOverrides.length > 1) {
63 | // console.warn(`Type: ${this.rootType.name} has multiple matching id field overrides`);
64 | // console.warn('Consider adding a more specific override.');
65 | // }
66 |
67 | // return mostSpecificIdOverride;
68 | // }
69 | // }
70 |
--------------------------------------------------------------------------------
/packages/mst-query-generator/src/field-handler-enum.ts:
--------------------------------------------------------------------------------
1 | import { GeneratedField } from './models';
2 | import { FieldHandlerProps, IHandleField, HandlerOptions } from './types';
3 |
4 | export const handleEnumField: IHandleField = (
5 | props: FieldHandlerProps,
6 | options: HandlerOptions
7 | ): GeneratedField | null => {
8 | const { fieldType, isNullable = false, isNested = false } = props;
9 | const { addImport } = options;
10 |
11 | if (fieldType?.kind.isEnum) {
12 | const value = `${fieldType.name}TypeEnum`;
13 | const name = `${fieldType.name}`;
14 | addImport?.(name, value);
15 |
16 | return new GeneratedField({ value, isNullable, isNested });
17 | }
18 |
19 | return null;
20 | };
21 |
--------------------------------------------------------------------------------
/packages/mst-query-generator/src/field-handler-interface-union.ts:
--------------------------------------------------------------------------------
1 | import { Type, GeneratedField } from './models';
2 | import { FieldHandlerProps, IHandleField, HandlerOptions } from './types';
3 |
4 | export const handleInterfaceOrUnionField: IHandleField = (
5 | props: FieldHandlerProps,
6 | options: HandlerOptions
7 | ): GeneratedField | null => {
8 | const { field, fieldType, fieldHandlers, isNullable = false, isNested = false } = props;
9 | const typeResolver = options.typeResolver;
10 |
11 | if (fieldType?.kind.isInterface || fieldType?.kind.isUnion) {
12 | const objectFieldHandler = fieldHandlers?.get('OBJECT');
13 | const interfaceAndUnionTypes = typeResolver?.GetInterfaceAndUnionTypeResults();
14 | const interfaceOrUnionType = interfaceAndUnionTypes?.get(fieldType?.name ?? '');
15 |
16 | const mstUnionArgs =
17 | interfaceOrUnionType?.rootTypes.map((rootType) => {
18 | const { name, kind } = rootType;
19 | const ofType = new Type({ name, kind: kind.value });
20 |
21 | // since we're already "inside a field" (nested),
22 | // child types are neither required or nullable
23 | const objectProps = {
24 | ...props,
25 | field,
26 | rootType,
27 | fieldType: ofType,
28 | fieldHandlers,
29 | isNullable: false,
30 | isNested: fieldType.isNested,
31 | } as FieldHandlerProps;
32 |
33 | const generatedObjectField = objectFieldHandler?.(objectProps, options);
34 |
35 | return generatedObjectField;
36 | }) ?? [];
37 |
38 | const hasChildrenWithIds = interfaceOrUnionType?.rootTypes.some((rootType) => {
39 | return rootType.fields.some((field) => field.isIdentifier());
40 | });
41 |
42 | const generatedFields = mstUnionArgs.map((f) => f?.toString());
43 |
44 | if (hasChildrenWithIds) {
45 | const value =
46 | generatedFields.length > 1
47 | ? `MstQueryRef(types.union(${generatedFields.join(', ')}))`
48 | : `MstQueryRef(${generatedFields.join(', ')})`;
49 | return new GeneratedField({ value, isNullable, isNested });
50 | }
51 |
52 | const value = `types.union(${generatedFields.join(', ')})`;
53 | return new GeneratedField({ value, isNullable, isNested });
54 | }
55 | return null;
56 | };
57 |
--------------------------------------------------------------------------------
/packages/mst-query-generator/src/field-handler-list.ts:
--------------------------------------------------------------------------------
1 | import { GeneratedField } from './models';
2 | import { FieldHandlerProps, IHandleField, HandlerOptions } from './types';
3 |
4 | export const handleListField: IHandleField = (
5 | props: FieldHandlerProps,
6 | options: HandlerOptions
7 | ): GeneratedField | null => {
8 | const { fieldType, fieldHandlers, isNullable = false } = props;
9 |
10 | if (fieldType?.kind.isList) {
11 | const listSubType = fieldType.ofType;
12 | const actualListType = listSubType?.kind.isNonNull ? listSubType.ofType : listSubType;
13 | const handlerProps = {
14 | ...props,
15 | fieldType: actualListType,
16 | isNullable,
17 | isNested: fieldType.isNested,
18 | } as FieldHandlerProps;
19 |
20 | const handlerName = actualListType?.kind.value ?? '';
21 | const fieldHandler = fieldHandlers?.get(handlerName);
22 | const generatedField = fieldHandler?.(handlerProps, options);
23 |
24 | if (generatedField) {
25 | const content = generatedField?.toString();
26 | const value = `types.array(${content})`;
27 | return new GeneratedField({ value, isNullable });
28 | }
29 | return null;
30 | }
31 |
32 | return null;
33 | };
34 |
--------------------------------------------------------------------------------
/packages/mst-query-generator/src/field-handler-object.ts:
--------------------------------------------------------------------------------
1 | import { GeneratedField } from './models';
2 | import { FieldHandlerProps, IHandleField, ModelFieldRef, HandlerOptions } from './types';
3 |
4 | export const handleObjectField: IHandleField = (
5 | props: FieldHandlerProps,
6 | options: HandlerOptions
7 | ): GeneratedField | null => {
8 | const {
9 | rootType,
10 | field,
11 | fieldType,
12 | knownTypes,
13 | isNullable = false,
14 | isNested = false,
15 | refs,
16 | } = props;
17 | const { addImport } = options;
18 |
19 | if (fieldType?.kind.isObject) {
20 | const isSelf = Boolean(fieldType.name === rootType?.name);
21 | const isKnownType = fieldType.name ? knownTypes?.includes(fieldType.name) : false;
22 |
23 | if (!isKnownType) {
24 | // unknown or unhandled type. make it frozen.
25 | return new GeneratedField({ value: `types.frozen()` });
26 | }
27 |
28 | const modelType = `${fieldType.name}Model`;
29 | const modelTypeType = `${fieldType.name}ModelType`;
30 | addImport?.(modelType, modelType);
31 | addImport?.(modelType, modelTypeType);
32 |
33 | // use late to prevent circular dependency
34 | const realType = `types.late(():any => ${fieldType.name}Model)`;
35 |
36 | // this object is not a root type, so assume composition relationship
37 | if (!isSelf && !isKnownType) {
38 | return new GeneratedField({ value: realType, isNullable, isNested });
39 | }
40 |
41 | const fieldMatch = refs?.find((ref) => ref.fieldName === field.name);
42 | const fieldName = field.name;
43 | const isList = field.type?.kind.isList || field.type?.ofType?.kind.isList;
44 |
45 | // handle union fields for withTypeRefs
46 | if (fieldMatch) {
47 | const index = refs.indexOf(fieldMatch);
48 | const modelType = isList ? `${modelTypeType}[]` : modelTypeType;
49 | const unionModelType = `${fieldMatch.modelType} | ${modelType}`;
50 | refs[index] = { fieldName, modelType: unionModelType, isNested } as ModelFieldRef;
51 | } else {
52 | const modelType = isList ? `${modelTypeType}[]` : modelTypeType;
53 | const refItem = { fieldName, modelType, isNested } as ModelFieldRef;
54 | refs?.push(refItem);
55 | }
56 |
57 | return new GeneratedField({ value: `${realType}`, isNullable, isNested });
58 | }
59 |
60 | return null;
61 | };
62 |
--------------------------------------------------------------------------------
/packages/mst-query-generator/src/field-handler-scalar.ts:
--------------------------------------------------------------------------------
1 | import { isPrimitive } from 'util';
2 | import { GeneratedField } from './models';
3 | import { FieldOverride } from './models/FieldOverride';
4 | import { FieldHandlerProps, IHandleField, HandlerOptions } from './types';
5 | import { primitiveFieldNames, requiredTypes } from './utils';
6 |
7 | export const handleScalarField: IHandleField = (
8 | props: FieldHandlerProps,
9 | options: HandlerOptions
10 | ): GeneratedField | null => {
11 | const { fieldType, isNullable = false, isNested = false, override } = props;
12 |
13 | if (fieldType?.kind.isScalar) {
14 | const primitiveType = getFieldTypeOrDefault(fieldType.name, override);
15 | const isRequired = requiredTypes.includes(primitiveType);
16 |
17 | let value = `types.${primitiveType}`;
18 |
19 | if (override && override.typeImportPath) {
20 | value = primitiveType;
21 | options.addImport?.(override.typeImportPath, override.newFieldType);
22 | }
23 |
24 | return new GeneratedField({
25 | value,
26 | isRequired,
27 | isNullable,
28 | isNested,
29 | });
30 | }
31 | return null;
32 | };
33 |
34 | const getFieldTypeOrDefault = (
35 | fieldTypeName: string | null | undefined,
36 | override: FieldOverride | undefined
37 | ) => {
38 | if (!fieldTypeName) {
39 | return 'frozen()';
40 | }
41 |
42 | if (override) {
43 | return override.newFieldType;
44 | }
45 |
46 | return primitiveFieldNames[fieldTypeName] ?? 'frozen()';
47 | };
48 |
--------------------------------------------------------------------------------
/packages/mst-query-generator/src/field-handler.ts:
--------------------------------------------------------------------------------
1 | import { GeneratedField } from './models';
2 | import { FieldHandlerProps, IHandleField, HandlerOptions } from './types';
3 | import { handleEnumField } from './field-handler-enum';
4 | import { handleListField } from './field-handler-list';
5 | import { handleObjectField } from './field-handler-object';
6 | import { handleInterfaceOrUnionField } from './field-handler-interface-union';
7 | import { handleScalarField } from './field-handler-scalar';
8 |
9 | export const defaultFieldHandlers = new Map([
10 | ['ENUM', handleEnumField],
11 | ['INTERFACE', handleInterfaceOrUnionField],
12 | ['LIST', handleListField],
13 | ['OBJECT', handleObjectField],
14 | ['SCALAR', handleScalarField],
15 | ['UNION', handleInterfaceOrUnionField],
16 | ]);
17 |
18 | export const fieldHandler = (
19 | props: FieldHandlerProps,
20 | options: HandlerOptions
21 | ): GeneratedField | null => {
22 | const { rootType, field, fieldHandlers = defaultFieldHandlers } = props;
23 | const { overrides } = options;
24 | const actualFieldType = field.type?.asActualType;
25 | const isNullable = field.type?.isNullable;
26 | const fieldOverride = overrides?.getOverrideForField(
27 | rootType.name,
28 | field.name,
29 | actualFieldType?.name
30 | );
31 |
32 | const handlerProps = {
33 | ...props,
34 | fieldType: actualFieldType,
35 | isNullable,
36 | fieldHandlers,
37 | override: fieldOverride,
38 | } as FieldHandlerProps;
39 |
40 | const handlerName = actualFieldType?.kind.value ?? '';
41 | const fieldHandler = fieldHandlers.get(handlerName);
42 | return fieldHandler?.(handlerProps, options) ?? null;
43 | };
44 |
--------------------------------------------------------------------------------
/packages/mst-query-generator/src/index.ts:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | import run from '../generator/scaffold';
4 |
5 | run();
6 |
--------------------------------------------------------------------------------
/packages/mst-query-generator/src/logger.ts:
--------------------------------------------------------------------------------
1 | export type ConfigurationLoggerType = (input: string, outDir: string) => void;
2 | export type SchemaTypesLoggerType = (json: string) => void;
3 |
4 | export const printConfigurationParams = (
5 | inputFilePath: string,
6 | outputDirectory: string,
7 | fileExtension = 'ts'
8 | ) => {
9 | const formatPart = `--format=\{${fileExtension}\}`;
10 | const outDirPart = `--outDir=\{${outputDirectory}\}`;
11 | const inputFilePart = `${inputFilePath}`;
12 | console.log(`generator ${formatPart} ${outDirPart} ${inputFilePart}`);
13 | };
14 |
15 | export const printDetectedSchemaTypes = (json: any) => {
16 | const detectedTypesOutput = `Detected types:\n`;
17 | const getTypeOutputFn = (type: any) => ` - [${type.kind}] ${type.name}`;
18 | const types = json.__schema.types.map(getTypeOutputFn).join('\n');
19 | console.log(detectedTypesOutput, types);
20 | };
21 |
--------------------------------------------------------------------------------
/packages/mst-query-generator/src/models/Arg.ts:
--------------------------------------------------------------------------------
1 | import { Type } from './Type';
2 |
3 | export class Arg {
4 | name: string;
5 | originalName: string;
6 | description?: any;
7 | type: Type;
8 | defaultValue: string;
9 |
10 | constructor(params: any) {
11 | this.name = params.name;
12 | this.originalName = params.name;
13 | this.description = params.description;
14 | this.type = params.type;
15 | this.defaultValue = params.defaultValue;
16 | }
17 |
18 | public updateName(value: string) {
19 | this.name = value;
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/packages/mst-query-generator/src/models/Config.ts:
--------------------------------------------------------------------------------
1 | import { FieldOverride } from './FieldOverride';
2 | import { Overrides } from './Overrides';
3 |
4 | export type ConfigProps = {
5 | force?: boolean;
6 | input?: string;
7 | outDir?: string;
8 | excludes?: string | Array;
9 | verbose?: boolean;
10 | models?: boolean;
11 | index?: boolean;
12 | fieldOverrides?: string | Array;
13 | withTypeRefsPath?: string;
14 | };
15 |
16 | export class Config {
17 | force: boolean;
18 | input: string;
19 | outDir: string;
20 | excludes: Array;
21 | verbose: boolean;
22 | models?: boolean;
23 | index?: boolean;
24 | overrides?: Overrides;
25 | withTypeRefsPath: string;
26 |
27 | constructor(params: ConfigProps = {}) {
28 | this.force = params.force ?? false;
29 | this.input = params.input ?? '';
30 | this.outDir = params.outDir ?? '';
31 | if (params.excludes) {
32 | const excludeValues = Array.isArray(params.excludes)
33 | ? params.excludes
34 | : params.excludes.split(',');
35 | this.excludes = excludeValues;
36 | } else {
37 | this.excludes = [];
38 | }
39 | this.verbose = params.verbose ?? false;
40 | this.models = params.models ?? false;
41 | this.index = params.index ?? false;
42 | this.withTypeRefsPath = params.withTypeRefsPath ?? '@utils';
43 |
44 | const overrides: FieldOverride[] = !Array.isArray(params.fieldOverrides)
45 | ? FieldOverride.parse(params.fieldOverrides)
46 | : (params.fieldOverrides as string[]).map((o) => FieldOverride.parse(o)).flat(1);
47 |
48 | this.overrides = new Overrides({ overrides });
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/packages/mst-query-generator/src/models/Directive.ts:
--------------------------------------------------------------------------------
1 | import { Arg } from './Arg';
2 |
3 | export class Directive {
4 | name: string;
5 | description: string;
6 | locations: string[];
7 | args: Arg[];
8 |
9 | constructor(params: any) {
10 | this.name = params.name;
11 | this.description = params.description;
12 | this.locations = params.locations ?? [];
13 | this.args = params.args ? params.args.map((a: any) => new Arg(a)) : [];
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/packages/mst-query-generator/src/models/EnumValue.ts:
--------------------------------------------------------------------------------
1 | export class EnumValue {
2 | name: string;
3 | description: string;
4 | isDeprecated: boolean;
5 | deprecationReason: string | null;
6 |
7 | constructor(params: any) {
8 | this.name = params.name;
9 | this.description = params.description;
10 | this.isDeprecated = params.isDeprecated;
11 | this.deprecationReason = params.deprecationReason;
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/packages/mst-query-generator/src/models/Field.ts:
--------------------------------------------------------------------------------
1 | import { Arg } from './Arg';
2 | import { Type } from './Type';
3 |
4 | export class Field {
5 | name: string;
6 | originalName: string;
7 | description: string;
8 | args: Arg[] | null;
9 | type: Type | null;
10 | isDeprecated: boolean;
11 | deprecationReason: string | null;
12 |
13 | constructor(params: any) {
14 | this.name = params.name;
15 | this.originalName = params.name;
16 | this.description = params.description ?? '';
17 | this.args = params.args ? params.args.map((a: any) => new Arg(a)) : [];
18 | this.type = params.type ? new Type(params.type) : null;
19 | this.isDeprecated = params.isDeprecated;
20 | this.deprecationReason = params.deprecationReason;
21 | }
22 |
23 | public updateName(value: string) {
24 | this.name = value;
25 | }
26 |
27 | public isIdentifier(): boolean {
28 | return Boolean(this.name === 'id');
29 | }
30 |
31 | public GetTypeOrDefault(): Type | null {
32 | return this.type?.kind.isNonNull ? this.type?.ofType : this.type;
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/packages/mst-query-generator/src/models/FieldOverride.ts:
--------------------------------------------------------------------------------
1 | import { isOnlyWildcard } from '../utils';
2 | import { Specificity } from './Specificity';
3 |
4 | export type FieldOverrideProps = {
5 | rootTypeName: string;
6 | fieldName: string;
7 | oldFieldType: string;
8 | newFieldType: string;
9 | typeImportPath: string;
10 | };
11 |
12 | export class FieldOverride {
13 | public rootTypeName: string;
14 | fieldName: string;
15 | oldFieldType: string;
16 | newFieldType: string;
17 | typeImportPath: string;
18 | rootTypeRegExp: RegExp;
19 | fieldNameRegExp: RegExp;
20 | specificity: Specificity;
21 |
22 | constructor(params: FieldOverrideProps) {
23 | this.rootTypeName = params.rootTypeName;
24 | this.fieldName = params.fieldName;
25 | this.oldFieldType = params.oldFieldType;
26 | this.newFieldType = params.newFieldType;
27 | this.typeImportPath = params.typeImportPath;
28 |
29 | this.specificity = new Specificity(params);
30 |
31 | this.rootTypeRegExp = this.wildcardToRegExp(params.rootTypeName);
32 | this.fieldNameRegExp = this.wildcardToRegExp(params.fieldName);
33 | }
34 |
35 | public json() {
36 | return {
37 | rootTypeName: this.rootTypeName,
38 | fieldName: this.fieldName,
39 | oldFieldType: this.oldFieldType,
40 | newFieldType: this.newFieldType,
41 | typeImportPath: this.typeImportPath,
42 | };
43 | }
44 |
45 | public isIdentifier() {
46 | const mstIdTypes = ['identifier', 'identifierNumber'];
47 | return mstIdTypes.includes(this.newFieldType);
48 | }
49 |
50 | public static parse(overrideValue: string | string[] | undefined): FieldOverride[] {
51 | if (!overrideValue) {
52 | return [];
53 | }
54 |
55 | const overrideValues = Array.isArray(overrideValue)
56 | ? overrideValue
57 | : overrideValue?.split(',');
58 |
59 | const fieldOverrideValues = overrideValues
60 | .map((value) => value.trim())
61 | .map((value) => {
62 | const override = value.split(':').map((part) => part.trim());
63 | const isValid = override.length === 3 || override.length === 4;
64 |
65 | if (!isValid) {
66 | throw new Error(`--fieldOverrides used with invalid override: ${value}`);
67 | }
68 |
69 | return override;
70 | });
71 |
72 | const fieldOverrides = fieldOverrideValues.map((splitValues) => {
73 | const [unsplitFieldName, oldFieldType, newFieldType, typeImportPath] = splitValues;
74 | const splitField = unsplitFieldName.split('.');
75 | const rootTypeName = splitField.length === 2 ? splitField[0] : '*';
76 | const fieldName = splitField.length === 1 ? splitField[0] : splitField[1];
77 |
78 | return new FieldOverride({
79 | rootTypeName,
80 | fieldName,
81 | oldFieldType,
82 | newFieldType,
83 | typeImportPath,
84 | });
85 | });
86 |
87 | return fieldOverrides;
88 | }
89 |
90 | public matches(rootTypeName: string, newFieldType: string, oldFieldType?: string | null) {
91 | const matchRootType = this.rootTypeRegExp.test(rootTypeName);
92 | const matchFieldName = this.fieldNameRegExp.test(newFieldType);
93 | const matchOldField = oldFieldType === this.oldFieldType;
94 | const matchOldOrWildcard = matchOldField || isOnlyWildcard(this.oldFieldType);
95 | return matchRootType && matchFieldName && matchOldOrWildcard;
96 | }
97 |
98 | private wildcardToRegExp(text: string) {
99 | const escapeStringRegexp = (value: string) => value;
100 | return new RegExp('^' + text.split(/\*+/).map(escapeStringRegexp).join('.+') + '$');
101 | }
102 | }
103 |
--------------------------------------------------------------------------------
/packages/mst-query-generator/src/models/GeneratedField.ts:
--------------------------------------------------------------------------------
1 | export class GeneratedField {
2 | isRequired: boolean;
3 | isNullable: boolean;
4 | isNested: boolean;
5 | value: string;
6 | imports: string[];
7 | canBeUndefined: boolean;
8 | canBeNull: boolean;
9 |
10 | constructor(params: any) {
11 | this.isRequired = params.isRequired ?? false;
12 | this.isNullable = params.isNullable ?? true;
13 | this.isNested = params.isNested ?? false;
14 | this.value = params.value;
15 | this.imports = params.imports ? params.imports : [];
16 | this.canBeUndefined = !this.isRequired && !this.isNested;
17 | this.canBeNull = !this.isRequired && this.isNullable;
18 | }
19 |
20 | public toString = (): string => {
21 | if (this.canBeNull || this.canBeUndefined) {
22 | const undefinedOrEmpty = this.canBeUndefined ? 'types.undefined, ' : '';
23 | const nullOrEmpty = this.canBeNull ? 'types.null, ' : '';
24 | return `types.union(${undefinedOrEmpty}${nullOrEmpty}${this.value})`;
25 | }
26 | return this.value;
27 | };
28 | }
29 |
--------------------------------------------------------------------------------
/packages/mst-query-generator/src/models/GeneratedFile.ts:
--------------------------------------------------------------------------------
1 | export interface IFile {}
2 |
3 | export class GeneratedFile implements IFile {
4 | name: string;
5 | content: string[];
6 |
7 | constructor(params: any) {
8 | this.name = params.name;
9 | this.content = params.content ? params.content : [];
10 | }
11 |
12 | public toString = (): string => {
13 | return this.content.join('');
14 | };
15 | }
16 |
--------------------------------------------------------------------------------
/packages/mst-query-generator/src/models/InputField.ts:
--------------------------------------------------------------------------------
1 | import { Type } from './Type';
2 |
3 | export class InputField {
4 | name: string;
5 | description: string;
6 | type: Type;
7 | defaultValue: any | null;
8 |
9 | constructor(params: any) {
10 | this.name = params.name;
11 | this.description = params.description;
12 | this.type = params.type;
13 | this.defaultValue = params.defaultValue;
14 | }
15 |
16 | public updateName(value: string) {
17 | this.name = value;
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/packages/mst-query-generator/src/models/InterfaceOrUnionTypeResult.ts:
--------------------------------------------------------------------------------
1 | import { RootType } from './RootType';
2 | import { Field } from './Field';
3 | import { Kind } from './Kind';
4 |
5 | export class InterfaceOrUnionTypeResult {
6 | kind: Kind;
7 | name: string;
8 | rootTypes: RootType[];
9 | fields: Field[];
10 |
11 | constructor(params: any) {
12 | this.kind = new Kind(params.kind);
13 | this.name = params.name;
14 | this.rootTypes = params.rootTypes ? params.rootTypes : [];
15 | this.fields = params.fields ? params.fields : [];
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/packages/mst-query-generator/src/models/Kind.ts:
--------------------------------------------------------------------------------
1 | export class Kind {
2 | value: string | null;
3 |
4 | constructor(value: string | null) {
5 | this.value = value;
6 | }
7 |
8 | public get isInterface(): boolean {
9 | return this.value === 'INTERFACE';
10 | }
11 |
12 | public get isUnion(): boolean {
13 | return this.value === 'UNION';
14 | }
15 |
16 | public get isObject(): boolean {
17 | return this.value === 'OBJECT';
18 | }
19 |
20 | public get isInputObject(): boolean {
21 | return this.value === 'INPUT_OBJECT';
22 | }
23 |
24 | public get isNonNull(): boolean {
25 | return this.value === 'NON_NULL';
26 | }
27 |
28 | public get isScalar(): boolean {
29 | return this.value === 'SCALAR';
30 | }
31 |
32 | public get isEnum(): boolean {
33 | return this.value === 'ENUM';
34 | }
35 |
36 | public get isList(): boolean {
37 | return this.value === 'LIST';
38 | }
39 |
40 | public get canCamelCase(): boolean {
41 | return this.isObject || this.isInputObject || this.isEnum || this.isList || this.isNonNull;
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/packages/mst-query-generator/src/models/Overrides.ts:
--------------------------------------------------------------------------------
1 | import { FieldOverride } from './FieldOverride';
2 |
3 | export class Overrides {
4 | public readonly fieldOverrides: FieldOverride[];
5 | public static Empty = new Overrides({ overrides: [] });
6 |
7 | constructor(params: { overrides: FieldOverride[] }) {
8 | this.fieldOverrides = params.overrides;
9 | }
10 |
11 | public findOverridesForRootType(rootTypeName: string) {
12 | return this.fieldOverrides.filter((o) => o.rootTypeName === rootTypeName);
13 | }
14 |
15 | private getMatchingOverridesForField(
16 | rootTypeName: string,
17 | fieldName: string,
18 | oldFieldType?: string | null
19 | ) {
20 | return this.fieldOverrides.filter((fieldOverride) => {
21 | return fieldOverride.matches(rootTypeName, fieldName, oldFieldType);
22 | });
23 | }
24 |
25 | public getMostSpecificOverride(fieldOverrides: FieldOverride[]) {
26 | return fieldOverrides.reduce((acc: FieldOverride | null, fieldOverride) => {
27 | if (acc === null || fieldOverride.specificity > acc.specificity) {
28 | return fieldOverride;
29 | }
30 | return acc;
31 | }, null);
32 | }
33 |
34 | public getOverrideForField(
35 | rootTypeName: string,
36 | fieldName: string,
37 | oldFieldType?: string | null
38 | ) {
39 | const matchingOverrides = this.getMatchingOverridesForField(
40 | rootTypeName,
41 | fieldName,
42 | oldFieldType
43 | );
44 | return this.getMostSpecificOverride(matchingOverrides);
45 | }
46 |
47 | public getMstTypeForField(rootTypeName: string, fieldName: string, oldFieldType: string) {
48 | const fieldOverride = this.getOverrideForField(rootTypeName, fieldName, oldFieldType);
49 | return fieldOverride && fieldOverride.newFieldType;
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/packages/mst-query-generator/src/models/RootType.ts:
--------------------------------------------------------------------------------
1 | import { Field } from './Field';
2 | import { InputField } from './InputField';
3 | import { Type } from './Type';
4 | import { EnumValue } from './EnumValue';
5 | import { Kind } from './Kind';
6 |
7 | export class RootType {
8 | kind: Kind;
9 | name: string;
10 | originalName: string;
11 | description: string | null;
12 | inputFields: InputField[];
13 | interfaces: Type[];
14 | enumValues: EnumValue[];
15 | possibleTypes: Type[];
16 | fields: Field[];
17 |
18 | constructor(params: any) {
19 | this.kind = new Kind(params.kind);
20 | this.name = params.name;
21 | this.originalName = params.name;
22 | this.description = params.description;
23 |
24 | this.fields = params.fields ? params.fields.map((f: any) => new Field(f)) : [];
25 |
26 | this.inputFields = params.inputFields
27 | ? params.inputFields.map((f: any) => new InputField(f))
28 | : [];
29 |
30 | this.interfaces = params.interfaces ? params.interfaces.map((i: any) => new Type(i)) : [];
31 |
32 | this.enumValues = params.enumValues
33 | ? params.enumValues.map((ev: any) => new EnumValue(ev))
34 | : [];
35 |
36 | this.possibleTypes = params.possibleTypes
37 | ? params.possibleTypes.map((pt: any) => new Type(pt))
38 | : [];
39 | }
40 |
41 | public updateName(value: string): void {
42 | this.name = value;
43 | }
44 |
45 | public get canCamelCase(): boolean {
46 | return this.name.startsWith('__') && this.kind.canCamelCase;
47 | }
48 |
49 | public get isActualRootType(): boolean {
50 | function skipNonNullKind(type: Type | null) {
51 | if (!type) {
52 | return null;
53 | }
54 | return type.kind.isNonNull ? type.ofType : type;
55 | }
56 | return this.fields.some(
57 | (field) =>
58 | field.isIdentifier() &&
59 | skipNonNullKind(field.type)?.kind.isScalar &&
60 | skipNonNullKind(field.type)?.isIdentifier()
61 | );
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/packages/mst-query-generator/src/models/Schema.ts:
--------------------------------------------------------------------------------
1 | import { RootType } from './RootType';
2 | import { Directive } from './Directive';
3 |
4 | export interface ISchema {
5 | types: RootType[];
6 | directives: Directive[];
7 | }
8 |
9 | export class Schema implements ISchema {
10 | types: RootType[];
11 | directives: Directive[];
12 |
13 | constructor(params: any) {
14 | this.types = params.types ? params.types.map((t: any) => new RootType(t)) : [];
15 |
16 | this.directives = params.directives
17 | ? params.directives.map((d: any) => new Directive(d))
18 | : [];
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/packages/mst-query-generator/src/models/Specificity.ts:
--------------------------------------------------------------------------------
1 | import { isOnlyWildcard } from '../utils';
2 |
3 | export class Specificity {
4 | private rootTypeName: string;
5 | private fieldName: string;
6 | private oldFieldType: string;
7 |
8 | constructor(params: any) {
9 | this.rootTypeName = params.rootTypeName;
10 | this.fieldName = params.fieldName;
11 | this.oldFieldType = params.oldFieldType;
12 | }
13 |
14 | public computeOverrideSpecificity() {
15 | try {
16 | const declaringTypeSpecificity = this.getDeclaringTypeSpecificity();
17 | const nameSpecificity = this.getNameSpecificity();
18 | const typeSpecificity = this.getTypeSpecificity();
19 |
20 | if (nameSpecificity === 0 && typeSpecificity === 0)
21 | throw new Error('Both name and type cannot be wildcards');
22 |
23 | return declaringTypeSpecificity + nameSpecificity + typeSpecificity;
24 | } catch (err: any) {
25 | throw Error(
26 | `Error parsing fieldOverride ${this.fieldName}:${this.oldFieldType}:\n ${err.message}`
27 | );
28 | }
29 | }
30 |
31 | private getDeclaringTypeSpecificity() {
32 | if (isOnlyWildcard(this.rootTypeName)) {
33 | return 0b0000;
34 | }
35 |
36 | if (this.hasWildcard(this.rootTypeName)) {
37 | return 0b0010;
38 | }
39 |
40 | return 0b0100;
41 | }
42 |
43 | private getNameSpecificity() {
44 | if (isOnlyWildcard(this.fieldName)) {
45 | return 0b0000;
46 | }
47 |
48 | if (this.hasWildcard(this.fieldName)) {
49 | return 0b0001;
50 | }
51 |
52 | return 0b0010;
53 | }
54 |
55 | private getTypeSpecificity() {
56 | if (isOnlyWildcard(this.oldFieldType)) {
57 | return 0b0000;
58 | }
59 |
60 | if (this.hasWildcard(this.oldFieldType)) {
61 | throw new Error('type cannot be a partial wildcard: e.g. *_id');
62 | }
63 |
64 | return 0b0001;
65 | }
66 |
67 | private hasWildcard(text: string) {
68 | return RegExp(/\*/).test(text);
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/packages/mst-query-generator/src/models/Type.ts:
--------------------------------------------------------------------------------
1 | import { Kind } from './Kind';
2 |
3 | export class Type {
4 | kind: Kind;
5 | name: string | null | undefined;
6 | ofType: Type | null;
7 |
8 | constructor(params: any) {
9 | this.kind = new Kind(params.kind);
10 | this.name = params.name;
11 | this.ofType = params.ofType ? new Type(params.ofType) : null;
12 | }
13 |
14 | public isIdentifier(): boolean {
15 | return Boolean(this.name === 'ID');
16 | }
17 |
18 | public get asActualType(): Type | null {
19 | return this.kind.isNonNull ? this.ofType : this;
20 | }
21 |
22 | public get isNullable(): boolean {
23 | return this.kind.isNonNull ? false : true;
24 | }
25 |
26 | public get isNested(): boolean {
27 | return this.kind.isUnion || this.kind.isInterface || this.kind.isList;
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/packages/mst-query-generator/src/models/TypeResolver.ts:
--------------------------------------------------------------------------------
1 | import { ITypeResolver } from '../types';
2 | import { RootType, InterfaceOrUnionTypeResult } from './';
3 |
4 | export class TypeResolver implements ITypeResolver {
5 | interfaceMap: Map;
6 | possibleTypeMap: Map>;
7 | result: Map;
8 | rootTypes: RootType[];
9 |
10 | constructor(params: any = {}) {
11 | this.rootTypes = params.rootTypes ?? [];
12 | this.result = new Map();
13 | this.interfaceMap = new Map();
14 | this.possibleTypeMap = new Map>();
15 | }
16 |
17 | GetInterfaceAndUnionTypeResults(): Map {
18 | // combine models with kind interface and union to a single result model
19 | // add underlying types (e.g actual models behind union) to result model
20 | // add underlying fields to result model
21 | // returns a map with interface or union type name as key and result model as value
22 | this.rootTypes.forEach((rootType) => {
23 | this.handleInterface(rootType);
24 | this.handleUnion(rootType);
25 | });
26 |
27 | this.rootTypes.forEach((type) => {
28 | this.handleObject(type);
29 | });
30 |
31 | return this.result;
32 | }
33 |
34 | handleInterface(rootType: RootType): void {
35 | if (rootType.kind.isInterface) {
36 | this.interfaceMap.set(rootType.name, rootType);
37 | }
38 | }
39 |
40 | handleUnion(rootType: RootType): void {
41 | if (rootType.kind.isUnion) {
42 | const possibleTypes = rootType.possibleTypes ?? [];
43 | possibleTypes.forEach((possibleType) => {
44 | const possibleTypeName = possibleType.name ?? '';
45 | const unionSet = this.possibleTypeMap.get(possibleTypeName);
46 |
47 | if (unionSet) {
48 | unionSet.add(rootType);
49 | } else if (possibleTypeName) {
50 | this.possibleTypeMap.set(possibleTypeName, new Set([rootType]));
51 | }
52 | });
53 | }
54 | }
55 |
56 | handleObject(rootType: RootType): void {
57 | if (rootType.kind.isObject) {
58 | rootType.interfaces.forEach((rootTypeInterface) => {
59 | const interfaceName = rootTypeInterface.name ?? '';
60 | const interfaceRootType = this.interfaceMap.get(interfaceName);
61 |
62 | if (interfaceRootType) {
63 | this.addToResult(interfaceRootType, rootType);
64 |
65 | interfaceRootType.fields.forEach((interfaceField) => {
66 | const fieldContainsName = rootType.fields.some(
67 | (objectField) => objectField.name === interfaceField.name
68 | );
69 |
70 | if (!fieldContainsName) {
71 | rootType.fields?.push(interfaceField);
72 | }
73 | });
74 | }
75 | });
76 |
77 | const possibleRootTypesSet = this.possibleTypeMap.get(rootType.originalName);
78 |
79 | if (!possibleRootTypesSet) {
80 | return;
81 | }
82 |
83 | possibleRootTypesSet.forEach((unionRootType) =>
84 | this.addToResult(unionRootType, rootType)
85 | );
86 | }
87 | }
88 |
89 | addToResult(type: RootType, subType: RootType) {
90 | if (!this.result.has(type.name)) {
91 | const interfaceOrUnionTypeResult = new InterfaceOrUnionTypeResult({
92 | name: type.name,
93 | kind: type.kind.value,
94 | rootTypes: [subType],
95 | fields: type.kind.isInterface ? type.fields : [],
96 | });
97 | this.result.set(type.name, interfaceOrUnionTypeResult);
98 |
99 | return;
100 | }
101 |
102 | const interfaceOrUnionTypeResult = this.result.get(type.name);
103 |
104 | if (!this.result.has(subType.name)) {
105 | const match = interfaceOrUnionTypeResult?.rootTypes.some(
106 | (x) => x.originalName === subType.originalName
107 | );
108 | if (!match) {
109 | interfaceOrUnionTypeResult?.rootTypes.push(subType);
110 | }
111 | }
112 | }
113 | }
114 |
--------------------------------------------------------------------------------
/packages/mst-query-generator/src/models/index.ts:
--------------------------------------------------------------------------------
1 | export * from './Arg';
2 | export * from './Config';
3 | export * from './Directive';
4 | export * from './EnumValue';
5 | export * from './Field';
6 | export * from './GeneratedField';
7 | export * from './GeneratedFile';
8 | export * from './InputField';
9 | export * from './InterfaceOrUnionTypeResult';
10 | export * from './Kind';
11 | export * from './RootType';
12 | export * from './Schema';
13 | export * from './Type';
14 |
--------------------------------------------------------------------------------
/packages/mst-query-generator/src/type-handler-enum.ts:
--------------------------------------------------------------------------------
1 | import { GeneratedFile } from './models';
2 | import { IHandleType, TypeHandlerProps } from './types';
3 | import { header, indent, nameToCamelCase, newRow } from './utils';
4 |
5 | export const handleEnumType: IHandleType = (props: TypeHandlerProps): GeneratedFile[] => {
6 | const { rootType } = props;
7 | if (rootType.kind.isEnum) {
8 | return handle(props);
9 | }
10 | return [];
11 | };
12 |
13 | const handle = (props: TypeHandlerProps): GeneratedFile[] => {
14 | const { rootType } = props;
15 |
16 | const values = rootType.enumValues
17 | .map((enumValue) => {
18 | const camelCaseName = nameToCamelCase(enumValue.name);
19 | const upperCaseName = enumValue.name.toUpperCase();
20 | return `${indent}${camelCaseName}: '${upperCaseName}'`;
21 | })
22 | .join(`,${newRow}`);
23 |
24 | const content = [
25 | header,
26 | newRow,
27 | `import { types } from 'mobx-state-tree';`,
28 | newRow,
29 | newRow,
30 | `export const ${rootType.name} = {`,
31 | newRow,
32 | `${values}`,
33 | newRow,
34 | '};',
35 | newRow,
36 | newRow,
37 | `export type ${rootType.name}Type = `,
38 | `typeof ${rootType.name}[keyof typeof ${rootType.name}];`,
39 | newRow,
40 | newRow,
41 | `export const ${rootType.name}TypeEnum = types.enumeration<${rootType.name}Type>(`,
42 | newRow,
43 | `${indent}'${rootType.name}TypeEnum',`,
44 | newRow,
45 | `${indent}Object.values(${rootType.name})`,
46 | newRow,
47 | `);`,
48 | ];
49 |
50 | return [new GeneratedFile({ name: `${rootType.name}`, content })];
51 | };
52 |
--------------------------------------------------------------------------------
/packages/mst-query-generator/src/type-handler-interface-union.ts:
--------------------------------------------------------------------------------
1 | import { GeneratedFile, RootType } from './models';
2 | import { IHandleType, HandlerOptions, TypeHandlerProps } from './types';
3 | import { header, newRow } from './utils';
4 |
5 | export const handleInterfaceOrUnionType: IHandleType = (
6 | props: TypeHandlerProps,
7 | options: HandlerOptions
8 | ): GeneratedFile[] => {
9 | const { rootType } = props;
10 | if (rootType.kind.isInterface || rootType.kind.isUnion) {
11 | return handle(props, options);
12 | }
13 | return [];
14 | };
15 |
16 | const handle = (props: TypeHandlerProps, options: HandlerOptions): GeneratedFile[] => {
17 | const { rootType, imports } = props;
18 | const { addImport, typeResolver } = options;
19 | const interfaceAndUnionTypes = typeResolver?.GetInterfaceAndUnionTypeResults();
20 | const interfaceOrUnionType = interfaceAndUnionTypes?.get(rootType.name);
21 |
22 | if (!interfaceOrUnionType) {
23 | return [];
24 | }
25 |
26 | interfaceOrUnionType.rootTypes.forEach((rootType) => {
27 | addImport?.(`${rootType.name}Model.base`, `${rootType.name}`);
28 | addImport?.(`${rootType.name}Model`, `${rootType.name}ModelType`);
29 | });
30 |
31 | const actualTypes = interfaceOrUnionType?.rootTypes;
32 |
33 | const interfaceOrUnionActualTypes = actualTypes
34 | .map((type) => `${type.name}ModelType`)
35 | .join(' | ');
36 |
37 | const content = [
38 | header,
39 | newRow,
40 | newRow,
41 | printRelativeImports(imports, actualTypes),
42 | newRow,
43 | newRow,
44 | `export type ${interfaceOrUnionType?.name}Type = `,
45 | interfaceOrUnionActualTypes,
46 | newRow,
47 | newRow,
48 | ];
49 |
50 | return [new GeneratedFile({ name: rootType.name, content })];
51 | };
52 |
53 | const printRelativeImports = (
54 | imports: Map> | undefined,
55 | actualTypes: RootType[]
56 | ): string | null => {
57 | if (!imports) {
58 | return null;
59 | }
60 |
61 | const actualImports = actualTypes.map((type) => `${type.name}ModelType`);
62 | const moduleNames = [...imports.keys()].sort();
63 |
64 | return moduleNames
65 | .map((moduleName) => {
66 | const result = imports.get(moduleName) ?? '';
67 | const sortedImports = [...result].sort();
68 | const filteredImports = sortedImports.filter((i) => actualImports.includes(i));
69 |
70 | // ignore unused imports
71 | if (!filteredImports.length) {
72 | return null;
73 | }
74 |
75 | return `import { ${[...filteredImports].join(', ')} } from './${moduleName}';`;
76 | })
77 | .filter((i) => i !== null)
78 | .join(newRow);
79 | };
80 |
--------------------------------------------------------------------------------
/packages/mst-query-generator/src/type-handler-object.ts:
--------------------------------------------------------------------------------
1 | import { Config, Field, GeneratedFile } from './models';
2 | import { FieldHandlerProps } from './types';
3 | import { IHandleType, ModelFieldRef, HandlerOptions, TypeHandlerProps } from './types';
4 | import { header, indent, newRow } from './utils';
5 |
6 | export const handleObjectType: IHandleType = (
7 | props: TypeHandlerProps,
8 | options: HandlerOptions
9 | ): GeneratedFile[] => {
10 | const { rootType } = props;
11 | if (rootType.kind.isObject) {
12 | return handle(props, options);
13 | }
14 | return [];
15 | };
16 |
17 | const handle = (props: TypeHandlerProps, options: HandlerOptions): GeneratedFile[] => {
18 | const { rootType, imports } = props;
19 | const { config } = options;
20 | const refs = [] as ModelFieldRef[];
21 |
22 | const fields = rootType.fields
23 | ? rootType.fields.map((field) => createField(field, props, options, refs))
24 | : [];
25 | const modelFields = rootType.fields ? fields.join(newRow) : '';
26 |
27 | const withTypeRefsOrEmpty = refs.length > 0 ? 'withTypedRefs()(' : '';
28 |
29 | const modelBaseContent = [
30 | header,
31 | newRow,
32 | `import { types } from 'mobx-state-tree';`,
33 | newRow,
34 | `import { ModelBase } from './ModelBase';`,
35 | printMstQueryRefImport(fields),
36 | printWithTypeRefImport(refs, config),
37 | printRelativeImports(imports),
38 | newRow,
39 | newRow,
40 | printTypeRefValue(refs),
41 | `export const ${rootType.name}ModelBase = ${withTypeRefsOrEmpty}ModelBase.named('${rootType.name}').props({`,
42 | newRow,
43 | `${indent}__typename: types.optional(types.literal('${rootType.originalName}'), '${rootType.originalName}'),`,
44 | newRow,
45 | `${modelFields}`,
46 | `${modelFields.length > 0 ? newRow : ''}`,
47 | `})${refs.length > 0 ? ')' : ''};`,
48 | ];
49 |
50 | const modelContent = [
51 | `import { Instance } from 'mobx-state-tree';`,
52 | newRow,
53 | `import { ${rootType.name}ModelBase } from './${rootType.name}Model.base';`,
54 | newRow,
55 | newRow,
56 | `export const ${rootType.name}Model = ${rootType.name}ModelBase;`,
57 | newRow,
58 | newRow,
59 | `export interface ${rootType.name}ModelType extends Instance {}`,
60 | ];
61 |
62 | const modelBaseName = `${rootType.name}Model.base`;
63 | const modelName = `${rootType.name}Model`;
64 |
65 | const files = [
66 | new GeneratedFile({ name: modelBaseName, content: modelBaseContent }),
67 | ] as GeneratedFile[];
68 |
69 | if (config?.models) {
70 | files.push(new GeneratedFile({ name: modelName, content: modelContent }));
71 | }
72 | return files;
73 | };
74 |
75 | const createField = (
76 | field: Field,
77 | props: TypeHandlerProps,
78 | options: HandlerOptions,
79 | refs: ModelFieldRef[]
80 | ) => {
81 | const { rootType } = props;
82 | const { fieldHandler } = options;
83 | const fieldHandlerProps = { ...props, field, rootType, refs } as FieldHandlerProps;
84 | const result = fieldHandler?.(fieldHandlerProps, options);
85 | return `${indent}${field.name}: ${result?.toString()},`;
86 | };
87 |
88 | const printTypeRefValue = (modelFieldRefs: ModelFieldRef[]) => {
89 | const typeRefValues = modelFieldRefs.map(({ fieldName, modelType }) => {
90 | return `${indent}${fieldName}: ${modelType};${newRow}`;
91 | });
92 | const textRows = ['type Refs = {', newRow, `${typeRefValues.join('')}`, '};', newRow];
93 | return modelFieldRefs.length > 0 ? `${textRows.join('')}${newRow}` : '';
94 | };
95 |
96 | const printWithTypeRefImport = (refs: ModelFieldRef[], config?: Config) => {
97 | return refs.length > 0
98 | ? `${newRow}import { withTypedRefs } from '${config?.withTypeRefsPath}';`
99 | : '';
100 | };
101 |
102 | const printMstQueryRefImport = (fields: string[]) => {
103 | const shouldImportMstQueryRef = fields.some((x) => x.includes('MstQueryRef'));
104 | return shouldImportMstQueryRef ? `${newRow}import { MstQueryRef } from 'mst-query';` : '';
105 | };
106 |
107 | const printRelativeImports = (imports: Map> | undefined): string | null => {
108 | if (!imports) {
109 | return null;
110 | }
111 |
112 | const moduleNames = [...imports.keys()].sort();
113 | const relativeImports = moduleNames
114 | .map((moduleName) => {
115 | const result = imports.get(moduleName) ?? '';
116 | const sortedImports = [...result].sort();
117 | const importValue = [...sortedImports].join(', ');
118 | const isRelative = !moduleName.startsWith('-');
119 | const fromValue = isRelative ? moduleName : moduleName.slice(1, moduleName.length);
120 | return `import { ${importValue} } from '${isRelative ? './' : ''}${fromValue}';`;
121 | })
122 | .join(newRow);
123 |
124 | return relativeImports.length > 0 ? `${newRow}${relativeImports}` : '';
125 | };
126 |
--------------------------------------------------------------------------------
/packages/mst-query-generator/src/type-handler.ts:
--------------------------------------------------------------------------------
1 | import { RootType, GeneratedFile } from './models';
2 | import { IHandleType, HandlerOptions, TypeHandlerProps } from './types';
3 | import { handleEnumType } from './type-handler-enum';
4 | import { handleInterfaceOrUnionType } from './type-handler-interface-union';
5 | import { handleObjectType } from './type-handler-object';
6 | import { fieldHandler as defaultFieldHandler } from './field-handler';
7 | import { Overrides } from './models/Overrides';
8 |
9 | export const defaultTypeHandlers = [handleEnumType, handleInterfaceOrUnionType, handleObjectType];
10 |
11 | export const typeHandler = (props: TypeHandlerProps, options: HandlerOptions): GeneratedFile[] => {
12 | const { rootType } = props;
13 | const { config } = options;
14 | const typeHandlers = options.typeHandlers ?? defaultTypeHandlers;
15 | const fieldHandler = options.fieldHandler ?? defaultFieldHandler;
16 | const imports = new Map>();
17 |
18 | validateTypeHandlers(rootType, typeHandlers);
19 |
20 | if (!canHandleCurrentRootType(props)) {
21 | return [];
22 | }
23 |
24 | const addImport = (modelName: string, importToAdd: string) => {
25 | handleAddImport(rootType, imports, modelName, importToAdd);
26 | };
27 |
28 | const generatedFiles = typeHandlers.map((handleType) => {
29 | const handlerProps = { ...props, rootType, imports };
30 | const handlerOptions = {
31 | ...options,
32 | fieldHandler,
33 | addImport,
34 | overrides: config?.overrides ?? Overrides.Empty,
35 | };
36 | return handleType(handlerProps, handlerOptions);
37 | });
38 |
39 | return generatedFiles.flat(1);
40 | };
41 |
42 | const validateTypeHandlers = (rootType: RootType, typeHandlers?: IHandleType[]) => {
43 | if (!typeHandlers?.length) {
44 | throw new Error(
45 | `Unable to create file for type ${rootType.name}. No handlers registered for kind ${rootType.kind.value}`
46 | );
47 | }
48 | };
49 |
50 | const canHandleCurrentRootType = (props: TypeHandlerProps) => {
51 | const { rootType, excludes = [] } = props;
52 | return (
53 | !excludes.includes(rootType.name) &&
54 | !rootType.name.startsWith('__') &&
55 | !rootType.kind.isScalar &&
56 | !rootType.kind.isInputObject
57 | );
58 | };
59 |
60 | const handleAddImport = (
61 | rootType: RootType,
62 | imports: Map>,
63 | modelName: string,
64 | importToAdd: string
65 | ): void => {
66 | const currentModelName = `${rootType.name}Model.base`;
67 |
68 | if (modelName === currentModelName) {
69 | return;
70 | }
71 |
72 | if (imports.has(modelName)) {
73 | const importSet = imports.get(modelName);
74 | importSet?.add(importToAdd);
75 | } else {
76 | const importSet = new Set();
77 | importSet.add(importToAdd);
78 | imports.set(modelName, importSet);
79 | }
80 | };
81 |
--------------------------------------------------------------------------------
/packages/mst-query-generator/src/types.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Config,
3 | RootType,
4 | Type,
5 | Field,
6 | GeneratedField,
7 | GeneratedFile,
8 | InterfaceOrUnionTypeResult,
9 | } from './models';
10 | import { FieldOverride } from './models/FieldOverride';
11 | import { Overrides } from './models/Overrides';
12 |
13 | export type ModelFieldRef = {
14 | fieldName: string;
15 | modelType: string;
16 | isNested: boolean;
17 | isList: boolean;
18 | };
19 |
20 | export type TypeHandlerProps = {
21 | rootType: RootType;
22 | knownTypes: string[];
23 | excludes?: string[];
24 | imports?: Map>;
25 | };
26 |
27 | export type FieldHandlerProps = {
28 | field: Field;
29 | fieldType: Type | null;
30 | isNullable: boolean;
31 | isNested: boolean;
32 | fieldHandlers?: Map;
33 | refs: ModelFieldRef[];
34 | override?: FieldOverride;
35 | } & TypeHandlerProps;
36 |
37 | export type HandlerOptions = {
38 | config?: Config;
39 | typeResolver?: ITypeResolver;
40 | fieldHandler?: IHandleField;
41 | typeHandlers?: IHandleType[];
42 | addImport?: (modelName: string, importToAdd: string) => void;
43 | overrides?: Overrides;
44 | };
45 |
46 | export type FieldOverrideProps = {
47 | rootTypeName: string;
48 | fieldName: string;
49 | oldFieldType: string;
50 | newFieldType: string;
51 | typeImportPath: string;
52 | };
53 |
54 | export interface ITypeResolver {
55 | GetInterfaceAndUnionTypeResults(): Map;
56 | }
57 |
58 | export interface IHandleType {
59 | (props: TypeHandlerProps, options: HandlerOptions): GeneratedFile[];
60 | }
61 |
62 | export interface IHandleField {
63 | (props: FieldHandlerProps, options: HandlerOptions): GeneratedField | null;
64 | }
65 |
--------------------------------------------------------------------------------
/packages/mst-query-generator/src/utils.ts:
--------------------------------------------------------------------------------
1 | import camelcase from 'camelcase';
2 | import { RootType } from './models';
3 | import { FieldOverride } from './models/FieldOverride';
4 |
5 | export const primitiveFieldNames: { [key: string]: string } = {
6 | ID: 'identifier',
7 | Int: 'integer',
8 | String: 'string',
9 | Float: 'number',
10 | Boolean: 'boolean',
11 | };
12 | export const requiredTypes = ['identifier', 'identifierNumber'];
13 | export const reservedGraphqlNames = ['Mutation', 'CacheControlScope', 'Query', 'Subscription'];
14 | export const newRow = '\n';
15 | export const indent = ' ';
16 | export const header = [
17 | `/* This is a generated file, don't modify it manually */`,
18 | `/* eslint-disable */`,
19 | `/* tslint:disable */`,
20 | ].join(newRow);
21 |
22 | export const nameToCamelCase = (value: string) => {
23 | return camelcase(value, { pascalCase: true });
24 | };
25 |
26 | export const filterTypes = (
27 | rootTypes: RootType[],
28 | excludes: string[] = [...reservedGraphqlNames]
29 | ): RootType[] => {
30 | return rootTypes
31 | .filter((type) => !excludes.includes(type.name))
32 | .filter((type) => !type.name.startsWith('__'))
33 | .filter((type) => !type.kind.isScalar);
34 | };
35 |
36 | export const validateTypes = (
37 | rootTypes: RootType[] = [],
38 | excludes: string[] = [...reservedGraphqlNames]
39 | ): void => {
40 | const filteredTypes = filterTypes(rootTypes, excludes);
41 |
42 | const objectTypeNames = filteredTypes
43 | .filter((type) => type.kind.isObject)
44 | .map((type) => type.originalName);
45 |
46 | const filteredActualRootTypes = rootTypes.filter(
47 | (type) => objectTypeNames.includes(type.name) && type.isActualRootType
48 | );
49 | const invalidTypes = filteredActualRootTypes.filter(
50 | (type) => !objectTypeNames.includes(type.name)
51 | );
52 |
53 | invalidTypes.forEach((type) => {
54 | if (reservedGraphqlNames.includes(type.name)) {
55 | throw new Error(
56 | `Cannot generate ${type.name}Model, ${type.name} is a graphql reserved name`
57 | );
58 | }
59 | throw new Error(
60 | `The root type specified: '${type.name}' is unknown, excluded or not an OBJECT type!`
61 | );
62 | });
63 | };
64 |
65 | export const isOnlyWildcard = (text: string) => {
66 | return text === '*';
67 | };
68 |
69 | export const isIdType = (override: FieldOverride, type: string) => {
70 | return override.isIdentifier() || type === 'ID';
71 | };
72 |
--------------------------------------------------------------------------------
/packages/mst-query-generator/tests/__snapshots__/type-resolver.test.ts.snap:
--------------------------------------------------------------------------------
1 | // Vitest Snapshot v1
2 |
3 | exports[`type resolver > should match snapshot 1`] = `
4 | Map {
5 | "Owner" => InterfaceOrUnionTypeResult {
6 | "fields": [
7 | Field {
8 | "args": [],
9 | "deprecationReason": null,
10 | "description": "",
11 | "isDeprecated": false,
12 | "name": "id",
13 | "originalName": "id",
14 | "type": Type {
15 | "kind": Kind {
16 | "value": "NON_NULL",
17 | },
18 | "name": null,
19 | "ofType": Type {
20 | "kind": Kind {
21 | "value": "SCALAR",
22 | },
23 | "name": "ID",
24 | "ofType": null,
25 | },
26 | },
27 | },
28 | Field {
29 | "args": [],
30 | "deprecationReason": null,
31 | "description": "",
32 | "isDeprecated": false,
33 | "name": "name",
34 | "originalName": "name",
35 | "type": Type {
36 | "kind": Kind {
37 | "value": "NON_NULL",
38 | },
39 | "name": null,
40 | "ofType": Type {
41 | "kind": Kind {
42 | "value": "SCALAR",
43 | },
44 | "name": "String",
45 | "ofType": null,
46 | },
47 | },
48 | },
49 | ],
50 | "kind": Kind {
51 | "value": "INTERFACE",
52 | },
53 | "name": "Owner",
54 | "rootTypes": [
55 | RootType {
56 | "description": null,
57 | "enumValues": [],
58 | "fields": [
59 | Field {
60 | "args": [],
61 | "deprecationReason": null,
62 | "description": "",
63 | "isDeprecated": false,
64 | "name": "id",
65 | "originalName": "id",
66 | "type": Type {
67 | "kind": Kind {
68 | "value": "NON_NULL",
69 | },
70 | "name": null,
71 | "ofType": Type {
72 | "kind": Kind {
73 | "value": "SCALAR",
74 | },
75 | "name": "ID",
76 | "ofType": null,
77 | },
78 | },
79 | },
80 | Field {
81 | "args": [],
82 | "deprecationReason": null,
83 | "description": "",
84 | "isDeprecated": false,
85 | "name": "name",
86 | "originalName": "name",
87 | "type": Type {
88 | "kind": Kind {
89 | "value": "NON_NULL",
90 | },
91 | "name": null,
92 | "ofType": Type {
93 | "kind": Kind {
94 | "value": "SCALAR",
95 | },
96 | "name": "String",
97 | "ofType": null,
98 | },
99 | },
100 | },
101 | Field {
102 | "args": [],
103 | "deprecationReason": null,
104 | "description": "",
105 | "isDeprecated": false,
106 | "name": "avatar",
107 | "originalName": "avatar",
108 | "type": Type {
109 | "kind": Kind {
110 | "value": "NON_NULL",
111 | },
112 | "name": null,
113 | "ofType": Type {
114 | "kind": Kind {
115 | "value": "SCALAR",
116 | },
117 | "name": "String",
118 | "ofType": null,
119 | },
120 | },
121 | },
122 | ],
123 | "inputFields": [],
124 | "interfaces": [
125 | Type {
126 | "kind": Kind {
127 | "value": "INTERFACE",
128 | },
129 | "name": "Owner",
130 | "ofType": null,
131 | },
132 | ],
133 | "kind": Kind {
134 | "value": "OBJECT",
135 | },
136 | "name": "User",
137 | "originalName": "User",
138 | "possibleTypes": [],
139 | },
140 | ],
141 | },
142 | "Todo" => InterfaceOrUnionTypeResult {
143 | "fields": [],
144 | "kind": Kind {
145 | "value": "UNION",
146 | },
147 | "name": "Todo",
148 | "rootTypes": [
149 | RootType {
150 | "description": null,
151 | "enumValues": [],
152 | "fields": [
153 | Field {
154 | "args": [],
155 | "deprecationReason": null,
156 | "description": "",
157 | "isDeprecated": false,
158 | "name": "id",
159 | "originalName": "id",
160 | "type": Type {
161 | "kind": Kind {
162 | "value": "SCALAR",
163 | },
164 | "name": "ID",
165 | "ofType": null,
166 | },
167 | },
168 | Field {
169 | "args": [],
170 | "deprecationReason": null,
171 | "description": "",
172 | "isDeprecated": false,
173 | "name": "text",
174 | "originalName": "text",
175 | "type": Type {
176 | "kind": Kind {
177 | "value": "SCALAR",
178 | },
179 | "name": "String",
180 | "ofType": null,
181 | },
182 | },
183 | Field {
184 | "args": [],
185 | "deprecationReason": null,
186 | "description": "",
187 | "isDeprecated": false,
188 | "name": "complete",
189 | "originalName": "complete",
190 | "type": Type {
191 | "kind": Kind {
192 | "value": "SCALAR",
193 | },
194 | "name": "Boolean",
195 | "ofType": null,
196 | },
197 | },
198 | Field {
199 | "args": [],
200 | "deprecationReason": null,
201 | "description": "",
202 | "isDeprecated": false,
203 | "name": "owner",
204 | "originalName": "owner",
205 | "type": Type {
206 | "kind": Kind {
207 | "value": "INTERFACE",
208 | },
209 | "name": "Owner",
210 | "ofType": null,
211 | },
212 | },
213 | ],
214 | "inputFields": [],
215 | "interfaces": [],
216 | "kind": Kind {
217 | "value": "OBJECT",
218 | },
219 | "name": "BasicTodo",
220 | "originalName": "BasicTodo",
221 | "possibleTypes": [],
222 | },
223 | RootType {
224 | "description": null,
225 | "enumValues": [],
226 | "fields": [
227 | Field {
228 | "args": [],
229 | "deprecationReason": null,
230 | "description": "",
231 | "isDeprecated": false,
232 | "name": "id",
233 | "originalName": "id",
234 | "type": Type {
235 | "kind": Kind {
236 | "value": "SCALAR",
237 | },
238 | "name": "ID",
239 | "ofType": null,
240 | },
241 | },
242 | Field {
243 | "args": [],
244 | "deprecationReason": null,
245 | "description": "",
246 | "isDeprecated": false,
247 | "name": "label",
248 | "originalName": "label",
249 | "type": Type {
250 | "kind": Kind {
251 | "value": "SCALAR",
252 | },
253 | "name": "String",
254 | "ofType": null,
255 | },
256 | },
257 | Field {
258 | "args": [],
259 | "deprecationReason": null,
260 | "description": "",
261 | "isDeprecated": false,
262 | "name": "color",
263 | "originalName": "color",
264 | "type": Type {
265 | "kind": Kind {
266 | "value": "SCALAR",
267 | },
268 | "name": "String",
269 | "ofType": null,
270 | },
271 | },
272 | Field {
273 | "args": [],
274 | "deprecationReason": null,
275 | "description": "",
276 | "isDeprecated": false,
277 | "name": "complete",
278 | "originalName": "complete",
279 | "type": Type {
280 | "kind": Kind {
281 | "value": "SCALAR",
282 | },
283 | "name": "Boolean",
284 | "ofType": null,
285 | },
286 | },
287 | Field {
288 | "args": [],
289 | "deprecationReason": null,
290 | "description": "",
291 | "isDeprecated": false,
292 | "name": "owner",
293 | "originalName": "owner",
294 | "type": Type {
295 | "kind": Kind {
296 | "value": "INTERFACE",
297 | },
298 | "name": "Owner",
299 | "ofType": null,
300 | },
301 | },
302 | ],
303 | "inputFields": [],
304 | "interfaces": [],
305 | "kind": Kind {
306 | "value": "OBJECT",
307 | },
308 | "name": "FancyTodo",
309 | "originalName": "FancyTodo",
310 | "possibleTypes": [],
311 | },
312 | ],
313 | },
314 | }
315 | `;
316 |
--------------------------------------------------------------------------------
/packages/mst-query-generator/tests/__snapshots__/typeResolver.test.ts.snap:
--------------------------------------------------------------------------------
1 | // Vitest Snapshot v1
2 |
3 | exports[`TypeResolver > should match snapshot 1`] = `
4 | Map {
5 | "Owner" => InterfaceOrUnionTypeResult {
6 | "fields": [
7 | Field {
8 | "args": [],
9 | "deprecationReason": null,
10 | "description": "",
11 | "isDeprecated": false,
12 | "name": "id",
13 | "originalName": "id",
14 | "type": Type {
15 | "kind": Kind {
16 | "value": "NON_NULL",
17 | },
18 | "name": null,
19 | "ofType": Type {
20 | "kind": Kind {
21 | "value": "SCALAR",
22 | },
23 | "name": "ID",
24 | "ofType": null,
25 | },
26 | },
27 | },
28 | Field {
29 | "args": [],
30 | "deprecationReason": null,
31 | "description": "",
32 | "isDeprecated": false,
33 | "name": "name",
34 | "originalName": "name",
35 | "type": Type {
36 | "kind": Kind {
37 | "value": "NON_NULL",
38 | },
39 | "name": null,
40 | "ofType": Type {
41 | "kind": Kind {
42 | "value": "SCALAR",
43 | },
44 | "name": "String",
45 | "ofType": null,
46 | },
47 | },
48 | },
49 | ],
50 | "kind": Kind {
51 | "value": "INTERFACE",
52 | },
53 | "name": "Owner",
54 | "rootTypes": [
55 | RootType {
56 | "description": null,
57 | "enumValues": [],
58 | "fields": [
59 | Field {
60 | "args": [],
61 | "deprecationReason": null,
62 | "description": "",
63 | "isDeprecated": false,
64 | "name": "id",
65 | "originalName": "id",
66 | "type": Type {
67 | "kind": Kind {
68 | "value": "NON_NULL",
69 | },
70 | "name": null,
71 | "ofType": Type {
72 | "kind": Kind {
73 | "value": "SCALAR",
74 | },
75 | "name": "ID",
76 | "ofType": null,
77 | },
78 | },
79 | },
80 | Field {
81 | "args": [],
82 | "deprecationReason": null,
83 | "description": "",
84 | "isDeprecated": false,
85 | "name": "name",
86 | "originalName": "name",
87 | "type": Type {
88 | "kind": Kind {
89 | "value": "NON_NULL",
90 | },
91 | "name": null,
92 | "ofType": Type {
93 | "kind": Kind {
94 | "value": "SCALAR",
95 | },
96 | "name": "String",
97 | "ofType": null,
98 | },
99 | },
100 | },
101 | Field {
102 | "args": [],
103 | "deprecationReason": null,
104 | "description": "",
105 | "isDeprecated": false,
106 | "name": "avatar",
107 | "originalName": "avatar",
108 | "type": Type {
109 | "kind": Kind {
110 | "value": "NON_NULL",
111 | },
112 | "name": null,
113 | "ofType": Type {
114 | "kind": Kind {
115 | "value": "SCALAR",
116 | },
117 | "name": "String",
118 | "ofType": null,
119 | },
120 | },
121 | },
122 | ],
123 | "inputFields": [],
124 | "interfaces": [
125 | Type {
126 | "kind": Kind {
127 | "value": "INTERFACE",
128 | },
129 | "name": "Owner",
130 | "ofType": null,
131 | },
132 | ],
133 | "kind": Kind {
134 | "value": "OBJECT",
135 | },
136 | "name": "User",
137 | "originalName": "User",
138 | "possibleTypes": [],
139 | },
140 | ],
141 | },
142 | "Todo" => InterfaceOrUnionTypeResult {
143 | "fields": [],
144 | "kind": Kind {
145 | "value": "UNION",
146 | },
147 | "name": "Todo",
148 | "rootTypes": [
149 | RootType {
150 | "description": null,
151 | "enumValues": [],
152 | "fields": [
153 | Field {
154 | "args": [],
155 | "deprecationReason": null,
156 | "description": "",
157 | "isDeprecated": false,
158 | "name": "id",
159 | "originalName": "id",
160 | "type": Type {
161 | "kind": Kind {
162 | "value": "SCALAR",
163 | },
164 | "name": "ID",
165 | "ofType": null,
166 | },
167 | },
168 | Field {
169 | "args": [],
170 | "deprecationReason": null,
171 | "description": "",
172 | "isDeprecated": false,
173 | "name": "text",
174 | "originalName": "text",
175 | "type": Type {
176 | "kind": Kind {
177 | "value": "SCALAR",
178 | },
179 | "name": "String",
180 | "ofType": null,
181 | },
182 | },
183 | Field {
184 | "args": [],
185 | "deprecationReason": null,
186 | "description": "",
187 | "isDeprecated": false,
188 | "name": "complete",
189 | "originalName": "complete",
190 | "type": Type {
191 | "kind": Kind {
192 | "value": "SCALAR",
193 | },
194 | "name": "Boolean",
195 | "ofType": null,
196 | },
197 | },
198 | Field {
199 | "args": [],
200 | "deprecationReason": null,
201 | "description": "",
202 | "isDeprecated": false,
203 | "name": "owner",
204 | "originalName": "owner",
205 | "type": Type {
206 | "kind": Kind {
207 | "value": "INTERFACE",
208 | },
209 | "name": "Owner",
210 | "ofType": null,
211 | },
212 | },
213 | ],
214 | "inputFields": [],
215 | "interfaces": [],
216 | "kind": Kind {
217 | "value": "OBJECT",
218 | },
219 | "name": "BasicTodo",
220 | "originalName": "BasicTodo",
221 | "possibleTypes": [],
222 | },
223 | RootType {
224 | "description": null,
225 | "enumValues": [],
226 | "fields": [
227 | Field {
228 | "args": [],
229 | "deprecationReason": null,
230 | "description": "",
231 | "isDeprecated": false,
232 | "name": "id",
233 | "originalName": "id",
234 | "type": Type {
235 | "kind": Kind {
236 | "value": "SCALAR",
237 | },
238 | "name": "ID",
239 | "ofType": null,
240 | },
241 | },
242 | Field {
243 | "args": [],
244 | "deprecationReason": null,
245 | "description": "",
246 | "isDeprecated": false,
247 | "name": "label",
248 | "originalName": "label",
249 | "type": Type {
250 | "kind": Kind {
251 | "value": "SCALAR",
252 | },
253 | "name": "String",
254 | "ofType": null,
255 | },
256 | },
257 | Field {
258 | "args": [],
259 | "deprecationReason": null,
260 | "description": "",
261 | "isDeprecated": false,
262 | "name": "color",
263 | "originalName": "color",
264 | "type": Type {
265 | "kind": Kind {
266 | "value": "SCALAR",
267 | },
268 | "name": "String",
269 | "ofType": null,
270 | },
271 | },
272 | Field {
273 | "args": [],
274 | "deprecationReason": null,
275 | "description": "",
276 | "isDeprecated": false,
277 | "name": "complete",
278 | "originalName": "complete",
279 | "type": Type {
280 | "kind": Kind {
281 | "value": "SCALAR",
282 | },
283 | "name": "Boolean",
284 | "ofType": null,
285 | },
286 | },
287 | Field {
288 | "args": [],
289 | "deprecationReason": null,
290 | "description": "",
291 | "isDeprecated": false,
292 | "name": "owner",
293 | "originalName": "owner",
294 | "type": Type {
295 | "kind": Kind {
296 | "value": "INTERFACE",
297 | },
298 | "name": "Owner",
299 | "ofType": null,
300 | },
301 | },
302 | ],
303 | "inputFields": [],
304 | "interfaces": [],
305 | "kind": Kind {
306 | "value": "OBJECT",
307 | },
308 | "name": "FancyTodo",
309 | "originalName": "FancyTodo",
310 | "possibleTypes": [],
311 | },
312 | ],
313 | },
314 | }
315 | `;
316 |
--------------------------------------------------------------------------------
/packages/mst-query-generator/tests/field-handler.test.ts:
--------------------------------------------------------------------------------
1 | import { Field } from '../src/models';
2 | import { FieldHandlerProps, HandlerOptions } from '../src/types';
3 | import { fieldHandler } from '../src/field-handler';
4 | import { test, expect, describe } from 'vitest';
5 |
6 | describe('field handler', () => {
7 | const createGeneratedField = (field: Field, knownTypes: string[] = []) => {
8 | const handlerProps = { field, fieldType: field.type, knownTypes } as FieldHandlerProps;
9 | const handlerOptions = { addImport: () => {} } as HandlerOptions;
10 | return fieldHandler?.(handlerProps, handlerOptions);
11 | };
12 |
13 | test('should handle boolean field', () => {
14 | const field = new Field({
15 | name: `TestBooleanField`,
16 | type: {
17 | kind: 'NON_NULL',
18 | ofType: {
19 | kind: 'SCALAR',
20 | name: 'Boolean',
21 | },
22 | },
23 | });
24 | const expected = `types.union(types.undefined, types.boolean)`;
25 |
26 | const generatedField = createGeneratedField(field);
27 | const value = generatedField?.toString();
28 |
29 | expect(expected).toStrictEqual(value);
30 | });
31 |
32 | test('should handle string field', () => {
33 | const field = new Field({
34 | name: `TestStringField`,
35 | type: {
36 | kind: 'NON_NULL',
37 | ofType: {
38 | kind: 'SCALAR',
39 | name: 'String',
40 | },
41 | },
42 | });
43 |
44 | const expected = `types.union(types.undefined, types.string)`;
45 |
46 | const generatedField = createGeneratedField(field);
47 | const value = generatedField?.toString();
48 |
49 | expect(expected).toStrictEqual(value);
50 | });
51 |
52 | test('should handle id field', () => {
53 | const field = new Field({
54 | name: `TestIdField`,
55 | type: {
56 | kind: 'NON_NULL',
57 | ofType: {
58 | kind: 'SCALAR',
59 | name: 'ID',
60 | },
61 | },
62 | });
63 |
64 | const expected = `types.identifier`;
65 |
66 | const generatedField = createGeneratedField(field);
67 | const value = generatedField?.toString();
68 |
69 | expect(expected).toStrictEqual(value);
70 | });
71 |
72 | test('should handle nullable path field', () => {
73 | const field = new Field({
74 | name: `TestPathField`,
75 | type: {
76 | kind: 'SCALAR',
77 | name: 'String',
78 | },
79 | });
80 |
81 | const expected = `types.union(types.undefined, types.null, types.string)`;
82 |
83 | const generatedField = createGeneratedField(field);
84 | const value = generatedField?.toString();
85 |
86 | expect(value).toStrictEqual(expected);
87 | });
88 |
89 | test('should handle nullable object field', () => {
90 | const field = new Field({
91 | name: `TestObjectField`,
92 | type: {
93 | kind: 'OBJECT',
94 | name: 'TestObject',
95 | },
96 | });
97 | const expected = `types.union(types.undefined, types.null, types.late(():any => TestObjectModel))`;
98 |
99 | const generatedField = createGeneratedField(field, ['TestObject']);
100 | const value = generatedField?.toString();
101 |
102 | expect(expected).toStrictEqual(value);
103 | });
104 |
105 | test('should handle non null object field', () => {
106 | const field = new Field({
107 | name: `TestObjectField`,
108 | type: {
109 | kind: 'NON_NULL',
110 | ofType: {
111 | kind: 'OBJECT',
112 | name: 'TestObject',
113 | },
114 | },
115 | });
116 | const expected = `types.union(types.undefined, types.late(():any => TestObjectModel))`;
117 |
118 | const generatedField = createGeneratedField(field, ['TestObject']);
119 | const value = generatedField?.toString();
120 |
121 | expect(expected).toStrictEqual(value);
122 | });
123 |
124 | test('should handle enum field', () => {
125 | const field = new Field({
126 | name: `TestEnumField`,
127 | type: {
128 | kind: 'NON_NULL',
129 | ofType: {
130 | kind: 'ENUM',
131 | name: 'Origin',
132 | },
133 | },
134 | });
135 | const expected = `types.union(types.undefined, OriginTypeEnum)`;
136 |
137 | const generatedField = createGeneratedField(field);
138 | const value = generatedField?.toString();
139 |
140 | expect(expected).toStrictEqual(value);
141 | });
142 | });
143 |
--------------------------------------------------------------------------------
/packages/mst-query-generator/tests/filter-types.test.ts:
--------------------------------------------------------------------------------
1 | import { Generate } from '../generator/generate';
2 | import { Config, RootType } from '../src/models';
3 | import { filterTypes } from '../src/utils';
4 | import { test, expect, describe } from 'vitest';
5 |
6 | describe('filter types', () => {
7 | test('should ignore types in exclude', () => {
8 | const model = new RootType({ name: 'test', kind: 'OBJECT' });
9 | const config = new Config({ excludes: 'test' });
10 | const generate = new Generate({ rootTypes: [model], config });
11 |
12 | const result = filterTypes(generate.rootTypes, generate.excludes);
13 |
14 | expect(result).toHaveLength(0);
15 | });
16 |
17 | test('should ignore types starting with __', () => {
18 | const model = new RootType({ name: '__test', kind: 'OBJECT' });
19 | const generate = new Generate({ rootTypes: [model] });
20 |
21 | const result = filterTypes(generate.rootTypes, generate.excludes);
22 |
23 | expect(result).toHaveLength(0);
24 | });
25 |
26 | test('should ignore scalar types', () => {
27 | const model1 = new RootType({ name: 'scalar-type', kind: 'SCALAR' });
28 | const model2 = new RootType({ name: 'object-type', kind: 'OBJECT' });
29 | const generate = new Generate({ rootTypes: [model1, model2] });
30 |
31 | const result = filterTypes(generate.rootTypes, generate.excludes);
32 |
33 | expect(result).toHaveLength(1);
34 | });
35 | });
36 |
--------------------------------------------------------------------------------
/packages/mst-query-generator/tests/generate.test.ts:
--------------------------------------------------------------------------------
1 | import { Generate } from '../generator/generate';
2 | import { RootType } from '../src/models';
3 | import { test, expect, describe } from 'vitest';
4 |
5 | describe('generate', () => {
6 | test('should create index file', () => {
7 | const model = new RootType({ name: 'test', kind: 'OBJECT' });
8 | const generate = new Generate({ rootTypes: [model] });
9 |
10 | generate.GenerateIndexFile();
11 |
12 | expect(generate.files).toHaveLength(1);
13 | });
14 |
15 | test('should update type name to camel case', () => {
16 | const model = new RootType({ name: 'test' });
17 | const generate = new Generate({ rootTypes: [model] });
18 |
19 | generate.UpdateNamesToCamelCase();
20 |
21 | expect(model.name).toBe('Test');
22 | });
23 |
24 | test('should generate model base', () => {
25 | const expected = `\
26 | import { types } from "mobx-state-tree"
27 | export const ModelBase = types.model({});`;
28 |
29 | const generate = new Generate({ rootTypes: [] });
30 | generate.GenerateModelBase();
31 |
32 | expect(generate.files).toHaveLength(1);
33 |
34 | const content = generate.files[0].toString();
35 |
36 | expect(content).toStrictEqual(expected);
37 | });
38 | });
39 |
--------------------------------------------------------------------------------
/packages/mst-query-generator/tests/override.test.ts:
--------------------------------------------------------------------------------
1 | import path from 'path';
2 | import { FieldHandlerProps, HandlerOptions } from '../src/types';
3 | import { Schema, Config } from '../src/models';
4 | import { TypeResolver } from '../src/models/TypeResolver';
5 | import { filterTypes } from '../src/utils';
6 | import { scaffold } from '../generator/scaffold';
7 | import { test, expect, describe } from 'vitest';
8 | import { typeHandler } from '../src/type-handler';
9 | import { FieldOverride } from '../src/models/FieldOverride';
10 | import { Overrides } from '../src/models/Overrides';
11 |
12 | describe('field overrides', () => {
13 | const createGeneratedFiles = (
14 | schemaFile: string,
15 | rootTypeName: string,
16 | fieldOverrides: string
17 | ) => {
18 | const config = new Config({
19 | input: `${path.resolve(__dirname)}/schema/${schemaFile}`,
20 | outDir: 'somepath',
21 | fieldOverrides,
22 | });
23 | const json = scaffold(config);
24 | const schema = new Schema(json.__schema);
25 | const rootTypes = filterTypes(schema.types);
26 | const typeNames = rootTypes.map((t) => t.name);
27 | const typeResolver = new TypeResolver({ rootTypes });
28 |
29 | const rootType = rootTypes.find((r) => r.name === rootTypeName)!;
30 | const props = { rootType, knownTypes: typeNames } as FieldHandlerProps;
31 | const options = { config, typeResolver } as HandlerOptions;
32 |
33 | return typeHandler(props, options);
34 | };
35 | test('should generate type field override result', () => {
36 | const override = 'User.user_id:ID:string';
37 | const expected = [
38 | {
39 | rootTypeName: 'User',
40 | fieldName: 'user_id',
41 | oldFieldType: 'ID',
42 | newFieldType: 'string',
43 | typeImportPath: undefined,
44 | },
45 | ];
46 |
47 | const fieldOverrides = FieldOverride.parse(override);
48 | const overrides = new Overrides({ overrides: fieldOverrides });
49 |
50 | expect(overrides.fieldOverrides.map((o) => o.json())).toEqual(expected);
51 | });
52 |
53 | test('should parse string or array overrides', () => {
54 | const override = 'User.user_id:ID:string';
55 | const overrides = ['User.user_id:ID:string'];
56 | const expected = [
57 | {
58 | rootTypeName: 'User',
59 | fieldName: 'user_id',
60 | oldFieldType: 'ID',
61 | newFieldType: 'string',
62 | typeImportPath: undefined,
63 | },
64 | ];
65 |
66 | const overrideResult: FieldOverride[] = FieldOverride.parse(override);
67 | expect(overrideResult.map((x) => x.json())).toEqual(expected);
68 |
69 | const overridesResult: FieldOverride[] = FieldOverride.parse(overrides);
70 | expect(overridesResult.map((x) => x.json())).toEqual(expected);
71 | });
72 |
73 | test('should override model fields', () => {
74 | const expected = `\
75 | /* This is a generated file, don't modify it manually */
76 | /* eslint-disable */
77 | /* tslint:disable */
78 | import { types } from 'mobx-state-tree';
79 | import { ModelBase } from './ModelBase';
80 | import { CustomDateTime } from '@conrabopto/components';
81 |
82 | export const TodoModelBase = ModelBase.named('Todo').props({
83 | __typename: types.optional(types.literal('Todo'), 'Todo'),
84 | id: types.union(types.undefined, types.null, types.string),
85 | text: types.union(types.undefined, types.testType),
86 | complete: types.union(types.undefined, types.boolean),
87 | date: types.union(types.undefined, types.null, CustomDateTime),
88 | });`;
89 |
90 | const idOverride = 'Todo.id:ID:string';
91 | const testTypeOverride = 'Todo.text:String:testType';
92 | const dateOverride = 'Todo.date:DateTime:CustomDateTime:-@conrabopto/components';
93 | const override = `${idOverride},${testTypeOverride},${dateOverride}`;
94 |
95 | const files = createGeneratedFiles('todos.graphql', 'Todo', override);
96 | const content = files[0].toString();
97 |
98 | expect(content).toStrictEqual(expected);
99 | });
100 | });
101 |
--------------------------------------------------------------------------------
/packages/mst-query-generator/tests/scaffold.test.ts:
--------------------------------------------------------------------------------
1 | import path from 'path';
2 | import { Config } from '../src/models';
3 | import { scaffold } from '../generator/scaffold';
4 | import { test, expect, describe, vi } from 'vitest';
5 |
6 | describe('scaffold', () => {
7 | test('should return json', () => {
8 | const config = new Config({
9 | input: `${path.resolve(__dirname)}/schema/abstractTypes.graphql`,
10 | outDir: 'somepath',
11 | });
12 | const json = scaffold(config);
13 | expect(json).not.toBeNull();
14 | });
15 |
16 | test('should be able to log configuration', () => {
17 | const config = new Config({
18 | input: `${path.resolve(__dirname)}/schema/abstractTypes.graphql`,
19 | outDir: 'somepath',
20 | verbose: true,
21 | });
22 | const mockConfigLogger = vi.fn(() => {});
23 |
24 | scaffold(config, mockConfigLogger, () => {});
25 |
26 | expect(mockConfigLogger).toBeCalledTimes(1);
27 | });
28 |
29 | test('should be able to log schema types', () => {
30 | const config = new Config({
31 | input: `${path.resolve(__dirname)}/schema/abstractTypes.graphql`,
32 | outDir: 'somepath',
33 | verbose: true,
34 | });
35 | const mockSchemaTypesLogger = vi.fn(() => {});
36 |
37 | scaffold(config, () => {}, mockSchemaTypesLogger);
38 |
39 | expect(mockSchemaTypesLogger).toBeCalledTimes(1);
40 | });
41 | });
42 |
--------------------------------------------------------------------------------
/packages/mst-query-generator/tests/schema/abstractTypes.graphql:
--------------------------------------------------------------------------------
1 | type Query {
2 | search(text: String!): SearchResult!
3 | getAllRepos: [Repo]!
4 | }
5 | type Mutation {
6 | addRepo(name: String!, ownerName: String!, avatar: String, logo: String): Repo
7 | }
8 |
9 | # union types setup
10 | type Movie {
11 | description: String!
12 | director: String!
13 | }
14 | type Book {
15 | description: String!
16 | author: String!
17 | }
18 | union SearchItem = Movie | Book
19 | type SearchResult {
20 | items: [SearchItem]!
21 | }
22 |
23 | # interface types setup
24 | interface Owner {
25 | id: ID!
26 | name: String!
27 | }
28 | type User implements Owner {
29 | id: ID!
30 | name: String!
31 | avatar: String!
32 | }
33 | type Organization implements Owner {
34 | id: ID!
35 | name: String!
36 | logo: String!
37 | }
38 | type Repo {
39 | id: ID!
40 | owner: Owner
41 | }
42 |
--------------------------------------------------------------------------------
/packages/mst-query-generator/tests/schema/todos.graphql:
--------------------------------------------------------------------------------
1 | scalar DateTime
2 |
3 | type Query {
4 | todos: [Todo]!
5 | }
6 | type Mutation {
7 | toggleTodo(id: ID!): Todo
8 | }
9 | type Todo {
10 | id: ID
11 | text: String!
12 | complete: Boolean!
13 | date: DateTime
14 | }
15 |
--------------------------------------------------------------------------------
/packages/mst-query-generator/tests/schema/unionTypes.graphql:
--------------------------------------------------------------------------------
1 | type Query {
2 | todoLists: [TodoList!]!
3 | }
4 |
5 | union Todo = BasicTodo | FancyTodo
6 |
7 | interface Owner {
8 | id: ID!
9 | name: String!
10 | }
11 |
12 | type User implements Owner {
13 | id: ID!
14 | name: String!
15 | avatar: String!
16 | }
17 |
18 | type BasicTodo {
19 | id: ID
20 | text: String
21 | complete: Boolean
22 | owner: Owner
23 | }
24 |
25 | type FancyTodo {
26 | id: ID
27 | label: String
28 | color: String
29 | complete: Boolean
30 | owner: Owner
31 | }
32 |
33 | type TodoList {
34 | id: ID!
35 | todos: [Todo!]!
36 | }
37 |
--------------------------------------------------------------------------------
/packages/mst-query-generator/tests/type-handler.test.ts:
--------------------------------------------------------------------------------
1 | import path from 'path';
2 | import { FieldHandlerProps, HandlerOptions, TypeHandlerProps } from '../src/types';
3 | import { RootType, Schema, Config } from '../src/models';
4 | import { TypeResolver } from '../src/models/TypeResolver';
5 | import { filterTypes } from '../src/utils';
6 | import { scaffold } from '../generator/scaffold';
7 | import { test, expect, describe } from 'vitest';
8 | import { typeHandler } from '../src/type-handler';
9 |
10 | describe('type handler', () => {
11 | const createGeneratedFiles = (schemaFile: string, rootTypeName: string) => {
12 | const config = new Config({
13 | input: `${path.resolve(__dirname)}/schema/${schemaFile}`,
14 | outDir: 'somepath',
15 | });
16 | const json = scaffold(config);
17 | const schema = new Schema(json.__schema);
18 | const rootTypes = filterTypes(schema.types);
19 | const typeNames = rootTypes.map((t) => t.name);
20 | const typeResolver = new TypeResolver({ rootTypes });
21 |
22 | const rootType = rootTypes.find((r) => r.name === rootTypeName)!;
23 | const props = { rootType, knownTypes: typeNames } as FieldHandlerProps;
24 | const options = { config, typeResolver } as HandlerOptions;
25 |
26 | return typeHandler(props, options);
27 | };
28 |
29 | test('should generate model file content for object type', () => {
30 | const expectedContent = `\
31 | import { Instance } from 'mobx-state-tree';
32 | import { TestObjectModelBase } from './TestObjectModel.base';
33 |
34 | export const TestObjectModel = TestObjectModelBase;
35 |
36 | export interface TestObjectModelType extends Instance {}`;
37 |
38 | const rootType = new RootType({
39 | name: 'TestObject',
40 | kind: 'OBJECT',
41 | });
42 | const typeResolver = new TypeResolver({ rootTypes: [rootType] });
43 | const config = new Config({ models: true });
44 |
45 | const props = { rootType, knownTypes: [] } as TypeHandlerProps;
46 | const options = { config, typeResolver } as HandlerOptions;
47 | const files = typeHandler(props, options);
48 |
49 | const content = files[1].toString();
50 | expect(content).toStrictEqual(expectedContent);
51 | });
52 |
53 | test('should generate enum file content', () => {
54 | const expected = `\
55 | /* This is a generated file, don't modify it manually */
56 | /* eslint-disable */
57 | /* tslint:disable */
58 | import { types } from 'mobx-state-tree';
59 |
60 | export const TestType = {
61 | SomeValue: 'SOME_VALUE',
62 | SomeOtherValue: 'SOME_OTHER_VALUE'
63 | };
64 |
65 | export type TestTypeType = typeof TestType[keyof typeof TestType];
66 |
67 | export const TestTypeTypeEnum = types.enumeration(
68 | 'TestTypeTypeEnum',
69 | Object.values(TestType)
70 | );`;
71 |
72 | const rootType = new RootType({
73 | name: 'TestType',
74 | kind: 'ENUM',
75 | enumValues: [{ name: 'SOME_VALUE' }, { name: 'SOME_OTHER_VALUE' }],
76 | });
77 |
78 | const typeNames = [rootType.name];
79 | const props = { rootType, knownTypes: typeNames } as FieldHandlerProps;
80 | const options = {} as HandlerOptions;
81 | const files = typeHandler(props, options);
82 | const content = files[0].toString();
83 |
84 | expect(content).toStrictEqual(expected);
85 | });
86 |
87 | test('should generate model base file content for object type', () => {
88 | const expectedContent = `\
89 | /* This is a generated file, don't modify it manually */
90 | /* eslint-disable */
91 | /* tslint:disable */
92 | import { types } from 'mobx-state-tree';
93 | import { ModelBase } from './ModelBase';
94 |
95 | export const TestObjectModelBase = ModelBase.named('TestObject').props({
96 | __typename: types.optional(types.literal('TestObject'), 'TestObject'),
97 | });`;
98 |
99 | const rootType = new RootType({
100 | name: 'TestObject',
101 | kind: 'OBJECT',
102 | });
103 |
104 | const typeResolver = new TypeResolver({ types: [rootType] });
105 | const props = { rootType, knownTypes: [] } as TypeHandlerProps;
106 | const options = { typeResolver } as HandlerOptions;
107 | const files = typeHandler(props, options);
108 |
109 | const content = files[0].toString();
110 | expect(content).toStrictEqual(expectedContent);
111 | });
112 |
113 | test('should handle union types', () => {
114 | const expected = `\
115 | /* This is a generated file, don't modify it manually */
116 | /* eslint-disable */
117 | /* tslint:disable */
118 | import { types } from 'mobx-state-tree';
119 | import { ModelBase } from './ModelBase';
120 | import { MstQueryRef } from 'mst-query';
121 | import { withTypedRefs } from '@utils';
122 | import { BasicTodoModel, BasicTodoModelType } from './BasicTodoModel';
123 | import { FancyTodoModel, FancyTodoModelType } from './FancyTodoModel';
124 |
125 | type Refs = {
126 | todos: BasicTodoModelType[] | FancyTodoModelType[];
127 | };
128 |
129 | export const TodoListModelBase = withTypedRefs()(ModelBase.named('TodoList').props({
130 | __typename: types.optional(types.literal('TodoList'), 'TodoList'),
131 | id: types.identifier,
132 | todos: types.union(types.undefined, types.array(MstQueryRef(types.union(types.late(():any => BasicTodoModel), types.late(():any => FancyTodoModel))))),
133 | }));`;
134 |
135 | const files = createGeneratedFiles('unionTypes.graphql', 'TodoList');
136 | const content = files[0].toString();
137 |
138 | expect(content).toStrictEqual(expected);
139 | });
140 |
141 | test('should handle nullable interface types', () => {
142 | const expected = `\
143 | /* This is a generated file, don't modify it manually */
144 | /* eslint-disable */
145 | /* tslint:disable */
146 | import { types } from 'mobx-state-tree';
147 | import { ModelBase } from './ModelBase';
148 | import { MstQueryRef } from 'mst-query';
149 | import { withTypedRefs } from '@utils';
150 | import { UserModel, UserModelType } from './UserModel';
151 |
152 | type Refs = {
153 | owner: UserModelType;
154 | };
155 |
156 | export const FancyTodoModelBase = withTypedRefs()(ModelBase.named('FancyTodo').props({
157 | __typename: types.optional(types.literal('FancyTodo'), 'FancyTodo'),
158 | id: types.identifier,
159 | label: types.union(types.undefined, types.null, types.string),
160 | color: types.union(types.undefined, types.null, types.string),
161 | complete: types.union(types.undefined, types.null, types.boolean),
162 | owner: types.union(types.undefined, types.null, MstQueryRef(types.late(():any => UserModel))),
163 | }));`;
164 |
165 | const files = createGeneratedFiles('unionTypes.graphql', 'FancyTodo');
166 | const content = files[0].toString();
167 |
168 | expect(content).toStrictEqual(expected);
169 | });
170 |
171 | test('should handle array Refs', () => {
172 | const expected = `\
173 | /* This is a generated file, don't modify it manually */
174 | /* eslint-disable */
175 | /* tslint:disable */
176 | import { types } from 'mobx-state-tree';
177 | import { ModelBase } from './ModelBase';
178 | import { MstQueryRef } from 'mst-query';
179 | import { withTypedRefs } from '@utils';
180 | import { BasicTodoModel, BasicTodoModelType } from './BasicTodoModel';
181 | import { FancyTodoModel, FancyTodoModelType } from './FancyTodoModel';
182 |
183 | type Refs = {
184 | todos: BasicTodoModelType[] | FancyTodoModelType[];
185 | };
186 |
187 | export const TodoListModelBase = withTypedRefs()(ModelBase.named('TodoList').props({
188 | __typename: types.optional(types.literal('TodoList'), 'TodoList'),
189 | id: types.identifier,
190 | todos: types.union(types.undefined, types.array(MstQueryRef(types.union(types.late(():any => BasicTodoModel), types.late(():any => FancyTodoModel))))),
191 | }));`;
192 |
193 | const files = createGeneratedFiles('unionTypes.graphql', 'TodoList');
194 | const content = files[0].toString();
195 |
196 | expect(content).toStrictEqual(expected);
197 | });
198 | });
199 |
--------------------------------------------------------------------------------
/packages/mst-query-generator/tests/type-resolver.test.ts:
--------------------------------------------------------------------------------
1 | import path from 'path';
2 | import { Config, Schema } from '../src/models';
3 | import { TypeResolver } from '../src/models/TypeResolver';
4 | import { filterTypes } from '../src/utils';
5 | import { scaffold } from '../generator/scaffold';
6 | import { test, expect, describe } from 'vitest';
7 |
8 | describe('type resolver', () => {
9 | test('should combine interface and union types to result model', () => {
10 | const expected = ['Owner', 'Todo'];
11 |
12 | const config = new Config({
13 | input: `${path.resolve(__dirname)}/schema/unionTypes.graphql`,
14 | outDir: 'somepath',
15 | verbose: false,
16 | });
17 | const json = scaffold(config);
18 | const schema = new Schema(json.__schema);
19 | const rootTypes = filterTypes(schema.types);
20 |
21 | const typeResolver = new TypeResolver({ rootTypes });
22 | const typeResults = typeResolver.GetInterfaceAndUnionTypeResults();
23 | const result = Array.from(typeResults.keys());
24 |
25 | expect(result.length).toBe(2);
26 | expect(expected).toStrictEqual(result);
27 | });
28 |
29 | test('should match snapshot', () => {
30 | const config = new Config({
31 | input: `${path.resolve(__dirname)}/schema/unionTypes.graphql`,
32 | outDir: 'somepath',
33 | });
34 | const json = scaffold(config);
35 | const schema = new Schema(json.__schema);
36 | const rootTypes = filterTypes(schema.types);
37 | const typeResolver = new TypeResolver({ rootTypes });
38 |
39 | const result = typeResolver.GetInterfaceAndUnionTypeResults();
40 |
41 | expect(result).toMatchSnapshot();
42 | });
43 | });
44 |
--------------------------------------------------------------------------------
/packages/mst-query-generator/tsconfig.build.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "include": ["./src"]
4 | }
5 |
--------------------------------------------------------------------------------
/packages/mst-query-generator/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "module": "commonjs",
5 | "lib": ["es6", "es2015", "dom"],
6 | "declaration": true,
7 | "outDir": "dist",
8 | "strict": true,
9 | "types": ["node"],
10 | "esModuleInterop": true,
11 | "resolveJsonModule": true
12 | },
13 | "include": ["./src", "./tests"]
14 | }
15 |
--------------------------------------------------------------------------------
/packages/mst-query-generator/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vitest/config';
2 |
3 | export default defineConfig({
4 | test: {
5 | environment: 'node',
6 | update: true,
7 | },
8 | });
9 |
--------------------------------------------------------------------------------
/packages/mst-query/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | dist/
3 |
--------------------------------------------------------------------------------
/packages/mst-query/.prettierignore:
--------------------------------------------------------------------------------
1 | package-lock.json
2 | package.json
3 | node_modules
4 | .gitignore
5 | .prettierignore
--------------------------------------------------------------------------------
/packages/mst-query/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "mst-query",
3 | "version": "4.2.1",
4 | "description": "Query library for mobx-state-tree",
5 | "source": "src/index.ts",
6 | "type": "module",
7 | "main": "dist/index.cjs",
8 | "module": "dist/index.js",
9 | "types": "dist/index.d.ts",
10 | "exports": {
11 | "import": {
12 | "types": "./dist/index.d.ts",
13 | "import": "./dist/index.js"
14 | },
15 | "require": {
16 | "types": "./dist/index.d.cts",
17 | "require": "./dist/index.cjs"
18 | }
19 | },
20 | "scripts": {
21 | "build": "tsup src/index.ts --format cjs,esm --dts --clean --sourcemap",
22 | "watch": "vitest",
23 | "test": "vitest run"
24 | },
25 | "repository": {
26 | "type": "git",
27 | "url": "https://github.com/ConrabOpto/mst-query.git"
28 | },
29 | "author": "Conrab Opto (www.conrabopto.se)",
30 | "license": "MIT",
31 | "files": [
32 | "dist"
33 | ],
34 | "devDependencies": {
35 | "@testing-library/react": "16.0.1",
36 | "@types/react": "18.3.3",
37 | "jsdom": "25.0.1",
38 | "mobx": "6.13.5",
39 | "mobx-react": "9.1.1",
40 | "mobx-state-tree": "6.0.1",
41 | "prettier": "3.3.3",
42 | "react": "18.3.1",
43 | "react-dom": "18.3.1",
44 | "tsup": "8.3.0",
45 | "typescript": "5.6.3",
46 | "vitest": "2.1.3"
47 | },
48 | "peerDependencies": {
49 | "mobx": ">=6.0.0 <7.0.0",
50 | "mobx-state-tree": ">=5.0.0 <7.0.0",
51 | "react": ">=18.0.0 <20.0.0",
52 | "react-dom": ">=18.0.0 <20.0.0"
53 | },
54 | "dependencies": {
55 | "@wry/equality": "0.5.7"
56 | },
57 | "volta": {
58 | "node": "18.12.1"
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/packages/mst-query/src/QueryClient.ts:
--------------------------------------------------------------------------------
1 | import { destroy, IAnyModelType, Instance } from 'mobx-state-tree';
2 | import { QueryStore } from './QueryStore';
3 |
4 | export type EndpointType = (
5 | options: {
6 | request?: any;
7 | pagination?: any;
8 | meta: { [key: string]: any };
9 | signal: AbortSignal;
10 | setData: (data: any) => any;
11 | query: any
12 | },
13 | ) => Promise;
14 |
15 | type QueryClientConfig = {
16 | env?: any;
17 | queryOptions?: {
18 | staleTime?: number;
19 | endpoint?: EndpointType;
20 | };
21 | RootStore: T;
22 | };
23 |
24 | const defaultConfig = {
25 | env: {},
26 | queryOptions: {
27 | staleTime: 0,
28 | cacheTime: 0,
29 | refetchOnMount: 'if-stale',
30 | refetchOnChanged: 'all',
31 | },
32 | };
33 |
34 | export class QueryClient {
35 | config: QueryClientConfig;
36 | rootStore!: Instance;
37 | queryStore!: QueryStore;
38 | #initialized = false;
39 | #initialData = {} as any;
40 |
41 | constructor(config: QueryClientConfig) {
42 | this.config = {
43 | ...defaultConfig,
44 | ...config,
45 | queryOptions: {
46 | ...defaultConfig.queryOptions,
47 | ...config.queryOptions,
48 | },
49 | };
50 | }
51 |
52 | init(initialData: any = {}, env = {}) {
53 | if (this.#initialized) {
54 | return this;
55 | }
56 |
57 | this.config.env = env;
58 | this.config.env.queryClient = this;
59 | this.queryStore = new QueryStore(this);
60 |
61 | this.rootStore = this.config.RootStore.create(initialData, this.config.env);
62 |
63 | this.#initialized = true;
64 |
65 | return this;
66 | }
67 |
68 | reset() {
69 | destroy(this.rootStore);
70 | this.queryStore = new QueryStore(this);
71 | this.rootStore = this.config.RootStore.create(this.#initialData, this.config.env);
72 | }
73 |
74 |
75 | }
76 |
--------------------------------------------------------------------------------
/packages/mst-query/src/QueryClientProvider.tsx:
--------------------------------------------------------------------------------
1 | import { IAnyModelType } from 'mobx-state-tree';
2 | import * as React from 'react';
3 | import { QueryClient } from './QueryClient';
4 |
5 | type QueryClientProviderProps = {
6 | env?: any;
7 | initialData?: any;
8 | children: React.ReactNode;
9 | };
10 |
11 | export const Context = React.createContext | undefined>(undefined);
12 |
13 | export function createContext(queryClient: QueryClient) {
14 | const QueryClientProvider = ({
15 | env,
16 | initialData,
17 | children,
18 | }: QueryClientProviderProps) => {
19 | const q = React.useRef(null);
20 | if (!q.current) {
21 | q.current = queryClient.init(initialData, env);
22 | }
23 | return {children};
24 | };
25 | const useQueryClient = () => {
26 | const qc = React.useContext(Context) as QueryClient;
27 | if (!qc) {
28 | throw new Error('No QueryClient set, use QueryClientProvider to set one');
29 | }
30 | return qc;
31 | };
32 | const useRootStore = () => {
33 | const qc = React.useContext(Context) as QueryClient;
34 | return qc.rootStore;
35 | };
36 | return {
37 | queryClient,
38 | useQueryClient,
39 | useRootStore,
40 | QueryClientProvider,
41 | };
42 | }
43 |
--------------------------------------------------------------------------------
/packages/mst-query/src/QueryStore.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Instance,
3 | IAnyModelType,
4 | isStateTreeNode,
5 | isAlive,
6 | getType,
7 | isArrayType,
8 | getIdentifier,
9 | IAnyComplexType,
10 | } from 'mobx-state-tree';
11 | import { observable, action, makeObservable } from 'mobx';
12 |
13 | export const getKey = (type: IAnyComplexType, id: string | number) => {
14 | return `${type.name}:${id}`;
15 | };
16 |
17 | type QueryCacheEntry = {
18 | cachedAt: number;
19 | data: any;
20 | timeout: number;
21 | };
22 |
23 | export class QueryStore {
24 | #scheduledGc = null as null | number;
25 | #queryClient: any;
26 | #queryData = new Map() as Map;
27 | #cache = new Map() as Map;
28 |
29 | models = new Map() as Map;
30 |
31 | constructor(queryClient: any) {
32 | makeObservable(this, {
33 | setQuery: action,
34 | removeQuery: action,
35 | clear: action,
36 | });
37 |
38 | this.#queryClient = queryClient;
39 | }
40 |
41 | getQueryData(type: IAnyComplexType, key: string) {
42 | return this.#queryData.get(getKey(type, key));
43 | }
44 |
45 | setQueryData(
46 | type: IAnyComplexType,
47 | key: string,
48 | model: Instance,
49 | cacheTime: number = 0,
50 | ) {
51 | const existingEntry = this.#queryData.get(getKey(type, key));
52 | if (existingEntry) {
53 | window.clearTimeout(existingEntry.timeout);
54 | }
55 |
56 | const cacheEntry = {
57 | cachedAt: Date.now(),
58 | data: model.data,
59 | timeout: window.setTimeout(() => {
60 | this.removeQueryData(type, key);
61 | }, cacheTime),
62 | };
63 | this.#queryData.set(getKey(type, key), cacheEntry);
64 | }
65 |
66 | removeQueryData(type: IAnyComplexType, key: string) {
67 | this.#queryData.delete(getKey(type, key));
68 | }
69 |
70 | getQueries(
71 | queryDef: T,
72 | matcherFn: (query: Instance) => boolean = () => true,
73 | ): Instance[] {
74 | let results = [];
75 | const arr = this.#cache.get(queryDef.name) ?? [];
76 | for (let query of arr) {
77 | if (getType(query) === queryDef && matcherFn(query)) {
78 | results.push(query);
79 | }
80 | }
81 | return results;
82 | }
83 |
84 | setQuery(q: any) {
85 | const type = getType(q);
86 | let arr = this.#cache.get(type.name);
87 | if (!arr) {
88 | arr = observable.array([], { deep: false });
89 | this.#cache.set(type.name, arr);
90 | }
91 | arr.push(q);
92 | }
93 |
94 | removeQuery(query: any) {
95 | const type = getType(query);
96 | this.#cache.get(type.name)?.remove(query);
97 | }
98 |
99 | clear() {
100 | for (let [, obj] of this.models) {
101 | this.#queryClient.rootStore.__MstQueryAction(
102 | 'delete',
103 | getType(obj),
104 | getIdentifier(obj),
105 | obj,
106 | );
107 | }
108 |
109 | this.models.clear();
110 | this.#cache.clear();
111 | }
112 |
113 | runGc() {
114 | if (this.#scheduledGc) {
115 | return;
116 | }
117 |
118 | const seenIdentifiers = new Set();
119 | for (let [_, arr] of this.#cache) {
120 | for (let query of arr) {
121 | if (query.isLoading) {
122 | this.#scheduledGc = window.setTimeout(() => {
123 | this.#scheduledGc = null;
124 | this.runGc();
125 | }, 1000);
126 |
127 | return;
128 | }
129 |
130 | collectSeenIdentifiers(query.data, seenIdentifiers);
131 | collectSeenIdentifiers(query.request, seenIdentifiers);
132 |
133 | for (let [_, queryData] of this.#queryData) {
134 | collectSeenIdentifiers(queryData.data, seenIdentifiers);
135 | }
136 | }
137 | }
138 |
139 | for (let [key, obj] of this.models) {
140 | const identifier = getIdentifier(obj) as string | number;
141 | if (!seenIdentifiers.has(getKey(getType(obj), identifier))) {
142 | this.models.delete(getKey(getType(obj), getIdentifier(obj) as string));
143 | this.#queryClient.rootStore.__MstQueryAction('delete', getType(obj), key, obj);
144 | }
145 | }
146 | }
147 | }
148 |
149 | export function collectSeenIdentifiers(node: any, seenIdentifiers: any) {
150 | if (!isStateTreeNode(node)) {
151 | return;
152 | }
153 | if (!isAlive(node)) {
154 | return;
155 | }
156 | if (!node || node instanceof Date || typeof node !== 'object') {
157 | return;
158 | }
159 | const n = node as any;
160 | const t = getType(node) as any;
161 | if (isArrayType(t)) {
162 | n.forEach((n: any) => collectSeenIdentifiers(n, seenIdentifiers));
163 | return;
164 | }
165 |
166 | const nodeIdentifier = getIdentifier(n);
167 | if (nodeIdentifier) {
168 | const identifier = getKey(t, nodeIdentifier);
169 |
170 | if (seenIdentifiers.has(identifier)) {
171 | return;
172 | } else {
173 | seenIdentifiers.add(identifier);
174 | }
175 | }
176 |
177 | for (const key in t.properties) {
178 | collectSeenIdentifiers(n[key], seenIdentifiers);
179 | }
180 | }
181 |
--------------------------------------------------------------------------------
/packages/mst-query/src/hooks.ts:
--------------------------------------------------------------------------------
1 | import { Instance, SnapshotIn } from 'mobx-state-tree';
2 | import { useContext, useEffect, useRef, useState } from 'react';
3 | import {
4 | VolatileQuery,
5 | MutationReturnType,
6 | QueryReturnType,
7 | InfiniteQueryReturnType,
8 | } from './create';
9 | import { Context } from './QueryClientProvider';
10 | import { QueryClient } from './QueryClient';
11 | import { CacheOptions, EmptyPagination, EmptyRequest, QueryObserver } from './MstQueryHandler';
12 | import { useEvent } from './utils';
13 |
14 | function mergeWithDefaultOptions(key: string, options: any, queryClient: QueryClient) {
15 | return Object.assign({ queryClient }, (queryClient.config as any)[key], {
16 | enabled: true,
17 | ...options,
18 | });
19 | }
20 |
21 | type QueryOptions> = {
22 | request?: SnapshotIn;
23 | refetchOnMount?: 'always' | 'never' | 'if-stale';
24 | refetchOnChanged?:
25 | | 'all'
26 | | 'request'
27 | | 'pagination'
28 | | 'none'
29 | | ((options: { prevRequest: Exclude }) => boolean);
30 | staleTime?: number;
31 | enabled?: boolean;
32 | initialData?: any;
33 | initialDataUpdatedAt?: number;
34 | meta?: { [key: string]: any };
35 | } & CacheOptions;
36 |
37 | export function useQuery>(
38 | query: T,
39 | options: QueryOptions = {},
40 | ) {
41 | const [observer, setObserver] = useState(() => new QueryObserver(query, true));
42 |
43 | const queryClient = useContext(Context)! as QueryClient;
44 | options = mergeWithDefaultOptions('queryOptions', options, queryClient);
45 |
46 | (options as any).request = options.request ?? EmptyRequest;
47 |
48 | if ((query as any).isInfinite) {
49 | throw new Error(
50 | 'useQuery should be used with a query that does not have pagination. Use useInfiniteQuery instead.',
51 | );
52 | }
53 |
54 | useEffect(() => {
55 | if (observer.query !== query) {
56 | setObserver(new QueryObserver(query, true));
57 | }
58 | }, [query]);
59 |
60 | useEffect(() => {
61 | observer.setOptions(options);
62 |
63 | return () => {
64 | observer.unsubscribe();
65 | };
66 | }, [options]);
67 |
68 | return {
69 | data: query.data as (typeof query)['data'],
70 | dataUpdatedAt: query.__MstQueryHandler.cachedAt?.getTime(),
71 | error: query.error,
72 | isFetched: query.isFetched,
73 | isLoading: query.isLoading,
74 | isRefetching: query.isRefetching,
75 | query: query,
76 | refetch: query.refetch,
77 | isStale: query.__MstQueryHandler.isStale(options),
78 | isFetchedAfterMount: observer.isFetchedAfterMount,
79 | };
80 | }
81 |
82 | type InfiniteQueryOptions> = {
83 | request?: SnapshotIn;
84 | pagination?: SnapshotIn;
85 | refetchOnMount?: 'always' | 'never' | 'if-stale';
86 | refetchOnChanged?:
87 | | 'all'
88 | | 'request'
89 | | 'pagination'
90 | | 'none'
91 | | ((options: {
92 | prevRequest: Exclude;
93 | prevPagination: Exclude;
94 | }) => boolean);
95 | staleTime?: number;
96 | enabled?: boolean;
97 | initialData?: any;
98 | initialDataUpdatedAt?: number;
99 | meta?: { [key: string]: any };
100 | };
101 |
102 | export function useInfiniteQuery>(
103 | query: T,
104 | options: InfiniteQueryOptions = {},
105 | ) {
106 | const [observer, setObserver] = useState(() => new QueryObserver(query, true));
107 |
108 | const queryClient = useContext(Context)! as QueryClient;
109 | options = mergeWithDefaultOptions('queryOptions', options, queryClient);
110 |
111 | (options as any).request = options.request ?? EmptyRequest;
112 | (options as any).pagination = options.pagination ?? EmptyPagination;
113 |
114 | if (!(query as any).isInfinite) {
115 | throw new Error(
116 | 'useInfiniteQuery should be used with a query that has pagination. Use useQuery instead.',
117 | );
118 | }
119 |
120 | useEffect(() => {
121 | if (observer.query !== query) {
122 | setObserver(new QueryObserver(query, true));
123 | }
124 | }, [query]);
125 |
126 | useEffect(() => {
127 | observer.setOptions(options);
128 |
129 | return () => {
130 | observer.unsubscribe();
131 | };
132 | }, [options]);
133 |
134 | return {
135 | data: query.data as (typeof query)['data'],
136 | dataUpdatedAt: query.__MstQueryHandler.cachedAt?.getTime(),
137 | error: query.error,
138 | isFetched: query.isFetched,
139 | isLoading: query.isLoading,
140 | isRefetching: query.isRefetching,
141 | isFetchingMore: query.isFetchingMore,
142 | query: query,
143 | refetch: query.refetch,
144 | isStale: query.__MstQueryHandler.isStale(options),
145 | isFetchedAfterMount: observer.isFetchedAfterMount,
146 | };
147 | }
148 |
149 | type MutationOptions> = {
150 | onMutate?: (data: T['data'], self: T) => void;
151 | meta?: { [key: string]: any };
152 | };
153 |
154 | export function useMutation>(
155 | mutation: T,
156 | options: MutationOptions = {},
157 | ) {
158 | const [observer, setObserver] = useState(() => new QueryObserver(mutation, false));
159 |
160 | const queryClient = useContext(Context) as QueryClient;
161 | options = { queryClient, ...options } as any;
162 |
163 | useEffect(() => {
164 | if (observer.query !== mutation) {
165 | setObserver(new QueryObserver(mutation, false));
166 | }
167 | }, [mutation]);
168 |
169 | useEffect(() => {
170 | observer.setOptions(options);
171 | }, [options]);
172 |
173 | const result = {
174 | data: mutation.data as (typeof mutation)['data'],
175 | error: mutation.error,
176 | isLoading: mutation.isLoading,
177 | mutation,
178 | };
179 |
180 | const mutate = useEvent(
181 | (params: {
182 | request: SnapshotIn;
183 | optimisticUpdate?: () => void;
184 | }) => {
185 | const result = mutation.mutate({ ...params, ...options } as any);
186 | return result as Promise<{ data: T['data']; error: any; result: TResult }>;
187 | },
188 | );
189 |
190 | return [mutate, result] as [typeof mutate, typeof result];
191 | }
192 |
193 | function useRefQuery(query: T, queryClient: any) {
194 | const q = useRef>();
195 | if (!q.current) {
196 | (q.current as any) = query.create(undefined, queryClient.config.env);
197 | }
198 | return q.current!;
199 | }
200 |
201 | type UseVolatileQueryOptions> = QueryOptions & {
202 | endpoint?: (args: any) => Promise;
203 | };
204 |
205 | export function useVolatileQuery(
206 | options: UseVolatileQueryOptions> = {},
207 | ) {
208 | const queryClient = useContext(Context)! as QueryClient;
209 | const query = useRefQuery(VolatileQuery, queryClient);
210 |
211 | if (!query.__MstQueryHandler.options.endpoint) {
212 | query.__MstQueryHandler.options.endpoint = options.endpoint as any;
213 | }
214 |
215 | return useQuery(query, options);
216 | }
217 |
--------------------------------------------------------------------------------
/packages/mst-query/src/index.ts:
--------------------------------------------------------------------------------
1 | export { onMutate } from './MstQueryHandler';
2 | export { useQuery, useMutation, useInfiniteQuery, useVolatileQuery } from './hooks';
3 | export { createQuery, createMutation, createInfiniteQuery } from './create';
4 | export { createRootStore, createModelStore } from './stores';
5 | export { QueryClient } from './QueryClient';
6 | export type { EndpointType } from './QueryClient';
7 | export { createContext } from './QueryClientProvider';
8 |
--------------------------------------------------------------------------------
/packages/mst-query/src/merge.ts:
--------------------------------------------------------------------------------
1 | import {
2 | clone,
3 | getIdentifier,
4 | getRoot,
5 | getSnapshot,
6 | getType,
7 | isFrozenType,
8 | isMapType,
9 | isStateTreeNode,
10 | protect,
11 | unprotect,
12 | } from 'mobx-state-tree';
13 | import { getKey } from './QueryStore';
14 | import { getRealTypeFromObject, getSubType, isObject } from './utils';
15 |
16 | export function merge(data: any, typeDef: any, ctx: any, cloneInstances = false): any {
17 | if (!data) {
18 | return data;
19 | }
20 | const instanceOrSnapshot = mergeInner(data, typeDef, ctx, cloneInstances);
21 | return instanceOrSnapshot;
22 | }
23 |
24 | export function mergeInner(data: any, typeDef: any, ctx: any, cloneInstances = false): any {
25 | if (!data || data instanceof Date || typeof data !== 'object') {
26 | return data;
27 | }
28 | if (Array.isArray(data)) {
29 | return data.map((d) => mergeInner(d, getSubType(typeDef, d), ctx));
30 | }
31 |
32 | // convert values deeply first to MST objects as much as possible
33 | const snapshot: any = {};
34 | for (const key in data) {
35 | const realType = getRealTypeFromObject(typeDef, data, key);
36 | if (!realType) continue;
37 | snapshot[key] = mergeInner(data[key], realType, ctx);
38 | }
39 |
40 | if (isMapType(typeDef)) {
41 | return snapshot;
42 | }
43 |
44 | return mergeInstance(snapshot, data, typeDef, ctx, cloneInstances);
45 | }
46 |
47 | export function mergeInstance(snapshot: any, data: any, typeDef: any, ctx: any, cloneInstances: boolean) {
48 | // GQL object with known type, instantiate or recycle MST object
49 | // Try to reuse instance.
50 | const modelType = getSubType(typeDef);
51 | const id = data[modelType.identifierAttribute];
52 |
53 | let instance = id && ctx.queryClient.queryStore.models.get(getKey(modelType, id));
54 |
55 | instance = cloneInstances && instance ? clone(instance) : instance;
56 |
57 | if (instance) {
58 | // update existing object
59 | const root = getRoot(instance);
60 | unprotect(root);
61 | Object.assign(instance, mergeObjects(instance, snapshot, typeDef));
62 | protect(root);
63 | return instance;
64 | } else if (!instance) {
65 | // create a new one
66 | instance = modelType.create(snapshot, ctx);
67 | const storedId = isStateTreeNode(instance) ? getIdentifier(instance) : id;
68 | if (storedId) {
69 | ctx.queryClient.rootStore.__MstQueryAction('put', modelType, storedId, instance);
70 | ctx.queryClient.queryStore.models.set(getKey(modelType, storedId), instance);
71 | return instance;
72 | }
73 | }
74 | return snapshot;
75 | }
76 |
77 | export function mergeObjects(instance: any, data: any, typeDef: any): any {
78 | const snapshot: any = {};
79 | const properties = data && isStateTreeNode(data) ? (getType(data) as any).properties : data;
80 | for (const key in properties) {
81 | const realType = getRealTypeFromObject(typeDef, data, key);
82 | if (!realType) continue;
83 | if (
84 | !isFrozenType(realType) &&
85 | isObject(data[key]) &&
86 | !(data[key] instanceof Date) &&
87 | isObject(instance[key]) &&
88 | !getIdentifier(instance[key])
89 | ) {
90 | // Non-identifier object that's not frozen and is updating an existing instance
91 | const mergedValue = mergeObjects(instance[key], data[key], realType);
92 | const isNode = isStateTreeNode(data);
93 | isNode && unprotect(data);
94 | snapshot[key] = Object.assign(instance[key], mergedValue);
95 | isNode && protect(data);
96 | } else if (isStateTreeNode(data[key]) && !getIdentifier(data[key])) {
97 | // Non-identifier instance in the merged data needs to be converted
98 | // to a snapshot for merging with the instance later
99 | snapshot[key] = getSnapshot(data[key]);
100 | } else {
101 | snapshot[key] = data[key];
102 | }
103 | }
104 | return snapshot;
105 | }
106 |
--------------------------------------------------------------------------------
/packages/mst-query/src/stores.ts:
--------------------------------------------------------------------------------
1 | import {
2 | destroy,
3 | getEnv,
4 | getRoot,
5 | IAnyModelType,
6 | Instance,
7 | ModelPropertiesDeclaration,
8 | types,
9 | } from 'mobx-state-tree';
10 | import { merge } from './merge';
11 |
12 | type MstQueryAction = 'get' | 'put' | 'delete';
13 |
14 | export const createModelStore = (name: string, type: T) => {
15 | const modelStore = types
16 | .model(name, {
17 | models: types.map(type),
18 | })
19 | .actions((self) => {
20 | (self as any).$treenode.registerHook('afterCreate', () => {
21 | const root: any = getRoot(self);
22 | const modelStores = root.__getModelStores();
23 | if (!modelStores.get(type.name)) {
24 | modelStores.set(type.name, self);
25 | }
26 | });
27 | return {
28 | __MstQueryAction(action: MstQueryAction, id: string, instance: any) {
29 | switch (action) {
30 | case 'get':
31 | return self.models.get(id);
32 | case 'put':
33 | self.models.put(instance);
34 | break;
35 | case 'delete':
36 | self.models.delete(id);
37 | break;
38 | }
39 | },
40 | merge(data: any): Instance {
41 | return merge(data, type, getEnv(self));
42 | },
43 | };
44 | });
45 |
46 | return modelStore;
47 | };
48 |
49 | export const createRootStore = (props: T) => {
50 | return types.model('RootStore', props).extend((self) => {
51 | const queryClient = getEnv(self).queryClient as any;
52 | const modelStores = new Map();
53 |
54 | // nodes are lazily created on property access, we need to
55 | // loop over them to setup our model store map
56 | (self as any).$treenode.registerHook('afterCreate', () => {
57 | // overriding log to stop bundler from removing lookups
58 | const log = console.log;
59 | console.log = () => {};
60 | for (let key in self as any) {
61 | console.log(self[key]);
62 | }
63 | console.log = log;
64 | });
65 |
66 | return {
67 | actions: {
68 | __MstQueryAction(
69 | action: MstQueryAction,
70 | type: IAnyModelType,
71 | id: string,
72 | instance?: any
73 | ) {
74 | const store = modelStores.get(type.name);
75 | if (!store) {
76 | throw new Error(`Missing model store for type: ${type.name}`);
77 | }
78 |
79 | const result = store.__MstQueryAction(action, id, instance);
80 |
81 | if (action === 'delete') {
82 | destroy(instance);
83 | }
84 |
85 | return result;
86 | },
87 | runGc() {
88 | return queryClient.queryStore.runGc();
89 | },
90 | },
91 | views: {
92 | __getModelStores() {
93 | return modelStores;
94 | },
95 | getQueries>(
96 | query: T,
97 | matcherFn?: (query: InstanceType) => boolean
98 | ): InstanceType[] {
99 | return queryClient.queryStore.getQueries(query, matcherFn);
100 | },
101 | },
102 | };
103 | });
104 | };
105 |
--------------------------------------------------------------------------------
/packages/mst-query/src/utils.ts:
--------------------------------------------------------------------------------
1 | import {
2 | isUnionType,
3 | isModelType,
4 | isLateType,
5 | isOptionalType,
6 | isArrayType,
7 | isReferenceType,
8 | isFrozenType,
9 | } from 'mobx-state-tree';
10 | import { isObservableArray } from 'mobx';
11 | import * as React from 'react';
12 |
13 | export function getRealTypeFromObject(typeDef: any, data: any, key: any) {
14 | const modelOrBaseType = getSubType(typeDef, data[key]);
15 | if (modelOrBaseType && modelOrBaseType.properties && !modelOrBaseType.properties[key]) {
16 | return null;
17 | }
18 | const subType =
19 | modelOrBaseType.properties && !isFrozenType(modelOrBaseType)
20 | ? getSubType((modelOrBaseType as any).properties[key], data[key])
21 | : modelOrBaseType;
22 | return subType;
23 | }
24 |
25 | export function getSubType(t: any, data?: any): any {
26 | if (isUnionType(t)) {
27 | const actualType = t.determineType && data !== undefined && t.determineType(data);
28 | if (actualType) {
29 | return actualType;
30 | }
31 | if (!t.determineType) {
32 | return getSubType(t._subtype);
33 | }
34 | const subTypes = t._types.map((t: any) => getSubType(t, data));
35 | // Every subtype is a model type or reference type - return the union type and let mst
36 | // handle reconciling the types.
37 | if (subTypes.every((x: any) => isModelType(x) || isReferenceType(x))) {
38 | return t;
39 | }
40 | // If we have a union of models and primitives (null/undefined), we need to find the first model or reference type
41 | // to enumerate the properties of the object.
42 | const modelWithProperties = subTypes.find((x: any) => isModelType(x) || isReferenceType(x));
43 | if (modelWithProperties) {
44 | return getSubType(modelWithProperties, data);
45 | }
46 | return t;
47 | } else if (isLateType(t)) {
48 | return getSubType((t as any).getSubType(), data);
49 | } else if (isOptionalType(t)) {
50 | return getSubType((t as any)._subtype, data);
51 | } else if (isArrayType(t)) {
52 | return getSubType((t as any)._subType, data);
53 | } else if (isReferenceType(t)) {
54 | return getSubType((t as any).targetType, data);
55 | } else {
56 | return t;
57 | }
58 | }
59 |
60 | function isArray(a: any) {
61 | return Array.isArray(a) || isObservableArray(a);
62 | }
63 |
64 | export function isObject(a: any) {
65 | return a && typeof a === 'object' && !isArray(a);
66 | }
67 |
68 | // From https://github.com/scottrippey/react-use-event-hook
69 |
70 | type AnyFunction = (...args: any[]) => any;
71 |
72 | /**
73 | * Suppress the warning when using useLayoutEffect with SSR. (https://reactjs.org/link/uselayouteffect-ssr)
74 | * Make use of useInsertionEffect if available.
75 | */
76 | const useInsertionEffect =
77 | typeof window !== 'undefined'
78 | ? // useInsertionEffect is available in React 18+
79 | React.useInsertionEffect || React.useLayoutEffect
80 | : () => {};
81 |
82 | /**
83 | * Similar to useCallback, with a few subtle differences:
84 | * - The returned function is a stable reference, and will always be the same between renders
85 | * - No dependency lists required
86 | * - Properties or state accessed within the callback will always be "current"
87 | */
88 | export function useEvent(callback: TCallback): TCallback {
89 | // Keep track of the latest callback:
90 | const latestRef = React.useRef(useEvent_shouldNotBeInvokedBeforeMount as any);
91 | useInsertionEffect(() => {
92 | latestRef.current = callback;
93 | }, [callback]);
94 |
95 | // Create a stable callback that always calls the latest callback:
96 | // using useRef instead of useCallback avoids creating and empty array on every render
97 | const stableRef = React.useRef(null as any);
98 | if (!stableRef.current) {
99 | stableRef.current = function (this: any) {
100 | return latestRef.current.apply(this, arguments as any);
101 | } as TCallback;
102 | }
103 |
104 | return stableRef.current;
105 | }
106 |
107 | /**
108 | * Render methods should be pure, especially when concurrency is used,
109 | * so we will throw this error if the callback is called while rendering.
110 | */
111 | function useEvent_shouldNotBeInvokedBeforeMount() {
112 | throw new Error(
113 | 'INVALID_USEEVENT_INVOCATION: the callback from useEvent cannot be invoked before the component has mounted.'
114 | );
115 | }
116 |
--------------------------------------------------------------------------------
/packages/mst-query/tests/api/api.ts:
--------------------------------------------------------------------------------
1 | import { itemData, moreListData, listData } from './data';
2 |
3 | export const api = {
4 | async getItem({ request }: any) {
5 | if (request.id === 'different-test') {
6 | return {
7 | ...itemData,
8 | id: 'different-test',
9 | };
10 | }
11 | return itemData;
12 | },
13 | async getItems({ pagination }: any = {}) {
14 | const { offset = 0 } = pagination ?? {};
15 | if (offset !== 0) {
16 | return moreListData;
17 | }
18 | return listData;
19 | },
20 | async setDescription({ request }: any) {
21 | const { description } = request;
22 | return {
23 | ...itemData,
24 | description,
25 | };
26 | },
27 | async addItem() {
28 | return {
29 | ...itemData,
30 | id: 'add-test',
31 | description: 'add',
32 | };
33 | },
34 | async removeItem({ request }: any) {
35 | return request.id;
36 | }
37 | };
38 |
--------------------------------------------------------------------------------
/packages/mst-query/tests/api/data.ts:
--------------------------------------------------------------------------------
1 | export const dataModel = {
2 | name: 'test',
3 | };
4 |
5 | export const itemData = {
6 | id: 'test',
7 | description: 'Test item',
8 | created: new Date('2020-01-01'),
9 | count: 4,
10 | createdBy: {
11 | id: 'ko',
12 | name: 'Kim',
13 | age: 35,
14 | },
15 | data: dataModel,
16 | nested: {
17 | by: {
18 | id: 'ko',
19 | name: 'Kim',
20 | age: 35,
21 | }
22 | }
23 | };
24 |
25 | export const listData = {
26 | id: 'list-1',
27 | items: [
28 | {
29 | id: 'test',
30 | description: 'Test item',
31 | created: new Date('2020-01-01'),
32 | count: 4,
33 | createdBy: {
34 | id: 'ko',
35 | name: 'Kim',
36 | age: 35,
37 | },
38 | },
39 | {
40 | id: 'test2',
41 | description: 'Test item2',
42 | created: new Date('2020-01-01'),
43 | count: 6,
44 | createdBy: {
45 | id: 'aa',
46 | name: 'Alice',
47 | age: 43,
48 | },
49 | },
50 | {
51 | id: 'test3',
52 | description: 'Test item3',
53 | created: new Date('2020-01-01'),
54 | count: 2,
55 | createdBy: {
56 | id: 'bb',
57 | name: 'Bob',
58 | age: 23,
59 | },
60 | },
61 | {
62 | id: 'test4',
63 | description: 'Test item4',
64 | created: new Date('2020-01-01'),
65 | count: 3,
66 | createdBy: {
67 | id: 'cc',
68 | name: 'Calle',
69 | age: 29,
70 | },
71 | },
72 | ],
73 | };
74 |
75 | export const moreListData = {
76 | id: 'list-1',
77 | items: [
78 | {
79 | id: 'test5',
80 | description: 'Test item5',
81 | created: new Date('2020-01-01'),
82 | count: 1,
83 | createdBy: {
84 | id: 'ko',
85 | name: 'Kim',
86 | age: 35,
87 | },
88 | },
89 | {
90 | id: 'test6',
91 | description: 'Test item6',
92 | created: new Date('2020-01-01'),
93 | count: 2,
94 | createdBy: {
95 | id: 'aa',
96 | name: 'Alice',
97 | age: 43,
98 | },
99 | },
100 | {
101 | id: 'test7',
102 | description: 'Test item7',
103 | created: new Date('2020-01-01'),
104 | count: 4,
105 | createdBy: {
106 | id: 'bb',
107 | name: 'Bob',
108 | age: 23,
109 | },
110 | },
111 | ],
112 | };
113 |
--------------------------------------------------------------------------------
/packages/mst-query/tests/models/AddItemMutation.ts:
--------------------------------------------------------------------------------
1 | import { types } from 'mobx-state-tree';
2 | import { createMutation } from '../../src';
3 | import { ItemModel } from './ItemModel';
4 | import { api } from '../api/api';
5 |
6 | export const AddItemMutation = createMutation('AddMutation', {
7 | data: types.reference(ItemModel),
8 | request: types.model({ path: types.string, message: types.string }),
9 | endpoint: api.addItem
10 | });
11 |
--------------------------------------------------------------------------------
/packages/mst-query/tests/models/ArrayQuery.ts:
--------------------------------------------------------------------------------
1 | import { types } from "mobx-state-tree";
2 | import { createQuery } from "../../src";
3 | import { api } from "../api/api";
4 | import { ItemModel } from "./ItemModel";
5 |
6 | export const ArrayQuery = createQuery('ListQuery', {
7 | data: types.array(types.reference(ItemModel)),
8 | async endpoint(args) {
9 | const result = await api.getItems(args);
10 | return result.items;
11 | },
12 | });
13 |
--------------------------------------------------------------------------------
/packages/mst-query/tests/models/ErrorMutation.ts:
--------------------------------------------------------------------------------
1 | import { createMutation } from "../../src";
2 |
3 | export const ErrorMutation = createMutation('ErrorMutation', {
4 | async endpoint() {
5 | throw new Error('Server side error');
6 | }
7 | });
--------------------------------------------------------------------------------
/packages/mst-query/tests/models/ItemModel.ts:
--------------------------------------------------------------------------------
1 | import { types } from 'mobx-state-tree';
2 | import { UserModel } from './UserModel';
3 |
4 | export const DataModel = types
5 | .model('DataModel', {
6 | name: types.string,
7 | })
8 | .volatile((self) => ({
9 | newName: '',
10 | }));
11 |
12 | export const ItemModel = types.model('ItemModel', {
13 | id: types.identifier,
14 | description: types.string,
15 | created: types.Date,
16 | count: types.number,
17 | createdBy: types.reference(UserModel),
18 | data: types.maybe(DataModel),
19 | nested: types.maybe(
20 | types.model({
21 | by: types.reference(UserModel),
22 | })
23 | ),
24 | }).actions(self => ({
25 | setDescription(description: string) {
26 | self.description = description;
27 | }
28 | }))
29 |
--------------------------------------------------------------------------------
/packages/mst-query/tests/models/ItemQuery.ts:
--------------------------------------------------------------------------------
1 | import { types } from 'mobx-state-tree';
2 | import { createQuery } from '../../src';
3 | import { api } from '../api/api';
4 | import { ItemModel } from './ItemModel';
5 |
6 | export const ItemQuery = createQuery('ItemQuery', {
7 | request: types.model({ id: types.string, id2: types.maybe(types.string) }),
8 | data: types.reference(ItemModel),
9 | async endpoint(args) {
10 | return args.meta.getItem ? args.meta.getItem(args) : api.getItem(args);
11 | },
12 | });
13 |
14 | const onUpdate = (url: string, callback: any) => (data: any) => callback(data);
15 |
16 | export const SubscriptionItemQuery = createQuery('SubscriptionItemQuery', {
17 | data: types.reference(ItemModel),
18 | request: types.model({ id: types.string }),
19 | async endpoint({ request, setData, meta }) {
20 | meta.updater = onUpdate(`item/${request.id}`, (data: any) => {
21 | setData(data);
22 | });
23 | },
24 | });
25 |
--------------------------------------------------------------------------------
/packages/mst-query/tests/models/ItemQueryWithOptionalRequest.ts:
--------------------------------------------------------------------------------
1 | import { types } from "mobx-state-tree";
2 | import { createQuery } from "../../src/create";
3 | import { api } from "../api/api";
4 | import { ItemModel } from "./ItemModel";
5 |
6 | export const ItemQueryWithOptionalRequest = createQuery('ItemQuery', {
7 | request: types.model({ id: types.string, filter: types.maybeNull(types.string) }),
8 | data: types.reference(ItemModel),
9 | async endpoint(args) {
10 | return args.meta.getItem ? args.meta.getItem(args) : api.getItem(args);
11 | },
12 | });
13 |
--------------------------------------------------------------------------------
/packages/mst-query/tests/models/ListModel.ts:
--------------------------------------------------------------------------------
1 | import { types, getSnapshot, getRoot } from 'mobx-state-tree';
2 | import { ItemModel } from './ItemModel';
3 |
4 | export const ListModel = types
5 | .model('ListModel', {
6 | id: types.optional(types.identifier, () => 'optional-1'),
7 | items: types.array(types.reference(ItemModel)),
8 | })
9 | .actions((self) => ({
10 | addItems(items: any) {
11 | self.items.push(...getSnapshot(items) as any);
12 | },
13 | removeItem(item: any) {
14 | self.items.remove(item);
15 | },
16 | addItem(item: any) {
17 | self.items.push(item);
18 | },
19 | }));
20 |
--------------------------------------------------------------------------------
/packages/mst-query/tests/models/ListQuery.ts:
--------------------------------------------------------------------------------
1 | import { types } from 'mobx-state-tree';
2 | import { api } from '../api/api';
3 | import { ListModel } from './ListModel';
4 | import { createInfiniteQuery } from '../../src/create';
5 |
6 | export const ListQuery = createInfiniteQuery('ListQuery', {
7 | data: types.reference(ListModel),
8 | pagination: types.optional(types.model({ offset: 0 }), {}),
9 | async endpoint(args) {
10 | return args.meta.getItems ? args.meta.getItems(args) : api.getItems(args);
11 | },
12 | onQueryMore({ data, query }) {
13 | query.data?.addItems(data?.items);
14 | },
15 | });
16 |
--------------------------------------------------------------------------------
/packages/mst-query/tests/models/RemoveItemMutation.ts:
--------------------------------------------------------------------------------
1 | import { types } from 'mobx-state-tree';
2 | import { createMutation } from '../../src';
3 | import { ItemModel } from './ItemModel';
4 | import { api } from '../api/api';
5 |
6 | export const RemoveItemMutation = createMutation('RemoveMutation', {
7 | data: types.reference(ItemModel),
8 | request: types.model({ id: types.string }),
9 | endpoint: api.removeItem
10 | });
11 |
--------------------------------------------------------------------------------
/packages/mst-query/tests/models/RootStore.ts:
--------------------------------------------------------------------------------
1 | import { destroy, IAnyModelType, Instance, types } from 'mobx-state-tree';
2 | import { createQuery } from '../../src/create';
3 | import { onMutate } from '../../src/MstQueryHandler';
4 | import { createModelStore, createRootStore } from '../../src/stores';
5 | import { AddItemMutation } from './AddItemMutation';
6 | import { ItemModel } from './ItemModel';
7 | import { ItemQuery, SubscriptionItemQuery } from './ItemQuery';
8 | import { ItemQueryWithOptionalRequest } from './ItemQueryWithOptionalRequest';
9 | import { ListModel } from './ListModel';
10 | import { ListQuery } from './ListQuery';
11 | import { SetDescriptionMutation } from './SetDescriptionMutation';
12 | import { UserModel } from './UserModel';
13 | import { ArrayQuery } from './ArrayQuery';
14 | import { SafeReferenceQuery } from './SafeReferenceQuery';
15 | import { RemoveItemMutation } from './RemoveItemMutation';
16 | import { ErrorMutation } from './ErrorMutation';
17 | import { FixedModel, FormatModel } from './UnionModel';
18 |
19 | export const DateModel = types.model('DateModel', {
20 | id: types.identifier,
21 | changed: types.model({
22 | at: types.Date,
23 | }),
24 | });
25 |
26 | const DeepModelC = types.model('DeepModelC', {
27 | id: types.identifier,
28 | a: types.maybe(types.string),
29 | });
30 | const DeepModelB = types.model('DeepModelB', {
31 | a: types.maybe(types.string),
32 | b: types.maybe(types.string),
33 | });
34 |
35 | export const DeepModelA = types.model('DeepModelA', {
36 | model: types.maybe(DeepModelB),
37 | ref: types.maybe(types.reference(DeepModelC)),
38 | });
39 |
40 | const AmountTag = {
41 | Limited: 'Limited',
42 | Unlimited: 'Unlimited',
43 | };
44 |
45 | const AmountLimitModel = types.model('AmountLimit').props({
46 | tag: types.maybe(types.enumeration(Object.values(AmountTag))),
47 | content: types.maybeNull(
48 | types.map(
49 | types.model({
50 | tag: types.enumeration(Object.values(AmountTag)),
51 | content: types.maybeNull(types.string),
52 | }),
53 | ),
54 | ),
55 | });
56 |
57 | const TestModel = types.model({
58 | id: types.string,
59 | frozen: types.frozen(),
60 | prop: types.maybeNull(
61 | types.model({
62 | ids: types.array(types.model({ baha: types.string })),
63 | }),
64 | ),
65 | folderPath: types.maybe(types.string),
66 | origin: types.union(types.string, types.undefined),
67 | amountLimit: types.maybe(AmountLimitModel),
68 | });
69 |
70 | const optional = (model: T) => types.optional(model, {});
71 |
72 | const ItemServiceStore = types
73 | .model({
74 | itemQuery: optional(ItemQuery),
75 | itemQuery2: optional(ItemQuery),
76 | addItemMutation: optional(AddItemMutation),
77 | removeItemMutation: optional(RemoveItemMutation),
78 | setDescriptionMutation: optional(SetDescriptionMutation),
79 | listQuery: optional(ListQuery),
80 | arrayQuery: optional(ArrayQuery),
81 | safeReferenceQuery: optional(SafeReferenceQuery),
82 | subscriptionQuery: optional(SubscriptionItemQuery),
83 | itemQueryWihthOptionalRequest: optional(ItemQueryWithOptionalRequest),
84 | errorMutation: optional(ErrorMutation),
85 | })
86 | .actions((self) => ({
87 | afterCreate() {
88 | onMutate(self.addItemMutation, (data) => {
89 | self.listQuery.data?.addItem(data);
90 | });
91 | onMutate(self.removeItemMutation, (data) => {
92 | destroy(data);
93 | });
94 | },
95 | }));
96 |
97 | const ServiceStore = types.model({
98 | itemServiceStore: optional(ItemServiceStore),
99 | frozenQuery: optional(createQuery('FrozenQuery', { data: TestModel })),
100 | deepModelA: types.maybe(DeepModelA),
101 | });
102 |
103 | export const Root = createRootStore({
104 | itemStore: optional(createModelStore('ItemStore', ItemModel)),
105 | userStore: optional(createModelStore('UserStore', UserModel)),
106 | listStore: optional(createModelStore('ListStore', ListModel)),
107 | dateStore: optional(createModelStore('DateStore', DateModel)),
108 | deepModelCStore: optional(createModelStore('DeepModelCStore', DeepModelC)),
109 | fixedModelStore: optional(createModelStore('FixedModelStore', FixedModel)),
110 | formatModelStore: optional(createModelStore('FixedModelStore', FormatModel)),
111 | serviceStore: optional(ServiceStore),
112 | });
113 |
114 | export interface RootStoreType extends Instance {}
115 |
--------------------------------------------------------------------------------
/packages/mst-query/tests/models/SafeReferenceQuery.ts:
--------------------------------------------------------------------------------
1 | import { types } from 'mobx-state-tree';
2 | import { createQuery } from '../../src';
3 | import { api } from '../api/api';
4 | import { ItemModel } from './ItemModel';
5 |
6 | export const SafeReferenceQuery = createQuery('ListQuery', {
7 | data: types.model({
8 | items: types.array(
9 | types.safeReference(ItemModel, {
10 | acceptsUndefined: false,
11 | })
12 | ),
13 | }),
14 | async endpoint(args) {
15 | const result = await api.getItems(args);
16 | return { items: result.items };
17 | },
18 | });
19 |
--------------------------------------------------------------------------------
/packages/mst-query/tests/models/SetDescriptionMutation.ts:
--------------------------------------------------------------------------------
1 | import { types } from 'mobx-state-tree';
2 | import { createMutation } from '../../src';
3 | import { api } from '../api/api';
4 | import { ItemModel } from './ItemModel';
5 |
6 | export const SetDescriptionMutation = createMutation('SetDescriptionMutation', {
7 | data: types.reference(ItemModel),
8 | request: types.model({ id: types.string, description: types.string }),
9 | endpoint: api.setDescription
10 | });
11 |
--------------------------------------------------------------------------------
/packages/mst-query/tests/models/UnionModel.ts:
--------------------------------------------------------------------------------
1 | import { types } from 'mobx-state-tree';
2 |
3 | export const FixedModel = types.model('FixedModel', {
4 | id: types.identifier,
5 | kind: types.literal('FIXED'),
6 | fixedValue: types.string,
7 | });
8 |
9 | export const FormatModel = types.model('FormatModel', {
10 | id: types.identifier,
11 | kind: types.literal('FORMAT'),
12 | formatValue: types.string,
13 | });
14 |
15 | export const UnionModel = types.union(FixedModel, FormatModel);
16 |
--------------------------------------------------------------------------------
/packages/mst-query/tests/models/UserModel.ts:
--------------------------------------------------------------------------------
1 | import { types } from 'mobx-state-tree';
2 |
3 | export const UserModel = types.model('UserModel', {
4 | id: types.identifier,
5 | name: types.string,
6 | age: types.number,
7 | });
8 |
--------------------------------------------------------------------------------
/packages/mst-query/tests/utils/index.ts:
--------------------------------------------------------------------------------
1 | export * from './utils';
2 |
--------------------------------------------------------------------------------
/packages/mst-query/tests/utils/utils.ts:
--------------------------------------------------------------------------------
1 | export const wait = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms));
2 |
--------------------------------------------------------------------------------
/packages/mst-query/tsconfig.build.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "module": "ESNext",
5 | "jsx": "react",
6 | "lib": ["ESNext", "dom"],
7 | "declaration": true,
8 | "skipLibCheck": true,
9 | "strict": true,
10 | "noImplicitAny": true,
11 | "alwaysStrict": true,
12 | "moduleResolution": "node",
13 | "experimentalDecorators": true,
14 | "downlevelIteration": true
15 | },
16 | "include": ["./src"]
17 | }
18 |
--------------------------------------------------------------------------------
/packages/mst-query/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2022",
4 | "module": "ES2022",
5 | "jsx": "react",
6 | "lib": ["ESNext", "dom"],
7 | "declaration": true,
8 | "skipLibCheck": true,
9 | "strict": true,
10 | "noImplicitAny": true,
11 | "alwaysStrict": true,
12 | "moduleResolution": "Bundler",
13 | "experimentalDecorators": true,
14 | "downlevelIteration": true,
15 | "esModuleInterop": true,
16 | "allowJs": true,
17 | },
18 | "include": ["./src", "./tests"]
19 | }
20 |
--------------------------------------------------------------------------------
/packages/mst-query/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vitest/config'
2 |
3 | export default defineConfig({
4 | test: {
5 | environment: 'jsdom'
6 | },
7 | })
--------------------------------------------------------------------------------
/prettier.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | singleQuote: true,
3 | printWidth: 100,
4 | tabWidth: 4,
5 | jsxBracketSameLine: true,
6 | };
7 |
--------------------------------------------------------------------------------