mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-10 16:40:34 +01:00
Compare commits
21 Commits
issue_5552
...
nv/functio
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b88ccdf150 | ||
|
|
cf3bb52dbd | ||
|
|
db56d10fd1 | ||
|
|
5f315bb082 | ||
|
|
0aff9ef889 | ||
|
|
2d653d1a6e | ||
|
|
36aa3dcf0b | ||
|
|
c31768f5fc | ||
|
|
ab9d78d314 | ||
|
|
2efad1c634 | ||
|
|
1b75857a6b | ||
|
|
531b637b6a | ||
|
|
9fdb62abbe | ||
|
|
fde7a5d818 | ||
|
|
83bbcd4b41 | ||
|
|
eafd71f205 | ||
|
|
e6a8736a1a | ||
|
|
0d744cf94c | ||
|
|
748dff9489 | ||
|
|
68da3b2beb | ||
|
|
5574e08ddc |
@@ -3,6 +3,7 @@ package postgressqlschema
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"log/slog"
|
||||
|
||||
"github.com/uptrace/bun"
|
||||
|
||||
@@ -214,6 +215,20 @@ WHERE
|
||||
}
|
||||
|
||||
func (provider *provider) GetIndices(ctx context.Context, name sqlschema.TableName) ([]sqlschema.Index, error) {
|
||||
// A key is one entry an index is built on: a column (plain) or an expression
|
||||
// like lower(x) (functional). Returns one row per key of each non-constraint-
|
||||
// backed index on the table, ordered by position. Keys are enumerated with
|
||||
// generate_series over indnkeyatts (count of key columns, excluding non-key
|
||||
// INCLUDE columns), so expression keys are covered too. Reconstruction uses:
|
||||
//
|
||||
// index_name | index the key belongs to
|
||||
// is_expression | true if the key is an expression, not a plain column
|
||||
// key_def | rendered key text, e.g. org_id or lower(key)
|
||||
// column_name | column name for plain keys; NULL for expression keys
|
||||
// predicate | partial-index WHERE; NULL if not partial (same every row)
|
||||
//
|
||||
// table_name/unique/primary/column_position are carried over from the prior
|
||||
// query (unique gates the loop, column_position orders the keys).
|
||||
rows, err := provider.
|
||||
sqlstore.
|
||||
BunDB().
|
||||
@@ -224,19 +239,21 @@ SELECT
|
||||
i.indisunique AS unique,
|
||||
i.indisprimary AS primary,
|
||||
a.attname AS column_name,
|
||||
array_position(i.indkey, a.attnum) AS column_position,
|
||||
n AS column_position,
|
||||
(i.indkey[n - 1] = 0) AS is_expression,
|
||||
pg_get_indexdef(i.indexrelid, n, true) AS key_def,
|
||||
pg_get_expr(i.indpred, i.indrelid) AS predicate
|
||||
FROM
|
||||
pg_index i
|
||||
LEFT JOIN pg_class ct ON ct.oid = i.indrelid
|
||||
LEFT JOIN pg_class ci ON ci.oid = i.indexrelid
|
||||
LEFT JOIN pg_attribute a ON a.attrelid = ct.oid
|
||||
LEFT JOIN pg_constraint con ON con.conindid = i.indexrelid
|
||||
CROSS JOIN generate_series(1, i.indnkeyatts) AS n
|
||||
LEFT JOIN pg_attribute a ON a.attrelid = ct.oid AND a.attnum = i.indkey[n - 1]
|
||||
WHERE
|
||||
a.attnum = ANY(i.indkey)
|
||||
AND con.oid IS NULL
|
||||
ct.relname = ?
|
||||
AND ct.relkind = 'r'
|
||||
AND ct.relname = ?
|
||||
AND con.oid IS NULL
|
||||
ORDER BY index_name, column_position`, string(name))
|
||||
if err != nil {
|
||||
return nil, provider.sqlstore.WrapNotFoundErrf(err, errors.CodeNotFound, "no indices for table (%s) found", name)
|
||||
@@ -251,6 +268,10 @@ ORDER BY index_name, column_position`, string(name))
|
||||
type indexEntry struct {
|
||||
columns []sqlschema.ColumnName
|
||||
predicate *string
|
||||
// keyDefs holds every key rendered by pg_get_indexdef, in key order; used
|
||||
// for functional indexes where a key may be an expression like lower(x).
|
||||
keyDefs []string
|
||||
hasExpression bool
|
||||
}
|
||||
|
||||
uniqueIndicesMap := make(map[string]*indexEntry)
|
||||
@@ -260,30 +281,45 @@ ORDER BY index_name, column_position`, string(name))
|
||||
indexName string
|
||||
unique bool
|
||||
primary bool
|
||||
columnName string
|
||||
// starts from 0 and is unused in this function, this is to ensure that the column names are in the correct order
|
||||
columnName *string
|
||||
// starts from 1 and is unused in this function, this is to ensure that the column names are in the correct order
|
||||
columnPosition int
|
||||
isExpression bool
|
||||
keyDef string
|
||||
predicate *string
|
||||
)
|
||||
|
||||
if err := rows.Scan(&tableName, &indexName, &unique, &primary, &columnName, &columnPosition, &predicate); err != nil {
|
||||
if err := rows.Scan(&tableName, &indexName, &unique, &primary, &columnName, &columnPosition, &isExpression, &keyDef, &predicate); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if unique {
|
||||
if _, ok := uniqueIndicesMap[indexName]; !ok {
|
||||
uniqueIndicesMap[indexName] = &indexEntry{
|
||||
columns: []sqlschema.ColumnName{sqlschema.ColumnName(columnName)},
|
||||
predicate: predicate,
|
||||
}
|
||||
} else {
|
||||
uniqueIndicesMap[indexName].columns = append(uniqueIndicesMap[indexName].columns, sqlschema.ColumnName(columnName))
|
||||
}
|
||||
if !unique {
|
||||
continue
|
||||
}
|
||||
|
||||
entry, ok := uniqueIndicesMap[indexName]
|
||||
if !ok {
|
||||
entry = &indexEntry{predicate: predicate}
|
||||
uniqueIndicesMap[indexName] = entry
|
||||
}
|
||||
|
||||
entry.keyDefs = append(entry.keyDefs, keyDef)
|
||||
if isExpression {
|
||||
entry.hasExpression = true
|
||||
} else if columnName != nil {
|
||||
entry.columns = append(entry.columns, sqlschema.ColumnName(*columnName))
|
||||
}
|
||||
}
|
||||
|
||||
indices := make([]sqlschema.Index, 0)
|
||||
for indexName, entry := range uniqueIndicesMap {
|
||||
// functional partial indexes aren't representable (PartialUniqueIndex has no
|
||||
// expressions); skip rather than misrepresent.
|
||||
if entry.hasExpression && entry.predicate != nil {
|
||||
provider.settings.Logger().WarnContext(ctx, "skipping functional partial unique index; not representable by sqlschema", slog.String("index", indexName), slog.String("table", string(name)))
|
||||
continue
|
||||
}
|
||||
|
||||
if entry.predicate != nil {
|
||||
index := &sqlschema.PartialUniqueIndex{
|
||||
TableName: name,
|
||||
@@ -291,6 +327,17 @@ ORDER BY index_name, column_position`, string(name))
|
||||
Where: *entry.predicate,
|
||||
}
|
||||
|
||||
if index.Name() == indexName {
|
||||
indices = append(indices, index)
|
||||
} else {
|
||||
indices = append(indices, index.Named(indexName))
|
||||
}
|
||||
} else if entry.hasExpression {
|
||||
index := &sqlschema.UniqueIndexWithExpressions{
|
||||
TableName: name,
|
||||
Expressions: entry.keyDefs,
|
||||
}
|
||||
|
||||
if index.Name() == indexName {
|
||||
indices = append(indices, index)
|
||||
} else {
|
||||
|
||||
116
ee/sqlschema/postgressqlschema/provider_test.go
Normal file
116
ee/sqlschema/postgressqlschema/provider_test.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package postgressqlschema
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql/driver"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
|
||||
"github.com/SigNoz/signoz/pkg/sqlschema"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore/sqlstoretest"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestGetIndices feeds GetIndices the exact rows the catalog query returns for a
|
||||
// real table (captured from devenv postgres) and checks each unique-index shape
|
||||
// is reconstructed: expression keys, partial (predicate), and plain columns.
|
||||
func TestGetIndices(t *testing.T) {
|
||||
// column order must match the SELECT in GetIndices.
|
||||
columns := []string{"table_name", "index_name", "unique", "primary", "column_name", "column_position", "is_expression", "key_def", "predicate"}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
table sqlschema.TableName
|
||||
rows [][]driver.Value
|
||||
want []sqlschema.Index
|
||||
}{
|
||||
{
|
||||
name: "UniqueIndexWithExpressions",
|
||||
table: "tag",
|
||||
rows: [][]driver.Value{
|
||||
{"tag", "uq_tag_a36c51df", true, false, "org_id", 1, false, "org_id", nil},
|
||||
{"tag", "uq_tag_a36c51df", true, false, "kind", 2, false, "kind", nil},
|
||||
{"tag", "uq_tag_a36c51df", true, false, nil, 3, true, "lower(key)", nil},
|
||||
{"tag", "uq_tag_a36c51df", true, false, nil, 4, true, "lower(value)", nil},
|
||||
},
|
||||
want: []sqlschema.Index{
|
||||
&sqlschema.UniqueIndexWithExpressions{
|
||||
TableName: "tag",
|
||||
Expressions: []string{"org_id", "kind", "lower(key)", "lower(value)"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "PartialUniqueIndexes",
|
||||
table: "service_account",
|
||||
rows: [][]driver.Value{
|
||||
{"service_account", "puq_service_account_email_org_id_471d6134", true, false, "email", 1, false, "email", "(status <> 'deleted'::text)"},
|
||||
{"service_account", "puq_service_account_email_org_id_471d6134", true, false, "org_id", 2, false, "org_id", "(status <> 'deleted'::text)"},
|
||||
{"service_account", "puq_service_account_name_org_id_471d6134", true, false, "name", 1, false, "name", "(status <> 'deleted'::text)"},
|
||||
{"service_account", "puq_service_account_name_org_id_471d6134", true, false, "org_id", 2, false, "org_id", "(status <> 'deleted'::text)"},
|
||||
},
|
||||
want: []sqlschema.Index{
|
||||
(&sqlschema.PartialUniqueIndex{
|
||||
TableName: "service_account",
|
||||
ColumnNames: []sqlschema.ColumnName{"email", "org_id"},
|
||||
Where: "(status <> 'deleted'::text)",
|
||||
}).Named("puq_service_account_email_org_id_471d6134"),
|
||||
(&sqlschema.PartialUniqueIndex{
|
||||
TableName: "service_account",
|
||||
ColumnNames: []sqlschema.ColumnName{"name", "org_id"},
|
||||
Where: "(status <> 'deleted'::text)",
|
||||
}).Named("puq_service_account_name_org_id_471d6134"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "PlainUniqueIndex",
|
||||
table: "tag_relation",
|
||||
rows: [][]driver.Value{
|
||||
{"tag_relation", "uq_tag_relation_kind_resource_id_tag_id", true, false, "kind", 1, false, "kind", nil},
|
||||
{"tag_relation", "uq_tag_relation_kind_resource_id_tag_id", true, false, "resource_id", 2, false, "resource_id", nil},
|
||||
{"tag_relation", "uq_tag_relation_kind_resource_id_tag_id", true, false, "tag_id", 3, false, "tag_id", nil},
|
||||
},
|
||||
want: []sqlschema.Index{
|
||||
&sqlschema.UniqueIndex{
|
||||
TableName: "tag_relation",
|
||||
ColumnNames: []sqlschema.ColumnName{"kind", "resource_id", "tag_id"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
store := sqlstoretest.New(sqlstore.Config{Provider: "postgres"}, sqlmock.QueryMatcherRegexp)
|
||||
rows := sqlmock.NewRows(columns)
|
||||
for _, row := range testCase.rows {
|
||||
rows.AddRow(row...)
|
||||
}
|
||||
store.Mock().ExpectQuery("pg_index").WillReturnRows(rows)
|
||||
|
||||
schema, err := New(context.Background(), instrumentationtest.New().ToProviderSettings(), sqlschema.Config{}, store)
|
||||
require.NoError(t, err)
|
||||
|
||||
indices, err := schema.GetIndices(context.Background(), testCase.table)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, indices, len(testCase.want))
|
||||
|
||||
// GetIndices iterates a map, so match by name rather than slice order.
|
||||
got := make(map[string]sqlschema.Index, len(indices))
|
||||
for _, index := range indices {
|
||||
got[index.Name()] = index
|
||||
}
|
||||
for _, want := range testCase.want {
|
||||
actual, ok := got[want.Name()]
|
||||
if !assert.True(t, ok, "expected index %s not returned", want.Name()) {
|
||||
// so that other items in the for loop can also be checked.
|
||||
continue
|
||||
}
|
||||
assert.True(t, want.Equals(actual), "index %s reconstructed differently", want.Name())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -75,7 +75,7 @@
|
||||
"crypto-js": "4.2.0",
|
||||
"d3-hierarchy": "3.1.2",
|
||||
"dayjs": "^1.10.7",
|
||||
"dompurify": "3.4.11",
|
||||
"dompurify": "3.4.0",
|
||||
"event-source-polyfill": "1.0.31",
|
||||
"eventemitter3": "5.0.1",
|
||||
"history": "4.10.1",
|
||||
@@ -238,18 +238,10 @@
|
||||
"prismjs": "1.30.0",
|
||||
"got": "11.8.5",
|
||||
"form-data": "4.0.6",
|
||||
"brace-expansion": "^2.0.3",
|
||||
"brace-expansion": "^2.0.2",
|
||||
"on-headers": "^1.1.0",
|
||||
"js-cookie": "^3.0.7",
|
||||
"tmp": "0.2.7",
|
||||
"vite": "npm:rolldown-vite@7.3.1",
|
||||
"dompurify": "3.4.11",
|
||||
"js-yaml@3": "3.15.0",
|
||||
"js-yaml@4": "4.2.0",
|
||||
"yaml@1": "1.10.3",
|
||||
"react-router@6": "6.30.4",
|
||||
"markdown-it": "14.2.0",
|
||||
"mdast-util-to-hast@13": "13.2.1",
|
||||
"protocol-buffers-schema": "3.6.1"
|
||||
"vite": "npm:rolldown-vite@7.3.1"
|
||||
}
|
||||
}
|
||||
152
frontend/pnpm-lock.yaml
generated
152
frontend/pnpm-lock.yaml
generated
@@ -19,19 +19,11 @@ overrides:
|
||||
prismjs: 1.30.0
|
||||
got: 11.8.5
|
||||
form-data: 4.0.6
|
||||
brace-expansion: ^2.0.3
|
||||
brace-expansion: ^2.0.2
|
||||
on-headers: ^1.1.0
|
||||
js-cookie: ^3.0.7
|
||||
tmp: 0.2.7
|
||||
vite: npm:rolldown-vite@7.3.1
|
||||
dompurify: 3.4.11
|
||||
js-yaml@3: 3.15.0
|
||||
js-yaml@4: 4.2.0
|
||||
yaml@1: 1.10.3
|
||||
react-router@6: 6.30.4
|
||||
markdown-it: 14.2.0
|
||||
mdast-util-to-hast@13: 13.2.1
|
||||
protocol-buffers-schema: 3.6.1
|
||||
|
||||
importers:
|
||||
|
||||
@@ -87,7 +79,7 @@ importers:
|
||||
version: 0.0.2(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
'@signozhq/ui':
|
||||
specifier: 0.0.23
|
||||
version: 0.0.23(@emotion/is-prop-valid@1.2.0)(@signozhq/icons@0.4.0)(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.4(react@18.2.0))(react@18.2.0)
|
||||
version: 0.0.23(@emotion/is-prop-valid@1.2.0)(@signozhq/icons@0.4.0)(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.3(react@18.2.0))(react@18.2.0)
|
||||
'@tanstack/react-table':
|
||||
specifier: 8.21.3
|
||||
version: 8.21.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
@@ -161,8 +153,8 @@ importers:
|
||||
specifier: ^1.10.7
|
||||
version: 1.11.20
|
||||
dompurify:
|
||||
specifier: 3.4.11
|
||||
version: 3.4.11
|
||||
specifier: 3.4.0
|
||||
version: 3.4.0
|
||||
event-source-polyfill:
|
||||
specifier: 1.0.31
|
||||
version: 1.0.31
|
||||
@@ -204,7 +196,7 @@ importers:
|
||||
version: 12.4.13(@emotion/is-prop-valid@1.2.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
nuqs:
|
||||
specifier: 2.8.8
|
||||
version: 2.8.8(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.4(react@18.2.0))(react@18.2.0)
|
||||
version: 2.8.8(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.3(react@18.2.0))(react@18.2.0)
|
||||
overlayscrollbars:
|
||||
specifier: ^2.8.1
|
||||
version: 2.9.2
|
||||
@@ -3048,10 +3040,6 @@ packages:
|
||||
resolution: {integrity: sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
'@remix-run/router@1.23.3':
|
||||
resolution: {integrity: sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
'@rolldown/binding-android-arm64@1.0.0-beta.53':
|
||||
resolution: {integrity: sha512-Ok9V8o7o6YfSdTTYA/uHH30r3YtOxLD6G3wih/U9DO0ucBBFq8WPt/DslU53OgfteLRHITZny9N/qCUxMf9kjQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
@@ -4155,8 +4143,8 @@ packages:
|
||||
boolbase@1.0.0:
|
||||
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
|
||||
|
||||
brace-expansion@2.1.1:
|
||||
resolution: {integrity: sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==}
|
||||
brace-expansion@2.0.2:
|
||||
resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==}
|
||||
|
||||
braces@3.0.3:
|
||||
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
|
||||
@@ -4817,8 +4805,14 @@ packages:
|
||||
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
dompurify@3.4.11:
|
||||
resolution: {integrity: sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==}
|
||||
dompurify@3.2.4:
|
||||
resolution: {integrity: sha512-ysFSFEDVduQpyhzAob/kkuJjf5zWkZD8/A9ywSp1byueyuCfHamrCBa14/Oc2iiB0e51B+NpxSl5gmzn+Ms/mg==}
|
||||
|
||||
dompurify@3.2.7:
|
||||
resolution: {integrity: sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==}
|
||||
|
||||
dompurify@3.4.0:
|
||||
resolution: {integrity: sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg==}
|
||||
|
||||
domutils@2.8.0:
|
||||
resolution: {integrity: sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==}
|
||||
@@ -6061,12 +6055,12 @@ packages:
|
||||
js-tokens@4.0.0:
|
||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||
|
||||
js-yaml@3.15.0:
|
||||
resolution: {integrity: sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==}
|
||||
js-yaml@3.14.1:
|
||||
resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==}
|
||||
hasBin: true
|
||||
|
||||
js-yaml@4.2.0:
|
||||
resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==}
|
||||
js-yaml@4.1.1:
|
||||
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
|
||||
hasBin: true
|
||||
|
||||
jsdom@20.0.3:
|
||||
@@ -6250,8 +6244,8 @@ packages:
|
||||
lines-and-columns@1.2.4:
|
||||
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
|
||||
|
||||
linkify-it@5.0.2:
|
||||
resolution: {integrity: sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==}
|
||||
linkify-it@5.0.0:
|
||||
resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==}
|
||||
|
||||
lint-staged@17.0.4:
|
||||
resolution: {integrity: sha512-+rU9lSUyVOZ/hDUmRLVGzyS2v73cDdQjX+XQz1AaOdIE4RysLq0HoPW2HrrgeNCLklkhi904VBU1bmgWLHVnkA==}
|
||||
@@ -6370,8 +6364,8 @@ packages:
|
||||
mapbox-to-css-font@2.4.5:
|
||||
resolution: {integrity: sha512-VJ6nB8emkO9VODI0Fk+TQ/0zKBTqmf/Pkt8Xv0kHstoc0iXRajA00DAid4Kc3K5xeFIOoiZrVxijEzj0GLVO2w==}
|
||||
|
||||
markdown-it@14.2.0:
|
||||
resolution: {integrity: sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ==}
|
||||
markdown-it@14.1.1:
|
||||
resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==}
|
||||
hasBin: true
|
||||
|
||||
markdown-table@3.0.4:
|
||||
@@ -6435,8 +6429,8 @@ packages:
|
||||
mdast-util-to-hast@12.3.0:
|
||||
resolution: {integrity: sha512-pits93r8PhnIoU4Vy9bjW39M2jJ6/tdHyja9rrot9uujkN7UTU9SDnE6WNJz/IGyQk3XHX6yNNtrBH6cQzm8Hw==}
|
||||
|
||||
mdast-util-to-hast@13.2.1:
|
||||
resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==}
|
||||
mdast-util-to-hast@13.1.0:
|
||||
resolution: {integrity: sha512-/e2l/6+OdGp/FB+ctrJ9Avz71AN/GRH3oi/3KAx/kMnoUsD6q0woXlDT8lLEeViVKE7oZxE7RXzvO3T8kF2/sA==}
|
||||
|
||||
mdast-util-to-markdown@1.5.0:
|
||||
resolution: {integrity: sha512-bbv7TPv/WC49thZPg3jXuqzuvI45IL2EVAr/KxF0BSdHsU0ceFHOmwQn6evxAh1GaoK/6GQ1wp4R4oW2+LFL/A==}
|
||||
@@ -6778,7 +6772,7 @@ packages:
|
||||
'@tanstack/react-router': ^1
|
||||
next: '>=14.2.0'
|
||||
react: '>=18.2.0 || ^19.0.0-0'
|
||||
react-router: 6.30.4
|
||||
react-router: ^5 || ^6 || ^7
|
||||
react-router-dom: ^5 || ^6 || ^7
|
||||
peerDependenciesMeta:
|
||||
'@remix-run/react':
|
||||
@@ -6799,7 +6793,7 @@ packages:
|
||||
'@tanstack/react-router': ^1
|
||||
next: '>=14.2.0'
|
||||
react: '>=18.2.0 || ^19.0.0-0'
|
||||
react-router: 6.30.4
|
||||
react-router: ^5 || ^6 || ^7
|
||||
react-router-dom: ^5 || ^6 || ^7
|
||||
peerDependenciesMeta:
|
||||
'@remix-run/react':
|
||||
@@ -7180,8 +7174,8 @@ packages:
|
||||
property-information@6.3.0:
|
||||
resolution: {integrity: sha512-gVNZ74nqhRMiIUYWGQdosYetaKc83x8oT41a0LlV3AAFCAZwCpg4vmGkq8t34+cUhp3cnM4XDiU/7xlgK7HGrg==}
|
||||
|
||||
protocol-buffers-schema@3.6.1:
|
||||
resolution: {integrity: sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==}
|
||||
protocol-buffers-schema@3.6.0:
|
||||
resolution: {integrity: sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==}
|
||||
|
||||
proxy-from-env@1.1.0:
|
||||
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
|
||||
@@ -7696,8 +7690,8 @@ packages:
|
||||
peerDependencies:
|
||||
react: '>=15'
|
||||
|
||||
react-router@6.30.4:
|
||||
resolution: {integrity: sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==}
|
||||
react-router@6.30.3:
|
||||
resolution: {integrity: sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
peerDependencies:
|
||||
react: '>=16.8'
|
||||
@@ -8952,8 +8946,8 @@ packages:
|
||||
yallist@4.0.0:
|
||||
resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==}
|
||||
|
||||
yaml@1.10.3:
|
||||
resolution: {integrity: sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==}
|
||||
yaml@1.10.2:
|
||||
resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
yaml@2.8.2:
|
||||
@@ -9074,7 +9068,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@jsdevtools/ono': 7.1.3
|
||||
'@types/json-schema': 7.0.15
|
||||
js-yaml: 4.2.0
|
||||
js-yaml: 4.1.1
|
||||
|
||||
'@babel/code-frame@7.29.0':
|
||||
dependencies:
|
||||
@@ -10333,7 +10327,7 @@ snapshots:
|
||||
'@types/string-hash': 1.1.3
|
||||
d3-interpolate: 3.0.1
|
||||
date-fns: 4.1.0
|
||||
dompurify: 3.4.11
|
||||
dompurify: 3.2.4
|
||||
eventemitter3: 5.0.1
|
||||
fast_array_intersect: 1.1.0
|
||||
history: 4.10.1
|
||||
@@ -10484,7 +10478,7 @@ snapshots:
|
||||
camelcase: 5.3.1
|
||||
find-up: 4.1.0
|
||||
get-package-type: 0.1.0
|
||||
js-yaml: 3.15.0
|
||||
js-yaml: 3.14.1
|
||||
resolve-from: 5.0.0
|
||||
|
||||
'@istanbuljs/schema@0.1.3': {}
|
||||
@@ -11811,8 +11805,6 @@ snapshots:
|
||||
|
||||
'@remix-run/router@1.23.2': {}
|
||||
|
||||
'@remix-run/router@1.23.3': {}
|
||||
|
||||
'@rolldown/binding-android-arm64@1.0.0-beta.53':
|
||||
optional: true
|
||||
|
||||
@@ -12050,7 +12042,7 @@ snapshots:
|
||||
- react-dom
|
||||
- tailwindcss
|
||||
|
||||
'@signozhq/ui@0.0.23(@emotion/is-prop-valid@1.2.0)(@signozhq/icons@0.4.0)(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.4(react@18.2.0))(react@18.2.0)':
|
||||
'@signozhq/ui@0.0.23(@emotion/is-prop-valid@1.2.0)(@signozhq/icons@0.4.0)(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.3(react@18.2.0))(react@18.2.0)':
|
||||
dependencies:
|
||||
'@chenglou/pretext': 0.0.5
|
||||
'@radix-ui/react-checkbox': 1.3.3(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
@@ -12079,7 +12071,7 @@ snapshots:
|
||||
lodash-es: 4.18.1
|
||||
motion: 11.18.2(@emotion/is-prop-valid@1.2.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
next-themes: 0.4.6(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
nuqs: 2.8.9(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.4(react@18.2.0))(react@18.2.0)
|
||||
nuqs: 2.8.9(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.3(react@18.2.0))(react@18.2.0)
|
||||
react: 18.2.0
|
||||
react-day-picker: 9.9.0(react@18.2.0)
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
@@ -13065,7 +13057,7 @@ snapshots:
|
||||
|
||||
boolbase@1.0.0: {}
|
||||
|
||||
brace-expansion@2.1.1:
|
||||
brace-expansion@2.0.2:
|
||||
dependencies:
|
||||
balanced-match: 1.0.2
|
||||
|
||||
@@ -13389,14 +13381,14 @@ snapshots:
|
||||
import-fresh: 3.3.1
|
||||
parse-json: 5.2.0
|
||||
path-type: 4.0.0
|
||||
yaml: 1.10.3
|
||||
yaml: 1.10.2
|
||||
optional: true
|
||||
|
||||
cosmiconfig@9.0.1(typescript@5.9.3):
|
||||
dependencies:
|
||||
env-paths: 2.2.1
|
||||
import-fresh: 3.3.1
|
||||
js-yaml: 4.2.0
|
||||
js-yaml: 4.1.1
|
||||
parse-json: 5.2.0
|
||||
optionalDependencies:
|
||||
typescript: 5.9.3
|
||||
@@ -13728,7 +13720,15 @@ snapshots:
|
||||
dependencies:
|
||||
domelementtype: 2.3.0
|
||||
|
||||
dompurify@3.4.11:
|
||||
dompurify@3.2.4:
|
||||
optionalDependencies:
|
||||
'@types/trusted-types': 2.0.7
|
||||
|
||||
dompurify@3.2.7:
|
||||
optionalDependencies:
|
||||
'@types/trusted-types': 2.0.7
|
||||
|
||||
dompurify@3.4.0:
|
||||
optionalDependencies:
|
||||
'@types/trusted-types': 2.0.7
|
||||
|
||||
@@ -14416,7 +14416,7 @@ snapshots:
|
||||
hast-util-from-parse5: 8.0.1
|
||||
hast-util-to-parse5: 8.0.0
|
||||
html-void-elements: 3.0.0
|
||||
mdast-util-to-hast: 13.2.1
|
||||
mdast-util-to-hast: 13.1.0
|
||||
parse5: 7.3.0
|
||||
unist-util-position: 5.0.0
|
||||
unist-util-visit: 5.0.0
|
||||
@@ -15342,12 +15342,12 @@ snapshots:
|
||||
|
||||
js-tokens@4.0.0: {}
|
||||
|
||||
js-yaml@3.15.0:
|
||||
js-yaml@3.14.1:
|
||||
dependencies:
|
||||
argparse: 1.0.10
|
||||
esprima: 4.0.1
|
||||
|
||||
js-yaml@4.2.0:
|
||||
js-yaml@4.1.1:
|
||||
dependencies:
|
||||
argparse: 2.0.1
|
||||
|
||||
@@ -15396,7 +15396,7 @@ snapshots:
|
||||
'@types/json-schema': 7.0.15
|
||||
'@types/lodash': 4.17.24
|
||||
is-glob: 4.0.3
|
||||
js-yaml: 4.2.0
|
||||
js-yaml: 4.1.1
|
||||
lodash: 4.18.1
|
||||
minimist: 1.2.8
|
||||
prettier: 3.8.3
|
||||
@@ -15533,7 +15533,7 @@ snapshots:
|
||||
|
||||
lines-and-columns@1.2.4: {}
|
||||
|
||||
linkify-it@5.0.2:
|
||||
linkify-it@5.0.0:
|
||||
dependencies:
|
||||
uc.micro: 2.1.0
|
||||
|
||||
@@ -15654,11 +15654,11 @@ snapshots:
|
||||
|
||||
mapbox-to-css-font@2.4.5: {}
|
||||
|
||||
markdown-it@14.2.0:
|
||||
markdown-it@14.1.1:
|
||||
dependencies:
|
||||
argparse: 2.0.1
|
||||
entities: 4.5.0
|
||||
linkify-it: 5.0.2
|
||||
linkify-it: 5.0.0
|
||||
mdurl: 2.0.0
|
||||
punycode.js: 2.3.1
|
||||
uc.micro: 2.1.0
|
||||
@@ -15772,7 +15772,7 @@ snapshots:
|
||||
unist-util-position: 4.0.4
|
||||
unist-util-visit: 4.1.2
|
||||
|
||||
mdast-util-to-hast@13.2.1:
|
||||
mdast-util-to-hast@13.1.0:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
'@types/mdast': 4.0.3
|
||||
@@ -16049,19 +16049,19 @@ snapshots:
|
||||
|
||||
minimatch@10.2.5:
|
||||
dependencies:
|
||||
brace-expansion: 2.1.1
|
||||
brace-expansion: 2.0.2
|
||||
|
||||
minimatch@3.1.5:
|
||||
dependencies:
|
||||
brace-expansion: 2.1.1
|
||||
brace-expansion: 2.0.2
|
||||
|
||||
minimatch@5.1.9:
|
||||
dependencies:
|
||||
brace-expansion: 2.1.1
|
||||
brace-expansion: 2.0.2
|
||||
|
||||
minimatch@9.0.9:
|
||||
dependencies:
|
||||
brace-expansion: 2.1.1
|
||||
brace-expansion: 2.0.2
|
||||
|
||||
minimist@1.2.8: {}
|
||||
|
||||
@@ -16075,7 +16075,7 @@ snapshots:
|
||||
|
||||
monaco-editor@0.55.1:
|
||||
dependencies:
|
||||
dompurify: 3.4.11
|
||||
dompurify: 3.2.7
|
||||
marked: 14.0.0
|
||||
|
||||
motion-dom@11.18.1:
|
||||
@@ -16216,20 +16216,20 @@ snapshots:
|
||||
dependencies:
|
||||
boolbase: 1.0.0
|
||||
|
||||
nuqs@2.8.8(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.4(react@18.2.0))(react@18.2.0):
|
||||
nuqs@2.8.8(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.3(react@18.2.0))(react@18.2.0):
|
||||
dependencies:
|
||||
'@standard-schema/spec': 1.0.0
|
||||
react: 18.2.0
|
||||
optionalDependencies:
|
||||
react-router: 6.30.4(react@18.2.0)
|
||||
react-router: 6.30.3(react@18.2.0)
|
||||
react-router-dom: 5.3.4(react@18.2.0)
|
||||
|
||||
nuqs@2.8.9(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.4(react@18.2.0))(react@18.2.0):
|
||||
nuqs@2.8.9(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.3(react@18.2.0))(react@18.2.0):
|
||||
dependencies:
|
||||
'@standard-schema/spec': 1.0.0
|
||||
react: 18.2.0
|
||||
optionalDependencies:
|
||||
react-router: 6.30.4(react@18.2.0)
|
||||
react-router: 6.30.3(react@18.2.0)
|
||||
react-router-dom: 5.3.4(react@18.2.0)
|
||||
|
||||
nwsapi@2.2.23: {}
|
||||
@@ -16336,7 +16336,7 @@ snapshots:
|
||||
find-up: 8.0.0
|
||||
fs-extra: 11.3.3
|
||||
jiti: 2.6.1
|
||||
js-yaml: 4.2.0
|
||||
js-yaml: 4.1.1
|
||||
remeda: 2.34.0
|
||||
string-argv: 0.3.2
|
||||
tsconfck: 3.1.6(typescript@5.9.3)
|
||||
@@ -16549,7 +16549,7 @@ snapshots:
|
||||
postcss-load-config@3.1.4(postcss@8.5.14)(ts-node@10.9.1(@types/node@16.18.25)(typescript@5.9.3)):
|
||||
dependencies:
|
||||
lilconfig: 2.1.0
|
||||
yaml: 1.10.3
|
||||
yaml: 1.10.2
|
||||
optionalDependencies:
|
||||
postcss: 8.5.14
|
||||
ts-node: 10.9.1(@types/node@16.18.25)(typescript@5.9.3)
|
||||
@@ -16654,7 +16654,7 @@ snapshots:
|
||||
|
||||
property-information@6.3.0: {}
|
||||
|
||||
protocol-buffers-schema@3.6.1: {}
|
||||
protocol-buffers-schema@3.6.0: {}
|
||||
|
||||
proxy-from-env@1.1.0: {}
|
||||
|
||||
@@ -17264,7 +17264,7 @@ snapshots:
|
||||
history: 5.3.0
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
react-router: 6.30.4(react@18.2.0)
|
||||
react-router: 6.30.3(react@18.2.0)
|
||||
react-router-dom: 5.3.4(react@18.2.0)
|
||||
|
||||
react-router-dom@5.3.4(react@18.2.0):
|
||||
@@ -17291,9 +17291,9 @@ snapshots:
|
||||
tiny-invariant: 1.3.3
|
||||
tiny-warning: 1.0.3
|
||||
|
||||
react-router@6.30.4(react@18.2.0):
|
||||
react-router@6.30.3(react@18.2.0):
|
||||
dependencies:
|
||||
'@remix-run/router': 1.23.3
|
||||
'@remix-run/router': 1.23.2
|
||||
react: 18.2.0
|
||||
|
||||
react-style-singleton@2.2.3(@types/react@18.0.26)(react@18.2.0):
|
||||
@@ -17517,7 +17517,7 @@ snapshots:
|
||||
|
||||
resolve-protobuf-schema@2.1.0:
|
||||
dependencies:
|
||||
protocol-buffers-schema: 3.6.1
|
||||
protocol-buffers-schema: 3.6.0
|
||||
|
||||
resolve@1.22.11:
|
||||
dependencies:
|
||||
@@ -18209,7 +18209,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@gerrit0/mini-shiki': 3.23.0
|
||||
lunr: 2.3.9
|
||||
markdown-it: 14.2.0
|
||||
markdown-it: 14.1.1
|
||||
minimatch: 10.2.5
|
||||
typescript: 5.9.3
|
||||
yaml: 2.8.4
|
||||
@@ -18673,7 +18673,7 @@ snapshots:
|
||||
|
||||
yallist@4.0.0: {}
|
||||
|
||||
yaml@1.10.3: {}
|
||||
yaml@1.10.2: {}
|
||||
|
||||
yaml@2.8.2: {}
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
|
||||
&__title {
|
||||
color: var(--l1-foreground);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
@@ -51,14 +51,14 @@
|
||||
|
||||
&__subtitle {
|
||||
color: var(--l2-foreground);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
&__description {
|
||||
font-size: var(--periscope-font-size-base);
|
||||
font-size: 14px;
|
||||
color: var(--l2-foreground);
|
||||
line-height: 20px;
|
||||
}
|
||||
@@ -67,7 +67,7 @@
|
||||
margin: 0;
|
||||
margin-top: 8px;
|
||||
color: var(--l2-foreground);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
@@ -106,7 +106,7 @@
|
||||
border: 1px dashed var(--l1-border);
|
||||
background: transparent;
|
||||
color: var(--l2-foreground);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.07px;
|
||||
@@ -120,15 +120,15 @@
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
// Stack the message and the resources card; card matches the content
|
||||
// width above it and is capped so it doesn't sprawl in a wide panel.
|
||||
&__row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 16px;
|
||||
width: fit-content;
|
||||
max-width: 600px;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
max-width: 825px;
|
||||
gap: 25px;
|
||||
justify-content: center;
|
||||
margin-left: 21px;
|
||||
}
|
||||
|
||||
&__content {
|
||||
@@ -142,7 +142,7 @@
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 4px;
|
||||
width: 100%; // match the content width above
|
||||
width: 332px;
|
||||
}
|
||||
|
||||
&__resources-title {
|
||||
@@ -155,6 +155,7 @@
|
||||
text-transform: uppercase;
|
||||
padding: 16px 16px 12px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
height: 46px;
|
||||
}
|
||||
|
||||
&__resources-links {
|
||||
|
||||
@@ -34,22 +34,6 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
// Logs-tab footer. Sibling of the scrolling .panelBody inside .panel, so it's
|
||||
// a true fixed footer flush with the panel's bottom edge — always visible
|
||||
// regardless of how the body is scrolled.
|
||||
.logsFooter {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
// Inset to match panelBody's 12px padding so the border-top aligns with
|
||||
// the content edges instead of bleeding to the panel border.
|
||||
margin: 0 12px;
|
||||
padding: 8px 0 12px;
|
||||
border-top: 1px solid var(--l2-border);
|
||||
background: var(--l1-background);
|
||||
}
|
||||
|
||||
// Single scroll: when the summary sits above (non-docked modes) it scrolls away
|
||||
// and the tab section pins to the top; tab content scrolls inside `.tabsScroll`.
|
||||
.tabsSection {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import {
|
||||
TabsContent,
|
||||
@@ -49,7 +49,6 @@ import DockModeSwitcher from './DockModeSwitcher';
|
||||
import { useSpanAttributeActions } from './hooks/useSpanAttributeActions';
|
||||
import { useTracePinnedFields } from './hooks/useTracePinnedFields';
|
||||
import Events from './Events/Events';
|
||||
import OpenInLogsExplorer from './SpanLogs/OpenInLogsExplorer';
|
||||
import SpanLogs from './SpanLogs/SpanLogs';
|
||||
import { useSpanContextLogs } from './SpanLogs/useSpanContextLogs';
|
||||
import SpanSummary from './SpanSummary';
|
||||
@@ -69,9 +68,6 @@ interface SpanDetailsPanelProps {
|
||||
// dock, or a floating/right panel widened to match). ~right-dock max width.
|
||||
const WIDE_PANEL_BREAKPOINT = 720;
|
||||
|
||||
// Context-log window padding around the span's trace time range.
|
||||
const FIVE_MINUTES_IN_MS = 5 * 60 * 1000;
|
||||
|
||||
function SpanDetailsContent({
|
||||
selectedSpan,
|
||||
traceStartTime,
|
||||
@@ -81,15 +77,12 @@ function SpanDetailsContent({
|
||||
traceStartTime?: number;
|
||||
traceEndTime?: number;
|
||||
}): JSX.Element {
|
||||
const FIVE_MINUTES_IN_MS = 5 * 60 * 1000;
|
||||
const [bodyRef, { width: bodyWidth }] = useMeasure<HTMLDivElement>();
|
||||
const spanAttributeActions = useSpanAttributeActions();
|
||||
const logTraceEvent = useTraceDetailLogEvent('v3', selectedSpan.trace_id);
|
||||
// Tracked so the panel can render tab-specific chrome (the Logs footer)
|
||||
// outside the scrolling tab content.
|
||||
const [activeTab, setActiveTab] = useState('overview');
|
||||
const handleTabChange = useCallback(
|
||||
(tab: string): void => {
|
||||
setActiveTab(tab);
|
||||
logTraceEvent(TraceDetailEvents.SpanPanelTabChanged, {
|
||||
[TraceDetailEventKeys.Tab]: tab,
|
||||
[TraceDetailEventKeys.SpanId]: selectedSpan.span_id,
|
||||
@@ -288,99 +281,89 @@ function SpanDetailsContent({
|
||||
const eventsCount = selectedSpan.events?.length || 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.panelBody} ref={bodyRef}>
|
||||
{!isWide && <div className={styles.detailsSection}>{summary}</div>}
|
||||
<div className={styles.panelBody} ref={bodyRef}>
|
||||
{!isWide && <div className={styles.detailsSection}>{summary}</div>}
|
||||
|
||||
<div className={styles.tabsSection}>
|
||||
{/* Step 9: ContentTabs */}
|
||||
<TabsRoot defaultValue="overview" onValueChange={handleTabChange}>
|
||||
<TabsList variant="secondary">
|
||||
<TabsTrigger value="overview" variant="secondary">
|
||||
<Bookmark size={14} /> Overview
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="events" variant="secondary">
|
||||
<ScrollText size={14} /> Events
|
||||
{eventsCount > 0 && (
|
||||
<Badge color="secondary" className={styles.eventsBadge}>
|
||||
{eventsCount}
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="logs" variant="secondary">
|
||||
<List size={14} /> Logs
|
||||
</TabsTrigger>
|
||||
{infraMetadata && (
|
||||
<TabsTrigger value="metrics" variant="secondary">
|
||||
<ChartColumnBig size={14} /> Metrics
|
||||
</TabsTrigger>
|
||||
<div className={styles.tabsSection}>
|
||||
{/* Step 9: ContentTabs */}
|
||||
<TabsRoot defaultValue="overview" onValueChange={handleTabChange}>
|
||||
<TabsList variant="secondary">
|
||||
<TabsTrigger value="overview" variant="secondary">
|
||||
<Bookmark size={14} /> Overview
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="events" variant="secondary">
|
||||
<ScrollText size={14} /> Events
|
||||
{eventsCount > 0 && (
|
||||
<Badge color="secondary" className={styles.eventsBadge}>
|
||||
{eventsCount}
|
||||
</Badge>
|
||||
)}
|
||||
</TabsList>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="logs" variant="secondary">
|
||||
<List size={14} /> Logs
|
||||
</TabsTrigger>
|
||||
{infraMetadata && (
|
||||
<TabsTrigger value="metrics" variant="secondary">
|
||||
<ChartColumnBig size={14} /> Metrics
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
<div className={styles.tabsScroll}>
|
||||
<TabsContent value="overview">
|
||||
{isWide && summary}
|
||||
<DataViewer
|
||||
data={spanDisplayData}
|
||||
drawerKey="trace-details"
|
||||
prettyViewProps={{
|
||||
showPinned: true,
|
||||
actions: prettyViewCustomActions,
|
||||
visibleActions: VISIBLE_ACTIONS,
|
||||
pinnedFieldsValue,
|
||||
onPinnedFieldsChange,
|
||||
}}
|
||||
<div className={styles.tabsScroll}>
|
||||
<TabsContent value="overview">
|
||||
{isWide && summary}
|
||||
<DataViewer
|
||||
data={spanDisplayData}
|
||||
drawerKey="trace-details"
|
||||
prettyViewProps={{
|
||||
showPinned: true,
|
||||
actions: prettyViewCustomActions,
|
||||
visibleActions: VISIBLE_ACTIONS,
|
||||
pinnedFieldsValue,
|
||||
onPinnedFieldsChange,
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="events">
|
||||
<Events
|
||||
span={selectedSpan}
|
||||
startTime={traceStartTime || 0}
|
||||
isSearchVisible
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="logs">
|
||||
<SpanLogs
|
||||
traceId={selectedSpan.trace_id}
|
||||
spanId={selectedSpan.span_id}
|
||||
timeRange={{
|
||||
startTime: (traceStartTime || 0) - FIVE_MINUTES_IN_MS,
|
||||
endTime: (traceEndTime || 0) + FIVE_MINUTES_IN_MS,
|
||||
}}
|
||||
logs={logs}
|
||||
isLoading={isLogsLoading}
|
||||
isError={isLogsError}
|
||||
isFetching={isLogsFetching}
|
||||
isLogSpanRelated={isLogSpanRelated}
|
||||
handleExplorerPageRedirect={handleExplorerPageRedirect}
|
||||
emptyStateConfig={!hasTraceIdLogs ? emptyLogsStateConfig : undefined}
|
||||
/>
|
||||
</TabsContent>
|
||||
{infraMetadata && (
|
||||
<TabsContent value="metrics">
|
||||
<InfraMetrics
|
||||
clusterName={infraMetadata.clusterName}
|
||||
podName={infraMetadata.podName}
|
||||
nodeName={infraMetadata.nodeName}
|
||||
hostName={infraMetadata.hostName}
|
||||
timestamp={infraMetadata.spanTimestamp}
|
||||
dataSource={DataSource.TRACES}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="events">
|
||||
<Events
|
||||
span={selectedSpan}
|
||||
startTime={traceStartTime || 0}
|
||||
isSearchVisible
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="logs">
|
||||
<SpanLogs
|
||||
traceId={selectedSpan.trace_id}
|
||||
spanId={selectedSpan.span_id}
|
||||
timeRange={{
|
||||
startTime: (traceStartTime || 0) - FIVE_MINUTES_IN_MS,
|
||||
endTime: (traceEndTime || 0) + FIVE_MINUTES_IN_MS,
|
||||
}}
|
||||
logs={logs}
|
||||
isLoading={isLogsLoading}
|
||||
isError={isLogsError}
|
||||
isFetching={isLogsFetching}
|
||||
isLogSpanRelated={isLogSpanRelated}
|
||||
handleExplorerPageRedirect={handleExplorerPageRedirect}
|
||||
emptyStateConfig={!hasTraceIdLogs ? emptyLogsStateConfig : undefined}
|
||||
/>
|
||||
</TabsContent>
|
||||
{infraMetadata && (
|
||||
<TabsContent value="metrics">
|
||||
<InfraMetrics
|
||||
clusterName={infraMetadata.clusterName}
|
||||
podName={infraMetadata.podName}
|
||||
nodeName={infraMetadata.nodeName}
|
||||
hostName={infraMetadata.hostName}
|
||||
timestamp={infraMetadata.spanTimestamp}
|
||||
dataSource={DataSource.TRACES}
|
||||
/>
|
||||
</TabsContent>
|
||||
)}
|
||||
</div>
|
||||
</TabsRoot>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsRoot>
|
||||
</div>
|
||||
|
||||
{/* Sibling of the scrolling panelBody, so it's a true fixed footer flush
|
||||
with the panel's bottom edge — always visible, never scrolls. */}
|
||||
{activeTab === 'logs' && (
|
||||
<div className={styles.logsFooter}>
|
||||
<OpenInLogsExplorer onClick={handleExplorerPageRedirect} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Compass } from '@signozhq/icons';
|
||||
|
||||
interface OpenInLogsExplorerProps {
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
// Opens the full Logs Explorer (new tab) filtered to this span's trace.
|
||||
// Placement/alignment is the caller's responsibility.
|
||||
function OpenInLogsExplorer({ onClick }: OpenInLogsExplorerProps): JSX.Element {
|
||||
return (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="md"
|
||||
onClick={onClick}
|
||||
prefix={<Compass size={16} />}
|
||||
data-testid="open-in-explorer-button"
|
||||
>
|
||||
Open in Logs Explorer
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export default OpenInLogsExplorer;
|
||||
@@ -1,4 +1,5 @@
|
||||
.spanLogs {
|
||||
margin-inline: var(--spacing-8);
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import ROUTES from 'constants/routes';
|
||||
import { getEmptyLogsListConfig } from 'container/LogsExplorerList/utils';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
|
||||
import SpanLogs from '../SpanLogs';
|
||||
|
||||
@@ -84,10 +82,8 @@ jest.mock('providers/preferences/context/PreferenceContextProvider', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock OverlayScrollbar (default export — needs __esModule for the interop
|
||||
// unwrap, otherwise the default import resolves to the module object).
|
||||
// Mock OverlayScrollbar
|
||||
jest.mock('components/OverlayScrollbar/OverlayScrollbar', () => ({
|
||||
__esModule: true,
|
||||
default: ({ children }: any): JSX.Element => (
|
||||
<div data-testid="overlay-scrollbar">{children}</div>
|
||||
),
|
||||
@@ -114,13 +110,6 @@ jest.mock(
|
||||
const TEST_TRACE_ID = 'test-trace-id';
|
||||
const TEST_SPAN_ID = 'test-span-id';
|
||||
|
||||
const sampleLog = {
|
||||
id: 'log-1',
|
||||
body: 'sample log body',
|
||||
timestamp: '1640995200000',
|
||||
spanID: TEST_SPAN_ID,
|
||||
} as unknown as ILog;
|
||||
|
||||
const defaultProps = {
|
||||
traceId: TEST_TRACE_ID,
|
||||
spanId: TEST_SPAN_ID,
|
||||
@@ -218,22 +207,4 @@ describe('SpanLogs', () => {
|
||||
|
||||
expect(mockHandleExplorerPageRedirect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('opens a new tab to Logs Explorer with the trace_id + span_id query when a log row is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<SpanLogs {...defaultProps} logs={[sampleLog]} />);
|
||||
|
||||
await user.click(screen.getByTestId(`raw-log-${sampleLog.id}`));
|
||||
|
||||
expect(mockWindowOpen).toHaveBeenCalledTimes(1);
|
||||
const [url, target] = mockWindowOpen.mock.calls[0];
|
||||
// Opens Logs Explorer in a new tab, filtered to this trace/span, and
|
||||
// deep-links to the clicked log.
|
||||
expect(url).toContain(ROUTES.LOGS_EXPLORER);
|
||||
expect(url).toContain(TEST_TRACE_ID);
|
||||
expect(url).toContain(TEST_SPAN_ID);
|
||||
expect(url).toContain(sampleLog.id); // activeLogId
|
||||
expect(target).toBe('_blank');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,19 +1,11 @@
|
||||
import { screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { render } from 'tests/test-utils';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import { SpanDetailVariant } from '../constants';
|
||||
import SpanDetailsPanel from '../SpanDetailsPanel';
|
||||
|
||||
// Mock window.open for the Open in Logs Explorer footer redirect.
|
||||
const mockWindowOpen = jest.fn();
|
||||
Object.defineProperty(window, 'open', {
|
||||
value: mockWindowOpen,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
// Placement is width-driven via useMeasure (jsdom reports 0), so we control the
|
||||
// reported width per test. `mock` prefix lets the jest.mock factory reference it.
|
||||
let mockWidth = 0;
|
||||
@@ -165,42 +157,3 @@ describe('SpanDetailsPanel – tabs', () => {
|
||||
expect(screen.getByTestId('logs-tab')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// The footer lives on the panel body (not inside the tab content) so it stays
|
||||
// pinned to the panel's visible bottom edge; it should track the active tab.
|
||||
describe('SpanDetailsPanel – Open in Logs Explorer footer', () => {
|
||||
beforeEach(() => {
|
||||
mockWindowOpen.mockClear();
|
||||
});
|
||||
|
||||
it('appears only while the Logs tab is active', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderPanel(400);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('open-in-explorer-button'),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: /logs/i }));
|
||||
expect(screen.getByTestId('open-in-explorer-button')).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: /overview/i }));
|
||||
expect(
|
||||
screen.queryByTestId('open-in-explorer-button'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens Logs Explorer in a new tab filtered to the span trace when clicked', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderPanel(400);
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: /logs/i }));
|
||||
await user.click(screen.getByTestId('open-in-explorer-button'));
|
||||
|
||||
expect(mockWindowOpen).toHaveBeenCalledTimes(1);
|
||||
const [url, target] = mockWindowOpen.mock.calls[0];
|
||||
expect(url).toContain(ROUTES.LOGS_EXPLORER);
|
||||
expect(url).toContain('trace-1');
|
||||
expect(target).toBe('_blank');
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,86 +8,7 @@
|
||||
"logs": true
|
||||
},
|
||||
"dataCollected": {
|
||||
"metrics": [
|
||||
{
|
||||
"name": "cloudsql.googleapis.com/database/up",
|
||||
"unit": "Count",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "cloudsql.googleapis.com/database/cpu/utilization",
|
||||
"unit": "Percent",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "cloudsql.googleapis.com/database/memory/utilization",
|
||||
"unit": "Percent",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "cloudsql.googleapis.com/database/memory/usage",
|
||||
"unit": "Bytes",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "cloudsql.googleapis.com/database/disk/bytes_used",
|
||||
"unit": "Bytes",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "cloudsql.googleapis.com/database/postgresql/num_backends",
|
||||
"unit": "Count",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "cloudsql.googleapis.com/database/postgresql/num_backends_by_state",
|
||||
"unit": "Count",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "cloudsql.googleapis.com/database/postgresql/transaction_count",
|
||||
"unit": "Count",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "cloudsql.googleapis.com/database/postgresql/deadlock_count",
|
||||
"unit": "Count",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "cloudsql.googleapis.com/database/postgresql/vacuum/oldest_transaction_age",
|
||||
"unit": "Count",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "cloudsql.googleapis.com/database/postgresql/insights/aggregate/execution_time",
|
||||
"unit": "Microseconds",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "cloudsql.googleapis.com/database/postgresql/insights/perquery/execution_time",
|
||||
"unit": "Microseconds",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "cloudsql.googleapis.com/database/postgresql/replication/replica_byte_lag",
|
||||
"unit": "Bytes",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
}
|
||||
],
|
||||
"metrics": [],
|
||||
"logs": []
|
||||
},
|
||||
"telemetryCollectionStrategy": {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package sqlschema
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
@@ -136,6 +138,121 @@ func (index *UniqueIndex) ToDropSQL(fmter SQLFormatter) []byte {
|
||||
return sql
|
||||
}
|
||||
|
||||
// UniqueIndexWithExpressions is a functional unique index: each key is a SQL
|
||||
// expression (e.g. LOWER(col)) emitted verbatim, so the caller owns its
|
||||
// well-formedness; plain columns go in as bare identifiers. The auto-generated
|
||||
// name uses a hash suffix (uq_t_<hash>) since expressions aren't valid
|
||||
// identifier fragments.
|
||||
//
|
||||
// Use this only when at least one key is a real expression: an all-identifier
|
||||
// key list reconstructs as a plain UniqueIndex on read-back and won't compare
|
||||
// equal — use UniqueIndex instead.
|
||||
type UniqueIndexWithExpressions struct {
|
||||
TableName TableName
|
||||
Expressions []string
|
||||
name string
|
||||
}
|
||||
|
||||
func (index *UniqueIndexWithExpressions) Name() string {
|
||||
if index.name != "" {
|
||||
return index.name
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(IndexTypeUnique.String())
|
||||
b.WriteString("_")
|
||||
b.WriteString(string(index.TableName))
|
||||
b.WriteString("_")
|
||||
hasher := fnv.New32a()
|
||||
_, _ = hasher.Write([]byte(strings.Join(normalizeExpressions(index.Expressions), "\x00")))
|
||||
fmt.Fprintf(&b, "%08x", hasher.Sum32())
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (index *UniqueIndexWithExpressions) Named(name string) Index {
|
||||
copyOfExpressions := make([]string, len(index.Expressions))
|
||||
copy(copyOfExpressions, index.Expressions)
|
||||
|
||||
return &UniqueIndexWithExpressions{
|
||||
TableName: index.TableName,
|
||||
Expressions: copyOfExpressions,
|
||||
name: name,
|
||||
}
|
||||
}
|
||||
|
||||
func (index *UniqueIndexWithExpressions) IsNamed() bool {
|
||||
return index.name != ""
|
||||
}
|
||||
|
||||
func (*UniqueIndexWithExpressions) Type() IndexType {
|
||||
return IndexTypeUnique
|
||||
}
|
||||
|
||||
// Columns is nil: a functional index exposes no plain key columns.
|
||||
func (*UniqueIndexWithExpressions) Columns() []ColumnName {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (index *UniqueIndexWithExpressions) Equals(other Index) bool {
|
||||
otherIndex, ok := other.(*UniqueIndexWithExpressions)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
// Expressions are compared normalized so a declared `LOWER(x)` matches the
|
||||
// `lower(x)` a backend renders on read-back.
|
||||
return index.Name() == other.Name() && slices.Equal(normalizeExpressions(index.Expressions), normalizeExpressions(otherIndex.Expressions))
|
||||
}
|
||||
|
||||
// normalizeExpressions canonicalizes each Expressions entry (case, whitespace,
|
||||
// redundant parens, identifier quoting) so a declared LOWER(x) matches the
|
||||
// lower(x) a backend renders on read-back. Reuses expressionNormalizer: an index key
|
||||
// is the same SQL grammar as a WHERE predicate. It does not handle opclass,
|
||||
// COLLATE, or ASC/DESC/NULLS suffixes: postgres strips them on read-back
|
||||
// (pg_get_indexdef), while sqlite keeps them verbatim, so an index declared with
|
||||
// one round-trips on sqlite but not on postgres.
|
||||
func normalizeExpressions(expressions []string) []string {
|
||||
if len(expressions) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
normalized := make([]string, len(expressions))
|
||||
for i, expression := range expressions {
|
||||
normalized[i] = (&expressionNormalizer{input: expression, foldCase: true}).normalize()
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func (index *UniqueIndexWithExpressions) ToCreateSQL(fmter SQLFormatter) []byte {
|
||||
sql := []byte{}
|
||||
|
||||
sql = append(sql, "CREATE UNIQUE INDEX IF NOT EXISTS "...)
|
||||
sql = fmter.AppendIdent(sql, index.Name())
|
||||
sql = append(sql, " ON "...)
|
||||
sql = fmter.AppendIdent(sql, string(index.TableName))
|
||||
sql = append(sql, " ("...)
|
||||
|
||||
for i, expr := range index.Expressions {
|
||||
if i > 0 {
|
||||
sql = append(sql, ", "...)
|
||||
}
|
||||
sql = append(sql, expr...)
|
||||
}
|
||||
|
||||
sql = append(sql, ")"...)
|
||||
|
||||
return sql
|
||||
}
|
||||
|
||||
func (index *UniqueIndexWithExpressions) ToDropSQL(fmter SQLFormatter) []byte {
|
||||
sql := []byte{}
|
||||
|
||||
sql = append(sql, "DROP INDEX IF EXISTS "...)
|
||||
sql = fmter.AppendIdent(sql, index.Name())
|
||||
|
||||
return sql
|
||||
}
|
||||
|
||||
type PartialUniqueIndex struct {
|
||||
TableName TableName
|
||||
ColumnNames []ColumnName
|
||||
@@ -160,7 +277,7 @@ func (index *PartialUniqueIndex) Name() string {
|
||||
b.WriteString(string(column))
|
||||
}
|
||||
b.WriteString("_")
|
||||
b.WriteString((&whereNormalizer{input: index.Where}).hash())
|
||||
b.WriteString((&expressionNormalizer{input: index.Where}).hash())
|
||||
return b.String()
|
||||
}
|
||||
|
||||
@@ -198,7 +315,7 @@ func (index *PartialUniqueIndex) Equals(other Index) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
return index.Name() == other.Name() && slices.Equal(index.Columns(), other.Columns()) && (&whereNormalizer{input: index.Where}).normalize() == (&whereNormalizer{input: otherPartial.Where}).normalize()
|
||||
return index.Name() == other.Name() && slices.Equal(index.Columns(), other.Columns()) && (&expressionNormalizer{input: index.Where}).normalize() == (&expressionNormalizer{input: otherPartial.Where}).normalize()
|
||||
}
|
||||
|
||||
func (index *PartialUniqueIndex) ToCreateSQL(fmter SQLFormatter) []byte {
|
||||
|
||||
@@ -38,6 +38,39 @@ func TestIndexToCreateSQL(t *testing.T) {
|
||||
},
|
||||
sql: `CREATE UNIQUE INDEX IF NOT EXISTS "my_index" ON "users" ("id", "name", "email")`,
|
||||
},
|
||||
{
|
||||
name: "Unique_Functional_SingleExpression",
|
||||
index: &UniqueIndexWithExpressions{
|
||||
TableName: "users",
|
||||
Expressions: []string{"LOWER(email)"},
|
||||
},
|
||||
sql: `CREATE UNIQUE INDEX IF NOT EXISTS "uq_users_72852951" ON "users" (LOWER(email))`,
|
||||
},
|
||||
{
|
||||
name: "Unique_Functional_MixedColumnsAndExpressions",
|
||||
index: &UniqueIndexWithExpressions{
|
||||
TableName: "tag",
|
||||
Expressions: []string{"org_id", "kind", "LOWER(key)", "LOWER(value)"},
|
||||
},
|
||||
sql: `CREATE UNIQUE INDEX IF NOT EXISTS "uq_tag_a36c51df" ON "tag" (org_id, kind, LOWER(key), LOWER(value))`,
|
||||
},
|
||||
{
|
||||
name: "Unique_Functional_ComplexExpression",
|
||||
index: &UniqueIndexWithExpressions{
|
||||
TableName: "users",
|
||||
Expressions: []string{"LOWER(TRIM(first_name) || ' ' || TRIM(last_name))"},
|
||||
},
|
||||
sql: `CREATE UNIQUE INDEX IF NOT EXISTS "uq_users_37e845f3" ON "users" (LOWER(TRIM(first_name) || ' ' || TRIM(last_name)))`,
|
||||
},
|
||||
{
|
||||
name: "Unique_Functional_Named",
|
||||
index: &UniqueIndexWithExpressions{
|
||||
TableName: "tag",
|
||||
Expressions: []string{"org_id", "kind", "LOWER(key)", "LOWER(value)"},
|
||||
name: "uq_tag_org_kind_lower_key_lower_value",
|
||||
},
|
||||
sql: `CREATE UNIQUE INDEX IF NOT EXISTS "uq_tag_org_kind_lower_key_lower_value" ON "tag" (org_id, kind, LOWER(key), LOWER(value))`,
|
||||
},
|
||||
{
|
||||
name: "PartialUnique_1Column",
|
||||
index: &PartialUniqueIndex{
|
||||
@@ -229,6 +262,90 @@ func TestIndexEquals(t *testing.T) {
|
||||
},
|
||||
equals: false,
|
||||
},
|
||||
{
|
||||
name: "Unique_Functional_Same",
|
||||
a: &UniqueIndexWithExpressions{
|
||||
TableName: "users",
|
||||
Expressions: []string{"LOWER(email)"},
|
||||
},
|
||||
b: &UniqueIndexWithExpressions{
|
||||
TableName: "users",
|
||||
Expressions: []string{"LOWER(email)"},
|
||||
},
|
||||
equals: true,
|
||||
},
|
||||
{
|
||||
name: "Unique_Functional_CaseInsensitiveEqual",
|
||||
a: &UniqueIndexWithExpressions{
|
||||
TableName: "users",
|
||||
Expressions: []string{"LOWER(email)"},
|
||||
},
|
||||
b: &UniqueIndexWithExpressions{
|
||||
TableName: "users",
|
||||
Expressions: []string{"lower(email)"},
|
||||
},
|
||||
equals: true,
|
||||
},
|
||||
{
|
||||
name: "Unique_Functional_QuotedSimpleIdentifierEqualsUnquoted",
|
||||
a: &UniqueIndexWithExpressions{
|
||||
TableName: "users",
|
||||
Expressions: []string{"LOWER(email)"},
|
||||
},
|
||||
b: &UniqueIndexWithExpressions{
|
||||
TableName: "users",
|
||||
Expressions: []string{`LOWER("email")`},
|
||||
},
|
||||
equals: true,
|
||||
},
|
||||
{
|
||||
name: "Unique_Functional_UnquotedMixedCaseEqualsLower",
|
||||
a: &UniqueIndexWithExpressions{
|
||||
TableName: "users",
|
||||
Expressions: []string{"LOWER(Email)"},
|
||||
},
|
||||
b: &UniqueIndexWithExpressions{
|
||||
TableName: "users",
|
||||
Expressions: []string{"LOWER(email)"},
|
||||
},
|
||||
equals: true,
|
||||
},
|
||||
{
|
||||
name: "Unique_Functional_QuotedMixedCaseNotEqualUnquoted",
|
||||
a: &UniqueIndexWithExpressions{
|
||||
TableName: "users",
|
||||
Expressions: []string{`LOWER("Email")`},
|
||||
},
|
||||
b: &UniqueIndexWithExpressions{
|
||||
TableName: "users",
|
||||
Expressions: []string{"LOWER(email)"},
|
||||
},
|
||||
equals: false,
|
||||
},
|
||||
{
|
||||
name: "Unique_Functional_DifferentExpressions",
|
||||
a: &UniqueIndexWithExpressions{
|
||||
TableName: "users",
|
||||
Expressions: []string{"LOWER(email)"},
|
||||
},
|
||||
b: &UniqueIndexWithExpressions{
|
||||
TableName: "users",
|
||||
Expressions: []string{"UPPER(email)"},
|
||||
},
|
||||
equals: false,
|
||||
},
|
||||
{
|
||||
name: "Unique_Functional_NotEqualToPlainSameColumns",
|
||||
a: &UniqueIndexWithExpressions{
|
||||
TableName: "users",
|
||||
Expressions: []string{"LOWER(email)"},
|
||||
},
|
||||
b: &UniqueIndex{
|
||||
TableName: "users",
|
||||
ColumnNames: []ColumnName{"email"},
|
||||
},
|
||||
equals: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
@@ -238,6 +355,138 @@ func TestIndexEquals(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUniqueIndexFunctionalName(t *testing.T) {
|
||||
t.Run("autogen uses uq_<table>_<hash>", func(t *testing.T) {
|
||||
idx := &UniqueIndexWithExpressions{
|
||||
TableName: "tag",
|
||||
Expressions: []string{"org_id", "kind", "LOWER(key)", "LOWER(value)"},
|
||||
}
|
||||
assert.Equal(t, "uq_tag_a36c51df", idx.Name())
|
||||
})
|
||||
|
||||
t.Run("same expressions produce the same name", func(t *testing.T) {
|
||||
a := &UniqueIndexWithExpressions{
|
||||
TableName: "users",
|
||||
Expressions: []string{"LOWER(email)"},
|
||||
}
|
||||
b := &UniqueIndexWithExpressions{
|
||||
TableName: "users",
|
||||
Expressions: []string{"LOWER(email)"},
|
||||
}
|
||||
assert.Equal(t, a.Name(), b.Name())
|
||||
})
|
||||
|
||||
t.Run("different expressions produce different names", func(t *testing.T) {
|
||||
a := &UniqueIndexWithExpressions{
|
||||
TableName: "users",
|
||||
Expressions: []string{"LOWER(email)"},
|
||||
}
|
||||
b := &UniqueIndexWithExpressions{
|
||||
TableName: "users",
|
||||
Expressions: []string{"UPPER(email)"},
|
||||
}
|
||||
assert.NotEqual(t, a.Name(), b.Name())
|
||||
})
|
||||
|
||||
t.Run("expressions in different order produce different names", func(t *testing.T) {
|
||||
a := &UniqueIndexWithExpressions{
|
||||
TableName: "tag",
|
||||
Expressions: []string{"org_id", "LOWER(key)"},
|
||||
}
|
||||
b := &UniqueIndexWithExpressions{
|
||||
TableName: "tag",
|
||||
Expressions: []string{"LOWER(key)", "org_id"},
|
||||
}
|
||||
assert.NotEqual(t, a.Name(), b.Name())
|
||||
})
|
||||
|
||||
t.Run("functional autogen differs from plain autogen for same columns", func(t *testing.T) {
|
||||
plain := &UniqueIndex{
|
||||
TableName: "users",
|
||||
ColumnNames: []ColumnName{"email"},
|
||||
}
|
||||
functional := &UniqueIndexWithExpressions{
|
||||
TableName: "users",
|
||||
Expressions: []string{"LOWER(email)"},
|
||||
}
|
||||
assert.Equal(t, "uq_users_email", plain.Name())
|
||||
assert.NotEqual(t, plain.Name(), functional.Name())
|
||||
})
|
||||
|
||||
t.Run("Named() override wins over hash", func(t *testing.T) {
|
||||
idx := (&UniqueIndexWithExpressions{
|
||||
TableName: "tag",
|
||||
Expressions: []string{"org_id", "LOWER(key)"},
|
||||
}).Named("my_functional_index")
|
||||
assert.Equal(t, "my_functional_index", idx.Name())
|
||||
})
|
||||
}
|
||||
|
||||
func TestNormalizeExpressions(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
expressions []string
|
||||
output []string
|
||||
}{
|
||||
{
|
||||
name: "Empty",
|
||||
expressions: nil,
|
||||
output: nil,
|
||||
},
|
||||
{
|
||||
name: "PlainColumnsUnchanged",
|
||||
expressions: []string{"org_id", "kind"},
|
||||
output: []string{"org_id", "kind"},
|
||||
},
|
||||
{
|
||||
name: "FunctionNameCaseFolded",
|
||||
expressions: []string{"LOWER(key)", "LOWER(value)"},
|
||||
output: []string{"lower(key)", "lower(value)"},
|
||||
},
|
||||
{
|
||||
name: "PostgresRenderedMatchesDeclared",
|
||||
expressions: []string{"lower(key)"},
|
||||
output: []string{"lower(key)"},
|
||||
},
|
||||
{
|
||||
name: "WhitespaceCollapsedPerExpression",
|
||||
expressions: []string{"LOWER( key )", "org_id"},
|
||||
output: []string{"lower( key )", "org_id"},
|
||||
},
|
||||
{
|
||||
name: "RedundantOuterParenthesesStripped",
|
||||
expressions: []string{"(LOWER(key))"},
|
||||
output: []string{"lower(key)"},
|
||||
},
|
||||
{
|
||||
name: "QuotedSimpleIdentifierUnquoted",
|
||||
expressions: []string{`LOWER("email")`},
|
||||
output: []string{"lower(email)"},
|
||||
},
|
||||
{
|
||||
name: "QuotedIdentifierCaseByIdentifierPreserved",
|
||||
expressions: []string{`LOWER("Key")`},
|
||||
output: []string{`lower("Key")`},
|
||||
},
|
||||
{
|
||||
name: "StringLiteralCasePreserved",
|
||||
expressions: []string{"COALESCE(status, 'Deleted')"},
|
||||
output: []string{"coalesce(status, 'Deleted')"},
|
||||
},
|
||||
{
|
||||
name: "NormalizedIndependentlyPerElement",
|
||||
expressions: []string{"UPPER(a)", "b", "LOWER(c)"},
|
||||
output: []string{"upper(a)", "b", "lower(c)"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
assert.Equal(t, testCase.output, normalizeExpressions(testCase.expressions))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPartialUniqueIndexName(t *testing.T) {
|
||||
a := &PartialUniqueIndex{
|
||||
TableName: "users",
|
||||
|
||||
@@ -6,25 +6,26 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
type whereNormalizer struct {
|
||||
input string
|
||||
type expressionNormalizer struct {
|
||||
input string
|
||||
foldCase bool
|
||||
}
|
||||
|
||||
func (n *whereNormalizer) hash() string {
|
||||
func (n *expressionNormalizer) hash() string {
|
||||
hasher := fnv.New32a()
|
||||
_, _ = hasher.Write([]byte(n.normalize()))
|
||||
return fmt.Sprintf("%08x", hasher.Sum32())
|
||||
}
|
||||
|
||||
func (n *whereNormalizer) normalize() string {
|
||||
where := strings.TrimSpace(n.input)
|
||||
where = n.stripOuterParentheses(where)
|
||||
func (n *expressionNormalizer) normalize() string {
|
||||
expr := strings.TrimSpace(n.input)
|
||||
expr = n.stripOuterParentheses(expr)
|
||||
|
||||
var output strings.Builder
|
||||
output.Grow(len(where))
|
||||
output.Grow(len(expr))
|
||||
|
||||
for i := 0; i < len(where); i++ {
|
||||
switch where[i] {
|
||||
for i := 0; i < len(expr); i++ {
|
||||
switch expr[i] {
|
||||
case ' ', '\t', '\n', '\r':
|
||||
if output.Len() > 0 {
|
||||
last := output.String()[output.Len()-1]
|
||||
@@ -33,21 +34,25 @@ func (n *whereNormalizer) normalize() string {
|
||||
}
|
||||
}
|
||||
case '\'':
|
||||
end := n.consumeSingleQuotedLiteral(where, i, &output)
|
||||
end := n.consumeSingleQuotedLiteral(expr, i, &output)
|
||||
i = end
|
||||
case '"':
|
||||
token, end := n.consumeDoubleQuotedToken(where, i)
|
||||
token, end := n.consumeDoubleQuotedToken(expr, i)
|
||||
output.WriteString(token)
|
||||
i = end
|
||||
default:
|
||||
output.WriteByte(where[i])
|
||||
c := expr[i]
|
||||
if n.foldCase && c >= 'A' && c <= 'Z' {
|
||||
c += 'a' - 'A'
|
||||
}
|
||||
output.WriteByte(c)
|
||||
}
|
||||
}
|
||||
|
||||
return strings.TrimSpace(output.String())
|
||||
}
|
||||
|
||||
func (n *whereNormalizer) stripOuterParentheses(s string) string {
|
||||
func (n *expressionNormalizer) stripOuterParentheses(s string) string {
|
||||
for {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) < 2 || s[0] != '(' || s[len(s)-1] != ')' || !n.hasWrappingParentheses(s) {
|
||||
@@ -57,7 +62,7 @@ func (n *whereNormalizer) stripOuterParentheses(s string) string {
|
||||
}
|
||||
}
|
||||
|
||||
func (n *whereNormalizer) hasWrappingParentheses(s string) bool {
|
||||
func (n *expressionNormalizer) hasWrappingParentheses(s string) bool {
|
||||
depth := 0
|
||||
inSingleQuotedLiteral := false
|
||||
inDoubleQuotedToken := false
|
||||
@@ -101,7 +106,7 @@ func (n *whereNormalizer) hasWrappingParentheses(s string) bool {
|
||||
return depth == 0
|
||||
}
|
||||
|
||||
func (n *whereNormalizer) consumeSingleQuotedLiteral(s string, start int, output *strings.Builder) int {
|
||||
func (n *expressionNormalizer) consumeSingleQuotedLiteral(s string, start int, output *strings.Builder) int {
|
||||
output.WriteByte(s[start])
|
||||
for i := start + 1; i < len(s); i++ {
|
||||
output.WriteByte(s[i])
|
||||
@@ -118,7 +123,7 @@ func (n *whereNormalizer) consumeSingleQuotedLiteral(s string, start int, output
|
||||
return len(s) - 1
|
||||
}
|
||||
|
||||
func (n *whereNormalizer) consumeDoubleQuotedToken(s string, start int) (string, int) {
|
||||
func (n *expressionNormalizer) consumeDoubleQuotedToken(s string, start int) (string, int) {
|
||||
var ident strings.Builder
|
||||
|
||||
for i := start + 1; i < len(s); i++ {
|
||||
@@ -142,7 +147,7 @@ func (n *whereNormalizer) consumeDoubleQuotedToken(s string, start int) (string,
|
||||
return s[start:], len(s) - 1
|
||||
}
|
||||
|
||||
func (n *whereNormalizer) isSimpleUnquotedIdentifier(s string) bool {
|
||||
func (n *expressionNormalizer) isSimpleUnquotedIdentifier(s string) bool {
|
||||
if s == "" || strings.ToLower(s) != s {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestWhereNormalizerNormalize(t *testing.T) {
|
||||
func TestExpressionNormalizerNormalize(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
input string
|
||||
@@ -47,11 +47,16 @@ func TestWhereNormalizerNormalize(t *testing.T) {
|
||||
input: `NOT ("deleted_at" IS NOT NULL AND ("active" = false OR "status" = 'archived'))`,
|
||||
output: `NOT (deleted_at IS NOT NULL AND (active = false OR status = 'archived'))`,
|
||||
},
|
||||
{
|
||||
name: "CaseNotFoldedWithoutFlag",
|
||||
input: `LOWER("email") IS NOT NULL`,
|
||||
output: `LOWER(email) IS NOT NULL`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
assert.Equal(t, testCase.output, (&whereNormalizer{input: testCase.input}).normalize())
|
||||
assert.Equal(t, testCase.output, (&expressionNormalizer{input: testCase.input}).normalize())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package sqlitesqlschema
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -92,7 +93,6 @@ func (provider *provider) GetIndices(ctx context.Context, tableName sqlschema.Ta
|
||||
unique bool
|
||||
origin string
|
||||
partial bool
|
||||
columns []sqlschema.ColumnName
|
||||
)
|
||||
if err := rows.Scan(&seq, &name, &unique, &origin, &partial); err != nil {
|
||||
return nil, err
|
||||
@@ -108,53 +108,103 @@ func (provider *provider) GetIndices(ctx context.Context, tableName sqlschema.Ta
|
||||
continue
|
||||
}
|
||||
|
||||
if err := provider.
|
||||
sqlstore.
|
||||
BunDB().
|
||||
NewRaw("SELECT name FROM PRAGMA_index_info(?)", string(name)).
|
||||
Scan(ctx, &columns); err != nil {
|
||||
if !unique {
|
||||
continue
|
||||
}
|
||||
|
||||
columns, hasExpression, err := provider.indexKeys(ctx, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if unique && partial {
|
||||
var indexSQL string
|
||||
// SQLite exposes expression keys and the predicate only in the DDL.
|
||||
var ddl string
|
||||
if hasExpression || partial {
|
||||
if err := provider.
|
||||
sqlstore.
|
||||
BunDB().
|
||||
NewRaw("SELECT sql FROM sqlite_master WHERE type = 'index' AND name = ?", name).
|
||||
Scan(ctx, &indexSQL); err != nil {
|
||||
Scan(ctx, &ddl); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
where := extractWhereClause(indexSQL)
|
||||
index := &sqlschema.PartialUniqueIndex{
|
||||
// functional partial indexes aren't representable (PartialUniqueIndex has no
|
||||
// expressions); skip rather than misrepresent.
|
||||
if hasExpression && partial {
|
||||
provider.settings.Logger().WarnContext(ctx, "skipping functional partial unique index; not representable by sqlschema", slog.String("index", name), slog.String("table", string(tableName)))
|
||||
continue
|
||||
}
|
||||
|
||||
var index sqlschema.Index
|
||||
if partial {
|
||||
index = &sqlschema.PartialUniqueIndex{
|
||||
TableName: tableName,
|
||||
ColumnNames: columns,
|
||||
Where: where,
|
||||
Where: extractWhereClause(ddl),
|
||||
}
|
||||
|
||||
if index.Name() == name {
|
||||
indices = append(indices, index)
|
||||
} else {
|
||||
indices = append(indices, index.Named(name))
|
||||
} else if hasExpression {
|
||||
index = &sqlschema.UniqueIndexWithExpressions{
|
||||
TableName: tableName,
|
||||
Expressions: extractIndexColumns(ddl),
|
||||
}
|
||||
} else if unique {
|
||||
index := &sqlschema.UniqueIndex{
|
||||
} else {
|
||||
index = &sqlschema.UniqueIndex{
|
||||
TableName: tableName,
|
||||
ColumnNames: columns,
|
||||
}
|
||||
|
||||
if index.Name() == name {
|
||||
indices = append(indices, index)
|
||||
} else {
|
||||
indices = append(indices, index.Named(name))
|
||||
}
|
||||
}
|
||||
|
||||
if index.Name() != name {
|
||||
index = index.Named(name)
|
||||
}
|
||||
indices = append(indices, index)
|
||||
}
|
||||
|
||||
return indices, nil
|
||||
}
|
||||
|
||||
// indexKeys returns an index's plain key columns and whether any key is an
|
||||
// expression (SQLite marks expression keys with cid = -2 and a NULL name).
|
||||
func (provider *provider) indexKeys(ctx context.Context, name string) ([]sqlschema.ColumnName, bool, error) {
|
||||
rows, err := provider.
|
||||
sqlstore.
|
||||
BunDB().
|
||||
QueryContext(ctx, "SELECT cid, name FROM PRAGMA_index_info(?)", name)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
defer func() {
|
||||
if err := rows.Close(); err != nil {
|
||||
provider.settings.Logger().ErrorContext(ctx, "error closing rows", errors.Attr(err))
|
||||
}
|
||||
}()
|
||||
|
||||
var (
|
||||
columns []sqlschema.ColumnName
|
||||
hasExpression bool
|
||||
)
|
||||
for rows.Next() {
|
||||
var (
|
||||
cid int
|
||||
columnName *string
|
||||
)
|
||||
if err := rows.Scan(&cid, &columnName); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
if cid == -2 {
|
||||
hasExpression = true
|
||||
continue
|
||||
}
|
||||
if columnName != nil {
|
||||
columns = append(columns, sqlschema.ColumnName(*columnName))
|
||||
}
|
||||
}
|
||||
|
||||
return columns, hasExpression, nil
|
||||
}
|
||||
|
||||
func (provider *provider) ToggleFKEnforcement(ctx context.Context, db bun.IDB, on bool) error {
|
||||
_, err := db.ExecContext(ctx, "PRAGMA foreign_keys = ?", on)
|
||||
if err != nil {
|
||||
@@ -173,6 +223,92 @@ func (provider *provider) ToggleFKEnforcement(ctx context.Context, db bun.IDB, o
|
||||
return errors.NewInternalf(errors.CodeInternal, "foreign_keys(actual: %s, expected: %s), maybe a transaction is in progress?", strconv.FormatBool(val), strconv.FormatBool(on))
|
||||
}
|
||||
|
||||
// extractIndexColumns returns an index's key entries: the comma-separated items
|
||||
// in the first top-level parenthesised list after ON, each verbatim (trimmed).
|
||||
// Quotes and nested parens are respected so their commas don't split keys.
|
||||
// SQLite reports expression keys with a NULL name in PRAGMA_index_info, so their
|
||||
// text is only recoverable from the DDL.
|
||||
func extractIndexColumns(sql string) []string {
|
||||
var (
|
||||
inSingleQuotedLiteral bool
|
||||
inDoubleQuotedIdentifier bool
|
||||
inBacktickQuotedIdentifier bool
|
||||
inBracketQuotedIdentifier bool
|
||||
depth int
|
||||
started bool
|
||||
keyStart int
|
||||
columns []string
|
||||
)
|
||||
|
||||
for i := 0; i < len(sql); i++ {
|
||||
switch sql[i] {
|
||||
case '\'':
|
||||
if inDoubleQuotedIdentifier || inBacktickQuotedIdentifier || inBracketQuotedIdentifier {
|
||||
break
|
||||
}
|
||||
if inSingleQuotedLiteral && i+1 < len(sql) && sql[i+1] == '\'' {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
inSingleQuotedLiteral = !inSingleQuotedLiteral
|
||||
continue
|
||||
case '"':
|
||||
if inSingleQuotedLiteral || inBacktickQuotedIdentifier || inBracketQuotedIdentifier {
|
||||
break
|
||||
}
|
||||
if inDoubleQuotedIdentifier && i+1 < len(sql) && sql[i+1] == '"' {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
inDoubleQuotedIdentifier = !inDoubleQuotedIdentifier
|
||||
continue
|
||||
case '`':
|
||||
if inSingleQuotedLiteral || inDoubleQuotedIdentifier || inBracketQuotedIdentifier {
|
||||
break
|
||||
}
|
||||
inBacktickQuotedIdentifier = !inBacktickQuotedIdentifier
|
||||
continue
|
||||
case '[':
|
||||
if inSingleQuotedLiteral || inDoubleQuotedIdentifier || inBacktickQuotedIdentifier || inBracketQuotedIdentifier {
|
||||
break
|
||||
}
|
||||
inBracketQuotedIdentifier = true
|
||||
continue
|
||||
case ']':
|
||||
if inBracketQuotedIdentifier {
|
||||
inBracketQuotedIdentifier = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if inSingleQuotedLiteral || inDoubleQuotedIdentifier || inBacktickQuotedIdentifier || inBracketQuotedIdentifier {
|
||||
continue
|
||||
}
|
||||
|
||||
switch sql[i] {
|
||||
case '(':
|
||||
depth++
|
||||
if depth == 1 && !started {
|
||||
started = true
|
||||
keyStart = i + 1
|
||||
}
|
||||
case ')':
|
||||
depth--
|
||||
if depth == 0 && started {
|
||||
columns = append(columns, strings.TrimSpace(sql[keyStart:i]))
|
||||
return columns
|
||||
}
|
||||
case ',':
|
||||
if depth == 1 {
|
||||
columns = append(columns, strings.TrimSpace(sql[keyStart:i]))
|
||||
keyStart = i + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return columns
|
||||
}
|
||||
|
||||
func extractWhereClause(sql string) string {
|
||||
lastWhere := -1
|
||||
inSingleQuotedLiteral := false
|
||||
|
||||
@@ -50,3 +50,93 @@ func TestExtractWhereClause(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractIndexColumns(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
sql string
|
||||
columns []string
|
||||
}{
|
||||
{
|
||||
name: "SingleColumn",
|
||||
sql: `CREATE UNIQUE INDEX "idx" ON "users" ("email")`,
|
||||
columns: []string{`"email"`},
|
||||
},
|
||||
{
|
||||
name: "SingleExpression",
|
||||
sql: `CREATE UNIQUE INDEX "idx" ON "users" (LOWER(email))`,
|
||||
columns: []string{"LOWER(email)"},
|
||||
},
|
||||
{
|
||||
name: "MixedColumnsAndExpressions",
|
||||
sql: `CREATE UNIQUE INDEX "idx" ON "tag" (org_id, kind, LOWER(key), LOWER(value))`,
|
||||
columns: []string{"org_id", "kind", "LOWER(key)", "LOWER(value)"},
|
||||
},
|
||||
{
|
||||
name: "CommaInsideStringLiteralNotSplit",
|
||||
sql: `CREATE UNIQUE INDEX "idx" ON "users" (LOWER(TRIM(first_name) || ', ' || last_name))`,
|
||||
columns: []string{"LOWER(TRIM(first_name) || ', ' || last_name)"},
|
||||
},
|
||||
{
|
||||
name: "CommaInsideQuotedIdentifierNotSplit",
|
||||
sql: `CREATE UNIQUE INDEX "idx" ON "t" ("weird,col", LOWER(x))`,
|
||||
columns: []string{`"weird,col"`, "LOWER(x)"},
|
||||
},
|
||||
{
|
||||
name: "WhereClauseAfterKeyListIgnored",
|
||||
sql: `CREATE UNIQUE INDEX "idx" ON "tag" (LOWER(key)) WHERE deleted = 0`,
|
||||
columns: []string{"LOWER(key)"},
|
||||
},
|
||||
{
|
||||
name: "WhereClauseWithParensAfterKeyListIgnored",
|
||||
sql: `CREATE UNIQUE INDEX "idx" ON "tag" (LOWER(key)) WHERE (deleted = 0 AND kind = 'a')`,
|
||||
columns: []string{"LOWER(key)"},
|
||||
},
|
||||
{
|
||||
name: "CommaInsideBacktickIdentifierNotSplit",
|
||||
sql: "CREATE UNIQUE INDEX `idx` ON `t` (`weird,col`, x)",
|
||||
columns: []string{"`weird,col`", "x"},
|
||||
},
|
||||
{
|
||||
name: "CommaInsideBracketIdentifierNotSplit",
|
||||
sql: `CREATE UNIQUE INDEX [idx] ON [t] ([weird,col], x)`,
|
||||
columns: []string{"[weird,col]", "x"},
|
||||
},
|
||||
{
|
||||
name: "EscapedDoubleQuoteInIdentifier",
|
||||
sql: `CREATE UNIQUE INDEX "idx" ON "t" ("a""b", x)`,
|
||||
columns: []string{`"a""b"`, "x"},
|
||||
},
|
||||
{
|
||||
name: "EscapedSingleQuoteInLiteral",
|
||||
sql: `CREATE UNIQUE INDEX "idx" ON "t" (COALESCE(x, 'it''s, ok'))`,
|
||||
columns: []string{"COALESCE(x, 'it''s, ok')"},
|
||||
},
|
||||
{
|
||||
name: "NestedFunctionCallCommasNotSplit",
|
||||
sql: `CREATE UNIQUE INDEX "idx" ON "t" (COALESCE(a, b, c), d)`,
|
||||
columns: []string{"COALESCE(a, b, c)", "d"},
|
||||
},
|
||||
{
|
||||
name: "NewlinesAndTabsTrimmed",
|
||||
sql: "CREATE UNIQUE INDEX \"idx\" ON \"t\" (\n\tLOWER(key),\n\torg_id\n)",
|
||||
columns: []string{"LOWER(key)", "org_id"},
|
||||
},
|
||||
{
|
||||
name: "CollateAndOrderSuffixKeptVerbatim",
|
||||
sql: `CREATE UNIQUE INDEX "idx" ON "t" (LOWER(key) COLLATE NOCASE, value DESC)`,
|
||||
columns: []string{"LOWER(key) COLLATE NOCASE", "value DESC"},
|
||||
},
|
||||
{
|
||||
name: "NoKeyListReturnsNil",
|
||||
sql: `CREATE INDEX "idx" ON "t"`,
|
||||
columns: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
assert.Equal(t, testCase.columns, extractIndexColumns(testCase.sql))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user