diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..ecdf105f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,44 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). + +## [Unreleased] + +### Added + +- **Saved Queries Feature**: Users can now save, edit, and delete queries directly from the UI + + - New "Saved" dropdown button in the editor panel for quick access to saved queries + - Queries are stored in a SQLite database for persistence + - Support for organizing queries by category + - Queries can be marked as either "query" or "mutate" type + - Full CRUD operations via REST API (`/api/saved-queries`) + +- **New Configuration Options**: + + - `--queries-db` flag to specify the SQLite database path + - `RATEL_QUERIES_DB` environment variable as alternative configuration + - Default database location: `/ratel/queries.db` (falls back to temp dir) + +- **Save Query Modal**: New modal dialog for saving queries with: + - Name (required) + - Description (optional) + - Category (defaults to "General") + - Action type (query or mutate) + +### Changed + +- Replaced YAML-based preloaded queries with SQLite-based saved queries +- Queries are now user-editable through the UI instead of requiring file changes + +### Removed + +- YAML preloaded queries support (`--preloaded-queries` flag and `RATEL_PRELOADED_QUERIES` env var) +- `preloaded-queries.example.yaml` file + +## Previous Changes + +For changes prior to this changelog, please refer to the +[commit history](https://github.com/dgraph-io/ratel/commits/main). diff --git a/INSTRUCTIONS.md b/INSTRUCTIONS.md index f6888507..3fdf78cf 100644 --- a/INSTRUCTIONS.md +++ b/INSTRUCTIONS.md @@ -104,6 +104,49 @@ make test ./scripts/build.prod.sh --version 20.04.1 ``` +## Saved Queries + +Ratel supports saving queries to a SQLite database for persistence and sharing. Queries can be +saved, edited, and deleted directly from the UI using the "Saved" dropdown button. + +### Configuration + +The saved queries database can be configured using either: + +- **Command-line flag**: `--queries-db /path/to/queries.db` +- **Environment variable**: `RATEL_QUERIES_DB=/path/to/queries.db` + +The flag takes precedence over the environment variable. If neither is specified, the database is +created in the per-user config directory (e.g. `~/.config/ratel/queries.db` on Linux, +`~/Library/Application Support/ratel/queries.db` on macOS), falling back to the system temp +directory only if that location is unavailable. + +### Docker Usage + +To persist saved queries when using Docker, mount a volume for the database file: + +```yaml +dgraph-ratel: + image: dgraph/ratel:latest + ports: + - "8000:8000" + environment: + - RATEL_QUERIES_DB=/data/queries.db + volumes: + - ./ratel-data:/data +``` + +### API Endpoints + +The saved queries feature exposes the following REST API: + +| Method | Endpoint | Description | +| ------ | ------------------------ | ------------------------ | +| GET | `/api/saved-queries` | List all saved queries | +| POST | `/api/saved-queries` | Create a new query | +| PUT | `/api/saved-queries/:id` | Update an existing query | +| DELETE | `/api/saved-queries/:id` | Delete a query | + ## Serving over HTTPS By default Ratel will serve the UI over HTTP. You can switch to serve the UI with **only** HTTPS by diff --git a/client/package-lock.json b/client/package-lock.json index cd72e49e..79f89b6c 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@babel/node": "^7.20.2", "@fortawesome/fontawesome-free": "^6.2.0", - "bootstrap": "^5.0.0", + "bootstrap": "4.6.2", "browserslist": "^4.21.4", "classnames": "^2.3.2", "codemirror": "^5.65.9", @@ -7555,15 +7555,24 @@ "dev": true }, "node_modules/bootstrap": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.0.0.tgz", - "integrity": "sha512-tmhPET9B9qCl8dCofvHeiIhi49iBt0EehmIsziZib65k1erBW1rHhj2s/2JsuQh5Pq+xz2E9bEbzp9B7xHG+VA==", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/bootstrap" - }, + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-4.6.2.tgz", + "integrity": "sha512-51Bbp/Uxr9aTuy6ca/8FbFloBUJZLHwnhTcnjIeRn2suQWsWzcuJhGjKDB5eppVte/8oCdOL3VuwxvZDUggwGQ==", + "deprecated": "This version of Bootstrap is no longer supported. Please upgrade to the latest version.", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/twbs" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/bootstrap" + } + ], + "license": "MIT", "peerDependencies": { - "@popperjs/core": "^2.9.2" + "jquery": "1.9.1 - 3", + "popper.js": "^1.16.1" } }, "node_modules/brace-expansion": { diff --git a/client/src/actions/savedQueries.js b/client/src/actions/savedQueries.js new file mode 100644 index 00000000..8504e859 --- /dev/null +++ b/client/src/actions/savedQueries.js @@ -0,0 +1,222 @@ +/* + * SPDX-FileCopyrightText: © Hypermode Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { updateAction, updateQuery } from 'actions/query' + +// Fetch actions +export const FETCH_SAVED_QUERIES_START = 'savedQueries/FETCH_START' +export const FETCH_SAVED_QUERIES_SUCCESS = 'savedQueries/FETCH_SUCCESS' +export const FETCH_SAVED_QUERIES_ERROR = 'savedQueries/FETCH_ERROR' + +// CRUD actions +export const CREATE_QUERY_START = 'savedQueries/CREATE_START' +export const CREATE_QUERY_SUCCESS = 'savedQueries/CREATE_SUCCESS' +export const CREATE_QUERY_ERROR = 'savedQueries/CREATE_ERROR' + +export const UPDATE_QUERY_START = 'savedQueries/UPDATE_START' +export const UPDATE_QUERY_SUCCESS = 'savedQueries/UPDATE_SUCCESS' +export const UPDATE_QUERY_ERROR = 'savedQueries/UPDATE_ERROR' + +export const DELETE_QUERY_START = 'savedQueries/DELETE_START' +export const DELETE_QUERY_SUCCESS = 'savedQueries/DELETE_SUCCESS' +export const DELETE_QUERY_ERROR = 'savedQueries/DELETE_ERROR' + +// UI actions +export const OPEN_SAVE_MODAL = 'savedQueries/OPEN_SAVE_MODAL' +export const CLOSE_SAVE_MODAL = 'savedQueries/CLOSE_SAVE_MODAL' +export const UPDATE_SAVE_FORM = 'savedQueries/UPDATE_SAVE_FORM' + +// Fetch all saved queries +export function fetchSavedQueries() { + return async (dispatch) => { + dispatch({ type: FETCH_SAVED_QUERIES_START }) + + try { + const response = await fetch('/api/saved-queries') + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + const data = await response.json() + + dispatch({ + type: FETCH_SAVED_QUERIES_SUCCESS, + payload: data, + }) + } catch (error) { + dispatch({ + type: FETCH_SAVED_QUERIES_ERROR, + error: error.message, + }) + } + } +} + +// Create a new saved query +export function createSavedQuery(queryData) { + return async (dispatch) => { + dispatch({ type: CREATE_QUERY_START }) + + try { + const response = await fetch('/api/saved-queries', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(queryData), + }) + + if (!response.ok) { + const data = await response.json() + throw new Error(data.error || `HTTP ${response.status}`) + } + + const query = await response.json() + + dispatch({ + type: CREATE_QUERY_SUCCESS, + query, + }) + + dispatch({ type: CLOSE_SAVE_MODAL }) + + return query + } catch (error) { + dispatch({ + type: CREATE_QUERY_ERROR, + error: error.message, + }) + throw error + } + } +} + +// Update an existing saved query +export function updateSavedQuery(id, queryData) { + return async (dispatch) => { + dispatch({ type: UPDATE_QUERY_START }) + + try { + const response = await fetch(`/api/saved-queries/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(queryData), + }) + + if (!response.ok) { + const data = await response.json() + throw new Error(data.error || `HTTP ${response.status}`) + } + + const query = await response.json() + + dispatch({ + type: UPDATE_QUERY_SUCCESS, + query, + }) + + dispatch({ type: CLOSE_SAVE_MODAL }) + + return query + } catch (error) { + dispatch({ + type: UPDATE_QUERY_ERROR, + error: error.message, + }) + throw error + } + } +} + +// Delete a saved query +export function deleteSavedQuery(id) { + return async (dispatch) => { + dispatch({ type: DELETE_QUERY_START, id }) + + try { + const response = await fetch(`/api/saved-queries/${id}`, { + method: 'DELETE', + }) + + if (!response.ok && response.status !== 204) { + const data = await response.json() + throw new Error(data.error || `HTTP ${response.status}`) + } + + dispatch({ + type: DELETE_QUERY_SUCCESS, + id, + }) + } catch (error) { + dispatch({ + type: DELETE_QUERY_ERROR, + error: error.message, + }) + throw error + } + } +} + +// Select a saved query (load into editor without running) +export function selectSavedQuery(query) { + return (dispatch) => { + // Load query into editor + dispatch(updateQuery(query.query)) + dispatch(updateAction(query.action)) + } +} + +// Open save modal (for new query or editing existing) +export function openSaveModal(editingQuery = null) { + return (dispatch, getState) => { + const { query } = getState() + + dispatch({ + type: OPEN_SAVE_MODAL, + editingQuery, + // Pre-fill form with current editor content if new, or existing query data if editing + formData: editingQuery + ? { + name: editingQuery.name, + description: editingQuery.description || '', + category: editingQuery.category || 'General', + action: editingQuery.action || 'query', + query: editingQuery.query, + } + : { + name: '', + description: '', + category: 'General', + action: query.action || 'query', + query: query.query || '', + }, + }) + } +} + +// Close save modal +export function closeSaveModal() { + return { type: CLOSE_SAVE_MODAL } +} + +// Update form field in save modal +export function updateSaveForm(field, value) { + return { + type: UPDATE_SAVE_FORM, + field, + value, + } +} + +// Save current query (create or update) +export function saveCurrentQuery() { + return (dispatch, getState) => { + const { savedQueries } = getState() + const { editingQuery, saveForm } = savedQueries + + if (editingQuery) { + return dispatch(updateSavedQuery(editingQuery.id, saveForm)) + } else { + return dispatch(createSavedQuery(saveForm)) + } + } +} diff --git a/client/src/actions/savedQueries.test.js b/client/src/actions/savedQueries.test.js new file mode 100644 index 00000000..1adcca45 --- /dev/null +++ b/client/src/actions/savedQueries.test.js @@ -0,0 +1,302 @@ +/* + * SPDX-FileCopyrightText: © Hypermode Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import configureMockStore from 'redux-mock-store' +import thunk from 'redux-thunk' + +import { + FETCH_SAVED_QUERIES_START, + FETCH_SAVED_QUERIES_SUCCESS, + FETCH_SAVED_QUERIES_ERROR, + CREATE_QUERY_START, + CREATE_QUERY_SUCCESS, + CREATE_QUERY_ERROR, + UPDATE_QUERY_START, + UPDATE_QUERY_SUCCESS, + UPDATE_QUERY_ERROR, + DELETE_QUERY_START, + DELETE_QUERY_SUCCESS, + DELETE_QUERY_ERROR, + OPEN_SAVE_MODAL, + CLOSE_SAVE_MODAL, + UPDATE_SAVE_FORM, + fetchSavedQueries, + createSavedQuery, + updateSavedQuery, + deleteSavedQuery, + selectSavedQuery, + openSaveModal, + closeSaveModal, + updateSaveForm, + saveCurrentQuery, +} from './savedQueries' + +const middlewares = [thunk] +const mockStore = configureMockStore(middlewares) + +// Mock fetch globally +global.fetch = jest.fn() + +describe('savedQueries actions', () => { + beforeEach(() => { + fetch.mockClear() + }) + + describe('fetchSavedQueries', () => { + it('dispatches FETCH_SUCCESS on successful fetch', async () => { + const mockQueries = [ + { id: 1, name: 'Query 1', category: 'Test', action: 'query', query: '{ q1 }' }, + ] + fetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ enabled: true, queries: mockQueries }), + }) + + const store = mockStore({}) + await store.dispatch(fetchSavedQueries()) + + const actions = store.getActions() + expect(actions[0]).toEqual({ type: FETCH_SAVED_QUERIES_START }) + expect(actions[1]).toEqual({ + type: FETCH_SAVED_QUERIES_SUCCESS, + payload: { enabled: true, queries: mockQueries }, + }) + }) + + it('dispatches FETCH_ERROR on failed fetch', async () => { + fetch.mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + }) + + const store = mockStore({}) + await store.dispatch(fetchSavedQueries()) + + const actions = store.getActions() + expect(actions[0]).toEqual({ type: FETCH_SAVED_QUERIES_START }) + expect(actions[1].type).toBe(FETCH_SAVED_QUERIES_ERROR) + expect(actions[1].error).toContain('500') + }) + + it('dispatches FETCH_ERROR on network error', async () => { + fetch.mockRejectedValueOnce(new Error('Network error')) + + const store = mockStore({}) + await store.dispatch(fetchSavedQueries()) + + const actions = store.getActions() + expect(actions[0]).toEqual({ type: FETCH_SAVED_QUERIES_START }) + expect(actions[1]).toEqual({ + type: FETCH_SAVED_QUERIES_ERROR, + error: 'Network error', + }) + }) + }) + + describe('createSavedQuery', () => { + it('dispatches CREATE_SUCCESS and CLOSE_SAVE_MODAL on success', async () => { + const queryData = { name: 'New Query', query: '{ new }' } + const createdQuery = { id: 1, ...queryData } + fetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(createdQuery), + }) + + const store = mockStore({}) + await store.dispatch(createSavedQuery(queryData)) + + const actions = store.getActions() + expect(actions[0]).toEqual({ type: CREATE_QUERY_START }) + expect(actions[1]).toEqual({ type: CREATE_QUERY_SUCCESS, query: createdQuery }) + expect(actions[2]).toEqual({ type: CLOSE_SAVE_MODAL }) + }) + + it('dispatches CREATE_ERROR on failure', async () => { + fetch.mockResolvedValueOnce({ + ok: false, + json: () => Promise.resolve({ error: 'Name is required' }), + }) + + const store = mockStore({}) + await expect(store.dispatch(createSavedQuery({}))).rejects.toThrow() + + const actions = store.getActions() + expect(actions[0]).toEqual({ type: CREATE_QUERY_START }) + expect(actions[1].type).toBe(CREATE_QUERY_ERROR) + }) + }) + + describe('updateSavedQuery', () => { + it('dispatches UPDATE_SUCCESS and CLOSE_SAVE_MODAL on success', async () => { + const queryData = { name: 'Updated Query', query: '{ updated }' } + const updatedQuery = { id: 1, ...queryData } + fetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(updatedQuery), + }) + + const store = mockStore({}) + await store.dispatch(updateSavedQuery(1, queryData)) + + const actions = store.getActions() + expect(actions[0]).toEqual({ type: UPDATE_QUERY_START }) + expect(actions[1]).toEqual({ type: UPDATE_QUERY_SUCCESS, query: updatedQuery }) + expect(actions[2]).toEqual({ type: CLOSE_SAVE_MODAL }) + }) + + it('dispatches UPDATE_ERROR on failure', async () => { + fetch.mockResolvedValueOnce({ + ok: false, + json: () => Promise.resolve({ error: 'Not found' }), + }) + + const store = mockStore({}) + await expect(store.dispatch(updateSavedQuery(999, {}))).rejects.toThrow() + + const actions = store.getActions() + expect(actions[0]).toEqual({ type: UPDATE_QUERY_START }) + expect(actions[1].type).toBe(UPDATE_QUERY_ERROR) + }) + }) + + describe('deleteSavedQuery', () => { + it('dispatches DELETE_SUCCESS on success', async () => { + fetch.mockResolvedValueOnce({ + ok: true, + status: 204, + }) + + const store = mockStore({}) + await store.dispatch(deleteSavedQuery(1)) + + const actions = store.getActions() + expect(actions[0]).toEqual({ type: DELETE_QUERY_START, id: 1 }) + expect(actions[1]).toEqual({ type: DELETE_QUERY_SUCCESS, id: 1 }) + }) + + it('dispatches DELETE_ERROR on failure', async () => { + fetch.mockResolvedValueOnce({ + ok: false, + status: 500, + json: () => Promise.resolve({ error: 'Database error' }), + }) + + const store = mockStore({}) + await expect(store.dispatch(deleteSavedQuery(1))).rejects.toThrow() + + const actions = store.getActions() + expect(actions[0]).toEqual({ type: DELETE_QUERY_START, id: 1 }) + expect(actions[1].type).toBe(DELETE_QUERY_ERROR) + }) + }) + + describe('selectSavedQuery', () => { + it('dispatches updateQuery and updateAction', () => { + const query = { name: 'Test', action: 'mutate', query: '{ test }' } + const store = mockStore({}) + store.dispatch(selectSavedQuery(query)) + + const actions = store.getActions() + // Should dispatch updateQuery and updateAction from query module + expect(actions.length).toBe(2) + expect(actions[0].type).toBe('query/UPDATE_QUERY') + expect(actions[0].query).toBe('{ test }') + expect(actions[1].type).toBe('query/UPDATE_ACTION') + expect(actions[1].action).toBe('mutate') + }) + }) + + describe('openSaveModal', () => { + it('opens modal for new query with current editor content', () => { + const store = mockStore({ + query: { query: '{ current }', action: 'query' }, + }) + store.dispatch(openSaveModal()) + + const actions = store.getActions() + expect(actions[0].type).toBe(OPEN_SAVE_MODAL) + expect(actions[0].editingQuery).toBe(null) + expect(actions[0].formData.query).toBe('{ current }') + expect(actions[0].formData.action).toBe('query') + expect(actions[0].formData.name).toBe('') + }) + + it('opens modal for editing with existing query data', () => { + const existingQuery = { + id: 1, + name: 'Existing', + description: 'Desc', + category: 'Cat', + action: 'mutate', + query: '{ existing }', + } + const store = mockStore({ + query: { query: '{ current }', action: 'query' }, + }) + store.dispatch(openSaveModal(existingQuery)) + + const actions = store.getActions() + expect(actions[0].type).toBe(OPEN_SAVE_MODAL) + expect(actions[0].editingQuery).toEqual(existingQuery) + expect(actions[0].formData.name).toBe('Existing') + expect(actions[0].formData.query).toBe('{ existing }') + }) + }) + + describe('closeSaveModal', () => { + it('dispatches CLOSE_SAVE_MODAL', () => { + const action = closeSaveModal() + expect(action).toEqual({ type: CLOSE_SAVE_MODAL }) + }) + }) + + describe('updateSaveForm', () => { + it('dispatches UPDATE_SAVE_FORM with field and value', () => { + const action = updateSaveForm('name', 'Test Name') + expect(action).toEqual({ + type: UPDATE_SAVE_FORM, + field: 'name', + value: 'Test Name', + }) + }) + }) + + describe('saveCurrentQuery', () => { + it('creates new query when editingQuery is null', async () => { + const saveForm = { name: 'New', query: '{ new }' } + const createdQuery = { id: 1, ...saveForm } + fetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(createdQuery), + }) + + const store = mockStore({ + savedQueries: { editingQuery: null, saveForm }, + }) + await store.dispatch(saveCurrentQuery()) + + const actions = store.getActions() + expect(actions[0]).toEqual({ type: CREATE_QUERY_START }) + }) + + it('updates existing query when editingQuery is set', async () => { + const saveForm = { name: 'Updated', query: '{ updated }' } + const updatedQuery = { id: 1, ...saveForm } + fetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(updatedQuery), + }) + + const store = mockStore({ + savedQueries: { editingQuery: { id: 1 }, saveForm }, + }) + await store.dispatch(saveCurrentQuery()) + + const actions = store.getActions() + expect(actions[0]).toEqual({ type: UPDATE_QUERY_START }) + }) + }) +}) diff --git a/client/src/components/EditorPanel.js b/client/src/components/EditorPanel.js index f3fb319f..0819290c 100644 --- a/client/src/components/EditorPanel.js +++ b/client/src/components/EditorPanel.js @@ -17,7 +17,10 @@ import { updateQueryVars, updateReadOnly, } from 'actions/query' +import { openSaveModal } from 'actions/savedQueries' +import SavedQueriesDropdown from 'components/SavedQueriesDropdown' +import SaveQueryModal from 'components/SaveQueryModal' import QueryVarsEditor from 'components/QueryVarsEditor' import Editor from 'containers/Editor' @@ -98,6 +101,8 @@ export default function EditorPanel() { {renderRadioBtn('mutate', 'Mutate', action, onUpdateAction)} + + {queryOptions}
@@ -109,6 +114,14 @@ export default function EditorPanel() { > Clear +
) } diff --git a/client/src/components/SaveQueryModal.js b/client/src/components/SaveQueryModal.js new file mode 100644 index 00000000..d79a9da2 --- /dev/null +++ b/client/src/components/SaveQueryModal.js @@ -0,0 +1,171 @@ +/* + * SPDX-FileCopyrightText: © Hypermode Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react' +import { useDispatch, useSelector } from 'react-redux' +import Alert from 'react-bootstrap/Alert' +import Button from 'react-bootstrap/Button' +import Form from 'react-bootstrap/Form' +import Modal from 'react-bootstrap/Modal' + +import { + closeSaveModal, + updateSaveForm, + saveCurrentQuery, +} from 'actions/savedQueries' + +import './SaveQueryModal.scss' + +export default function SaveQueryModal() { + const dispatch = useDispatch() + const { showSaveModal, editingQuery, saveForm, saving, saveError, queries } = + useSelector((state) => state.savedQueries) + + if (!showSaveModal) { + return null + } + + const isEditing = editingQuery !== null + + // Get unique categories from existing queries for suggestions + const existingCategories = [ + ...new Set(queries.map((q) => q.category).filter(Boolean)), + ].sort() + + const handleSubmit = async (e) => { + e.preventDefault() + try { + await dispatch(saveCurrentQuery()) + } catch (err) { + // Error is handled by reducer + } + } + + const handleCancel = () => { + dispatch(closeSaveModal()) + } + + const handleChange = (field) => (e) => { + dispatch(updateSaveForm(field, e.target.value)) + } + + const isValid = saveForm.name.trim() && saveForm.query.trim() + + return ( + + + + {' '} + {isEditing ? 'Edit Saved Query' : 'Save Query'} + + +
+ + {saveError && {saveError}} + + + + Name * + + + + + + Category + + + {existingCategories.map((cat) => ( + + + Used to group queries in the dropdown + + + + + Description + + + + + Type +
+ dispatch(updateSaveForm('action', 'query'))} + /> + dispatch(updateSaveForm('action', 'mutate'))} + /> +
+
+ + + + Query * + + + +
+ + + + + +
+ ) +} diff --git a/client/src/components/SaveQueryModal.scss b/client/src/components/SaveQueryModal.scss new file mode 100644 index 00000000..b34e534e --- /dev/null +++ b/client/src/components/SaveQueryModal.scss @@ -0,0 +1,17 @@ +.save-query-modal { + .modal-title { + i { + margin-right: 8px; + color: #007bff; + } + } + + .query-textarea { + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + font-size: 13px; + } + + .text-danger { + margin-left: 2px; + } +} diff --git a/client/src/components/SavedQueriesDropdown.js b/client/src/components/SavedQueriesDropdown.js new file mode 100644 index 00000000..7567a486 --- /dev/null +++ b/client/src/components/SavedQueriesDropdown.js @@ -0,0 +1,172 @@ +/* + * SPDX-FileCopyrightText: © Hypermode Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useEffect, useState } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import Dropdown from 'react-bootstrap/Dropdown' +import DropdownButton from 'react-bootstrap/DropdownButton' + +import { + fetchSavedQueries, + selectSavedQuery, + deleteSavedQuery, + openSaveModal, +} from 'actions/savedQueries' + +import './SavedQueriesDropdown.scss' + +export default function SavedQueriesDropdown() { + const dispatch = useDispatch() + const { enabled, loading, queries, deleting } = useSelector( + (state) => state.savedQueries, + ) + const [confirmDelete, setConfirmDelete] = useState(null) + const [isOpen, setIsOpen] = useState(false) + + useEffect(() => { + dispatch(fetchSavedQueries()) + }, [dispatch]) + + // Don't render if not enabled + if (!enabled) { + return null + } + + // Group queries by category + const categories = queries.reduce((acc, query) => { + const category = query.category || 'General' + if (!acc[category]) { + acc[category] = [] + } + acc[category].push(query) + return acc + }, {}) + + const handleSelect = (e, query) => { + e.preventDefault() + e.stopPropagation() + dispatch(selectSavedQuery(query)) + setIsOpen(false) + } + + const handleEdit = (e, query) => { + e.preventDefault() + e.stopPropagation() + dispatch(openSaveModal(query)) + setIsOpen(false) + } + + const handleDeleteClick = (e, query) => { + e.preventDefault() + e.stopPropagation() + setConfirmDelete(query.id) + } + + const handleDeleteConfirm = (e, query) => { + e.preventDefault() + e.stopPropagation() + dispatch(deleteSavedQuery(query.id)) + setConfirmDelete(null) + } + + const handleDeleteCancel = (e) => { + e.preventDefault() + e.stopPropagation() + setConfirmDelete(null) + } + + const hasQueries = queries.length > 0 + + return ( + + Saved + + } + disabled={loading} + show={isOpen} + onToggle={(nextShow) => { + setIsOpen(nextShow) + if (!nextShow) { + setConfirmDelete(null) + } + }} + > + {!hasQueries && ( + + No saved queries + + )} + {Object.entries(categories).map(([category, categoryQueries], idx) => ( + + {idx > 0 && } + {category} + {categoryQueries.map((query) => ( + + {confirmDelete === query.id ? ( +
+ Delete? + + +
+ ) : ( + <> + handleSelect(e, query)} + > + {' '} + {query.name} + + + + + + + )} +
+ ))} +
+ ))} +
+ ) +} diff --git a/client/src/components/SavedQueriesDropdown.scss b/client/src/components/SavedQueriesDropdown.scss new file mode 100644 index 00000000..f70e7e29 --- /dev/null +++ b/client/src/components/SavedQueriesDropdown.scss @@ -0,0 +1,111 @@ +.saved-queries-dropdown { + .dropdown-menu { + min-width: 280px; + max-height: 400px; + overflow-y: auto; + } + + .saved-query-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5rem 1rem; + cursor: default; + + &:hover { + background-color: #f8f9fa; + } + + .query-name { + flex: 1; + cursor: pointer; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + i { + margin-right: 6px; + color: #666; + } + + &:hover { + color: #007bff; + } + } + + .query-actions { + display: flex; + gap: 4px; + margin-left: 8px; + opacity: 0; + transition: opacity 0.15s; + + button { + background: none; + border: none; + padding: 4px 6px; + cursor: pointer; + border-radius: 3px; + color: #666; + + &:hover { + background-color: #e9ecef; + } + + &.btn-edit:hover { + color: #007bff; + } + + &.btn-delete:hover { + color: #dc3545; + } + } + } + + &:hover .query-actions { + opacity: 1; + } + + .delete-confirm { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + + span { + color: #dc3545; + font-weight: 500; + } + + button { + background: none; + border: 1px solid #ccc; + padding: 2px 10px; + border-radius: 3px; + cursor: pointer; + font-size: 12px; + + &.btn-confirm { + background-color: #dc3545; + border-color: #dc3545; + color: white; + + &:hover { + background-color: #c82333; + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } + } + + &.btn-cancel { + &:hover { + background-color: #e9ecef; + } + } + } + } + } +} diff --git a/client/src/reducers/index.js b/client/src/reducers/index.js index 1dfcc43c..bbd1394b 100644 --- a/client/src/reducers/index.js +++ b/client/src/reducers/index.js @@ -9,6 +9,7 @@ import backup from './backup' import cluster from './cluster' import connection from './connection' import frames from './frames' +import savedQueries from './savedQueries' import query from './query' import ui from './ui' @@ -18,6 +19,7 @@ export default function makeRootReducer(config) { cluster, connection, frames, + savedQueries, query, ui, }) diff --git a/client/src/reducers/savedQueries.js b/client/src/reducers/savedQueries.js new file mode 100644 index 00000000..da2e5902 --- /dev/null +++ b/client/src/reducers/savedQueries.js @@ -0,0 +1,154 @@ +/* + * SPDX-FileCopyrightText: © Hypermode Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import produce from 'immer' + +import { + CLOSE_SAVE_MODAL, + CREATE_QUERY_ERROR, + CREATE_QUERY_START, + CREATE_QUERY_SUCCESS, + DELETE_QUERY_ERROR, + DELETE_QUERY_START, + DELETE_QUERY_SUCCESS, + FETCH_SAVED_QUERIES_ERROR, + FETCH_SAVED_QUERIES_START, + FETCH_SAVED_QUERIES_SUCCESS, + OPEN_SAVE_MODAL, + UPDATE_QUERY_ERROR, + UPDATE_QUERY_START, + UPDATE_QUERY_SUCCESS, + UPDATE_SAVE_FORM, +} from 'actions/savedQueries' + +const EMPTY_SAVE_FORM = { + name: '', + description: '', + category: 'General', + action: 'query', + query: '', +} + +// Sort queries by category, then name (matches the server's ORDER BY). +const byCategoryThenName = (a, b) => + a.category !== b.category + ? a.category.localeCompare(b.category) + : a.name.localeCompare(b.name) + +const defaultState = { + enabled: false, + loading: false, + error: null, + queries: [], + + // Save modal state + showSaveModal: false, + editingQuery: null, // null = new, object = editing existing + saveForm: EMPTY_SAVE_FORM, + saving: false, + saveError: null, + + // Delete state + deleting: null, // ID being deleted +} + +export default (state = defaultState, action) => + produce(state, (draft) => { + switch (action.type) { + // Fetch + case FETCH_SAVED_QUERIES_START: + draft.loading = true + draft.error = null + break + + case FETCH_SAVED_QUERIES_SUCCESS: + draft.loading = false + draft.enabled = action.payload.enabled + draft.queries = action.payload.queries || [] + break + + case FETCH_SAVED_QUERIES_ERROR: + draft.loading = false + draft.error = action.error + break + + // Create + case CREATE_QUERY_START: + draft.saving = true + draft.saveError = null + break + + case CREATE_QUERY_SUCCESS: + draft.saving = false + draft.queries.push(action.query) + draft.queries.sort(byCategoryThenName) + break + + case CREATE_QUERY_ERROR: + draft.saving = false + draft.saveError = action.error + break + + // Update + case UPDATE_QUERY_START: + draft.saving = true + draft.saveError = null + break + + case UPDATE_QUERY_SUCCESS: { + draft.saving = false + const updateIdx = draft.queries.findIndex( + (q) => q.id === action.query.id, + ) + if (updateIdx !== -1) { + draft.queries[updateIdx] = action.query + } + draft.queries.sort(byCategoryThenName) + break + } + + case UPDATE_QUERY_ERROR: + draft.saving = false + draft.saveError = action.error + break + + // Delete + case DELETE_QUERY_START: + draft.deleting = action.id + break + + case DELETE_QUERY_SUCCESS: + draft.deleting = null + draft.queries = draft.queries.filter((q) => q.id !== action.id) + break + + case DELETE_QUERY_ERROR: + draft.deleting = null + draft.error = action.error + break + + // Save modal + case OPEN_SAVE_MODAL: + draft.showSaveModal = true + draft.editingQuery = action.editingQuery + draft.saveForm = action.formData + draft.saveError = null + break + + case CLOSE_SAVE_MODAL: + draft.showSaveModal = false + draft.editingQuery = null + draft.saveForm = EMPTY_SAVE_FORM + draft.saveError = null + break + + case UPDATE_SAVE_FORM: + draft.saveForm[action.field] = action.value + break + + default: + break + } + }) diff --git a/client/src/reducers/savedQueries.test.js b/client/src/reducers/savedQueries.test.js new file mode 100644 index 00000000..2b5ade87 --- /dev/null +++ b/client/src/reducers/savedQueries.test.js @@ -0,0 +1,248 @@ +/* + * SPDX-FileCopyrightText: © Hypermode Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import reducer from './savedQueries' +import { + FETCH_SAVED_QUERIES_START, + FETCH_SAVED_QUERIES_SUCCESS, + FETCH_SAVED_QUERIES_ERROR, + CREATE_QUERY_START, + CREATE_QUERY_SUCCESS, + CREATE_QUERY_ERROR, + UPDATE_QUERY_START, + UPDATE_QUERY_SUCCESS, + UPDATE_QUERY_ERROR, + DELETE_QUERY_START, + DELETE_QUERY_SUCCESS, + DELETE_QUERY_ERROR, + OPEN_SAVE_MODAL, + CLOSE_SAVE_MODAL, + UPDATE_SAVE_FORM, +} from 'actions/savedQueries' + +describe('savedQueries reducer', () => { + const initialState = { + enabled: false, + loading: false, + error: null, + queries: [], + showSaveModal: false, + editingQuery: null, + saveForm: { + name: '', + description: '', + category: 'General', + action: 'query', + query: '', + }, + saving: false, + saveError: null, + deleting: null, + } + + it('should return the initial state', () => { + expect(reducer(undefined, {})).toEqual(initialState) + }) + + describe('fetch queries', () => { + it('should handle FETCH_SAVED_QUERIES_START', () => { + const action = { type: FETCH_SAVED_QUERIES_START } + const state = reducer(initialState, action) + expect(state.loading).toBe(true) + expect(state.error).toBe(null) + }) + + it('should handle FETCH_SAVED_QUERIES_SUCCESS', () => { + const queries = [ + { id: 1, name: 'Query 1', category: 'Test', action: 'query', query: '{ q1 }' }, + { id: 2, name: 'Query 2', category: 'Test', action: 'mutate', query: '{ q2 }' }, + ] + const action = { + type: FETCH_SAVED_QUERIES_SUCCESS, + payload: { enabled: true, queries }, + } + const state = reducer({ ...initialState, loading: true }, action) + expect(state.loading).toBe(false) + expect(state.enabled).toBe(true) + expect(state.queries).toEqual(queries) + }) + + it('should handle FETCH_SAVED_QUERIES_ERROR', () => { + const action = { + type: FETCH_SAVED_QUERIES_ERROR, + error: 'Network error', + } + const state = reducer({ ...initialState, loading: true }, action) + expect(state.loading).toBe(false) + expect(state.error).toBe('Network error') + }) + }) + + describe('create query', () => { + it('should handle CREATE_QUERY_START', () => { + const action = { type: CREATE_QUERY_START } + const state = reducer(initialState, action) + expect(state.saving).toBe(true) + expect(state.saveError).toBe(null) + }) + + it('should handle CREATE_QUERY_SUCCESS', () => { + const newQuery = { id: 1, name: 'New Query', category: 'Test', action: 'query', query: '{ new }' } + const action = { type: CREATE_QUERY_SUCCESS, query: newQuery } + const state = reducer({ ...initialState, saving: true }, action) + expect(state.saving).toBe(false) + expect(state.queries).toContainEqual(newQuery) + }) + + it('should sort queries after CREATE_QUERY_SUCCESS', () => { + const existingState = { + ...initialState, + queries: [ + { id: 1, name: 'Zebra', category: 'B', action: 'query', query: '{ z }' }, + ], + saving: true, + } + const newQuery = { id: 2, name: 'Apple', category: 'A', action: 'query', query: '{ a }' } + const action = { type: CREATE_QUERY_SUCCESS, query: newQuery } + const state = reducer(existingState, action) + expect(state.queries[0].name).toBe('Apple') + expect(state.queries[1].name).toBe('Zebra') + }) + + it('should handle CREATE_QUERY_ERROR', () => { + const action = { type: CREATE_QUERY_ERROR, error: 'Failed to create' } + const state = reducer({ ...initialState, saving: true }, action) + expect(state.saving).toBe(false) + expect(state.saveError).toBe('Failed to create') + }) + }) + + describe('update query', () => { + it('should handle UPDATE_QUERY_START', () => { + const action = { type: UPDATE_QUERY_START } + const state = reducer(initialState, action) + expect(state.saving).toBe(true) + expect(state.saveError).toBe(null) + }) + + it('should handle UPDATE_QUERY_SUCCESS', () => { + const existingState = { + ...initialState, + queries: [ + { id: 1, name: 'Original', category: 'Test', action: 'query', query: '{ orig }' }, + ], + saving: true, + } + const updatedQuery = { id: 1, name: 'Updated', category: 'Test', action: 'query', query: '{ updated }' } + const action = { type: UPDATE_QUERY_SUCCESS, query: updatedQuery } + const state = reducer(existingState, action) + expect(state.saving).toBe(false) + expect(state.queries[0].name).toBe('Updated') + expect(state.queries[0].query).toBe('{ updated }') + }) + + it('should handle UPDATE_QUERY_ERROR', () => { + const action = { type: UPDATE_QUERY_ERROR, error: 'Failed to update' } + const state = reducer({ ...initialState, saving: true }, action) + expect(state.saving).toBe(false) + expect(state.saveError).toBe('Failed to update') + }) + }) + + describe('delete query', () => { + it('should handle DELETE_QUERY_START', () => { + const action = { type: DELETE_QUERY_START, id: 1 } + const state = reducer(initialState, action) + expect(state.deleting).toBe(1) + }) + + it('should handle DELETE_QUERY_SUCCESS', () => { + const existingState = { + ...initialState, + queries: [ + { id: 1, name: 'Query 1', category: 'Test', action: 'query', query: '{ q1 }' }, + { id: 2, name: 'Query 2', category: 'Test', action: 'query', query: '{ q2 }' }, + ], + deleting: 1, + } + const action = { type: DELETE_QUERY_SUCCESS, id: 1 } + const state = reducer(existingState, action) + expect(state.deleting).toBe(null) + expect(state.queries.length).toBe(1) + expect(state.queries[0].id).toBe(2) + }) + + it('should handle DELETE_QUERY_ERROR', () => { + const action = { type: DELETE_QUERY_ERROR, error: 'Failed to delete' } + const state = reducer({ ...initialState, deleting: 1 }, action) + expect(state.deleting).toBe(null) + expect(state.error).toBe('Failed to delete') + }) + }) + + describe('save modal', () => { + it('should handle OPEN_SAVE_MODAL for new query', () => { + const formData = { + name: '', + description: '', + category: 'General', + action: 'query', + query: '{ current_editor_query }', + } + const action = { type: OPEN_SAVE_MODAL, editingQuery: null, formData } + const state = reducer(initialState, action) + expect(state.showSaveModal).toBe(true) + expect(state.editingQuery).toBe(null) + expect(state.saveForm).toEqual(formData) + }) + + it('should handle OPEN_SAVE_MODAL for editing existing query', () => { + const existingQuery = { id: 1, name: 'Existing', description: 'Desc', category: 'Cat', action: 'query', query: '{ existing }' } + const formData = { + name: 'Existing', + description: 'Desc', + category: 'Cat', + action: 'query', + query: '{ existing }', + } + const action = { type: OPEN_SAVE_MODAL, editingQuery: existingQuery, formData } + const state = reducer(initialState, action) + expect(state.showSaveModal).toBe(true) + expect(state.editingQuery).toEqual(existingQuery) + expect(state.saveForm).toEqual(formData) + }) + + it('should handle CLOSE_SAVE_MODAL', () => { + const openState = { + ...initialState, + showSaveModal: true, + editingQuery: { id: 1 }, + saveForm: { name: 'Test', description: '', category: 'Test', action: 'query', query: '{ test }' }, + saveError: 'Some error', + } + const action = { type: CLOSE_SAVE_MODAL } + const state = reducer(openState, action) + expect(state.showSaveModal).toBe(false) + expect(state.editingQuery).toBe(null) + expect(state.saveForm).toEqual(initialState.saveForm) + expect(state.saveError).toBe(null) + }) + + it('should handle UPDATE_SAVE_FORM', () => { + const action = { type: UPDATE_SAVE_FORM, field: 'name', value: 'New Name' } + const state = reducer(initialState, action) + expect(state.saveForm.name).toBe('New Name') + }) + + it('should handle multiple UPDATE_SAVE_FORM calls', () => { + let state = reducer(initialState, { type: UPDATE_SAVE_FORM, field: 'name', value: 'My Query' }) + state = reducer(state, { type: UPDATE_SAVE_FORM, field: 'category', value: 'Custom' }) + state = reducer(state, { type: UPDATE_SAVE_FORM, field: 'action', value: 'mutate' }) + expect(state.saveForm.name).toBe('My Query') + expect(state.saveForm.category).toBe('Custom') + expect(state.saveForm.action).toBe('mutate') + }) + }) +}) diff --git a/client/src/setupProxy.js b/client/src/setupProxy.js new file mode 100644 index 00000000..b6c46287 --- /dev/null +++ b/client/src/setupProxy.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: © Hypermode Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +const { createProxyMiddleware } = require('http-proxy-middleware') + +module.exports = function (app) { + app.use( + '/api', + createProxyMiddleware({ + target: 'http://localhost:8000', + changeOrigin: true, + }), + ) +} diff --git a/go.mod b/go.mod index 2cb091d2..629d13bc 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,16 @@ module github.com/dgraph-io/ratel go 1.23.2 -require github.com/go-bindata/go-bindata v3.1.2+incompatible // indirect +require modernc.org/sqlite v1.34.5 + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.22.0 // indirect + modernc.org/libc v1.55.3 // indirect + modernc.org/mathutil v1.6.0 // indirect + modernc.org/memory v1.8.0 // indirect +) diff --git a/go.sum b/go.sum index 402dde70..5424fe41 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,43 @@ -github.com/go-bindata/go-bindata v3.1.2+incompatible h1:5vjJMVhowQdPzjE1LdxyFF7YFTXg5IgGVW4gBr5IbvE= -github.com/go-bindata/go-bindata v3.1.2+incompatible/go.mod h1:xK8Dsgwmeed+BBsSy2XTopBn/8uK2HWuGSnA11C3Joo= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic= +golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= +golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw= +golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc= +modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ= +modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= +modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y= +modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s= +modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= +modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= +modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw= +modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= +modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U= +modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= +modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= +modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= +modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= +modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= +modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= +modernc.org/sqlite v1.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g= +modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE= +modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= +modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/server/queries.go b/server/queries.go new file mode 100644 index 00000000..dedc95ab --- /dev/null +++ b/server/queries.go @@ -0,0 +1,194 @@ +/* + * SPDX-FileCopyrightText: © Hypermode Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package server + +import ( + "database/sql" + "log" + "time" + + _ "modernc.org/sqlite" +) + +// SavedQuery represents a saved query in the database +type SavedQuery struct { + ID int64 `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Category string `json:"category"` + Action string `json:"action"` // "query" or "mutate" + Query string `json:"query"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// SavedQueryInput is used for creating/updating queries (without ID and timestamps) +type SavedQueryInput struct { + Name string `json:"name"` + Description string `json:"description"` + Category string `json:"category"` + Action string `json:"action"` + Query string `json:"query"` +} + +// SavedQueriesResponse is the API response for listing queries +type SavedQueriesResponse struct { + Queries []SavedQuery `json:"queries"` +} + +var db *sql.DB + +// InitDB initializes the SQLite database connection and creates tables +func InitDB(dbPath string) error { + var err error + db, err = sql.Open("sqlite", dbPath) + if err != nil { + return err + } + + // SQLite concurrency settings - prevent "database is locked" errors + db.SetMaxOpenConns(1) + db.SetMaxIdleConns(1) + + // Enable WAL mode for better concurrent read performance + if _, err := db.Exec(`PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=5000;`); err != nil { + log.Printf("Warning: Failed to set SQLite pragmas: %v", err) + } + + // Create table if not exists + createTableSQL := ` + CREATE TABLE IF NOT EXISTS saved_queries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + description TEXT DEFAULT '', + category TEXT DEFAULT 'General', + action TEXT DEFAULT 'query' CHECK(action IN ('query', 'mutate')), + query TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + )` + + if _, err = db.Exec(createTableSQL); err != nil { + return err + } + + // Create index separately for better driver compatibility + if _, err = db.Exec(`CREATE INDEX IF NOT EXISTS idx_category_name ON saved_queries(category, name)`); err != nil { + return err + } + + log.Printf("SQLite database initialized at %s", dbPath) + return nil +} + +// CloseDB closes the database connection +func CloseDB() { + if db != nil { + db.Close() + } +} + +// GetAllQueries returns all saved queries +func GetAllQueries() ([]SavedQuery, error) { + rows, err := db.Query(` + SELECT id, name, description, category, action, query, created_at, updated_at + FROM saved_queries + ORDER BY category, name + `) + if err != nil { + return nil, err + } + defer rows.Close() + + queries := []SavedQuery{} + for rows.Next() { + var q SavedQuery + err := rows.Scan(&q.ID, &q.Name, &q.Description, &q.Category, &q.Action, &q.Query, &q.CreatedAt, &q.UpdatedAt) + if err != nil { + return nil, err + } + queries = append(queries, q) + } + + // Check for iteration errors + if err := rows.Err(); err != nil { + return nil, err + } + + return queries, nil +} + +// GetQueryByID returns a single query by ID +func GetQueryByID(id int64) (*SavedQuery, error) { + var q SavedQuery + err := db.QueryRow(` + SELECT id, name, description, category, action, query, created_at, updated_at + FROM saved_queries + WHERE id = ? + `, id).Scan(&q.ID, &q.Name, &q.Description, &q.Category, &q.Action, &q.Query, &q.CreatedAt, &q.UpdatedAt) + + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return &q, nil +} + +// applyDefaults fills in empty Category/Action with their default values. +func (input *SavedQueryInput) applyDefaults() { + if input.Category == "" { + input.Category = "General" + } + if input.Action == "" { + input.Action = "query" + } +} + +// CreateQuery inserts a new query and returns it with the generated ID +func CreateQuery(input SavedQueryInput) (*SavedQuery, error) { + input.applyDefaults() + + result, err := db.Exec(` + INSERT INTO saved_queries (name, description, category, action, query) + VALUES (?, ?, ?, ?, ?) + `, input.Name, input.Description, input.Category, input.Action, input.Query) + + if err != nil { + return nil, err + } + + id, err := result.LastInsertId() + if err != nil { + return nil, err + } + + return GetQueryByID(id) +} + +// UpdateQuery updates an existing query +func UpdateQuery(id int64, input SavedQueryInput) (*SavedQuery, error) { + input.applyDefaults() + + _, err := db.Exec(` + UPDATE saved_queries + SET name = ?, description = ?, category = ?, action = ?, query = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + `, input.Name, input.Description, input.Category, input.Action, input.Query, id) + + if err != nil { + return nil, err + } + + return GetQueryByID(id) +} + +// DeleteQuery removes a query by ID +func DeleteQuery(id int64) error { + _, err := db.Exec(`DELETE FROM saved_queries WHERE id = ?`, id) + return err +} diff --git a/server/queries_test.go b/server/queries_test.go new file mode 100644 index 00000000..f4ff4d08 --- /dev/null +++ b/server/queries_test.go @@ -0,0 +1,308 @@ +/* + * SPDX-FileCopyrightText: © Hypermode Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package server + +import ( + "os" + "testing" +) + +func setupTestDB(t *testing.T) func() { + // Create a temporary database file + tmpFile, err := os.CreateTemp("", "test_queries_*.db") + if err != nil { + t.Fatalf("Failed to create temp file: %v", err) + } + tmpFile.Close() + + // Initialize the database + if err := InitDB(tmpFile.Name()); err != nil { + os.Remove(tmpFile.Name()) + t.Fatalf("Failed to initialize database: %v", err) + } + + // Return cleanup function + return func() { + CloseDB() + os.Remove(tmpFile.Name()) + } +} + +func TestInitDB(t *testing.T) { + cleanup := setupTestDB(t) + defer cleanup() + + // Database should be initialized - test by getting all queries + queries, err := GetAllQueries() + if err != nil { + t.Fatalf("GetAllQueries failed: %v", err) + } + if len(queries) != 0 { + t.Errorf("Expected 0 queries, got %d", len(queries)) + } +} + +func TestCreateQuery(t *testing.T) { + cleanup := setupTestDB(t) + defer cleanup() + + input := SavedQueryInput{ + Name: "Test Query", + Description: "A test query", + Category: "Testing", + Action: "query", + Query: "{ test { uid } }", + } + + query, err := CreateQuery(input) + if err != nil { + t.Fatalf("CreateQuery failed: %v", err) + } + + if query.ID == 0 { + t.Error("Expected non-zero ID") + } + if query.Name != input.Name { + t.Errorf("Expected name %q, got %q", input.Name, query.Name) + } + if query.Description != input.Description { + t.Errorf("Expected description %q, got %q", input.Description, query.Description) + } + if query.Category != input.Category { + t.Errorf("Expected category %q, got %q", input.Category, query.Category) + } + if query.Action != input.Action { + t.Errorf("Expected action %q, got %q", input.Action, query.Action) + } + if query.Query != input.Query { + t.Errorf("Expected query %q, got %q", input.Query, query.Query) + } +} + +func TestCreateQueryDefaults(t *testing.T) { + cleanup := setupTestDB(t) + defer cleanup() + + // Create query without category and action - should use defaults + input := SavedQueryInput{ + Name: "Minimal Query", + Query: "{ minimal { uid } }", + } + + query, err := CreateQuery(input) + if err != nil { + t.Fatalf("CreateQuery failed: %v", err) + } + + if query.Category != "General" { + t.Errorf("Expected default category 'General', got %q", query.Category) + } + if query.Action != "query" { + t.Errorf("Expected default action 'query', got %q", query.Action) + } +} + +func TestGetQueryByID(t *testing.T) { + cleanup := setupTestDB(t) + defer cleanup() + + // Create a query first + input := SavedQueryInput{ + Name: "Get By ID Test", + Query: "{ test { uid } }", + } + created, err := CreateQuery(input) + if err != nil { + t.Fatalf("CreateQuery failed: %v", err) + } + + // Get by ID + query, err := GetQueryByID(created.ID) + if err != nil { + t.Fatalf("GetQueryByID failed: %v", err) + } + if query == nil { + t.Fatal("Expected query, got nil") + } + if query.ID != created.ID { + t.Errorf("Expected ID %d, got %d", created.ID, query.ID) + } + if query.Name != input.Name { + t.Errorf("Expected name %q, got %q", input.Name, query.Name) + } +} + +func TestGetQueryByIDNotFound(t *testing.T) { + cleanup := setupTestDB(t) + defer cleanup() + + query, err := GetQueryByID(99999) + if err != nil { + t.Fatalf("GetQueryByID failed: %v", err) + } + if query != nil { + t.Error("Expected nil for non-existent ID") + } +} + +func TestGetAllQueries(t *testing.T) { + cleanup := setupTestDB(t) + defer cleanup() + + // Create multiple queries + inputs := []SavedQueryInput{ + {Name: "Query 1", Category: "Cat A", Query: "{ q1 }"}, + {Name: "Query 2", Category: "Cat B", Query: "{ q2 }"}, + {Name: "Query 3", Category: "Cat A", Query: "{ q3 }"}, + } + + for _, input := range inputs { + if _, err := CreateQuery(input); err != nil { + t.Fatalf("CreateQuery failed: %v", err) + } + } + + queries, err := GetAllQueries() + if err != nil { + t.Fatalf("GetAllQueries failed: %v", err) + } + if len(queries) != 3 { + t.Errorf("Expected 3 queries, got %d", len(queries)) + } + + // Should be sorted by category, then name + if queries[0].Category != "Cat A" || queries[0].Name != "Query 1" { + t.Errorf("Expected first query to be Cat A/Query 1, got %s/%s", queries[0].Category, queries[0].Name) + } + if queries[1].Category != "Cat A" || queries[1].Name != "Query 3" { + t.Errorf("Expected second query to be Cat A/Query 3, got %s/%s", queries[1].Category, queries[1].Name) + } + if queries[2].Category != "Cat B" || queries[2].Name != "Query 2" { + t.Errorf("Expected third query to be Cat B/Query 2, got %s/%s", queries[2].Category, queries[2].Name) + } +} + +func TestUpdateQuery(t *testing.T) { + cleanup := setupTestDB(t) + defer cleanup() + + // Create a query + input := SavedQueryInput{ + Name: "Original Name", + Description: "Original Description", + Category: "Original", + Action: "query", + Query: "{ original }", + } + created, err := CreateQuery(input) + if err != nil { + t.Fatalf("CreateQuery failed: %v", err) + } + + // Update it + updateInput := SavedQueryInput{ + Name: "Updated Name", + Description: "Updated Description", + Category: "Updated", + Action: "mutate", + Query: "{ updated }", + } + updated, err := UpdateQuery(created.ID, updateInput) + if err != nil { + t.Fatalf("UpdateQuery failed: %v", err) + } + + if updated.ID != created.ID { + t.Errorf("Expected ID %d, got %d", created.ID, updated.ID) + } + if updated.Name != updateInput.Name { + t.Errorf("Expected name %q, got %q", updateInput.Name, updated.Name) + } + if updated.Description != updateInput.Description { + t.Errorf("Expected description %q, got %q", updateInput.Description, updated.Description) + } + if updated.Category != updateInput.Category { + t.Errorf("Expected category %q, got %q", updateInput.Category, updated.Category) + } + if updated.Action != updateInput.Action { + t.Errorf("Expected action %q, got %q", updateInput.Action, updated.Action) + } + if updated.Query != updateInput.Query { + t.Errorf("Expected query %q, got %q", updateInput.Query, updated.Query) + } + // UpdatedAt should be >= CreatedAt (may be same if update is fast) + if updated.UpdatedAt.Before(created.CreatedAt) { + t.Error("Expected UpdatedAt to be >= CreatedAt") + } +} + +func TestDeleteQuery(t *testing.T) { + cleanup := setupTestDB(t) + defer cleanup() + + // Create a query + input := SavedQueryInput{ + Name: "To Delete", + Query: "{ delete_me }", + } + created, err := CreateQuery(input) + if err != nil { + t.Fatalf("CreateQuery failed: %v", err) + } + + // Verify it exists + query, err := GetQueryByID(created.ID) + if err != nil { + t.Fatalf("GetQueryByID failed: %v", err) + } + if query == nil { + t.Fatal("Query should exist before deletion") + } + + // Delete it + if err := DeleteQuery(created.ID); err != nil { + t.Fatalf("DeleteQuery failed: %v", err) + } + + // Verify it's gone + query, err = GetQueryByID(created.ID) + if err != nil { + t.Fatalf("GetQueryByID failed: %v", err) + } + if query != nil { + t.Error("Query should not exist after deletion") + } +} + +func TestDeleteQueryNonExistent(t *testing.T) { + cleanup := setupTestDB(t) + defer cleanup() + + // Deleting non-existent query should not error + if err := DeleteQuery(99999); err != nil { + t.Errorf("DeleteQuery should not error for non-existent ID: %v", err) + } +} + +func TestMutateAction(t *testing.T) { + cleanup := setupTestDB(t) + defer cleanup() + + input := SavedQueryInput{ + Name: "Mutation Test", + Action: "mutate", + Query: `mutation { set { _:new "test" . } }`, + } + + query, err := CreateQuery(input) + if err != nil { + t.Fatalf("CreateQuery failed: %v", err) + } + + if query.Action != "mutate" { + t.Errorf("Expected action 'mutate', got %q", query.Action) + } +} diff --git a/server/server.go b/server/server.go index 5f257b1e..6f60a9c8 100644 --- a/server/server.go +++ b/server/server.go @@ -7,12 +7,15 @@ package server import ( "bytes" + "encoding/json" "flag" "fmt" "html/template" "log" "net/http" "os" + "path/filepath" + "strconv" "strings" ) @@ -34,13 +37,26 @@ var ( tlsKey string listenAddr string + + queriesDBPath string ) // Run starts the server. func Run() { parseFlags() + + if err := InitDB(queriesDBPath); err != nil { + log.Fatalf("Failed to initialize database: %v", err) + } + defer CloseDB() + indexContent := prepareIndexContent() + // ServeMux routes by longest matching pattern, so the "/{id}" subtree + // handler and the exact "/api/saved-queries" handler coexist regardless + // of registration order. + http.HandleFunc("/api/saved-queries/", handleSavedQueryByID) + http.HandleFunc("/api/saved-queries", handleSavedQueries) http.HandleFunc("/", makeMainHandler(indexContent)) addrStr := fmt.Sprintf("%s:%d", listenAddr, port) @@ -61,6 +77,8 @@ func parseFlags() { tlsCrtPtr := flag.String("tls_crt", "", "TLS cert for serving HTTPS requests.") tlsKeyPtr := flag.String("tls_key", "", "TLS key for serving HTTPS requests.") listenAddrPtr := flag.String("listen-addr", defaultAddr, "Address Ratel server should listen on.") + queriesDBPtr := flag.String("queries-db", "", + "Path to SQLite database file for saved queries. Can also be set via RATEL_QUERIES_DB env var.") flag.Parse() @@ -84,6 +102,31 @@ func parseFlags() { tlsKey = *tlsKeyPtr listenAddr = *listenAddrPtr + + // Handle queries DB path (flag takes precedence over env var, then a + // persistent per-user default). + queriesDBPath = *queriesDBPtr + if queriesDBPath == "" { + queriesDBPath = os.Getenv("RATEL_QUERIES_DB") + } + if queriesDBPath == "" { + queriesDBPath = defaultQueriesDBPath() + } +} + +// defaultQueriesDBPath returns a persistent location for the saved-queries +// database, creating the parent directory if needed. It falls back to the temp +// dir only if the user config dir is unavailable. +func defaultQueriesDBPath() string { + configDir, err := os.UserConfigDir() + if err != nil { + return filepath.Join(os.TempDir(), "ratel_queries.db") + } + dir := filepath.Join(configDir, "ratel") + if err := os.MkdirAll(dir, 0o755); err != nil { + return filepath.Join(os.TempDir(), "ratel_queries.db") + } + return filepath.Join(dir, "queries.db") } func getAsset(path string) string { @@ -157,3 +200,132 @@ func makeMainHandler(indexContent *content) http.HandlerFunc { http.ServeContent(w, r, info.Name(), info.ModTime(), newBuffer(bs)) } } + +// maxQueryBodyBytes caps request bodies so a runaway payload can't be buffered +// wholesale before validation. +const maxQueryBodyBytes = 1 << 20 // 1 MiB + +// writeJSONError writes a JSON {"error": msg} body with the given status code. +func writeJSONError(w http.ResponseWriter, code int, msg string) { + w.WriteHeader(code) + json.NewEncoder(w).Encode(map[string]string{"error": msg}) +} + +// decodeQueryInput reads and validates a SavedQueryInput from the request body, +// writing the appropriate error response and returning false on failure. +func decodeQueryInput(w http.ResponseWriter, r *http.Request) (SavedQueryInput, bool) { + var input SavedQueryInput + r.Body = http.MaxBytesReader(w, r.Body, maxQueryBodyBytes) + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeJSONError(w, http.StatusBadRequest, "Invalid JSON") + return input, false + } + if input.Name == "" || input.Query == "" { + writeJSONError(w, http.StatusBadRequest, "Name and query are required") + return input, false + } + return input, true +} + +// requireQuery confirms a query exists, writing a 500 on lookup failure or a 404 +// if missing, and returning false in either case. +func requireQuery(w http.ResponseWriter, id int64) bool { + existing, err := GetQueryByID(id) + if err != nil { + log.Printf("Error fetching query: %v", err) + writeJSONError(w, http.StatusInternalServerError, "Failed to fetch query") + return false + } + if existing == nil { + writeJSONError(w, http.StatusNotFound, "Query not found") + return false + } + return true +} + +// handleSavedQueries handles GET (list all) and POST (create) requests +func handleSavedQueries(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + switch r.Method { + case http.MethodGet: + queries, err := GetAllQueries() + if err != nil { + log.Printf("Error fetching queries: %v", err) + writeJSONError(w, http.StatusInternalServerError, "Failed to fetch queries") + return + } + json.NewEncoder(w).Encode(map[string]interface{}{ + "enabled": true, + "queries": queries, + }) + + case http.MethodPost: + input, ok := decodeQueryInput(w, r) + if !ok { + return + } + + query, err := CreateQuery(input) + if err != nil { + log.Printf("Error creating query: %v", err) + writeJSONError(w, http.StatusInternalServerError, "Failed to create query") + return + } + + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(query) + + default: + writeJSONError(w, http.StatusMethodNotAllowed, "Method not allowed") + } +} + +// handleSavedQueryByID handles PUT (update) and DELETE requests for a specific query +func handleSavedQueryByID(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + // Extract ID from URL path: /api/saved-queries/{id} + path := strings.TrimPrefix(r.URL.Path, "/api/saved-queries/") + id, err := strconv.ParseInt(path, 10, 64) + if err != nil { + writeJSONError(w, http.StatusBadRequest, "Invalid query ID") + return + } + + switch r.Method { + case http.MethodPut: + input, ok := decodeQueryInput(w, r) + if !ok { + return + } + if !requireQuery(w, id) { + return + } + + query, err := UpdateQuery(id, input) + if err != nil { + log.Printf("Error updating query: %v", err) + writeJSONError(w, http.StatusInternalServerError, "Failed to update query") + return + } + + json.NewEncoder(w).Encode(query) + + case http.MethodDelete: + if !requireQuery(w, id) { + return + } + + if err := DeleteQuery(id); err != nil { + log.Printf("Error deleting query: %v", err) + writeJSONError(w, http.StatusInternalServerError, "Failed to delete query") + return + } + + w.WriteHeader(http.StatusNoContent) + + default: + writeJSONError(w, http.StatusMethodNotAllowed, "Method not allowed") + } +}