From a7edf6b5c62d02b7d5199fc83d435f6a37a8eac5 Mon Sep 17 00:00:00 2001 From: Takeshi Nakata <7553415+nktks@users.noreply.github.com> Date: Mon, 6 Mar 2023 13:00:14 +0900 Subject: [PATCH] feat(spanner/spansql): support fine-grained access control DDL syntax (#6691) * feat(spanner/spannersql): support create or drop role clause * feat(spanner/spannersql): support grant or revoke role clause * fix(spanner/spannersql): fix variable name * fix(spanner/spansql): adjust case * fix(spanner/spansql): fix duplicate token check * fix(spanner/spansql): gofmt -s -d -l -w . * feat(spanner/spansql): fix debug log message * feat(spanner/spansql): modify func name from parseCommaListWithBraket to parseCommaList --- spanner/spansql/parser.go | 233 ++++++++++++++++++++++++++++++++- spanner/spansql/parser_test.go | 183 +++++++++++++++++++++++++- spanner/spansql/sql.go | 61 +++++++++ spanner/spansql/sql_test.go | 44 +++++++ spanner/spansql/types.go | 73 +++++++++++ 5 files changed, 590 insertions(+), 4 deletions(-) diff --git a/spanner/spansql/parser.go b/spanner/spansql/parser.go index 2de6334bc47e..b34d24af06de 100644 --- a/spanner/spansql/parser.go +++ b/spanner/spansql/parser.go @@ -996,6 +996,9 @@ func (p *parser) parseDDLStmt() (DDLStmt, *parseError) { } else if p.sniff("CREATE", "VIEW") || p.sniff("CREATE", "OR", "REPLACE", "VIEW") { cv, err := p.parseCreateView() return cv, err + } else if p.sniff("CREATE", "ROLE") { + cr, err := p.parseCreateRole() + return cr, err } else if p.sniff("ALTER", "TABLE") { a, err := p.parseAlterTable() return a, err @@ -1005,6 +1008,7 @@ func (p *parser) parseDDLStmt() (DDLStmt, *parseError) { // DROP TABLE table_name // DROP INDEX index_name // DROP VIEW view_name + // DROP ROLE role_name // DROP CHANGE STREAM change_stream_name tok := p.next() if tok.err != nil { @@ -1031,6 +1035,12 @@ func (p *parser) parseDDLStmt() (DDLStmt, *parseError) { return nil, err } return &DropView{Name: name, Position: pos}, nil + case tok.caseEqual("ROLE"): + name, err := p.parseTableOrIndexOrColumnName() + if err != nil { + return nil, err + } + return &DropRole{Name: name, Position: pos}, nil case tok.caseEqual("CHANGE"): if err := p.expect("STREAM"); err != nil { return nil, err @@ -1044,6 +1054,12 @@ func (p *parser) parseDDLStmt() (DDLStmt, *parseError) { } else if p.sniff("ALTER", "DATABASE") { a, err := p.parseAlterDatabase() return a, err + } else if p.eat("GRANT") { + a, err := p.parseGrantRole() + return a, err + } else if p.eat("REVOKE") { + a, err := p.parseRevokeRole() + return a, err } else if p.sniff("CREATE", "CHANGE", "STREAM") { cs, err := p.parseCreateChangeStream() return cs, err @@ -1297,6 +1313,176 @@ func (p *parser) parseCreateView() (*CreateView, *parseError) { }, nil } +func (p *parser) parseCreateRole() (*CreateRole, *parseError) { + debugf("parseCreateRole: %v", p) + + /* + CREATE ROLE database_role_name + */ + + if err := p.expect("CREATE"); err != nil { + return nil, err + } + pos := p.Pos() + if err := p.expect("ROLE"); err != nil { + return nil, err + } + rname, err := p.parseTableOrIndexOrColumnName() + if err != nil { + return nil, err + } + cr := &CreateRole{ + Name: rname, + + Position: pos, + } + + return cr, nil +} + +func (p *parser) parseGrantRole() (*GrantRole, *parseError) { + pos := p.Pos() + g := &GrantRole{ + Position: pos, + } + if p.eat("ROLE") { + roleList, err := p.parseGrantOrRevokeRoleList("TO") + if err != nil { + return nil, err + } + g.GrantRoleNames = roleList + } else { + var privs []Privilege + privs, err := p.parsePrivileges() + if err != nil { + return nil, err + } + g.Privileges = privs + var tableList []ID + f := func(p *parser) *parseError { + table, err := p.parseTableOrIndexOrColumnName() + if err != nil { + return err + } + tableList = append(tableList, table) + return nil + } + if err := p.parseCommaListWithEnds(f, "TO", "ROLE"); err != nil { + return nil, err + } + g.TableNames = tableList + } + list, err := p.parseIDList() + if err != nil { + return nil, err + } + g.ToRoleNames = list + + return g, nil +} + +func (p *parser) parseRevokeRole() (*RevokeRole, *parseError) { + pos := p.Pos() + r := &RevokeRole{ + Position: pos, + } + if p.eat("ROLE") { + roleList, err := p.parseGrantOrRevokeRoleList("FROM") + if err != nil { + return nil, err + } + r.RevokeRoleNames = roleList + } else { + var privs []Privilege + privs, err := p.parsePrivileges() + if err != nil { + return nil, err + } + r.Privileges = privs + var tableList []ID + f := func(p *parser) *parseError { + table, err := p.parseTableOrIndexOrColumnName() + if err != nil { + return err + } + tableList = append(tableList, table) + return nil + } + if err := p.parseCommaListWithEnds(f, "FROM", "ROLE"); err != nil { + return nil, err + } + r.TableNames = tableList + } + list, err := p.parseIDList() + if err != nil { + return nil, err + } + r.FromRoleNames = list + + return r, nil +} +func (p *parser) parseGrantOrRevokeRoleList(end string) ([]ID, *parseError) { + var roleList []ID + f := func(p *parser) *parseError { + role, err := p.parseTableOrIndexOrColumnName() + if err != nil { + return err + } + roleList = append(roleList, role) + return nil + } + err := p.parseCommaListWithEnds(f, end, "ROLE") + if err != nil { + return nil, err + } + return roleList, nil +} + +func (p *parser) parsePrivileges() ([]Privilege, *parseError) { + var privs []Privilege + for { + tok := p.next() + if tok.err != nil { + return []Privilege{}, tok.err + } + + priv := Privilege{} + switch { + default: + return []Privilege{}, p.errorf("got %q, want SELECT or UPDATE or INSERT or DELETE", tok.value) + case tok.caseEqual("SELECT"): + priv.Type = PrivilegeTypeSelect + case tok.caseEqual("UPDATE"): + priv.Type = PrivilegeTypeUpdate + case tok.caseEqual("INSERT"): + priv.Type = PrivilegeTypeInsert + case tok.caseEqual("DELETE"): + priv.Type = PrivilegeTypeDelete + } + // can grant DELETE only at the table level. + // https://cloud.google.com/spanner/docs/reference/standard-sql/data-definition-language#notes_and_restrictions + if p.sniff("(") && !tok.caseEqual("DELETE") { + list, err := p.parseColumnNameList() + if err != nil { + return nil, err + } + priv.Columns = list + } + privs = append(privs, priv) + tok = p.next() + if tok.err != nil { + return []Privilege{}, tok.err + } + if tok.value == "," { + continue + } else if tok.caseEqual("ON") && p.eat("TABLE") { + break + } else { + return []Privilege{}, p.errorf("got %q, want , or ON TABLE", tok.value) + } + } + return privs, nil +} func (p *parser) parseAlterTable() (*AlterTable, *parseError) { debugf("parseAlterTable: %v", p) @@ -2053,6 +2239,23 @@ func (p *parser) parseColumnNameList() ([]ID, *parseError) { return list, err } +func (p *parser) parseIDList() ([]ID, *parseError) { + var list []ID + for { + n, err := p.parseTableOrIndexOrColumnName() + if err != nil { + return nil, err + } + list = append(list, n) + + if p.eat(",") { + continue + } + break + } + return list, nil +} + func (p *parser) parseCreateChangeStream() (*CreateChangeStream, *parseError) { debugf("parseCreateChangeStream: %v", p) @@ -3898,7 +4101,7 @@ func (p *parser) parseHints(hints map[string]string) (map[string]string, *parseE func (p *parser) parseTableOrIndexOrColumnName() (ID, *parseError) { /* - table_name and column_name and index_name: + table_name and column_name and index_name and role_name: {a—z|A—Z}[{a—z|A—Z|0—9|_}+] */ @@ -4001,3 +4204,31 @@ func (p *parser) parseCommaList(bra, ket string, f func(*parser) *parseError) *p } } } + +// parseCommaListWithEnds parses a comma-separated list to expected ends, +// delegating to f for the individual element parsing. +// Only invoke this with symbols as end; they are matched case insensitively. +func (p *parser) parseCommaListWithEnds(f func(*parser) *parseError, end ...string) *parseError { + if p.eat(end...) { + return nil + } + for { + err := f(p) + if err != nil { + return err + } + if p.eat(end...) { + return nil + } + + tok := p.next() + if tok.err != nil { + return err + } + if tok.value == "," { + continue + } else if tok.value == ";" { + return nil + } + } +} diff --git a/spanner/spansql/parser_test.go b/spanner/spansql/parser_test.go index 848d5e83f049..5a4c083abbab 100644 --- a/spanner/spansql/parser_test.go +++ b/spanner/spansql/parser_test.go @@ -668,6 +668,20 @@ func TestParseDDL(t *testing.T) { ALTER TABLE DefaultCol ALTER COLUMN Age SET DEFAULT (0); ALTER TABLE DefaultCol ALTER COLUMN Age STRING(MAX) DEFAULT ("0"); + CREATE ROLE TestRole; + + GRANT SELECT ON TABLE employees TO ROLE hr_rep; + GRANT SELECT(name, address, phone) ON TABLE contractors TO ROLE hr_rep; + GRANT SELECT, UPDATE(location), DELETE ON TABLE employees TO ROLE hr_manager; + GRANT SELECT(name, level, location), UPDATE(location) ON TABLE employees, contractors TO ROLE hr_manager; + GRANT ROLE pii_access, pii_update TO ROLE hr_manager, hr_director; + + REVOKE SELECT ON TABLE employees FROM ROLE hr_rep; + REVOKE SELECT(name, address, phone) ON TABLE contractors FROM ROLE hr_rep; + REVOKE SELECT, UPDATE(location), DELETE ON TABLE employees FROM ROLE hr_manager; + REVOKE SELECT(name, level, location), UPDATE(location) ON TABLE employees, contractors FROM ROLE hr_manager; + REVOKE ROLE pii_access, pii_update FROM ROLE hr_manager, hr_director; + ALTER INDEX MyFirstIndex ADD STORED COLUMN UpdatedAt; ALTER INDEX MyFirstIndex DROP STORED COLUMN UpdatedAt; @@ -957,15 +971,109 @@ func TestParseDDL(t *testing.T) { }, Position: line(83), }, + &CreateRole{ + Name: "TestRole", + Position: line(85), + }, + &GrantRole{ + ToRoleNames: []ID{"hr_rep"}, + Privileges: []Privilege{ + {Type: PrivilegeTypeSelect}, + }, + TableNames: []ID{"employees"}, + + Position: line(87), + }, + &GrantRole{ + ToRoleNames: []ID{"hr_rep"}, + Privileges: []Privilege{ + {Type: PrivilegeTypeSelect, Columns: []ID{"name", "address", "phone"}}, + }, + TableNames: []ID{"contractors"}, + + Position: line(88), + }, + &GrantRole{ + ToRoleNames: []ID{"hr_manager"}, + Privileges: []Privilege{ + {Type: PrivilegeTypeSelect}, + {Type: PrivilegeTypeUpdate, Columns: []ID{"location"}}, + {Type: PrivilegeTypeDelete}, + }, + TableNames: []ID{"employees"}, + + Position: line(89), + }, + &GrantRole{ + ToRoleNames: []ID{"hr_manager"}, + Privileges: []Privilege{ + {Type: PrivilegeTypeSelect, Columns: []ID{"name", "level", "location"}}, + {Type: PrivilegeTypeUpdate, Columns: []ID{"location"}}, + }, + TableNames: []ID{"employees", "contractors"}, + + Position: line(90), + }, + &GrantRole{ + ToRoleNames: []ID{"hr_manager", "hr_director"}, + GrantRoleNames: []ID{"pii_access", "pii_update"}, + + Position: line(91), + }, + &RevokeRole{ + FromRoleNames: []ID{"hr_rep"}, + Privileges: []Privilege{ + {Type: PrivilegeTypeSelect}, + }, + TableNames: []ID{"employees"}, + + Position: line(93), + }, + &RevokeRole{ + FromRoleNames: []ID{"hr_rep"}, + Privileges: []Privilege{ + {Type: PrivilegeTypeSelect, Columns: []ID{"name", "address", "phone"}}, + }, + TableNames: []ID{"contractors"}, + + Position: line(94), + }, + &RevokeRole{ + FromRoleNames: []ID{"hr_manager"}, + Privileges: []Privilege{ + {Type: PrivilegeTypeSelect}, + {Type: PrivilegeTypeUpdate, Columns: []ID{"location"}}, + {Type: PrivilegeTypeDelete}, + }, + TableNames: []ID{"employees"}, + + Position: line(95), + }, + &RevokeRole{ + FromRoleNames: []ID{"hr_manager"}, + Privileges: []Privilege{ + {Type: PrivilegeTypeSelect, Columns: []ID{"name", "level", "location"}}, + {Type: PrivilegeTypeUpdate, Columns: []ID{"location"}}, + }, + TableNames: []ID{"employees", "contractors"}, + + Position: line(96), + }, + &RevokeRole{ + FromRoleNames: []ID{"hr_manager", "hr_director"}, + RevokeRoleNames: []ID{"pii_access", "pii_update"}, + + Position: line(97), + }, &AlterIndex{ Name: "MyFirstIndex", Alteration: AddStoredColumn{Name: "UpdatedAt"}, - Position: line(85), + Position: line(99), }, &AlterIndex{ Name: "MyFirstIndex", Alteration: DropStoredColumn{Name: "UpdatedAt"}, - Position: line(86), + Position: line(100), }, }, Comments: []*Comment{ { @@ -1002,7 +1110,7 @@ func TestParseDDL(t *testing.T) { {Marker: "--", Isolated: true, Start: line(75), End: line(75), Text: []string{"Table has a column with a default value."}}, // Comment after everything else. - {Marker: "--", Isolated: true, Start: line(88), End: line(88), Text: []string{"Trailing comment at end of file."}}, + {Marker: "--", Isolated: true, Start: line(102), End: line(102), Text: []string{"Trailing comment at end of file."}}, }}}, // No trailing comma: {`ALTER TABLE T ADD COLUMN C2 INT64`, &DDL{Filename: "filename", List: []DDLStmt{ @@ -1164,6 +1272,75 @@ func TestParseDDL(t *testing.T) { }, }, }, + { + "DROP ROLE `TestRole`", + &DDL{ + Filename: "filename", List: []DDLStmt{ + &DropRole{ + Name: "TestRole", + Position: line(1), + }, + }, + }, + }, + { + "GRANT SELECT(`name`, `level`, `location`), UPDATE(`location`) ON TABLE `employees`, `contractors` TO ROLE `hr_manager`;", + &DDL{ + Filename: "filename", List: []DDLStmt{ + &GrantRole{ + ToRoleNames: []ID{"hr_manager"}, + Privileges: []Privilege{ + {Type: PrivilegeTypeSelect, Columns: []ID{"name", "level", "location"}}, + {Type: PrivilegeTypeUpdate, Columns: []ID{"location"}}, + }, + TableNames: []ID{"employees", "contractors"}, + Position: line(1), + }, + }, + }, + }, + { + "GRANT ROLE `pii_access`, `pii_update` TO ROLE `hr_manager`, `hr_director`;", + &DDL{ + Filename: "filename", List: []DDLStmt{ + &GrantRole{ + ToRoleNames: []ID{"hr_manager", "hr_director"}, + GrantRoleNames: []ID{"pii_access", "pii_update"}, + + Position: line(1), + }, + }, + }, + }, + { + "REVOKE SELECT(`name`, `level`, `location`), UPDATE(`location`) ON TABLE `employees`, `contractors` FROM ROLE `hr_manager`;", + &DDL{ + Filename: "filename", List: []DDLStmt{ + &RevokeRole{ + FromRoleNames: []ID{"hr_manager"}, + Privileges: []Privilege{ + {Type: PrivilegeTypeSelect, Columns: []ID{"name", "level", "location"}}, + {Type: PrivilegeTypeUpdate, Columns: []ID{"location"}}, + }, + TableNames: []ID{"employees", "contractors"}, + + Position: line(1), + }, + }, + }, + }, + { + "REVOKE ROLE `pii_access`, `pii_update` FROM ROLE `hr_manager`, `hr_director`;", + &DDL{ + Filename: "filename", List: []DDLStmt{ + &RevokeRole{ + FromRoleNames: []ID{"hr_manager", "hr_director"}, + RevokeRoleNames: []ID{"pii_access", "pii_update"}, + Position: line(1), + }, + }, + }, + }, { `CREATE CHANGE STREAM csname; CREATE CHANGE STREAM csname FOR ALL; diff --git a/spanner/spansql/sql.go b/spanner/spansql/sql.go index d3a2b7484784..b05a0c6aae97 100644 --- a/spanner/spansql/sql.go +++ b/spanner/spansql/sql.go @@ -96,6 +96,10 @@ func (cv CreateView) SQL() string { return str } +func (cr CreateRole) SQL() string { + return "CREATE ROLE " + cr.Name.SQL() +} + func (cs CreateChangeStream) SQL() string { str := "CREATE CHANGE STREAM " str += cs.Name.SQL() + " FOR " @@ -143,6 +147,50 @@ func (dv DropView) SQL() string { return "DROP VIEW " + dv.Name.SQL() } +func (dr DropRole) SQL() string { + return "DROP ROLE " + dr.Name.SQL() +} + +func (gr GrantRole) SQL() string { + sql := "GRANT " + if gr.Privileges != nil { + for i, priv := range gr.Privileges { + if i > 0 { + sql += ", " + } + sql += priv.Type.SQL() + if priv.Columns != nil { + sql += "(" + idList(priv.Columns, ", ") + ")" + } + } + sql += " ON TABLE " + idList(gr.TableNames, ", ") + } else { + sql += "ROLE " + idList(gr.GrantRoleNames, ", ") + } + sql += " TO ROLE " + idList(gr.ToRoleNames, ", ") + return sql +} + +func (rr RevokeRole) SQL() string { + sql := "REVOKE " + if rr.Privileges != nil { + for i, priv := range rr.Privileges { + if i > 0 { + sql += ", " + } + sql += priv.Type.SQL() + if priv.Columns != nil { + sql += "(" + idList(priv.Columns, ", ") + ")" + } + } + sql += " ON TABLE " + idList(rr.TableNames, ", ") + } else { + sql += "ROLE " + idList(rr.RevokeRoleNames, ", ") + } + sql += " FROM ROLE " + idList(rr.FromRoleNames, ", ") + return sql +} + func (dc DropChangeStream) SQL() string { return "DROP CHANGE STREAM " + dc.Name.SQL() } @@ -509,6 +557,19 @@ func (tb TypeBase) SQL() string { panic("unknown TypeBase") } +func (pt PrivilegeType) SQL() string { + switch pt { + case PrivilegeTypeSelect: + return "SELECT" + case PrivilegeTypeInsert: + return "INSERT" + case PrivilegeTypeUpdate: + return "UPDATE" + case PrivilegeTypeDelete: + return "DELETE" + } + panic("unknown PrivilegeType") +} func (kp KeyPart) SQL() string { str := kp.Column.SQL() if kp.Desc { diff --git a/spanner/spansql/sql_test.go b/spanner/spansql/sql_test.go index 3e24352e89fb..ca1de52228a1 100644 --- a/spanner/spansql/sql_test.go +++ b/spanner/spansql/sql_test.go @@ -218,6 +218,50 @@ func TestSQL(t *testing.T) { "DROP VIEW SingersView", reparseDDL, }, + { + &CreateRole{ + Name: "TestRole", + Position: line(1), + }, + "CREATE ROLE TestRole", + reparseDDL, + }, + { + &DropRole{ + Name: "TestRole", + Position: line(1), + }, + "DROP ROLE TestRole", + reparseDDL, + }, + { + &GrantRole{ + ToRoleNames: []ID{"hr_manager"}, + Privileges: []Privilege{ + {Type: PrivilegeTypeSelect, Columns: []ID{"name", "level", "location"}}, + {Type: PrivilegeTypeUpdate, Columns: []ID{"location"}}, + }, + TableNames: []ID{"employees", "contractors"}, + + Position: line(1), + }, + "GRANT SELECT(name, level, location), UPDATE(location) ON TABLE employees, contractors TO ROLE hr_manager", + reparseDDL, + }, + { + &RevokeRole{ + FromRoleNames: []ID{"hr_manager"}, + Privileges: []Privilege{ + {Type: PrivilegeTypeSelect, Columns: []ID{"name", "level", "location"}}, + {Type: PrivilegeTypeUpdate, Columns: []ID{"location"}}, + }, + TableNames: []ID{"employees", "contractors"}, + + Position: line(1), + }, + "REVOKE SELECT(name, level, location), UPDATE(location) ON TABLE employees, contractors FROM ROLE hr_manager", + reparseDDL, + }, { &AlterTable{ Name: "Ta", diff --git a/spanner/spansql/types.go b/spanner/spansql/types.go index 89f818dea2ad..7d2109899db2 100644 --- a/spanner/spansql/types.go +++ b/spanner/spansql/types.go @@ -133,6 +133,19 @@ func (*CreateView) isDDLStmt() {} func (cv *CreateView) Pos() Position { return cv.Position } func (cv *CreateView) clearOffset() { cv.Position.Offset = 0 } +// CreateRole represents a CREATE Role statement. +// https://cloud.google.com/spanner/docs/reference/standard-sql/data-definition-language#create_role +type CreateRole struct { + Name ID + + Position Position // position of the "CREATE" token +} + +func (cr *CreateRole) String() string { return fmt.Sprintf("%#v", cr) } +func (*CreateRole) isDDLStmt() {} +func (cr *CreateRole) Pos() Position { return cr.Position } +func (cr *CreateRole) clearOffset() { cr.Position.Offset = 0 } + // DropTable represents a DROP TABLE statement. // https://cloud.google.com/spanner/docs/data-definition-language#drop_table type DropTable struct { @@ -172,6 +185,57 @@ func (*DropView) isDDLStmt() {} func (dv *DropView) Pos() Position { return dv.Position } func (dv *DropView) clearOffset() { dv.Position.Offset = 0 } +// DropRole represents a DROP ROLE statement. +// https://cloud.google.com/spanner/docs/reference/standard-sql/data-definition-language#drop_role +type DropRole struct { + Name ID + + Position Position // position of the "DROP" token +} + +func (dr *DropRole) String() string { return fmt.Sprintf("%#v", dr) } +func (*DropRole) isDDLStmt() {} +func (dr *DropRole) Pos() Position { return dr.Position } +func (dr *DropRole) clearOffset() { dr.Position.Offset = 0 } + +// GrantRole represents a GRANT statement. +// https://cloud.google.com/spanner/docs/reference/standard-sql/data-definition-language#grant_statement +type GrantRole struct { + ToRoleNames []ID + GrantRoleNames []ID + Privileges []Privilege + TableNames []ID + + Position Position // position of the "GRANT" token +} + +func (gr *GrantRole) String() string { return fmt.Sprintf("%#v", gr) } +func (*GrantRole) isDDLStmt() {} +func (gr *GrantRole) Pos() Position { return gr.Position } +func (gr *GrantRole) clearOffset() { gr.Position.Offset = 0 } + +// RevokeRole represents a REVOKE statement. +// https://cloud.google.com/spanner/docs/reference/standard-sql/data-definition-language#revoke_statement +type RevokeRole struct { + FromRoleNames []ID + RevokeRoleNames []ID + Privileges []Privilege + TableNames []ID + + Position Position // position of the "REVOKE" token +} + +func (rr *RevokeRole) String() string { return fmt.Sprintf("%#v", rr) } +func (*RevokeRole) isDDLStmt() {} +func (rr *RevokeRole) Pos() Position { return rr.Position } +func (rr *RevokeRole) clearOffset() { rr.Position.Offset = 0 } + +// Privilege represents privilege to grant or revoke. +type Privilege struct { + Type PrivilegeType + Columns []ID +} + // AlterTable represents an ALTER TABLE statement. // https://cloud.google.com/spanner/docs/data-definition-language#alter_table type AlterTable struct { @@ -429,6 +493,15 @@ const ( JSON ) +type PrivilegeType int + +const ( + PrivilegeTypeSelect PrivilegeType = iota + PrivilegeTypeInsert + PrivilegeTypeUpdate + PrivilegeTypeDelete +) + // KeyPart represents a column specification as part of a primary key or index definition. type KeyPart struct { Column ID