@querypanel/node-sdk 1.3.7 → 1.3.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -0
- package/dist/index.cjs +318 -29
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +50 -3
- package/dist/index.d.ts +50 -3
- package/dist/index.js +313 -28
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -578,6 +578,26 @@ const response = await qp.ask("Show top products", {
|
|
|
578
578
|
|
|
579
579
|
BigQuery is attached the same way with `attachBigQuery(name, clientFn, options)`. Any other engine can be plugged in with `attachDatabase(name, adapter)` and your own `DatabaseAdapter` implementation.
|
|
580
580
|
|
|
581
|
+
## One schema per tenant
|
|
582
|
+
|
|
583
|
+
If each tenant has its own schema with the same tables (for example `group_4048_indexed`, `group_4049_indexed`), attach with `tenantSchema` instead of `tenantFieldName`. Sync the schema of one tenant; the SQL QueryPanel generates names that schema, and the SDK rewrites it to the caller's schema (`{tenant}` replaced by `tenantId`) before running it:
|
|
584
|
+
|
|
585
|
+
```ts
|
|
586
|
+
qp.attachRedshift("analytics", createClient, {
|
|
587
|
+
tenantSchema: {
|
|
588
|
+
template: "group_{tenant}_indexed",
|
|
589
|
+
sharedSchemas: ["lookups"], // optional: schemas every tenant may also read
|
|
590
|
+
},
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
await qp.ask("How many members joined last month?", { tenantId: "4048" });
|
|
594
|
+
// runs against group_4048_indexed
|
|
595
|
+
```
|
|
596
|
+
|
|
597
|
+
Queries that read anything outside the tenant's schema, a shared schema or a CTE are refused, so a tenant cannot reach another tenant's schema. `allowedTables` written for the template schema (`group_4048_indexed.users`) applies to every tenant's schema. `tenantId` must be 1–64 letters, digits or `_`. Postgres and Redshift only. Adding a tenant needs no QueryPanel call: create its schema, then sign JWTs with its `tenantId`.
|
|
598
|
+
|
|
599
|
+
For embedded dashboards, set the same template as **Tenant schema template** on the datasource in QueryPanel and mint the usual JWT; `tenantId` is only the part that fills `{tenant}` (`"4048"`, not `"group_4048_indexed"`). See [Tenant isolation](https://querypanel.io/docs/tenant-isolation#schema-per-tenant).
|
|
600
|
+
|
|
581
601
|
## Building locally
|
|
582
602
|
|
|
583
603
|
```bash
|
|
@@ -621,6 +641,20 @@ The SDK will:
|
|
|
621
641
|
|
|
622
642
|
Without `maxRetry`, execution errors throw immediately (default behavior).
|
|
623
643
|
|
|
644
|
+
#### Retry when the query returns nothing
|
|
645
|
+
|
|
646
|
+
A query can run fine and still return nothing because a filter value does not match how the data stores it (`state = 'approved'` when the column holds `APPROVE`). With `retryOnEmptyResult`, the SDK treats an empty result, or a single row of only zeros/NULLs such as `COUNT(*) = 0`, like an execution error and asks for a repaired query within the same `maxRetry` budget:
|
|
647
|
+
|
|
648
|
+
```ts
|
|
649
|
+
const response = await qp.ask("How many transactions were declined?", {
|
|
650
|
+
tenantId: "tenant_123",
|
|
651
|
+
maxRetry: 1,
|
|
652
|
+
retryOnEmptyResult: true,
|
|
653
|
+
});
|
|
654
|
+
```
|
|
655
|
+
|
|
656
|
+
Only the fact that the result was empty and the previous SQL are sent back; no rows or values leave your backend. If the last attempt is still empty, it is returned as is (no error). Off by default; each retry is one more generation round, so expect a few extra seconds on those questions.
|
|
657
|
+
|
|
624
658
|
## Need more?
|
|
625
659
|
|
|
626
660
|
Open an issue or extend the SDK: the public class is in `src/index.ts` and each API route has a module under `src/routes/`. Pull requests are welcome for additional adapters, richer param coercion, or convenience helpers around charts/annotations.
|
package/dist/index.cjs
CHANGED
|
@@ -39,10 +39,14 @@ __export(index_exports, {
|
|
|
39
39
|
QueryPanelSdkAPI: () => QueryPanelSdkAPI,
|
|
40
40
|
QueryPipelineError: () => QueryPipelineError,
|
|
41
41
|
SchemaTooLargeError: () => SchemaTooLargeError,
|
|
42
|
+
TenantSchemaError: () => TenantSchemaError,
|
|
42
43
|
anonymizeResults: () => anonymizeResults,
|
|
43
44
|
mapGeneratedParams: () => mapGeneratedParams,
|
|
44
45
|
normalizeSqlParamsInput: () => normalizeSqlParamsInput,
|
|
45
|
-
|
|
46
|
+
parseTenantSchemaTemplate: () => parseTenantSchemaTemplate,
|
|
47
|
+
resolveQueryPipeline: () => resolveQueryPipeline,
|
|
48
|
+
resolveTenantSchema: () => resolveTenantSchema,
|
|
49
|
+
rewriteTenantSchema: () => rewriteTenantSchema
|
|
46
50
|
});
|
|
47
51
|
module.exports = __toCommonJS(index_exports);
|
|
48
52
|
|
|
@@ -629,6 +633,248 @@ function toNumber(value) {
|
|
|
629
633
|
return Number.isNaN(parsed) ? void 0 : parsed;
|
|
630
634
|
}
|
|
631
635
|
|
|
636
|
+
// src/core/sql-relations.ts
|
|
637
|
+
function tokenize(sql) {
|
|
638
|
+
const tokens = [];
|
|
639
|
+
let i = 0;
|
|
640
|
+
while (i < sql.length) {
|
|
641
|
+
const ch = sql[i];
|
|
642
|
+
const next = sql[i + 1];
|
|
643
|
+
if (/\s/.test(ch)) {
|
|
644
|
+
i++;
|
|
645
|
+
} else if (ch === "-" && next === "-") {
|
|
646
|
+
while (i < sql.length && sql[i] !== "\n") i++;
|
|
647
|
+
} else if (ch === "/" && next === "*") {
|
|
648
|
+
const end = sql.indexOf("*/", i + 2);
|
|
649
|
+
i = end < 0 ? sql.length : end + 2;
|
|
650
|
+
} else if (ch === "'") {
|
|
651
|
+
i++;
|
|
652
|
+
while (i < sql.length) {
|
|
653
|
+
if (sql[i] === "\\") i += 2;
|
|
654
|
+
else if (sql[i] === "'" && sql[i + 1] === "'") i += 2;
|
|
655
|
+
else if (sql[i] === "'") break;
|
|
656
|
+
else i++;
|
|
657
|
+
}
|
|
658
|
+
i++;
|
|
659
|
+
} else if (ch === "$" && /[A-Za-z_$]/.test(next ?? "")) {
|
|
660
|
+
const tag = /^\$[A-Za-z_]*\$/.exec(sql.slice(i))?.[0];
|
|
661
|
+
if (tag) {
|
|
662
|
+
const end = sql.indexOf(tag, i + tag.length);
|
|
663
|
+
i = end < 0 ? sql.length : end + tag.length;
|
|
664
|
+
} else {
|
|
665
|
+
i++;
|
|
666
|
+
}
|
|
667
|
+
} else if (ch === '"') {
|
|
668
|
+
const start = i;
|
|
669
|
+
let text = "";
|
|
670
|
+
i++;
|
|
671
|
+
while (i < sql.length) {
|
|
672
|
+
if (sql[i] === '"' && sql[i + 1] === '"') {
|
|
673
|
+
text += '"';
|
|
674
|
+
i += 2;
|
|
675
|
+
} else if (sql[i] === '"') break;
|
|
676
|
+
else text += sql[i++];
|
|
677
|
+
}
|
|
678
|
+
i++;
|
|
679
|
+
tokens.push({ kind: "word", text, quoted: true, start, end: i });
|
|
680
|
+
} else if (/[A-Za-z_]/.test(ch)) {
|
|
681
|
+
const start = i;
|
|
682
|
+
while (i < sql.length && /[A-Za-z0-9_$]/.test(sql[i])) i++;
|
|
683
|
+
tokens.push({ kind: "word", text: sql.slice(start, i), quoted: false, start, end: i });
|
|
684
|
+
} else if (/[0-9]/.test(ch)) {
|
|
685
|
+
while (i < sql.length && /[0-9.eE]/.test(sql[i])) i++;
|
|
686
|
+
} else {
|
|
687
|
+
tokens.push({ kind: "punct", text: ch, start: i, end: i + 1 });
|
|
688
|
+
i++;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
return tokens;
|
|
692
|
+
}
|
|
693
|
+
var identifier = (t) => t?.kind === "word" ? t.quoted ? t.text : t.text.toLowerCase() : void 0;
|
|
694
|
+
var isKeyword = (t, word) => t?.kind === "word" && !t.quoted && t.text.toLowerCase() === word;
|
|
695
|
+
var isPunct = (t, p) => t?.kind === "punct" && t.text === p;
|
|
696
|
+
var CLAUSE_WORDS = /* @__PURE__ */ new Set([
|
|
697
|
+
"where",
|
|
698
|
+
"group",
|
|
699
|
+
"order",
|
|
700
|
+
"limit",
|
|
701
|
+
"having",
|
|
702
|
+
"union",
|
|
703
|
+
"intersect",
|
|
704
|
+
"except",
|
|
705
|
+
"join",
|
|
706
|
+
"inner",
|
|
707
|
+
"left",
|
|
708
|
+
"right",
|
|
709
|
+
"full",
|
|
710
|
+
"cross",
|
|
711
|
+
"natural",
|
|
712
|
+
"on",
|
|
713
|
+
"using",
|
|
714
|
+
"window",
|
|
715
|
+
"qualify",
|
|
716
|
+
"offset",
|
|
717
|
+
"fetch",
|
|
718
|
+
"for",
|
|
719
|
+
"lateral",
|
|
720
|
+
"as"
|
|
721
|
+
]);
|
|
722
|
+
var FROM_CLAUSE_END = /* @__PURE__ */ new Set([
|
|
723
|
+
"where",
|
|
724
|
+
"group",
|
|
725
|
+
"having",
|
|
726
|
+
"order",
|
|
727
|
+
"limit",
|
|
728
|
+
"offset",
|
|
729
|
+
"fetch",
|
|
730
|
+
"union",
|
|
731
|
+
"intersect",
|
|
732
|
+
"except",
|
|
733
|
+
"window",
|
|
734
|
+
"qualify",
|
|
735
|
+
"for",
|
|
736
|
+
"returning"
|
|
737
|
+
]);
|
|
738
|
+
function cteNames(tokens) {
|
|
739
|
+
const names = /* @__PURE__ */ new Set();
|
|
740
|
+
for (let k = 0; k + 2 < tokens.length; k++) {
|
|
741
|
+
const value = identifier(tokens[k]);
|
|
742
|
+
if (!value) continue;
|
|
743
|
+
if (isKeyword(tokens[k + 1], "as") && isPunct(tokens[k + 2], "(")) names.add(value);
|
|
744
|
+
if (isPunct(tokens[k + 1], "(")) {
|
|
745
|
+
let j = k + 2;
|
|
746
|
+
while (j < tokens.length && !isPunct(tokens[j], ")")) j++;
|
|
747
|
+
if (isKeyword(tokens[j + 1], "as") && isPunct(tokens[j + 2], "(")) names.add(value);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
return names;
|
|
751
|
+
}
|
|
752
|
+
function findRelations(sql) {
|
|
753
|
+
const tokens = tokenize(sql);
|
|
754
|
+
const ctes = cteNames(tokens);
|
|
755
|
+
const relations = [];
|
|
756
|
+
const readItem = (start) => {
|
|
757
|
+
let k = start;
|
|
758
|
+
if (isKeyword(tokens[k], "lateral")) k++;
|
|
759
|
+
const first = identifier(tokens[k]);
|
|
760
|
+
if (!first) return k;
|
|
761
|
+
const parts = [first];
|
|
762
|
+
k++;
|
|
763
|
+
while (isPunct(tokens[k], ".") && identifier(tokens[k + 1])) {
|
|
764
|
+
parts.push(identifier(tokens[k + 1]));
|
|
765
|
+
k += 2;
|
|
766
|
+
}
|
|
767
|
+
if (isPunct(tokens[k], "(")) return k;
|
|
768
|
+
if (!(parts.length === 1 && ctes.has(first))) relations.push({ parts });
|
|
769
|
+
if (isKeyword(tokens[k], "as")) k++;
|
|
770
|
+
const alias = tokens[k];
|
|
771
|
+
if (alias?.kind === "word" && (alias.quoted || !CLAUSE_WORDS.has(alias.text.toLowerCase()))) k++;
|
|
772
|
+
return k;
|
|
773
|
+
};
|
|
774
|
+
const selectAtDepth = [false];
|
|
775
|
+
const inFromAtDepth = [false];
|
|
776
|
+
const top = () => selectAtDepth.length - 1;
|
|
777
|
+
for (let k = 0; k < tokens.length; k++) {
|
|
778
|
+
const token = tokens[k];
|
|
779
|
+
if (isPunct(token, "(")) {
|
|
780
|
+
selectAtDepth.push(false);
|
|
781
|
+
inFromAtDepth.push(false);
|
|
782
|
+
} else if (isPunct(token, ")")) {
|
|
783
|
+
if (selectAtDepth.length > 1) {
|
|
784
|
+
selectAtDepth.pop();
|
|
785
|
+
inFromAtDepth.pop();
|
|
786
|
+
}
|
|
787
|
+
} else if (isKeyword(token, "select")) {
|
|
788
|
+
selectAtDepth[top()] = true;
|
|
789
|
+
inFromAtDepth[top()] = false;
|
|
790
|
+
} else if (isKeyword(token, "join")) {
|
|
791
|
+
k = readItem(k + 1) - 1;
|
|
792
|
+
} else if (isKeyword(token, "from") && selectAtDepth[top()] && !isKeyword(tokens[k - 1], "distinct")) {
|
|
793
|
+
inFromAtDepth[top()] = true;
|
|
794
|
+
k = readItem(k + 1) - 1;
|
|
795
|
+
} else if (isPunct(token, ",") && inFromAtDepth[top()]) {
|
|
796
|
+
k = readItem(k + 1) - 1;
|
|
797
|
+
} else if (token?.kind === "word" && !token.quoted && FROM_CLAUSE_END.has(token.text.toLowerCase())) {
|
|
798
|
+
inFromAtDepth[top()] = false;
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
return relations;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// src/core/tenant-schema.ts
|
|
805
|
+
var TenantSchemaError = class extends Error {
|
|
806
|
+
constructor(message) {
|
|
807
|
+
super(message);
|
|
808
|
+
this.name = "TenantSchemaError";
|
|
809
|
+
}
|
|
810
|
+
};
|
|
811
|
+
var PLACEHOLDER = "{tenant}";
|
|
812
|
+
var TEMPLATE_PART = /^[a-z0-9_]*$/;
|
|
813
|
+
var TENANT_ID = /^[A-Za-z0-9_]{1,64}$/;
|
|
814
|
+
function parseTenantSchemaTemplate(template) {
|
|
815
|
+
const value = template.trim().toLowerCase();
|
|
816
|
+
const index = value.indexOf(PLACEHOLDER);
|
|
817
|
+
if (index < 0 || value.indexOf(PLACEHOLDER, index + 1) >= 0) {
|
|
818
|
+
throw new TenantSchemaError(
|
|
819
|
+
`Tenant schema template must contain ${PLACEHOLDER} exactly once`
|
|
820
|
+
);
|
|
821
|
+
}
|
|
822
|
+
const prefix = value.slice(0, index);
|
|
823
|
+
const suffix = value.slice(index + PLACEHOLDER.length);
|
|
824
|
+
if (!TEMPLATE_PART.test(prefix) || !TEMPLATE_PART.test(suffix) || !(prefix || suffix)) {
|
|
825
|
+
throw new TenantSchemaError(
|
|
826
|
+
"Tenant schema template may only use a-z, 0-9 and _ around {tenant}"
|
|
827
|
+
);
|
|
828
|
+
}
|
|
829
|
+
return { prefix, suffix };
|
|
830
|
+
}
|
|
831
|
+
function matchesTemplate({ prefix, suffix }, schema) {
|
|
832
|
+
const value = schema.toLowerCase();
|
|
833
|
+
return value.length > prefix.length + suffix.length && value.startsWith(prefix) && value.endsWith(suffix) && /^[a-z0-9_]+$/.test(value.slice(prefix.length, value.length - suffix.length));
|
|
834
|
+
}
|
|
835
|
+
function resolveTenantSchema(template, tenantId) {
|
|
836
|
+
const { prefix, suffix } = parseTenantSchemaTemplate(template);
|
|
837
|
+
const id = tenantId.trim();
|
|
838
|
+
if (!TENANT_ID.test(id)) {
|
|
839
|
+
throw new TenantSchemaError(
|
|
840
|
+
"tenantId must be 1-64 characters of letters, digits or _ for schema-per-tenant isolation"
|
|
841
|
+
);
|
|
842
|
+
}
|
|
843
|
+
return `${prefix}${id.toLowerCase()}${suffix}`;
|
|
844
|
+
}
|
|
845
|
+
function rewriteTenantSchema(sql, template, tenantId) {
|
|
846
|
+
const parsed = parseTenantSchemaTemplate(template);
|
|
847
|
+
const tenantSchema = resolveTenantSchema(template, tenantId);
|
|
848
|
+
const tokens = tokenize(sql);
|
|
849
|
+
let rewritten = "";
|
|
850
|
+
let cursor = 0;
|
|
851
|
+
for (let k = 0; k < tokens.length; k++) {
|
|
852
|
+
const token = tokens[k];
|
|
853
|
+
const value = identifier(token);
|
|
854
|
+
if (!token || !value || !isPunct(tokens[k + 1], ".")) continue;
|
|
855
|
+
if (!matchesTemplate(parsed, value) || value.toLowerCase() === tenantSchema) continue;
|
|
856
|
+
const replacement = token.kind === "word" && token.quoted ? `"${tenantSchema}"` : tenantSchema;
|
|
857
|
+
rewritten += sql.slice(cursor, token.start) + replacement;
|
|
858
|
+
cursor = token.end;
|
|
859
|
+
}
|
|
860
|
+
rewritten += sql.slice(cursor);
|
|
861
|
+
const pattern = new RegExp(`${escapeRegex(parsed.prefix)}[a-z0-9_]+${escapeRegex(parsed.suffix)}`, "gi");
|
|
862
|
+
for (const match of rewritten.matchAll(pattern)) {
|
|
863
|
+
if (match[0].toLowerCase() !== tenantSchema) {
|
|
864
|
+
throw new TenantSchemaError("Query references another tenant's schema");
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
return rewritten;
|
|
868
|
+
}
|
|
869
|
+
var TENANT_SCHEMA_KEY = PLACEHOLDER;
|
|
870
|
+
function tenantSchemaKey(template) {
|
|
871
|
+
const parsed = parseTenantSchemaTemplate(template);
|
|
872
|
+
return (schema) => matchesTemplate(parsed, schema) ? TENANT_SCHEMA_KEY : schema.toLowerCase();
|
|
873
|
+
}
|
|
874
|
+
function escapeRegex(value) {
|
|
875
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
876
|
+
}
|
|
877
|
+
|
|
632
878
|
// src/adapters/postgres.ts
|
|
633
879
|
var WILDCARD = "*";
|
|
634
880
|
function maxPostgresPlaceholderIndex(sql) {
|
|
@@ -675,6 +921,11 @@ var PostgresAdapter = class {
|
|
|
675
921
|
this.defaultSchema
|
|
676
922
|
);
|
|
677
923
|
}
|
|
924
|
+
this.allowsRelation = buildRelationAllowlist(
|
|
925
|
+
this.allowedTables,
|
|
926
|
+
options.tenantSchema,
|
|
927
|
+
this.defaultSchema
|
|
928
|
+
);
|
|
678
929
|
}
|
|
679
930
|
clientFn;
|
|
680
931
|
databaseName;
|
|
@@ -682,9 +933,11 @@ var PostgresAdapter = class {
|
|
|
682
933
|
kind;
|
|
683
934
|
dialect;
|
|
684
935
|
allowedTables;
|
|
936
|
+
/** Undefined when queries are unrestricted. */
|
|
937
|
+
allowsRelation;
|
|
685
938
|
async execute(sql, params) {
|
|
686
|
-
if (this.
|
|
687
|
-
this.validateQueryTables(sql);
|
|
939
|
+
if (this.allowsRelation) {
|
|
940
|
+
this.validateQueryTables(sql, this.allowsRelation);
|
|
688
941
|
}
|
|
689
942
|
const boundParams = filterParamsForPostgresSql(sql, params);
|
|
690
943
|
let paramArray;
|
|
@@ -695,29 +948,11 @@ var PostgresAdapter = class {
|
|
|
695
948
|
const fields = result.fields.map((f) => f.name);
|
|
696
949
|
return { fields, rows: result.rows };
|
|
697
950
|
}
|
|
698
|
-
validateQueryTables(sql) {
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
this.allowedTables.map((t) => tableKey2(t.schema, t.table))
|
|
704
|
-
);
|
|
705
|
-
const allowedSchemas = new Set(
|
|
706
|
-
this.allowedTables.filter((t) => t.table === WILDCARD).map((t) => t.schema)
|
|
707
|
-
);
|
|
708
|
-
const neutralizedSql = sql.replace(/EXTRACT\s*\([^)]*FROM\s+[^)]+\)/gi, "EXTRACT(/*neutralized*/)").replace(/SUBSTRING\s*\([^)]*FROM\s+[^)]+\)/gi, "SUBSTRING(/*neutralized*/)").replace(/TRIM\s*\([^)]*FROM\s+[^)]+\)/gi, "TRIM(/*neutralized*/)").replace(/POSITION\s*\([^)]*FROM\s+[^)]+\)/gi, "POSITION(/*neutralized*/)");
|
|
709
|
-
const tablePattern = /(?:FROM|JOIN)\s+(?:ONLY\s+)?(?:([a-zA-Z_][a-zA-Z0-9_]*)\.)?(["']?[a-zA-Z_][a-zA-Z0-9_]*["']?)/gi;
|
|
710
|
-
const matches = neutralizedSql.matchAll(tablePattern);
|
|
711
|
-
for (const match of matches) {
|
|
712
|
-
const schema = match[1] ?? this.defaultSchema;
|
|
713
|
-
const table = match[2]?.replace(/['"]/g, "");
|
|
714
|
-
if (table) {
|
|
715
|
-
const key = tableKey2(schema, table);
|
|
716
|
-
if (!allowedSet.has(key) && !allowedSchemas.has(schema)) {
|
|
717
|
-
throw new Error(
|
|
718
|
-
`Query references table "${schema}.${table}" which is not in the allowed tables list`
|
|
719
|
-
);
|
|
720
|
-
}
|
|
951
|
+
validateQueryTables(sql, allows) {
|
|
952
|
+
for (const relation of findRelations(sql)) {
|
|
953
|
+
if (!allows(relation)) {
|
|
954
|
+
const name = relation.parts.length === 1 ? `${this.defaultSchema}.${relation.parts[0]}` : relation.parts.join(".");
|
|
955
|
+
throw new Error(`Query references table "${name}" which is not in the allowed tables list`);
|
|
721
956
|
}
|
|
722
957
|
}
|
|
723
958
|
}
|
|
@@ -755,6 +990,9 @@ var PostgresAdapter = class {
|
|
|
755
990
|
return positionalParams;
|
|
756
991
|
}
|
|
757
992
|
async validate(sql, params) {
|
|
993
|
+
if (this.allowsRelation) {
|
|
994
|
+
this.validateQueryTables(sql, this.allowsRelation);
|
|
995
|
+
}
|
|
758
996
|
const boundParams = filterParamsForPostgresSql(sql, params);
|
|
759
997
|
let paramArray;
|
|
760
998
|
if (boundParams) {
|
|
@@ -1044,6 +1282,27 @@ function buildListTablesQuery(dialect, options) {
|
|
|
1044
1282
|
LIMIT ${limit};`;
|
|
1045
1283
|
return { sql, params };
|
|
1046
1284
|
}
|
|
1285
|
+
function buildRelationAllowlist(allowedTables, tenantSchema, defaultSchema) {
|
|
1286
|
+
if (!allowedTables?.length && !tenantSchema) return void 0;
|
|
1287
|
+
const schemaKey = tenantSchema ? tenantSchemaKey(tenantSchema.template) : (schema) => schema.toLowerCase();
|
|
1288
|
+
const entries = [
|
|
1289
|
+
...allowedTables?.length ? allowedTables : [{ schema: TENANT_SCHEMA_KEY, table: WILDCARD }],
|
|
1290
|
+
...(tenantSchema?.sharedSchemas ?? []).map((schema) => ({ schema, table: WILDCARD }))
|
|
1291
|
+
];
|
|
1292
|
+
const tables = /* @__PURE__ */ new Set();
|
|
1293
|
+
const wildcardSchemas = /* @__PURE__ */ new Set();
|
|
1294
|
+
for (const entry of entries) {
|
|
1295
|
+
const schema = entry.schema === TENANT_SCHEMA_KEY ? entry.schema : schemaKey(entry.schema);
|
|
1296
|
+
if (entry.table === WILDCARD) wildcardSchemas.add(schema);
|
|
1297
|
+
else tables.add(tableKey2(schema, entry.table.toLowerCase()));
|
|
1298
|
+
}
|
|
1299
|
+
return ({ parts }) => {
|
|
1300
|
+
if (parts.length > 2) return false;
|
|
1301
|
+
const table = parts[parts.length - 1].toLowerCase();
|
|
1302
|
+
const schema = schemaKey(parts.length === 2 ? parts[0] : defaultSchema);
|
|
1303
|
+
return wildcardSchemas.has(schema) || tables.has(tableKey2(schema, table));
|
|
1304
|
+
};
|
|
1305
|
+
}
|
|
1047
1306
|
function tableKey2(schema, table) {
|
|
1048
1307
|
return `${schema}.${table}`;
|
|
1049
1308
|
}
|
|
@@ -1648,7 +1907,9 @@ var QueryEngine = class {
|
|
|
1648
1907
|
const adapter = this.getDatabase(databaseName);
|
|
1649
1908
|
const metadata = this.getDatabaseMetadata(databaseName);
|
|
1650
1909
|
let finalSql = sql;
|
|
1651
|
-
if (metadata) {
|
|
1910
|
+
if (metadata?.tenantSchema) {
|
|
1911
|
+
finalSql = rewriteTenantSchema(sql, metadata.tenantSchema.template, tenantId);
|
|
1912
|
+
} else if (metadata) {
|
|
1652
1913
|
finalSql = this.ensureTenantIsolation(sql, params, metadata, tenantId);
|
|
1653
1914
|
}
|
|
1654
1915
|
await adapter.validate(finalSql, params);
|
|
@@ -2492,6 +2753,15 @@ function resolveQueryPipeline(pipeline) {
|
|
|
2492
2753
|
}
|
|
2493
2754
|
return "v2";
|
|
2494
2755
|
}
|
|
2756
|
+
var EMPTY_RESULT_ERROR = "The query ran but returned no rows (or only zero/NULL values). A filter value probably does not match how the data stores it (case, spelling, codes such as 'APPROVE' vs 'approved'), or a filter is narrower than the question. Check each filter value against the column's documented values and fix it; keep every filter the question asks for.";
|
|
2757
|
+
function isEmptyLikeResult(rows) {
|
|
2758
|
+
if (rows.length === 0) return true;
|
|
2759
|
+
if (rows.length > 1) return false;
|
|
2760
|
+
const row = rows[0];
|
|
2761
|
+
if (!row || typeof row !== "object") return false;
|
|
2762
|
+
const values = Object.values(row);
|
|
2763
|
+
return values.length > 0 && values.every((v) => v === null || v === 0 || v === "0" || v === 0n);
|
|
2764
|
+
}
|
|
2495
2765
|
async function ask(client, queryEngine, question, options, signal) {
|
|
2496
2766
|
const tenantId = resolveTenantId5(client, options.tenantId);
|
|
2497
2767
|
const sessionId = import_node_crypto3.default.randomUUID();
|
|
@@ -2574,6 +2844,17 @@ async function ask(client, queryEngine, question, options, signal) {
|
|
|
2574
2844
|
tenantId
|
|
2575
2845
|
);
|
|
2576
2846
|
const rows = execution.rows ?? [];
|
|
2847
|
+
if (options.retryOnEmptyResult && attempt < maxRetry && isEmptyLikeResult(rows)) {
|
|
2848
|
+
attempt++;
|
|
2849
|
+
lastError = EMPTY_RESULT_ERROR;
|
|
2850
|
+
previousSql = sql;
|
|
2851
|
+
console.warn("Query returned no data, retrying query generation", {
|
|
2852
|
+
attempt,
|
|
2853
|
+
maxAttempts: maxRetry + 1,
|
|
2854
|
+
sql
|
|
2855
|
+
});
|
|
2856
|
+
continue;
|
|
2857
|
+
}
|
|
2577
2858
|
const chartType = options.chartType ?? "vega-lite";
|
|
2578
2859
|
let chart = {
|
|
2579
2860
|
specType: chartType,
|
|
@@ -3377,6 +3658,9 @@ var QueryPanelSdkAPI = class _QueryPanelSdkAPI {
|
|
|
3377
3658
|
this.queryEngine.attachDatabase(name, adapter, metadata);
|
|
3378
3659
|
}
|
|
3379
3660
|
attachPostgres(name, clientFn, options) {
|
|
3661
|
+
if (options?.tenantSchema && options.tenantFieldName) {
|
|
3662
|
+
throw new Error("Use either tenantSchema or tenantFieldName for tenant isolation, not both");
|
|
3663
|
+
}
|
|
3380
3664
|
const adapter = new PostgresAdapter(clientFn, options);
|
|
3381
3665
|
const metadata = {
|
|
3382
3666
|
name,
|
|
@@ -3388,7 +3672,8 @@ var QueryPanelSdkAPI = class _QueryPanelSdkAPI {
|
|
|
3388
3672
|
enforceTenantIsolation: resolveAttachEnforceTenantIsolation(
|
|
3389
3673
|
options?.tenantFieldName,
|
|
3390
3674
|
options?.enforceTenantIsolation
|
|
3391
|
-
)
|
|
3675
|
+
),
|
|
3676
|
+
tenantSchema: options?.tenantSchema
|
|
3392
3677
|
};
|
|
3393
3678
|
this.queryEngine.attachDatabase(name, adapter, metadata);
|
|
3394
3679
|
}
|
|
@@ -4443,9 +4728,13 @@ var QueryPanelSdkAPI = class _QueryPanelSdkAPI {
|
|
|
4443
4728
|
QueryPanelSdkAPI,
|
|
4444
4729
|
QueryPipelineError,
|
|
4445
4730
|
SchemaTooLargeError,
|
|
4731
|
+
TenantSchemaError,
|
|
4446
4732
|
anonymizeResults,
|
|
4447
4733
|
mapGeneratedParams,
|
|
4448
4734
|
normalizeSqlParamsInput,
|
|
4449
|
-
|
|
4735
|
+
parseTenantSchemaTemplate,
|
|
4736
|
+
resolveQueryPipeline,
|
|
4737
|
+
resolveTenantSchema,
|
|
4738
|
+
rewriteTenantSchema
|
|
4450
4739
|
});
|
|
4451
4740
|
//# sourceMappingURL=index.cjs.map
|