github.com/authzed/spicedb@v1.32.1-0.20240520085336-ebda56537386/internal/datastore/common/sql.go (about) 1 package common 2 3 import ( 4 "context" 5 "math" 6 "strings" 7 8 sq "github.com/Masterminds/squirrel" 9 v1 "github.com/authzed/authzed-go/proto/authzed/api/v1" 10 "github.com/jzelinskie/stringz" 11 "go.opentelemetry.io/otel" 12 "go.opentelemetry.io/otel/attribute" 13 14 "github.com/authzed/spicedb/pkg/datastore" 15 "github.com/authzed/spicedb/pkg/datastore/options" 16 core "github.com/authzed/spicedb/pkg/proto/core/v1" 17 "github.com/authzed/spicedb/pkg/spiceerrors" 18 ) 19 20 var ( 21 // CaveatNameKey is a tracing attribute representing a caveat name 22 CaveatNameKey = attribute.Key("authzed.com/spicedb/sql/caveatName") 23 24 // ObjNamespaceNameKey is a tracing attribute representing the resource 25 // object type. 26 ObjNamespaceNameKey = attribute.Key("authzed.com/spicedb/sql/objNamespaceName") 27 28 // ObjRelationNameKey is a tracing attribute representing the resource 29 // relation. 30 ObjRelationNameKey = attribute.Key("authzed.com/spicedb/sql/objRelationName") 31 32 // ObjIDKey is a tracing attribute representing the resource object ID. 33 ObjIDKey = attribute.Key("authzed.com/spicedb/sql/objId") 34 35 // SubNamespaceNameKey is a tracing attribute representing the subject object 36 // type. 37 SubNamespaceNameKey = attribute.Key("authzed.com/spicedb/sql/subNamespaceName") 38 39 // SubRelationNameKey is a tracing attribute representing the subject 40 // relation. 41 SubRelationNameKey = attribute.Key("authzed.com/spicedb/sql/subRelationName") 42 43 // SubObjectIDKey is a tracing attribute representing the the subject object 44 // ID. 45 SubObjectIDKey = attribute.Key("authzed.com/spicedb/sql/subObjectId") 46 47 limitKey = attribute.Key("authzed.com/spicedb/sql/limit") 48 49 tracer = otel.Tracer("spicedb/internal/datastore/common") 50 ) 51 52 // PaginationFilterType is an enumerator 53 type PaginationFilterType uint8 54 55 const ( 56 // TupleComparison uses a comparison with a compound key, 57 // e.g. (namespace, object_id, relation) > ('ns', '123', 'viewer') 58 // which is not compatible with all datastores. 59 TupleComparison PaginationFilterType = iota 60 61 // ExpandedLogicComparison comparison uses a nested tree of ANDs and ORs to properly 62 // filter out already received relationships. Useful for databases that do not support 63 // tuple comparison, or do not execute it efficiently 64 ExpandedLogicComparison 65 ) 66 67 // SchemaInformation holds the schema information from the SQL datastore implementation. 68 type SchemaInformation struct { 69 colNamespace string 70 colObjectID string 71 colRelation string 72 colUsersetNamespace string 73 colUsersetObjectID string 74 colUsersetRelation string 75 colCaveatName string 76 paginationFilterType PaginationFilterType 77 } 78 79 func NewSchemaInformation( 80 colNamespace, 81 colObjectID, 82 colRelation, 83 colUsersetNamespace, 84 colUsersetObjectID, 85 colUsersetRelation, 86 colCaveatName string, 87 paginationFilterType PaginationFilterType, 88 ) SchemaInformation { 89 return SchemaInformation{ 90 colNamespace, 91 colObjectID, 92 colRelation, 93 colUsersetNamespace, 94 colUsersetObjectID, 95 colUsersetRelation, 96 colCaveatName, 97 paginationFilterType, 98 } 99 } 100 101 // SchemaQueryFilterer wraps a SchemaInformation and SelectBuilder to give an opinionated 102 // way to build query objects. 103 type SchemaQueryFilterer struct { 104 schema SchemaInformation 105 queryBuilder sq.SelectBuilder 106 filteringColumnCounts map[string]int 107 tracerAttributes []attribute.KeyValue 108 } 109 110 // NewSchemaQueryFilterer creates a new SchemaQueryFilterer object. 111 func NewSchemaQueryFilterer(schema SchemaInformation, initialQuery sq.SelectBuilder) SchemaQueryFilterer { 112 return SchemaQueryFilterer{ 113 schema: schema, 114 queryBuilder: initialQuery, 115 filteringColumnCounts: map[string]int{}, 116 } 117 } 118 119 func (sqf SchemaQueryFilterer) TupleOrder(order options.SortOrder) SchemaQueryFilterer { 120 switch order { 121 case options.ByResource: 122 sqf.queryBuilder = sqf.queryBuilder.OrderBy( 123 sqf.schema.colNamespace, 124 sqf.schema.colObjectID, 125 sqf.schema.colRelation, 126 sqf.schema.colUsersetNamespace, 127 sqf.schema.colUsersetObjectID, 128 sqf.schema.colUsersetRelation, 129 ) 130 131 case options.BySubject: 132 sqf.queryBuilder = sqf.queryBuilder.OrderBy( 133 sqf.schema.colUsersetNamespace, 134 sqf.schema.colUsersetObjectID, 135 sqf.schema.colUsersetRelation, 136 sqf.schema.colNamespace, 137 sqf.schema.colObjectID, 138 sqf.schema.colRelation, 139 ) 140 } 141 142 return sqf 143 } 144 145 type nameAndValue struct { 146 name string 147 value string 148 } 149 150 func (sqf SchemaQueryFilterer) After(cursor *core.RelationTuple, order options.SortOrder) SchemaQueryFilterer { 151 // NOTE: The ordering of these columns can affect query performance, be aware when changing. 152 columnsAndValues := map[options.SortOrder][]nameAndValue{ 153 options.ByResource: { 154 { 155 sqf.schema.colNamespace, cursor.ResourceAndRelation.Namespace, 156 }, 157 { 158 sqf.schema.colObjectID, cursor.ResourceAndRelation.ObjectId, 159 }, 160 { 161 sqf.schema.colRelation, cursor.ResourceAndRelation.Relation, 162 }, 163 { 164 sqf.schema.colUsersetNamespace, cursor.Subject.Namespace, 165 }, 166 { 167 sqf.schema.colUsersetObjectID, cursor.Subject.ObjectId, 168 }, 169 { 170 sqf.schema.colUsersetRelation, cursor.Subject.Relation, 171 }, 172 }, 173 options.BySubject: { 174 { 175 sqf.schema.colUsersetNamespace, cursor.Subject.Namespace, 176 }, 177 { 178 sqf.schema.colUsersetObjectID, cursor.Subject.ObjectId, 179 }, 180 { 181 sqf.schema.colNamespace, cursor.ResourceAndRelation.Namespace, 182 }, 183 { 184 sqf.schema.colObjectID, cursor.ResourceAndRelation.ObjectId, 185 }, 186 { 187 sqf.schema.colRelation, cursor.ResourceAndRelation.Relation, 188 }, 189 { 190 sqf.schema.colUsersetRelation, cursor.Subject.Relation, 191 }, 192 }, 193 }[order] 194 195 switch sqf.schema.paginationFilterType { 196 case TupleComparison: 197 // For performance reasons, remove any column names that have static values in the query. 198 columnNames := make([]string, 0, len(columnsAndValues)) 199 valueSlots := make([]any, 0, len(columnsAndValues)) 200 comparisonSlotCount := 0 201 202 for _, cav := range columnsAndValues { 203 if sqf.filteringColumnCounts[cav.name] != 1 { 204 columnNames = append(columnNames, cav.name) 205 valueSlots = append(valueSlots, cav.value) 206 comparisonSlotCount++ 207 } 208 } 209 210 if comparisonSlotCount > 0 { 211 comparisonTuple := "(" + strings.Join(columnNames, ",") + ") > (" + strings.Repeat(",?", comparisonSlotCount)[1:] + ")" 212 sqf.queryBuilder = sqf.queryBuilder.Where( 213 comparisonTuple, 214 valueSlots..., 215 ) 216 } 217 218 case ExpandedLogicComparison: 219 // For performance reasons, remove any column names that have static values in the query. 220 orClause := sq.Or{} 221 222 for index, cav := range columnsAndValues { 223 if sqf.filteringColumnCounts[cav.name] != 1 { 224 andClause := sq.And{} 225 for _, previous := range columnsAndValues[0:index] { 226 if sqf.filteringColumnCounts[previous.name] != 1 { 227 andClause = append(andClause, sq.Eq{previous.name: previous.value}) 228 } 229 } 230 231 andClause = append(andClause, sq.Gt{cav.name: cav.value}) 232 orClause = append(orClause, andClause) 233 } 234 } 235 236 if len(orClause) > 0 { 237 sqf.queryBuilder = sqf.queryBuilder.Where(orClause) 238 } 239 } 240 241 return sqf 242 } 243 244 // FilterToResourceType returns a new SchemaQueryFilterer that is limited to resources of the 245 // specified type. 246 func (sqf SchemaQueryFilterer) FilterToResourceType(resourceType string) SchemaQueryFilterer { 247 sqf.queryBuilder = sqf.queryBuilder.Where(sq.Eq{sqf.schema.colNamespace: resourceType}) 248 sqf.tracerAttributes = append(sqf.tracerAttributes, ObjNamespaceNameKey.String(resourceType)) 249 sqf.recordColumnValue(sqf.schema.colNamespace) 250 return sqf 251 } 252 253 func (sqf SchemaQueryFilterer) recordColumnValue(colName string) { 254 if value, ok := sqf.filteringColumnCounts[colName]; ok { 255 sqf.filteringColumnCounts[colName] = value + 1 256 return 257 } 258 259 sqf.filteringColumnCounts[colName] = 1 260 } 261 262 // FilterToResourceID returns a new SchemaQueryFilterer that is limited to resources with the 263 // specified ID. 264 func (sqf SchemaQueryFilterer) FilterToResourceID(objectID string) SchemaQueryFilterer { 265 sqf.queryBuilder = sqf.queryBuilder.Where(sq.Eq{sqf.schema.colObjectID: objectID}) 266 sqf.tracerAttributes = append(sqf.tracerAttributes, ObjIDKey.String(objectID)) 267 sqf.recordColumnValue(sqf.schema.colObjectID) 268 return sqf 269 } 270 271 func (sqf SchemaQueryFilterer) MustFilterToResourceIDs(resourceIds []string) SchemaQueryFilterer { 272 updated, err := sqf.FilterToResourceIDs(resourceIds) 273 if err != nil { 274 panic(err) 275 } 276 return updated 277 } 278 279 // FilterWithResourceIDPrefix returns new SchemaQueryFilterer that is limited to resources whose ID 280 // starts with the specified prefix. 281 func (sqf SchemaQueryFilterer) FilterWithResourceIDPrefix(prefix string) (SchemaQueryFilterer, error) { 282 if strings.Contains(prefix, "%") { 283 return sqf, spiceerrors.MustBugf("prefix cannot contain the percent sign") 284 } 285 if prefix == "" { 286 return sqf, spiceerrors.MustBugf("prefix cannot be empty") 287 } 288 289 sqf.queryBuilder = sqf.queryBuilder.Where(sq.Like{sqf.schema.colObjectID: prefix + "%"}) 290 sqf.tracerAttributes = append(sqf.tracerAttributes, ObjIDKey.String(prefix+"*")) 291 292 // NOTE: we do *not* record the use of the resource ID column here, because it is not used 293 // statically and thus is necessary for sorting operations. 294 return sqf, nil 295 } 296 297 func (sqf SchemaQueryFilterer) MustFilterWithResourceIDPrefix(prefix string) SchemaQueryFilterer { 298 updated, err := sqf.FilterWithResourceIDPrefix(prefix) 299 if err != nil { 300 panic(err) 301 } 302 return updated 303 } 304 305 // FilterToResourceIDs returns a new SchemaQueryFilterer that is limited to resources with any of the 306 // specified IDs. 307 func (sqf SchemaQueryFilterer) FilterToResourceIDs(resourceIds []string) (SchemaQueryFilterer, error) { 308 if len(resourceIds) > int(datastore.FilterMaximumIDCount) { 309 return sqf, spiceerrors.MustBugf("cannot have more than %d resources IDs in a single filter", datastore.FilterMaximumIDCount) 310 } 311 312 inClause := sqf.schema.colObjectID + " IN (" 313 args := make([]any, 0, len(resourceIds)) 314 315 for index, resourceID := range resourceIds { 316 if len(resourceID) == 0 { 317 return sqf, spiceerrors.MustBugf("got empty resource ID") 318 } 319 320 if index > 0 { 321 inClause += ", " 322 } 323 324 inClause += "?" 325 326 args = append(args, resourceID) 327 sqf.tracerAttributes = append(sqf.tracerAttributes, ObjIDKey.String(resourceID)) 328 sqf.recordColumnValue(sqf.schema.colObjectID) 329 } 330 331 sqf.queryBuilder = sqf.queryBuilder.Where(inClause+")", args...) 332 return sqf, nil 333 } 334 335 // FilterToRelation returns a new SchemaQueryFilterer that is limited to resources with the 336 // specified relation. 337 func (sqf SchemaQueryFilterer) FilterToRelation(relation string) SchemaQueryFilterer { 338 sqf.queryBuilder = sqf.queryBuilder.Where(sq.Eq{sqf.schema.colRelation: relation}) 339 sqf.tracerAttributes = append(sqf.tracerAttributes, ObjRelationNameKey.String(relation)) 340 sqf.recordColumnValue(sqf.schema.colRelation) 341 return sqf 342 } 343 344 // MustFilterWithRelationshipsFilter returns a new SchemaQueryFilterer that is limited to resources with 345 // resources that match the specified filter. 346 func (sqf SchemaQueryFilterer) MustFilterWithRelationshipsFilter(filter datastore.RelationshipsFilter) SchemaQueryFilterer { 347 updated, err := sqf.FilterWithRelationshipsFilter(filter) 348 if err != nil { 349 panic(err) 350 } 351 return updated 352 } 353 354 func (sqf SchemaQueryFilterer) FilterWithRelationshipsFilter(filter datastore.RelationshipsFilter) (SchemaQueryFilterer, error) { 355 csqf := sqf 356 357 if filter.OptionalResourceType != "" { 358 csqf = csqf.FilterToResourceType(filter.OptionalResourceType) 359 } 360 361 if filter.OptionalResourceRelation != "" { 362 csqf = csqf.FilterToRelation(filter.OptionalResourceRelation) 363 } 364 365 if len(filter.OptionalResourceIds) > 0 && filter.OptionalResourceIDPrefix != "" { 366 return csqf, spiceerrors.MustBugf("cannot filter by both resource IDs and ID prefix") 367 } 368 369 if len(filter.OptionalResourceIds) > 0 { 370 usqf, err := csqf.FilterToResourceIDs(filter.OptionalResourceIds) 371 if err != nil { 372 return csqf, err 373 } 374 csqf = usqf 375 } 376 377 if len(filter.OptionalResourceIDPrefix) > 0 { 378 usqf, err := csqf.FilterWithResourceIDPrefix(filter.OptionalResourceIDPrefix) 379 if err != nil { 380 return csqf, err 381 } 382 csqf = usqf 383 } 384 385 if len(filter.OptionalSubjectsSelectors) > 0 { 386 usqf, err := csqf.FilterWithSubjectsSelectors(filter.OptionalSubjectsSelectors...) 387 if err != nil { 388 return csqf, err 389 } 390 csqf = usqf 391 } 392 393 if filter.OptionalCaveatName != "" { 394 csqf = csqf.FilterWithCaveatName(filter.OptionalCaveatName) 395 } 396 397 return csqf, nil 398 } 399 400 // MustFilterWithSubjectsSelectors returns a new SchemaQueryFilterer that is limited to resources with 401 // subjects that match the specified selector(s). 402 func (sqf SchemaQueryFilterer) MustFilterWithSubjectsSelectors(selectors ...datastore.SubjectsSelector) SchemaQueryFilterer { 403 usqf, err := sqf.FilterWithSubjectsSelectors(selectors...) 404 if err != nil { 405 panic(err) 406 } 407 return usqf 408 } 409 410 // FilterWithSubjectsSelectors returns a new SchemaQueryFilterer that is limited to resources with 411 // subjects that match the specified selector(s). 412 func (sqf SchemaQueryFilterer) FilterWithSubjectsSelectors(selectors ...datastore.SubjectsSelector) (SchemaQueryFilterer, error) { 413 selectorsOrClause := sq.Or{} 414 415 for _, selector := range selectors { 416 selectorClause := sq.And{} 417 418 if len(selector.OptionalSubjectType) > 0 { 419 selectorClause = append(selectorClause, sq.Eq{sqf.schema.colUsersetNamespace: selector.OptionalSubjectType}) 420 sqf.tracerAttributes = append(sqf.tracerAttributes, SubNamespaceNameKey.String(selector.OptionalSubjectType)) 421 sqf.recordColumnValue(sqf.schema.colUsersetNamespace) 422 } 423 424 if len(selector.OptionalSubjectIds) > 0 { 425 if len(selector.OptionalSubjectIds) > int(datastore.FilterMaximumIDCount) { 426 return sqf, spiceerrors.MustBugf("cannot have more than %d subject IDs in a single filter", datastore.FilterMaximumIDCount) 427 } 428 429 inClause := sqf.schema.colUsersetObjectID + " IN (" 430 args := make([]any, 0, len(selector.OptionalSubjectIds)) 431 432 for index, subjectID := range selector.OptionalSubjectIds { 433 if len(subjectID) == 0 { 434 return sqf, spiceerrors.MustBugf("got empty subject ID") 435 } 436 437 if index > 0 { 438 inClause += ", " 439 } 440 441 inClause += "?" 442 443 args = append(args, subjectID) 444 sqf.tracerAttributes = append(sqf.tracerAttributes, SubObjectIDKey.String(subjectID)) 445 sqf.recordColumnValue(sqf.schema.colUsersetObjectID) 446 } 447 448 selectorClause = append(selectorClause, sq.Expr(inClause+")", args...)) 449 } 450 451 if !selector.RelationFilter.IsEmpty() { 452 if selector.RelationFilter.OnlyNonEllipsisRelations { 453 selectorClause = append(selectorClause, sq.NotEq{sqf.schema.colUsersetRelation: datastore.Ellipsis}) 454 sqf.recordColumnValue(sqf.schema.colUsersetRelation) 455 } else { 456 relations := make([]string, 0, 2) 457 if selector.RelationFilter.IncludeEllipsisRelation { 458 relations = append(relations, datastore.Ellipsis) 459 } 460 461 if selector.RelationFilter.NonEllipsisRelation != "" { 462 relations = append(relations, selector.RelationFilter.NonEllipsisRelation) 463 } 464 465 if len(relations) == 1 { 466 relName := relations[0] 467 sqf.tracerAttributes = append(sqf.tracerAttributes, SubRelationNameKey.String(relName)) 468 selectorClause = append(selectorClause, sq.Eq{sqf.schema.colUsersetRelation: relName}) 469 sqf.recordColumnValue(sqf.schema.colUsersetRelation) 470 } else { 471 orClause := sq.Or{} 472 for _, relationName := range relations { 473 dsRelationName := stringz.DefaultEmpty(relationName, datastore.Ellipsis) 474 orClause = append(orClause, sq.Eq{sqf.schema.colUsersetRelation: dsRelationName}) 475 sqf.tracerAttributes = append(sqf.tracerAttributes, SubRelationNameKey.String(dsRelationName)) 476 sqf.recordColumnValue(sqf.schema.colUsersetRelation) 477 } 478 479 selectorClause = append(selectorClause, orClause) 480 } 481 } 482 } 483 484 selectorsOrClause = append(selectorsOrClause, selectorClause) 485 } 486 487 sqf.queryBuilder = sqf.queryBuilder.Where(selectorsOrClause) 488 return sqf, nil 489 } 490 491 // FilterToSubjectFilter returns a new SchemaQueryFilterer that is limited to resources with 492 // subjects that match the specified filter. 493 func (sqf SchemaQueryFilterer) FilterToSubjectFilter(filter *v1.SubjectFilter) SchemaQueryFilterer { 494 sqf.queryBuilder = sqf.queryBuilder.Where(sq.Eq{sqf.schema.colUsersetNamespace: filter.SubjectType}) 495 sqf.tracerAttributes = append(sqf.tracerAttributes, SubNamespaceNameKey.String(filter.SubjectType)) 496 sqf.recordColumnValue(sqf.schema.colUsersetNamespace) 497 498 if filter.OptionalSubjectId != "" { 499 sqf.queryBuilder = sqf.queryBuilder.Where(sq.Eq{sqf.schema.colUsersetObjectID: filter.OptionalSubjectId}) 500 sqf.tracerAttributes = append(sqf.tracerAttributes, SubObjectIDKey.String(filter.OptionalSubjectId)) 501 sqf.recordColumnValue(sqf.schema.colUsersetObjectID) 502 } 503 504 if filter.OptionalRelation != nil { 505 dsRelationName := stringz.DefaultEmpty(filter.OptionalRelation.Relation, datastore.Ellipsis) 506 507 sqf.queryBuilder = sqf.queryBuilder.Where(sq.Eq{sqf.schema.colUsersetRelation: dsRelationName}) 508 sqf.tracerAttributes = append(sqf.tracerAttributes, SubRelationNameKey.String(dsRelationName)) 509 sqf.recordColumnValue(sqf.schema.colUsersetRelation) 510 } 511 512 return sqf 513 } 514 515 func (sqf SchemaQueryFilterer) FilterWithCaveatName(caveatName string) SchemaQueryFilterer { 516 sqf.queryBuilder = sqf.queryBuilder.Where(sq.Eq{sqf.schema.colCaveatName: caveatName}) 517 sqf.tracerAttributes = append(sqf.tracerAttributes, CaveatNameKey.String(caveatName)) 518 sqf.recordColumnValue(sqf.schema.colCaveatName) 519 return sqf 520 } 521 522 // Limit returns a new SchemaQueryFilterer which is limited to the specified number of results. 523 func (sqf SchemaQueryFilterer) limit(limit uint64) SchemaQueryFilterer { 524 sqf.queryBuilder = sqf.queryBuilder.Limit(limit) 525 sqf.tracerAttributes = append(sqf.tracerAttributes, limitKey.Int64(int64(limit))) 526 return sqf 527 } 528 529 // QueryExecutor is a tuple query runner shared by SQL implementations of the datastore. 530 type QueryExecutor struct { 531 Executor ExecuteQueryFunc 532 } 533 534 // ExecuteQuery executes the query. 535 func (tqs QueryExecutor) ExecuteQuery( 536 ctx context.Context, 537 query SchemaQueryFilterer, 538 opts ...options.QueryOptionsOption, 539 ) (datastore.RelationshipIterator, error) { 540 queryOpts := options.NewQueryOptionsWithOptions(opts...) 541 542 query = query.TupleOrder(queryOpts.Sort) 543 544 if queryOpts.After != nil { 545 if queryOpts.Sort == options.Unsorted { 546 return nil, datastore.ErrCursorsWithoutSorting 547 } 548 549 query = query.After(queryOpts.After, queryOpts.Sort) 550 } 551 552 limit := math.MaxInt 553 if queryOpts.Limit != nil { 554 limit = int(*queryOpts.Limit) 555 } 556 557 toExecute := query.limit(uint64(limit)) 558 sql, args, err := toExecute.queryBuilder.ToSql() 559 if err != nil { 560 return nil, err 561 } 562 563 queryTuples, err := tqs.Executor(ctx, sql, args) 564 if err != nil { 565 return nil, err 566 } 567 568 if len(queryTuples) > limit { 569 queryTuples = queryTuples[:limit] 570 } 571 572 return NewSliceRelationshipIterator(queryTuples, queryOpts.Sort), nil 573 } 574 575 // ExecuteQueryFunc is a function that can be used to execute a single rendered SQL query. 576 type ExecuteQueryFunc func(ctx context.Context, sql string, args []any) ([]*core.RelationTuple, error) 577 578 // TxCleanupFunc is a function that should be executed when the caller of 579 // TransactionFactory is done with the transaction. 580 type TxCleanupFunc func(context.Context)