FEAT: Many changes! Tree is almost done

Loading indicator for loading the tree. Tree displays PK, FK, R, and U.
Still some bugs with the connections and I want to implement a
"connection manager" which the ability to change, edit, and delete
connections. PLUS right now the tree view is only supported for PSQL.
Need to use different queries for different SQL types. SOME types, like
SQLlite, might not support the tree, and I will need a display for that.
Plus errors are not handled, just bubbled and logged :|
This commit is contained in:
Azpect3120 2024-08-09 18:04:04 -07:00
parent c1a328ee63
commit 0876222f4c
6 changed files with 198 additions and 76 deletions

View File

@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"github.com/Azpect3120/Web-Database-Viewer/internal/model"
"github.com/Azpect3120/Web-Database-Viewer/internal/templates" "github.com/Azpect3120/Web-Database-Viewer/internal/templates"
"github.com/gin-contrib/sessions" "github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@ -38,20 +39,20 @@ func TableTree(c *gin.Context) string {
} }
// Generate the tree of the database tables // Generate the tree of the database tables
func generateTree(url string) (map[string][]string, error) { func generateTree(url string) (map[string][]model.Column, error) {
conn, err := sql.Open("postgres", url) conn, err := sql.Open("postgres", url)
if err != nil { if err != nil {
return map[string][]string{}, err return map[string][]model.Column{}, err
} }
defer conn.Close() defer conn.Close()
tree, err := tableList(conn) tree, err := tableList(conn)
if err != nil { if err != nil {
return map[string][]string{}, err return map[string][]model.Column{}, err
} }
if err := fillColumns(conn, tree); err != nil { if err := fillColumns(conn, tree); err != nil {
return map[string][]string{}, err return map[string][]model.Column{}, err
} }
return tree, nil return tree, nil
@ -59,20 +60,20 @@ func generateTree(url string) (map[string][]string, error) {
// Return a map with the keys being the table names and the values // Return a map with the keys being the table names and the values
// being blank which can be later used to store the columns. // being blank which can be later used to store the columns.
func tableList(conn *sql.DB) (map[string][]string, error) { func tableList(conn *sql.DB) (map[string][]model.Column, error) {
rows, err := conn.Query("SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_type = 'BASE TABLE';") rows, err := conn.Query("SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_type = 'BASE TABLE';")
if err != nil { if err != nil {
return map[string][]string{}, err return map[string][]model.Column{}, err
} }
defer rows.Close() defer rows.Close()
tree := make(map[string][]string) tree := make(map[string][]model.Column)
for rows.Next() { for rows.Next() {
var table string var table string
if err := rows.Scan(&table); err != nil { if err := rows.Scan(&table); err != nil {
return map[string][]string{}, err return map[string][]model.Column{}, err
} }
tree[table] = []string{} tree[table] = []model.Column{}
} }
return tree, nil return tree, nil
@ -84,22 +85,90 @@ func tableList(conn *sql.DB) (map[string][]string, error) {
// For now, the only data stored is the // For now, the only data stored is the
// column name, but in the future this could be expanded to store // column name, but in the future this could be expanded to store
// datatype, constraints, primary keys, relationship, etc. // datatype, constraints, primary keys, relationship, etc.
func fillColumns(conn *sql.DB, tree map[string][]string) error { func fillColumns(conn *sql.DB, tree map[string][]model.Column) error {
var pkey string
var fkeys []model.ForeignKey
for table := range tree { for table := range tree {
rows, err := conn.Query(fmt.Sprintf("SELECT column_name FROM information_schema.columns WHERE table_name = '%s';", table)) unique, err := getUniqueColumns(conn, table)
if err != nil {
return err
}
pk, err := conn.Query(fmt.Sprintf("SELECT kcu.column_name FROM information_schema.table_constraints tc JOIN information_schema.key_column_usage kcu ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema WHERE tc.constraint_type = 'PRIMARY KEY' AND tc.table_name = '%s';", table))
if err != nil {
return err
}
defer pk.Close()
for pk.Next() {
if err := pk.Scan(&pkey); err != nil {
return err
}
}
fk, err := conn.Query(fmt.Sprintf("SELECT tc.table_schema, tc.table_name, kcu.column_name, ccu.table_schema AS foreign_table_schema, ccu.table_name AS foreign_table_name, ccu.column_name AS foreign_column_name FROM information_schema.table_constraints AS tc JOIN information_schema.key_column_usage AS kcu ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema JOIN information_schema.constraint_column_usage AS ccu ON ccu.constraint_name = tc.constraint_name AND ccu.table_schema = tc.table_schema WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_name = '%s';", table))
if err != nil {
return err
}
defer fk.Close()
for fk.Next() {
var fkey model.ForeignKey
if err := fk.Scan(new(interface{}), new(interface{}), &fkey.Column, new(interface{}), &fkey.ForeignTable, &fkey.ForeignColumn); err != nil {
return err
}
fkeys = append(fkeys, fkey)
}
rows, err := conn.Query(fmt.Sprintf("SELECT column_name, is_nullable, data_type, character_maximum_length FROM information_schema.columns WHERE table_name = '%s';", table))
if err != nil { if err != nil {
return err return err
} }
defer rows.Close() defer rows.Close()
for rows.Next() { for rows.Next() {
var column string var column model.Column
if err := rows.Scan(&column); err != nil { if err := rows.Scan(&column.Name, &column.Nullable, &column.Type, &column.MaxLength); err != nil {
return err return err
} }
if column.Name == pkey {
column.PrimaryKey = true
}
for _, fkey := range fkeys {
if column.Name == fkey.Column {
column.ForeignKey = fkey
} else {
column.ForeignKey = model.ForeignKey{}
}
}
for _, u := range unique {
if column.Name == u {
column.Unique = true
}
}
tree[table] = append(tree[table], column) tree[table] = append(tree[table], column)
} }
} }
return nil return nil
} }
// Returns a list of the unique columns in a table
func getUniqueColumns(conn *sql.DB, table string) ([]string, error) {
var cols []string
rows, err := conn.Query(fmt.Sprintf("SELECT kcu.column_name FROM information_schema.table_constraints AS tc JOIN information_schema.key_column_usage AS kcu ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema WHERE tc.constraint_type = 'UNIQUE' AND kcu.table_name = '%s';", table))
if err != nil {
return []string{}, err
}
defer rows.Close()
for rows.Next() {
var col string
if err := rows.Scan(&col); err != nil {
return []string{}, err
}
cols = append(cols, col)
}
return cols, nil
}

20
internal/model/tree.go Normal file
View File

@ -0,0 +1,20 @@
package model
import "database/sql"
// Column data structure
type Column struct {
Name string
Type string
MaxLength sql.NullInt64
Nullable string
PrimaryKey bool
ForeignKey ForeignKey
Unique bool
}
type ForeignKey struct {
Column string
ForeignTable string
ForeignColumn string
}

View File

@ -3,6 +3,8 @@ package templates
import ( import (
"fmt" "fmt"
"sort" "sort"
"github.com/Azpect3120/Web-Database-Viewer/internal/model"
) )
// Tree definition // Tree definition
@ -28,13 +30,14 @@ const FIELDS_LIST_OPEN string = `<ul id="fields-%s" class="hidden ml-6 mt-1 spac
const FIELDS_LIST_CLOSE string = `</ul>` const FIELDS_LIST_CLOSE string = `</ul>`
const FIELD_TEMPLATE string = ` const FIELD_TEMPLATE string = `
<li> <li>
<button class="flex items-center" title="Select this field"> <button onclick="LoadTableQueryWithFields('%s', '%s')" class="flex items-center w-full" title="Select this field">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24" <svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"> xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16m-7 6h7"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16m-7 6h7">
</path> </path>
</svg> </svg>
<span>%s</span> <span>%s</span>
<span class="text-xs ml-auto">%s</span>
</button> </button>
</li> </li>
` `
@ -43,14 +46,14 @@ const FIELD_TEMPLATE string = `
const PRIMARY_KEY string = `<span class="h-1.5 w-1.5 bg-yellow-500 rounded-full mx-2" title="Primary Key"></span>` const PRIMARY_KEY string = `<span class="h-1.5 w-1.5 bg-yellow-500 rounded-full mx-2" title="Primary Key"></span>`
// Generate the tree based on the database tables and columns // Generate the tree based on the database tables and columns
func TableTree(tree map[string][]string) string { func TableTree(tree map[string][]model.Column) string {
html := TREE_OPEN html := TREE_OPEN
var body string var body string
for _, table := range getSortedKeys(tree) { for _, table := range getSortedKeys(tree) {
body += fmt.Sprintf(TABLE_TEMPLATE, table, table, table, table, table) body += fmt.Sprintf(TABLE_TEMPLATE, table, table, table, table, table)
fields := fmt.Sprintf(FIELDS_LIST_OPEN, table) fields := fmt.Sprintf(FIELDS_LIST_OPEN, table)
body += fields + generateFields(tree[table]) + FIELDS_LIST_CLOSE body += fields + generateFields(table, tree[table]) + FIELDS_LIST_CLOSE
} }
html += fmt.Sprintf(TREE_BODY_TEMPLATE, body) html += fmt.Sprintf(TREE_BODY_TEMPLATE, body)
@ -58,16 +61,16 @@ func TableTree(tree map[string][]string) string {
} }
// Using a list of fields, generate the HTML for the fields // Using a list of fields, generate the HTML for the fields
func generateFields(fields []string) string { func generateFields(table string, fields []model.Column) string {
var html string var html string
for _, field := range fields { for _, field := range fields {
html += fmt.Sprintf(FIELD_TEMPLATE, field) html += fmt.Sprintf(FIELD_TEMPLATE, table, field.Name, field.Name, generateType(field))
} }
return html return html
} }
// Return a list of the keys in a map, sorted alphabetically // Return a list of the keys in a map, sorted alphabetically
func getSortedKeys(m map[string][]string) []string { func getSortedKeys(m map[string][]model.Column) []string {
keys := make([]string, 0, len(m)) keys := make([]string, 0, len(m))
for k := range m { for k := range m {
keys = append(keys, k) keys = append(keys, k)
@ -76,3 +79,30 @@ func getSortedKeys(m map[string][]string) []string {
sort.Strings(keys) sort.Strings(keys)
return keys return keys
} }
// Generate the string for the type of column base on the column definition
func generateType(col model.Column) string {
var str string
if col.PrimaryKey {
str = `<span class="text-yellow-500">PK</span>, `
} else if col.ForeignKey.Column != "" {
str = fmt.Sprintf(`<span class="text-blue-500">FK: %s(%s)</span>, `, col.ForeignKey.ForeignTable, col.ForeignKey.ForeignColumn)
}
if col.Nullable == "NO" {
str += `<span class="text-red-500">R</span>, `
}
if col.Unique {
str += `<span class="text-green-500">U</span>, `
}
if col.MaxLength.Valid {
str += fmt.Sprintf("%s(%d)", col.Type, col.MaxLength.Int64)
} else {
str += col.Type
}
return str
}

View File

@ -17,9 +17,15 @@ function ToggleFields(id) {
} }
} }
function LoadTableQuery(table) { function LoadTableQuery(table) {
const sql = document.getElementById("sql") const sql = document.getElementById("sql")
sql.value = `SELECT * FROM ${table};`; sql.value = `SELECT * FROM ${table};`;
sql.dispatchEvent(new Event("input", { bubbles: true })); sql.dispatchEvent(new Event("input", { bubbles: true }));
} }
function LoadTableQueryWithFields(table, fields) {
const sql = document.getElementById("sql")
sql.value = `SELECT ${fields} FROM ${table};`;
sql.dispatchEvent(new Event("input", { bubbles: true }));
}

View File

@ -593,6 +593,11 @@ video {
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
.mx-1 {
margin-left: 0.25rem;
margin-right: 0.25rem;
}
.mb-4 { .mb-4 {
margin-bottom: 1rem; margin-bottom: 1rem;
} }
@ -601,6 +606,10 @@ video {
margin-left: 1.5rem; margin-left: 1.5rem;
} }
.ml-auto {
margin-left: auto;
}
.mr-2 { .mr-2 {
margin-right: 0.5rem; margin-right: 0.5rem;
} }
@ -613,14 +622,6 @@ video {
margin-top: 1rem; margin-top: 1rem;
} }
.mr-auto {
margin-right: auto;
}
.ml-auto {
margin-left: auto;
}
.block { .block {
display: block; display: block;
} }
@ -661,14 +662,14 @@ video {
height: 1.5rem; height: 1.5rem;
} }
.h-screen {
height: 100vh;
}
.h-8 { .h-8 {
height: 2rem; height: 2rem;
} }
.h-screen {
height: 100vh;
}
.max-h-full { .max-h-full {
max-height: 100%; max-height: 100%;
} }
@ -701,14 +702,14 @@ video {
width: 1.5rem; width: 1.5rem;
} }
.w-full {
width: 100%;
}
.w-8 { .w-8 {
width: 2rem; width: 2rem;
} }
.w-full {
width: 100%;
}
.min-w-full { .min-w-full {
min-width: 100%; min-width: 100%;
} }
@ -717,16 +718,6 @@ video {
flex-grow: 1; flex-grow: 1;
} }
.rotate-180 {
--tw-rotate: 180deg;
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
}
.rotate-90 {
--tw-rotate: 90deg;
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
}
.transform { .transform {
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
} }
@ -884,16 +875,6 @@ video {
background-color: rgb(234 179 8 / var(--tw-bg-opacity)); background-color: rgb(234 179 8 / var(--tw-bg-opacity));
} }
.bg-red-500 {
--tw-bg-opacity: 1;
background-color: rgb(239 68 68 / var(--tw-bg-opacity));
}
.bg-red-400 {
--tw-bg-opacity: 1;
background-color: rgb(248 113 113 / var(--tw-bg-opacity));
}
.bg-opacity-50 { .bg-opacity-50 {
--tw-bg-opacity: 0.5; --tw-bg-opacity: 0.5;
} }
@ -910,10 +891,6 @@ video {
padding: 1.5rem; padding: 1.5rem;
} }
.p-1 {
padding: 0.25rem;
}
.px-1 { .px-1 {
padding-left: 0.25rem; padding-left: 0.25rem;
padding-right: 0.25rem; padding-right: 0.25rem;
@ -994,6 +971,10 @@ video {
font-weight: 500; font-weight: 500;
} }
.font-normal {
font-weight: 400;
}
.uppercase { .uppercase {
text-transform: uppercase; text-transform: uppercase;
} }
@ -1002,6 +983,11 @@ video {
letter-spacing: 0.05em; letter-spacing: 0.05em;
} }
.text-blue-500 {
--tw-text-opacity: 1;
color: rgb(59 130 246 / var(--tw-text-opacity));
}
.text-gray-500 { .text-gray-500 {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(107 114 128 / var(--tw-text-opacity)); color: rgb(107 114 128 / var(--tw-text-opacity));
@ -1017,6 +1003,11 @@ video {
color: rgb(55 65 81 / var(--tw-text-opacity)); color: rgb(55 65 81 / var(--tw-text-opacity));
} }
.text-green-500 {
--tw-text-opacity: 1;
color: rgb(34 197 94 / var(--tw-text-opacity));
}
.text-red-500 { .text-red-500 {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(239 68 68 / var(--tw-text-opacity)); color: rgb(239 68 68 / var(--tw-text-opacity));
@ -1027,6 +1018,11 @@ video {
color: rgb(255 255 255 / var(--tw-text-opacity)); color: rgb(255 255 255 / var(--tw-text-opacity));
} }
.text-yellow-500 {
--tw-text-opacity: 1;
color: rgb(234 179 8 / var(--tw-text-opacity));
}
.opacity-0 { .opacity-0 {
opacity: 0; opacity: 0;
} }
@ -1049,20 +1045,22 @@ video {
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
} }
.duration-300 { .transition-all {
transition-duration: 300ms; transition-property: all;
} transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
.duration-100 {
transition-duration: 100ms;
} }
.duration-150 { .duration-150 {
transition-duration: 150ms; transition-duration: 150ms;
} }
.\*\:hidden > * { .duration-300 {
display: none; transition-duration: 300ms;
}
.ease-in-out {
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
} }
.hover\:bg-gray-100:hover { .hover\:bg-gray-100:hover {
@ -1070,11 +1068,6 @@ video {
background-color: rgb(243 244 246 / var(--tw-bg-opacity)); background-color: rgb(243 244 246 / var(--tw-bg-opacity));
} }
.hover\:bg-gray-200:hover {
--tw-bg-opacity: 1;
background-color: rgb(229 231 235 / var(--tw-bg-opacity));
}
.hover\:bg-gray-300:hover { .hover\:bg-gray-300:hover {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(209 213 219 / var(--tw-bg-opacity)); background-color: rgb(209 213 219 / var(--tw-bg-opacity));

View File

@ -20,7 +20,7 @@
<p class="text-sm text-gray-600">Connect and query your databases effortlessly.</p> <p class="text-sm text-gray-600">Connect and query your databases effortlessly.</p>
</div> </div>
<div class="flex items-center justify-end space-x-4 flex-wrap"> <div class="flex items-center justify-end space-x-4 flex-wrap">
<form hx-post="/v1/api/connections/connect" hx-trigger="change" hx-swap="outerHTML" hx-target="#connected-database" hx-encoding="multipart/form-data" class="flex items-center justify-end space-x-2 flex-wrap"> <form hx-post="/v1/api/connections/connect" hx-trigger="change" hx-swap="outerHTML" hx-target="#connected-database" hx-indicator="#table-loading" hx-encoding="multipart/form-data" class="flex items-center justify-end space-x-2 flex-wrap">
<label for="connected-database" class="block text-sm font-medium text-gray-700">Connected Database:</label> <label for="connected-database" class="block text-sm font-medium text-gray-700">Connected Database:</label>
<select hx-get="/v1/web/connections" hx-trigger="load" hx-swap="outerHTML" id="connected-database" name="connected-database" hx-params="none" class="mt-1 block p-2 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 text-sm md:text-base"></select> <select hx-get="/v1/web/connections" hx-trigger="load" hx-swap="outerHTML" id="connected-database" name="connected-database" hx-params="none" class="mt-1 block p-2 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 text-sm md:text-base"></select>
</form> </form>
@ -34,12 +34,16 @@
<!-- Sidebar --> <!-- Sidebar -->
<div class="w-full md:w-1/4 bg-white shadow-md"> <div class="w-full md:w-1/4 bg-white shadow-md">
<div class="p-4 border-b flex justify-between items-center"> <div class="p-4 border-b flex justify-between items-center">
<h2 class="text-lg font-bold"><span id="database-name-tree">database</span> Tables</h2> <h2 class="text-lg font-bold">
<span id="database-name-tree">database</span>
<span id="table-loading" class="text-sm font-normal mx-1 htmx-indicator duration-300">loading tables...</span>
</h2>
<button <button
hx-get="/v1/web/connections/tree" hx-get="/v1/web/connections/tree"
hx-trigger="click" hx-trigger="click"
hx-target="#database-table-tree" hx-target="#database-table-tree"
class="hover:bg-gray-100 p-2 rounded-md" class="hover:bg-gray-100 p-2 rounded-md"
hx-indicator="#table-loading"
> >
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon h-4 w-4" viewBox="0 0 512 512"> <svg xmlns="http://www.w3.org/2000/svg" class="ionicon h-4 w-4" viewBox="0 0 512 512">
<path d="M400 148l-21.12-24.57A191.43 191.43 0 00240 64C134 64 48 150 48 256s86 192 192 192a192.09 192.09 0 00181.07-128" fill="none" stroke="currentColor" stroke-linecap="round" stroke-miterlimit="10" stroke-width="32"/> <path d="M400 148l-21.12-24.57A191.43 191.43 0 00240 64C134 64 48 150 48 256s86 192 192 192a192.09 192.09 0 00181.07-128" fill="none" stroke="currentColor" stroke-linecap="round" stroke-miterlimit="10" stroke-width="32"/>
@ -47,7 +51,7 @@
</svg> </svg>
</button> </button>
</div> </div>
<div class="p-4 max-h-full" hx-get="/v1/web/connections/tree" hx-trigger="load" hx-params="none" hx-target="#database-table-tree"> <div class="p-4 max-h-full" hx-get="/v1/web/connections/tree" hx-trigger="load" hx-params="none" hx-indicator="#table-loading" hx-target="#database-table-tree">
<ul hx-swap-oob="outerHTML" id="database-table-tree" class="space-y-2"></ul> <ul hx-swap-oob="outerHTML" id="database-table-tree" class="space-y-2"></ul>
</div> </div>
</div> </div>