Skip to content
This repository was archived by the owner on Nov 7, 2025. It is now read-only.

Commit 4a191fe

Browse files
nablaonemieciu
andauthored
Experimental - Field map syntax (#1344)
This is experimental support for accessing Map columns (String,...). For example: ``` CREATE TABLE IF NOT EXISTS "foo" ( "@timestamp" DateTime64 DEFAULT now64(), `bar` Map(String, Nullable(String)) ) ENGINE = MergeTree ORDER BY ("@timestamp"); insert into foo values ('2020-01-01 00:00:00', {'a': 'b'}); insert into foo values ('2020-01-01 00:00:00', {'c': 'd', 'e': 'f'}); insert into foo values ('2020-01-01 00:00:00', {'g': 'h', 'i': 'j', 'k': 'l'}); ``` We can access the value of the map by using `fieldname.key` syntax ``` { "query": { "bool": { "must": [ { "match": { "bar.a": "b" } } ] }, "match_all": {} } } ``` Feature is disabled by default, it can be enabled by: ``` foo: enableFieldMapSyntax: true target: [ my-clickhouse-data-source ] ``` --------- Co-authored-by: Przemysław Hejman <[email protected]>
1 parent f1fc327 commit 4a191fe

File tree

5 files changed

+221
-2
lines changed

5 files changed

+221
-2
lines changed

platform/clickhouse/schema.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,7 @@ func ResolveType(clickHouseTypeName string) reflect.Type {
233233
return reflect.TypeOf(true)
234234
case "JSON":
235235
return reflect.TypeOf(map[string]interface{}{})
236-
case "Map(String, Nullable(String))", "Map(String, String)":
236+
case "Map(String, Nullable(String))", "Map(String, String)", "Map(LowCardinality(String), String)":
237237
return reflect.TypeOf(map[string]string{})
238238
case "Unknown":
239239
return reflect.TypeOf(UnknownType{})

platform/clickhouse/type_adapter.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ func (c SchemaTypeAdapter) Convert(s string) (schema.QuesmaType, bool) {
3838
return schema.QuesmaTypeDate, true
3939
case "Point":
4040
return schema.QuesmaTypePoint, true
41-
case "Map(String, Nullable(String))", "Map(String, String)":
41+
case "Map(String, Nullable(String))", "Map(String, String)", "Map(LowCardinality(String), Nullable(String))", "Map(LowCardinality(String), String)":
4242
return schema.QuesmaTypeMap, true
4343
default:
4444
return schema.QuesmaTypeUnknown, false

platform/config/index_config.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ type IndexConfiguration struct {
2020
UseCommonTable bool `koanf:"useCommonTable"`
2121
Target any `koanf:"target"`
2222

23+
EnableFieldMapSyntax bool `koanf:"enableFieldMapSyntax"`
24+
2325
// Computed based on the overall configuration
2426
QueryTarget []string
2527
IngestTarget []string

platform/frontend_connectors/schema_transformer.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,19 @@ func NewSchemaCheckPass(cfg *config.QuesmaConfiguration, tableDiscovery clickhou
2929
}
3030
}
3131

32+
func (s *SchemaCheckPass) isFieldMapSyntaxEnabled(query *model.Query) bool {
33+
34+
var enabled bool
35+
36+
if len(query.Indexes) == 1 {
37+
if indexConf, ok := s.cfg.IndexConfig[query.Indexes[0]]; ok {
38+
enabled = indexConf.EnableFieldMapSyntax
39+
}
40+
}
41+
42+
return enabled
43+
}
44+
3245
func (s *SchemaCheckPass) applyBooleanLiteralLowering(index schema.Schema, query *model.Query) (*model.Query, error) {
3346

3447
visitor := model.NewBaseVisitor()
@@ -716,6 +729,26 @@ func (s *SchemaCheckPass) applyFieldEncoding(indexSchema schema.Schema, query *m
716729
if resolvedField, ok := indexSchema.ResolveField(e.ColumnName); ok {
717730
return model.NewColumnRefWithTable(resolvedField.InternalPropertyName.AsString(), e.TableAlias)
718731
} else {
732+
// here we didn't find a column by field name,
733+
// we try some other options
734+
735+
// 1. we check if the field name point to the map
736+
if s.isFieldMapSyntaxEnabled(query) {
737+
elements := strings.Split(e.ColumnName, ".")
738+
if len(elements) > 1 {
739+
if mapField, ok := indexSchema.ResolveField(elements[0]); ok {
740+
// check if we have map type, especially Map(String, any) here
741+
if mapField.Type.Name == schema.QuesmaTypeMap.Name &&
742+
(strings.HasPrefix(mapField.InternalPropertyType, "Map(String") ||
743+
strings.HasPrefix(mapField.InternalPropertyType, "Map(LowCardinality(String")) {
744+
return model.NewFunction("arrayElement", model.NewColumnRef(elements[0]), model.NewLiteral(fmt.Sprintf("'%s'", strings.Join(elements[1:], "."))))
745+
}
746+
}
747+
}
748+
}
749+
750+
// 2. maybe we should use attributes
751+
719752
if hasAttributesValuesColumn {
720753
return model.NewArrayAccess(model.NewColumnRef(clickhouse.AttributesValuesColumn), model.NewLiteral(fmt.Sprintf("'%s'", e.ColumnName)))
721754
} else {
@@ -1041,6 +1074,46 @@ func (s *SchemaCheckPass) applyMatchOperator(indexSchema schema.Schema, query *m
10411074
}
10421075
}
10431076

1077+
if s.isFieldMapSyntaxEnabled(query) {
1078+
// special case where left side is arrayElement,
1079+
// arrayElement comes from applyFieldEncoding function
1080+
arrayElementFn, ok := e.Left.(model.FunctionExpr)
1081+
if ok && arrayElementFn.Name == "arrayElement" && e.Op == model.MatchOperator {
1082+
1083+
if len(arrayElementFn.Args) == 2 {
1084+
if col, ok := arrayElementFn.Args[0].(model.ColumnRef); ok {
1085+
field, found := indexSchema.ResolveFieldByInternalName(col.ColumnName)
1086+
1087+
if found {
1088+
internalType := field.InternalPropertyType
1089+
1090+
// we support Map(K,V) type only
1091+
if strings.HasPrefix(internalType, "Map(") {
1092+
types := strings.TrimPrefix(strings.TrimSuffix(internalType, ")"), "Map(")
1093+
types = strings.ReplaceAll(types, " ", "")
1094+
kvTypes := strings.Split(types, ",")
1095+
1096+
// sanity check for map type with two elements
1097+
if len(kvTypes) == 2 {
1098+
rhsValue := rhs.Value.(string)
1099+
rhsValue = strings.TrimPrefix(rhsValue, "'")
1100+
rhsValue = strings.TrimSuffix(rhsValue, "'")
1101+
1102+
// here we check if the value of the map is string or not
1103+
1104+
if strings.Contains(kvTypes[1], "String") {
1105+
return model.NewInfixExpr(arrayElementFn.Accept(b).(model.Expr), "iLIKE", model.NewLiteralWithEscapeType(rhsValue, model.NotEscapedLikeFull))
1106+
} else {
1107+
return model.NewInfixExpr(arrayElementFn.Accept(b).(model.Expr), "=", e.Right.Accept(b).(model.Expr))
1108+
}
1109+
}
1110+
}
1111+
}
1112+
}
1113+
}
1114+
}
1115+
}
1116+
10441117
return model.NewInfixExpr(e.Left.Accept(b).(model.Expr), e.Op, e.Right.Accept(b).(model.Expr))
10451118
}
10461119

platform/frontend_connectors/schema_transformer_test.go

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1344,3 +1344,147 @@ func Test_checkAggOverUnsupportedType(t *testing.T) {
13441344
})
13451345
}
13461346
}
1347+
1348+
func Test_mapKeys(t *testing.T) {
1349+
1350+
indexConfig := map[string]config.IndexConfiguration{
1351+
"test": {EnableFieldMapSyntax: true},
1352+
"test2": {EnableFieldMapSyntax: false},
1353+
}
1354+
1355+
fields := map[schema.FieldName]schema.Field{
1356+
"@timestamp": {PropertyName: "@timestamp", InternalPropertyName: "@timestamp", InternalPropertyType: "DateTime64", Type: schema.QuesmaTypeDate},
1357+
"foo": {PropertyName: "foo", InternalPropertyName: "foo", InternalPropertyType: "Map(String, String)", Type: schema.QuesmaTypeMap},
1358+
"sizes": {PropertyName: "sizes", InternalPropertyName: "sizes", InternalPropertyType: "Map(String, Int64)", Type: schema.QuesmaTypeMap},
1359+
}
1360+
1361+
indexSchema := schema.Schema{
1362+
Fields: fields,
1363+
}
1364+
1365+
tableMap := clickhouse.NewTableMap()
1366+
1367+
tableDiscovery := clickhouse.NewEmptyTableDiscovery()
1368+
tableDiscovery.TableMap = tableMap
1369+
for indexName := range indexConfig {
1370+
tableMap.Store(indexName, clickhouse.NewEmptyTable(indexName))
1371+
}
1372+
1373+
transform := NewSchemaCheckPass(&config.QuesmaConfiguration{IndexConfig: indexConfig}, tableDiscovery, defaultSearchAfterStrategy)
1374+
1375+
tests := []struct {
1376+
name string
1377+
query *model.Query
1378+
expected *model.Query
1379+
}{
1380+
1381+
{
1382+
name: "match operator transformation for String (ILIKE)",
1383+
query: &model.Query{
1384+
TableName: "test",
1385+
SelectCommand: model.SelectCommand{
1386+
FromClause: model.NewTableRef("test"),
1387+
Columns: []model.Expr{model.NewColumnRef("foo")},
1388+
WhereClause: model.NewInfixExpr(
1389+
model.NewColumnRef("foo.bar"),
1390+
model.MatchOperator,
1391+
model.NewLiteral("'baz'"),
1392+
),
1393+
},
1394+
},
1395+
expected: &model.Query{
1396+
TableName: "test",
1397+
SelectCommand: model.SelectCommand{
1398+
FromClause: model.NewTableRef("test"),
1399+
Columns: []model.Expr{model.NewColumnRef("foo")},
1400+
WhereClause: model.NewInfixExpr(
1401+
model.NewFunction("arrayElement", model.NewColumnRef("foo"), model.NewLiteral("'bar'")),
1402+
"iLIKE",
1403+
model.NewLiteral("'%baz%'"),
1404+
),
1405+
},
1406+
},
1407+
},
1408+
1409+
{
1410+
name: "match operator transformation for int (=)",
1411+
query: &model.Query{
1412+
TableName: "test",
1413+
SelectCommand: model.SelectCommand{
1414+
FromClause: model.NewTableRef("test"),
1415+
Columns: []model.Expr{model.NewColumnRef("foo")},
1416+
WhereClause: model.NewInfixExpr(
1417+
model.NewColumnRef("sizes.bar"),
1418+
model.MatchOperator,
1419+
model.NewLiteral("1"),
1420+
),
1421+
},
1422+
},
1423+
expected: &model.Query{
1424+
TableName: "test",
1425+
SelectCommand: model.SelectCommand{
1426+
FromClause: model.NewTableRef("test"),
1427+
Columns: []model.Expr{model.NewColumnRef("foo")},
1428+
WhereClause: model.NewInfixExpr(
1429+
model.NewFunction("arrayElement", model.NewColumnRef("sizes"), model.NewLiteral("'bar'")),
1430+
"=",
1431+
model.NewLiteral("1"),
1432+
),
1433+
},
1434+
},
1435+
},
1436+
1437+
{
1438+
name: "not enabled opt-in flag, we do not transform at all",
1439+
query: &model.Query{
1440+
TableName: "test2",
1441+
SelectCommand: model.SelectCommand{
1442+
FromClause: model.NewTableRef("test2"),
1443+
Columns: []model.Expr{model.NewColumnRef("foo")},
1444+
WhereClause: model.NewInfixExpr(
1445+
model.NewColumnRef("foo.bar"),
1446+
model.MatchOperator,
1447+
model.NewLiteral("'baz'"),
1448+
),
1449+
},
1450+
},
1451+
expected: &model.Query{
1452+
TableName: "test2",
1453+
SelectCommand: model.SelectCommand{
1454+
FromClause: model.NewTableRef("test2"),
1455+
Columns: []model.Expr{model.NewColumnRef("foo")},
1456+
WhereClause: model.NewInfixExpr(
1457+
model.NewLiteral("NULL"),
1458+
model.MatchOperator,
1459+
model.NewLiteral("'baz'"),
1460+
),
1461+
},
1462+
},
1463+
},
1464+
}
1465+
1466+
asString := func(query *model.Query) string {
1467+
return query.SelectCommand.String()
1468+
}
1469+
1470+
for _, tt := range tests {
1471+
t.Run(tt.name, func(t *testing.T) {
1472+
tt.query.Schema = indexSchema
1473+
tt.query.Indexes = []string{tt.query.TableName}
1474+
actual, err := transform.Transform([]*model.Query{tt.query})
1475+
assert.NoError(t, err)
1476+
1477+
if err != nil {
1478+
t.Fatal(err)
1479+
}
1480+
1481+
assert.True(t, len(actual) == 1, "len queries == 1")
1482+
1483+
expectedJson := asString(tt.expected)
1484+
actualJson := asString(actual[0])
1485+
1486+
assert.Equal(t, expectedJson, actualJson)
1487+
})
1488+
}
1489+
1490+
}

0 commit comments

Comments
 (0)