feat(sqlmigration): cleanup the licenses and sites table (#7422)

* feat(sqlmigration): added migration for schema cleanup

* feat(sqlmigration): drop sites,licenses table and added uuid v7 for saved views

* feat(sqlmigration): commit the transaction

* feat(sqlmigration): address review comments

* feat(sqlmigration): address review comments

* feat(sqlmigration): frontend changes for saved views

* feat(sqlmigration): frontend changes for saved views

* feat(sqlmigration): frontend changes for saved views

* feat(sqlmigration): frontend changes for saved views

* feat(sqlmigration): frontend changes for saved views
This commit is contained in:
Vikrant Gupta 2025-03-25 04:05:40 +05:30 committed by GitHub
parent 9c25a33cd9
commit 64071165c4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 399 additions and 110 deletions

View File

@ -199,12 +199,12 @@ function ExplorerCard({
value={viewName || undefined} value={viewName || undefined}
> >
{viewsData?.data.data.map((view) => ( {viewsData?.data.data.map((view) => (
<Select.Option key={view.uuid} value={view.name}> <Select.Option key={view.id} value={view.name}>
<MenuItemGenerator <MenuItemGenerator
viewName={view.name} viewName={view.name}
viewKey={viewKey} viewKey={viewKey}
createdBy={view.createdBy} createdBy={view.createdBy}
uuid={view.uuid} uuid={view.id}
refetchAllView={refetchAllView} refetchAllView={refetchAllView}
viewData={viewsData.data.data} viewData={viewsData.data.data}
sourcePage={sourcepage} sourcePage={sourcepage}

View File

@ -53,17 +53,12 @@ function MenuItemGenerator({
({ key }: { key: string }): void => { ({ key }: { key: string }): void => {
const currentViewDetails = getViewDetailsUsingViewKey(key, viewData); const currentViewDetails = getViewDetailsUsingViewKey(key, viewData);
if (!currentViewDetails) return; if (!currentViewDetails) return;
const { const { query, name, id, panelType: currentPanelType } = currentViewDetails;
query,
name,
uuid,
panelType: currentPanelType,
} = currentViewDetails;
handleExplorerTabChange(currentPanelType, { handleExplorerTabChange(currentPanelType, {
query, query,
name, name,
uuid, id,
}); });
}, },
[viewData, handleExplorerTabChange], [viewData, handleExplorerTabChange],

View File

@ -4,7 +4,7 @@ import { DataSource } from 'types/common/queryBuilder';
export const viewMockData: ViewProps[] = [ export const viewMockData: ViewProps[] = [
{ {
uuid: 'view1', id: 'view1',
name: 'View 1', name: 'View 1',
createdBy: 'User 1', createdBy: 'User 1',
category: 'category 1', category: 'category 1',
@ -17,7 +17,7 @@ export const viewMockData: ViewProps[] = [
updatedBy: 'User 1', updatedBy: 'User 1',
}, },
{ {
uuid: 'view2', id: 'view2',
name: 'View 2', name: 'View 2',
createdBy: 'User 2', createdBy: 'User 2',
category: 'category 2', category: 'category 2',

View File

@ -25,9 +25,9 @@ describe('MenuItemGenerator', () => {
<MockQueryClientProvider> <MockQueryClientProvider>
<MenuItemGenerator <MenuItemGenerator
viewName={viewMockData[0].name} viewName={viewMockData[0].name}
viewKey={viewMockData[0].uuid} viewKey={viewMockData[0].id}
createdBy={viewMockData[0].createdBy} createdBy={viewMockData[0].createdBy}
uuid={viewMockData[0].uuid} uuid={viewMockData[0].id}
refetchAllView={jest.fn()} refetchAllView={jest.fn()}
viewData={viewMockData} viewData={viewMockData}
sourcePage={DataSource.TRACES} sourcePage={DataSource.TRACES}
@ -43,9 +43,9 @@ describe('MenuItemGenerator', () => {
<MockQueryClientProvider> <MockQueryClientProvider>
<MenuItemGenerator <MenuItemGenerator
viewName={viewMockData[0].name} viewName={viewMockData[0].name}
viewKey={viewMockData[0].uuid} viewKey={viewMockData[0].id}
createdBy={viewMockData[0].createdBy} createdBy={viewMockData[0].createdBy}
uuid={viewMockData[0].uuid} uuid={viewMockData[0].id}
refetchAllView={jest.fn()} refetchAllView={jest.fn()}
viewData={viewMockData} viewData={viewMockData}
sourcePage={DataSource.TRACES} sourcePage={DataSource.TRACES}

View File

@ -26,7 +26,7 @@ export type GetViewDetailsUsingViewKey = (
| { | {
query: Query; query: Query;
name: string; name: string;
uuid: string; id: string;
panelType: PANEL_TYPES; panelType: PANEL_TYPES;
extraData?: string; extraData?: string;
} }

View File

@ -27,11 +27,11 @@ export const getViewDetailsUsingViewKey: GetViewDetailsUsingViewKey = (
viewKey, viewKey,
data, data,
) => { ) => {
const selectedView = data?.find((view) => view.uuid === viewKey); const selectedView = data?.find((view) => view.id === viewKey);
if (selectedView) { if (selectedView) {
const { compositeQuery, name, uuid, extraData } = selectedView; const { compositeQuery, name, id, extraData } = selectedView;
const query = mapQueryDataFromApi(compositeQuery); const query = mapQueryDataFromApi(compositeQuery);
return { query, name, uuid, panelType: compositeQuery.panelType, extraData }; return { query, name, id, panelType: compositeQuery.panelType, extraData };
} }
return undefined; return undefined;
}; };

View File

@ -223,7 +223,7 @@ function ExplorerOptions({
const viewName = useGetSearchQueryParam(QueryParams.viewName) || ''; const viewName = useGetSearchQueryParam(QueryParams.viewName) || '';
const viewKey = useGetSearchQueryParam(QueryParams.viewKey) || ''; const viewKey = useGetSearchQueryParam(QueryParams.viewKey) || '';
const extraData = viewsData?.data?.data?.find((view) => view.uuid === viewKey) const extraData = viewsData?.data?.data?.find((view) => view.id === viewKey)
?.extraData; ?.extraData;
const extraDataColor = extraData ? JSON.parse(extraData).color : ''; const extraDataColor = extraData ? JSON.parse(extraData).color : '';
@ -357,17 +357,12 @@ function ExplorerOptions({
viewsData?.data?.data, viewsData?.data?.data,
); );
if (!currentViewDetails) return; if (!currentViewDetails) return;
const { const { query, name, id, panelType: currentPanelType } = currentViewDetails;
query,
name,
uuid,
panelType: currentPanelType,
} = currentViewDetails;
handleExplorerTabChange(currentPanelType, { handleExplorerTabChange(currentPanelType, {
query, query,
name, name,
uuid, id,
}); });
}, },
[viewsData, handleExplorerTabChange], [viewsData, handleExplorerTabChange],
@ -694,7 +689,7 @@ function ExplorerOptions({
bgColor = extraData.color; bgColor = extraData.color;
} }
return ( return (
<Select.Option key={view.uuid} value={view.name}> <Select.Option key={view.id} value={view.name}>
<div className="render-options"> <div className="render-options">
<span <span
className="dot" className="dot"

View File

@ -63,17 +63,17 @@ export default function SavedViews({
const handleRedirectQuery = (view: ViewProps): void => { const handleRedirectQuery = (view: ViewProps): void => {
logEvent('Homepage: Saved view clicked', { logEvent('Homepage: Saved view clicked', {
viewId: view.uuid, viewId: view.id,
viewName: view.name, viewName: view.name,
entity: selectedEntity, entity: selectedEntity,
}); });
const currentViewDetails = getViewDetailsUsingViewKey( const currentViewDetails = getViewDetailsUsingViewKey(
view.uuid, view.id,
selectedEntity === 'logs' ? logsViews : tracesViews, selectedEntity === 'logs' ? logsViews : tracesViews,
); );
if (!currentViewDetails) return; if (!currentViewDetails) return;
const { query, name, uuid, panelType: currentPanelType } = currentViewDetails; const { query, name, id, panelType: currentPanelType } = currentViewDetails;
if (selectedEntity) { if (selectedEntity) {
handleExplorerTabChange( handleExplorerTabChange(
@ -81,7 +81,7 @@ export default function SavedViews({
{ {
query, query,
name, name,
uuid, id,
}, },
SOURCEPAGE_VS_ROUTES[selectedEntity], SOURCEPAGE_VS_ROUTES[selectedEntity],
); );

View File

@ -70,7 +70,7 @@ export const useHandleExplorerTabChange = (): {
{ {
[QueryParams.panelTypes]: newPanelType, [QueryParams.panelTypes]: newPanelType,
[QueryParams.viewName]: currentQueryData?.name || viewName, [QueryParams.viewName]: currentQueryData?.name || viewName,
[QueryParams.viewKey]: currentQueryData?.uuid || viewKey, [QueryParams.viewKey]: currentQueryData?.id || viewKey,
}, },
redirectToUrl, redirectToUrl,
); );
@ -78,7 +78,7 @@ export const useHandleExplorerTabChange = (): {
redirectWithQueryBuilderData(query, { redirectWithQueryBuilderData(query, {
[QueryParams.panelTypes]: newPanelType, [QueryParams.panelTypes]: newPanelType,
[QueryParams.viewName]: currentQueryData?.name || viewName, [QueryParams.viewName]: currentQueryData?.name || viewName,
[QueryParams.viewKey]: currentQueryData?.uuid || viewKey, [QueryParams.viewKey]: currentQueryData?.id || viewKey,
}); });
} }
}, },
@ -90,6 +90,6 @@ export const useHandleExplorerTabChange = (): {
interface ICurrentQueryData { interface ICurrentQueryData {
name: string; name: string;
uuid: string; id: string;
query: Query; query: Query;
} }

View File

@ -2,7 +2,7 @@ export const explorerView = {
status: 'success', status: 'success',
data: [ data: [
{ {
uuid: 'test-uuid-1', id: 'test-uuid-1',
name: 'Table View', name: 'Table View',
category: '', category: '',
createdAt: '2023-08-29T18:04:10.906310033Z', createdAt: '2023-08-29T18:04:10.906310033Z',
@ -78,7 +78,7 @@ export const explorerView = {
extraData: '{"color":"#00ffd0"}', extraData: '{"color":"#00ffd0"}',
}, },
{ {
uuid: '8c4bf492-d54d-4ab2-a8d6-9c1563f46e1f', id: '8c4bf492-d54d-4ab2-a8d6-9c1563f46e1f',
name: 'R-test panel', name: 'R-test panel',
category: '', category: '',
createdAt: '2024-07-01T13:45:57.924686766Z', createdAt: '2024-07-01T13:45:57.924686766Z',

View File

@ -81,7 +81,7 @@ function SaveView(): JSX.Element {
}; };
const handleEditModelOpen = (view: ViewProps, color: string): void => { const handleEditModelOpen = (view: ViewProps, color: string): void => {
setActiveViewKey(view.uuid); setActiveViewKey(view.id);
setColor(color); setColor(color);
setActiveViewName(view.name); setActiveViewName(view.name);
setNewViewName(view.name); setNewViewName(view.name);
@ -188,11 +188,11 @@ function SaveView(): JSX.Element {
const handleRedirectQuery = (view: ViewProps): void => { const handleRedirectQuery = (view: ViewProps): void => {
const currentViewDetails = getViewDetailsUsingViewKey( const currentViewDetails = getViewDetailsUsingViewKey(
view.uuid, view.id,
viewsData?.data.data, viewsData?.data.data,
); );
if (!currentViewDetails) return; if (!currentViewDetails) return;
const { query, name, uuid, panelType: currentPanelType } = currentViewDetails; const { query, name, id, panelType: currentPanelType } = currentViewDetails;
if (sourcepage) { if (sourcepage) {
handleExplorerTabChange( handleExplorerTabChange(
@ -200,7 +200,7 @@ function SaveView(): JSX.Element {
{ {
query, query,
name, name,
uuid, id,
}, },
SOURCEPAGE_VS_ROUTES[sourcepage], SOURCEPAGE_VS_ROUTES[sourcepage],
); );
@ -258,7 +258,7 @@ function SaveView(): JSX.Element {
className={isEditDeleteSupported ? '' : 'hidden'} className={isEditDeleteSupported ? '' : 'hidden'}
color={Color.BG_CHERRY_500} color={Color.BG_CHERRY_500}
data-testid="delete-view" data-testid="delete-view"
onClick={(): void => handleDeleteModelOpen(view.uuid, view.name)} onClick={(): void => handleDeleteModelOpen(view.id, view.name)}
/> />
</div> </div>
</div> </div>

View File

@ -3,7 +3,7 @@ import { DataSource } from 'types/common/queryBuilder';
import { ICompositeMetricQuery } from '../alerts/compositeQuery'; import { ICompositeMetricQuery } from '../alerts/compositeQuery';
export interface ViewProps { export interface ViewProps {
uuid: string; id: string;
name: string; name: string;
category: string; category: string;
createdAt: string; createdAt: string;

View File

@ -14,7 +14,7 @@ import (
"github.com/SigNoz/signoz/pkg/sqlstore" "github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types" "github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes" "github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/google/uuid" "github.com/SigNoz/signoz/pkg/valuer"
"go.uber.org/zap" "go.uber.org/zap"
) )
@ -47,7 +47,7 @@ func GetViews(ctx context.Context, orgID string) ([]*v3.SavedView, error) {
return nil, fmt.Errorf("error in unmarshalling explorer query data: %s", err.Error()) return nil, fmt.Errorf("error in unmarshalling explorer query data: %s", err.Error())
} }
savedViews = append(savedViews, &v3.SavedView{ savedViews = append(savedViews, &v3.SavedView{
UUID: view.UUID, ID: view.ID,
Name: view.Name, Name: view.Name,
Category: view.Category, Category: view.Category,
CreatedAt: view.CreatedAt, CreatedAt: view.CreatedAt,
@ -83,7 +83,7 @@ func GetViewsForFilters(ctx context.Context, orgID string, sourcePage string, na
return nil, fmt.Errorf("error in unmarshalling explorer query data: %s", err.Error()) return nil, fmt.Errorf("error in unmarshalling explorer query data: %s", err.Error())
} }
savedViews = append(savedViews, &v3.SavedView{ savedViews = append(savedViews, &v3.SavedView{
UUID: view.UUID, ID: view.ID,
Name: view.Name, Name: view.Name,
CreatedAt: view.CreatedAt, CreatedAt: view.CreatedAt,
CreatedBy: view.CreatedBy, CreatedBy: view.CreatedBy,
@ -98,23 +98,19 @@ func GetViewsForFilters(ctx context.Context, orgID string, sourcePage string, na
return savedViews, nil return savedViews, nil
} }
func CreateView(ctx context.Context, orgID string, view v3.SavedView) (string, error) { func CreateView(ctx context.Context, orgID string, view v3.SavedView) (valuer.UUID, error) {
data, err := json.Marshal(view.CompositeQuery) data, err := json.Marshal(view.CompositeQuery)
if err != nil { if err != nil {
return "", fmt.Errorf("error in marshalling explorer query data: %s", err.Error()) return valuer.UUID{}, fmt.Errorf("error in marshalling explorer query data: %s", err.Error())
} }
uuid_ := view.UUID uuid := valuer.GenerateUUID()
if uuid_ == "" {
uuid_ = uuid.New().String()
}
createdAt := time.Now() createdAt := time.Now()
updatedAt := time.Now() updatedAt := time.Now()
claims, ok := authtypes.ClaimsFromContext(ctx) claims, ok := authtypes.ClaimsFromContext(ctx)
if !ok { if !ok {
return "", fmt.Errorf("error in getting email from context") return valuer.UUID{}, fmt.Errorf("error in getting email from context")
} }
createBy := claims.Email createBy := claims.Email
@ -129,8 +125,10 @@ func CreateView(ctx context.Context, orgID string, view v3.SavedView) (string, e
CreatedBy: createBy, CreatedBy: createBy,
UpdatedBy: updatedBy, UpdatedBy: updatedBy,
}, },
OrgID: orgID, OrgID: orgID,
UUID: uuid_, Identifiable: types.Identifiable{
ID: uuid,
},
Name: view.Name, Name: view.Name,
Category: view.Category, Category: view.Category,
SourcePage: view.SourcePage, SourcePage: view.SourcePage,
@ -141,14 +139,14 @@ func CreateView(ctx context.Context, orgID string, view v3.SavedView) (string, e
_, err = store.BunDB().NewInsert().Model(&dbView).Exec(ctx) _, err = store.BunDB().NewInsert().Model(&dbView).Exec(ctx)
if err != nil { if err != nil {
return "", fmt.Errorf("error in creating saved view: %s", err.Error()) return valuer.UUID{}, fmt.Errorf("error in creating saved view: %s", err.Error())
} }
return uuid_, nil return uuid, nil
} }
func GetView(ctx context.Context, orgID string, uuid_ string) (*v3.SavedView, error) { func GetView(ctx context.Context, orgID string, uuid valuer.UUID) (*v3.SavedView, error) {
var view types.SavedView var view types.SavedView
err := store.BunDB().NewSelect().Model(&view).Where("org_id = ? AND uuid = ?", orgID, uuid_).Scan(ctx) err := store.BunDB().NewSelect().Model(&view).Where("org_id = ? AND id = ?", orgID, uuid.StringValue()).Scan(ctx)
if err != nil { if err != nil {
return nil, fmt.Errorf("error in getting saved view: %s", err.Error()) return nil, fmt.Errorf("error in getting saved view: %s", err.Error())
} }
@ -159,7 +157,7 @@ func GetView(ctx context.Context, orgID string, uuid_ string) (*v3.SavedView, er
return nil, fmt.Errorf("error in unmarshalling explorer query data: %s", err.Error()) return nil, fmt.Errorf("error in unmarshalling explorer query data: %s", err.Error())
} }
return &v3.SavedView{ return &v3.SavedView{
UUID: view.UUID, ID: view.ID,
Name: view.Name, Name: view.Name,
Category: view.Category, Category: view.Category,
CreatedAt: view.CreatedAt, CreatedAt: view.CreatedAt,
@ -173,7 +171,7 @@ func GetView(ctx context.Context, orgID string, uuid_ string) (*v3.SavedView, er
}, nil }, nil
} }
func UpdateView(ctx context.Context, orgID string, uuid_ string, view v3.SavedView) error { func UpdateView(ctx context.Context, orgID string, uuid valuer.UUID, view v3.SavedView) error {
data, err := json.Marshal(view.CompositeQuery) data, err := json.Marshal(view.CompositeQuery)
if err != nil { if err != nil {
return fmt.Errorf("error in marshalling explorer query data: %s", err.Error()) return fmt.Errorf("error in marshalling explorer query data: %s", err.Error())
@ -191,7 +189,7 @@ func UpdateView(ctx context.Context, orgID string, uuid_ string, view v3.SavedVi
Model(&types.SavedView{}). Model(&types.SavedView{}).
Set("updated_at = ?, updated_by = ?, name = ?, category = ?, source_page = ?, tags = ?, data = ?, extra_data = ?", Set("updated_at = ?, updated_by = ?, name = ?, category = ?, source_page = ?, tags = ?, data = ?, extra_data = ?",
updatedAt, updatedBy, view.Name, view.Category, view.SourcePage, strings.Join(view.Tags, ","), data, view.ExtraData). updatedAt, updatedBy, view.Name, view.Category, view.SourcePage, strings.Join(view.Tags, ","), data, view.ExtraData).
Where("uuid = ?", uuid_). Where("id = ?", uuid.StringValue()).
Where("org_id = ?", orgID). Where("org_id = ?", orgID).
Exec(ctx) Exec(ctx)
if err != nil { if err != nil {
@ -200,10 +198,10 @@ func UpdateView(ctx context.Context, orgID string, uuid_ string, view v3.SavedVi
return nil return nil
} }
func DeleteView(ctx context.Context, orgID string, uuid_ string) error { func DeleteView(ctx context.Context, orgID string, uuid valuer.UUID) error {
_, err := store.BunDB().NewDelete(). _, err := store.BunDB().NewDelete().
Model(&types.SavedView{}). Model(&types.SavedView{}).
Where("uuid = ?", uuid_). Where("id = ?", uuid.StringValue()).
Where("org_id = ?", orgID). Where("org_id = ?", orgID).
Exec(ctx) Exec(ctx)
if err != nil { if err != nil {

View File

@ -23,6 +23,7 @@ import (
"github.com/SigNoz/signoz/pkg/http/render" "github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/query-service/app/metricsexplorer" "github.com/SigNoz/signoz/pkg/query-service/app/metricsexplorer"
"github.com/SigNoz/signoz/pkg/signoz" "github.com/SigNoz/signoz/pkg/signoz"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
@ -4625,12 +4626,18 @@ func (aH *APIHandler) createSavedViews(w http.ResponseWriter, r *http.Request) {
func (aH *APIHandler) getSavedView(w http.ResponseWriter, r *http.Request) { func (aH *APIHandler) getSavedView(w http.ResponseWriter, r *http.Request) {
viewID := mux.Vars(r)["viewId"] viewID := mux.Vars(r)["viewId"]
viewUUID, err := valuer.NewUUID(viewID)
if err != nil {
render.Error(w, errorsV2.Newf(errorsV2.TypeInvalidInput, errorsV2.CodeInvalidInput, err.Error()))
return
}
claims, ok := authtypes.ClaimsFromContext(r.Context()) claims, ok := authtypes.ClaimsFromContext(r.Context())
if !ok { if !ok {
render.Error(w, errorsV2.Newf(errorsV2.TypeUnauthenticated, errorsV2.CodeUnauthenticated, "unauthenticated")) render.Error(w, errorsV2.Newf(errorsV2.TypeUnauthenticated, errorsV2.CodeUnauthenticated, "unauthenticated"))
return return
} }
view, err := explorer.GetView(r.Context(), claims.OrgID, viewID) view, err := explorer.GetView(r.Context(), claims.OrgID, viewUUID)
if err != nil { if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: err}, nil) RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: err}, nil)
return return
@ -4641,8 +4648,13 @@ func (aH *APIHandler) getSavedView(w http.ResponseWriter, r *http.Request) {
func (aH *APIHandler) updateSavedView(w http.ResponseWriter, r *http.Request) { func (aH *APIHandler) updateSavedView(w http.ResponseWriter, r *http.Request) {
viewID := mux.Vars(r)["viewId"] viewID := mux.Vars(r)["viewId"]
viewUUID, err := valuer.NewUUID(viewID)
if err != nil {
render.Error(w, errorsV2.Newf(errorsV2.TypeInvalidInput, errorsV2.CodeInvalidInput, err.Error()))
return
}
var view v3.SavedView var view v3.SavedView
err := json.NewDecoder(r.Body).Decode(&view) err = json.NewDecoder(r.Body).Decode(&view)
if err != nil { if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: err}, nil) RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: err}, nil)
return return
@ -4658,7 +4670,7 @@ func (aH *APIHandler) updateSavedView(w http.ResponseWriter, r *http.Request) {
render.Error(w, errorsV2.Newf(errorsV2.TypeUnauthenticated, errorsV2.CodeUnauthenticated, "unauthenticated")) render.Error(w, errorsV2.Newf(errorsV2.TypeUnauthenticated, errorsV2.CodeUnauthenticated, "unauthenticated"))
return return
} }
err = explorer.UpdateView(r.Context(), claims.OrgID, viewID, view) err = explorer.UpdateView(r.Context(), claims.OrgID, viewUUID, view)
if err != nil { if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: err}, nil) RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: err}, nil)
return return
@ -4670,12 +4682,17 @@ func (aH *APIHandler) updateSavedView(w http.ResponseWriter, r *http.Request) {
func (aH *APIHandler) deleteSavedView(w http.ResponseWriter, r *http.Request) { func (aH *APIHandler) deleteSavedView(w http.ResponseWriter, r *http.Request) {
viewID := mux.Vars(r)["viewId"] viewID := mux.Vars(r)["viewId"]
viewUUID, err := valuer.NewUUID(viewID)
if err != nil {
render.Error(w, errorsV2.Newf(errorsV2.TypeInvalidInput, errorsV2.CodeInvalidInput, err.Error()))
return
}
claims, ok := authtypes.ClaimsFromContext(r.Context()) claims, ok := authtypes.ClaimsFromContext(r.Context())
if !ok { if !ok {
render.Error(w, errorsV2.Newf(errorsV2.TypeUnauthenticated, errorsV2.CodeUnauthenticated, "unauthenticated")) render.Error(w, errorsV2.Newf(errorsV2.TypeUnauthenticated, errorsV2.CodeUnauthenticated, "unauthenticated"))
return return
} }
err := explorer.DeleteView(r.Context(), claims.OrgID, viewID) err = explorer.DeleteView(r.Context(), claims.OrgID, viewUUID)
if err != nil { if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: err}, nil) RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: err}, nil)
return return

View File

@ -9,7 +9,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/google/uuid" "github.com/SigNoz/signoz/pkg/valuer"
"github.com/pkg/errors" "github.com/pkg/errors"
"go.uber.org/zap" "go.uber.org/zap"
) )
@ -1412,7 +1412,7 @@ func (p *Point) UnmarshalJSON(data []byte) error {
// The source page name is used to identify the page that initiated the query // The source page name is used to identify the page that initiated the query
// The source page could be "traces", "logs", "metrics". // The source page could be "traces", "logs", "metrics".
type SavedView struct { type SavedView struct {
UUID string `json:"uuid,omitempty"` ID valuer.UUID `json:"id,omitempty"`
Name string `json:"name"` Name string `json:"name"`
Category string `json:"category"` Category string `json:"category"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
@ -1432,9 +1432,6 @@ func (eq *SavedView) Validate() error {
return fmt.Errorf("composite query is required") return fmt.Errorf("composite query is required")
} }
if eq.UUID == "" {
eq.UUID = uuid.New().String()
}
return eq.CompositeQuery.Validate() return eq.CompositeQuery.Validate()
} }

View File

@ -62,6 +62,7 @@ func NewSQLMigrationProviderFactories(sqlstore sqlstore.SQLStore) factory.NamedM
sqlmigration.NewUpdateDashboardAndSavedViewsFactory(sqlstore), sqlmigration.NewUpdateDashboardAndSavedViewsFactory(sqlstore),
sqlmigration.NewUpdatePatAndOrgDomainsFactory(sqlstore), sqlmigration.NewUpdatePatAndOrgDomainsFactory(sqlstore),
sqlmigration.NewUpdatePipelines(sqlstore), sqlmigration.NewUpdatePipelines(sqlstore),
sqlmigration.NewDropLicensesSitesFactory(sqlstore),
) )
} }

View File

@ -0,0 +1,62 @@
package sqlmigration
import (
"context"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
type dropLicensesSites struct {
store sqlstore.SQLStore
}
func NewDropLicensesSitesFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("drop_licenses_sites"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return newDropLicensesSites(ctx, ps, c, sqlstore)
})
}
func newDropLicensesSites(_ context.Context, _ factory.ProviderSettings, _ Config, store sqlstore.SQLStore) (SQLMigration, error) {
return &dropLicensesSites{store: store}, nil
}
func (migration *dropLicensesSites) Register(migrations *migrate.Migrations) error {
if err := migrations.Register(migration.Up, migration.Down); err != nil {
return err
}
return nil
}
func (migration *dropLicensesSites) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.NewDropTable().IfExists().Table("sites").Exec(ctx); err != nil {
return err
}
if _, err := tx.NewDropTable().IfExists().Table("licenses").Exec(ctx); err != nil {
return err
}
_, err = migration.store.Dialect().RenameColumn(ctx, tx, "saved_views", "uuid", "id")
if err != nil {
return err
}
if err := tx.Commit(); err != nil {
return err
}
return nil
}
func (migration *dropLicensesSites) Down(context.Context, *bun.DB) error {
return nil
}

View File

@ -6,10 +6,10 @@ import (
"github.com/uptrace/bun" "github.com/uptrace/bun"
) )
type PGDialect struct { type dialect struct {
} }
func (dialect *PGDialect) MigrateIntToTimestamp(ctx context.Context, bun bun.IDB, table string, column string) error { func (dialect *dialect) MigrateIntToTimestamp(ctx context.Context, bun bun.IDB, table string, column string) error {
columnType, err := dialect.GetColumnType(ctx, bun, table, column) columnType, err := dialect.GetColumnType(ctx, bun, table, column)
if err != nil { if err != nil {
return err return err
@ -21,16 +21,22 @@ func (dialect *PGDialect) MigrateIntToTimestamp(ctx context.Context, bun bun.IDB
} }
// if the columns is integer then do this // if the columns is integer then do this
if _, err := bun.ExecContext(ctx, `ALTER TABLE `+table+` RENAME COLUMN `+column+` TO `+column+`_old`); err != nil { if _, err := bun.
ExecContext(ctx, `ALTER TABLE `+table+` RENAME COLUMN `+column+` TO `+column+`_old`); err != nil {
return err return err
} }
// add new timestamp column // add new timestamp column
if _, err := bun.NewAddColumn().Table(table).ColumnExpr(column + " TIMESTAMP").Exec(ctx); err != nil { if _, err := bun.
NewAddColumn().
Table(table).
ColumnExpr(column + " TIMESTAMP").
Exec(ctx); err != nil {
return err return err
} }
if _, err := bun.NewUpdate(). if _, err := bun.
NewUpdate().
Table(table). Table(table).
Set(column + " = to_timestamp(cast(" + column + "_old as INTEGER))"). Set(column + " = to_timestamp(cast(" + column + "_old as INTEGER))").
Where("1=1"). Where("1=1").
@ -39,14 +45,18 @@ func (dialect *PGDialect) MigrateIntToTimestamp(ctx context.Context, bun bun.IDB
} }
// drop old column // drop old column
if _, err := bun.NewDropColumn().Table(table).Column(column + "_old").Exec(ctx); err != nil { if _, err := bun.
NewDropColumn().
Table(table).
Column(column + "_old").
Exec(ctx); err != nil {
return err return err
} }
return nil return nil
} }
func (dialect *PGDialect) MigrateIntToBoolean(ctx context.Context, bun bun.IDB, table string, column string) error { func (dialect *dialect) MigrateIntToBoolean(ctx context.Context, bun bun.IDB, table string, column string) error {
columnType, err := dialect.GetColumnType(ctx, bun, table, column) columnType, err := dialect.GetColumnType(ctx, bun, table, column)
if err != nil { if err != nil {
return err return err
@ -56,12 +66,17 @@ func (dialect *PGDialect) MigrateIntToBoolean(ctx context.Context, bun bun.IDB,
return nil return nil
} }
if _, err := bun.ExecContext(ctx, `ALTER TABLE `+table+` RENAME COLUMN `+column+` TO `+column+`_old`); err != nil { if _, err := bun.
ExecContext(ctx, `ALTER TABLE `+table+` RENAME COLUMN `+column+` TO `+column+`_old`); err != nil {
return err return err
} }
// add new boolean column // add new boolean column
if _, err := bun.NewAddColumn().Table(table).ColumnExpr(column + " BOOLEAN").Exec(ctx); err != nil { if _, err := bun.
NewAddColumn().
Table(table).
ColumnExpr(column + " BOOLEAN").
Exec(ctx); err != nil {
return err return err
} }
@ -82,7 +97,7 @@ func (dialect *PGDialect) MigrateIntToBoolean(ctx context.Context, bun bun.IDB,
return nil return nil
} }
func (dialect *PGDialect) GetColumnType(ctx context.Context, bun bun.IDB, table string, column string) (string, error) { func (dialect *dialect) GetColumnType(ctx context.Context, bun bun.IDB, table string, column string) (string, error) {
var columnType string var columnType string
err := bun.NewSelect(). err := bun.NewSelect().
@ -98,7 +113,7 @@ func (dialect *PGDialect) GetColumnType(ctx context.Context, bun bun.IDB, table
return columnType, nil return columnType, nil
} }
func (dialect *PGDialect) ColumnExists(ctx context.Context, bun bun.IDB, table string, column string) (bool, error) { func (dialect *dialect) ColumnExists(ctx context.Context, bun bun.IDB, table string, column string) (bool, error) {
var count int var count int
err := bun.NewSelect(). err := bun.NewSelect().
ColumnExpr("COUNT(*)"). ColumnExpr("COUNT(*)").
@ -113,3 +128,26 @@ func (dialect *PGDialect) ColumnExists(ctx context.Context, bun bun.IDB, table s
return count > 0, nil return count > 0, nil
} }
func (dialect *dialect) RenameColumn(ctx context.Context, bun bun.IDB, table string, oldColumnName string, newColumnName string) (bool, error) {
oldColumnExists, err := dialect.ColumnExists(ctx, bun, table, oldColumnName)
if err != nil {
return false, err
}
newColumnExists, err := dialect.ColumnExists(ctx, bun, table, newColumnName)
if err != nil {
return false, err
}
if !oldColumnExists && newColumnExists {
return true, nil
}
_, err = bun.
ExecContext(ctx, "ALTER TABLE "+table+" RENAME COLUMN "+oldColumnName+" TO "+newColumnName)
if err != nil {
return false, err
}
return true, nil
}

View File

@ -18,7 +18,7 @@ type provider struct {
sqldb *sql.DB sqldb *sql.DB
bundb *sqlstore.BunDB bundb *sqlstore.BunDB
sqlxdb *sqlx.DB sqlxdb *sqlx.DB
dialect *PGDialect dialect *dialect
} }
func NewFactory(hookFactories ...factory.ProviderFactory[sqlstore.SQLStoreHook, sqlstore.Config]) factory.ProviderFactory[sqlstore.SQLStore, sqlstore.Config] { func NewFactory(hookFactories ...factory.ProviderFactory[sqlstore.SQLStoreHook, sqlstore.Config]) factory.ProviderFactory[sqlstore.SQLStore, sqlstore.Config] {
@ -60,7 +60,7 @@ func New(ctx context.Context, providerSettings factory.ProviderSettings, config
sqldb: sqldb, sqldb: sqldb,
bundb: sqlstore.NewBunDB(settings, sqldb, pgdialect.New(), hooks), bundb: sqlstore.NewBunDB(settings, sqldb, pgdialect.New(), hooks),
sqlxdb: sqlx.NewDb(sqldb, "postgres"), sqlxdb: sqlx.NewDb(sqldb, "postgres"),
dialect: &PGDialect{}, dialect: new(dialect),
}, nil }, nil
} }

View File

@ -6,10 +6,10 @@ import (
"github.com/uptrace/bun" "github.com/uptrace/bun"
) )
type SQLiteDialect struct { type dialect struct {
} }
func (dialect *SQLiteDialect) MigrateIntToTimestamp(ctx context.Context, bun bun.IDB, table string, column string) error { func (dialect *dialect) MigrateIntToTimestamp(ctx context.Context, bun bun.IDB, table string, column string) error {
columnType, err := dialect.GetColumnType(ctx, bun, table, column) columnType, err := dialect.GetColumnType(ctx, bun, table, column)
if err != nil { if err != nil {
return err return err
@ -25,12 +25,17 @@ func (dialect *SQLiteDialect) MigrateIntToTimestamp(ctx context.Context, bun bun
} }
// add new timestamp column // add new timestamp column
if _, err := bun.NewAddColumn().Table(table).ColumnExpr(column + " TIMESTAMP").Exec(ctx); err != nil { if _, err := bun.
NewAddColumn().
Table(table).
ColumnExpr(column + " TIMESTAMP").
Exec(ctx); err != nil {
return err return err
} }
// copy data from old column to new column, converting from int (unix timestamp) to timestamp // copy data from old column to new column, converting from int (unix timestamp) to timestamp
if _, err := bun.NewUpdate(). if _, err := bun.
NewUpdate().
Table(table). Table(table).
Set(column + " = datetime(" + column + "_old, 'unixepoch')"). Set(column + " = datetime(" + column + "_old, 'unixepoch')").
Where("1=1"). Where("1=1").
@ -46,7 +51,7 @@ func (dialect *SQLiteDialect) MigrateIntToTimestamp(ctx context.Context, bun bun
return nil return nil
} }
func (dialect *SQLiteDialect) MigrateIntToBoolean(ctx context.Context, bun bun.IDB, table string, column string) error { func (dialect *dialect) MigrateIntToBoolean(ctx context.Context, bun bun.IDB, table string, column string) error {
columnType, err := dialect.GetColumnType(ctx, bun, table, column) columnType, err := dialect.GetColumnType(ctx, bun, table, column)
if err != nil { if err != nil {
return err return err
@ -66,7 +71,8 @@ func (dialect *SQLiteDialect) MigrateIntToBoolean(ctx context.Context, bun bun.I
} }
// copy data from old column to new column, converting from int to boolean // copy data from old column to new column, converting from int to boolean
if _, err := bun.NewUpdate(). if _, err := bun.
NewUpdate().
Table(table). Table(table).
Set(column + " = CASE WHEN " + column + "_old = 1 THEN true ELSE false END"). Set(column + " = CASE WHEN " + column + "_old = 1 THEN true ELSE false END").
Where("1=1"). Where("1=1").
@ -82,10 +88,11 @@ func (dialect *SQLiteDialect) MigrateIntToBoolean(ctx context.Context, bun bun.I
return nil return nil
} }
func (dialect *SQLiteDialect) GetColumnType(ctx context.Context, bun bun.IDB, table string, column string) (string, error) { func (dialect *dialect) GetColumnType(ctx context.Context, bun bun.IDB, table string, column string) (string, error) {
var columnType string var columnType string
err := bun.NewSelect(). err := bun.
NewSelect().
ColumnExpr("type"). ColumnExpr("type").
TableExpr("pragma_table_info(?)", table). TableExpr("pragma_table_info(?)", table).
Where("name = ?", column). Where("name = ?", column).
@ -97,7 +104,7 @@ func (dialect *SQLiteDialect) GetColumnType(ctx context.Context, bun bun.IDB, ta
return columnType, nil return columnType, nil
} }
func (dialect *SQLiteDialect) ColumnExists(ctx context.Context, bun bun.IDB, table string, column string) (bool, error) { func (dialect *dialect) ColumnExists(ctx context.Context, bun bun.IDB, table string, column string) (bool, error) {
var count int var count int
err := bun.NewSelect(). err := bun.NewSelect().
ColumnExpr("COUNT(*)"). ColumnExpr("COUNT(*)").
@ -111,3 +118,26 @@ func (dialect *SQLiteDialect) ColumnExists(ctx context.Context, bun bun.IDB, tab
return count > 0, nil return count > 0, nil
} }
func (dialect *dialect) RenameColumn(ctx context.Context, bun bun.IDB, table string, oldColumnName string, newColumnName string) (bool, error) {
oldColumnExists, err := dialect.ColumnExists(ctx, bun, table, oldColumnName)
if err != nil {
return false, err
}
newColumnExists, err := dialect.ColumnExists(ctx, bun, table, newColumnName)
if err != nil {
return false, err
}
if !oldColumnExists && newColumnExists {
return true, nil
}
_, err = bun.
ExecContext(ctx, "ALTER TABLE "+table+" RENAME COLUMN "+oldColumnName+" TO "+newColumnName)
if err != nil {
return false, err
}
return true, nil
}

View File

@ -17,7 +17,7 @@ type provider struct {
sqldb *sql.DB sqldb *sql.DB
bundb *sqlstore.BunDB bundb *sqlstore.BunDB
sqlxdb *sqlx.DB sqlxdb *sqlx.DB
dialect *SQLiteDialect dialect *dialect
} }
func NewFactory(hookFactories ...factory.ProviderFactory[sqlstore.SQLStoreHook, sqlstore.Config]) factory.ProviderFactory[sqlstore.SQLStore, sqlstore.Config] { func NewFactory(hookFactories ...factory.ProviderFactory[sqlstore.SQLStoreHook, sqlstore.Config]) factory.ProviderFactory[sqlstore.SQLStore, sqlstore.Config] {
@ -50,7 +50,7 @@ func New(ctx context.Context, providerSettings factory.ProviderSettings, config
sqldb: sqldb, sqldb: sqldb,
bundb: sqlstore.NewBunDB(settings, sqldb, sqlitedialect.New(), hooks), bundb: sqlstore.NewBunDB(settings, sqldb, sqlitedialect.New(), hooks),
sqlxdb: sqlx.NewDb(sqldb, "sqlite3"), sqlxdb: sqlx.NewDb(sqldb, "sqlite3"),
dialect: &SQLiteDialect{}, dialect: new(dialect),
}, nil }, nil
} }

View File

@ -37,8 +37,9 @@ type SQLStoreHook interface {
} }
type SQLDialect interface { type SQLDialect interface {
MigrateIntToTimestamp(ctx context.Context, bun bun.IDB, table string, column string) error MigrateIntToTimestamp(context.Context, bun.IDB, string, string) error
MigrateIntToBoolean(ctx context.Context, bun bun.IDB, table string, column string) error MigrateIntToBoolean(context.Context, bun.IDB, string, string) error
GetColumnType(ctx context.Context, bun bun.IDB, table string, column string) (string, error) GetColumnType(context.Context, bun.IDB, string, string) (string, error)
ColumnExists(ctx context.Context, bun bun.IDB, table string, column string) (bool, error) ColumnExists(context.Context, bun.IDB, string, string) (bool, error)
RenameColumn(context.Context, bun.IDB, string, string, string) (bool, error)
} }

View File

@ -6,21 +6,25 @@ import (
"github.com/uptrace/bun" "github.com/uptrace/bun"
) )
type TestDialect struct { type dialect struct {
} }
func (dialect *TestDialect) MigrateIntToTimestamp(ctx context.Context, bun bun.IDB, table string, column string) error { func (dialect *dialect) MigrateIntToTimestamp(ctx context.Context, bun bun.IDB, table string, column string) error {
return nil return nil
} }
func (dialect *TestDialect) MigrateIntToBoolean(ctx context.Context, bun bun.IDB, table string, column string) error { func (dialect *dialect) MigrateIntToBoolean(ctx context.Context, bun bun.IDB, table string, column string) error {
return nil return nil
} }
func (dialect *TestDialect) GetColumnType(ctx context.Context, bun bun.IDB, table string, column string) (string, error) { func (dialect *dialect) GetColumnType(ctx context.Context, bun bun.IDB, table string, column string) (string, error) {
return "", nil return "", nil
} }
func (dialect *TestDialect) ColumnExists(ctx context.Context, bun bun.IDB, table string, column string) (bool, error) { func (dialect *dialect) ColumnExists(ctx context.Context, bun bun.IDB, table string, column string) (bool, error) {
return false, nil return false, nil
} }
func (dialect *dialect) RenameColumn(ctx context.Context, bun bun.IDB, table string, oldColumnName string, newColumnName string) (bool, error) {
return true, nil
}

View File

@ -19,7 +19,7 @@ type Provider struct {
mock sqlmock.Sqlmock mock sqlmock.Sqlmock
bunDB *bun.DB bunDB *bun.DB
sqlxDB *sqlx.DB sqlxDB *sqlx.DB
dialect *TestDialect dialect *dialect
} }
func New(config sqlstore.Config, matcher sqlmock.QueryMatcher) *Provider { func New(config sqlstore.Config, matcher sqlmock.QueryMatcher) *Provider {
@ -43,7 +43,7 @@ func New(config sqlstore.Config, matcher sqlmock.QueryMatcher) *Provider {
mock: mock, mock: mock,
bunDB: bunDB, bunDB: bunDB,
sqlxDB: sqlxDB, sqlxDB: sqlxDB,
dialect: &TestDialect{}, dialect: new(dialect),
} }
} }

9
pkg/types/identity.go Normal file
View File

@ -0,0 +1,9 @@
package types
import (
"github.com/SigNoz/signoz/pkg/valuer"
)
type Identifiable struct {
ID valuer.UUID `json:"id" bun:"id,pk,type:text"`
}

View File

@ -7,10 +7,10 @@ import (
type SavedView struct { type SavedView struct {
bun.BaseModel `bun:"table:saved_views"` bun.BaseModel `bun:"table:saved_views"`
Identifiable
TimeAuditable TimeAuditable
UserAuditable UserAuditable
OrgID string `json:"orgId" bun:"org_id,notnull"` OrgID string `json:"orgId" bun:"org_id,notnull"`
UUID string `json:"uuid" bun:"uuid,pk,type:text"`
Name string `json:"name" bun:"name,type:text,notnull"` Name string `json:"name" bun:"name,type:text,notnull"`
Category string `json:"category" bun:"category,type:text,notnull"` Category string `json:"category" bun:"category,type:text,notnull"`
SourcePage string `json:"sourcePage" bun:"source_page,type:text,notnull"` SourcePage string `json:"sourcePage" bun:"source_page,type:text,notnull"`

120
pkg/valuer/uuid.go Normal file
View File

@ -0,0 +1,120 @@
package valuer
import (
"database/sql/driver"
"encoding/json"
"fmt"
"reflect"
"github.com/google/uuid"
)
var _ Valuer = (*UUID)(nil)
type UUID struct {
val uuid.UUID
}
func NewUUID(value string) (UUID, error) {
val, err := uuid.Parse(value)
if err != nil {
return UUID{}, err
}
return UUID{
val: val,
}, nil
}
func NewUUIDFromBytes(value []byte) (UUID, error) {
val, err := uuid.ParseBytes(value)
if err != nil {
return UUID{}, err
}
return UUID{
val: val,
}, nil
}
func MustNewUUID(val string) UUID {
uuid, err := NewUUID(val)
if err != nil {
panic(err)
}
return uuid
}
func GenerateUUID() UUID {
val, err := uuid.NewV7()
if err != nil {
panic(err)
}
return UUID{
val: val,
}
}
func (enum UUID) IsZero() bool {
return enum.val == uuid.UUID{}
}
func (enum UUID) StringValue() string {
return enum.val.String()
}
func (enum UUID) MarshalJSON() ([]byte, error) {
return json.Marshal(enum.StringValue())
}
func (enum *UUID) UnmarshalJSON(data []byte) error {
var str string
if err := json.Unmarshal(data, &str); err != nil {
return err
}
uuid, err := NewUUID(str)
if err != nil {
return err
}
*enum = uuid
return nil
}
func (enum UUID) Value() (driver.Value, error) {
return enum.StringValue(), nil
}
func (enum *UUID) Scan(val interface{}) error {
if enum == nil {
return fmt.Errorf("uuid: (nil \"%s\")", reflect.TypeOf(enum).String())
}
if val == nil {
return fmt.Errorf("uuid: (nil \"%s\")", reflect.TypeOf(val).String())
}
var enumVal UUID
switch val := val.(type) {
case string:
_enumVal, err := NewUUID(val)
if err != nil {
return fmt.Errorf("uuid: (invalid-uuid \"%s\")", err.Error())
}
enumVal = _enumVal
case []byte:
_enumVal, err := NewUUIDFromBytes(val)
if err != nil {
return fmt.Errorf("uuid: (invalid-uuid \"%s\")", err.Error())
}
enumVal = _enumVal
default:
return fmt.Errorf("uuid: (non-uuid \"%s\")", reflect.TypeOf(val).String())
}
*enum = enumVal
return nil
}

22
pkg/valuer/valuer.go Normal file
View File

@ -0,0 +1,22 @@
package valuer
import (
"database/sql"
"database/sql/driver"
"encoding/json"
)
type Valuer interface {
// IsZero returns true if the value is considered empty or zero
IsZero() bool
// StringValue returns the string representation of the value
StringValue() string
// MarshalJSON returns the JSON encoding of the value.
json.Marshaler
// UnmarshalJSON returns the JSON decoding of the value.
json.Unmarshaler
// Scan into underlying struct from a database driver's value
sql.Scanner
// Convert the struct to a database driver's value
driver.Valuer
}