Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Example frontend use of querying the backend db with sql #224

Merged
merged 1 commit into from
Sep 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"@mui/material": "^5.16.4",
"@tanstack/react-query": "^5.48.0",
"dayjs": "^1.11.12",
"kysely": "^0.27.4",
"react": "^18.3.1",
"react-router-dom": "^6.24.0"
},
Expand Down
39 changes: 24 additions & 15 deletions packages/app/src/api/pulse.api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,16 @@ import { BASE_API_URL, useBetterQuery } from '.'
import { apiRequest } from '../utils/request'
import { pulseKeys } from './keys'
import { Dayjs } from 'dayjs'
import pulseRepo from '../repos/pulse.repo'
import queryApi from './query.api'
import { getFeatureFlag } from '../utils/flag.util'
import csvUtil from '../utils/csv.util'

export function useLatestPulses() {
const queryFn = () =>
apiRequest({
url: `${BASE_API_URL}/pulses/latest`,
method: 'GET',
})
const queryFn = () => {
const sql = pulseRepo.getLatestPulses()
return queryApi.sqlQueryFn(sql)
}
return useBetterQuery<CodeClimbers.Pulse[], Error>({
queryKey: pulseKeys.latestPulses,
queryFn,
Expand Down Expand Up @@ -58,19 +61,25 @@ export function useGetSitesWithMinutes(startDate: string, endDate: string) {
export function useExportPulses() {
const exportPulses = useCallback(async () => {
try {
const response = await apiRequest({
url: `${BASE_API_URL}/pulses/export`,
method: 'GET',
responseType: 'blob',
})

const blob = new Blob([response])
const url = window.URL.createObjectURL(blob)
let blob
if (getFeatureFlag('use-frontend-db')) {
const response = await queryApi.sqlQueryFn(pulseRepo.getAllPulses())
const csvContent = csvUtil.convertPulsesToCSV(response)
blob = new Blob([csvContent])
} else {
const response = await apiRequest({
url: `${BASE_API_URL}/pulses/export`,
method: 'GET',
responseType: 'blob',
})
blob = new Blob([response])
}
const encodedUri = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.href = encodedUri
a.download = 'pulses.csv'
a.click()
window.URL.revokeObjectURL(url)
window.URL.revokeObjectURL(encodedUri)
} catch (error) {
console.error('Failed to export pulses:', error)
}
Expand Down
22 changes: 9 additions & 13 deletions packages/app/src/api/query.api.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,13 @@
import { BASE_API_URL, useBetterQuery } from '.'
import { BASE_API_URL } from '.'
import { apiRequest } from '../utils/request'

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function useGetLocalSqlQuery<T = any>(query: string) {
const queryFn = () =>
apiRequest({
url: `${BASE_API_URL}/localdb/query`,
method: 'POST',
body: { query },
credentials: 'include',
})
return useBetterQuery<T[], Error>({
queryKey: ['localdb/query'],
queryFn,
const sqlQueryFn = (query: string) =>
apiRequest({
url: `${BASE_API_URL}/localdb/query`,
method: 'POST',
body: { query },
})

export default {
sqlQueryFn,
}
30 changes: 30 additions & 0 deletions packages/app/src/repos/pulse.repo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import dbUtil from '../utils/db.util'

const getLatestPulses = () => {
// Example query
const query = dbUtil.db
.selectFrom('activities_pulse')
.selectAll()
.orderBy('id', 'desc')
.limit(10)

// Get the SQL string
const sql = query.compile()

return dbUtil.sqlWithBindings(sql)
}

const getAllPulses = () => {
const query = dbUtil.db
.selectFrom('activities_pulse')
.selectAll()
.orderBy('created_at', 'desc')
const sql = query.compile()

return dbUtil.sqlWithBindings(sql)
}

export default {
getAllPulses,
getLatestPulses,
}
55 changes: 55 additions & 0 deletions packages/app/src/utils/csv.util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
const convertPulsesToCSV = (pulses: CodeClimbers.Pulse[]): string => {
const header = [
'ID',
'User ID',
'Entity',
'Type',
'Category',
'Project',
'Branch',
'Language',
'Is Write',
'Editor',
'Operating System',
'Machine',
'User Agent',
'Time',
'Hash',
'Origin',
'Origin ID',
'Created At',
'Description',
].join(',')

const rows = pulses.map((row) =>
[
row.id,
row.userId,
row.entity,
row.type,
row.category,
row.project,
row.branch,
row.language,
row.isWrite,
row.editor,
row.operatingSystem,
row.machine,
row.userAgent,
row.time,
row.hash,
row.origin,
row.originId,
row.createdAt,
row.description,
]
.map((value) => (value === null ? '' : value?.toString()))
.join(','),
)

return [header, ...rows].join('\n')
}

export default {
convertPulsesToCSV,
}
61 changes: 61 additions & 0 deletions packages/app/src/utils/db.util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import {
Kysely,
PostgresAdapter,
DummyDriver,
PostgresIntrospector,
PostgresQueryCompiler,
CompiledQuery,
} from 'kysely'

interface Database {
activities_pulse: CodeClimbers.PulseDB
}

const db = new Kysely<Database>({
dialect: {
createAdapter: () => new PostgresAdapter(),
createDriver: () => new DummyDriver(),
createIntrospector: (db) => new PostgresIntrospector(db),
createQueryCompiler: () => new PostgresQueryCompiler(),
},
})

// Extend the CompiledQuery interface
declare module 'kysely' {
interface CompiledQuery {
sqlWithBindings(): string
}
}

// Implement the sqlWithBindings method
function sqlWithBindings(compiledQuery: CompiledQuery): string {
let sql = compiledQuery.sql
const parameters = compiledQuery.parameters

// Replace each parameter placeholder with its corresponding value
parameters.forEach((param, index) => {
const placeholder = `$${index + 1}`
let replacement: string

if (param === null) {
replacement = 'NULL'
} else if (typeof param === 'string') {
replacement = `'${param.replace(/'/g, "''")}'` // Escape single quotes
} else if (typeof param === 'number' || typeof param === 'boolean') {
replacement = param.toString()
} else if (param instanceof Date) {
replacement = `'${param.toISOString()}'`
} else {
replacement = `'${JSON.stringify(param)}'`
}

sql = sql.replace(placeholder, replacement)
})

return sql
}

export default {
db,
sqlWithBindings,
}
9 changes: 9 additions & 0 deletions packages/app/src/utils/flag.util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// save feature flag to local storage
export const saveFeatureFlag = (featureFlag: string, value: boolean) => {
localStorage.setItem(`feature-flag-${featureFlag}`, value.toString())
}

// get feature flag from local storage
export const getFeatureFlag = (featureFlag: string) => {
return localStorage.getItem(`feature-flag-${featureFlag}`) === 'true'
}
32 changes: 32 additions & 0 deletions packages/app/src/utils/sql.util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { CompiledQuery } from 'kysely'

function sqlWithBindings(compiledQuery: CompiledQuery): string {
let sql = compiledQuery.sql
const parameters = compiledQuery.parameters

// Replace each parameter placeholder with its corresponding value
parameters.forEach((param, index) => {
const placeholder = `$${index + 1}`
let replacement: string

if (param === null) {
replacement = 'NULL'
} else if (typeof param === 'string') {
replacement = `'${param.replace(/'/g, "''")}'` // Escape single quotes
} else if (typeof param === 'number' || typeof param === 'boolean') {
replacement = param.toString()
} else if (param instanceof Date) {
replacement = `'${param.toISOString()}'`
} else {
replacement = `'${JSON.stringify(param)}'`
}

sql = sql.replace(placeholder, replacement)
})

return sql
}

export default {
sqlWithBindings,
}
23 changes: 23 additions & 0 deletions packages/server/src/v1/database/models/pulse.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,27 @@ declare namespace CodeClimbers {
createdAt: string // The time the activity was created in the database. In ISO format
description?: string // A description of the activity generated by the cli based on the other fields
}
// same as Pulse but snake case
export interface PulseDB {
id?: number
user_id: string // Used by wakatime to identify the user (email address). We store as "local"
entity: string // The activity that was done. The file worked on or the url of the site visited
type: string // What type of entity it is. For example: 'file' or 'domain'
category?: string // What category the activity falls under. For example: 'coding', 'browsing', 'designing'
project?: string // The project the activity was done under. Gathered from the directory name the work was done from. Only used for 'file' type activities
branch?: string // The branch the activity was done under. Gathered from the git branch the work was done from. Only used for 'file' type activities
language?: string // The language the activity was done in. Gathered from the file extension. Only used for 'file' type activities
is_write: boolean // Whether the activity was a write operation. If false, it was a read operation
editor: string // if the activity was done in an editor, the name of the editor. Only used for 'file' type activities
operating_system: string // The operating system the activity was done on. Gathered from the user agent
application?: string // The application the activity was done in.
machine: string // The machine the activity was done on. Gathered from the user agent
user_agent: string // The user agent of the activity. Used to determine the operating system and application
time: string // The time the activity was done. In ISO format
hash: string // A hash of the activity. Used to determine if the activity is a duplicate. Browser extension sends a lot of duplicate activities
origin?: string // unused
origin_id?: string // unused
created_at: string // The time the activity was created in the database. In ISO format
description?: string // A description of the activity generated by the cli based on the other fields
}
}
Loading