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)